利用Jetpack?Compose復(fù)刻游戲Flappy?Bird
Flappy Bird是13年紅極一時(shí)的小游戲,其簡單有趣的玩法和變態(tài)的難度形成了強(qiáng)烈反差,引發(fā)全球玩家競相把玩,欲罷不能!遂選擇復(fù)刻這個(gè)小游戲,在實(shí)現(xiàn)的過程中向大家演示Compose工具包的UI組合、數(shù)據(jù)驅(qū)動等重要思想。
1.拆解游戲
不記得這個(gè)游戲或完全沒玩過的朋友,可以點(diǎn)擊下面的鏈接,體驗(yàn)一下Flappy Bird的玩法。
為拆解游戲,筆者也錄了一段游戲過程。

反復(fù)觀看這段GIF,可以發(fā)現(xiàn)游戲的一些規(guī)律:
- 遠(yuǎn)處的建筑和近處的土壤是靜止不動的
- 小鳥一直在上下移動,伴隨著翅膀和身體的飛翔姿態(tài)
- 管道和路面則不斷地向左移動,營造出小鳥向前飛翔的視覺效果

通過截圖、切圖、填充像素和簡單的PS,可以拿到各元素的圖片。

2.復(fù)刻畫面
各方卡司已就位,接下來開始布置整個(gè)畫面。暫不實(shí)現(xiàn)元素的移動效果,先把靜態(tài)的整體效果搭建好。
ⅰ.布置遠(yuǎn)近景
靜止不動的建筑遠(yuǎn)景最為簡單,封裝到可組合函數(shù)FarBackground里,內(nèi)部放置一張圖片即可。
@Composable
fun?FarBackground(modifier:?Modifier)?{
????Column?{
????????Image(
????????????painter?=?painterResource(id?=?R.drawable.background),
????????????contentScale?=?ContentScale.FillBounds,
????????????contentDescription?=?null,
????????????modifier?=?modifier.fillMaxSize()
????????)
????}
}遠(yuǎn)景的下面由分割線、路面和土壤組成,封裝到NearForeground函數(shù)里。通過Modifier的fraction參數(shù)控制路面和土壤的比例,保證在不同尺寸屏幕上能按比例呈現(xiàn)游戲界面。
@Composable
fun?NearForeground(...)?{
????Column(?modifier?)?{
????????//?分割線
????????Divider(
????????????color?=?GroundDividerPurple,
????????????thickness?=?5.dp
????????)
????????//?路面
????????Box(modifier?=?Modifier.fillMaxWidth())?{
????????????Image(
????????????????painter?=?painterResource(id?=?R.drawable.foreground_road),
????????????????...
????????????????modifier?=?modifier
????????????????????.fillMaxWidth()
????????????????????.fillMaxHeight(0.23f)
????????????????)
????????????}
????????}
????????//?土壤
????????Image(
????????????painter?=?painterResource(id?=?R.drawable.foreground_earth),
???????????...
????????????modifier?=?modifier
????????????????.fillMaxWidth()
????????????????.fillMaxHeight(0.77f)
????????)
????}
}將整個(gè)游戲畫面抽象成GameScreen函數(shù),通過Column豎著排列遠(yuǎn)景和前景。考慮到移動的小鳥和管道需要呈現(xiàn)在遠(yuǎn)景之上,所以在遠(yuǎn)景的外面包上一層Box組件。
@Composable
fun?GameScreen(?...?)?{
????Column(?...??)?{
????????Box(modifier?=?Modifier
????????????.align(Alignment.CenterHorizontally)
????????????.fillMaxWidth()
????????)?{
????????????FarBackground(Modifier.fillMaxSize())
????????}
????????Box(modifier?=?Modifier
????????????.align(Alignment.CenterHorizontally)
????????????.fillMaxWidth()
????????)?{
????????????NearForeground(
????????????????modifier?=?Modifier.fillMaxSize()
????????????)
????????}
????}
}
ⅱ.擺放管道
仔細(xì)觀察管道,會發(fā)現(xiàn)一些管道具備朝上朝下、高度隨機(jī)的特點(diǎn)。為此將管道的視圖分拆成蓋子和柱子兩部分:
- 蓋子和柱子的放置順序決定管道的朝向
- 柱子的高度則控制著管道整體的高度 這樣的話,只使用蓋子和柱子兩張圖片,就可以靈活實(shí)現(xiàn)各種形態(tài)的管道。
先來組合蓋子PipeCover和柱子PipePillar的可組合函數(shù)。
@Composable
fun?PipeCover()?{
????Image(
????????painter?=?painterResource(id?=?R.drawable.pipe_cover),
????????contentScale?=?ContentScale.FillBounds,
????????contentDescription?=?null,
????????modifier?=?Modifier.size(PipeCoverWidth,?PipeCoverHeight)
????)
}
@Composable
fun?PipePillar(modifier:?Modifier?=?Modifier,?height:?Dp?=?90.dp)?{
????Image(
????????painter?=?painterResource(id?=?R.drawable.pipe_pillar),
????????contentScale?=?ContentScale.FillBounds,
????????contentDescription?=?null,
????????modifier?=?modifier.size(50.dp,?height)
????)
}管道的可組合函數(shù)Pipe可以根據(jù)照朝向和高度的參數(shù),組合成對應(yīng)的管道。
@Composable
fun?Pipe(?
????height:?Dp?=?HighPipe,
????up:?Boolean?=?true
)?{
????Box(?...?)?{
????????Column?{
????????????if?(up)?{
????????????????PipePillar(Modifier.align(CenterHorizontally),?height?-?30.dp)
????????????????PipeCover()
????????????}?else?{
????????????????PipeCover()
????????????????PipePillar(Modifier.align(CenterHorizontally),?height?-?30.dp)
????????????}
????????}
????}
}
另外,管道都是成對出現(xiàn)、且無論高度如何中間的間距是固定的。所以我們再實(shí)現(xiàn)一個(gè)管道組的可組合函數(shù)PipeCouple。
@Composable
fun?PipeCouple(?...?)?{
????Box(...)?{
????????GetUpPipe(height?=?upHeight,
????????????modifier?=?Modifier
????????????????.align(Alignment.TopEnd)
????????)
????????GetDownPipe(height?=?downHeight,
????????????modifier?=?Modifier
????????????????.align(Alignment.BottomEnd)
????????)
????}
}將PipeCouple添加到FarBackground的下面,管道就放置完畢了。
@Composable
fun?GameScreen(?...?)?{
????Column(...)?{
????????Box(...)?{
????????????FarBackground(Modifier.fillMaxSize())
????????????
????????????//?管道對添加遠(yuǎn)景上去
????????????PipeCouple(
????????????????modifier?=?Modifier.fillMaxSize()
????????????)
????????}
????????...
????}
}
ⅲ.放置小鳥
小鳥通過Image組件即可實(shí)現(xiàn),默認(rèn)情況下放置到布局的Center方位。
@Composable
fun?Bird(?...?)?{
????Box(?...?)?{
????????Image(
????????????painter?=?painterResource(id?=?R.drawable.bird_match),
????????????contentScale?=?ContentScale.FillBounds,
????????????contentDescription?=?null,
????????????modifier?=?Modifier
????????????????.size(BirdSizeWidth,?BirdSizeHeight)
????????????????.align(Alignment.Center)
????????)
????}
}視覺上小鳥呈現(xiàn)在管道的前面,所以Bird可組合函數(shù)要添加到管道組函數(shù)的后面。
@Composable
fun?GameScreen(?...?)?{
????Column(...)?{
????????Box(...)?{
????????????...
????????????PipeCouple(?...?)
????????????//?將小鳥添加到遠(yuǎn)景上去
????????????Bird(
????????????????modifier?=?Modifier.fillMaxSize(),
????????????????state?=?viewState
????????????)
????????}
????}
}
至此,各元素都放置完了。接下來著手讓小鳥,管道和路面這些動態(tài)元素動起來。
3.狀態(tài)管理和架構(gòu)
Compose中Modifier#offset()函數(shù)可以更改視圖在橫縱方向上的偏移值,通過不斷地調(diào)整這個(gè)偏移值,即可營造出動態(tài)的視覺效果。無論是小鳥還是管道和路面,它們的移動狀態(tài)都可以依賴這個(gè)思路。
那如何管理這些持續(xù)變化的偏移值數(shù)據(jù)?如何將數(shù)據(jù)反映到畫面上?
Compose通過State驅(qū)動可組合函數(shù)進(jìn)行重組,進(jìn)而達(dá)到畫面的重繪。所以我們將這些數(shù)據(jù)封到ViewState中,交由ViewModel框架計(jì)算和更新,Compose訂閱State之后驅(qū)動所有元素活動起來。除了個(gè)元素的偏移值數(shù)據(jù),State中還要存放游戲分值,游戲狀態(tài)等額外信息。
data?class?ViewState(
????val?gameStatus:?GameStatus?=?GameStatus.Waiting,
????//?小鳥狀態(tài)
????val?birdState:?BirdState?=?BirdState(),
????//?管道組狀態(tài)
????val?pipeStateList:?List<PipeState>?=?PipeStateList,
????var?targetPipeIndex:?Int?=?-1,
????//?路面狀態(tài)
????val?roadStateList:?List<RoadState>?=?RoadStateList,
????var?targetRoadIndex:?Int?=?-1,
????//?分值數(shù)據(jù)
????val?score:?Int?=?0,
????val?bestScore:?Int?=?0,
)
enum?class?GameStatus?{
????Waiting,
????Running,
????Dying,?
????Over
}用戶點(diǎn)擊屏幕會觸發(fā)游戲開始、重新開始、小鳥上升等動作,這些視圖上的事件需要反向傳遞給ViewModel處理和做出響應(yīng)。事件由Clickable數(shù)據(jù)類封裝,再轉(zhuǎn)為對應(yīng)的GameAction發(fā)送到ViewModel中。
data?class?Clickable(
????val?onStart:?()?->?Unit?=?{},
????val?onTap:?()?->?Unit?=?{},
????val?onRestart:?()?->?Unit?=?{},
????val?onExit:?()?->?Unit?=?{}
)
sealed?class?GameAction?{
????object?Start?:?GameAction()
????object?AutoTick?:?GameAction()
????object?TouchLift?:?GameAction()
????object?Restart?:?GameAction()
}
前面說過,可以不斷調(diào)整下Offset數(shù)據(jù)使得視圖動起來。具體實(shí)現(xiàn)可以通過LaunchedEffect啟動一個(gè)定時(shí)任務(wù),定期發(fā)送一個(gè)更新視圖的動作AutoTick。注意:Compose里獲取ViewModel實(shí)例發(fā)生NoSuchMethodError錯(cuò)誤的話,記得按照官方構(gòu)建的版本重新Sync一下。
setContent?{
????FlappyBirdTheme?{
????????Surface(color?=?MaterialTheme.colors.background)?{
????????????val?gameViewModel:?GameViewModel?=?viewModel()
????????????LaunchedEffect(key1?=?Unit)?{
????????????????while?(isActive)?{
????????????????????delay(AutoTickDuration)
????????????????????gameViewModel.dispatch(GameAction.AutoTick)
????????????????}
????????????}
????????????Flappy(Clickable(
????????????????onStart?=?{
????????????????????gameViewModel.dispatch(GameAction.Start)
????????????????}...
????????????))
????????}
????}ViewModel收到Action后開啟協(xié)程,計(jì)算視圖的位置、更新對應(yīng)State,之后發(fā)射出去。
class?GameViewModel?:?ViewModel()?{
????fun?dispatch(...)?{
????????response(action,?viewState.value)
????}
????private?fun?response(action:?GameAction,?state:?ViewState)?{
????????viewModelScope.launch?{
????????????withContext(Dispatchers.Default)?{
????????????????emit(when?(action)?{
????????????????????GameAction.AutoTick?->?run?{
????????????????????????//?路面,管道組以及小鳥移動的新State獲取
????????????????????????...
???????????????????????state.copy(
????????????????????????????gameStatus?=?GameStatus.Running,
????????????????????????????birdState?=?newBirdState,
????????????????????????????pipeStateList?=?newPipeStateList,
????????????????????????????roadStateList?=?newRoadStateList
????????????????????????)
????????????????????}
????????????????????...
????????????????})
????????????}
????????}
????}
}4.路面動起來
如果畫面上只放一張路面圖片,更改X軸Offset的話,剩余的部分會沒有路面,無法呈現(xiàn)出不斷移動的效果。
思前想后,發(fā)現(xiàn)放置兩張路面圖片可以解決:一張放在屏幕外側(cè),一張放在屏幕內(nèi)側(cè)。游戲的過程中同時(shí)同方向移動兩張圖片,當(dāng)前一張圖片移出屏幕后重置其位置,進(jìn)而營造出道路不斷移動的效果。

@Composable
fun?NearForeground(?...?)?{
????val?viewModel:?GameViewModel?=?viewModel()
????Column(?...?)?{
????????...
????????//?路面
????????Box(modifier?=?Modifier.fillMaxWidth())?{
????????????state.roadStateList.forEach?{?roadState?->
????????????????Image(
????????????????????...
????????????????????modifier?=?modifier
????????????????????????...
?????????????????????????//?不斷調(diào)整路面在x軸的偏移值
????????????????????????.offset(x?=?roadState.offset)
????????????????)
????????????}
????????}
????????...
????????if?(state.playZoneSize.first?>?0)?{
????????????state.roadStateList.forEachIndexed?{?index,?roadState?->
????????????????//?任意路面的偏移值達(dá)到兩張圖片位置差的時(shí)候
????????????????//?重置路面位置,重新回到屏幕外
????????????????if?(roadState.offset?<=?-?TempRoadWidthOffset)?{
????????????????????viewModel.dispatch(GameAction.RoadExit,?roadIndex?=?index)
????????????????}
????????????}
????????}
????}
}ViewModel收到RoadExit的Action之后通知路面State進(jìn)行位置的重置。
class?GameViewModel?:?ViewModel()?{
????private?fun?response(action:?GameAction,?state:?ViewState)?{
????????viewModelScope.launch?{
????????????withContext(Dispatchers.Default)?{
????????????????emit(when?(action)?{
????????????????????GameAction.RoadExit?->?run?{
????????????????????????val?newRoadState:?List<RoadState>?=
????????????????????????????if?(state.targetRoadIndex?==?0)?{
????????????????????????????????listOf(state.roadStateList[0].reset(),?state.roadStateList[1])
????????????????????????????}?else?{
????????????????????????????????listOf(state.roadStateList[0],?state.roadStateList[1].reset())
????????????????????????????}
????????????????????????state.copy(
????????????????????????????gameStatus?=?GameStatus.Running,
????????????????????????????roadStateList?=?newRoadState
????????????????????????)
????????????????????}
????????????????})
????????????}
????????}
????}
}
data?class?RoadState?(var?offset:?Dp?=?RoadWidthOffset)?{
????//?移動路面
????fun?move():?RoadState?=?copy(offset?=?offset?-?RoadMoveVelocity)
????//?重置路面
????fun?reset():?RoadState?=?copy(offset?=?TempRoadWidthOffset)
}
5.管道動起來
設(shè)備屏幕寬度有限,同一時(shí)間最多呈現(xiàn)兩組管道就可以了。和路面運(yùn)動的思路類似,只需要放置兩組管道,就可以實(shí)現(xiàn)管道不停移動的視覺效果。
具體的話,兩組管道相隔一段距離放置,游戲中兩組管道一起同時(shí)向左移動。當(dāng)前一組管道運(yùn)動到屏幕外的時(shí)候,將其位置重置。

那如何計(jì)算管道移動到屏幕外的時(shí)機(jī)?
畫面重組的時(shí)候判斷管道偏移值是否達(dá)到屏幕寬度,YES的話向ViewModel發(fā)送管道重置的Action。
@Composable
fun?PipeCouple(
????modifier:?Modifier?=?Modifier,
????state:?ViewState?=?ViewState(),
????pipeIndex:?Int?=?0
)?{
????val?viewModel:?GameViewModel?=?viewModel()
????val?pipeState?=?state.pipeStateList[pipeIndex]
????Box(?...?)?{
????????//從State中獲取管道的偏移值,在重組的時(shí)候讓管道移動?
????????GetUpPipe(height?=?pipeState.upHeight,
????????????modifier?=?Modifier
????????????????.align(Alignment.TopEnd)
????????????????.offset(x?=?pipeState.offset)
????????)
????????GetDownPipe(...)
????????if?(state.playZoneSize.first?>?0)?{
????????????...
????????????//?移動到屏幕外的時(shí)候發(fā)送重置Action
????????????if?(pipeState.offset?<?-?playZoneWidthInDP)?{
????????????????viewModel.dispatch(GameAction.PipeExit,?pipeIndex?=?pipeIndex)
????????????}
????????}
????}
}ViewModel收到PipeExit的Action后發(fā)起重置管道數(shù)據(jù),并將更新發(fā)射出去。
class?GameViewModel?:?ViewModel()?{
????private?fun?response(action:?GameAction,?state:?ViewState)?{
????????viewModelScope.launch?{
????????????withContext(Dispatchers.Default)?{
????????????????emit(when?(action)?{
????????????????????GameAction.PipeExit?->?run?{
????????????????????????val?newPipeStateList:?List<PipeState>?=
????????????????????????????if?(state.targetPipeIndex?==?0)?{
????????????????????????????????listOf(
????????????????????????????????????state.pipeStateList[0].reset(),
????????????????????????????????????state.pipeStateList[1]
????????????????????????????????)
????????????????????????????}?else?{
????????????????????????????????listOf(
????????????????????????????????????state.pipeStateList[0],
????????????????????????????????????state.pipeStateList[1].reset()
????????????????????????????????)
????????????????????????????}
????????????????????????state.copy(
????????????????????????????pipeStateList?=?newPipeStateList
????????????????????????)
????????????????????}
????????????????})
????????????}
????????}
????}
}但相比路面,管道還具備高度隨機(jī)、間距固定的特性。所以重置位置的同時(shí)記得將柱子的高度隨機(jī)賦值,并給另一根柱子賦值剩余的高度。
data?class?PipeState?(
????var?offset:?Dp?=?FirstPipeWidthOffset,
????var?upHeight:?Dp?=?ValueUtil.getRandomDp(LowPipe,?HighPipe),
????var?downHeight:?Dp?=?TotalPipeHeight?-?upHeight?-?PipeDistance
)?{
????//?移動管道
????fun?move():?PipeState?=
????????copy(offset?=?offset?-?PipeMoveVelocity)
????//?重置管道
????fun?reset():?PipeState?{
????????//?隨機(jī)賦值上面管道的高度
????????val?newUpHeight?=?ValueUtil.getRandomDp(LowPipe,?HighPipe)
????????return?copy(
????????????offset?=?FirstPipeWidthOffset,
????????????upHeight?=?newUpHeight,
????????????//?下面管道的高度由差值賦值
????????????downHeight?=?TotalPipeHeight?-?newUpHeight?-?PipeDistance
????????)
????}
}
需要留意一點(diǎn)的是,如果希望管道組出現(xiàn)的節(jié)奏固定,那么管道組之間的橫向間距(不是上下管道的間距)始終需要保持一致。為此兩組管道初始的Offset數(shù)據(jù)要遵循一些規(guī)則,此處省略計(jì)算的過程,大概規(guī)則如下。
val?FirstPipeWidthOffset?=?PipeCoverWidth?*?2 //?第二組管道的offset等于 //?屏幕寬度?加上?三倍第一組管道offset?的一半 val?SecondPipeWidthOffset?=?(TotalPipeWidth?+?FirstPipeWidthOffset?*?3)?/?2 val?PipeStateList?=?listOf( ????PipeState(), ????PipeState(offset?=?(SecondPipeWidthOffset)) )
6.小鳥飛起來
不斷調(diào)整小鳥圖片在Y軸上的偏移值可以實(shí)現(xiàn)小鳥的上下移動。但相較于路面和管道,小鳥的需要些特有的處理:
- 監(jiān)聽用戶的點(diǎn)擊事件,向上調(diào)整偏移值實(shí)現(xiàn)上升效果
- 在上升和下降的過程中,調(diào)整小鳥的
Rotate角度,以演示運(yùn)動的姿態(tài) - 在觸碰到路面的時(shí)刻,發(fā)送
HitGround的Action停止游戲
@Composable
fun?GameScreen(...)?{
????...
????Column(
????????modifier?=?Modifier
????????????.fillMaxSize()
????????????.background(ForegroundEarthYellow)
????????????.run?{
????????????????pointerInteropFilter?{
????????????????????when?(it.action)?{
????????????????????????//?監(jiān)聽點(diǎn)擊事件,觸發(fā)游戲開始或小鳥上升
????????????????????????ACTION_DOWN?->?{
????????????????????????????if?(viewState.gameStatus?==?GameStatus.Waiting)
????????????????????????????????clickable.onStart()
????????????????????????????else?if?(viewState.gameStatus?==?GameStatus.Running)
????????????????????????????????clickable.onTap()
????????????????????????}
????????????????????????...
????????????????????}
????????????????????false
????????????????}
????????????}
????)?{?...?}
}小鳥根據(jù)State的Offset數(shù)據(jù)開始移動和調(diào)整姿態(tài),同時(shí)在觸地的時(shí)候告知ViewModel。因?yàn)橄陆档钠浦嫡`差可能導(dǎo)致觸地的那刻小鳥位置發(fā)生偏差,所以在小鳥下落到路面的臨界點(diǎn)后需要手動調(diào)整下Offset值。
@Composable
fun?Bird(...)?{
????...
????//?根據(jù)小鳥上升或下降的狀態(tài)調(diào)整小鳥的Roate角度
????val?rotateDegree?=
????????if?(state.isLifting)?LiftingDegree
????????else?if?(state.isFalling)?FallingDegree
????????else?PendingDegree
????Box(...)?{
????????var?correctBirdHeight?=?state.birdState.birdHeight
????????if?(state.playZoneSize.second?>?0)?{
????????????...
????????????val?fallingThreshold?=?BirdHitGroundThreshold
????????????//?小鳥偏移值達(dá)到背景邊界時(shí)發(fā)送落地Action
????????????if?(correctBirdHeight?+?fallingThreshold?>=?playZoneHeightInDP?/?2)?{
????????????????viewModel.dispatch(GameAction.HitGround)
????????????????//?修改下offset值避免下落到臨界位置的誤差
????????????????correctBirdHeight?=?playZoneHeightInDP?/?2?-?fallingThreshold
????????????}
????????}
????????Image(
????????????...
????????????modifier?=?Modifier
????????????????.size(BirdSizeWidth,?BirdSizeHeight)
????????????????.align(Alignment.Center)
????????????????.offset(y?=?correctBirdHeight)
?????????????????//?將旋轉(zhuǎn)角度應(yīng)用到小鳥,展示飛翔姿態(tài)
????????????????.rotate(rotateDegree)
????????)
????}
}
7.碰撞和實(shí)時(shí)分值
動態(tài)的元素都實(shí)現(xiàn)好了,下一步開始安排碰撞算法,并將實(shí)時(shí)分值同步展示到游戲上方。
仔細(xì)思考,發(fā)現(xiàn)當(dāng)管道組移動到小鳥飛翔區(qū)域的時(shí)候,計(jì)算小鳥是否處在管道區(qū)域即可判斷是否產(chǎn)生了碰撞。而當(dāng)管道移動出小鳥飛翔范圍的時(shí)候,即可判定小鳥成功穿過了管道,開始計(jì)分。
如下圖所示當(dāng)管道移動到小鳥飛翔區(qū)域的時(shí)候,紅色部分為危險(xiǎn)地帶,綠色部分才是安全區(qū)域。

@Composable
fun?GameScreen(...)?{
????...
????Column(...)?{
????????Box(...)?{
????????????...
????????????//?添加實(shí)時(shí)展示分值的Text組件
????????????ScoreBoard(
????????????????modifier?=?Modifier.fillMaxSize(),
????????????????state?=?viewState,
????????????????clickable?=?clickable
????????????)
????????????//?遍歷兩個(gè)管道組,檢查小鳥的穿過狀態(tài)
????????????if?(viewState.gameStatus?==?GameStatus.Running)?{
????????????????viewState.pipeStateList.forEachIndexed?{?pipeIndex,?pipeState?->
????????????????????CheckPipeStatus(
????????????????????????viewState.birdState.birdHeight,
????????????????????????pipeState,
????????????????????????playZoneWidthInDP,
????????????????????????playZoneHeightInDP
????????????????????).also?{
????????????????????????when?(it)?{
????????????????????????????//?碰撞到管道的話通知ViewModel,安排墜落
????????????????????????????PipeStatus.BirdHit?->?{
????????????????????????????????viewModel.dispatch(GameAction.HitPipe)
????????????????????????????}
????????????????????????????//?成功通過的話通知ViewModel計(jì)分
????????????????????????????PipeStatus.BirdCrossed?->?{
????????????????????????????????viewModel.dispatch(GameAction.CrossedPipe,?pipeIndex?=?pipeIndex)
????????????????????????????}
????????????????????????}
????????????????????}
????????????????}
????????????}
????????}
????}
}
@Composable
fun?CheckPipeStatus(...):?PipeStatus?{
????//?管道尚未移動到小鳥運(yùn)動區(qū)域
????if?(pipeState.offset?-?PipeCoverWidth?>?-?zoneWidth?/?2?+?BirdSizeWidth?/?2)?{
????????return?PipeStatus.BirdComing
????}?else?if?(pipeState.offset?-?PipeCoverWidth?<?-?zoneWidth?/?2?-?BirdSizeWidth?/?2)?{
????????//?小鳥成功穿過管道
????????return?PipeStatus.BirdCrossed
????}?else?{
????????val?birdTop?=?(zoneHeight?-?BirdSizeHeight)?/?2?+?birdHeightOffset
????????val?birdBottom?=?(zoneHeight?+?BirdSizeHeight)?/?2?+?birdHeightOffset
????????//?管道移動到小鳥運(yùn)動區(qū)域并和小鳥重合
????????if?(birdTop?<?pipeState.upHeight?||?birdBottom?>?zoneHeight?-?pipeState.downHeight)?{
????????????return?PipeStatus.BirdHit
????????}
????????return?PipeStatus.BirdCrossing
????}
?}ViewModel收到碰撞HitPipe和穿過管道CrossedPipe的Action后進(jìn)行墜落或計(jì)分的處理。
class?GameViewModel?:?ViewModel()?{
????private?fun?response(action:?GameAction,?state:?ViewState)?{
????????viewModelScope.launch?{
????????????withContext(Dispatchers.Default)?{
????????????????emit(when?(action)?{
????????????????????GameAction.HitPipe?->?run?{
????????????????????????//?撞擊到管道后快速墜落
????????????????????????val?newBirdState?=?state.birdState.quickFall()
????????????????????????state.copy(
????????????????????????????//?并將游戲Status更新為Dying
????????????????????????????gameStatus?=?GameStatus.Dying,
????????????????????????????birdState?=?newBirdState
????????????????????????)
????????????????????}
????????????????????GameAction.CrossedPipe?->?run?{
????????????????????????val?targetPipeState?=?state.pipeStateList[state.targetPipeIndex]
????????????????????????//?計(jì)算過分值的話跳過,避免重復(fù)計(jì)分
????????????????????????if?(targetPipeState.counted)?{
????????????????????????????return@run?state.copy()
????????????????????????}
????????????????????????//?標(biāo)記該管道組已經(jīng)統(tǒng)計(jì)過分值
????????????????????????val?countedPipeState?=?targetPipeState.count()
????????????????????????val?newPipeStateList?=?if?(state.targetPipeIndex?==?0)?{
????????????????????????????listOf(countedPipeState,?state.pipeStateList[1])
????????????????????????}?else?{
????????????????????????????listOf(state.pipeStateList[0],?countedPipeState)
????????????????????????}
????????????????????????state.copy(
????????????????????????????pipeStateList?=?newPipeStateList,
????????????????????????????//?當(dāng)前分值累加
????????????????????????????score?=?state.score?+?1,
????????????????????????????//?最高分取最高分和當(dāng)前分值的較大值即可
????????????????????????????bestScore?=?(state.score?+?1).coerceAtLeast(state.bestScore)
????????????????????????)
????????????????????}
????????????????})
????????????}
????????}
????}
}當(dāng)小鳥碰撞到了管道,立刻將下落的速度提高,并將Rotate角度加大,營造出快速墜落的效果。
@Composable
fun?Bird(...)?{
????...
????val?rotateDegree?=
????????if?(state.isLifting)?LiftingDegree
????????else?if?(state.isFalling)?FallingDegree
????????else?if?(state.isQuickFalling)?DyingDegree
????????else?if?(state.isOver)?DeadDegree
????????else?PendingDegree
}
8.結(jié)束分值和重新開始
結(jié)束和實(shí)時(shí)兩種分值功能有交叉,統(tǒng)一封裝到ScoreBoard可組合函數(shù)中,根據(jù)游戲狀態(tài)自由切換。
游戲結(jié)束時(shí)展示的信息較為豐富,包含本次分值、最高分值,以及重新開始和退出兩個(gè)按鈕。為了方便視圖的Preview和提高重組性能,我們將其拆分為單個(gè)分值、按鈕、分值儀表盤和結(jié)束分值四個(gè)部分。
Compose的Preview功能很好用,但要留意一點(diǎn):其Composable函數(shù)里不要放入ViewModel邏輯,否則會渲染失敗。我們可以拆分UI和ViewModel邏輯,在保證Preview能順利進(jìn)行的同時(shí)能復(fù)用視圖部分的代碼。
@Composable
fun?ScoreBoard(...)?{
????when?(state.gameStatus)?{
????????//?開始的狀態(tài)下展示簡單的實(shí)時(shí)分值
????????GameStatus.Running?->?RealTimeBoard(modifier,?state.score)
????????//?結(jié)束的話展示豐富的儀表盤
????????GameStatus.Over?->?GameOverBoard(modifier,?state.score,?state.bestScore,?clickable)
????}
}
//?包含豐富分值和按鈕的Box組件
@Composable
fun?GameOverBoard(...)?{
????Box(...)?{
????????Column(...)?{
????????????GameOverScoreBoard(
????????????????Modifier.align(CenterHorizontally),
????????????????score,
????????????????maxScore
????????????)
????????????Spacer(...)
????????????GameOverButton(modifier?=?Modifier.wrapContentSize().align(CenterHorizontally),?clickable)
????????}
????}
}豐富分值和按鈕的可組合函數(shù)的分別實(shí)現(xiàn)。
//?展示豐富分值,包括背景邊框、當(dāng)前分值和最高分值
@Composable
fun?GameOverScoreBoard(...)?{
????Box(...)?{
????????//?Score?board?background
????????Image(
????????????painter?=?painterResource(id?=?R.drawable.score_board_bg),
????????????...
????????)
????????Column(...)?{
????????????LabelScoreField(modifier,?R.drawable.score_bg,?score)
????????????Spacer(
????????????????modifier?=?Modifier
????????????????????.wrapContentWidth()
????????????????????.height(3.dp)
????????????)
????????????LabelScoreField(modifier,?R.drawable.best_score_bg,?maxScore)
????????}
????}
}
//?重新開始和退出按鈕
@Composable
fun?GameOverButton(...)?{
????Row(...)?{
????????//?重新開始按鈕
????????Image(
????????????painter?=?painterResource(id?=?R.drawable.restart_button),
????????????...
????????????modifier?=?Modifier
????????????????...
????????????????.clickable(true)?{
????????????????????clickable.onRestart()
????????????????}
????????)
????????Spacer(...)
????????//?退出按鈕
????????Image(
????????????painter?=?painterResource(id?=?R.drawable.exit_button),
????????????...
????????????modifier?=?Modifier
????????????????...
????????????????.clickable(true)?{
????????????????????clickable.onExit()
????????????????}
????????)
????}
}
再監(jiān)聽重新開始和退出按鈕的事件,發(fā)送Restart和Exit的Action。Exit的響應(yīng)比較簡單,直接關(guān)閉Activity即可。
setContent?{
????FlappyBirdTheme?{
????????Surface(color?=?MaterialTheme.colors.background)?{
????????????val?gameViewModel:?GameViewModel?=?viewModel()
????????????Flappy(Clickable(
????????????????...
????????????????onRestart?=?{
????????????????????gameViewModel.dispatch(GameAction.Restart)
????????????????},
????????????????onExit?=?{
????????????????????finish()
????????????????}
????????))
????????}
????}
}Restart則要告知ViewModel去重置各種游戲數(shù)據(jù),包括小鳥位置、管道和道路的位置、以及分值,但最高分值數(shù)據(jù)應(yīng)當(dāng)保留下來。
class?GameViewModel?:?ViewModel()?{
????private?fun?response(action:?GameAction,?state:?ViewState)?{
????????viewModelScope.launch?{
????????????withContext(Dispatchers.Default)?{
????????????????emit(when?(action)?{
????????????????????GameAction.Restart?->?run?{
????????????????????????state.reset(state.bestScore)
????????????????????}
????????????????})
????????????}
????????}
????}
}
data?class?ViewState(
????...
????//?重置State數(shù)據(jù),最高分值除外
????fun?reset(bestScore:?Int):?ViewState?=
????????ViewState(bestScore?=?bestScore)
}
9.最終效果
給復(fù)刻好的游戲做個(gè)Logo:采用小鳥的Icon和特有的藍(lán)色背景作成的Adaptive Icon。

從點(diǎn)擊Logo到游戲結(jié)束再到重新開始,錄制一段完整游戲。

復(fù)刻的效果還是比較完整的,但仍然有不少可以優(yōu)化和擴(kuò)展的地方:
1.比如增加簡易模式的選擇。可以從小鳥的升降幅度、管道的間隔、管道移動的速度、連續(xù)出現(xiàn)的組數(shù)等角度入手
2.增加翅膀扇動的姿態(tài)。實(shí)現(xiàn)的話也不難,比如將小鳥的翅膀部分扣出來,在飛翔的過程中不斷地來回Rotate一定角度
3.Canvas自定義描畫。部分視圖元素采用的是圖片,其實(shí)也可以通過Canvas來實(shí)現(xiàn),順道強(qiáng)化一下Compose的描畫使用
以上就是利用Jetpack Compose復(fù)刻游戲Flappy Bird的詳細(xì)內(nèi)容,更多關(guān)于Jetpack Compose Flappy Bird游戲的資料請關(guān)注腳本之家其它相關(guān)文章!
相關(guān)文章
詳解關(guān)于MIUI 9沉浸式狀態(tài)欄的最新適配
由于各系統(tǒng)版本的限制,沉浸式狀態(tài)欄對系統(tǒng)有要求,本篇文章主要介紹了詳解關(guān)于MIUI 9沉浸式狀態(tài)欄的最新適配,非常具有實(shí)用價(jià)值,需要的朋友可以參考下2018-05-05
android中soap協(xié)議使用(ksoap調(diào)用webservice)
kSOAP是如何調(diào)用ebservice的呢,首先要使用SoapObject,這是一個(gè)高度抽象化的類,完成SOAP調(diào)用??梢哉{(diào)用它的addProperty方法填寫要調(diào)用的webservice方法的參數(shù)2014-02-02
兩分鐘讓你徹底明白Android Activity生命周期的詳解(圖文介紹)
本篇文章是對Android的生命周期進(jìn)行了詳細(xì)的分析介紹,需要的朋友參考下2013-05-05
Android ContentProvider基礎(chǔ)應(yīng)用詳解
ContentProvider是android四大組件之一。它是不同應(yīng)用程序之間交換數(shù)據(jù)的標(biāo)準(zhǔn)api,ContentProvider以某種uri的形式對外提供數(shù)據(jù),允許其它應(yīng)用程序?qū)ζ湓L問或者修改數(shù)據(jù)。本文將介紹ContentProvider的基礎(chǔ)應(yīng)用,感興趣的可以學(xué)習(xí)一下2021-12-12
android上一個(gè)可追蹤代碼具體到函數(shù)某行的日志類
追蹤代碼到函數(shù)具體某行,這樣的功能,是每一個(gè)程序員都希望會有的,因?yàn)樗梢詭椭覀冏粉櫟侥承写a的錯(cuò)誤,接下來介紹下android上一個(gè)可追蹤代碼到函數(shù)具體某行的日志類,希望對開發(fā)者有所幫助2012-12-12
Android EventBus 3.0.0 使用總結(jié)(必看篇)
下面小編就為大家?guī)硪黄狝ndroid EventBus 3.0.0 使用總結(jié)(必看篇)。小編覺得挺不錯(cuò)的,現(xiàn)在就分享給大家,也給大家做個(gè)參考。一起跟隨小編過來看看吧2017-05-05
android studio 使用Mocklocation虛擬定位
這篇文章主要介紹了android studio 使用Mocklocation虛擬定位總結(jié),本文分步驟給大家介紹的非常詳細(xì),具有一定的參考借鑒價(jià)值,需要的朋友可以參考下2019-12-12

