原生js实现五子棋

为什突然做这个,因为这是个笔试题,拖了一个月才写(最近终于闲了O(∩_∩)O),废话不多说,说说这个题吧

题目要求

编写一个单机【五子棋】游戏,要求如下:
1.使用原生技术实现,兼容 Chrome 浏览器即可。
2.实现胜负判断,并给出赢棋提示;任意玩家赢得棋局,锁定棋盘。
3.请尽可能的考虑游戏的扩展性,界面可以使用 DOM / Canvas 实现。考虑后续切换界面实现的方式成本最低。(比如选择使用 DOM 实现界面,需求改变为使用 Canvas 实现时尽可能少的改动代码)。
4.实现一个悔棋功能
5.实现一个撤销悔棋功能。
6.人机对战部分可选。
7.尽可能的考虑实现的灵活性和扩展性。

UI部分

棋盘

五子棋,首先我们会想到棋盘、棋子,这是棋类游戏的基本元素,实现棋盘、棋子基本的两种思路,一种使用html标签模拟,另外一种就是使用canvas去画。这里我们选择使用canvas。

chess
    canvas.strokeStyle = "#ccc";
    for(let i = 0; i<=15; i++){
        canvasA.moveTo(20+40*i, 20);
        canvasA.lineTo(20+40*i, 620);
        canvasA.stroke();
        canvasA.moveTo(20, 20+40*i);
        canvasA.lineTo(620, 20+40*i);
        canvasA.stroke();
    }

主要的思路就是画等距离的直线,包活横向和纵向,用一个循环即可。


image.png
棋子

棋子的注意点就是位置,圆心和棋盘的交叉点要对齐,当鼠标点击某个范围的时候要在对应的交叉点为圆心处画圆。

oChess.addEventListener('click', (event) => {
    let x = Math.floor(event.offsetX/40),
        y = Math.floor(event.offsetY/40);
}, false)

我们以交叉点为中心的正方形(变长为棋格边长)为范围,也就是说每当鼠标点击到红色区域就在该中心点画棋子。这里获得中心点坐标之后就可以在对应点画棋子


image.png
//画棋子
const drawChessman = (x, y, temp) => {
    canvas.beginPath();
    canvas.arc(20+x*40,20+y*40, 18, 0, 360);
    canvas.closePath();
    canvas.fillStyle = temp ? "#fff" : "#000";
    canvas.fill();
}
其他

由于有悔棋功能,我们可以使用canvas的clearRect,但是,在清除棋子的时候不仅把棋子清除掉,而且棋子覆盖的棋盘也被清除掉,这是不被允许的,但是我们总不能把棋盘再重新画一遍,这样棋盘就会把棋子覆盖,而且总归是有性能问题。

image.png

这里耍了个小聪明,我们使用两个canvas来完成,底层的canvas用来画棋盘,上层的canvas用来画棋谱,并将其背景色设置为透明,这样清除棋子就不会影响到棋盘。

算法

界面是有了,但是玩的的时候怎么确定谁赢了?人机的时候电脑该怎么走?

赢法

这里使用一种最简单的算法,那就是先枚举出所有的赢法,然后再每走一步的时候都去判断下棋人赢的权重。那么有多少种赢的方式呢,当然是跟棋盘大小有关(可不是棋盘的宽高哦😏),格子有多少决定这有多少赢法。我们都知道只要五个棋子连成一条线即可,这条线可以横着可以竖着也可以斜着,所以我们就按照这几个方向来计算即可,横、竖、斜、反斜。

//可以赢的方式
let canWin = [];
//可以赢的种类数量
let winCount = 0;

