作者:小影前端团队——阿星
本文通过使用简单的canvas技术实现一个破产版的五子棋游戏,为初学canvas的小伙伴提供对一些基本概念的了解,以及记录我的学习心得,欢迎大家多多交流,也请各位大佬不吝赐教。效果图如下
游戏规则
游戏和一般的五子棋规则一样,但很多地方做了简化。
- 五子连通用即代表游戏胜利
- 先手通过点击黑子或白子决定
- 悔棋按钮可以取回棋子
设计思路
- 绘制15 * 15 的网格作为棋盘
- 游戏分为两种模式
- 玩家点击棋子按钮后才能在棋盘下棋子
- 玩家下棋子后可以选中棋子并移动
- 悔棋时将保存棋子数组的最后一项弹出
关键点
绘制棋盘
在canvas绘制的过程中如果想绘制出我们想要的效果,需要不断设置绘制上下文的属性。
如代码所示:在绘制网格前我设置了要绘制网格的线宽、线的颜色以及设置了填充颜色用于清空画布。
function drawGrid(color, stepX, stepY) {
// 保存之前的上下文
context.save()
// 设置线宽、线的颜色
context.strokeStyle = color;
context.lineWidth = 0.5;
// 清空画布
context.fillStyle = '#ffffff';
context.fillRect(0, 0, context.canvas.width, context.canvas.height);
// 开启一段子路径
context.beginPath();
// 绘制列
for (var i = stepX + 0.5; i < context.canvas.width; i += stepX) {
context.moveTo(i, 0);
context.lineTo(i, context.canvas.height);
}
// 绘制行
for (var i = stepY + 0.5; i < context.canvas.height; i += stepY) {
context.moveTo(0, i);
context.lineTo(context.canvas.width, i);
}
//对当前路径进行描边
context.stroke();
// 恢复之前的上下文
context.restore();
}
- context.save()和context.restore() 绘制上下文属性管理
上面的两个方法在canvas绘制中是很常用的,他们的作用很简单,save 将该方法调用之前的上下文push入栈中,restore 方法将栈顶的上下文弹出。因为canvas在绘制的过程中需要不断的设置绘制上下文的属性,在绘制对象之前将当前的属性保存,然后设置当前对象的属性,绘制完成后恢复之前的属性。这样能保证每个对象都被应用正确的绘制属性。
- context.beginPath() context.stroke()
在canvas中大多数的图形绘制都是通过路径实现的, beginPath 方法会清除子路径列表,开启一个新的子路径。这里的子路径可以想象为透明直线或曲线组成的区域(可能是未闭合的)调用stroke 方法会对当前存在的子路径进行描边。调用fill 方法会对当前存在的子路径进行填充。
为什么beginPath 在开启一段路径时要清除子路径列表,因为stroke、fill 方法应用于当存在的所有子路径。这会导致前面绘制的路径样式被覆盖。具体如下:
ctx.beginPath();
ctx.moveTo(100.5,20.5);
ctx.lineTo(200.5,20.5);
ctx.strokeStyle = 'black'; // 设置描边为黑色
ctx.stroke();
ctx.moveTo(100.5,40.5);
ctx.lineTo(200.5,40.5);
ctx.strokeStyle = 'red'; // 设置描边为红色
ctx.stroke(); // 由于未使用beginPath,上面的黑色线段会被新描边为红色
效果图如下:
- canvas像素边界问题
注意上面在绘制行和列时,都会加上i = stepX + 0.5 ,这是由于像素边界导致的,具体原因看下图:
如图所示:canvas的绘制表面可以看作由1px宽高的行和列组成,当我们绘制一条起点为整数像素,线宽为1px的线时,线的中心会默认放在绘制的起点。如上moveTo(3, 1) lineTo(3, 5) 。线中心会处于3px处,因为像素无法在每一列或行显示为0.5px,所以会线性插值填满整格。导致1px看起来更粗切模糊。解决方法就是默认加上0.5像素,将线中心移动到格中心。
- 绘图表面(绘制表面的宽高和canvas的大小是两个完全不同的东西)
在绘制棋盘前通过以下代码设置了背景色。
context.fillStyle = '#ffffff';
// 这里设置的宽高是绘图表面的宽高
context.fillRect(0, 0, context.canvas.width, context.canvas.height);
这里有一个绘图表面的概念。我将其理解会我们通过canvas api绘制时可见坐标系的大小(理论上这个坐标系是无穷大的,不过超出绘图表面大小的物体不显示)绘图表面的大小可以通过两种方式设置
- canvas.width = 720; canvas.height = 750;
绘图表面是我们在进行绘制时可感知的,而可见的canvas大小受另外一种因素的影响 —— css的样式(width、height、boder、padding、box-sizing...)。其中boder、padding、box-sizing影响的是宽高属性的计算。
当设置绘制表面的宽高和设置canvas的大小不一致时,会对绘图表面进行缩放(也影响在canvas上面的坐标获取)。如下图:
可以看出图二绘制的图形被放大了两倍。所以发现绘制的效果在大小上不正常可以看看是不是这个原因。代码如下:
<canvas width="600" height="300" style="border: 1px solid #000;"></canvas>
<canvas width="300" height="150" style="height: 600px; height: 300px; border: 1px solid #000;"></canvas>
<script>
const [canvas1, canvas2] = document.querySelectorAll('canvas');
const ctx1 = canvas1.getContext('2d');
const ctx2 = canvas2.getContext('2d');
draw(ctx1, canvas1);
draw(ctx2, canvas2);
function draw(ctx, canvas) {
ctx.font = '38px Arial';
ctx.fillStyle = 'cornflowerblue';
ctx.strokeStyle = 'blue';
ctx.fillText("Hello Canvas", canvas.width / 2 - 150, canvas.height / 2 + 15);
ctx.strokeText("Hello Canvas", canvas.width / 2 - 150, canvas.height / 2 + 15 );
}
</script>
获取canvas上面的点击坐标
这里在获取坐标时考虑了绘图表面和canvas css大小不一的问题。
(x - box.left) * (canvas.width / box.width) 通过将计算后的坐标乘以缩放比,得到在绘制表面上要应用的正确坐标。注意这里的坐标位置还受canvas padding、border 的影响。所以尽量不要给canvas设置这些样式属性。
// x e.clientX 点击X坐标
// y e.clientY 点击Y坐标
function windowToCanvas(x, y) {
// 获取canvas元素的坐标信息
var box = canvas.getBoundingClientRect();
return {
x: (x - box.left) / (box.width / canvas.width),
y: (y - box.top) / (box.height / canvas.height)
};
}
背景的保存与恢复
canvas绘制模式属于即时模式(还有一种是保留模式),所以当发生变化时需要将所有的绘制对象重新绘制(不包括clip这种技术)。
这里引用一个例子
假设我们现在需要一个数字2 ,然后我又需要两个数字 20,再者我们需要数字201,最后我们需要数字2019。
我们会怎么做呢?先拿出一张纸,写下一个数字2,然后在2后面写下 0,而后在0后面写下1,最后在1 后面写下9;这样我们就得到了2019 。这种模式就是保留模式。
或者我们先拿出一张纸写下2 ,然后再拿出一张纸写下20 ,而后拿出一张纸写下201 ,最后再拿出一张纸写下2019。这种模式就是立即模式。
保留模式会在内存中保存状态,当有需要改变的时候,执行的是改变的处理,也就是说前后不改变的地方不会变化。但是立即模式不是这样,立即模式会每次都重新绘制所有的元素,不管这个对象改变的是一点点还是两点点,也就是说不会像保留模式那样,占用过高的内存资源。
为了简单,每次变化我都会重新绘制所有的图形对象。有想法的大佬们可以考虑将绘制一段变化前的canvas保存为ImageData,在每次绘制变化前作为背景填充。然后绘制变化对象。
棋子绘制之径向渐变
为了模拟棋子,我采取了一种很简单的方案,通过径向渐变产生色差绘制棋子,代码如下:
// 获取一个渐变对象
const radialGradient = context.createRadialGradient(
this.x,
this.y,
this.radius - 2, // 外部圆心和半径
this.x,
this.y,
0 // 内部圆心和半径
);
// 判断棋子的类型,设置不同的渐变色
if (this.type === "black") {
radialGradient.addColorStop(0, "#0A0A0A");
radialGradient.addColorStop(1, "#636766");
} else {
radialGradient.addColorStop(0, "#D1D1D1");
radialGradient.addColorStop(1, "#F9F9F9");
}
context.fillStyle = radialGradient;
context.fill();
对象拾取
在五子棋游戏中,我们需要拖动棋子。这就要求我们知道当前选中的是那个棋子,对其的坐标进行计算,然后重新绘制。
canvas拾取方案 这里有一个比较详细的canvas拾取方案介绍。我采取的是第二种,通过canvas内置的apicontext.isPointInPath(loc.x, loc.y)进行对象拾取。这个api很简单,判断当前鼠标的位置是否在当前子路径中。所以我们所有创建的棋子,都需要将其路径存信息储起来。在判断拾取对象时遍历子路径,当返回为true时,表示当前对象正被拾取。代码如下:
// Chessman class
class Chessman {
constructor(centerX, centerY, radius, type) {
this.x = centerX;
this.y = centerY;
this.radius = radius;
this.type = type;
}
createPath(context) {
context.beginPath();
context.arc(this.x, this.y, this.radius, 0, Math.PI * 2, false);
context.closePath();
}
}
// chessmanList 是棋子对象列表
chessmanList.forEach( function (chessman) {
chessman.createPath(context);
if (context.isPointInPath(loc.x, loc.y)) {
target = chessman;
draggingOffsetX = loc.x - chessman.x;
draggingOffsetY = loc.y - chessman.y;
return;
}
});
具体代码
Html
<!DOCTYPE html>
<html>
<head>
<title>五子棋游戏</title>
<style>
body { position: relative; background: #eeeeee; height: 100vh; }
#canvas {
position: absolute; top: 0; left: 0; right: 0; bottom: 0; margin: auto;
cursor: pointer; background: #ffffff;box-shadow: -1px -1px 1px #6F6767, 1px 1px 1px #6F6767;
}
.btnWrap { position: relative; top: 40px; text-align: center; }
</style>
</head>
<body>
<div class="btnWrap">
<button>黑子</button>
<span style="display: inline-block;width: 240px;"></span>
<button>悔棋</button>
<span style="display: inline-block;width: 240px;"></span>
<button>白子</button>
</div>
<script src = './chessman.js'></script>
<script src = 'example.js'></script>
</body>
</html>
棋子类
class Chessman {
constructor(centerX, centerY, radius, type) {
this.x = centerX;
this.y = centerY;
this.radius = radius;
this.type = type;
}
createPath(context) {
context.beginPath();
context.arc(this.x, this.y, this.radius, 0, Math.PI * 2, false);
context.closePath();
}
draw(context) {
context.save();
this.createPath(context);
const radialGradient = context.createRadialGradient(
this.x,
this.y,
this.radius - 2,
this.x,
this.y,
0
);
if (this.type === "black") {
radialGradient.addColorStop(0, "#0A0A0A");
radialGradient.addColorStop(1, "#636766");
} else {
radialGradient.addColorStop(0, "#D1D1D1");
radialGradient.addColorStop(1, "#F9F9F9");
}
context.fillStyle = radialGradient;
context.fill();
context.restore();
}
move(x, y) {
this.x = x;
this.y = y;
}
}
绘制代码
let canvas = document.getElementById('canvas'),
context = canvas.getContext('2d'),
blackBtn = document.querySelectorAll('button')[0],
backBtn = document.querySelectorAll('button')[1],
whiteBtn = document.querySelectorAll('button')[2],
type,// 当前要绘制的棋子类型
mousedown = {}, // 点击坐标
target, // 选中的棋子
draggingOffsetX, // x方向移动偏移
draggingOffsetY, // y方向移动偏移
dragging = false, // 是否在移动棋子,点击下棋按钮后不可移动,绘制完棋子后可移动
chessmanList = []; // 保存棋子对象的列表
function drawGrid(color, stepX, stepY) {
context.save()
// 设置线宽、线的颜色
context.strokeStyle = color;
context.lineWidth = 1;
// 清空画布
context.fillStyle = '#ffffff';
context.fillRect(0, 0, context.canvas.width, context.canvas.height);
context.beginPath();
for (let i = stepX + 0.5; i < context.canvas.width; i += stepX) {
context.moveTo(i, 0);
context.lineTo(i, context.canvas.height);
}
for (let i = stepY + 0.5; i < context.canvas.height; i += stepY) {
context.moveTo(0, i);
context.lineTo(context.canvas.width, i);
}
context.stroke();
context.restore();
}
// x e.clientX 点击X坐标
// y e.clientY 点击Y坐标
function windowToCanvas(x, y) {
// 获取canvas元素的坐标信息
const box = canvas.getBoundingClientRect();
return {
x: (x - box.left) / (box.width / canvas.width),
y: (y - box.top) / (box.height / canvas.height)
};
}
// 添加棋子
function drawChessman() {
if (!type) return;
const chessman = new Chessman(mousedown.x, mousedown.y, 16, type,);
chessman.draw(context);
chessmanList.push(chessman);
dragging = true;
}
// 绘制所有棋子
function drawPolygons() {
chessmanList.forEach( function (chessman) {
chessman.draw(context);
});
}
// 保存点击坐标
function savePosition(loc) {
mousedown.x = loc.x;
mousedown.y = loc.y;
}
backBtn.onclick = function (e) {
chessmanList.pop();
drawGrid('#c7c1c1', 50, 50);
drawPolygons();
};
blackBtn.onclick = function (e) {
type = 'black';
dragging = false;
};
whiteBtn.onclick = function (e) {
type = 'white';
dragging = false;
};
canvas.onmousedown = function (e) {
const loc = windowToCanvas(e.clientX, e.clientY);
e.preventDefault();
savePosition(loc);
// 如果可以移动,则找到要移动的对象
if (dragging) {
chessmanList.forEach( function (chessman) {
chessman.createPath(context);
if (context.isPointInPath(loc.x, loc.y)) {
target = chessman;
draggingOffsetX = loc.x - chessman.x;
draggingOffsetY = loc.y - chessman.y;
return;
}
});
} else {
drawChessman();
}
};
canvas.onmousemove = function (e) {
const loc = windowToCanvas(e.clientX, e.clientY);
e.preventDefault();
if (dragging && target) {
target.move(loc.x - draggingOffsetX, loc.y - draggingOffsetY);
drawGrid('#c7c1c1', 50, 50);
drawPolygons();
}
};
canvas.onmouseup = function (e) {
target = null;
};
// init
drawGrid('#c7c1c1', 50, 50);
招贤纳士
小影前端团队,是一个年轻、活动且富有创造力的团队,隶属于小影科技开发中心,Base 在风景如画的杭州。团队在日常的业务对接之外,还在互动技术、图像渲染、跨端技术、工程化平台、性能体验、数据分析及可视化等方向进行技术探索和实战,推动并落地了一系列的内部技术产品,并一直保持好奇,持续探索前端技术。 如果你想大胆自信的表达你的想法;如果你想有个机会实现自己的想法;如果你希望落地的想法有一个团队来支撑;如果你愿意跟一群积极向上的小伙伴干一些持续迭代自己,持续提升技术能力的事;如果你相信相信的力量;那就加入我们吧!快戳链接》》quvideo.jobs.feishu.cn/s/e94EjWC