用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()

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

目录
相关文章
|
1月前
|
存储 缓存 Android开发
安卓Jetpack Compose+Kotlin, 使用ExoPlayer播放多个【远程url】音频,搭配Okhttp库进行下载和缓存,播放完随机播放下一首
这是一个Kotlin项目,使用Jetpack Compose和ExoPlayer框架开发Android应用,功能是播放远程URL音频列表。应用会检查本地缓存,如果文件存在且大小与远程文件一致则使用缓存,否则下载文件并播放。播放完成后或遇到异常,会随机播放下一首音频,并在播放前随机设置播放速度(0.9到1.2倍速)。代码包括ViewModel,负责音频管理和播放逻辑,以及UI层,包含播放和停止按钮。
126 0
|
1月前
|
存储 数据库 Android开发
安卓Jetpack Compose+Kotlin,支持从本地添加音频文件到播放列表,支持删除,使用ExoPlayer播放音乐
为了在UI界面添加用于添加和删除本地音乐文件的按钮,以及相关的播放功能,你需要实现以下几个步骤: 1. **集成用户选择本地音乐**:允许用户从设备中选择音乐文件。 2. **创建UI按钮**:在界面中创建添加和删除按钮。 3. **数据库功能**:使用Room数据库来存储音频文件信息。 4. **更新ViewModel**:处理添加、删除和播放音频文件的逻辑。 5. **UI实现**:在UI层支持添加、删除音乐以及播放功能。
102 1
|
28天前
|
安全 JavaScript 前端开发
kotlin开发安卓app,JetPack Compose框架,给webview新增一个按钮,点击刷新网页
在Kotlin中开发Android应用,使用Jetpack Compose框架时,可以通过添加一个按钮到TopAppBar来实现WebView页面的刷新功能。按钮位于右上角,点击后调用`webViewState?.reload()`来刷新网页内容。以下是代码摘要:
|
29天前
|
JavaScript Java Android开发
kotlin安卓在Jetpack Compose 框架下跨组件通讯EventBus
**EventBus** 是一个Android事件总线库,简化组件间通信。要使用它,首先在Gradle中添加依赖`implementation 'org.greenrobot:eventbus:3.3.1'`。然后,可选地定义事件类如`MessageEvent`。在活动或Fragment的`onCreate`中注册订阅者,在`onDestroy`中反注册。通过`@Subscribe`注解方法处理事件,如`onMessageEvent`。发送事件使用`EventBus.getDefault().post()`。
|
29天前
|
JavaScript 前端开发 Android开发
kotlin安卓在Jetpack Compose 框架下使用webview , 网页中的JavaScript代码如何与native交互
在Jetpack Compose中使用Kotlin创建Webview组件,设置JavaScript交互:`@Composable`函数`ComposableWebView`加载网页并启用JavaScript。通过`addJavascriptInterface`添加`WebAppInterface`类,允许JavaScript调用Android方法如播放音频。当页面加载完成时,执行`onWebViewReady`回调。
|
1月前
|
监控 Android开发 数据安全/隐私保护
安卓kotlin JetPack Compose 实现摄像头监控画面变化并录制视频
在这个示例中,开发者正在使用Kotlin和Jetpack Compose构建一个Android应用程序,该程序 能够通过手机后置主摄像头录制视频、检测画面差异、实时预览并将视频上传至FTP服务器的Android应用
|
17天前
深入了解 Jetpack Compose 中的 Modifier
深入了解 Jetpack Compose 中的 Modifier
9 0
|
17天前
|
Android开发
Jetpack Compose: Hello Android
Jetpack Compose: Hello Android
12 0
|
1月前
|
安全 网络安全 API
kotlin安卓开发JetPack Compose 如何使用webview 打开网页时给webview注入cookie
在Jetpack Compose中使用WebView需借助AndroidView。要注入Cookie,首先在`build.gradle`添加WebView依赖,如`androidx.webkit:webkit:1.4.0`。接着创建自定义`ComposableWebView`,通过`CookieManager`设置接受第三方Cookie并注入Cookie字符串。最后在Compose界面使用这个自定义组件加载URL。注意Android 9及以上版本可能需要在网络安全配置中允许第三方Cookie。
166 0
|
1月前
|
Android开发 Kotlin
kotlin安卓开发【Jetpack Compose】:封装SnackBarUtil工具类方便使用
GPT-4o 是一个非常智能的模型,比当前的通义千问最新版本在能力上有显著提升。作者让GPT开发一段代码,功能为在 Kotlin 中使用 Jetpack Compose 框架封装一个 Snackbar 工具类,方便调用