记一个 Compose 版华容道,你值得拥有

基本思路

游戏逻辑比较简单,所以没有运用 MVI 之类的结构,可是全体仍然遵从数据驱动UI的设计思想

记一个 Compose 版华容道,你值得拥有

  1. 界说游戏的状况
  2. 依据状况的UI制作
  3. 用户输入触发状况改变

记一个 Compose 版华容道,你值得拥有

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)坐标。

记一个 Compose 版华容道,你值得拥有

最终经过下面代码,将 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())
                    }
                }
            },
            ...
    )
}

需求留意 detectHorizontalDragGesturesdetectVerticalDragGestures 是挂起函数,所以需求别离启动协程进行监听,能够类比成多个 flowcollect

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的区域有交集:

记一个 Compose 版华容道,你值得拥有

比如上面的棋局中,能够得到如下判定成果:

  • 曹操 坐落 关羽 之上
  • 关羽 坐落 卒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 是默认值,但一般不会直接运用,一般咱们会经过 CompositionLocalProviderCompositionLocal 创立 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 版华容道,你值得拥有

最终

本文主要介绍了怎么运用 Compose 的 Gesture, Theme 等特性快速完结一个华容道小游戏,更多 API 的完结原理,能够参阅以下文章:

深化理解 MaterialTheme 与 CompositionLocal

运用Jetpack Compose完结自界说手势处理

代码地址:github.com/vitaviva/co…

文末

您的点赞保藏便是对我最大的鼓励! 欢迎重视我,分享Android干货,沟通Android技术。 对文章有何见解,或许有何技术问题,欢迎在谈论区一起留言评论!