使用Compose DeskTop实现一个五子棋小游戏,并实现人机对战

1,420 阅读21分钟

在之前的文章里面分别介绍了使用Compose DeskTop开发一个秒表应用和钟表应用,虽说是应用但是功能略显单一,今天我们把难度稍微调高一点,来实现一个桌面版的五子棋小游戏,其中除了涉及到之前讲过的Compose Canvas绘制的知识点以外,还加入了使用MVI模式实现页面交互与视图更新,以及五子棋算法分析,最后简单实现一个人机对战功能

绘制棋盘

棋盘长什么样子大家应该心里都清楚,就是一个n * n的网格布局,在Canvas里面就相当于在x轴跟y轴方向各均匀的绘制n-1条线,为什么是n-1呢,因为边边上画不画线都一样,所以我们第一步先把绘制棋盘所需要的变量定义出来

image.png
  • gridCount : 网格数量,可自定义
  • screenW : 画布的宽度,在Canvas中使用size.width更新值
  • screenY : 画布的高度,在Canvas中使用size.height更新值
  • xUnit : 每一格的宽度
  • yUnit : 每一格的高度
  • xList : 保存所有点的x坐标
  • yList : 保存所有点的y坐标

在画布里面,棋盘的大小通常基本都是铺满整个窗口的,所以这里使用fillMaxSize,画布颜色我们挑选一个稍微偏黄一点的颜色毕竟棋盘多数情况下都是木质的

image.png

在画布里面我们先给screenWscreenY两个变量赋值,然后开始画棋盘上的网格,网格的绘制相当于就是遍历xListyList,每一次遍历就在对应的x坐标与y坐标位置分别画上横线与竖线,线的长度或者高度就是screenW或者screenY

image.png

网格的绘制部分就结束了,我们在Main.kt的里面加入这个Fivekids的Composable函数,代码如下

image.png

简单的设置了一下标题和窗口的位置,运行一下后我们的棋盘就出来了

image.png

棋子

绘制棋子的过程跟棋盘就不一样了,棋子是动态的,是根据鼠标点击棋盘某一个位置的时候,判断点击位置是否在有效位置才开始 在该位置所在的坐标区域画棋子,这里涉及到几个问题需要思考下

  • 棋子是个圆,圆的半径大小如何确定?太大了两个棋子之间可能会重叠,太小了在棋盘上展示的效果就不会很好
  • 如何确定点击的位置属于哪个区域?要知道从我们刚才绘制好的棋盘上看,横线竖线相交的地方都是绘制棋子的圆心,而点击的位置可以是任意地方,我们需要将点击位置与某一个圆心关联起来
  • 用什么方式将绘制好的棋子保存起来?因为最终是需要去计算绘制好的棋子是否是五个相连并且同色,所以至少需要一个数组将这些棋子所对应的坐标保存起来

第一个问题,我们可以观察下棋盘,如果需要让两棋子刚刚好摆放在两个有效位置之间,由于棋子圆心就在白线相交位置,所以我们可以理解为俩棋子的半径加一起刚好等于xUnit或者yUnit,为什么是或者?因为我们的棋盘所在的窗口不一定是正方形的,所以导致里面的小方格也不一定是正方形,所以一个棋子的直径应该刚好是xUnityUnit的较小值,如下所示

image.png

变量chessRadius就是棋子的半径,那么第一个问题就解决了,我们看第二个问题,如何确定点击位置属于哪个区域,由于我们已经确定好了棋子的半径,那么是不是可以这样思考,点击位置属于某一个区域的条件是这个区域的中心点xy坐标减去点击位置xy坐标的绝对值小于半径的话,那么说明棋子就应该在这个区域绘制,所以我们可以事先将所有区域的棋子先绘制出来,将颜色设置为透明,当某一个区域通过遍历满足被绘制的条件以后,该区域的棋子再设置为黑色或者白色,因此需要创建一个棋子的实体类,该类代表着一个棋子的x坐标,y坐标以及当前展示的颜色

image.png

然后创建一个数组,这个数组里面永远保存着棋盘上所有的SingleChess,并在初始化的时候将这个数组里面的SingleChessblack值设置为0,即透明

image.png

