跟我一起使用 compose 做一个跨平台的黑白棋游戏(2)界面布局

前言

在上一篇文章中,我们讲解了实现这个游戏的总体思路,这篇文章我们将讲解如何实现游戏界面。

本文将涉及到 compose 的自定义绘制与触摸处理,这些内容都可以在我往期的文章中找到对应的教程,如果对这部分内容不太熟悉的话,可以翻回去看看。

实现过程

效果预览

s1

界面分析

我们想要实现的界面分为三个大部分:

  1. 顶部的游戏信息界面:在这个界面中标识当前棋子与棋手信息以及对局信息
  2. 中间的游戏棋盘
  3. 底部控制按钮

其中,1 和 3 都可以使用基础的 compose 组件实现,而 2 的棋盘以及棋子需要使用自定义绘制来手动绘制。

分析完成,我们首先绘制出棋盘。

绘制棋盘

棋盘同样由三个部分组成:背景、线条、棋子。

在绘制之前,我们需要先构建出绘制作用域(DrawScope),这里直接使用 Canvas 绘制:

@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 是点击棋盘格子的回调匿名函数,其中 rowcol 分别表示点击的横纵坐标(这里的坐标指格子坐标,如 7x7 表示最右下角的格子),并且我们需要对点击范围做处理,确保只回调点击格子内的触摸事件,格子外不会回调。

接下来,我们先计算出需要使用的几个参数:

// 棋盘内容边界
val chessBoardSide = size.width * ChessBoardScale
// 棋盘线长
val lineLength = size.width - chessBoardSide * 2
// 棋盘格子尺寸
val boxSize = lineLength / 8

其中,sizeDrawScope 提供的变量,表示的是当前绘制区域的大小;ChessBoardScale 是我们定义的一个常量,表示棋盘四周的边界比例:const val ChessBoardScale = 0.05f

绘制背景

然后先绘制出背景的木板,这里我们其实就是直接将准备好的图片放了上去:

// 画棋盘背景
drawImage(
    image = backgroundImage,
    srcOffset = IntOffset(0, 0),
    dstSize = IntSize(size.width.toInt(), size.width.toInt())
)

画背景这里有两点需要注意。

一是 drawImage 需要的是一个 ImageBitmap 类型的图片,这里我们可以将其理解为 compose 封装的,可以跨平台的 Bitmap 数据。

我们这里获取 ImageBitmap 的函数如下:

// 在 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

绘制线条

线条的绘制十分简单,没有什么需要注意的地方,直接画就完事了:

// 画棋盘线
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 这个数组,并根据其中的数值大小决定需要绘制的棋子颜色,或者是否绘制棋子:

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

绘制棋子,我们依旧使用的是直接绘制图片,其实这里我想自己画一个棋子来着,但是画了一通都觉得画出来的棋子好丑啊,所以就放弃了,索性直接用图片算了。

需要注意的是,绘制棋子需要对绘制的图片做偏移处理,使其绘制到正确的格子内:

dstOffset = IntOffset(
    (chessBoardSide + col * boxSize).toInt(),
    (chessBoardSide + row * boxSize).toInt()
)

这里我们通过每个格子的大小(boxSize)乘以横向坐标(col 横向格子数)能得到 x 轴坐标,同理通过 row 计算得到 y 轴坐标。

并且,我们需要指定棋子尺寸为占满格子尺寸:dstSize = IntSize(boxSize.toInt(), boxSize.toInt())

最终效果如下(这里是棋盘的初始状态):

s4

完成棋盘的点击事件

给 Canvas 的 modifier 添加修饰符:

modifier = modifier.pointerInput(Unit) {
    detectTapGestures(
        onTap = { offset: Offset ->
            getChessCoordinate(
                size, offset, onClick
            )
        }
    )
}

其中,getChessCoordinate 定义如下:

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加上即可:

@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 = "提示")
            }
        }
    }
}

从上面的调用棋盘界面的代码中:

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,到时会在文中附上链接的。