diff options
| author | Pinapelz <yukais@pinapelz.com> | 2026-06-02 02:12:57 -0700 |
|---|---|---|
| committer | Pinapelz <yukais@pinapelz.com> | 2026-06-02 02:13:10 -0700 |
| commit | 0335b0ad81169232a3dbb1be1341fdcfce548645 (patch) | |
| tree | 910593fa5e072ea77f594b6f10ddd96e49452446 /src/app/game/page.tsx | |
| parent | 0d35e75edbc75f186e4a1ed52fbc3549ee9f5cd6 (diff) | |
migrate to pocketbase backend + auth/login
Diffstat (limited to 'src/app/game/page.tsx')
| -rw-r--r-- | src/app/game/page.tsx | 1011 |
1 files changed, 0 insertions, 1011 deletions
diff --git a/src/app/game/page.tsx b/src/app/game/page.tsx deleted file mode 100644 index bce01b3..0000000 --- a/src/app/game/page.tsx +++ /dev/null @@ -1,1011 +0,0 @@ -"use client"; -import { - useCallback, - useEffect, - useLayoutEffect, - useMemo, - useReducer, - useRef, - useState, - Suspense, -} from "react"; -import { useSearchParams, useRouter } from "next/navigation"; -import Link from "next/link"; -import { FaRedo } from "react-icons/fa"; -import { MdLibraryMusic } from "react-icons/md"; -import { toast, ToastContainer } from "react-toastify"; -import "react-toastify/dist/ReactToastify.css"; -import { - GameRoot, - GameGlobalStyle, - GameNavbar, - GameContent, - HUD, - HudStat, - HudValue, - HudLabel, - ComboValue, - GameArea, - UpcomingWrap, - UpcomingLabel, - UpcomingText, - CurrentWrap, - LineTimingMeta, - LineTimingValue, - LineTimingRow, - LineTimingBar, - LineTimingFill, - CharRow, - WordWrap, - CharBox, - ClearToast, - GetReadyText, - BackgroundVideo, - OpacityControl, - OpacityLabel, - OpacitySlider, - OpacityValue, - PreviewWrap, - PreviewBtn, - PreviewHint, - CompletedLineFade, - GameFooter, - ControlBtn, - ProgressWrap, - ProgressFill, - TimeText, - StartOverlay, - StartCard, - CountdownNumber, - SongTitleText, - SongArtistText, - StartBtn, - CodeSection, - CodeInputRow, - CodeInputField, - CodeLoadBtn, - ResultsOverlay, - ResultsCard, - ResultsTitle, - BigScore, - StatsGrid, - StatBlock, - StatValue, - StatLabel, - ActionRow, - PlayAgainBtn, - HomeBtn, -} from "./page.styles"; -import { gReducer, initialGState } from "./game.stat"; -import { formatTime, parseLrcLines, calculateCPSNeeded } from "./game.utils"; - -type GamePhase = "idle" | "countdown" | "playing" | "paused" | "finished"; - -const VIDEO_EXTENSIONS = new Set(["mp4", "webm", "mov", "m4v", "ogv"]); -const isVideoUrl = (url: string) => { - if (!url) return false; - const cleaned = url.split("?")[0].split("#")[0]; - const ext = cleaned.split(".").pop()?.toLowerCase(); - return !!ext && VIDEO_EXTENSIONS.has(ext); -}; -const BACKGROUND_OPACITY_KEY = "lrcType.backgroundOpacity"; -const AUDIO_VOLUME_KEY = "lrcType.audioVolume"; - -function GameInner() { - const searchParams = useSearchParams(); - const router = useRouter(); - - useEffect(() => { - window.scrollTo({ top: 0, left: 0, behavior: "auto" }); - }, []); - - const audioRef = useRef<HTMLAudioElement>(null); - const videoRef = useRef<HTMLVideoElement>(null); - const gameStartTimeRef = useRef<number>(0); - const lastHandledIdxRef = useRef(-1); - const lastLineAdvanceAtRef = useRef(0); - - const [phase, setPhase] = useState<GamePhase>("idle"); - const [currentMs, setCurrentMs] = useState(0); - const [lineTimingPct, setLineTimingPct] = useState(0); - const [lineRemainingMs, setLineRemainingMs] = useState(0); - const [currentLineTime, setCurrentLineTime] = useState(0); - const [duration, setDuration] = useState(0); - const [progressPct, setProgressPct] = useState(0); - const [gameDurationMs, setGameDurationMs] = useState(0); - - const [lrcContent, setLrcContent] = useState(""); - const [audioUrl, setAudioUrl] = useState(""); - const [songTitle, setSongTitle] = useState("Unknown Title"); - const [songArtist, setSongArtist] = useState("Unknown Artist"); - const [offset, setOffset] = useState(0); - const [loadingLrc, setLoadingLrc] = useState(false); - - const [codeInput, setCodeInput] = useState(""); - const [wrongChar, setWrongChar] = useState(false); - const [clearShowing, setClearShowing] = useState(false); - const [comboAnimKey, setComboAnimKey] = useState(0); - const [countdown, setCountdown] = useState(0); - const [backgroundOpacity, setBackgroundOpacity] = useState(() => { - if (typeof window === "undefined") return 0; - const stored = localStorage.getItem(BACKGROUND_OPACITY_KEY); - if (stored === null) return 0; - const parsed = Number(stored); - if (!Number.isFinite(parsed)) return 0; - return Math.min(100, Math.max(0, parsed)); - }); - const [audioVolume, setAudioVolume] = useState(() => { - if (typeof window === "undefined") return 100; - const stored = localStorage.getItem(AUDIO_VOLUME_KEY); - if (stored === null) return 100; - const parsed = Number(stored); - if (!Number.isFinite(parsed)) return 100; - return Math.min(100, Math.max(0, parsed)); - }); - const [isPreviewPlaying, setIsPreviewPlaying] = useState(false); - const [skipBacking, setSkipBacking] = useState(false); - const isVideo = useMemo(() => isVideoUrl(audioUrl), [audioUrl]); - - - - useEffect(() => { - localStorage.setItem(BACKGROUND_OPACITY_KEY, String(backgroundOpacity)); - }, [backgroundOpacity]); - - useEffect(() => { - localStorage.setItem(AUDIO_VOLUME_KEY, String(audioVolume)); - }, [audioVolume]); - - useEffect(() => { - const media = isVideo ? videoRef.current : audioRef.current; - if (!media) return; - media.volume = audioVolume / 100; - }, [audioVolume, isVideo, audioUrl]); - - useEffect(() => { - const media = isVideo ? videoRef.current : audioRef.current; - if (!media) { - setIsPreviewPlaying(false); - return; - } - const handlePlay = () => setIsPreviewPlaying(true); - const handlePause = () => setIsPreviewPlaying(false); - const handleEnded = () => setIsPreviewPlaying(false); - media.addEventListener("play", handlePlay); - media.addEventListener("pause", handlePause); - media.addEventListener("ended", handleEnded); - return () => { - media.removeEventListener("play", handlePlay); - media.removeEventListener("pause", handlePause); - media.removeEventListener("ended", handleEnded); - }; - }, [isVideo, audioUrl]); - - useEffect(() => { - setIsPreviewPlaying(false); - }, [audioUrl]); - - const charRowRef = useRef<HTMLDivElement | null>(null); - const charRefs = useRef<(HTMLSpanElement | null)[]>([]); - const [wrapSpaceIndicators, setWrapSpaceIndicators] = useState<boolean[]>([]); - const countdownIntervalRef = useRef<number | null>(null); - - const [g, dispatch] = useReducer(gReducer, initialGState); - - const gameLines = useMemo( - () => parseLrcLines(lrcContent, { skipBacking }), - [lrcContent, skipBacking] - ); - const isReady = !loadingLrc && !!lrcContent && !!audioUrl; - - const accuracy = - g.totalCorrect + g.totalMiss > 0 - ? Math.round((g.totalCorrect / (g.totalCorrect + g.totalMiss)) * 100) - : 100; - - const elapsedMs = - phase === "playing" - ? Math.max(1, Date.now() - gameStartTimeRef.current) - : gameDurationMs; - - const wpm = - elapsedMs > 0 ? Math.round(g.totalCorrect / 5 / (elapsedMs / 60000)) : 0; - - const gRef = useRef(g); - const currentLineContent = - g.displayedLineIdx >= 0 ? gameLines[g.displayedLineIdx]?.content ?? "" : ""; - - useEffect(() => { - charRefs.current = []; - }, [currentLineContent]); - - useLayoutEffect(() => { - if (!charRowRef.current) return; - let frame = 0; - const text = currentLineContent.toLowerCase(); - - const recompute = () => { - const nodes = charRefs.current; - const indicators = new Array(text.length).fill(false); - for (let i = 0; i < text.length - 1; i += 1) { - if (text[i] !== " ") continue; - const curr = nodes[i]; - const next = nodes[i + 1]; - if (!curr || !next) continue; - const currRect = curr.getBoundingClientRect(); - const nextRect = next.getBoundingClientRect(); - if (nextRect.top - currRect.top > 1) { - indicators[i] = true; - } - } - setWrapSpaceIndicators(indicators); - }; - - const schedule = () => { - cancelAnimationFrame(frame); - frame = requestAnimationFrame(() => { - frame = requestAnimationFrame(recompute); - }); - }; - - schedule(); - - if (document.fonts?.ready) { - document.fonts.ready.then(schedule); - } - - const observer = new ResizeObserver(schedule); - observer.observe(charRowRef.current); - window.addEventListener("resize", schedule); - - return () => { - observer.disconnect(); - window.removeEventListener("resize", schedule); - cancelAnimationFrame(frame); - }; - }, [currentLineContent]); - useEffect(() => { - gRef.current = g; - }, [g]); - - const phaseRef = useRef<GamePhase>("idle"); - useEffect(() => { - phaseRef.current = phase; - }, [phase]); - - const offsetRef = useRef(0); - useEffect(() => { - offsetRef.current = offset; - }, [offset]); - - useEffect(() => { - if (!("mediaSession" in navigator)) return; - const mediaSession = navigator.mediaSession; - mediaSession.setActionHandler("pause", () => {}); - return () => { - mediaSession.setActionHandler("pause", null); - }; - }, []); - - useEffect(() => { - return () => { - if (countdownIntervalRef.current !== null) { - clearInterval(countdownIntervalRef.current); - countdownIntervalRef.current = null; - } - }; - }, []); - - const lineAnimRef = useRef({ startMs: 0, endMs: 0, startPerf: 0 }); - - const timeBasedLineIdx = useMemo(() => { - if (!gameLines.length) return -1; - let idx = -1; - for (let i = 0; i < gameLines.length; i++) { - if (gameLines[i].millisecond <= currentMs) idx = i; - else break; - } - return idx; - }, [currentMs, gameLines]); - - const intermissionData = useMemo(() => { - const firstMs = gameLines[0]?.millisecond ?? 0; - const firstMediaMs = firstMs - offsetRef.current; - const remainingMs = Math.max(0, firstMs - currentMs); - if (!gameLines.length || firstMediaMs <= 0) { - return { pct: remainingMs === 0 ? 100 : 0, remainingMs }; - } - - const mediaCurrentMs = currentMs - offsetRef.current; - const pct = Math.min(100, Math.max(0, (mediaCurrentMs / firstMediaMs) * 100)); - - return { pct, remainingMs }; - }, [gameLines, currentMs, offset]); - - useEffect(() => { - const idx = g.displayedLineIdx; - if (idx < 0 || !gameLines[idx]) { - lineAnimRef.current = { startMs: 0, endMs: 0, startPerf: 0 }; - setLineTimingPct(0); - setLineRemainingMs(0); - setCurrentLineTime(-1); - return; - } - const start = gameLines[idx].millisecond; - const end = gameLines[idx + 1]?.millisecond ?? start + 5000; - lineAnimRef.current = { - startMs: start, - endMs: end, - startPerf: performance.now(), - }; - setLineTimingPct(0); - const currentLineTime = end - start; - setLineRemainingMs(Math.max(0, currentLineTime)); - setCurrentLineTime(Math.max(currentLineTime, currentLineTime)); - }, [g.displayedLineIdx, gameLines]); - - useEffect(() => { - if (phase !== "playing") return; - let rafId = 0; - const tick = () => { - const { startMs, endMs, startPerf } = lineAnimRef.current; - if (endMs <= startMs) { - setLineTimingPct(100); - setLineRemainingMs(0); - } else { - const elapsed = performance.now() - startPerf; - const duration = endMs - startMs; - const pct = Math.min(100, Math.max(0, (elapsed / duration) * 100)); - const remaining = Math.max(0, duration - elapsed); - setLineTimingPct(pct); - setLineRemainingMs(remaining); - } - rafId = requestAnimationFrame(tick); - }; - rafId = requestAnimationFrame(tick); - return () => cancelAnimationFrame(rafId); - }, [phase]); - - useEffect(() => { - const media = isVideo ? videoRef.current : audioRef.current; - if (!media) return; - const onTimeUpdate = () => { - setCurrentMs(media.currentTime * 1000 + offsetRef.current); - if (media.duration && !isNaN(media.duration)) { - setDuration(media.duration * 1000); - setProgressPct((media.currentTime / media.duration) * 100); - } - }; - const onLoadedMetadata = () => { - if (!isNaN(media.duration)) { - setDuration(media.duration * 1000); - setGameDurationMs(media.duration * 1000); - } - }; - const onEnded = () => { - if (phaseRef.current === "playing") { - setPhase("finished"); - setGameDurationMs(Date.now() - gameStartTimeRef.current); - return; - } - setIsPreviewPlaying(false); - }; - media.addEventListener("timeupdate", onTimeUpdate); - media.addEventListener("loadedmetadata", onLoadedMetadata); - media.addEventListener("ended", onEnded); - return () => { - media.removeEventListener("timeupdate", onTimeUpdate); - media.removeEventListener("loadedmetadata", onLoadedMetadata); - media.removeEventListener("ended", onEnded); - }; - }, [isVideo, audioUrl]); - - useEffect(() => { - if (phaseRef.current !== "playing") return; - if (timeBasedLineIdx < 0) return; - if (timeBasedLineIdx <= lastHandledIdxRef.current) return; - lastHandledIdxRef.current = timeBasedLineIdx; - lastLineAdvanceAtRef.current = performance.now(); - dispatch({ - type: "ADVANCE", - newIdx: timeBasedLineIdx, - prevCompleted: gRef.current.lineCompleted, - }); - }, [timeBasedLineIdx]); - - const loadData = useCallback((data: Record<string, unknown>) => { - if (typeof data.lrc === "string" && data.lrc) { - setLoadingLrc(true); - fetch(data.lrc) - .then((r) => r.text()) - .then((t) => { - setLrcContent(t); - setLoadingLrc(false); - }); - } - if (typeof data.file1 === "string") setAudioUrl(data.file1); - if (typeof data.offset === "number") setOffset(data.offset); - if (typeof data.offset === "string" && data.offset.trim() !== "") - setOffset(Number(data.offset)); - if (typeof data.title === "string") setSongTitle(data.title); - if (typeof data.artist === "string") setSongArtist(data.artist); - if (typeof data.skip_backing === "boolean") - setSkipBacking(data.skip_backing); - if (typeof data.skip_backing === "string") - setSkipBacking(data.skip_backing === "true"); - }, []); - - useEffect(() => { - const code = searchParams.get("code"); - if (!code) return; - try { - const json = atob(code); - const data = JSON.parse(json) as Record<string, unknown>; - loadData(data); - } catch {} - }, []); // eslint-disable-line react-hooks/exhaustive-deps - - const handlePreviewToggle = useCallback(() => { - if (phase !== "idle") return; - const media = isVideo ? videoRef.current : audioRef.current; - if (!media || !audioUrl) return; - - if (media.paused) { - void media.play().catch(() => { - toast.error("Unable to start preview. Try interacting with the page again.", { - theme: "dark", - }); - }); - return; - } - - media.pause(); - }, [phase, isVideo, audioUrl]); - - const handleStart = useCallback(() => { - const media = isVideo ? videoRef.current : audioRef.current; - if (!media || !lrcContent || !audioUrl) return; - if (countdownIntervalRef.current !== null) { - clearInterval(countdownIntervalRef.current); - countdownIntervalRef.current = null; - } - dispatch({ type: "RESET" }); - lastHandledIdxRef.current = -1; - media.pause(); - media.currentTime = 0; - setIsPreviewPlaying(false); - setPhase("countdown"); - setCountdown(5); - setGameDurationMs(0); - setProgressPct(0); - setCurrentMs(0); - - const beginPlayback = () => { - media.currentTime = 0; - media.play(); - setPhase("playing"); - gameStartTimeRef.current = Date.now(); - }; - - countdownIntervalRef.current = window.setInterval(() => { - setCountdown((c) => { - if (c <= 1) { - if (countdownIntervalRef.current !== null) { - clearInterval(countdownIntervalRef.current); - countdownIntervalRef.current = null; - } - beginPlayback(); - return 0; - } - return c - 1; - }); - }, 1000); - }, [lrcContent, audioUrl, gameLines, isVideo]); - - const handleRestart = useCallback(() => { - const media = isVideo ? videoRef.current : audioRef.current; - if (media) { - media.pause(); - media.currentTime = 0; - } - setIsPreviewPlaying(false); - if (countdownIntervalRef.current !== null) { - clearInterval(countdownIntervalRef.current); - countdownIntervalRef.current = null; - } - setCountdown(0); - dispatch({ type: "RESET" }); - lastHandledIdxRef.current = -1; - setPhase("idle"); - setCurrentMs(0); - setProgressPct(0); - }, [isVideo]); - - const handleLoadCode = useCallback(() => { - if (!codeInput.trim()) return; - try { - const json = atob(codeInput.trim()); - const data = JSON.parse(json) as Record<string, unknown>; - loadData(data); - handleRestart(); - toast.success("Song loaded!", { theme: "dark" }); - } catch { - toast.error("Invalid code. Please check and try again.", { - theme: "dark", - }); - } - }, [codeInput, loadData, handleRestart]); - - const handleKeyPress = useCallback( - (char: string) => { - if (phaseRef.current !== "playing") return; - const line = gameLines[gRef.current.displayedLineIdx]; - if (!line || gRef.current.lineCompleted) return; - const expected = line.content[gRef.current.typedCount]; - if (expected === undefined) return; - if (char.toLowerCase() === expected.toLowerCase()) { - const willComplete = gRef.current.typedCount + 1 >= line.content.length; - dispatch({ type: "CORRECT", willComplete }); - if (willComplete) { - setClearShowing(true); - setTimeout(() => setClearShowing(false), 700); - setComboAnimKey((k) => k + 1); - } - } else { - if (performance.now() - lastLineAdvanceAtRef.current < 100) return; - dispatch({ type: "WRONG" }); - setWrongChar(true); - setTimeout(() => setWrongChar(false), 320); - } - }, - [gameLines], - ); - - useEffect(() => { - if (phase !== "playing") return; - const handler = (e: KeyboardEvent) => { - if (e.key === " ") { - const idx = gRef.current.displayedLineIdx; - if (idx < 0 && gameLines.length > 0) { - const firstMs = gameLines[0]?.millisecond ?? 0; - const media = isVideo ? videoRef.current : audioRef.current; - if (media) { - const currentMsLocal = media.currentTime * 1000 + offsetRef.current; - const intermissionRemaining = Math.max(0, firstMs - currentMsLocal); - if (intermissionRemaining > 5000) { - e.preventDefault(); - const targetMs = firstMs - 3000; - media.currentTime = Math.max(0, (targetMs - offsetRef.current) / 1000); - setCurrentMs(media.currentTime * 1000 + offsetRef.current); - return; - } - } - } - } - if (e.key.length === 1) { - e.preventDefault(); - handleKeyPress(e.key); - } - }; - window.addEventListener("keydown", handler); - return () => window.removeEventListener("keydown", handler); - }, [phase, handleKeyPress, gameLines, isVideo]); - - return ( - <GameRoot> - <ToastContainer theme="dark" /> - {!isVideo && ( - <audio ref={audioRef} src={audioUrl || undefined} preload="auto" /> - )} - {isVideo && ( - <BackgroundVideo - ref={videoRef} - src={audioUrl || undefined} - preload="auto" - playsInline - style={{ opacity: backgroundOpacity / 100 }} - /> - )} - <GameNavbar style={{ justifyContent: "space-between" }}> - <div style={{ display: "flex", alignItems: "center", gap: 16 }}> - <Link - href="/" - style={{ - display: "flex", - alignItems: "center", - gap: 8, - textDecoration: "none", - color: "#ffffff", - fontWeight: 700, - fontSize: 15, - }} - > - <MdLibraryMusic style={{ fontSize: 20, color: "#a78bfa" }} /> - LRC-Type - </Link> - - <Link - href="/create" - style={{ - fontSize: 13, - color: "rgba(255,255,255,0.6)", - textDecoration: "none", - }} - > - Create - </Link> - </div> - </GameNavbar> - - <GameContent style={{ position: "relative" }}> - {phase === "idle" && ( - <StartOverlay> - <StartCard> - {!isReady ? ( - <> - <SongTitleText>LRC-Type</SongTitleText> - <SongArtistText>Enter a game code to begin!</SongArtistText> - </> - ) : ( - <> - <SongTitleText> - {loadingLrc ? "Loading..." : songTitle} - </SongTitleText> - <SongArtistText>{songArtist}</SongArtistText> - </> - )} - - <StartBtn - onClick={handleStart} - disabled={!isReady} - suppressHydrationWarning - > - {loadingLrc ? "Loading song..." : "▶ Start Game"} - </StartBtn> - - <OpacityControl> - <OpacityLabel> - Volume - <OpacityValue>{audioVolume}%</OpacityValue> - </OpacityLabel> - <OpacitySlider - type="range" - min="0" - max="100" - value={audioVolume} - onChange={(e) => setAudioVolume(Number(e.target.value))} - /> - </OpacityControl> - - <PreviewWrap> - <PreviewBtn - onClick={handlePreviewToggle} - disabled={!audioUrl} - suppressHydrationWarning - > - {isPreviewPlaying ? "⏸ Pause Preview" : "▶ Preview Audio"} - </PreviewBtn> - <PreviewHint> - {audioUrl - ? "Use preview to test your volume before starting." - : "Load a chart to enable audio preview."} - </PreviewHint> - </PreviewWrap> - - {isVideo && ( - <OpacityControl> - <OpacityLabel> - Background opacity - <OpacityValue>{backgroundOpacity}%</OpacityValue> - </OpacityLabel> - <OpacitySlider - type="range" - min="0" - max="100" - value={backgroundOpacity} - onChange={(e) => - setBackgroundOpacity(Number(e.target.value)) - } - /> - </OpacityControl> - )} - <CodeSection> - <div - style={{ - fontSize: 11, - color: "rgba(255,255,255,0.3)", - letterSpacing: 1, - textTransform: "uppercase", - }} - > - Load a chart - </div> - <CodeInputRow> - <CodeInputField - placeholder="Enter a LRC-Type code..." - value={codeInput} - onChange={(e) => setCodeInput(e.target.value)} - onKeyDown={(e) => e.key === "Enter" && handleLoadCode()} - /> - <CodeLoadBtn onClick={handleLoadCode}>Load</CodeLoadBtn> - </CodeInputRow> - </CodeSection> - </StartCard> - </StartOverlay> - )} - - {phase === "countdown" && ( - <StartOverlay> - <StartCard> - <SongTitleText>Get Ready</SongTitleText> - <CountdownNumber>{countdown}</CountdownNumber> - </StartCard> - </StartOverlay> - )} - - {phase === "finished" && ( - <ResultsOverlay> - <ResultsCard> - <ResultsTitle>Results {songTitle}</ResultsTitle> - <BigScore>{g.score.toLocaleString()}</BigScore> - <StatsGrid> - <StatBlock> - <StatValue>{accuracy}%</StatValue> - <StatLabel>Accuracy</StatLabel> - </StatBlock> - <StatBlock> - <StatValue>x{g.maxCombo}</StatValue> - <StatLabel>Max Combo</StatLabel> - </StatBlock> - <StatBlock> - <StatValue>{wpm}</StatValue> - <StatLabel>WPM</StatLabel> - </StatBlock> - <StatBlock> - <StatValue>{g.totalMiss}</StatValue> - <StatLabel>Missed Chars</StatLabel> - </StatBlock> - </StatsGrid> - <ActionRow> - <PlayAgainBtn onClick={handleRestart}>Play Again</PlayAgainBtn> - <HomeBtn onClick={() => router.push("/typing")}>Home</HomeBtn> - </ActionRow> - </ResultsCard> - </ResultsOverlay> - )} - - <HUD> - <HudStat> - <HudValue>{g.score.toLocaleString()}</HudValue> - <HudLabel>Score</HudLabel> - </HudStat> - <HudStat> - <ComboValue - $animate={comboAnimKey > 0} - key={`combo-${comboAnimKey}`} - > - x{g.combo} - </ComboValue> - <HudLabel>Combo</HudLabel> - </HudStat> - <HudStat> - <HudValue>{accuracy}%</HudValue> - <HudLabel>Accuracy</HudLabel> - </HudStat> - <HudStat> - <HudValue>{wpm}</HudValue> - <HudLabel>WPM</HudLabel> - </HudStat> - <HudStat> - <HudValue>{g.totalMiss}</HudValue> - <HudLabel>Misses</HudLabel> - </HudStat> - </HUD> - - <GameArea> - {phase === "playing" && - g.displayedLineIdx < 0 && - gameLines.length > 0 && ( - <> - <UpcomingWrap> - <UpcomingLabel>Next</UpcomingLabel> - <UpcomingText> - {gameLines[0] && gameLines[0].content.trim() === "" - ? "[INTERMISSION]" - : gameLines[0]?.content ?? ""} - </UpcomingText> - </UpcomingWrap> - <CurrentWrap style={{ position: "relative" }}> - <LineTimingRow> - <LineTimingMeta> - Time to first line:{" "} - <LineTimingValue> - {Math.max(0, intermissionData.remainingMs / 1000).toFixed(1)}s - </LineTimingValue> - </LineTimingMeta> - </LineTimingRow> - <div - style={{ - fontSize: 12, - color: "rgba(255,255,255,0.6)", - marginTop: 8, - textAlign: "center", - }} - > - {intermissionData.remainingMs > 5000 && "Press Space to skip long intermissions"} - </div> - <LineTimingBar> - <LineTimingFill $pct={intermissionData.pct} /> - </LineTimingBar> - <CharRow ref={charRowRef} /> - <CompletedLineFade>[INTERMISSION]</CompletedLineFade> - </CurrentWrap> - </> - )} - {g.displayedLineIdx >= 0 && gameLines[g.displayedLineIdx] && ( - <> - <UpcomingWrap> - <UpcomingLabel>Next</UpcomingLabel> - <UpcomingText> - {gameLines[g.displayedLineIdx + 1] && - gameLines[g.displayedLineIdx + 1].content.trim() === "" - ? "[INTERMISSION]" - : gameLines[g.displayedLineIdx + 1]?.content ?? ""} - </UpcomingText> - </UpcomingWrap> - <CurrentWrap style={{ position: "relative" }}> - <LineTimingRow> - <LineTimingMeta> - Time left:{" "} - <LineTimingValue> - {Math.max(0, lineRemainingMs / 1000).toFixed(1)}s - </LineTimingValue> - </LineTimingMeta> - {gameLines[g.displayedLineIdx].content.trim() !== "" && ( - <LineTimingMeta> - Estimated CPS:{" "} - <LineTimingValue> - {calculateCPSNeeded( - gameLines[g.displayedLineIdx].content, - currentLineTime / 1000 - ).toFixed(1)} - </LineTimingValue> - </LineTimingMeta> - )} - </LineTimingRow> - <LineTimingBar> - <LineTimingFill $pct={lineTimingPct} /> - </LineTimingBar> - <CharRow ref={charRowRef}> - {gameLines[g.displayedLineIdx].content.trim() !== "" && - (() => { - const rawText = gameLines[g.displayedLineIdx].content; - const text = rawText.toLowerCase(); - const tokens = text.split(/(\s+)/).filter(Boolean); - let renderIndex = 0; - return tokens.flatMap((token, tokenIdx) => { - if (/^\s+$/.test(token)) { - return token.split("").map((ch, spaceIdx) => { - let state: "typed" | "active" | "pending" | "wrong"; - if (renderIndex < g.typedCount) state = "typed"; - else if (renderIndex === g.typedCount) - state = wrongChar ? "wrong" : "active"; - else state = "pending"; - const charIndex = renderIndex; - const showIndicator = - ch === " " && - wrapSpaceIndicators[charIndex] && - state !== "typed"; - const displayChar = - ch === " " - ? showIndicator - ? "␣" - : "\u00A0" - : ch; - const element = ( - <CharBox - key={`space-${tokenIdx}-${spaceIdx}`} - $state={state} - ref={(el) => { - charRefs.current[charIndex] = el; - }} - > - {displayChar} - </CharBox> - ); - renderIndex += 1; - return element; - }); - } - - const wordChars = token.split("").map((ch, charIdx) => { - let state: "typed" | "active" | "pending" | "wrong"; - if (renderIndex < g.typedCount) state = "typed"; - else if (renderIndex === g.typedCount) - state = wrongChar ? "wrong" : "active"; - else state = "pending"; - const charIndex = renderIndex; - const element = ( - <CharBox - key={`char-${tokenIdx}-${charIdx}`} - $state={state} - ref={(el) => { - charRefs.current[charIndex] = el; - }} - > - {ch} - </CharBox> - ); - renderIndex += 1; - return element; - }); - - return ( - <WordWrap key={`word-${tokenIdx}`}> - {wordChars} - </WordWrap> - ); - }); - })()} - </CharRow> - {clearShowing && <ClearToast>CLEAR!</ClearToast>} - <CompletedLineFade> - {gameLines[g.displayedLineIdx].content.trim() === "" - ? "[INTERMISSION]" - : g.lineCompleted - ? "Cleared - waiting for next line..." - : gameLines[g.displayedLineIdx].content} - </CompletedLineFade> - </CurrentWrap> - </> - )} - {phase === "idle" && ( - <GetReadyText style={{ opacity: 0.3 }}> - Start the game to begin typing - </GetReadyText> - )} - </GameArea> - - <GameFooter> - <ControlBtn onClick={handleRestart} title="Restart"> - <FaRedo /> - </ControlBtn> - <ProgressWrap> - <ProgressFill $pct={progressPct} /> - </ProgressWrap> - <TimeText> - {formatTime(Math.max(0, currentMs))} / {formatTime(duration)} - </TimeText> - </GameFooter> - </GameContent> - </GameRoot> - ); -} - -export default function GamePage() { - return ( - <> - <GameGlobalStyle /> - <Suspense - fallback={ - <GameRoot> - <div - style={{ - flex: 1, - display: "flex", - alignItems: "center", - justifyContent: "center", - fontSize: 18, - color: "rgba(255,255,255,0.5)", - }} - > - Loading... - </div> - </GameRoot> - } - > - <GameInner /> - </Suspense> - </> - ); -} |
