前言
在上一篇文章中,我们讲解了实现这个游戏的总体思路,这篇文章我们将讲解如何实现游戏界面。
本文将涉及到 compose 的自定义绘制与触摸处理,这些内容都可以在我往期的文章中找到对应的教程,如果对这部分内容不太熟悉的话,可以翻回去看看。
实现过程
效果预览
界面分析
我们想要实现的界面分为三个大部分:
- 顶部的游戏信息界面:在这个界面中标识当前棋子与棋手信息以及对局信息
- 中间的游戏棋盘
- 底部控制按钮
其中,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
是点击棋盘格子的回调匿名函数,其中 row
和 col
分别表示点击的横纵坐标(这里的坐标指格子坐标,如 7x7 表示最右下角的格子),并且我们需要对点击范围做处理,确保只回调点击格子内的触摸事件,格子外不会回调。
接下来,我们先计算出需要使用的几个参数:
// 棋盘内容边界
val chessBoardSide = size.width * ChessBoardScale
// 棋盘线长
val lineLength = size.width - chessBoardSide * 2
// 棋盘格子尺寸
val boxSize = lineLength / 8
其中,size
是 DrawScope
提供的变量,表示的是当前绘制区域的大小;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
,并不是我写错了哦。
效果:
绘制线条
线条的绘制十分简单,没有什么需要注意的地方,直接画就完事了:
// 画棋盘线
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
这个数组,并根据其中的数值大小决定需要绘制的棋子颜色,或者是否绘制棋子:
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())
最终效果如下(这里是棋盘的初始状态):
完成棋盘的点击事件
给 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:其实只要你自己写一下就知道为什么了)
最终效果:
总结
自此,咱们的界面布局就算完成了,虽然现在看起来可能简陋了点,但是现在还只是在验证可行性,等所有代码写完,我们再进行亿点点优化,就会丰富好看多了。
对了,项目源码我将在这系列文章完结,也就是项目真正写完的时候上传到 Github,到时会在文中附上链接的。