diff --git a/package-lock.json b/package-lock.json index d52fa5f..4ef3a42 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index d843afd..a8cf21f 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/main.jsx b/src/main.jsx index f613e75..0279091 100644 --- a/src/main.jsx +++ b/src/main.jsx @@ -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( - // + - // , + , ); diff --git a/src/tictactoe/Board.tsx b/src/tictactoe/Board.tsx index 7634a38..92520b0 100644 --- a/src/tictactoe/Board.tsx +++ b/src/tictactoe/Board.tsx @@ -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 ( -
- {squares.map((value, i) => ( - onClick(i)} /> - ))} +
+ {winner ? ( +

Winner: {winner}

+ ) : ( +

Turn: Player {turn === 0 ? "X" : "O"}

+ )} + +
+ {board.map((row, rIdx) => + row.map((cell, cIdx) => ( + + )) + )} +
); } diff --git a/src/tictactoe/TicTacToe.tsx b/src/tictactoe/TicTacToe.tsx index 854741f..3396a7f 100644 --- a/src/tictactoe/TicTacToe.tsx +++ b/src/tictactoe/TicTacToe.tsx @@ -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([ + ["", "", ""], + ["", "", ""], + ["", "", ""] + ]); + const [turn, setTurn] = useState(0); + const [winner, setWinner] = useState(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 ( -
-

Tic Tac Toe

-
{status}
- - +
+

Tic Tac Toe Multiplayer

+ + {!matchId && ( + <> + setUsername(e.target.value)} + /> + + + + )} + + {matchId && ( + + )}
); } - -// --- 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; -} diff --git a/src/tictactoe/providers/NakamaProvider.tsx b/src/tictactoe/providers/NakamaProvider.tsx new file mode 100644 index 0000000..2fac9f2 --- /dev/null +++ b/src/tictactoe/providers/NakamaProvider.tsx @@ -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; + joinMatchmaker: (mode: string) => Promise; + leaveMatchmaker: (ticket: string) => Promise; + joinMatch: (matchId: string) => Promise; + sendMatchData: (matchId: string, opCode: number, data: object) => void; + onMatchData: ( + cb: (msg: { opCode: number; data: any; userId: string | null }) => void + ) => void; +} + +export const NakamaContext = createContext(null); + +export function NakamaProvider({ children }: { children: React.ReactNode }) { + const [client] = useState( + () => new Client( + "defaultkey", + "127.0.0.1", + "7350" + ) + ); + + const [socket, setSocket] = useState(null); + const [session, setSession] = useState(null); + + // ----------------------- + // LOGIN / REGISTER + // ----------------------- + async function loginOrRegister(username: string): Promise { + 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 { + 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 { + if (socket) { + await socket.removeMatchmaker(ticket); + } + } + + // ----------------------- + // JOIN MATCH + // ----------------------- + async function joinMatch(matchId: string): Promise { + 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 ( + + {children} + + ); +} + +export function useNakama(): NakamaContextType { + const ctx = useContext(NakamaContext); + if (!ctx) { + throw new Error("useNakama must be used inside a NakamaProvider"); + } + return ctx; +}