近日接到騰訊 CDC 前端開發團隊的求職意向詢問,在微信上簡單地聊了下技術,然后拋給我一道面試題。題目內容是編寫一個單機五子棋,用原生 web 技術實現,兼容 Chrome 即可,完成時間不作限制。同時還有幾個要求:
- 實現勝負判斷,並給出贏棋提示。任意一方贏棋,鎖定棋盤。
- 盡可能考慮游戲的擴展性,界面可用 DOM/ Canvas 實現,並且切換實現方式代價最小。
- 實現悔棋和撤銷悔棋功能。
- 人機對戰部分可選。
工作上一直跟游戲開發毫無關聯,自己也不怎么熱衷玩游戲,不過五子棋還是玩過的。簡單構思了下,決定用 DOM 實現。當天晚上在家忙活了兩個多小時,基本完成。最終效果圖如下:
在線 Demo
因為代碼規模較小,總共不到 250 行,就沒有考慮分文件模塊化的設計,一個 game.js
文件搞定。主要定義了 三個類:Board, Piece 和 Game,分別代表棋盤、棋子和整個游戲。
游戲狀態數據
用一個二維數組保存棋盤數據,data[x][y] = 0
表示該位置為空,data[x][y] = 1
表示放置了黑子,data[x][y] = 2
表示放置了白子。
下棋動作
監聽棋盤的點擊事件,計算出點位。玩家可能不是精確地點擊交叉點,所以要進行糾偏計算。黑白交替進行,如果點位合法,就創建一個 DOM 元素表示棋子。
悔棋與撤銷悔棋
每下一步棋,都保存當前棋子的坐標和 DOM 元素引用。如果要悔棋,就把該位置的數據清零,同時把 DOM 移除掉。撤銷悔棋則執行相反的操作。
勝負判定
按照簡單的規則,從當前下子點位的八個方向判斷。如果有一個方向滿足連續5個黑子或白子,游戲結束。
全局變量
var SIZE = 15;
var BLACK = 1;
var WHITE = 2;
var WIN = 5;
function approximate(number) {
if(number - Math.floor(number) > 0.5) {
return Math.ceil(number);
}
return Math.floor(number);
}
Board 類
//棋盤
function Board(el) {
this.el = typeof el === 'string' ? document.querySelector(el) : el;
}
//初始化棋盤
Board.prototype.init = function() {
this.el.innerHTML = '';
var frag = document.createDocumentFragment();
for (var i = SIZE - 1; i >= 0; i--) {
var row = document.createElement('div');
row.classList.add('row');
for (var j = SIZE - 1; j >= 0; j--) {
var cell = document.createElement('div');
cell.classList.add('cell');
row.appendChild(cell);
}
frag.appendChild(row);
}
this.el.appendChild(frag);
var aCell = this.el.querySelector('.cell');
var rect = aCell.getBoundingClientRect();
var maxWidth = Math.min(document.body.clientWidth * 0.8, SIZE * 40);
var w = ~~(maxWidth / (SIZE - 1));
this.el.style.height = w * (SIZE - 1) + 'px';
this.el.style.width = w * (SIZE - 1) + 'px';
rect = aCell.getBoundingClientRect();
this.unit = rect.width;
}
//畫棋子
Board.prototype.drawPiece = function(piece) {
var dom = document.createElement('div');
dom.classList.add('piece');
dom.style.width = this.unit + 'px';
dom.style.height = this.unit + 'px';
dom.style.left = ~~((piece.x - .5) * this.unit) + 'px';
dom.style.top = ~~((piece.y - .5) * this.unit) + 'px';
dom.classList.add(piece.player === 1 ? 'black' : 'white');
this.el.appendChild(dom);
return dom;
}
Piece 類
//棋子
function Piece(x, y, player) {
this.x = x;
this.y = y;
this.player = player;
}
Game 類
function Game(engine) {
this.engine = engine || 'DOM';
this.init();
}
Game.prototype.init = function() {
this.ended = false;
var chessData = new Array(SIZE);
for (var x = 0; x < SIZE; x++) {
chessData[x] = new Array(SIZE);
for (var y = 0; y < SIZE; y++) {
chessData[x][y] = 0;
}
}
this.data = chessData;
this.currentPlayer = WHITE;
this.updateIndicator();
}
Game.prototype.start = function() {
var board = new Board('.board');
board.init();
this.board = board;
var rect = this.board.el.getBoundingClientRect();
this.board.el.addEventListener('click', function(event) {
var ptX = event.clientX - rect.left;
var ptY = event.clientY - rect.top;
var x = approximate(ptX / this.board.unit);
var y = approximate(ptY / this.board.unit);
console.log(x, y);
this.play(x, y);
}.bind(this));
var btnUndo = document.querySelector('.undo');
var btnRedo = document.querySelector('.redo');
var btnRestart = document.querySelector('.restart');
btnUndo.addEventListener('click', function() {
this.undo();
}.bind(this));
btnRedo.addEventListener('click', function() {
this.redo();
}.bind(this));
btnRestart.addEventListener('click', function() {
this.init();
this.board.init();
}.bind(this));
}
Game.prototype.play = function(x, y) {
if (this.ended) {
return;
}
if (this.data[x][y] > 0) {
return;
}
if(!this.lockPlayer) {
this.currentPlayer = this.currentPlayer === BLACK ? WHITE : BLACK;
}
this.lockPlayer = false;
var piece = new Piece(x, y, this.currentPlayer);
var pieceEl = this.board.drawPiece(piece);
this.data[x][y] = this.currentPlayer;
this.updateIndicator();
var winner = this.judge(x, y, this.currentPlayer);
this.ended = winner > 0;
if(this.ended) {
setTimeout(function() {
this.gameOver();
}.bind(this), 0);
}
this.move = {
piece: piece,
el: pieceEl
};
}
Game.prototype.updateIndicator = function() {
var el = document.querySelector('.turn');
if(this.currentPlayer === WHITE) {
el.classList.add('black');
el.classList.remove('white');
} else {
el.classList.add('white');
el.classList.remove('black');
}
}
Game.prototype.gameOver = function() {
alert((this.currentPlayer === BLACK ? '黑方' : '白方') + '勝!');
}
Game.prototype.undo = function() {
if(this.ended) {
return;
}
this.lockPlayer = true;
this.move.el.remove();
var piece = this.move.piece;
this.data[piece.x][piece.y] = 0;
}
Game.prototype.redo = function() {
if(this.ended) {
return;
}
this.lockPlayer = true;
this.board.el.appendChild(this.move.el);
var piece = this.move.piece;
this.data[piece.x][piece.y] = piece.player;
}
//判斷勝負
Game.prototype.judge = function(x, y, player) {
var horizontal = 0;
var vertical = 0;
var cross1 = 0;
var cross2 = 0;
var gameData = this.data;
//左右判斷
for (var i = x; i >= 0; i--) {
if (gameData[i][y] != player) {
break;
}
horizontal++;
}
for (var i = x + 1; i < SIZE; i++) {
if (gameData[i][y] != player) {
break;
}
horizontal++;
}
//上下判斷
for (var i = y; i >= 0; i--) {
if (gameData[x][i] != player) {
break;
}
vertical++;
}
for (var i = y + 1; i < SIZE; i++) {
if (gameData[x][i] != player) {
break;
}
vertical++;
}
//左上右下判斷
for (var i = x, j = y; i >= 0, j >= 0; i--, j--) {
if (gameData[i][j] != player) {
break;
}
cross1++;
}
for (var i = x + 1, j = y + 1; i < SIZE, j < SIZE; i++, j++) {
if (gameData[i][j] != player) {
break;
}
cross1++;
}
//右上左下判斷
for (var i = x, j = y; i >= 0, j < SIZE; i--, j++) {
if (gameData[i][j] != player) {
break;
}
cross2++;
}
for (var i = x + 1, j = y - 1; i < SIZE, j >= 0; i++, j--) {
if (gameData[i][j] != player) {
break;
}
cross2++;
}
if (horizontal >= WIN || vertical >= WIN || cross1 >= WIN || cross2 >= WIN) {
return player;
}
return 0;
}
啟動游戲
document.addEventListener('DOMContentLoaded', function() {
var game = new Game();
game.start();
console.log('DOMContentLoaded')
})
總結:整體還是比較簡單的,游戲邏輯已經抽象出來,界面部分可替換成 Canvas 實現。人機對戰部分沒有實現,沒去研究五子棋贏棋策略。由於沒花太多時間,代碼比較粗糙,界面也比較丑。如果大家有更好的實現方式,歡迎交流。
后記
多年前也折騰過一些小游戲,比如:
7X7小游戲
用Vue.js和Webpack開發Web在線鋼琴
止增笑耳。