前段时间看了TechMerger大佬写的《一气呵成:用Compose完美复刻Flappy Bird!》,甚是有趣,按耐不住那躁动的心,笔者决定跟随大佬的脚步通过写游戏的方式学习Jetpack Compose,Let’s Go!

在这里也强推下fundroid大佬的《用Jetpack Compose做一个俄罗斯方块游戏机》《100 行写一个 Compose 版华容道》,十分精彩。

多看看大佬们的博文,受益匪浅,感谢分享。

《经典飞机大战》是腾讯交流软件微信5.0版本在2013年8月推出的软件内置经典小游戏,现在已经找不到了,但是有其它复刻的小游戏作为参照,本文主要介绍Jetpack Compose Api的一些使用方法,供大家参考。

1.游戏预览

玩家点击并移动自己的飞机,在躲避迎面而来的其它敌机时,飞机通过发射子弹打掉其它敌机来赢取分数。一旦撞上其它敌机,游戏就结束。感兴趣的小伙伴,微信小程序搜索飞机大战就可以直接玩。

或者Github下载源码导入安装体验:https://github.com/xiangang/AndroidDevelopmentPractices/tree/master/ComposePlane

2.游戏拆解

游戏主要由以下元素组成:
舞台背景,玩家飞机,子弹,音效(子弹射击音效,爆炸音效),敌机(小、中、大三种类型),动画(玩家飞机出场动画和爆炸动画,敌机爆炸),道具奖励,分数,游戏控制(开始,暂停,恢复,重开,退出)等。

2.1舞台背景

这个简单,画一张图就完事了,背景不需要运动。

2.2玩家飞机

玩家飞机,可以手指任意拖拽移动,发射子弹,并且有飞行动画,被敌机碰撞后会爆炸,每击落一个敌机即可获取对应份数,游戏过程中可通过碰撞获取子弹和爆炸道具奖励。

## 2.3子弹 子弹从玩家飞机头部处不断出现,沿Y轴负方向以一定的速度移动,但不能沿着X轴水平移动,击中敌机后会消耗敌机的生命值,同时子弹消失。

子弹分红色单发子弹和蓝色双发子弹两种类型,击打敌机的能力(每次敌机消耗的敌机生命值)不同,大小也不同(影响碰撞检测)。

2.4 敌机

敌机随机在屏幕上方出生,沿着Y轴正方向向下运动,但不能沿着X轴水平移动,也不会发射子弹。敌机分侦察机(小)、战斗机(中)、战舰(大)三种类型,飞行速度,抗击大能力各不相同。目前设计了三个难度,随着难度的升级,敌机的数量也会不断增多。

2.5爆炸动画

玩家飞机被敌机碰撞,敌机被子弹击落会触发爆炸动画。




2.5道具奖励

游戏过程中,随着游戏难度的增加,会随机生成道具奖励,提高玩家飞机的生存能力。道具奖励只有两种:子弹和炸弹。

2.6其它

游戏开始界面,显示Logo,玩家飞机,开始游戏按钮。

游戏中界面,左上角可暂停继续游戏,右上角显示分数,左下角显示炸弹道具,点击可引爆屏幕内所有敌机。

游戏结束界面,显示分数,重新开始和退出游戏按钮。

所有素材预览:

游戏素材来自于:
​https://github.com/iSpring/GamePlane/
https://github.com/zhangphil/Android-WeiXinDaFeiJi

3.游戏基础与架构

3.1基础概念

为了使本文更易于理解,会额外补充一些说明,不感兴趣建议跳过。

既然是做游戏开发,还是得先学习下游戏开发的基本概念,建议阅读《游戏开发基本概念》。Sprites是个用于角色、道具、炮弹以及其他2D游戏元素的二维图形对象。在2D游戏中,图像部分主要是图片的处理,图片通常称为精灵(Sprite)

精灵(Sprite) 对象需要可以被控制,可以在屏幕上移动,看下图的Android屏幕坐标系:

关于Android屏幕坐标系更多知识点,可以参考AWeiLoveAndroid的《Android应用坐标系统全面详解》

说白了,要使精灵(Sprite) 对象移动起来,就是要感知时间流逝,控制其坐标(x、y)发生变化。既然是对象,那就需要一个精灵(Sprite) 类。

