本文介紹如何使用Jetpack Compose打造一個經典版的俄羅斯方塊游戲,

玩過上面這種游戲機的朋友應該會對本文內容感到親切,廢話不多說,先看東西:
1. 為什么Compose適合做游戲?
通常一個游戲程式的執行流程如下所示:

簡單說就是一個不斷等待輸入、渲染界面的程序,
這種模型非常符合當下前端的開發思想:資料驅動UI, 因此基于各種前端框架的小游戲層出不窮,相比之下,用客戶端開發同類應用成本則會高出不少,
如今有了Compose,客戶端終于在開發范式上追上了前端的步伐,像前端那樣開發小游戲成為可能,
2. 基于MVI的游戲架構
MVI即Model-View-Intent,它受前端框架的啟發,提倡一種單向資料流的設計思想,非常適合在Compose專案中實作邏輯部分,可以徹底貫徹資料驅動UI的核心思想,
之前的文章里曾對MVI架構做過簡單介紹,后續也計劃對MVI與MVVM等其他架構做一個對比介紹,本文只聚焦MVI在俄羅斯方塊游戲中的具體使用,
專案結構如下:

- View層:基于Compose打造,所有UI元素都由代碼實作
- Model層:
ViewModel維護State的變化,游戲邏輯交由reduce處理 - V-M通信:通過
StateFlow驅動Compose重繪,用戶事件由Action分發至ViewModel
ViewModel的核心代碼如下:
class GameViewModel : ViewModel() {
private val _viewState: MutableStateFlow<ViewState> = MutableStateFlow(ViewState())
//Provide State to observed by UI layer
val viewState = _viewState.asStateFlow()
//dispatch Action from UI layer
fun dispatch(action: Action) {
viewModelScope.launch {
_viewState.value = reduce(viewState.value, action)
}
}
//update viewState according to the Action
private fun reduce(state: ViewState, action: Action): ViewState =
when(action) { //handle
Action.Reset -> {
//略...
state.copy(...)
}
Action.Move -> {
//略...
state.copy(...)
}
//略...
}
}
接下來我們看一下View層和Model層的具體實作,
3. View Layer:基于Compose實作
作為一個單頁面的游戲沒有頁面跳轉,界面由以下幾部分構成:

- GameBody:繪制按鍵、處理用戶輸入
- GameScreen:
- BrickMatrix:繪制方塊矩陣背景、下落中的方塊
- Scoreboard:顯示游戲得分、時鐘等資訊
接下來重點介紹一下方塊區域(BrickMatrix)以及游戲機身(GameBody)的繪制
3.1 方塊繪制(BrickMatrix)
方塊區域由12 * 24 的小方塊組成的矩陣構成,為了模擬液晶屏的顯示效果,需要分別繪制淺色的矩陣以及深色的磚塊(下落中的以及底部的),所有元素均基于Compose的Canvas繪制,
關于Canvas的基本使用,我之前的文章中有介紹:https://blog.csdn.net/vitaviva/article/details/115257165
繪制背景矩陣
首先繪制每個磚塊單元的圖形,形狀為正方形:

drawBrick:
Canvas中繪制圖形需要借助DrawScope,為了便于使用,我們定義drawBrick為DrawScope的擴展函式
private fun DrawScope.drawBrick(
brickSize: Float,//每一個方塊的size
offset: Offset,//在矩陣中的偏移位置
color: Color//磚塊顏色
) {
//根據Offset計算實際位置
val actualLocation = Offset(
offset.x * brickSize,
offset.y * brickSize
)
val outerSize = brickSize * 0.8f
val outerOffset = (brickSize - outerSize) / 2
//繪制外部矩形邊框
drawRect(
color,
topLeft = actualLocation + Offset(outerOffset, outerOffset),
size = Size(outerSize, outerSize),
style = Stroke(outerSize / 10)
)
val innerSize = brickSize * 0.5f
val innerOffset = (brickSize - innerSize) / 2
//繪制內部矩形方塊
drawRect(
color,
actualLocation + Offset(innerOffset, innerOffset),
size = Size(innerSize, innerSize)
)
}
drawMatrix:
搞定磚塊單元,繪制矩陣如下

