人人都会玩的【五子棋】不如动手来写一个?

我正在参加掘金社区游戏创意投稿大赛个人赛,详情请看:游戏创意投稿大赛

五子棋作为大家休闲娱乐的方式,相信大家都玩过,并且不少人应该精于此道。作为程序员的我们是否考虑过它是如何实现的?人机如何实现?在线又是如何进行匹配和对战的呢?

我耗时几天,终于完成初版的五子棋小游戏,主要包含下面几个小功能:

  • 登录注册
  • 人机对战
  • 在线对战
  • 积分模块

在线体验地址:五子棋大作战

开源代码地址:我犟不过你:gobang

一、概述

本篇文章重点讲解此五子棋的玩法、运用的技术实现,以及当前游戏的不足和改进方案

目前作为一个初版,能够完成我所预期的功能,且项目整体代码量较小,复杂度较低,比较适合想要学习相关代码技术的同学们参考,同时也能帮我提出更好的改进方案。欢迎大家赏玩,参考,点评。

需要说明的一点,本人的前端很一般,目前界面较为简陋,只为实现基本功能,需要以后逐步的优化。

二、玩法介绍

开篇提到了本游戏有四个小功能,下面结合图例,来逐步看下每个功能。

2.1 首页

访问五子棋大作战,直接进入到首页:

image.png

可以进行模式选择:

  • 人机模式
  • 对战模式

可以查看排行榜:

  • 人机榜
  • 天梯榜

2.2 登录注册

此处的登录注册功能,主要是为了记录玩家的得分情况。所以账号可以直接使用中文,账号即昵称,直观体现。

2.2.1 登录

当我们点击人机模式对战模式,会弹出提示框,提示我们登录:

image.png

2.2.2 注册

首次进来的玩家,需要进行注册,点击上图左下角的注册按钮即可:

image.png

我们输入自己的账号和密码,点击注册,同时会直接登入游戏。

2.3 人机对战

2.3.1 先手 后手

完成前面的登录注册,再次点击首页的人机对战按钮,即可进入游戏界面,第一步您需要选择先手或者后手

image.png

2.3.2 超时

我们此处点击先手,即可开始游戏,需要注意,您有30秒的思考时间,超时即判输

image.png

电脑落子需要一定的思考时间,取决于当前机器的性能,基本不会超过4到5s,电脑是不会超时的:

image.png

2.3.3 输赢提示

当您赢下此局、或失败后,会给出如下提示:

  • 返回首页则退出当前模式;
  • 再来一局,游戏重新开始,再次开始选择先手或者后手

image.png

如果您获胜了,会得到100积分,输了会相应扣除10积分

另外界面提供返回首页,和重开一局按钮,此两种不扣除积分。

2.4 在线对战

2.4.1 匹配

点击对战模式按钮,会直接进入到匹配界面:

image.png

此过程点击取消匹配,则退出该模式,返回首页。

当另外玩家进入到匹配过程当中,双方会进行配对,成功后开始游戏:

image.png

2.4.2 超时

进入界面后,会看到当前是黑子回合,还是白子回合,提供倒计时提示

image.png

如若超时未落子,则己方输,扣除10积分

image.png

相应的,对方获胜,获得100积分image.png

2.4.3 认输

提供认输按钮:

image.png

认输方,扣除10积分

image.png

相应的,对方获得100积分

image.png

2.4.4 输赢提示

当前模式下,无论输赢,点击提示的确定,则会返回首页,想要继续,可再次点击对战模式进行匹配。

2.5 积分模块

此模块用来记录玩家的得分情况,增加游戏乐趣。

首页能看有两个超链接:

image.png

2.5.1 人机榜

选择人机榜,则会从左侧弹出TOP20榜单:

image.png

2.5.2 天梯榜

选择天梯榜,则会从右侧弹出TOP20榜单:

image.png

三、主要技术实现

下面主要按照功能模块来聊技术实现。

3.1 项目架构

项目整体的架构非常简单,只有三层,且都是单体服务:

  • 应用层:通过nginx部署vue项目
  • 服务层:单体springboot服务
  • 数据层:mysql、redis

未命名文件 (6).png