/*** 精灵基类*/
@InternalCoroutinesApi
open class Sprite(open var id: Long = System.currentTimeMillis(), //idopen 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是14open 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.LIFEfun isDead() = state == SpriteState.DEATHopen 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 themeSurface(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实现发现的确很好用,不过为了展现不同的思路,于是部分逻辑又改成使用动画来实现,但是看效果每次动画结束重新开始的瞬间有感觉都明显的顿挫感,这个问题暂时没解决。

3.2状态和架构

状态:可以简单理解为随时间变化的任何值。

对于精灵(Sprite) 对象而言,我们需要更新其x、y属性(状态)并驱动界面中元素进行重新绘制,从而使View发生位移。

由于 Compose 是声明式工具集,因此更新它的唯一方法是通过新参数调用同一可组合函数。这些参数是界面状态的表现形式。每当状态更新时,都会发生重组。可组合函数必须明确获知新状态,才能相应地进行更新。如下图:

重新绘制界面元素,需要更新状态并使用新数据调用可组合函数,完成重组过程。但可组合函数本质就是一个函数,那就不能够在可组合函数里声明局部变量来管理状态,那应该怎么管理?

3.3.1可组合项中的状态管理

可组合函数使用 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 中将可变对象(如 ArrayListmutableListOf())用作状态会导致用户在您的应用中看到不正确或陈旧的数据。
不可观察的可变对象(如 ArrayList 或可变数据类)不能由 Compose 观察,因而 Compose 不能在它们发生变化时触发重组。
我们建议您使用可观察的数据存储器(如 State<List>)和不可变的 listOf(),而不是使用不可观察的可变对象。

3.3.2状态提升

上面的示例代码,状态是定义在可组合函数内部的。这样的方式优点是不依赖于外部,可独立使用。缺点是外部无法更改这个可组合函数内部的状态,难以跟其它可组合函数联动,这样一来,复用性就降低了。好的架构,应该是高复用的。那有什么办法可以解决这个问题?

使用状态提升。既然可组合函数内部的状态,不能被外部修改,那就把状态从内部移到外部即可。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 表达式》

像这种状态提升后变成状态下降、事件上升的模式称为“单向数据流”。通过遵循单向数据流,统一由最低公共上层可组合函数管理状态,从而使下层可组合函数解耦,这意味着最低公共上层可组合函数的修改几乎不影响下层可组合函数,这样一来下层可组合函数即可高效复用。

这里比较啰嗦,笔者刚开始看官方文档的时候,状态又是提升又是下降的,很晕,这里试图讲清楚,不知道有没有弄巧成拙。

3.3.3ViewModel状态管理

既然是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> = _gameScorefun 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() {@InternalCoroutinesApiprivate val gameViewModel: GameViewModel by viewModels()@InternalCoroutinesApi@ExperimentalComposeUiApi@ExperimentalAnimationApioverride fun onCreate(savedInstanceState: Bundle?) {super.onCreate(savedInstanceState)setContent {ComposePlaneTheme {// A surface container using the 'background' color from the themeSurface(color = MaterialTheme.colors.background) {//利用协程定时执行任务LaunchedEffect(key1 = Unit) {while (isActive) {delay(100)var score = gameViewModel.gameScore.valuegameViewModel.onGameScoreChange(++score)}}Stage(gameViewModel, onGameAction)}}}}
}@InternalCoroutinesApi
@ExperimentalComposeUiApi
@ExperimentalAnimationApi
@Preview()
@Composable
fun PreviewStage() {val gameViewModel: GameViewModel = viewModel()Stage(gameViewModel)
}

可以看到,状态管理是在ViewModel进行的,遵循单向数据流,Compose只负责显示UI。这样一来,ViewModel和Compose可组合函数就都可以复用了。

3.3.4游戏架构

建议先阅读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的函数可能未完整列出或名称上有所改动,实际以源码为准)

所有的精灵类如下:

3.3.5部分核心代码

游戏状态和动作定义:

/*** 游戏状态*/
enum class GameState {Waiting, // wait to startRunning, // gamingPaused, // pauseDying, // hit enemy and dyingOver, // overExit // finish activity
}/*** 游戏动作*/
@InternalCoroutinesApi
data class GameAction(val start: () -> Unit = {}, //游戏状态进入Running,游戏中val pause: () -> Unit = {},//游戏状态进入Paused,暂停val reset: () -> Unit = {},//游戏状态进入Waiting,显示GameWaitingval die: () -> Unit = {},//游戏状态进入Dying,触发爆炸动画val over: () -> Unit = {},//游戏状态进入Over,显示GameOverBoardval 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) {//idval 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.bulletAwardvar num = bulletAward and 0xFFFF //数量num += award.amountonPlayerAwardBullet(BULLET_DOUBLE shl 16 or num)}//奖励爆炸道具if (award.type == AWARD_BOMB) {val bombAward = playerPlaneStateFlow.value.bombAwardvar num = bombAward and 0xFFFF //数量num += award.amountonPlayerAwardBomb(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.onGameActionval 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》的内容,并加入了自己的理解,可能确实太啰嗦了,并且贴了较多代码,也不好,欢迎大家指正,提提意见。

4.玩家飞机控制和动画

从本章节到后面的章节,几乎都是介绍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(), //idoverride 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 <= 0override fun reBirth() {state = SpriteState.LIFEanimateIn = truex = startXy = startYprotect = PLAYER_PLANE_PROTECTbulletAward = 0bombAward = 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.widthPixelsval heightPixels = LocalContext.current.resources.displayMetrics.heightPixelsval 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))//游戏开始后,动画完成减少保护次数,直到为0if (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.xvar 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}))}
}

4.1拖拽控制

实现效果:

通过 pointerInput 修饰符使用拖动手势检测器,不断的调用GameAction的onPlayerPlaneMove(x, y)函数更新PlayerPlane的坐标就可以了。 pointerInput 的使用见官方文档《手势》。

Compose拖拽代码:

  Modifier.pointerInput(Unit) {detectDragGestures { change, dragAmount ->change.consumeAllChanges()var newOffsetX = playerPlane.xvar 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.valueplayerPlane.x = xplayerPlane.y = yif (playerPlane.animateIn) {playerPlane.animateIn = false}onPlayerPlaneStateFlowChange(playerPlane)}

4.2飞行动画

飞行动画通过循环显示和隐藏两张不同的图片来实现,一开始还在想怎么设置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}))}

