之前看到fundroid大神用Compose打造了俄羅斯方塊游戲,深受啟發。便萌生了也打造一個游戲的想法,順便精進一下Compose的學習。
Flappy Bird
是13年紅極一時的小游戲,其簡單有趣的玩法和變態的難度形成了強烈反差,引發全球玩家競相把玩,欲罷不能!遂選擇復刻這個小游戲,在實現的過程中向大家演示Compose
工具包的UI組合、數據驅動等重要思想。
Ⅰ.拆解游戲
不記得這個游戲或完全沒玩過的朋友,可以點擊下面的鏈接,體驗一下Flappy Bird
的玩法。
為拆解游戲,筆者也錄了一段游戲過程。
反復觀看這段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
函數里。通過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到游戲結束再到重新開始,錄制一段完整游戲。
結語
復刻Flappy Bird
的中途,發現一位韓國朋友早在去年底就用Compose
實現過了。忍不住下載試用了一下,發現只完成了基礎功能,而且實現的思路和我完全不同。
談不上孰優孰劣,感興趣的朋友可以看看他的效果和代碼,地址貼在參考資料里。
整個游戲復刻下來,發現Compose
數據驅動視圖的思想特別適合游戲開發。再加上Compose對ViewModel
、Flow
和Coroutines
等技術的優秀支持,使這個過程變得更加簡單和高效。
七月底要到了,Compose將正式發布。仍未嘗鮮的朋友,可以安排上了,就從這個小游戲開始!另外的話也整理了一套Compose入門學習文檔教程,有需要的朋友可以私信我來獲取!對了需要本文源碼的也可以來拿。