上小学六年级的妹妹在零零星星地学习了几个月的 HTML、Css 和 JavaScript 之后,我终于开始教她如何编写一个最简单的井字棋游戏了。
每一种编程语言在学习阶段,都绕不开游戏开发这个话题。因为每一种编程语言都可以实现一些小游戏,这篇文章主要讲述如何使用纯原生的技术来实现一个网页井字棋,每个步骤和环节都是用最简单的方式实现,非常适合 JavaScript 初学者学习。
准备工作
由于妹妹还没有学习任何第三方库或者框架,所以我打算用纯原生的技术来实现井字棋游戏。
首先来创建基本的目录结构。
.
|____tic-tac-toe.js // 游戏主逻辑
|____index.html // 页面
|____style.css // 样式
|____lib // 自己编写的库
| |____$.js // 便于 DOM 操作的库
编写 $
为了便于获取 DOM 和创建 DOM,这里首先仿照 JQuery 编写一个非常简单的库。
$.js 文件的主要作用就是在全局的 window 对象上挂在一个 $ 属性。$ 属性本身是一个函数,用于获取 DOM 元素。
其次,$ 对象上还挂载了一个 createEle 方法,用于创建 DOM 对象。相对于原始的 document.createElement, craeteEle 方法还做了更多的事情,它可以传入一个选择器字符串来更快速的创建 DOM。
(function (global) {
/**
* 快速查找 dom
* @param {string} sel 选择器
* @return {undefined|Array|Element}
*/
function $(sel) {
if (typeof sel === "string") {
const nodes = document.querySelectorAll(sel);
if (nodes.length === 0) return;
if (nodes.length === 1) return nodes[0];
return nodes;
} else if (sel instanceof Element) {
return sel;
}
}
/**
* 创建元素
* @param {string} sel 选择器
* @param {object} data 配置对象
* @return {Element}
*/
$.createEle = function (sel, data) {
const idIdx = sel.indexOf("#");
const classIdx = sel.indexOf(".");
const hasId = idIdx > -1;
const hasClass = classIdx > -1;
const tagEndIdx = hasId ? idIdx : hasClass ? classIdx : sel.length - 1;
// 创建元素
const tag = sel.substr(0, tagEndIdx);
const node = document.createElement(tag);
// 设置ID
hasId &&
(node.id = sel.substring(
idIdx + 1,
hasClass ? classIdx : sel.length - 1
));
// 设置样式
classIdx > -1 &&
sel
.substr(classIdx, sel.length - 1)
.split(".")
.forEach((className) => {
className && node.classList.add(className);
});
// 设置属性
if (data && data.attrs) {
Object.keys(data.attrs).forEach((attr) => {
node.setAttribute(attr, data.attrs[attr]);
});
}
return node;
};
global.$ = $;
})(window);
编写主逻辑
主逻辑的内容都放在 tic-tac-toe.js 文件中。
绘制棋盘
棋盘的绘制方法有两种,一种是在 html 中手动编写标签,但是这种做法比较 low。更加符合现代编程的方法是通过 JavaScript 来创建 DOM。
首先创建一个 TicTacToe 类,井字棋的棋盘绘制、绑定事件、胜负判定等逻辑全部都在这个类中实现。
TicTacToe 类中有一个 genCheckerboard 方法,用于创建棋盘,它接受两个参数,x 和 y,用于生成几行几列的棋盘。
class TicTacToe {
/**
* 生成棋盘
* @param {number} x 横轴
* @param {number} y 纵轴
*/
genCheckerboard(x, y) {
const board = [];
for (let i = 0; i < x; i++) {
// let row = $.createEle("div.row", { attr: {} });
let row = [];
for (let j = 0; j < y; j++) {
let cell = $.createEle("div.cell", {
attrs: {
"data-index": i + "." + j,
},
});
row.push(cell);
}
board.push(row);
}
return board;
}
}
TicTacToe 类的构造函数中接收一个 DOM 选择器或者 DOM 对象,类似于 Vue 的 el,用于作为渲染棋盘的容器。
class TicTacToe {
/**
* @param {string|Element} boardEl 棋盘根元素
*/
constructor(boardEl) {
this.boardEl = $(boardEl);
}
genCheckerboard(x, y) {
// ...
}
}
构造函数中还需要执行一些初始化的操作,这些操作主要有 3 步。
- 生成棋盘。
- 渲染棋盘。
- 给棋盘的每一个格子添加点击事件。
这些初始化的流程放在 _init 函数中。
class TicTacToe {
/**
* @param {string|Element} boardEl 棋盘根元素
*/
constructor(boardEl) {
this.boardEl = $(boardEl);
this._init();
}
_init() {
// 生成棋盘
// 渲染棋盘
// 绑定事件
}
genCheckerboard(x, y) {
// ...
}
}
生成棋盘
生成棋盘的逻辑比较简单,在前面已经实现了 genCheckerboard 方法,只需要调用它就可以生成棋盘了。
class TicTacToe {
/**
* @param {string|Element} boardEl 棋盘根元素
*/
constructor(boardEl) {
this.boardEl = $(boardEl);
this.grid;
this._init();
}
_init() {
// 生成棋盘
this.grid = this.genCheckerboard(3, 3);
// 渲染棋盘
// 绑定事件
}
genCheckerboard(x, y) {
// ...
}
}
将棋盘存储到 grid 中的作用是用于稍后渲染棋盘以及判断胜负用的。
渲染棋盘
渲染棋盘的逻辑就是通过遍历 grid,从 grid 中读取生成的 DOM 并添加到构造函数传入的棋盘容器元素中。
class TicTacToe {
/**
* @param {string|Element} boardEl 棋盘根元素
*/
constructor(boardEl) {
// ...
}
_init() {
// 生成棋盘
this.grid = this.genCheckerboard(3, 3);
// 渲染棋盘
this.render();
// 绑定事件
}
// 渲染
render() {
this.grid.forEach((row) => {
let rowEle = $.createEle("div.row");
rowEle.append(...row);
this.boardEl.append(rowEle);
});
}
genCheckerboard(x, y) {
// ...
}
}
绘制样式
样式的绘制比较简单,重置一下原始样式,将背景设置为黑色,设置一下每个格子的边框。
美观起见,将棋盘四周的边框去掉,并设置了一个简单的开场动画。
* {
box-sizing: border-box;
padding: 0;
margin: 0;
--border-color: rgb(88, 186, 172);
}
body {
height: 100vh;
width: 100vw;
background-color: #000000;
}
#board {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
}
@keyframes rowCreated {
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}
.row {
display: flex;
animation-duration: 1s;
animation-name: rowCreated;
}
.row:last-child > .cell {
border-bottom: none;
}
.cell {
width: 100px;
height: 100px;
border-bottom: 1px var(--border-color) solid;
border-right: 1px var(--border-color) solid;
display: flex;
}
.cell:last-child {
border-right: none;
}
测试渲染棋盘
到这里就可以测试一下棋盘是否渲染成功了。
在 index.html 中导入写好的 js 文件和 css 文件。
<html>
<head>
<title>玉环的猫🐱 井字棋</title>
<link rel="stylesheet" href="./style.css" />
<script src="./lib/$.js"></script>
</head>
<body>
<div id="board"></div>
<script src="./tic-tac-toe.js"></script>
<script>
new TicTacToe("#board");
</script>
</body>
</html>
我们所采用的编辑器是 VSCode,VSCode 中有一个 Live Server 插件,可以帮助我们启动一个服务来监听文件变化并自动刷新浏览器。
此时再次右键,就可以看到 Open with Live Server 的选项,也可以通过快捷键 CMD + L + Q 来启动。
正常情况下,浏览器中可以看到生成后的棋盘。
添加点击事件
电脑版井字棋的玩法有两种,第一种是人人对战,另一种是人机对战。人机对象牵扯到自动下棋,相对复杂,不利于妹妹理解,所以使用人人对战的模式更加简单易懂。
人人对象,就是每次点击,在棋盘的每个格子中落下不同的棋子。
棋子有两种,一种是 x,一种是 o。
美观起见,o 棋子使用 css 来绘制,而 x 棋子使用文字就可以了。
首先添加一个 frequency 的属性,并设置为 0,用于记录步数。
再添加一个 isOdd 的方法,用于区别两种不同的棋子。
每次点击时,都给 cell 元素的 dataset 标记为 1,保证每个单元格只能容纳一个棋子。
最后的步骤是判断胜负,但是由于逻辑稍微复杂,等会再来实现。
class TicTacToe {
/**
* @param {string|Element} boardEl 棋盘根元素
*/
constructor(boardEl) {
this.boardEl = $(boardEl); // 棋盘根元素
this.grid; // 棋盘单元格
this.frequency = 0; // 步数
this._init();
}
_init() {
// 生成棋盘
this.grid = this.genCheckerboard(3, 3);
// 渲染棋盘
this.render();
// 绑定事件
this.addClickEvent();
}
// 添加点击事件
addClickEvent() {
this.grid.forEach((row) => {
row.forEach((cell) => {
cell.addEventListener("click", () => {
// 如果该单元格已经下过了,就不能继续下
if (cell.dataset.status === "1") return;
// 如果没下过,先进行标记
cell.dataset.status = "1";
this.isOdd(this.frequency++)
? cell.append($.createEle("div.fork"))
: cell.append($.createEle("div.circular"));
// 判断胜负
});
});
});
}
// 是否为奇数
isOdd(num) {
return num !== 0 && num % 2 !== 0;
}
// 渲染
render() {
// ...
}
genCheckerboard(x, y) {
// ...
}
}
添加棋子样式
在 style.css 中的最下面添加上棋子的样式。
// ... other style
.circular {
zoom: 1;
width: 30px;
height: 30px;
background-color: pink;
border-radius: 50%;
margin: auto;
}
.fork {
margin: auto;
font-size: 60px;
line-height: 60px;
color: rgb(241, 235, 214);
}
.fork::after {
content: "×";
}
测试下棋逻辑
如果你使用了上面推荐的 Live Server 插件,那么这时的下棋逻辑是没有问题的。
判断胜负
判断是否胜利的条件就是判断是否具有 3 个棋子在同一条线上,正常人的思维是每种棋子都会有 4 种情况,因为每个棋子都有横竖撇捺。这种思维在做五子棋游戏时是没有问题的,但是在井字棋中则不同,井字棋的棋盘过小,九个格子中有 8 个都贴近边缘,属于「边界问题」。
为了便于妹妹理解,我这里采用了一种比较 low 的实现方式,就是枚举出每个棋子胜利的情况。虽然麻烦,但是胜在好理解,幸好井字棋的比较情况也比较少。
由于最快获胜的步数是第 5 步,所以胜负判断的逻辑可以放到 5 步之后再进行。
如果一直下到第 9 步,仍然没有人获胜,那么就可以认为这局是平局。
class TicTacToe {
/**
* @param {string|Element} boardEl 棋盘根元素
*/
constructor(boardEl) { // ...
}
_init() { // ...
}
// 添加点击事件
addClickEvent() {
this.grid.forEach(row => {
row.forEach(cell => {
cell.addEventListener("click", () => {
// 如果该单元格已经下过了,就不能继续下
if (cell.dataset.status === "1") return;
// 如果没下过,先进行标记
cell.dataset.status = "1";
this.isOdd(this.frequency++)
? cell.append($.createEle("div.fork"))
: cell.append($.createEle("div.circular"));
// 判断胜负
setTimeout(() => {
if (this.frequency >= 5) {
this.isSuccess(cell) ?
this.success() :
(this.frequency === 9) ?
this.draw() :
void 0
}
}, 0);
});
})
})
}
// 检测是否成功
isSuccess(node) {
let idx = node.dataset.index;
idx = idx.split(".");
const row = idx[0];
const col = idx[1];
const isInSameLine = (area) => this.isInSameLine(area, node.firstChild.classList[0]);
switch (row) {
case "0":
switch (col) {
case "0":
return isInSameLine([[[0, 1], [0, 2]], [[1, 0], [2, 0]], [[1, 1], [2, 2]]]);
case "1":
return isInSameLine([[[0, 0], [0, 2]], [[1, 1], [1, 2]]]);
case "2":
return isInSameLine([[[0, 0], [0, 1]], [[1, 1], [2, 0]], [[1, 2], [2, 2]]]);
}
case "1":
switch (col) {
case "0":
return isInSameLine([[[0, 0], [2, 0]], [[1, 1], [1, 2]]]);
case "1":
return isInSameLine(
[
[[0, 0], [2, 2]],
[[1, 0], [1, 2]],
[[2, 2], [0, 2]],
[[0, 1], [2, 1]]
]
);
case "2":
return isInSameLine([[[0, 2], [2, 2]], [[1, 0], [1, 1]]]);
}
case "2":
switch (col) {
case "0":
return isInSameLine([[[0, 0], [1, 0]], [[1, 1], [0, 2]], [[2, 1], [2, 2]]]);
case "1":
return isInSameLine([[[0, 1], [1, 1]], [[2, 0], [2, 2]]]);
case "2":
return isInSameLine([[[0, 0], [1, 1]], [[0, 2], [1, 2]], [[2, 0], [2, 1]]]);
}
}
}
// 三个点是否在一条线
isInSameLine(coordinatePoint, className) {
return coordinatePoint.reduce((acc, current) => {
let result = current.map(cur => {
const piece = this.grid[cur[0]][cur[1]];
if (piece && piece.firstChild) {
return Array.from(piece.firstChild.classList).includes(className);
}
});
if (result.every(r => r)) acc = true;
return acc;
}, false);
}
// 成功
success() {
alert((this.isOdd(this.frequency) ? 'o' : 'x') + "获胜!");
}
// 平局
draw() {
alert("平局");
}
// 是否为奇数
isOdd(num) { // ... }
// 渲染
render() { // ...
}
genCheckerboard(x, y) { // ...
}
}
你可以尝试优化一下 isSuccess 方法。
重置游戏
在一方获胜或者平局之后,游戏状态应该重置。
添加 reStart 方法和 destory 方法来重置游戏。
destory 方法就是将游戏容器中的元素全部销毁。
reStart 方法是将游戏状态全部重置为初始状态,再重新执行 _init 方法重新执行游戏流程。
class TicTacToe {
/**
* @param {string|Element} boardEl 棋盘根元素
*/
constructor(boardEl) { // ...
}
_init() { // ...
}
// 添加点击事件
addClickEvent() { // ...
}
// 检测是否成功
isSuccess(node) { // ...
}
// 三个点是否在一条线
isInSameLine(coordinatePoint, className) { // ...
}
// 成功
success() {
alert((this.isOdd(this.frequency) ? 'o' : 'x') + "获胜!");
this.reStart();
}
// 平局
draw() {
alert("平局");
this.reStart();
}
// 重启
reStart() {
this.destory();
this.grid = []; // 棋盘单元格
this.frequency = 0;// 步数
this._init()
}
destory() {
this.boardEl.innerHTML = ''
}
// 是否为奇数
isOdd(num) { // ... }
// 渲染
render() { // ...
}
genCheckerboard(x, y) { // ...
}
}
连 6 年级小学生都会的井字棋,你学会了吗?