added nakama provider using nakama package

This commit is contained in:
2025-11-27 16:51:06 +05:30
parent c6c9d10476
commit 4565c4b33a
6 changed files with 331 additions and 50 deletions

44
package-lock.json generated
View File

@@ -1,15 +1,16 @@
{ {
"name": "aetoskia-blog-app", "name": "tictactoe-vite",
"version": "0.2.5", "version": "1.0.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "aetoskia-blog-app", "name": "tictactoe-vite",
"version": "0.2.5", "version": "1.0.0",
"dependencies": { "dependencies": {
"@emotion/react": "latest", "@emotion/react": "latest",
"@emotion/styled": "latest", "@emotion/styled": "latest",
"@heroiclabs/nakama-js": "^2.8.0",
"@mui/icons-material": "latest", "@mui/icons-material": "latest",
"@mui/material": "latest", "@mui/material": "latest",
"axios": "latest", "axios": "latest",
@@ -841,6 +842,17 @@
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/@heroiclabs/nakama-js": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@heroiclabs/nakama-js/-/nakama-js-2.8.0.tgz",
"integrity": "sha512-E3bH/pqosASGHmVttsa708UjoLYkzZ4Sy3JUZV0TMK3oZK19QVyKrWhqjwyFwvKI2WyVf30xiRD+2ffvmfpw4A==",
"dependencies": {
"@scarf/scarf": "^1.1.1",
"base64-arraybuffer": "^1.0.2",
"js-base64": "^3.7.4",
"whatwg-fetch": "^3.6.2"
}
},
"node_modules/@jridgewell/gen-mapping": { "node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13", "version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
@@ -1408,6 +1420,12 @@
"win32" "win32"
] ]
}, },
"node_modules/@scarf/scarf": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/@scarf/scarf/-/scarf-1.4.0.tgz",
"integrity": "sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==",
"hasInstallScript": true
},
"node_modules/@types/babel__core": { "node_modules/@types/babel__core": {
"version": "7.20.5", "version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
@@ -1586,6 +1604,14 @@
"url": "https://github.com/sponsors/wooorm" "url": "https://github.com/sponsors/wooorm"
} }
}, },
"node_modules/base64-arraybuffer": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz",
"integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==",
"engines": {
"node": ">= 0.6.0"
}
},
"node_modules/baseline-browser-mapping": { "node_modules/baseline-browser-mapping": {
"version": "2.8.20", "version": "2.8.20",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.20.tgz", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.20.tgz",
@@ -2301,6 +2327,11 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/js-base64": {
"version": "3.7.8",
"resolved": "https://registry.npmjs.org/js-base64/-/js-base64-3.7.8.tgz",
"integrity": "sha512-hNngCeKxIUQiEUN3GPJOkz4wF/YvdUdbNL9hsBcMQTkKzboD7T/q3OYOuuPZLUE6dBxSGpwhk5mwuDud7JVAow=="
},
"node_modules/js-tokens": { "node_modules/js-tokens": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -3941,6 +3972,11 @@
} }
} }
}, },
"node_modules/whatwg-fetch": {
"version": "3.6.20",
"resolved": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-3.6.20.tgz",
"integrity": "sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg=="
},
"node_modules/yallist": { "node_modules/yallist": {
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",

View File

@@ -18,7 +18,8 @@
"markdown-to-jsx": "latest", "markdown-to-jsx": "latest",
"remark-gfm": "latest", "remark-gfm": "latest",
"marked": "latest", "marked": "latest",
"axios": "latest" "axios": "latest",
"@heroiclabs/nakama-js": "^2.8.0"
}, },
"devDependencies": { "devDependencies": {
"@vitejs/plugin-react": "latest", "@vitejs/plugin-react": "latest",

View File

@@ -1,14 +1,14 @@
import * as React from 'react'; import * as React from 'react';
import { createRoot } from 'react-dom/client'; import { createRoot } from 'react-dom/client';
import TicTacToe from './tictactoe/TicTacToe'; import TicTacToe from './tictactoe/TicTacToe';
// import { TicTacToeProvider } from './tictactoe/providers/TicTacToeProvider'; import { NakamaProvider } from './tictactoe/providers/NakamaProvider';
import "./tictactoe/styles.css"; import "./tictactoe/styles.css";
const rootElement = document.getElementById('root'); const rootElement = document.getElementById('root');
const root = createRoot(rootElement); const root = createRoot(rootElement);
root.render( root.render(
// <TicTacToeProvider> <NakamaProvider>
<TicTacToe /> <TicTacToe />
// </TicTacToeProvider>, </NakamaProvider>,
); );

View File

@@ -1,11 +1,48 @@
import Square from "./Square"; import React from "react";
export default function Board({ squares, onClick }) { interface BoardProps {
board: string[][];
turn: number;
winner: string | null;
onCellClick: (row: number, col: number) => void;
}
export default function Board({ board, turn, winner, onCellClick }: BoardProps) {
return ( return (
<div className="board"> <div>
{squares.map((value, i) => ( {winner ? (
<Square key={i} value={value} onClick={() => onClick(i)} /> <h2>Winner: {winner}</h2>
))} ) : (
<h2>Turn: Player {turn === 0 ? "X" : "O"}</h2>
)}
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(3, 80px)",
gap: "10px",
marginTop: "20px",
}}
>
{board.map((row, rIdx) =>
row.map((cell, cIdx) => (
<button
key={`${rIdx}-${cIdx}`}
style={{
width: "80px",
height: "80px",
fontSize: "2rem",
cursor: cell || winner ? "not-allowed" : "pointer",
}}
onClick={() => {
if (!cell && !winner) onCellClick(rIdx, cIdx);
}}
>
{cell}
</button>
))
)}
</div>
</div> </div>
); );
} }

