多 UI 版本网页五子棋实现

2,040 阅读13分钟

作者:马雪琴

五子棋是大家很熟悉的一种小游戏,本文给大家介绍如何制作一个简易的网页版五子棋游戏,并且考虑实现普通 DOM 和 Canvas 两种 UI 绘图模式供随时切换。最终的实现效果参考:littuomuxin.github.io/gobang/

思路

该简易版五子棋主要包含以下基本功能:

  1. 下棋:五子棋对战分为黑棋和白棋两方,双方依次在棋盘上落一颗棋子
  2. 悔棋:一方在棋盘上落一颗棋子之后,在对方还未落棋子之前,允许悔棋
  3. 撤销悔棋:悔棋时,也可以重新将棋子落在悔棋前的位置
  4. 判断胜负:总共有4种赢法,同一种颜色的棋子在横、竖、正斜、反斜任意一个方向连成5个,其代表的这一方即获胜
  5. 重玩:一盘棋局分出胜负后,可以清理掉棋盘上的棋子,重来一局

在代码设计上,我们将整个程序分为控制层和渲染层,控制器负责逻辑实现,并通过调用渲染器来实现绘制工作。谈到网页绘图,简单的效果完全可以通过普通的 DOM 来实现,但如果图形过于复杂,我们则应该考虑更为专业的绘图 API,如 Canvas。本文将实现普通 DOM 和 Canvas 两个版本的渲染器,并介绍如何轻松地在这两个渲染器之间进行切换。

控制器实现

控制器定义了一个五子棋类 Gobang。要实现上述功能,需要在控制器类构造器中定义如下一些私有状态和数据:棋局状态、下棋的角色、下棋数据、悔棋数据等。此外,还需要初始化棋盘数据,本例中的实现是一个 15 * 15 的棋盘,所以需要初始化一个 15 * 15 的二维数组。最后,再定义一些游戏中的话术,用于在游戏过程中调用另外实现的 notice 方法进行相应的通知提示。

构造器具体的实现代码如下:

function Gobang() {
    this._status = 0; // 棋局状态,0表示对战中,1表示已分胜负
    this._role = 0; // 下棋的角色,0表示黑棋,1表示白棋
    this._chessDatas = []; // 存放下棋数据
    this._resetStepData = []; // 存放悔棋数据

    this._gridNum = 15; // 棋盘行列数
    this._chessBoardDatas = this._initChessBoardDatas(); // 初始化棋盘数据

    this._notice = window.notice;
    this._msgs = {
        'start': '比赛开始!',
        'reStart': '比赛重新开始!',
        'blackWin': '黑棋胜!',
        'whiteWin': '白棋胜!',
    };
}

然后,控制器还需要暴露一个实例方法供外部初始化调用,并依赖外部传入一个渲染器实例,控制器内部会通过调用该渲染器实例的各种方法来实现五子棋里的绘图工作。代码如下所示:

/**
 * 初始化
 * @param {Object} renderer 渲染器
 */
Gobang.prototype.init = function(renderer) {
    var _this = this;

    // 游戏开始
    setTimeout(function() {
        _this._notice.showMsg(_this._msgs.start, 1000);
    }, 1000);

    if (!renderer) throw new Error('缺少渲染器!');

    _this.renderer = renderer;
    renderer.renderChessBoard(); // 绘制棋盘
    renderer.bindEvents(_this); // 绑定事件
};

上述构造器和初始化方法实现后,接下来的下棋、悔棋、撤销悔棋、判断胜负、重玩等所有操作即是对控制器内私有状态和数据进行更改,与此同时,再调用渲染器进行相应的绘制工作。

首先是下棋方法 goStep 的实现。下棋时需要判断相应位置是否有棋子(_hasChess),没有棋子的位置才可以落棋子,落棋后需要更新棋盘数据(_chessBoardDatas)、下棋数据(_chessDatas),并调用渲染器方法 _this.renderer.renderStep 更新绘图界面。然后还需要判断棋局胜负是否已分(_isWin),分出胜负的情况下调用 notice 方法给出相应提示,最后还要切换下棋的角色(_role)。代码如下:

