前言
最近新开了一个专栏,纯Canvas实现一些经典的小游戏,如五子棋、俄罗斯方块、2048、贪吃蛇等,主打是纯Canvas和简洁的ES6语法。今天我们先从五子棋开始吧!
第一步,绘制棋盘
思路:创建Canvas对象,设置宽高,填充背景色,并将其水平垂直居中于页面正中间,然后绘制15*15的网格线,较为简单,直接上代码。(这里的代码不用细看,简单过一下就行了,都是调用Canvas的API)
细节:如果Canvas没有代码提示,可在创建Canvas对象前加上这个注释/** @type {HTMLCanvasElement} */
。inset:0
时left: 0; right: 0; top: 0; bottom: 0
的缩写,不兼容IE。
js
const SIZE = 15, // 棋盘15*15=225个点
W = 50 , // 棋盘格子大小
SL = W * (SIZE + 1); // 边长 = 棋盘宽高
/** @type {HTMLCanvasElement} */
let canvas = document.createElement('canvas'), ctx = canvas.getContext('2d');
canvas.width = canvas.height = SL; // 棋盘宽高 = 边长
canvas.style.cssText = 'position: absolute; inset: 0; margin: auto; cursor: pointer;';
document.body.appendChild(canvas);
// 绘制棋盘(棋盘背景色 && 网格线)
const drawBoard = () => {
ctx.fillStyle = '#E4A751';
ctx.fillRect(0, 0, SL, SL);
for (let i = 0; i < SIZE; i++) {
drawLine(0, i, SIZE - 1, i);
drawLine(i, 0, i, SIZE - 1);
}
}
// 两点连线:(x1, y1) <-> (x2, y2),设置线条宽度lineWidth和颜色LINE_COLOR
const drawLine = (x1, y1, x2, y2, lineWidth = 1, lineColor = '#000000') => {
ctx.lineWidth = lineWidth;
ctx.strokeStyle = lineColor;
ctx.beginPath();
ctx.moveTo(x1 * W + W, y1 * W + W);
ctx.lineTo(x2 * W + W, y2 * W + W);
ctx.stroke();
}
window.onload = drawBoard
第二步,绘制棋子
思路:监听棋盘的点击事件,获取坐标值。若该位置没有棋子,则可落子,另外黑白棋切换。
细节:我们需要将点击位置的e.offsetX
和e.offsetY
转换成相应的坐标值,通过这个坐标值进行棋子的绘制和后续的判断胜负。
js
// 记录棋盘的黑白棋,15*15的二维数组,初始值:-1,黑棋:1,白棋:2
let chess = Array.from({ length: SIZE }, () => Array(SIZE).fill(-1)),
isBlack = true; // 黑棋先下
// 监听棋盘点击位置
canvas.onclick = e => {
let [x, y] = [e.offsetX, e.offsetY].map(p => Math.round(p / W) - 1);
if (chess[x][y] !== -1 || x < 0 || x > SIZE - 1 || y < 0 || y > SIZE - 1) return alert('该位置不可落子!');
drawPiece(x, y, isBlack);
chess[x][y] = isBlack ? 1 : 2;
isBlack = !isBlack;
}
// 绘制棋子,x、y为坐标,isBlack判断黑白棋
const drawPiece = (x, y, isBlack) => {
ctx.beginPath();
ctx.arc(x * W + W, y * W + W, W * 0.4, 0, 2 * Math.PI);
ctx.closePath();
ctx.fillStyle = isBlack ? 'black' : 'white';
ctx.fill();
}
第三步,判断胜负
核心算法:从落子的位置开始,往横向、纵向、正斜、反斜四个方向判断是否五子连线。
我们先看看横向怎么判断吧! 如上图所示,落子的位置坐标为(x,y),向右一位的坐标为(x+1,y),向右i位的坐标为(x+i,y),向左一位的坐标为(x-1,y),向左j位的坐标为(x-j,y)。因此,我们可以得到的代码如下。
js
// 判断横向是否获胜,(先往右查询,再往左查询,判断左右是否有4个和当前落子颜色一样的棋子)
// x, y:当前落子的坐标
// role:当前落子的值,黑1白2
// chess:存储所有坐标对应的值,黑1白2,空的位置-1
const isWin = (x, y, role, chess) => {
// count计数(落子就有,从1开始算),i为向右查询次数,j为向左查询次数
let count = 1, i = 0, j = 0;
// 向右,遇到同色棋子,count++;遇到不同的颜色的棋子、空的位置、或者count=5退出
while (count < 5 && chess[x + i++][y] === role) count++
// 向左,遇到同色棋子;count++,遇到不同的颜色的棋子、空的位置、或者count=5退出
while (count < 5 && chess[x - j++][y] === role) count++
// 正负两个方向都查询完毕,count的值小于5或刚好等于5,如果等于5,判定为胜,否则为负
if (count === 5) {
return true
}
return false
}
上面的代码只能专门用来判断横向的,我们现在改写一下,使其可以适配另外3个方向。向左右移动一步时x是加1或减1,可以定义为x变化量dx = 1;向左右移动一步是y始终不变,可以定义为y的变化量为dy = 0。向右设为正方向,正方向走了i步之后:x + dx * i,向左设为负方向,负方向走了j步之后,x - dx * i。y也是同理。在上面的代码基础上改写一下,新代码如下:
js
// 判断横向是否获胜,(先往右查询,再往左查询,判断左右是否有4个和当前落子颜色一样的棋子)
// x, y:当前落子的坐标
// role:当前落子的值,黑1白2
// chess:存储所有坐标对应的值(15*15的二维数组),黑1白2,空的位置-1
const isWin = (x, y, role, chess) => {
// dx、dy为变化量,count计数(落子就有,从1开始算),i为往正方向查询次数,j为负方向查询次数
let dx = 1, dy = 0, count = 1, i = 0, j = 0;
// 向右(正方向查询),遇到同色棋子,count++;遇到不同的颜色的棋子、空的位置、或者count=5退出
while (count < 5 && chess[x + dx * i++][y + dy * i] === role) count++
// 向左(负方向查询),遇到同色棋子,count++;遇到不同的颜色的棋子、空的位置、或者count=5退出
while (count < 5 && chess[x + dx * j++][y + dy * j] === role) count++
// 正负两个方向都查询完毕,count的值小于5或刚好等于5,如果等于5,判定为胜,否则为负
if (count === 5) {
return true
}
return false
}
x - 1, y - 1 | x, y - 1 | x + 1, y - 1 |
---|---|---|
x - 1, y | x, y | x + 1, y |
x - 1, y + 1 | x, y + 1 | x + 1, y + 1 |
根据上面的规律,我们可以得到另外三个方向的变化量的值
竖向 :dx = 0, dy = 1
斜向 :dx = 1, dy = 1
反斜向:dy = 1, dy = -1
四个方向的代码进行合并,如下
js
// 判断横向是否获胜,(先往正方向查询,再往负方向查询,判断是否有4个和当前落子颜色一样的棋子)
// x, y:当前落子的坐标
// role:当前落子的值,黑1白2
// chess:存储所有坐标对应的值(15*15的二维数组),黑1白2,空的位置-1
const isWin = (x, y, role, chess) => {
// dx、dy为变化量,count计数(落子就有,从1开始算),i为往正方向查询次数,j为负方向查询次数
for (let [dx, dy] of [[1, 0], [0, 1], [1, 1], [1, -1]]) {
let [count, i, j] = [1, 0, 0];
// 正方向查询,遇到同色棋子,count++;遇到不同的颜色的棋子、空的位置、或者count=5退出
while(count < 5 && chess[x + dx * ++i][y + dy * i] === role) count++
// 负方向查询,遇到同色棋子,count++;遇到不同的颜色的棋子、空的位置、或者count=5退出
while(count < 5 && chess[x - dx * ++j][y - dy * j] === role) count++
if (count === 5) {
return true
}
}
return false
}
当然,往各个方向查询的时候,我们也要注意一下边界值
js
while(
count < 5 &&
x + dx * ++i >= 0 && x + dx * i < SIZE &&
y + dy * i >= 0 && y + dy * i < SIZE &&
chess[x + dx * i][y + dy * i] === role) count++
显然,这样写很麻烦,那有没有优雅一点的写法呢?当然有!我们可以利用ES6的链判断运算符?.
js
// 看一下chess[x]?.[y],x和y变化时,值的情况
// x范围0-14,y的范围0-14,正常值黑1白2空位-1
chess[3]?.[3] // x正常,y正常,得到正常的值1、2或-1
chess[3]?.[25] // x正常,y超界,得到异常的值undefined
chess[25]?.[3] // x超界,y正常,得到异常的值undefined
chess[25]?.[25] // x超界,y超界,得到异常的值undefined
// 额外说明:都不会报错
由上可知,只有在x和y都处于正常范围时,我们才能得到正常的值,否则都是返回undefined,因此我们可以将判断的代码改写如下
js
while(count < 5 && chess[x + dx * ++i]?.[y + dy * i] === role) count++
当chess[x + dx * ++i]?.[y + dy * i]
有一个坐标超界了,返回undefined,肯定不会等于role
,退出循环,刚好可以达到我们的目的。所以最后完整的算法代码如下:
js
// 判断是否获胜
const isWin = (x, y, role, chess) => {
for (let [dx, dy] of [[1, 0], [0, 1], [1, 1], [1, -1]]) {
let count = 1, i = 0, j = 0;
while(count < 5 && chess[x + dx * ++i]?.[y + dy * i] === role) count++
while(count < 5 && chess[x - dx * ++j]?.[y - dy * j] === role) count++
if (count === 5) return true
}
return false
}
这就是最核心的算法代码,只用几行就可以很优雅的解决。再补上和棋的代码,五子棋最简单的版本就完成了。最简单版本的完整代码如下:
js
const SIZE = 15, // 棋盘15*15=225个点
W = 50 , // 棋盘格子大小
SL = W * (SIZE + 1), // 边长 = 棋盘宽高
TOTAL_STEPS = SIZE * SIZE; // 总步数
/** @type {HTMLCanvasElement} */
let canvas = document.createElement('canvas'), ctx = canvas.getContext('2d');
canvas.width = canvas.height = SL; // 棋盘宽高 = 边长
canvas.style.cssText = 'position: absolute; inset: 0; margin: auto; cursor: pointer;';
document.body.appendChild(canvas);
// 记录棋盘的黑白棋,15*15的二维数组,初始值:-1,黑棋:1,白棋:2
let chess = Array.from({ length: SIZE }, () => Array(SIZE).fill(-1)),
isBlack = true, // 黑棋先下
moveSteps = 0; // 下棋步数
// 监听棋盘点击位置
canvas.onclick = e => {
let [x, y] = [e.offsetX, e.offsetY].map(p => Math.round(p / W) - 1);
if (chess[x]?.[y] !== -1) return alert('该位置不可落子!');
drawPiece(x, y, isBlack);
chess[x][y] = isBlack ? 1 : 2;
isWin(x, y, chess[x][y], chess) ? alert(`${isBlack ? '黑' : '白'}棋赢了!`) :
++moveSteps === TOTAL_STEPS ? alert('游戏结束,平局!') : isBlack = !isBlack;
}
// 绘制棋盘(棋盘背景色 && 网格线)
const drawBoard = () => {
ctx.fillStyle = '#E4A751';
ctx.fillRect(0, 0, SL, SL);
for (let i = 0; i < SIZE; i++) {
drawLine(0, i, SIZE - 1, i);
drawLine(i, 0, i, SIZE - 1);
}
}
// 两点连线:(x1, y1) <-> (x2, y2),设置线条宽度lineWidth和颜色LINE_COLOR
const drawLine = (x1, y1, x2, y2, lineWidth = 1, lineColor = '#000000') => {
ctx.lineWidth = lineWidth;
ctx.strokeStyle = lineColor;
ctx.beginPath();
ctx.moveTo(x1 * W + W, y1 * W + W);
ctx.lineTo(x2 * W + W, y2 * W + W);
ctx.stroke();
}
// 绘制棋子,x、y为坐标,isBlack判断黑白棋
const drawPiece = (x, y, isBlack) => {
ctx.beginPath();
ctx.arc(x * W + W, y * W + W, W * 0.4, 0, 2 * Math.PI);
ctx.closePath();
ctx.fillStyle = isBlack ? 'black' : 'white';
ctx.fill();
}
// 判断游戏胜负,(x, y)当前下棋坐标,role:黑1白2,chess:棋盘数据,二维数组,黑1白2空位-1
const isWin = (x, y, role, chess) => {
for (let [dx, dy] of [[1, 0], [0, 1], [1, 1], [1, -1]]) {
let count = 1, i = 0, j = 0;
while(count < 5 && chess[x + dx * ++i]?.[y + dy * i] === role) count++
while(count < 5 && chess[x - dx * ++j]?.[y - dy * j] === role) count++
if (count === 5) return true
}
return false
}
window.onload = drawBoard
去掉注释空行的话,大概只有50行代码,而且在html中不写任何标签和css,即可非常优雅地实现完整的功能。
第四步,美化界面,抽离全局变量
美化1:根据页面宽高,计算出每个格子的宽度,然后让棋盘尽可能铺满页面
js
const SIZE = 15, // 棋盘15*15=225个点
W = Math.min(window.innerWidth, window.innerHeight) / (SIZE + 3) , // 棋盘格子大小
SL = W * (SIZE + 1), // 边长 = 棋盘宽高
美化2:棋子添加渐变和阴影,使其看起来有一定的立体感
js
// 绘制棋子
const drawPiece = (x, y, isBlack) => {
ctx.save();
ctx.beginPath();
x = x * W + W;
y = y * W + W;
ctx.arc(x, y, W * 0.4, 0, 2 * Math.PI);
ctx.closePath();
ctx.shadowColor = SHADOW_COLOR;
ctx.shadowOffsetX = ctx.shadowOffsetY = W * 0.06;
ctx.shadowBlur = W * 0.04;
const gradient = ctx.createRadialGradient(x, y, 0, x, y, W * 0.4);
gradient.addColorStop(0, isBlack ? BLACK_CHESS_TOP_COLOR : WHITE_CHESS_TOP_COLOR);
gradient.addColorStop(1, isBlack ? BLACK_CHESS_COLOR : WHITE_CHESS_COLOR);
ctx.fillStyle = gradient;
ctx.fill();
ctx.restore();
}
第五步,功能扩展
功能1:最后一步棋子标记小红点
js
// 清除棋子(标记红点时,要把之前那个棋子涂抹掉,重新绘制)
const clearPiece = (x, y) => {
ctx.fillStyle = BOARD_BG_COLOR;
ctx.fillRect(x * W + W / 2, y * W + W / 2, W, W);
drawLine(x, y > 0 ? y - 0.5 : y, x, y < SIZE - 1 ? y + 0.5 : y);
drawLine(x > 0 ? x - 0.5 : x, y, x < SIZE - 1 ? x + 0.5 : x, y);
}
// 绘制小红点
const drawRedPoint = (x, y, r = 0.05 * W) => {
ctx.beginPath();
ctx.fillStyle = RED_POINT_COLOR;
ctx.arc(x * W + W, y * W + W, r, 0, 2 * Math.PI);
ctx.closePath();
ctx.fill();
}
// 监听棋盘点击位置
canvas.onclick = e => {
... 省略无关代码
if (steps.length > 0) { // 删除上一步的棋子(有红点),重新绘制
let {x, y, isBlack} = steps.at(-1)
clearPiece(x, y);
drawPiece(x, y, isBlack);
};
// 绘制新的棋子并标记小红点
drawPiece(x, y, isBlack);
drawRedPoint(x, y);
steps.push({x, y, isBlack})
... 省略无关代码
}
功能2:获胜时,将获胜的五个棋子连成一条线
思路:判断胜负的算法中,i代表向正方向走的步数,j代表向负方向走的步数,所以i和j代表的就是查询时停下的位置,正好就是我们想要的点,根据i和j的值,可以获得这两个点的坐标,然后使用我们绘制网格时用到的绘制线段的函数drawLine(两个坐标值)
即可。不过,这里有个问题,如下图所示: 出现问题的原因:优先正方向查询,所以i可能出现刚好停下的位置就是最后的位置,也有可能会超出一位(查询到不符合条件的点)。所以期望的i值可能是正确的,也可能超过1。换个思路,负方向是后面再查询的,所以j的值一定是对的,而获胜的时候,i + j = 4,所以可以通过j推断出正确的i值,i = 4 - j。因此,代码如下
js
// 判断游戏胜负,(x, y)当前下棋坐标,role:黑1白2,chess:棋盘信息
const isWin = (x, y, role, chess) => {
for (let [dx, dy] of [[1, 0], [0, 1], [1, 1], [1, -1]]) {
let [count, i, j] = [1, 0, 0];
while(count < 5 && chess[x + dx * ++i]?.[y + dy * i] === role) count++
while(count < 5 && chess[x - dx * ++j]?.[y - dy * j] === role) count++
if (count === 5) {
i = 4 - j;
drawLine(x + dx * i, y + dy * i, x - dx * j, y - dy * j, 线段宽度, 线段颜色)
return true
}
}
return false
}
这里提一个比较偏的语法:逗号操作符(不建议使用!!!)
js
// 逗号操作符,依次执行函数f1,函数f2,a = a + 1,返回最后一项的值true
(f1(), f2(), a = a + 1, true)
// 初始代码
if (count === 5) {
i = 4 - j;
drawLine(参数)
return true
}
// 简化
if (count === 5) return (i = 4 - j, drawLine(参数), true)
// 再简化,有return后面的语句就不用括号包着
if (count === 5) return i = 4 - j, drawLine(参数), true
// 特别说明:这种写法尽量不要用,前面的语句会对后面的语句造成影响,风险极大。开源项目装B可用。
还有悔棋的功能,大概实现思路,删掉最后一个棋子,绘制同时被删掉的棋盘线,给前一个棋子标记红点。这里就不再赘述了,有兴趣的可以试着实现。
总结 && 亮点
- 将点击位置转换成相应的坐标,无论是绘制、判断胜负等都是使用转换后的坐标
- 开发思路及编码过程:START → 绘制棋盘 → 监听棋盘点击 → 绘制棋子 → 判断是否获胜(算法)→ 美化界面 → 抽离全局变量 → 优化代码 → 功能扩展 → 整理代码 → 优化注释 → END
- 谨慎使用的偏门语法:逗号操作符:
(f1(), f2(), a = a + 1, true)
,if (count === 5) return i = 4 - j, drawLine(参数), true
。平时不要这样写,开源项目可以。 - 核心算法,直接上代码,前面很详细了,这里就不再赘述。
js
const isWin = (x, y, role, chess) => {
for (let [dx, dy] of [[1, 0], [0, 1], [1, 1], [1, -1]]) {
let count = 1, i = 0, j = 0;
while(count < 5 && chess[x + dx * ++i]?.[y + dy * i] === role) count++
while(count < 5 && chess[x - dx * ++j]?.[y - dy * j] === role) count++
if (count === 5) return i = 4 - j, drawLine(参数), true
}
return false
}
完整代码
简洁、优雅、清爽、自由,这也是JavaScript的魅力所在吧!
js
const SIZE = 15, // 棋盘15*15=225个点
W = Math.min(window.innerWidth, window.innerHeight) / (SIZE + 3) , // 棋盘格子大小
SL = W * (SIZE + 1), // 边长 = 棋盘宽高
BOARD_BG_COLOR = '#E4A751', // 棋盘背景颜色
LINE_WIDTH = 1, // 默认线条宽度
LINE_COLOR = '#000000', // 棋盘线颜色
WIN_LINE_WIDTH = 5, // 获胜时连线的宽度
WIN_LINE_COLOR = '#F05459', // 获胜线颜色
BLACK_CHESS_COLOR = '#000000', // 黑棋底黑
BLACK_CHESS_TOP_COLOR = '#707070', // 黑棋顶灰,顶灰过渡到底黑+阴影=立体
WHITE_CHESS_COLOR = '#D5D8DC', // 白棋底灰
WHITE_CHESS_TOP_COLOR = '#FFFFFF', // 白棋顶白,顶白过渡到底灰+阴影=立体
SHADOW_COLOR = 'rgba(0, 0, 0, 0.5)', // 阴影颜色
EMPTY_ROLE = -1, // 空位
BLACK_ROLE = 1, // 黑棋
WHITE_ROLE = 2, // 白棋
TOTAL_STEPS = SIZE * SIZE; // 总步数
/** @type {HTMLCanvasElement} */
let canvas = document.createElement('canvas'), ctx = canvas.getContext('2d');
canvas.width = canvas.height = SL; // 棋盘宽高 = 边长
canvas.style.cssText = 'position: absolute; inset: 0; margin: auto;cursor: pointer;';
document.body.appendChild(canvas);
// 记录棋盘的黑白棋,15*15的二维数组,初始值:0,黑棋:1,白棋:2
let chess = Array.from({ length: SIZE }, () => Array(SIZE).fill(EMPTY_ROLE)),
isBlack = true, // 黑棋先下
moveSteps = 0; // 下棋步数
// 监听棋盘点击位置
canvas.onclick = e => {
let [x, y] = [e.offsetX, e.offsetY].map(p => Math.round(p / W) - 1);
if (chess[x]?.[y] !== EMPTY_ROLE) return;
drawPiece(x, y, isBlack);
chess[x][y] = isBlack ? BLACK_ROLE : WHITE_ROLE;
isWin(x, y, chess[x][y], chess) ? alert(`${isBlack ? '黑' : '白'}棋赢了!`) :
++moveSteps === TOTAL_STEPS ? alert('游戏结束,平局!') : isBlack = !isBlack;
}
// 绘制棋盘(棋盘背景色 && 网格线)
const drawBoard = () => {
ctx.fillStyle = BOARD_BG_COLOR;
ctx.fillRect(0, 0, SL, SL);
for (let i = 0; i < SIZE; i++) {
drawLine(0, i, SIZE - 1, i);
drawLine(i, 0, i, SIZE - 1);
}
}
// 两点连线:(x1, y1) - (x2, y2),设置线条宽度lineWidth和颜色LINE_COLOR
const drawLine = (x1, y1, x2, y2, lineWidth = LINE_WIDTH, lineColor = LINE_COLOR) => {
ctx.lineWidth = lineWidth;
ctx.strokeStyle = lineColor;
ctx.beginPath();
ctx.moveTo(x1 * W + W, y1 * W + W);
ctx.lineTo(x2 * W + W, y2 * W + W);
ctx.stroke();
}
// 绘制棋子
const drawPiece = (x, y, isBlack) => {
ctx.save();
ctx.beginPath();
x = x * W + W;
y = y * W + W;
ctx.arc(x, y, W * 0.4, 0, 2 * Math.PI);
ctx.closePath();
ctx.shadowColor = SHADOW_COLOR;
ctx.shadowOffsetX = ctx.shadowOffsetY = W * 0.06;
ctx.shadowBlur = W * 0.04;
const gradient = ctx.createRadialGradient(x, y, 0, x, y, W * 0.4);
gradient.addColorStop(0, isBlack ? BLACK_CHESS_TOP_COLOR : WHITE_CHESS_TOP_COLOR);
gradient.addColorStop(1, isBlack ? BLACK_CHESS_COLOR : WHITE_CHESS_COLOR);
ctx.fillStyle = gradient;
ctx.fill();
ctx.restore();
}
// 判断游戏胜负,(x, y)当前下棋坐标,role:黑1白2,chess:棋盘信息
const isWin = (x, y, role, chess) => {
for (let [dx, dy] of [[1, 0], [0, 1], [1, 1], [1, -1]]) {
let count = 1, i = 0, j = 0;
while(count < 5 && chess[x + dx * ++i]?.[y + dy * i] === role) count++
while(count < 5 && chess[x - dx * ++j]?.[y - dy * j] === role) count++
if (count === 5) return i = 4 - j, drawLine(x + dx * i, y + dy * i, x - dx * j, y - dy * j, WIN_LINE_WIDTH, WIN_LINE_COLOR), true
}
return false
}
window.onload = drawBoard
多说两句
Github:github.com/gaoxiaosi/c...
这是一个系列,纯Canvas实现经典小游戏,目前只完成了五子棋和2048。如果感兴趣的话,可以关注一下,后续会持续更新,谢谢!