跟我一起使用 compose 做一個跨平台的黑白棋遊戲(3)狀態與遊戲控制邏輯

語言: CN / TW / HK

持續創作,加速成長!這是我參與「掘金日新計劃 · 10 月更文挑戰」的第5天,點擊查看活動詳情

前言

在上一篇文章中,我們已經完成了黑白棋的界面設計與編寫,今天這篇文章我們將完成狀態控制和遊戲邏輯代碼的編寫。

正如第一篇文章所述,在本項目中,我們需要實現不依賴於平台的狀態管理,也就是使用 Flow 和 composable 來實現。

另外,還是再聲明一下,這個項目的 AI 算法來自於 reversi 項目。

老規矩,先上游戲效果:

s1.gif

(ps:哈哈,這個GIF原本有8mb,但是我給壓縮成了300kb,而且還是在沒有改變分辨率和幀率的情況下,畫質的損失也還能接受。想知道怎麼壓縮的可以看我之前的文章有説哦)

開始實現

解答上次留下的問題

上一篇文章,我們留下了一個問題,為什麼要自己指定使用屏幕寬度而不是直接使用 fillMaxWidth

其實這個問題只要自己寫一遍就知道為什麼。

因為如果使用 fillMaxWidth 的話,在 Canvas 中返回的 size.height 會是 0。

或許你會説,那我直接使用 fillMaxSize 不就不會是 0 了嗎?

你品品這話,難道你的界面中只有一個棋盤?其他組件不要了?哈哈。

狀態提升與界面修改

在正式開始編寫邏輯代碼前,我們需要先將上次實現的界面中的狀態抽出來,做一個狀態提升:

