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:ViewModelmaintenanceStateChange the game logic byreduceTo deal with
  • V – M communicationThrough:StateFlowDriver Compose refresh, user event byActionDistributed 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.
  • whenSpiritWhen it hits the bottom,updatedBricksResponsible 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 toInvisiable)
  • cleared: bricks after eliminating lines (equivalent to eliminating blank lines set toGone)
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