随后我们就在Canvas里面将这些点通过drawCircle逐个绘制出来,并且根据black值的不同,将棋子显示不同的颜色

image.png

现在这个状态我们所有的棋子都还是透明的,所以在棋盘上是看不到棋子的,想要看到的话那肯定得下棋啊,下棋我们就要通过点击棋盘获取点击位置了,而我们的棋盘就是Canvas,Compose里面通过使用Modifier操作符的pointerInput函数来获取点击的坐标,代码如下

image.png

PointerInputScope中的detectTapGestures函数里面就可以拿到点击坐标了,我们先创建两个变量用来保存每一次点击获取的坐标,并在detectTapGestures里面对它们赋值

image.png

点击坐标有了,那么我们如果想要在点击完成以后显示对应的棋子,那么就要在刚才对pointList赋值的地方通过判断点击位置的xy坐标是否与pointList里面某一个SingleChess的xy坐标相差在chessRadius范围之内,是的话就add进去一个黑棋或者白棋,不是的话还是add进去一个透明的棋子,所以我们还需要一个值来表示当前应该是下黑棋还是白棋,并且将最新下完的棋也保存在一个变量里面,代码如下

image.png

black是个布尔值,默认为true,表示黑棋先下,然后在下完黑棋,也就是在pointList里面添加完black值为1的SingleChess之后,将black设置为false,表示该白棋下了,point是最新下完的棋,下面是完整的下棋代码

image.png

现在我们点击棋盘以后,棋子就会出来了,运行一下看看效果

0422aa1.gif

做点交互

棋子的绘制做完了,但毕竟是游戏,咱除了基本的下棋以外,也得有些交互,而我们这个五子棋小游戏的交互暂时我想出了以下几点

  • 窗口中得有个地方提示当前应该轮到白棋下还是黑棋下
  • 白棋获胜或者黑棋获胜的时候,窗口中添加一段“恭喜某方获胜”的提示语
  • 不一定非得等到某一方棋子到了五个才决出胜负,有时候棋盘上出现比如“双活三”,"双冲四"的这样的必败局面,得有个地方让玩家主动认输,并且出现“某方认输,某方获胜”这样的提示语
  • 一局比赛结束之后,需要有个地方让玩家主动发起再来一局,然后棋盘上棋子清空,先手回到黑棋这边

那么要实现这些交互的话,我们窗口里面除了棋盘,就要多几样元素了,分别是提示哪一方开始下棋的状态位,显示提示语的文案,以及认输和再来一局这俩按钮,接下去我们就在Main.kt里面加上这些元素

image.png

我们在棋盘的上方增加了一个Row布局,其中Surface用来显示当前是哪一方开始下棋,并且我们把它设置为圆形,相当于就是一个棋子一样,放在Row的最左边,Text用来展示提示语,在Row的正中间,最右边是认输和再来一局两个按钮,我们看下实际效果

image.png

黑白棋状态

我们现在的状态还是写死的色值,如果需要实现黑棋下完,状态变成白棋,白棋下完状态变成黑棋的话,需要让状态监听棋盘里面哪一方刚下完,那么这里可以使用MVI的模式去实现这个交互,棋盘就是我们唯一的数据发送源,再创建个UI State类叫ChessState

image.png

ChessState类里面维护着一个用来刷新状态视图的TurnSide类,它接收一个布尔值的参数,很明显当棋盘里面下完黑棋之后,TurnSide发送false,当下完白棋之后,TurnSide发送true,那么发送的事情我们就交给StateFlow,先在Main.kt里面创建个StateFlow

image.png

然后再创建个记录当前状态颜色的变量

image.png

spaceColor就是用来展示状态的色值,它需要根据chessState监听TurnSide传递过来的值来决定是显示黑色还是白色,StateFlow监听数据是一个挂起函数,所以要把它放在一个协程环境里面,我们这里再添加上LaunchedEffect函数,把监听数据的操作放在LaunchedEffect中进行,代码实现如下

image.png

这段代码就可以实现根据棋盘传递过来的黑白棋状态来展示不同色值,那么我们在哪里发送这个事件呢?没错,就在我们pointList添加完黑白棋状态的SingleChess的位置,所以我们这边需要把chessState这个StateFlow当作参数传递给我们棋盘的函数Fivekids