5.子弹生成和射击

子弹的连续射击效果花了很多时间去调整,差强人意吧。
实现效果:

定义一个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(), //idoverride 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.bulletAwardvar bulletNum = bulletAward and 0xFFFF //数量val bulletType = bulletAward shr 16 //类型val bulletList = bulletListStateFlow.value as ArrayListval 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.valueval 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 = startXbullet.startY = startYbullet.x = bullet.startXbullet.y = bullet.startYbullet.init = true}

一开始只做了一颗子弹的射击效果,使用一个重复动画,不断的调整子弹的x、y值,从玩家飞机头部不断的沿Y轴负方向飞行指定的距离,到达指定距离后再周而复始的从玩家飞机头部继续飞行,但是这样的效果体验不好,必须等待子弹飞行完指定距离后才能重复利用。

后来在此基础上改用一个List维护Bullet对象,复用List里的Bullet对象,每次动画值发生改变时,for循环更新所有子弹的状态,并且Bullet对象发生碰撞或非出飞出屏幕即可重新复用,这样一来效果比之前的好很多了。

6.敌机飞行和爆炸

实现效果:

定义一个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(), //idoverride 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,大飞机是6override 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) <= 0fun bomb() {hit = power}override fun reBirth() {state = SpriteState.LIFEhit = 0}override fun die() {state = SpriteState.DEATHbombX = xbombY = y}}

6.1敌机飞行

分析:

关键代码:

/*** 敌机* 只能沿着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.widthPixelsval 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!! * 21 -> newY -= enemyPlaneHeightPx!! * 42 -> newY -= enemyPlaneHeightPx!! * 10}enemyPlane.y = newYLogUtil.printLog(message = "enemyPlaneMove: newY $newY ")LogUtil.printLog(message = "enemyPlaneMove: id = ${enemyPlane.id},type = ${enemyPlane.type}, x = ${enemyPlane.x}, y = ${enemyPlane.y} ")enemyPlane.init = trueenemyPlane.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值,达到敌机飞行的效果。

6.2敌机爆炸

如果让你来实现一键触发所有敌机爆炸动画的功能,你会怎么设计?

这里讲下笔者的思路,一开始是打算直接在敌机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.valuevar countScore = 0withContext(Dispatchers.Default) {for (enemyPlane in listEnemyPlane) {//存活并且在屏幕内if (enemyPlane.isAlive() && !enemyPlane.isNoPower() && enemyPlane.y > 0 && enemyPlane.y < getApplication<Application>().resources.displayMetrics.heightPixels) {countScore += enemyPlane.valueenemyPlane.bomb()//能量归零就爆炸}}_enemyPlaneListStateFlow.emit(listEnemyPlane)}//更新分数gameScoreStateFlow.value.plus(countScore).let { onGameScoreStateFlowChange(it) }//爆炸道具减1val bombAward = playerPlaneStateFlow.value.bombAwardvar bombNum = bombAward and 0xFFFF //数量val bombType = bombAward shr 16 //类型if (bombNum-- <= 0) {bombNum = 0}onPlayerAwardBomb(bombType shl 16 or bombNum)}}

关于爆炸动画,放到下一章节讲解。

7.碰撞检测和爆炸动画

7.1碰撞检测

碰撞检测有很多种,这里用的是矩形碰撞,感兴趣的小伙伴可以直接搜索学习。

如上图,以敌机为视角,敌机所属的红色区域是危险区域,子弹和玩家飞机的矩形框只要触碰红色区域则代表发生碰撞检测,而绿色区域则是安全区域。

关键代码:


/*** 精灵工具类*/
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.valueval 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.valueif (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分别传入子弹和玩家飞机对象的矩形数据进行比较,得出碰撞检测结果,根据结果执行对应的游戏逻辑。

7.2爆炸动画

爆炸动画的素材如下,这实际上就是帧动画了。

关键代码:

/*** 测试爆炸动画*/
@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 = 33easing = 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 } - startTimeanimationSegmentIndex = 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.widthval bombWidthWidthPx = with(LocalDensity.current) { bombWidth.toPx() }//这里使用修改ImageBitmap.imageResource返回bitmap方便处理val bitmap: Bitmap = imageResource(bomb.bombDrawableId)//分割Bitmapval 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.currentval 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) }
}

思路如下:

  1. 如何加载帧动画素材?使用imageResource函数加载帧动画素材即可得到一个Bitmap对象。
  2. 动画素材和对应的Sprite对象宽高不一致?使用Matrix的postScale函数进行缩放即可,这里只要保持高度一致。
  3. 得到的帧动画Bitmap怎么用?使用Bitmap.createBitmap函数获取局部的Bitmap并显示即可。这里定义一个animationSegmentIndex作为当前要显示的动画帧的下标,通过横向切割,在Btimap高度一致的情况下,只要计算createBitmap函数的x参数即可通过偏移量定位并获取到当前动画帧的Bitmap,最后通过asImageBitmap把Bitmap转成ImageBitmap,使用drawImage显示图片。
  4. 如何确定当前的动画帧下标?使用TargetBasedAnimation动画得到animationSegmentIndex从0到对应爆炸动画素材总帧数的变化过程,即可驱动Compose函数显示对应动画帧,当然时间上也要控制以下,这样连续的播放动画帧,动画效果就出来了。
  5. 如何触发动画?由于LaunchedEffect动画可以观察状态来触发,因此想要播放爆炸动画的时候,我们只需要修改LaunchedEffect观察的状态即可,动画播放结束后再改回去,注意动画是否显示是通过alpha值来控制的。

8.其它

有了以上知识点的铺垫,其它功能,如分数的显示和计算,道具奖励的生成和获取等就很简单了,这里不在赘述,有兴趣可查看源码,注释还是比较详细的。

9.游戏控制

游戏控制可以认为就是游戏状态管理,定义GameState和GameAction,通过GameViewModel来管理,高内聚低耦合可复用。其中GameState定义了游戏的状态,通过GameAction驱动状态转换,构造一个完整的有限状态机。要注意的是State和Action并不是一一对应的。

