23
Creating a retro-style game with Jetpack Compose: movement
Welcome to the second part of Creating a retro-style game with Jetpack Compose. In this installment we will take a look at how to move players, enemies, and other objects.
To understand how this works, please recall that this series uses Jetpack Compose to emulate the text or pseudo graphics mode of home computers. The screen is divided in a grid, often 40 by 25 cells. Each cell displays one character (a byte). In text mode, the look of the character is defined by the character set in use. In pseudo graphics mode each character is represented by a small bitmap, usually 8 by 8 pixels.
We focus on text mode. The game screen of Compose Dash consists of 40 columns and 14 rows. So we can address 560 cells. This can be done in two ways:
- By index: the cells are numbered from 0 to 559. The first row contains cells 0 to 39. The second 40 to 79. And so on.
- By location: Here we specify the row (or y) and column (x)
To convert between these two representations is really simple. If we have a location consisting of x
and y
we get the index through y * 40 + x
. The other way round is equally easy: x = index % 40
and y = index / 40
.
Now, what does this mean regarding movement? Generally speaking, depending on the direction x
, y
, or both change. To move upward (north) y
is decremented by 1. To go south, we increment y
by 1. The same applies to x
regarding west (-1) and east (+1). If we want to implement movement using the index we add or subtract 40 to go north or south. To go west or east we subtract or add 1. Let's see how I make use of this in Compose Dash. Please recall that currently each cell is represented through a Text()
.
Text(
modifier = Modifier
.background(background)
.clickable {
movePlayerTo(levelData, index, gemsCollected)
},
text = symbol.unicodeToString()
)
When a cell is clicked movePlayer()
is invoked. It receives the index
of the location to move to. Please recall that I have defined the game screen like this:
private fun createLevelData(): SnapshotStateList<Char> {
val data = mutableStateListOf<Char>()
var rows = 0
level.split("\n").forEach {
if (it.length != COLUMNS)
throw RuntimeException("length of row $rows is not $COLUMNS")
data.addAll(it.toList())
rows += 1
}
if (rows != ROWS)
throw RuntimeException("number of rows is not $ROWS")
return data
}
We parse a multiline string that contains the level data and convert it to a SnapshotStateList<Char>
. This list is used inside LazyVerticalGrid()
and passed to itemsIndexed()
. Now let's take a look at movePlayer()
. Please remember, it is passed an index
which represents the new location.
private fun movePlayerTo(
levelData: SnapshotStateList<Char>,
desti: Int,
gemsCollected: MutableState<Int>
) {
val start = levelData.indexOf(CHAR_PLAYER)
if (start == desti) return
val startX = start % COLUMNS
val startY = start / COLUMNS
val destiX = desti % COLUMNS
val destiY = desti / COLUMNS
val dirX = if (destiX > startX) 1 else -1
val dirY = if (destiY > startY) 1 else -1
var current = start
lifecycleScope.launch {
var x = startX
var y = startY
while (current != -1 && y != destiY) {
current = walk(levelData, current, x, y, gemsCollected)
y += dirY
}
while (current != -1 && current != desti) {
current = walk(levelData, current, x, y, gemsCollected)
x += dirX
}
}
}
To calculate the direction it is not enough to know the new location. We also require the current one. I do it like this: val start = levelData.indexOf(CHAR_PLAYER)
(a rather simplistic approach, by the way). As there is always exactly one player on the game screen I can search for it in the levelData
list. Another (and possibly faster) way would be to just store it in some variable. Now, let's look at movement.
if (start == desti) return
means: if there has been no movement we don't have anything to do.
val dirX = if (destiX > startX) 1 else -1
val dirY = if (destiY > startY) 1 else -1
By using index
we move through simple arithmetics. The two lines above make this particularly convenient as we need not bother if we want to add or subtract. We can always add dirX
or dirY
because if we go upward or left, the value is -1. Adding -1 is the same as subtracting 1.
Compose Dash splits movement into two steps. First we walk along the y axis (vertically), then along the x axis (horizontally). Movement takes place until we reach the desired location on the corresponding axis or the index (stored in current
) is -1.
var x = startX
var y = startY
while (current != -1 && y != destiY) {
current = walk(levelData, current, x, y, gemsCollected)
y += dirY
}
while (current != -1 && current != desti) {
current = walk(levelData, current, x, y, gemsCollected)
x += dirX
}
So, movePlayerTo()
orchestrates the movement of the player. Inside a lifecycleScope.launch {
a function named walk()
is invoked repeatedly.
But why do I need a coroutine here? Well, movement should take some time. As movePlayerTo()
has been called from a composable it must return as soon as it can. That's why the actual movement must take place asynchronously. And, as you shall see shortly, moving around may well trigger other concurrent tasks. But before that, let's see what walk()
does.
private suspend fun walk(
levelData: SnapshotStateList<Char>,
current: Int,
x: Int,
y: Int,
gemsCollected: MutableState<Int>
): Int {
val newPos = (y * COLUMNS) + x
when (levelData[newPos]) {
CHAR_GEM -> {
gemsCollected.value += 1
}
CHAR_ROCK, CHAR_BRICK -> {
return -1
}
}
levelData[current] = ' '
levelData[newPos] = CHAR_PLAYER
delay(200)
if (current != -1) {
freeFall(levelData, current - COLUMNS, CHAR_ROCK)
freeFall(levelData, current - COLUMNS, CHAR_GEM)
}
return newPos
}
The main idea of this function is to check the value in levelData
at the current
position and act accordingly. For example, detecting a collision with a rock or brick is as simple as
CHAR_ROCK, CHAR_BRICK -> {
return -1
}
To control the duration of the movement, delay(200)
makes sure that the function does not return too early.
If the new location is not -1 (the player has moved in some direction) I invoke yet another function called freeFall()
. It let's rocks or gems fall down. Have you noticed that I pass current - COLUMNS
? This means check what's above the player. So we trigger the movement of objects that are directly above the current location.
private suspend fun freeFall(
levelData: SnapshotStateList<Char>,
current: Int,
what: Char
) {
if (levelData[current] == what) {
lifecycleScope.launch {
delay(200)
freeFall(levelData, current - COLUMNS, what)
val x = current % COLUMNS
var y = current / COLUMNS + 1
var pos = current
while (y < ROWS) {
val newPos = y * COLUMNS + x
when (levelData[newPos]) {
CHAR_BRICK, CHAR_ROCK, CHAR_GEM -> {
break
}
}
levelData[pos] = ' '
levelData[newPos] = what
y += 1
pos = newPos
delay(200)
}
}
}
}
Movement happens inside lifecycleScope.launch {
. After a short delay freeFall()
is invoked with a location above the current object. This recursion is necessary to have everything above the player fall down eventually. Besides that, the movement of the current object takes place. Here, too, we have some collision checks to determine the fate of the player, rock, or gem.
So far our player is not hurt by rocks (which should probably be the case ). We will turn our attention to this in the next episode. And we will introduce other enemies - seeing the rocks as enemies is probably far fetched given that they currently do not hurt the player. 🤣 Is there anything else you would like to see covered? Please do not hesitate to share your thoughts in the comments.
23