/**
 * 判断一个位置是否有棋子
 * @param {Number} x 水平坐标
 * @param {Number} y 垂直坐标
 * @returns {Boolean} 初始棋盘数据
 */
Gobang.prototype._hasChess = function(x, y) {
    var _this = this;
    var hasChess = false;
    _this._chessDatas.forEach(function(item) {
        if (item.x === x && item.y === y) hasChess = true;
    });
    return hasChess;
};

/**
 * 下一步棋
 * @param {Number} x 水平坐标
 * @param {Number} y 垂直坐标
 * @param {Boolean} normal 正常下棋,不是撤销悔棋之类
 * @returns {Boolean} 是否成功下棋
 */
Gobang.prototype.goStep = function(x, y, normal) {
    var _this = this;
    if (_this._status) return false;
    if (_this._hasChess(x, y)) return false;
    _this._chessBoardDatas[x][y] = _this._role;
    var step = {
        x: x,
        y: y,
        role: _this._role
    };
    _this._chessDatas.push(step);
    // 存入 localstorage
    localStorage && (localStorage.chessDatas = JSON.stringify(_this._chessDatas));

    // 绘制棋子
    _this.renderer.renderStep(step);

    // 判断是否胜出
    if (_this._isWin(step.x, step.y)) {
        // 获胜
        _this._status = 1;
        var msg = _this._role ? _this._msgs.whiteWin : _this._msgs.blackWin;
        setTimeout(function() {
            _this._notice.showMsg(msg, 5000);
        }, 500);
    }
    // 切换角色
    _this._role = 1 - _this._role;
    // 清除悔棋数据
    if (normal) _this._resetStepData = [];
    return true;
};

悔棋 resetStep 为下棋的逆操作,需要将下棋数据数组 _chessDatas 做一个 pop 操作,将棋盘数据 _chessBoardDatas 相对应的数组元素恢复成初始值,并存储悔棋数据 _resetStepData;然后是切换下棋角色 _role,调用 _this.renderer.renderUndo 更新绘图界面。

/**
 * 悔一步棋
 */
Gobang.prototype.resetStep = function() {
    var _this = this;
    if (_this._chessDatas.length < 1) return;
    _this._status = 0; // 即使分出了胜负,悔棋后也回到了对战状态
    var lastStep = _this._chessDatas.pop();

    // 存入 localstorage
    localStorage && (localStorage.chessDatas = JSON.stringify(_this._chessDatas));
    // 修改棋盘数据
    _this._chessBoardDatas[lastStep.x][lastStep.y] = undefined;
    // 存储悔棋数据
    _this._resetStepData.push(lastStep);
    // 切换用户角色
    _this._role = 1 - _this._role;
    // 移除棋子
    _this.renderer.renderUndo(lastStep, _this._chessDatas);
};

撤销悔棋 reResetStep 是悔棋的逆操作,也就相当于是下棋操作,只是这一步棋的位置是从悔棋数据 _resetStepData 中自动取出的:

/**
 * 撤销悔棋
 */
Gobang.prototype.reResetStep = function() {
    var _this = this;
    if (_this._resetStepData.length < 1) return;
    var lastStep = _this._resetStepData.pop();
    _this.goStep(lastStep.x, lastStep.y);

    // 绘制棋子
    _this.renderer.renderStep(lastStep);
};

接下来介绍判断胜负方法 _isWin 的实现。我们知道五子棋总共有4种赢法,即同一种颜色的棋子在横、竖、正斜、反斜任意一个方向连成5个,其代表的这一方即获胜。所以,当前棋子落定后,我们需要根据该棋子所在的位置,从四个方向上计算与之相连的相同颜色的棋子的数量。具体的实现代码如下:

/**
 * 判断某个单元格是否在棋盘上
 * @param {Number} x 水平坐标
 * @param {Number} y 垂直坐标
 * @returns {Boolean} 指定坐标是否在棋盘范围内
 */
Gobang.prototype._inRange = function(x, y) {
    return x >= 0 && x < this._gridNum && y >= 0 && y < this._gridNum;
};