View File

@@ -1,52 +1,99 @@
import { useState } from "react"; import { useState, useEffect } from "react";
import { useNakama } from "./providers/NakamaProvider";
import Board from "./Board"; import Board from "./Board";
export default function TicTacToe() { export default function TicTacToe() {
const [board, setBoard] = useState(Array(9).fill(null)); const [username, setUsername] = useState("");
const [xIsNext, setXIsNext] = useState(true); const [matchId, setMatchId] = useState("");
const winner = calculateWinner(board); const [board, setBoard] = useState<string[][]>([
["", "", ""],
["", "", ""],
["", "", ""]
]);
const [turn, setTurn] = useState<number>(0);
const [winner, setWinner] = useState<string | null>(null);
function handleSquareClick(i: number) { const {
if (board[i] || winner) return; loginOrRegister,
joinMatchmaker,
joinMatch,
onMatchData,
socket,
sendMatchData
} = useNakama();
const newBoard = board.slice(); // ------------------------------------------
newBoard[i] = xIsNext ? "X" : "O"; // CONNECT
// ------------------------------------------
async function connect() {
await loginOrRegister(username);
setBoard(newBoard); // Match data listener
setXIsNext(!xIsNext); onMatchData((msg) => {
console.log("[Match Data]", msg);
if (msg.opCode === 2) {
const state = msg.data;
setBoard(state.board);
setTurn(state.turn);
setWinner(state.winner || null);
}
});
// When matchmaker finds a match
socket!.onmatchmakermatched = async (matched) => {
console.log("Matched:", matched);
const fullMatchId = matched.match_id;
setMatchId(fullMatchId);
await joinMatch(fullMatchId);
};
} }
function reset() { // ------------------------------------------
setBoard(Array(9).fill(null)); // SEND A MOVE
setXIsNext(true); // ------------------------------------------
function handleCellClick(row: number, col: number) {
if (!matchId) return;
sendMatchData(matchId, 1, { row, col }); // OpMove=1
} }
const status = winner // ------------------------------------------
? `Winner: ${winner}` // MATCHMAKING
: `Next player: ${xIsNext ? "X" : "O"}`; // ------------------------------------------
async function startQueue() {
const ticket = await joinMatchmaker("classic");
console.log("Queued:", ticket);
}
return ( return (
<div className="game-container"> <div>
<h1>Tic Tac Toe</h1> <h1>Tic Tac Toe Multiplayer</h1>
<div className="status">{status}</div>
<Board squares={board} onClick={handleSquareClick} /> {!matchId && (
<button className="reset-btn" onClick={reset}>Reset</button> <>
<input
placeholder="username/device ID"
value={username}
onChange={(e) => setUsername(e.target.value)}
/>
<button onClick={connect}>Connect</button>
<button onClick={startQueue}>Join Matchmaking</button>
</>
)}
{matchId && (
<Board
board={board}
turn={turn}
winner={winner}
onCellClick={handleCellClick}
/>
)}
</div> </div>
); );
} }
// --- winner logic ---
function calculateWinner(sq: any[]) {
const lines = [
[0,1,2],[3,4,5],[6,7,8],
[0,3,6],[1,4,7],[2,5,8],
[0,4,8],[2,4,6]
];
for (let [a,b,c] of lines) {
if (sq[a] && sq[a] === sq[b] && sq[a] === sq[c]) {
return sq[a];
}
}
return null;
}

