做Windows桌面游戏是少不了与键盘交互的,不过其实并非我们做Windows桌面应用才需要小游戏,如果要做安卓机顶盒APP,不也是得监听键盘嘛,只不过那是遥控器的键盘,其实也是一样的。要做键盘交互是根据监听的作用域大小来的,监听方式各不相同,是全局监听还是窗口监听?亦或是web页面常用的焦点监听?对于Jetpack Compose来说,用的大部分其实也是对于某些组件的焦点监听,下面来看一个推箱子小游戏的例子:
配置项目
首先是初始化项目,不同于之前我写的用Jetpack Compose Desktop极简配置做一个Windows桌面时间显示器从空白开始,这次就按官方的步骤来吧,首先在IDEA中New Project
,然后进行如下选择:
如图所示在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()
里面,用LazyRow
和LazyColumn
初始化出一个正方形网格,里面先塞个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)
)
}
}
}
}
}
}
}
然后就能看到这么个效果了:
这样就算是有个大致框架了,然后就是改下前面的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)
)
}
然后就能看到这样的效果:
可能你会感觉奇怪,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)
)
}
下面就完成布局了:
接下来就是加入键盘事件来控制人物移动了。
监听键盘事件
这个键盘移动,需要先给焦点,首先在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
里面,其实按一次键一般会激活KeyDown
和KeyUp
两个事件,这里需要判断一下,监听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()
}
}
总结
这个做起来还是相当简便呀,就一百行多一点就能做好了,要用什么Swing
、SWT
那是纯纯的噩梦,得一大堆代码。不过这个键盘绑定虽然需要聚焦很正常,但这个写法我还是稍微有些不满,稍显啰嗦,不过因为语法本身的原因也还好,比起原生java
确实看着清爽很多。就比预算好久之前觉得kotlin
没有三目表达式是不是有点不太好,现在看感觉这种if
和else
夹在什么位置都那么方便,读起来也更明了,确实也没必要用三目表达式了。可见语法简化后更具有可读性是很正常的,并非要写的很啰嗦很完整才行。