added nakama provider using nakama package
This commit is contained in:
44
package-lock.json
generated
44
package-lock.json
generated
@@ -1,15 +1,16 @@
|
||||
{
|
||||
"name": "aetoskia-blog-app",
|
||||
"version": "0.2.5",
|
||||
"name": "tictactoe-vite",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "aetoskia-blog-app",
|
||||
"version": "0.2.5",
|
||||
"name": "tictactoe-vite",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@emotion/react": "latest",
|
||||
"@emotion/styled": "latest",
|
||||
"@heroiclabs/nakama-js": "^2.8.0",
|
||||
"@mui/icons-material": "latest",
|
||||
"@mui/material": "latest",
|
||||
"axios": "latest",
|
||||
@@ -841,6 +842,17 @@
|
||||
"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": {
|
||||
"version": "0.3.13",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
||||
@@ -1408,6 +1420,12 @@
|
||||
"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": {
|
||||
"version": "7.20.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
||||
@@ -1586,6 +1604,14 @@
|
||||
"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": {
|
||||
"version": "2.8.20",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"version": "4.0.0",
|
||||
"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": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
||||
|
||||
@@ -18,7 +18,8 @@
|
||||
"markdown-to-jsx": "latest",
|
||||
"remark-gfm": "latest",
|
||||
"marked": "latest",
|
||||
"axios": "latest"
|
||||
"axios": "latest",
|
||||
"@heroiclabs/nakama-js": "^2.8.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react": "latest",
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import * as React from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import TicTacToe from './tictactoe/TicTacToe';
|
||||
// import { TicTacToeProvider } from './tictactoe/providers/TicTacToeProvider';
|
||||
import { NakamaProvider } from './tictactoe/providers/NakamaProvider';
|
||||
import "./tictactoe/styles.css";
|
||||
|
||||
const rootElement = document.getElementById('root');
|
||||
const root = createRoot(rootElement);
|
||||
|
||||
root.render(
|
||||
// <TicTacToeProvider>
|
||||
<NakamaProvider>
|
||||
<TicTacToe />
|
||||
// </TicTacToeProvider>,
|
||||
</NakamaProvider>,
|
||||
);
|
||||
|
||||
@@ -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 (
|
||||
<div className="board">
|
||||
{squares.map((value, i) => (
|
||||
<Square key={i} value={value} onClick={() => onClick(i)} />
|
||||
))}
|
||||
<div>
|
||||
{winner ? (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,52 +1,99 @@
|
||||
import { useState } from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { useNakama } from "./providers/NakamaProvider";
|
||||
import Board from "./Board";
|
||||
|
||||
export default function TicTacToe() {
|
||||
const [board, setBoard] = useState(Array(9).fill(null));
|
||||
const [xIsNext, setXIsNext] = useState(true);
|
||||
const [username, setUsername] = useState("");
|
||||
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) {
|
||||
if (board[i] || winner) return;
|
||||
const {
|
||||
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);
|
||||
setXIsNext(!xIsNext);
|
||||
// Match data listener
|
||||
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));
|
||||
setXIsNext(true);
|
||||
// ------------------------------------------
|
||||
// SEND A MOVE
|
||||
// ------------------------------------------
|
||||
function handleCellClick(row: number, col: number) {
|
||||
if (!matchId) return;
|
||||
|
||||
sendMatchData(matchId, 1, { row, col }); // OpMove=1
|
||||
}
|
||||
|
||||
const status = winner
|
||||
? `Winner: ${winner}`
|
||||
: `Next player: ${xIsNext ? "X" : "O"}`;
|
||||
// ------------------------------------------
|
||||
// MATCHMAKING
|
||||
// ------------------------------------------
|
||||
async function startQueue() {
|
||||
const ticket = await joinMatchmaker("classic");
|
||||
console.log("Queued:", ticket);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="game-container">
|
||||
<h1>Tic Tac Toe</h1>
|
||||
<div className="status">{status}</div>
|
||||
<Board squares={board} onClick={handleSquareClick} />
|
||||
<button className="reset-btn" onClick={reset}>Reset</button>
|
||||
<div>
|
||||
<h1>Tic Tac Toe Multiplayer</h1>
|
||||
|
||||
{!matchId && (
|
||||
<>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
// --- 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;
|
||||
}
|
||||
|
||||
160
src/tictactoe/providers/NakamaProvider.tsx
Normal file
160
src/tictactoe/providers/NakamaProvider.tsx
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user