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

做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)

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

  1. 推箱子的人,这是个单个元素,都是一个人推箱子嘛,当然要做联机就得改了。
  2. 箱子本体,这是个集合,一般都要推多个箱子吧?
  3. 箱子的目的地,也是集合,要和箱子本体的数量保持一致。少了过不了关,多了很奇怪吧。
  4. 墙面,没有墙那不就相当于随便推了吗?

所以我们依次设计下面4个核心变量,并设为容器对象:

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

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

然后想想,这起码得有个大小吧,这里没必要分开设宽高,暂时做个8×8大小的正方形就行了,可以省事只写一个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夹在什么位置都那么方便,读起来也更明了,确实也没必要用三目表达式了。可见语法简化后更具有可读性是很正常的,并非要写的很啰嗦很完整才行。

© 版权声明
THE END
喜欢就支持一下吧
点赞0

Warning: mysqli_query(): (HY000/3): Error writing file '/tmp/MY364Yun' (Errcode: 28 - No space left on device) in /www/wwwroot/583.cn/wp-includes/class-wpdb.php on line 2345
admin的头像-五八三
评论 抢沙发
头像
欢迎您留下宝贵的见解!
提交
头像

昵称

图形验证码
取消
昵称代码图片