Add local bot practice mode
This commit is contained in:
136
game.js
136
game.js
@@ -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();
|
||||||
|
|||||||
19
index.html
19
index.html
@@ -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>
|
||||||
|
|||||||
36
styles.css
36
styles.css
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user