Some time ago, I read a book by the head of TechMerger called “Compose perfect Reproduction of Flappy Bird!” So I decided to learn Jetpack Compose by writing a game. Let’s Go!
Here, I also strongly push fundroid’s “Make a Tetris game machine with Jetpack Compose” and “write a Compose version of Huarong Road in 100 lines”, which are very wonderful.
Take a look at your blog, benefit a lot, thank you for sharing.
“Classic Plane Wars” is a classic built-in game launched by Tencent communication software wechat 5.0 in August 2013. It is not available now, but there are other mini games for reference. This article mainly introduces some usage methods of Jetpack Compose Api for your reference.
1. Game preview
Players click and move their planes, which score points by firing bullets at other oncoming enemy planes while dodging them. Once we hit another enemy plane, the game is over. Interested partners, micro channel small program search aircraft war can be played directly.
Or Github download source import installation experience: github.com/xiangang/An…
2. Game disassembly
Game is mainly composed of the following elements: the stage background, aircraft, bullets, sound effects (the bullet shooting sound, explosion sound), enemy planes (small, medium and three kind of types), animation (players play animation, animation and explosion plane enemy planes bomb), props reward, score, game control (start, pause, resume, restart, exit), etc.
2.1 Stage Background
This is easy, just draw a picture and you’re done, the background doesn’t need to move.
2.2 Player Aircraft
Player aircraft can be dragged and moved by fingers at will, firing bullets, and there is flight animation, will explode after the collision of enemy aircraft, every shot down an enemy aircraft can get the corresponding score, during the game can get bullets and explosive props through the collision reward.
2.3 the bullet
Bullets constantly emerge from the head of the player’s plane, moving at a certain speed in the negative direction of the Y axis, but not horizontally along the X axis. They consume the enemy’s health when they hit and disappear.
Bullets come in red single-shot and blue double-shot types, and vary in their ability to hit enemy planes (enemy hit points per shot) and size (impact collision detection).
2.4 the enemy
Enemy planes are randomly born at the top of the screen, moving down the Y-axis in the positive direction, but cannot move horizontally along the X-axis, and do not fire bullets. The enemy aircraft is divided into reconnaissance plane (small), fighter (middle), battleship (big) three types, flying speed, the ability to fight against large are not the same. There are currently three difficulty levels, and the number of enemy planes will increase as the difficulty level increases.
2.5 Explosion Animation
An explosion animation is triggered when the player’s plane is hit by an enemy plane and the enemy plane is shot down by a bullet.
2.5 Item Rewards
During the game, as the difficulty of the game increases, randomly generated props will be rewarded to improve the survivability of the player’s aircraft. There are only two types of item rewards: bullets and bombs.
2.6 other
Game start interface, display Logo, player plane, start the game button.
Game interface, the upper left corner can suspend to continue the game, the upper right corner shows the score, the lower left corner shows the bomb props, click can detonate all the enemy planes on the screen.
Game end interface, display score, restart and exit the game button.
Preview of all materials:
Game material from: github.com/iSpring/Gam… Github.com/zhangphil/A…
3. Game basics and architecture
3.1 Basic Concepts
To make this article easier to understand, a few additional notes will be added.
Since you are doing game development, you still need to learn the basic concepts of game development first. I recommend reading the Basic Concepts of Game Development. Sprites are two-dimensional graphic objects for characters, items, shells, and other 2D game elements. In 2D games, the graphics part is mostly the manipulation of images, often referred to as sprites.
Elf (Sprite)Objects need to be controlled and moved around the screen. Look at the Android screen coordinate system below:
For more knowledge about Android screen coordinate system, you can refer to AWeiLoveAndroid “Android application coordinate system comprehensive details”
In plain English, to make a Sprite object move, you need to sense the passage of time and control its coordinates (x and y) to change. Since it’s an object, you need a Sprite class.
/** * Sprite base class */
@InternalCoroutinesApi
open class Sprite(
open var id: Long = System.currentTimeMillis().//id
open var name: String = "Father elf."./ / name
open var type: Int = 0./ / type
@DrawableRes open val drawableIds: List<Int> = listOf(
R.drawable.sprite_player_plane_1,
R.drawable.sprite_player_plane_2
),// Resource icon
@DrawableRes open val bombDrawableId: Int = R.drawable.sprite_explosion_seq, // Enemy explosion frame animation resources
open var segment: Int = 14.// Explosion effect consists of four segments: player plane 4, small plane 3, medium plane 4, large plane 6, and explosion 14
open var x: Int = 0.// Real-time x coordinates
open var y: Int = 0.// Real-time y coordinates
open var startX: Int = -100.// The starting position of the occurrence
open var startY: Int = -100.// The starting position of the occurrence
open var width: Dp = BULLET_SPRITE_WIDTH.dp, / / wide
open var height: Dp = BULLET_SPRITE_HEIGHT.dp, / / high
open var speed: Int = 500.// Flight speed (deprecated)
open var velocity: Int = 40.// Flight speed (pixels moved per frame)
open var state: SpriteState = SpriteState.LIFE, // Control whether to display
open var init: Boolean = false.// Whether to initialize, mainly used for Sprite initialization starting point x, y coordinates, etc. State is used to disable the display, init is used to re-initialize the data, and it must be done after the Sprite has left the screen (a full cycle of movement), otherwise the timing of reuse after the death of the Sprite is difficult to master (of course it does not have to be done).
) {
fun isAlive(a) = state == SpriteState.LIFE
fun isDead(a) = state == SpriteState.DEATH
open fun reBirth(a) {
state = SpriteState.LIFE
}
open fun die(a) {
state = SpriteState.DEATH
}
override fun toString(a): String {
return "Sprite(id=$id, name='$name', drawableIds=$drawableIds, bombDrawableId=$bombDrawableId, segment=$segment, x=$x, y=$y, width=$width, height=$height, speed=$speed, state=$state)"}}Copy the code
With the Sprite class, object-oriented programming, we can control the Sprite object displacement by controlling the X and Y properties of the Sprite object.
For more information about Jetpack Compose, see the official documentation, “Compose Programming Ideas,” and the Fundroid Jetpack Compose tutorials.
In Jetpack Compose UI, use Modifier. Offset {IntOffset(x, y)} to set the View offset from the origin (0,0) in Android screen coordinate system.
For an introduction to the Modifier, see the official document “Modifier” for the use of Modifier, see the official document “Compose Modifier List”
In addition to controlling the X and Y properties of Sprite objects, you also need to sense the passage of time, as mentioned earlier.
How do you perceive it? What the big guys do is launch a scheduled task through LaunchedEffect that periodically sends an AutoTick that updates the view.
When LaunchedEffect enters the composition, it launches a coroutine and passes code blocks as arguments. If LaunchedEffect exits the composition, the coroutine is cancelled. The following code executes a 100s delayed task through a coroutine loop.
/ / to draw
setContent {
ComposePlaneTheme {
// A surface container using the 'background' color from the theme
Surface(color = MaterialTheme.colors.background) {
// Use coroutines to execute periodically
LaunchedEffect(key1 = Unit) {
while (isActive) {
delay(100)
//TODO auto tick, to do something
}
}
Stage(gameViewModel, onGameAction)
}
}
}
Copy the code
This way, you can constantly change the x and Y properties of the Sprite object in the composition function, and it looks like the Sprite object is constantly moving.
LaunchedEffect: Runs a suspended function in the scope of a composable item, described in “spin-off effects in Compose.”
However, instead of using this AutoTick approach, I started with pure repetitive animations using Jetpack Compose (essentially the same as the LaunchedEffect AutoTick approach, with the lowest level of animation API: **TargetBasedAnimation ** is also implemented with LaunchedEffect), took some devious steps, later tried to use the AutoTick implementation found it was very good, but in order to show different ideas, so part of the logic was changed to use animation. But look at the effect every time the animation end to restart the moment have a feeling of obvious frustration, this problem has not been solved for the time being.
3.2 Status and Architecture
State: Can be understood simply as any value that changes over time.
For Sprite objects, we need to update their X and Y properties (state) and drive elements in the interface to be redrawn to shift the View.
Because Compose is a declarative toolset,So the only way to update it is to call the same composable function with a new parameter. These parameters are representations of interface state.Every time a state update occurs, a reorganization occurs. Composable functions must explicitly know the new state before they can be updated accordingly. The diagram below:Redrawing an interface element completes the recombination process by updating the state and calling composable functions with new data. But a composable function is essentially a function, so you can’t declare local variables in a composable function to manage state. How can you manage state?
3.2.1 State management in composable items
Composable functions use remember to store a single object. The system stores the value calculated by Remember in the composition during initial composition and returns the stored value during reorganization. Remember can be used to store both mutable and immutable objects. In simple terms, remember is used to save and read the latest value of the state in composable functions.
However, remember can only save and read the latest value of the state. Our goal is to automatically drive the reorganization when the state changes.
Use mutableStateOf to create an observable MutableState, an observable type integrated with the Compose runtime, so that changes in state can be observed, driving the recombination of composable functions to redraw interface elements.
Example code is as follows:
@Composable
fun LowComposable(a) {
Column(modifier = Modifier.padding(16.dp)) {
var name by remember { mutableStateOf("")}if (name.isNotEmpty()) {
Text(
text = "Hello, $name!",
modifier = Modifier.padding(bottom = 8.dp),
style = MaterialTheme.typography.h5
)
}
OutlinedTextField(
value = name,
onValueChange = { name = it },
label = { Text("Name")})}}Copy the code
If there is any change to name, the system arranges to reorganize all composable functions that read name. Remember and mutableStateOf are indispensable, think about it, if one of them is missing, what is the phenomenon?
Jetpack Compose supports other observable types such as LiveData, Flow, RxJava2, and more. Before the other observable types are read in Jetpack Compose, they must be converted to State so that Jetpack Compose can automatically reorganize the interface when the State changes, as we’ll see in the code below.
The following paragraph is very important, I stepped on this pit.
Note: Using mutable objects (such as ArrayList or mutableListOf()) as state in Compose can cause users to see incorrect or stale data in your application. Mutable objects that cannot be observed, such as an ArrayList or a mutable data class, cannot be observed by Compose, and thus Compose cannot trigger a reorganization when they change. We recommend that you use observable data stores (such as State
) and immutable listOf() instead of imobservable mutable objects.
3.2.2 Status upgrade
In the example code above, the state is defined inside the composable function. The advantage of this approach is that it does not depend on the outside and can be used independently. The disadvantage is that the external can not change the internal state of the composable function, and it is difficult to interact with other composable functions, so the reusability is reduced. A good architecture should be highly reusable. Is there any way to solve this problem?
Use state promotion. Since the internal state of a composable function cannot be modified externally, it can be moved from the internal state to the external state. The normal state improvement pattern in Jetpack Compose is to replace the state variable with two parameters: a state value and a state modification function.
Please refer to the official document “Status Upgrade” for details.
Sample code:
// Before the status is improved
@Composable
fun LowComposable(a) {
Column(modifier = Modifier.padding(16.dp)) {
var name by remember { mutableStateOf("")}if (name.isNotEmpty()) {
Text(
text = "Hello, $name!",
modifier = Modifier.padding(bottom = 8.dp),
style = MaterialTheme.typography.h5
)
}
OutlinedTextField(
value = name,
onValueChange = { name = it },
label = { Text("Name")})}}// After the status is improved
// The state of LowComposable is raised to HighComposable and then lowered from HighComposable to LowComposable via parameters. The state modification function is also passed down through parameters. LowComposable can read and modify the state values, but HighComposable is responsible for managing the state.
@Composable
fun HighComposable(a) {
var name by rememberSaveable { mutableStateOf("") }
LowComposable(name = name, onNameChange = { name = it })
}
@Composable
fun LowComposable(name: String, onNameChange: (String) -> Unit) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = "Hello, $name",
modifier = Modifier.padding(bottom = 8.dp),
style = MaterialTheme.typography.h5
)
OutlinedTextField(
value = name,
onValueChange = onNameChange,
label = { Text("Name")})}}Copy the code
Pictured above, state management from the lower public upper can ascend to the minimum combination function combination function, state the value and status updates function from the lowest public upper combination function to send ginseng to the lower combination function, lower combination function directly read status value, status updates or by the lowest upper public functions which are combined to realize, The underlying composable function is only responsible for passing arguments to call the status update function (thanks to Kotlin’s language features, functions can be passed as arguments, so UI interactions can call the passed function directly).
See Higher-order Functions and Lambda Expressions for an introduction to using functions as arguments or return values.
A pattern like this, in which states rise and then fall and events rise, is called “one-way data flow.” By following one-way data flow, the state is managed uniformly by the lowest common upper composable function, thus decoupling the lower composable function. This means that the modification of the lowest common upper composable function hardly affects the lower composable function, so that the lower composable function can be reused efficiently.
Here is a bit wordy, when I first read the official documents, the status was both rising and falling, very dizzy, here I try to make it clear, I don’t know whether it is self-defeating.
3.2.3ViewModel Status Management
Now that Jetpack Compose is composing, how can you get the ViewModel? Android officially recommends using ViewModel as state containers for composable items that are located higher in the Compose interface tree or as objects in the Navigation library.
The ViewModel can persist in state after configuration changes, and it is a good place to encapsulate state and events related to the interface without having to worry about the activity or fragment life cycle that hosts the Compose code.
Jetpack Compose supports other observable types, such as LiveData, Flow, RxJava2, etc., which come in handy in the ViewModel context. A ViewModel should expose state in an observable container, such as LiveData or StateFlow. When a state object is read during composition, the current reorganization scope of the composition automatically subscribes to updates of the state object.
An example of implementing a one-way data stream using LiveData and ViewModel in Jetpack Compose uses the ViewModel implementation shown below:
@InternalCoroutinesApi
class GameViewModel(application: Application) : AndroidViewModel(application) {
/** ** score */
private val _gameScore = MutableLiveData(0)
val gameScore: LiveData<Int> = _gameScore
fun onGameScoreChange(score: Int) {
_gameScore.value = score
}
}
/** * stage */
@InternalCoroutinesApi
@ExperimentalComposeUiApi
@ExperimentalAnimationApi
@Composable
fun Stage(gameViewModel: GameViewModel, onGameAction: OnGameAction = OnGameAction()) {
LogUtil.printLog(message = "Stage -------> ")
/ / state up to here, see the official document: https://developer.android.google.cn/jetpack/compose/state#state-hoisting
// Use the ViewModel () command to view the ViewModel () file.
// Get the game score
val gameScore by gameViewModel.gameScore.observeAsState(0)
val modifier = Modifier.fillMaxSize()
Box(modifier = modifier
.run {
pointerInteropFilter {
when (it.action) {
MotionEvent.ACTION_DOWN -> {
LogUtil.printLog(message = "Stage ACTION_DOWN ")
}
MotionEvent.ACTION_MOVE -> {
LogUtil.printLog(message = "Stage ACTION_MOVE")
return@pointerInteropFilter false
}
MotionEvent.ACTION_CANCEL, Stage.ACTION_UP -> {
LogUtil.printLog(message = "GameScreen ACTION_CANCEL/UP")
return@pointerInteropFilter false}}false{}})/ / score
ComposeScore(gameScore)
}
}
@InternalCoroutinesApi
@ExperimentalComposeUiApi
@ExperimentalAnimationApi
@Preview()
@Composable
fun PreviewStage(a) {
val gameViewModel: GameViewModel = viewModel()
Stage(gameViewModel)
}
/** * score */
@InternalCoroutinesApi
@Composable
fun ComposeScore(
gameScore: Int = 0.) {
LogUtil.printLog(message = "ComposeScore()")
Row(
modifier = Modifier
.fillMaxWidth()
.padding(10.dp)
.absolutePadding(top = 20.dp)
) {
Text(
text = "score: $gameScore",
modifier = Modifier
.padding(start = 4.dp)
.align(Alignment.CenterVertically)
.wrapContentWidth(Alignment.End),
style = MaterialTheme.typography.h5,
color = Color.Black,
fontFamily = ScoreFontFamily
)
}
}
@InternalCoroutinesApi
@Preview()
@Composable
fun PreviewComposeScore(a) {
ComposeScore()
}
class MainActivity : ComponentActivity(a){
@InternalCoroutinesApi
private val gameViewModel: GameViewModel by viewModels(a)
@InternalCoroutinesApi
@ExperimentalComposeUiApi
@ExperimentalAnimationApi
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
ComposePlaneTheme {
// A surface container using the 'background' color from the theme
Surface(color = MaterialTheme.colors.background) {
// Use coroutines to execute tasks on time
LaunchedEffect(key1 = Unit) {
while (isActive) {
delay(100)
var score = gameViewModel.gameScore.value
gameViewModel.onGameScoreChange(++score)
}
}
Stage(gameViewModel, onGameAction)
}
}
}
}
}
@InternalCoroutinesApi
@ExperimentalComposeUiApi
@ExperimentalAnimationApi
@Preview()
@Composable
fun PreviewStage(a) {
val gameViewModel: GameViewModel = viewModel()
Stage(gameViewModel)
}
Copy the code
As you can see, state management is done in the ViewModel, following a one-way data flow, and Compose is only responsible for displaying the UI. In this way, both the ViewModel and the Compose composable functions can be reused.
3.2.4 Game architecture
It is recommended to first read Fundroid’s “[Android] MVI Architecture Quick Start: From Bidirectional binding to Unidirectional Data Flow” and “Jetpack Compose Architecture comparison: MVP & MVVM & MVI”. This is the first time I have heard of MVI architecture.
With the above matting, the architecture is easier to understand, see the picture below.
1. GameViewMode is defined to manage game state, using MutableStateFlow as an observable container. GameViewMode objects are generated in activities/fragments.
2. Define a GameAction to update the game state, including start, pause and other functions to update different state values, GameAction implementation in GameViewMode.
3. Define a Compose minimum public composable function Stage (in game development terms: Stage), pass in the GameViewMode instance, convert the StateFlow exposed in GameViewMode to State via collectAsState, and drop the State to the lower composable function Background, etc. The GameAction object is passed to implement Event raising.
4. Other lower level composable functions are only responsible for observing State changes, redrawing and calling GameAction functions.
5. In this way, a complete one-way data flow architecture is complete.
Note: because the code is still iterating, the functions for Compose and GameAction in the graph may not be listed in their entirety or have their names changed.
All sprites are as follows:
3.2.5 Core code
Game State and Action Definitions:
/** * Game state */
enum class GameState {
Waiting, // wait to start
Running, // gaming
Paused, // pause
Dying, // hit enemy and dying
Over, // over
Exit // finish activity
}
/** * game action */
@InternalCoroutinesApi
data class GameAction(
val start: () -> Unit = {}, // The game state is Running
val pause: () -> Unit = {},// The game state enters Paused
val reset: () -> Unit = {},// The game state is in Waiting, showing GameWaiting
val die: () -> Unit = {},// The game state enters Dying, triggering an explosion animation
val over: () -> Unit = {},// Go to Over and show GameOverBoard
val exit: () -> Unit = {},// Exit the game
val playerMove: (x: Int, y: Int) - >Unit = { _: Int, _ :Int -> },// The player moves
val score: (score: Int) - >Unit = { _: Int -> },// Update the score
val award: (award: Award) -> Unit = { _: Award -> },// Get a reward
val createBullet: () -> Unit= {},// Bullet generation
val initBullet: (bullet: Bullet) -> Unit = { _: Bullet -> },// The bullet initializes its birth position
val shooting: (resId: Int) - >Unit = { _: Int -> },/ / shooting
val destroyAllEnemy: () -> Unit = {},// Destroy all enemy aircraft
val levelUp: (score: Int) - >Unit = { _: Int -> },// Difficulty level up
)
Copy the code
The StateFlow and GameAction implementations defined in GameViewModel are as follows:
@InternalCoroutinesApi
class GameViewModel(application: Application) : AndroidViewModel(application) {
//id
val id = AtomicLong(0L)
/** * Game state StateFlow */
private val _gameStateFlow = MutableStateFlow(GameState.Waiting)
val gameStateFlow = _gameStateFlow.asStateFlow()
/** * Player plane StateFlow */
private val _playerPlaneStateFlow = MutableStateFlow(PlayerPlane())
val playerPlaneStateFlow = _playerPlaneStateFlow.asStateFlow()
/** * Enemy StateFlow */
private val _enemyPlaneListStateFlow = MutableStateFlow(mutableListOf<EnemyPlane>())
val enemyPlaneListStateFlow = _enemyPlaneListStateFlow.asStateFlow()
/** * bullet StateFlow */
private val _bulletListStateFlow = MutableStateFlow(mutableListOf<Bullet>())
val bulletListStateFlow = _bulletListStateFlow.asStateFlow()
/** * Item reward tateFlow */
private val _awardListStateFlow = MutableStateFlow(CopyOnWriteArrayList<Award>())
val awardListStateFlow = _awardListStateFlow.asStateFlow()
/** ** score */
private val _gameScoreStateFlow = MutableStateFlow(0)
val gameScoreStateFlow = _gameScoreStateFlow.asStateFlow()
/** * Difficulty level */
private val _gameLevelStateFlow = MutableStateFlow(0)
// Game action
val onGameAction = GameAction(
start = {
onGameStateFlowChange(GameState.Running)
},
reset = {
resetGame()
onGameStateFlowChange(GameState.Waiting)
},
pause = {
onGameStateFlowChange(GameState.Paused)
},
playerMove = { x, y ->
run {
onPlayerPlaneMove(x, y)
}
},
score = { score ->
run {
// Play explosion sound
viewModelScope.launch {
withContext(Dispatchers.Default) {
SoundPoolUtil.getInstance(application.applicationContext)
.playByRes(R.raw.explosion)// Play the audio in res}}// Update the score
onGameScoreStateFlowChange(score)
// Different scores correspond to different grades
if (score in 100.999.) {
onGameLevelStateFlowChange(2)}if (score in 1000.1999.) {
onGameLevelStateFlowChange(3)}// Random rewards are generated when the score is an integer of 100
if (score % 100= =0) {
createAwardSprite()
}
}
},
award = { award ->
run {
// Bonus bullets
if (award.type == AWARD_BULLET) {
val bulletAward = playerPlaneStateFlow.value.bulletAward
var num = bulletAward and 0xFFFF / / the number of
num += award.amount
onPlayerAwardBullet(BULLET_DOUBLE shl 16 or num)
}
// Reward explosive items
if (award.type == AWARD_BOMB) {
val bombAward = playerPlaneStateFlow.value.bombAward
var num = bombAward and 0xFFFF / / the number of
num += award.amount
onPlayerAwardBomb(0 shl 16 or num)
}
onAwardRemove(award)
}
},
die = {
viewModelScope.launch {
withContext(Dispatchers.Default) {
SoundPoolUtil.getInstance(application.applicationContext)
.playByRes(R.raw.explosion)// Play the audio in res
}
}
onGameStateFlowChange(GameState.Dying)
},
over = {
onGameStateFlowChange(GameState.Over)
},
exit = {
onGameStateFlowChange(GameState.Exit)
},
destroyAllEnemy = {
onDestroyAllEnemy()
},
shooting = { resId ->
run {
LogUtil.printLog(message = "onShooting resId $resId")
viewModelScope.launch {
withContext(Dispatchers.Default) {
SoundPoolUtil.getInstance(application.applicationContext)
.playByRes(resId)// Play the audio in res
}
}
}
},
createBullet = { createBullet() },
initBullet = { initBullet(it) },
)
}
Copy the code
Stage is the lowest common composable function:
/** * stage */
@InternalCoroutinesApi
@ExperimentalComposeUiApi
@ExperimentalAnimationApi
@Composable
fun Stage(gameViewModel: GameViewModel) {
LogUtil.printLog(message = "Stage -------> ")
/ / state up to here, see the official document: https://developer.android.google.cn/jetpack/compose/state#state-hoisting
// Use the ViewModel () command to view the ViewModel () file.
// Get the game state
val gameState by gameViewModel.gameStateFlow.collectAsState()
// Get the game score
val gameScore by gameViewModel.gameScoreStateFlow.collectAsState(0)
// Get the player's plane
val playerPlane by gameViewModel.playerPlaneStateFlow.collectAsState()
// Get all bullets
val bulletList by gameViewModel.bulletListStateFlow.collectAsState()
// Get all rewards
val awardList by gameViewModel.awardListStateFlow.collectAsState()
// Capture all enemy forces
val enemyPlaneList by gameViewModel.enemyPlaneListStateFlow.collectAsState()
// Get the game action function
val gameAction: GameAction = gameViewModel.onGameAction
val modifier = Modifier.fillMaxSize()
Box(modifier = modifier) {
/ / vision
FarBackground(modifier)
// The game starts
GameStart(gameState, playerPlane, gameAction)
// Player planes
PlayerPlaneSprite(
gameState,
playerPlane,
gameAction
)
// The player's plane comes out and flies into the animation
PlayerPlaneAnimIn(
gameState,
playerPlane,
gameAction
)
// Player plane explosion animation
PlayerPlaneBombSprite(gameState, playerPlane, gameAction)
// Enemy aircraft
EnemyPlaneSprite(
gameState,
gameScore,
playerPlane,
bulletList,
enemyPlaneList,
gameAction
)
/ / the bullet
BulletSprite(gameState, bulletList, gameAction)
/ / reward
AwardSprite(gameState, playerPlane, awardList, gameAction)
// Explosive items
BombAward(playerPlane, gameAction)
// Game score
GameScore(gameState, gameScore, gameAction)
// The game starts
GameOver(gameState, gameScore, gameAction)
}
}
Copy the code
Warm tips: In order to improve the fluency and integrity of reading, this chapter has extracted and sorted out a large number of contents from the official document “Status and Jetpack Compose”, and added my own understanding. It may be too wordy, and there are many codes, which is not good either. Welcome your comments and suggestions.
4. Player aircraft controls and animations
From this chapter to the following chapters, we will introduce the usage of Compose design. For example, the usage of animation is quite common in Compose design. If you are not interested in animation, you can skip it directly.
The Sprite base class is defined as a PlayerPlane, which can be used by adding unique attributes to the Sprite. The code is as follows:
/** ** Player plane spirit */
const val PLAYER_PLANE_SPRITE_SIZE = 60
const val PLAYER_PLANE_PROTECT = 60
@InternalCoroutinesApi
data class PlayerPlane(
override var id: Long = System.currentTimeMillis(), //id
override var name: String = "Lightning".@DrawableRes override val drawableIds: List<Int> = listOf(
R.drawable.sprite_player_plane_1,
R.drawable.sprite_player_plane_2
), // Player aircraft resource icon
@DrawableRes val bombDrawableIds: Int = R.drawable.sprite_player_plane_bomb_seq, // Player aircraft explosion frame animation resources
override var segment: Int = 4.// Explosion effect consists of segments
override var x: Int = -100.// The position of the player's plane on the X-axis
override var y: Int = -100.// The position of the player's plane on the Y axis
override var width: Dp = PLAYER_PLANE_SPRITE_SIZE.dp, / / wide
override var height: Dp = PLAYER_PLANE_SPRITE_SIZE.dp, / / high
var protect: Int = PLAYER_PLANE_PROTECT, // The number of flashes on the first appearance (when invincible)
var life: Int = 1.// Life (several lives, unlike enemy planes, can withstand multiple hits, player planes touch Over)
var animateIn: Boolean = true.// Whether an exit animation is required
var bulletAward: Int = BULLET_DOUBLE shl 16 or 0./ / the bullet reward (the bullet type | bullet number), type 0 is single red bullets, one is blue double bullet
var bombAward: Int = 0 shl 16 or 0./ / explosion reward (| explosion explosion type), currently the only type 0
) : Sprite() {
/** * reduces the number of protection times, when it is 0, the collision will explode */
fun reduceProtect(a) {
if (protect > 0) {
protect--
}
}
fun isNoProtect(a) = protect <= 0
override fun reBirth(a) {
state = SpriteState.LIFE
animateIn = true
x = startX
y = startY
protect = PLAYER_PLANE_PROTECT
bulletAward = 0
bombAward = 0}}Copy the code
The Compose code looks like this:
/** * Player aircraft, can be dragged along the XY axis */
val FastShowAndHiddenEasing: Easing = CubicBezierEasing(0.0 f.0.0 f.1.0 f.1.0 f)// Jet speed changes
const val SMALL_ENEMY_PLANE_SPRITE_ALPHA = 100; // Jet speed
@InternalCoroutinesApi
@ExperimentalAnimationApi
@Composable
fun PlayerPlaneSprite(
gameState: GameState,
playerPlane: PlayerPlane,
gameAction: GameAction
) {
if(! (gameState == GameState.Running || gameState == GameState.Paused)) {return
}
// Initialize parameters
val widthPixels = LocalContext.current.resources.displayMetrics.widthPixels
val heightPixels = LocalContext.current.resources.displayMetrics.heightPixels
val playerPlaneHeightPx = with(LocalDensity.current) { playerPlane.height.toPx() }
// Loop animation
val infiniteTransition = rememberInfiniteTransition()
val alpha by infiniteTransition.animateFloat(
initialValue = 0f,
targetValue = 1f,
animationSpec = infiniteRepeatable(
animation = tween(SMALL_ENEMY_PLANE_SPRITE_ALPHA, easing = FastShowAndHiddenEasing),
repeatMode = RepeatMode.Restart
)
)
// After the game starts, the animation completes and reduces the number of protection times until it reaches 0
if(gameState == GameState.Running && ! playerPlane.isNoProtect() && alpha >=0.5 f) {
playerPlane.reduceProtect()
}
LogUtil.printLog(message = "PlayerPlaneSprite() playerPlane.x = ${playerPlane.x} playerPlane.y = ${playerPlane.y}")
Box(modifier = Modifier.fillMaxSize()) {
Image(
painter = painterResource(id = R.drawable.sprite_player_plane_1),
contentScale = ContentScale.FillBounds,
contentDescription = null,
modifier = Modifier
.offset { IntOffset(playerPlane.x, playerPlane.y) }
//.background(Color.Blue)
.size(playerPlane.width, playerPlane.height)
.pointerInput(Unit) {
detectDragGestures { change, dragAmount ->
change.consumeAllChanges()
var newOffsetX = playerPlane.x
var newOffsetY = playerPlane.y
// boundary detection
when {
newOffsetX + dragAmount.x <= 0 -> {
newOffsetX = 0
}
(newOffsetX + dragAmount.x + playerPlaneHeightPx) >= widthPixels -> {
widthPixels.let {
newOffsetX = it - playerPlaneHeightPx.roundToInt()
}
}
else -> {
newOffsetX += dragAmount.x.roundToInt()
}
}
when {
newOffsetY + dragAmount.y <= 0 -> {
newOffsetY = 0
}
(newOffsetY + dragAmount.y) >= heightPixels -> {
heightPixels.let {
newOffsetY = it
}
}
else -> {
newOffsetY += dragAmount.y.roundToInt()
}
}
gameAction.playerMove(newOffsetX, newOffsetY)
}
}
.alpha(
if (gameState == GameState.Running || gameState == GameState.Paused) {
if (alpha < 0.5 f) 0f else 1f
} else {
0f}))// Display another plane jet, by setting the opposite alpha loop to achieve the effect of dynamic jet
Image(
painter = painterResource(id = R.drawable.sprite_player_plane_2),
contentScale = ContentScale.FillBounds,
contentDescription = null,
modifier = Modifier
.offset { IntOffset(playerPlane.x, playerPlane.y) }
//.background(Color.Blue)
.size(playerPlane.width, playerPlane.height)
.alpha(
if (gameState == GameState.Running || gameState == GameState.Paused) {
// If it is in the protected state, it is not displayed here
if(! playerPlane.isNoProtect()) {0f
} else {
if (1 - alpha < 0.5 f) 0f else 1f}}else {
0f}))}}Copy the code
4.1 Drag control
Effect:
Using the drag gesture detector via the pointerInput modifier, keep calling GameAction’s onPlayerPlaneMove(x, y) to update the coordinates of the PlayerPlane. For details about how to use pointerInput, see the official document Gesture.
Compose drag code:
Modifier.pointerInput(Unit) {
detectDragGestures { change, dragAmount ->
change.consumeAllChanges()
var newOffsetX = playerPlane.x
var newOffsetY = playerPlane.y
// boundary detection
when {
newOffsetX + dragAmount.x <= 0 -> {
newOffsetX = 0
}
(newOffsetX + dragAmount.x + playerPlaneHeightPx) >= widthPixels -> {
widthPixels.let {
newOffsetX = it - playerPlaneHeightPx.roundToInt()
}
}
else -> {
newOffsetX += dragAmount.x.roundToInt()
}
}
when {
newOffsetY + dragAmount.y <= 0 -> {
newOffsetY = 0
}
(newOffsetY + dragAmount.y) >= heightPixels -> {
heightPixels.let {
newOffsetY = it
}
}
else -> {
newOffsetY += dragAmount.y.roundToInt()
}
}
gameAction.playerMove(newOffsetX, newOffsetY)
}
}
Copy the code
GameVIewModel updates the player plane coordinate code:
/** * Player plane StateFlow */
private val _playerPlaneStateFlow = MutableStateFlow(PlayerPlane())
val playerPlaneStateFlow = _playerPlaneStateFlow.asStateFlow()
private fun onPlayerPlaneStateFlowChange(plane: PlayerPlane) {
viewModelScope.launch {
withContext(Dispatchers.Default) {
_playerPlaneStateFlow.emit(plane)
}
}
}
/** * Player plane moves */
private fun onPlayerPlaneMove(x: Int, y: Int) {
if(gameStateFlow.value ! = GameState.Running) {return
}
val playerPlane = playerPlaneStateFlow.value
playerPlane.x = x
playerPlane.y = y
if (playerPlane.animateIn) {
playerPlane.animateIn = false
}
onPlayerPlaneStateFlowChange(playerPlane)
}
Copy the code
4.2 Flight Animation
The flying animation is implemented by looping and hiding two different images. At first, I was thinking about setting the visibility of the Compose Image, but I actually adjusted the alpha value to achieve this.
Effect:
Material:
Key code:
Box(modifier = Modifier.fillMaxSize()) {
Image(
painter = painterResource(id = R.drawable.sprite_player_plane_1),
contentScale = ContentScale.FillBounds,
contentDescription = null,
modifier = Modifier
.offset { IntOffset(playerPlane.x, playerPlane.y) }
//.background(Color.Blue)
.size(playerPlane.width, playerPlane.height)
.pointerInput(Unit) {
detectDragGestures { change, dragAmount ->
/ / to omit
}
.alpha(
if (gameState == GameState.Running || gameState == GameState.Paused) {
if (alpha < 0.5 f) 0f else 1f
} else {
0f}))// Display another plane jet, by setting the opposite alpha loop to achieve the effect of dynamic jet
Image(
painter = painterResource(id = R.drawable.sprite_player_plane_2),
contentScale = ContentScale.FillBounds,
contentDescription = null,
modifier = Modifier
.offset { IntOffset(playerPlane.x, playerPlane.y) }
//.background(Color.Blue)
.size(playerPlane.width, playerPlane.height)
.alpha(
if (gameState == GameState.Running || gameState == GameState.Paused) {
// If it is in the protected state, it is not displayed here
if(! playerPlane.isNoProtect()) {0f
} else {
if (1 - alpha < 0.5 f) 0f else 1f}}else {
0f}}))Copy the code
5. Bullet generation and firing
The continuous firing effect of the bullet took a lot of time to adjust. Effect:
Define a Bullet that inherits Sprite as follows:
/** * bullet elf */
const val BULLET_SPRITE_WIDTH = 6
const val BULLET_SPRITE_HEIGHT = 18
const val BULLET_SINGLE = 0
const val BULLET_DOUBLE = 1
@InternalCoroutinesApi
data class Bullet(
override var id: Long = System.currentTimeMillis(), //id
override var name: String = "Single blue bullet.".override var type: Int = BULLET_SINGLE, Type :0 single bullets, 1 double bullets
@DrawableRes val drawableId: Int = R.drawable.sprite_bullet_single, // Bullet resource icon
override var width: Dp = BULLET_SPRITE_WIDTH.dp, / / wide
override var height: Dp = BULLET_SPRITE_HEIGHT.dp, / / high
override var speed: Int = 200.// Flight speed, the time it takes to fly one screen height from the head of the player's plane along the Y axis to the top of the screen
override var x: Int = 0.// Real-time x coordinates
override var y: Int = 0.// Real-time y coordinates
override var state: SpriteState = SpriteState.DEATH, // Death by default
override var init: Boolean = false.// Uninitialized by default
var hit: Int = 1.// Hit ability, hit an enemy once, the enemy loses health
) : Sprite()
Copy the code
The animation above refreshes too fast, it may be hard to see clearly, lower the speed of the bullet slightly, and increase the background to see the effect.
Notice that the first bullet at the top, coming from the head of the player’s plane, is constantly moving in the negative direction of the Y axis, while the next bullets appear in sequence, one after the other, in order, and in order. Look at the picture:
Key code:
/** * Bullets are fired from the top of the player's plane and can only move along the X axis. They are destroyed when they are outside the screen, and they are also destroyed when they collide with enemy planes, counting points */
@InternalCoroutinesApi
@Composable
fun BulletSprite(
gameState: GameState = GameState.Waiting,
bulletList: List<Bullet> = mutableListOf(),
gameAction: GameAction = GameAction()
) {
// Repeat animation, 60 frames per second
val infiniteTransition = rememberInfiniteTransition()
val frame by infiniteTransition.animateInt(
initialValue = 0,
targetValue = 60,
animationSpec = infiniteRepeatable(
animation = tween(
durationMillis = 1000,
easing = LinearEasing
),
repeatMode = RepeatMode.Restart
)
)
// The game is not in progress
if(gameState ! = GameState.Running) {return
}
// Generate a bullet every 100 milliseconds
if (frame % 6= =0) {
gameAction.createBullet()
}
for (bullet in bulletList) {
if (bullet.isAlive()) {
Init = false; init = false; // Init = false; // Init = false;
// If checked according to isAlive, the position of the aircraft will be reinitialized as soon as Bullet dies, but the position of the aircraft may have changed by the time of relaunch.
if(! bullet.init) {
// Initialize the bullet's birth position
gameAction.initBullet(bullet)
// Play the shooting sound on a non-UI thread
gameAction.shooting(R.raw.shoot)
}
// The bullet leaves the screen and dies
if (bullet.isInvalid()) {
bullet.die()
}
/ / shooting
bullet.shoot()
// Display bullet image
BulletShootingSprite(bullet)
}
}
}
/** * Update bullet x and y values to show bullet images */
@InternalCoroutinesApi
@Composable
fun BulletShootingSprite(
bullet: Bullet = Bullet()
) {
// Draw a picture
Box(modifier = Modifier.fillMaxSize()) {
Image(
painter = painterResource(id = bullet.drawableId),
contentScale = ContentScale.FillBounds,
contentDescription = null,
modifier = Modifier
.offset {
IntOffset(
bullet.x,
bullet.y
)
}
.width(bullet.width)
.height(bullet.height)
.alpha(
if (bullet.isAlive()) {
1f
} else {
0f}))}}/** * spawn bullets */
private fun createBullet(a) {
// The game starts and the plane is on-screen
if (gameStateFlow.value == GameState.Running && playerPlaneStateFlow.value.y < getApplication<Application>().resources.displayMetrics.heightPixels) {
val bulletAward = playerPlaneStateFlow.value.bulletAward
var bulletNum = bulletAward and 0xFFFF / / the number of
val bulletType = bulletAward shr 16 / / type
val bulletList = bulletListStateFlow.value as ArrayList
val firstBullet = bulletList.firstOrNull { it.isDead() }
if (firstBullet == null) {
var newBullet = Bullet(
type = BULLET_SINGLE,
drawableId = R.drawable.sprite_bullet_single,
width = BULLET_SPRITE_WIDTH.dp,
hit = 1,
state = SpriteState.LIFE,
init = false
)
// Bullet bonus
if (bulletNum > 0 && bulletType == BULLET_DOUBLE) {
newBullet = newBullet.copy(
type = BULLET_DOUBLE,
drawableId = R.drawable.sprite_bullet_double,
width = 18.dp,
hit = 2,
state = SpriteState.LIFE,
init = false
)
// Consume bullets
bulletNum--
onPlayerAwardBullet(BULLET_DOUBLE shl 16 or bulletNum)
}
bulletList.add(newBullet)
} else {
var newBullet = firstBullet.copy(
type = BULLET_SINGLE,
drawableId = R.drawable.sprite_bullet_single,
width = BULLET_SPRITE_WIDTH.dp,
hit = 1,
state = SpriteState.LIFE,
init = false
)
// Bullet bonus
if (bulletNum > 0 && bulletType == BULLET_DOUBLE) {
newBullet = firstBullet.copy(
type = BULLET_DOUBLE,
drawableId = R.drawable.sprite_bullet_double,
width = 18.dp,
hit = 2,
state = SpriteState.LIFE,
init = false
)
// Consume bullets
bulletNum--
onPlayerAwardBullet(BULLET_DOUBLE shl 16 or bulletNum)
}
bulletList.add(newBullet)
bulletList.removeAt(0)
}
onBulletListStateFlowChange(bulletList)
}
}
/** * Initializes the bullet birth position */
private fun initBullet(bullet: Bullet) {
val playerPlane = playerPlaneStateFlow.value
val playerPlaneWidthPx = dp2px(playerPlane.width)
val bulletWidthPx = dp2px(bullet.width)
val bulletHeightPx = dp2px(bullet.height)
val startX = (playerPlane.x + playerPlaneWidthPx!! / 2 - bulletWidthPx!! / 2)
val startY = (playerPlane.y - bulletHeightPx!!)
bullet.startX = startX
bullet.startY = startY
bullet.x = bullet.startX
bullet.y = bullet.startY
bullet.init = true
}
Copy the code
At first, do a bullet shooting effect, use a repeated animations, constantly adjust the x and y values of bullets, from players plane head continuously along the y axis of the negative direction of flight specified distance, to reach the specified distance before the cycle from players head continued to fly aircraft, but the effect is not good, experience must wait after a bullet the specified distance can be reused.
Later, we used a List to maintain the Bullet object, reuse the Bullet object in the List, and every time the animation value changes, the for loop updates the state of all bullets, and the Bullet object can be reused when it collides or flies off the screen, so the effect is much better than before.
6. Enemy flight and explosion
Effect:
Define an EnemyPlane extension of Sprite as follows:
/** * Enemy spirit */
const val SMALL_ENEMY_PLANE_SPRITE_SIZE = 40
const val MIDDLE_ENEMY_PLANE_SPRITE_SIZE = 60
const val BIG_ENEMY_PLANE_SPRITE_SIZE = 100
@InternalCoroutinesApi
data class EnemyPlane(
override var id: Long = System.currentTimeMillis(), //id
override var name: String = "Enemy reconnaissance plane".@DrawableRes override val drawableIds: List<Int> = listOf(R.drawable.sprite_small_enemy_plane), // Aircraft resource icon
@DrawableRes override val bombDrawableId: Int = R.drawable.sprite_small_enemy_plane_seq, // Enemy explosion frame animation resources
override var segment: Int = 3.// The explosion effect is composed of segments, small plane is 3, medium plane is 4, large plane is 6
override var x: Int = 0.// The current position of enemy aircraft on the X-axis
override var y: Int = -100.// The current position of the enemy aircraft on the Y axis
override var startY: Int = -100.// The starting position of the occurrence
override var width: Dp = SMALL_ENEMY_PLANE_SPRITE_SIZE.dp, / / wide
override var height: Dp = SMALL_ENEMY_PLANE_SPRITE_SIZE.dp, / / high
override var velocity: Int = 1.// Flight speed (pixels moved per frame)
var bombX: Int = -100.// The current X-axis position of the explosion animation
var bombY: Int = -100.// The current position of the explosion animation on the Y axis
val power: Int = 1.// Health, the enemy's ability to resist attack
var hit: Int = 0.// The health cost of being hit
val value: Int = 10.// Score an enemy plane
) : Sprite() {
fun beHit(reduce: Int) {
hit += reduce
}
fun isNoPower(a) = (power - hit) <= 0
fun bomb(a) {
hit = power
}
override fun reBirth(a) {
state = SpriteState.LIFE
hit = 0
}
override fun die(a) {
state = SpriteState.DEATH
bombX = x
bombY = y
}
}
Copy the code
6.1 Enemy flight
Analysis:
Key code:
/** * Enemy aircraft * can only fly along the Y axis */
@InternalCoroutinesApi
@ExperimentalAnimationApi
@Composable
fun EnemyPlaneSprite(
gameState: GameState,
gameScore: Int,
enemyPlaneList: List<EnemyPlane>,
gameAction: GameAction
) {
for (enemyPlane in enemyPlaneList) {
EnemyPlaneSpriteMoveAndBomb(
gameState,
gameScore,
enemyPlane,
gameAction
)
}
}
@InternalCoroutinesApi
@ExperimentalAnimationApi
@Composable
fun EnemyPlaneSpriteMoveAndBomb(
gameState: GameState,
gameScore: Int,
enemyPlane: EnemyPlane,
gameAction: GameAction
) {
// Each EnemyPlane has a separate flag for easy observation. Cannot be placed on EnemyPlane as it is not easy to observe directly
var showBombAnim by remember {
mutableStateOf(false)
}
EnemyPlaneSpriteMove(
gameState,
onBombAnimChange = {
showBombAnim = it
},
enemyPlane,
gameAction
)
EnemyPlaneSpriteBomb(
gameScore,
enemyPlane,
showBombAnim,
onBombAnimChange = {
showBombAnim = it
})
}
@InternalCoroutinesApi
@ExperimentalAnimationApi
@Composable
fun EnemyPlaneSpriteMove(
gameState: GameState,
onBombAnimChange: (Boolean) - >Unit,
enemyPlane: EnemyPlane,
gameAction: GameAction
) {
// Repeat the animation, 60 frames per second (strangely, the test found that the animation will not loop if the frame variable is not used)
val infiniteTransition = rememberInfiniteTransition()
val frame by infiniteTransition.animateInt(
initialValue = 0,
targetValue = 60,
animationSpec = infiniteRepeatable(
animation = tween(1000, easing = LinearEasing),
repeatMode = RepeatMode.Restart
)
)
// The game is not in progress
if(gameState ! = GameState.Running) {return
}
// Enemy flight, including collision detection
gameAction.moveEnemyPlane(enemyPlane,onBombAnimChange)
LogUtil.printLog(message = "EnemyPlaneSpriteFly: state = ${enemyPlane.state}, enemyPlane. X =${enemyPlane.x}, enemyPlane. Y =${enemyPlane.y}, frame = $frame ")
/ / to draw
Box(modifier = Modifier.fillMaxSize()) {
Image(
painter = painterResource(enemyPlane.getRealDrawableId()),
contentScale = ContentScale.FillBounds,
contentDescription = null,
modifier = Modifier
.offset { IntOffset(enemyPlane.x, enemyPlane.y) }
//.background(Color.Red)
.size(enemyPlane.width)
.alpha(if (enemyPlane.isAlive()) 1f else 0f))}}/** * Enemy aircraft move */
private fun onEnemyPlaneMove(
enemyPlane: EnemyPlane,
onBombAnimChange: (Boolean) - >Unit
) {
viewModelScope.launch {
withContext(Dispatchers.Default) {
// Get the screen width and height
val widthPixels = getApplication<Application>().resources.displayMetrics.widthPixels
val heightPixels =
getApplication<Application>().resources.displayMetrics.heightPixels
// The size and range of the enemy aircraft
val enemyPlaneWidthPx = dp2px(enemyPlane.width)
val enemyPlaneHeightPx = dp2px(enemyPlane.height)
val maxEnemyPlaneSpriteX = widthPixels - enemyPlaneWidthPx!! // The X-axis screen width is offset to the left by one fuselage
val maxEnemyPlaneSpriteY = heightPixels * 1.5 //Y axis 1.5 times the screen height
// If not initialized, give a random value (on-screen)
if(! enemyPlane.init) {
enemyPlane.x = (0..maxEnemyPlaneSpriteX).random()
var newY = -(0..heightPixels).random() - (0..heightPixels).random()
when (enemyPlane.type) {
0 -> newY -= enemyPlaneHeightPx!! * 2
1 -> newY -= enemyPlaneHeightPx!! * 4
2 -> newY -= enemyPlaneHeightPx!! * 10
}
enemyPlane.y = newY
LogUtil.printLog(message = "enemyPlaneMove: newY $newY ")
LogUtil.printLog(message = "enemyPlaneMove: id = ${enemyPlane.id}, type =${enemyPlane.type}, x =${enemyPlane.x}, y =${enemyPlane.y} ")
enemyPlane.init = true
enemyPlane.reBirth()
}
// Fly out of the screen (displacement to the specified distance), then die
if (enemyPlane.y >= maxEnemyPlaneSpriteY) {
enemyPlane.init = false// This cannot be called in the die method, otherwise the position of the enemy aircraft will change immediately after the collision detection explosion
enemyPlane.die()
}
// Enemy aircraft displacement
enemyPlane.move()
onCollisionDetect(enemyPlane, onBombAnimChange)
}
}
}
Copy the code
As you can see, the enemy Sprite object is managed uniformly with a List collection that was passed in from GameViewModel. Through EnemyPlaneSpriteMoveAndBomb function calls a for loop and realize each enemy planes Sprite object flying and explosions. EnemyPlaneSpriteMove in EnemyPlaneSpriteMoveAndBomb function is responsible for the control of the enemy, according to mobile and Sprite object EnemyPlaneSpriteBomb is responsible for the control of the enemy animated Sprite object explosion play and stop.
EnemyPlaneSpriteMove function mainly use rememberInfiniteTransition repeated animations to continuously drive EnemyPlaneSpriteMove function call, GameAction’s moveEnemyPlane function is used to modify the x and Y values of the enemy Sprite object to achieve the effect of enemy flight.
6.2 Explosion of enemy aircraft
What would you do if you could trigger all enemy explosion animations with one click?
The first thing I did was to add an explosion flag bit to the enemy Sprite object to see if the explosion animation was playing. However, I found that it was impossible to observe the change because I updated the properties of the object in the List directly. Only by calling the EMIT function can the observer be notified, and each enemy explosion is independent. This operation is obviously too cumbersome to call the EMIT function after updating the MutableStateFlow.
How about each enemy Sprite object define a showBombAnim explosion animation flag bit in the Compose function? When the enemy Sprite reaches zero health, modify the Compose component immediately. The state change will drive the Compose composite function. At this point, use the flag to determine whether to play the explosion animation.
// Each EnemyPlane has a separate flag for easy observation. Cannot be placed on EnemyPlane as it is not easy to observe directly
var showBombAnim by remember {
mutableStateOf(false)
}
EnemyPlaneSpriteMove(
gameState,
onBombAnimChange = {
showBombAnim = it
},
enemyPlane,
gameAction
)
EnemyPlaneSpriteBomb(
gameScore,
enemyPlane,
showBombAnim,
onBombAnimChange = {
showBombAnim = it
})
Copy the code
Look at the code above, which also uses state promotion. EnemyPlaneSpriteMove function onBombAnimChange is used to control the playback of an explosion animation when enemy planes have zero health. EnemyPlaneSpriteBomb The onBombAnimChange EnemyPlaneSpriteBomb function is used to hide the explosion image after the explosion animation has finished playing.
In this way, it is easy to trigger the explosion animation of all enemy aircraft with one click, and change the health of all enemy objects to 0.
/** * All enemy planes on screen explode */
private fun onDestroyAllEnemy(a) {
viewModelScope.launch {
// All enemy aircraft disappeared
val listEnemyPlane = enemyPlaneListStateFlow.value
var countScore = 0
withContext(Dispatchers.Default) {
for (enemyPlane in listEnemyPlane) {
// Live and in screen
if(enemyPlane.isAlive() && ! enemyPlane.isNoPower() && enemyPlane.y >0 && enemyPlane.y < getApplication<Application>().resources.displayMetrics.heightPixels) {
countScore += enemyPlane.value
enemyPlane.bomb()// When the energy reaches zero, it explodes
}
}
_enemyPlaneListStateFlow.emit(listEnemyPlane)
}
// Update the score
gameScoreStateFlow.value.plus(countScore).let { onGameScoreStateFlowChange(it) }
// Explosive item minus 1
val bombAward = playerPlaneStateFlow.value.bombAward
var bombNum = bombAward and 0xFFFF / / the number of
val bombType = bombAward shr 16 / / type
if (bombNum-- <= 0) {
bombNum = 0
}
onPlayerAwardBomb(bombType shl 16 or bombNum)
}
}
Copy the code
The explosion animation will be covered in the next section.
7. Collision detection and explosion animation
7.1 Collision Detection
There are many kinds of collision detection, and here we use rectangle collision.
As shown above, from the perspective of an enemy plane, the red zone of the enemy plane is the danger zone, the bullet and the rectangular frame of the player’s plane touch the red zone, and the other green zone is the safe zone.
Key code:
/** * Sprite tool class */
object SpriteUtil {
/** * rectangle collision function *@paramX1 the x-coordinate of the first rectangle star@paramY1 the y-coordinate of the first rectangle star@paramW1 Width of the first rectangle *@paramH1 The height of the first rectangle *@paramX2 the x-coordinate of the second rectangle star@paramY2 the y-coordinate of the second rectangle star@paramW2 Width of the second rectangle *@paramH2 the height of the second rectangle */
fun isCollisionWithRect(
x1: Int,
y1: Int,
w1: Int,
h1: Int,
x2: Int,
y2: Int,
w2: Int,
h2: Int
): Boolean {
if (x1 >= x2 && x1 >= x2 + w2) {
return false
} else if (x1 <= x2 && x1 + w1 <= x2) {
return false
} else if (y1 >= y2 && y1 >= y2 + h2) {
return false
} else if (y1 <= y2 && y1 + h1 <= y2) {
return false
}
return true}}/** * Collision detection against enemy aircraft */
private fun onCollisionDetect(
enemyPlane: EnemyPlane,
onBombAnimChange: (Boolean) - >Unit
) {
viewModelScope.launch {
withContext(Dispatchers.Default) {
// If a bomb is used, it causes all enemy planes to have zero health, triggering an explosion animation
if (enemyPlane.isAlive() && enemyPlane.isNoPower()) {
// Enemy aircraft dead
enemyPlane.die()
// Explosion animation can be displayed
onBombAnimChange(true)}// The size of the enemy aircraft
val enemyPlaneWidthPx = dp2px(enemyPlane.width)
val enemyPlaneHeightPx = dp2px(enemyPlane.height)
// Player plane size
val playerPlane = playerPlaneStateFlow.value
val playerPlaneWidthPx = dp2px(playerPlane.width)
val playerPlaneHeightPx = dp2px(playerPlane.height)
// If enemy planes collide with player planes (collision detection requirements, both sides must be on screen)
if (enemyPlane.isAlive() && playerPlane.x > 0 && playerPlane.y > 0 && enemyPlane.x > 0 && enemyPlane.y > 0&& SpriteUtil.isCollisionWithRect( playerPlane.x, playerPlane.y, playerPlaneWidthPx!! , playerPlaneHeightPx!! , enemyPlane.x, enemyPlane.y, enemyPlaneWidthPx!! , enemyPlaneHeightPx!! ) ) {// Player plane explodes, enters gamestate. Dying state, plays explosion animation, enters Gamestate. Over after animation, popup prompt box, chooses restart or exit
if (gameStateFlow.value == GameState.Running) {
if (playerPlane.isNoProtect()) {
onGameAction.die()
}
}
}
// Bullet size
val bulletList = bulletListStateFlow.value
if (bulletList.isEmpty()) {
return@withContext
}
val firstBullet = bulletList.first()
val bulletSpriteWidthPx = dp2px(firstBullet.width)
val bulletSpriteHeightPx = dp2px(firstBullet.height)
// Check to see if there is a collision between bullets and enemy aircraft
bulletList.forEach { bullet ->
// If the enemy plane survived and hit the bullet (collision detection requirements, both sides must be in the screen)
if (enemyPlane.isAlive() && bullet.isAlive() && bullet.x > 0 && bullet.y > 0&& SpriteUtil.isCollisionWithRect( bullet.x, bullet.y, bulletSpriteWidthPx!! , bulletSpriteHeightPx!! , enemyPlane.x, enemyPlane.y, enemyPlaneWidthPx!! , enemyPlaneHeightPx!! ) ) { bullet.die() enemyPlane.beHit(bullet.hit)// The enemy plane will explode when it runs out of energy
if (enemyPlane.isNoPower()) {
// Enemy aircraft dead
enemyPlane.die()
// Explosion animation can be displayed
onBombAnimChange(true)
// The explosion animation is triggered by watching the score change
onGameScore(gameScoreStateFlow.value + enemyPlane.value)
// Play explosion sound
onPlayByRes(getApplication(), R.raw.explosion)
return@forEach
}
}
}
}
}
}
Copy the code
OnCollisionDetect is called each time in the onEnemyPlaneMove function for enemy aircraft movements. For enemy aircraft objects, isCollisionWithRect is called to compare the rectangular data of bullets and player aircraft objects to obtain collision detection results. Implement the game logic based on the results.
7.2 Explosion Animation
The material for the explosion animation is as follows, which is essentially a frame animation.
Key code:
/** * Test explosion animation */
@InternalCoroutinesApi
@Composable
fun TestComposeShowBombSprite(a) {
val bomb by remember { mutableStateOf(Bomb(x = 500, y = 500))}var state by remember {
mutableStateOf(0)}val anim = remember {
TargetBasedAnimation(
animationSpec = tween(
durationMillis = bomb.segment * 33.// Play 30 frames per second, 1000/30 = 33
easing = LinearEasing
),
typeConverter = Int.VectorConverter,
initialValue = 0,
targetValue = bomb.segment - 1)}var playTime by remember { mutableStateOf(0L)}var animationSegmentIndex by remember {
mutableStateOf(0)
}
LaunchedEffect(state) {
val startTime = withFrameNanos { it }
do {
playTime = withFrameNanos { it } - startTime
animationSegmentIndex = anim.getValueFromNanos(playTime)
} while(! anim.isFinishedFromNanos(playTime)) } Box(modifier = Modifier.fillMaxSize(1f), contentAlignment = Alignment.Center) {
Box(
modifier = Modifier
.size(60.dp)
.background(Color.Red, shape = RoundedCornerShape(60 / 5))
.clickable {
LogUtil.printLog(message = "Trigger animation")
state++
bomb.reBirth()
}, contentAlignment = Alignment.Center
) {
Text(
text = animationSegmentIndex.toString(),
style = TextStyle(color = Color.White, fontSize = 12.sp)
)
}
}
//LogUtil.printLog(message = "TestComposeShowBombSprite() animationSegmentIndex $animationSegmentIndex")
//LogUtil.printLog(message = "TestComposeShowBombSprite() bomb.state ${bomb.state}")
PlayBombSpriteAnimate(bomb, animationSegmentIndex)
}
@InternalCoroutinesApi
@Composable
fun PlayBombSpriteAnimate(bomb: Bomb, animationSegmentIndex: Int) {
// Out-of-bounds detection
if (animationSegmentIndex >= bomb.segment) {
return
}
// Initialize the size of the bomb
val bombWidth = bomb.width
val bombWidthWidthPx = with(LocalDensity.current) { bombWidth.toPx() }
/ / is used here to modify ImageBitmap imageResource returned bitmap convenient processing
val bitmap: Bitmap = imageResource(bomb.bombDrawableId)
/ / segmentation Bitmap
val displayBitmapWidth = bitmap.width / bomb.segment
//Matrix is used to scale up to the size of bombWidthWidthPx
val matrix = Matrix()
matrix.postScale(
bombWidthWidthPx / displayBitmapWidth,
bombWidthWidthPx / bitmap.height
)
// Out-of-bounds detection
if ((animationSegmentIndex * displayBitmapWidth) + displayBitmapWidth > bitmap.width) {
return
}
// Get only the required parts
val displayBitmap = Bitmap.createBitmap(
bitmap,
(animationSegmentIndex * displayBitmapWidth),
0,
displayBitmapWidth,
bitmap.height,
matrix,
true
)
val imageBitmap: ImageBitmap = displayBitmap.asImageBitmap()
Canvas(
modifier = Modifier
.fillMaxSize()
.size(bombWidth)
) {
drawImage(
imageBitmap,
topLeft = Offset(
bomb.x.toFloat(),
bomb.y.toFloat(),
),
alpha = if (bomb.isAlive()) 1.0 f else 0f)}},/**
* Load an ImageBitmap from an image resource.
*
* This function is intended to be used for when low-level ImageBitmap-specific
* functionality is required. For simply displaying onscreen, the vector/bitmap-agnostic
* [painterResource] is recommended instead.
*
* @param id the resource identifier
* @return the decoded image data associated with the resource
*/
@Composable
fun imageResource(@DrawableRes id: Int): Bitmap {
val context = LocalContext.current
val value = remember { TypedValue() }
context.resources.getValue(id, value, true)
valkey = value.string!! .toString()// image resource must have resource path.
return remember(key) { imageResource(context.resources, id) }
}
Copy the code
Here’s the idea:
- How to load frame animation material? use
imageResource
The function loads the frame animation material to get a Bitmap object. - Does the animation material have the same width and height as the corresponding Sprite object? Using Matrix
postScale
I’m just going to scale, but I’m just going to keep the height the same. - How to use the resulting frame animation Bitmap? use
Bitmap.createBitmap
The Bitmap function retrieves a partial Bitmap and displays it. Let me define aanimationSegmentIndex
As the subscript of the current animation frame to be displayed, by cutting horizontally, in the case of Btimap height consistency, as long as computedcreateBitmap
The x parameter of the function locates and retrieves the Bitmap of the current animation frame by the offset, and finally passesasImageBitmap
Convert the Bitmap to ImageBitmap and usedrawImage
Display images. - How do I determine the current animation frame subscript? use
TargetBasedAnimation
Animation isanimationSegmentIndex
From 0 to the total number of frames of the corresponding explosion animation material, the Compose function can be driven to display the corresponding animation frame. Of course, the time should be controlled as follows, so that the animation effect will come out when the animation frame is continuously played. - How to trigger animation? LaunchedEffect animation can be triggered by observing the state, so when we want to play the explosion animation, we only need to modify the state of LaunchedEffect observation, and then change back after the animation is finished. Note whether the animation display is controlled by the alpha value.
Other 8.
With the knowledge of the above matting, other functions, such as the display and calculation of scores, props reward generation and acquisition is very simple, here is not redundant, interested in viewing the source code, notes or more detailed.
9. Game controls
Game control can be thought of as GameState management, defining GameState and GameAction, managed through GameViewModel, with high cohesion and low coupling for reuse. GameState defines the state of the game, and GameAction drives state transitions to construct a complete finite state machine. Note that State and Action are not one-to-one.
Resources: Understanding Finite State Machines in simple Terms
- Wating: the state in which the game starts.
- Running: State in the game.
-
Paused: Paused state of the game, as above, look at the picture, that is, all elements and states will not change.
-
Dying: The Dying state of the player’s plane, used to trigger the player’s plane to explode. This is not necessary. Removing the state does not affect the Compose composable function. You can also define a state inside the Compose composable function. But if you have other requirements, for example, when the player’s plane explodes, bullets and enemy planes all disappear, plus the Dying is very convenient to deal with, each has its own good, architecture is not dead, can be adjusted according to actual needs.
-
Over: The game ends. The player automatically enters the Over state after the animation of aircraft explosion is played, as shown below.
- Exit: Used to Exit the game, which also seems redundant? Quit the game and call the Activity’s Finish method, but the GameViewModel does not rely on the Activity directly, so you cannot call the Finish method. The recommended way is to observe the public state provided by the GameViewModel in the Activity and implement the communication between the ViewModel and the Activity. Reference code is as follows:
// Observe the game state
lifecycleScope.launch {
gameViewModel.gameStateFlow.collect {
LogUtil.printLog(message = "lifecycleScope gameState $it")
/ / out of the app
if (GameState.Exit == it) {
finish()
}
}
}
Copy the code
Key code:
/** * Game state */
enum class GameState {
Waiting, // wait to start
Running, // gaming
Paused, // pause
Dying, // hit enemy and dying
Over, // over
Exit // finish activity
}
/** * game action */
@InternalCoroutinesApi
data class GameAction(
val start: () -> Unit = {}, // The game state is Running
val pause: () -> Unit = {},// The game state enters Paused
val reset: () -> Unit = {},// The game state is in Waiting, showing GameWaiting
val die: () -> Unit = {},// The game state enters Dying, triggering an explosion animation
val over: () -> Unit = {},// Go to Over and show GameOverBoard
val exit: () -> Unit = {},// Exit the game
val playerMove: (x: Int, y: Int) - >Unit = { _: Int, _ :Int -> },// The player moves
val score: (score: Int) - >Unit = { _: Int -> },// Update the score
val award: (award: Award) -> Unit = { _: Award -> },// Get a reward
val createBullet: () -> Unit= {},// Bullet generation
val initBullet: (bullet: Bullet) -> Unit = { _: Bullet -> },// The bullet initializes its birth position
val shooting: (resId: Int) - >Unit = { _: Int -> },/ / shooting
val destroyAllEnemy: () -> Unit = {},// Destroy all enemy aircraft
val levelUp: (score: Int) - >Unit = { _: Int -> },// Difficulty level up
)
/** * Game state StateFlow */
private val _gameStateFlow = MutableStateFlow(GameState.Waiting)
val gameStateFlow = _gameStateFlow.asStateFlow()
private fun onGameStateFlowChange(newGameSate: GameState) {
viewModelScope.launch {
withContext(Dispatchers.Default) {
_gameStateFlow.emit(newGameSate)
}
}
}
Copy the code
In the whole game logic, mainly through interface operation, collision detection, life cycle callback, trigger various actions, and finally call onGameStateFlowChange to update the state.
In the Compose composable function, the interface is displayed differently depending on the State. As shown in the following code, observe the gameState through LaunchedEffect(gameState). When the condition of gameState == gamestate. Dying is met, the explosion animation will be triggered to display and play the explosion resource picture sequence.
/** * Player plane explosion animation */
@InternalCoroutinesApi
@ExperimentalAnimationApi
@Composable
fun PlayerPlaneBombSprite(
gameState: GameState = GameState.Waiting,
playerPlane: PlayerPlane,
gameAction: GameAction
) {
if(gameState ! = GameState.Dying) {return
}
val spriteSize = PLAYER_PLANE_SPRITE_SIZE.dp
val spriteSizePx = with(LocalDensity.current) { spriteSize.toPx() }
val segment = playerPlane.segment
val anim = remember {
TargetBasedAnimation(
animationSpec = tween(172),
typeConverter = Int.VectorConverter,
initialValue = 0,
targetValue = segment - 1)}var animationValue by remember {
mutableStateOf(0)}var playTime by remember { mutableStateOf(0L) }
LaunchedEffect(gameState) {
val startTime = withFrameNanos { it }
do {
playTime = withFrameNanos { it } - startTime
animationValue = anim.getValueFromNanos(playTime)
} while(! anim.isFinishedFromNanos(playTime)) } LogUtil.printLog(message ="PlayerPlaneBombSprite() animationValue $animationValue")
/ / is used here to modify ImageBitmap imageResource returned bitmap convenient processing
val bitmap: Bitmap = imageResource(R.drawable.sprite_player_plane_bomb_seq)
/ / segmentation Bitmap
val displayBitmapWidth = bitmap.width / segment
val matrix = Matrix()
matrix.postScale(spriteSizePx / displayBitmapWidth, spriteSizePx / bitmap.height)
// Get only the required parts
val displayBitmap = Bitmap.createBitmap(
bitmap,
(animationValue * displayBitmapWidth),
0,
displayBitmapWidth,
bitmap.height,
matrix,
true
)
val imageBitmap: ImageBitmap = displayBitmap.asImageBitmap()
Canvas(
modifier = Modifier
.fillMaxSize()
.size(spriteSize)
) {
val canvasWidth = size.width
val canvasHeight = size.height
drawImage(
imageBitmap,
topLeft = Offset(
playerPlane.x.toFloat(),
playerPlane.y.toFloat(),
),
alpha = if (gameState == GameState.Dying) 1.0 f else 0f)},if (animationValue == segment - 1) {
gameAction.over()
}
}
Copy the code
Similarly, to achieve a game pause effect, basically all elements in the game are stopped and all actions except start are unavailable. This effect can be achieved by adding the following code to the implementation of the corresponding Action and Compose combination functions.
// The game is not in progress
if(gameState ! = GameState.Running) {return
}
Copy the code
Isn’t it easy? It’s that simple. To resume the game, simply change State to Gamestate.running.
10. Summary
Or spend a lot of time to realize this game, because the Jetpack Compose is just the knowledge point of contact, and didn’t before the game development experience, can only be continuous trial and error, and repeatedly reading bosses, and read the official document, read the source code to dig the details, including the Kotlin language to learn, the entire process is fruitful.
Compared with writing code, the pace of writing this article is much slower. On the one hand, I hope to explain the knowledge points in a simple way, and on the other hand, I can not be too wordy. I still feel that the length is too long after I finish writing it.
For this game, there are many regrets: such as the generation of enemy planes, the starting position of birth is not scattered enough, easy to overlapping enemy planes; The level design is too simple and not very playable; Game scores are not recorded and so on.
As a matter of fact, this article was finished by Mid-Autumn Festival, but I felt that I was not satisfied with it. The code was also being modified constantly when I wrote it. Some of the code even did not correspond to the article, so I did not want to send it out. I suddenly remembered it last weekend, and then I optimized it. From the perspective of learning Jetpack Compose, AFTER writing this article, the goal has been achieved, so I’d better share it. If it happens to be helpful to you, that would be even better.
11. Reference materials
TechMerger Guru’s One Merger: The Perfect Re-creation of Flappy Bird with Compose!
Make a Tetris game console with Jetpack Compose by Fundroid
Sun Qun bosses [“/making open source Android custom View implementation WeChat aircraft games] “] (blog.csdn.net/iispring/ar…).
Build Better Apps Faster with Jetpack Compose
Not one list, the knowledge points involved in the article are basically linked, convenient for everyone to read and learn.