6 年级小学生都会的 JavaScript 井字棋

简介: 6 年级小学生都会的 JavaScript 井字棋

上小学六年级的妹妹在零零星星地学习了几个月的 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 插件,可以帮助我们启动一个服务来监听文件变化并自动刷新浏览器。

image.png

此时再次右键,就可以看到 Open with Live Server 的选项,也可以通过快捷键 CMD + L + Q 来启动。

正常情况下,浏览器中可以看到生成后的棋盘。

image.png


添加点击事件


电脑版井字棋的玩法有两种,第一种是人人对战,另一种是人机对战。人机对象牵扯到自动下棋,相对复杂,不利于妹妹理解,所以使用人人对战的模式更加简单易懂。

人人对象,就是每次点击,在棋盘的每个格子中落下不同的棋子。

棋子有两种,一种是 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 插件,那么这时的下棋逻辑是没有问题的。

image.png


判断胜负


判断是否胜利的条件就是判断是否具有 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 年级小学生都会的井字棋,你学会了吗?



相关文章
|
4月前
|
JavaScript 前端开发 安全
揭秘TypeScript的魔力:它是如何华丽变身为JavaScript的超能英雄,让您的代码飞入全新的编程维度!
【8月更文挑战第22天】在Web开发领域,JavaScript是最主流的编程语言之一。但随着应用规模的增长,其类型安全和模块化的不足逐渐显现。为解决这些问题,微软推出了TypeScript,这是JavaScript的一个超集,通过添加静态类型检查来提升开发效率。TypeScript兼容所有JavaScript代码,并引入类型注解功能。
42 2
|
7月前
|
Web App开发 JavaScript 前端开发
<Javascript技巧: Javascript 是个难泡的妞,学点技巧征服 “ 她 ” >
在前端开发中,无论是否使用框架,在代码编写上,都与 Javascript 息息相关。本篇文章将带领大家学习 JS的相关技巧,征服 Javascript 这个高冷的 “ 妞 ”!
<Javascript技巧: Javascript 是个难泡的妞,学点技巧征服 “ 她 ” >
|
7月前
|
前端开发 JavaScript Java
JavaScript!震惊你,只需一行代码!
JavaScript!震惊你,只需一行代码!
|
JavaScript 前端开发
JavaScript 手写代码 第四期
JavaScript 手写代码 第四期
95 0
|
JavaScript 前端开发
JavaScript 手写代码 第二期
JavaScript 手写代码 第二期
76 0
|
JavaScript 前端开发
JavaScript简易五子棋游戏
JavaScript简易五子棋游戏
|
移动开发 JavaScript 前端开发
JavaScript波澜起伏的一生
JavaScript俨然是热度最高的编程语言之一,作为前端开发在工作中总离不开写JS,但有些疑问总在我脑海中:它与Java到底什么关系?所谓的ES、TS又是什么?现在就让我们一起走进JS的前世今生吧。
 JavaScript波澜起伏的一生
|
JavaScript 前端开发 对象存储
正经人一辈子都用不到的 JavaScript 方法总结 (二)
现在有这样一个需求:用一个对象存储某学生的各科成绩,要求每次只能改变科目分数,不能再添加或者删除科目。
97 0
正经人一辈子都用不到的 JavaScript 方法总结 (二)
|
JavaScript 前端开发
正经人一辈子都用不到的 JavaScript 方法总结 (一)
假如有这样一个需求:要求将给定的一个文件路径 D:\bianchengsanmei\blogs\categories\JavaScript 在页面展示出来。
97 0
正经人一辈子都用不到的 JavaScript 方法总结 (一)
|
算法 JavaScript 前端开发
JavaScript猜数字游戏的简单算法
JavaScript猜数字游戏的简单算法