aboutsummaryrefslogtreecommitdiffstats
path: root/src/app/game/page.tsx
diff options
context:
space:
mode:
authorPinapelz <yukais@pinapelz.com>2026-06-02 02:12:57 -0700
committerPinapelz <yukais@pinapelz.com>2026-06-02 02:13:10 -0700
commit0335b0ad81169232a3dbb1be1341fdcfce548645 (patch)
tree910593fa5e072ea77f594b6f10ddd96e49452446 /src/app/game/page.tsx
parent0d35e75edbc75f186e4a1ed52fbc3549ee9f5cd6 (diff)
migrate to pocketbase backend + auth/login
Diffstat (limited to 'src/app/game/page.tsx')
-rw-r--r--src/app/game/page.tsx1011
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>
- </>
- );
-}
send patches to the email below
yukais@pinapelz.com
include the subject [PATCH repo_name]
pinapelz.com
homepage