ナゲッツコミュニティゲームクリエイティビティコンテストの個人コンテストに参加しています。詳しくは、ゲームクリエイティビティコンテストをご覧ください。
みんなの余暇と娯楽の手段として、誰もがゴバンをプレイしたことがあると思います。多くの人がそれを上手にできるはずです。私たちはプログラマーとして、それがどのように実装されているかを考えましたか?マンマシンはどのように実現されますか?オンラインマッチとバトルはどのように機能しますか?
バックギャモンミニゲームの最初のバージョンを最終的に完成させるのに数日かかりました。これには主に次の小さな機能が含まれています。
- ログイン登録
- マンマシンバトル
- オンラインバトル
- 一体型モジュール
オンライン体験アドレス:ゴバンバトル
オープンソースコードアドレス:我慢できない:gobang
I.概要
この記事では、このGobangのゲームプレイ、使用されている技術的な実装、および現在のゲームの欠点と改善計画について説明します。
現在、最初のバージョンとして、私が期待した機能を完了することができ、プロジェクトの全体的なコード量は少なく、複雑さは低いです。関連するコード技術を学びたい学生に適しています。楽しんで、参照して、コメントするために皆を歓迎します。
私のフロントエンドは非常に一般的であり、現在のインターフェースは比較的単純であることに注意する必要があります。これは基本的な機能を実現するためだけのものであり、将来的に徐々に最適化する必要があります。
2.ゲームプレイの概要
冒頭、このゲームには4つの小さな機能があると言われていましたが、凡例と組み合わせて各機能を段階的に見ていきましょう。
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の本質をうまく利用することはあまりなく、ページはそうではありません。スムーズで、リファクタリングする必要があります。
改善:コードスタイルのリファクタリング、コンポーネントとページの機能による分割。ゲームの詳細と豊かな色を増やします。
V.まとめ
いつもの暇な時間にゲーム全体で約10日かかり、効率も良くなく、途中でトラブルに見舞われて長時間立ち往生しました。特にヒューマンマシンモジュールの設計では、時間がかかり、途中で何度か計画が覆され、ようやくつまずきが終わりました。まだまだ問題が多く、最適化する時間があります。未来。
いくつかの欠点を要約します。
- 貧しい数学の基礎
- 一般的なアルゴリズム
- フロントエンドのコーディング能力を改善する必要があります
- 問題に遭遇したときは、ループから飛び出して考えを整理し、分析することを学ぶ必要があります
- 年齢の問題は本当に理解できません
実際、最も重要な点は、開始する前に考える必要があるということです。どのような開発を行っても、繰り返しを避けるために実現可能性を検証するために、最初に設計を行う必要があります。