From 4631f9b7c5294a1eb5b735c7d27ac49a8011471d Mon Sep 17 00:00:00 2001 From: Spencer Date: Fri, 8 May 2026 13:22:53 -0400 Subject: [PATCH] Add local bot practice mode --- game.js | 142 ++++++++++++++++++++++++++++++++++++++++++++++++----- index.html | 19 ++++++- styles.css | 36 ++++++++++++++ 3 files changed, 182 insertions(+), 15 deletions(-) diff --git a/game.js b/game.js index 1f914f7..e052af6 100644 --- a/game.js +++ b/game.js @@ -16,6 +16,8 @@ const boardElement = document.querySelector("#board"); const messageElement = document.querySelector("#message"); const turnBadge = document.querySelector("#turnBadge"); const resetButton = document.querySelector("#resetButton"); +const practiceButton = document.querySelector("#practiceButton"); +const onlineButton = document.querySelector("#onlineButton"); const xCapturesElement = document.querySelector("#xCaptures"); const oCapturesElement = document.querySelector("#oCaptures"); const moveList = document.querySelector("#moveList"); @@ -30,13 +32,16 @@ let winningCells; let socket; let localPlayer = ""; let roomId = ""; -let multiplayerState = "connecting"; +let gameMode = "practice"; +let multiplayerState = "local"; +let botTimer = 0; function createBoard() { return Array.from({ length: size }, () => Array(size).fill(empty)); } function setupGame() { + clearBotTimer(); board = createBoard(); currentPlayer = "X"; captures = { X: 0, O: 0 }; @@ -48,9 +53,14 @@ function setupGame() { } function connectToServer() { + gameMode = "online"; + closeSocket(); + localPlayer = ""; + roomId = ""; const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; socket = new WebSocket(`${protocol}//${window.location.host}/ws`); setMultiplayerState("connecting", "Connecting to matchmaker..."); + updateModeButtons(); socket.addEventListener("open", () => { setMultiplayerState("waiting", "Connected. Waiting for another player..."); @@ -61,19 +71,50 @@ function connectToServer() { handleServerMessage(payload); }); - socket.addEventListener("close", () => { + socket.addEventListener("close", (event) => { + if (event.currentTarget !== socket) { + return; + } + + if (gameMode !== "online") { + return; + } + localPlayer = ""; roomId = ""; setMultiplayerState("error", "Connection closed. Refresh to reconnect."); render(); }); - socket.addEventListener("error", () => { + socket.addEventListener("error", (event) => { + if (event.currentTarget !== socket) { + return; + } + setMultiplayerState("error", "Could not reach the multiplayer server."); render(); }); } +function startPracticeMode() { + gameMode = "practice"; + closeSocket(); + localPlayer = "X"; + roomId = ""; + multiplayerState = "local"; + setupGame(); + setMultiplayerState("local", "BOT practice. You are X. Random O replies after each move."); + updateModeButtons(); +} + +function closeSocket() { + if (socket && socket.readyState <= WebSocket.OPEN) { + socket.close(); + } + + socket = null; +} + function handleServerMessage(payload) { if (payload.type === "waiting") { localPlayer = ""; @@ -154,7 +195,7 @@ function render() { turnBadge.className = `turn-badge player-${currentPlayer.toLowerCase()}`; xCapturesElement.textContent = captures.X; oCapturesElement.textContent = captures.O; - resetButton.disabled = multiplayerState !== "paired"; + resetButton.disabled = gameMode === "online" && multiplayerState !== "paired"; } function turnLabel() { @@ -174,6 +215,10 @@ function turnLabel() { return "Offline"; } + if (multiplayerState === "local" && currentPlayer !== localPlayer) { + return "Bot turn"; + } + if (currentPlayer === localPlayer) { return `Your turn (${localPlayer})`; } @@ -182,7 +227,7 @@ function turnLabel() { } function canPlayCell(row, col) { - return multiplayerState === "paired" && + return (multiplayerState === "paired" || multiplayerState === "local") && !gameOver && localPlayer === currentPlayer && board[row][col] === empty; @@ -204,7 +249,15 @@ function playLocalMove(row, col) { return; } - sendToServer({ type: "move", roomId, row, col, player: localPlayer }); + if (gameMode === "online") { + sendToServer({ type: "move", roomId, row, col, player: localPlayer }); + return; + } + + if (!gameOver) { + setMultiplayerState("local", "Bot thinking..."); + botTimer = window.setTimeout(playBotMove, 360); + } } function applyMove(row, col, player, source) { @@ -223,22 +276,22 @@ function applyMove(row, col, player, source) { if (win.length > 0) { winningCells = win; gameOver = true; - setMultiplayerState("paired", `${player} wins with four in a row.`); + setMultiplayerState(activeState(), `${player} wins with four in a row.`); render(); return true; } if (isDraw()) { gameOver = true; - setMultiplayerState("paired", "Draw. The board is full."); + setMultiplayerState(activeState(), "Draw. The board is full."); render(); return true; } currentPlayer = opponentOf(player); const captureMessage = captured.length > 0 ? `${player} captured ${captured.length}. ` : ""; - const turnMessage = currentPlayer === localPlayer ? "Your turn." : `Waiting for ${currentPlayer}.`; - setMultiplayerState("paired", `${captureMessage}${turnMessage}`); + const turnMessage = currentPlayer === localPlayer ? "Your turn." : waitingMessage(); + setMultiplayerState(activeState(), `${captureMessage}${turnMessage}`); render(); flashCapturedCells(captured); @@ -249,6 +302,14 @@ function applyMove(row, col, player, source) { return true; } +function activeState() { + return gameMode === "practice" ? "local" : "paired"; +} + +function waitingMessage() { + return gameMode === "practice" ? "Bot turn." : `Waiting for ${currentPlayer}.`; +} + function captureFrom(row, col, player) { const opponent = opponentOf(player); const captured = []; @@ -340,6 +401,46 @@ function setMultiplayerState(state, text) { messageElement.textContent = text; } +function playBotMove() { + if (gameMode !== "practice" || gameOver || currentPlayer !== "O") { + return; + } + + const moves = legalMoves(); + if (moves.length === 0) { + return; + } + + const move = moves[Math.floor(Math.random() * moves.length)]; + applyMove(move.row, move.col, "O", "bot"); +} + +function legalMoves() { + const moves = []; + + for (let row = 0; row < size; row += 1) { + for (let col = 0; col < size; col += 1) { + if (board[row][col] === empty) { + moves.push({ row, col }); + } + } + } + + return moves; +} + +function clearBotTimer() { + if (botTimer) { + window.clearTimeout(botTimer); + botTimer = 0; + } +} + +function updateModeButtons() { + practiceButton.classList.toggle("active", gameMode === "practice"); + onlineButton.classList.toggle("active", gameMode === "online"); +} + function sendToServer(payload) { if (!socket || socket.readyState !== WebSocket.OPEN) { setMultiplayerState("error", "Connection lost. Refresh to reconnect."); @@ -363,14 +464,29 @@ function flashCapturedCells(captured) { } resetButton.addEventListener("click", () => { - if (multiplayerState !== "paired") { + if (gameMode === "online" && multiplayerState !== "paired") { return; } setupGame(); + + if (gameMode === "practice") { + setMultiplayerState("local", "BOT practice. You are X. Random O replies after each move."); + return; + } + setMultiplayerState("paired", `Game reset. You are ${localPlayer}. ${currentPlayer} plays first.`); sendToServer({ type: "reset", roomId }); }); -setupGame(); -connectToServer(); +practiceButton.addEventListener("click", startPracticeMode); +onlineButton.addEventListener("click", () => { + gameMode = "online"; + localPlayer = ""; + roomId = ""; + multiplayerState = "connecting"; + setupGame(); + connectToServer(); +}); + +startPracticeMode(); diff --git a/index.html b/index.html index e3fc144..bbd10ac 100644 --- a/index.html +++ b/index.html @@ -19,6 +19,11 @@ +
+ + +
+
X to play
Trap enemy marks between your marks. First to four wins.
@@ -42,8 +47,18 @@
-

RULE

-

Open the game in two browser windows. The first two connected players are paired automatically.

+

GOAL

+

Place marks on the 5x5 grid. First player to make 4 in a row horizontally, vertically, or diagonally wins.

+
+ +
+

CAPTURE

+

When your new mark traps enemy marks in a straight line between two of your marks, those enemy marks are removed.

+
+ +
+

MODES

+

BOT is local practice against random moves. NET pairs the first two connected players automatically.

    diff --git a/styles.css b/styles.css index 4c8000b..17a0fc4 100644 --- a/styles.css +++ b/styles.css @@ -89,6 +89,33 @@ button { gap: 12px; } +.mode-row { + display: flex; + gap: 8px; + margin-top: 12px; +} + +.mode-button { + min-width: 54px; + padding: 6px 10px; + border: 1px solid var(--line); + border-radius: 2px; + color: var(--muted); + background: #070b11; + cursor: pointer; + font-size: 0.72rem; + font-weight: 800; + text-transform: uppercase; +} + +.mode-button.active, +.mode-button:hover { + color: var(--bg); + border-color: var(--o); + background: var(--o); + box-shadow: 0 0 16px rgba(71, 229, 188, 0.35); +} + .eyebrow { margin: 0 0 4px; color: var(--o); @@ -191,6 +218,11 @@ h1 { border-color: rgba(124, 255, 107, 0.5); } +.network-status.local { + color: var(--o); + border-color: rgba(71, 229, 188, 0.5); +} + .network-status.waiting, .network-status.connecting { color: var(--warn); @@ -311,6 +343,10 @@ h1 { background: #070b11; } +.rule-card + .rule-card { + margin-top: 8px; +} + .rule-card h2 { margin: 0 0 7px; color: var(--line-bright);