kotlin @Composable fun GameView( chessBoard: Array<ByteArray>, playerChessNum: Int, aiChessNum: Int, gameState: Int, aiLevel: AiLevel, whoFirst: Int, onClickChess: (row: Int, col: Int) -> Unit, onRequestNewGame: () -> Unit, onNewGame: (whoFirst: Int, aiLevel: AiLevel) -> Unit, onTip: () -> Unit ) { // …… }

這個函數中的參數全是之前實現的界面中需要用到的狀態數據,這裏我們把它們都提出來作為這個函數的參數。

對應的,我們需要把之前界面中寫死的狀態改為使用這些參數,以實現動態更新。

另外,我們需要加兩個 Dialog 用於提示遊戲結束和新建遊戲。

在 GameView() 中添加:

```kotlin // 遊戲結束彈窗 if (gameState >= 3) { RequestNewGameDialog(gameState) { onRequestNewGame() } }

// 新遊戲彈窗 if (gameState == NeedNewGame) { NewGameDialog(onStart = { whoFirst: Int, aiLevel: AiLevel -> onNewGame(whoFirst, aiLevel) }) } ```

新創建兩個 composable:

```kotlin @Composable private fun RequestNewGameDialog(gameState: Int, onStart: () -> Unit) { val text = when (gameState) { 3 -> "恭喜,你贏了!" 4 -> "抱歉,電腦贏了" 5 -> "遊戲結束,這次是平局哦" else -> "遊戲結束" } Dialog(onDismissRequest = { }) { Card(backgroundColor = Color.White) { Column(modifier = Modifier.padding(8.dp), horizontalAlignment = Alignment.CenterHorizontally) { Text(text = text, fontSize = 24.sp, modifier = Modifier.padding(vertical = 6.dp)) Button(onClick = { onStart() }) { Text(text = "重新開始") } } } } }

@Composable private fun NewGameDialog(onStart: (whoFirst: Int, aiLevel: AiLevel) -> Unit) { var isPLayerFirst by remember { mutableStateOf(true) } var aiLevel by remember { mutableStateOf(AiLevel.Level1) }

Dialog(onDismissRequest = { }) {
    Card(backgroundColor = Color.White) {
        Column(
            modifier = Modifier.padding(8.dp),
            horizontalAlignment = Alignment.CenterHorizontally
        ) {
            Row(verticalAlignment = Alignment.CenterVertically) {
                Checkbox(
                    checked = isPLayerFirst,
                    onCheckedChange = { isPLayerFirst = !isPLayerFirst })
                Text(text = "玩家先手")
            }

            Text(text = "AI難度")

            Row(
                verticalAlignment = Alignment.CenterVertically,
                modifier = Modifier.padding(vertical = 6.dp)
            ) {
                RadioButton(
                    selected = aiLevel == AiLevel.Level1,
                    onClick = { aiLevel = AiLevel.Level1 },
                    modifier = Modifier.size(26.dp)
                )
                Text(text = AiLevel.Level1.showName)

                RadioButton(
                    selected = aiLevel == AiLevel.Level2,
                    onClick = { aiLevel = AiLevel.Level2 },
                    modifier = Modifier.size(26.dp)
                )
                Text(text = AiLevel.Level2.showName)

                RadioButton(
                    selected = aiLevel == AiLevel.Level3,
                    onClick = { aiLevel = AiLevel.Level3 },
                    modifier = Modifier.size(26.dp)
                )
                Text(text = AiLevel.Level3.showName)

                RadioButton(
                    selected = aiLevel == AiLevel.Level4,
                    onClick = { aiLevel = AiLevel.Level4 },
                    modifier = Modifier.size(26.dp)
                )
                Text(text = AiLevel.Level4.showName)
            }

            Row(verticalAlignment = Alignment.CenterVertically) {
                RadioButton(
                    selected = aiLevel == AiLevel.Level5,
                    onClick = { aiLevel = AiLevel.Level5 },
                    modifier = Modifier.size(26.dp)
                )
                Text(text = AiLevel.Level5.showName)

                RadioButton(
                    selected = aiLevel == AiLevel.Level6,
                    onClick = { aiLevel = AiLevel.Level6 },
                    modifier = Modifier.size(26.dp)
                )
                Text(text = AiLevel.Level6.showName)

                RadioButton(
                    selected = aiLevel == AiLevel.Level7,
                    onClick = { aiLevel = AiLevel.Level7 },
                    modifier = Modifier.size(26.dp)
                )
                Text(text = AiLevel.Level7.showName)

                RadioButton(
                    selected = aiLevel == AiLevel.Level8,
                    onClick = { aiLevel = AiLevel.Level8 },
                    modifier = Modifier.size(26.dp)
                )
                Text(text = AiLevel.Level8.showName)
            }


            Button(
                onClick = {
                    onStart(if (isPLayerFirst) PLayerRound else AiRound, aiLevel)
                },
                modifier = Modifier.padding(6.dp)
            ) {
                Text(text = "開始")
            }
        }
    }
}

} ```

上面兩個 Dialog 顯示效果如下,

遊戲結束:

s2.png

創建新遊戲:

s3.png

邏輯結構

首先看一下完成後的代碼結構:

s4.png

為了避免混淆,我把界面相關的包打碼了。

可以看到,核心邏輯就兩個包:gameLogic 和 viewModel。

其中,gameLogic 包下的 AlgorithmRule 兩個類是複製自大佬的AI算法代碼。這裏有一個坑需要注意一下,因為大佬的代碼是 java 寫,為了統一代碼,我使用 AndroidStudio 的自動轉換功能自動轉成了 kt ,然而自動轉換有點問題,可能會轉出錯,所以最好是自己手動寫一遍,或者轉了之後再檢查一下,改一改。

另外兩個類是之前編寫的幾個輔助工具類。

而 viewModel 下的內容則是狀態控制的核心代碼。

下面將一一進行講解。

GameState

這個類是對當前遊戲中所有用到的狀態的封裝:

```kotlin data class GameState ( /當前棋盤上的棋子信息/ val chessBoardState: ChessBoardState = ChessBoardState(), /AI難度等級/ val aiLevel: AiLevel = AiLevel.Level1, /遊戲狀態/ val gameState: Int = PLayerRound, /先手/ val whoFirst: Int = PLayerRound, /AI當前棋子數量/ val aiChessNum: Int = 2, /玩家當前棋子數量/ val playerChessNum: Int = 2, )

enum class AiLevel(val showName: String, val level: Int) { Level1("菜鳥", 1), Level2("新手", 2), Level3("入門", 3), Level4("棋手", 4), Level5("棋士", 5), Level6("大師", 6), Level7("宗師", 7), Level8("棋聖", 8), }

data class ChessBoardState ( val chessBoardArray: Array = initChessBoard(), ) { override fun equals(other: Any?): Boolean { if (this === other) return true if (javaClass != other?.javaClass) return false

    other as ChessBoardState

    if (!chessBoardArray.contentDeepEquals(other.chessBoardArray)) return false

    return true
}

override fun hashCode(): Int {
    return chessBoardArray.contentDeepHashCode()
}

} ```

其中每個狀態表示什麼用途我在註釋中已經説明。

需要注意的一點是,對於狀態 chessBoardState 我沒有直接聲明類型為 Array<ByteArray> 而是使用另外一個 data class 封裝了一下,為什麼要這樣呢?

這是因為 kotlin 中雖然會自動為 data class 生成 equalshashCode 等方法,但是如果參數中包含可變類型,例如 Array 、 list 等,則自動生成會失效,需要我們自己手動寫一下。

data class ChessBoardState 中也可以看到,我確實重載了這兩個方法。

回到我們的問題,如果我們不單獨抽出一個 data class 來存放這個 Array 的話,一旦我們修改了這個 data class 的參數(刪除或新增),就必須同步的修改 equalshashCode 這個兩個方法,不然會出現意想不到的錯誤。

然而不是每次修改參數我們都會記得同時去修改這兩個方法,我之前就因為這個問題被坑了。

將可變參數抽出來單獨封裝的話,可以保證這個抽出來的類只有這一個參數,我們也不會去動它,這就避免了修改參數時忘記修改方法。

GameAction

這個類是用於承載 view 向 gamePresenter 發送事件的類:

kotlin sealed class GameAction { /**請求開啟新的遊戲**/ object ClickRequestNewGame : GameAction() /** 請求提示**/ object ClickTip : GameAction() /**點擊了棋盤的某一個格子**/ data class ClickChess(val row: Int, val col: Int) : GameAction() /**開始一個新的遊戲**/ data class CLickNewGame(val whoFirst: Int, val aiLevel: AiLevel): GameAction() }

每個事件的作用我已經使用註釋標明。

有一點需要注意,新建遊戲使用到了兩個事件: ClickRequestNewGameClickNewGame

其中,ClickRequestNewGame 只是用於更改狀態以在 view 中顯示新建遊戲的 Dialog。

接收到 ClickNewGame 事件後才會真正的初始化狀態並按照用户選擇的參數開啟一局新的遊戲。

gamePresenter

這個類是用於處理從 view 接收到的事件:

```kotlin @Composable fun gamePresenter( gameAction: Flow, ): GameState { var gameState by remember { mutableStateOf(GameState()) }

LaunchedEffect(gameAction) {
    gameAction.collect { action: GameAction ->
        when (action) {
            is GameAction.ClickChess -> {
                val newState = clickChess(gameState, action.row, action.col)

                if (newState != null) {
                    gameState = newState

                    withContext(Dispatchers.IO) {
                        // 電腦下子
                        gameState = runAi(gameState)
                        // 檢查遊戲是否已結束
                        gameState = checkIfGameOver(gameState)
                    }
                }
            }
            is GameAction.ClickRequestNewGame -> {
                gameState = gameState.copy(
                    gameState = NeedNewGame
                )
            }
            is GameAction.ClickTip -> {
                // TODO 暫時不寫這個
            }
            is GameAction.CLickNewGame -> {
                gameState = GameState(
                    whoFirst = action.whoFirst,
                    aiLevel = action.aiLevel,
                    gameState = action.whoFirst
                )

                if (action.whoFirst == AiRound) {
                    withContext(Dispatchers.IO) {
                        // 電腦下子
                        gameState = runAi(gameState)
                    }
                }
            }
        }
    }
}

return gameState

} ```

在這個類中,我們使用 Flow 接收發送過來的新流,並在接收到新流後做出相應的操作,更新 gameState 的值,由於 gameState 被託管給了 mutableStateOf

所以當 gameState 被改變時會觸發 compose 的重組機制,導致重新調用這個函數,發送新的狀態給使用到的地方,從而讓所有使用到了這個狀態的地方全部重組,這樣,UI就會對應的更新。

在 MainActivity 中我們是這樣調用的:

```kotlin setContent { ReversiChessComposeTheme { // A surface container using the 'background' color from the theme Surface( modifier = Modifier.fillMaxSize(), color = MaterialTheme.colors.background ) { val channel = remember { Channel() } val flow = remember(channel) { channel.consumeAsFlow() } val state = gamePresenter(flow)

        Column(
            modifier = Modifier.fillMaxSize(),
            verticalArrangement = Arrangement.Center,
            horizontalAlignment = Alignment.CenterHorizontally
        ) {
            GameView(
                chessBoard = state.chessBoardState.chessBoardArray,
                playerChessNum = state.playerChessNum,
                aiChessNum = state.aiChessNum,
                gameState = state.gameState,
                aiLevel = state.aiLevel,
                whoFirst = state.whoFirst,
                onClickChess = { row: Int, col: Int ->
                    channel.trySend(GameAction.ClickChess(row, col))
                },
                onRequestNewGame = {
                    channel.trySend(GameAction.ClickRequestNewGame)
                },
                onNewGame = { whoFirst: Int, aiLevel: AiLevel ->
                    channel.trySend(GameAction.CLickNewGame(whoFirst, aiLevel))
                },
                onTip = {
                    channel.trySend(GameAction.ClickTip)
                }
            )
        }
    }
}

} ```

gamePresenter 中實現開始新遊戲很簡單,所以我們就不過多贅述,大家看代碼即可。

我們着重説説玩家下子後的處理:

```kotlin val newState = clickChess(gameState, action.row, action.col)

if (newState != null) { gameState = newState

withContext(Dispatchers.IO) {
    // 電腦下子
    gameState = runAi(gameState)
    // 檢查遊戲是否已結束
    gameState = checkIfGameOver(gameState)
}

} ```

首先我們調用 clickChess 檢查下子是否合法,並更新 UI,如果不合法則返回 Null:

```kotlin private fun clickChess(gameState: GameState, row: Int, col: Int): GameState? { // 黑白棋規則是黑子先手,所以如果是AI先手的話,意味着玩家執白子 val playerColor: Byte = if (gameState.whoFirst == PLayerRound) BlackChess else WhiteChess

// 判斷是否是玩家回合
if (gameState.gameState != PLayerRound) {
    return null
}

// 下子區域不合法
if (!Rule.isLegalMove(gameState.chessBoardState.chessBoardArray, Move(col, row), playerColor)) {
    return null
}

// FIXME 這裏有一個BUG,可能會出現玩家已無棋可走,但是沒有繼續跳回AI或者結束遊戲導致"卡死"
val legalMoves = Rule.getLegalMoves(gameState.chessBoardState.chessBoardArray, playerColor)
if (legalMoves.isEmpty()) { // 玩家已經無棋可走
    return gameState.copy(
        gameState = AiRound
    )
}

val move = Move(col, row)
// 調用該方法後會更新傳入的 chessBoardArray 並返回關聯更改的棋子信息
val moves = Rule.move(gameState.chessBoardState.chessBoardArray, move, playerColor) // TODO moves 可以用來做動畫效果

// 計算棋子數量
val statistic: Statistic = Rule.analyse(gameState.chessBoardState.chessBoardArray, playerColor)

return gameState.copy(
    chessBoardState = ChessBoardState(gameState.chessBoardState.chessBoardArray),
    gameState = AiRound,
    playerChessNum = statistic.PLAYER,
    aiChessNum = statistic.AI
)

} ```

並且在玩家無路可走時則直接跳到 AI 下子。

完成玩家下子後則是 AI 下子:

```kotlin private suspend fun runAi(gameState: GameState): GameState { val delayTime: Long = Random(System.currentTimeMillis()).nextLong(200, 1000) delay(delayTime) // 假裝AI在思考(

val aiColor: Byte = if (gameState.whoFirst == AiRound) BlackChess else WhiteChess

val legalMoves: Int = Rule.getLegalMoves(gameState.chessBoardState.chessBoardArray, aiColor).size

if (legalMoves > 0) {
    val move: Move? = Algorithm.getGoodMove(
        gameState.chessBoardState.chessBoardArray,
        Algorithm.depth[gameState.aiLevel.level],
        aiColor,
        gameState.aiLevel.level
    )
    if (move != null) {
        val moves = Rule.move(gameState.chessBoardState.chessBoardArray, move, aiColor) // TODO moves 可以用來做動畫效果

        // 計算棋子數量
        val statistic: Statistic = Rule.analyse(gameState.chessBoardState.chessBoardArray, (-aiColor).toByte())

        return gameState.copy(
            chessBoardState = ChessBoardState(gameState.chessBoardState.chessBoardArray),
            gameState = PLayerRound,
            playerChessNum = statistic.PLAYER,
            aiChessNum = statistic.AI
        )
    }
}

return gameState.copy(
    gameState = PLayerRound
)

} ```

AI 開始計算前會先有一個隨機的延時,模擬AI思考的過程(

哈哈,其實這裏的延時是為了用户體驗,因為計算非常快,幾乎幾毫秒就算好了,如果不加延時,在用户眼中看到的則是我下了之後怎麼沒反應?其實不是沒反應,只是在用户下完了之後AI瞬間就下好了,導致用户會產生AI一直不動的錯覺。

其實這裏如果加上動畫的話體驗會更好,但是動畫的內容咱們還是放到後面的億點點優化中去做吧。

AI 下子完成後就開始判斷當前遊戲狀態是否已結束:

```kotlin private fun checkIfGameOver(gameState: GameState): GameState { val aiColor: Byte = if (gameState.whoFirst == AiRound) BlackChess else WhiteChess val playerColor: Byte = if (gameState.whoFirst == PLayerRound) BlackChess else WhiteChess

val aiLegalMoves: Int = Rule.getLegalMoves(gameState.chessBoardState.chessBoardArray, aiColor).size
val playerLegalMoves: Int = Rule.getLegalMoves(gameState.chessBoardState.chessBoardArray, playerColor).size

if (aiLegalMoves == 0 && playerLegalMoves == 0) {
    // 兩方都無子可走,遊戲結束
    val statistic = Rule.analyse(gameState.chessBoardState.chessBoardArray, playerColor)
    val newState = if (statistic.AI > statistic.PLAYER) GameOverWithAi
                    else if (statistic.AI < statistic.PLAYER) GameOverWithPLayer
                    else GameOverWithTie

    return gameState.copy(
        gameState = newState
    )
}

return gameState

} ```

這裏判斷遊戲結束的方法很簡單,就是檢查當前玩家或者AI能否繼續下子,如果都不能下子則統計各自的棋子數量判定遊戲勝負。

總結

自此,所有遊戲控制邏輯編寫完成!現在這個遊戲已經完全是可以玩的程度了,我也試了下,有點難度,不好下啊。

但是目前還存在以下幾個問題:

  1. 界面佈局不美觀
  2. 遊戲提示功能還沒做(這個其實很好做,獲取到所有可以合法下子的格子,然後更新一個提示UI到棋盤上就可以了)
  3. 邏輯上還有點問題,例如,當玩家已經無法繼續下子時應該跳到AI下子或開始判定遊戲結果,但是現在如果出現這種情況會直接"卡死"

當然,這些問題都無傷大雅,所以我決定先暫時不去改這些,下一步我們先將其移植到桌面端,然後再慢慢的做億點優化。