Flappy Bird是13年红极一时的小游戏,其简略乏味的玩法和变态的难度造成了强烈反差,引发寰球玩家竞相把玩,骑虎难下!遂抉择复刻这个小游戏,在实现的过程中向大家演示Compose工具包的UI组合、数据驱动等重要思维。

Ⅰ.拆解游戏

不记得这个游戏或齐全没玩过的敌人,能够点击上面的链接,体验一下Flappy Bird的玩法。

https://flappybird.io/

为拆解游戏,笔者也录了一段游戏过程。

重复观看这段GIF,能够发现游戏的一些法则:

  • 远处的修建和近处的土壤是静止不动的
  • 小鸟始终在高低挪动,随同着翅膀和身材的翱翔姿势
  • 管道和路面则一直地向左挪动,营造出小鸟向前翱翔的视觉效果

通过截图、切图、填充像素和简略的PS,能够拿到各元素的图片。

Ⅱ.复刻画面

各方卡司已就位,接下来开始安排整个画面。暂不实现元素的挪动成果,先把动态的整体成果搭建好。

ⅰ.安排远近景

静止不动的修建近景最为简略,封装到可组合函数FarBackground里,外部搁置一张图片即可。

@Composable  fun FarBackground(modifier: Modifier) {      Column {          Image(              painter = painterResource(id = R.drawable.background),              contentScale = ContentScale.FillBounds,              contentDescription = null,              modifier = modifier.fillMaxSize()          )      }  }  

近景的上面由分割线、路面和土壤组成,封装到NearForeground函数里。通过Modifierfraction参数管制路面和土壤的比例,保障在不同尺寸屏幕上能按比例出现游戏界面。

@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)          )      }  }  

将整个游戏画面形象成GameScreen函数,通过Column竖着排列近景和前景。思考到挪动的小鸟和管道须要出现在近景之上,所以在近景的外面包上一层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()              )          }      }  }  

ⅱ.摆放管道

仔细观察管道,会发现一些管道具备朝上朝下、高度随机的特点。为此将管道的视图分拆成盖子和柱子两局部:

  • 盖子和柱子的搁置程序决定管道的朝向
  • 柱子的高度则管制着管道整体的高度 这样的话,只应用盖子和柱子两张图片,就能够灵便实现各种状态的管道。

先来组合盖子PipeCover和柱子PipePillar的可组合函数。

@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)      )  }  

管道的可组合函数Pipe能够依据照朝向和高度的参数,组合成对应的管道。

@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)              }          }      }  }  

另外,管道都是成对呈现、且无论高度如何两头的间距是固定的。所以咱们再实现一个管道组的可组合函数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())                            // 管道对增加近景下来              PipeCouple(                  modifier = Modifier.fillMaxSize()              )          }          ...      }  }  

ⅲ.搁置小鸟

小鸟通过Image组件即可实现,默认状况下搁置到布局的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)          )      }  }  

视觉上小鸟出现在管道的后面,所以Bird可组合函数要增加到管道组函数的前面。

