diff --git a/src/tictactoe/TicTacToe.tsx b/src/tictactoe/TicTacToe.tsx index 3396a7f..bab7d52 100644 --- a/src/tictactoe/TicTacToe.tsx +++ b/src/tictactoe/TicTacToe.tsx @@ -19,8 +19,8 @@ export default function TicTacToe() { joinMatchmaker, joinMatch, onMatchData, - socket, - sendMatchData + onMatchmakerMatched, + sendMatchData, } = useNakama(); // ------------------------------------------ @@ -42,15 +42,14 @@ export default function TicTacToe() { } }); - // When matchmaker finds a match - socket!.onmatchmakermatched = async (matched) => { + onMatchmakerMatched(async (matched) => { console.log("Matched:", matched); const fullMatchId = matched.match_id; setMatchId(fullMatchId); await joinMatch(fullMatchId); - }; + }) } // ------------------------------------------ @@ -77,7 +76,7 @@ export default function TicTacToe() { {!matchId && ( <> setUsername(e.target.value)} /> diff --git a/src/tictactoe/providers/NakamaProvider.tsx b/src/tictactoe/providers/NakamaProvider.tsx index 2fac9f2..fca4671 100644 --- a/src/tictactoe/providers/NakamaProvider.tsx +++ b/src/tictactoe/providers/NakamaProvider.tsx @@ -6,90 +6,186 @@ import { MatchmakerTicket, Match, MatchData, + MatchmakerMatched, } from "@heroiclabs/nakama-js"; -import React, { createContext, useContext, useState } from "react"; +import React, { + createContext, + useContext, + useEffect, + useRef, + useState, +} from "react"; + +// --------------------------------------------- +// UNIQUE DEVICE ID FOR BROWSER +// --------------------------------------------- +function getOrCreateDeviceId(): string { + const key = "nakama.deviceId"; + let id = localStorage.getItem(key); + + if (!id) { + id = crypto.randomUUID(); + localStorage.setItem(key, id); + } + + return id; +} + +// --------------------------------------------- +// CONTEXT TYPE +// --------------------------------------------- export interface NakamaContextType { client: Client; socket: Socket | null; session: Session | null; loginOrRegister: (username: string) => Promise; + joinMatchmaker: (mode: string) => Promise; leaveMatchmaker: (ticket: string) => Promise; + joinMatch: (matchId: string) => Promise; + sendMatchData: (matchId: string, opCode: number, data: object) => void; + onMatchData: ( cb: (msg: { opCode: number; data: any; userId: string | null }) => void ) => void; + + onMatchmakerMatched: (cb: (result: MatchmakerMatched) => void) => void; } export const NakamaContext = createContext(null); +// --------------------------------------------- +// PROVIDER IMPLEMENTATION +// --------------------------------------------- export function NakamaProvider({ children }: { children: React.ReactNode }) { - const [client] = useState( - () => new Client( - "defaultkey", - "127.0.0.1", - "7350" - ) + // Use "defaultkey", "127.0.0.1", "7350" — match your given client + const [client] = useState( + () => + new Client( + "defaultkey", + "127.0.0.1", + "7350", + false, // use SSL? + ) ); - const [socket, setSocket] = useState(null); const [session, setSession] = useState(null); + const [socket, setSocket] = useState(null); - // ----------------------- - // LOGIN / REGISTER - // ----------------------- + // --------------------------------------------- + // CALLBACK REGISTRIES (React-safe) + // --------------------------------------------- + const matchDataCallbacks = useRef< + ((msg: { opCode: number; data: any; userId: string | null }) => void)[] + >([]); + + const matchmakerMatchedCallbacks = useRef< + ((result: MatchmakerMatched) => void)[] + >([]); + + // Register match data listener + function onMatchData( + cb: (msg: { opCode: number; data: any; userId: string | null }) => void + ) { + matchDataCallbacks.current.push(cb); + } + + // Register matchmaker matched listener + function onMatchmakerMatched(cb: (result: MatchmakerMatched) => void) { + matchmakerMatchedCallbacks.current.push(cb); + } + + // --------------------------------------------- + // SETUP SOCKET EVENT LISTENERS WHEN SOCKET IS READY + // --------------------------------------------- + useEffect(() => { + if (!socket) return; + + // MATCH DATA + socket.onmatchdata = (msg: MatchData) => { + const raw = msg.data; + let decoded: any; + + try { + decoded = JSON.parse(new TextDecoder().decode(raw)); + } catch { + decoded = raw; + } + + matchDataCallbacks.current.forEach((cb) => + cb({ + opCode: msg.op_code, + data: decoded, + userId: msg.presence?.user_id || null, + }) + ); + }; + + // MATCHMAKER MATCHED + socket.onmatchmakermatched = (result) => { + matchmakerMatchedCallbacks.current.forEach((cb) => cb(result)); + }; + + return () => { + // Cleanup (not strictly needed, but clean React practice) + socket.onmatchdata = undefined as any; + socket.onmatchmakermatched = undefined as any; + }; + }, [socket]); + + // --------------------------------------------- + // LOGIN OR REGISTER + // --------------------------------------------- async function loginOrRegister(username: string): Promise { try { - const s = await client.authenticateDevice(username, true); + const deviceId = getOrCreateDeviceId(); + + // authenticateDevice(id, create, username) + const s = await client.authenticateDevice(deviceId, true, username); setSession(s); - const newSocket = new DefaultSocket( - client.host, - client.port, - false, // useSSL - false // verbose - ); - + const newSocket = client.createSocket(false, false); await newSocket.connect(s, true); setSocket(newSocket); - console.log("[Nakama] Connected WS"); + console.log("[Nakama] Connected via WebSocket"); return s; } catch (err) { - console.error("[Nakama] Login error", err); + console.error("[Nakama] Login error:", err); throw err; } } - // ----------------------- + // --------------------------------------------- // MATCHMAKING - // ----------------------- + // --------------------------------------------- async function joinMatchmaker(mode: string): Promise { if (!socket) throw new Error("Socket not ready."); - const ticket = await socket.addMatchmaker( - `+mode:"${mode}"`, // query - 2, // min players - 2, // max players - { mode } // string properties + const ticket: MatchmakerTicket = await socket.addMatchmaker( + `+mode:"${mode}"`, // query + 2, // min count + 2, // max count + { mode } // stringProperties ); - console.log("[Nakama] Matchmaker ticket:", ticket.ticket); + console.log("[Nakama] Ticket:", ticket.ticket); return ticket.ticket; } - async function leaveMatchmaker(ticket: string): Promise { + async function leaveMatchmaker(ticket: string) { if (socket) { await socket.removeMatchmaker(ticket); } } - // ----------------------- + // --------------------------------------------- // JOIN MATCH - // ----------------------- + // --------------------------------------------- async function joinMatch(matchId: string): Promise { if (!socket) throw new Error("Socket not connected."); const match = await socket.joinMatch(matchId); @@ -97,41 +193,17 @@ export function NakamaProvider({ children }: { children: React.ReactNode }) { return match; } - // ----------------------- + // --------------------------------------------- // SEND MATCH DATA - // ----------------------- - function sendMatchData(matchId: string, opCode: number, data: object = {}): void { + // --------------------------------------------- + function sendMatchData(matchId: string, opCode: number, data: object) { if (!socket) return; socket.sendMatchState(matchId, opCode, JSON.stringify(data)); } - // ----------------------- - // LISTENERS - // ----------------------- - function onMatchData( - cb: (msg: { opCode: number; data: any; userId: string | null }) => void - ) { - if (!socket) return; - - socket.onmatchdata = (msg: MatchData) => { - const raw = msg.data; - let decoded: any = null; - - try { - decoded = JSON.parse(new TextDecoder().decode(raw)); - } catch { - decoded = raw; // fallback to raw binary - } - - cb({ - opCode: msg.op_code, - data: decoded, - userId: msg.presence?.user_id ?? null, - }); - }; - } - - + // --------------------------------------------- + // PROVIDER VALUE + // --------------------------------------------- return ( {children} @@ -151,10 +224,11 @@ export function NakamaProvider({ children }: { children: React.ReactNode }) { ); } +// --------------------------------------------- +// USE HOOK +// --------------------------------------------- export function useNakama(): NakamaContextType { const ctx = useContext(NakamaContext); - if (!ctx) { - throw new Error("useNakama must be used inside a NakamaProvider"); - } + if (!ctx) throw new Error("useNakama must be inside a NakamaProvider"); return ctx; }