3.2 输赢判断

我们都知道,五子棋的棋盘是一个15 * 15的正方形,只要有一方连成五子,即算获胜。那么如何判断当前棋局中有相同颜色的五子呢?

此处我在前端当中使用二维数组,去模拟整个棋盘,棋盘的横坐标纵坐标可以完美的放到二维数组当中。

假设有大小为15的二维数组: arr[15][15]

那么其对应到棋盘上就如下所示:

未命名文件 (7).png

如上所示的二维数组,代表棋盘上的每个位置,以此类推,直到arr[15][15]

由于棋子主要是两种颜色黑色白色,所以我定义当假设黑子落到棋盘时,我就在此点设置数组的值为1,同样的,白色落子点位就可以设置为2;经过这么记录,棋盘上所有的点位都被我记录在二维数组当中的。

那么如何判断输赢呢?

棋盘主要有四个方向:反斜

我们将每个方向的数组值通过join方法转成字符串,查看棋盘上是否有1111122222这样的连续字符串即可。

关于二维数组横向和竖向遍历很简单,难点在于反斜的遍历,本文不做详细介绍了,相关算法在力扣上就有。

到此为止,每次落子我们去判断次数组是否有匹配字符串即可判断输赢了。

3.3 五子棋基础引申设计理念

真正的五子棋高手,都对于棋盘的落点有自己想法,然后落在哪,如何落,都是有讲究的,感兴趣的同学看下此地址,介绍的很详细:game.onegreen.net/wzq/HTML/14…

上面这个文章主要突出了几个不同的棋势,如下所示:

  • 连五
  • 活四
  • 冲四
  • 活三
  • 眠三
  • 活二
  • 眠二

看懂上面的内容,似乎发现从上到下的危险程度是逐渐减少的,那么我就以此制定一个根据不同棋势的分数枚举,也可以称为分数模型:

LIAN_5("LIAN_5",1000000),
HUO_4("HUO_4",30000),
CHONG_4("CHONG_4",25000),
HUO_3("HUO_3",6000),
HUO_2("HUO_2",1000),
MIAN_3("MIAN_3",500),
MIAN_2("MIAN_2",300),
ONE_CHESS("ONE_CHESS",10);
复制代码

既然有了分数的概念,那么相信同学们能想到接下来的动作了,我要针对当前的棋局为玩家电脑进行打分了。

3.3 【人机模式】实现方案

通过前面的描述,我的人机模式就是基于这样一套分数模型来进行自动下棋。整个人机对战模式,是项目的最难点,要实现电脑自动的防守和进攻

3.3.1 思想设计

那么电脑如何知道下在哪最合适呢?

最简单的方式,当你不知道是否合适的时候,去试一下不就知道了?

难么我们的方式是相同的,让电脑自己去尝试一下,下在哪里会获得最高分!!!则必然会有下面的流程:

未命名文件 (8).png

如上图,到了最为困难的地方,如何权衡这个玩家得分电脑得分

我的想法就是以防守为主,就是以玩家得分为基础。

想象一下,电脑在棋盘的每个点都进行模拟下棋,此时形成不同的棋势,必然有不同的总得分,最后会得到每个点位得分列表,作为电脑一定希望玩家得分越低越好;反之,对于电脑自己,它希望自己的得分要高一些。

不能只取最低分,你这样做导致电脑只会防守。也不能让电脑只取自己的最高分,那就只会进攻了。

所以最后,我定下了下面的解决方案:

未命名文件 (10).png

【差值列表】有什么含义?

列表当中的每一个得分都是,电脑得分 减去 玩家得分 的差值,那么我是不是可以认为此列表的最大值,就是在玩家得分的所有点位当中,对于电脑最为有利的点位

直白来说,电脑是以玩家的得分为基础,找到既不利于玩家,又对电脑最有利的点位。

差值列表如果存在相同值怎么办?

假如有两个相同的点位,那么如何判断下在哪里最优呢?

我这里用相同的点取模拟用户的下一步,取其得分最高的点,作为电脑下在最优的点。

3.3.2 得分设计

经过前面的思想设计,发现似乎可行,那么下面就来设计下如何计算得分呢?

