Tic-Tac-Toe with JavaScript: Building the User Interface | Ali Alaa - Front-end Web Developerhttps://alialaa.com/blog/tic-tac-toe-js-ui
在上一部分中,我们为计算机玩家创建了一个 JavaScript 类。在最后一部分中,我们将为井字游戏棋盘构建一个简单的用户界面。我们将使用前面部分中创建的类和方法来模拟具有一定搜索深度和起始玩家的游戏。
这是 3 部分系列的第二部分。您可以在下面找到其他部分的列表:
我们的 HTML 标记将非常简单,只是一个 id 为 board 的 div。这个 div 将在我们的 JavaScript 代码中填充单元格。除此之外,我们将为起始玩家和深度添加几个下拉菜单。我们还将添加一个新的游戏按钮。最后,让我们在根文件夹中创建一个style.css文件,并使用链接标签包含它。我们在根文件夹中的 index.html 最终应该是这样的:
Tic Tac Toe
让我们在根文件夹中创建一个新文件并将其命名为helpers.js。在这个文件中,我们将导出一些在构建 UI 时有用的函数。首先,我们将在构建 UI 期间向 HTML 元素添加和删除类。所以我从 Jake Trent 的博客中借用了一些函数来做到这一点。所以让我们在helpers.js中添加这些:
//Helpers (from http://jaketrent.com/post/addremove-classes-raw-javascript/)
export function hasClass(el, className) {
if (el.classList) return el.classList.contains(className);
else return !!el.className.match(new RegExp("(\\s|^)" + className + "(\\s|$)"));
}
export function addClass(el, className) {
if (el.classList) el.classList.add(className);
else if (!hasClass(el, className)) el.className += " " + className;
}
export function removeClass(el, className) {
if (el.classList) el.classList.remove(className);
else if (hasClass(el, className)) {
var reg = new RegExp("(\\s|^)" + className + "(\\s|$)");
el.className = el.className.replace(reg, " ");
}
}
除此之外,我们将定义另一个辅助函数,它接受从isTerminal()方法返回的对象并使用方向(行/列/对角线)和行/列号(1/2/3)或对角线名称(主/计数器)添加某个类到棋盘。本类将帮助我们使用 CSS 为获胜单元格设置线条动画。例如,如果获胜者在水平的第一行获胜,那么类将是h-1。如果获胜者在主对角线上获胜,则该类将是d-main等等。我们最终在添加另一个类(fullLine)之前设置了一个小的超时,它将线的宽度/高度从 0 转换为 100%,以便我们可以制作动画。
//Helper function that takes the object returned from isTerminal() and adds a
//class to the board that will handle drawing the winning line's animation
export function drawWinningLine(statusObject) {
if (!statusObject) return;
const { winner, direction, row, column, diagonal } = statusObject;
if (winner === "draw") return;
const board = document.getElementById("board");
addClass(board, `${direction.toLowerCase()}-${row || column || diagonal}`);
setTimeout(() => {
addClass(board, "fullLine");
}, 50);
}
现在是时候创建一个负责创建新游戏的函数了。newGame函数将接收两个参数;最大深度和起始玩家(人类为 1,计算机为 0)。我们将实例化一个具有给定最大深度的新玩家和一个新的空棋盘。之后,我们将清除以前游戏中棋盘 div 上的所有类,并用单元格 div 填充它。然后我们将填充的单元格存储在一个数组中,以便我们可以循环它们并附加点击事件。我们还将初始化一些稍后将使用的变量,这些变量是起始玩家,无论人类是最大化还是最小化以及当前玩家轮流。所以让我们在入口点(script.js)中导入我们需要的类和函数并定义我们的 newGame 函数:
import Board from "./classes/board.js";
import Player from "./classes/player.js";
import { drawWinningLine, hasClass, addClass } from "./helpers.js";
//Starts a new game with a certain depth and a startingPlayer of 1 if human is going to start
function newGame(depth = -1, startingPlayer = 1) {
//Instantiating a new player and an empty board
const player = new Player(parseInt(depth));
const board = new Board(["", "", "", "", "", "", "", "", ""]);
//Clearing all #Board classes and populating cells HTML
const boardDIV = document.getElementById("board");
boardDIV.className = "";
boardDIV.innerHTML = `
`;
//Storing HTML cells in an array
const htmlCells = [...boardDIV.querySelector(".cells-wrap").children];
//Initializing some variables for internal use
const starting = parseInt(startingPlayer),
maximizing = starting;
let playerTurn = starting;
}
在下一步中,我们将检查计算机是否会启动。如果是这样,我们将选择一个随机单元格,只要它是角单元格或中心单元格,而不是在空板上运行性能密集型递归 getBestMove 函数,因为边缘不是开始的好地方。我们将假设最大化玩家总是 X,最小化玩家是 O。此外,我们将向单元格添加一个x或o类,以便我们可以在 CSS 中使用它。
//If computer is going to start, choose a random cell as long as it is the center or a corner
if (!starting) {
const centerAndCorners = [0, 2, 4, 6, 8];
const firstChoice = centerAndCorners[Math.floor(Math.random() * centerAndCorners.length)];
const symbol = !maximizing ? "x" : "o";
board.insert(symbol, firstChoice);
addClass(htmlCells[firstChoice], symbol);
playerTurn = 1; //Switch turns
}
最后,在我们的 newGame 函数中,我们将点击事件附加到每个单元格。在循环我们的棋盘状态时,我们会将一个点击事件附加到我们存储在htmlCells中的相应 HTML 单元格上。在事件处理程序内部,如果单击的单元格被占用或游戏结束或轮不到人类,我们将退出该函数。否则,我们会将符号插入单元格并检查此移动是否是最终获胜并相应地绘制获胜线。如果不是最终移动,我们将切换轮次并使用getBestMove模拟计算机的轮次并进行相同的最终检查。
//Adding Click event listener for each cell
board.state.forEach((cell, index) => {
htmlCells[index].addEventListener(
"click",
() => {
//If cell is already occupied or the board is in a terminal state or it's not humans turn, return false
if (
hasClass(htmlCells[index], "x") ||
hasClass(htmlCells[index], "o") ||
board.isTerminal() ||
!playerTurn
)
return false;
const symbol = maximizing ? "x" : "o"; //Maximizing player is always 'x'
//Update the Board class instance as well as the Board UI
board.insert(symbol, index);
addClass(htmlCells[index], symbol);
//If it's a terminal move and it's not a draw, then human won
if (board.isTerminal()) {
drawWinningLine(board.isTerminal());
}
playerTurn = 0; //Switch turns
//Get computer's best move and update the UI
player.getBestMove(board, !maximizing, best => {
const symbol = !maximizing ? "x" : "o";
board.insert(symbol, parseInt(best));
addClass(htmlCells[best], symbol);
if (board.isTerminal()) {
drawWinningLine(board.isTerminal());
}
playerTurn = 1; //Switch turns
});
},
false
);
if (cell) addClass(htmlCells[index], cell);
});
我们现在只需要在页面加载或用户单击新游戏按钮时初始化一个新游戏。请注意,如果单击新游戏按钮,我们将使用用户选择的选项初始化游戏:
document.addEventListener("DOMContentLoaded", () => {
//Start a new game when page loads with default values
const depth = -1;
const startingPlayer = 1;
newGame(depth, startingPlayer);
//Start a new game with chosen options when new game button is clicked
document.getElementById("newGame").addEventListener("click", () => {
const startingDIV = document.getElementById("starting");
const starting = startingDIV.options[startingDIV.selectedIndex].value;
const depthDIV = document.getElementById("depth");
const depth = depthDIV.options[depthDIV.selectedIndex].value;
newGame(depth, starting);
});
});
我们现在完成了 JavaScript 部分。我们现在需要用一点 CSS 来设置棋盘的样式。我在这里使用纯 CSS 没有任何预处理器:
* {
box-sizing: border-box;
}
body {
background-color: #0a0710;
}
.container {
max-width: 500px;
padding: 0 30px;
margin: 100px auto;
}
.field {
margin-bottom: 20px;
}
.field label {
color: #fff;
}
#board {
width: 100%;
padding-top: 100%;
position: relative;
margin-bottom: 30px;
}
#board .cells-wrap {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
flex-direction: row;
flex-wrap: wrap;
}
#board [class^="cell-"] {
height: 33.3333333%;
width: 33.3333333%;
border: 2px solid #0a0710;
background: #130f1e;
position: relative;
cursor: pointer;
color: #fff;
font-size: calc(18px + 5vw);
font-family: fantasy;
}
#board [class^="cell-"].x,
#board [class^="cell-"].o {
cursor: not-allowed;
}
#board [class^="cell-"].x:after {
content: "x";
}
#board [class^="cell-"].o:after {
content: "o";
}
#board:after {
content: "";
position: absolute;
background-color: #c11dd4;
transition: 0.7s;
}
/* Horizontal Lines */
#board[class^="h-"]:after {
width: 0%;
height: 3px;
left: 0;
transform: width translateY(-50%);
}
#board.fullLine[class^="h-"]:after {
width: 100%;
}
#board.h-1:after {
top: 16.6666666665%;
}
#board.h-2:after {
top: 50%;
}
#board.h-3:after {
top: 83.33333333%;
}
/* Vertical Lines */
#board[class^="v-"]:after {
width: 3px;
height: 0%;
top: 0;
transform: height translateX(-50%);
}
#board.fullLine[class^="v-"]:after {
height: 100%;
}
#board.v-1:after {
left: 16.6666666665%;
}
#board.v-2:after {
left: 50%;
}
#board.v-3:after {
left: 83.33333333%;
}
/* Diagonal Lines */
#board[class^="d-main"]:after {
width: 3px;
height: 0%;
left: 0;
top: 0;
transform: rotateZ(-45deg);
transform-origin: 50% 0;
transition: height 0.7s;
}
#board.fullLine[class^="d-main"]:after {
height: 140%;
}
#board[class^="d-counter"]:after {
height: 0%;
width: 3px;
right: 0;
top: 0;
transform: rotateZ(45deg);
transform-origin: 50% 0;
transition: height 0.7s;
}
#board.fullLine[class^="d-counter"]:after {
height: 140%;
}
我们的最终输出应该是这样的: