小試牛刀:用Compose完美復刻Flappy Bird!

之前看到fundroid大神用Compose打造了俄羅斯方塊游戲,深受啟發。便萌生了也打造一個游戲的想法,順便精進一下Compose的學習。

Flappy Bird是13年紅極一時的小游戲,其簡單有趣的玩法和變態的難度形成了強烈反差,引發全球玩家競相把玩,欲罷不能!遂選擇復刻這個小游戲,在實現的過程中向大家演示Compose工具包的UI組合、數據驅動等重要思想。

Ⅰ.拆解游戲

不記得這個游戲或完全沒玩過的朋友,可以點擊下面的鏈接,體驗一下Flappy Bird的玩法。

flappybird.io/

為拆解游戲,筆者也錄了一段游戲過程。

反復觀看這段GIF,可以發現游戲的一些規律:

  • 遠處的建筑和近處的土壤是靜止不動的
  • 小鳥一直在上下移動,伴隨著翅膀和身體的飛翔姿態
  • 管道和路面則不斷地向左移動,營造出小鳥向前飛翔的視覺效果

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

Ⅱ.復刻畫面

各方卡司已就位,接下來開始布置整個畫面。暫不實現元素的移動效果,先把靜態的整體效果搭建好。

?。贾眠h近景

靜止不動的建筑遠景最為簡單,封裝到可組合函數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到游戲結束再到重新開始,錄制一段完整游戲。

結語

復刻Flappy Bird的中途,發現一位韓國朋友早在去年底就用Compose實現過了。忍不住下載試用了一下,發現只完成了基礎功能,而且實現的思路和我完全不同。

談不上孰優孰劣,感興趣的朋友可以看看他的效果和代碼,地址貼在參考資料里。

整個游戲復刻下來,發現Compose數據驅動視圖的思想特別適合游戲開發。再加上Compose對ViewModel、FlowCoroutines等技術的優秀支持,使這個過程變得更加簡單和高效。

七月底要到了,Compose將正式發布。仍未嘗鮮的朋友,可以安排上了,就從這個小游戲開始!另外的話也整理了一套Compose入門學習文檔教程,有需要的朋友可以私信我來獲取!對了需要本文源碼的也可以來拿。

?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 227,797評論 6 531
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,179評論 3 414
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 175,628評論 0 373
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,642評論 1 309
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,444評論 6 405
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 54,948評論 1 321
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,040評論 3 440
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,185評論 0 287
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,717評論 1 333
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,602評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,794評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,316評論 5 358
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,045評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,418評論 0 26
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,671評論 1 281
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,414評論 3 390
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,750評論 2 370

推薦閱讀更多精彩內容