/**
 * 判断在某个方向上有多少个同样的棋子
 * @param {Number} xPos 水平坐标
 * @param {Number} yPos 垂直坐标
 * @param {Number} deltaX 水平移动方向
 * @param {Number} deltaY 垂直移动方向
 * @returns {Number} 与给定位置棋子朝给定位置上计算得到的相同的棋子数量
 */
Gobang.prototype._getCount = function(xPos, yPos, deltaX, deltaY) {
    var _this = this;
    var count = 0;
    while (true) {
        xPos += deltaX;
        yPos += deltaY;
        if (!_this._inRange(xPos, yPos) || _this._chessBoardDatas[xPos][yPos] != _this._role)
            break;
        count++;
    }
    return count;
};

/**
 * 判断在某个方向上是否获胜
 * @param {Number} x 水平坐标
 * @param {Number} y 垂直坐标
 * @param {Object} direction 方向
 * @returns {Boolean} 在某个方向上是否获胜
 */
Gobang.prototype._isWinInDirection = function(x, y, direction) {
    var _this = this;
    var count = 1;
    count += _this._getCount(x, y, direction.deltaX, direction.deltaY);
    count += _this._getCount(x, y, -1 * direction.deltaX, -1 * direction.deltaY);
    return count >= 5;
};

/**
 * 判断是否获胜
 * @param {Number} x 水平坐标
 * @param {Number} y 垂直坐标
 * @returns {Boolean} 是否获胜
 */
Gobang.prototype._isWin = function(x, y) {
    var _this = this;
    var length = _this._chessDatas.length;
    if (length < 9) return 0;
    // 4种赢法:横、竖、正斜、反斜
    var directions = [{
        deltaX: 1,
        deltaY: 0
    }, {
        deltaX: 0,
        deltaY: 1
    }, {
        deltaX: 1,
        deltaY: 1
    }, {
        deltaX: 1,
        deltaY: -1
    }];
    for (var i = 0; i < 4; i++) {
        if (_this._isWinInDirection(x, y, directions[i])) {
            return true;
        }
    }
};

最后,当棋局胜负已分后,我们可以通过清除所有数据和绘制工作来重新开始新的一局:

/**
 * 清除一切重新开始
 */
Gobang.prototype.clear = function() {
    var _this = this;
    _this._status = 0;
    _this._role = 0;
    if (_this._chessDatas.length < 1) return;

    // 清除棋子
    _this.renderer.renderClear();

    _this._chessDatas = [];
    localStorage && (localStorage.chessDatas = '');
    this._resetStepData = [];
    _this._chessBoardDatas = _this._initChessBoardDatas();
    _this._notice.showMsg(_this._msgs.reStart, 1000);
};

渲染器实现

渲染器的工作主要包括以下几个:

  1. 棋盘的绘制工作
  2. 下一个棋子的绘制工作
  3. 悔一个棋子的绘制工作
  4. 清除所有棋子的绘制工作
  5. 棋盘界面的事件交互工作:用户点击棋盘中的某个位置落棋

其中事件交互工作中需要调用控制器来控制下棋逻辑。

因为需要实现普通 DOM 和 Canvas 两个版本的渲染器,并且供控制器灵活切换,所以这两个渲染器需要暴露相同的实例方法。 根据上述介绍的渲染器的5项工作,它需要的暴露的5个方法如下:

  1. renderChessBoard
  2. renderStep
  3. renderUndo
  4. renderClear
  5. bindEvents

下面分别介绍普通 DOM 渲染器和 Canvas 渲染器的具体实现。

普通 DOM 渲染器

普通 DOM 渲染器需要绘制 15 * 15 的网格,对应 15 * 15 个 div 元素,每个元素在初始化的过程中可以通过定义 attr-data 属性来标示其对应的网格位置。相关实现如下:

/**
 * 普通 Dom 版本五子棋渲染器构造函数
 * @param {Object} container 渲染所在的 DOM 容器
 */
