Public number: byte array, hope to help you 🤣🤣

How to use Jetpack Compose to create tetris in your childhood 🤣🤣

First look at the effect diagram, the function is quite perfect

In my own experience, I don’t see any performance differences between composing and Android native apps, but Compose is a lot easier to develop

Google says about Compose on its website: Jetpack Compose is a new Android toolkit for building native interfaces. It simplifies and speeds up interface development on Android, using less code, powerful tools, and intuitive Kotlin apis to quickly bring your apps alive and exciting

Android’s View hierarchy has long been represented as a View tree containing several views and viewgroups. When the application’s data changes due to, for example, user interaction, the interface hierarchy needs to be updated to display the latest data. The most common way to update an interface is to traverse the view tree using functions like findViewById(), And change specific nodes by calling methods such as button.settext (String), container.addChild(View), or img.setimageBitmap (Bitmap), which change the View’s internal state. But this manual manipulation of the view raises the possibility of error. If a piece of data needs to be presented in multiple places, developers can accidentally forget to update a view that displays it. In addition, it is easy to create an abnormal state when two updates collide in unexpected ways. For example, an update might attempt to modify a node that has just been removed from the interface. In general, software maintenance complexity increases as the number of views that need to be updated increases

In the past few years, the industry has moved toward declarative interface models that greatly simplify the engineering associated with building and updating interfaces. The technique works by conceptually regenerating the entire screen from scratch, then only making the necessary changes. This approach avoids the complexity of manually updating the stateful view hierarchy. Compose is a new declarative interface toolkit for Android that provides declarative apis to make it easier to write and maintain application interfaces by rendering them without changing the front view by command

Combinable function

Compose focuses on the @composable function, which can Compose functions. Each Composable function can take several input parameters to participate in the rendering of the view structure, but the function does not return any values. Composable functions are only used to describe how the view structure is drawn and how it interacts with the user, but instead of returning view objects, Compose generates concrete view objects based on the developer’s description

The icon of this game is generated in this way. As you can see, the PreviewTetrisIcon() function does not contain a return value, nor does it require an input parameter in this case. In addition, one of the advantages of Compose is that what you see is what you get. By adding the @Preview annotation, you can Preview the implementation and see the changes after each change without compiling

Compose is a declarative interface framework, which itself is a bit of a composition. Each view node is declared as a function, so it is natural to declare each view node as a function, and then combine each function as an input parameter to the final view tree function

Take this game as an example, the whole game contains only one page, which can be subdivided into three nodes: game body, game screen and game button.

The TetrisBody function contains two input parameters that hold the TetrisScreen and TetrisButton

