用Jetpack Compose Desktop做一个推箱子小游戏,演示键盘事件绑定的方式

2023年 7月 17日 107.7k 0

做Windows桌面游戏是少不了与键盘交互的,不过其实并非我们做Windows桌面应用才需要小游戏,如果要做安卓机顶盒APP,不也是得监听键盘嘛,只不过那是遥控器的键盘,其实也是一样的。要做键盘交互是根据监听的作用域大小来的,监听方式各不相同,是全局监听还是窗口监听?亦或是web页面常用的焦点监听?对于Jetpack Compose来说,用的大部分其实也是对于某些组件的焦点监听,下面来看一个推箱子小游戏的例子:

配置项目

首先是初始化项目,不同于之前我写的用Jetpack Compose Desktop极简配置做一个Windows桌面时间显示器从空白开始,这次就按官方的步骤来吧,首先在IDEA中New Project,然后进行如下选择:

image.png

如图所示在Configuration在Switch滑块中选择Single platform,然后下面的Platform下拉选择Desktop,JDK可以参考官方文档Github JetBrains/compose-multiplatform,因为Skia的内存方案,所以最低要用JDK11。如果要本地打包发行版的话,那么因为jpackage的限制最低需要JDK17

这样的话,build.gradle.kts就不需要任何的修改,直接开始写代码吧。

参数设定

首先还是得有个对象来确定一个点位的元素,最基本的莫过于坐标位置:

data class Article(val x: Int, val y: Int)

