Add local bot practice mode

This commit is contained in:
2026-05-08 13:22:53 -04:00
parent c26a833eda
commit 4631f9b7c5
3 changed files with 182 additions and 15 deletions

136
game.js
View File

@@ -16,6 +16,8 @@ const boardElement = document.querySelector("#board");
const messageElement = document.querySelector("#message"); const messageElement = document.querySelector("#message");
const turnBadge = document.querySelector("#turnBadge"); const turnBadge = document.querySelector("#turnBadge");
const resetButton = document.querySelector("#resetButton"); const resetButton = document.querySelector("#resetButton");
const practiceButton = document.querySelector("#practiceButton");
const onlineButton = document.querySelector("#onlineButton");
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");
@@ -30,13 +32,16 @@ let winningCells;
let socket; let socket;
let localPlayer = ""; let localPlayer = "";
let roomId = ""; let roomId = "";
let multiplayerState = "connecting"; let gameMode = "practice";
let multiplayerState = "local";
let botTimer = 0;
function createBoard() { function createBoard() {
return Array.from({ length: size }, () => Array(size).fill(empty)); return Array.from({ length: size }, () => Array(size).fill(empty));
} }
function setupGame() { function setupGame() {
clearBotTimer();
board = createBoard(); board = createBoard();
currentPlayer = "X"; currentPlayer = "X";
captures = { X: 0, O: 0 }; captures = { X: 0, O: 0 };
@@ -48,9 +53,14 @@ function setupGame() {
} }
function connectToServer() { function connectToServer() {
gameMode = "online";
closeSocket();
localPlayer = "";
roomId = "";
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
socket = new WebSocket(`${protocol}//${window.location.host}/ws`); socket = new WebSocket(`${protocol}//${window.location.host}/ws`);
setMultiplayerState("connecting", "Connecting to matchmaker..."); setMultiplayerState("connecting", "Connecting to matchmaker...");
updateModeButtons();
socket.addEventListener("open", () => { socket.addEventListener("open", () => {
setMultiplayerState("waiting", "Connected. Waiting for another player..."); setMultiplayerState("waiting", "Connected. Waiting for another player...");
@@ -61,19 +71,50 @@ function connectToServer() {
handleServerMessage(payload); handleServerMessage(payload);
}); });
socket.addEventListener("close", () => { socket.addEventListener("close", (event) => {
if (event.currentTarget !== socket) {
return;
}
if (gameMode !== "online") {
return;
}
localPlayer = ""; localPlayer = "";
roomId = ""; roomId = "";
setMultiplayerState("error", "Connection closed. Refresh to reconnect."); setMultiplayerState("error", "Connection closed. Refresh to reconnect.");
render(); render();
}); });
socket.addEventListener("error", () => { socket.addEventListener("error", (event) => {
if (event.currentTarget !== socket) {
return;
}
setMultiplayerState("error", "Could not reach the multiplayer server."); setMultiplayerState("error", "Could not reach the multiplayer server.");
render(); 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) { function handleServerMessage(payload) {
if (payload.type === "waiting") { if (payload.type === "waiting") {
localPlayer = ""; localPlayer = "";
@@ -154,7 +195,7 @@ 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"; resetButton.disabled = gameMode === "online" && multiplayerState !== "paired";
} }
function turnLabel() { function turnLabel() {
@@ -174,6 +215,10 @@ function turnLabel() {
return "Offline"; return "Offline";
} }
if (multiplayerState === "local" && currentPlayer !== localPlayer) {
return "Bot turn";
}
if (currentPlayer === localPlayer) { if (currentPlayer === localPlayer) {
return `Your turn (${localPlayer})`; return `Your turn (${localPlayer})`;
} }
@@ -182,7 +227,7 @@ function turnLabel() {
} }
function canPlayCell(row, col) { function canPlayCell(row, col) {
return multiplayerState === "paired" && return (multiplayerState === "paired" || multiplayerState === "local") &&
!gameOver && !gameOver &&
localPlayer === currentPlayer && localPlayer === currentPlayer &&
board[row][col] === empty; board[row][col] === empty;
@@ -204,7 +249,15 @@ function playLocalMove(row, col) {
return; return;
} }
if (gameMode === "online") {
sendToServer({ type: "move", roomId, row, col, player: localPlayer }); 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) { function applyMove(row, col, player, source) {
@@ -223,22 +276,22 @@ function applyMove(row, col, player, source) {
if (win.length > 0) { if (win.length > 0) {
winningCells = win; winningCells = win;
gameOver = true; gameOver = true;
setMultiplayerState("paired", `${player} wins with four in a row.`); setMultiplayerState(activeState(), `${player} wins with four in a row.`);
render(); render();
return true; return true;
} }
if (isDraw()) { if (isDraw()) {
gameOver = true; gameOver = true;
setMultiplayerState("paired", "Draw. The board is full."); setMultiplayerState(activeState(), "Draw. The board is full.");
render(); render();
return true; return true;
} }
currentPlayer = opponentOf(player); currentPlayer = opponentOf(player);
const captureMessage = captured.length > 0 ? `${player} captured ${captured.length}. ` : ""; const captureMessage = captured.length > 0 ? `${player} captured ${captured.length}. ` : "";
const turnMessage = currentPlayer === localPlayer ? "Your turn." : `Waiting for ${currentPlayer}.`; const turnMessage = currentPlayer === localPlayer ? "Your turn." : waitingMessage();
setMultiplayerState("paired", `${captureMessage}${turnMessage}`); setMultiplayerState(activeState(), `${captureMessage}${turnMessage}`);
render(); render();
flashCapturedCells(captured); flashCapturedCells(captured);
@@ -249,6 +302,14 @@ function applyMove(row, col, player, source) {
return true; return true;
} }
function activeState() {
return gameMode === "practice" ? "local" : "paired";
}
function waitingMessage() {
return gameMode === "practice" ? "Bot turn." : `Waiting for ${currentPlayer}.`;
}
function captureFrom(row, col, player) { function captureFrom(row, col, player) {
const opponent = opponentOf(player); const opponent = opponentOf(player);
const captured = []; const captured = [];
@@ -340,6 +401,46 @@ function setMultiplayerState(state, text) {
messageElement.textContent = 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) { function sendToServer(payload) {
if (!socket || socket.readyState !== WebSocket.OPEN) { if (!socket || socket.readyState !== WebSocket.OPEN) {
setMultiplayerState("error", "Connection lost. Refresh to reconnect."); setMultiplayerState("error", "Connection lost. Refresh to reconnect.");
@@ -363,14 +464,29 @@ function flashCapturedCells(captured) {
} }
resetButton.addEventListener("click", () => { resetButton.addEventListener("click", () => {
if (multiplayerState !== "paired") { if (gameMode === "online" && multiplayerState !== "paired") {
return; return;
} }
setupGame(); 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.`); setMultiplayerState("paired", `Game reset. You are ${localPlayer}. ${currentPlayer} plays first.`);
sendToServer({ type: "reset", roomId }); sendToServer({ type: "reset", roomId });
}); });
practiceButton.addEventListener("click", startPracticeMode);
onlineButton.addEventListener("click", () => {
gameMode = "online";
localPlayer = "";
roomId = "";
multiplayerState = "connecting";
setupGame(); setupGame();
connectToServer(); connectToServer();
});
startPracticeMode();

View File

@@ -19,6 +19,11 @@
</button> </button>
</header> </header>
<div class="mode-row" aria-label="Game mode">
<button id="practiceButton" class="mode-button active" type="button">BOT</button>
<button id="onlineButton" class="mode-button" type="button">NET</button>
</div>
<div class="status-row" aria-live="polite"> <div class="status-row" aria-live="polite">
<div id="turnBadge" class="turn-badge player-x">X to play</div> <div id="turnBadge" class="turn-badge player-x">X to play</div>
<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>
@@ -42,8 +47,18 @@
</div> </div>
<div class="rule-card"> <div class="rule-card">
<h2>RULE</h2> <h2>GOAL</h2>
<p>Open the game in two browser windows. The first two connected players are paired automatically.</p> <p>Place marks on the 5x5 grid. First player to make 4 in a row horizontally, vertically, or diagonally wins.</p>
</div>
<div class="rule-card">
<h2>CAPTURE</h2>
<p>When your new mark traps enemy marks in a straight line between two of your marks, those enemy marks are removed.</p>
</div>
<div class="rule-card">
<h2>MODES</h2>
<p>BOT is local practice against random moves. NET pairs the first two connected players 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>

View File

@@ -89,6 +89,33 @@ button {
gap: 12px; 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 { .eyebrow {
margin: 0 0 4px; margin: 0 0 4px;
color: var(--o); color: var(--o);
@@ -191,6 +218,11 @@ h1 {
border-color: rgba(124, 255, 107, 0.5); 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.waiting,
.network-status.connecting { .network-status.connecting {
color: var(--warn); color: var(--warn);
@@ -311,6 +343,10 @@ h1 {
background: #070b11; background: #070b11;
} }
.rule-card + .rule-card {
margin-top: 8px;
}
.rule-card h2 { .rule-card h2 {
margin: 0 0 7px; margin: 0 0 7px;
color: var(--line-bright); color: var(--line-bright);