diff options
| author | Pinapelz <yukais@pinapelz.com> | 2026-06-11 23:14:04 -0700 |
|---|---|---|
| committer | Pinapelz <yukais@pinapelz.com> | 2026-06-11 23:14:04 -0700 |
| commit | e156d7200a136178f73c7590877087d8e06e6382 (patch) | |
| tree | ca0e5849cd8164b4994462e83ee81b26f181f2fd | |
| parent | e7a4a7410ca9f512a7dec4ba92d4b592d0b43512 (diff) | |
implement leaderboard
| -rw-r--r-- | pb_migrations/1781244232_updated_scores.js | 20 | ||||
| -rw-r--r-- | pb_migrations/1781244260_updated_scores.js | 22 | ||||
| -rw-r--r-- | src/app/game/[slug]/components/PreGameView.tsx | 257 | ||||
| -rw-r--r-- | src/app/game/[slug]/components/ResultsView.tsx | 54 | ||||
| -rw-r--r-- | src/app/game/[slug]/hooks/useGameEngine.ts | 5 | ||||
| -rw-r--r-- | src/app/game/[slug]/page.styles.ts | 96 | ||||
| -rw-r--r-- | src/app/game/[slug]/page.tsx | 4 |
7 files changed, 402 insertions, 56 deletions
diff --git a/pb_migrations/1781244232_updated_scores.js b/pb_migrations/1781244232_updated_scores.js new file mode 100644 index 0000000..5bb7eec --- /dev/null +++ b/pb_migrations/1781244232_updated_scores.js @@ -0,0 +1,20 @@ +/// <reference path="../pb_data/types.d.ts" /> +migrate((app) => { + const collection = app.findCollectionByNameOrId("pbc_809716240") + + // update collection data + unmarshal({ + "viewRule": "" + }, collection) + + return app.save(collection) +}, (app) => { + const collection = app.findCollectionByNameOrId("pbc_809716240") + + // update collection data + unmarshal({ + "viewRule": null + }, collection) + + return app.save(collection) +}) diff --git a/pb_migrations/1781244260_updated_scores.js b/pb_migrations/1781244260_updated_scores.js new file mode 100644 index 0000000..e3a4533 --- /dev/null +++ b/pb_migrations/1781244260_updated_scores.js @@ -0,0 +1,22 @@ +/// <reference path="../pb_data/types.d.ts" /> +migrate((app) => { + const collection = app.findCollectionByNameOrId("pbc_809716240") + + // update collection data + unmarshal({ + "listRule": "", + "viewRule": null + }, collection) + + return app.save(collection) +}, (app) => { + const collection = app.findCollectionByNameOrId("pbc_809716240") + + // update collection data + unmarshal({ + "listRule": null, + "viewRule": "" + }, collection) + + return app.save(collection) +}) diff --git a/src/app/game/[slug]/components/PreGameView.tsx b/src/app/game/[slug]/components/PreGameView.tsx index a92b1cb..77af6ea 100644 --- a/src/app/game/[slug]/components/PreGameView.tsx +++ b/src/app/game/[slug]/components/PreGameView.tsx @@ -1,8 +1,13 @@ "use client"; +import { useEffect, useState } from "react"; +import pb from "../../../lib/pocketbase"; import { StartOverlay, - StartCard, + PreGameCard, + PreGameGrid, + PreGameLeft, + PreGameRight, SongTitleText, SongArtistText, StartBtn, @@ -13,6 +18,15 @@ import { PreviewWrap, PreviewBtn, PreviewHint, + LeaderboardCard, + LeaderboardHeader, + LeaderboardTitle, + LeaderboardCount, + LeaderboardList, + LeaderboardRow, + LeaderboardRank, + LeaderboardName, + LeaderboardScore, } from "../page.styles"; interface PreGameViewProps { @@ -20,6 +34,7 @@ interface PreGameViewProps { loadingLrc: boolean; songTitle: string; songArtist: string; + chartId: string | null; audioUrl: string; isVideo: boolean; audioVolume: number; @@ -31,11 +46,48 @@ interface PreGameViewProps { onPreviewToggle: () => void; } +interface LeaderboardEntry { + id: string; + name: string; + score: number; +} + +type ScoreRecord = Record<string, unknown> & { + expand?: { + user?: unknown; + }; +}; + +function hasChartRelation(chartValue: unknown, chartId: string): boolean { + if (typeof chartValue === "string") return chartValue === chartId; + if (Array.isArray(chartValue)) return chartValue.includes(chartId); + return false; +} + +function extractUserName(record: ScoreRecord): string { + const expandedUser = record.expand?.user; + const resolvedExpandedUser = Array.isArray(expandedUser) + ? expandedUser[0] + : expandedUser; + + if (resolvedExpandedUser && typeof resolvedExpandedUser === "object") { + const user = resolvedExpandedUser as Record<string, unknown>; + const username = typeof user.username === "string" ? user.username.trim() : ""; + const name = typeof user.name === "string" ? user.name.trim() : ""; + + if (username) return username; + if (name) return name; + } + + return "Unknown"; +} + export default function PreGameView({ isReady, loadingLrc, songTitle, songArtist, + chartId, audioUrl, isVideo, audioVolume, @@ -46,66 +98,161 @@ export default function PreGameView({ onStart, onPreviewToggle, }: PreGameViewProps) { + const [leaderboardEntries, setLeaderboardEntries] = useState<LeaderboardEntry[]>([]); + const [loadingLeaderboard, setLoadingLeaderboard] = useState(false); + + useEffect(() => { + if (!chartId) { + setLeaderboardEntries([]); + return; + } + + let cancelled = false; + + const loadLeaderboard = async () => { + setLoadingLeaderboard(true); + try { + const records = await pb + .collection("scores") + .getFullList<ScoreRecord>({ sort: "-score", expand: "user" }); + + if (cancelled) return; + + const bestScores = new Map<string, LeaderboardEntry>(); + + for (const record of records) { + if (!hasChartRelation(record.chart, chartId)) continue; + + const score = Number(record.score); + if (!Number.isFinite(score)) continue; + + const name = extractUserName(record); + + const existing = bestScores.get(name); + + if (!existing || score > existing.score) { + bestScores.set(name, { + id: String(record.id), + name, + score, + }); + } + } + + const topEntries = Array.from(bestScores.values()) + .sort((a, b) => b.score - a.score) + .slice(0, 10); + + setLeaderboardEntries(topEntries); + } catch { + if (!cancelled) setLeaderboardEntries([]); + } finally { + if (!cancelled) setLoadingLeaderboard(false); + } + }; + + void loadLeaderboard(); + + return () => { + cancelled = true; + }; + }, [chartId]); + return ( <StartOverlay> - <StartCard> - {!isReady ? ( - <> - <SongTitleText>Loading...</SongTitleText> - <SongArtistText>Please wait while we load the chart</SongArtistText> - </> - ) : ( - <> - <SongTitleText>{loadingLrc ? "Loading..." : songTitle}</SongTitleText> - <SongArtistText>{songArtist}</SongArtistText> - </> - )} + <PreGameCard> + <PreGameGrid> + <PreGameLeft> + {!isReady ? ( + <> + <SongTitleText>Loading...</SongTitleText> + <SongArtistText>Please wait while we load the chart</SongArtistText> + </> + ) : ( + <> + <SongTitleText>{loadingLrc ? "Loading..." : songTitle}</SongTitleText> + <SongArtistText>{songArtist}</SongArtistText> + </> + )} + + <StartBtn onClick={onStart} disabled={!isReady} suppressHydrationWarning> + {loadingLrc ? "Loading song..." : "▶ Start Game"} + </StartBtn> + + <OpacityControl> + <OpacityLabel> + Volume + <OpacityValue>{audioVolume}%</OpacityValue> + </OpacityLabel> + <OpacitySlider + type="range" + min="0" + max="100" + value={audioVolume} + onChange={(e) => setAudioVolume(Number(e.target.value))} + /> + </OpacityControl> - <StartBtn onClick={onStart} disabled={!isReady} suppressHydrationWarning> - {loadingLrc ? "Loading song..." : "▶ Start Game"} - </StartBtn> + {isVideo && ( + <OpacityControl> + <OpacityLabel> + Background opacity + <OpacityValue>{backgroundOpacity}%</OpacityValue> + </OpacityLabel> + <OpacitySlider + type="range" + min="0" + max="100" + value={backgroundOpacity} + onChange={(e) => setBackgroundOpacity(Number(e.target.value))} + /> + </OpacityControl> + )} - <OpacityControl> - <OpacityLabel> - Volume - <OpacityValue>{audioVolume}%</OpacityValue> - </OpacityLabel> - <OpacitySlider - type="range" - min="0" - max="100" - value={audioVolume} - onChange={(e) => setAudioVolume(Number(e.target.value))} - /> - </OpacityControl> + <PreviewWrap> + <PreviewBtn onClick={onPreviewToggle} disabled={!audioUrl} suppressHydrationWarning> + {isPreviewPlaying ? "⏸ Pause Preview" : "▶ Preview Audio"} + </PreviewBtn> + <PreviewHint> + {audioUrl + ? "Use preview to test your volume before starting." + : "Load a chart to enable audio preview."} + </PreviewHint> + </PreviewWrap> + </PreGameLeft> - {isVideo && ( - <OpacityControl> - <OpacityLabel> - Background opacity - <OpacityValue>{backgroundOpacity}%</OpacityValue> - </OpacityLabel> - <OpacitySlider - type="range" - min="0" - max="100" - value={backgroundOpacity} - onChange={(e) => setBackgroundOpacity(Number(e.target.value))} - /> - </OpacityControl> - )} + <PreGameRight> + <LeaderboardCard> + <LeaderboardHeader> + <LeaderboardTitle>Leaderboard</LeaderboardTitle> + <LeaderboardCount> + {leaderboardEntries.length + ? `Top ${leaderboardEntries.length}` + : "Top 0"} + </LeaderboardCount> + </LeaderboardHeader> - <PreviewWrap> - <PreviewBtn onClick={onPreviewToggle} disabled={!audioUrl} suppressHydrationWarning> - {isPreviewPlaying ? "⏸ Pause Preview" : "▶ Preview Audio"} - </PreviewBtn> - <PreviewHint> - {audioUrl - ? "Use preview to test your volume before starting." - : "Load a chart to enable audio preview."} - </PreviewHint> - </PreviewWrap> - </StartCard> + {!chartId ? ( + <PreviewHint>Leaderboard unavailable for this chart.</PreviewHint> + ) : loadingLeaderboard ? ( + <PreviewHint>Loading leaderboard...</PreviewHint> + ) : leaderboardEntries.length === 0 ? ( + <PreviewHint>No scores yet. Be the first!</PreviewHint> + ) : ( + <LeaderboardList> + {leaderboardEntries.map((entry, index) => ( + <LeaderboardRow key={entry.id}> + <LeaderboardRank>#{index + 1}</LeaderboardRank> + <LeaderboardName>{entry.name}</LeaderboardName> + <LeaderboardScore>{entry.score.toLocaleString()}</LeaderboardScore> + </LeaderboardRow> + ))} + </LeaderboardList> + )} + </LeaderboardCard> + </PreGameRight> + </PreGameGrid> + </PreGameCard> </StartOverlay> ); } diff --git a/src/app/game/[slug]/components/ResultsView.tsx b/src/app/game/[slug]/components/ResultsView.tsx index 48b41bb..df9d72c 100644 --- a/src/app/game/[slug]/components/ResultsView.tsx +++ b/src/app/game/[slug]/components/ResultsView.tsx @@ -1,6 +1,8 @@ "use client"; +import { useEffect, useRef } from "react"; import { useRouter } from "next/navigation"; +import { toast } from "react-toastify"; import { ResultsOverlay, ResultsCard, @@ -15,12 +17,15 @@ import { HomeBtn, } from "../page.styles"; import { GState } from "../game.stat"; +import { useAuth } from "../../../context/auth"; +import pb from "../../../lib/pocketbase"; interface ResultsViewProps { g: GState; accuracy: number; wpm: number; songTitle: string; + chartId: string | null; onPlayAgain: () => void; } @@ -29,9 +34,58 @@ export default function ResultsView({ accuracy, wpm, songTitle, + chartId, onPlayAgain, }: ResultsViewProps) { const router = useRouter(); + const { user, loading } = useAuth(); + const hasTriedAutoUploadRef = useRef(false); + + useEffect(() => { + if (hasTriedAutoUploadRef.current) return; + if (loading) return; + + hasTriedAutoUploadRef.current = true; + + if (!user || !chartId) return; + + const dedupeKey = `scores:auto:${chartId}:${user.id}:${g.score}:${wpm}:${g.maxCombo}:${g.totalMiss}:${accuracy}`; + + if (sessionStorage.getItem(dedupeKey) === "1") return; + + const uploadScore = async () => { + try { + try { + await pb.collection("scores").create({ + accuracy, + combo: g.maxCombo, + wpm, + miss: g.totalMiss, + score: g.score, + user: user.id, + chart: chartId, + }); + } catch { + await pb.collection("scores").create({ + accuracy, + combo: g.maxCombo, + wpm, + miss: g.totalMiss, + score: g.score, + user: [user.id], + chart: [chartId], + }); + } + + sessionStorage.setItem(dedupeKey, "1"); + toast.success("Score uploaded.", { theme: "dark" }); + } catch { + toast.error("Failed to upload score. Please try again.", { theme: "dark" }); + } + }; + + void uploadScore(); + }, [accuracy, chartId, g.maxCombo, g.score, g.totalMiss, loading, user, wpm]); return ( <ResultsOverlay> diff --git a/src/app/game/[slug]/hooks/useGameEngine.ts b/src/app/game/[slug]/hooks/useGameEngine.ts index 42fc2c1..da3240e 100644 --- a/src/app/game/[slug]/hooks/useGameEngine.ts +++ b/src/app/game/[slug]/hooks/useGameEngine.ts @@ -57,6 +57,7 @@ export interface GameEngineResult { audioUrl: string; songTitle: string; songArtist: string; + chartId: string | null; offset: number; loadingLrc: boolean; @@ -127,6 +128,7 @@ export function useGameEngine(): GameEngineResult { const [audioUrl, setAudioUrl] = useState(""); const [songTitle, setSongTitle] = useState("Unknown Title"); const [songArtist, setSongArtist] = useState("Unknown Artist"); + const [chartId, setChartId] = useState<string | null>(null); const [offset, setOffset] = useState(0); const [loadingLrc, setLoadingLrc] = useState(false); @@ -412,6 +414,7 @@ export function useGameEngine(): GameEngineResult { pb.collection("charts") .getOne(slug) .then((record) => { + setChartId(record.id); loadData({ media: (record as Record<string, unknown>).media, lrc: (record as Record<string, unknown>).lrc, @@ -421,6 +424,7 @@ export function useGameEngine(): GameEngineResult { }); }) .catch(() => { + setChartId(null); try { const json = atob(slug); const data = JSON.parse(json) as Record<string, unknown>; @@ -586,6 +590,7 @@ export function useGameEngine(): GameEngineResult { audioUrl, songTitle, songArtist, + chartId, offset, loadingLrc, diff --git a/src/app/game/[slug]/page.styles.ts b/src/app/game/[slug]/page.styles.ts index 1b5880b..38411ea 100644 --- a/src/app/game/[slug]/page.styles.ts +++ b/src/app/game/[slug]/page.styles.ts @@ -395,6 +395,102 @@ export const StartCard = styled.div` text-align: center; `; +export const PreGameCard = styled(StartCard)` + max-width: 980px; + align-items: stretch; + text-align: left; + gap: 24px; +`; + +export const PreGameGrid = styled.div` + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(300px, 360px); + gap: 24px; + width: 100%; + + @media (max-width: 920px) { + grid-template-columns: 1fr; + } +`; + +export const PreGameLeft = styled.div` + display: flex; + flex-direction: column; + gap: 16px; +`; + +export const PreGameRight = styled.div` + display: flex; + flex-direction: column; + gap: 12px; +`; + +export const LeaderboardCard = styled.div` + width: 100%; + display: flex; + flex-direction: column; + gap: 8px; + padding: 14px; + border: 1px solid rgba(255, 255, 255, 0.12); + background: rgba(255, 255, 255, 0.04); +`; + +export const LeaderboardHeader = styled.div` + display: flex; + justify-content: space-between; + align-items: center; +`; + +export const LeaderboardTitle = styled.div` + font-size: 11px; + color: rgba(255, 255, 255, 0.45); + letter-spacing: 1px; + text-transform: uppercase; +`; + +export const LeaderboardCount = styled.span` + font-size: 11px; + color: rgba(255, 255, 255, 0.6); + font-variant-numeric: tabular-nums; +`; + +export const LeaderboardList = styled.div` + display: flex; + flex-direction: column; + gap: 6px; +`; + +export const LeaderboardRow = styled.div` + display: grid; + grid-template-columns: 42px minmax(0, 1fr) auto; + align-items: center; + gap: 10px; + padding: 8px 10px; + border: 1px solid rgba(255, 255, 255, 0.12); + background: rgba(255, 255, 255, 0.04); +`; + +export const LeaderboardRank = styled.span` + color: rgba(255, 255, 255, 0.6); + font-variant-numeric: tabular-nums; + font-size: 13px; +`; + +export const LeaderboardName = styled.span` + color: rgba(255, 255, 255, 0.86); + font-size: 13px; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +`; + +export const LeaderboardScore = styled.span` + color: #ffffff; + font-weight: 700; + font-size: 13px; + font-variant-numeric: tabular-nums; +`; + export const OpacityControl = styled.div` width: 100%; display: flex; diff --git a/src/app/game/[slug]/page.tsx b/src/app/game/[slug]/page.tsx index 57ae6b2..312ee70 100644 --- a/src/app/game/[slug]/page.tsx +++ b/src/app/game/[slug]/page.tsx @@ -17,7 +17,7 @@ function GameInner() { // refs audioRef, videoRef, charRowRef, charRefs, // song - audioUrl, songTitle, songArtist, isVideo, isReady, loadingLrc, + audioUrl, songTitle, songArtist, chartId, isVideo, isReady, loadingLrc, // phase phase, countdown, // game state @@ -76,6 +76,7 @@ function GameInner() { loadingLrc={loadingLrc} songTitle={songTitle} songArtist={songArtist} + chartId={chartId} audioUrl={audioUrl} isVideo={isVideo} audioVolume={audioVolume} @@ -120,6 +121,7 @@ function GameInner() { accuracy={accuracy} wpm={wpm} songTitle={songTitle} + chartId={chartId} onPlayAgain={handleRestart} /> )} |
