refactoring game for separate folders for game boards and common logic for player
This commit is contained in:
306
src/providers/NakamaProvider.tsx
Normal file
306
src/providers/NakamaProvider.tsx
Normal file
@@ -0,0 +1,306 @@
|
||||
import {
|
||||
Client,
|
||||
Session,
|
||||
Socket,
|
||||
MatchmakerTicket,
|
||||
MatchData,
|
||||
MatchmakerMatched,
|
||||
} from "@heroiclabs/nakama-js";
|
||||
|
||||
import {
|
||||
ApiMatch,
|
||||
ApiLeaderboardRecordList,
|
||||
// @ts-ignore
|
||||
} from "@heroiclabs/nakama-js/dist/api.gen"
|
||||
|
||||
import React, { createContext, useContext, useState } from "react";
|
||||
|
||||
function getOrCreateDeviceId(): string {
|
||||
const key = "nakama.deviceId";
|
||||
let id = localStorage.getItem(key);
|
||||
if (!id) {
|
||||
id = crypto.randomUUID();
|
||||
localStorage.setItem(key, id);
|
||||
}
|
||||
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<void>;
|
||||
logout(): Promise<void>;
|
||||
joinMatchmaker(gameMetadata: GameMetadata): Promise<string>;
|
||||
joinMatch(matchId: string): Promise<void>;
|
||||
|
||||
sendMatchData(matchId: string, op: number, data: object): void;
|
||||
|
||||
onMatchData(cb: (msg: any) => void): void;
|
||||
getLeaderboardTop(): Promise<ApiLeaderboardRecordList>;
|
||||
listOpenMatches(): Promise<ApiMatch[]>;
|
||||
}
|
||||
|
||||
export const NakamaContext = createContext<NakamaContextType>(null!);
|
||||
|
||||
export function NakamaProvider({ children }: { children: React.ReactNode }) {
|
||||
console.log(
|
||||
"[Nakama] Initializing...",
|
||||
// import.meta.env.VITE_WS_SKEY,
|
||||
import.meta.env.VITE_WS_HOST,
|
||||
import.meta.env.VITE_WS_PORT,
|
||||
import.meta.env.VITE_WS_SSL === "true"
|
||||
);
|
||||
const [client] = useState(
|
||||
() => new Client(
|
||||
import.meta.env.VITE_WS_SKEY,
|
||||
import.meta.env.VITE_WS_HOST,
|
||||
import.meta.env.VITE_WS_PORT,
|
||||
import.meta.env.VITE_WS_SSL === "true"
|
||||
)
|
||||
);
|
||||
|
||||
const gameMetadataRef = React.useRef<GameMetadata | null>(null);
|
||||
const [session, setSession] = useState<Session | null>(null);
|
||||
const [socket, setSocket] = useState<Socket | null>(null);
|
||||
const [matchId, setMatchId] = useState<string | null>(null);
|
||||
const socketRef = React.useRef<Socket | null>(null);
|
||||
|
||||
async function autoLogin() {
|
||||
const deviceId = getOrCreateDeviceId();
|
||||
|
||||
try {
|
||||
return await client.authenticateDevice(
|
||||
deviceId,
|
||||
false
|
||||
);
|
||||
} catch (e) {
|
||||
// fallback: treat as new user
|
||||
localStorage.removeItem("registered");
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async function registerWithUsername(username: string) {
|
||||
const deviceId = getOrCreateDeviceId();
|
||||
|
||||
// create + set username
|
||||
const session = await client.authenticateDevice(
|
||||
deviceId,
|
||||
true,
|
||||
username
|
||||
);
|
||||
|
||||
// mark an account as registered
|
||||
localStorage.setItem("registered", "yes");
|
||||
localStorage.setItem("username", username);
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
async function getSession(username?: string) {
|
||||
const isRegistered = localStorage.getItem("registered") === "yes";
|
||||
if (!username && !isRegistered) {
|
||||
throw new Error("No username provided and not registered");
|
||||
}
|
||||
|
||||
let newSession;
|
||||
if (!isRegistered) {
|
||||
newSession = await registerWithUsername(username ?? "");
|
||||
} else {
|
||||
newSession = await autoLogin();
|
||||
}
|
||||
|
||||
return newSession;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------
|
||||
// LOGIN
|
||||
// ----------------------------------------------------
|
||||
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
|
||||
);
|
||||
await s.connect(newSession, true);
|
||||
setSocket(s);
|
||||
socketRef.current = s;
|
||||
|
||||
console.log("[Nakama] WebSocket connected");
|
||||
|
||||
// MATCHMAKER MATCHED CALLBACK
|
||||
s.onmatchmakermatched = async (matched: MatchmakerMatched) => {
|
||||
// 1) If match_id is empty → server rejected the group.
|
||||
if (!matched.match_id) {
|
||||
console.warn("[Nakama] Match rejected by server. Auto-requeueing...");
|
||||
|
||||
if (gameMetadataRef.current) {
|
||||
try {
|
||||
await joinMatchmaker(gameMetadataRef.current);
|
||||
} catch (e) {
|
||||
console.error("[Nakama] Requeue failed:", e);
|
||||
}
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// 2) Valid match: continue as usual.
|
||||
console.log("[Nakama] MATCHED:", matched);
|
||||
try {
|
||||
await s.joinMatch(matched.match_id);
|
||||
setMatchId(matched.match_id);
|
||||
console.log("[Nakama] Auto-joined match:", matched.match_id);
|
||||
} catch (err) {
|
||||
console.error("[Nakama] Failed to join match:", err);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
try {
|
||||
// 1) Disconnect socket if present
|
||||
if (socketRef.current) {
|
||||
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);
|
||||
|
||||
console.log("[Nakama] Clean logout completed");
|
||||
}
|
||||
|
||||
// ----------------------------------------------------
|
||||
// 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");
|
||||
|
||||
if (!game || game.trim() === "") {
|
||||
throw new Error("Matchmaking requires a game name");
|
||||
}
|
||||
|
||||
if (!mode || mode.trim() === "") {
|
||||
throw new Error("Matchmaking requires a mode");
|
||||
}
|
||||
console.log(`[Nakama] Matchmaking... game="${game}" mode="${mode}"`);
|
||||
const ticket: MatchmakerTicket = await socket.addMatchmaker(
|
||||
`*`, // query
|
||||
2, // min count
|
||||
2, // max count
|
||||
{ game, mode }
|
||||
);
|
||||
|
||||
gameMetadataRef.current = { game, mode };
|
||||
|
||||
return ticket.ticket;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------
|
||||
// EXPLICIT MATCH JOIN
|
||||
// ----------------------------------------------------
|
||||
async function joinMatch(id: string) {
|
||||
if (!socket) throw new Error("socket missing");
|
||||
await socket.joinMatch(id);
|
||||
setMatchId(id);
|
||||
console.log("[Nakama] Joined match", id);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------
|
||||
// MATCH STATE SEND
|
||||
// ----------------------------------------------------
|
||||
function sendMatchData(matchId: string, op: number, data: object) {
|
||||
if (!socket) return;
|
||||
console.log("[Nakama] Sending match state:", matchId, op, data);
|
||||
socket.sendMatchState(matchId, op, JSON.stringify(data));
|
||||
}
|
||||
|
||||
// ----------------------------------------------------
|
||||
// MATCH DATA LISTENER
|
||||
// ----------------------------------------------------
|
||||
function onMatchData(cb: (msg: any) => void) {
|
||||
if (!socket) return;
|
||||
|
||||
socket.onmatchdata = (m: MatchData) => {
|
||||
const decoded = JSON.parse(new TextDecoder().decode(m.data));
|
||||
cb({
|
||||
opCode: m.op_code,
|
||||
data: decoded,
|
||||
userId: m.presence?.user_id ?? null,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
async function getLeaderboardTop(): Promise<ApiLeaderboardRecordList> {
|
||||
if (!session) return [];
|
||||
|
||||
return await client.listLeaderboardRecords(
|
||||
session,
|
||||
"tictactoe",
|
||||
[],
|
||||
10 // top 10
|
||||
);
|
||||
}
|
||||
async function listOpenMatches(): Promise<ApiMatch[]> {
|
||||
if (!session) {
|
||||
console.warn("[Nakama] listOpenMatches called before login");
|
||||
return [];
|
||||
}
|
||||
|
||||
const result = await client.listMatches(session, 10);
|
||||
|
||||
console.log("[Nakama] Open matches:", result.matches);
|
||||
|
||||
return result.matches ?? [];
|
||||
}
|
||||
|
||||
return (
|
||||
<NakamaContext.Provider
|
||||
value={{
|
||||
client,
|
||||
session,
|
||||
socket,
|
||||
matchId,
|
||||
loginOrRegister,
|
||||
logout,
|
||||
joinMatchmaker,
|
||||
joinMatch,
|
||||
sendMatchData,
|
||||
onMatchData,
|
||||
getLeaderboardTop,
|
||||
listOpenMatches,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</NakamaContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------
|
||||
// USE HOOK
|
||||
// ---------------------------------------------
|
||||
export function useNakama(): NakamaContextType {
|
||||
const ctx = useContext(NakamaContext);
|
||||
if (!ctx) throw new Error("useNakama must be inside a NakamaProvider");
|
||||
return ctx;
|
||||
}
|
||||
Reference in New Issue
Block a user