@Composable  fun GameScreen( ... ) {      Column(...) {          Box(...) {              ...              PipeCouple( ... )              // 将小鸟增加到近景下来              Bird(                  modifier = Modifier.fillMaxSize(),                  state = viewState              )          }      }  }  

至此,各元素都搁置完了。接下来着手让小鸟,管道和路面这些动静元素动起来。

Ⅲ.状态治理和架构

Compose中Modifier#offset()函数能够更改视图在横纵方向上的偏移值,通过一直地调整这个偏移值,即可营造出动静的视觉效果。无论是小鸟还是管道和路面,它们的挪动状态都能够依赖这个思路。

那如何治理这些继续变动的偏移值数据?如何将数据反映到画面上?

Compose通过State驱动可组合函数进行重组,进而达到画面的重绘。所以咱们将这些数据封到ViewState中,交由ViewModel框架计算和更新,Compose订阅State之后驱动所有元素流动起来。除了个元素的偏移值数据,State中还要寄存游戏分值,游戏状态等额定信息。

data class ViewState(      val gameStatus: GameStatus = GameStatus.Waiting,      // 小鸟状态      val birdState: BirdState = BirdState(),      // 管道组状态      val pipeStateList: List<PipeState> = PipeStateList,      var targetPipeIndex: Int = -1,      // 路面状态      val roadStateList: List<RoadState> = RoadStateList,      var targetRoadIndex: Int = -1,      // 分值数据      val score: Int = 0,      val bestScore: Int = 0,  )    enum class GameStatus {      Waiting,      Running,      Dying,       Over  }  

用户点击屏幕会触发游戏开始、从新开始、小鸟回升等动作,这些视图上的事件须要反向传递给ViewModel解决和做出响应。事件由Clickable数据类封装,再转为对应的GameAction发送到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()  }  

后面说过,能够一直调整下Offset数据使得视图动起来。具体实现能够通过LaunchedEffect启动一个定时工作,定期发送一个更新视图的动作AutoTick。留神:Compose里获取ViewModel实例产生NoSuchMethodError谬误的话,记得依照官网构建的版本从新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后开启协程,计算视图的地位、更新对应State,之后发射进来。

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                          )                      }                      ...                  })              }          }      }  }  

Ⅳ.路面动起来

如果画面上只放一张路面图片,更改X轴Offset的话,残余的局部会没有路面,无奈呈现出一直挪动的成果。

思前想后,发现搁置两张路面图片能够解决:一张放在屏幕外侧,一张放在屏幕内侧。游戏的过程中同时同方向挪动两张图片,以后一张图片移出屏幕后重置其地位,进而营造出路线一直挪动的成果。

@Composable  fun NearForeground( ... ) {      val viewModel: GameViewModel = viewModel()      Column( ... ) {          ...          // 路面          Box(modifier = Modifier.fillMaxWidth()) {              state.roadStateList.forEach { roadState ->                  Image(                      ...                      modifier = modifier                          ...                           // 一直调整路面在x轴的偏移值                          .offset(x = roadState.offset)                  )              }          }          ...          if (state.playZoneSize.first > 0) {              state.roadStateList.forEachIndexed { index, roadState ->                  // 任意路面的偏移值达到两张图片地位差的时候                  // 重置路面地位,从新回到屏幕外                  if (roadState.offset <= - TempRoadWidthOffset) {                      viewModel.dispatch(GameAction.RoadExit, roadIndex = index)                  }              }          }      }  }  

ViewModel收到RoadExit的Action之后告诉路面State进行地位的重置。

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)  }  

Ⅴ.管道动起来

设施屏幕宽度无限,同一时间最多出现两组管道就能够了。和路面静止的思路相似,只须要搁置两组管道,就能够实现管道不停挪动的视觉效果。

具体的话,两组管道相隔一段距离搁置,游戏中两组管道一起同时向左挪动。以后一组管道静止到屏幕外的时候,将其地位重置。

那如何计算管道挪动到屏幕外的机会?

画面重组的时候判断管道偏移值是否达到屏幕宽度,YES的话向ViewModel发送管道重置的Action。

@Composable  fun PipeCouple(      modifier: Modifier = Modifier,      state: ViewState = ViewState(),      pipeIndex: Int = 0  ) {      val viewModel: GameViewModel = viewModel()      val pipeState = state.pipeStateList[pipeIndex]        Box( ... ) {          //从State中获取管道的偏移值,在重组的时候让管道挪动           GetUpPipe(height = pipeState.upHeight,              modifier = Modifier                  .align(Alignment.TopEnd)                  .offset(x = pipeState.offset)          )          GetDownPipe(...)            if (state.playZoneSize.first > 0) {              ...              // 挪动到屏幕外的时候发送重置Action              if (pipeState.offset < - playZoneWidthInDP) {                  viewModel.dispatch(GameAction.PipeExit, pipeIndex = pipeIndex)              }          }      }  }  

ViewModel收到PipeExit的Action后发动重置管道数据,并将更新发射进来。

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                          )                      }                  })              }          }      }  }  