参考资料《深入浅出理解有限状态机》

  • Wating:游戏开始状态,看图就懂了。
  • Running:游戏中状态,看图就懂了。
  • Paused:游戏暂停状态,同上,看图就懂了,就是一切元素和状态都不会发生变化。

  • Dying:玩家飞机死亡状态,用于触发玩家飞机爆炸动画,这个看起来没有必要放到GameState里吧?确实没有必要,去掉完全不影响,也可以通过在Compose可组合函数内部定义一个state来实现。但如果你有其它需求,比如玩家飞机爆炸时,子弹、敌机全部消失,加上这个Dying就很方便处理了,各有各的好,架构也不是死的,可以根据实际需要进行调整。

  • Over:游戏结束状态,玩家飞机爆炸动画播放完毕就自动进入Over状态,如下图。

  • Exit:用于退出游戏,看起来也是多余的?退出游戏就是调用Activity的finish方法,但是在GameViewModel并不会直接依赖Activity,那就调不到finish方法了,怎么办?推荐的方式是在Activity中观察GameViewModel提供的公开的状态,实现ViewModel和Activity通信,参考代码如下:
//观察游戏状态lifecycleScope.launch {gameViewModel.gameStateFlow.collect {LogUtil.printLog(message = "lifecycleScope gameState $it")//退出appif (GameState.Exit == it) {finish()}}}

关键代码:

