跟我一起使用 compose 做一個跨平臺的黑白棋遊戲(2)介面佈局
theme: smartblue
持續創作,加速成長!這是我參與「掘金日新計劃 · 10 月更文挑戰」的第4天,點選檢視活動詳情
前言
在上一篇文章中,我們講解了實現這個遊戲的總體思路,這篇文章我們將講解如何實現遊戲介面。
本文將涉及到 compose 的自定義繪製與觸控處理,這些內容都可以在我往期的文章中找到對應的教程,如果對這部分內容不太熟悉的話,可以翻回去看看。
實現過程
效果預覽
介面分析
我們想要實現的介面分為三個大部分:
- 頂部的遊戲資訊介面:在這個介面中標識當前棋子與棋手資訊以及對局資訊
- 中間的遊戲棋盤
- 底部控制按鈕
其中,1 和 3 都可以使用基礎的 compose 元件實現,而 2 的棋盤以及棋子需要使用自定義繪製來手動繪製。
分析完成,我們首先繪製出棋盤。
繪製棋盤
棋盤同樣由三個部分組成:背景、線條、棋子。
在繪製之前,我們需要先構建出繪製作用域(DrawScope
),這裡直接使用 Canvas
繪製:
kotlin
@Composable
fun ReversiView(
modifier: Modifier,
chessBoard: Array<ByteArray>,
onClick: (row: Int, col: Int) -> Unit
) {
Canvas(
modifier = modifier
) {
// ……
}
}
在這裡,我們給 ReversiView
抽出了三個引數:
modifier
這個不用多說,幾乎所有 composable 都會抽出這個引數,但是這裡沒有給出預設值而是選擇使用必須值是因為 Canvas
明確要求必須使用 modifier
指定元件的大小,無論是指定準確值還是使用 fillMaxSize
等指定相對值都可以,但是這裡會有一個坑,下面會講到。
chessBoard
則是當前的棋盤資料陣列,這裡使用 Byte
來表示是因為我們要使用的演算法用的是 Byte
…… 其中,使用 -1 表示黑子; 1 表示 白子;0 表示空白。
onClick
是點選棋盤格子的回撥匿名函式,其中 row
和 col
分別表示點選的橫縱座標(這裡的座標指格子座標,如 7x7 表示最右下角的格子),並且我們需要對點選範圍做處理,確保只回調點選格子內的觸控事件,格子外不會回撥。
接下來,我們先計算出需要使用的幾個引數:
kotlin
// 棋盤內容邊界
val chessBoardSide = size.width * ChessBoardScale
// 棋盤線長
val lineLength = size.width - chessBoardSide * 2
// 棋盤格子尺寸
val boxSize = lineLength / 8
其中,size
是 DrawScope
提供的變數,表示的是當前繪製區域的大小;ChessBoardScale
是我們定義的一個常量,表示棋盤四周的邊界比例:const val ChessBoardScale = 0.05f
繪製背景
然後先繪製出背景的木板,這裡我們其實就是直接將準備好的圖片放了上去:
kotlin
// 畫棋盤背景
drawImage(
image = backgroundImage,
srcOffset = IntOffset(0, 0),
dstSize = IntSize(size.width.toInt(), size.width.toInt())
)
畫背景這裡有兩點需要注意。
一是 drawImage
需要的是一個 ImageBitmap
型別的圖片,這裡我們可以將其理解為 compose 封裝的,可以跨平臺的 Bitmap
資料。
我們這裡獲取 ImageBitmap 的函式如下:
```kotlin // 在 ViewUtils 中 /* * 安卓平臺需要的是 Int 型別的 ID, 但是在桌面端,使用的是 String 型別的路徑, * 為了後期移植方便,現在直接寫成 String 型別 * / @Composable fun loadImageBitmap(resourceName: String): ImageBitmap { return ImageBitmap.imageResource(id = resourceName.toInt()) }
// ……
// 在 ReversiView 中 val backgroundImage = loadImageBitmap(resourceName = R.drawable.mood.toString()) ```
上面程式碼的註釋中我們也說了,這裡單獨抽出一個方法用於獲取資原始檔是為了之後的跨平臺處理,因為不同平臺對於資源載入的方式不一樣,所以需要自己處理一下。
第二點需要注意的是,我們需要指定繪製的 ImageBitmap
的大小,不然取決於呼叫時附加的 modifier
可能會出現意想不到的結果。
指定繪製大小的方法也很簡單,使用 dstSize = IntSize(size.width.toInt(), size.width.toInt())
這個引數的作用就是將繪製的圖片鋪滿繪製區域(size.width
)
對了,因為黑白棋的棋盤是一個 8x8 格子的正方形,並且我們編寫的是一個豎屏遊戲,所以我們會以寬為基準作為繪製區域尺寸,所以這裡我們的寬和高使用的都是 size.width
,並不是我寫錯了哦。
效果:
繪製線條
線條的繪製十分簡單,沒有什麼需要注意的地方,直接畫就完事了:
kotlin
// 畫棋盤線
for (i in 0..8) {
// 橫線
drawLine(
color = Color.Black,
start = Offset(chessBoardSide, chessBoardSide + i * boxSize),
end = Offset(lineLength+chessBoardSide, chessBoardSide + i * boxSize)
)
// 豎線
drawLine(
color = Color.Black,
start = Offset(chessBoardSide + i * boxSize, chessBoardSide),
end = Offset(chessBoardSide + i * boxSize, lineLength+chessBoardSide)
)
}
效果:
繪製棋子
繪製棋子時需要遍歷 chessBoard
這個陣列,並根據其中的數值大小決定需要繪製的棋子顏色,或者是否繪製棋子:
```kotlin val whiteChess = loadImageBitmap(resourceName = R.drawable.white_chess.toString()) val blackChess = loadImageBitmap(resourceName = R.drawable.black_chess.toString())
// ……
// 畫棋子 for (col in 0 until 8) { for (row in 0 until 8) { if (chessBoard[col][row] == (-1).toByte()) { // 黑子 drawImage( image = blackChess, srcOffset = IntOffset(0, 0), dstOffset = IntOffset( (chessBoardSide + col * boxSize).toInt(), (chessBoardSide + row * boxSize).toInt() ), dstSize = IntSize(boxSize.toInt(), boxSize.toInt()) ) } if (chessBoard[col][row] == (1).toByte()) { // 白子 drawImage( image = whiteChess, srcOffset = IntOffset(0, 0), dstOffset = IntOffset( (chessBoardSide + col * boxSize).toInt(), (chessBoardSide + row * boxSize).toInt() ), dstSize = IntSize(boxSize.toInt(), boxSize.toInt()) ) } } } ```
繪製棋子,我們依舊使用的是直接繪製圖片,其實這裡我想自己畫一個棋子來著,但是畫了一通都覺得畫出來的棋子好醜啊,所以就放棄了,索性直接用圖片算了。
需要注意的是,繪製棋子需要對繪製的圖片做偏移處理,使其繪製到正確的格子內:
kotlin
dstOffset = IntOffset(
(chessBoardSide + col * boxSize).toInt(),
(chessBoardSide + row * boxSize).toInt()
)
這裡我們通過每個格子的大小(boxSize
)乘以橫向座標(col
橫向格子數)能得到 x 軸座標,同理通過 row
計算得到 y 軸座標。
並且,我們需要指定棋子尺寸為佔滿格子尺寸:dstSize = IntSize(boxSize.toInt(), boxSize.toInt())
最終效果如下(這裡是棋盤的初始狀態):
完成棋盤的點選事件
給 Canvas 的 modifier 新增修飾符:
kotlin
modifier = modifier.pointerInput(Unit) {
detectTapGestures(
onTap = { offset: Offset ->
getChessCoordinate(
size, offset, onClick
)
}
)
}
其中,getChessCoordinate
定義如下:
```kotlin fun getChessCoordinate( size: IntSize, offset: Offset, onClick: (row: Int, col: Int) -> Unit ) { // 棋盤內容邊界 val chessBoardSide = size.width * ChessBoardScale // 棋盤線長 val lineLength = size.width - chessBoardSide * 2 // 棋盤格子尺寸 val boxSize = lineLength / 8
if (offset.x in chessBoardSide..size.width-chessBoardSide
&& offset.y in chessBoardSide..size.width-chessBoardSide) { // 判斷是否在有效範圍內
// 計算點選座標
val row = floor((offset.x - chessBoardSide) / boxSize).toInt()
val col = floor((offset.y - chessBoardSide) / boxSize).toInt()
// 回撥點選函式
onClick(row, col)
Log.i("test", "ReversiView: row=$row, col=$col")
}
} ```
上面程式碼也很簡單,和繪製時差不多,按照座標計算出點選的是哪個格子,並回調給上級函式。
不過在計算時會先判斷點選的是不是格子區域,如果不是則不會回撥。
完成剩餘元件
剩下的就是將底部控制UI和頂部資訊UI加上即可:
```kotlin @Composable fun GameView() { val screenWidth = LocalConfiguration.current.screenWidthDp
Column(
Modifier
.fillMaxSize()
.padding(24.dp)
) {
// 頂部資訊欄
Row(
Modifier
.fillMaxWidth()
.padding(bottom = 36.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Column(
Modifier
.fillMaxWidth()
.weight(0.3f)
.background(Color.LightGray),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "您",
Modifier.padding(bottom = 8.dp),
fontSize = 18.sp
)
Row(
Modifier.padding(4.dp),
verticalAlignment = Alignment.CenterVertically
) {
Image(
bitmap = loadImageBitmap(resourceName = R.drawable.black_chess.toString()),
contentDescription = "black")
Text(text = "x2", Modifier.padding(2.dp))
}
}
Column(
Modifier
.fillMaxWidth()
.weight(0.3f),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "VS",
fontSize = 24.sp,
fontWeight = FontWeight.Bold
)
}
Column(
Modifier
.fillMaxWidth()
.weight(0.3f),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "電腦",
Modifier.padding(bottom = 8.dp),
fontSize = 18.sp
)
Row(
Modifier.padding(4.dp),
verticalAlignment = Alignment.CenterVertically
) {
Image(
bitmap = loadImageBitmap(resourceName = R.drawable.white_chess.toString()),
contentDescription = "black")
Text(text = "x2", Modifier.padding(2.dp))
}
}
}
// 遊戲棋盤
ReversiView(
modifier = Modifier.size(screenWidth.dp),
chessBoard = initChessBoard(),
onClick = { row: Int, col: Int ->
Log.i("test", "GameView: click row=$row, col=$col")
}
)
// 底部控制按鈕
Row(
Modifier
.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceAround
) {
Button(onClick = { /*TODO*/ }) {
Text(text = "重新開始")
}
Button(onClick = { /*TODO*/ }) {
Text(text = "提示")
}
}
}
} ```
從上面的呼叫棋盤介面的程式碼中:
kotlin
ReversiView(
modifier = Modifier.size(screenWidth.dp),
chessBoard = initChessBoard(),
onClick = { row: Int, col: Int ->
Log.i("test", "GameView: click row=$row, col=$col")
}
)
我們可以看到,對於棋盤的尺寸定義,我們定義成了指定長寬均為螢幕寬度: val screenWidth = LocalConfiguration.current.screenWidthDp
。
為啥長寬都用螢幕寬度,上面已經說了,那麼,思考一個問題,為什麼這裡不直接使用 Modifier .fillMaxWidth()
呢?而非要獲取到螢幕寬度後再手動設定給它呢?
這個問題,留給各位略微思考一下,下一篇文章再告訴大家為什麼。(ps:其實只要你自己寫一下就知道為什麼了)
最終效果:
總結
自此,咱們的介面佈局就算完成了,雖然現在看起來可能簡陋了點,但是現在還只是在驗證可行性,等所有程式碼寫完,我們再進行億點點優化,就會豐富好看多了。
對了,專案原始碼我將在這系列文章完結,也就是專案真正寫完的時候上傳到 Github,到時會在文中附上鍊接的。
- 安卓與串列埠通訊-校驗篇
- 安卓與串列埠通訊-實踐篇
- 為 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