diff --git a/src/App.tsx b/src/App.tsx index ff855ee..bc2edd2 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -2,7 +2,7 @@ import React, { useState, useEffect } from "react"; import { motion } from "framer-motion"; import { useNakama } from "./providers/NakamaProvider"; import Player from "./Player"; -import { PlayerModel } from "./models/player"; +import { PlayerModel } from "./interfaces/models"; import TicTacToeBoard from "./games/tictactoe/TicTacToeBoard"; import BattleShipBoard from "./games/battleship/BattleShipBoard"; diff --git a/src/games/battleship/props.ts b/src/games/battleship/props.ts new file mode 100644 index 0000000..0329f71 --- /dev/null +++ b/src/games/battleship/props.ts @@ -0,0 +1,15 @@ +import { + Board, + PlayerModel, +} from '../../interfaces/models' + + +export interface BattleShipBoardProps { + boards: Record; + turn: number; + winner: string | null; + gameOver: boolean | null; + players: PlayerModel[]; + myUserId: string | null; + metadata: Record; +} diff --git a/src/games/tictactoe/props.ts b/src/games/tictactoe/props.ts new file mode 100644 index 0000000..bad6ef9 --- /dev/null +++ b/src/games/tictactoe/props.ts @@ -0,0 +1,15 @@ +import { + Board, + PlayerModel, +} from '../../interfaces/models' + + +export interface TicTacToeBoardProps { + boards: Record; + turn: number; + winner: string | null; + gameOver: boolean | null; + players: PlayerModel[]; + myUserId: string | null; + onCellClick: (row: number, col: number) => void; +} diff --git a/src/interfaces/contexts.ts b/src/interfaces/contexts.ts new file mode 100644 index 0000000..f072ea4 --- /dev/null +++ b/src/interfaces/contexts.ts @@ -0,0 +1,38 @@ +import { + Client, + Session, + Socket, +} from "@heroiclabs/nakama-js"; + +import { + ApiMatch, + ApiLeaderboardRecordList, +// @ts-ignore +} from "@heroiclabs/nakama-js/dist/api.gen" + +import { + GameMetadata, + MatchDataMessage, +} from './models' + + +export interface NakamaContextType { + client: Client; + socket: Socket | null; + session: Session | null; + matchId: string | null; + + loginOrRegister(username?: string): Promise; + logout(): Promise; + + joinMatchmaker(gameMetadata: GameMetadata): Promise; + exitMatchmaker(gameMetadata: GameMetadata): Promise; + joinMatch(matchId: string): Promise; + + sendMatchData(matchId: string, op: number, data: object): void; + + onMatchData(cb: (msg: MatchDataMessage) => void): void; + + getLeaderboardTop(): Promise; + listOpenMatches(): Promise; +} diff --git a/src/interfaces/models.ts b/src/interfaces/models.ts new file mode 100644 index 0000000..5da685c --- /dev/null +++ b/src/interfaces/models.ts @@ -0,0 +1,36 @@ +export interface PlayerModel { + user_id: string; + username: string; + index: number; + metadata: Record; // e.g. { symbol: "X" } +} + +export interface MatchDataMessage { + opCode: number; + data: T; + userId: string | null; +} + +export interface Board { + grid: string[][]; +} + +export interface GameState { + boards: Record; + turn: number; + winner: string | null; + gameOver: boolean; + players: PlayerModel[]; + metadata: Record; +} + +export interface GameMetadata { + game: string; + mode: string; +} + +export interface MatchDataMessage { + opCode: number; + data: T; + userId: string | null; +} diff --git a/src/interfaces/props.ts b/src/interfaces/props.ts new file mode 100644 index 0000000..6ad6962 --- /dev/null +++ b/src/interfaces/props.ts @@ -0,0 +1,7 @@ +import { + MatchDataMessage, +} from './models' + +export interface PlayerProps { + onMatchDataCallback: (msg:MatchDataMessage) => void; +} diff --git a/src/interfaces/refs.ts b/src/interfaces/refs.ts new file mode 100644 index 0000000..ad086fa --- /dev/null +++ b/src/interfaces/refs.ts @@ -0,0 +1,15 @@ +import React from "react"; + +import { + Socket +} from "@heroiclabs/nakama-js"; + +import { + GameMetadata, +} from './models' + + +export interface NakamaRefs { + socketRef: React.RefObject; + gameMetadataRef: React.RefObject; +} \ No newline at end of file diff --git a/src/interfaces/states.ts b/src/interfaces/states.ts new file mode 100644 index 0000000..aa144e8 --- /dev/null +++ b/src/interfaces/states.ts @@ -0,0 +1,11 @@ +import { + Session, + Socket +} from "@heroiclabs/nakama-js"; + +export interface NakamaProviderState { + session: Session | null; + socket: Socket | null; + matchId: string | null; + matchmakerTicket: string | null; +} \ No newline at end of file diff --git a/src/models/player.ts b/src/models/player.ts deleted file mode 100644 index 17940cd..0000000 --- a/src/models/player.ts +++ /dev/null @@ -1,6 +0,0 @@ -export interface PlayerModel { - user_id: string; - username: string; - index: number; - metadata: Record; // e.g. { symbol: "X" } -} diff --git a/src/providers/NakamaProvider.tsx b/src/providers/NakamaProvider.tsx index a3e92c2..edff171 100644 --- a/src/providers/NakamaProvider.tsx +++ b/src/providers/NakamaProvider.tsx @@ -1,6 +1,12 @@ +import React, { + createContext, + useContext, + useState, + useRef +} from "react"; + import { Client, - Session, Socket, MatchmakerTicket, MatchData, @@ -13,7 +19,10 @@ import { // @ts-ignore } from "@heroiclabs/nakama-js/dist/api.gen" -import React, { createContext, useContext, useState } from "react"; +import { NakamaContextType } from "../interfaces/contexts"; +import { NakamaRefs } from "../interfaces/refs"; +import { NakamaProviderState } from "../interfaces/states"; +import { GameMetadata, MatchDataMessage } from "../interfaces/models"; function getOrCreateDeviceId(): string { const key = "nakama.deviceId"; @@ -25,30 +34,6 @@ function getOrCreateDeviceId(): string { return id; } -type GameMetadata = { - game: string; - mode: string; -}; - -export interface NakamaContextType { - client: Client; - socket: Socket | null; - session: Session | null; - matchId: string | null; - - loginOrRegister(username: string): Promise; - logout(): Promise; - joinMatchmaker(gameMetadata: GameMetadata): Promise; - exitMatchmaker(gameMetadata: GameMetadata): Promise; - joinMatch(matchId: string): Promise; - - sendMatchData(matchId: string, op: number, data: object): void; - - onMatchData(cb: (msg: any) => void): void; - getLeaderboardTop(): Promise; - listOpenMatches(): Promise; -} - export const NakamaContext = createContext(null!); export function NakamaProvider({ children }: { children: React.ReactNode }) { @@ -68,13 +53,32 @@ export function NakamaProvider({ children }: { children: React.ReactNode }) { ) ); - const gameMetadataRef = React.useRef(null); - const [session, setSession] = useState(null); - const [socket, setSocket] = useState(null); - const [matchmakerTicket, setMatchmakerTicket] = useState(null); - const [matchId, setMatchId] = useState(null); - const socketRef = React.useRef(null); + // -------------------------------------- + // INTERNAL STATE (React state) + // -------------------------------------- + const [internal, setInternal] = useState({ + session: null, + socket: null, + matchId: null, + matchmakerTicket: null, + }); + // -------------------------------------- + // INTERNAL REFS (non-reactive, stable) + // -------------------------------------- + const refs: NakamaRefs = { + socketRef: useRef(null), + gameMetadataRef: useRef(null), + }; + + // Helpers to update internal state cleanly + function updateState(values: Partial) { + setInternal(prev => ({ ...prev, ...values })); + } + + // --------------------------------------- + // LOGIN FLOW + // --------------------------------------- async function autoLogin() { const deviceId = getOrCreateDeviceId(); @@ -129,15 +133,14 @@ export function NakamaProvider({ children }: { children: React.ReactNode }) { async function loginOrRegister(username?: string) { // authenticate user const newSession = await getSession(username); - setSession(newSession); - const s = client.createSocket( - import.meta.env.VITE_WS_SSL === "true", - undefined - ); + updateState({ session: newSession }); + + const s = client.createSocket(import.meta.env.VITE_WS_SSL === "true"); await s.connect(newSession, true); - setSocket(s); - socketRef.current = s; + + updateState({ socket: s }); + refs.socketRef.current = s; console.log("[Nakama] WebSocket connected"); @@ -147,9 +150,9 @@ export function NakamaProvider({ children }: { children: React.ReactNode }) { if (!matched.match_id) { console.warn("[Nakama] Match rejected by server. Auto-requeueing..."); - if (gameMetadataRef.current) { + if (refs.gameMetadataRef.current) { try { - await joinMatchmaker(gameMetadataRef.current); + await joinMatchmaker(refs.gameMetadataRef.current); } catch (e) { console.error("[Nakama] Requeue failed:", e); } @@ -162,7 +165,8 @@ export function NakamaProvider({ children }: { children: React.ReactNode }) { console.log("[Nakama] MATCHED:", matched); try { await s.joinMatch(matched.match_id); - setMatchId(matched.match_id); + updateState({ matchId: matched.match_id }); + console.log("[Nakama] Auto-joined match:", matched.match_id); } catch (err) { console.error("[Nakama] Failed to join match:", err); @@ -170,21 +174,27 @@ export function NakamaProvider({ children }: { children: React.ReactNode }) { }; } + // --------------------------------------- + // LOGOUT + // --------------------------------------- async function logout() { try { - // 1) Disconnect socket if present - if (socketRef.current) { - socketRef.current.disconnect(true); + if (refs.socketRef.current) { + refs.socketRef.current.disconnect(true); console.log("[Nakama] WebSocket disconnected"); } } catch (err) { console.warn("[Nakama] Error while disconnecting socket:", err); } - // 2) Clear state - setSocket(null); - socketRef.current = null; - setSession(null); + updateState({ + session: null, + socket: null, + matchId: null, + matchmakerTicket: null, + }); + + refs.socketRef.current = null; console.log("[Nakama] Clean logout completed"); } @@ -193,11 +203,10 @@ export function NakamaProvider({ children }: { children: React.ReactNode }) { // MATCHMAKING // ---------------------------------------------------- async function joinMatchmaker(gameMetadata: GameMetadata) { - const socket = socketRef.current; - const game = gameMetadata.game; - const mode = gameMetadata.mode; - if (!socket) throw new Error("socket missing"); + const socket = refs.socketRef.current; + if (!socket) throw new Error("Socket missing"); + const { game, mode } = gameMetadata; if (!game || game.trim() === "") { throw new Error("Matchmaking requires a game name"); } @@ -213,30 +222,35 @@ export function NakamaProvider({ children }: { children: React.ReactNode }) { { game, mode } ); - gameMetadataRef.current = { game, mode }; - setMatchmakerTicket(ticket.ticket); + refs.gameMetadataRef.current = { game, mode }; + + updateState({ matchmakerTicket: ticket.ticket }); return ticket.ticket; } - async function exitMatchmaker(gameMetadata: GameMetadata) { - const socket = socketRef.current; - const game = gameMetadata.game; - const mode = gameMetadata.mode; - if (!socket) throw new Error("socket missing"); + async function exitMatchmaker() { + const socket = refs.socketRef.current; + const { matchmakerTicket } = internal; - console.log(`[Nakama] Exiting Matchmaking... game="${game}" mode="${mode}"`); - if (matchmakerTicket) await socket.removeMatchmaker(matchmakerTicket); - setMatchmakerTicket(null); + if (!socket) throw new Error("Socket missing"); + + if (matchmakerTicket) { + await socket.removeMatchmaker(matchmakerTicket); + } + + updateState({ matchmakerTicket: null }); } // ---------------------------------------------------- // EXPLICIT MATCH JOIN // ---------------------------------------------------- async function joinMatch(id: string) { - if (!socket) throw new Error("socket missing"); - await socket.joinMatch(id); - setMatchId(id); + if (!internal.socket) throw new Error("Socket missing"); + + await internal.socket.joinMatch(id); + + updateState({ matchId: id }); console.log("[Nakama] Joined match", id); } @@ -244,18 +258,19 @@ export function NakamaProvider({ children }: { children: React.ReactNode }) { // MATCH STATE SEND // ---------------------------------------------------- function sendMatchData(matchId: string, op: number, data: object) { - if (!socket) return; + if (!internal.socket) return; + console.log("[Nakama] Sending match state:", matchId, op, data); - socket.sendMatchState(matchId, op, JSON.stringify(data)); + internal.socket.sendMatchState(matchId, op, JSON.stringify(data)); } // ---------------------------------------------------- // MATCH DATA LISTENER // ---------------------------------------------------- - function onMatchData(cb: (msg: any) => void) { - if (!socket) return; + function onMatchData(cb: (msg: MatchDataMessage) => void) { + if (!internal.socket) return; - socket.onmatchdata = (m: MatchData) => { + internal.socket.onmatchdata = (m: MatchData) => { const decoded = JSON.parse(new TextDecoder().decode(m.data)); cb({ opCode: m.op_code, @@ -265,36 +280,43 @@ export function NakamaProvider({ children }: { children: React.ReactNode }) { }; } + // --------------------------------------- + // LEADERBOARD + LIST MATCHES + // --------------------------------------- async function getLeaderboardTop(): Promise { - if (!session) return []; + if (!internal.session) return []; return await client.listLeaderboardRecords( - session, + internal.session, "tictactoe", [], 10 // top 10 ); } + async function listOpenMatches(): Promise { - if (!session) { + if (!internal.session) { console.warn("[Nakama] listOpenMatches called before login"); return []; } - const result = await client.listMatches(session, 10); - + const result = await client.listMatches(internal.session, 10); console.log("[Nakama] Open matches:", result.matches); return result.matches ?? []; } + // --------------------------------------- + // PROVIDER VALUE + // --------------------------------------- return (