跟我一起使用 compose 做一個跨平臺的黑白棋遊戲(3)狀態與遊戲控制邏輯
持續創作,加速成長!這是我參與「掘金日新計劃 · 10 月更文挑戰」的第5天,點選檢視活動詳情
前言
在上一篇文章中,我們已經完成了黑白棋的介面設計與編寫,今天這篇文章我們將完成狀態控制和遊戲邏輯程式碼的編寫。
正如第一篇文章所述,在本專案中,我們需要實現不依賴於平臺的狀態管理,也就是使用 Flow 和 composable 來實現。
另外,還是再宣告一下,這個專案的 AI 演算法來自於 reversi 專案。
老規矩,先上游戲效果:
(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 顯示效果如下,
遊戲結束:
建立新遊戲:
邏輯結構
首先看一下完成後的程式碼結構:
為了避免混淆,我把介面相關的包打碼了。
可以看到,核心邏輯就兩個包:gameLogic 和 viewModel。
其中,gameLogic 包下的 Algorithm
和 Rule
兩個類是複製自大佬的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
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 生成 equals
、 hashCode
等方法,但是如果引數中包含可變型別,例如 Array 、 list 等,則自動生成會失效,需要我們自己手動寫一下。
從 data class ChessBoardState
中也可以看到,我確實過載了這兩個方法。
回到我們的問題,如果我們不單獨抽出一個 data class 來存放這個 Array 的話,一旦我們修改了這個 data class 的引數(刪除或新增),就必須同步的修改 equals
、 hashCode
這個兩個方法,不然會出現意想不到的錯誤。
然而不是每次修改引數我們都會記得同時去修改這兩個方法,我之前就因為這個問題被坑了。
將可變引數抽出來單獨封裝的話,可以保證這個抽出來的類只有這一個引數,我們也不會去動它,這就避免了修改引數時忘記修改方法。
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()
}
每個事件的作用我已經使用註釋標明。
有一點需要注意,新建遊戲使用到了兩個事件: ClickRequestNewGame
和 ClickNewGame
。
其中,ClickRequestNewGame
只是用於更改狀態以在 view 中顯示新建遊戲的 Dialog。
接收到 ClickNewGame
事件後才會真正的初始化狀態並按照使用者選擇的引數開啟一局新的遊戲。
gamePresenter
這個類是用於處理從 view 接收到的事件:
```kotlin
@Composable
fun gamePresenter(
gameAction: Flow
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
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能否繼續下子,如果都不能下子則統計各自的棋子數量判定遊戲勝負。
總結
自此,所有遊戲控制邏輯編寫完成!現在這個遊戲已經完全是可以玩的程度了,我也試了下,有點難度,不好下啊。
但是目前還存在以下幾個問題:
- 介面佈局不美觀
- 遊戲提示功能還沒做(這個其實很好做,獲取到所有可以合法下子的格子,然後更新一個提示UI到棋盤上就可以了)
- 邏輯上還有點問題,例如,當玩家已經無法繼續下子時應該跳到AI下子或開始判定遊戲結果,但是現在如果出現這種情況會直接"卡死"
當然,這些問題都無傷大雅,所以我決定先暫時不去改這些,下一步我們先將其移植到桌面端,然後再慢慢的做億點優化。
- 安卓與串列埠通訊-校驗篇
- 安卓與串列埠通訊-實踐篇
- 為 Kotlin 的函式新增作用域限制(以 Compose 為例)
- 安卓與串列埠通訊-基礎篇
- Compose For Desktop 實踐:使用 Compose-jb 做一個時間水印助手
- 初探 Compose for Wear OS:實現一個簡易選擇APP
- Compose太香了,不想再寫傳統 xml View?教你如何在已有View專案中混合使用Compose
- 在安卓中壓縮GIF的幾種方法(附例項程式碼)
- 魔改車鑰匙實現遠端控車:(1)整體思路及控制方案實現
- 跟我一起使用 compose 做一個跨平臺的黑白棋遊戲(3)狀態與遊戲控制邏輯
- 跟我一起使用 compose 做一個跨平臺的黑白棋遊戲(2)介面佈局
- 以不同的形式在安卓中建立GIF動圖
- 跟我一起使用 compose 做一個跨平臺的黑白棋遊戲(4)移植到compose-jb實現跨平臺
- 羨慕大勞星空頂?不如跟我一起使用 Jetpack compose 繪製一個星空背景(帶流星動畫)
- 魔改車鑰匙實現遠端控車:(4)基於compose和經典藍芽編寫一個控制APP