3.3.2.1 棋势细化

首先,我对于前面提到的打分模型,进行了细化。设想一下,冲四这个场景,要根据字符串的形式去匹配,那么可能存在21111,或者11112,此种标识被其他颜色堵住了;那么如果是靠边界的呢?就需要使用比如31111来表示了吧;还有中间有空位间隔的,再被堵住,或者是边界的情况。

所以形成了如下可能形成的棋势,包含一部分但并不全面:

image.png

3.3.2.2 按方向计算得分

与前面介绍获胜的方法相同,我们仍然需要去对整个棋盘数据进行遍历,分为:反斜。 针对与四种不同方向的得分计算,抽象一个抽象类:

/**
 * 棋局方向分析抽象类
 *
 * @author weirx
 * @date 2022/04/01 18:41
 **/
public abstract class AbstractChessDirectionAnalysis {

    public abstract Map<String, Object> analysis(Integer[][] integers, boolean flag);

    public Map<String, Object> analysis(ChessScoreEnum chessScoreEnum, String join, boolean flag) {

        join = this.handleBorder(join);
        Map<String, Object> map = new HashMap<>();

        if (flag) {
            List<PlayerChessScoreChildEnum> playerChessScoreChildEnums = PlayerChessScoreChildEnum.getByChessScoreEnum(chessScoreEnum);
            for (PlayerChessScoreChildEnum c : playerChessScoreChildEnums) {
                String type = c.getType();
                if (join.contains(type)) {
                    map.put("score", chessScoreEnum.getScore());
                    map.put("type", chessScoreEnum.getCode());
                    map.put("str", c.getType());
                }
            }
        } else {
            List<AIChessScoreChildEnum> aiChessScoreChildEnums = AIChessScoreChildEnum.getByChessScoreEnum(chessScoreEnum);
            for (AIChessScoreChildEnum c : aiChessScoreChildEnums) {
                String type = c.getType();
                if (join.contains(type)) {
                    //如果玩家冲四了,那么分数要给大于自己可能活四的情况
                    map.put("score", chessScoreEnum.getCode().equals(ChessScoreEnum.CHONG_4) ? chessScoreEnum.getScore() * 2 : chessScoreEnum.getScore());
                    map.put("type", chessScoreEnum.getCode());
                    map.put("str", c.getType());
                }
            }
        }
        return map;
    }

    public String handleBorder(String join) {
        if (join.indexOf("1") == 0) {
            join = "3" + join;
        }
        if (join.lastIndexOf("1") == join.length() - 1) {
            join = join + "3";
        }
        return join;
    }
}
复制代码

此抽象类包含通用的棋势匹配封装得分的能力。其中有一个if-esle判断,用来区分当前获取的是玩家得分,还是电脑得分,双方的枚举字符串一个是1,一个是2

还有一点需要注意:如果玩家此时有冲四,电脑的下一步恰好有活四,那么给的分值一定要比电脑的活四分值要大,否则电脑会去下自己的活四,而忽略玩家的冲四,导致游戏输掉。

另外提供一个需要实现的分析方法analysis(Integer[][] integers, boolean flag)以及针对边界的处理方法andleBorder(String join)

四种方向的分析,分别去继承此抽象类,下面以横向分析为例。实现analysis方法,此方法主要用来遍历横向的棋局,核心还在于抽象类的方法调用,用获取当前行的字符串去匹配棋势枚举中定义好的字符串,如果匹配上了,那么就得到该分数了。

/**
 * 棋局横向分析
 *
 * @author weirx
 * @date 2022/04/01 18:44
 **/
@Slf4j
@Service
public class ChessRowAnalysisService extends AbstractChessDirectionAnalysis {
    @Override
    public Map<String, Object> analysis(Integer[][] integers, boolean flag) {

        Map<String, Object> map = new HashMap<>();
        for (int i = 0; i < 15; i++) {
            Integer[][] col = new Integer[15][15];
            for (int j = 0; j < 15; j++) {
                col[i][j] = integers[j][i];
            }
            Map<String, Object> row = this.analysis(col, i,flag);
            if (!row.isEmpty()) {
                map.put("ROW_" + i, row);
            }

        }
        return map;
    }

