Compare commits
9 Commits
multi-game
...
code-restr
| Author | SHA1 | Date | |
|---|---|---|---|
| b00519347a | |||
| 8436cdbcdd | |||
| 135fdd332d | |||
| 8dc41fca2c | |||
| fc7cb8efb6 | |||
| 06bdc92190 | |||
| 650d7b7ed6 | |||
| 68c2e3a8d9 | |||
| ab9dd42689 |
115
src/App.tsx
115
src/App.tsx
@@ -1,57 +1,72 @@
|
||||
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 TicTacToeBoard from "./games/tictactoe/TicTacToeBoard";
|
||||
import BattleShipBoard from "./games/battleship/BattleShipBoard";
|
||||
import Player from "./Player";
|
||||
import TicTacToeGame from "./games/tictactoe/TicTacToeGame";
|
||||
import { TicTacToeGameProps } from "./games/tictactoe/props";
|
||||
import BattleshipGame from "./games/battleship/BattleshipGame"
|
||||
import { BattleshipGameProps } from "./games/battleship/props";
|
||||
|
||||
import { GameState } from "./interfaces/states";
|
||||
import { GameProps } from "./interfaces/props";
|
||||
|
||||
const INITIAL_GAME_STATE: GameState = {
|
||||
boards: {},
|
||||
turn: 0,
|
||||
winner: null,
|
||||
gameOver: false,
|
||||
players: [],
|
||||
metadata: {},
|
||||
};
|
||||
|
||||
export default function App() {
|
||||
// setting up a 2D game boards
|
||||
const [boards, setBoards] = useState<Record<string, { grid: string[][] }>>({});
|
||||
const [turn, setTurn] = useState<number>(0);
|
||||
const [winner, setWinner] = useState<string | null>(null);
|
||||
const [gameOver, setGameOver] = useState<boolean | null>(null);
|
||||
const [players, setPlayers] = useState<PlayerModel[]>([]);
|
||||
const [metadata, setMetadata] = useState<Record<string, any>>({});
|
||||
// unified game state
|
||||
const [game, setGame] = useState<GameState>(INITIAL_GAME_STATE);
|
||||
const { onMatchData, matchId, session } = useNakama();
|
||||
|
||||
const { sendMatchData, onMatchData, matchId, session } = useNakama();
|
||||
const commonProps: GameProps = {
|
||||
boards: game.boards,
|
||||
turn: game.turn,
|
||||
winner: game.winner,
|
||||
gameOver: game.gameOver,
|
||||
players: game.players,
|
||||
myUserId: session?.user_id ?? null,
|
||||
};
|
||||
const ticTacToeProps: TicTacToeGameProps = {
|
||||
...commonProps,
|
||||
};
|
||||
const battleshipProps: BattleshipGameProps = {
|
||||
...commonProps,
|
||||
metadata: game.metadata,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------
|
||||
// RENDER GAME BOARD
|
||||
// ---------------------------------------------------
|
||||
function renderGameBoard() {
|
||||
if (!matchId || !metadata?.game) return null;
|
||||
if (!matchId || !game.metadata?.game) return null;
|
||||
|
||||
switch (metadata.game) {
|
||||
switch (game.metadata.game) {
|
||||
case "tictactoe":
|
||||
return (
|
||||
<TicTacToeBoard
|
||||
boards={boards}
|
||||
turn={turn}
|
||||
winner={winner}
|
||||
gameOver={gameOver}
|
||||
players={players}
|
||||
myUserId={session?.user_id ?? null}
|
||||
onCellClick={handleCellClick}
|
||||
<TicTacToeGame
|
||||
{...ticTacToeProps}
|
||||
/>
|
||||
);
|
||||
|
||||
case "battleship":
|
||||
return (
|
||||
<BattleShipBoard
|
||||
boards={boards}
|
||||
turn={turn}
|
||||
winner={winner}
|
||||
gameOver={gameOver}
|
||||
players={players}
|
||||
myUserId={session?.user_id ?? null}
|
||||
metadata={metadata}
|
||||
<BattleshipGame
|
||||
{...battleshipProps}
|
||||
/>
|
||||
);
|
||||
|
||||
default:
|
||||
return <div>Unknown game: {metadata.game}</div>;
|
||||
return <div>Unknown game: {game.metadata.game}</div>;
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------
|
||||
// MATCH DATA CALLBACK (from Player component)
|
||||
// ------------------------------------------
|
||||
@@ -62,23 +77,21 @@ export default function App() {
|
||||
const state = msg.data;
|
||||
console.log("Match state:", state);
|
||||
|
||||
setBoards(state.boards);
|
||||
setTurn(state.turn);
|
||||
setGameOver(state.game_over);
|
||||
if (state.winner >= 0) {
|
||||
setWinner(state.players[state.winner].username);
|
||||
// } else if (state.game_over) {
|
||||
// // Game ended but winner = -1 → draw
|
||||
// setWinner("draw");
|
||||
} else {
|
||||
// Ongoing game, no winner
|
||||
setWinner(null);
|
||||
}
|
||||
setPlayers(state.players || []);
|
||||
setMetadata(state.metadata || {});
|
||||
setGame({
|
||||
boards: state.boards,
|
||||
turn: state.turn,
|
||||
gameOver: state.game_over,
|
||||
winner:
|
||||
state.winner >= 0 ? state.players[state.winner].username : null,
|
||||
players: state.players ?? [],
|
||||
metadata: state.metadata ?? {},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------
|
||||
// EFFECTS
|
||||
// ---------------------------------------------------
|
||||
useEffect(() => {
|
||||
document.body.style.overflow = "hidden";
|
||||
return () => {
|
||||
@@ -90,15 +103,9 @@ export default function App() {
|
||||
onMatchData(onMatchDataCallback);
|
||||
}, [onMatchData]);
|
||||
|
||||
// ------------------------------------------
|
||||
// SEND A MOVE
|
||||
// ------------------------------------------
|
||||
function handleCellClick(row: number, col: number) {
|
||||
if (!matchId) return;
|
||||
|
||||
sendMatchData(matchId, 1, {data: {row, col}});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------
|
||||
// UI LAYOUT
|
||||
// ---------------------------------------------------
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { useNakama } from "./providers/NakamaProvider";
|
||||
import { PlayerProps } from "./interfaces/props";
|
||||
|
||||
export default function Player({
|
||||
onMatchDataCallback,
|
||||
}: {
|
||||
onMatchDataCallback: (msg: any) => void;
|
||||
}) {
|
||||
}: PlayerProps) {
|
||||
const {
|
||||
session,
|
||||
matchId,
|
||||
|
||||
@@ -1,20 +1,15 @@
|
||||
import React, { useMemo } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { useNakama } from "../../providers/NakamaProvider";
|
||||
import { PlayerModel } from "../../models/player";
|
||||
|
||||
import PlacementGrid from "./placement/PlacementGrid";
|
||||
import ShotGrid from "./battle/ShotGrid";
|
||||
|
||||
interface BattleBoardProps {
|
||||
boards: Record<string, { grid: string[][] }>;
|
||||
players: PlayerModel[];
|
||||
myUserId: string | null;
|
||||
turn: number;
|
||||
winner: string | null;
|
||||
gameOver: boolean | null;
|
||||
metadata: Record<string, any>;
|
||||
}
|
||||
import { BattleshipGameProps } from "./props";
|
||||
import { BattleshipPayload } from "./models";
|
||||
import {
|
||||
placePayload,
|
||||
shootPayload,
|
||||
} from "./utils";
|
||||
|
||||
const Fleet: Record<string, number> = {
|
||||
carrier: 5,
|
||||
@@ -25,7 +20,7 @@ const Fleet: Record<string, number> = {
|
||||
};
|
||||
const FLEET_ORDER = ["carrier", "battleship", "cruiser", "submarine", "destroyer"];
|
||||
|
||||
export default function BattleShipBoard({
|
||||
export default function BattleshipGame({
|
||||
boards,
|
||||
players,
|
||||
myUserId,
|
||||
@@ -33,7 +28,7 @@ export default function BattleShipBoard({
|
||||
winner,
|
||||
gameOver,
|
||||
metadata,
|
||||
}: BattleBoardProps) {
|
||||
}: BattleshipGameProps) {
|
||||
const { sendMatchData, matchId } = useNakama();
|
||||
|
||||
const myIndex = players.findIndex((p) => p.user_id === myUserId);
|
||||
@@ -50,28 +45,10 @@ export default function BattleShipBoard({
|
||||
const nextShip = FLEET_ORDER[placed] || null;
|
||||
const nextShipSize = nextShip ? Fleet[nextShip] : null;
|
||||
|
||||
// ------------------- PLACE SHIP -------------------
|
||||
function handlePlace(ship: string, r: number, c: number, dir: "h" | "v") {
|
||||
sendMatchData(matchId!, 1, {
|
||||
action: "place",
|
||||
data: {
|
||||
ship: ship,
|
||||
row: r,
|
||||
col: c,
|
||||
dir,
|
||||
}
|
||||
});
|
||||
}
|
||||
function handleMove(matchPayload: BattleshipPayload) {
|
||||
if (!matchId) return;
|
||||
|
||||
// ------------------- SHOOT -------------------
|
||||
function handleShoot(r: number, c: number) {
|
||||
sendMatchData(matchId!, 1, {
|
||||
action: "shoot",
|
||||
data: {
|
||||
row: r,
|
||||
col: c,
|
||||
}
|
||||
});
|
||||
sendMatchData(matchId!, 1, matchPayload);
|
||||
}
|
||||
|
||||
// ------------------- STATUS LABEL -------------------
|
||||
@@ -100,7 +77,11 @@ export default function BattleShipBoard({
|
||||
shipBoard={myShips}
|
||||
shipName={nextShip}
|
||||
shipSize={nextShipSize}
|
||||
onPlace={handlePlace}
|
||||
onPlace={(
|
||||
s,r,c,d
|
||||
) => handleMove(
|
||||
placePayload(s,r,c,d)
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -113,7 +94,11 @@ export default function BattleShipBoard({
|
||||
grid={myShots}
|
||||
isMyTurn={isMyTurn}
|
||||
gameOver={!!gameOver}
|
||||
onShoot={handleShoot}
|
||||
onShoot={(
|
||||
r,c
|
||||
) => handleMove(
|
||||
shootPayload(r,c)
|
||||
)}
|
||||
/>
|
||||
|
||||
<h3 style={{ marginTop: "18px" }}>Your Ships</h3>
|
||||
15
src/games/battleship/models.ts
Normal file
15
src/games/battleship/models.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import {
|
||||
MatchDataModel,
|
||||
} from '../../interfaces/models'
|
||||
|
||||
export interface BattleshipPayload {
|
||||
action: "place" | "shoot"; // extend as needed
|
||||
data: {
|
||||
ship?: string; // only for placement
|
||||
row: number;
|
||||
col: number;
|
||||
dir?: "h" | "v";
|
||||
};
|
||||
}
|
||||
|
||||
export type BattleshipMatchDataModel = MatchDataModel<BattleshipPayload>;
|
||||
8
src/games/battleship/props.ts
Normal file
8
src/games/battleship/props.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import {
|
||||
GameProps,
|
||||
} from '../../interfaces/props'
|
||||
|
||||
|
||||
export interface BattleshipGameProps extends GameProps {
|
||||
metadata: Record<string, any>;
|
||||
}
|
||||
22
src/games/battleship/utils.ts
Normal file
22
src/games/battleship/utils.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import {
|
||||
BattleshipPayload
|
||||
} from "./models";
|
||||
|
||||
export function placePayload(
|
||||
ship: string,
|
||||
row: number,
|
||||
col: number,
|
||||
dir: "h" | "v"
|
||||
): BattleshipPayload {
|
||||
return {
|
||||
action: "place",
|
||||
data: { ship, row, col, dir }
|
||||
};
|
||||
}
|
||||
|
||||
export function shootPayload(row: number, col: number): BattleshipPayload {
|
||||
return {
|
||||
action: "shoot",
|
||||
data: { row, col }
|
||||
};
|
||||
}
|
||||
@@ -2,32 +2,23 @@ import React, { useEffect, useState } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { useNakama } from "../../providers/NakamaProvider";
|
||||
import getHaiku from "../../utils/haikus";
|
||||
import { PlayerModel } from "../../models/player";
|
||||
|
||||
interface BoardProps {
|
||||
boards: Record<string, { grid: string[][] }>;
|
||||
turn: number;
|
||||
winner: string | null;
|
||||
gameOver: boolean | null;
|
||||
players: PlayerModel[];
|
||||
myUserId: string | null;
|
||||
onCellClick: (row: number, col: number) => void;
|
||||
}
|
||||
import { TicTacToeGameProps } from "./props";
|
||||
import { TicTacToePayload } from "./models";
|
||||
import { movePayload } from "./utils";
|
||||
|
||||
export default function TicTacToeBoard({
|
||||
export default function TicTacToeGame({
|
||||
boards,
|
||||
turn,
|
||||
winner,
|
||||
gameOver,
|
||||
players,
|
||||
myUserId,
|
||||
onCellClick,
|
||||
}: BoardProps) {
|
||||
}: TicTacToeGameProps) {
|
||||
const { sendMatchData, matchId } = useNakama();
|
||||
|
||||
const myIndex = players.findIndex(p => p.user_id === myUserId);
|
||||
const gameReady = players.length === 2;
|
||||
const {
|
||||
matchId
|
||||
} = useNakama();
|
||||
|
||||
const mySymbol =
|
||||
myIndex !== null && players[myIndex]
|
||||
@@ -76,6 +67,12 @@ export default function TicTacToeBoard({
|
||||
return () => clearTimeout(timer);
|
||||
}, [haikuIndex]);
|
||||
|
||||
function handleMove(matchPayload: TicTacToePayload) {
|
||||
if (!matchId) return;
|
||||
|
||||
sendMatchData(matchId!, 1, matchPayload);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{matchId && (
|
||||
@@ -141,7 +138,9 @@ export default function TicTacToeBoard({
|
||||
: {}
|
||||
}
|
||||
whileTap={!disabled ? { scale: 0.85 } : {}}
|
||||
onClick={() => !disabled && onCellClick(rIdx, cIdx)}
|
||||
onClick={() => !disabled && handleMove(
|
||||
movePayload(rIdx, cIdx)
|
||||
)}
|
||||
style={{
|
||||
width: "80px",
|
||||
height: "80px",
|
||||
11
src/games/tictactoe/models.ts
Normal file
11
src/games/tictactoe/models.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import {
|
||||
MatchDataModel,
|
||||
} from '../../interfaces/models'
|
||||
|
||||
export interface TicTacToePayload {
|
||||
data: {
|
||||
row: number;
|
||||
col: number;
|
||||
};
|
||||
}
|
||||
export type TicTacToeMatchDataModel = MatchDataModel<TicTacToePayload>;
|
||||
8
src/games/tictactoe/props.ts
Normal file
8
src/games/tictactoe/props.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import {
|
||||
GameProps,
|
||||
} from '../../interfaces/props'
|
||||
|
||||
|
||||
export interface TicTacToeGameProps extends GameProps {
|
||||
// metadata: Record<string, any>;
|
||||
}
|
||||
12
src/games/tictactoe/utils.ts
Normal file
12
src/games/tictactoe/utils.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import {
|
||||
TicTacToePayload
|
||||
} from "./models";
|
||||
|
||||
export function movePayload(
|
||||
row: number,
|
||||
col: number,
|
||||
): TicTacToePayload {
|
||||
return {
|
||||
data: { row, col }
|
||||
};
|
||||
}
|
||||
38
src/interfaces/contexts.ts
Normal file
38
src/interfaces/contexts.ts
Normal file
@@ -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 {
|
||||
GameMetadataModel,
|
||||
MatchDataModel,
|
||||
} from './models'
|
||||
|
||||
|
||||
export interface NakamaContextType {
|
||||
client: Client;
|
||||
socket: Socket | null;
|
||||
session: Session | null;
|
||||
matchId: string | null;
|
||||
|
||||
loginOrRegister(username?: string): Promise<void>;
|
||||
logout(): Promise<void>;
|
||||
|
||||
joinMatchmaker(gameMetadata: GameMetadataModel): Promise<string>;
|
||||
exitMatchmaker(gameMetadata: GameMetadataModel): Promise<void>;
|
||||
joinMatch(matchId: string): Promise<void>;
|
||||
|
||||
sendMatchData(matchId: string, op: number, data: object): void;
|
||||
|
||||
onMatchData(cb: (msg: MatchDataModel) => void): void;
|
||||
|
||||
getLeaderboardTop(): Promise<ApiLeaderboardRecordList>;
|
||||
listOpenMatches(): Promise<ApiMatch[]>;
|
||||
}
|
||||
21
src/interfaces/models.ts
Normal file
21
src/interfaces/models.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
export interface PlayerModel {
|
||||
user_id: string;
|
||||
username: string;
|
||||
index: number;
|
||||
metadata: Record<string, string>; // e.g. { symbol: "X" }
|
||||
}
|
||||
|
||||
export interface MatchDataModel<T = any> {
|
||||
opCode: number;
|
||||
data: T;
|
||||
userId: string | null;
|
||||
}
|
||||
|
||||
export interface BoardModel {
|
||||
grid: string[][];
|
||||
}
|
||||
|
||||
export interface GameMetadataModel {
|
||||
game: string;
|
||||
mode: string;
|
||||
}
|
||||
19
src/interfaces/props.ts
Normal file
19
src/interfaces/props.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import {
|
||||
MatchDataModel,
|
||||
} from './models'
|
||||
|
||||
import {
|
||||
GameState
|
||||
} from "./states";
|
||||
|
||||
export interface PlayerProps {
|
||||
onMatchDataCallback: (msg:MatchDataModel) => void;
|
||||
}
|
||||
|
||||
export interface GameProps
|
||||
extends Pick<
|
||||
GameState,
|
||||
"boards" | "turn" | "winner" | "gameOver" | "players"
|
||||
> {
|
||||
myUserId: string | null;
|
||||
}
|
||||
15
src/interfaces/refs.ts
Normal file
15
src/interfaces/refs.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import React from "react";
|
||||
|
||||
import {
|
||||
Socket
|
||||
} from "@heroiclabs/nakama-js";
|
||||
|
||||
import {
|
||||
GameMetadataModel,
|
||||
} from './models'
|
||||
|
||||
|
||||
export interface NakamaRefs {
|
||||
socketRef: React.RefObject<Socket | null>;
|
||||
gameMetadataRef: React.RefObject<GameMetadataModel | null>;
|
||||
}
|
||||
26
src/interfaces/states.ts
Normal file
26
src/interfaces/states.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import {
|
||||
Session,
|
||||
Socket
|
||||
} from "@heroiclabs/nakama-js";
|
||||
|
||||
import {
|
||||
BoardModel,
|
||||
PlayerModel,
|
||||
} from "./models"
|
||||
|
||||
|
||||
export interface NakamaProviderState {
|
||||
session: Session | null;
|
||||
socket: Socket | null;
|
||||
matchId: string | null;
|
||||
matchmakerTicket: string | null;
|
||||
}
|
||||
|
||||
export interface GameState {
|
||||
boards: Record<string, BoardModel>;
|
||||
turn: number;
|
||||
winner: string | null;
|
||||
gameOver: boolean;
|
||||
players: PlayerModel[];
|
||||
metadata: Record<string, any>;
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
export interface PlayerModel {
|
||||
user_id: string;
|
||||
username: string;
|
||||
index: number;
|
||||
metadata: Record<string, string>; // e.g. { symbol: "X" }
|
||||
}
|
||||
@@ -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 { GameMetadataModel, MatchDataModel } 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<void>;
|
||||
logout(): Promise<void>;
|
||||
joinMatchmaker(gameMetadata: GameMetadata): Promise<string>;
|
||||
exitMatchmaker(gameMetadata: GameMetadata): Promise<void>;
|
||||
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 }) {
|
||||
@@ -68,13 +53,32 @@ export function NakamaProvider({ children }: { children: React.ReactNode }) {
|
||||
)
|
||||
);
|
||||
|
||||
const gameMetadataRef = React.useRef<GameMetadata | null>(null);
|
||||
const [session, setSession] = useState<Session | null>(null);
|
||||
const [socket, setSocket] = useState<Socket | null>(null);
|
||||
const [matchmakerTicket, setMatchmakerTicket] = useState<string | null>(null);
|
||||
const [matchId, setMatchId] = useState<string | null>(null);
|
||||
const socketRef = React.useRef<Socket | null>(null);
|
||||
// --------------------------------------
|
||||
// INTERNAL STATE (React state)
|
||||
// --------------------------------------
|
||||
const [internal, setInternal] = useState<NakamaProviderState>({
|
||||
session: null,
|
||||
socket: null,
|
||||
matchId: null,
|
||||
matchmakerTicket: null,
|
||||
});
|
||||
|
||||
// --------------------------------------
|
||||
// INTERNAL REFS (non-reactive, stable)
|
||||
// --------------------------------------
|
||||
const refs: NakamaRefs = {
|
||||
socketRef: useRef<Socket | null>(null),
|
||||
gameMetadataRef: useRef<GameMetadataModel | null>(null),
|
||||
};
|
||||
|
||||
// Helpers to update internal state cleanly
|
||||
function updateState(values: Partial<NakamaProviderState>) {
|
||||
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");
|
||||
}
|
||||
@@ -192,12 +202,11 @@ 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");
|
||||
async function joinMatchmaker(gameMetadata: GameMetadataModel) {
|
||||
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: MatchDataModel) => 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<ApiLeaderboardRecordList> {
|
||||
if (!session) return [];
|
||||
if (!internal.session) return [];
|
||||
|
||||
return await client.listLeaderboardRecords(
|
||||
session,
|
||||
internal.session,
|
||||
"tictactoe",
|
||||
[],
|
||||
10 // top 10
|
||||
);
|
||||
}
|
||||
|
||||
async function listOpenMatches(): Promise<ApiMatch[]> {
|
||||
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 (
|
||||
<NakamaContext.Provider
|
||||
value={{
|
||||
client,
|
||||
session,
|
||||
socket,
|
||||
matchId,
|
||||
session: internal.session,
|
||||
socket: internal.socket,
|
||||
matchId: internal.matchId,
|
||||
|
||||
loginOrRegister,
|
||||
logout,
|
||||
joinMatchmaker,
|
||||
|
||||
Reference in New Issue
Block a user