KorGE Tutorial - Writing 2048 game. Step 3 - Animation

KorGE Tutorial - Writing 2048 game. Step 3 - Animation

In the previous step we have added PositionMap class for the game state and initial code that handles interaction. In this step we are going to define the main logic of the game - the movement and merging of blocks. We’ll also add animation for that. So the resulting game after this step will be quite playable and will look like this:

Preliminary checks

As you may remember, the last code lines we wrote in the previous step were moveBlocksTo(direction) function and a TODO in it:

fun Stage.moveBlocksTo(direction: Direction) {
	println(direction)
	// TODO: we'll implement this function in the next step
}

Now, let’s get back to it. First, remove lines with println and TODO. Second, add a few preliminary checks. Since we’re going to play animation of movement and merging, we need to check whether the animation is already running when a gamer presses a key or swipes. After that we need to check if there are available moves or the game is over (in the second case we’ll show a special overlay offering to start the game over). So let’s add two special flags isAnimationRunning and isGameOver before the main function:

var isAnimationRunning = false
var isGameOver = false

suspend fun main() = ...

Next, let’s add checks in moveBlocksTo function:

fun Stage.moveBlocksTo(direction: Direction) {
	if (isAnimationRunning) return
	if (!map.hasAvailableMoves()) {
		if (!isGameOver) {
			isGameOver = true
			showGameOver {
				isGameOver = false
				restart()
			}
		}
		return
	}
}

If the animation is running, we simply return from the function. Otherwise we check if there are available moves. Now let’s add two functions for that in PositionMap class:

class PositionMap /*...*/ {

	//...

	fun hasAvailableMoves(): Boolean {
		array.each { x, y, _ ->
			if (hasAdjacentEqualPosition(x, y)) return true
		}
		return false
	}

	private fun hasAdjacentEqualPosition(x: Int, y: Int) = getNumber(x, y).let {
		it == getNumber(x - 1, y) || it == getNumber(x + 1, y) || it == getNumber(x, y - 1) || it == getNumber(x, y + 1)
	}
}

Here we look at each position on the map and check if there is an adjacent position with the same number. If the position is out of the field (like when x == -1 or y == 4) or there is no number there (the cell is empty), then function getNumber will return -1, therefore covering the case when there is a single empty cell on the field with no empty cells next to it (no cells with the same number).

Well, the check of available cells is implemented. Let’s get back to moveBlockTo function and see what we should do next.

Game over overlay

Now we need to add a special function showGameOver that will show a game over overlay and execute a special callback (provided as a parameter onRestart) after a gamer clicks “Try again”. This function should be called on a Container (and it is called on our Stage), thus we can add other views to our stage:

fun Container.showGameOver(onRestart: () -> Unit) = container {
   ...
}

In the first line, there is a container {…} call. This way we add an overlay container to the Stage, this container will be removed from the stage container when the gamer clicks “Try again”. All other views added inside the showGameOver function will actually be added to this overlay container, so their coordinates should be determined relative to the container bounds.

Let’s add content inside the overlay container. We’ll use a textview for title and a uiText view for clickable text “Try again”. UIText is a special view in KorGE that lets you define different text formats (color, size and font) for normal, over and downstates. We also need to define an onClick {…} listener on the text “Try again” that will remove the overlay container from the stage and call onRestart callback. The same action should be performed when a gamer presses Enter or Space keys. The resulting function looks like that:

fun Container.showGameOver(onRestart: () -> Unit) = container {
	fun restart() {
		this@container.removeFromParent()
		onRestart()
	}

	position(leftIndent, topIndent)

	roundRect(fieldSize, fieldSize, 5.0, fill = Colors["#FFFFFF33"])
	text("Game Over", 60.0, Colors.BLACK, font) {
        centerBetween(0.0, 0.0, fieldSize, fieldSize)
        y -= 60
    }
    text("Try again", 120.0) {
        centerBetween(0.0, 0.0, fieldSize, fieldSize)
        y += 20
        textSize = 40.0
        font = font
        color = RGBA(0, 0, 0)
        onOver { color = RGBA(90, 90, 90) }
        onOut { color = RGBA(0, 0, 0) }
        onDown { color = RGBA(120, 120, 120) }
        onUp { color = RGBA(120, 120, 120) }
        onClick { restart() }
    }

	keys.down {
		when (it.key) {
			Key.ENTER, Key.SPACE -> restart()
			else -> Unit
		}
	}
}

