Add simple WebSocket multiplayer
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -15,6 +15,7 @@ out/
|
|||||||
|
|
||||||
# Logs
|
# Logs
|
||||||
*.log
|
*.log
|
||||||
|
server.log
|
||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
yarn-debug.log*
|
yarn-debug.log*
|
||||||
yarn-error.log*
|
yarn-error.log*
|
||||||
@@ -23,4 +24,3 @@ pnpm-debug.log*
|
|||||||
# OS files
|
# OS files
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|
||||||
|
|||||||
20
README.md
20
README.md
@@ -11,9 +11,23 @@ A notepad-playable browser game that combines Tic Tac Toe's line-making goal wit
|
|||||||
- First player to make `4 in a row` wins.
|
- First player to make `4 in a row` wins.
|
||||||
- If the board fills with no winner, the game is a draw.
|
- If the board fills with no winner, the game is a draw.
|
||||||
|
|
||||||
## Run
|
## Run Multiplayer
|
||||||
|
|
||||||
Open `index.html` in a browser.
|
```powershell
|
||||||
|
npm start
|
||||||
|
```
|
||||||
|
|
||||||
No build step is required.
|
Then open `http://localhost:3000` in two browser windows. The first two connected players are paired automatically.
|
||||||
|
|
||||||
|
Set a different port with:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$env:PORT=4000; npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- No authentication or user accounts are required.
|
||||||
|
- The server keeps games in memory only.
|
||||||
|
- If an opponent closes their window, the remaining player sees a disconnect error and waits for a new player.
|
||||||
|
- No build step is required.
|
||||||
|
|||||||
174
game.js
174
game.js
@@ -19,6 +19,7 @@ const resetButton = document.querySelector("#resetButton");
|
|||||||
const xCapturesElement = document.querySelector("#xCaptures");
|
const xCapturesElement = document.querySelector("#xCaptures");
|
||||||
const oCapturesElement = document.querySelector("#oCaptures");
|
const oCapturesElement = document.querySelector("#oCaptures");
|
||||||
const moveList = document.querySelector("#moveList");
|
const moveList = document.querySelector("#moveList");
|
||||||
|
const networkStatus = document.querySelector("#networkStatus");
|
||||||
|
|
||||||
let board;
|
let board;
|
||||||
let currentPlayer;
|
let currentPlayer;
|
||||||
@@ -26,6 +27,10 @@ let captures;
|
|||||||
let gameOver;
|
let gameOver;
|
||||||
let moveNumber;
|
let moveNumber;
|
||||||
let winningCells;
|
let winningCells;
|
||||||
|
let socket;
|
||||||
|
let localPlayer = "";
|
||||||
|
let roomId = "";
|
||||||
|
let multiplayerState = "connecting";
|
||||||
|
|
||||||
function createBoard() {
|
function createBoard() {
|
||||||
return Array.from({ length: size }, () => Array(size).fill(empty));
|
return Array.from({ length: size }, () => Array(size).fill(empty));
|
||||||
@@ -40,12 +45,80 @@ function setupGame() {
|
|||||||
winningCells = [];
|
winningCells = [];
|
||||||
moveList.innerHTML = "";
|
moveList.innerHTML = "";
|
||||||
render();
|
render();
|
||||||
setMessage("Trap enemy marks between your marks. First to four wins.");
|
}
|
||||||
|
|
||||||
|
function connectToServer() {
|
||||||
|
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||||
|
socket = new WebSocket(`${protocol}//${window.location.host}/ws`);
|
||||||
|
setMultiplayerState("connecting", "Connecting to matchmaker...");
|
||||||
|
|
||||||
|
socket.addEventListener("open", () => {
|
||||||
|
setMultiplayerState("waiting", "Connected. Waiting for another player...");
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.addEventListener("message", (event) => {
|
||||||
|
const payload = JSON.parse(event.data);
|
||||||
|
handleServerMessage(payload);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.addEventListener("close", () => {
|
||||||
|
localPlayer = "";
|
||||||
|
roomId = "";
|
||||||
|
setMultiplayerState("error", "Connection closed. Refresh to reconnect.");
|
||||||
|
render();
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.addEventListener("error", () => {
|
||||||
|
setMultiplayerState("error", "Could not reach the multiplayer server.");
|
||||||
|
render();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleServerMessage(payload) {
|
||||||
|
if (payload.type === "waiting") {
|
||||||
|
localPlayer = "";
|
||||||
|
roomId = "";
|
||||||
|
setupGame();
|
||||||
|
setMultiplayerState("waiting", "Waiting for another player...");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payload.type === "paired") {
|
||||||
|
localPlayer = payload.player;
|
||||||
|
roomId = payload.roomId;
|
||||||
|
setupGame();
|
||||||
|
setMultiplayerState("paired", `You are ${localPlayer}. ${currentPlayer} plays first.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payload.type === "move") {
|
||||||
|
applyMove(payload.row, payload.col, payload.player, "remote");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payload.type === "reset") {
|
||||||
|
setupGame();
|
||||||
|
setMultiplayerState("paired", `Game reset. You are ${localPlayer}. ${currentPlayer} plays first.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payload.type === "opponent-left") {
|
||||||
|
localPlayer = "";
|
||||||
|
roomId = "";
|
||||||
|
gameOver = true;
|
||||||
|
setMultiplayerState("error", payload.message || "Opponent disconnected. Waiting for a new player...");
|
||||||
|
render();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payload.type === "error") {
|
||||||
|
setMultiplayerState("error", payload.message || "Multiplayer error.");
|
||||||
|
render();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function render() {
|
function render() {
|
||||||
boardElement.innerHTML = "";
|
boardElement.innerHTML = "";
|
||||||
boardElement.style.setProperty("--size", size);
|
|
||||||
|
|
||||||
for (let row = 0; row < size; row += 1) {
|
for (let row = 0; row < size; row += 1) {
|
||||||
for (let col = 0; col < size; col += 1) {
|
for (let col = 0; col < size; col += 1) {
|
||||||
@@ -56,7 +129,7 @@ function render() {
|
|||||||
cell.dataset.row = row;
|
cell.dataset.row = row;
|
||||||
cell.dataset.col = col;
|
cell.dataset.col = col;
|
||||||
cell.setAttribute("aria-label", cellLabel(row, col));
|
cell.setAttribute("aria-label", cellLabel(row, col));
|
||||||
cell.disabled = gameOver || board[row][col] !== empty;
|
cell.disabled = !canPlayCell(row, col);
|
||||||
|
|
||||||
if (winningCells.some(([winRow, winCol]) => winRow === row && winCol === col)) {
|
if (winningCells.some(([winRow, winCol]) => winRow === row && winCol === col)) {
|
||||||
cell.classList.add("winning");
|
cell.classList.add("winning");
|
||||||
@@ -69,7 +142,7 @@ function render() {
|
|||||||
cell.append(mark);
|
cell.append(mark);
|
||||||
}
|
}
|
||||||
|
|
||||||
cell.addEventListener("click", () => playMove(row, col));
|
cell.addEventListener("click", () => playLocalMove(row, col));
|
||||||
boardElement.append(cell);
|
boardElement.append(cell);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -78,6 +151,14 @@ function render() {
|
|||||||
turnBadge.className = `turn-badge player-${currentPlayer.toLowerCase()}`;
|
turnBadge.className = `turn-badge player-${currentPlayer.toLowerCase()}`;
|
||||||
xCapturesElement.textContent = captures.X;
|
xCapturesElement.textContent = captures.X;
|
||||||
oCapturesElement.textContent = captures.O;
|
oCapturesElement.textContent = captures.O;
|
||||||
|
resetButton.disabled = multiplayerState !== "paired";
|
||||||
|
}
|
||||||
|
|
||||||
|
function canPlayCell(row, col) {
|
||||||
|
return multiplayerState === "paired" &&
|
||||||
|
!gameOver &&
|
||||||
|
localPlayer === currentPlayer &&
|
||||||
|
board[row][col] === empty;
|
||||||
}
|
}
|
||||||
|
|
||||||
function cellLabel(row, col) {
|
function cellLabel(row, col) {
|
||||||
@@ -86,41 +167,59 @@ function cellLabel(row, col) {
|
|||||||
return mark ? `${mark} at ${position}` : `Empty cell at ${position}`;
|
return mark ? `${mark} at ${position}` : `Empty cell at ${position}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function playMove(row, col) {
|
function playLocalMove(row, col) {
|
||||||
if (gameOver || board[row][col] !== empty) {
|
if (!canPlayCell(row, col)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
moveNumber += 1;
|
const applied = applyMove(row, col, localPlayer, "local");
|
||||||
board[row][col] = currentPlayer;
|
if (!applied) {
|
||||||
const captured = captureFrom(row, col, currentPlayer);
|
return;
|
||||||
captures[currentPlayer] += captured.length;
|
}
|
||||||
|
|
||||||
const win = findWin(currentPlayer);
|
sendToServer({ type: "move", roomId, row, col, player: localPlayer });
|
||||||
addMove(row, col, captured.length);
|
}
|
||||||
|
|
||||||
|
function applyMove(row, col, player, source) {
|
||||||
|
if (gameOver || player !== currentPlayer || board[row][col] !== empty) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
moveNumber += 1;
|
||||||
|
board[row][col] = player;
|
||||||
|
const captured = captureFrom(row, col, player);
|
||||||
|
captures[player] += captured.length;
|
||||||
|
|
||||||
|
const win = findWin(player);
|
||||||
|
addMove(row, col, player, captured.length);
|
||||||
|
|
||||||
if (win.length > 0) {
|
if (win.length > 0) {
|
||||||
winningCells = win;
|
winningCells = win;
|
||||||
gameOver = true;
|
gameOver = true;
|
||||||
setMessage(`${currentPlayer} wins with four in a row.`);
|
setMultiplayerState("paired", `${player} wins with four in a row.`);
|
||||||
render();
|
render();
|
||||||
return;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isDraw()) {
|
if (isDraw()) {
|
||||||
gameOver = true;
|
gameOver = true;
|
||||||
setMessage("Draw. The board is full.");
|
setMultiplayerState("paired", "Draw. The board is full.");
|
||||||
render();
|
render();
|
||||||
return;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
currentPlayer = opponentOf(currentPlayer);
|
currentPlayer = opponentOf(player);
|
||||||
setMessage(captured.length > 0
|
const captureMessage = captured.length > 0 ? `${player} captured ${captured.length}. ` : "";
|
||||||
? `${opponentOf(currentPlayer)} captured ${captured.length}. ${currentPlayer} to play.`
|
const turnMessage = currentPlayer === localPlayer ? "Your turn." : `Waiting for ${currentPlayer}.`;
|
||||||
: `${currentPlayer} to play.`
|
setMultiplayerState("paired", `${captureMessage}${turnMessage}`);
|
||||||
);
|
|
||||||
render();
|
render();
|
||||||
flashCapturedCells(captured);
|
flashCapturedCells(captured);
|
||||||
|
|
||||||
|
if (source === "remote" && currentPlayer === localPlayer) {
|
||||||
|
window.navigator.vibrate?.(40);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function captureFrom(row, col, player) {
|
function captureFrom(row, col, player) {
|
||||||
@@ -200,17 +299,30 @@ function isInside(row, col) {
|
|||||||
return row >= 0 && row < size && col >= 0 && col < size;
|
return row >= 0 && row < size && col >= 0 && col < size;
|
||||||
}
|
}
|
||||||
|
|
||||||
function addMove(row, col, captureCount) {
|
function addMove(row, col, player, captureCount) {
|
||||||
const item = document.createElement("li");
|
const item = document.createElement("li");
|
||||||
const captureText = captureCount === 0 ? "" : `, captured ${captureCount}`;
|
const captureText = captureCount === 0 ? "" : `, captured ${captureCount}`;
|
||||||
item.textContent = `${moveNumber}. ${currentPlayer} to ${row + 1},${col + 1}${captureText}`;
|
item.textContent = `${moveNumber}. ${player} to ${row + 1},${col + 1}${captureText}`;
|
||||||
moveList.prepend(item);
|
moveList.prepend(item);
|
||||||
}
|
}
|
||||||
|
|
||||||
function setMessage(text) {
|
function setMultiplayerState(state, text) {
|
||||||
|
multiplayerState = state;
|
||||||
|
networkStatus.textContent = text;
|
||||||
|
networkStatus.className = `network-status ${state}`;
|
||||||
messageElement.textContent = text;
|
messageElement.textContent = text;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sendToServer(payload) {
|
||||||
|
if (!socket || socket.readyState !== WebSocket.OPEN) {
|
||||||
|
setMultiplayerState("error", "Connection lost. Refresh to reconnect.");
|
||||||
|
render();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
socket.send(JSON.stringify(payload));
|
||||||
|
}
|
||||||
|
|
||||||
function flashCapturedCells(captured) {
|
function flashCapturedCells(captured) {
|
||||||
for (const [row, col] of captured) {
|
for (const [row, col] of captured) {
|
||||||
const cell = boardElement.querySelector(`[data-row="${row}"][data-col="${col}"]`);
|
const cell = boardElement.querySelector(`[data-row="${row}"][data-col="${col}"]`);
|
||||||
@@ -223,6 +335,16 @@ function flashCapturedCells(captured) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
resetButton.addEventListener("click", setupGame);
|
resetButton.addEventListener("click", () => {
|
||||||
setupGame();
|
if (multiplayerState !== "paired") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setupGame();
|
||||||
|
setMultiplayerState("paired", `Game reset. You are ${localPlayer}. ${currentPlayer} plays first.`);
|
||||||
|
sendToServer({ type: "reset", roomId });
|
||||||
|
});
|
||||||
|
|
||||||
|
setupGame();
|
||||||
|
connectToServer();
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,8 @@
|
|||||||
<div id="message" class="message">Trap enemy marks between your marks. First to four wins.</div>
|
<div id="message" class="message">Trap enemy marks between your marks. First to four wins.</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div id="networkStatus" class="network-status connecting" role="status">Connecting to matchmaker...</div>
|
||||||
|
|
||||||
<div id="board" class="board" role="grid" aria-label="Tictactics board"></div>
|
<div id="board" class="board" role="grid" aria-label="Tictactics board"></div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -41,7 +43,7 @@
|
|||||||
|
|
||||||
<div class="rule-card">
|
<div class="rule-card">
|
||||||
<h2>Rule</h2>
|
<h2>Rule</h2>
|
||||||
<p>Place a mark on an empty cell. Any enemy marks trapped in a straight line between your marks are removed.</p>
|
<p>Open the game in two browser windows. The first two connected players are paired automatically.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ol id="moveList" class="move-list" aria-label="Move history"></ol>
|
<ol id="moveList" class="move-list" aria-label="Move history"></ol>
|
||||||
@@ -51,4 +53,3 @@
|
|||||||
<script src="game.js"></script>
|
<script src="game.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
||||||
|
|||||||
10
package.json
Normal file
10
package.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"name": "tictactics",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"description": "A notepad-playable capture tic tac toe browser game with simple WebSocket multiplayer.",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node server.js"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
284
server.js
Normal file
284
server.js
Normal file
@@ -0,0 +1,284 @@
|
|||||||
|
const crypto = require("crypto");
|
||||||
|
const fs = require("fs");
|
||||||
|
const http = require("http");
|
||||||
|
const path = require("path");
|
||||||
|
|
||||||
|
const port = Number(process.env.PORT || 3000);
|
||||||
|
const root = __dirname;
|
||||||
|
const clients = new Map();
|
||||||
|
const rooms = new Map();
|
||||||
|
let waitingClientId = "";
|
||||||
|
let nextClientId = 1;
|
||||||
|
let nextRoomId = 1;
|
||||||
|
|
||||||
|
const mimeTypes = {
|
||||||
|
".html": "text/html; charset=utf-8",
|
||||||
|
".css": "text/css; charset=utf-8",
|
||||||
|
".js": "text/javascript; charset=utf-8",
|
||||||
|
".json": "application/json; charset=utf-8",
|
||||||
|
".svg": "image/svg+xml",
|
||||||
|
".ico": "image/x-icon",
|
||||||
|
};
|
||||||
|
|
||||||
|
const server = http.createServer((request, response) => {
|
||||||
|
const requestUrl = new URL(request.url, `http://${request.headers.host}`);
|
||||||
|
const pathname = requestUrl.pathname === "/" ? "/index.html" : requestUrl.pathname;
|
||||||
|
const filePath = path.normalize(path.join(root, pathname));
|
||||||
|
|
||||||
|
if (!filePath.startsWith(root)) {
|
||||||
|
response.writeHead(403);
|
||||||
|
response.end("Forbidden");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.readFile(filePath, (error, content) => {
|
||||||
|
if (error) {
|
||||||
|
response.writeHead(404);
|
||||||
|
response.end("Not found");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const type = mimeTypes[path.extname(filePath)] || "application/octet-stream";
|
||||||
|
response.writeHead(200, { "Content-Type": type });
|
||||||
|
response.end(content);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
server.on("upgrade", (request, socket) => {
|
||||||
|
if (request.url !== "/ws") {
|
||||||
|
socket.destroy();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = request.headers["sec-websocket-key"];
|
||||||
|
if (!key) {
|
||||||
|
socket.destroy();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const accept = crypto
|
||||||
|
.createHash("sha1")
|
||||||
|
.update(`${key}258EAFA5-E914-47DA-95CA-C5AB0DC85B11`)
|
||||||
|
.digest("base64");
|
||||||
|
|
||||||
|
socket.write([
|
||||||
|
"HTTP/1.1 101 Switching Protocols",
|
||||||
|
"Upgrade: websocket",
|
||||||
|
"Connection: Upgrade",
|
||||||
|
`Sec-WebSocket-Accept: ${accept}`,
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
].join("\r\n"));
|
||||||
|
|
||||||
|
addClient(socket);
|
||||||
|
});
|
||||||
|
|
||||||
|
function addClient(socket) {
|
||||||
|
const id = String(nextClientId);
|
||||||
|
nextClientId += 1;
|
||||||
|
|
||||||
|
const client = { id, socket, roomId: "", player: "" };
|
||||||
|
clients.set(id, client);
|
||||||
|
|
||||||
|
socket.on("data", (buffer) => handleSocketData(client, buffer));
|
||||||
|
socket.on("close", () => removeClient(client));
|
||||||
|
socket.on("error", () => removeClient(client));
|
||||||
|
|
||||||
|
if (waitingClientId && clients.has(waitingClientId)) {
|
||||||
|
pairClients(clients.get(waitingClientId), client);
|
||||||
|
waitingClientId = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
waitingClientId = id;
|
||||||
|
send(client, { type: "waiting" });
|
||||||
|
}
|
||||||
|
|
||||||
|
function pairClients(xClient, oClient) {
|
||||||
|
const roomId = String(nextRoomId);
|
||||||
|
nextRoomId += 1;
|
||||||
|
|
||||||
|
xClient.roomId = roomId;
|
||||||
|
xClient.player = "X";
|
||||||
|
oClient.roomId = roomId;
|
||||||
|
oClient.player = "O";
|
||||||
|
|
||||||
|
rooms.set(roomId, { id: roomId, players: [xClient.id, oClient.id] });
|
||||||
|
|
||||||
|
send(xClient, { type: "paired", player: "X", roomId });
|
||||||
|
send(oClient, { type: "paired", player: "O", roomId });
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSocketData(client, buffer) {
|
||||||
|
const opcode = buffer[0] & 0x0f;
|
||||||
|
if (opcode === 0x8) {
|
||||||
|
client.socket.write(Buffer.from([0x88, 0x00]));
|
||||||
|
client.socket.end();
|
||||||
|
removeClient(client);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (opcode === 0x9) {
|
||||||
|
client.socket.write(Buffer.from([0x8a, 0x00]));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = decodeFrame(buffer);
|
||||||
|
if (!message) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let payload;
|
||||||
|
try {
|
||||||
|
payload = JSON.parse(message);
|
||||||
|
} catch {
|
||||||
|
send(client, { type: "error", message: "Invalid message." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!client.roomId || payload.roomId !== client.roomId) {
|
||||||
|
send(client, { type: "error", message: "You are not currently paired." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payload.type === "move") {
|
||||||
|
if (payload.player !== client.player) {
|
||||||
|
send(client, { type: "error", message: "Move rejected for wrong player." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
relayToOpponent(client, {
|
||||||
|
type: "move",
|
||||||
|
row: payload.row,
|
||||||
|
col: payload.col,
|
||||||
|
player: payload.player,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payload.type === "reset") {
|
||||||
|
relayToOpponent(client, { type: "reset" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeClient(client) {
|
||||||
|
if (!clients.has(client.id)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
clients.delete(client.id);
|
||||||
|
|
||||||
|
if (waitingClientId === client.id) {
|
||||||
|
waitingClientId = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!client.roomId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const room = rooms.get(client.roomId);
|
||||||
|
rooms.delete(client.roomId);
|
||||||
|
|
||||||
|
if (!room) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const playerId of room.players) {
|
||||||
|
if (playerId === client.id || !clients.has(playerId)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const opponent = clients.get(playerId);
|
||||||
|
opponent.roomId = "";
|
||||||
|
opponent.player = "";
|
||||||
|
send(opponent, {
|
||||||
|
type: "opponent-left",
|
||||||
|
message: "Opponent disconnected. Waiting for a new player...",
|
||||||
|
});
|
||||||
|
waitingClientId = opponent.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function relayToOpponent(client, payload) {
|
||||||
|
const room = rooms.get(client.roomId);
|
||||||
|
if (!room) {
|
||||||
|
send(client, { type: "error", message: "Room no longer exists." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const playerId of room.players) {
|
||||||
|
if (playerId !== client.id && clients.has(playerId)) {
|
||||||
|
send(clients.get(playerId), payload);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function send(client, payload) {
|
||||||
|
if (client.socket.destroyed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
client.socket.write(encodeFrame(JSON.stringify(payload)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeFrame(buffer) {
|
||||||
|
const firstByte = buffer[0];
|
||||||
|
const opcode = firstByte & 0x0f;
|
||||||
|
const secondByte = buffer[1];
|
||||||
|
const masked = (secondByte & 0x80) === 0x80;
|
||||||
|
let length = secondByte & 0x7f;
|
||||||
|
let offset = 2;
|
||||||
|
|
||||||
|
if (length === 126) {
|
||||||
|
length = buffer.readUInt16BE(offset);
|
||||||
|
offset += 2;
|
||||||
|
} else if (length === 127) {
|
||||||
|
length = Number(buffer.readBigUInt64BE(offset));
|
||||||
|
offset += 8;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mask;
|
||||||
|
if (masked) {
|
||||||
|
mask = buffer.subarray(offset, offset + 4);
|
||||||
|
offset += 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = buffer.subarray(offset, offset + length);
|
||||||
|
if (!masked) {
|
||||||
|
return payload.toString("utf8");
|
||||||
|
}
|
||||||
|
|
||||||
|
const decoded = Buffer.alloc(payload.length);
|
||||||
|
for (let index = 0; index < payload.length; index += 1) {
|
||||||
|
decoded[index] = payload[index] ^ mask[index % 4];
|
||||||
|
}
|
||||||
|
|
||||||
|
return decoded.toString("utf8");
|
||||||
|
}
|
||||||
|
|
||||||
|
function encodeFrame(message) {
|
||||||
|
const payload = Buffer.from(message);
|
||||||
|
const length = payload.length;
|
||||||
|
|
||||||
|
if (length < 126) {
|
||||||
|
return Buffer.concat([Buffer.from([0x81, length]), payload]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (length < 65536) {
|
||||||
|
const header = Buffer.alloc(4);
|
||||||
|
header[0] = 0x81;
|
||||||
|
header[1] = 126;
|
||||||
|
header.writeUInt16BE(length, 2);
|
||||||
|
return Buffer.concat([header, payload]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const header = Buffer.alloc(10);
|
||||||
|
header[0] = 0x81;
|
||||||
|
header[1] = 127;
|
||||||
|
header.writeBigUInt64BE(BigInt(length), 2);
|
||||||
|
return Buffer.concat([header, payload]);
|
||||||
|
}
|
||||||
|
|
||||||
|
server.listen(port, () => {
|
||||||
|
console.log(`Tictactics multiplayer server running at http://localhost:${port}`);
|
||||||
|
});
|
||||||
33
styles.css
33
styles.css
@@ -96,6 +96,12 @@ h1 {
|
|||||||
transition: transform 150ms ease, background 150ms ease;
|
transition: transform 150ms ease, background 150ms ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.icon-button:disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: not-allowed;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
.icon-button:hover {
|
.icon-button:hover {
|
||||||
background: #eee6d6;
|
background: #eee6d6;
|
||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
@@ -136,6 +142,32 @@ h1 {
|
|||||||
text-align: right;
|
text-align: right;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.network-status {
|
||||||
|
margin: 0 0 18px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid rgba(37, 37, 37, 0.14);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--muted);
|
||||||
|
background: var(--panel);
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.network-status.paired {
|
||||||
|
color: #31592d;
|
||||||
|
background: #e5f3df;
|
||||||
|
}
|
||||||
|
|
||||||
|
.network-status.waiting,
|
||||||
|
.network-status.connecting {
|
||||||
|
color: #665019;
|
||||||
|
background: #fff3c7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.network-status.error {
|
||||||
|
color: #8d2525;
|
||||||
|
background: #ffe1df;
|
||||||
|
}
|
||||||
|
|
||||||
.board {
|
.board {
|
||||||
aspect-ratio: 1;
|
aspect-ratio: 1;
|
||||||
width: min(100%, 620px);
|
width: min(100%, 620px);
|
||||||
@@ -307,4 +339,3 @@ h1 {
|
|||||||
max-height: 180px;
|
max-height: 180px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user