diff --git a/src/tictactoe/Leaderboard.tsx b/src/tictactoe/Leaderboard.tsx new file mode 100644 index 0000000..ef0186d --- /dev/null +++ b/src/tictactoe/Leaderboard.tsx @@ -0,0 +1,68 @@ +import React from "react"; +import { useRef, useEffect, useState } from "react"; +import { + ApiLeaderboardRecordList, +// @ts-ignore +} from "@heroiclabs/nakama-js/dist/api.gen" +import { useNakama } from "./providers/NakamaProvider"; + +export default function Leaderboard({ + intervalMs = 10000, +}: { + intervalMs?: number; +}) { + const { + getLeaderboardTop + } = useNakama() + const [records, setRecords] = useState([]); + const timerRef = useRef(null); + useEffect(() => { + let mounted = true; + + async function load() { + try { + const data = await getLeaderboardTop(); + if (mounted) setRecords(data); + } catch (err) { + console.error("Leaderboard fetch failed:", err); + } + } + + // First load immediately + load(); + + // Start interval polling + timerRef.current = window.setInterval(load, intervalMs); + + return () => { + mounted = false; + if (timerRef.current) clearInterval(timerRef.current); + }; + }, [getLeaderboardTop, intervalMs]); + + // @ts-ignore + return ( +
+

🏆 Leaderboard – Wins

+ + {records?.records?.length === 0 && ( +
No entries yet.
+ )} + + {records?.records?.map(( + r: { + owner_id: number, + username: string, + score: number + }, + i: number + ) => ( +
+
{i + 1}
+
{r.username || r.owner_id}
+
{r.score}
+
+ ))} +
+ ); +} diff --git a/src/tictactoe/TicTacToe.tsx b/src/tictactoe/TicTacToe.tsx index 353a6ce..505cad3 100644 --- a/src/tictactoe/TicTacToe.tsx +++ b/src/tictactoe/TicTacToe.tsx @@ -1,7 +1,8 @@ import { useState, useEffect } from "react"; import { useNakama } from "./providers/NakamaProvider"; -import { Match } from "@heroiclabs/nakama-js"; import Board from "./Board"; +import Leaderboard from "./Leaderboard"; +// import { Match } from "@heroiclabs/nakama-js"; // import MatchList from "./MatchList"; export default function TicTacToe() { @@ -143,6 +144,7 @@ export default function TicTacToe() { {/*/!* List open matches *!/*/} {/**/} + )} diff --git a/src/tictactoe/providers/NakamaProvider.tsx b/src/tictactoe/providers/NakamaProvider.tsx index 5531361..3cf8cda 100644 --- a/src/tictactoe/providers/NakamaProvider.tsx +++ b/src/tictactoe/providers/NakamaProvider.tsx @@ -6,8 +6,12 @@ import { MatchData, MatchmakerMatched, } from "@heroiclabs/nakama-js"; + +import { + ApiMatch, + ApiLeaderboardRecordList, // @ts-ignore -import { ApiMatch } from "@heroiclabs/nakama-js/dist/api.gen" +} from "@heroiclabs/nakama-js/dist/api.gen" import React, { createContext, useContext, useState } from "react"; @@ -34,6 +38,7 @@ export interface NakamaContextType { sendMatchData(matchId: string, op: number, data: object): void; onMatchData(cb: (msg: any) => void): void; + getLeaderboardTop(): Promise; listOpenMatches(): Promise; } @@ -151,6 +156,16 @@ export function NakamaProvider({ children }: { children: React.ReactNode }) { }; } + async function getLeaderboardTop(): Promise { + if (!session) return []; + + return await client.listLeaderboardRecords( + session, + "tictactoe", + [], + 10 // top 10 + ); + } async function listOpenMatches(): Promise { if (!session) { console.warn("[Nakama] listOpenMatches called before login"); @@ -176,6 +191,7 @@ export function NakamaProvider({ children }: { children: React.ReactNode }) { joinMatch, sendMatchData, onMatchData, + getLeaderboardTop, listOpenMatches, }} > diff --git a/src/tictactoe/styles.css b/src/tictactoe/styles.css index 748195a..5a0fd1c 100644 --- a/src/tictactoe/styles.css +++ b/src/tictactoe/styles.css @@ -1,14 +1,16 @@ body { font-family: sans-serif; - background: #f4f4f4; + background: #eaeef3; display: flex; justify-content: center; padding-top: 50px; margin: 0; + color: #222; } .game-container { text-align: center; + padding: 20px; } .board { @@ -22,32 +24,111 @@ body { .square { background: white; - border: 2px solid #333; - font-size: 2rem; + border-radius: 10px; + border: 2px solid #444; + font-size: 2.2rem; font-weight: bold; cursor: pointer; height: 100px; width: 100px; + display: flex; + align-items: center; + justify-content: center; + transition: 0.15s ease; + box-shadow: 0 2px 4px rgba(0,0,0,0.15); } .square:hover { - background: #eee; + background: #f1f1f1; + transform: scale(1.04); } .status { - font-size: 1.4rem; - margin-bottom: 10px; + font-size: 1.6rem; + margin-bottom: 15px; + font-weight: bold; + color: #222; } .reset-btn { - padding: 10px 20px; - background: #333; + padding: 12px 26px; + background: #3558d8; color: white; border: none; cursor: pointer; font-size: 1rem; + border-radius: 8px; + transition: 0.2s ease; + box-shadow: 0 2px 4px rgba(0,0,0,0.25); } .reset-btn:hover { - background: #555; + background: #2449c7; + transform: translateY(-2px); } + +.leaderboard { + width: 300px; + margin: 25px auto; + padding: 15px 20px; + background: white; + border-radius: 12px; + box-shadow: 0 2px 10px rgba(0,0,0,0.1); + font-family: sans-serif; +} + +/* Leaderboard title */ +.leaderboard-title { + margin: 0 0 15px 0; + font-size: 1.4rem; + font-weight: 700; + text-align: center; + color: #222; +} + +/* Each row */ +.leaderboard-row { + display: flex; + align-items: center; + padding: 10px 8px; + background: #f7f9fc; + border-radius: 8px; + margin-bottom: 8px; + box-shadow: 0 1px 2px rgba(0,0,0,0.08); +} + +/* Empty state */ +.leaderboard-empty { + padding: 12px 0; + text-align: center; + color: #777; + font-size: 1rem; +} + +/* Rank number */ +.leaderboard-rank { + width: 28px; + font-size: 1.2rem; + font-weight: 700; + color: #3558d8; +} + +/* Username */ +.leaderboard-name { + flex: 1; + margin-left: 10px; + font-weight: 500; + color: #333; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* Score value */ +.leaderboard-score { + width: 40px; + text-align: right; + font-weight: 700; + color: #111; +} +