引言,没有,免得虐待动物。(大雾)
先上实战项目地址,求star
Chinese Checkers by React.js & Socket.io.
跳棋
跳棋的游戏规则很简单,棋子的移动可以一步步在有直线连接的相邻六个方向进行,如果相邻位置上有任何方的一个棋子,该位置直线方向下一个位置是空的,则可以直接“跳”到该空位上,“跳”的过程中,只要相同条件满足就可以连续进行。谁最先把正对面的阵地全部占领,谁就取得胜利。一玩就懂,所以几乎每个人都下过跳棋。在香港跳棋被称为“波子棋”。
大概就是张这样子。

哈哈,不要被迷倒,你下棋的样子最认真。
好,我们接下来开始实现她。
基本实现思路
- 先创建一个canvas元素。
- 先得出棋盘所有的坐标,用空心圆渲染。
- 再找到所有玩家的坐标,然后用不同颜色的实心圆渲染玩家的棋子。
- 监听canvas的点击事件,判断出是否在操作棋子,并做出相应的响应:
如果是移动棋子,先在目标位置画上实心圆,再清理圆形区域,再画上空心圆。
是不是非常的easy,下面我们一步一步的来。
我们先温习一下 canvas 中的绘图
比如,先画一个空心圆,用来表示没有被压上棋子的空棋盘格子,好奇怪,为什么是压上。。。
function strokeArc(x, x, borderColor, radius) {
ctx.beginPath();
ctx.arc(x, x, radius, 0, 2 * Math.PI, true);
ctx.strokeStyle = borderColor;
ctx.stroke();
}
再来画个实心圆,当然这个是用来表示棋子的
function fillArc(x, y, color, radius) {
ctx.beginPath();
ctx.arc(x, x, radius, 0, 2 * Math.PI, true);
ctx.fillStyle = color;
ctx.fill();
}
好像都很容易吧,哈哈。当棋子移动的时候,就需要把之前一坨实心圆给清理掉,再画上空心圆,基本思路, canvas 里面有clearRect
方法,文档翻烂都没有找到clearArc
,咋办,还好有 globalCompositeOperation 这个属性,来我们自己 DIY 一个出来。
function cleanArc(x, y, radius) {
ctx.globalCompositeOperation = 'destination-out';
this.fillArc(x, y, radius * 1.1);
ctx.globalCompositeOperation = 'source-over';
}
接着定义一下棋盘坐标
显然,不能直接使用canvas的坐标体系。棋盘是个六边形,所以,那就歪啦,这个歪坐标系,我称之为跳棋坐标系。横着的是X轴(自左向右自增),竖着的是Y轴(由上向下自增)。

