aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--src/app/game/[slug]/components/PlayingView.tsx267
-rw-r--r--src/app/game/[slug]/components/PreGameView.tsx111
-rw-r--r--src/app/game/[slug]/components/ResultsView.tsx66
-rw-r--r--src/app/game/[slug]/hooks/useGameEngine.ts597
-rw-r--r--src/app/game/[slug]/page.tsx1012
5 files changed, 1121 insertions, 932 deletions
diff --git a/src/app/game/[slug]/components/PlayingView.tsx b/src/app/game/[slug]/components/PlayingView.tsx
new file mode 100644
index 0000000..446670a
--- /dev/null
+++ b/src/app/game/[slug]/components/PlayingView.tsx
@@ -0,0 +1,267 @@
+"use client";
+
+import { FaRedo } from "react-icons/fa";
+import {
+ HUD,
+ HudStat,
+ HudValue,
+ HudLabel,
+ ComboValue,
+ GameArea,
+ UpcomingWrap,
+ UpcomingLabel,
+ UpcomingText,
+ CurrentWrap,
+ LineTimingRow,
+ LineTimingMeta,
+ LineTimingValue,
+ LineTimingBar,
+ LineTimingFill,
+ CharRow,
+ WordWrap,
+ CharBox,
+ ClearToast,
+ GetReadyText,
+ CompletedLineFade,
+ GameFooter,
+ ControlBtn,
+ ProgressWrap,
+ ProgressFill,
+ TimeText,
+ StartOverlay,
+ StartCard,
+ SongTitleText,
+ CountdownNumber,
+} from "../page.styles";
+import { formatTime, calculateCPSNeeded, GameLine } from "../game.utils";
+import { GState } from "../game.stat";
+
+type GamePhase = "idle" | "countdown" | "playing" | "paused" | "finished";
+
+interface PlayingViewProps {
+ phase: GamePhase;
+ countdown: number;
+ g: GState;
+ accuracy: number;
+ wpm: number;
+ gameLines: GameLine[];
+ currentMs: number;
+ duration: number;
+ progressPct: number;
+ lineTimingPct: number;
+ lineRemainingMs: number;
+ currentLineTime: number;
+ intermissionData: { pct: number; remainingMs: number };
+ wrongChar: boolean;
+ clearShowing: boolean;
+ comboAnimKey: number;
+ wrapSpaceIndicators: boolean[];
+ charRowRef: React.MutableRefObject<HTMLDivElement | null>;
+ charRefs: React.MutableRefObject<(HTMLSpanElement | null)[]>;
+ onRestart: () => void;
+}
+
+export default function PlayingView({
+ phase,
+ countdown,
+ g,
+ accuracy,
+ wpm,
+ gameLines,
+ currentMs,
+ duration,
+ progressPct,
+ lineTimingPct,
+ lineRemainingMs,
+ currentLineTime,
+ intermissionData,
+ wrongChar,
+ clearShowing,
+ comboAnimKey,
+ wrapSpaceIndicators,
+ charRowRef,
+ charRefs,
+ onRestart,
+}: PlayingViewProps) {
+ return (
+ <>
+ {phase === "countdown" && (
+ <StartOverlay>
+ <StartCard>
+ <SongTitleText>Get Ready</SongTitleText>
+ <CountdownNumber>{countdown}</CountdownNumber>
+ </StartCard>
+ </StartOverlay>
+ )}
+
+ <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={onRestart} title="Restart">
+ <FaRedo />
+ </ControlBtn>
+ <ProgressWrap>
+ <ProgressFill $pct={progressPct} />
+ </ProgressWrap>
+ <TimeText>
+ {formatTime(Math.max(0, currentMs))} / {formatTime(duration)}
+ </TimeText>
+ </GameFooter>
+ </>
+ );
+}
diff --git a/src/app/game/[slug]/components/PreGameView.tsx b/src/app/game/[slug]/components/PreGameView.tsx
new file mode 100644
index 0000000..a92b1cb
--- /dev/null
+++ b/src/app/game/[slug]/components/PreGameView.tsx
@@ -0,0 +1,111 @@
+"use client";
+
+import {
+ StartOverlay,
+ StartCard,
+ SongTitleText,
+ SongArtistText,
+ StartBtn,
+ OpacityControl,
+ OpacityLabel,
+ OpacitySlider,
+ OpacityValue,
+ PreviewWrap,
+ PreviewBtn,
+ PreviewHint,
+} from "../page.styles";
+
+interface PreGameViewProps {
+ isReady: boolean;
+ loadingLrc: boolean;
+ songTitle: string;
+ songArtist: string;
+ audioUrl: string;
+ isVideo: boolean;
+ audioVolume: number;
+ setAudioVolume: (v: number) => void;
+ backgroundOpacity: number;
+ setBackgroundOpacity: (v: number) => void;
+ isPreviewPlaying: boolean;
+ onStart: () => void;
+ onPreviewToggle: () => void;
+}
+
+export default function PreGameView({
+ isReady,
+ loadingLrc,
+ songTitle,
+ songArtist,
+ audioUrl,
+ isVideo,
+ audioVolume,
+ setAudioVolume,
+ backgroundOpacity,
+ setBackgroundOpacity,
+ isPreviewPlaying,
+ onStart,
+ onPreviewToggle,
+}: PreGameViewProps) {
+ return (
+ <StartOverlay>
+ <StartCard>
+ {!isReady ? (
+ <>
+ <SongTitleText>Loading...</SongTitleText>
+ <SongArtistText>Please wait while we load the chart</SongArtistText>
+ </>
+ ) : (
+ <>
+ <SongTitleText>{loadingLrc ? "Loading..." : songTitle}</SongTitleText>
+ <SongArtistText>{songArtist}</SongArtistText>
+ </>
+ )}
+
+ <StartBtn onClick={onStart} 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>
+
+ {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>
+ )}
+
+ <PreviewWrap>
+ <PreviewBtn onClick={onPreviewToggle} 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>
+ </StartCard>
+ </StartOverlay>
+ );
+}
diff --git a/src/app/game/[slug]/components/ResultsView.tsx b/src/app/game/[slug]/components/ResultsView.tsx
new file mode 100644
index 0000000..48b41bb
--- /dev/null
+++ b/src/app/game/[slug]/components/ResultsView.tsx
@@ -0,0 +1,66 @@
+"use client";
+
+import { useRouter } from "next/navigation";
+import {
+ ResultsOverlay,
+ ResultsCard,
+ ResultsTitle,
+ BigScore,
+ StatsGrid,
+ StatBlock,
+ StatValue,
+ StatLabel,
+ ActionRow,
+ PlayAgainBtn,
+ HomeBtn,
+} from "../page.styles";
+import { GState } from "../game.stat";
+
+interface ResultsViewProps {
+ g: GState;
+ accuracy: number;
+ wpm: number;
+ songTitle: string;
+ onPlayAgain: () => void;
+}
+
+export default function ResultsView({
+ g,
+ accuracy,
+ wpm,
+ songTitle,
+ onPlayAgain,
+}: ResultsViewProps) {
+ const router = useRouter();
+
+ return (
+ <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={onPlayAgain}>Play Again</PlayAgainBtn>
+ <HomeBtn onClick={() => router.push("/")}>Home</HomeBtn>
+ </ActionRow>
+ </ResultsCard>
+ </ResultsOverlay>
+ );
+}
diff --git a/src/app/game/[slug]/hooks/useGameEngine.ts b/src/app/game/[slug]/hooks/useGameEngine.ts
new file mode 100644
index 0000000..b2bd126
--- /dev/null
+++ b/src/app/game/[slug]/hooks/useGameEngine.ts
@@ -0,0 +1,597 @@
+"use client";
+
+import {
+ useCallback,
+ useEffect,
+ useLayoutEffect,
+ useMemo,
+ useReducer,
+ useRef,
+ useState,
+} from "react";
+import { useParams, useRouter } from "next/navigation";
+import { toast } from "react-toastify";
+import pb from "../../../lib/pocketbase";
+import { gReducer, initialGState, GState } from "../game.stat";
+import {
+ parseLrcLines,
+ GameLine,
+ formatTime,
+ calculateCPSNeeded,
+} from "../game.utils";
+
+export type GamePhase = "idle" | "countdown" | "playing" | "paused" | "finished";
+
+const VIDEO_EXTENSIONS = new Set(["mp4", "webm", "mov", "m4v", "ogv"]);
+
+export function isVideoUrl(url: string): boolean {
+ 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";
+
+export interface GameEngineResult {
+ // refs
+ audioRef: React.RefObject<HTMLAudioElement | null>;
+ videoRef: React.RefObject<HTMLVideoElement | null>;
+ charRowRef: React.MutableRefObject<HTMLDivElement | null>;
+ charRefs: React.MutableRefObject<(HTMLSpanElement | null)[]>;
+
+ // playback / timing state
+ phase: GamePhase;
+ currentMs: number;
+ duration: number;
+ progressPct: number;
+ gameDurationMs: number;
+ countdown: number;
+ lineTimingPct: number;
+ lineRemainingMs: number;
+ currentLineTime: number;
+
+ // song metadata & content
+ lrcContent: string;
+ audioUrl: string;
+ songTitle: string;
+ songArtist: string;
+ offset: number;
+ loadingLrc: boolean;
+
+ // game logic
+ g: GState;
+ gameLines: GameLine[];
+ isReady: boolean;
+ accuracy: number;
+ wpm: number;
+
+ // visual feedback
+ wrongChar: boolean;
+ clearShowing: boolean;
+ comboAnimKey: number;
+ wrapSpaceIndicators: boolean[];
+
+ // settings
+ backgroundOpacity: number;
+ setBackgroundOpacity: React.Dispatch<React.SetStateAction<number>>;
+ audioVolume: number;
+ setAudioVolume: React.Dispatch<React.SetStateAction<number>>;
+
+ // preview / misc
+ isPreviewPlaying: boolean;
+ skipBacking: boolean;
+ isVideo: boolean;
+ intermissionData: { pct: number; remainingMs: number };
+
+ // handlers
+ handleStart: () => void;
+ handleRestart: () => void;
+ handlePreviewToggle: () => void;
+
+ // navigation
+ router: ReturnType<typeof useRouter>;
+
+ // helpers (forwarded for consumers that need them)
+ formatTime: typeof formatTime;
+ calculateCPSNeeded: typeof calculateCPSNeeded;
+}
+
+export function useGameEngine(): GameEngineResult {
+ const params = useParams<{ slug: string }>();
+ const slug = params?.slug ?? "";
+ 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 [wrongChar, setWrongChar] = useState(false);
+ const [clearShowing, setClearShowing] = useState(false);
+ const [comboAnimKey, setComboAnimKey] = useState(0);
+ const [countdown, setCountdown] = useState(0);
+ const [backgroundOpacity, setBackgroundOpacity] = useState(0);
+ const [audioVolume, setAudioVolume] = useState(100);
+ const [isPreviewPlaying, setIsPreviewPlaying] = useState(false);
+ const [skipBacking, setSkipBacking] = useState(false);
+ const isVideo = useMemo(() => isVideoUrl(audioUrl), [audioUrl]);
+
+ useEffect(() => {
+ const storedOpacity = localStorage.getItem(BACKGROUND_OPACITY_KEY);
+ if (storedOpacity !== null) {
+ const parsed = Number(storedOpacity);
+ if (Number.isFinite(parsed)) setBackgroundOpacity(Math.min(100, Math.max(0, parsed)));
+ }
+ const storedVolume = localStorage.getItem(AUDIO_VOLUME_KEY);
+ if (storedVolume !== null) {
+ const parsed = Number(storedVolume);
+ if (Number.isFinite(parsed)) setAudioVolume(Math.min(100, Math.max(0, parsed)));
+ }
+ }, []);
+
+ 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]); // eslint-disable-line react-hooks/exhaustive-deps
+
+ 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.media === "string") setAudioUrl(data.media);
+ 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(() => {
+ if (!slug) return;
+ pb.collection("charts")
+ .getOne(slug)
+ .then((record) => {
+ loadData({
+ media: (record as Record<string, unknown>).media,
+ lrc: (record as Record<string, unknown>).lrc,
+ offset: (record as Record<string, unknown>).offset,
+ title: (record as Record<string, unknown>).title,
+ artist: (record as Record<string, unknown>).artist,
+ });
+ })
+ .catch(() => {
+ try {
+ const json = atob(slug);
+ 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]); // eslint-disable-line react-hooks/exhaustive-deps
+
+ 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 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 {
+ // refs
+ audioRef,
+ videoRef,
+ charRowRef,
+ charRefs,
+
+ // playback / timing
+ phase,
+ currentMs,
+ duration,
+ progressPct,
+ gameDurationMs,
+ countdown,
+ lineTimingPct,
+ lineRemainingMs,
+ currentLineTime,
+
+ // song metadata & content
+ lrcContent,
+ audioUrl,
+ songTitle,
+ songArtist,
+ offset,
+ loadingLrc,
+
+ // game logic
+ g,
+ gameLines,
+ isReady,
+ accuracy,
+ wpm,
+
+ // visual feedback
+ wrongChar,
+ clearShowing,
+ comboAnimKey,
+ wrapSpaceIndicators,
+
+ // settings
+ backgroundOpacity,
+ setBackgroundOpacity,
+ audioVolume,
+ setAudioVolume,
+
+ // preview / misc
+ isPreviewPlaying,
+ skipBacking,
+ isVideo,
+ intermissionData,
+
+ // handlers
+ handleStart,
+ handleRestart,
+ handlePreviewToggle,
+
+ // navigation
+ router,
+
+ // helpers
+ formatTime,
+ calculateCPSNeeded,
+ };
+}
diff --git a/src/app/game/[slug]/page.tsx b/src/app/game/[slug]/page.tsx
index 9d449ce..d9f431f 100644
--- a/src/app/game/[slug]/page.tsx
+++ b/src/app/game/[slug]/page.tsx
@@ -1,603 +1,43 @@
"use client";
-import {
- useCallback,
- useEffect,
- useLayoutEffect,
- useMemo,
- useReducer,
- useRef,
- useState,
- Suspense,
-} from "react";
-import { useRouter, useParams } from "next/navigation";
-import pb from "../../lib/pocketbase";
+import { Suspense } from "react";
+import { ToastContainer } from "react-toastify";
+import "react-toastify/dist/ReactToastify.css";
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,
- 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";
+import { GameGlobalStyle, GameRoot, GameNavbar, GameContent, BackgroundVideo } from "./page.styles";
+import { useGameEngine } from "./hooks/useGameEngine";
+import PreGameView from "./components/PreGameView";
+import PlayingView from "./components/PlayingView";
+import ResultsView from "./components/ResultsView";
function GameInner() {
- const params = useParams<{ slug: string }>();
- const slug = params?.slug ?? "";
- 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 [wrongChar, setWrongChar] = useState(false);
- const [clearShowing, setClearShowing] = useState(false);
- const [comboAnimKey, setComboAnimKey] = useState(0);
- const [countdown, setCountdown] = useState(0);
- const [backgroundOpacity, setBackgroundOpacity] = useState(0);
- const [audioVolume, setAudioVolume] = useState(100);
- const [isPreviewPlaying, setIsPreviewPlaying] = useState(false);
- const [skipBacking, setSkipBacking] = useState(false);
- const isVideo = useMemo(() => isVideoUrl(audioUrl), [audioUrl]);
-
- useEffect(() => {
- const storedOpacity = localStorage.getItem(BACKGROUND_OPACITY_KEY);
- if (storedOpacity !== null) {
- const parsed = Number(storedOpacity);
- if (Number.isFinite(parsed))
- setBackgroundOpacity(Math.min(100, Math.max(0, parsed)));
- }
- const storedVolume = localStorage.getItem(AUDIO_VOLUME_KEY);
- if (storedVolume !== null) {
- const parsed = Number(storedVolume);
- if (Number.isFinite(parsed))
- setAudioVolume(Math.min(100, Math.max(0, parsed)));
- }
- }, []);
-
- 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 engine = useGameEngine();
- 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.media === "string") setAudioUrl(data.media);
- 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(() => {
- if (!slug) return;
- pb.collection("charts")
- .getOne(slug)
- .then((record) => {
- loadData({
- media: (record as Record<string, unknown>).media,
- lrc: (record as Record<string, unknown>).lrc,
- offset: (record as Record<string, unknown>).offset,
- title: (record as Record<string, unknown>).title,
- artist: (record as Record<string, unknown>).artist,
- });
- })
- .catch(() => {
- try {
- const json = atob(slug);
- 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 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]);
+ const {
+ // refs
+ audioRef, videoRef, charRowRef, charRefs,
+ // song
+ audioUrl, songTitle, songArtist, isVideo, isReady, loadingLrc,
+ // phase
+ phase, countdown,
+ // game state
+ g, gameLines, accuracy, wpm,
+ // timing
+ currentMs, duration, progressPct, lineTimingPct, lineRemainingMs,
+ currentLineTime, intermissionData,
+ // display
+ wrongChar, clearShowing, comboAnimKey, wrapSpaceIndicators,
+ // settings
+ backgroundOpacity, setBackgroundOpacity, audioVolume, setAudioVolume,
+ isPreviewPlaying,
+ // handlers
+ handleStart, handleRestart, handlePreviewToggle,
+ } = engine;
return (
<GameRoot>
<ToastContainer theme="dark" />
+
{!isVideo && (
<audio ref={audioRef} src={audioUrl || undefined} preload="auto" />
)}
@@ -610,31 +50,20 @@ function GameInner() {
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,
+ display: "flex", alignItems: "center", gap: 8,
+ textDecoration: "none", color: "#ffffff", fontWeight: 700, fontSize: 15,
}}
>
<MdLibraryMusic style={{ fontSize: 20, color: "#a78bfa" }} />
TypingMIXX
</Link>
- <Link
- href="/"
- style={{
- fontSize: 13,
- color: "rgba(255,255,255,0.6)",
- textDecoration: "none",
- }}
- >
+ <Link href="/" style={{ fontSize: 13, color: "rgba(255,255,255,0.6)", textDecoration: "none" }}>
Home
</Link>
</div>
@@ -642,329 +71,57 @@ function GameInner() {
<GameContent style={{ position: "relative" }}>
{phase === "idle" && (
- <StartOverlay>
- <StartCard>
- {!isReady ? (
- <>
- <SongTitleText>Loading...</SongTitleText>
- <SongArtistText>
- Please wait while we load the chart
- </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>
-
- {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>
- )}
- <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>
- </StartCard>
- </StartOverlay>
+ <PreGameView
+ isReady={isReady}
+ loadingLrc={loadingLrc}
+ songTitle={songTitle}
+ songArtist={songArtist}
+ audioUrl={audioUrl}
+ isVideo={isVideo}
+ audioVolume={audioVolume}
+ setAudioVolume={setAudioVolume}
+ backgroundOpacity={backgroundOpacity}
+ setBackgroundOpacity={setBackgroundOpacity}
+ isPreviewPlaying={isPreviewPlaying}
+ onStart={handleStart}
+ onPreviewToggle={handlePreviewToggle}
+ />
)}
- {phase === "countdown" && (
- <StartOverlay>
- <StartCard>
- <SongTitleText>Get Ready</SongTitleText>
- <CountdownNumber>{countdown}</CountdownNumber>
- </StartCard>
- </StartOverlay>
+ {(phase === "countdown" || phase === "playing") && (
+ <PlayingView
+ phase={phase}
+ countdown={countdown}
+ g={g}
+ accuracy={accuracy}
+ wpm={wpm}
+ gameLines={gameLines}
+ currentMs={currentMs}
+ duration={duration}
+ progressPct={progressPct}
+ lineTimingPct={lineTimingPct}
+ lineRemainingMs={lineRemainingMs}
+ currentLineTime={currentLineTime}
+ intermissionData={intermissionData}
+ wrongChar={wrongChar}
+ clearShowing={clearShowing}
+ comboAnimKey={comboAnimKey}
+ wrapSpaceIndicators={wrapSpaceIndicators}
+ charRowRef={charRowRef}
+ charRefs={charRefs}
+ onRestart={handleRestart}
+ />
)}
{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>
+ <ResultsView
+ g={g}
+ accuracy={accuracy}
+ wpm={wpm}
+ songTitle={songTitle}
+ onPlayAgain={handleRestart}
+ />
)}
-
- <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>
);
@@ -977,16 +134,7 @@ export default function GamePage() {
<Suspense
fallback={
<GameRoot>
- <div
- style={{
- flex: 1,
- display: "flex",
- alignItems: "center",
- justifyContent: "center",
- fontSize: 18,
- color: "rgba(255,255,255,0.5)",
- }}
- >
+ <div style={{ flex: 1, display: "flex", alignItems: "center", justifyContent: "center", fontSize: 18, color: "rgba(255,255,255,0.5)" }}>
Loading...
</div>
</GameRoot>
send patches to the email below
yukais@pinapelz.com
include the subject [PATCH repo_name]
pinapelz.com
homepage