private fun DrawScope.drawMatrix(
brickSize: Float,
matrix: Pair<Int, Int> //橫向、縱向的數量: 12 * 24
) {
(0 until matrix.first).forEach { x ->
(0 until matrix.second).forEach { y ->
//遍歷呼叫drawBrick
drawBrick(
brickSize,
Offset(x.toFloat(), y.toFloat()),
BrickMatrix
)
}
}
}
繪制下落的磚塊
一個個磚塊單元根據擺放位置的不同,組成不同形狀(Shape)的下落磚塊,

用相對top-left的Offset定義每個方塊的擺放位置,每種Shape無非是一組Offset的串列,
Shape:
我們如下定義所有Shape的常量:
val SpiritType = listOf(
listOf(Offset(1, -1), Offset(1, 0), Offset(0, 0), Offset(0, 1)),//Z
listOf(Offset(0, -1), Offset(0, 0), Offset(1, 0), Offset(1, 1)),//S
listOf(Offset(0, -1), Offset(0, 0), Offset(0, 1), Offset(0, 2)),//I
listOf(Offset(0, 1), Offset(0, 0), Offset(0, -1), Offset(1, 0)),//T
listOf(Offset(1, 0), Offset(0, 0), Offset(1, -1), Offset(0, -1)),//O
listOf(Offset(0, -1), Offset(1, -1), Offset(1, 0), Offset(1, 1)),//L
listOf(Offset(1, -1), Offset(0, -1), Offset(0, 0), Offset(0, 1))//J
)
Spirit:
由Shape和Offset便可以決定下落磚塊在Matrix中的具體位置,定義Spirit代表下落磚塊:
data class Spirit(
val shape: List<Offset> = emptyList(),
val offset: Offset = Offset(0, 0),
) {
val location: List<Offset> = shape.map { it + offset }
}
drawSpirit
最后呼叫drawBrick,繪制下落磚塊
fun DrawScope.drawSpirit(spirit: Spirit, brickSize: Float, matrix: Pair<Int, Int>) {
clipRect(
0f, 0f,
matrix.first * brickSize,
matrix.second * brickSize
) {
spirit.location.forEach {
drawBrick(
brickSize,
Offset(it.x, it.y),
BrickSpirit
)
}
}
}
3.2 游戲機身(GameBody)
GameBody的核心是按鈕的繪制以及事件處理
繪制Button
button的繪制很簡單, 通過RoundedCornerShape實作圓形、通過Modifier添加陰影增加立體感
GameButton:
@Composable
fun GameButton(
modifier: Modifier = Modifier,
size: Dp,
content: @Composable (Modifier) -> Unit
) {
val backgroundShape = RoundedCornerShape(size / 2)
Box(
modifier = modifier
.shadow(5.dp, shape = backgroundShape)
.size(size = size)
.clip(backgroundShape)
.background(
brush = Brush.verticalGradient(
colors = listOf(
Purple200,
Purple500
),
startY = 0f,
endY = 80f
)
)
) {
content(Modifier.align(Alignment.Center))
}
}
添加事件
當按下方向鍵不放時希望方塊能持續移動,Modifier.clickable()只能設定單擊事件,不滿足使用需求,需要讓button能處理連發事件,
Modifier.pointerIneropFilter:攔截MotionEvent:
通常需要通過處理MotionEvent實作類似需求,Compose中提供了處理MotionEvent的方法:
Modifier.pointerIneropFilter { //it:MotionEvent //可以獲取當前MotionEvent
when(it.action) {
ACTION_DOWN -> {
...
}
...
}
}
Modifier.indication:設定click背景色
攔截MotionEvent后,默認的button按下時背景色變化的邏輯失效,此時可以借助Modifier.indication進行彌補,indication允許我們根據當前按鈕的互動狀態改變顯示狀態:
Modifier
.indication(
interactionSource = interactionSource, //觀察互動狀態
indication = rememberRipple() //設定Ripple風格的顯示效果
)
.pointerInteropFilter {
when(it.action) {
ACTION_DOWN -> {
val interaction = PressInteraction.Press(Offset(50f, 50f))
interactionSource.emit(interaction) //通知互動狀態的改變、改變顯示狀態
}
...
}
}
ReceiveChannel發送連發事件:
添加了Modifier.pointerIneropFilter和Modifier.indication的完整代碼如下:
@Composable
fun GameButton(
modifier: Modifier = Modifier,
size: Dp,
onClick: () -> Unit = {},
content: @Composable (Modifier) -> Unit
) {
val backgroundShape = RoundedCornerShape(size / 2)
lateinit var ticker: ReceiveChannel<Unit> //定時器
val coroutineScope = rememberCoroutineScope()
val pressedInteraction = remember { mutableStateOf<PressInteraction.Press?>(null) }
val interactionSource = MutableInteractionSource()
Box(
modifier = modifier
.shadow(5.dp, shape = backgroundShape)
.size(size = size)
.clip(backgroundShape)
.background(
brush = Brush.verticalGradient(
colors = listOf(
Purple200,
Purple500
),
startY = 0f,
endY = 80f
)
)
.indication(interactionSource = interactionSource, indication = rememberRipple())
.pointerInteropFilter {
when (it.action) {
ACTION_DOWN -> {
coroutineScope.launch {
// Remove any old interactions if we didn't fire stop / cancel properly
pressedInteraction.value?.let { oldValue ->
val interaction = PressInteraction.Cancel(oldValue)
interactionSource.emit(interaction)
pressedInteraction.value = null
}
val interaction = PressInteraction.Press(Offset(50f, 50f))
interactionSource.emit(interaction)
pressedInteraction.value = interaction
}
ticker = ticker(initialDelayMillis = 300, delayMillis = 60)
coroutineScope.launch {
//ticker發送連發事件
ticker
.receiveAsFlow()
.collect { onClick() }
}
}
//略...
}
true
}
) {
content(Modifier.align(Alignment.Center))
}
}
使用ticker()創建連發事件源的ReceiveChannel
組裝Button、發送Action
最后在GameBody中對各Button進行布局,并在OnClick中向ViewModel發送Action,
例如,四個方向鍵的布局:

Box(
modifier = Modifier
.fillMaxHeight()
.weight(1f)
) {
GameButton(
Modifier.align(Alignment.TopCenter),
onClick = { clickable.onMove(Direction.Up) },
size = DirectionButtonSize
) {
ButtonText(it, stringResource(id = R.string.button_up))
}
GameButton(
Modifier.align(Alignment.CenterStart),
onClick = { clickable.onMove(Direction.Left) },
size = DirectionButtonSize
) {
ButtonText(it, stringResource(id = R.string.button_left))
}
GameButton(
Modifier.align(Alignment.CenterEnd),
onClick = { clickable.onMove(Direction.Right) },
size = DirectionButtonSize
) {
ButtonText(it, stringResource(id = R.string.button_right))
}
GameButton(
Modifier.align(Alignment.BottomCenter),
onClick = { clickable.onMove(Direction.Down) },
size = DirectionButtonSize
) {
ButtonText(it, stringResource(id = R.string.button_down))
}
}
Clicable:分發事件
clickable負責事件分發:
data class Clickable constructor(
val onMove: (Direction) -> Unit,//移動
val onRotate: () -> Unit,//旋轉
val onRestart: () -> Unit,//開始、重置游戲
val onPause: () -> Unit,//暫停、恢復游戲
val onMute: () -> Unit//打開、關閉游戲音樂
)
3.3 訂閱游戲狀態(ViewState)
GameScreen訂閱viewModel的資料實作UI的重繪,ViewState是唯一的資料源,遵循Single Source Of Truth的要求,
Compose中使用ViewModel
添加viewmodel-compose的支持,方便在Composable中訪問ViewModle
implementation "androidx.lifecycle:lifecycle-viewmodel-compose:1.0.0-alpha03"
@Composable
fun GameScreen(modifier: Modifier = Modifier) {
val viewModel = viewModel<GameViewModel>() //獲取ViewModel
val viewState by viewModel.viewState.collectAsState() //訂閱State
Box() {
Canvas(
modifier = Modifier.fillMaxSize()
) {
val brickSize = min(
size.width / viewState.matrix.first,
size.height / viewState.matrix.second
)
//僅負責繪制UI,沒有任何邏輯處理
drawMatrix(brickSize, viewState.matrix)
drawBricks(viewState.bricks, brickSize, viewState.matrix)
drawSpirit(viewState.spirit, brickSize, viewState.matrix)
}
//略...
}
}
瞧! 有了MVI的加持,Compose無需再染指任何邏輯,僅負責drawXXX即可,邏輯全部交由ViewModel處理,
接下來,我們移步ViewModel的實作,
4. Model Layer :基于ViewModel實作
MVI中的Model層一般負責資料請求以及State的更新,俄羅斯方塊中沒有資料請求場景,只處理本地state更新即可,
4.1 ViewState
遵循SSOT原則,所有影響UI重繪的資料都定義在ViewState中
data class ViewState(
val bricks: List<Brick> = emptyList(), //底部落地成盒的磚塊
val spirit: Spirit = Empty, // 下落中的磚塊
val spiritReserve: List<Spirit> = emptyList(), //后補t磚塊(Next)
val matrix: Pair<Int, Int> = MatrixWidth to MatrixHeight,//矩陣尺寸
val gameStatus: GameStatus = GameStatus.Onboard,//游戲狀態
val score: Int = 0, //得分
val line: Int = 0, //下了多少行
val level: Int = 0,//當前級別(難度)
val isMute: Boolean = false,//是否靜音
)
enum class GameStatus {
Onboard, //游戲歡迎頁
Running, //游戲進行中
LineClearing,// 消行影片中
Paused,//游戲暫停
ScreenClearing, //清屏影片中
GameOver//游戲結束
}
如上,甚至連消行、清屏這類邏輯也統一由ViewModel負責,Compose無腦反應State即可,
4.2 Action
用戶的輸入通過Action通知到ViewModel,目前支持以下幾種Action:
sealed class Action {
data class Move(val direction: Direction) : Action() //點擊方向鍵
object Reset : Action() //點擊start
object Pause : Action() //點擊pause
object Resume : Action() //點擊resume
object Rotate : Action() //點擊rotate
object Drop : Action() //點擊↑,直接掉落
object GameTick : Action() //磚塊下落通知
object Mute : Action()//點擊mute
}
4.3 reduce
ViewModel接收到Action后,分發到reduce、更新ViewState,
GameTicker:磚塊下落Action
以最核心的GameTicker為例,其他所有Action都是用戶觸發的,唯有GameTicker是自動觸發,用來保證磚塊按一定速度下降,

