关于android:记一个-Compose-版华容道你值得拥有

112次阅读

共计 9047 个字符,预计需要花费 23 分钟才能阅读完成。

基本思路

游戏逻辑比较简单,所以没有应用 MVI 之类的框架,然而整体依然听从数据驱动 UI 的设计思维:

  1. 定义游戏的状态
  2. 基于状态的 UI 绘制
  3. 用户输出触发状态变动

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 的父容器,它有两个特点:

  1. 其子 Composable 能够共享 CompositionLocal 中的数据,防止了层层参数传递。
  2. 当 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 的实现原理,能够参考以下文章:

深刻了解 MaterialTheme 与 CompositionLocal

应用 Jetpack Compose 实现自定义手势解决

代码地址:https://github.com/vitaviva/c…

文末

您的点赞珍藏就是对我最大的激励!
欢送关注我,分享 Android 干货,交换 Android 技术。
对文章有何见解,或者有何技术问题,欢送在评论区一起留言探讨!

正文完
 0