diff options
| author | Pinapelz <yukais@pinapelz.com> | 2026-06-02 11:26:03 -0700 |
|---|---|---|
| committer | Pinapelz <yukais@pinapelz.com> | 2026-06-02 11:26:03 -0700 |
| commit | f9f1a4a5377d99db30ae6e4507cb0af970f003ef (patch) | |
| tree | 0a8ad680624d5ca3d4fc4a3de21a6df63aff4853 /src | |
| parent | 99b41151bb55841e5ff6925023a49e077cbe6daf (diff) | |
seperate game page into views
Diffstat (limited to 'src')
| -rw-r--r-- | src/app/game/[slug]/components/PlayingView.tsx | 267 | ||||
| -rw-r--r-- | src/app/game/[slug]/components/PreGameView.tsx | 111 | ||||
| -rw-r--r-- | src/app/game/[slug]/components/ResultsView.tsx | 66 | ||||
| -rw-r--r-- | src/app/game/[slug]/hooks/useGameEngine.ts | 597 | ||||
| -rw-r--r-- | src/app/game/[slug]/page.tsx | 1012 |
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> |
