一步步实现人人对战五子棋游戏【canvas版】

线上地址–gobang online pc上使用谷歌浏览器比较友好@~@

代码仓库–gobang tutorial 欢迎对此仓库进行扩展或star啦 @~@

前置知识点: 阮生的es6教程和MDN的canvas教程

以上,兵马未动,粮草先行。看官可以先体验下小游戏并且粗略了解下相关的知识点后(熟悉者可跳过,欢迎留言改进哈),再往下读。

前言

秉承着会就分享,不会就折腾的宗旨。自己利用周末的时间(2018.12.01-2018.12.02)将五子棋小游戏梳理了一波,整理成一个教程,放出来给大伙指点指点。下面进入正题:

五子棋规则

五子棋的规则有点点复杂,我这里就简化并改写成下面这几条:

  1. 对局双方各执一色棋子。
  2. 空棋盘开局。
  3. 黑先、白后或者白先、黑后,交替下子,每次只能下一子。
  4. 横线、竖线或者斜线上有连续五个同一色的棋子,则游戏结束。

正式比赛的规则,看官可以到五子棋_百度百科这里了解。本博文的案例是以上面列出来的四条规则为基础,来实现五子棋小游戏的。

项目骨架

为了方便管理、扩展功能和编写代码,我这里使用了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类中,包含了一个constructorinit方法。其中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进行相关动能补充或者完善@~@

你可能感兴趣的:(前端开发)