缓存一个变量
pos
,用来缓存所有的坐标,用来关联跳棋坐标系和canvas中真实位置。其数据结构如下:
// 棋盘的坐标
let pos = {
'1-5': {
ID: '1-5',
x: 1, // 跳棋坐标系x
y: 5, // 跳棋坐标系y
_x: 0, // 实际上的垂直坐标系的x
_y: 60, // 实际上的垂直坐标系的y
playerID: 'A' // 可能不存在该属性。若存在则代表当前坐标上有一枚该角色的棋子
},
// ...
};
// 全局统一的跳棋坐标系中的ID样式
function getID(x, y) {
if (Object.prototype.toString.call(x) === '[object Object]') return `${x.x}-${x.y}`;
return `${x}-${y}`;
}
这好几百个位置,一个一个算太麻烦了,我们仔细看一下,发现还是有点规律。比如:
x坐标的为1,y的坐标只能是5,
x坐标的为2,y的坐标的上限是5,下限是6
...
这只需要找到所有的可能的x坐标就搞定了啊。哈哈,写段脚本跑起来。
// 初始化棋盘,一个巨大的六角形东西
function drawBoard(canvasWidth, radius) {
const spaceWidth = canvasWidth / 14;
const lineHeight = canvasWidth / 15;
const sapceX = spaceWidth / 2;
const padding = 2 * radius;
// 坐标系区域的限制
// x坐标为1,y的上限是5,下限是5
// x坐标为2,y的上限是5,下限是6
const posRegions = [[5, 5], [5, 6], [5, 7], [5, 8], [1, 13], [2, 13], [3, 13], [4, 13], [5, 13], [5, 14], [5, 15], [5, 16], [5, 17], [10, 13], [11, 13], [12, 13], [13, 13]];
posRegions.forEach((regions, i) => {
let x = i + 1;
let min = regions[0];
let max = regions[1];
for (let y = min; y <= max; y++) {
// 需要修正一下 x 轴的实际坐标
let correct = 0;
if (y < 5) correct = (5 - y) * sapceX;
if (y > 5) correct = -(y - 5) * sapceX;
let _x = i * spaceWidth + correct + padding;
let _y = y * lineHeight;
// 画棋盘
strokeArc(_x, _y);
// 记录位置
let ID = this.getID(x, y);
pos[ID] = { x, y, _x, _y, ID };
}
});
}
棋盘都搞定了,棋子还远么。
渲染玩家棋子
准备一个players
变量来缓存所有跟玩家棋子相关的东西,包括玩家棋子所在区域的开始位置,玩家颜色等什么的。
// 初始化的棋盘的6个区域内的坐标限制
let players = {
A: {
color: 'rgba(255, 165, 0, 1)',
area: { x: 5, y: 1 } // 起始坐标x,y
},
B: {
color: 'rgba(0, 255, 0, 0.25)',
area: { x: 10, y: 5, special: 1 } // 特殊的角
},
C: {
color: '#f44336',
area: { x: 14, y: 10 }
},
D: {
color: '#5badf0',
area: { x: 10, y: 14, special: 1 }
},
E: {
color: '#e91e63',
area: { x: 5, y: 10 }
},
F: {
color: '#ff9800',
area: { x: 1, y: 5, special: 1 }
}
};
// 根据角色初始化玩家的棋子
// playerID --> 'ABCDEF'.substr(0, 1);
function initPlayer(playerID) {
if (!players.hasOwnProperty(playerID)) return;
let palyer = players[playerID];
for (let i = 0; i < 4; i++) {
let j = palyer.area.special ? 0 : i;
let maxY = palyer.area.special ? i + 1 : 4;
for (; j < maxY; j++) {
let x = palyer.area.x + i;
let y = palyer.area.y + j;
let ID = getID(x, y);
let newPos = pos[ID];
if (!newPos) continue;
pos[ID].playerID = playerID;
fillArc(newPos._x, newPos._y, players[playerID].color);
}
}
}
如果您有耐心看到这里,几乎就成功了一大半了。
canvas中的点击事件
众所周知 canvas 不同于 svg 没有 DOM ,添加事件只能添加给整个 canvas ,这个时候就非常尴尬了,那么多棋子,咋弄!
别慌,我们有办法。
基本思路就是我们可以通过 event 获得鼠标或者手指的点击坐标,然后根据坐标相对于整个 canvas 左上角的偏移得出当前点击的相对位置,然后再遍历一下,看当前这个坐标是否是棋盘中的有效坐标,这样我们就得到了点击的这个位置是不是 棋子/空格/空白地区 的方法。
// 通过event事件获得点击的坐标
function getPointByEvent(ev) {
if (ev.layerX || ev.layerX === 0) return { x: ev.layerX, y: ev.layerY };
// Opera
if (ev.offsetX || ev.offsetX === 0) return { x: ev.offsetX, y: ev.offsetY };
}
// 通过某坐标找出其是否属于一个棋子/棋盘区域内
function getPieceByPoint(point) {
for (let i in pos) {
let piece = pos[i];
// 勾股定理,求出这个点击的坐标点距离圆心的长度
let a = ~~(point.x - piece._x);
let b = ~~(point.y - piece._y);
let len = Math.sqrt(a * a + b * b);
if (len < radius) return piece;
}
}
点击事件都搞定了,这后面的就太容易了。
棋子的移动
还记得前面说的什么吗?一个棋子从一个位置移动到另一个位置,只需要在新位置画上带颜色的实心圆,然后把原位置清空,画上空心的棋盘就大功告成。
需要注意的,移动棋子要怎么操作。
当然是先拎起来,在选个位子,松手,吧唧一下,棋子就移动过去了。
用js事件解释就是,点击一个棋子,选中;鼠标挪动,点击一个棋盘的空格,落子。
这么梳理后就简单了。
来来来,整起走。
// 缓存选中的棋子
let currentPiece;
// 描绘棋子移动
function renderMove(newPos) {
let oldPos = currentPiece;
newPos.playerID = oldPos.playerID; // 传递下去,好赋颜色
fillArc(newPos._x, newPos._y, players[oldPos.playerID].color);
cleanArc(oldPos._x, oldPos._y);
strokeArc(oldPos._x, oldPos._y);
delete this.pos[oldPos.ID].playerID;
}
是不是格外的简单,清晰。。。
结语
可能有的朋友会问,如果我乱走棋子咋办,都没检测一下落子合法性什么的,别慌,来再给你们放一次我的项目地址: Chinese Checkers by React.js & Socket.io.
在这个项目中实现了,自定义用户名、可选人数、系统判定玩家走步次序、检查玩家落子的合法性、玩家的走位落子历史记录、多端同步通讯等等......基本达到了可玩的状态。
当然,这只是个开始,如果有时间和精力会继续写下去的,我想要整个漂亮的界面、下棋的时候聊天、倒计时、人机对战等等好多。
感谢您的阅读,希望能参与我的小游戏,或者给我的这个跳棋一点点意见,或者去给我点个赞鼓舞一下我吧。
谢谢!
群友建议,改个名字,哈哈