共计 21985 个字符,预计需要花费 55 分钟才能阅读完成。
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
函数里。通过 Modifier
的fraction
参数管制路面和土壤的比例,保障在不同尺寸屏幕上能按比例出现游戏界面。
@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()
}
)
}
}
再监听从新开始和退出按钮的事件,发送 Restart
和Exit
的 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
一下,试着改改!