跟我一起使用 compose 做一個跨平臺的黑白棋遊戲(2)介面佈局

語言: CN / TW / HK

theme: smartblue

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

前言

在上一篇文章中,我們講解了實現這個遊戲的總體思路,這篇文章我們將講解如何實現遊戲介面。

本文將涉及到 compose 的自定義繪製與觸控處理,這些內容都可以在我往期的文章中找到對應的教程,如果對這部分內容不太熟悉的話,可以翻回去看看。

實現過程

效果預覽

s1

介面分析

我們想要實現的介面分為三個大部分:

  1. 頂部的遊戲資訊介面:在這個介面中標識當前棋子與棋手資訊以及對局資訊
  2. 中間的遊戲棋盤
  3. 底部控制按鈕

其中,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 ,並不是我寫錯了哦。

效果:

s2

繪製線條

線條的繪製十分簡單,沒有什麼需要注意的地方,直接畫就完事了:

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) ) }

效果:

s3

繪製棋子

繪製棋子時需要遍歷 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())

最終效果如下(這裡是棋盤的初始狀態):

s4

完成棋盤的點選事件

給 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:其實只要你自己寫一下就知道為什麼了)

最終效果:

s1

總結

自此,咱們的介面佈局就算完成了,雖然現在看起來可能簡陋了點,但是現在還只是在驗證可行性,等所有程式碼寫完,我們再進行億點點優化,就會豐富好看多了。

對了,專案原始碼我將在這系列文章完結,也就是專案真正寫完的時候上傳到 Github,到時會在文中附上鍊接的。