24
Creating a retro-style game with Jetpack Compose: level completed
Welcome to the final part of Creating a retro-style game with Jetpack Compose. In this instalment we will wrap things up by learning how to reset the game, complete a level, and to create enemies that chase our hero.
A lot of the game mechanics in Compose Dash relies on state. As this is an intentionally incomplete prototype, I have not included a view model (which would usually store the domain data). This is an omission you should certainly not copy. But it makes my code smaller, therefore easier to follow. So, what domain data is there? The aim of the game is to complete a level by collecting all gems, and not being hit by falling gems or rocks. So we have:
- the number of gems to be collected
- the number of gems that already have been collected
- the number of lives still available
- the level description
The total amount of lives after a reset is a constant, so I do not consider it domain data. Now, please take a look at the following code fragment.
fun ComposeDash() {
key = remember { mutableStateOf(0L) }
levelData = remember(key.value) {
createLevelData()
}
enemies = remember(key.value) { createEnemies(levelData) }
val gemsTotal = remember(key.value) { Collections.frequency(levelData, CHAR_GEM) }
val gemsCollected = remember(key.value) { mutableStateOf(0) }
// Must be reset explicitly
val lastLives = remember { mutableStateOf(NUMBER_OF_LIVES) }
lives = remember { mutableStateOf(NUMBER_OF_LIVES) }
Box {
LazyVerticalGrid(
Some state variables have become global, here is how they look:
lateinit var enemies: SnapshotStateList<Enemy>
lateinit var levelData: SnapshotStateList<Char>
lateinit var lives: MutableState<Int>
lateinit var key: MutableState<Long>
You will see later why I have done this. ComposeDash()
is the relevant root composable for the game play, so I remember
all game-relevant states here. Please recall that whenever a remembered state changes, a recomposition will take place. The initial value is usually computed once. Both lastLives
and lives
are set to NUMBER_OF_LIVES
. If I change the value, for example like in the following code snippet, there is no way for ComposeDash()
to know what the initial value has been.
private suspend fun freeFall(
levelData: SnapshotStateList<Char>,
current: Int,
what: Char,
lives: MutableState<Int>
) {
lifecycleScope.launch {
delay(800)
if (levelData[current] == what) {
freeFall(levelData, current - COLUMNS, what, lives)
val x = current % COLUMNS
var y = current / COLUMNS + 1
var pos = current
var playerHit = false
while (y < ROWS) {
val newPos = y * COLUMNS + x
when (levelData[newPos]) {
CHAR_BRICK, CHAR_ROCK, CHAR_GEM -> {
break
}
CHAR_PLAYER -> {
if (!playerHit) {
playerHit = true
lives.value -= 1
}
}
}
levelData[pos] = CHAR_BLANK
levelData[newPos] = what
y += 1
pos = newPos
delay(200)
}
}
}
}
If I need to set lives
to its initial value, I must know what this has been. I'll show you shortly when and where this is done. But first, let's take a closer look to some other states.
key = remember { mutableStateOf(0L) }
levelData = remember(key.value) {
createLevelData()
}
levelData
holds the level data and reflects all changes since the initial creation. For example,
- if gems have already been collected, they are no longer there
- if rocks or gems have been falling down, they are at new locations
- if the player has been moved, it is at a new location
Please recall that this is precisely why I use SnapshotStateList<Char>
to hold the level data. I want any change to cause a recomposition.
But why do I pass a key to remember
? There are situations in the game when you want to reset (almost) all states to their initial values. This, for example, is the case if the user has made a bad move, thus the player is hit by a rock or gem. The number of lives is decremented, but the number of collected gems must be set to zero, and the level data must be brought to its initial state, too. While you could do this in a function, there is a much more convenient way: if you pass a key to remember
the value is recalculated when that key changes. Take a look:
@Composable
fun NextTry(
key: MutableState<Long>,
lives: MutableState<Int>,
lastLives: MutableState<Int>
) {
val canTryAgain = lives.value > 0
Box(
modifier = Modifier
.fillMaxSize()
.background(color = Color(0xa0000000))
.clickable {
if (canTryAgain)
lastLives.value = lives.value
else {
lives.value = NUMBER_OF_LIVES
lastLives.value = NUMBER_OF_LIVES
}
key.value += 1
}
) {
Text(
"I am sorry!\n${if (canTryAgain) "Try again" else "You lost"}",
style = TextStyle(fontSize = 48.sp, textAlign = TextAlign.Center),
color = Color.White,
modifier = Modifier
.align(Alignment.Center)
)
}
}
So, a simple key.value += 1
leads to all remembered values that are bound to key
will have their values recomputed. Please note that using a true counter here is not necessary, even a Boolean
would have sufficed. I just wanted to show that you can even measure the number of changes, if you need to. In the code snippet above you can also see why lives
and lastLives
have no key. The logic when to set their values to the initial one just differs. Here's another example:
@Composable
fun RestartButton(
key: MutableState<Long>, scope: BoxScope,
lives: MutableState<Int>,
lastLives: MutableState<Int>
) {
scope.run {
Text(
POWER.unicodeToString(),
style = TextStyle(fontSize = 32.sp),
color = Color.White,
modifier = Modifier
.align(Alignment.BottomStart)
.clickable {
lives.value = NUMBER_OF_LIVES
lastLives.value = NUMBER_OF_LIVES
key.value += 1
}
)
}
}
We have now explored almost all relevant parts of Compose Dash. To conclude this series, I would like to share one more thing: how to create enemies that make life of the player a little harder.
First, I slightly altered the level map.
val level = """
########################################
#...............................X......#
#.......OO.......OOOOOO................#
#.......OO........OOOOOO...............#
#.......XXXX!.......!X.................#
#......................................#
#.........................##############
#.........OO...........................#
#.........XXX..........................#
##################.....................#
#......................XXXXXX..........#
# OOOOOOO........................#
# !.X......................@......#
########################################
""".trimIndent()
and
const val SPIDER = 0x1F577
const val CHAR_SPIDER = '!'
so !
becomes 🕷
Now, quite a few lines of code. Please recall that I defined enemies
likes this: lateinit var enemies: SnapshotStateList<Enemy>
. Each spider is represented by an instance of a data class which holds its current location (index
) and the direction it is traveling. moveEnemies()
take care of the movement.
data class Enemy(var index: Int) {
var dirX = 0
var dirY = 0
}
suspend fun moveEnemies() {
delay(200)
var playerHit = false
if (::enemies.isInitialized) {
val indexPlayer = levelData.indexOf(CHAR_PLAYER)
val colPlayer = indexPlayer % COLUMNS
val rowPlayer = indexPlayer / COLUMNS
enemies.forEach {
if (!playerHit) {
val current = it.index
val row = current / COLUMNS
val col = current % COLUMNS
var newPos = current
if (col != colPlayer) {
if (it.dirX == 0)
it.dirX = if (col >= colPlayer) -1 else 1
newPos += it.dirX
val newCol = newPos % COLUMNS
if (newCol < 0 || newCol >= COLUMNS || levelData[newPos] != CHAR_BLANK) {
if (isPlayer(levelData, newPos)) {
playerHit = true
}
newPos = current
it.dirX = -it.dirX
}
}
if (row != rowPlayer) {
val temp = newPos
if (it.dirY == 0)
it.dirY = if (row >= rowPlayer) -COLUMNS else COLUMNS
newPos += it.dirY
val newRow = newPos / COLUMNS
if (newRow < 0 || newRow >= ROWS || levelData[newPos] != CHAR_BLANK) {
if (isPlayer(levelData, newPos)) {
playerHit = true
}
newPos = temp
it.dirY = 0
}
}
if (newPos != it.index) {
levelData[newPos] = CHAR_SPIDER
levelData[it.index] = CHAR_BLANK
it.index = newPos
}
}
}
}
if (playerHit) {
lives.value -= 1
key.value += 1
}
}
fun isPlayer(levelData: SnapshotStateList<Char>, index: Int) = levelData[index] == CHAR_PLAYER
The movement of the spider is pretty simplistic. I take a look at where the player is and let it move in that direction until an obstacle is met. If so, the direction is reversed.
Here is how I populate the list. This is done in ComposeDash()
(after levelData
has been filled).
enemies = remember(key.value) { createEnemies(levelData) }
Finally, here's how the asynchronous movement is triggered:
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
ComposeDashTheme {
Surface(color = Color.Black) {
ComposeDash()
}
}
}
lifecycleScope.launch {
while (isActive) moveEnemies()
}
}
The movement is not bound to a composable. The coroutine runs as long as the activity is running. This is fine because as long as there are no enemies, nothing happens. And the list is filled after the level data has been constructed.
As you have seen, it is really easy and fun to do simple games using Jetpack Compose. I hope I have spawned your interest to try it for yourself. I am more than curious to see what games you will be implementing.
This prototype still has a few loose endings:
- If you click during movement of the player it will be cloned
- Spiders cannot be hurt by rocks and gems
I will eventually be fixing this but I would love to see your pull requests. You can find the source on GitHub. Also, I would love to hear your thoughts. Please share them in the comments.
24