前段时间看了TechMerger大佬写的《一气呵成:用Compose完美复刻Flappy Bird!》,甚是有趣,按耐不住那躁动的心,笔者决定跟随大佬的脚步通过写游戏的方式学习Jetpack Compose,Let’s Go!
在这里也强推下fundroid大佬的《用Jetpack Compose做一个俄罗斯方块游戏机》《100 行写一个 Compose 版华容道》,十分精彩。
多看看大佬们的博文,受益匪浅,感谢分享。
《经典飞机大战》是腾讯交流软件微信5.0版本在2013年8月推出的软件内置经典小游戏,现在已经找不到了,但是有其它复刻的小游戏作为参照,本文主要介绍Jetpack Compose Api的一些使用方法,供大家参考。
玩家点击并移动自己的飞机,在躲避迎面而来的其它敌机时,飞机通过发射子弹打掉其它敌机来赢取分数。一旦撞上其它敌机,游戏就结束。感兴趣的小伙伴,微信小程序搜索飞机大战就可以直接玩。
或者Github下载源码导入安装体验:https://github.com/xiangang/AndroidDevelopmentPractices/tree/master/ComposePlane
游戏主要由以下元素组成:
舞台背景,玩家飞机,子弹,音效(子弹射击音效,爆炸音效),敌机(小、中、大三种类型),动画(玩家飞机出场动画和爆炸动画,敌机爆炸),道具奖励,分数,游戏控制(开始,暂停,恢复,重开,退出)等。
玩家飞机,可以手指任意拖拽移动,发射子弹,并且有飞行动画,被敌机碰撞后会爆炸,每击落一个敌机即可获取对应份数,游戏过程中可通过碰撞获取子弹和爆炸道具奖励。
子弹从玩家飞机头部处不断出现,沿Y轴负方向以一定的速度移动,但不能沿着X轴水平移动,击中敌机后会消耗敌机的生命值,同时子弹消失。
子弹分红色单发子弹和蓝色双发子弹两种类型,击打敌机的能力(每次敌机消耗的敌机生命值)不同,大小也不同(影响碰撞检测)。
敌机随机在屏幕上方出生,沿着Y轴正方向向下运动,但不能沿着X轴水平移动,也不会发射子弹。敌机分侦察机(小)、战斗机(中)、战舰(大)三种类型,飞行速度,抗击大能力各不相同。目前设计了三个难度,随着难度的升级,敌机的数量也会不断增多。
游戏过程中,随着游戏难度的增加,会随机生成道具奖励,提高玩家飞机的生存能力。道具奖励只有两种:子弹和炸弹。
游戏开始界面,显示Logo,玩家飞机,开始游戏按钮。
游戏中界面,左上角可暂停继续游戏,右上角显示分数,左下角显示炸弹道具,点击可引爆屏幕内所有敌机。
游戏结束界面,显示分数,重新开始和退出游戏按钮。
游戏素材来自于:
https://github.com/iSpring/GamePlane/
https://github.com/zhangphil/Android-WeiXinDaFeiJi
为了使本文更易于理解,会额外补充一些说明,不感兴趣建议跳过。
既然是做游戏开发,还是得先学习下游戏开发的基本概念,建议阅读《游戏开发基本概念》。Sprites是个用于角色、道具、炮弹以及其他2D游戏元素的二维图形对象。在2D游戏中,图像部分主要是图片的处理,图片通常称为精灵(Sprite)。
精灵(Sprite) 对象需要可以被控制,可以在屏幕上移动,看下图的Android屏幕坐标系:
关于Android屏幕坐标系更多知识点,可以参考AWeiLoveAndroid的《Android应用坐标系统全面详解》
说白了,要使精灵(Sprite) 对象移动起来,就是要感知时间流逝,控制其坐标(x、y)发生变化。既然是对象,那就需要一个精灵(Sprite) 类。
/**
* 精灵基类
*/
@InternalCoroutinesApi
open class Sprite(
open var id: Long = System.currentTimeMillis(), //id
open var name: String = "精灵之父", //名称
open var type: Int = 0, //类型
@DrawableRes open val drawableIds: List<Int> = listOf(
R.drawable.sprite_player_plane_1,
R.drawable.sprite_player_plane_2
),//资源图标
@DrawableRes open val bombDrawableId: Int = R.drawable.sprite_explosion_seq, //敌机爆炸帧动画资源
open var segment: Int = 14, //爆炸效果由segment个片段组成:玩家飞机是4,小飞机是3,中飞机是4大飞机是6,explosion是14
open var x: Int = 0, //实时x轴坐标
open var y: Int = 0, //实时y轴坐标
open var startX: Int = -100, //出现的起始位置
open var startY: Int = -100, //出现的起始位置
open var width: Dp = BULLET_SPRITE_WIDTH.dp, //宽
open var height: Dp = BULLET_SPRITE_HEIGHT.dp, //高
open var speed: Int = 500, //飞行速度(弃用)
open var velocity: Int = 40, //飞行速度(每帧移动的像素)
open var state: SpriteState = SpriteState.LIFE, //控制是否显示
open var init: Boolean = false, //是否初始化,主要用于精灵初始化起点x,y坐标等,这里为什么不用state控制?state用于否显示,init用于重新初始化数据,而且必须是精灵离开屏幕后(走完整个移动的周期)才能重新初始化,否则精灵死亡后的复用时机不好掌握(当然不一定要这么做)。
) {
fun isAlive() = state == SpriteState.LIFE
fun isDead() = state == SpriteState.DEATH
open fun reBirth() {
state = SpriteState.LIFE
}
open fun die() {
state = SpriteState.DEATH
}
override fun toString(): String {
return "Sprite(id=$id, name='$name', drawableIds=$drawableIds, bombDrawableId=$bombDrawableId, segment=$segment, x=$x, y=$y, width=$width, height=$height, speed=$speed, state=$state)"
}
}
有了精灵(Sprite) 类,面向对象编程,我们只要控制精灵(Sprite) 对象的x、y属性即可控制**精灵(Sprite)**对象产生位移。
阅读到此,需要具备Jetpack Compose的基础,建议阅读官方文档《Compose 编程思想》,结合fundroid大佬的Jetpack Compose系列教程更佳。
在Jetpack Compose UI体系中,通过Modifier.offset { IntOffset(x, y) }
传参给可组合函数的方式,实现View在Android屏幕坐标系上的相对于原点(0,0)的偏移量。
关于Modifier的介绍,见官方文档《Modifier》
关于Modifier的使用,见官方文档《Compose 修饰符列表》
除了控制精灵(Sprite) 对象的x、y属性,前面还提到了,要感知时间的流逝。
那怎么感知?大佬们的做法是通过LaunchedEffect启动一个定时任务,定期发送一个更新视图的动作AutoTick。
当 LaunchedEffect 进入组合时,它会启动一个协程,并将代码块作为参数传递。如果 LaunchedEffect 退出组合,协程将取消。如下代码,通过协程死循环执行100s的延迟任务。
//绘制
setContent {
ComposePlaneTheme {
// A surface container using the 'background' color from the theme
Surface(color = MaterialTheme.colors.background) {
//利用协程定时执行
LaunchedEffect(key1 = Unit) {
while (isActive) {
delay(100)
//TODO auto tick,to do something
}
}
Stage(gameViewModel, onGameAction)
}
}
}
这样,就可以在组合函数中不断的修改精灵(Sprite) 对象的x、y属性,看起来精灵(Sprite) 对象就是在不断运动了。
LaunchedEffect:在某个可组合项的作用域内运行挂起函数,介绍见《Compose 中的附带效应》
然而,笔者一开始不是使用这种AutoTick的方式,而是纯粹的使用Jetpack Compose的重复动画(本质上跟LaunchedEffect AutoTick方式没什么区别,最低级别的动画 API:**TargetBasedAnimation **也是用LaunchedEffect实现的),走了一些弯路,后面转而使用AutoTick实现发现的确很好用,不过为了展现不同的思路,于是部分逻辑又改成使用动画来实现,但是看效果每次动画结束重新开始的瞬间有感觉都明显的顿挫感,这个问题暂时没解决。
状态:可以简单理解为随时间变化的任何值。
对于精灵(Sprite) 对象而言,我们需要更新其x、y属性(状态)并驱动界面中元素进行重新绘制,从而使View发生位移。
由于 Compose 是声明式工具集,因此更新它的唯一方法是通过新参数调用同一可组合函数。这些参数是界面状态的表现形式。每当状态更新时,都会发生重组。可组合函数必须明确获知新状态,才能相应地进行更新。如下图:
重新绘制界面元素,需要更新状态并使用新数据调用可组合函数,完成重组过程。但可组合函数本质就是一个函数,那就不能够在可组合函数里声明局部变量来管理状态,那应该怎么管理?
可组合函数使用 remember存储单个对象。系统会在初始组合期间将由 remember 计算的值存储在组合中,并在重组期间返回存储的值。remember 既可用于存储可变对象,又可用于存储不可变对象。简单的说,使用remember 可以在可组合函数中保存和读取状态的最新值。
但使用remember 也仅能保存和读取状态的最新值,我们的目的是状态发生改变时自动驱动重组。
使用mutableStateOf 创建可观察的 MutableState,后者是与 Compose 运行时集成的可观察类型,这样一来就可以观察状态到状态的变化,从而驱动可组合函数重组,进而重新绘制界面元素。
示例代码如下:
@Composable
fun LowComposable() {
Column(modifier = Modifier.padding(16.dp)) {
var name by remember { mutableStateOf("") }
if (name.isNotEmpty()) {
Text(
text = "Hello, $name!",
modifier = Modifier.padding(bottom = 8.dp),
style = MaterialTheme.typography.h5
)
}
OutlinedTextField(
value = name,
onValueChange = { name = it },
label = { Text("Name") }
)
}
}
name 如有任何更改,系统会安排重组读取name 的所有可组合函数。remember 和mutableStateOf 缺一不可,想一想,如果少了其中一个,现象是怎样的?
Jetpack Compose 支持其他可观察类型,如:LiveData,Flow,RxJava2等。在 Jetpack Compose 中读取其他可观察类型之前,必须将其转换为 State,以便 Jetpack Compose 可以在状态发生变化时自动重组界面,具体使用方法等下会上代码。
以下这段很重要,笔者就踩了这个坑。
注意:在 Compose 中将可变对象(如 ArrayList 或 mutableListOf())用作状态会导致用户在您的应用中看到不正确或陈旧的数据。
不可观察的可变对象(如 ArrayList 或可变数据类)不能由 Compose 观察,因而 Compose 不能在它们发生变化时触发重组。
我们建议您使用可观察的数据存储器(如 State)和不可变的 listOf(),而不是使用不可观察的可变对象。
上面的示例代码,状态是定义在可组合函数内部的。这样的方式优点是不依赖于外部,可独立使用。缺点是外部无法更改这个可组合函数内部的状态,难以跟其它可组合函数联动,这样一来,复用性就降低了。好的架构,应该是高复用的。那有什么办法可以解决这个问题?
使用状态提升。既然可组合函数内部的状态,不能被外部修改,那就把状态从内部移到外部即可。Jetpack Compose 中的常规状态提升模式是将状态变量替换为两个参数:一个是状态值,一个是状态修改函数。
具体见官方文档《状态提升》
示例代码:
//状态提升前
@Composable
fun LowComposable() {
Column(modifier = Modifier.padding(16.dp)) {
var name by remember { mutableStateOf("") }
if (name.isNotEmpty()) {
Text(
text = "Hello, $name!",
modifier = Modifier.padding(bottom = 8.dp),
style = MaterialTheme.typography.h5
)
}
OutlinedTextField(
value = name,
onValueChange = { name = it },
label = { Text("Name") }
)
}
}
//状态提升后
//LowComposable的状态提升到了HighComposable,再通过参数形式从HighComposable下降到LowComposable,同时,状态的修改,也通过参数往下传递一个状态值修改函数,这样一来LowComposable可以读取状态值,也可以修改状态值,但状态的管理是HighComposable负责的。
@Composable
fun HighComposable() {
var name by rememberSaveable { mutableStateOf("") }
LowComposable(name = name, onNameChange = { name = it })
}
@Composable
fun LowComposable(name: String, onNameChange: (String) -> Unit) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = "Hello, $name",
modifier = Modifier.padding(bottom = 8.dp),
style = MaterialTheme.typography.h5
)
OutlinedTextField(
value = name,
onValueChange = onNameChange,
label = { Text("Name") }
)
}
}
如上图,状态管理从下层可组合函数提升到最低公共上层可组合函数,状态的值和状态更新函数从最低公共上层可组合函数传参给下层可组合函数,下层可组合函数直接读取状态值,状态更新还是由最低公共上层可组合函数来实现,下层可组合函数只负责传参调用状态更新函数(得益于Kotlin的语言特性,函数可以像参数一样传递,因此UI交互后可以直接调用传递过来的函数)。
将函数用作参数或返回值的介绍见《高阶函数与 lambda 表达式》
像这种状态提升后变成状态下降、事件上升的模式称为“单向数据流”。通过遵循单向数据流,统一由最低公共上层可组合函数管理状态,从而使下层可组合函数解耦,这意味着最低公共上层可组合函数的修改几乎不影响下层可组合函数,这样一来下层可组合函数即可高效复用。
这里比较啰嗦,笔者刚开始看官方文档的时候,状态又是提升又是下降的,很晕,这里试图讲清楚,不知道有没有弄巧成拙。
既然是Jetpack Compose怎么能少得了ViewModel?对于位于 Compose 界面树中较高位置的可组合项或作为 Navigation 库中目标的可组合项,Android官方建议使用 ViewModel 作为状态容器。
ViewModel 在配置更改后可以继续保持状态,在这里封装与界面相关的状态和事件是非常合适的,而且不必关心托管 Compose 代码的 activity 或 fragment 生命周期。
前面提到Jetpack Compose 支持其他可观察类型,如:LiveData,Flow,RxJava2等,在ViewModel这里就派上用场了。ViewModel 应在可观察的容器(如 LiveData 或 StateFlow)中公开状态。在组合期间读取状态对象时,组合的当前重组作用域会自动订阅该状态对象的更新。
在 Jetpack Compose 中使用 LiveData 和 ViewModel 实现单向数据流的示例使用如下所示的 ViewModel 实现:
@InternalCoroutinesApi
class GameViewModel(application: Application) : AndroidViewModel(application) {
/**
* 分数记录
*/
private val _gameScore = MutableLiveData(0)
val gameScore: LiveData<Int> = _gameScore
fun onGameScoreChange(score: Int) {
_gameScore.value = score
}
}
/**
* 舞台
*/
@InternalCoroutinesApi
@ExperimentalComposeUiApi
@ExperimentalAnimationApi
@Composable
fun Stage(gameViewModel: GameViewModel, onGameAction: OnGameAction = OnGameAction()) {
LogUtil.printLog(message = "Stage -------> ")
//状态提升到这里,介绍见官方文档:https://developer.android.google.cn/jetpack/compose/state#state-hoisting
//这里主要是方便统一管理,也避免直接使用ViewModel导致无法预览(预览时viewModel()会报错)
//获取游戏分数
val gameScore by gameViewModel.gameScore.observeAsState(0)
val modifier = Modifier.fillMaxSize()
Box(modifier = modifier
.run {
pointerInteropFilter {
when (it.action) {
MotionEvent.ACTION_DOWN -> {
LogUtil.printLog(message = "Stage ACTION_DOWN ")
}
MotionEvent.ACTION_MOVE -> {
LogUtil.printLog(message = "Stage ACTION_MOVE")
return@pointerInteropFilter false
}
MotionEvent.ACTION_CANCEL, Stage.ACTION_UP -> {
LogUtil.printLog(message = "GameScreen ACTION_CANCEL/UP")
return@pointerInteropFilter false
}
}
false
}
}) {
//得分
ComposeScore(gameScore)
}
}
@InternalCoroutinesApi
@ExperimentalComposeUiApi
@ExperimentalAnimationApi
@Preview()
@Composable
fun PreviewStage() {
val gameViewModel: GameViewModel = viewModel()
Stage(gameViewModel)
}
/**
* 得分
*/
@InternalCoroutinesApi
@Composable
fun ComposeScore(
gameScore: Int = 0,
) {
LogUtil.printLog(message = "ComposeScore()")
Row(
modifier = Modifier
.fillMaxWidth()
.padding(10.dp)
.absolutePadding(top = 20.dp)
) {
Text(
text = "score: $gameScore",
modifier = Modifier
.padding(start = 4.dp)
.align(Alignment.CenterVertically)
.wrapContentWidth(Alignment.End),
style = MaterialTheme.typography.h5,
color = Color.Black,
fontFamily = ScoreFontFamily
)
}
}
@InternalCoroutinesApi
@Preview()
@Composable
fun PreviewComposeScore() {
ComposeScore()
}
class MainActivity : ComponentActivity() {
@InternalCoroutinesApi
private val gameViewModel: GameViewModel by viewModels()
@InternalCoroutinesApi
@ExperimentalComposeUiApi
@ExperimentalAnimationApi
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
ComposePlaneTheme {
// A surface container using the 'background' color from the theme
Surface(color = MaterialTheme.colors.background) {
//利用协程定时执行任务
LaunchedEffect(key1 = Unit) {
while (isActive) {
delay(100)
var score = gameViewModel.gameScore.value
gameViewModel.onGameScoreChange(++score)
}
}
Stage(gameViewModel, onGameAction)
}
}
}
}
}
@InternalCoroutinesApi
@ExperimentalComposeUiApi
@ExperimentalAnimationApi
@Preview()
@Composable
fun PreviewStage() {
val gameViewModel: GameViewModel = viewModel()
Stage(gameViewModel)
}
可以看到,状态管理是在ViewModel进行的,遵循单向数据流,Compose只负责显示UI。这样一来,ViewModel和Compose可组合函数就都可以复用了。
建议先阅读fundroid大佬的《【Android】MVI架构快速入门:从双向绑定到单向数据流》《Jetpack Compose 架构比较:MVP & MVVM & MVI》,笔者也是第一次听说MVI架构。
有了以上铺垫,再来讲架构,就比较容易理解了,看下图。
分析下这个架构:
1.定义了一个GameViewMode用于管理游戏状态,使用MutableStateFlow作为可观察容器,GameViewMode的对象在Activity/Fragment中生成。
2.定义了一个GameAction用于更新游戏状态,包含start,pause等函数用于更新不同的状态值,GameAction的实现在GameViewMode中。
3.定义了一个Compose最低公共可组合函数Stage(游戏开发术语的中的概念:舞台),传入GameViewMode实例,通过collectAsState把GameViewMode中公开的StateFlow转换为 State,并将State(状态)下降到下层可组合函数Background等,并传递了GameAction对象实现Event(事件)上升。
4.其它下层可组合函数只负责观察State变化进行重绘和调用GameAction定义的Action函数即可。
5.这样一来,一个完整的单向数据流架构就完成了。
注意:由于代码还在不断迭代中,图中部分Compose和GameAction的函数可能未完整列出或名称上有所改动,实际以源码为准)
所有的精灵类如下:
游戏状态和动作定义:
/**
* 游戏状态
*/
enum class GameState {
Waiting, // wait to start
Running, // gaming
Paused, // pause
Dying, // hit enemy and dying
Over, // over
Exit // finish activity
}
/**
* 游戏动作
*/
@InternalCoroutinesApi
data class GameAction(
val start: () -> Unit = {}, //游戏状态进入Running,游戏中
val pause: () -> Unit = {},//游戏状态进入Paused,暂停
val reset: () -> Unit = {},//游戏状态进入Waiting,显示GameWaiting
val die: () -> Unit = {},//游戏状态进入Dying,触发爆炸动画
val over: () -> Unit = {},//游戏状态进入Over,显示GameOverBoard
val exit: () -> Unit = {},//退出游戏
val playerMove: (x: Int, y: Int) -> Unit = { _: Int, _: Int -> },//玩家移动
val score: (score: Int) -> Unit = { _: Int -> },//更新分数
val award: (award: Award) -> Unit = { _: Award -> },//获得奖励
val createBullet: () -> Unit = { },//子弹生成
val initBullet: (bullet: Bullet) -> Unit = { _: Bullet -> },//子弹初始化出生位置
val shooting: (resId: Int) -> Unit = { _: Int -> },//射击
val destroyAllEnemy: () -> Unit = {},//摧毁所有敌机
val levelUp: (score: Int) -> Unit = { _: Int -> },//难度升级
)
GameViewModel中定义的StateFlow和GameAction实现代码如下:
@InternalCoroutinesApi
class GameViewModel(application: Application) : AndroidViewModel(application) {
//id
val id = AtomicLong(0L)
/**
* 游戏状态StateFlow
*/
private val _gameStateFlow = MutableStateFlow(GameState.Waiting)
val gameStateFlow = _gameStateFlow.asStateFlow()
/**
* 玩家飞机StateFlow
*/
private val _playerPlaneStateFlow = MutableStateFlow(PlayerPlane())
val playerPlaneStateFlow = _playerPlaneStateFlow.asStateFlow()
/**
* 敌机StateFlow
*/
private val _enemyPlaneListStateFlow = MutableStateFlow(mutableListOf<EnemyPlane>())
val enemyPlaneListStateFlow = _enemyPlaneListStateFlow.asStateFlow()
/**
* 子弹StateFlow
*/
private val _bulletListStateFlow = MutableStateFlow(mutableListOf<Bullet>())
val bulletListStateFlow = _bulletListStateFlow.asStateFlow()
/**
* 道具奖励tateFlow
*/
private val _awardListStateFlow = MutableStateFlow(CopyOnWriteArrayList<Award>())
val awardListStateFlow = _awardListStateFlow.asStateFlow()
/**
* 分数记录
*/
private val _gameScoreStateFlow = MutableStateFlow(0)
val gameScoreStateFlow = _gameScoreStateFlow.asStateFlow()
/**
* 难度等级
*/
private val _gameLevelStateFlow = MutableStateFlow(0)
//游戏动作
val onGameAction = GameAction(
start = {
onGameStateFlowChange(GameState.Running)
},
reset = {
resetGame()
onGameStateFlowChange(GameState.Waiting)
},
pause = {
onGameStateFlowChange(GameState.Paused)
},
playerMove = { x, y ->
run {
onPlayerPlaneMove(x, y)
}
},
score = { score ->
run {
//播放爆炸音效
viewModelScope.launch {
withContext(Dispatchers.Default) {
SoundPoolUtil.getInstance(application.applicationContext)
.playByRes(R.raw.explosion)//播放res中的音频
}
}
//更新分数
onGameScoreStateFlowChange(score)
//简单处理,不同分数对应不同的等级
if (score in 100..999) {
onGameLevelStateFlowChange(2)
}
if (score in 1000..1999) {
onGameLevelStateFlowChange(3)
}
//分数是100整数时,产生随机奖励
if (score % 100 == 0) {
createAwardSprite()
}
}
},
award = { award ->
run {
//奖励子弹
if (award.type == AWARD_BULLET) {
val bulletAward = playerPlaneStateFlow.value.bulletAward
var num = bulletAward and 0xFFFF //数量
num += award.amount
onPlayerAwardBullet(BULLET_DOUBLE shl 16 or num)
}
//奖励爆炸道具
if (award.type == AWARD_BOMB) {
val bombAward = playerPlaneStateFlow.value.bombAward
var num = bombAward and 0xFFFF //数量
num += award.amount
onPlayerAwardBomb(0 shl 16 or num)
}
onAwardRemove(award)
}
},
die = {
viewModelScope.launch {
withContext(Dispatchers.Default) {
SoundPoolUtil.getInstance(application.applicationContext)
.playByRes(R.raw.explosion)//播放res中的音频
}
}
onGameStateFlowChange(GameState.Dying)
},
over = {
onGameStateFlowChange(GameState.Over)
},
exit = {
onGameStateFlowChange(GameState.Exit)
},
destroyAllEnemy = {
onDestroyAllEnemy()
},
shooting = { resId ->
run {
LogUtil.printLog(message = "onShooting resId $resId")
viewModelScope.launch {
withContext(Dispatchers.Default) {
SoundPoolUtil.getInstance(application.applicationContext)
.playByRes(resId)//播放res中的音频
}
}
}
},
createBullet = { createBullet() },
initBullet = { initBullet(it) },
)
}
Stage最低公共可组合函数:
/**
* 舞台
*/
@InternalCoroutinesApi
@ExperimentalComposeUiApi
@ExperimentalAnimationApi
@Composable
fun Stage(gameViewModel: GameViewModel) {
LogUtil.printLog(message = "Stage -------> ")
//状态提升到这里,介绍见官方文档:https://developer.android.google.cn/jetpack/compose/state#state-hoisting
//这里主要是方便统一管理,也避免直接使用ViewModel导致无法预览(预览时viewModel()会报错)
//获取游戏状态
val gameState by gameViewModel.gameStateFlow.collectAsState()
//获取游戏分数
val gameScore by gameViewModel.gameScoreStateFlow.collectAsState(0)
//获取玩家飞机
val playerPlane by gameViewModel.playerPlaneStateFlow.collectAsState()
//获取所有子弹
val bulletList by gameViewModel.bulletListStateFlow.collectAsState()
//获取所有奖励
val awardList by gameViewModel.awardListStateFlow.collectAsState()
//获取所有敌军
val enemyPlaneList by gameViewModel.enemyPlaneListStateFlow.collectAsState()
//获取游戏动作函数
val gameAction: GameAction = gameViewModel.onGameAction
val modifier = Modifier.fillMaxSize()
Box(modifier = modifier) {
// 远景
FarBackground(modifier)
//游戏开始界面
GameStart(gameState, playerPlane, gameAction)
//玩家飞机
PlayerPlaneSprite(
gameState,
playerPlane,
gameAction
)
//玩家飞机出场飞入动画
PlayerPlaneAnimIn(
gameState,
playerPlane,
gameAction
)
//玩家飞机爆炸动画
PlayerPlaneBombSprite(gameState, playerPlane, gameAction)
//敌军飞机
EnemyPlaneSprite(
gameState,
gameScore,
playerPlane,
bulletList,
enemyPlaneList,
gameAction
)
//子弹
BulletSprite(gameState, bulletList, gameAction)
//奖励
AwardSprite(gameState, playerPlane, awardList, gameAction)
//爆炸道具
BombAward(playerPlane, gameAction)
//游戏得分
GameScore(gameState, gameScore, gameAction)
//游戏开始界面
GameOver(gameState, gameScore, gameAction)
}
}
温馨提示:为了提高阅读的流畅性和完整性,此章节摘抄整理大量来自于官方文档:《状态和 Jetpack Compose》的内容,并加入了自己的理解,可能确实太啰嗦了,并且贴了较多代码,也不好,欢迎大家指正,提提意见。
从本章节到后面的章节,几乎都是介绍Compose设计相关知识点的用法,其中关于动画的使用比较多,不感兴趣可直接跳过,阅读官方文档《Compose设计》结合自身实践更佳。
前面提到过定义了一个Sprite精灵基类,玩家飞机定义一个PlayerPlane继承Sprite,增加独有的属性即可使用,代码如下:
/**
* 玩家飞机精灵
*/
const val PLAYER_PLANE_SPRITE_SIZE = 60
const val PLAYER_PLANE_PROTECT = 60
@InternalCoroutinesApi
data class PlayerPlane(
override var id: Long = System.currentTimeMillis(), //id
override var name: String = "雷电",
@DrawableRes override val drawableIds: List<Int> = listOf(
R.drawable.sprite_player_plane_1,
R.drawable.sprite_player_plane_2
), //玩家飞机资源图标
@DrawableRes val bombDrawableIds: Int = R.drawable.sprite_player_plane_bomb_seq, //玩家飞机爆炸帧动画资源
override var segment: Int = 4, //爆炸效果由segment个片段组成
override var x: Int = -100, //玩家飞机在X轴上的位置
override var y: Int = -100, //玩家飞机在Y轴上的位置
override var width: Dp = PLAYER_PLANE_SPRITE_SIZE.dp, //宽
override var height: Dp = PLAYER_PLANE_SPRITE_SIZE.dp, //高
var protect: Int = PLAYER_PLANE_PROTECT, //刚出现时的闪烁次数(此时无敌状态)
var life: Int = 1, //生命(几条命的意思,不像敌机,可以经受多次击打,玩家飞机碰一下就Over)
var animateIn: Boolean = true, //是否需要出场动画
var bulletAward: Int = BULLET_DOUBLE shl 16 or 0, //子弹奖励(子弹类型 | 子弹数量),类型0是单发红色子弹,1是蓝色双发子弹
var bombAward: Int = 0 shl 16 or 0, //爆炸奖励(爆炸类型 | 爆炸数量),目前类型只有0
) : Sprite() {
/**
* 减少保护次数,为0的时候碰撞即爆炸
*/
fun reduceProtect() {
if (protect > 0) {
protect--
}
}
fun isNoProtect() = protect <= 0
override fun reBirth() {
state = SpriteState.LIFE
animateIn = true
x = startX
y = startY
protect = PLAYER_PLANE_PROTECT
bulletAward = 0
bombAward = 0
}
}
Compose代码如下:
/**
* 玩家飞机,可手指拖动,沿XY轴同时移动
*/
val FastShowAndHiddenEasing: Easing = CubicBezierEasing(0.0f, 0.0f, 1.0f, 1.0f)//喷气速度变化
const val SMALL_ENEMY_PLANE_SPRITE_ALPHA = 100; //喷气速度
@InternalCoroutinesApi
@ExperimentalAnimationApi
@Composable
fun PlayerPlaneSprite(
gameState: GameState,
playerPlane: PlayerPlane,
gameAction: GameAction
) {
if (!(gameState == GameState.Running || gameState == GameState.Paused)) {
return
}
//初始化参数
val widthPixels = LocalContext.current.resources.displayMetrics.widthPixels
val heightPixels = LocalContext.current.resources.displayMetrics.heightPixels
val playerPlaneHeightPx = with(LocalDensity.current) { playerPlane.height.toPx() }
//循环动画
val infiniteTransition = rememberInfiniteTransition()
val alpha by infiniteTransition.animateFloat(
initialValue = 0f,
targetValue = 1f,
animationSpec = infiniteRepeatable(
animation = tween(SMALL_ENEMY_PLANE_SPRITE_ALPHA, easing = FastShowAndHiddenEasing),
repeatMode = RepeatMode.Restart
)
)
//游戏开始后,动画完成减少保护次数,直到为0
if (gameState == GameState.Running && !playerPlane.isNoProtect() && alpha >= 0.5f) {
playerPlane.reduceProtect()
}
LogUtil.printLog(message = "PlayerPlaneSprite() playerPlane.x = ${playerPlane.x} playerPlane.y = ${playerPlane.y}")
Box(modifier = Modifier.fillMaxSize()) {
Image(
painter = painterResource(id = R.drawable.sprite_player_plane_1),
contentScale = ContentScale.FillBounds,
contentDescription = null,
modifier = Modifier
.offset { IntOffset(playerPlane.x, playerPlane.y) }
//.background(Color.Blue)
.size(playerPlane.width, playerPlane.height)
.pointerInput(Unit) {
detectDragGestures { change, dragAmount ->
change.consumeAllChanges()
var newOffsetX = playerPlane.x
var newOffsetY = playerPlane.y
//边界检测
when {
newOffsetX + dragAmount.x <= 0 -> {
newOffsetX = 0
}
(newOffsetX + dragAmount.x + playerPlaneHeightPx) >= widthPixels -> {
widthPixels.let {
newOffsetX = it - playerPlaneHeightPx.roundToInt()
}
}
else -> {
newOffsetX += dragAmount.x.roundToInt()
}
}
when {
newOffsetY + dragAmount.y <= 0 -> {
newOffsetY = 0
}
(newOffsetY + dragAmount.y) >= heightPixels -> {
heightPixels.let {
newOffsetY = it
}
}
else -> {
newOffsetY += dragAmount.y.roundToInt()
}
}
gameAction.playerMove(newOffsetX, newOffsetY)
}
}
.alpha(
if (gameState == GameState.Running || gameState == GameState.Paused) {
if (alpha < 0.5f) 0f else 1f
} else {
0f
}
)
)
//显示另一张飞机喷气图,通过循环设置相反的alpha,达到动态喷气的效果
Image(
painter = painterResource(id = R.drawable.sprite_player_plane_2),
contentScale = ContentScale.FillBounds,
contentDescription = null,
modifier = Modifier
.offset { IntOffset(playerPlane.x, playerPlane.y) }
//.background(Color.Blue)
.size(playerPlane.width, playerPlane.height)
.alpha(
if (gameState == GameState.Running || gameState == GameState.Paused) {
//如果处于保护状态这里就不显示了
if (!playerPlane.isNoProtect()) {
0f
} else {
if (1 - alpha < 0.5f) 0f else 1f
}
} else {
0f
}
)
)
}
}
实现效果:
通过 pointerInput 修饰符使用拖动手势检测器,不断的调用GameAction的onPlayerPlaneMove(x, y)函数更新PlayerPlane的坐标就可以了。 pointerInput 的使用见官方文档《手势》。
Compose拖拽代码:
Modifier.pointerInput(Unit) {
detectDragGestures { change, dragAmount ->
change.consumeAllChanges()
var newOffsetX = playerPlane.x
var newOffsetY = playerPlane.y
//边界检测
when {
newOffsetX + dragAmount.x <= 0 -> {
newOffsetX = 0
}
(newOffsetX + dragAmount.x + playerPlaneHeightPx) >= widthPixels -> {
widthPixels.let {
newOffsetX = it - playerPlaneHeightPx.roundToInt()
}
}
else -> {
newOffsetX += dragAmount.x.roundToInt()
}
}
when {
newOffsetY + dragAmount.y <= 0 -> {
newOffsetY = 0
}
(newOffsetY + dragAmount.y) >= heightPixels -> {
heightPixels.let {
newOffsetY = it
}
}
else -> {
newOffsetY += dragAmount.y.roundToInt()
}
}
gameAction.playerMove(newOffsetX, newOffsetY)
}
}
GameVIewModel更新玩家飞机坐标代码:
/**
* 玩家飞机StateFlow
*/
private val _playerPlaneStateFlow = MutableStateFlow(PlayerPlane())
val playerPlaneStateFlow = _playerPlaneStateFlow.asStateFlow()
private fun onPlayerPlaneStateFlowChange(plane: PlayerPlane) {
viewModelScope.launch {
withContext(Dispatchers.Default) {
_playerPlaneStateFlow.emit(plane)
}
}
}
/**
* 玩家飞机移动
*/
private fun onPlayerPlaneMove(x: Int, y: Int) {
if (gameStateFlow.value != GameState.Running) {
return
}
val playerPlane = playerPlaneStateFlow.value
playerPlane.x = x
playerPlane.y = y
if (playerPlane.animateIn) {
playerPlane.animateIn = false
}
onPlayerPlaneStateFlowChange(playerPlane)
}
飞行动画通过循环显示和隐藏两张不同的图片来实现,一开始还在想怎么设置Compose Image的visibility(惯性思维了),但是实际上是通过调整alpha值实现的。
关键代码:
Box(modifier = Modifier.fillMaxSize()) {
Image(
painter = painterResource(id = R.drawable.sprite_player_plane_1),
contentScale = ContentScale.FillBounds,
contentDescription = null,
modifier = Modifier
.offset { IntOffset(playerPlane.x, playerPlane.y) }
//.background(Color.Blue)
.size(playerPlane.width, playerPlane.height)
.pointerInput(Unit) {
detectDragGestures { change, dragAmount ->
//省略
}
.alpha(
if (gameState == GameState.Running || gameState == GameState.Paused) {
if (alpha < 0.5f) 0f else 1f
} else {
0f
}
)
)
//显示另一张飞机喷气图,通过循环设置相反的alpha,达到动态喷气的效果
Image(
painter = painterResource(id = R.drawable.sprite_player_plane_2),
contentScale = ContentScale.FillBounds,
contentDescription = null,
modifier = Modifier
.offset { IntOffset(playerPlane.x, playerPlane.y) }
//.background(Color.Blue)
.size(playerPlane.width, playerPlane.height)
.alpha(
if (gameState == GameState.Running || gameState == GameState.Paused) {
//如果处于保护状态这里就不显示了
if (!playerPlane.isNoProtect()) {
0f
} else {
if (1 - alpha < 0.5f) 0f else 1f
}
} else {
0f
}
)
)
}
子弹的连续射击效果花了很多时间去调整,差强人意吧。
实现效果:
定义一个Bullet继承Sprite,代码如下:
/**
* 子弹精灵
*/
const val BULLET_SPRITE_WIDTH = 6
const val BULLET_SPRITE_HEIGHT = 18
const val BULLET_SINGLE = 0
const val BULLET_DOUBLE = 1
@InternalCoroutinesApi
data class Bullet(
override var id: Long = System.currentTimeMillis(), //id
override var name: String = "蓝色单发子弹",
override var type: Int = BULLET_SINGLE, //类型:0单发子弹,1双发子弹
@DrawableRes val drawableId: Int = R.drawable.sprite_bullet_single, //子弹资源图标
override var width: Dp = BULLET_SPRITE_WIDTH.dp, //宽
override var height: Dp = BULLET_SPRITE_HEIGHT.dp, //高
override var speed: Int = 200, //飞行速度,从玩家飞机头部沿着Y轴往屏幕顶部飞行一次屏幕高度所花费的时间
override var x: Int = 0, //实时x轴坐标
override var y: Int = 0, //实时y轴坐标
override var state: SpriteState = SpriteState.DEATH, //默认死亡
override var init: Boolean = false, //默认未初始化
var hit: Int = 1,//击打能力,击中一次敌人,敌人减掉的生命值
) : Sprite()
上面的动画刷新的太快了,可能看不清楚,稍微降低下子弹的飞行速度,增加背景看下效果。
注意看顶部第一颗子弹,从玩家飞机头部出现,沿着Y轴负方向不断的移动,后面的子弹则依次出现,一个接着一个,排列整齐,前仆后继。看图:
关键代码:
/**
* 子弹从玩家飞机顶部发射,只能沿着X轴运动,超出屏幕则销毁,与敌机碰撞也销毁,同时计算得分
*/
@InternalCoroutinesApi
@Composable
fun BulletSprite(
gameState: GameState = GameState.Waiting,
bulletList: List<Bullet> = mutableListOf(),
gameAction: GameAction = GameAction()
) {
//重复动画,1秒60帧
val infiniteTransition = rememberInfiniteTransition()
val frame by infiniteTransition.animateInt(
initialValue = 0,
targetValue = 60,
animationSpec = infiniteRepeatable(
animation = tween(
durationMillis = 1000,
easing = LinearEasing
),
repeatMode = RepeatMode.Restart
)
)
//游戏不在进行中
if (gameState != GameState.Running) {
return
}
//每100毫秒生成一颗子弹
if (frame % 6 == 0) {
gameAction.createBullet()
}
for (bullet in bulletList) {
if (bullet.isAlive()) {
//初始化起点(为什么单独搞一个init属性,因为init属性是添加到队里列时才设置false,这样渲染时检测init为false才去初始化起点.
//如果根据isAlive来检测会导致Bullet一死亡就算重新初始化位置,但是复用重新发射时,飞机的位置可能已经变动了。
if (!bullet.init) {
//初始化子弹出生位置
gameAction.initBullet(bullet)
//播放射击音效,放到非UI线程
gameAction.shooting(R.raw.shoot)
}
//子弹离开屏幕后则死亡
if (bullet.isInvalid()) {
bullet.die()
}
//射击
bullet.shoot()
//显示子弹图片
BulletShootingSprite(bullet)
}
}
}
/**
* 更新子弹x、y值,显示子弹图片
*/
@InternalCoroutinesApi
@Composable
fun BulletShootingSprite(
bullet: Bullet = Bullet()
) {
//绘制图片
Box(modifier = Modifier.fillMaxSize()) {
Image(
painter = painterResource(id = bullet.drawableId),
contentScale = ContentScale.FillBounds,
contentDescription = null,
modifier = Modifier
.offset {
IntOffset(
bullet.x,
bullet.y
)
}
.width(bullet.width)
.height(bullet.height)
.alpha(
if (bullet.isAlive()) {
1f
} else {
0f
}
)
)
}
}
/**
* 生成子弹
*/
private fun createBullet() {
//游戏开始并且飞机在屏幕内才会生成
if (gameStateFlow.value == GameState.Running && playerPlaneStateFlow.value.y < getApplication<Application>().resources.displayMetrics.heightPixels) {
val bulletAward = playerPlaneStateFlow.value.bulletAward
var bulletNum = bulletAward and 0xFFFF //数量
val bulletType = bulletAward shr 16 //类型
val bulletList = bulletListStateFlow.value as ArrayList
val firstBullet = bulletList.firstOrNull { it.isDead() }
if (firstBullet == null) {
var newBullet = Bullet(
type = BULLET_SINGLE,
drawableId = R.drawable.sprite_bullet_single,
width = BULLET_SPRITE_WIDTH.dp,
hit = 1,
state = SpriteState.LIFE,
init = false
)
//子弹奖励
if (bulletNum > 0 && bulletType == BULLET_DOUBLE) {
newBullet = newBullet.copy(
type = BULLET_DOUBLE,
drawableId = R.drawable.sprite_bullet_double,
width = 18.dp,
hit = 2,
state = SpriteState.LIFE,
init = false
)
//消耗子弹
bulletNum--
onPlayerAwardBullet(BULLET_DOUBLE shl 16 or bulletNum)
}
bulletList.add(newBullet)
} else {
var newBullet = firstBullet.copy(
type = BULLET_SINGLE,
drawableId = R.drawable.sprite_bullet_single,
width = BULLET_SPRITE_WIDTH.dp,
hit = 1,
state = SpriteState.LIFE,
init = false
)
//子弹奖励
if (bulletNum > 0 && bulletType == BULLET_DOUBLE) {
newBullet = firstBullet.copy(
type = BULLET_DOUBLE,
drawableId = R.drawable.sprite_bullet_double,
width = 18.dp,
hit = 2,
state = SpriteState.LIFE,
init = false
)
//消耗子弹
bulletNum--
onPlayerAwardBullet(BULLET_DOUBLE shl 16 or bulletNum)
}
bulletList.add(newBullet)
bulletList.removeAt(0)
}
onBulletListStateFlowChange(bulletList)
}
}
/**
* 初始化子弹出生位置
*/
private fun initBullet(bullet: Bullet) {
val playerPlane = playerPlaneStateFlow.value
val playerPlaneWidthPx = dp2px(playerPlane.width)
val bulletWidthPx = dp2px(bullet.width)
val bulletHeightPx = dp2px(bullet.height)
val startX = (playerPlane.x + playerPlaneWidthPx!! / 2 - bulletWidthPx!! / 2)
val startY = (playerPlane.y - bulletHeightPx!!)
bullet.startX = startX
bullet.startY = startY
bullet.x = bullet.startX
bullet.y = bullet.startY
bullet.init = true
}
一开始只做了一颗子弹的射击效果,使用一个重复动画,不断的调整子弹的x、y值,从玩家飞机头部不断的沿Y轴负方向飞行指定的距离,到达指定距离后再周而复始的从玩家飞机头部继续飞行,但是这样的效果体验不好,必须等待子弹飞行完指定距离后才能重复利用。
后来在此基础上改用一个List维护Bullet对象,复用List里的Bullet对象,每次动画值发生改变时,for循环更新所有子弹的状态,并且Bullet对象发生碰撞或非出飞出屏幕即可重新复用,这样一来效果比之前的好很多了。
实现效果:
定义一个EnemyPlane继承Sprite,代码如下:
/**
* 敌机精灵
*/
const val SMALL_ENEMY_PLANE_SPRITE_SIZE = 40
const val MIDDLE_ENEMY_PLANE_SPRITE_SIZE = 60
const val BIG_ENEMY_PLANE_SPRITE_SIZE = 100
@InternalCoroutinesApi
data class EnemyPlane(
override var id: Long = System.currentTimeMillis(), //id
override var name: String = "敌军侦察机",
@DrawableRes override val drawableIds: List<Int> = listOf(R.drawable.sprite_small_enemy_plane), //飞机资源图标
@DrawableRes override val bombDrawableId: Int = R.drawable.sprite_small_enemy_plane_seq, //敌机爆炸帧动画资源
override var segment: Int = 3, //爆炸效果由segment个片段组成,小飞机是3,中飞机是4,大飞机是6
override var x: Int = 0, //敌机当前在X轴上的位置
override var y: Int = -100, //敌机当前在Y轴上的位置
override var startY: Int = -100, //出现的起始位置
override var width: Dp = SMALL_ENEMY_PLANE_SPRITE_SIZE.dp, //宽
override var height: Dp = SMALL_ENEMY_PLANE_SPRITE_SIZE.dp, //高
override var velocity: Int = 1, //飞行速度(每帧移动的像素)
var bombX: Int = -100, //爆炸动画当前在X轴上的位置
var bombY: Int = -100, //爆炸动画当前在Y轴上的位置
val power: Int = 1, //生命值,敌机的抗打击能力
var hit: Int = 0, //被击中消耗的生命值
val value: Int = 10, //打一个敌机的得分
) : Sprite() {
fun beHit(reduce: Int) {
hit += reduce
}
fun isNoPower() = (power - hit) <= 0
fun bomb() {
hit = power
}
override fun reBirth() {
state = SpriteState.LIFE
hit = 0
}
override fun die() {
state = SpriteState.DEATH
bombX = x
bombY = y
}
}
关键代码:
/**
* 敌机
* 只能沿着Y轴飞行(不能沿X轴运动)
*/
@InternalCoroutinesApi
@ExperimentalAnimationApi
@Composable
fun EnemyPlaneSprite(
gameState: GameState,
gameScore: Int,
enemyPlaneList: List<EnemyPlane>,
gameAction: GameAction
) {
for (enemyPlane in enemyPlaneList) {
EnemyPlaneSpriteMoveAndBomb(
gameState,
gameScore,
enemyPlane,
gameAction
)
}
}
@InternalCoroutinesApi
@ExperimentalAnimationApi
@Composable
fun EnemyPlaneSpriteMoveAndBomb(
gameState: GameState,
gameScore: Int,
enemyPlane: EnemyPlane,
gameAction: GameAction
) {
//爆炸动画控制标志位,每个敌机都有一个独立的标志位,方便观察,不能放到EnemyPlane,因为不方便直接观察
var showBombAnim by remember {
mutableStateOf(false)
}
EnemyPlaneSpriteMove(
gameState,
onBombAnimChange = {
showBombAnim = it
},
enemyPlane,
gameAction
)
EnemyPlaneSpriteBomb(
gameScore,
enemyPlane,
showBombAnim,
onBombAnimChange = {
showBombAnim = it
})
}
@InternalCoroutinesApi
@ExperimentalAnimationApi
@Composable
fun EnemyPlaneSpriteMove(
gameState: GameState,
onBombAnimChange: (Boolean) -> Unit,
enemyPlane: EnemyPlane,
gameAction: GameAction
) {
//重复动画,1秒60帧(很奇怪,测试发现,如果不使用frame这个变量,则动画不会循环进行)
val infiniteTransition = rememberInfiniteTransition()
val frame by infiniteTransition.animateInt(
initialValue = 0,
targetValue = 60,
animationSpec = infiniteRepeatable(
animation = tween(1000, easing = LinearEasing),
repeatMode = RepeatMode.Restart
)
)
//游戏不在进行中
if (gameState != GameState.Running) {
return
}
//敌机飞行,包含碰撞检测
gameAction.moveEnemyPlane(enemyPlane,onBombAnimChange)
LogUtil.printLog(message = "EnemyPlaneSpriteFly: state = ${enemyPlane.state},enemyPlane.x = ${enemyPlane.x}, enemyPlane.y = ${enemyPlane.y}, frame = $frame ")
//绘制
Box(modifier = Modifier.fillMaxSize()) {
Image(
painter = painterResource(enemyPlane.getRealDrawableId()),
contentScale = ContentScale.FillBounds,
contentDescription = null,
modifier = Modifier
.offset { IntOffset(enemyPlane.x, enemyPlane.y) }
//.background(Color.Red)
.size(enemyPlane.width)
.alpha(if (enemyPlane.isAlive()) 1f else 0f)
)
}
}
/**
* 敌机移动
*/
private fun onEnemyPlaneMove(
enemyPlane: EnemyPlane,
onBombAnimChange: (Boolean) -> Unit
) {
viewModelScope.launch {
withContext(Dispatchers.Default) {
//获取屏幕宽高
val widthPixels = getApplication<Application>().resources.displayMetrics.widthPixels
val heightPixels =
getApplication<Application>().resources.displayMetrics.heightPixels
//敌机的大小和活动范围
val enemyPlaneWidthPx = dp2px(enemyPlane.width)
val enemyPlaneHeightPx = dp2px(enemyPlane.height)
val maxEnemyPlaneSpriteX = widthPixels - enemyPlaneWidthPx!! //X轴屏幕宽度向左偏移一个机身
val maxEnemyPlaneSpriteY = heightPixels * 1.5 //Y轴1.5倍屏幕高度
//如果未初始化,则给个随机值(在屏幕范围内)
if (!enemyPlane.init) {
enemyPlane.x = (0..maxEnemyPlaneSpriteX).random()
var newY = -(0..heightPixels).random() - (0..heightPixels).random()
when (enemyPlane.type) {
0 -> newY -= enemyPlaneHeightPx!! * 2
1 -> newY -= enemyPlaneHeightPx!! * 4
2 -> newY -= enemyPlaneHeightPx!! * 10
}
enemyPlane.y = newY
LogUtil.printLog(message = "enemyPlaneMove: newY $newY ")
LogUtil.printLog(message = "enemyPlaneMove: id = ${enemyPlane.id},type = ${enemyPlane.type}, x = ${enemyPlane.x}, y = ${enemyPlane.y} ")
enemyPlane.init = true
enemyPlane.reBirth()
}
//飞出屏幕(位移到指定距离),则死亡
if (enemyPlane.y >= maxEnemyPlaneSpriteY) {
enemyPlane.init = false//这里不能在die方法里调用,否则碰撞检测爆炸后,敌机的位置马上变化了
enemyPlane.die()
}
//敌机位移
enemyPlane.move()
onCollisionDetect(enemyPlane, onBombAnimChange)
}
}
}
可以看到,这里是用一个List集合统一管理敌机Sprite对象,而这个List对象是从GameViewModel传过来的。
通过for循环调用EnemyPlaneSpriteMoveAndBomb
函数,实现每个敌机Sprite对象的飞行和爆炸。在EnemyPlaneSpriteMoveAndBomb
函数中,EnemyPlaneSpriteMove
负责控制敌机Sprite对象的移动和显示,EnemyPlaneSpriteBomb
负责控制敌机Sprite对象爆炸动画的播放和停止。
EnemyPlaneSpriteMove
函数中主要使用rememberInfiniteTransition
重复动画来不断驱动
EnemyPlaneSpriteMove
函数调用,并通过GameAction的moveEnemyPlane
函数修改敌机Sprite对象的x,y值,达到敌机飞行的效果。
如果让你来实现一键触发所有敌机爆炸动画的功能,你会怎么设计?
这里讲下笔者的思路,一开始是打算直接在敌机Sprite对象里增加一个爆炸标志位,用于观察是否播放爆炸动画,但是发现根本观察不到,因为直接更新List里对象的属性,并不能观察到变化,对于_MutableStateFlow_
而言,只有调用emit
函数才能通知观察者,而且每个敌机发生爆炸都是独立的,统一放到MutableStateFlow
更新再调用emit
函数,这个操作显然太笨重了。
那每个敌机Sprite对象都在Compose函数中定义一个showBombAnim
爆炸动画标志位如何?当敌机Sprite对象生命值为0的时候,马上去修改这个标志位,状态发生改变就会驱动Compose组合函数,此时根据标志位来判断是否需要播放爆炸动画就可以了。
//爆炸动画控制标志位,每个敌机都有一个独立的标志位,方便观察,不能放到EnemyPlane,因为不方便直接观察
var showBombAnim by remember {
mutableStateOf(false)
}
EnemyPlaneSpriteMove(
gameState,
onBombAnimChange = {
showBombAnim = it
},
enemyPlane,
gameAction
)
EnemyPlaneSpriteBomb(
gameScore,
enemyPlane,
showBombAnim,
onBombAnimChange = {
showBombAnim = it
})
看以上代码,同样使用了状态提升。EnemyPlaneSpriteMove函数的onBombAnimChange用于敌机生命值为零时,控制爆炸动画播放。EnemyPlaneSpriteBomb函数的onBombAnimChange用于爆炸动画播放完毕后隐藏爆炸图片。
这样一来,一键触发所有敌机的爆炸动画就很简单了,将所有敌机对象的生命值变为0即可。
/**
* 屏幕内所有敌机爆炸
*/
private fun onDestroyAllEnemy() {
viewModelScope.launch {
//敌机全部消失
val listEnemyPlane = enemyPlaneListStateFlow.value
var countScore = 0
withContext(Dispatchers.Default) {
for (enemyPlane in listEnemyPlane) {
//存活并且在屏幕内
if (enemyPlane.isAlive() && !enemyPlane.isNoPower() && enemyPlane.y > 0 && enemyPlane.y < getApplication<Application>().resources.displayMetrics.heightPixels) {
countScore += enemyPlane.value
enemyPlane.bomb()//能量归零就爆炸
}
}
_enemyPlaneListStateFlow.emit(listEnemyPlane)
}
//更新分数
gameScoreStateFlow.value.plus(countScore).let { onGameScoreStateFlowChange(it) }
//爆炸道具减1
val bombAward = playerPlaneStateFlow.value.bombAward
var bombNum = bombAward and 0xFFFF //数量
val bombType = bombAward shr 16 //类型
if (bombNum-- <= 0) {
bombNum = 0
}
onPlayerAwardBomb(bombType shl 16 or bombNum)
}
}
关于爆炸动画,放到下一章节讲解。
碰撞检测有很多种,这里用的是矩形碰撞,感兴趣的小伙伴可以直接搜索学习。
如上图,以敌机为视角,敌机所属的红色区域是危险区域,子弹和玩家飞机的矩形框只要触碰红色区域则代表发生碰撞检测,而绿色区域则是安全区域。
关键代码:
/**
* 精灵工具类
*/
object SpriteUtil {
/**
* 矩形碰撞的函数
* @param x1 第一个矩形的X坐标
* @param y1 第一个矩形的Y坐标
* @param w1 第一个矩形的宽
* @param h1 第一个矩形的高
* @param x2 第二个矩形的X坐标
* @param y2 第二个矩形的Y坐标
* @param w2 第二个矩形的宽
* @param h2 第二个矩形的高
*/
fun isCollisionWithRect(
x1: Int,
y1: Int,
w1: Int,
h1: Int,
x2: Int,
y2: Int,
w2: Int,
h2: Int
): Boolean {
if (x1 >= x2 && x1 >= x2 + w2) {
return false
} else if (x1 <= x2 && x1 + w1 <= x2) {
return false
} else if (y1 >= y2 && y1 >= y2 + h2) {
return false
} else if (y1 <= y2 && y1 + h1 <= y2) {
return false
}
return true
}
}
/**
* 针对敌机的碰撞检测
*/
private fun onCollisionDetect(
enemyPlane: EnemyPlane,
onBombAnimChange: (Boolean) -> Unit
) {
viewModelScope.launch {
withContext(Dispatchers.Default) {
//如果使用了炸弹,会导致所有敌机的生命变成0,触发爆炸动画
if (enemyPlane.isAlive() && enemyPlane.isNoPower()) {
//敌机死亡
enemyPlane.die()
//爆炸动画可显示
onBombAnimChange(true)
}
//敌机的大小
val enemyPlaneWidthPx = dp2px(enemyPlane.width)
val enemyPlaneHeightPx = dp2px(enemyPlane.height)
//玩家飞机大小
val playerPlane = playerPlaneStateFlow.value
val playerPlaneWidthPx = dp2px(playerPlane.width)
val playerPlaneHeightPx = dp2px(playerPlane.height)
//如果敌机碰撞到了玩家飞机(碰撞检测要求,碰撞双方必须都在屏幕内)
if (enemyPlane.isAlive() && playerPlane.x > 0 && playerPlane.y > 0 && enemyPlane.x > 0 && enemyPlane.y > 0 && SpriteUtil.isCollisionWithRect(
playerPlane.x,
playerPlane.y,
playerPlaneWidthPx!!,
playerPlaneHeightPx!!,
enemyPlane.x,
enemyPlane.y,
enemyPlaneWidthPx!!,
enemyPlaneHeightPx!!
)
) {
//玩家飞机爆炸,进入GameState.Dying状态,播放爆炸动画,动画结束后进入GameState.Over,弹出提示框,选择重新开始或退出
if (gameStateFlow.value == GameState.Running) {
if (playerPlane.isNoProtect()) {
onGameAction.die()
}
}
}
//子弹大小
val bulletList = bulletListStateFlow.value
if (bulletList.isEmpty()) {
return@withContext
}
val firstBullet = bulletList.first()
val bulletSpriteWidthPx = dp2px(firstBullet.width)
val bulletSpriteHeightPx = dp2px(firstBullet.height)
//遍历子弹和敌机是否发生碰撞
bulletList.forEach { bullet ->
//如果敌机存活且碰撞到了子弹(碰撞检测要求,碰撞双方必须都在屏幕内)
if (enemyPlane.isAlive() && bullet.isAlive() && bullet.x > 0 && bullet.y > 0 && SpriteUtil.isCollisionWithRect(
bullet.x,
bullet.y,
bulletSpriteWidthPx!!,
bulletSpriteHeightPx!!,
enemyPlane.x,
enemyPlane.y,
enemyPlaneWidthPx!!,
enemyPlaneHeightPx!!
)
) {
bullet.die()
enemyPlane.beHit(bullet.hit)
//敌机无能量后就爆炸
if (enemyPlane.isNoPower()) {
//敌机死亡
enemyPlane.die()
//爆炸动画可显示
onBombAnimChange(true)
//游戏得分,爆炸动画是观察分数变化来触发的
onGameScore(gameScoreStateFlow.value + enemyPlane.value)
//播放爆炸音效
onPlayByRes(getApplication(), R.raw.explosion)
return@forEach
}
}
}
}
}
}
在敌机移动的onEnemyPlaneMove
函数中,每次都会调用onCollisionDetect
进行碰撞检测,对于敌机对象而言,需要调用isCollisionWithRect
分别传入子弹和玩家飞机对象的矩形数据进行比较,得出碰撞检测结果,根据结果执行对应的游戏逻辑。
关键代码:
/**
* 测试爆炸动画
*/
@InternalCoroutinesApi
@Composable
fun TestComposeShowBombSprite() {
val bomb by remember { mutableStateOf(Bomb(x = 500, y = 500)) }
var state by remember {
mutableStateOf(0)
}
val anim = remember {
TargetBasedAnimation(
animationSpec = tween(
durationMillis = bomb.segment * 33,//相当一秒播放30帧, 1000/30 = 33
easing = LinearEasing
),
typeConverter = Int.VectorConverter,
initialValue = 0,
targetValue = bomb.segment - 1
)
}
var playTime by remember { mutableStateOf(0L) }
var animationSegmentIndex by remember {
mutableStateOf(0)
}
LaunchedEffect(state) {
val startTime = withFrameNanos { it }
do {
playTime = withFrameNanos { it } - startTime
animationSegmentIndex = anim.getValueFromNanos(playTime)
} while (!anim.isFinishedFromNanos(playTime))
}
Box(modifier = Modifier.fillMaxSize(1f), contentAlignment = Alignment.Center) {
Box(
modifier = Modifier
.size(60.dp)
.background(Color.Red, shape = RoundedCornerShape(60 / 5))
.clickable {
LogUtil.printLog(message = "触发动画 ")
state++
bomb.reBirth()
}, contentAlignment = Alignment.Center
) {
Text(
text = animationSegmentIndex.toString(),
style = TextStyle(color = Color.White, fontSize = 12.sp)
)
}
}
//LogUtil.printLog(message = "TestComposeShowBombSprite() animationSegmentIndex $animationSegmentIndex")
//LogUtil.printLog(message = "TestComposeShowBombSprite() bomb.state ${bomb.state}")
PlayBombSpriteAnimate(bomb, animationSegmentIndex)
}
@InternalCoroutinesApi
@Composable
fun PlayBombSpriteAnimate(bomb: Bomb, animationSegmentIndex: Int) {
//越界检测
if (animationSegmentIndex >= bomb.segment) {
return
}
//初始化炸弹的大小
val bombWidth = bomb.width
val bombWidthWidthPx = with(LocalDensity.current) { bombWidth.toPx() }
//这里使用修改ImageBitmap.imageResource返回bitmap方便处理
val bitmap: Bitmap = imageResource(bomb.bombDrawableId)
//分割Bitmap
val displayBitmapWidth = bitmap.width / bomb.segment
//Matrix用来放大到跟bombWidthWidthPx一样大小
val matrix = Matrix()
matrix.postScale(
bombWidthWidthPx / displayBitmapWidth,
bombWidthWidthPx / bitmap.height
)
//越界检测
if ((animationSegmentIndex * displayBitmapWidth) + displayBitmapWidth > bitmap.width) {
return
}
//只获取需要的部分
val displayBitmap = Bitmap.createBitmap(
bitmap,
(animationSegmentIndex * displayBitmapWidth),
0,
displayBitmapWidth,
bitmap.height,
matrix,
true
)
val imageBitmap: ImageBitmap = displayBitmap.asImageBitmap()
Canvas(
modifier = Modifier
.fillMaxSize()
.size(bombWidth)
) {
drawImage(
imageBitmap,
topLeft = Offset(
bomb.x.toFloat(),
bomb.y.toFloat(),
),
alpha = if (bomb.isAlive()) 1.0f else 0f,
)
}
}
/**
* Load an ImageBitmap from an image resource.
*
* This function is intended to be used for when low-level ImageBitmap-specific
* functionality is required. For simply displaying onscreen, the vector/bitmap-agnostic
* [painterResource] is recommended instead.
*
* @param id the resource identifier
* @return the decoded image data associated with the resource
*/
@Composable
fun imageResource(@DrawableRes id: Int): Bitmap {
val context = LocalContext.current
val value = remember { TypedValue() }
context.resources.getValue(id, value, true)
val key = value.string!!.toString() // image resource must have resource path.
return remember(key) { imageResource(context.resources, id) }
}
思路如下:
imageResource
函数加载帧动画素材即可得到一个Bitmap对象。postScale
函数进行缩放即可,这里只要保持高度一致。Bitmap.createBitmap
函数获取局部的Bitmap并显示即可。这里定义一个animationSegmentIndex
作为当前要显示的动画帧的下标,通过横向切割,在Btimap高度一致的情况下,只要计算createBitmap
函数的x参数即可通过偏移量定位并获取到当前动画帧的Bitmap,最后通过asImageBitmap
把Bitmap转成ImageBitmap,使用drawImage
显示图片。TargetBasedAnimation
动画得到animationSegmentIndex
从0到对应爆炸动画素材总帧数的变化过程,即可驱动Compose函数显示对应动画帧,当然时间上也要控制以下,这样连续的播放动画帧,动画效果就出来了。
有了以上知识点的铺垫,其它功能,如分数的显示和计算,道具奖励的生成和获取等就很简单了,这里不在赘述,有兴趣可查看源码,注释还是比较详细的。
游戏控制可以认为就是游戏状态管理,定义GameState和GameAction,通过GameViewModel来管理,高内聚低耦合可复用。其中GameState定义了游戏的状态,通过GameAction驱动状态转换,构造一个完整的有限状态机。要注意的是State和Action并不是一一对应的。
参考资料《深入浅出理解有限状态机》
Paused:游戏暂停状态,同上,看图就懂了,就是一切元素和状态都不会发生变化。
Dying:玩家飞机死亡状态,用于触发玩家飞机爆炸动画,这个看起来没有必要放到GameState里吧?确实没有必要,去掉完全不影响,也可以通过在Compose可组合函数内部定义一个state来实现。但如果你有其它需求,比如玩家飞机爆炸时,子弹、敌机全部消失,加上这个Dying就很方便处理了,各有各的好,架构也不是死的,可以根据实际需要进行调整。
Over:游戏结束状态,玩家飞机爆炸动画播放完毕就自动进入Over状态,如下图。
//观察游戏状态
lifecycleScope.launch {
gameViewModel.gameStateFlow.collect {
LogUtil.printLog(message = "lifecycleScope gameState $it")
//退出app
if (GameState.Exit == it) {
finish()
}
}
}
关键代码:
/**
* 游戏状态
*/
enum class GameState {
Waiting, // wait to start
Running, // gaming
Paused, // pause
Dying, // hit enemy and dying
Over, // over
Exit // finish activity
}
/**
* 游戏动作
*/
@InternalCoroutinesApi
data class GameAction(
val start: () -> Unit = {}, //游戏状态进入Running,游戏中
val pause: () -> Unit = {},//游戏状态进入Paused,暂停
val reset: () -> Unit = {},//游戏状态进入Waiting,显示GameWaiting
val die: () -> Unit = {},//游戏状态进入Dying,触发爆炸动画
val over: () -> Unit = {},//游戏状态进入Over,显示GameOverBoard
val exit: () -> Unit = {},//退出游戏
val playerMove: (x: Int, y: Int) -> Unit = { _: Int, _: Int -> },//玩家移动
val score: (score: Int) -> Unit = { _: Int -> },//更新分数
val award: (award: Award) -> Unit = { _: Award -> },//获得奖励
val createBullet: () -> Unit = { },//子弹生成
val initBullet: (bullet: Bullet) -> Unit = { _: Bullet -> },//子弹初始化出生位置
val shooting: (resId: Int) -> Unit = { _: Int -> },//射击
val destroyAllEnemy: () -> Unit = {},//摧毁所有敌机
val levelUp: (score: Int) -> Unit = { _: Int -> },//难度升级
)
/**
* 游戏状态StateFlow
*/
private val _gameStateFlow = MutableStateFlow(GameState.Waiting)
val gameStateFlow = _gameStateFlow.asStateFlow()
private fun onGameStateFlowChange(newGameSate: GameState) {
viewModelScope.launch {
withContext(Dispatchers.Default) {
_gameStateFlow.emit(newGameSate)
}
}
}
在整个游戏逻辑中,主要是通过界面操作,碰撞检测,生命周期回调,触发各种Action,最终调用onGameStateFlowChange更新状态。
在Compose可组合函数中,根据不同的State对界面进行不同的显示。如以下代码,通过LaunchedEffect(gameState)观察游戏状态,当gameState == GameState.Dying
条件满足时,才触发爆炸动画,显示并播放爆炸资源图片序列。
/**
* 玩家飞机爆炸动画
*/
@InternalCoroutinesApi
@ExperimentalAnimationApi
@Composable
fun PlayerPlaneBombSprite(
gameState: GameState = GameState.Waiting,
playerPlane: PlayerPlane,
gameAction: GameAction
) {
if (gameState != GameState.Dying) {
return
}
val spriteSize = PLAYER_PLANE_SPRITE_SIZE.dp
val spriteSizePx = with(LocalDensity.current) { spriteSize.toPx() }
val segment = playerPlane.segment
val anim = remember {
TargetBasedAnimation(
animationSpec = tween(172),
typeConverter = Int.VectorConverter,
initialValue = 0,
targetValue = segment - 1
)
}
var animationValue by remember {
mutableStateOf(0)
}
var playTime by remember { mutableStateOf(0L) }
LaunchedEffect(gameState) {
val startTime = withFrameNanos { it }
do {
playTime = withFrameNanos { it } - startTime
animationValue = anim.getValueFromNanos(playTime)
} while (!anim.isFinishedFromNanos(playTime))
}
LogUtil.printLog(message = "PlayerPlaneBombSprite() animationValue $animationValue")
//这里使用修改ImageBitmap.imageResource返回bitmap方便处理
val bitmap: Bitmap = imageResource(R.drawable.sprite_player_plane_bomb_seq)
//分割Bitmap
val displayBitmapWidth = bitmap.width / segment
val matrix = Matrix()
matrix.postScale(spriteSizePx / displayBitmapWidth, spriteSizePx / bitmap.height)
//只获取需要的部分
val displayBitmap = Bitmap.createBitmap(
bitmap,
(animationValue * displayBitmapWidth),
0,
displayBitmapWidth,
bitmap.height,
matrix,
true
)
val imageBitmap: ImageBitmap = displayBitmap.asImageBitmap()
Canvas(
modifier = Modifier
.fillMaxSize()
.size(spriteSize)
) {
val canvasWidth = size.width
val canvasHeight = size.height
drawImage(
imageBitmap,
topLeft = Offset(
playerPlane.x.toFloat(),
playerPlane.y.toFloat(),
),
alpha = if (gameState == GameState.Dying) 1.0f else 0f,
)
}
if (animationValue == segment - 1) {
gameAction.over()
}
}
以此类推,要实现游戏暂停效果,说白了就是控制游戏中的所有元素停止移动,并且所有Action除了start
之外全部不可用。实现这个效果,只要对应Action和Compose组合函数的实现要加上以下代码即可解决。
//游戏不在进行中
if (gameState != GameState.Running) {
return
}
是不是很简单,就是这么简单。恢复游戏只要把State改成GameState.Running即可。
还是花费了很多时间去实现这个游戏的,因为Jetpack Compose是刚接触的知识点,并且之前也没游戏开发的经验,只能不断的试错,并反复阅读大佬们的文章,阅读官方文档,阅读源码抠细节,包括Kotlin语言的再学习,整个过程还是收获良多。
相比于写代码,写这个文章节奏要慢很多,一方面希望能够把知识点讲的通俗易懂,一方面又不能太啰嗦,直到最后写完还是感觉篇幅过长了。
对于这个游戏来说,有很多遗憾:如敌机的生成,出生的起点位置不够分散,容易出现敌机重叠的情况;关卡的设计太简单,可玩性不高;游戏分数没有做记录等等。
实际上这篇文章中秋的时候就写完了,但是感觉写的不太满意,写的时候代码也在不断修改中,部分代码甚至跟文章对应不上,就不想发出来了。上周末的时候突然想起,然后又优化了下,想了想,从学习Jetpack Compose的角度来说,写完这篇文章,目标已经达成了,还是分享出来吧,如果刚好对大家有一些帮助,那就更好了,感谢阅读。
TechMerger大佬的《一气呵成:用Compose完美复刻Flappy Bird!》
fundroid大佬的《用Jetpack Compose做一个俄罗斯方块游戏机》《100 行写一个 Compose 版华容道》
孙群大佬的[《[GitHub开源]Android自定义View实现微信打飞机游戏]》](https://blog.csdn.net/iispring/article/details/51999881)
官方文档《使用 Jetpack Compose 更快地打造更出色的应用》
不一一列举了,文章中涉及的知识点基本都加上了链接,方便大家阅读学习。