跟我一起使用 compose 做一个跨平台的黑白棋游戏(1)整体实现思路

前言

为什么写这系列文章

虽然 compose 正式版已经出来很久了,也有很多大佬写了很多教程文章和实例 demo ,但是对于 compose 其实我也还是一知半解的。

特别是对于 compose 的状态管理,由于 compose 声明式的特性,如果不对状态进行完善的管理,那么界面代码和业务逻辑代码将会杂糅在一起,导致代码可读性、可维护性非常差。

很多大佬们都说使用 MVI 架构来管理 compose 的状态是天生一对。

我也尝试使用 MVI 架构编写了一个简单的游戏:基于 Jetpack Compose,使用MVI架构+自定义布局实现的康威生命游戏

不过,这就存在一个很大的问题,大佬们几乎都是使用 ViewModel 来实现 MVI 架构。但是, ViewModel 是强依赖于安卓原生 API,这就导致无法将这个项目移植到 compose-jb 实现跨平台。

我也收集了大佬们的解决方案,无非以下几种:

  1. 不再使用安卓的 ViewModel,而是自己参照源码手撸一个跨平台的类似功能的库。例如:Compose Mutiplatform 实战联机小游戏
  2. 给不同的平台封装不同的状态管理实现类,例如:不止 Android,Compose Multiplatform 初探
  3. 索性直接不使用 ViewModel ,改用其它可以跨平台使用的库,例如:Compose 下的 MVI 架构实践,用 Compose 写业务逻辑,取代 ViewModel

回到我们标题的问题,为什么我要写这系列文章?

因为现在对于 compose 的使用方法,最佳实践都尚在探索期。我也不确定什么才是最适合自己使用的,唯有多尝试才知道。

所以我觉得我应该再试试不同的实现方式,这次就使用上面所说的方法3进行尝试。

由于这系列文章不同于以往采用的是代码已经写完并且测试没问题后才开始撰写文章,而是采用边尝试写代码,边记录的方式。

所以文章可能会有所纰漏或错误,但是我会在发现问题后第一时间在后续文章中说明并校正。

黑白棋是什么

黑白棋(英语:Reversi),又称翻转棋、苹果棋或奥赛罗棋(Othello),是一种双人对弈的棋类游戏。

一般棋子双面为黑白两色,故称“黑白棋”;因为行棋之时将对方棋子翻转,变为己方棋子,故又称“翻转棋”(Reversi);棋子双面为红、绿色的称为“苹果棋”,因苹果有红苹果和青苹果。

游戏规则:

棋盘共有8行8列共64格。开局时,棋盘正中央的4格先置放黑白相隔的4枚棋子(亦有求变化相邻放置)。通常黑子先行。双方轮流落子。只要落子和棋盘上任一枚己方的棋子在一条在线(横、直、斜线皆可)夹着对方棋子,就能将对方的这些棋子转变为我己方(翻面即可)。如果在任一位置落子都不能夹住对手的任一颗棋子,就要让对手下子。当双方皆不能下子时,游戏就结束,子多的一方胜。

p1

以上内容和图片摘自 维基百科 黑白棋 条目

实现思路

我的目标

这个项目的目标是首先使用 Jetpack compose 实现安卓端的黑白棋游戏,然后移植到 compose-jb 实现跨平台。

我会优先实现单机版游戏,后期考虑加入联机游戏。

对于游戏的状态管理,依旧使用 MVI 作为架构,但是不再使用 Jetpack ViewModel 实现,而是尝试使用 composable 和 Flow 做一个平台无关的状态管理。

关于单机游戏的AI

由于这个项目的目的是找到对于我来说 compose 开发的最佳实践,所以算法逻辑不在这个项目的重点。

但是如果要做单机游戏,对战AI是必不可少的,所以我找到了一个开源项目 reversi , 之后项目中的AI算法将使用这个项目的,部分UI可能也会直接从这个项目里面拿。

准确的说,我现在是在将这个项目移植为使用 compose 实现。 (*^_^*)

所以在开始之前我们需要先简单分析一下这个项目的组成结构。

s1

不得不说,大佬的项目看起来就是赏心悦目,各个模块分工明确:

模块 说明
activity 这个不用多解释,就是 Activity,我们需要留意的是 GameActivity ,承载游戏界面的 Activity
bean 一些数据 bean
game AI核心算法逻辑自定义的棋盘view
util 一些工具方法
widget 大佬在这里封装了几个 dialog

这是大佬的游戏主界面:

s2

在这里我们需要重点关注的是 GameActivity 这个 Activity 承载了游戏的主界面和控制逻辑。

game layout 的布局结构如下:

s3

可以看到,除了棋盘使用的是自定义 view : ReversiView 外,其他都是使用基础控件组成的 游戏信息控制按钮

ReversiView 的内容这里我们就不具体看了,如果我们要移植到 compose 的话可以很轻松的直接将它的代码 “copy” 过来并转成 compose 的 canvas 代码。当然,我们也可以完全自己重写,具体的绘制内容,我们将在下一篇文章详细说明。

这里我们着重看一下如何使用它的AI算法。

