前些天, 一个朋友给我分享了一道笔试题, 题目如下
先贴结果
分析需求
拿到题目之后, 首先要做的是分析所给的需求背后的逻辑与实现. 针对题目所给的需求, 我个人的理解如下
- 五子棋游戏的功能, 包括棋盘绘制, 轮流落子(轮流绘制黑白棋子), 输赢判定 (输赢判断的逻辑就只存在一种可能, 就是无论黑子白字, 只有在落子之后这个棋子的八个方向是否构成了连续的五子)
- 渲染模式的切换(DOM与Canvas的切换), 这一点首先是要针对第一点的需求作出Dom和canvas两个版本的实现, 其次呢, DOM版本与canvas版本来回切换的过程中, 游戏需要具备保存棋局和根据保存的数据恢复棋局的能力, 才能做到在游戏的过程中进行切换
- 悔棋与撤销悔棋, 首先假设只能进行一步悔棋操作与撤销悔棋操作,那么我们需要做的有一下几点
- 维护两个状态分别保存以下信息 (1). 当前能进行悔棋操作还是撤销悔棋操作, (2). 当前需要操作的是哪一个棋子
- 针对DOM版本和canvas版本需要分别实现移出棋子的功能(DOM版本可以直接移出棋子的DOM, canvas版本则需要整个重绘)
更详细代码的实现会在代码中讨论
Coding
需求分析完了, 接下来需要按部就班用代码实现
- 首先约定一下游戏的各个参数
// config.js
export const CHESS_BOARD_SIZE = 18 // 棋盘尺寸 18 * 18
export const COLUMN_WIDTH = 40 // 棋盘格子间隙
export const CHESS_PIECE_WIDTH = 36 // 棋子直径
export const CHESS_BOARD_LINE_COLOR = '#000' // 棋盘线的演示
export const CHESS_BOARD_BACKGROUND = '#3096f0' // 棋盘背景颜色
export const CHESS_TYPES = { // 棋子类型
WHITE_CHESS_PIECE: { color: '#fff', name: '白子' },
BLACK_CHESS_PIECE: { color: '#000', name: '黑子' }
}
复制代码
- 实现绘制棋盘的方法
// 这里只讲一下dom版本的实现, canvas版本的可以去源码中看drawChessBoardCanvas.js的实现
// drawChessBoard.js
import {
CHESS_BOARD_SIZE,
COLUMN_WIDTH,
CHESS_BOARD_LINE_COLOR,
CHESS_BOARD_BACKGROUND
} from './config'
export default () => {
const chessBoard = document.createElement('div')
const boardSize = (CHESS_BOARD_SIZE + 1) * COLUMN_WIDTH
chessBoard.style.width = boardSize + 'px'
chessBoard.style.height = boardSize + 'px'
chessBoard.style.border = `1px solid ${CHESS_BOARD_LINE_COLOR}`
chessBoard.style.backgroundColor = CHESS_BOARD_BACKGROUND
// 设置棋盘定位为relative, 后续棋子定位全部用absolute实现
chessBoard.style.position = 'relative'
// 画棋盘线
const drawLine = (type = 'H') => (item, index, arr) => {
const elem = document.createElement('div')
elem.style.backgroundColor = CHESS_BOARD_LINE_COLOR
elem.style.position = 'absolute'
if (type === 'H') {
elem.style.top = (index + 1) * COLUMN_WIDTH + 'px'
elem.style.width = boardSize + 'px'
elem.style.height = 1 + 'px'
} else {
elem.style.left = (index + 1) * COLUMN_WIDTH + 'px'
elem.style.height = boardSize + 'px'
elem.style.width = 1 + 'px'
}
return elem
}
const sizeArr = new Array(CHESS_BOARD_SIZE).fill(1)
// 画横线
sizeArr.map(drawLine('H')).forEach(item => { chessBoard.appendChild(item) })
// 画竖线
sizeArr.map(drawLine('V')).forEach(item => { chessBoard.appendChild(item) })
return chessBoard
}
复制代码
- 实现一个棋子类
// ChessPiece.js
export default class ChessPiece {
constructor (x, y, chessType, id) {
this.x = x // 棋子位于棋盘的格子坐标系的x坐标
this.y = y // 棋子位于棋盘格子坐标系的y坐标
this.chessType = chessType // 棋子类型黑or白
this.id = id // 棋子id, 用作dom id, 移出棋子的时候会用到
}
// 传入棋盘dom, 插入棋子dom节点
draw (chessBoard) {
// 创建一个dom, 根据this中的各项棋子状态绘制棋子
const chessPieceDom = document.createElement('div')
chessPieceDom.id = this.id // 设置id
chessPieceDom.style.width = CHESS_PIECE_WIDTH + 'px'
chessPieceDom.style.height = CHESS_PIECE_WIDTH + 'px'
chessPieceDom.style.borderRadius = (CHESS_PIECE_WIDTH / 2) + 'px'
// 设置棋子颜色
chessPieceDom.style.backgroundColor = CHESS_TYPES[this.chessType].color
chessPieceDom.style.position = 'absolute'
const getOffset = val => (((val + 1) * COLUMN_WIDTH) - CHESS_PIECE_WIDTH / 2) + 'px'
// 设置棋子位置
chessPieceDom.style.left = getOffset(this.x)
chessPieceDom.style.top = getOffset(this.y)
// 插入dom
chessBoard.appendChild(chessPieceDom)
}
// canvas版棋子绘制方法
drawCanvas (ctx) {
// ... 考虑到篇幅具体实现不在这里贴出来, 感兴趣可以去文章开始的Github仓库看
}
}
复制代码
- 实现一个Gamer类, 用于控制游戏流程
// util.js
/**
* 初始化一个存储五子棋棋局信息的数组
*/
export const initChessPieceArr = () => new Array(CHESS_BOARD_SIZE).fill(0).map(() => new Array(CHESS_BOARD_SIZE).fill(null))
/**
* 将棋盘坐标转化为棋盘格子坐标
* @param {Number} val 棋盘上的坐标
*/
export const transfromOffset2Grid = val => ~~((val / COLUMN_WIDTH) - 0.5)
// Gamer.js
/**
* Gamer 类, 初始化一局五子棋游戏
* PS: 我觉得这个类写的有点乱了...
* 总共维护了6个状态
* isCanvas 是否是canvas模式
* chessPieceArr 保存棋盘中的棋子状态
* count 棋盘中棋子数量, 关系到落子轮替顺序
* lastStep 保存上一次的落子情况, 用于悔棋操作 (当可以毁多子的时候, 用数组去维护)
* chessBoardDom 保存棋盘Dom节点
* chessBoardCtx 当渲染模式是canvas时, 保存canvas的Context (其实也可以不保存, 根据dom去getContext即可)
*/
export default class Gamer {
constructor ({isCanvas, chessPieceArr = initChessPieceArr()} = {isCanvas: false, chessPieceArr: initChessPieceArr()}) {
this.chessPieceArr = chessPieceArr
this.isCanvas = isCanvas
// getRecoverArray这个方法就一开始分析需求讲的从保存的数据中恢复棋局的方法
const chessTemp = this.getRecoverArray(chessPieceArr)
this.count = chessTemp.length
if (this.isCanvas) { // canvas初始化
this.chessBoardDom = initCanvas()
const chessBoardCtx = this.chessBoardDom.getContext('2d')
this.chessBoardCtx = chessBoardCtx
drawBoardLines(chessBoardCtx)
// 如果是切换渲染方法触发的new Gamer(gameConfig), 就将原来棋局中的棋子进行绘制
chessTemp.forEach(item => { item.drawCanvas(chessBoardCtx) })
} else { // dom 初始化
this.chessBoardDom = drawBorad()
// 如果是切换渲染方法触发的new Gamer(gameConfig), 就将原来棋局中的棋子进行绘制
chessTemp.forEach(item => { item.draw(this.chessBoardDom) })
}
this.chessBoardDom.onclick = (e) => { this.onBoardClick(e) }
// 插入dom, 游戏开始
if (!isCanvas) {
document.getElementById('app').appendChild(this.chessBoardDom)
}
document.getElementById('cancel').style.display = 'inline'
}
// 遍历二维数组, 返回一个Array<ChessPiece>
getRecoverArray (chessPieceArr) {
const chessTemp = []
// 把初始的棋子拿出来并画在棋盘上, 这个时候要祭出for循环大法了
for (let i = 0, len1 = chessPieceArr.length; i < len1; i++) {
for (let j = 0, len2 = chessPieceArr[i].length; j < len2; j++) {
let chessSave = chessPieceArr[i][j]
if (chessSave) {
let chessPieceNew = new ChessPiece(i, j, chessSave.type, chessSave.id)
chessTemp.push(chessPieceNew)
}
}
}
return chessTemp
}
onBoardClick ({clientX, clientY}) {
console.log(this)
const x = transfromOffset2Grid(clientX)
const y = transfromOffset2Grid(clientY + window.scrollY)
// 如果当前位置已经有棋子了, 大家就当做无事发生
if (!this.chessPieceArr[x][y]) {
// 控制棋子交替顺序
const type = this.count % 2 === 0 ? 'BLACK_CHESS_PIECE' : 'WHITE_CHESS_PIECE'
// 维护lastStep这个状态
this.lastStep = {x, y, type, id: this.count}
const cancel = document.getElementById('cancel')
if (cancel.innerHTML !== '悔棋') { cancel.innerHTML = '悔棋' }
const chessPiece = new ChessPiece(x, y, type, this.count)
this.chessPieceArr[x][y] = {type, id: this.count}
console.log(this.chessPieceArr[x][y])
this.count++
if (this.isCanvas) {
chessPiece.drawCanvas(this.chessBoardCtx)
} else {
chessPiece.draw(this.chessBoardDom)
}
this.judge(x, y)
}
}
// 悔棋
cancelLastStep () {
document.getElementById('cancel').innerHTML = '撤销悔棋'
if (this.lastStep) {
const {x, y} = this.lastStep
this.count = this.count - 1
this.chessPieceArr[x][y] = null // 将目标棋子的信息设为null
if (this.isCanvas) {
// canvas版本的悔棋, 将棋盘棋子重新绘制
const temp = this.getRecoverArray(this.chessPieceArr)
drawBoardLines(this.chessBoardCtx)
temp.forEach(item => { item.drawCanvas() })
} else {
// Dom版本悔棋, 直接移出棋子dom
const chessPiece = document.getElementById(this.count)
chessPiece.parentNode.removeChild(chessPiece)
}
}
}
// 撤销悔棋, 将棋子重新绘制
cancelTheCancel () {
document.getElementById('cancel').innerHTML = '悔棋'
const {x, y, type, id} = this.lastStep
const canceledPiece = new ChessPiece(x, y, type, id)
if (this.isCanvas) {
canceledPiece.drawCanvas(this.chessBoardCtx)
} else {
canceledPiece.draw(this.chessBoardDom)
}
this.chessPieceArr[x][y] = {type, id}
this.count = this.count + 1
}
removeDom () {
if (!this.isCanvas) {
this.chessBoardDom.parentNode.removeChild(this.chessBoardDom)
} else {
this.chessBoardDom.style.display = 'none'
}
}
/**
* 判断当前棋子是否构成胜利条件, 落子之后, 判断这个子的八个方向是否连成了五子
* @param {Number} x 棋子x坐标
* @param {Number} y 棋子y坐标
*/
judge (x, y) {
const type = this.chessPieceArr[x][y].type
const isWin = atLeastOneTrue(
this.judgeX(x, y, type), // 具体实现请看源码
this.judgeX_(x, y, type),
this.judgeY(x, y, type),
this.judgeY_(x, y, type),
this.judgeXY(x, y, type),
this.judgeXY_(x, y, type),
this.judgeYX(x, y, type),
this.judgeYX_(x, y, type)
)
if (isWin) {
setTimeout(() => window.alert(`${CHESS_TYPES[type].name}赢了!!!`), 0)
document.getElementById('cancel').style.display = 'none'
this.chessBoardDom.onclick = () => { window.alert(`${CHESS_TYPES[type].name}赢了, 别点了...`) }
}
}
}
复制代码
- 最后, 就可以开心的new Gamer(gameconfig) 开始一局游戏啦
// index.js
import Gamer from './src/Gamer'
let gameConfig = {isCanvas: false}
let game = new Gamer(gameConfig)
// 开始, 重新开始
document.getElementById('start').onclick = () => {
game.removeDom()
gameConfig = {isCanvas: gameConfig.isCanvas}
game = new Gamer(gameConfig)
}
// 切换dom渲染与canvas渲染
document.getElementById('switch').onclick = () => {
game.removeDom()
gameConfig = {chessPieceArr: game.chessPieceArr, isCanvas: !gameConfig.isCanvas}
game = new Gamer(gameConfig)
}
// 悔棋
// ps: 一开始讲的需要用一个状态去维护当前是悔棋还是撤消悔棋, 我直接用的Dom innerHtml判断了....
const cancel = document.getElementById('cancel')
cancel.onclick = () => {
cancel.innerHTML === '悔棋' ? game.cancelLastStep() : game.cancelTheCancel()
}
复制代码