多 UI 版本網頁五子棋實現

做者:馬雪琴node

五子棋是你們很熟悉的一種小遊戲,本文給你們介紹如何製做一個簡易的網頁版五子棋遊戲,而且考慮實現普通 DOM 和 Canvas 兩種 UI 繪圖模式供隨時切換。最終的實現效果參考:littuomuxin.github.io/gobang/git

思路

該簡易版五子棋主要包含如下基本功能:github

  1. 下棋:五子棋對戰分爲黑棋和白棋兩方,雙方依次在棋盤上落一顆棋子
  2. 悔棋:一方在棋盤上落一顆棋子以後,在對方還未落棋子以前,容許悔棋
  3. 撤銷悔棋:悔棋時,也能夠從新將棋子落在悔棋前的位置
  4. 判斷勝負:總共有4種贏法,同一種顏色的棋子在橫、豎、正斜、反斜任意一個方向連成5個,其表明的這一方即獲勝
  5. 重玩:一盤棋局分出勝負後,能夠清理掉棋盤上的棋子,重來一局

在代碼設計上,咱們將整個程序分爲控制層和渲染層,控制器負責邏輯實現,並經過調用渲染器來實現繪製工做。談到網頁繪圖,簡單的效果徹底能夠經過普通的 DOM 來實現,但若是圖形過於複雜,咱們則應該考慮更爲專業的繪圖 API,如 Canvas。本文將實現普通 DOM 和 Canvas 兩個版本的渲染器,並介紹如何輕鬆地在這兩個渲染器之間進行切換。canvas

控制器實現

控制器定義了一個五子棋類 Gobang。要實現上述功能,須要在控制器類構造器中定義以下一些私有狀態和數據:棋局狀態、下棋的角色、下棋數據、悔棋數據等。此外,還須要初始化棋盤數據,本例中的實現是一個 15 * 15 的棋盤,因此須要初始化一個 15 * 15 的二維數組。最後,再定義一些遊戲中的話術,用於在遊戲過程當中調用另外實現的 notice 方法進行相應的通知提示。數組

構造器具體的實現代碼以下:bash

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); // 綁定事件
};
複製代碼

上述構造器和初始化方法實現後,接下來的下棋、悔棋、撤銷悔棋、判斷勝負、重玩等全部操做便是對控制器內私有狀態和數據進行更改,與此同時,再調用渲染器進行相應的繪製工做。markdown

首先是下棋方法 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 更新繪圖界面。app

/**
 * 悔一步棋
 */
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
相關文章
相關標籤/搜索