aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorPinapelz <yukais@pinapelz.com>2026-04-16 03:13:31 -0700
committerPinapelz <yukais@pinapelz.com>2026-04-16 03:13:31 -0700
commit784c99aa2d5ec4d2b861b0c44e4943f89f0144ce (patch)
tree035ba8887b6eb81424a563e1dc9acfb34f25eef0
parent30d2ca8480caea1ce76cc1ec29d454e3a669c638 (diff)
wip: typing tube mode
-rw-r--r--src/app/game/game.stat.ts82
-rw-r--r--src/app/game/game.utils.ts45
-rw-r--r--src/app/game/page.styles.ts553
-rw-r--r--src/app/game/page.tsx689
-rw-r--r--src/app/page.tsx10
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>
);
send patches to the email below
yukais@pinapelz.com
include the subject [PATCH repo_name]
pinapelz.com
homepage