之前写过几个 Compose 的 demo,但一直没使用到 Gesture, Theme 等特性,于是写了一个华容道的小程序来展示上述这些特性。写完后又一次被 Compose 的生产力所折服,整个程序的完成不足百行代码,这在传统开发方式中是难以想象的。
基本思路
游戏逻辑比较简单,所以没有使用 MVI 之类的框架,但是整体仍然遵从数据驱动UI的设计思想:
- 定义游戏的状态
- 基于状态的UI绘制
- 用户输入触发状态变化
1. 定义游戏状态
游戏的状态很简单,即当前各棋子(Chees)的摆放位置,所以可以将一个棋子的 List 作为承载 State 的数据结构
1.1 棋子定义
先来看一下单个棋子的定义
data class Chess( val name: String, //角色名称 val drawable: Int //角色图片 val w: Int, //棋子宽度 val h: Int, //棋子长度 val offset: IntOffset = IntOffset(0, 0) //偏移量 )
通过 w
,h
可以确定棋子的形状,offset
确定在棋牌中的当前位置
1.2 开局棋子摆放
接下来我们定义各个角色的棋子,并按照开局的状态摆放这些棋子
val zhang = Chess("张飞", R.drawable.zhangfei, 1, 2) val cao = Chess("曹操", R.drawable.caocao, 2, 2) val huang = Chess("黄忠", R.drawable.huangzhong, 1, 2) val zhao = Chess("赵云", R.drawable.zhaoyun, 1, 2) val ma = Chess("马超", R.drawable.machao, 1, 2) val guan = Chess("关羽", R.drawable.guanyu, 2, 1) val zu = buildList { repeat(4) { add(Chess("卒$it", R.drawable.zu, 1, 1)) } }
各角色的定义中明确棋子形状,比如“张飞”的长宽比是 2:1,“曹操” 的长宽比是2:2。
接下来定义一个游戏开局:
val gameOpening: List<Triple<Chess, Int, Int>> = buildList { add(Triple(zhang, 0, 0)); add(Triple(cao, 1, 0)) add(Triple(zhao, 3, 0)); add(Triple(huang, 0, 2)) add(Triple(ma, 3, 2)); add(Triple(guan, 1, 2)) add(Triple(zu[0], 0, 4)); add(Triple(zu[1], 1, 3)) add(Triple(zu[2], 2, 3)); add(Triple(zu[3], 3, 4)) }
Triple
的三个成员分别表示棋子以及其在棋盘中的偏移,例如 Triple(cao, 1, 0)
表示曹操开局处于(1,0)坐标。
最后通过下面代码,将 gameOpening
转化为我们所需的 State, 即一个 List<Chess>
:
const val boardGridPx = 200 //棋子单位尺寸 fun ChessOpening.toList() = map { (chess, x, y) -> chess.moveBy(IntOffset(x * boardGridPx, y * boardGridPx)) }
2. UI渲染,绘制棋局
有了 List<Chess>
之后,依次绘制棋子,从而完成整个棋局的绘制。
@Composable fun ChessBoard (chessList: List<Chess>) { Box( Modifier .width(boardWidth.toDp()) .height(boardHeight.toDp()) ) { chessList.forEach { chess -> Image( //棋子图片 Modifier .offset { chess.offset } //偏移位置 .width(chess.width.toDp()) //棋子宽度 .height(chess.height.toDp())) //棋子高度 painter = painterResource(id = chess.drawable), contentDescription = chess.name ) } } }
Box
确定棋盘的范围,Image
绘制棋子,并通过 Modifier.offset{ }
将其摆放到正确的位置。
到此为止,我们使用 Compose 绘制了一个静态的开局,接下来就是让棋子跟随手指动起来,这就涉及到 Compose Gesture 的使用了
3. 拖拽棋子,触发状态变化
Compose 的事件处理也是通过 Modifier
设置的, 例如 Modifier.draggable()
, Modifier.swipeable()
等可以做到开箱即用。 华容道的游戏场景中,可以使用 draggable
监听拖拽
3.1 监听手势
1) 使用 draggable 监听手势
棋子可以x轴、y轴两个方向进行拖拽,所以我们分别设置两个 draggable
:
@Composable fun ChessBoard ( chessList: List<Chess>, onMove: (chess: String, x: Int, y: Int) -> Unit ) { Image( modifier = Modifier ... .draggable(//监听水平拖拽 orientation = Orientation.Horizontal, state = rememberDraggableState(onDelta = { onMove(chess.name, it.roundToInt(), 0) }) ) .draggable(//监听垂直拖拽 orientation = Orientation.Vertical, state = rememberDraggableState(onDelta = { onMove(chess.name, 0, it.roundToInt()) }) ), ... ) }
orientation
用来指定监听什么方向的手势:水平或垂直。 rememberDraggableState
保存拖动状态,onDelta
指定手势的回调。 我们通过自定义的 onMove
将拖拽手势的位移信息抛出。
此时有人会问了,draggable
只能监听或者水平或者垂直的拖拽,那如果想监听任意方向的拖拽呢,此时可以使用 detectDragGestures
2) 使用 pointerInput 监听手势
draggable
, swipeable
等,其内部都是通过调用 Modifier.pointerInput()
实现的,基于 pointerInput
可以实现更复杂的自定义手势:
fun Modifier.pointerInput( key1: Any?, block: suspend PointerInputScope.() -> Unit ) : Modifier = composed (...) { ... }
pointerInput
提供了 PointerInputScope
,在其中可以使用suspend函数对各种手势进行监听。例如,可以使用 detectDragGestures
监听任意方向的拖拽:
suspend fun PointerInputScope.detectDragGestures( onDragStart: (Offset) -> Unit = { }, onDragEnd: () -> Unit = { }, onDragCancel: () -> Unit = { }, onDrag: (change: PointerInputChange, dragAmount: Offset) -> Unit )
detectDragGestures
也提供了水平、垂直版本供选择,所以在华容道的场景中,也可以使用以下方式进行水平和垂直方向的监听:
@Composable fun ChessBoard ( chessList: List<Chess>, onMove: (chess: String, x: Int, y: Int) -> Unit ) { Image( modifier = Modifier ... .pointerInput(Unit) { scope.launch {//监听水平拖拽 detectHorizontalDragGestures { change, dragAmount -> change.consumeAllChanges() onMove(chess.name, 0, dragAmount.roundToInt()) } } scope.launch {//监听垂直拖拽 detectVerticalDragGestures { change, dragAmount -> change.consumeAllChanges() onMove(chess.name, 0, dragAmount.roundToInt()) } } }, ... ) }
需要注意 detectHorizontalDragGestures
和 detectVerticalDragGestures
是挂起函数,所以需要分别启动协程进行监听,可以类比成多个 flow
的 collect
。
3.2 棋子的碰撞检测
获取了棋子拖拽的位移信息后,可以更新棋局状态并最终刷新UI。但是在更新状态之前需要对棋子的碰撞进行检测,棋子的拖拽是有边界的。
碰撞检测的原则很简单:棋子不能越过当前移动方向上的其他棋子。
1) 相对位置判定
首先,需要确定棋子之间的相对位置。 可以使用下面方法,判定棋子A在棋子B的上方:
val Chess.left get() = offset.x val Chess.right get() = left + width infix fun Chess.isAboveOf(other: Chess) = (bottom <= other.top) && ((left until right) intersect (other.left until other.right)).isNotEmpty()
拆解上述条件表达式,即 棋子A的下边界位于棋子B上边界之上
且 在水平方向上棋子A与棋子B的区域有交集
:
比如上面的棋局中,可以得到如下判定结果:
曹操
位于关羽
之上关羽
位于卒1
黄忠
之上卒1
位于卒2
卒3
之上
虽然位置上 关羽
位于卒2
的上方,但是从碰撞检测的角度看,关羽
和 卒2
在x轴方向没有交集,因此 关羽
在y轴方向上的移动不会碰撞到 卒2
,
guan.isAboveOf(zu1) == false
同理,其他几种位置关系如下:
infix fun Chess.isToRightOf(other: Chess) = (left >= other.right) && ((top until bottom) intersect (other.top until other.bottom)).isNotEmpty() infix fun Chess.isToLeftOf(other: Chess) = (right <= other.left) && ((top until bottom) intersect (other.top until other.bottom)).isNotEmpty() infix fun Chess.isBelowOf(other: Chess) = (top >= other.bottom) && ((left until right) intersect (other.left until other.right)).isNotEmpty()
2) 越界检测
接下来,判断棋子移动时是否越界,即是否越过了其移动方向上的其他棋子或者出界
例如,棋子在x轴方向的移动中检查是否越界:
// X轴方向移动 fun Chess.moveByX(x: Int) = moveBy(IntOffset(x, 0)) //检测碰撞并移动 fun Chess.checkAndMoveX(x: Int, others: List<Chess>): Chess { others.filter { it.name != name }.forEach { other -> if (x > 0 && this isToLeftOf other && right + x >= other.left) return moveByX(other.left - right) else if (x < 0 && this isToRightOf other && left + x <= other.right) return moveByX(other.right - left) } return if (x > 0) moveByX(min(x, boardWidth - right)) else moveByX(max(x, 0 - left)) }
上述逻辑很清晰:当棋子在x轴方向正移动时,如果碰撞到其右侧的棋子则停止移动;否则继续移动,直至碰撞棋盘边界为止 ,其他方向同理。
3.3 更新棋局状态
综上,获取手势位移信息后,检测碰撞并移动到正确位置,最后更新状态,刷新UI:
val chessList: List<Chess> by remember { mutableStateOf(opening.toList()) } ChessBoard(chessList = chessState) { cur, x, y -> // onMove回调 chessState = chessState.map { //it: Chess if (it.name == cur) { if (x != 0) it.checkAndMoveX(x, chessState) else it.checkAndMoveY(y, chessState) } else { it } } }
4. 主题切换,游戏换肤
最后,再来看一下如何为游戏实现多套皮肤,用到的是 Compose 的 Theme。
Compose 的 Theme 的配置简单直观,这要得益于它是基于 CompositionLocal
实现的。可以把 CompositionLocal
看做是一个 Composable 的父容器,它有两个特点:
- 其子 Composable 可以共享 CompositionLocal 中的数据,避免了层层参数传递。
- 当
CompositionLocal
的数据发生变化时,子 Composable 会自动重组以获取最新数据。
通过 CompositionLocal
的特点,我们可以实现 Compose 的动态换肤:
4.1 定义皮肤
首先,我们定义多套皮肤,也就是棋子的多套图片资源
object DarkChess : ChessAssets { override val huangzhong = R.drawable.huangzhong override val caocao = R.drawable.caocao override val zhaoyun = R.drawable.zhaoyun override val zhangfei = R.drawable.zhangfei override val guanyu = R.drawable.guanyu override val machao = R.drawable.machao override val zu = R.drawable.zu } object LightChess : ChessAssets { //...同上,略 } object WoodChess : ChessAssets { //...同上,略 }
4.2 创建 CompositionLocal
然后创建皮肤的 CompositionLocal
, 我们使用 compositionLocalOf
方法创建
internal var LocalChessAssets = compositionLocalOf<ChessAssets> { DarkChess }
此处的 DarkChess
是默认值,但通常不会直接使用,一般我们会通过 CompositionLocalProvider
为 CompositionLocal
创建 Composable 容器,同时设置当前值:
CompositionLocalProvider(LocalChessAssets provides chess) { //... }
其内部的子Composable共享当前设置的值。
4.3 跟随 Theme 变化切换皮肤
这个游戏中,我们希望将棋子的皮肤加入到整个游戏主题中,并跟随 Theme 变化而切换:
@Composable fun ComposehuarongdaoTheme( theme: Int = 0, content: @Composable() () -> Unit ) { val (colors, chess) = when (theme) { 0 -> DarkColorPalette to DarkChess 1 -> LightColorPalette to LightChess 2 -> WoodColorPalette to WoodChess else -> error("") } CompositionLocalProvider(LocalChessAssets provides chess) { MaterialTheme( colors = colors, typography = Typography, shapes = Shapes, content = content ) } }
定义 theme 的枚举值, 根据枚举获取不同的 colors
以及 ChessAssets
, 将 MaterialTheme
置于 LocalChessAssets
内部,MaterialTheme
内的所有 Composalbe 可以共享 MaterialTheme 和 LocalChessAssets 的值。
最后,为 LocalChessAssets 定一个 MaterialTheme 的扩展函数,
val MaterialTheme.chessAssets @Composable @ReadOnlyComposable get() = LocalChessAssets.current
可以像访问 MaterialTheme 的其他属性一样,访问 ChessAssets
。
最后
本文主要介绍了如何使用 Compose 的 Gesture, Theme 等特性快速完成一个华容道小游戏,更多 API 的实现原理,可以参考以下文章: