上小学六年级的妹妹在零零星星地学习了几个月的 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 年级小学生都会的井字棋,你学会了吗?