然后想想推箱子这个界面,有哪几个重要的基本元素?

  • 推箱子的人,这是个单个元素,都是一个人推箱子嘛,当然要做联机就得改了。
  • 箱子本体,这是个集合,一般都要推多个箱子吧?
  • 箱子的目的地,也是集合,要和箱子本体的数量保持一致。少了过不了关,多了很奇怪吧。
  • 墙面,没有墙那不就相当于随便推了吗?
  • 所以我们依次设计下面4个核心变量,并设为容器对象:

    var player by mutableStateOf()
    val boxes = mutableStateListOf()
    val stars = mutableStateListOf()
    val wall = mutableStateListOf()
    

    因为是集合呢,所以用mutableStateListOf(),他们是不能by的,直接=即可,因为他们返回的对象不是什么ArrayList这种东西,而是特制的SnapshotStateList,所以也是能监听到变化的,不用去get()set()

    然后想想,这起码得有个大小吧,这里没必要分开设宽高,暂时做个8x8大小的正方形就行了,可以省事只写一个size变量:

    const val size by mutableStateOf(8)
    

    这些变量都是全局都要用的,所以放到app()方法上面就行,以后别的方法可能要用,所以没必要app()放方法里面

    初始化窗口布局

    先设置一下main()方法里面Window的属性:

    Window(
        title = "推箱子",
        state = rememberWindowState(
            //程序居中弹出
            position = WindowPosition(Alignment.Center),
            size = DpSize(500.dp, 500.dp)
        ),
        //禁止调大小,应该固定大小
        resizable = false,
        onCloseRequest = ::exitApplication
    ) {
        app()
    }
    

    然后就是去app()里面,用LazyRowLazyColumn初始化出一个正方形网格,里面先塞个Icon垫一垫:

    @OptIn(ExperimentalComposeUiApi::class)
    @Preview
    @Composable
    fun app() {
        MaterialTheme {
            Box(
                modifier = Modifier.fillMaxSize(),
                contentAlignment = Alignment.Center
            ) {
                //因为往后里面会有好多不同的Icon,但大小边距都会一样的,所以先设置在这
                val basic = Modifier.padding(0.5.dp).size(50.dp)
                LazyRow {
                    items(size) { x ->
                        LazyColumn {
                            items(size) { y ->
                                Icon(
                                    imageVector = Icons.Default.Done,
                                    contentDescription = null,
                                    modifier = basic.background(Color.Blue)
                                )
                            }
                        }
                    }
                }
            }
        }
    }
    

    然后就能看到这么个效果了:
    image.png

    这样就算是有个大致框架了,然后就是改下前面的4个主要参数,设好具体坐标值,准备填充:

    var player by mutableStateOf(Article(4, 4))
    val boxes = mutableStateListOf(
        Article(3, 3), Article(3, 4),
        Article(4, 5), Article(5, 3)
    )
    val stars = mutableStateListOf(
        Article(1, 4), Article(3, 1),
        Article(4, 6), Article(6, 3)
    )
    val wall = mutableStateListOf(
        Article(2, 1), Article(2, 2), Article(1, 3), Article(2, 3),
        Article(4, 1), Article(4, 2), Article(5, 2), Article(6, 2),
        Article(1, 5), Article(2, 5), Article(3, 5), Article(3, 6),
        Article(5, 4), Article(5, 5), Article(5, 6), Article(6, 4)
    )
    

    然后把Icon的部分改一改,要根据条件来嘛,这里都用Icon做了,图标数量少找不到什么合适的图标,如果想做更精细一些的图标,可以导入文件,也可以直接在这里画想画好看一点的图案,可以参考我之前的文章Jetpack compose使用ImageVector绘制自定义图标:

    when (Article(x, y)) {
        player -> Icon(
            imageVector = Icons.Default.AccountBox,
            contentDescription = null,
            modifier = basic.background(Color.Blue)
        )
        in stars -> Icon(
            imageVector = Icons.Default.Star,
            contentDescription = null,
            modifier = basic.background(Color.Green)
        )
        in boxes -> Icon(
            imageVector = Icons.Default.Done,
            contentDescription = null,
            tint = Color.Yellow,
            modifier = basic.background(Color.Yellow)
        )
        in wall -> Icon(
            imageVector = Icons.Default.Done,
            contentDescription = null,
            tint = Color.DarkGray,
            modifier = basic.background(Color.DarkGray)
        )
        else -> Icon(
            imageVector = Icons.Default.Done,
            contentDescription = null,
            tint = Color.LightGray,
            modifier = basic.background(Color.LightGray)
        )
    }
    

    然后就能看到这样的效果:
    image.png

    可能你会感觉奇怪,wall集合是不是少填充了一些墙,边上的不也应该贴上么,其实这是我预想好的,不用往集合塞那么多东西,直接固定填好边缘的一层就行了,所以需要改一改wall的判断,那么先做一个isWall()的判断:

    fun isWall(article: Article): Boolean {
        val x = article.x
        val y = article.y
        return x == 0 || y == 0 || x == size - 1 || y == size - 1 || wall.contains(article)
    }
    

    然后就可以改Icon的部分了,但有判断了就不好用上面那个when(Article(x, y))的方式了,要改成when{},这里可以再改一个地方,就是当箱子到达目标点后,换个形状:

    val current = Article(x, y)
    when {
        current == player -> Icon(
            imageVector = Icons.Default.AccountBox,
            contentDescription = null,
            modifier = basic.background(Color.Blue)
        )
        current in stars -> Icon(
            imageVector = if (boxes.contains(current)) Icons.Default.CheckCircle else Icons.Default.Star,
            contentDescription = null,
            modifier = basic.background(Color.Green)
        )
        current in boxes -> Icon(
            imageVector = Icons.Default.Done,
            contentDescription = null,
            tint = Color.Yellow,
            modifier = basic.background(Color.Yellow)
        )
        isWall(current) -> Icon(
            imageVector = Icons.Default.Done,
            contentDescription = null,
            tint = Color.DarkGray,
            modifier = basic.background(Color.DarkGray)
        )
        else -> Icon(
            imageVector = Icons.Default.Done,
            contentDescription = null,
            tint = Color.LightGray,
            modifier = basic.background(Color.LightGray)
        )
    }
    

    下面就完成布局了:

    image.png

    接下来就是加入键盘事件来控制人物移动了。

    监听键盘事件

    这个键盘移动,需要先给焦点,首先在app()中定义一个FocusRequester

    val requester = remember { FocusRequester() }
    

    然后在Box尾部加入一个LaunchedEffect给焦点:

    LaunchedEffect(Unit) {
        requester.requestFocus()
    }
    

    最后再才能给Box加入onKeyEvent事件监听,并且还要加上focusable()给予焦点,请看Box的改动部分:

    Box(
        modifier = Modifier.fillMaxSize().onKeyEvent {
            ...
        }.focusRequester(requester).focusable(),
        contentAlignment = Alignment.Center
    )
    

    这样事件才能监听到,下一步就是监听后做什么动作,监听自然是写在上面的onKeyEvent里面,其实按一次键一般会激活KeyDownKeyUp两个事件,这里需要判断一下,监听KeyUp事件:

    if (it.type == KeyEventType.KeyUp) {
        return@onKeyEvent true
    }
    

    下面再监听具体的键进行具体的移动操作就好了。

    根据键位移动元素

    下面就监听WASD四个键用来移动,分别代表四个方向,直接写Key.xxx会报错,提示加入@OptIn(ExperimentalComposeUiApi::class)注解,这个直接按提示加上就行:

    when (it.key) {
        Key.W -> ...
        Key.S -> ...
        Key.A -> ...
        Key.D -> ...
    }
    return@onKeyEvent true
    

    移动如果一个一个写走法,那就太重复了,所以我这里做一个通用的方法:

    //这里的x和y都是相对的方向,如果向上就是x保持0,y值应该是-1。向右就是x为1,y值不变
    fun tryMove(x: Int, y: Int): Article {
        //target是目标方位
        val target = Article(player.x + x, player.y + y)
        //如果是墙面,直接返回即可
        if (wall.contains(target)) {
            return player
        }
        //判断前面是不是推到箱子了
        val findStarIndex = boxes.indexOf(target)
        if (findStarIndex != -1) {
            //如果是箱子,箱子也往前走的话就应该也给箱子找个位置,把相对位置计算过程再重复一下就好了
            val startTarget = Article(target.x + x, target.y + y)
            //如果箱子的目标位置是墙那自然不给推,如果箱子前面还是箱子,那自然也不行,不能一口气推俩箱子
            if (isWall(startTarget) || boxes.contains(startTarget)) {
                return player
            }
            //如果可以推就直接改目标箱子的位置
            boxes[findStarIndex] = startTarget
        }
        //寻找箱子集合和箱子目标点集合的差集,如果差集为空,说明全走到位置了
        if (boxes.subtract(stars).isEmpty()) {
            //如果完成了,就把界面缩小当做过关
            size = 1
            boxes += Article(0, 0)
            stars += Article(0, 0)
        }
        return target
    }
    

    然后改下前面的监听,写好具体对应的相对位置:

    when (it.key) {
        Key.W -> player = tryMove(0, -1)
        Key.S -> player = tryMove(0, 1)
        Key.A -> player = tryMove(-1, 0)
        Key.D -> player = tryMove(1, 0)
    }
    

    如果觉得这个赋值有点重复也可以改成这样的写法:

    player = when (it.key) {
        Key.W -> tryMove(0, -1)
        Key.S -> tryMove(0, 1)
        Key.A -> tryMove(-1, 0)
        Key.D -> tryMove(1, 0)
        else -> player
    }
    

    只是要多写个else,都是一样的啦,这样就大功告成了,可以试一试,反应很灵敏。

    综合源代码

    import androidx.compose.desktop.ui.tooling.preview.Preview
    import androidx.compose.foundation.background
    import androidx.compose.foundation.focusable
    import androidx.compose.foundation.layout.*
    import androidx.compose.foundation.lazy.LazyColumn
    import androidx.compose.foundation.lazy.LazyRow
    import androidx.compose.material.*
    import androidx.compose.material.icons.Icons
    import androidx.compose.material.icons.filled.*
    import androidx.compose.runtime.*
    import androidx.compose.ui.Alignment
    import androidx.compose.ui.ExperimentalComposeUiApi
    import androidx.compose.ui.Modifier
    import androidx.compose.ui.focus.FocusRequester
    import androidx.compose.ui.focus.focusRequester
    import androidx.compose.ui.graphics.Color
    import androidx.compose.ui.input.key.*
    import androidx.compose.ui.unit.DpSize
    import androidx.compose.ui.unit.dp
    import androidx.compose.ui.window.Window
    import androidx.compose.ui.window.WindowPosition
    import androidx.compose.ui.window.application
    import androidx.compose.ui.window.rememberWindowState
    
    data class Article(val x: Int, val y: Int)
    
    var size by mutableStateOf(8)
    var player by mutableStateOf(Article(4, 4))
    val boxes = mutableStateListOf(
        Article(3, 3), Article(3, 4),
        Article(4, 5), Article(5, 3)
    )
    val stars = mutableStateListOf(
        Article(1, 4), Article(3, 1),
        Article(4, 6), Article(6, 3)
    )
    val wall = mutableStateListOf(
        Article(2, 1), Article(2, 2), Article(1, 3), Article(2, 3),
        Article(4, 1), Article(4, 2), Article(5, 2), Article(6, 2),
        Article(1, 5), Article(2, 5), Article(3, 5), Article(3, 6),
        Article(5, 4), Article(5, 5), Article(5, 6), Article(6, 4)
    )
    
    fun isWall(article: Article): Boolean {
        val x = article.x
        val y = article.y
        return x == 0 || y == 0 || x == size - 1 || y == size - 1 || wall.contains(article)
    }
    
    fun tryMove(x: Int, y: Int): Article {
        val target = Article(player.x + x, player.y + y)
        if (wall.contains(target)) {
            return player
        }
        val findStarIndex = boxes.indexOf(target)
        if (findStarIndex != -1) {
            val startTarget = Article(target.x + x, target.y + y)
            if (isWall(startTarget) || boxes.contains(startTarget)) {
                return player
            }
            boxes[findStarIndex] = startTarget
        }
        if (boxes.subtract(stars).isEmpty()) {
            size = 1
            boxes += Article(0, 0)
            stars += Article(0, 0)
        }
        return target
    }
    
    @OptIn(ExperimentalComposeUiApi::class)
    @Preview
    @Composable
    fun app() {
        val requester = remember { FocusRequester() }
        MaterialTheme {
            Box(
                modifier = Modifier.fillMaxSize().onKeyEvent {
                    if (it.type == KeyEventType.KeyUp) {
                        return@onKeyEvent true
                    }
                    when (it.key) {
                        Key.W -> player = tryMove(0, -1)
                        Key.S -> player = tryMove(0, 1)
                        Key.A -> player = tryMove(-1, 0)
                        Key.D -> player = tryMove(1, 0)
                    }
                    return@onKeyEvent true
                }.focusRequester(requester).focusable(),
                contentAlignment = Alignment.Center
            ) { game() }
            LaunchedEffect(Unit) {
                requester.requestFocus()
            }
        }
    }
    
    @Preview
    @Composable
    fun game() {
        val basic = Modifier.padding(0.5.dp).size(50.dp)
        LazyRow {
            items(size) { x ->
                LazyColumn {
                    items(size) { y ->
                        val current = Article(x, y)
                        when {
                            current == player -> Icon(
                                imageVector = Icons.Default.AccountBox,
                                contentDescription = null,
                                modifier = basic.background(Color.Blue)
                            )
                            current in stars -> Icon(
                                imageVector = if (boxes.contains(current)) Icons.Default.CheckCircle else Icons.Default.Star,
                                contentDescription = null,
                                modifier = basic.background(Color.Green)
                            )
                            current in boxes -> Icon(
                                imageVector = Icons.Default.Done,
                                contentDescription = null,
                                tint = Color.Yellow,
                                modifier = basic.background(Color.Yellow)
                            )
                            isWall(current) -> Icon(
                                imageVector = Icons.Default.Done,
                                contentDescription = null,
                                tint = Color.DarkGray,
                                modifier = basic.background(Color.DarkGray)
                            )
                            else -> Icon(
                                imageVector = Icons.Default.Done,
                                contentDescription = null,
                                tint = Color.LightGray,
                                modifier = basic.background(Color.LightGray)
                            )
                        }
                    }
                }
            }
        }
    }
    
    fun main() = application {
        Window(
            title = "推箱子",
            state = rememberWindowState(
                position = WindowPosition(Alignment.Center),
                size = DpSize(500.dp, 500.dp)
            ),
            resizable = false,
            onCloseRequest = ::exitApplication
        ) {
            app()
        }
    }
    

    总结

    这个做起来还是相当简便呀,就一百行多一点就能做好了,要用什么SwingSWT那是纯纯的噩梦,得一大堆代码。不过这个键盘绑定虽然需要聚焦很正常,但这个写法我还是稍微有些不满,稍显啰嗦,不过因为语法本身的原因也还好,比起原生java确实看着清爽很多。就比预算好久之前觉得kotlin没有三目表达式是不是有点不太好,现在看感觉这种ifelse夹在什么位置都那么方便,读起来也更明了,确实也没必要用三目表达式了。可见语法简化后更具有可读性是很正常的,并非要写的很啰嗦很完整才行。

    相关文章

    JavaScript2024新功能:Object.groupBy、正则表达式v标志
    PHP trim 函数对多字节字符的使用和限制
    新函数 json_validate() 、randomizer 类扩展…20 个PHP 8.3 新特性全面解析
    使用HTMX为WordPress增效:如何在不使用复杂框架的情况下增强平台功能
    为React 19做准备:WordPress 6.6用户指南
    如何删除WordPress中的所有评论

    发布评论