image.png

然后在下完黑白棋的位置用StateFlow把事件发送出去,像这样

image.png

现在我们的黑白棋状态已经可以动态的根据下完棋以后动态改变了,我们看下实际效果

0423aa1.gif

显示提示语

提示语是在某一方认输,某一方获胜以及其他情况下显示在棋盘上方正中间,所以也是根据棋盘里面发送出来的状态来显示的,那么我们在刚才创建的UI State类里面还需要加上一个提示语的状态类

image.png

而我们之前在Text组件里面是写死的一个空字符串,现在要让它显示的文案发生变化,所以得先创建个文案的变量用来保存当前是显示的什么文案

image.png

变量title还要去监听来自棋盘里面发出来的文案,所以在LaunchedEffect里面再加上对文案的监听

image.png

这里给这段代码做个测试,比如刚才我们发送黑白棋状态的位置,这里将发送黑白棋状态的代码改为发送文案,同样也表示哪一方下完轮到另一方开始下,代码如下

image.png

我们再看下效果

0423aa2.gif

提示语的展示也处理完了,现在我们来把两个按钮的点击事件也处理下

“认输”和“再来一盘”

认输按钮的功能是点击以后,棋盘根据哪一方发起的认输事件,来发送对应提示语,如果是黑方发起的认输,那么发送文案“黑方认输,白方获胜”,反之则发送“白方认输,黑方获胜”,但是发送文案这个事件本身是根据外部按钮点击触发的,所以我们这里需要从外部传递一个状态到棋盘里面来通知应该发送文案事件了,外部传递进来的状态我们就定义为

image.png

gameState的默认值为0,表示为正常下棋状态,然后我们在点击认输按钮的时候,将gameState设置为1,表示为某一方认输了,同时在函数Fivekids函数里面增加一个Int类型的gameState参数,每一次gameState值改变的时候,触发Fivekids重组

image.png

当点击认输按钮以后,棋盘上是不能再继续比赛了,也就是gameState为1的时候,发送对应文案的同时,将棋盘设置为不能再有新的棋子出现的,代码如下

image.png

我们看到除了发送对应的文案,我们在下棋的地方也添加了新的判断,只有当gameState为0的时候,才可以有新的棋子出现,其他状态点击棋盘上任意位置,黑白棋的数量保持不变,再看下效果

0423aa3.gif

认输按钮的功能完成了,然后接着处理再来一盘的功能,点击这个按钮的时候,棋盘上的棋子需要清空,先手方回到黑方,然后将文案也置空,所以我们也需要在UI State类里面新增一个状态来刷新点击完再来一盘按钮后的窗口视图,新的状态类定义如下

image.png

然后在再来一盘的点击事件中,将gameState设置为2,表示此刻比赛需要重新开始

image.png

在棋盘内,当接收到gameState为2的时候,将pointList清空,黑白棋状态设置为黑方,并发送重置状态

image.png

此刻在LaunchedEffect中新增对ResetState的处理,将黑白棋状态变成黑色,提示语置空,gameState变成0,重新回到下棋状态

image.png

再看下效果

0423aa4.gif

现在所有交户都开发完成了,现在就要思考下如何去判断哪一方获胜了

如何判断哪一方获胜

下过五子棋的都知道,判断一方获胜的条件是只有当相邻的五个点出现同色的棋子才算赢,可以是横着排,也可以是竖着排,斜着也可以,规则十分容易,但是如何在我们的棋盘上去实现这个规则呢?我们需要考虑的问题其实只有一个,如何判断棋盘上出现了连续五个同色的棋子,同色的好实现,只需要用一个filter操作符将pointListblack为1或者2的SingleChess过滤出来就可以了,我们就能得到只有黑棋或者只有白棋的子list,代码如下

image.png

现在我们就把这个问题简化到了如何在一个list中判断有五个相邻的坐标点,好家伙~是简化了但不多,硬生生的给自己安排了一道算法题,没办法只能烧点脑细胞了

区间算法

首先想到的是先将数组里面的点按照x坐标排一下序,使用sortedBy操作符,然后下标从0开始,五个五个判断它们的x,y相减是否等于我们的xUnit或者yUnit值,相当于就是判断一个区间里面的坐标了

