diff options
| author | Pinapelz <yukais@pinapelz.com> | 2026-04-16 03:13:31 -0700 |
|---|---|---|
| committer | Pinapelz <yukais@pinapelz.com> | 2026-04-16 03:13:31 -0700 |
| commit | 784c99aa2d5ec4d2b861b0c44e4943f89f0144ce (patch) | |
| tree | 035ba8887b6eb81424a563e1dc9acfb34f25eef0 /src | |
| parent | 30d2ca8480caea1ce76cc1ec29d454e3a669c638 (diff) | |
wip: typing tube mode
Diffstat (limited to 'src')
| -rw-r--r-- | src/app/game/game.stat.ts | 82 | ||||
| -rw-r--r-- | src/app/game/game.utils.ts | 45 | ||||
| -rw-r--r-- | src/app/game/page.styles.ts | 553 | ||||
| -rw-r--r-- | src/app/game/page.tsx | 689 | ||||
| -rw-r--r-- | src/app/page.tsx | 10 |
5 files changed, 1378 insertions, 1 deletions
diff --git a/src/app/game/game.stat.ts b/src/app/game/game.stat.ts new file mode 100644 index 0000000..43136e6 --- /dev/null +++ b/src/app/game/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/game.utils.ts b/src/app/game/game.utils.ts new file mode 100644 index 0000000..73d2884 --- /dev/null +++ b/src/app/game/game.utils.ts @@ -0,0 +1,45 @@ +export interface GameLine { + millisecond: number; + content: string; +} + +export function parseLrcLines(lrcText: string): GameLine[] { + const result: GameLine[] = []; + const lineRegex = /\[(\d{2,3}):(\d{2})\.(\d{2,3})\]/g; + + 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 = rawLine.slice(lastIndex).trim(); + if (!content) continue; + + for (const ms of timestamps) { + result.push({ millisecond: ms, content }); + } + } + + result.sort((a, b) => a.millisecond - b.millisecond); + return result; +} + +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")}`; +}
\ No newline at end of file diff --git a/src/app/game/page.styles.ts b/src/app/game/page.styles.ts new file mode 100644 index 0000000..1ced4cc --- /dev/null +++ b/src/app/game/page.styles.ts @@ -0,0 +1,553 @@ +import styled, { keyframes, css } 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); } +`; + +/* ----- 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; +`; + +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; +`; + +/* ----- 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(255, 255, 255, 0.04); + border-bottom: 1px solid rgba(255, 255, 255, 0.06); +`; + +export const HudStat = styled.div` + display: flex; + flex-direction: column; + align-items: center; + gap: 2px; +`; + +export const HudValue = styled.span<{ $color?: string }>` + font-size: 22px; + font-weight: 700; + color: ${({ $color }) => $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` + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 24px 32px; + gap: 24px; + overflow: hidden; +`; + +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; + 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 LineTimingMeta = styled.div` + display: flex; + align-items: center; + justify-content: flex-end; + font-size: 11px; + 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<{ $pct: number }>` + height: 100%; + border-radius: 2px; + background: #7c3aed; + width: ${({ $pct }) => $pct}%; + transition: width 0.1s linear; +`; + +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: monospace; + 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); +`; + +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<{ $pct: number }>` + height: 100%; + background: #7c3aed; + border-radius: 3px; + width: ${({ $pct }) => $pct}%; + 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 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 new file mode 100644 index 0000000..3bbbbac --- /dev/null +++ b/src/app/game/page.tsx @@ -0,0 +1,689 @@ +"use client"; +import { + useCallback, + useEffect, + 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, + GameNavbar, + GameContent, + HUD, + HudStat, + HudValue, + HudLabel, + ComboValue, + GameArea, + UpcomingWrap, + UpcomingLabel, + UpcomingText, + CurrentWrap, + LineTimingMeta, + LineTimingValue, + LineTimingBar, + LineTimingFill, + CharRow, + WordWrap, + CharBox, + ClearToast, + GetReadyText, + 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, type GameLine } from "./game.utils"; + +type GamePhase = "idle" | "countdown" | "playing" | "paused" | "finished"; + +function GameInner() { + const searchParams = useSearchParams(); + const router = useRouter(); + + const audioRef = useRef<HTMLAudioElement>(null); + const gameStartTimeRef = useRef<number>(0); + const lastHandledIdxRef = useRef(-1); + + const [phase, setPhase] = useState<GamePhase>("idle"); + const [currentMs, setCurrentMs] = useState(0); + const [lineTimingPct, setLineTimingPct] = useState(0); + const [lineRemainingMs, setLineRemainingMs] = 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 countdownIntervalRef = useRef<number | null>(null); + + const [g, dispatch] = useReducer(gReducer, initialGState); + + const gameLines = useMemo(() => parseLrcLines(lrcContent), [lrcContent]); + 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); + 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]); + + useEffect(() => { + const idx = g.displayedLineIdx; + if (idx < 0 || !gameLines[idx]) { + lineAnimRef.current = { startMs: 0, endMs: 0, startPerf: 0 }; + setLineTimingPct(0); + setLineRemainingMs(0); + 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); + setLineRemainingMs(Math.max(0, end - start)); + }, [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 audio = audioRef.current; + if (!audio) return; + const onTimeUpdate = () => { + setCurrentMs(audio.currentTime * 1000 + offsetRef.current); + if (audio.duration && !isNaN(audio.duration)) { + setDuration(audio.duration * 1000); + setProgressPct((audio.currentTime / audio.duration) * 100); + } + }; + const onLoadedMetadata = () => { + if (!isNaN(audio.duration)) setDuration(audio.duration * 1000); + }; + const onEnded = () => { + setPhase("finished"); + setGameDurationMs(Date.now() - gameStartTimeRef.current); + }; + audio.addEventListener("timeupdate", onTimeUpdate); + audio.addEventListener("loadedmetadata", onLoadedMetadata); + audio.addEventListener("ended", onEnded); + return () => { + audio.removeEventListener("timeupdate", onTimeUpdate); + audio.removeEventListener("loadedmetadata", onLoadedMetadata); + audio.removeEventListener("ended", onEnded); + }; + }, []); // intentionally empty deps + + useEffect(() => { + if (phaseRef.current !== "playing") return; + if (timeBasedLineIdx < 0) return; + if (timeBasedLineIdx <= lastHandledIdxRef.current) return; + lastHandledIdxRef.current = timeBasedLineIdx; + dispatch({ + type: "ADVANCE", + newIdx: timeBasedLineIdx, + prevCompleted: gRef.current.lineCompleted, + }); + }, [timeBasedLineIdx]); + + const loadData = useCallback((data: Record<string, string>) => { + if (data.lrc) { + setLoadingLrc(true); + fetch(data.lrc) + .then((r) => r.text()) + .then((t) => { + setLrcContent(t); + setLoadingLrc(false); + }); + } + if (data.file1) setAudioUrl(data.file1); + if (data.offset) setOffset(Number(data.offset)); + if (data.title) setSongTitle(data.title); + if (data.artist) setSongArtist(data.artist); + }, []); + + useEffect(() => { + const code = searchParams.get("code"); + if (!code) return; + try { + const json = atob(code); + const data = JSON.parse(json) as Record<string, string>; + loadData(data); + } catch {} + }, []); // eslint-disable-line react-hooks/exhaustive-deps + + const handleStart = useCallback(() => { + const audio = audioRef.current; + if (!audio || !lrcContent || !audioUrl) return; + if (countdownIntervalRef.current !== null) { + clearInterval(countdownIntervalRef.current); + countdownIntervalRef.current = null; + } + dispatch({ type: "RESET" }); + lastHandledIdxRef.current = -1; + audio.pause(); + audio.currentTime = 0; + setPhase("countdown"); + setCountdown(5); + setGameDurationMs(0); + setProgressPct(0); + setCurrentMs(0); + + const beginPlayback = () => { + audio.currentTime = 0; + audio.play(); + setPhase("playing"); + gameStartTimeRef.current = Date.now(); + if (gameLines[0]) { + dispatch({ + type: "ADVANCE", + newIdx: 0, + prevCompleted: true, + }); + lastHandledIdxRef.current = 0; + } + }; + + 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]); + + const handleRestart = useCallback(() => { + const audio = audioRef.current; + if (audio) { + audio.pause(); + audio.currentTime = 0; + } + if (countdownIntervalRef.current !== null) { + clearInterval(countdownIntervalRef.current); + countdownIntervalRef.current = null; + } + setCountdown(0); + dispatch({ type: "RESET" }); + lastHandledIdxRef.current = -1; + setPhase("idle"); + setCurrentMs(0); + setProgressPct(0); + }, []); + + const handleLoadCode = useCallback(() => { + if (!codeInput.trim()) return; + try { + const json = atob(codeInput.trim()); + const data = JSON.parse(json) as Record<string, string>; + 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 { + dispatch({ type: "WRONG" }); + setWrongChar(true); + setTimeout(() => setWrongChar(false), 320); + } + }, + [gameLines], + ); + + useEffect(() => { + if (phase !== "playing") return; + const handler = (e: KeyboardEvent) => { + if (e.key.length === 1) { + e.preventDefault(); + handleKeyPress(e.key); + } + }; + window.addEventListener("keydown", handler); + return () => window.removeEventListener("keydown", handler); + }, [phase, handleKeyPress]); + + return ( + <GameRoot> + <ToastContainer theme="dark" /> + <audio ref={audioRef} src={audioUrl || undefined} preload="auto" /> + <GameNavbar style={{ justifyContent: "space-between" }}> + <div style={{ display: "flex", alignItems: "center", gap: 16 }}> + <Link + href="/" + style={{ + display: "flex", + alignItems: "center", + gap: 8, + textDecoration: "none", + color: "#ffffff", + fontWeight: 700, + fontSize: 15, + }} + > + <MdLibraryMusic style={{ fontSize: 20, color: "#a78bfa" }} /> + LRC-Type + </Link> + <Link + href="/player" + style={{ + fontSize: 13, + color: "rgba(255,255,255,0.6)", + textDecoration: "none", + }} + > + Player + </Link> + <Link + href="/create" + style={{ + fontSize: 13, + color: "rgba(255,255,255,0.6)", + textDecoration: "none", + }} + > + Create + </Link> + </div> + </GameNavbar> + + <GameContent style={{ position: "relative" }}> + {phase === "idle" && ( + <StartOverlay> + <StartCard> + {!isReady ? ( + <> + <SongTitleText>LRC-Type</SongTitleText> + <SongArtistText>Enter a game code to begin!</SongArtistText> + </> + ) : ( + <> + <SongTitleText> + {loadingLrc ? "Loading..." : songTitle} + </SongTitleText> + <SongArtistText>{songArtist}</SongArtistText> + </> + )} + + <StartBtn + onClick={handleStart} + disabled={!isReady} + suppressHydrationWarning + > + {loadingLrc ? "Loading song..." : "▶ Start Game"} + </StartBtn> + <CodeSection> + <div + style={{ + fontSize: 11, + color: "rgba(255,255,255,0.3)", + letterSpacing: 1, + textTransform: "uppercase", + }} + > + Load another song + </div> + <CodeInputRow> + <CodeInputField + placeholder="Paste MoekyunKaraoke or MoekyunKaraoke+ code..." + value={codeInput} + onChange={(e) => setCodeInput(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && handleLoadCode()} + /> + <CodeLoadBtn onClick={handleLoadCode}>Load</CodeLoadBtn> + </CodeInputRow> + </CodeSection> + <Link + href="/player" + style={{ + fontSize: 13, + color: "rgba(255,255,255,0.35)", + textDecoration: "none", + }} + > + Back to Player + </Link> + </StartCard> + </StartOverlay> + )} + + {phase === "countdown" && ( + <StartOverlay> + <StartCard> + <SongTitleText>Get Ready</SongTitleText> + <CountdownNumber>{countdown}</CountdownNumber> + </StartCard> + </StartOverlay> + )} + + {phase === "finished" && ( + <ResultsOverlay> + <ResultsCard> + <ResultsTitle>Results {songTitle}</ResultsTitle> + <BigScore>{g.score.toLocaleString()}</BigScore> + <StatsGrid> + <StatBlock> + <StatValue>{accuracy}%</StatValue> + <StatLabel>Accuracy</StatLabel> + </StatBlock> + <StatBlock> + <StatValue>x{g.maxCombo}</StatValue> + <StatLabel>Max Combo</StatLabel> + </StatBlock> + <StatBlock> + <StatValue>{wpm}</StatValue> + <StatLabel>WPM</StatLabel> + </StatBlock> + <StatBlock> + <StatValue>{g.totalMiss}</StatValue> + <StatLabel>Missed Chars</StatLabel> + </StatBlock> + </StatsGrid> + <ActionRow> + <PlayAgainBtn onClick={handleRestart}>Play Again</PlayAgainBtn> + <HomeBtn onClick={() => router.push("/")}>Home</HomeBtn> + </ActionRow> + </ResultsCard> + </ResultsOverlay> + )} + + <HUD> + <HudStat> + <HudValue $color="#a78bfa">{g.score.toLocaleString()}</HudValue> + <HudLabel>Score</HudLabel> + </HudStat> + <HudStat> + <ComboValue + $color="#fbbf24" + $animate={comboAnimKey > 0} + key={`combo-${comboAnimKey}`} + > + x{g.combo} + </ComboValue> + <HudLabel>Combo</HudLabel> + </HudStat> + <HudStat> + <HudValue $color="#22c55e">{accuracy}%</HudValue> + <HudLabel>Accuracy</HudLabel> + </HudStat> + <HudStat> + <HudValue>{wpm}</HudValue> + <HudLabel>WPM</HudLabel> + </HudStat> + <HudStat> + <HudValue $color="#ef4444">{g.totalMiss}</HudValue> + <HudLabel>Misses</HudLabel> + </HudStat> + </HUD> + + <GameArea> + {phase === "playing" && + g.displayedLineIdx < 0 && + gameLines.length > 0 && <GetReadyText>Get ready...</GetReadyText>} + {g.displayedLineIdx >= 0 && gameLines[g.displayedLineIdx] && ( + <> + <UpcomingWrap> + <UpcomingLabel>Next</UpcomingLabel> + <UpcomingText> + {gameLines[g.displayedLineIdx + 1]?.content ?? ""} + </UpcomingText> + </UpcomingWrap> + <CurrentWrap style={{ position: "relative" }}> + <LineTimingMeta> + Time left:{" "} + <LineTimingValue> + {Math.max(0, lineRemainingMs / 1000).toFixed(1)}s + </LineTimingValue> + </LineTimingMeta> + <LineTimingBar> + <LineTimingFill $pct={lineTimingPct} /> + </LineTimingBar> + <CharRow> + {(() => { + const text = + gameLines[g.displayedLineIdx].content.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 element = ( + <CharBox + key={`space-${tokenIdx}-${spaceIdx}`} + $state={state} + > + {ch === " " ? "\u00A0" : ch} + </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 element = ( + <CharBox + key={`char-${tokenIdx}-${charIdx}`} + $state={state} + > + {ch} + </CharBox> + ); + renderIndex += 1; + return element; + }); + + return ( + <WordWrap key={`word-${tokenIdx}`}> + {wordChars} + </WordWrap> + ); + }); + })()} + </CharRow> + {clearShowing && <ClearToast>CLEAR!</ClearToast>} + <CompletedLineFade> + {g.lineCompleted ? "Cleared - waiting for next line..." : ""} + </CompletedLineFade> + </CurrentWrap> + </> + )} + {phase === "idle" && ( + <GetReadyText style={{ opacity: 0.3 }}> + Start the game to begin typing + </GetReadyText> + )} + </GameArea> + + <GameFooter> + <ControlBtn onClick={handleRestart} title="Restart"> + <FaRedo /> + </ControlBtn> + <ProgressWrap> + <ProgressFill $pct={progressPct} /> + </ProgressWrap> + <TimeText> + {formatTime(Math.max(0, currentMs))} / {formatTime(duration)} + </TimeText> + </GameFooter> + </GameContent> + </GameRoot> + ); +} + +export default function GamePage() { + return ( + <Suspense + fallback={ + <GameRoot> + <div + style={{ + flex: 1, + display: "flex", + alignItems: "center", + justifyContent: "center", + fontSize: 18, + color: "rgba(255,255,255,0.5)", + }} + > + Loading... + </div> + </GameRoot> + } + > + <GameInner /> + </Suspense> + ); +} diff --git a/src/app/page.tsx b/src/app/page.tsx index fbe91be..66ef915 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,6 +1,6 @@ "use client"; import { useState, useEffect } from "react"; -import { FaPlay, FaMusic, FaSearch, FaUserCircle } from "react-icons/fa"; +import { FaPlay, FaMusic, FaSearch, FaUserCircle, FaKeyboard } from "react-icons/fa"; import { MdLibraryMusic } from "react-icons/md"; import { Root, Navbar, Logo, LogoIcon, NavLink } from "./styles/shared"; import { @@ -101,6 +101,7 @@ export default function HomePage() { </NavCenter> <NavRight> + <NavLink href="/game">Typing Game</NavLink> <NavLink href="/create">Create Karaoke Code</NavLink> <Avatar> <FaUserCircle /> @@ -165,6 +166,13 @@ export default function HomePage() { <PlayerDescription> Load your own video, audio, LRC lyrics </PlayerDescription> + <SectionHeading style={{ marginTop: 24 }}>Typing Game</SectionHeading> + <OpenPlayerLink href="/game"> + <FaKeyboard /> Play Typing Game + </OpenPlayerLink> + <PlayerDescription> + Type lyrics in sync with the music to score points + </PlayerDescription> </CtaSection> </Root> ); |
