多 UI 版本網頁五子棋實現

  • 2020 年 1 月 13 日
  • 筆記

五子棋是大家很熟悉的一種小遊戲,本文給大家介紹如何製作一個簡易的網頁版五子棋遊戲,並且考慮實現普通 DOM 和 Canvas 兩種 UI 繪圖模式供隨時切換。最終的實現效果參考:https://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 對戰等。本文只是一個簡易版本的網頁五子棋實現,重點在於多渲染器及其切換的實現思路,希望在這一方面能起到一點參考意義。