image.png

如上图所示,先判断绿框的点,然后再判断红框的点,但很快就验证了这个做法的问题,因为就算按照x坐标排序,但遇到同一个x坐标下有超过一个点的情况就有问题了,会把真正相邻的点圈在了框子的外面

Map算法

通过上面的思考,我又想着把同一个x坐标下的所有点放在一个新的数组里面,同一个y坐标下的所有点也放在一个新的数组下,然后分别保存在一个Map里面,那么只需要遍历这个Map,再将每个list按照y坐标或者x坐标排序,这样如果水平方向或者垂直方向出现五个连续的坐标点的话,就可以找出来了,但也只能满足这两个方向,如果需要判断斜着的方向,就得同时遍历五个相邻的数组,这算法的效率就很低了,如果棋子的数量慢慢增加的话,会发现点击棋盘时候,有很明显的卡顿

扩散算法

这个是休息了一个晚上想出来的,所以说算法这东西一个思路可能比代码更重要,我们之前的思路是把棋盘上所有的点都考虑进去,然后从里面找五个连续的棋子,其实仔细想想没有很大的必要,因为下棋的时候,我们都会去找像活三,冲四这样的棋子组合,再往里面下我们最新的棋子来组成五个,所以我们每次判断的起始点是我们棋盘上最新下的点,然后往四周扩散出去查找需要的点是否在我们的list里面,我们看下面这张图

image.png

中间这个点永远都是棋盘上最新更新的点,然后对它的x,y坐标分别按照水平,垂直,左上至右下,左下至右上四个方向进行增加或者减去单位长度,每一次计算完以后,把新的坐标点与list中所有坐标点逐个比较,如果坐标一致并且颜色一致,说明存在一个相邻的点,累加器加一,直到累加器的值变成五以后,说明我们棋盘上有五个相邻的点,点代表的颜色一方获胜,现在我们就把这个思路转换成代码,新增一个函数judgeList,返回值是个布尔值,用来返回一个List里面是否存在五个相邻的棋子

image.png

其中pList是只存在黑色棋子的数组或者只存在白色棋子的数组,point是最新加入pointList的棋子,xUnityUnit分别是x轴方向和y轴方向的单位长度,当pList长度小于5的时候,我们不计算,大于5的时候,分别从countHorizontalFivecountVerticalFivecountLTtoRBFivecountRTtoLBFive四个函数判断各个方向上是否有五个连续相邻的棋子,先看计算水平方向的函数

image.png

对于水平方向来讲,point的y坐标不参与计算,x坐标首先向右走一格,然后得到的点通过samePoint函数判断是否满足要求,满足的话计数器加一,然后再往右走一格,直到4次遍历结束,如果计数器没有到5函数没有被return,那么再往左继续同样的计算,如果计数器到5了,说明水平方向存在连续五个点,如果没有到5,那么继续执行其他方向的函数,至于repeat4次的原因,是因为对于某一个点来讲,如果周围存在连续五个点,那么单一方向上最多只会存在四个点,所以遍历4次就够了,我们再看下samePoint函数是如何判断得到的点存在于pList

image.png

看到这里的判断很简单,除了判断颜色一致,就是在遍历list的时候,针对每一个list里面的点的xy坐标与计算出来的xy坐标相减,得到的结果取绝对值如果小于等于3,那么证明是一个点,至于为什么是小于等于3而不是直接等于0,因为我们所有的坐标在参与计算的时候都会从Float转成Int,这个过程可能存在精度上的误差,所以把差值定为3是考虑到了精读问题,其他方向上的逻辑都基本相同,代码就不贴了,基本就是把水平方向的造几个轮子下去,再改几个变量就好,我们最后一步就是调用我们的judgeList函数,代码如下

image.png

这样我们在棋盘上计算哪一方获胜的逻辑就写完了,看下效果如何吧

0423aa5.gif

人机对战

目前是实现了自己与自己下棋,但作为一个竞技类游戏,五子棋还是要真正对战起来才有意思,但是如果加入电脑的话,如何让电脑判断应该在哪里下棋是个难点,我们知道一个厉害的电脑,下棋时候是可以判断什么应该进攻什么时候应该防守,也可以识别棋盘上是否有冲四或者活三这样的棋局,我这边只能暂时先实现个弱一点的电脑,先能够在棋子周围的有效区域下棋