    public Map<String, Object> analysis(Integer[][] integers, int index, boolean flag) {
        Map<String, Object> map = new HashMap<>();
        String join = StringUtils.join(integers[index], "");
        for (ChessScoreEnum chessScoreEnum : ChessScoreEnum.values()) {
            Map<String, Object> row = this.analysis(chessScoreEnum, join,flag);
            if (!row.isEmpty()){
                map.put("ROW_"+chessScoreEnum.getCode(), row);
            }
        }
        return map;
    }
}
复制代码

以上的难点,就是对于格式分的拆分和封装。尤其是反斜的分析,是其实完全不同的过程,需要重点关注,限于篇幅,需要的请看源码。

每一种方向分析会返回一个Map对象,下面需要我们做的就是拆分出来求和就好了,较简单,此处不介绍了。

关于人机对战就暂时分析这么多了。

3.4 【对战模式】实现方案

相对于在线对战,其对算法等等的逻辑分析,要弱很多,在线的重点在于如何实现两个玩家的通信和数据同步

本文采用的实现方案基于webSokcet实现。

3.4.1 匹配玩家

匹配玩家是我在实现此功能遇到的第一个难题,需要考虑如何存储正在匹配的用户? 如何对匹配中的玩家进行配对呢? 匹配过后,需要将前面保存的正在匹配的用户信息删除

我通过较为简单的方式去实现,如下图所示:

未命名文件 (11).png

过程分析:

  • 玩家发起匹配,会将其信息存储到redis当中

  • 定时任务每5秒不断拉取该key

  • 定时任务判断是否至少两人在匹配:

    • 否:继续循环
    • 是:删除二人匹配信息,同时推动websocket
  • websocket根据匹配信息,分别推送匹配成功的消息

  • 两位玩家建立连接

3.4.2 在线对弈

双方在自己的客户端的每一次落子,都会将消息(主要是当前落子位置信息,对手用户名),通过websocket推送到服务端,服务端根据对手用户名推送topic,对手客户端通过监听topic(redis发布订阅)获取到此消息,通过websocket推送到页面,最终完成客户端棋子的渲染,达到双方同步的目的。

此处redis主要是解决分布式session的问题,虽然本项目是单机,但是出于习惯,还是使用redis中转一步。

未命名文件 (12).png

四、不足和改进方案

  • 人机对战

不足:目前人机对战,对于复杂多变的棋局,仍然会出现分数计算不准确的情况,导致电脑落子出错。尤其是棋子被堵截、涉及边界,在添加空位的情况,没能覆盖所有的可能。

人为归纳比较费力,毕竟种类繁多。

改进:后续的改进方案主要以AI为主,让电脑学会自学习。每一次失败,都自动记录失败位置,并持久化,经过长期训练,应该能覆盖绝大多数的可能性。

  • 在线对战

不足:目前是定时任务轮询匹配玩家,小并发项目没有问题,并发量大的话可能会存在分配效率低下的问题。且如果分布式部署的情况下,还要针对定时任务做幂等处理。

改进:后期会将底层修改为Netty。

  • 页面优化

不足:作为后端程序员,目前页面写的简单,且代码格式较为混乱,没有很多好的用到VUE的精髓,如路由等,导致页面不流畅,待重构。

改进: 重构代码风格,按功能划分组件、页面。增加游戏细节,丰富色彩。

五、总结

整个游戏,利用平时的业余时间,大概用了10天左右,效率不算高,中间也遇到问题,卡了很久。尤其是在人机模块的设计耗费了很多时间,中间也推翻了几次方案,磕磕绊绊的总算完成了,问题还有很多,后续有时间会去优化。

总结几点自身的不足:

  • 数学基础不好
  • 算法一般
  • 前端编码能力有待提升
  • 遇到问题,要学会跳出循环,整理思路后再分析
  • 年龄问题,思考确实不透彻

其实最重要的一点在于动手之前需要先思考,无论做什么开发,还是要先做设计,验证可行性,以免反复

猜你喜欢

转载自juejin.im/post/7084581591938252808
今日推荐