You can comment the if condition that checks available moves in moveBlocksTo function and see how the game over overlay looks like (it’ll appear after you press any arrow key or swipe):

Game restart

After implementing the game over overlay, we need to add logic that should be executed when the user clicks on the “Try again” text. We already have restart() call in the moveBlocksTofunction, let’s create this function and add code in it. We need to clear the field and add a new block there:

fun Container.restart() {
	map = PositionMap()
	blocks.values.forEach { it.removeFromParent() }
	blocks.clear()
	generateBlock()
}

Here we create a new PositionMap object and assign it to the map property, remove all Block views added to the blocksmutable map as values, from the parent container, then we clear the blocks map (i.e. remove all map entries and make the map empty) and generate a new Block on the currently empty field.

Now you can comment the if condition in moveBlocksTofunction again and see that game restart works correctly. After pressing “Try again” you will see a new block being added to the empty field:

There is also a small addition to the restartBlock in the main()function — we need to restart the game after the restart “button” is called:

suspend fun main()... {
	...
	val restartBlock = container {
		...
		onClick {
			this@Korge.restart()
		}
	}
	...
}

Changes of the position map

Let’s return to our moveBlocksTo function. After checking that animation is not running and the map has available moves, we need to change the map based on a user reaction. First we need to calculate a new map and all moves and merges of the blocks on the field. Then, if the old map and the new map are different, we need to animate the changes:

fun Stage.moveBlocksTo(direction: Direction) {
	if (isAnimationRunning) return
	if (!map.hasAvailableMoves()) {
		//...
	}

	val moves = mutableListOf<Pair<Int, Position>>()
	val merges = mutableListOf<Triple<Int, Int, Position>>()

	val newMap = calculateNewMap(map.copy(), direction, moves, merges)

	if (map != newMap) {
		isAnimationRunning = true
		showAnimation(moves, merges) {
			// when animation ends
			map = newMap
			generateBlock()
			isAnimationRunning = false
		}
	}
}

Here we create two mutable lists: one for moves (pairs of the moving block id and a new position) and the other one for merges (triples of two merging block ids and a new position). Then we calculate a new map - we pass a copy of the current map that will be changed during calculation, a direction of the movement and our two lists.

After the calculation we check that two maps are different (using equals operator defined in PositionMap class). If so, we set isAnimationRunning to true and show an animation of moves and merges. When the animation ends, we assign the new map to the current one, generate a new block and set isAnimaitonRunning flag to false.

Let’s add a new function copy() to PositionMap that should create and return a new map with the same content:

class PositionMap(...) {
	...

	
fun calculateNewMap(
		map: PositionMap,
		direction: Direction,
		moves: MutableList<Pair<Int, Position>>,
		merges: MutableList<Triple<Int, Int, Position>>
): PositionMap {
	val newMap = PositionMap()
	val startIndex = when (direction) {
		Direction.LEFT, Direction.TOP -> 0
		Direction.RIGHT, Direction.BOTTOM -> 3
	}
	var columnRow = startIndex

	fun newPosition(line: Int) = when (direction) {
		Direction.LEFT -> Position(columnRow++, line)
		Direction.RIGHT -> Position(columnRow--, line)
		Direction.TOP -> Position(line, columnRow++)
		Direction.BOTTOM -> Position(line, columnRow--)
	}

	for (line in 0..3) {
		var curPos = map.getNotEmptyPositionFrom(direction, line)
		columnRow = startIndex
		while (curPos != null) {
			val newPos = newPosition(line)
			val curId = map[curPos.x, curPos.y]
			map[curPos.x, curPos.y] = -1

			val nextPos = map.getNotEmptyPositionFrom(direction, line)
			val nextId = nextPos?.let { map[it.x, it.y] }
			//two blocks are equal
			if (nextId != null && numberFor(curId) == numberFor(nextId)) {
				//merge these blocks
				map[nextPos.x, nextPos.y] = -1
				newMap[newPos.x, newPos.y] = curId
				merges += Triple(curId, nextId, newPos)
			} else {
				//add old block
				newMap[newPos.x, newPos.y] = curId
				moves += Pair(curId, newPos)
			}
			curPos = map.getNotEmptyPositionFrom(direction, line)
		}
	}
	return newMap
}
}