但相比路面,管道还具备高度随机、间距固定的个性。所以重置地位的同时记得将柱子的高度随机赋值,并给另一根柱子赋值残余的高度。

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 {          // 随机赋值下面管道的高度          val newUpHeight = ValueUtil.getRandomDp(LowPipe, HighPipe)          return copy(              offset = FirstPipeWidthOffset,              upHeight = newUpHeight,              // 上面管道的高度由差值赋值              downHeight = TotalPipeHeight - newUpHeight - PipeDistance          )      }  }  

须要注意一点的是,如果心愿管道组呈现的节奏固定,那么管道组之间的横向间距(不是高低管道的间距)始终须要保持一致。为此两组管道初始的Offset数据要遵循一些规定,此处省略计算的过程,大略规定如下。

val FirstPipeWidthOffset = PipeCoverWidth * 2  // 第二组管道的offset等于  // 屏幕宽度 加上 三倍第一组管道offset 的一半  val SecondPipeWidthOffset = (TotalPipeWidth + FirstPipeWidthOffset * 3) / 2    val PipeStateList = listOf(      PipeState(),      PipeState(offset = (SecondPipeWidthOffset))  )  

Ⅵ.小鸟飞起来

一直调整小鸟图片在Y轴上的偏移值能够实现小鸟的高低挪动。但相较于路面和管道,小鸟的须要些特有的解决:

  • 监听用户的点击事件,向上调整偏移值实现回升成果
  • 在回升和降落的过程中,调整小鸟的Rotate角度,以演示静止的姿势
  • 在触碰到路面的时刻,发送HitGround的Action进行游戏
@Composable  fun GameScreen(...) {      ...      Column(          modifier = Modifier              .fillMaxSize()              .background(ForegroundEarthYellow)              .run {                  pointerInteropFilter {                      when (it.action) {                          // 监听点击事件,触发游戏开始或小鸟回升                          ACTION_DOWN -> {                              if (viewState.gameStatus == GameStatus.Waiting)                                  clickable.onStart()                              else if (viewState.gameStatus == GameStatus.Running)                                  clickable.onTap()                          }                          ...                      }                      false                  }              }      ) { ... }  }  

小鸟依据State的Offset数据开始挪动和调整姿势,同时在触地的时候告知ViewModel。因为降落的偏移值误差可能导致触地的那刻小鸟地位产生偏差,所以在小鸟着落到路面的临界点后须要手动调整下Offset值。

@Composable  fun Bird(...) {      ...      // 依据小鸟回升或降落的状态调整小鸟的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              // 小鸟偏移值达到背景边界时发送落地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)                   // 将旋转角度利用到小鸟,展现翱翔姿势                  .rotate(rotateDegree)          )      }  }  

Ⅶ.碰撞和实时分值

动静的元素都实现好了,下一步开始安顿碰撞算法,并将实时分值同步展现到游戏上方。

认真思考,发现当管道组挪动到小鸟翱翔区域的时候,计算小鸟是否处在管道区域即可判断是否产生了碰撞。而当管道挪动出小鸟翱翔范畴的时候,即可断定小鸟胜利穿过了管道,开始计分。

如下图所示当管道挪动到小鸟翱翔区域的时候,红色局部为危险地带,绿色局部才是平安区域。

@Composable  fun GameScreen(...) {      ...      Column(...) {          Box(...) {              ...              // 增加实时展现分值的Text组件              ScoreBoard(                  modifier = Modifier.fillMaxSize(),                  state = viewState,                  clickable = clickable              )                // 遍历两个管道组,查看小鸟的穿过状态              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计分                              PipeStatus.BirdCrossed -> {                                  viewModel.dispatch(GameAction.CrossedPipe, pipeIndex = pipeIndex)                              }                          }                      }                    }              }          }      }  }    @Composable  fun CheckPipeStatus(...): PipeStatus {      // 管道尚未挪动到小鸟静止区域      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          // 管道挪动到小鸟静止区域并和小鸟重合          if (birdTop < pipeState.upHeight || birdBottom > zoneHeight - pipeState.downHeight) {              return PipeStatus.BirdHit          }          return PipeStatus.BirdCrossing      }   }  

ViewModel收到碰撞HitPipe和穿过管道CrossedPipe的Action后进行坠落或计分的解决。

 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]                          // 计算过分值的话跳过,防止反复计分                          if (targetPipeState.counted) {                              return@run state.copy()                          }                            // 标记该管道组曾经统计过分值                          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,                              // 以后分值累加                              score = state.score + 1,                              // 最高分取最高分和以后分值的较大值即可                              bestScore = (state.score + 1).coerceAtLeast(state.bestScore)                          )                      }                  })              }          }      }  }

当小鸟碰撞到了管道,立即将着落的速度进步,并将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  }  

Ⅷ.完结分值和从新开始

完结和实时两种分值性能有穿插,对立封装到ScoreBoard可组合函数中,依据游戏状态自在切换。

游戏完结时展现的信息较为丰盛,蕴含本次分值、最高分值,以及从新开始和退出两个按钮。为了不便视图的Preview和进步重组性能,咱们将其拆分为单个分值、按钮、分值仪表盘和完结分值四个局部。

Compose的Preview性能很好用,但要注意一点:其Composable函数里不要放入ViewModel逻辑,否则会渲染失败。咱们能够拆分UI和ViewModel逻辑,在保障Preview能顺利进行的同时能复用视图局部的代码。

@Composable  fun ScoreBoard(...) {      when (state.gameStatus) {          // 开始的状态下展现简略的实时分值          GameStatus.Running -> RealTimeBoard(modifier, state.score)          // 完结的话展现丰盛的仪表盘          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)          }      }  }  

丰盛分值和按钮的可组合函数的别离实现。

// 展现丰盛分值,包含背景边框、以后分值和最高分值  @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()                  }          )      }  }  

再监听从新开始和退出按钮的事件,发送RestartExit的Action。Exit的响应比较简单,间接敞开Activity即可。

setContent {      FlappyBirdTheme {          Surface(color = MaterialTheme.colors.background) {              val gameViewModel: GameViewModel = viewModel()              Flappy(Clickable(                  ...                  onRestart = {                      gameViewModel.dispatch(GameAction.Restart)                  },                  onExit = {                      finish()                  }          ))          }      }  }  

Restart则要告知ViewModel去重置各种游戏数据,包含小鸟地位、管道和路线的地位、以及分值,但最高分值数据该当保留下来。

 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数据,最高分值除外      fun reset(bestScore: Int): ViewState =          ViewState(bestScore = bestScore)  }

Ⅸ.最终成果

给复刻好的游戏做个Logo:采纳小鸟的Icon和特有的蓝色背景作成的Adaptive Icon

从点击Logo到游戏完结再到从新开始,录制一段残缺游戏。

复刻的成果还是比拟残缺的,但依然有不少能够优化和扩大的中央:

  • 比方减少繁难模式的抉择。能够从小鸟的升降幅度、管道的距离、管道挪动的速度、间断呈现的组数等角度动手
  • 减少翅膀扇动的姿势。实现的话也不难,比方将小鸟的翅膀局部扣进去,在翱翔的过程中一直地来回Rotate肯定角度
  • Canvas自定义描绘。局部视图元素采纳的是图片,其实也能够通过Canvas来实现,顺道强化一下Compose的描绘应用

感兴趣的敌人能够Fork一下,试着改改!