学不动了,尝试用Android Jetpack Compose重写微信经典飞机大战游戏
前段時間看了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.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 中將可變對象(如 ArrayList 或 mutableListOf())用作狀態會導致用戶在您的應用中看到不正確或陳舊的數據。
不可觀察的可變對象(如 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) } }思路如下:
?
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通信,參考代碼如下:
關鍵代碼:
/*** 游戲狀態*/ 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重写微信经典飞机大战游戏的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 拥抱POL正当时,这里有四个故事五个理由
- 下一篇: python中基例是什么意思_pytho