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…