手把手教会 6 年级的妹妹开发井字棋

2020 年 08 月 11 日

阅读量:0

正文共 3137 字,预计阅读时间 16 分钟

banner

上小学六年级的妹妹在零零星星地学习了几个月的 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 步。

  1. 生成棋盘。
  2. 渲染棋盘。
  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 插件,可以帮助我们启动一个服务来监听文件变化并自动刷新浏览器。

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 年级小学生都会的井字棋,你学会了吗?


你好,我是 卢振千,一名软件工程师。欢迎你来到我的网站。