什么时候应该轮到电脑下

之前是在Canvas里面通过点击事件记录坐标来实现黑白棋互相下棋的,那么现在我们必须下完棋子以后,要等到电脑操作完完以后才能继续下,所以我们需要用black标识符来做个开关,当这边黑棋下完以后,开关关闭,这个时候无论怎么点击棋盘,得到的坐标都是无效的,等到电脑下完以后再把开关打开

image.png

我们看到当black为false的时候,此时点击棋盘不会有任何反应的,因为xy坐标都为0,这个时候应该轮到电脑下了,电脑需要判断棋盘上所有空的位置里面哪里最合适下,这个是比较耗时的操作,所以我们需要把这些操作放在协程里面,这里就需要使用LaunchedEffect函数,在函数里面通过生成一个Flow,在Flow的上游计算合适的下棋点,在下游把点传给tapXtapY,代码如下

image.png

LaunchedEffect函数里面执行了经过两秒以后,传递一个随机点给下游,我们看到参数传了个black,那是因为LaunchedEffect函数只有当参数发生改变,才会再一次执行它里面的代码块,所以选择使用black作为入参,因为black会在每次下完棋以后改变一下值,现在再看下效果

0427aa1.gif

现在当我们下完黑棋的时候可以发现,我们鼠标没有移动,白棋已经由电脑下好了,然后我们只需要将生成随机点的代码改成真正去计算合适位置的代码就好了

计算电脑下棋位置

电脑应该下在什么地方的计算方式同最终判定哪一方胜负的思路是一致的,都是需要通过判断某一个点的周围是否存在黑子或者白子,所以棋盘上那些没有被下过棋的点可以分为两大类,一类是周围没有棋子,一类是周围有棋子,而最终电脑需要下棋的位置就是从那些周围有棋子的空白位置处选一个,那么首先我们把棋盘上所有的点分成三个集合

image.png

入参是我们的pointList,然后在我们新建的函数里面,将pointList分成空白位置点,黑棋位置点以及白棋位置点的集合,接下去要做的就是遍历整个emptyList,将里面每个点周围存在几个白棋或者几个黑棋计算出来,为此,我们SingleChess需要新增两个属性

image.png

每一个空白点都会在计算后标志出周围有几个黑棋或者几个白棋

image.png

最终优先从emptyList中过滤出周围有棋子的空白位置,随机出一个交给棋盘去渲染,没有的话就从所有空白位置随机一个

image.png

而每一个空白点周围有几个黑棋或者白棋,就在函数whetherInList里面进行

image.png

需要计算出四个数字,分别是某一个位置水平,垂直,左上至右下,左下至右上方向上存在多少个同色棋子,然后比较得出最大值,任意一个方向上计算方式同最终判断胜负的方式相似,不同的是,这边是先一直判断一侧,直到没有带颜色的棋子的时候,再去判断另一侧,然后最后返回计数器的值,比如计算左上至右下方向的代码

image.png

当两个开关都变为false的时候,计数器计算出来的值就是某一个点在该方向上周围存在的某一个颜色的棋子个数,其他方向上的计算方式类似,我们就跳过了,直接将pickRightPoint放入LaunchedEffect函数中

image.png

这边的参数还多加了xUnityUnit,因为如果不添加这两个参数,最终pickRightPoint函数中的xUnityUnit都只会为初始值0,现在在跑下代码,看看跟电脑下棋的效果

0427aa2.gif

总结

这个demo写的时间算是比较长的,总共花了一个礼拜时间,主要是算法这边卡了会,然后计算电脑下棋的位置也卡了会,细心的小伙伴可能有发现,其实这套代码最终设计是打算将电脑下棋的位置分个优先级,whiteCount>3的优先级最高,毕竟表示马上就要获胜了,blackCount>3的优先级其次,表示防守,然后优先级慢慢轮下去,但这样做总是会让白棋与黑棋到了一定数量以后就重叠绘制了,所以只能暂时先弄一个超简易版的电脑,后面解决了再更新一篇,然后还会尝试一下局域网对战,等开发出来了一起分享给大家