经常和朋友玩「五子棋」微信小游戏双人对战,我就在想为什么不自己开发一个呢?正赶上公司这周的内部技术分享会排到我了,我就写了一个五子棋。由于时间有限,先完成单机模式!
会议结束后同事表示:
原来这么简单,我也去写一个!
在线体验地址 和 github源码 见文末
<canvas id="canvas">canvas>
class FiveLine {
/**
* 接收canvas的dom
* css宽高可选,默认500px
* 为什么叫cssWidth、cssHeight,
* 是因为这个值表示的只是dom元素在网页内显示的宽高
* 绘制出来的图形大小往往会比这个大,显示出来会更清晰
*/
constructor(canvas, {cssWidth = 500, cssHeight = 500} = {}) {
this.canvas = canvas;
this.ctx = canvas.getContext("2d");
this.cssWidth = cssWidth;
this.cssHeight = cssHeight;
// 定义当前玩家,默认为B,代表黑色棋子
this.curPlayer = 'B';
// 定义保存玩家已经放下的所有棋子的坐标数组
this.pieces = [];
// 初始化画布的样式
this.init()
}
init() {
const { ctx, canvas } = this;
// 设置canvas元素的宽高
canvas.style.width = this.cssWidth + "px";
canvas.style.height = this.cssHeight + "px";
// 棋盘背景
ctx.fillStyle = "#dbb263";
ctx.fillRect(0, 0, width, height);
// 格子线条样式
ctx.lineWidth = 1;
ctx.strokeStyle = "#000";
}
}
为什么会有这一步,顺便补充一下我们容易忽略的基础问题,请查看解决canvas在高清屏中绘制模糊的问题
回过头来我们就知道了,这里涉及到一个概念,「canvas的width、height属性与canvas元素style的width、height是有区别的」,即
<canvas id="canvas" width="300" height="300">canvas>
和
<canvas style="width: 300px; height: 300px">canvas>
或者
#canvas {
width: 300px;
height: 300px;
}
是有区别的。
综上所述,我们已经知道了canvas元素的宽高是500px,那么画布实际大小应该是多少呢?「元素宽高和画布宽高之间应该有一个比例」。
增加一个获取像素比的方法:
getPixelRatio(context) {
const backingStore =
context.backingStorePixelRatio ||
context.webkitBackingStorePixelRatio ||
context.mozBackingStorePixelRatio ||
context.msBackingStorePixelRatio ||
context.oBackingStorePixelRatio ||
1;
// window.devicePixelRatio:当前显示设备的物理像素分辨率与CSS 像素分辨率之比
return Math.round((window.devicePixelRatio || 1) / backingStore);
}
知道了这个比例,我们来改一下init方法
init() {
const { ctx, canvas } = this;
// 设置canvas元素的宽高
canvas.style.width = this.cssWidth + "px";
canvas.style.height = this.cssHeight + "px";
// 设置canvas的实际宽高
canvas.width = this.cssWidth * this.pixelRatio(ctx);
canvas.height = this.cssHeight * this.pixelRatio(ctx);
// 棋盘背景
ctx.fillStyle = "#dbb263";
ctx.fillRect(0, 0, width, height);
// 格子线条样式
ctx.lineWidth = 1;
ctx.strokeStyle = "#000";
}
我们都知道在canvas世界中,「线条是由点组成的」,两个点连起来就有了一条线,所以绘制线条要先知道「起点和终点的坐标」,现在一般五子棋的棋盘都是由「横竖15条线」组成,一共产生255个交叉点。仔细想想,画个草图如下:
// 线条数量默认15
getLinePoints(lineNum = 15) {
const { width, height } = this.canvas
// 一般五子棋游戏棋盘都会有一个外边距
// 同方向15条线一共组成14个格子
// 每个格子的宽度为:
const gap = width / lineNum;
// 外边距设置为格子宽度的一半
// 可以得到左上角第一个点的位置
const start = gap / 2;
// 生成线条起点和终点坐标
const row = [];
const col = [];
for (let i = 0; i < lineNum; i++) {
row.push({
startX: start,
startY: start + i * gap,
endX: width - gap / 2,
endY: start + i * gap
});
}
for (let i = 0; i < lineNum; i++) {
col.push({
startX: start + i * gap,
startY: start,
endX: start + i * gap,
endY: height - gap / 2
});
}
return { row, col, gap };
}
在init中调用一下,并且绘制线条
init() {
// ...前面的就省略了
// 得到15条横线、15条竖线的开始和结束坐标,以及格子的宽度,即两两线条的间距
const { row, col, gap } = this.getLinePoints();
// gap保存到全局,后面用得上
this.gap = gap;
// 绘制线条
this.drawLine(ctx, row, col);
}
定义绘制线条的方法
// 画线
drawLine(ctx, row, col) {
// 循环画15条横线
row.forEach((item, index) => {
// 每画完一条都重新开始
ctx.beginPath();
ctx.moveTo(item.startX, item.startY);
ctx.lineTo(item.endX, item.endY);
ctx.stroke();
ctx.closePath();
});
// 循环画15条竖线
col.forEach((item, index) => {
ctx.beginPath();
ctx.moveTo(item.startX, item.startY);
ctx.lineTo(item.endX, item.endY);
ctx.stroke();
ctx.closePath();
});
}
此时完成效果如下:
// 计算所有的交叉点
getCrossPoints(row, col) {
const points = [];
row.forEach((r) => {
col.forEach((c) => {
const A = [r.startX, r.startY];
const B = [r.endX, r.endY];
const C = [c.startX, c.endY];
const D = [c.endX, c.startY];
const intersection = this.getIntersection(A, B, C, D);
if (intersection) {
points.push(intersection);
}
});
});
return points;
}
// 获取AB和CD两条线的交点坐标
// A:开始坐标
// B:结束坐标
// C:开始坐标
// D:结束坐标
getIntersection(A, B, C, D) {
const x1 = A[0],
y1 = A[1],
x2 = B[0],
y2 = B[1],
x3 = C[0],
y3 = C[1],
x4 = D[0],
y4 = D[1];
const denominator = (x1 - x2) * (y3 - y4) - (y1 - y2) * (x3 - x4);
if (denominator === 0) return null; // The lines are parallel
const x =
((x1 * y2 - y1 * x2) * (x3 - x4) - (x1 - x2) * (x3 * y4 - y3 * x4)) /
denominator;
const y =
((x1 * y2 - y1 * x2) * (y3 - y4) - (y1 - y2) * (x3 * y4 - y3 * x4)) /
denominator;
return [x, y];
}
在init中调用一下
init() {
// ...前面的就省略了
// 获取所有交叉点坐标,并保存到全局
this.crossPoints = this.getCrossPoints(row, col);
}
添加绑定事件的方法,在init调用一下
init() {
// ...前面的就省略了
this.bindEvent()
}
bindEvent() {
const { canvas } = this;
// 监听点击画布
canvas.addEventListener("click", this.handleClick)
}
// 处理点击事件
handleClick(e) {
const { canvas } = this;
// 获取点击的坐标
const { x, y } = this.getMousePos(canvas, e)
}
// 获取鼠标点击在canvas内以左上角为基点的坐标
getMousePos(canvas, event) {
const rect = canvas.getBoundingClientRect();
const x = event.clientX - rect.left;
const y = event.clientY - rect.top;
// 前面我们设置的canvas大小跟css宽高不一致,所以要乘以比例
return { x: x * this.pixelRatio, y: y * this.pixelRatio };
}
现在有一个问题,我们已经拿到了点击的坐标,但是「点击的位置不一定刚好在交叉点上」(这是一个极小概率事件),而我们的目标是以交叉点为圆心画圆(棋子),那么就要「找到离点击位置最近的一个交叉点」坐标,在那个位置上落子。
// 计算离点击的位置最近的一个交叉点坐标
nearestPoint(point, coords) {
let minDist = Infinity;
let nearest;
for (let coord of coords) {
let dist = this.getDistance(point, coord);
if (dist < minDist) {
minDist = dist;
nearest = coord;
}
}
return nearest;
}
getDistance(p1, p2) {
let dx = p1[0] - p2[0];
let dy = p1[1] - p2[1];
return Math.sqrt(dx * dx + dy * dy);
}
改一下处理点击事件
handleClick(e) {
const { canvas } = this;
// 获取点击的坐标
const { x, y } = this.getMousePos(canvas, e)
// 把实际点击的坐标和所有交叉点坐标传进去
const point = this.nearestPoint([x, y], this.crossPoints);
console.log('实际落子位置 ===>', point)
// 判断点击的位置是否已经有棋子了
if (
this.pieces.find((c) => c.point[0] === point[0] && c.point[1] === point[1])
) {
alert("此处已有棋子");
return;
}
// 保存此次棋子,标记是属于哪个玩家的
this.pieces.push({
player: this.curPlayer,
point
});
// 绘制棋子
this.drawPiece(
point[0],
point[1],
this.curPlayer === "B" ? "black" : "white"
);
// 监听是否已有五子相连
const isWin = this.watchWin();
if (!isWin) {
// 还没有出现胜者
// 变更下一次的玩家
this.curPlayer = this.curPlayer === "W" ? "B" : "W";
} else {
// 有人胜利了
setTimeout(() => {
alert(this.curPlayer === "B" ? "小黑赢了" : "小白赢了")
}, 0)
}
}
// 绘制棋子
drawPiece(x, y, color) {
const { ctx } = this;
// 阴影
ctx.shadowColor = "#ccc";
ctx.shadowBlur = this.gap / 3;
ctx.beginPath();
ctx.arc(x, y, this.gap / 3, 0, Math.PI * 2);
ctx.closePath();
ctx.fillStyle = color;
ctx.fill();
}
到这里看下落子效果
实现上一步中的watchWin方法
watchWin() {
// 我们前面是把两个玩家的棋子都放到了一个数组里
// 并且标记了每个棋子是谁的
// 现在要分开
// 当前玩家是谁,就判断谁的棋子是否达成五连
const { pieces, curPlayer } = this;
const B = pieces
.filter((item) => item.player === "B")
.map((item) => item.point);
const W = pieces
.filter((item) => item.player === "W")
.map((item) => item.point);
// 检查玩家的棋子是否达成五连并返回boolean值
return this.checkWin(curPlayer === "B" ? B : W)
}
判断输赢有三种情况
只要达成一种情况就赢了
// 检查是否赢了
checkWin(coordinates) {
// 玩家棋子数量小于5直接返回
if (coordinates.length < 5) return;
return (
this.transverse(coordinates) ||
this.vertical(coordinates) ||
this.slant(coordinates)
)
}
思路分析:
// 判断横向
transverse(arr) {
// 把数据保存到一个对象
let obj = {};
// 先按x轴坐标大小升序,便于比较
const xCoordinates = JSON.parse(JSON.stringify(arr)).sort(
(a, b) => a[0] - b[0]
);
// 按y轴分组
xCoordinates.forEach((item) => {
if (obj[item[1]]) {
obj[item[1]].push(item);
} else {
obj[item[1]] = [item];
}
});
console.log("[ obj ] >", obj);
for (const y in obj) {
// 统计一条线上连续棋子的数量
let count = 1;
const element = obj[y];
if (element.length >= 5) {
// 一条横线上的连续棋子数量大于等于5才有效
for (let i = 1; i < element.length; i++) {
if (element[i][0] === element[i - 1][0] + this.gap) {
// 因为前面排序了的
// 如果下一个坐标的x轴等于上一个坐标的x轴 + 线条间距
// 说明是连续的,数量+1
count++;
}
}
// 一旦大于等于5,就赢了
return count >= 5;
}
}
};
竖向和横向的思路是一样的
vertical(arr) {
let obj = {};
// 按y轴大小排序
const yCoordinates = JSON.parse(JSON.stringify(arr)).sort(
(a, b) => a[1] - b[1]
);
// 按x轴分组
yCoordinates.forEach((item) => {
if (obj[item[0]]) {
obj[item[0]].push(item);
} else {
obj[item[0]] = [item];
}
});
for (const x in obj) {
let count = 1;
const element = obj[x];
if (element.length >= 5) {
for (let i = 1; i < element.length; i++) {
if (element[i][1] === element[i - 1][1] + this.gap) {
count++;
}
}
return count >= 5;
}
}
};
斜向分两种情况,从左下角到右上角和从左上角到右下角,思路也是先按x轴大小排序
slant(arr) {
// 按x轴大小升序
const xCoordinates = JSON.parse(JSON.stringify(arr)).sort(
(a, b) => a[0] - b[0]
);
const findFiveInARow = (points) => {
// 将点转换为字符串,并放入一个集合中,以便我们可以快速查找它们
const pointSet = new Set(points.map((p) => p.join(",")));
// 遍历每一个点
for (let p of points) {
// 检查右斜线方向
let rightDiagonal = [];
for (let i = 0; i < 5; i++) {
const nextPoint = [p[0] + i * this.gap, p[1] + i * this.gap];
if (pointSet.has(nextPoint.join(","))) {
rightDiagonal.push(nextPoint);
} else {
break;
}
}
// 如果找到了五个连续的点,返回它们
if (rightDiagonal.length === 5) {
return rightDiagonal;
}
// 检查左斜线方向
let leftDiagonal = [];
for (let i = 0; i < 5; i++) {
const nextPoint = [p[0] + i * this.gap, p[1] - i * this.gap];
if (pointSet.has(nextPoint.join(","))) {
leftDiagonal.push(nextPoint);
} else {
break;
}
}
// 如果找到了五个连续的点,返回它们
if (leftDiagonal.length === 5) {
return leftDiagonal;
}
}
return false;
};
return findFiveInARow(xCoordinates);
};
看下效果
在线体验
github