View File

@@ -0,0 +1,160 @@
import {
Client,
DefaultSocket,
Session,
Socket,
MatchmakerTicket,
Match,
MatchData,
} from "@heroiclabs/nakama-js";
import React, { createContext, useContext, useState } from "react";
export interface NakamaContextType {
client: Client;
socket: Socket | null;
session: Session | null;
loginOrRegister: (username: string) => Promise<Session>;
joinMatchmaker: (mode: string) => Promise<string>;
leaveMatchmaker: (ticket: string) => Promise<void>;
joinMatch: (matchId: string) => Promise<Match>;
sendMatchData: (matchId: string, opCode: number, data: object) => void;
onMatchData: (
cb: (msg: { opCode: number; data: any; userId: string | null }) => void
) => void;
}
export const NakamaContext = createContext<NakamaContextType | null>(null);
export function NakamaProvider({ children }: { children: React.ReactNode }) {
const [client] = useState<Client>(
() => new Client(
"defaultkey",
"127.0.0.1",
"7350"
)
);
const [socket, setSocket] = useState<Socket | null>(null);
const [session, setSession] = useState<Session | null>(null);
// -----------------------
// LOGIN / REGISTER
// -----------------------
async function loginOrRegister(username: string): Promise<Session> {
try {
const s = await client.authenticateDevice(username, true);
setSession(s);
const newSocket = new DefaultSocket(
client.host,
client.port,
false, // useSSL
false // verbose
);
await newSocket.connect(s, true);
setSocket(newSocket);
console.log("[Nakama] Connected WS");
return s;
} catch (err) {
console.error("[Nakama] Login error", err);
throw err;
}
}
// -----------------------
// MATCHMAKING
// -----------------------
async function joinMatchmaker(mode: string): Promise<string> {
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
);
console.log("[Nakama] Matchmaker ticket:", ticket.ticket);
return ticket.ticket;
}
async function leaveMatchmaker(ticket: string): Promise<void> {
if (socket) {
await socket.removeMatchmaker(ticket);
}
}
// -----------------------
// JOIN MATCH
// -----------------------
async function joinMatch(matchId: string): Promise<Match> {
if (!socket) throw new Error("Socket not connected.");
const match = await socket.joinMatch(matchId);
console.log("[Nakama] Joined match:", matchId);
return match;
}
// -----------------------
// SEND MATCH DATA
// -----------------------
function sendMatchData(matchId: string, opCode: number, data: object = {}): void {
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,
});
};
}
return (
<NakamaContext.Provider
value={{
client,
socket,
session,
loginOrRegister,
joinMatchmaker,
leaveMatchmaker,
joinMatch,
sendMatchData,
onMatchData,
}}
>
{children}
</NakamaContext.Provider>
);
}
export function useNakama(): NakamaContextType {
const ctx = useContext(NakamaContext);
if (!ctx) {
throw new Error("useNakama must be used inside a NakamaProvider");
}
return ctx;
}