用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夹在什么位置都那么方便,读起来也更明了,确实也没必要用三目表达式了。可见语法简化后更具有可读性是很正常的,并非要写的很啰嗦很完整才行。

目录
相关文章
|
5天前
|
存储 缓存 编译器
探索 Jetpack Compose 内核:深入 SlotTable 系统
探索 Jetpack Compose 内核:深入 SlotTable 系统
84 1
|
5天前
|
IDE API 开发工具
Google I/O :Android Jetpack 最新变化(四)Compose
Google I/O :Android Jetpack 最新变化(四)Compose
121 0
|
5天前
|
前端开发 API Android开发
Jetpack Compose 实现波浪加载效果
Jetpack Compose 实现波浪加载效果
79 0
|
3天前
|
API Kotlin Python
Jetpack Compose for Desktop实现复杂的自动布局网格,熬夜整理蚂蚁金服Python高级笔试题
Jetpack Compose for Desktop实现复杂的自动布局网格,熬夜整理蚂蚁金服Python高级笔试题
|
5天前
|
XML API Android开发
构建高效的安卓应用:使用Jetpack Compose实现动态UI
【4月更文挑战第13天】 在移动应用开发领域,随着用户对流畅体验和即时反馈的期待不断上升,开发者面临着构建高效、响应式且具有丰富交互性的用户界面的挑战。传统的Android开发方法,如基于XML的布局,虽然稳定但往往伴随着较高的资源消耗和较低的开发效率。本文将探讨如何使用Jetpack Compose——一种现代声明式UI工具包,来构建动态且高效的安卓应用界面。通过深入分析Jetpack Compose的核心原理及其与传统方法的对比,揭示如何利用其强大的功能集合提升应用性能和开发效率。我们将通过实例演示如何快速构建可重用组件、实现实时数据绑定,以及优化布局渲染过程,从而为开发者提供一种更简洁、
|
5天前
|
XML 移动开发 Android开发
构建高效安卓应用:采用Jetpack Compose实现动态UI
【4月更文挑战第10天】 在现代移动开发中,用户界面的流畅性和响应性对于应用的成功至关重要。随着技术的不断进步,安卓开发者寻求更加高效和简洁的方式来构建动态且吸引人的UI。本文将深入探讨Jetpack Compose这一革新性技术,它通过声明式编程模型简化了UI构建过程,并提升了性能与跨平台开发的可行性。我们将从基本概念出发,逐步解析如何利用Jetpack Compose来创建具有数据动态绑定能力的安卓应用,同时确保应用的高性能和良好用户体验。
22 0
|
5天前
|
XML 开发工具 Android开发
构建高效的安卓应用:使用Jetpack Compose优化UI开发
【4月更文挑战第7天】 随着Android开发不断进化,开发者面临着提高应用性能与简化UI构建流程的双重挑战。本文将探讨如何使用Jetpack Compose这一现代UI工具包来优化安卓应用的开发流程,并提升用户界面的流畅性与一致性。通过介绍Jetpack Compose的核心概念、与传统方法的区别以及实际集成步骤,我们旨在提供一种高效且可靠的解决方案,以帮助开发者构建响应迅速且用户体验优良的安卓应用。
|
5天前
|
XML API Android开发
【Android 从入门到出门】第三章:使用Hilt处理Jetpack Compose UI状态
【Android 从入门到出门】第三章:使用Hilt处理Jetpack Compose UI状态
38 4
|
5天前
|
安全 API 开发工具
一文看懂 Jetpack Compose 快照系统
一文看懂 Jetpack Compose 快照系统
52 0
|
5天前
|
Android开发 开发者
Twitter 宣布将全面拥抱 Jetpack Compose
Twitter 宣布将全面拥抱 Jetpack Compose
47 0