Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Open
reng99 opened this issue Dec 7, 2018 · 0 comments
Open

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

reng99 opened this issue Dec 7, 2018 · 0 comments
Labels
blog a single blog javascript javascript tag

Comments

@reng99
Copy link
Owner

reng99 commented Dec 7, 2018

gif图演示

线上地址--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进行相关动能补充或者完善@~@

@reng99 reng99 added the blog a single blog label Dec 7, 2018
@reng99 reng99 added the javascript javascript tag label Apr 21, 2019
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
blog a single blog javascript javascript tag
Projects
None yet
Development

No branches or pull requests

1 participant