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",
|
"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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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>,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
|
|||||||
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