From 0335b0ad81169232a3dbb1be1341fdcfce548645 Mon Sep 17 00:00:00 2001 From: Pinapelz Date: Tue, 2 Jun 2026 02:12:57 -0700 Subject: migrate to pocketbase backend + auth/login --- src/app/game/[slug]/game.stat.ts | 82 +++ src/app/game/[slug]/game.utils.ts | 55 ++ src/app/game/[slug]/page.styles.ts | 650 +++++++++++++++++++++++ src/app/game/[slug]/page.tsx | 999 +++++++++++++++++++++++++++++++++++ src/app/game/game.stat.ts | 82 --- src/app/game/game.utils.ts | 55 -- src/app/game/page.styles.ts | 663 ----------------------- src/app/game/page.tsx | 1011 ------------------------------------ 8 files changed, 1786 insertions(+), 1811 deletions(-) create mode 100644 src/app/game/[slug]/game.stat.ts create mode 100644 src/app/game/[slug]/game.utils.ts create mode 100644 src/app/game/[slug]/page.styles.ts create mode 100644 src/app/game/[slug]/page.tsx delete mode 100644 src/app/game/game.stat.ts delete mode 100644 src/app/game/game.utils.ts delete mode 100644 src/app/game/page.styles.ts delete mode 100644 src/app/game/page.tsx (limited to 'src/app/game') diff --git a/src/app/game/[slug]/game.stat.ts b/src/app/game/[slug]/game.stat.ts new file mode 100644 index 0000000..43136e6 --- /dev/null +++ b/src/app/game/[slug]/game.stat.ts @@ -0,0 +1,82 @@ +export interface GState { + displayedLineIdx: number; + typedCount: number; + lineCompleted: boolean; + combo: number; + maxCombo: number; + score: number; + totalCorrect: number; + totalMiss: number; + linesCleared: number; + wpm: number; +} + +export type GAction = + | { type: "ADVANCE"; newIdx: number; prevCompleted: boolean } + | { type: "CORRECT"; willComplete: boolean } + | { type: "WRONG" } + | { type: "RESET" }; + +export const initialGState: GState = { + displayedLineIdx: -1, + typedCount: 0, + lineCompleted: false, + combo: 0, + maxCombo: 0, + score: 0, + totalCorrect: 0, + totalMiss: 0, + linesCleared: 0, + wpm: 0, +}; + +export function gReducer(state: GState, action: GAction): GState { + switch (action.type) { + case "ADVANCE": { + const prevIdx = state.displayedLineIdx; + const comboReset = !action.prevCompleted && prevIdx >= 0; + return { + ...state, + displayedLineIdx: action.newIdx, + typedCount: 0, + lineCompleted: false, + combo: comboReset ? 0 : state.combo, + }; + } + + case "CORRECT": { + const newTypedCount = state.typedCount + 1; + const newCombo = state.combo + 1; + const newMaxCombo = Math.max(state.maxCombo, newCombo); + const comboBonus = Math.min(50, Math.floor(newCombo / 10) * 5); + const newScore = state.score + 10 + comboBonus; + const newTotalCorrect = state.totalCorrect + 1; + if (action.willComplete) { + return { + ...state, + typedCount: newTypedCount, + lineCompleted: true, + combo: newCombo, + maxCombo: newMaxCombo, + score: newScore, + totalCorrect: newTotalCorrect, + linesCleared: state.linesCleared + 1, + }; + } + return { + ...state, + typedCount: newTypedCount, + combo: newCombo, + maxCombo: newMaxCombo, + score: newScore, + totalCorrect: newTotalCorrect, + }; + } + case "WRONG": + return { ...state, totalMiss: state.totalMiss + 1, combo: 0 }; + case "RESET": + return { ...initialGState }; + default: + return state; + } +} \ No newline at end of file diff --git a/src/app/game/[slug]/game.utils.ts b/src/app/game/[slug]/game.utils.ts new file mode 100644 index 0000000..b2037e5 --- /dev/null +++ b/src/app/game/[slug]/game.utils.ts @@ -0,0 +1,55 @@ +export interface GameLine { + millisecond: number; + content: string; +} + +export function parseLrcLines( + lrcText: string, + options?: { skipBacking?: boolean } +): GameLine[] { + const result: GameLine[] = []; + const lineRegex = /\[(\d{2,3}):(\d{2})\.(\d{2,3})\]/g; + const { skipBacking = false } = options ?? {}; + + for (const rawLine of lrcText.split("\n")) { + const timestamps: number[] = []; + let match: RegExpExecArray | null; + let lastIndex = 0; + + lineRegex.lastIndex = 0; + while ((match = lineRegex.exec(rawLine)) !== null) { + const minutes = parseInt(match[1], 10); + const seconds = parseInt(match[2], 10); + const msField = match[3]; + const ms = + msField.length === 2 + ? parseInt(msField, 10) * 10 + : parseInt(msField, 10); + timestamps.push(minutes * 60_000 + seconds * 1_000 + ms); + lastIndex = match.index + match[0].length; + } + + if (timestamps.length === 0) continue; + + const content = (skipBacking + ? rawLine.slice(lastIndex).replace(/\([^)]*\)/g, "") + : rawLine.slice(lastIndex) + ).trim(); + + for (const ms of timestamps) { + result.push({ millisecond: ms, content }); + } + } + + result.sort((a, b) => a.millisecond - b.millisecond); + return result; +} + +export function calculateCPSNeeded(text: string, seconds: number): number { + return text.length / seconds; +} + +export function formatTime(ms: number): string { + const s = Math.max(0, Math.floor(ms / 1000)); + return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`; +} diff --git a/src/app/game/[slug]/page.styles.ts b/src/app/game/[slug]/page.styles.ts new file mode 100644 index 0000000..1b5880b --- /dev/null +++ b/src/app/game/[slug]/page.styles.ts @@ -0,0 +1,650 @@ +import styled, { keyframes, css, createGlobalStyle } from "styled-components"; + +/* ----- ANIMATIONS ----- */ + +export const pulseAnim = keyframes` + 0% { opacity: 1; } + 50% { opacity: 0.4; } + 100% { opacity: 1; } +`; + +export const wrongShakeAnim = keyframes` + 0% { transform: translateX(0); } + 20% { transform: translateX(-4px); } + 40% { transform: translateX(4px); } + 60% { transform: translateX(-4px); } + 80% { transform: translateX(4px); } + 100% { transform: translateX(0); } +`; + +export const clearPopAnim = keyframes` + 0% { transform: scale(0.8); opacity: 0; } + 40% { transform: scale(1.2); opacity: 1; } + 100% { transform: scale(1.0); opacity: 0; } +`; + +export const fadeInUpAnim = keyframes` + 0% { transform: translateY(10px); opacity: 0; } + 100% { transform: translateY(0); opacity: 1; } +`; + +export const comboScaleAnim = keyframes` + 0% { transform: scale(1); } + 50% { transform: scale(1.4); } + 100% { transform: scale(1); } +`; + +export const glowAnim = keyframes` + 0% { box-shadow: 0 0 4px 0px rgba(124, 58, 237, 0.4); } + 50% { box-shadow: 0 0 16px 4px rgba(124, 58, 237, 0.8); } + 100% { box-shadow: 0 0 4px 0px rgba(124, 58, 237, 0.4); } +`; + +export const GameGlobalStyle = createGlobalStyle` + html, + body { + height: 100%; + overflow: hidden; + } +`; + +/* ----- LAYOUT ----- */ + +export const GameRoot = styled.div` + position: fixed; + inset: 0; + display: flex; + flex-direction: column; + background: #0d0d14; + color: #ffffff; + font-family: "Roboto", "Segoe UI", Arial, sans-serif; + overflow: hidden; + z-index: 0; +`; + +export const BackgroundVideo = styled.video` + position: absolute; + inset: 0; + width: 100%; + height: 100%; + object-fit: cover; + pointer-events: none; + z-index: 0; +`; + +export const GameNavbar = styled.nav` + position: sticky; + top: 0; + display: flex; + flex-direction: row; + align-items: center; + height: 52px; + padding: 0 20px; + background: rgba(13, 13, 20, 0.75); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + border-bottom: 1px solid rgba(255, 255, 255, 0.08); + z-index: 20; +`; + +export const GameContent = styled.div` + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; + position: relative; + z-index: 1; +`; + +/* ----- HUD ----- */ + +export const HUD = styled.div` + display: flex; + flex-direction: row; + align-items: center; + justify-content: space-between; + gap: 24px; + padding: 10px 24px; + background: rgba(13, 13, 20, 0.75); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + border-bottom: 1px solid rgba(255, 255, 255, 0.08); + position: relative; + z-index: 2; +`; + +export const HudStat = styled.div` + display: flex; + flex-direction: column; + align-items: center; + gap: 2px; +`; + +export const HudValue = styled.span` + font-size: 22px; + font-weight: 700; + color: #ffffff; +`; + +export const HudLabel = styled.span` + font-size: 10px; + letter-spacing: 1.5px; + text-transform: uppercase; + color: rgba(255, 255, 255, 0.45); +`; + +export const ComboValue = styled(HudValue)<{ $animate: boolean }>` + ${({ $animate }) => + $animate && + css` + animation: ${comboScaleAnim} 0.25s ease; + `} +`; + +/* ----- MAIN GAME AREA ----- */ + +export const GameArea = styled.div` + position: relative; + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 24px 32px; + gap: 24px; + overflow: hidden; + + & > * { + position: relative; + z-index: 1; + } +`; + +export const UpcomingWrap = styled.div` + width: 100%; + max-width: 800px; + display: flex; + flex-direction: column; + gap: 6px; +`; + +export const UpcomingLabel = styled.span` + font-size: 10px; + letter-spacing: 2px; + text-transform: uppercase; + color: rgba(255, 255, 255, 0.30); + margin-bottom: 2px; +`; + +export const UpcomingText = styled.p` + font-size: 20px; + color: rgba(255, 255, 255, 0.30); + font-weight: 400; + font-family: "Inter", "Segoe UI", "Helvetica Neue", Arial, sans-serif; + min-height: 28px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + margin: 0; +`; + +export const CurrentWrap = styled.div` + width: 100%; + max-width: 800px; + display: flex; + flex-direction: column; + gap: 10px; +`; + +export const LineTimingRow = styled.div` + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +`; + +export const LineTimingMeta = styled.div` + display: flex; + align-items: center; + justify-content: flex-start; + font-size: 13px; + letter-spacing: 1px; + text-transform: uppercase; + color: rgba(255, 255, 255, 0.45); +`; + +export const LineTimingValue = styled.span` + font-variant-numeric: tabular-nums; +`; + +export const LineTimingBar = styled.div` + width: 100%; + height: 3px; + background: rgba(255, 255, 255, 0.12); + overflow: hidden; +`; + +export const LineTimingFill = styled.div.attrs<{ $pct: number }>((props) => ({ + style: { + transform: `scaleX(${props.$pct / 100})`, + }, +}))<{ $pct: number }>` + height: 100%; + width: 100%; + background: #7c3aed; + transform-origin: left; + will-change: transform; +`; + +export const CharRow = styled.div` + display: flex; + flex-wrap: wrap; + gap: 2px; + align-items: flex-end; + min-height: 64px; +`; + +export const WordWrap = styled.span` + display: inline-flex; + gap: 2px; + white-space: nowrap; +`; + +export const CharBox = styled.span<{ + $state: "typed" | "active" | "pending" | "wrong"; +}>` + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 36px; + font-weight: 700; + font-family: "Inter", "Segoe UI", "Helvetica Neue", Arial, sans-serif; + padding: 0 3px; + transition: all 0.08s ease; + + ${({ $state }) => { + switch ($state) { + case "typed": + return css` + color: #22c55e; + opacity: 0.7; + `; + case "active": + return css` + color: #fbbf24; + border-bottom: 3px solid #fbbf24; + animation: ${pulseAnim} 1s ease infinite; + `; + case "pending": + return css` + color: rgba(255, 255, 255, 0.25); + `; + case "wrong": + return css` + color: #ef4444; + animation: ${wrongShakeAnim} 0.3s ease; + `; + } + }} +`; + +export const ClearToast = styled.div` + position: absolute; + font-size: 28px; + font-weight: 800; + color: #22c55e; + animation: ${clearPopAnim} 0.7s ease forwards; + pointer-events: none; +`; + +export const GetReadyText = styled.p` + font-size: 28px; + color: rgba(255, 255, 255, 0.50); + font-weight: 500; + text-align: center; + animation: ${pulseAnim} 1.5s ease infinite; + margin: 0; +`; + +export const CompletedLineFade = styled.div` + font-size: 18px; + color: rgba(255, 255, 255, 0.20); + margin-top: 4px; + min-height: 26px; + transition: opacity 0.3s; +`; + +/* ----- FOOTER ----- */ + +export const GameFooter = styled.footer` + display: flex; + align-items: center; + gap: 12px; + padding: 12px 24px; + background: rgba(255, 255, 255, 0.04); + border-top: 1px solid rgba(255, 255, 255, 0.06); + position: relative; + z-index: 2; +`; + +export const ControlBtn = styled.button` + width: 40px; + height: 40px; + border: 1px solid rgba(255, 255, 255, 0.20); + background: rgba(255, 255, 255, 0.08); + color: #ffffff; + font-size: 14px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + transition: background 0.15s; + + &:hover { + background: rgba(255, 255, 255, 0.15); + } +`; + +export const ProgressWrap = styled.div` + flex: 1; + height: 6px; + background: rgba(255, 255, 255, 0.12); + overflow: hidden; + cursor: pointer; +`; + +export const ProgressFill = styled.div.attrs<{ $pct: number }>((props) => ({ + style: { + width: `${props.$pct}%`, + }, +}))<{ $pct: number }>` + height: 100%; + background: #7c3aed; + transition: width 0.3s linear; +`; + +export const TimeText = styled.span` + font-size: 12px; + color: rgba(255, 255, 255, 0.50); + font-family: monospace; + white-space: nowrap; +`; + +/* ----- START SCREEN ----- */ + +export const StartOverlay = styled.div` + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + background: rgba(13, 13, 20, 0.96); + z-index: 10; + animation: ${fadeInUpAnim} 0.4s ease; +`; + +export const StartCard = styled.div` + display: flex; + flex-direction: column; + align-items: center; + gap: 20px; + padding: 40px; + max-width: 520px; + width: 100%; + text-align: center; +`; + +export const OpacityControl = styled.div` + width: 100%; + display: flex; + flex-direction: column; + gap: 8px; + align-items: stretch; +`; + +export const OpacityLabel = styled.div` + font-size: 11px; + color: rgba(255, 255, 255, 0.45); + letter-spacing: 1px; + text-transform: uppercase; + display: flex; + justify-content: space-between; + align-items: center; +`; + +export const OpacityValue = styled.span` + font-variant-numeric: tabular-nums; +`; + +export const OpacitySlider = styled.input` + width: 100%; +`; + +export const PreviewWrap = styled.div` + width: 100%; + display: flex; + flex-direction: column; + gap: 8px; +`; + +export const PreviewBtn = styled.button` + width: 100%; + padding: 10px 16px; + border: 1px solid rgba(255, 255, 255, 0.2); + background: rgba(255, 255, 255, 0.08); + color: #ffffff; + font-size: 14px; + font-weight: 600; + cursor: pointer; + transition: all 0.15s; + + &:hover:not(:disabled) { + background: rgba(255, 255, 255, 0.16); + } + + &:disabled { + opacity: 0.45; + cursor: not-allowed; + } +`; + +export const PreviewHint = styled.div` + font-size: 11px; + color: rgba(255, 255, 255, 0.45); + text-align: center; +`; + +export const CountdownNumber = styled.div` + font-size: 72px; + font-weight: 900; + color: #ffffff; + line-height: 1; + letter-spacing: 2px; +`; + +export const SongTitleText = styled.h1` + font-size: 32px; + font-weight: 800; + color: #ffffff; + line-height: 1.2; + margin: 0; +`; + +export const SongArtistText = styled.p` + font-size: 16px; + color: rgba(255, 255, 255, 0.50); + margin: 0; +`; + +export const StartBtn = styled.button` + padding: 14px 40px; + background: #7c3aed; + color: #ffffff; + font-size: 18px; + font-weight: 700; + border: none; + cursor: pointer; + transition: all 0.15s; + + &:hover:not(:disabled) { + background: #6d28d9; + transform: translateY(-2px); + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } +`; + +export const CodeSection = styled.div` + width: 100%; + display: flex; + flex-direction: column; + gap: 8px; +`; + +export const CodeInputRow = styled.div` + display: flex; + gap: 8px; +`; + +export const CodeInputField = styled.input` + flex: 1; + padding: 8px 12px; + border: 1px solid rgba(255, 255, 255, 0.15); + background: rgba(255, 255, 255, 0.06); + color: #ffffff; + font-size: 13px; + outline: none; + transition: border-color 0.15s; + + &:focus { + border-color: rgba(255, 255, 255, 0.35); + } + + &::placeholder { + color: rgba(255, 255, 255, 0.30); + } +`; + +export const CodeLoadBtn = styled.button` + padding: 8px 16px; + border: 1px solid rgba(255, 255, 255, 0.15); + background: rgba(255, 255, 255, 0.08); + color: #ffffff; + font-size: 13px; + cursor: pointer; + white-space: nowrap; + transition: background 0.15s; + + &:hover { + background: rgba(255, 255, 255, 0.15); + } +`; + +/* ----- RESULTS SCREEN ----- */ + +export const ResultsOverlay = styled.div` + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + background: rgba(13, 13, 20, 0.96); + z-index: 10; + animation: ${fadeInUpAnim} 0.4s ease; +`; + +export const ResultsCard = styled.div` + display: flex; + flex-direction: column; + align-items: center; + gap: 24px; + padding: 40px; + max-width: 540px; + width: 100%; + text-align: center; +`; + +export const ResultsTitle = styled.p` + font-size: 14px; + letter-spacing: 3px; + text-transform: uppercase; + color: rgba(255, 255, 255, 0.40); + margin: 0; +`; + +export const BigScore = styled.h2` + font-size: 64px; + font-weight: 900; + color: #ffffff; + line-height: 1; + margin: 0; +`; + +export const StatsGrid = styled.div` + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 16px; + width: 100%; +`; + +export const StatBlock = styled.div` + background: rgba(255, 255, 255, 0.05); + border: 1px solid rgba(255, 255, 255, 0.08); + padding: 16px; + display: flex; + flex-direction: column; + gap: 4px; +`; + +export const StatValue = styled.span` + font-size: 28px; + font-weight: 700; + color: #ffffff; +`; + +export const StatLabel = styled.span` + font-size: 11px; + letter-spacing: 1.5px; + text-transform: uppercase; + color: rgba(255, 255, 255, 0.40); +`; + +export const ActionRow = styled.div` + display: flex; + gap: 12px; +`; + +export const PlayAgainBtn = styled.button` + padding: 10px 28px; + background: #7c3aed; + color: #ffffff; + font-size: 15px; + font-weight: 700; + border: none; + cursor: pointer; + transition: all 0.15s; + + &:hover { + background: #6d28d9; + transform: translateY(-2px); + } +`; + +export const HomeBtn = styled.button` + padding: 10px 28px; + background: transparent; + border: 1px solid rgba(255, 255, 255, 0.20); + color: #ffffff; + font-size: 15px; + font-weight: 700; + cursor: pointer; + transition: all 0.15s; + + &:hover { + background: rgba(255, 255, 255, 0.08); + } +`; diff --git a/src/app/game/[slug]/page.tsx b/src/app/game/[slug]/page.tsx new file mode 100644 index 0000000..9d449ce --- /dev/null +++ b/src/app/game/[slug]/page.tsx @@ -0,0 +1,999 @@ +"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 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"; + +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(null); + const videoRef = useRef(null); + const gameStartTimeRef = useRef(0); + const lastHandledIdxRef = useRef(-1); + const lastLineAdvanceAtRef = useRef(0); + + const [phase, setPhase] = useState("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(null); + const charRefs = useRef<(HTMLSpanElement | null)[]>([]); + const [wrapSpaceIndicators, setWrapSpaceIndicators] = useState([]); + const countdownIntervalRef = useRef(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("idle"); + useEffect(() => { + phaseRef.current = phase; + }, [phase]); + + const offsetRef = useRef(0); + useEffect(() => { + offsetRef.current = offset; + }, [offset]); + + useEffect(() => { + if (!("mediaSession" in navigator)) return; + const mediaSession = navigator.mediaSession; + mediaSession.setActionHandler("pause", () => {}); + return () => { + mediaSession.setActionHandler("pause", null); + }; + }, []); + + useEffect(() => { + return () => { + if (countdownIntervalRef.current !== null) { + clearInterval(countdownIntervalRef.current); + countdownIntervalRef.current = null; + } + }; + }, []); + + const lineAnimRef = useRef({ startMs: 0, endMs: 0, startPerf: 0 }); + + const timeBasedLineIdx = useMemo(() => { + if (!gameLines.length) return -1; + let idx = -1; + for (let i = 0; i < gameLines.length; i++) { + if (gameLines[i].millisecond <= currentMs) idx = i; + else break; + } + return idx; + }, [currentMs, gameLines]); + + const intermissionData = useMemo(() => { + const firstMs = gameLines[0]?.millisecond ?? 0; + const firstMediaMs = firstMs - offsetRef.current; + const remainingMs = Math.max(0, firstMs - currentMs); + if (!gameLines.length || firstMediaMs <= 0) { + return { pct: remainingMs === 0 ? 100 : 0, remainingMs }; + } + + const mediaCurrentMs = currentMs - offsetRef.current; + const pct = Math.min( + 100, + Math.max(0, (mediaCurrentMs / firstMediaMs) * 100), + ); + + return { pct, remainingMs }; + }, [gameLines, currentMs, offset]); + + useEffect(() => { + const idx = g.displayedLineIdx; + if (idx < 0 || !gameLines[idx]) { + lineAnimRef.current = { startMs: 0, endMs: 0, startPerf: 0 }; + setLineTimingPct(0); + setLineRemainingMs(0); + setCurrentLineTime(-1); + return; + } + const start = gameLines[idx].millisecond; + const end = gameLines[idx + 1]?.millisecond ?? start + 5000; + lineAnimRef.current = { + startMs: start, + endMs: end, + startPerf: performance.now(), + }; + setLineTimingPct(0); + const currentLineTime = end - start; + setLineRemainingMs(Math.max(0, currentLineTime)); + setCurrentLineTime(Math.max(currentLineTime, currentLineTime)); + }, [g.displayedLineIdx, gameLines]); + + useEffect(() => { + if (phase !== "playing") return; + let rafId = 0; + const tick = () => { + const { startMs, endMs, startPerf } = lineAnimRef.current; + if (endMs <= startMs) { + setLineTimingPct(100); + setLineRemainingMs(0); + } else { + const elapsed = performance.now() - startPerf; + const duration = endMs - startMs; + const pct = Math.min(100, Math.max(0, (elapsed / duration) * 100)); + const remaining = Math.max(0, duration - elapsed); + setLineTimingPct(pct); + setLineRemainingMs(remaining); + } + rafId = requestAnimationFrame(tick); + }; + rafId = requestAnimationFrame(tick); + return () => cancelAnimationFrame(rafId); + }, [phase]); + + useEffect(() => { + const media = isVideo ? videoRef.current : audioRef.current; + if (!media) return; + const onTimeUpdate = () => { + setCurrentMs(media.currentTime * 1000 + offsetRef.current); + if (media.duration && !isNaN(media.duration)) { + setDuration(media.duration * 1000); + setProgressPct((media.currentTime / media.duration) * 100); + } + }; + const onLoadedMetadata = () => { + if (!isNaN(media.duration)) { + setDuration(media.duration * 1000); + setGameDurationMs(media.duration * 1000); + } + }; + const onEnded = () => { + if (phaseRef.current === "playing") { + setPhase("finished"); + setGameDurationMs(Date.now() - gameStartTimeRef.current); + return; + } + setIsPreviewPlaying(false); + }; + media.addEventListener("timeupdate", onTimeUpdate); + media.addEventListener("loadedmetadata", onLoadedMetadata); + media.addEventListener("ended", onEnded); + return () => { + media.removeEventListener("timeupdate", onTimeUpdate); + media.removeEventListener("loadedmetadata", onLoadedMetadata); + media.removeEventListener("ended", onEnded); + }; + }, [isVideo, audioUrl]); + + useEffect(() => { + if (phaseRef.current !== "playing") return; + if (timeBasedLineIdx < 0) return; + if (timeBasedLineIdx <= lastHandledIdxRef.current) return; + lastHandledIdxRef.current = timeBasedLineIdx; + lastLineAdvanceAtRef.current = performance.now(); + dispatch({ + type: "ADVANCE", + newIdx: timeBasedLineIdx, + prevCompleted: gRef.current.lineCompleted, + }); + }, [timeBasedLineIdx]); + + const loadData = useCallback((data: Record) => { + 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).media, + lrc: (record as Record).lrc, + offset: (record as Record).offset, + title: (record as Record).title, + artist: (record as Record).artist, + }); + }) + .catch(() => { + try { + const json = atob(slug); + const data = JSON.parse(json) as Record; + 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]); + + return ( + + + {!isVideo && ( + + ); +} + +export default function GamePage() { + return ( + <> + + +
+ Loading... +
+ + } + > + +
+ + ); +} diff --git a/src/app/game/game.stat.ts b/src/app/game/game.stat.ts deleted file mode 100644 index 43136e6..0000000 --- a/src/app/game/game.stat.ts +++ /dev/null @@ -1,82 +0,0 @@ -export interface GState { - displayedLineIdx: number; - typedCount: number; - lineCompleted: boolean; - combo: number; - maxCombo: number; - score: number; - totalCorrect: number; - totalMiss: number; - linesCleared: number; - wpm: number; -} - -export type GAction = - | { type: "ADVANCE"; newIdx: number; prevCompleted: boolean } - | { type: "CORRECT"; willComplete: boolean } - | { type: "WRONG" } - | { type: "RESET" }; - -export const initialGState: GState = { - displayedLineIdx: -1, - typedCount: 0, - lineCompleted: false, - combo: 0, - maxCombo: 0, - score: 0, - totalCorrect: 0, - totalMiss: 0, - linesCleared: 0, - wpm: 0, -}; - -export function gReducer(state: GState, action: GAction): GState { - switch (action.type) { - case "ADVANCE": { - const prevIdx = state.displayedLineIdx; - const comboReset = !action.prevCompleted && prevIdx >= 0; - return { - ...state, - displayedLineIdx: action.newIdx, - typedCount: 0, - lineCompleted: false, - combo: comboReset ? 0 : state.combo, - }; - } - - case "CORRECT": { - const newTypedCount = state.typedCount + 1; - const newCombo = state.combo + 1; - const newMaxCombo = Math.max(state.maxCombo, newCombo); - const comboBonus = Math.min(50, Math.floor(newCombo / 10) * 5); - const newScore = state.score + 10 + comboBonus; - const newTotalCorrect = state.totalCorrect + 1; - if (action.willComplete) { - return { - ...state, - typedCount: newTypedCount, - lineCompleted: true, - combo: newCombo, - maxCombo: newMaxCombo, - score: newScore, - totalCorrect: newTotalCorrect, - linesCleared: state.linesCleared + 1, - }; - } - return { - ...state, - typedCount: newTypedCount, - combo: newCombo, - maxCombo: newMaxCombo, - score: newScore, - totalCorrect: newTotalCorrect, - }; - } - case "WRONG": - return { ...state, totalMiss: state.totalMiss + 1, combo: 0 }; - case "RESET": - return { ...initialGState }; - default: - return state; - } -} \ No newline at end of file diff --git a/src/app/game/game.utils.ts b/src/app/game/game.utils.ts deleted file mode 100644 index b2037e5..0000000 --- a/src/app/game/game.utils.ts +++ /dev/null @@ -1,55 +0,0 @@ -export interface GameLine { - millisecond: number; - content: string; -} - -export function parseLrcLines( - lrcText: string, - options?: { skipBacking?: boolean } -): GameLine[] { - const result: GameLine[] = []; - const lineRegex = /\[(\d{2,3}):(\d{2})\.(\d{2,3})\]/g; - const { skipBacking = false } = options ?? {}; - - for (const rawLine of lrcText.split("\n")) { - const timestamps: number[] = []; - let match: RegExpExecArray | null; - let lastIndex = 0; - - lineRegex.lastIndex = 0; - while ((match = lineRegex.exec(rawLine)) !== null) { - const minutes = parseInt(match[1], 10); - const seconds = parseInt(match[2], 10); - const msField = match[3]; - const ms = - msField.length === 2 - ? parseInt(msField, 10) * 10 - : parseInt(msField, 10); - timestamps.push(minutes * 60_000 + seconds * 1_000 + ms); - lastIndex = match.index + match[0].length; - } - - if (timestamps.length === 0) continue; - - const content = (skipBacking - ? rawLine.slice(lastIndex).replace(/\([^)]*\)/g, "") - : rawLine.slice(lastIndex) - ).trim(); - - for (const ms of timestamps) { - result.push({ millisecond: ms, content }); - } - } - - result.sort((a, b) => a.millisecond - b.millisecond); - return result; -} - -export function calculateCPSNeeded(text: string, seconds: number): number { - return text.length / seconds; -} - -export function formatTime(ms: number): string { - const s = Math.max(0, Math.floor(ms / 1000)); - return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`; -} diff --git a/src/app/game/page.styles.ts b/src/app/game/page.styles.ts deleted file mode 100644 index d410339..0000000 --- a/src/app/game/page.styles.ts +++ /dev/null @@ -1,663 +0,0 @@ -import styled, { keyframes, css, createGlobalStyle } from "styled-components"; - -/* ----- ANIMATIONS ----- */ - -export const pulseAnim = keyframes` - 0% { opacity: 1; } - 50% { opacity: 0.4; } - 100% { opacity: 1; } -`; - -export const wrongShakeAnim = keyframes` - 0% { transform: translateX(0); } - 20% { transform: translateX(-4px); } - 40% { transform: translateX(4px); } - 60% { transform: translateX(-4px); } - 80% { transform: translateX(4px); } - 100% { transform: translateX(0); } -`; - -export const clearPopAnim = keyframes` - 0% { transform: scale(0.8); opacity: 0; } - 40% { transform: scale(1.2); opacity: 1; } - 100% { transform: scale(1.0); opacity: 0; } -`; - -export const fadeInUpAnim = keyframes` - 0% { transform: translateY(10px); opacity: 0; } - 100% { transform: translateY(0); opacity: 1; } -`; - -export const comboScaleAnim = keyframes` - 0% { transform: scale(1); } - 50% { transform: scale(1.4); } - 100% { transform: scale(1); } -`; - -export const glowAnim = keyframes` - 0% { box-shadow: 0 0 4px 0px rgba(124, 58, 237, 0.4); } - 50% { box-shadow: 0 0 16px 4px rgba(124, 58, 237, 0.8); } - 100% { box-shadow: 0 0 4px 0px rgba(124, 58, 237, 0.4); } -`; - -export const GameGlobalStyle = createGlobalStyle` - html, - body { - height: 100%; - overflow: hidden; - } -`; - -/* ----- LAYOUT ----- */ - -export const GameRoot = styled.div` - position: fixed; - inset: 0; - display: flex; - flex-direction: column; - background: #0d0d14; - color: #ffffff; - font-family: "Roboto", "Segoe UI", Arial, sans-serif; - overflow: hidden; - z-index: 0; -`; - -export const BackgroundVideo = styled.video` - position: absolute; - inset: 0; - width: 100%; - height: 100%; - object-fit: cover; - pointer-events: none; - z-index: 0; -`; - -export const GameNavbar = styled.nav` - position: sticky; - top: 0; - display: flex; - flex-direction: row; - align-items: center; - height: 52px; - padding: 0 20px; - background: rgba(13, 13, 20, 0.75); - backdrop-filter: blur(12px); - -webkit-backdrop-filter: blur(12px); - border-bottom: 1px solid rgba(255, 255, 255, 0.08); - z-index: 20; -`; - -export const GameContent = styled.div` - flex: 1; - display: flex; - flex-direction: column; - overflow: hidden; - position: relative; - z-index: 1; -`; - -/* ----- HUD ----- */ - -export const HUD = styled.div` - display: flex; - flex-direction: row; - align-items: center; - justify-content: space-between; - gap: 24px; - padding: 10px 24px; - background: rgba(13, 13, 20, 0.75); - backdrop-filter: blur(12px); - -webkit-backdrop-filter: blur(12px); - border-bottom: 1px solid rgba(255, 255, 255, 0.08); - position: relative; - z-index: 2; -`; - -export const HudStat = styled.div` - display: flex; - flex-direction: column; - align-items: center; - gap: 2px; -`; - -export const HudValue = styled.span` - font-size: 22px; - font-weight: 700; - color: #ffffff; -`; - -export const HudLabel = styled.span` - font-size: 10px; - letter-spacing: 1.5px; - text-transform: uppercase; - color: rgba(255, 255, 255, 0.45); -`; - -export const ComboValue = styled(HudValue)<{ $animate: boolean }>` - ${({ $animate }) => - $animate && - css` - animation: ${comboScaleAnim} 0.25s ease; - `} -`; - -/* ----- MAIN GAME AREA ----- */ - -export const GameArea = styled.div` - position: relative; - flex: 1; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - padding: 24px 32px; - gap: 24px; - overflow: hidden; - - & > * { - position: relative; - z-index: 1; - } -`; - -export const UpcomingWrap = styled.div` - width: 100%; - max-width: 800px; - display: flex; - flex-direction: column; - gap: 6px; -`; - -export const UpcomingLabel = styled.span` - font-size: 10px; - letter-spacing: 2px; - text-transform: uppercase; - color: rgba(255, 255, 255, 0.30); - margin-bottom: 2px; -`; - -export const UpcomingText = styled.p` - font-size: 20px; - color: rgba(255, 255, 255, 0.30); - font-weight: 400; - font-family: "Inter", "Segoe UI", "Helvetica Neue", Arial, sans-serif; - min-height: 28px; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - margin: 0; -`; - -export const CurrentWrap = styled.div` - width: 100%; - max-width: 800px; - display: flex; - flex-direction: column; - gap: 10px; -`; - -export const LineTimingRow = styled.div` - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; -`; - -export const LineTimingMeta = styled.div` - display: flex; - align-items: center; - justify-content: flex-start; - font-size: 13px; - letter-spacing: 1px; - text-transform: uppercase; - color: rgba(255, 255, 255, 0.45); -`; - -export const LineTimingValue = styled.span` - font-variant-numeric: tabular-nums; -`; - -export const LineTimingBar = styled.div` - width: 100%; - height: 3px; - background: rgba(255, 255, 255, 0.12); - border-radius: 2px; - overflow: hidden; -`; - -export const LineTimingFill = styled.div.attrs<{ $pct: number }>((props) => ({ - style: { - transform: `scaleX(${props.$pct / 100})`, - }, -}))<{ $pct: number }>` - height: 100%; - width: 100%; - border-radius: 2px; - background: #7c3aed; - transform-origin: left; - will-change: transform; -`; - -export const CharRow = styled.div` - display: flex; - flex-wrap: wrap; - gap: 2px; - align-items: flex-end; - min-height: 64px; -`; - -export const WordWrap = styled.span` - display: inline-flex; - gap: 2px; - white-space: nowrap; -`; - -export const CharBox = styled.span<{ - $state: "typed" | "active" | "pending" | "wrong"; -}>` - display: inline-flex; - align-items: center; - justify-content: center; - font-size: 36px; - font-weight: 700; - font-family: "Inter", "Segoe UI", "Helvetica Neue", Arial, sans-serif; - padding: 0 3px; - border-radius: 4px; - transition: all 0.08s ease; - - ${({ $state }) => { - switch ($state) { - case "typed": - return css` - color: #22c55e; - opacity: 0.7; - `; - case "active": - return css` - color: #fbbf24; - border-bottom: 3px solid #fbbf24; - animation: ${pulseAnim} 1s ease infinite; - `; - case "pending": - return css` - color: rgba(255, 255, 255, 0.25); - `; - case "wrong": - return css` - color: #ef4444; - animation: ${wrongShakeAnim} 0.3s ease; - `; - } - }} -`; - -export const ClearToast = styled.div` - position: absolute; - font-size: 28px; - font-weight: 800; - color: #22c55e; - animation: ${clearPopAnim} 0.7s ease forwards; - pointer-events: none; -`; - -export const GetReadyText = styled.p` - font-size: 28px; - color: rgba(255, 255, 255, 0.50); - font-weight: 500; - text-align: center; - animation: ${pulseAnim} 1.5s ease infinite; - margin: 0; -`; - -export const CompletedLineFade = styled.div` - font-size: 18px; - color: rgba(255, 255, 255, 0.20); - margin-top: 4px; - min-height: 26px; - transition: opacity 0.3s; -`; - -/* ----- FOOTER ----- */ - -export const GameFooter = styled.footer` - display: flex; - align-items: center; - gap: 12px; - padding: 12px 24px; - background: rgba(255, 255, 255, 0.04); - border-top: 1px solid rgba(255, 255, 255, 0.06); - position: relative; - z-index: 2; -`; - -export const ControlBtn = styled.button` - width: 40px; - height: 40px; - border-radius: 50%; - border: 1px solid rgba(255, 255, 255, 0.20); - background: rgba(255, 255, 255, 0.08); - color: #ffffff; - font-size: 14px; - cursor: pointer; - display: flex; - align-items: center; - justify-content: center; - flex-shrink: 0; - transition: background 0.15s; - - &:hover { - background: rgba(255, 255, 255, 0.15); - } -`; - -export const ProgressWrap = styled.div` - flex: 1; - height: 6px; - background: rgba(255, 255, 255, 0.12); - border-radius: 3px; - overflow: hidden; - cursor: pointer; -`; - -export const ProgressFill = styled.div.attrs<{ $pct: number }>((props) => ({ - style: { - width: `${props.$pct}%`, - }, -}))<{ $pct: number }>` - height: 100%; - background: #7c3aed; - border-radius: 3px; - transition: width 0.3s linear; -`; - -export const TimeText = styled.span` - font-size: 12px; - color: rgba(255, 255, 255, 0.50); - font-family: monospace; - white-space: nowrap; -`; - -/* ----- START SCREEN ----- */ - -export const StartOverlay = styled.div` - position: absolute; - inset: 0; - display: flex; - align-items: center; - justify-content: center; - background: rgba(13, 13, 20, 0.96); - z-index: 10; - animation: ${fadeInUpAnim} 0.4s ease; -`; - -export const StartCard = styled.div` - display: flex; - flex-direction: column; - align-items: center; - gap: 20px; - padding: 40px; - max-width: 520px; - width: 100%; - text-align: center; -`; - -export const OpacityControl = styled.div` - width: 100%; - display: flex; - flex-direction: column; - gap: 8px; - align-items: stretch; -`; - -export const OpacityLabel = styled.div` - font-size: 11px; - color: rgba(255, 255, 255, 0.45); - letter-spacing: 1px; - text-transform: uppercase; - display: flex; - justify-content: space-between; - align-items: center; -`; - -export const OpacityValue = styled.span` - font-variant-numeric: tabular-nums; -`; - -export const OpacitySlider = styled.input` - width: 100%; -`; - -export const PreviewWrap = styled.div` - width: 100%; - display: flex; - flex-direction: column; - gap: 8px; -`; - -export const PreviewBtn = styled.button` - width: 100%; - padding: 10px 16px; - border-radius: 10px; - border: 1px solid rgba(255, 255, 255, 0.2); - background: rgba(255, 255, 255, 0.08); - color: #ffffff; - font-size: 14px; - font-weight: 600; - cursor: pointer; - transition: all 0.15s; - - &:hover:not(:disabled) { - background: rgba(255, 255, 255, 0.16); - } - - &:disabled { - opacity: 0.45; - cursor: not-allowed; - } -`; - -export const PreviewHint = styled.div` - font-size: 11px; - color: rgba(255, 255, 255, 0.45); - text-align: center; -`; - -export const CountdownNumber = styled.div` - font-size: 72px; - font-weight: 900; - color: #ffffff; - line-height: 1; - letter-spacing: 2px; -`; - -export const SongTitleText = styled.h1` - font-size: 32px; - font-weight: 800; - color: #ffffff; - line-height: 1.2; - margin: 0; -`; - -export const SongArtistText = styled.p` - font-size: 16px; - color: rgba(255, 255, 255, 0.50); - margin: 0; -`; - -export const StartBtn = styled.button` - padding: 14px 40px; - border-radius: 12px; - background: #7c3aed; - color: #ffffff; - font-size: 18px; - font-weight: 700; - border: none; - cursor: pointer; - transition: all 0.15s; - - &:hover:not(:disabled) { - background: #6d28d9; - transform: translateY(-2px); - } - - &:disabled { - opacity: 0.5; - cursor: not-allowed; - } -`; - -export const CodeSection = styled.div` - width: 100%; - display: flex; - flex-direction: column; - gap: 8px; -`; - -export const CodeInputRow = styled.div` - display: flex; - gap: 8px; -`; - -export const CodeInputField = styled.input` - flex: 1; - padding: 8px 12px; - border-radius: 8px; - border: 1px solid rgba(255, 255, 255, 0.15); - background: rgba(255, 255, 255, 0.06); - color: #ffffff; - font-size: 13px; - outline: none; - transition: border-color 0.15s; - - &:focus { - border-color: rgba(255, 255, 255, 0.35); - } - - &::placeholder { - color: rgba(255, 255, 255, 0.30); - } -`; - -export const CodeLoadBtn = styled.button` - padding: 8px 16px; - border-radius: 8px; - border: 1px solid rgba(255, 255, 255, 0.15); - background: rgba(255, 255, 255, 0.08); - color: #ffffff; - font-size: 13px; - cursor: pointer; - white-space: nowrap; - transition: background 0.15s; - - &:hover { - background: rgba(255, 255, 255, 0.15); - } -`; - -/* ----- RESULTS SCREEN ----- */ - -export const ResultsOverlay = styled.div` - position: absolute; - inset: 0; - display: flex; - align-items: center; - justify-content: center; - background: rgba(13, 13, 20, 0.96); - z-index: 10; - animation: ${fadeInUpAnim} 0.4s ease; -`; - -export const ResultsCard = styled.div` - display: flex; - flex-direction: column; - align-items: center; - gap: 24px; - padding: 40px; - max-width: 540px; - width: 100%; - text-align: center; -`; - -export const ResultsTitle = styled.p` - font-size: 14px; - letter-spacing: 3px; - text-transform: uppercase; - color: rgba(255, 255, 255, 0.40); - margin: 0; -`; - -export const BigScore = styled.h2` - font-size: 64px; - font-weight: 900; - color: #ffffff; - line-height: 1; - margin: 0; -`; - -export const StatsGrid = styled.div` - display: grid; - grid-template-columns: repeat(2, 1fr); - gap: 16px; - width: 100%; -`; - -export const StatBlock = styled.div` - background: rgba(255, 255, 255, 0.05); - border: 1px solid rgba(255, 255, 255, 0.08); - border-radius: 12px; - padding: 16px; - display: flex; - flex-direction: column; - gap: 4px; -`; - -export const StatValue = styled.span` - font-size: 28px; - font-weight: 700; - color: #ffffff; -`; - -export const StatLabel = styled.span` - font-size: 11px; - letter-spacing: 1.5px; - text-transform: uppercase; - color: rgba(255, 255, 255, 0.40); -`; - -export const ActionRow = styled.div` - display: flex; - gap: 12px; -`; - -export const PlayAgainBtn = styled.button` - padding: 10px 28px; - border-radius: 12px; - background: #7c3aed; - color: #ffffff; - font-size: 15px; - font-weight: 700; - border: none; - cursor: pointer; - transition: all 0.15s; - - &:hover { - background: #6d28d9; - transform: translateY(-2px); - } -`; - -export const HomeBtn = styled.button` - padding: 10px 28px; - border-radius: 12px; - background: transparent; - border: 1px solid rgba(255, 255, 255, 0.20); - color: #ffffff; - font-size: 15px; - font-weight: 700; - cursor: pointer; - transition: all 0.15s; - - &:hover { - background: rgba(255, 255, 255, 0.08); - } -`; diff --git a/src/app/game/page.tsx b/src/app/game/page.tsx deleted file mode 100644 index bce01b3..0000000 --- a/src/app/game/page.tsx +++ /dev/null @@ -1,1011 +0,0 @@ -"use client"; -import { - useCallback, - useEffect, - useLayoutEffect, - useMemo, - useReducer, - useRef, - useState, - Suspense, -} from "react"; -import { useSearchParams, useRouter } from "next/navigation"; -import Link from "next/link"; -import { FaRedo } from "react-icons/fa"; -import { MdLibraryMusic } from "react-icons/md"; -import { toast, ToastContainer } from "react-toastify"; -import "react-toastify/dist/ReactToastify.css"; -import { - GameRoot, - GameGlobalStyle, - GameNavbar, - GameContent, - HUD, - HudStat, - HudValue, - HudLabel, - ComboValue, - GameArea, - UpcomingWrap, - UpcomingLabel, - UpcomingText, - CurrentWrap, - LineTimingMeta, - LineTimingValue, - LineTimingRow, - LineTimingBar, - LineTimingFill, - CharRow, - WordWrap, - CharBox, - ClearToast, - GetReadyText, - BackgroundVideo, - OpacityControl, - OpacityLabel, - OpacitySlider, - OpacityValue, - PreviewWrap, - PreviewBtn, - PreviewHint, - CompletedLineFade, - GameFooter, - ControlBtn, - ProgressWrap, - ProgressFill, - TimeText, - StartOverlay, - StartCard, - CountdownNumber, - SongTitleText, - SongArtistText, - StartBtn, - CodeSection, - CodeInputRow, - CodeInputField, - CodeLoadBtn, - ResultsOverlay, - ResultsCard, - ResultsTitle, - BigScore, - StatsGrid, - StatBlock, - StatValue, - StatLabel, - ActionRow, - PlayAgainBtn, - HomeBtn, -} from "./page.styles"; -import { gReducer, initialGState } from "./game.stat"; -import { formatTime, parseLrcLines, calculateCPSNeeded } from "./game.utils"; - -type GamePhase = "idle" | "countdown" | "playing" | "paused" | "finished"; - -const VIDEO_EXTENSIONS = new Set(["mp4", "webm", "mov", "m4v", "ogv"]); -const isVideoUrl = (url: string) => { - if (!url) return false; - const cleaned = url.split("?")[0].split("#")[0]; - const ext = cleaned.split(".").pop()?.toLowerCase(); - return !!ext && VIDEO_EXTENSIONS.has(ext); -}; -const BACKGROUND_OPACITY_KEY = "lrcType.backgroundOpacity"; -const AUDIO_VOLUME_KEY = "lrcType.audioVolume"; - -function GameInner() { - const searchParams = useSearchParams(); - const router = useRouter(); - - useEffect(() => { - window.scrollTo({ top: 0, left: 0, behavior: "auto" }); - }, []); - - const audioRef = useRef(null); - const videoRef = useRef(null); - const gameStartTimeRef = useRef(0); - const lastHandledIdxRef = useRef(-1); - const lastLineAdvanceAtRef = useRef(0); - - const [phase, setPhase] = useState("idle"); - const [currentMs, setCurrentMs] = useState(0); - const [lineTimingPct, setLineTimingPct] = useState(0); - const [lineRemainingMs, setLineRemainingMs] = useState(0); - const [currentLineTime, setCurrentLineTime] = useState(0); - const [duration, setDuration] = useState(0); - const [progressPct, setProgressPct] = useState(0); - const [gameDurationMs, setGameDurationMs] = useState(0); - - const [lrcContent, setLrcContent] = useState(""); - const [audioUrl, setAudioUrl] = useState(""); - const [songTitle, setSongTitle] = useState("Unknown Title"); - const [songArtist, setSongArtist] = useState("Unknown Artist"); - const [offset, setOffset] = useState(0); - const [loadingLrc, setLoadingLrc] = useState(false); - - const [codeInput, setCodeInput] = useState(""); - const [wrongChar, setWrongChar] = useState(false); - const [clearShowing, setClearShowing] = useState(false); - const [comboAnimKey, setComboAnimKey] = useState(0); - const [countdown, setCountdown] = useState(0); - const [backgroundOpacity, setBackgroundOpacity] = useState(() => { - if (typeof window === "undefined") return 0; - const stored = localStorage.getItem(BACKGROUND_OPACITY_KEY); - if (stored === null) return 0; - const parsed = Number(stored); - if (!Number.isFinite(parsed)) return 0; - return Math.min(100, Math.max(0, parsed)); - }); - const [audioVolume, setAudioVolume] = useState(() => { - if (typeof window === "undefined") return 100; - const stored = localStorage.getItem(AUDIO_VOLUME_KEY); - if (stored === null) return 100; - const parsed = Number(stored); - if (!Number.isFinite(parsed)) return 100; - return Math.min(100, Math.max(0, parsed)); - }); - const [isPreviewPlaying, setIsPreviewPlaying] = useState(false); - const [skipBacking, setSkipBacking] = useState(false); - const isVideo = useMemo(() => isVideoUrl(audioUrl), [audioUrl]); - - - - useEffect(() => { - localStorage.setItem(BACKGROUND_OPACITY_KEY, String(backgroundOpacity)); - }, [backgroundOpacity]); - - useEffect(() => { - localStorage.setItem(AUDIO_VOLUME_KEY, String(audioVolume)); - }, [audioVolume]); - - useEffect(() => { - const media = isVideo ? videoRef.current : audioRef.current; - if (!media) return; - media.volume = audioVolume / 100; - }, [audioVolume, isVideo, audioUrl]); - - useEffect(() => { - const media = isVideo ? videoRef.current : audioRef.current; - if (!media) { - setIsPreviewPlaying(false); - return; - } - const handlePlay = () => setIsPreviewPlaying(true); - const handlePause = () => setIsPreviewPlaying(false); - const handleEnded = () => setIsPreviewPlaying(false); - media.addEventListener("play", handlePlay); - media.addEventListener("pause", handlePause); - media.addEventListener("ended", handleEnded); - return () => { - media.removeEventListener("play", handlePlay); - media.removeEventListener("pause", handlePause); - media.removeEventListener("ended", handleEnded); - }; - }, [isVideo, audioUrl]); - - useEffect(() => { - setIsPreviewPlaying(false); - }, [audioUrl]); - - const charRowRef = useRef(null); - const charRefs = useRef<(HTMLSpanElement | null)[]>([]); - const [wrapSpaceIndicators, setWrapSpaceIndicators] = useState([]); - const countdownIntervalRef = useRef(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("idle"); - useEffect(() => { - phaseRef.current = phase; - }, [phase]); - - const offsetRef = useRef(0); - useEffect(() => { - offsetRef.current = offset; - }, [offset]); - - useEffect(() => { - if (!("mediaSession" in navigator)) return; - const mediaSession = navigator.mediaSession; - mediaSession.setActionHandler("pause", () => {}); - return () => { - mediaSession.setActionHandler("pause", null); - }; - }, []); - - useEffect(() => { - return () => { - if (countdownIntervalRef.current !== null) { - clearInterval(countdownIntervalRef.current); - countdownIntervalRef.current = null; - } - }; - }, []); - - const lineAnimRef = useRef({ startMs: 0, endMs: 0, startPerf: 0 }); - - const timeBasedLineIdx = useMemo(() => { - if (!gameLines.length) return -1; - let idx = -1; - for (let i = 0; i < gameLines.length; i++) { - if (gameLines[i].millisecond <= currentMs) idx = i; - else break; - } - return idx; - }, [currentMs, gameLines]); - - const intermissionData = useMemo(() => { - const firstMs = gameLines[0]?.millisecond ?? 0; - const firstMediaMs = firstMs - offsetRef.current; - const remainingMs = Math.max(0, firstMs - currentMs); - if (!gameLines.length || firstMediaMs <= 0) { - return { pct: remainingMs === 0 ? 100 : 0, remainingMs }; - } - - const mediaCurrentMs = currentMs - offsetRef.current; - const pct = Math.min(100, Math.max(0, (mediaCurrentMs / firstMediaMs) * 100)); - - return { pct, remainingMs }; - }, [gameLines, currentMs, offset]); - - useEffect(() => { - const idx = g.displayedLineIdx; - if (idx < 0 || !gameLines[idx]) { - lineAnimRef.current = { startMs: 0, endMs: 0, startPerf: 0 }; - setLineTimingPct(0); - setLineRemainingMs(0); - setCurrentLineTime(-1); - return; - } - const start = gameLines[idx].millisecond; - const end = gameLines[idx + 1]?.millisecond ?? start + 5000; - lineAnimRef.current = { - startMs: start, - endMs: end, - startPerf: performance.now(), - }; - setLineTimingPct(0); - const currentLineTime = end - start; - setLineRemainingMs(Math.max(0, currentLineTime)); - setCurrentLineTime(Math.max(currentLineTime, currentLineTime)); - }, [g.displayedLineIdx, gameLines]); - - useEffect(() => { - if (phase !== "playing") return; - let rafId = 0; - const tick = () => { - const { startMs, endMs, startPerf } = lineAnimRef.current; - if (endMs <= startMs) { - setLineTimingPct(100); - setLineRemainingMs(0); - } else { - const elapsed = performance.now() - startPerf; - const duration = endMs - startMs; - const pct = Math.min(100, Math.max(0, (elapsed / duration) * 100)); - const remaining = Math.max(0, duration - elapsed); - setLineTimingPct(pct); - setLineRemainingMs(remaining); - } - rafId = requestAnimationFrame(tick); - }; - rafId = requestAnimationFrame(tick); - return () => cancelAnimationFrame(rafId); - }, [phase]); - - useEffect(() => { - const media = isVideo ? videoRef.current : audioRef.current; - if (!media) return; - const onTimeUpdate = () => { - setCurrentMs(media.currentTime * 1000 + offsetRef.current); - if (media.duration && !isNaN(media.duration)) { - setDuration(media.duration * 1000); - setProgressPct((media.currentTime / media.duration) * 100); - } - }; - const onLoadedMetadata = () => { - if (!isNaN(media.duration)) { - setDuration(media.duration * 1000); - setGameDurationMs(media.duration * 1000); - } - }; - const onEnded = () => { - if (phaseRef.current === "playing") { - setPhase("finished"); - setGameDurationMs(Date.now() - gameStartTimeRef.current); - return; - } - setIsPreviewPlaying(false); - }; - media.addEventListener("timeupdate", onTimeUpdate); - media.addEventListener("loadedmetadata", onLoadedMetadata); - media.addEventListener("ended", onEnded); - return () => { - media.removeEventListener("timeupdate", onTimeUpdate); - media.removeEventListener("loadedmetadata", onLoadedMetadata); - media.removeEventListener("ended", onEnded); - }; - }, [isVideo, audioUrl]); - - useEffect(() => { - if (phaseRef.current !== "playing") return; - if (timeBasedLineIdx < 0) return; - if (timeBasedLineIdx <= lastHandledIdxRef.current) return; - lastHandledIdxRef.current = timeBasedLineIdx; - lastLineAdvanceAtRef.current = performance.now(); - dispatch({ - type: "ADVANCE", - newIdx: timeBasedLineIdx, - prevCompleted: gRef.current.lineCompleted, - }); - }, [timeBasedLineIdx]); - - const loadData = useCallback((data: Record) => { - if (typeof data.lrc === "string" && data.lrc) { - setLoadingLrc(true); - fetch(data.lrc) - .then((r) => r.text()) - .then((t) => { - setLrcContent(t); - setLoadingLrc(false); - }); - } - if (typeof data.file1 === "string") setAudioUrl(data.file1); - if (typeof data.offset === "number") setOffset(data.offset); - if (typeof data.offset === "string" && data.offset.trim() !== "") - setOffset(Number(data.offset)); - if (typeof data.title === "string") setSongTitle(data.title); - if (typeof data.artist === "string") setSongArtist(data.artist); - if (typeof data.skip_backing === "boolean") - setSkipBacking(data.skip_backing); - if (typeof data.skip_backing === "string") - setSkipBacking(data.skip_backing === "true"); - }, []); - - useEffect(() => { - const code = searchParams.get("code"); - if (!code) return; - try { - const json = atob(code); - const data = JSON.parse(json) as Record; - loadData(data); - } catch {} - }, []); // eslint-disable-line react-hooks/exhaustive-deps - - const handlePreviewToggle = useCallback(() => { - if (phase !== "idle") return; - const media = isVideo ? videoRef.current : audioRef.current; - if (!media || !audioUrl) return; - - if (media.paused) { - void media.play().catch(() => { - toast.error("Unable to start preview. Try interacting with the page again.", { - theme: "dark", - }); - }); - return; - } - - media.pause(); - }, [phase, isVideo, audioUrl]); - - const handleStart = useCallback(() => { - const media = isVideo ? videoRef.current : audioRef.current; - if (!media || !lrcContent || !audioUrl) return; - if (countdownIntervalRef.current !== null) { - clearInterval(countdownIntervalRef.current); - countdownIntervalRef.current = null; - } - dispatch({ type: "RESET" }); - lastHandledIdxRef.current = -1; - media.pause(); - media.currentTime = 0; - setIsPreviewPlaying(false); - setPhase("countdown"); - setCountdown(5); - setGameDurationMs(0); - setProgressPct(0); - setCurrentMs(0); - - const beginPlayback = () => { - media.currentTime = 0; - media.play(); - setPhase("playing"); - gameStartTimeRef.current = Date.now(); - }; - - countdownIntervalRef.current = window.setInterval(() => { - setCountdown((c) => { - if (c <= 1) { - if (countdownIntervalRef.current !== null) { - clearInterval(countdownIntervalRef.current); - countdownIntervalRef.current = null; - } - beginPlayback(); - return 0; - } - return c - 1; - }); - }, 1000); - }, [lrcContent, audioUrl, gameLines, isVideo]); - - const handleRestart = useCallback(() => { - const media = isVideo ? videoRef.current : audioRef.current; - if (media) { - media.pause(); - media.currentTime = 0; - } - setIsPreviewPlaying(false); - if (countdownIntervalRef.current !== null) { - clearInterval(countdownIntervalRef.current); - countdownIntervalRef.current = null; - } - setCountdown(0); - dispatch({ type: "RESET" }); - lastHandledIdxRef.current = -1; - setPhase("idle"); - setCurrentMs(0); - setProgressPct(0); - }, [isVideo]); - - const handleLoadCode = useCallback(() => { - if (!codeInput.trim()) return; - try { - const json = atob(codeInput.trim()); - const data = JSON.parse(json) as Record; - loadData(data); - handleRestart(); - toast.success("Song loaded!", { theme: "dark" }); - } catch { - toast.error("Invalid code. Please check and try again.", { - theme: "dark", - }); - } - }, [codeInput, loadData, handleRestart]); - - const handleKeyPress = useCallback( - (char: string) => { - if (phaseRef.current !== "playing") return; - const line = gameLines[gRef.current.displayedLineIdx]; - if (!line || gRef.current.lineCompleted) return; - const expected = line.content[gRef.current.typedCount]; - if (expected === undefined) return; - if (char.toLowerCase() === expected.toLowerCase()) { - const willComplete = gRef.current.typedCount + 1 >= line.content.length; - dispatch({ type: "CORRECT", willComplete }); - if (willComplete) { - setClearShowing(true); - setTimeout(() => setClearShowing(false), 700); - setComboAnimKey((k) => k + 1); - } - } else { - if (performance.now() - lastLineAdvanceAtRef.current < 100) return; - dispatch({ type: "WRONG" }); - setWrongChar(true); - setTimeout(() => setWrongChar(false), 320); - } - }, - [gameLines], - ); - - useEffect(() => { - if (phase !== "playing") return; - const handler = (e: KeyboardEvent) => { - if (e.key === " ") { - const idx = gRef.current.displayedLineIdx; - if (idx < 0 && gameLines.length > 0) { - const firstMs = gameLines[0]?.millisecond ?? 0; - const media = isVideo ? videoRef.current : audioRef.current; - if (media) { - const currentMsLocal = media.currentTime * 1000 + offsetRef.current; - const intermissionRemaining = Math.max(0, firstMs - currentMsLocal); - if (intermissionRemaining > 5000) { - e.preventDefault(); - const targetMs = firstMs - 3000; - media.currentTime = Math.max(0, (targetMs - offsetRef.current) / 1000); - setCurrentMs(media.currentTime * 1000 + offsetRef.current); - return; - } - } - } - } - if (e.key.length === 1) { - e.preventDefault(); - handleKeyPress(e.key); - } - }; - window.addEventListener("keydown", handler); - return () => window.removeEventListener("keydown", handler); - }, [phase, handleKeyPress, gameLines, isVideo]); - - return ( - - - {!isVideo && ( - - ); -} - -export default function GamePage() { - return ( - <> - - -
- Loading... -
- - } - > - -
- - ); -} -- cgit v1.2.3