//这横线竖线都是15个点,初始化每个点的赢法,每个点都有可能在任意方向与其他棋子连成一条线(五子),所以每个点赢的方式构成一个数组
for(let i = 0; i< 15; i++){
    canWin[i] = [];
    for(let j = 0; j<15; j++){
        canWin[i][j] = [];
    }
}
//横线赢的方式
for(let i = 0; i<15;i++){
        //横线有十一种不重复的五个点,下面同理
    for(let j = 0;j<11; j++){
        //连成五个子
        for(let k = 0; k<5; k++){
            canWin[i][j+k][winCount] = true;
        }
        winCount++;
    }
}
//竖线赢的方式
for(let i = 0; i<11;i++){
    for(let j = 0;j<15; j++){
        //连成五个子
        for(let k = 0; k<5; k++){
            canWin[i+k][j][winCount] = true;
        }
        winCount++;
    }
}
//正斜线赢的方式
for(let i = 0; i<11;i++){
    for(let j = 0;j<11; j++){
        //连成五个子
        for(let k = 0; k<5; k++){
            canWin[i+k][j+k][winCount] = true;
        }
        winCount++;
    }
}
//反斜线赢的方式
for(let i = 0; i<11;i++){
    for(let j = 14;j>3; j--){
        //连成五个子
        for(let k = 0; k<5; k++){
            canWin[i+k][j-k][winCount] = true;
        }
        winCount++;
    }
}

所有的赢法都已经统计出来,那么对于电脑下棋来说肯定是要走最有可能赢的位置,那么哪个位置最有可能赢,当然是最有可能连成五个棋子的地方,因此,我们需要计算所有的没有落子的地方如果落子的话赢得概率大小,我们只需要加个权重即可

//电脑下棋
const computerStep = () => {
        //人赢得期望值
    let peopleScore = [];
        //电脑赢得期望值
    let computerScore = [];
    let maxScore = 0;
        //电脑落子坐标
    let currentX = 0;
    let currentY = 0;
    //每个坐标点落子胜利的期望值(初始化)
    for(let i = 0; i<15; i++){
        peopleScore[i] = [];
        computerScore[i] = [];
        for(let j = 0; j<15; j++){
            peopleScore[i][j] = 0;
            computerScore[i][j] = 0;
        }
    }
    for(let i = 0; i<15; i++){
        for(let j = 0; j<15; j++){
            //还未落子
            if(chessArr[i][j] == 0){
                for(let k = 0;k<winCount;k++){
                    if(canWin[i][j][k]){
                                                //peopleWin,computerWin每种赢法人或者电脑落子数量,如果有别的落子则加10,表示这种赢法不可能赢
                        switch(peopleWin[k]){
                            case 1:peopleScore[i][j]+=100;
                                break;
                            case 2:peopleScore[i][j]+=400;
                                break;
                            case 3:peopleScore[i][j]+=800;
                                break;
                            case 4:peopleScore[i][j]+=2000;
                                break;
                        }
                        switch(computerWin[k]){
                            case 1:computerScore[i][j]+=150;
                                break;
                            case 2:computerScore[i][j]+=450;
                                break;
                            case 3:computerScore[i][j]+=850;
                                break;
                            case 4:computerScore[i][j]+=10000;
                                break;
                        }
                    }
                }
                if(peopleScore[i][j]>maxScore){
                    maxScore = peopleScore[i][j];
                    currentX = i;
                    currentY = j;
                }else if(peopleScore[i][j] == maxScore){
                    if(computerScore[i][j]>computerScore[currentX][currentY]){
                        currentX = i;
                        currentY = j;
                    }
                }
                if(computerScore[i][j]>maxScore){
                    maxScore = computerScore[i][j];
                    currentX = i;
                    currentY = j;
                }else if(computerScore[i][j] == maxScore){
                    if(peopleScore[i][j]>peopleScore[currentX][currentY]){
                        currentX = i;
                        currentY = j;
                    }
                }
            }
        }
    }
    drawChessman(currentX, currentY, false);
    // currentComputer = [currentX, currentY];
        //记录电脑落子位置
    pTwo[0].push([currentX, currentY]);
    chessArr[currentX][currentY] = 2;
    for(let i = 0; i<winCount; i++){
        if(canWin[currentX][currentY][i]){
            computerWin[i]++;
            //人不可能赢
            peopleWin[i] += 10;
            if(computerWin[i]==5){
                alert('computer win!')
                gameOver = true;
            }
        }
    }

}

