diff --git a/backend/app.py b/backend/app.py index a033676..9d76fb6 100644 --- a/backend/app.py +++ b/backend/app.py @@ -4,26 +4,67 @@ import numpy as np import mnk, mcts_demo -print (np.__version__) +# Libraries for SocketIO (Live Connection with Server) +from flask import render_template, session +from flask_socketio import SocketIO + app = Flask(__name__) -CORS(app) +socketio = SocketIO(app) +#CORS(app) + +# ======================================================== # +# Auxiliary Functions # +# ======================================================== # + +def unpack(dimensions): + """Given a string of the form "nxm", returns a tuple (n, m). + + Args: + dimensions (str): A string of the form "nxm". + + Returns: + typle: A tuple (n, m). + """ + size = str(dimensions) + return tuple(map(int, size.split('x'))) + +def toClientMatrix(board): + """Given a board object, return its matrix representation + to be interpreted by the client. (1 counter-clockwise rotation) + of the raw board matrix. (1 = 'X', -1 = 'O', 0 = ' ') + + Args: + board (mnk.Board): A board object. + + Returns: + np.ndarray: Matrix representation of the board. + """ + return np.rot90(board.board).tolist() -@app.route('/', methods=['GET']) + +# ======================================================== # +# Main Routing # +# ======================================================== # + +@app.route('/') def index(): - return send_from_directory('../frontend/', 'index.html') + return send_from_directory('../frontend/', 'index.html') @app.route('/') def serve(webpage): return send_from_directory('../frontend/', webpage) -# Converts size "nxm" to a tuple -def unpack(dimensions): - size = str(dimensions) - return tuple(map(int, size.split('x'))) + +# ======================================================== # +# Get Boards (APIs) # +# ======================================================== # @app.route('/board/random//') def random_nxm(dimensions): + """Return an random matrix with 1s, 0s, and -1s of the + given dimensions. = string of the form "nxm". + """ size = unpack(dimensions) # Return (n,m) matrix with random numbers [-1, 0, 1] @@ -36,16 +77,117 @@ def random_nxm(dimensions): # Return the matrix as a JSON object, with name "board" return jsonify(board=matrix) + @app.route('/board/empty//') def empty_nxm(dimensions): + """Return an empty matrix of the given dimensions. + = string of the form "nxm". + """ size = unpack(dimensions) return jsonify(board=np.zeros(size).tolist()) -@app.route('/play', methods=['POST']) -def play(): - currentBoard = request.json - return '' +# ======================================================== # +# User Events (Web Sockets) # +# ======================================================== # + +@socketio.on("connection") +def new_connection(json): + print("New Connection") + # route = json['route'] + # print(route) + # Start a new game (7x7x3 Default) + session["board"] = mnk.Board(7, 3, 3) + + #socketio.emit("board_update", session["board"].board.tolist()) + + print(session["board"].board) + +@socketio.on("new_game") +def new_game(json): + print("New Game") + + print(json['k']) + # { m: m, n:n, k: k } + m = int(json['m']) + n = int(json['n']) + k = int(json['k']) + + # Start a new game + session["board"] = mnk.Board(m, n, k) + print(session["board"].board) + socketio.emit("board_update", session["board"].board.tolist()) + + +@socketio.on("user_move") +def user_move(json): + board = session["board"] + iterations = 1000 # Hard Coded for now + # (TODO: Change iterations to user input. Send as JSON from client) + + # Get the user's move. Json = {i : row, j : column} + i = json["i"] + j = json["j"] + + print("User Move:", i, j) + print("Board Max:", board.m, board.n) + # ======================================================== # + # Note: the moves are somewhat messed up, because the board is + # represented as a matrix differently in the server and + # client. To fix it we do the following: + + # 1. Invert i <-> j (Could have done this above, but it's a bit more clear this way) + # i, j = j + 1, board.m - i + + # 2 Invert the board vertically (i.e. invert j) + #j = board.m - j - 1 + # + # End of Note :) + # ======================================================== # + print("User Move (Change):", i, j) + print("Board Max:", board.m, board.n) + # Make the move on the board + board.move(i, j) # <---- + print(board) + print(board.board) + print(toClientMatrix(board)) + + # Check user's move for win + if board.who_won() != 2: + # User won + socketio.emit("win", 1) + return + + # Get move from MCTS + print('AI is thinking') + root = mcts_demo.Node() + if root.isLeaf: + root.expand(board.legal_moves()) + + root = mcts_demo.AI(board, root, iterations) + board.move(*root.last_move) + + # Check AI's move for win + if board.who_won() != 2: + socketio.emit("win", 0) + + socketio.emit("board_update", board.board.tolist()) + + # Send the board to the client + # message = { + # "board": toClientMatrix(board), + # "who_won": board.who_won() + # } + + + + +# ======================================================== # +# Start Server # +# ======================================================== # if __name__ == '__main__': - app.run(debug=True) \ No newline at end of file + # app.run(debug=True) + socketio.run(app) + + diff --git a/backend/requirements.txt b/backend/requirements.txt index e520236..7125636 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -2,3 +2,5 @@ Flask-Cors==3.0.10 Flask==2.1.1 gunicorn==20.1.0 numpy==1.22.2 +simple-websocket===0.5.2 +Flask-SocketIO===5.1.1 \ No newline at end of file diff --git a/frontend/display-board.js b/frontend/display-board.js index bc8a587..a66b482 100644 --- a/frontend/display-board.js +++ b/frontend/display-board.js @@ -1,56 +1,66 @@ -env = 'http://127.0.0.1:5000' -var n = 7 -var m = 7 +env = "https://team-game-bot.herokuapp.com/"; +var m = 7; +var n = 3; +var k = 3; main(); async function main() { - board = await getEmptyBoard(env, n, m) - displayBoard(board) + board = await getEmptyBoard(env, m, n); + displayBoard(board); - // Just testing this - await postBoard(board) + // Just testing this + await postBoard(board); } async function postBoard(board) { - await fetch(`${env}/play`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(board) - }) + await fetch(`${env}/play`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(board), + }); } -async function getRandomBoard(env, n, m) { - return (await fetch(`${env}/board/random/${n}x${m}/`).then(response => { return response.json() })).board +async function getRandomBoard(env, m, n) { + return ( + await fetch(`${env}/board/random/${m}x${n}/`).then((response) => { + return response.json(); + }) + ).board; } -async function getEmptyBoard(env, n, m) { - return (await fetch(`${env}/board/empty/${n}x${m}/`).then(response => { return response.json() })).board +async function getEmptyBoard(env, m, n) { + return ( + await fetch(`${env}/board/empty/${m}x${n}/`).then((response) => { + return response.json(); + }) + ).board; } function displayBoard(board) { + var m = board.length; + var n = board[0].length; - var n = board.length - var m = board[0].length + let cellSize = 100 - Math.min((Math.max(m, n) - 3) * 8, 50); + document.documentElement.style.setProperty("--cell-size", `${cellSize}px`); - let cellSize = 100 - Math.min(((Math.max(n, m) - 3) * 8), 50) - document.documentElement.style.setProperty("--cell-size", `${cellSize}px`) + boardHtml = document.getElementById("board"); - boardHtml = document.getElementById('board') + boardHtml.style.setProperty("grid-template-columns", `repeat(${n}, 1fr)`); - boardHtml.style.setProperty("grid-template-columns", `repeat(${m}, 1fr)`) + boardHtml.style.setProperty("height", `${m} * var(--cell-size) + ${m - 1} * var(--gap-size)`); + boardHtml.style.setProperty("width", `${n} * var(--cell-size) + ${n - 1} * var(--gap-size)`); - boardHtml.style.setProperty("width", `${m} * var(--cell-size) + ${m-1} * var(--gap-size)`) - boardHtml.style.setProperty("height", `${n} * var(--cell-size) + ${n-1} * var(--gap-size)`) + let cellType = [" O", "", " X"]; + let display = ""; - let cellType = [" O", "", " X"] - let display = "" + for (var i = 0; i < m; i++) + for (var j = 0; j < n; j++) + display += `
`; - for (var i = 0; i < n; i++) - for (var j = 0; j < m; j++) - display += `
` - - boardHtml.innerHTML = display -} \ No newline at end of file + boardHtml.innerHTML = display; +} diff --git a/frontend/index.html b/frontend/index.html index 9bae58e..fb53c6c 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1,23 +1,66 @@ + + + + + + + + + + + Tic-Tac-Toe + - - - - - - - Tic-Tac-Toe - + + +
+ +

Sgv Image: MNK Game

+ +
+
+
+
+ M + +
+
+
+
+ N + +
+
+
+
+ K + +
+
- - TODO: Win detection -
+
+ +
+ + + -
-
- -
- +
+
+
+ +
+ diff --git a/frontend/script.js b/frontend/script.js index b0b9f2d..0241a48 100644 --- a/frontend/script.js +++ b/frontend/script.js @@ -1,87 +1,123 @@ -var cells, board, winningMessage, winningMessageText, newGameButton, xIsNext +var cells, board, winningMessage, winningMessageText, newGameButton, xIsNext; // I wrote this terrible hacky code in this way because newGame() MUST trigger AFTER displayBoard() // has fully finished and all elements have been loaded onto the page, since it operates on those elements. // Adding a forced delay of 0.5s was just the easiest (but def not best) way to make it work window.onpageshow = (event) => { - setTimeout(() => { - newGame() - newGameButton.addEventListener('click', newGame) - }, 500); + setTimeout(() => { + console.log("Page has been shown"); + newGame(); + + newGameButton.addEventListener("click", newGame); + }, 500); }; -function newGame() { - cells = document.querySelectorAll('.cell') - board = document.getElementById('board') - winningMessage = document.querySelector('.winningMessage') - winningMessageText = document.querySelector('.winningMessageText') - newGameButton = document.querySelector('.newGameButton') - xIsNext = true - - board.classList.add('X') - board.classList.remove('O') - winningMessage.classList.remove('show') - - cells.forEach(cell => { - cell.classList.remove('X') - cell.classList.remove('O') - cell.addEventListener('click', handleClick, {once: true}) - }) +// Change size of board based on user input +document.getElementById("start").addEventListener("click", () => { + changeMNK( + document.getElementById("m").value, + document.getElementById("n").value, + document.getElementById("k").value + ); +}); + +function updateBoard(board) { + displayBoard(board); + + cells = document.querySelectorAll(".cell"); + + cells.forEach((cell) => { + cell.addEventListener("click", handleClick, { once: true }); + }); } -function handleClick(e) { - // place mark - const cell = e.target - const player = xIsNext ? 'X' : 'O' - cell.classList.add(player) - // check for win - if (checkForWin(player)) { - winningMessageText.innerText = `${xIsNext ? 'X' : 'O'} Wins!` - winningMessage.classList.add('show') - } - // check for draw - else if ([...cells].every(cell => { - return cell.classList.contains('X') || cell.classList.contains('O') - })) { - winningMessageText.innerText = `It's a draw.` - winningMessage.classList.add('show') - } - // switch turns - else - { - xIsNext = !xIsNext - if (xIsNext) { - board.classList.add('X') - board.classList.remove('O') - } else { - board.classList.add('O') - board.classList.remove('X') - } - } +function newGame() { + console.log("Page has been shown 2"); + cells = document.querySelectorAll(".cell"); + board = document.getElementById("board"); + winningMessage = document.querySelector(".winningMessage"); + winningMessageText = document.querySelector(".winningMessageText"); + newGameButton = document.querySelector(".newGameButton"); + xIsNext = true; + + board.classList.add("X"); + board.classList.remove("O"); + winningMessage.classList.remove("show"); + + cells.forEach((cell) => { + cell.classList.remove("X"); + cell.classList.remove("O"); + cell.addEventListener("click", handleClick, { once: true }); + }); + + // Post to server signal that new game has been started + socket.emit("new_game", { m: m, n: n, k: k }); } -// TODO -function checkForWin(player) { +function getCoordinates(str) { + // Given a string in the form "i, j" return the coordinates as an array [i, j] + var coordinates = str.split(","); + return [parseInt(coordinates[0]), parseInt(coordinates[1])]; +} - return false; +async function changeMNK(new_m, new_n, new_k) { + board = await getEmptyBoard(env, m, n); + displayBoard(board); + newGame(); - /* - var m = 7, n = 7 - var k_in_a_row = 3 + // Update global variables + m = new_m; + n = new_n; + k = new_k; - for (var r = 0; r < m; r++) - for (var c = 0; c < n; c++) - if (winFromPos(r, c, k_in_a_row)) - return true - */ + // Post to server signal that new n, m, k have been chosen + socket.emit("new_game", { m: m, n: n, k: k }); } -// TODO -function winFromPos(r, c, k_in_a_row) { - /* - var m = 7, n = 7 - var k_in_a_row = 3 +function handleClick(e) { + // place mark + const cell = e.target; + const player = xIsNext ? "X" : "O"; + cell.classList.add(player); + + // check for draw + if ( + [...cells].every((cell) => { + return cell.classList.contains("X") || cell.classList.contains("O"); + }) + ) { + winningMessageText.innerText = `It's a draw.`; + winningMessage.classList.add("show"); + } + // switch turns + else { + // xIsNext = !xIsNext; + if (xIsNext) { + board.classList.add("X"); + board.classList.remove("O"); + } else { + board.classList.add("O"); + board.classList.remove("X"); + } + } - cells[index].classList.contains(player) - */ + // Post to move to server. Send coordinates previously encoded in cell ID. + const coordinates = getCoordinates(e.target.getAttribute("id")); + socket.emit("user_move", { i: coordinates[0], j: coordinates[1] }); } + +// check for win +function displayWinningMessage(who_won) { + winningMessageText.innerText = `${who_won ? "X" : "O"} Wins!`; + winningMessage.classList.add("show"); +} + +// Receives board from server and displays it +socket.on("board_update", (board) => { + console.log(board); + updateBoard(board); +}); + +socket.on("win", (who_won) => { + displayWinningMessage(who_won); +}); diff --git a/frontend/style.css b/frontend/style.css index ae33a34..3f6ebb6 100644 --- a/frontend/style.css +++ b/frontend/style.css @@ -1,29 +1,81 @@ :root { - --cell-size: 100px; /* Property gets modified by display-board.js */ - --gap-size: 4px; - --mark-size: calc(var(--cell-size) * .9); + --cell-size: 100px; /* Property gets modified by display-board.js */ + --gap-size: 4px; + --mark-size: calc(var(--cell-size) * 0.9); +} + +.btn:focus, +.btn:active { + outline: none !important; + box-shadow: none; +} + +.ucfai { + height: 80px; + display: inline; + margin-top: -0.2em; +} + +.input-mnk { + margin-top: -2em; + margin-left: 7em; +} + +input { + border: 1px solid #ccc; + text-align: center; +} + +/* Chrome, Safari, Edge, Opera */ +input::-webkit-outer-spin-button, +input::-webkit-inner-spin-button { + -webkit-appearance: none; + margin: 0; +} + +/* Firefox */ +input[type="number"] { + -moz-appearance: textfield; +} + +#start { + width: 5.5em; +} + +body { + /* Black but not too dark */ + background-color: #111; +} + +h2 { + /* White but not too white */ + color: #eee; } .board { - position: absolute; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); - display: grid; - grid-gap: var(--gap-size); - width: calc(3 * var(--cell-size) + 2 * var(--gap-size)); /* Property gets modified by display-board.js */ - height: calc(3 * var(--cell-size) + 2 * var(--gap-size)); /* Property gets modified by display-board.js */ - background-color: black; - grid-template-columns: repeat(3, 1fr); /* Property gets modified by display-board.js */ + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + display: grid; + grid-gap: var(--gap-size); + width: calc( + 3 * var(--cell-size) + 2 * var(--gap-size) + ); /* Property gets modified by display-board.js */ + height: calc( + 3 * var(--cell-size) + 2 * var(--gap-size) + ); /* Property gets modified by display-board.js */ + background-color: #eee; + grid-template-columns: repeat(3, 1fr); /* Property gets modified by display-board.js */ } .cell { - width: var(--cell-size); - height: var(--cell-size); - background-color: white; - display: flex; - justify-content: center; - align-items: center; + width: var(--cell-size); + height: var(--cell-size); + background-color: #111; + display: flex; + justify-content: center; + align-items: center; } /* BEGIN MARK STYLING */ @@ -32,85 +84,87 @@ .cell.X::after, .board.X .cell:not(.X):not(.O):hover::before, .board.X .cell:not(.X):not(.O):hover::after { - content: ''; - position: absolute; - width: calc(var(--mark-size) * .15); - height: var(--mark-size); - background-color: black; + content: ""; + position: absolute; + width: calc(var(--mark-size) * 0.15); + height: var(--mark-size); + /* background-color: black; */ + background-color: #ffc904; } .board.X .cell:not(.X):not(.O):hover::before, .board.X .cell:not(.X):not(.O):hover::after { - background-color: lightgray; + /* Darker than #ffc904; */ + background-color: #5b4807; } .cell.X::before, .board.X .cell:not(.X):not(.O):hover::before { - transform: rotate(45deg); + transform: rotate(45deg); } .cell.X::after, .board.X .cell:not(.X):not(.O):hover::after { - transform: rotate(-45deg); + transform: rotate(-45deg); } .cell.O::before, .cell.O::after, .board.O .cell:not(.X):not(.O):hover::before, .board.O .cell:not(.X):not(.O):hover::after { - content: ''; - position: absolute; - border-radius: 50%; + content: ""; + position: absolute; + border-radius: 50%; } .cell.O::before, .board.O .cell:not(.X):not(.O):hover::before { - width: var(--mark-size); - height: var(--mark-size); - background-color: black; + width: var(--mark-size); + height: var(--mark-size); + background-color: white; } .board.O .cell:not(.X):not(.O):hover::before { - background-color: lightgray; + background-color: lightgray; } .cell.O::after, .board.O .cell:not(.X):not(.O):hover::after { - width: calc(var(--mark-size) * .7); - height: calc(var(--mark-size) * .7); - background-color: white; + width: calc(var(--mark-size) * 0.7); + height: calc(var(--mark-size) * 0.7); + background-color: #111; } /* END MARK STYLING */ .winningMessage { - display: none; - position: fixed; - top: 0; - left: 0; - right: 0; - bottom: 0; - background-color: rgba(0, 0, 0, .9); - justify-content: center; - align-items: center; - color: white; - font-size: 5rem; - flex-direction: column; + display: none; + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(0, 0, 0, 0.9); + justify-content: center; + align-items: center; + color: white; + font-size: 5rem; + flex-direction: column; } .winningMessage button { - font-size: 3rem; - background-color: white; - border: 1px solid black; - padding: .25em .5em; + font-size: 3rem; + background-color: white; + border: 1px solid black; + padding: 0.25em 0.5em; } .winningMessage button:hover { - background-color: black; - color: white; - border-color: white; + background-color: black; + color: white; + border-color: white; } .winningMessage.show { - display: flex; + display: flex; }