@Composable
fun TetrisBody(
    tetrisScreen: @Composable(() - >Unit),
    tetrisButton: @Composable(() - >Unit),
Copy the code

Game body – TetrisBody

TetrisBody is simple and has three functions:

  • Draw background color
  • Reserve space for TetrisScreen and TetrisButton
  • Draw a shaded border for TetrisScreen
@Composable
fun TetrisBody(
    tetrisScreen: @Composable(() - >Unit),
    tetrisButton: @Composable(() - >Unit), {
    Column(
        modifier = Modifier
            .fillMaxSize()
            .background(color = BodyBackground)
            .padding(bottom = 30.dp)
    ) {
        Box(
            Modifier
                .align(alignment = Alignment.CenterHorizontally)
                .fillMaxWidth()
                .weight(weight = 1f)
                .padding(start = 40.dp, top = 50.dp, end = 40.dp, bottom = 10.dp),
        ) {

            // Draw the borders of the game screen
            val borderPadding = 8.dp
            Canvas(modifier = Modifier.fillMaxSize()) {
                drawScreenBorder(
                    leftTop = Offset(x = 0f, y = 0f),
                    width = size.width,
                    height = size.height,
                    borderPadding = borderPadding,
                )
            }

            // Game screen
            Row(
                modifier = Modifier
                    .matchParentSize()
                    .padding(all = borderPadding)
            ) {
                tetrisScreen()
            }
        }

        // The game button
        tetrisButton()
    }
}
Copy the code

Game button – TetrisButton

TetrisButton is also very simple. It has two functions to implement:

  • Draws nine action buttons
  • Transparent transmission of user click operations to distinguish event types

So the TetrisButton function needs to include an input parameter, PlayListener object, and TetrisButton needs to call the Corresponding method of PlayListener back and forth based on which button the user clicked, and pass through the click event

enum class TransformationType {
    Left, Right, Rotate, Down, FastDown, Fall
}

data class PlayListener constructor(
    val onStart: () -> Unit.val onPause: () -> Unit.val onReset: () -> Unit.val onSound: () -> Unit.val onTransformation: (TransformationType) -> Unit
)

@Preview(backgroundColor = 0xffefcc19, showBackground = true)
@Composable
fun TetrisButton(
    playListener: PlayListener = combinedPlayListener()
) {
    Column(
        modifier = Modifier
            .fillMaxWidth()
            .wrapContentHeight(),
    ) {
        Row(
            modifier = Modifier
                .fillMaxWidth()
                .wrapContentHeight(),
            horizontalArrangement = Arrangement.Center
        ) {
            val controlPadding = 20.dp
            ControlButton(hint = "Start", modifier = Modifier.padding(end = controlPadding)) {
                playListener.onStart()
            }
            ControlButton(
                hint = "Pause",
                modifier = Modifier.padding(start = controlPadding, end = controlPadding)
            ) {
                playListener.onPause()
            }
            ControlButton(
                hint = "Reset",
                modifier = Modifier.padding(start = controlPadding, end = controlPadding)
            ) {
                playListener.onReset()
            }
            ControlButton(hint = "Sound". modifier = Modifier.padding(start = controlPadding)) { playListener.onSound() } } ConstraintLayout( modifier = Modifier .padding(top =20.dp)
                .fillMaxWidth()
                .wrapContentWidth(align = Alignment.CenterHorizontally)
        ) {
            val (leftBtn, rightBtn, fastDownBtn, rotateBtn, fallBtn) = createRefs()
            val innerMargin = 24.dp
            PlayButton(icon = "â—€". modifier = Modifier.constrainAs(leftBtn) { start.linkTo(anchor = parent.start) top.linkTo(anchor = parent.top) end.linkTo(anchor = rightBtn.start, margin = innerMargin) }) { playListener.onTransformation(Left) } PlayButton(icon ="â–¶", modifier = Modifier.constrainAs(rightBtn) {
                start.linkTo(anchor = leftBtn.end, margin = innerMargin)
                top.linkTo(anchor = leftBtn.top)
                bottom.linkTo(anchor = leftBtn.bottom)
            }) {
                playListener.onTransformation(Right)
            }
            PlayButton(
                icon = "Rotate",
                fontSize = 18.sp,
                modifier = Modifier.constrainAs(rotateBtn) {
                    top.linkTo(anchor = rightBtn.top)
                    start.linkTo(anchor = rightBtn.end, margin = innerMargin)
                }) {
                playListener.onTransformation(Rotate)
            }
            PlayButton(icon = "â–¼". modifier = Modifier.constrainAs(fastDownBtn) { top.linkTo(anchor = leftBtn.bottom) start.linkTo(anchor = leftBtn.start) end.linkTo(anchor = rightBtn.end) }) { playListener.onTransformation(FastDown) } PlayButton( icon ="â–¼ â–¼ \ n". modifier = Modifier.constrainAs(fallBtn) { top.linkTo(anchor = fastDownBtn.top) start.linkTo(anchor = rightBtn.end) end.linkTo(anchor = rotateBtn.start) }) { playListener.onTransformation(Fall) } } } }Copy the code

Game screen – TetrisScreen

TetrisScreen is relatively complex and needs to realize five main function points:

  • Draw the game screen background
  • Draws falling blocks
  • Square provides left shift, right shift, uniform drop, acceleration drop, rotation and other functions
  • When the block can no longer fall, according to the need to decide whether to cancel, and then save the coordinate information of the block to the screen background, according to the coordinate information to draw solid block, and then generate a new block, repeat the second step
  • When the block can no longer fall, if the block is beyond the current screen, the game ends and the screen is cleared

Compose is according to the function into the reference parameters are changed to determine whether to need to update the interface, so when we are in drawing the whereabouts of the box you can see the entire page as static, only need to according to the coordinates of the current map, then every few hundred milliseconds change square coordinate information, thus generating new into the parameter, For notification Compose, update the page

The State information for the entire game is stored in a TetrisState object, which Compose listens for changes in the median State

to determine if an interface update is needed. The entire game screen is defined as a 10 x 24 two-dimensional array, the brickArray, which corresponds to a solid block when the array value equals 1, or a hollow block otherwise. A Tetris is a cube in a falling state

data class TetrisState(
    val brickArray: Array<IntArray>, // Screen coordinates
    val tetris: Tetris, // Falling blocks
    val gameStatus: GameStatus = GameStatus.Welcome, // Game state
    val soundEnable: Boolean = true.// Whether to enable sound effects
    val nextTetris: Tetris = Tetris(), // Next block
)
Copy the code

Square types can be divided into seven types, with letters are: I, S, Z, L, O, J, T. Each type can fit into a 4 x 4 two-dimensional array, and no matter how rotated it is, it doesn’t go beyond that range. You can use the following array to easily remember each possible rotation result

val mockData = arrayOf(
    arrayOf(//I
        intArrayOf(
            0.0.0.0.0.0.0.0.0.0.0.0.1.1.1.1
        ),
        intArrayOf(
            0.1.0.0.0.1.0.0.0.1.0.0.0.1.0.0
        )
    ),
    arrayOf(//S
        intArrayOf(
            0.0.0.0.0.0.0.0.0.1.1.0.1.1.0.0
        ),
        intArrayOf(
            0.0.0.0.1.0.0.0.1.1.0.0.0.1.0.0
        )
    ),
    arrayOf(//Z
        intArrayOf(
            0.0.0.0.0.0.0.0.1.1.0.0.0.1.1.0
        ),
        intArrayOf(
            0.0.0.0.0.1.0.0.1.1.0.0.1.0.0.0)),...Copy the code

Every falling square is defined as a Tetris object. In the initial state, the value of brickArray is equal to 0, while the initial position of Tetris is outside the screen. Every time the Tetris falls, the coordinate value of the position of the Tetris in the brickArray is changed to 1, which determines the position of the solid block to be drawn on the screen. Then by changing the square relative to the upper left corner of the screen Offset value, in order to change the square relative to the screen position, so as to achieve the square left and right movement and falling

data class Location(val x: Int.val y: Int)

data class Tetris constructor(
    val shapes: List<List<Location>>, // All possible rotation results for this square
    val type: Int.// Used to indicate which rotation state is currently in
    val offset: Location, // The offset of the square relative to the upper left corner of the screen
) {

    // The current shape of this box
    val shape: List<Location>
        get() = shapes[type]
    
}
Copy the code

For simplicity, you can define in advance the various possible Tetris square types and the various rotation results of that square

		private val allShapes = listOf(
            //I
            listOf(
                listOf(Location(0.3), Location(1.3), Location(2.3), Location(3.3)),
                listOf(Location(1.0), Location(1.1), Location(1.2), Location(1.3))),//S
            listOf(
                listOf(Location(0.3), Location(1.2), Location(1.3), Location(2.2)),
                listOf(Location(0.1), Location(0.2), Location(1.2), Location(1.3))),//Z
            listOf(
                listOf(Location(0.2), Location(1.2), Location(1.3), Location(2.3)),
                listOf(Location(0.2), Location(0.3), Location(1.1), Location(1.2))),//L
            listOf(
                listOf(Location(0.1), Location(0.2), Location(0.3), Location(1.3)),
                listOf(Location(0.2), Location(0.3), Location(1.2), Location(2.2)),
                listOf(Location(0.1), Location(1.1), Location(1.2), Location(1.3)),
                listOf(Location(0.3), Location(1.3), Location(2.3), Location(2.2))),//O
            listOf(
                listOf(Location(0.2), Location(0.3), Location(1.2), Location(1.3))),//J
            listOf(
                listOf(Location(0.3), Location(1.1), Location(1.2), Location(1.3)),
                listOf(Location(0.2), Location(0.3), Location(1.3), Location(2.3)),
                listOf(Location(0.1), Location(0.2), Location(0.3), Location(1.1)),
                listOf(Location(0.2), Location(1.2), Location(2.2), Location(2.3))),//T
            listOf(
                listOf(Location(0.2), Location(1.2), Location(2.2), Location(1.3)),
                listOf(Location(1.1), Location(0.2), Location(1.2), Location(1.3)),
                listOf(Location(1.2), Location(0.3), Location(1.3), Location(2.3)),
                listOf(Location(0.1), Location(0.2), Location(0.3), Location(1.2)),),)Copy the code

Each time a Tetris object is then generated, it is randomly evaluated from allShapes. And the Y value of the initial offset of each Tetris object is fixed at -4, that is, it is off the screen by default. When the block is constantly moving, its offset will be Location(0, -3), Location(1, -2)…. Location(2, 10) and other values, by changing the X value to achieve left and right movement, change the Y value to achieve down

        operator fun invoke(a): Tetris {
            val shapes = allShapes.random()
            val type = Random.nextInt(0, shapes.size)
            return Tetris(
                shapes = shapes,
                type = type,
                offset = Location(
                    Random.nextInt(
                        0,
                        BRICK_WIDTH - 3
                    ), -4))}Copy the code

Each square can be drawn using Canvas, which is defined as an extension function for convenience. Color controls whether to draw solid or open squares

fun DrawScope.drawBrick(brickSize: Float, color: Color) {
    drawRect(color = color, size = Size(brickSize, brickSize))
    val strokeWidth = brickSize / 9f
    translate(left = strokeWidth, top = strokeWidth) {
        drawRect(
            color = ScreenBackground,
            size = Size(
                width = brickSize - 2 * strokeWidth,
                height = brickSize - 2 * strokeWidth
            )
        )
    }
    val brickInnerSize = brickSize / 2.0 f
    val translateLeft = (brickSize - brickInnerSize) / 2
    translate(left = translateLeft, top = translateLeft) {
        drawRect(
            color = color,
            size = Size(brickInnerSize, brickInnerSize)
        )
    }
}
Copy the code

Then you can draw the screen background and the falling blocks by simply iterating through the screenMatrix that represents the coordinates of the entire screen. If the value is equal to one, BrickFill color is used, otherwise BrickAlpha is used. Each time a block cannot fall further, the coordinate value of the block is written to the screenMatrix to preserve each fixed solid block

	Canvas(
        modifier = Modifier
            .fillMaxSize()
            .background(color = ScreenBackground)
            .padding(
                start = screenPadding, top = screenPadding,
                end = screenPadding, bottom = screenPadding
            )
    ) {
        val width = size.width
        val height = size.height
        val screenPaddingPx = screenPadding.toPx()
        val spiritPaddingPx = spiritPadding.toPx()
        val brickSize = (height - spiritPaddingPx * (matrixHeight - 1)) / matrixHeight

        kotlin.run {
            screenMatrix.forEachIndexed { y, ints ->
                ints.forEachIndexed { x, isFill ->
                    translate(
                        left = x * (brickSize + spiritPaddingPx),
                        top = y * (brickSize + spiritPaddingPx)
                    ) {
                        drawBrick(
                            brickSize = brickSize,
                            color = if (isFill == 1) BrickFill elseBrickAlpha)}}}} ···}Copy the code

Scheduler – TetrisViewModel

The TetrisViewModel is a scheduler for the entire game, and its general structure is shown below. The Dispatch method is responsible for receiving external events, the type of which corresponds to the enclosing Action class

class TetrisViewModel : ViewModel() {

    companion object {

        private const val DOWN_SPEED = 500L

        private const val CLEAR_SCREEN_SPEED = 30L

    }

    private val _tetrisStateLD: MutableStateFlow<TetrisState> = MutableStateFlow(TetrisState())

    val tetrisStateLD = _tetrisStateLD.asStateFlow()

    private val tetrisState: TetrisState
        get() = _tetrisStateLD.value

    private var downJob: Job? = null

    private var clearScreenJob: Job? = null

    fun dispatch(action: Action) {
        playSound(action)
        val unit = when(Action) {action.welcome, action.reset -> {··· ·} action.start -> {··· ·} action.background, Action. The Pause - > {...} Action. The Resume - > {} Action. The Sound - > {...}isAction.Transformation -> {···}}} ···}sealed class Action {
    object Welcome : Action()
    object Start : Action()
    object Pause : Action()
    object Reset : Action()
    object Sound : Action()
    object Background : Action()
    object Resume : Action()
    data class Transformation(val transformationType: TransformationType) : Action()
}

enum class TransformationType {
    Left, Right, Rotate, Down, FastDown, Fall
}
Copy the code

When the game starts for the first time, MainActivity sends the Action.Welcome event to execute the Welcome animation. When a subsequent user clicks the Start button to Start the game, the Action.Start event is issued, which starts a coroutine task that executes a delayed task, downJob, which delivers the transformationType. Down event. When the event is consumed, the startDownJob() method is repeatedly called to achieve a self-driven uniform drop of squares

    private var downJob: Job? = null

    private fun onStartGame(a) {
        dispatchState(TetrisState().copy(gameStatus = GameStatus.Running))
        startDownJob()
    }

    private fun startDownJob(a) {
        cancelDownJob()
        cancelClearScreenJob()
        downJob = viewModelScope.launch {
            delay(DOWN_SPEED)
            dispatch(Action.Transformation(TransformationType.Down))
        }
    }
Copy the code

Action.Transformation stands for multiple actions, such as left and right movement, rotation, and so on. But not every action will work, because doing it can cause squares to go off the screen. Therefore, if the onTransformation method returns null, the action is invalid and no interface needs to be updated

fun TetrisState.onTransformation(transformationType: TransformationType): TetrisState {
    return when(transformationType) { TransformationType.Left -> { onLeft() } TransformationType.Right -> { onRight() } TransformationType.Down -> { onDown() } TransformationType.FastDown -> { onFastDown() } TransformationType.Fall -> { onFall() } TransformationType.Rotate -> { onRotate() } }? .finalize() ? :this.finalize()
}
Copy the code

The Left, Right, Down, FastDown, and Fall events all operate on offset by changing the X and Y coordinates of offset to move the square position

private fun TetrisState.onLeft(a): TetrisState? {
    return copy(
        tetris = tetris.copy(offset = Location(tetris.offset.x - 1, tetris.offset.y))
    ).takeIf { it.isValidInMatrix() }
}

private fun TetrisState.onRight(a): TetrisState? {
    return copy(
        tetris = tetris.copy(offset = Location(tetris.offset.x + 1, tetris.offset.y))
    ).takeIf { it.isValidInMatrix() }
}

private fun TetrisState.onDown(a): TetrisState? {
    return copy(
        tetris = tetris.copy(
            offset = Location(tetris.offset.x, tetris.offset.y + 1)
        )
    ).takeIf { it.isValidInMatrix() }
}

private fun TetrisState.onFastDown(a): TetrisState? {
    return copy(
        tetris = tetris.copy(
            offset = Location(tetris.offset.x, tetris.offset.y + 2)
        )
    ).takeIf { it.isValidInMatrix() }
}

private fun TetrisState.onFall(a): TetrisState? {
    while (true) {
        valresult = onDown() ? :return this
        return result.onFall()
    }
}
Copy the code

As mentioned earlier, each square type contains multiple rotation results, so the Rotate event needs to change the square to another rotation shape. Since the coordinate system of squares can be out of the current screen after rotation, adjustOffset() method is also needed to adjust the coordinate system of squares back to the screen

private fun TetrisState.onRotate(a): TetrisState? {
    if (tetris.shapes.size == 1) {
        return null
    }
    val nextType = if (tetris.type + 1 < tetris.shapes.size) {
        tetris.type + 1
    } else {
        0
    }
    return copy(
        tetris = tetris.copy(
            type = nextType,
        )
    ).adjustOffset().takeIf { it.isValidInMatrix() }
}
Copy the code

When a block can no longer fall, or is already out of the screen, finalize() method should be used to write the coordinate value of the block into the screen background brickArray and reset the game state

private fun TetrisState.finalize(a): TetrisState {
    if (canDown()) {
        return this
    } else {
        var gameOver = false
        for (location in tetris.shape) {
            val x = location.x + tetris.offset.x
            val y = location.y + tetris.offset.y
            if (x in 0 until width && y in 0 until height) {
                brickArray[y][x] = 1
            } else {
                gameOver = true}}return if (gameOver) {
            copy(gameStatus = GameStatus.GameOver)
        } else {
            val clearRes = clearIfNeed()
            if (clearRes == null) {
                copy(
                    gameStatus = GameStatus.Running,
                    tetris = nextTetris,
                    nextTetris = Tetris()
                )
            } else {
                copy(
                    gameStatus = GameStatus.LineClearing,
                    tetris = nextTetris,
                    nextTetris = Tetris()
                )
            }
        }
    }
}
Copy the code

The project address

The general implementation of the game as described above, the expression ability is limited, some places can not speak too clearly, implementation details welcome to see the source code to understand 😂😂

Github address: github.com/leavesC/com…

Apk download experience: github.com/leavesC/com…