/*** 游戏状态*/
enum class GameState {Waiting, // wait to startRunning, // gamingPaused, // pauseDying, // hit enemy and dyingOver, // overExit // finish activity
}/*** 游戏动作*/
@InternalCoroutinesApi
data class GameAction(val start: () -> Unit = {}, //游戏状态进入Running,游戏中val pause: () -> Unit = {},//游戏状态进入Paused,暂停val reset: () -> Unit = {},//游戏状态进入Waiting,显示GameWaitingval die: () -> Unit = {},//游戏状态进入Dying,触发爆炸动画val over: () -> Unit = {},//游戏状态进入Over,显示GameOverBoardval 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.dpval spriteSizePx = with(LocalDensity.current) { spriteSize.toPx() }val segment = playerPlane.segmentval 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 } - startTimeanimationValue = 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)//分割Bitmapval displayBitmapWidth = bitmap.width / segmentval 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.widthval canvasHeight = size.heightdrawImage(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即可。

10.总结

还是花费了很多时间去实现这个游戏的,因为Jetpack Compose是刚接触的知识点,并且之前也没游戏开发的经验,只能不断的试错,并反复阅读大佬们的文章,阅读官方文档,阅读源码抠细节,包括Kotlin语言的再学习,整个过程还是收获良多。

相比于写代码,写这个文章节奏要慢很多,一方面希望能够把知识点讲的通俗易懂,一方面又不能太啰嗦,直到最后写完还是感觉篇幅过长了。

对于这个游戏来说,有很多遗憾:如敌机的生成,出生的起点位置不够分散,容易出现敌机重叠的情况;关卡的设计太简单,可玩性不高;游戏分数没有做记录等等。

实际上这篇文章中秋的时候就写完了,但是感觉写的不太满意,写的时候代码也在不断修改中,部分代码甚至跟文章对应不上,就不想发出来了。上周末的时候突然想起,然后又优化了下,想了想,从学习Jetpack Compose的角度来说,写完这篇文章,目标已经达成了,还是分享出来吧,如果刚好对大家有一些帮助,那就更好了,感谢阅读。

11.参考资料

TechMerger大佬的《一气呵成:用Compose完美复刻Flappy Bird!》

fundroid大佬的《用Jetpack Compose做一个俄罗斯方块游戏机》《100 行写一个 Compose 版华容道》

孙群大佬的[《[GitHub开源]Android自定义View实现微信打飞机游戏]》](https://blog.csdn.net/iispring/article/details/51999881)

官方文档《使用 Jetpack Compose 更快地打造更出色的应用》

不一一列举了,文章中涉及的知识点基本都加上了链接,方便大家阅读学习。

学不动了,尝试用Android Jetpack Compose重写微信经典飞机大战游戏相关推荐

  1. Android: Jetpack Compose如何禁用涟漪(水波纹)效果

    系列文章目录 Android: Jetpack Compose如何禁用涟漪(水波纹)效果 Android:使用Jetpack Compose 实现Text控件跑马灯效果 Android:使用Jetpa ...

  2. Android JetPack Compose初步2~实现可滚动列表的功能

    Android JetPack Compose的配置参考Android JetPack Compose初步1 在本应用中定义可滚动的列表的界面,类似RecyclerView组件的显示效果. 一.定义实 ...

  3. Android Jetpack Compose

    Android Jetpack Compose 一.什么是Jetpack Compose 二.关于Jetpack Compase的介绍 Jetpack Compose的特点 Jetpack Compo ...

  4. Android studio飞机大战游戏分析-月末总结

    整体实现思路 绘制循环滚动的背景图片创建BackGround类 绘制飞机和子弹.创建Myplane和Bullet类 在Myplane中构造isCollision绘制飞机与boss飞机的碰撞,飞机与子弹 ...

  5. 基于Android的飞机大战游戏的设计与实现

    在2007年11月5日谷歌公司发布了一款全新的面向智能移动端设备的操作系统,这就是Android.经历了几年市场的洗礼,Android凭借其优异的性能占据了大部分智能手机市场.根据最新的调查显示,An ...

  6. python飞机大战概要设计_飞机大战游戏开发文档(Android版)

    飞机大战游戏 开发文档 (Android版) 课程名称:飞机大战游戏 课程类型:Android游戏编程精彩内容,尽在百度攻略:https://gl.baidu.com 姓名:苏均灿 学号:131342 ...

  7. (转)Android Jetpack Compose 最全上手指南

    在今年的Google/IO大会上,亮相了一个全新的 Android 原生 UI 开发框架-Jetpack Compose, 与苹果的SwiftIUI一样,Jetpack Compose是一个声明式的U ...

  8. 原创|Android Jetpack Compose 最全上手指南

    在今年的Google/IO大会上,亮相了一个全新的 Android 原生 UI 开发框架-Jetpack Compose, 与苹果的SwiftIUI一样,Jetpack Compose是一个声明式的U ...

  9. Android Jetpack Compose 最全上手指南 | 开发者说·DTalk

    本文原作者: 码农西哥,原文发布于微信公众号: Android 技术杂货铺  https://mp.weixin.qq.com/s/7tKv_RamfW0rG8tZHXH_rg 在 2019 年的 G ...

最新文章

  1. saiku+kettle整合(二)数据装载
  2. Android中在使用Room时提示:Cannot figure out how to save this field into database. You can consider adding a
  3. matplotlib绘制多张图、多子图、多例图
  4. java对象底层原存储结构图解_图解图库JanusGraph系列-一文知晓“图数据“底层存储结构...
  5. PAT学习资料汇总(PAT甲级、PAT顶级、PAT考试经验)
  6. spring中事务失效的几种情况
  7. SCADA之父:物理隔离没什么用
  8. 无法加载 Chrome PDF Viewer
  9. SHA256算法可逆吗,SHA256算法流程步骤
  10. Rockchip RK3288 Datasheet芯片手册资料
  11. Django笔记09:一招解决使用regroup模板标签出现的重复分组问题
  12. 计算机与网络安全系列书籍推荐
  13. Android N Idle模式分析
  14. 分析盘点44,630,000条攻击数据后,创宇蜜罐发现——
  15. AI 开发者被疯抢,华为做了什么?
  16. Facebook老员工的十点经验
  17. HSV色彩空间筛选 2021-10-06
  18. No module named 'exceptions'
  19. 人工智能会成为下一场的科技革命吗?
  20. Vim配置及使用技巧

热门文章

  1. 谈谈自己对目前新型冠状病毒疫情的想法
  2. Golang 项目配置文件读取之 viper 实践
  3. SQL注入与ASP木马上传
  4. svn发布网站(转载于badb0y)
  5. python 海龟交易法则_【手把手教你】用Python量化海龟交易法则
  6. 关于东野圭吾的《无名之町》读后感
  7. Jitter的基本知识
  8. win8能直接升级到win11吗
  9. 问题分析报告--简单SQL启动MR
  10. CSCLa测试指标 (照明研究中心) 计算软件