主要思路就是,首先找出棋盘上所有没有落子的位置,然后计算该点电脑或人落子的话赢得期望值,分别找出最大值,那么就是我们需要落子的地方,这时有可能是两个点,如果人下一步落子的地方期望值大于电脑,说明需要堵棋,否则不用。

判断输赢
for(let i = 0; i<winCount; i++){
        if(canWin[currentX][currentY][i]){
            computerWin[i]++;
            //人不可能赢
            peopleWin[i] += 10;
            if(computerWin[i]==5){
                alert('computer win!')
                gameOver = true;
            }
        }
    }

判断输赢就很简单了,走完一步就判断人或者电脑是否在某一种赢法已经落子5颗,当然,如果已经赢了就不能再落子了。

人对人

人对人的时候就简单很多了,不需要对电脑判断,只需要切换人即可,并且要注意一些判断条件不再是人机。

oChess.addEventListener('click', (event) => {
    if(gameOver){
        return;
    }
    if(!isp2p){
        if(!isMan){
            return;
        }
    }
    let x = Math.floor(event.offsetX/40),
        y = Math.floor(event.offsetY/40);
    if(chessArr[x][y] == 0){
        drawChessman(x, y, isMan);
        // currentPeople = [x, y];
        //只有当前是人对人而且不是第一个人下棋才赋值
        if(isp2p && !isMan){
            pTwo[0].push([x,y]);
        }else{
            pOne[0].push([x, y]);
        }
        chessArr[x][y] =  1;
        for(let i = 0; i<winCount; i++){
            if(canWin[x][y][i]){

                if(isp2p && !isMan){
                    computerWin[i]++;
                    //HOU
                    peopleWin[i] += 10;
                    if(computerWin[i]==5){
                        alert('opponent win!')
                        gameOver = true;
                    }
                }else{
                    peopleWin[i]++;
                    //电脑不可能赢
                    computerWin[i] += 10;
                    if(peopleWin[i]==5){
                        alert('you win!')
                        gameOver = true;
                    }
                }
            }

        }
        if(!gameOver){
            if(!isp2p){
                computerStep();
            }
                        //这个值还代表着棋子颜色的变化
            isMan = !isMan;
        }
    }

})

这里用isp2p判断是否是人对人,isMan表示是否是人在下棋(这个值还代表着棋子颜色的变化,互不影响的),注意点就是一个人下完之后不再是电脑下

悔棋与取消悔棋

之前我们在每次下棋的时候都记录了双方的落子位置,所以只需要将最后一个落子位置的棋子清除掉,并将落子记录删除,而且包括得分等都需要重置,只要按照落子的规则反着来即可。

oBack.addEventListener('click', (event) => {
    let currentOne = [];
    let currentTwo = [];
    isBack = true;
        //这里悔棋会删除落子位置,但是取消悔棋还需要记录之前位置
    currentOne = pOne[0].pop();
    pOne[1].push(currentOne);
    currentTwo = pTwo[0].pop();
    pTwo[1].push(currentTwo);
    if(!currentOne){
        return;
    }
    clearChessman(currentOne[0], currentOne[1], true);
    clearChessman(currentTwo[0], currentTwo[1], true);
    chessArr[currentOne[0]][ currentOne[1]] = 0;
    chessArr[currentTwo[0]][ currentTwo[1]] = 0;
    for(let i = 0; i<winCount; i++){
        if(canWin[currentOne[0]][currentOne[1]][i]){
            peopleWin[i]--;
            computerWin[i] -= 10;
        }
    }
    for(let i = 0; i<winCount; i++){
        if(canWin[currentTwo[0]][currentTwo[1]][i]){
            computerWin[i]--;
            peopleWin[i] -= 10;
        }
    }
    gameOver = false;

})

