This article describes how to use Jetpack Compose to create a classic tetris game.
This article will be familiar to anyone who has played one of these consoles; If you haven’t, you’re welcome to read about our generation’s childhood joys 👴🏻👵🏻
Without further ado, let’s see something first:
1. Why is Compose suitable for making games?
The usual execution flow of a game program is as follows:
Simply put, it is a process of constantly waiting for input and rendering the interface.
This model is very much in line with the current front-end development philosophy: data-driven UIs. As a result, small games based on various front-end frameworks emerge one after another. In contrast, the cost of developing similar applications on the client side is much higher.
Now, with Compose, the client is finally catching up with the front end in the development paradigm, making it possible to develop smaller games like the front end.
2. Mvi-based game architecture
The MVI, known as Model-View-Intent, is inspired by the front-end framework and promotes a one-way flow of data, making it a perfect fit for the logical part of the Compose project, which fully implements the core idea of a data-driven UI.
The MVI architecture was briefly introduced in the previous article, and I plan to do a comparison between MVI and other architectures such as MVVM. This article focuses only on the specific use of MVI in tetris games.
The project structure is as follows:
- View layer: Built in Compose, all UI elements are implemented in code
- Model layer:
ViewModel
maintenanceState
Change the game logic byreduce
To deal with - V – M communicationThrough:
StateFlow
Driver Compose refresh, user event byAction
Distributed to the ViewModel
The core code of the ViewModel is as follows:
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 -> {
/ / a little...
state.copy(...)
}
Action.Move -> {
/ / a little...
state.copy(...)
}
/ / a little...}}Copy the code
Next, let’s look at the implementation of the View and Model layers.
3. View Layer: Based on Compose
As a single page game, there is no page jump, and the interface is composed of the following parts:
- GameBody: Draws buttons and handles user input
- GameScreen:
- BrickMatrix: Draws the background of the square matrix and the falling square
- Scoreboard: Displays the game score, clock, and other information
Next, I will focus on the BrickMatrix and GameBody drawing
3.1 Square drawing (BrickMatrix)
The square area is made up of a matrix of 12 by 24 squares. In order to simulate the display effect of an LCD screen, you need to draw a light colored matrix and a dark colored brick (falling and bottom) separately. All elements are drawn based on the Compose Canvas.
The basic use of Canvas is described in my previous post: juejin.cn/post/694488…
Draw background matrix
First draw the shape of each brick unit as a square:
DrawBrick:
DrawScope is needed to draw graphs in Canvas. For ease of use, we define drawBrick as the extension function of DrawScope
private fun DrawScope.drawBrick(
brickSize: Float.// The size of each square
offset: Offset.// The offset position in the matrix
color: Color// Brick color
) {
// Calculate the actual position based on the Offset
val actualLocation = Offset(
offset.x * brickSize,
offset.y * brickSize
)
val outerSize = brickSize * 0.8 f
val outerOffset = (brickSize - outerSize) / 2
// Draw the external rectangle border
drawRect(
color,
topLeft = actualLocation + Offset(outerOffset, outerOffset),
size = Size(outerSize, outerSize),
style = Stroke(outerSize / 10))val innerSize = brickSize * 0.5 f
val innerOffset = (brickSize - innerSize) / 2
// Draw the inner rectangle
drawRect(
color,
actualLocation + Offset(innerOffset, innerOffset),
size = Size(innerSize, innerSize)
)
}
Copy the code
DrawMatrix:
With the brick units out of the way, draw the matrix below
private fun DrawScope.drawMatrix(
brickSize: Float,
matrix: Pair<Int.Int> // The number of horizontal and vertical: 12 * 24
){(0 until matrix.first).forEach { x ->
(0 until matrix.second).forEach { y ->
// Traverse calls drawBrick
drawBrick(
brickSize,
Offset(x.toFloat(), y.toFloat()),
BrickMatrix
)
}
}
}
Copy the code
Draw falling bricks
Depending on where the brick units are placed, they form different shapes of falling bricks.
Each Shape is defined as a list of offsets relative to the top left.
Shape:
We define all Shape constants as follows:
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
)
Copy the code
Spirit:
Shape and Offset determine the exact position of the falling brick in the Matrix. Define Spirit to represent falling bricks:
data class Spirit(
val shape: List<Offset> = emptyList(),
val offset: Offset = Offset(0.0)) {val location: List<Offset> = shape.map { it + offset }
}
Copy the code
drawSpirit
Finally, drawBrick is called to draw the falling brick
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
)
}
}
}
Copy the code
3.2 GameBody
The core of GameBody is button drawing and event handling
Draw a Button
Button drawing is very simple, through the RoundedCornerShape to achieve the circle, through the Modifier to add a shadow to increase the three-dimensional sense
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))
}
}
Copy the code
Add event
Expect the block to keep moving while holding down the arrow key. Modifier. Clickable () can only set click events, do not meet the use of requirements, need to let button can handle continuous events.
Modifier. PointerIneropFilter: intercept MotionEvent:
A similar requirement is usually achieved by handling motionEvents, which we provide in Compose:
Modifier.pointerIneropFilter { //it:MotionEvent // Can get the current MotionEvent
when(it.action) { ACTION_DOWN -> { ... }... }}Copy the code
Modifier. Indication: Set the click background color
After intercepting a MotionEvent, the default logic that the background color changes when a button is pressed is disabled. This can be compensated by using Modifier. Indication, which allows us to change the display state based on the interactive state of the current button:
Modifier
.indication(
interactionSource = interactionSource, // Observe the interaction state
indication = rememberRipple() // Set Ripple style display effect
)
.pointerInteropFilter {
when(it.action) {
ACTION_DOWN -> {
val interaction = PressInteraction.Press(Offset(50f.50f))
interactionSource.emit(interaction) // Notify the change of the interactive state and change the display state}... }}Copy the code
ReceiveChannel Send continuous event:
Adding the Modifier. PointerIneropFilter and Modifier. Indication of the complete code is as follows:
@Composable
fun GameButton(
modifier: Modifier = Modifier,
size: Dp,
onClick: () -> Unit = {},
content: @Composable (Modifier) - >Unit
) {
val backgroundShape = RoundedCornerShape(size / 2)
lateinit var ticker: ReceiveChannel<Unit> / / timer
val coroutineScope = rememberCoroutineScope()
valpressedInteraction = 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 properlypressedInteraction.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 sends continuous events
ticker
.receiveAsFlow()
.collect { onClick() }
}
}
/ / a little...
}
true
}
) {
content(Modifier.align(Alignment.Center))
}
}
Copy the code
Use ticker() to create a ReceiveChannel for the continuous event source
Assemble the Button and send the Action
Finally, lay out each Button in GameBody and send the Action to the ViewModel in OnClick.
For example, the layout of the four arrow keys:
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))
}
}
Copy the code
Clicable: distribution event
Clickable is responsible for event distribution:
data class Clickable constructor(
val onMove: (Direction) -> Unit./ / move
val onRotate: () -> Unit./ / rotation
val onRestart: () -> Unit.// Start and reset the game
val onPause: () -> Unit.// Pause and resume the game
val onMute: () -> Unit// Turn on and off the game music
)
Copy the code
3.3 subscribe to game state (ViewState)
GameScreen subscribing to the viewModel’s data to refresh the UI. ViewState is the only data Source and follows the requirements Of Single Source Of Truth.
Use the ViewModel in Compose
Added support for ViewModel-compose to make it easier to access ViewModle in the Composable
implementation "Androidx. Lifecycle: lifecycle - viewmodel - compose: 1.0.0 - alpha03"
Copy the code
@Composable
fun GameScreen(modifier: Modifier = Modifier) {
val viewModel = viewModel<GameViewModel>() / / get the ViewModel
val viewState by viewModel.viewState.collectAsState() / / to subscribe to the State
Box() {
Canvas(
modifier = Modifier.fillMaxSize()
) {
val brickSize = min(
size.width / viewState.matrix.first,
size.height / viewState.matrix.second
)
// Only draw the UI, without any logic
drawMatrix(brickSize, viewState.matrix)
drawBricks(viewState.bricks, brickSize, viewState.matrix)
drawSpirit(viewState.spirit, brickSize, viewState.matrix)
}
/ / a little...}}Copy the code
Look! With the blessing of MVI, Compose does not need to touch any logic any more. It is only responsible for drawXXX, and all logic is handled by ViewModel.
Next, let’s move on to the ViewModel implementation.
4. Model Layer: Implement based on ViewModel
The Model layer in MVI is generally responsible for data requests and State updates. Tetris has no data request scenario, only local state updates are handled.
4.1 view state
Following the SSOT principle, all data that affects UI refresh is defined in ViewState
data class ViewState(
val bricks: List<Brick> = emptyList(), // The bottom is a box of bricks
val spirit: Spirit = Empty, // Falling bricks
val spiritReserve: List<Spirit> = emptyList(), // Add t brick (Next)
val matrix: Pair<Int.Int> = MatrixWidth to MatrixHeight,// Matrix size
val gameStatus: GameStatus = GameStatus.Onboard,// State of the game
val score: Int = 0./ / score
val line: Int = 0.// How many lines
val level: Int = 0.// Current level (difficulty)
val isMute: Boolean = false.// Whether to mute
)
Copy the code
enum class GameStatus {
Onboard, // Welcome to the game
Running, // The game is in progress
LineClearing,// Cancel the action in the picture
Paused,// Pause the game
ScreenClearing, // Clear the screen animation
GameOver// Game over
}
Copy the code
As mentioned above, even the logic such as line elimination and screen clearance is also responsible for ViewModel, and Compose can react State without brainpower.
4.2 the Action
User input is notified to the ViewModel via actions. The following actions are currently supported:
sealed class Action {
data class Move(val direction: Direction) : Action() // Click the arrow keys
object Reset : Action() / / click on start
object Pause : Action() / / click pause
object Resume : Action() / / click on resume
object Rotate : Action() / / click to rotate
object Drop : Action() // Click ↑ to drop directly
object GameTick : Action() // Brick drop notification
object Mute : Action()/ / click.mute
}
Copy the code
4.3 the reduce
After receiving the Action, the ViewModel distributes the Action to reduce and updates the ViewState.
GameTicker: Brick falling Action
In the core GameTicker example, all other actions are triggered by the user, but the GameTicker is automatically triggered to ensure that the brick drops at a certain speed.
The basic process is shown in the figure above, updating the ViewStae based on the status of Spirit in the current Matrix:
- Not hitting the bottom:
- Spirit takes a step forward on the Y-axis
- Hit the bottom, but failed to cancel:
- Update Next Spirit
- – Updated bricks that sink to the bottom (absorbing Spirit)
- Successful elimination:
- Update Next Spirit
- Update GameState to LineClearing
- Screen overflow:
- Update GameState to GameOver
fun reduce(state: ViewState, action: Action) {
when(action) {
Action.GameTick -> run {
// If the bottom is not touched, the Y-axis is offset by +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)) {// If the block reaches the upper bounds of the screen, the game is over
}
// Update the bottom of Bricks,
UpdateBricks: status information of bottom Bricks
// clearedLine: delete line information
val (updatedBricks, clearedLines) = updateBricks(
state.bricks,
state.spirit,
matrix = state.matrix
)
//updatedBricks returns the bottom Bricks information consisting of three List
val (noClear, clearing, cleared) = updatedBricks
if(clearedLines ! =0) {
// Row cancellation succeeded
// Execute the action diagram, see below
} else {
// No cancellationEmit (newstate.copy (bricks = noClear, spirit = state.spiritnext))}} emit(newstate.copy (bricks = noClear, spirit = state.spiritnext))}}//end of run}}Copy the code
isValidInMatrix()
Determine if Spirit is out of bounds relative to Matrix. Out of bounds means game over.- when
Spirit
When it hits the bottom,updatedBricks
Responsible for updating the bottom Bricks data by adding the Bricks absorbed by Spirit to the bottom Bricks.
Brick is simply defined as Offset in the Matrix
data class Brick(val location: Offset = Offset(0.0))
Copy the code
UpdatedBricks returns three lists
, each of which records the intermediate state of Bricks during the elimination process
- NoClear: bricks that have not been eliminated
- clearing: bricks in eliminating lines (equivalent to eliminating blank lines is set to
Invisiable
) - cleared: bricks after eliminating lines (equivalent to eliminating blank lines set to
Gone
)
noClear | clearing | cleared |
---|---|---|
Action elimination painting:
Based on the returned List
, the action drawing is eliminated by updating the state
launch {
//animate the clearing lines
repeat(5) {
emit(
// noClear/ Clearing
state.copy(
gameStatus = GameStatus.LineClearing,
spirit = Empty,
bricks = if (it % 2= =0) noClear else clearing
)
delay(100)}//delay emit new state
emit(
// When the animation ends, bricks is updated to cleared
state.copy(
spirit = state.spiritNext,
bricks = cleared,
gameStatus = GameStatus.Running
)
)
}
Copy the code
5. Use @ the Preview
One more word at the end of this article @Preview.
Many Android developers are used to seeing the UI from a real machine because AndroidStudio’s XML preview features are weak. For example: Compose’s @Preview Preview is the same as the real thing, allowing you to develop what you see. Therefore, it is recommended to include @Preview for any Composable that needs debugging. This will greatly improve your UI development efficiency.
Since @preview does not accept business parameters, the Composable interface definition should be Preview friendly and include previewable default values as much as possible
With @Preview, we can easily Preview parts of the UI, and combine the Composalbe of @Preview to Preview the full picture. UI development is streamlined like an assembly shop:
In addition to the basic Preview, @Preview also provides more functions such as interactive Preview, real machine Preview and so on. In addition, you can save the preview UI as.png directly by right-clicking. This game’s AppIcon is created in this way.
6. The final
Space is limited, this article can only introduce the basic implementation process of the game, other more functional implementation welcome to consult the source code to understand.
All UI refreshes, including animations, are driven by State throughout the game. Thanks to the powerful compile-time optimization of Compose Compiler, even frequent Recomposition runs smoothly without any performance problems.
Compose is such a smooth game, how about a normal UI? In terms of performance, there is no need to worry about it. In the future, with the continuous improvement of the function level, Compose’s era may really come, and android.View. view system, which has existed since the birth of Android, will also usher in its end.
~ Game Over ~
The project address
Github.com/vitaviva/co…
The latest version of Canary from AndroidStudio is recommended