线上地址–gobang online pc上使用谷歌浏览器比较友好@~@
代码仓库–gobang tutorial 欢迎对此仓库进行扩展或star啦 @~@
前置知识点: 阮生的es6教程和MDN的canvas教程
以上,兵马未动,粮草先行。看官可以先体验下小游戏并且粗略了解下相关的知识点后(熟悉者可跳过,欢迎留言改进哈),再往下读。
秉承着会就分享,不会就折腾的宗旨。自己利用周末的时间(2018.12.01-2018.12.02)将五子棋小游戏梳理了一波,整理成一个教程,放出来给大伙指点指点。下面进入正题:
五子棋的规则有点点复杂,我这里就简化并改写成下面这几条:
正式比赛的规则,看官可以到五子棋_百度百科这里了解。本博文的案例是以上面列出来的四条规则为基础,来实现五子棋小游戏的。
为了方便管理、扩展功能和编写代码,我这里使用了es6的class语法,面向对象的思想来实现。首先,自己定义一个类Gobang
,如下:
class Gobang { // 这里设置一个五子棋的类,统一管理代码
// Gobang这个类的构造函数,options是在实例化的时候要传过来的值
constructor(options={}){ // 设置参数的默认值,es6之前不允许这样设置
this.options = options;
// 初始化
this.init();
}
// 初始化
init() {
const { options } = this;// 结构赋值
console.log(options); // 打印出传入的实例的配置选项
}
}
// 实例化对象
let gobangInstance1 = new Gobang(); // 没有传配置项的时候
let gobangInstance2 = new Gobang({
canvas: 'chess'
}); // 传配置项的时候
上面的Gobang
类中,包含了一个constructor
和init
方法。其中constructor
方法是类默认的方法,通过new
命令生成对象实例时候,自动调用该方法。一个类必须有一个constructor
方法,如果没有显式定义,一个空的constructor
方法会默认添加。然后就是init
方法了,这里我是整个类的初始化的入口方法。
项目骨架
代码在仓库中对应的位置是skeleton。
棋盘,我们可以分为两种,一种是视觉上的棋盘,另外一个是逻辑上的棋盘,你是看不见的。如下截图:
首先,我们实现20*20
的物理上的棋盘,并且配上一些样式。当然,为了高可配置,我们使用上面代码骨架上的options
进行传值:
// 实例化对象
let gobang = new Gobang({
canvas: 'chess', // html中设定的画布的id
gobangStyle: { // 五子棋的一些样式
padding: 30, // 边和边之间的距离
count: 20, // 棋盘的边数,整数
borderColor: '#bfbfbf', // 描边的颜色
}
});
然后就进行物理棋盘的绘制了,这里是使用canvas
的相关知识点,控制画笔更改着笔点并画线条:
// 绘制出物理棋盘
drawChessBoard() {
const context = this.chessboard.getContext('2d');// 获取绘制上下文
const {padding, count, borderColor} = this.options.gobangStyle;
// 设置棋盘的宽高
this.chessboard.width = this.chessboard.height = padding * count;
// 设置画笔的颜色
context.strokeStyle = borderColor;
let half_padding = padding/2;// 考虑绘制的棋子展示的位置,所以要预留一些边距,可以审查元素看下
// 画棋盘
for(var i = 0; i < count; i++){
context.moveTo(half_padding+i*padding, half_padding);
context.lineTo(half_padding+i*padding, padding*count-half_padding);
context.stroke(); // 这里绘制出的是竖轴
context.moveTo(half_padding, half_padding+i*padding);
context.lineTo(count*padding-half_padding, half_padding+i*padding);
context.stroke(); // 这里绘制出的是横轴
}
}
接着就是逻辑的棋盘的记录了。这里我使用了二维数组去记录棋盘点的位置,比如(0,0)
点对应的数组下标是[0][0]
;然后(1,2)
点对应的下标是[1][2]
…以此类推。这里在记录好点之后,也为他们进行赋值为0,表示此处没有落子,如果有落子,记录为1(黑子)或2(白子)。具体逻辑棋盘代码如下:
// 绘制逻辑矩阵棋盘
initChessboardMatrix(){
const {count} = this.options.gobangStyle;
const checkerboard = [];
// 存在(x,y)矩阵点
for(let x = 0; x < count; x++){
checkerboard[x] = [];
for(let y = 0; y < count; y++){
checkerboard[x][y] = 0; // 全部赋值为0,表示此坐标是没有棋子的
}
}
}
绘制棋盘
代码在仓库中对应的位置是chess_board。
绘制棋子这个简单。在标题中表明了是使用canvas的相关知识点,棋子是使用canvas来绘制的。具体用的canvas的知识点有arc和createRadialGradient
方法。前者是绘制一个圆,后者是为这个圆添加颜色渐变效果,使得棋子看起来更加有质感。当然,这里需要绘制黑白两种颜色的棋子,需要有个flag来进行标识是否是黑色/白色,代码中有介绍。
drawChessman(x , y, isBlack){// 绘制的(x,y)坐标,isBlack判断是黑棋子还是白色棋子
const context = this.chessboard.getContext('2d');
context.beginPath();
context.arc(x, y, 10, 0, 2 * Math.PI);// 画圆,半径这里设定为10px
context.closePath();
// 为棋子添加渐变颜色
let gradient = context.createRadialGradient(x, y, 10, x-5, y-5, 0);// createRadialGradient(x1,y1,r1,x2,y2,r2)创建放射状/圆形渐变对象。
if(isBlack){ // 黑子
gradient.addColorStop(0,'#0a0a0a'); // 开始的颜色
gradient.addColorStop(1,'#636766'); // 结束的颜色
}else{ // 白子
gradient.addColorStop(0,'#d1d1d1');
gradient.addColorStop(1,'#f9f9f9');
}
context.fillStyle = gradient;
context.fill();
}
对应的效果图如下:
绘制棋子
代码在仓库中对应的位置是chessman。
在上一节中,只是讲解了怎么去绘制棋子。接下来我们要将绘制好的棋子放到要下在棋盘的相关点击位置,并且实现黑白两棋的交替下棋,也就是实现人人对战啦。
首先,我们在初始化入口那里先初始化下棋子的角色(是黑棋还是白棋),获取单元格的宽度。
init() {
// 角色,1是黑色棋子,2是白色棋子
this.role = options.role || 1;
// 单个格子的宽高
this.lattice = {
width: options.gobangStyle.padding,
height: options.gobangStyle.padding
};
}
接下来就可以实行点击棋盘位置的计算了,获取相关的逻辑棋盘的坐标点,之后在这个坐标点进行棋子的绘制:
// 监听落子
listenDownChessman() {
// 监听点击棋盘对象事件
this.chessboard.onclick = event => {
let {padding} = this.options.gobangStyle;
// 获取棋子的位置(x,y)坐标,如(0,0),(0,2)
let {
offsetX: x,
offsetY: y,
} = event; // 解构赋值
// console.log(x,y);
x = Math.abs(Math.round((x-padding/2)/this.lattice.width));// 防止边界的为负数,故取绝对值
y = Math.abs(Math.round((y-padding/2)/this.lattice.height));
// console.log(x,y);
// 点击的是棋盘,并且是空位置才可以落子
if(this.checkerboard[x][y] !== undefined && Object.is(this.checkerboard[x][y],0)){
// 更新矩阵值
this.checkerboard[x][y] = this.role;
// 刻画棋子
this.drawChessman(x,y,Object.is(this.role , 1));
// 切换棋子的角色
this.role = Object.is(this.role , 1) ? 2 : 1;
}
}
}
// 刻画棋子
drawChessman(x,y,isBlack) {
const context = this.chessboard.getContext('2d');
const {padding} = this.options.gobangStyle;
let half_padding = padding/2;
context.beginPath();
context.arc(half_padding+x*padding,half_padding+y*padding,half_padding-2,0,2*Math.PI);
let gradient = context.createRadialGradient(half_padding+x*padding+2,half_padding+y*padding-2,half_padding-2,half_padding+x*padding+2,half_padding+y*padding-2,0);
if(isBlack){
gradient.addColorStop(0,'#0a0a0a');
gradient.addColorStop(1,'#636766');
}else{
gradient.addColorStop(0,'#d1d1d1');
gradient.addColorStop(1,'#f9f9f9');
}
context.fillStyle = gradient;
context.fill();
}
落子实现人人对战
代码在仓库中对应的位置是listen_chessman。
在双方下棋中,允许对方或者自己对已经下的棋子进行调整,也就是悔棋,恢复上一步的操作,然后再重新下棋。实现悔棋功能的时候,需要知道下棋的历史记录和当前的落子步数和角色。
对于历史的记录,这里对每一步的落子都使用一个对象进行存储,并放到一个history
的数组里面进行保存:
init() {
// 走棋的历史记录
this.history = [];
// 当前步
this.currentStep = 0;
}
listenDownChessman() {
...
// 落子之后有可能悔棋之后落子,这种情况下应该重置历史记录
this.history.length = this.currentStep;
this.history.push({// 保存坐标和角色快照
x,
y,
role: this.role
});
this.currentStep++; // 当前步骤自加
...
}
然后在执行悔棋的时候,将前一个记录的棋子的在棋盘上对应的ui给抹除掉就行了,不能将history
中对应的位置移除哦,因为是要用到撤销悔棋的啊。销毁完棋子后,要对物理棋盘上的ui进行修补,修补的情况一共有九种:
// 悔棋
regretChess() {
// 找到最后一次记录,回滚到上一次的ui状态
if(this.history.length){
const prev = this.history[this.currentStep - 1];
if(prev){
const {
x,
y,
role
} = prev;
// 销毁棋子
this.minusStep(x,y);
this.checkerboard[prev.x][prev.y] = 0; // 置空操作
this.currentStep--; // 步数自减
// 角色发生改变,下一步的下棋是该撤销棋子的角色
this.role = Object.is(role,1) ? 1 : 2;
}
}
}
// 销毁棋子
minusStep(x, y) {
const context = this.chessboard.getContext('2d');
const {padding, count} = this.options.gobangStyle;
context.clearRect(x*padding, y*padding, padding,padding);
// 修补删除的棋盘位置
// 重画该圆周围的格子,对边角的格式进行特殊的处理
let half_padding = padding/2; // 棋盘单元格的一半
if(x<=0 && y <=0){ // 情况比较多,一共九种情况
this.fixchessboard(half_padding,half_padding,half_padding,padding,half_padding,half_padding,padding,half_padding);
}else if(x>=count-1 && y<=0){
this.fixchessboard(count*padding-half_padding,half_padding,count*padding-padding,half_padding,count*padding-half_padding,half_padding,count*padding-half_padding,padding);
}else if(y>=count-1 && x <=0){
this.fixchessboard(15,count*padding-half_padding,half_padding,count*padding-padding,half_padding,count*padding-half_padding,padding,count*padding-half_padding);
}else if(x>=count-1 && y >= count-1){
this.fixchessboard(count*padding-half_padding,count*padding-half_padding,count*padding-padding,count*padding-half_padding,count*padding-half_padding,count*padding-half_padding,count*padding-half_padding,count*padding-padding);
}else if(x <=0 && y >0 && y <count-1){
this.fixchessboard(half_padding,padding*y+half_padding,padding,padding*y+half_padding,half_padding,padding*y,half_padding,padding*y+padding);
}else if(y <= 0 && x > 0 && x < count-1){
this.fixchessboard(x*padding+half_padding,half_padding,x*padding+half_padding,padding,x*padding,half_padding,x*padding+padding,half_padding);
}else if(x>=count-1 && y >0 && y < count-1){
this.fixchessboard(count*padding-half_padding,y*padding+half_padding,count*padding-padding,y*padding+half_padding,count*padding-half_padding,y*padding,count*padding-half_padding,y*padding+padding);
}else if(y>=count-1 && x > 0 && x < count-1){
this.fixchessboard(x*padding+half_padding,count*padding-half_padding,x*padding+half_padding,count*padding-padding,x*padding,count*padding-half_padding,x*padding+padding,count*padding-half_padding);
}else{
this.fixchessboard(half_padding+x*padding,y*padding,half_padding+x*padding,y*padding + padding,x*padding,y*padding+half_padding,(x+1)*padding,y*padding+half_padding)
}
}
// 修补删除后的棋盘
fixchessboard (a , b, c , d , e , f , g , h){
const context = this.chessboard.getContext('2d');
const {borderColor, lineWidth} = this.options.gobangStyle;
context.strokeStyle = borderColor;
context.lineWidth = lineWidth;
context.beginPath();
context.moveTo(a , b);
context.lineTo(c , d);
context.moveTo(e, f);
context.lineTo(g , h);
context.stroke();
}
实现悔棋
代码在仓库中对应的位置是regret_chess。
有允许悔棋,那么就有允许撤销悔棋这样子才合理。同悔棋功能,撤销悔棋是需要知道下棋的历史记录和当前的步骤和棋子角色的。如下:
// 撤销悔棋
revokedRegretChess(){
const next = this.history[this.currentStep]; // 撤销的点的下一个
if(next) {
this.drawChessman(next.x, next.y, next.role === 1); // 在上次撤销的点上画棋
this.checkerboard[next.x][next.y] = next.role;
this.currentStep++; // 当前步骤自加
this.role = Object.is(this.role, 1) ? 2 : 1; // 角色的切换
}
}
实现撤销悔棋
代码在仓库中对应的位置是revoked_regret_chess。
五子棋的的结束也就是必须要决出胜利者,或者是棋盘没有位置可以下棋了。这里考虑决出胜利为游戏结束的切入点,上面也说到了如何才算是一方获胜–横线、竖线或者斜线上有连续五个同一色的棋子
。那么我们就对这四种情况进行处理了,我们在矩阵中记录当前点击的数组点中是否有连续的五个1(黑子)或者连续的五个2(白子)即可。如下截图的x轴获胜,注意gif图右侧打印出来的数组内容:
四种获胜的情况和或者的提示相关的代码如下:
// 裁判观察棋子,判断获胜一方
checkReferee(x , y , role) {
if((x == undefined)||(y == undefined)||(role==undefined)) return;
// 连杀的分数,五个同一色的棋子连成一条直线就是胜利
let countContinuous = 0;
const XContinuous = this.checkerboard.map(x => x[y]); // x轴上连杀
const YContinuous = this.checkerboard[x]; // y轴上连杀
const S1Continuous = []; // 存储左斜线连杀
const S2Continuous = []; // 存储右斜线连杀
this.checkerboard.forEach((_y,i) => {
// 左斜线
const S1Item = _y[y - (x - i)];
if(S1Item !== undefined){
S1Continuous.push(S1Item);
}
// 右斜线
const S2Item = _y[y + (x - i)];
if(S2Item !== undefined) {
S2Continuous.push(S2Item);
}
});
// 当前落棋点所在的X轴/Y轴/交叉斜轴,只要有能连起来的5个子的角色即有胜者
[XContinuous, YContinuous, S1Continuous, S2Continuous].forEach(axis => {
if(axis.some((x, i) => axis[i] !== 0 &&
axis[i - 2] === axis[i - 1] &&
axis[i - 1] === axis[i] &&
axis[i] === axis[i + 1] &&
axis[i + 1] === axis[i + 2])) {
countContinuous++
}
});
// 如果赢了就给出提示
if(countContinuous){
this.win = true;
let msg = (role == 1 ? '黑' : '白') + '子胜利✌️';
// 提示信息
this.result.innerText = msg;
// 不允许再操作
this.chessboard.onclick = null;
}
}
胜利提示/游戏结束
代码在仓库中对应的位置是winner_hint。
嗯~至此,已经一步步讲解完如何开发一个能够在pc上愉快玩耍的休闲小游戏-五子棋了。当然,很多的参数我都是设置在代码的options
这里,其实为了更好的用户体验,你可以将这些设置在ui层面供用户自行调节的;再者你可以在项目基础上实现其他功能,比如人机对战等。如果有什么想法的话,欢迎下方留言或者前往此代码仓库gobang-tutorial进行相关动能补充或者完善@~@