Calculation of a new map

Now we need to add a function in which moves, merges and a new map should be calculated. Let’s do it like that:

fun calculateNewMap(
		map: PositionMap,
		direction: Direction,
		moves: MutableList<Pair<Int, Position>>,
		merges: MutableList<Triple<Int, Int, Position>>
): PositionMap {
	val newMap = PositionMap()
	val startIndex = when (direction) {
		Direction.LEFT, Direction.TOP -> 0
		Direction.RIGHT, Direction.BOTTOM -> 3
	}
	var columnRow = startIndex

	fun newPosition(line: Int) = when (direction) {
		Direction.LEFT -> Position(columnRow++, line)
		Direction.RIGHT -> Position(columnRow--, line)
		Direction.TOP -> Position(line, columnRow++)
		Direction.BOTTOM -> Position(line, columnRow--)
	}

	for (line in 0..3) {
		var curPos = map.getNotEmptyPositionFrom(direction, line)
		columnRow = startIndex
		while (curPos != null) {
			val newPos = newPosition(line)
			val curId = map[curPos.x, curPos.y]
			map[curPos.x, curPos.y] = -1

			val nextPos = map.getNotEmptyPositionFrom(direction, line)
			val nextId = nextPos?.let { map[it.x, it.y] }
			//two blocks are equal
			if (nextId != null && numberFor(curId) == numberFor(nextId)) {
				//merge these blocks
				map[nextPos.x, nextPos.y] = -1
				newMap[newPos.x, newPos.y] = curId
				merges += Triple(curId, nextId, newPos)
			} else {
				//add old block
				newMap[newPos.x, newPos.y] = curId
				moves += Pair(curId, newPos)
			}
			curPos = map.getNotEmptyPositionFrom(direction, line)
		}
	}
	return newMap
}

First, we create a new PositionMap instance, define a start index based on the direction (it’s a number of a column or a row with which we start calculating) and define a current index of a column or a row (columnRow variable).

Second, we define an inner function newPosition that takes a line (row/column) number and returns the next position based on the movement direction.

And third, we go through all the lines (from 0 to 3), get current position that has a block and get current block id, get next position that has a block and get next id, then check whether the current number and the next number are the same or not. If yes - we merge them: we clear the next position on the map (the current one will no longer be viewed after the current check), define the current id in the new position on the new map and add a triple to the merges list. If no - we move the current number: we define the current id in the new position on the new map and add a pair to the moves list. Then we update the position of the current block and repeat the previous steps.

After going through all the positions is done, we return a new map.

Here we need to add a new function getNotEmptyPositionFrom(direction, line) in the PositionMapclass that returns a Position instance if the empty position is found, or null otherwise:

class PositionMap(...) {
	...

	fun getNotEmptyPositionFrom(direction: Direction, line: Int): Position? {
		when (direction) {
			Direction.LEFT -> for (i in 0..3) getOrNull(i, line)?.let { return it }
			Direction.RIGHT -> for (i in 3 downTo 0) getOrNull(i, line)?.let { return it }
			Direction.TOP -> for (i in 0..3) getOrNull(line, i)?.let { return it }
			Direction.BOTTOM -> for (i in 3 downTo 0) getOrNull(line, i)?.let { return it }
		}
		return null
	}
}

We also need to add an utility function numberFor(blockId) in the main.kt file:

...
var map = ...
var blocks = ...

fun numberFor(blockId: Int) = blocks[blockId]!!.number

Animation of moves and merges

Now, the last feature we will add in this article and also the main feature of it – animation! KorGE allows you to use different types of animation. We will use tween animationswith the Animator DSL because it will let us define different animation elements (tweens) in a special order, so that some elements will be executed in parallel, some – in sequence, and we will be able to define blocks of plain code (without tweening animation) that should be executed between them.

The animation is suspending, so we should use launchImmediately function and a Stage instance to create a coroutine in which the animation will be running. The resulting showAnimation function is shown below:

fun Stage.showAnimation(
		moves: List<Pair<Int, Position>>,
		merges: List<Triple<Int, Int, Position>>,
		onEnd: () -> Unit
) = launchImmediately {
	animateSequence {
		parallel {
			moves.forEach { (id, pos) ->
				blocks[id]!!.moveTo(columnX(pos.x), rowY(pos.y), 0.15.seconds, Easing.LINEAR)
			}
			merges.forEach { (id1, id2, pos) ->
				sequence {
					parallel {
						blocks[id1]!!.moveTo(columnX(pos.x), rowY(pos.y), 0.15.seconds, Easing.LINEAR)
						blocks[id2]!!.moveTo(columnX(pos.x), rowY(pos.y), 0.15.seconds, Easing.LINEAR)
					}
					block {
						val nextNumber = numberFor(id1).next()
						deleteBlock(id1)
						deleteBlock(id2)
						createNewBlockWithId(id1, nextNumber, pos)
					}
					sequenceLazy {
						animateScale(blocks[id1]!!)
					}
				}
			}
		}
		block {
			onEnd()
		}
	}
}

Here we use animateSequence function to create an Animatorinstance. Inside it we define a parallel animation and a block with onEnd() callback. Inside parallel block we go through the moves list and define a movement animation via moveToextension function in Animator, we also go through the mergeslist and define a sequence animation for each merge triple. Inside sequence block we execute parallel movement animation of both blocks, then we exucute code in blockelement where we delete both blocks and create a new one with the id of the first block, and then we use a sequenceLazyanimation element to animate scale of a block.

Note: the code inside sequence is executed right away when the showAnimation function is called, the code inside sequenceLazy is executed only when the animation inside this animation element should be shown. That’s why we use sequenceLazy to animate scale of a block – the block that we animate there is created only in the block element, it’s not available when the whole animation starts. We don’t use sequenceLazy everywhere because this animation function is quite expensive in comparison with sequence.

Well, let’s add the missing deleteBlock function in the main.ktfile. In this function we remove a block view with the corresponding id from the blocks map and then remove this view from its parent container:

fun numberFor(blockId: Int) = ...
fun deleteBlock(blockId: Int) = blocks.remove(blockId)!!.removeFromParent()

And the last function we need to add is animateScale(block).

fun Animator.animateScale(block: Block) {
	val x = block.x
	val y = block.y
	val scale = block.scale
	tween(
			block::x[x - 4],
			block::y[y - 4],
			block::scale[scale + 0.1],
			time = 0.1.seconds,
			easing = Easing.LINEAR
	)
	tween(
			block::x[x],
			block::y[y],
			block::scale[scale],
			time = 0.1.seconds,
			easing = Easing.LINEAR
	)
}

It uses Animator as a receiver and calls its tween function twice to animate position and scale at the same time. These tweening animations will be executed sequentially because the animateScale function is called inside sequenceLazyanimation block.

In order to make this code compile, we need to add import for tweening properties either by adding import com.soywiz.korge.tween.* at the beginning of the file, or manually by setting the mouse pointer on the tweening parameters, pressing Alt+Enter and choosing Import option.

The result

Well, we’ve added a lot of code in this step. If you run the game now, you will see that it’s already quite playable:

The whole project code after this step is shown here. In the next step we’ll add scores and saving state via NativeStorage.

rezmike
comments powered by Disqus