aboutsummaryrefslogtreecommitdiffstats
path: root/src/app/game/page.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'src/app/game/page.tsx')
-rw-r--r--src/app/game/page.tsx1011
1 files changed, 1011 insertions, 0 deletions
diff --git a/src/app/game/page.tsx b/src/app/game/page.tsx
new file mode 100644
index 0000000..bce01b3
--- /dev/null
+++ b/src/app/game/page.tsx
@@ -0,0 +1,1011 @@
+"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>
+ </>
+ );
+}
send patches to the email below
yukais@pinapelz.com
include the subject [PATCH repo_name]
pinapelz.com
homepage