跟我一起使用 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,到时会在文中附上链接的。