首先,在 GameActivity 中,他使用 setOnTouchListener 监听了 ReversiView 的触摸事件:

reversiView.setOnTouchListener(new OnTouchListener() {
    boolean down = false;
    int downRow;
    int downCol;

    @Override
    public boolean onTouch(View v, MotionEvent event) {

        if (gameState != STATE_PLAYER_MOVE) { // 没有轮到玩家下子,直接返回
            return false;
        }
        
        float x = event.getX();
        float y = event.getY();
        
        if (!reversiView.inChessBoard(x, y)) {  // 判断是否在棋盘范围内
            return false;
        }
        
        // 计算棋盘的横纵坐标
        int row = reversiView.getRow(y);
        int col = reversiView.getCol(x);
        
        switch (event.getAction()) {
            case MotionEvent.ACTION_DOWN:
                // 按下时记录按下的棋盘坐标
                down = true;
                downRow = row;
                downCol = col;
                break;
                
            case MotionEvent.ACTION_UP:

                if (down && downRow == row && downCol == col) { // 只有在抬起坐标和按下坐标一致时才继续处理
                    down = false;
                    if (!Rule.isLegalMove(chessBoard, new Move(row, col), playerColor)) { // isLegalMove 这个方法用于判断往这个坐标下子是否合法
                        return true;
                    }
                    
                    // 判断完成后开始按照规则更新数据和UI
                    Move move = new Move(row, col);
                    List<Move> moves = Rule.move(chessBoard, move, playerColor);
                    reversiView.move(chessBoard, moves, move, playerColor);
                    
                    // 轮到AI下子
                    aiTurn();

                }
                break;
            case MotionEvent.ACTION_CANCEL:
                down = false;
                break;
        }
        return true;
    }
});

我已经把不重要的代码删除,并加上了注释。

这里我们需要关注更新数据的方法:Rule.move() ;AI下子的方法:aiTurn()

aiTurn() 这个方法首先会调用 Rule.analyse() 方法计算当前玩家和 AI 拥有的棋子数量,然后根据计算出的数量更新游戏界面,并将游戏状态更改为 STATE_AI_MOVE 即轮到 AI 下子,最后启动一个新的线程 new ThinkingThread(aiColor).start(); 用于运行AI算法。

ThinkingThread 的代码如下:

class ThinkingThread extends Thread {

    private byte thinkingColor;

    public ThinkingThread(byte thinkingColor) {
        this.thinkingColor = thinkingColor;
    }

    public void run() {
        try {
            sleep(20 * 100);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        int legalMoves = Rule.getLegalMoves(chessBoard, thinkingColor).size();
        if (legalMoves > 0) {
            Move move = Algorithm.getGoodMove(chessBoard, depth[difficulty], thinkingColor, difficulty);
            List<Move> moves = Rule.move(chessBoard, move, thinkingColor);
            reversiView.move(chessBoard, moves, move, thinkingColor);
        }
        updateUI.handle(0, legalMoves, thinkingColor);
    }
}

可以看到,这个线程首先暂停了自己 2000 ms …… 额,为了让人看起来这个算法很厉害需要算 2s 吗?哈哈~

不管这个奇怪的暂停,咱们接着往下看。

首先,调用 Rule.getLegalMoves().size(); 获取到所有可以下子的位置数量,如果数量大于 0 则继续处理。

通过调用 Algorithm.getGoodMove() 获取到算法计算出的最佳下子位置,然后更新数据和UI。

因为这里我们只需要知道怎么复用作者的算法即可,所以我们不深究算法的具体实现。

如果感兴趣的可以看看作者自己写的解读:android黑白棋游戏实现

综上所述,我们已经明了应该如何使用这位大佬编写的AI算法了。

基础架构demo

正如上文所述,我们现在需要使用 Flow 和 composable 实现一个平台无关的数据管理框架。

这里我们按照上文大佬的思路编写一个简单的 demo 验证可行性:

@Composable
fun Demo() {
    val channel = remember { Channel<Action>() }
    val flow = remember(channel) { channel.consumeAsFlow() }
    val state = presenter(action = flow)

    Column {
        Text(text = state.count.toString())
        Button(
            onClick = {
                channel.trySend(Action.ClickAdd)
            }
        ) {
            Text(text = "ADD")
        }
    }
}

sealed class Action {
    object ClickAdd : Action()
}

data class State (
    val count: Int = 0,
)

@Composable
fun presenter(
    action: Flow<Action>,
): State {
    var count by remember { mutableStateOf(0) }

    LaunchedEffect(action) {
        action.collect { action: Action ->
            when (action) {
                is Action.ClickAdd -> count++
            }
        }
    }
    return State(
        count = count
    )
}

因为这里只是为了验证可行性,所以我直接把所有代码写到了一起,实际编写时肯定是要分开的

Android 运行效果:

g1

Desktop 运行效果:

g2

总结

经过上面的分析和实践,证明不依赖安卓的 ViewModel 确实是可以实现 MVI 架构,这就意味着之后移植至 compose-jb 将更加方便。

当然,本文只是简单的梳理了一下思路,从下一篇开始我们将正式开始编写。

下一篇我们介绍怎么绘制棋盘和棋子,以及编写界面布局。