100 行写一个 Compose 版华容道

简介: 100 行写一个 Compose 版华容道

之前写过几个 Compose 的 demo,但一直没使用到 Gesture, Theme 等特性,于是写了一个华容道的小程序来展示上述这些特性。写完后又一次被 Compose 的生产力所折服,整个程序的完成不足百行代码,这在传统开发方式中是难以想象的。

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

基本思路

游戏逻辑比较简单,所以没有使用 MVI 之类的框架,但是整体仍然遵从数据驱动UI的设计思想:

image.png

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

image.png

最后通过下面代码,将 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的区域有交集:

image.png

比如上面的棋局中,可以得到如下判定结果:

  • 曹操 位于 关羽 之上
  • 关羽 位于 卒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

image.png

最后

本文主要介绍了如何使用 Compose 的 Gesture, Theme 等特性快速完成一个华容道小游戏,更多 API 的实现原理,可以参考以下文章:

使用Jetpack Compose完成自定义手势处理

主题配置繁琐?Compose帮你轻松搞定!

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

目录
相关文章
|
网络协议 Python
python中socket模块的导入和使用基础
【4月更文挑战第3天】Python的`socket`模块是网络编程的基础,用于创建套接字、绑定地址和端口、监听连接及数据传输。首先,使用`import socket`导入模块。接着,通过`socket.socket()`创建套接字,指定地址族(如`AF_INET`)和类型(如`SOCK_STREAM`)。然后,使用`bind()`方法绑定地址和端口,`listen()`方法监听连接。服务器端通过`accept()`接受连接,`recv()`接收数据,`send()`发送响应。客户端则用`connect()`连接服务器,`send()`发送数据,`recv()`接收响应。
|
缓存 Java API
Java工具篇之Guava-retry重试组件
Guava 是一组来自 Google 的核心 Java 库,其中包括新的集合类型(例如 multimap 和 multiset)、不可变集合、图形库以及用于并发、I/O、散列、缓存、原语、字符串等的实用程序!它广泛用于 Google 内部的大多数 Java 项目,也被许多其他公司广泛使用。 API 非常的简单,我们可以非常轻松的使用,来封装成我们业务中自己的组件。
1175 0
|
Python
告别阻塞,拥抱未来!Python 异步编程 asyncio 库实战指南!
高效处理并发任务对提升程序性能至关重要,Python 的 `asyncio` 库提供了强大的异步编程支持。通过 `async/await` 关键字,可以在等待操作完成时不阻塞程序执行,显著提高效率和响应性。`asyncio` 支持定义异步函数、创建任务、等待多个任务完成等功能,并能结合第三方库如 `aiohttp` 实现异步网络请求。此外,它还支持异常处理,确保异步代码的健壮性。借助 `asyncio`,您可以轻松构建高性能、响应迅速的应用程序。
464 0
|
11月前
|
存储 监控 数据可视化
设计行业如何借助协作软件提升团队协作力?
随着设计项目规模和复杂性的增加,设计行业越来越依赖协作软件来提高工作效率、加强团队协作、支持远程办公、实现文件版本控制等,确保项目高效推进。协作软件不仅优化了设计流程,还促进了创意交流和团队创新。
|
机器学习/深度学习 人工智能 芯片
一文详解多模态大模型发展及高频因子计算加速GPU算力 | 英伟达显卡被限,华为如何力挽狂澜?
近年来,全球范围内的芯片禁令不断升级,给许多企业和科研机构带来了很大的困扰,需要在技术层面进行创新和突破。一方面,可以探索使用国产芯片和其他不受限制的芯片来替代被禁用的芯片;另一方面,可以通过优化算法和架构等方法来降低对特定芯片的依赖程度。
1314 0
|
消息中间件 Java 开发工具
消息队列 MQ使用问题之如何使用DefaultMQPushConsumer来消费消息
消息队列(MQ)是一种用于异步通信和解耦的应用程序间消息传递的服务,广泛应用于分布式系统中。针对不同的MQ产品,如阿里云的RocketMQ、RabbitMQ等,它们在实现上述场景时可能会有不同的特性和优势,比如RocketMQ强调高吞吐量、低延迟和高可用性,适合大规模分布式系统;而RabbitMQ则以其灵活的路由规则和丰富的协议支持受到青睐。下面是一些常见的消息队列MQ产品的使用场景合集,这些场景涵盖了多种行业和业务需求。
|
Java
如何使用 Java 8 进行字符串排序?
【2月更文挑战第21天】
758 3
|
SQL 缓存 数据库
淘宝购物车扩容与性能优化(上)
淘宝购物车扩容与性能优化(上)
740 2
|
数据可视化 小程序 前端开发
【iVX】十五分钟制作一款小游戏,iVX真有怎么神?
【iVX】十五分钟制作一款小游戏,iVX真有怎么神?
481 0
|
JSON API 数据处理
使用Python调用API接口获取拼多多商品数据:一篇详细说明文章
拼多多是中国著名的电商平台之一,提供了丰富的商品信息和购物服务。为了更好地利用拼多多的数据资源,我们可以使用Python编程语言调用拼多多的API接口,获取商品数据并进行处理和分析。本文将详细介绍如何使用Python完成这一任务,包括API的基本概念、接口调用流程、代码实现和数据处理等方面的内容。