一气呵成:用Compose完美复刻Flappy Bird!

Posted TechMerger

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了一气呵成:用Compose完美复刻Flappy Bird!相关的知识,希望对你有一定的参考价值。

之前看到fundroid大神用Compose打造了俄罗斯方块游戏,深受启发。便萌生了也打造一个游戏的想法,顺便精进一下Compose的学习。

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,
 

以上是关于一气呵成:用Compose完美复刻Flappy Bird!的主要内容,如果未能解决你的问题,请参考以下文章

vb写程序:flappy bird

flappy bird如何制作

用原生js写小游戏--Flappy Bird

用Tensorflow基于Deep Q Learning DQN 玩Flappy Bird

用Tensorflow基于Deep Q Learning DQN 玩Flappy Bird

Flappy Paddle现身江湖!使用强化学习DQN让你划船划到停不下来