基本流程如上圖所示,根據Spirit在當前Matrix中的狀態更新ViewStae:
- 沒有觸達底部:
- Spirit在y軸前進一步
- 觸達底部,但沒有成功消行:
- 更新Next Spirit
- 更新下沉到底部的bricks(吸收Spirit的brick)
- 成功消行:
- 更新Next Spirit
- 更新GameState到LineClearing
- 螢屏溢位:
- 更新GameState到GameOver
fun reduce(state: ViewState, action: Action) {
when(action) {
Action.GameTick -> run {
// 沒有觸達底部,y軸偏移+1
val spirit = state.spirit.moveBy(Direction.Down.toOffset())
if (spirit.isValidInMatrix(state.bricks, state.matrix)) {
emit(state.copy(spirit = spirit))
}
// GameOver
if (!state.spirit.isValidInMatrix(state.bricks, state.matrix)) {
//磚塊超出螢屏上界,游戲結束
}
// 更新底部Bricks,
// updateBricks: 底部Bricks的狀態資訊
// clearedLine:消行資訊
val (updatedBricks, clearedLines) = updateBricks(
state.bricks,
state.spirit,
matrix = state.matrix
)
//updatedBricks回傳的底部Bricks的資訊由三個List<Brick>組成
val (noClear, clearing, cleared) = updatedBricks
if (clearedLines != 0) {
// 成功消行
// 執行消行影片,見后文
} else {
//沒有消行
emit(newState.copy(
bricks = noClear,
spirit = state.spiritNext))
}
}//end of run
}
}
isValidInMatrix()判斷Spirit相對于Matrix是否已經出界,出界以為游戲結束,- 當
Spirit觸達底部時,updatedBricks負責更新底部Bricks資料,即將Spirit的bricks吸收添加到底部Bricks中,
Brick的定義很簡單,就是在Matrix中的Offset
data class Brick(val location: Offset = Offset(0, 0))
updatedBricks回傳三個List<Brick>,分別記錄消行影片程序中Bricks的中間狀態
- noClear: 未消行的bricks
- clearing: 消行中的bricks(相當于消除的空行設定為
Invisiable) - cleared: 消行后的bricks(相當于消除的空行設定為
Gone)
| noClear | clearing | cleared |
|---|---|---|
![]() | ![]() | ![]() |
消行影片:
基于回傳的List<Brick>,通過更新state,實作消行影片
launch {
//animate the clearing lines
repeat(5) {
emit(
//間隔100ms,交替顯示noClear/clearing
state.copy(
gameStatus = GameStatus.LineClearing,
spirit = Empty,
bricks = if (it % 2 == 0) noClear else clearing
)
delay(100)
}
//delay emit new state
emit(
//影片結束,bricks更新到cleared
state.copy(
spirit = state.spiritNext,
bricks = cleared,
gameStatus = GameStatus.Running
)
)
}
5. 活用@Preview
文章最后再聊一聊@Preview,
由于AndroidStudio的XML預覽功能很雞肋,很多Android開發者都習慣于通過實機運行查看UI,Compose的@Preview的預覽效果可以做到與真機無異,實作所見所即得開發,因此,建議為所有有除錯需要的Composable配備@Preview,將大大提高你的UI開發效率,
由于@Preview不能接受業務引數,Composable的介面定義需要秉承對Preview友好的原則,盡量為其添加可預覽的默認值
借助@Preview,我們可以方便地進行區域UI的預覽,并且可以組合各個@Preview的Composalbe來預覽全貌,UI開發如同裝配車間那樣實作流水化作業:

除了基本預覽以外@Preview還提供了例如互動預覽、實機預覽等更多使用功能,此外,通過右擊還可以將預覽UI直接保存為.png, 本游戲的AppIcon就是通過這種方式創建生成的,
6. 最后
篇幅有限,本文只能以小見大地介紹游戲的基本實作程序,其他更多功能的實作歡迎查閱原始碼了解,
整個游戲中,包括影片在內的所有的UI重繪全是由State驅動完成的,借助于Compose Compiler強大的編譯期優化,即使再頻繁的Recomposition(重組)也沒有任何性能問題,運行起來十分流暢,
用Compose做游戲都如此流暢,更何況普通的UI?在性能層面已無需擔心,未來隨著功能層面的不斷完善,Compose的時代或許真的要來了,自Android誕生就存在的android.view.View體系也將迎來它的謝幕,,Game Over

專案地址
https://github.com/vitaviva/compose-tetris
轉載請註明出處,本文鏈接:https://www.uj5u.com/qita/279223.html
標籤:其他