function DomRenderer(container) {
    this._chessBoardWidth = 450; // 棋盘宽度
    this._chessBoardPadding = 4; // 棋盘内边距
    this._gridNum = 15; // 棋盘行列数
    this._gridDoms = []; // 存放棋盘 DOM
    this._chessboardContainer = container; // 容器
    this.chessBoardRendered = false; // 是否渲染了棋盘
    this.eventsBinded = false; // 是否绑定了事件
}

/**
 * 渲染棋盘
 */
DomRenderer.prototype.renderChessBoard = function() {
    var _this = this;

    _this._chessboardContainer.style.width = _this._chessBoardWidth + 'px';
    _this._chessboardContainer.style.height = _this._chessBoardWidth + 'px';
    _this._chessboardContainer.style.padding = _this._chessBoardPadding + 'px';
    _this._chessboardContainer.style.backgroundImage = 'url(./imgs/board.jpg)';
    _this._chessboardContainer.style.backgroundSize = 'cover';

    var fragment = '';
    for (var i = 0; i < _this._gridNum * _this._gridNum; i++) {
        fragment += '<div class="chess-grid" attr-data="' + i + '"></div>';
    }
    _this._chessboardContainer.innerHTML = fragment;
    _this._gridDoms = _this._chessboardContainer.getElementsByClassName('chess-grid');
    _this.chessBoardRendered = true;
};

每个网格对应的 div 有三种状态,没有棋子、有黑棋、有白棋三种状态,这三种状态可以通过给 div 添加不同的三种样式来实现。然后,下一个棋子和悔一个棋子的绘制工作即通过切换相应 div 的样式来实现;清除所有棋子的绘制工作则是将所有的 div 样式恢复成没有棋子的状态:

/**
 * 渲染一步棋子
 * @param {Object} step 棋的位置
 */
DomRenderer.prototype.renderStep = function(step) {
    var _this = this;

    if (!step) return;

    var index = step.x + _this._gridNum * step.y;
    var domGrid = _this._gridDoms[index];
    domGrid.className = 'chess-grid ' + (step.role ? 'white-chess' : 'black-chess');
};

/**
 * 悔一步棋子
 * @param {Object} step 棋的位置
 * @param {Array} allSteps 剩下的所有棋的位置
 */
DomRenderer.prototype.renderUndo = function(step) {
    var _this = this;

    if (!step) return;
    var index = step.x + _this._gridNum * step.y;
    var domGrid = _this._gridDoms[index];
    domGrid.className = 'chess-grid';
};

/**
 * 清除所有棋子
 */
DomRenderer.prototype.renderClear = function() {
    var _this = this;

    for (var i = 0; i < _this._gridDoms.length; i++) {
        _this._gridDoms[i].className = 'chess-grid';
    }
};

最后是棋盘界面的事件交互工作,用户点击其中任意一个网格 div,都需要做出响应,该响应事件即为下一步棋,通过传入的控制器对象的 goStep 方法实现。为了性能考虑,我们不应该给每个棋盘网格 div 绑定点击事件,而是在棋盘容器上绑定一个点击事件即可,通过真实 targetattr-data 属性即可轻松计算得到下棋的位置,传给 goStep 方法。下面是具体的实现:

/**
 * 绑定事件
 * @param {Object} controllerObj 控制器对象
 */
DomRenderer.prototype.bindEvents = function(controllerObj) {
    var _this = this;

    _this._chessboardContainer.addEventListener('click', function(ev) {
        var target = ev.target;
        var attrData = target.getAttribute('attr-data');
        if (attrData === undefined || attrData === null) return;
        var position = attrData - 0;
        var x = position % _this._gridNum;
        var y = parseInt(position / _this._gridNum, 10);
        controllerObj.goStep(x, y, true);
    }, false);
    _this.eventsBinded = true;
};

Canvas 渲染器

接下来是 Canvas 渲染器的具体实现。为了性能考虑,我们可以用多个 Canvas 画布叠加实现整个绘图效果,每个画布负责单一元素的绘制,不变的元素和变化的元素尽量绘制到不同的画布。本示例中创建了三个画布:绘制背景的画布、绘制阴影的画布和绘制棋子的画布。相关实现代码如下:

/**
 * Canvas 版本五子棋渲染器构造函数
 * @param {Object} container 渲染所在的 DOM 容器
 */
function CanvasRenderer(container) {
    this._chessBoardWidth = 450; // 棋盘宽度
    this._chessBoardPadding = 4; // 棋盘内边距
    this._gridNum = 15; // 棋盘行列数
    this._padding = 4; // 棋盘内边距
    this._gridWidth = 30; // 棋盘格宽度
    this._chessRadius = 13; // 棋子的半径
    this._container = container; // 创建 canvas 的 DOM 容器
    this.chessBoardRendered = false; // 是否渲染了棋盘
    this.eventsBinded = false; // 是否绑定了事件
    this._init();
}

/**
 * 初始化操作,创建画布
 */
CanvasRenderer.prototype._init = function() {
    var _this = this;

    var width = _this._chessBoardWidth + _this._chessBoardPadding * 2;

    // 创建绘制背景的画布
    _this._bgCanvas = document.createElement('canvas');
    _this._bgCanvas.setAttribute('width', width);
    _this._bgCanvas.setAttribute('height', width);

    // 创建绘制阴影的画布
    _this._shadowCanvas = document.createElement('canvas');
    _this._shadowCanvas.setAttribute('width', width);
    _this._shadowCanvas.setAttribute('height', width);

    // 创建绘制棋子的画布
    _this._chessCanvas = document.createElement('canvas');
    _this._chessCanvas.setAttribute('width', width);
    _this._chessCanvas.setAttribute('height', width);

    // 在容器中插入画布
    _this._container.appendChild(_this._bgCanvas);
    _this._container.appendChild(_this._shadowCanvas);
    _this._container.appendChild(_this._chessCanvas);

    // 棋子的绘图环境
    _this._context = _this._chessCanvas.getContext('2d');
};

棋子的绘制过程则是使用棋子画布的 2D 绘图环境绘制一个圆形,具体代码如下:

/**
 * 渲染一步棋子
 * @param {Object} step 棋的位置
 */
CanvasRenderer.prototype.renderStep = function(step) {
    var _this = this;

    if (!step) return;

    var x = _this._padding + (step.x + 0.5) * _this._gridWidth;
    var y = _this._padding + (step.y + 0.5) * _this._gridWidth;
    _this._context.beginPath();
    _this._context.arc(x, y, _this._chessRadius, 0, 2 * Math.PI);
    if (step.role) {
        _this._context.fillStyle = '#FFFFFF';
    } else {
        _this._context.fillStyle = '#000000';
    }
    _this._context.fill();
    _this._context.closePath();
};

因为棋子都被绘制在一个画布上,所以清除所有棋子很简单,只用清除整个画布的绘制即可。因为 Canvas 在宽度或高度被重设时,画布内容就会被清空,所以可以用以下方法快速清除画布:

/**
 * 清除所有棋子
 */
CanvasRenderer.prototype.renderClear = function() {
    this._chessCanvas.height = this._chessCanvas.height; // 快速清除画布
};

而悔一步棋则相对复杂一点,我们采取的方案是先清除整个画布,然后重新绘制前面的棋局状态:

/**
 * 悔一步棋子
 * @param {Object} step 当前这一步棋的位置
 * @param {Array} allSteps 剩下的所有棋的位置
 */
CanvasRenderer.prototype.renderUndo = function(step, allSteps) {
    var _this = this;

    if (!step) return;
    _this._chessCanvas.height = _this._chessCanvas.height; // 快速清除画布
    if (allSteps.length < 1) return;
    // 重绘
    allSteps.forEach(function(p) {
        _this.renderStep(p);
    });
};

最后是事件交互工作:鼠标在棋盘上移动时,绘制阴影;鼠标在棋盘上点击时,通过传入的控制器对象的 goStep 方法实现下棋操作,能够成功绘制时,还需要注意清除阴影。具体实现如下:

/**
 * 判断某个单元格是否在棋盘上
 * @param {Number} x 水平坐标
 * @param {Number} y 垂直坐标
 * @returns {Boolean} 指定坐标是否在棋盘范围内
 */
CanvasRenderer.prototype._inRange = function(x, y) {
    return x >= 0 && x < this._gridNum && y >= 0 && y < this._gridNum;
};

/**
 * 绑定事件
 * @param {Object} controllerObj 控制器对象
 */
CanvasRenderer.prototype.bindEvents = function(controllerObj) {
    var _this = this;

    var chessShodowContext = _this._shadowCanvas.getContext('2d');

    // 鼠标移出画布时隐藏画阴影的画布
    document.body.addEventListener('mousemove', function(ev) {
        if (ev.target.nodeName !== 'CANVAS') {
            _this._shadowCanvas.style.display = 'none';
        }
    }, false);

    // 鼠标在画布移动时绘制阴影效果
    _this._container.addEventListener('mousemove', function(ev) {
        var xPos = ev.offsetX;
        var yPos = ev.offsetY;
        var i = Math.floor((xPos - _this._padding) / _this._gridWidth);
        var j = Math.floor((yPos - _this._padding) / _this._gridWidth);
        var x = _this._padding + (i + 0.5) * _this._gridWidth;
        var y = _this._padding + (j + 0.5) * _this._gridWidth;

        // 显示画阴影的画布
        _this._shadowCanvas.style.display = 'block';
        // 快速清除画布
        _this._shadowCanvas.height = _this._shadowCanvas.height;

        // 超出棋盘范围不要阴影效果
        if (!_this._inRange(i, j)) return;
        // 有棋子的地方不要阴影效果
        if (controllerObj._chessBoardDatas[i][j] !== undefined) return;

        chessShodowContext.beginPath();
        chessShodowContext.arc(x, y, _this._gridWidth / 2, 0, 2 * Math.PI);
        chessShodowContext.fillStyle = 'rgba(0, 0, 0, 0.2)';
        chessShodowContext.fill();
        chessShodowContext.closePath();
    }, false);

    // 鼠标在棋盘点击下棋
    _this._container.addEventListener('click', function(ev) {
        var x = ev.offsetX;
        var y = ev.offsetY;
        var i = Math.floor((x - _this._padding) / _this._gridWidth);
        var j = Math.floor((y - _this._padding) / _this._gridWidth);
        var success = controllerObj.goStep(i, j, true);
        if (success) {
            // 清除阴影
            _this._shadowCanvas.height = _this._shadowCanvas.height;
        }
    }, false);

    _this.eventsBinded = true;
};

切换绘图模式

两种绘图模式可以随时切换,渲染器是供控制器调用的,所以在控制器中需要暴露一个切换渲染器的方法。切换渲染器的操作分为以下三步:

  1. 旧的渲染器清除其所有的绘制工作
  2. 新的渲染器初始化棋盘绘制工作
  3. 根据已下棋数据重新绘制当前棋局

具体实现如下:

/**
 * 切换渲染器
 * @param {Object} renderer 渲染器对象
 */
Gobang.prototype.changeRenderer = function(renderer) {
    var _this = this;

    if (!renderer) return;

    _this.renderer = renderer;

    // 先清除棋盘,再根据当前数据绘制棋局状态
    renderer.renderClear();
    if (!renderer.chessBoardRendered) renderer.renderChessBoard();
    if (!renderer.eventsBinded) renderer.bindEvents(_this);
    _this._chessDatas.forEach(function(step) {
        renderer.renderStep(step);
    });
};

因为两个渲染器暴露的可供控制器调用的实例方法完全一致,所以上述几个简单步骤即可实现无缝切换,接下来的下棋游戏可以继续进行!

总结

要完整的制作一个网页五子棋游戏产品,还需要考虑网络对战、AI 对战等。本文只是一个简易版本的网页五子棋实现,重点在于多渲染器及其切换的实现思路,希望在这一方面能起到一点参考意义。


如果你觉得这篇内容对你有价值,请点赞,并关注我们的官网和我们的微信公众号(WecTeam),每周都有优质文章推送:

WecTeam