我正在参加掘金社区游戏创意投稿大赛个人赛,详情请看:游戏创意投稿大赛
五子棋作为大家休闲娱乐的方式,相信大家都玩过,并且不少人应该精于此道。作为程序员的我们是否考虑过它是如何实现的?人机如何实现?在线又是如何进行匹配和对战的呢?
我耗时几天,终于完成初版的五子棋小游戏,主要包含下面几个小功能:
- 登录注册
- 人机对战
- 在线对战
- 积分模块
在线体验地址:五子棋大作战
开源代码地址:我犟不过你:gobang
一、概述
本篇文章重点讲解此五子棋的玩法、运用的技术实现,以及当前游戏的不足和改进方案。
目前作为一个初版,能够完成我所预期的功能,且项目整体代码量较小,复杂度较低,比较适合想要学习相关代码技术的同学们参考,同时也能帮我提出更好的改进方案。欢迎大家赏玩,参考,点评。
需要说明的一点,本人的前端很一般,目前界面较为简陋,只为实现基本功能,需要以后逐步的优化。
二、玩法介绍
开篇提到了本游戏有四个小功能,下面结合图例,来逐步看下每个功能。
2.1 首页
访问五子棋大作战,直接进入到首页:
可以进行模式选择:
- 人机模式
- 对战模式
可以查看排行榜:
- 人机榜
- 天梯榜
2.2 登录注册
此处的登录注册功能,主要是为了记录玩家的得分情况。所以账号可以直接使用中文,账号即昵称,直观体现。
2.2.1 登录
当我们点击人机模式或对战模式,会弹出提示框,提示我们登录:
2.2.2 注册
首次进来的玩家,需要进行注册,点击上图左下角的注册按钮即可:
我们输入自己的账号和密码,点击注册,同时会直接登入游戏。
2.3 人机对战
2.3.1 先手 后手
完成前面的登录注册,再次点击首页的人机对战按钮,即可进入游戏界面,第一步您需要选择先手或者后手:
2.3.2 超时
我们此处点击先手,即可开始游戏,需要注意,您有30秒的思考时间,超时即判输:
电脑落子需要一定的思考时间,取决于当前机器的性能,基本不会超过4到5s,电脑是不会超时的:
2.3.3 输赢提示
当您赢下此局、或失败后,会给出如下提示:
- 返回首页则退出当前模式;
- 再来一局,游戏重新开始,再次开始选择先手或者后手。
如果您获胜了,会得到100积分,输了会相应扣除10积分。
另外界面提供返回首页,和重开一局按钮,此两种不扣除积分。
2.4 在线对战
2.4.1 匹配
点击对战模式按钮,会直接进入到匹配界面:
此过程点击取消匹配,则退出该模式,返回首页。
当另外玩家进入到匹配过程当中,双方会进行配对,成功后开始游戏:
2.4.2 超时
进入界面后,会看到当前是黑子回合,还是白子回合,提供倒计时提示
如若超时未落子,则己方输,扣除10积分
相应的,对方获胜,获得100积分:
2.4.3 认输
提供认输按钮:
认输方,扣除10积分:
相应的,对方获得100积分:
2.4.4 输赢提示
当前模式下,无论输赢,点击提示的确定,则会返回首页,想要继续,可再次点击对战模式进行匹配。
2.5 积分模块
此模块用来记录玩家的得分情况,增加游戏乐趣。
首页能看有两个超链接:
2.5.1 人机榜
选择人机榜,则会从左侧弹出TOP20榜单:
2.5.2 天梯榜
选择天梯榜,则会从右侧弹出TOP20榜单:
三、主要技术实现
下面主要按照功能模块来聊技术实现。
3.1 项目架构
项目整体的架构非常简单,只有三层,且都是单体服务:
- 应用层:通过nginx部署vue项目
- 服务层:单体springboot服务
- 数据层:mysql、redis
3.2 输赢判断
我们都知道,五子棋的棋盘是一个15 * 15的正方形,只要有一方连成五子,即算获胜。那么如何判断当前棋局中有相同颜色的五子呢?
此处我在前端当中使用二维数组,去模拟整个棋盘,棋盘的横坐标和纵坐标可以完美的放到二维数组当中。
假设有大小为15的二维数组: arr[15][15]
那么其对应到棋盘上就如下所示:
如上所示的二维数组,代表棋盘上的每个位置,以此类推,直到arr[15][15]
。
由于棋子主要是两种颜色黑色和白色,所以我定义当假设黑子落到棋盘时,我就在此点设置数组的值为1,同样的,白色落子点位就可以设置为2;经过这么记录,棋盘上所有的点位都被我记录在二维数组当中的。
那么如何判断输赢呢?
棋盘主要有四个方向:横、竖、斜、反斜。
我们将每个方向的数组值通过join方法转成字符串,查看棋盘上是否有11111,22222这样的连续字符串即可。
关于二维数组横向和竖向遍历很简单,难点在于斜、反斜的遍历,本文不做详细介绍了,相关算法在力扣上就有。
到此为止,每次落子我们去判断次数组是否有匹配字符串即可判断输赢了。
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 思想设计
那么电脑如何知道下在哪最合适呢?
最简单的方式,当你不知道是否合适的时候,去试一下不就知道了?
难么我们的方式是相同的,让电脑自己去尝试一下,下在哪里会获得最高分!!!则必然会有下面的流程:
如上图,到了最为困难的地方,如何权衡这个玩家得分,电脑得分?
我的想法就是以防守为主,就是以玩家得分为基础。
想象一下,电脑在棋盘的每个点都进行模拟下棋,此时形成不同的棋势,必然有不同的总得分,最后会得到每个点位得分列表,作为电脑一定希望玩家得分越低越好;反之,对于电脑自己,它希望自己的得分要高一些。
不能只取最低分,你这样做导致电脑只会防守。也不能让电脑只取自己的最高分,那就只会进攻了。
所以最后,我定下了下面的解决方案:
【差值列表】有什么含义?
列表当中的每一个得分都是,电脑得分 减去 玩家得分 的差值,那么我是不是可以认为此列表的最大值,就是在玩家得分的所有点位当中,对于电脑最为有利的点位。
直白来说,电脑是以玩家的得分为基础,找到既不利于玩家,又对电脑最有利的点位。
差值列表如果存在相同值怎么办?
假如有两个相同的点位,那么如何判断下在哪里最优呢?
我这里用相同的点取模拟用户的下一步,取其得分最高的点,作为电脑下在最优的点。
3.3.2 得分设计
经过前面的思想设计,发现似乎可行,那么下面就来设计下如何计算得分呢?
3.3.2.1 棋势细化
首先,我对于前面提到的打分模型,进行了细化。设想一下,冲四这个场景,要根据字符串的形式去匹配,那么可能存在21111,或者11112,此种标识被其他颜色堵住了;那么如果是靠边界的呢?就需要使用比如31111来表示了吧;还有中间有空位间隔的,再被堵住,或者是边界的情况。
所以形成了如下可能形成的棋势,包含一部分但并不全面:
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 匹配玩家
匹配玩家是我在实现此功能遇到的第一个难题,需要考虑如何存储正在匹配的用户? 如何对匹配中的玩家进行配对呢? 匹配过后,需要将前面保存的正在匹配的用户信息删除
我通过较为简单的方式去实现,如下图所示:
过程分析:
-
玩家发起匹配,会将其信息存储到redis当中
-
定时任务每5秒不断拉取该key
-
定时任务判断是否至少两人在匹配:
- 否:继续循环
- 是:删除二人匹配信息,同时推动websocket
-
websocket根据匹配信息,分别推送匹配成功的消息
-
两位玩家建立连接
3.4.2 在线对弈
双方在自己的客户端的每一次落子,都会将消息(主要是当前落子位置信息,对手用户名),通过websocket推送到服务端,服务端根据对手用户名推送topic,对手客户端通过监听topic(redis发布订阅)获取到此消息,通过websocket推送到页面,最终完成客户端棋子的渲染,达到双方同步的目的。
此处redis主要是解决分布式session的问题,虽然本项目是单机,但是出于习惯,还是使用redis中转一步。
四、不足和改进方案
- 人机对战
不足:目前人机对战,对于复杂多变的棋局,仍然会出现分数计算不准确的情况,导致电脑落子出错。尤其是棋子被堵截、涉及边界,在添加空位的情况,没能覆盖所有的可能。
人为归纳比较费力,毕竟种类繁多。
改进:后续的改进方案主要以AI为主,让电脑学会自学习。每一次失败,都自动记录失败位置,并持久化,经过长期训练,应该能覆盖绝大多数的可能性。
- 在线对战
不足:目前是定时任务轮询匹配玩家,小并发项目没有问题,并发量大的话可能会存在分配效率低下的问题。且如果分布式部署的情况下,还要针对定时任务做幂等处理。
改进:后期会将底层修改为Netty。
- 页面优化
不足:作为后端程序员,目前页面写的简单,且代码格式较为混乱,没有很多好的用到VUE的精髓,如路由等,导致页面不流畅,待重构。
改进: 重构代码风格,按功能划分组件、页面。增加游戏细节,丰富色彩。
五、总结
整个游戏,利用平时的业余时间,大概用了10天左右,效率不算高,中间也遇到问题,卡了很久。尤其是在人机模块的设计耗费了很多时间,中间也推翻了几次方案,磕磕绊绊的总算完成了,问题还有很多,后续有时间会去优化。
总结几点自身的不足:
- 数学基础不好
- 算法一般
- 前端编码能力有待提升
- 遇到问题,要学会跳出循环,整理思路后再分析
- 年龄问题,思考确实不透彻
其实最重要的一点在于动手之前需要先思考,无论做什么开发,还是要先做设计,验证可行性,以免反复。