这里悔棋的时候我们把记录的位置删除了,但是取消悔棋的时候我们还需要上一步的位置,所以每次悔棋的时候都需要把删除的位置记录下来,以便取消悔棋。取消悔棋做的呢就跟悔棋相反了。

注意点:

1.没有悔棋就没有取消悔棋。这不是废话吗?这里要注意的有两点:第一,所有悔棋记录全都取消悔棋就不再悔棋;第二:每次下棋之后之前的悔棋就不再算数,需要制空。因此在取消悔棋完要将变量isBack重置为false,还有就是每次人或电脑下棋都要重置悔棋和取消悔棋记录

       //重置悔棋部分
   isBack = false;
   pOne = [];
   pTwo = [];

2.取消悔棋也要把每一方的得分重新计算。

oReback.addEventListener('click', (event) => {
    let currentOne = [];
    let currentTwo = [];

    //如果没有悔棋就没有取消悔棋
    if(!isBack){
        return;
    }
    console.log(pTwo[0])
    currentOne = pOne[1].pop();
    pOne[0].push(currentOne);
    currentTwo = pTwo[1].pop();
    pTwo[0].push(currentTwo);
    console.log(pTwo)

    if(!currentOne){
        return;
    }
    //所有悔棋撤销之后再重制
    if(!pOne[1].length){
        isBack = false;
    }
    drawChessman(currentOne[0], currentOne[1], true);
    chessArr[currentOne[0]][currentOne[1]] =  1;
    for(let i = 0; i<winCount; i++){
        if(canWin[currentOne[0]][currentOne[1]][i]){
            peopleWin[i]++;
            //电脑不可能赢
            computerWin[i] = 10;
            if(peopleWin[i]==5){
                alert('you win!')
                gameOver = true;
            }
        }
    }
    if(!gameOver){
        if(!isp2p){
            //为了防止二次添加到已走位置
            drawChessman(currentTwo[0], currentTwo[1], false);
            chessArr[currentTwo[0]][currentTwo[1]] =  2;
            for(let i = 0; i<winCount; i++){
                if(canWin[currentTwo[0]][currentTwo[1]][i]){
                    computerWin[i]++;
                    //电脑
                    peopleWin[i] += 10;
                    if(computerWin[i]==5){
                        alert('computer win!')
                        gameOver = true;
                    }
                }
            }
        }else{
            drawChessman(currentTwo[0], currentTwo[1], false);
            chessArr[currentTwo[0]][currentTwo[1]] =  2;
            for(let i = 0; i<winCount; i++){
                if(canWin[currentTwo[0]][currentTwo[1]][i]){
                    computerWin[i]++;
                    //先手
                    peopleWin[i] += 10;
                    if(computerWin[i]==5){
                        alert('opponent win!')
                        gameOver = true;
                    }
                }
            }
        }
        isMan = !isMan;
    }

})
重置

这个就很简单了,我们只需要把棋盘清空,然后把所有参数初始化原始值即可,主要用于重新开始游戏以及第一次进入的时候重置棋盘。
洋洋洒洒写了这么多,大家慢慢看,有些地方有点绕,但是没有很难的算法,所以不会很费劲的。
源码地址:https://github.com/Stevenzwzhai/stevenzwzhai.github.com/tree/master/FE/src/chess
在线演示:https://stevenzwzhai.github.io/FE/src/chess

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 156,265评论 4 359
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 66,274评论 1 288
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 106,087评论 0 237
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,479评论 0 203
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 51,782评论 3 285
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,218评论 1 207
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,594评论 2 309
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,316评论 0 194
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 33,955评论 1 237
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,274评论 2 240
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 31,803评论 1 255
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,177评论 2 250
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 32,732评论 3 229
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 25,953评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,687评论 0 192
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,263评论 2 267
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,189评论 2 258

推荐阅读更多精彩内容