© The Author(s), under exclusive license to APress Media, LLC, part of Springer Nature 2023
C. PittProcedural Generation in Godothttps://doi.org/10.1007/978-1-4842-8795-8_10

10. Recreating Invasion

Christopher Pitt1  
(1)
Durbanville, South Africa
 

Invasion is a game I recently built, inspired by world events. It’s a strategy game, where you try to lead survivors through a war zone.

A screenshot of the invasion game has animated elements of a character, trees, buildings, and paths.

Invasion (2022)

It has a surprising amount of depth, despite being capable of running on a phone or tablet. That’s thanks to the click-to-move navigation and solid content generation code.

In this chapter, we’re going to rebuild this game, putting into practice everything we’ve learned so far.

Getting Set Up

We’ve been through this process quite a few times now, so I’m not going to go super deep into any of it. Let’s summarize the steps we should follow and then let’s talk about polish:
  1. 1.

    We need to set up a folder structure that supports our screens, globals, scenery, and actors.

     
  2. 2.

    Our screens should extend a base scene so that we can add screen switching, transitions, and mobile support.

     
  3. 3.

    We should base our level generation on pixel art, though we can forgo a seeding system.

     
  4. 4.

    The scenery code should account for collective nodes, which we can see in the preceding screenshot.

     

I’ll go through each of these, with screenshots.

Screens

Head over to ProjectProject Settings, and adjust the Viewport, Override, and Stretch settings:

A screenshot of the project settings page. The general tab and advanced settings option are selected. The right side has options under size with viewport width and height, window width override, and window height override highlighted.

Adjusting screen size

With this done, I set up the usual MarginContainer-based screen system:

A screenshot of a new screen. The scene panel on the left has the screen, interface, and cover options highlighted. The file system panel below has a list of items under the screens folder. The right side has an output screen with a rectangular box highlighted.

Screens for the game

I’ve added a new CanvasLayerColorRect node combination. We’re going to use this for a fancy screen transition, but first, we need to add the screen switching and menu buttons.

As before, we need to define the list of screens and their scene files:

This is from nodes/globals/constants.gd
extends Node
class_name Types
@export var game_over_scene : PackedScene
@export var menu_scene : PackedScene
@export var play_scene : PackedScene
@export var summary_scene : PackedScene
enum screens {
    game_over,
    menu,
    play,
    summary,
}
@onready var screen_scenes := {
    screens.game_over: game_over_scene,
    screens.menu: menu_scene,
    screens.play: play_scene,
    screens.summary: summary_scene,
}

We can reference these in the standard screen switching code we’ve written a few times:

This is from nodes/globals/screens.gd
extends Node
var root = null
var current_screen := Types.screens.menu
var current_screen_node : GameScreen
var is_changing_screen := false
func _ready() -> void:
    root = get_tree().get_root()
    current_screen = Types.screens.menu
    current_screen_node = root.get_children().back()
func change_screen(new_screen: Types.screens) -> void:
    if is_changing_screen:
        return
    is_changing_screen = true
    var new_screen_node : GameScreen = Constants.screen_scenes[new_screen].instantiate()
    await load_new_screen(new_screen_node, new_screen)
    is_changing_screen = false
func load_new_screen(new_screen_node: GameScreen, new_screen: Types.screens) -> void:
    current_screen_node.queue_free()
    root.add_child(new_screen_node)
    current_screen = new_screen
    current_screen_node = new_screen_node

We can connect to this code from our MenuScreen buttons. Speaking of which, I want to add a quit button to our main menu.

Only this time, I want to explore a new way of finding the button, so we can hide it when we export the game to platforms that don’t support quitting.

The most common way we’ve referenced nodes is with the @onready var _button := $Path/To/Button notation. There’s another that doesn’t hard-code the relationships between the button and its parent nodes. If we right-click on the button node and select Access as Scene Unique Name, we can reference it using this new %Syntax.

A screenshot of the menu screen page. The scene panel on the left has a quit button option selected. A right-click list has the option, access as scene unique name highlighted. The right side has the code and output.

Using scene-unique names

Our menu code looks like this:

This is from nodes/screens/menu_screen.gd
extends GameScreen
@onready var _quit_button := %QuitButton as Button
func _ready() -> void:
    _quit_button.visible = not OS.has_feature("HTML5")
func _on_play_button_pressed() -> void:
    Screens.change_screen(Constants.screens.play)
func _on_quit_button_pressed() -> void:
    get_tree().quit()

Transitions

I had to make the Cover node invisible to see what I was doing with the menu nodes. Let’s add the screen transition code so we can hide Cover with a fancy shader.

I like to implement transitions with signals and hooks. Hooks are methods with special names that our code can call automatically. The flow can be quite confusing at first:

A block diagram of signals and actions when switching screens. Block 1 has steps for global screens, block 2 for the menu screen, and block 3 for the play screen, with the dependencies indicated by arrows.

Signals and actions when switching screens

This requires adding signals to the screens we’re going to switch between, which is where the common parent class comes in handy:

This is from nodes/screens/screen.gd
extends MarginContainer
class_name GameScreen
signal did_prepare_to_hide
signal did_hide_with_transition
signal did_prepare_to_show
signal did_show_with_transition
func prepare_to_hide(next_screen : Types.screens) -> void:
    did_prepare_to_hide.emit()
func hide_with_transition(next_screen : Types.screens) -> void:
    did_hide_with_transition.emit()
func prepare_to_show(previous_screen : Types.screens) -> void:
    did_prepare_to_show.emit()
func show_with_transition(previous_screen : Types.screens) -> void:
    did_show_with_transition.emit()

We need to call these from our screen switching code. We should prepare the screens for showing and hiding and only free the old screen when all the transitions have happened.

The methods we’ve defined on the Screen node immediately emit their related signals, so there aren’t any animations. We’re going to change that shortly. For now, let’s call the appropriate Screen methods:

This is from nodes/globals/screens.gd
func load_new_screen(new_screen_node: GameScreen, new_screen: Types.screens) -> void:
    current_screen_node.call_deferred("prepare_to_hide", new_screen)
    await current_screen_node.did_prepare_to_hide
    current_screen_node.call_deferred("hide_with_transition", new_screen)
    await current_screen_node.did_hide_with_transition
    current_screen_node.queue_free()
    root.add_child(new_screen_node)
    new_screen_node.call_deferred("prepare_to_show", current_screen)
    await new_screen_node.did_prepare_to_show
    new_screen_node.call_deferred("show_with_transition", current_screen)
    await new_screen_node.did_show_with_transition
    current_screen = new_screen
    current_screen_node = new_screen_node

We can await custom signals in the same way as we await signals from built-in classes and methods. We can shuffle these calls and awaits around if we wanted the operations to happen in parallel.

The animation we’re going for only makes sense if they show and hide in sequence, though.

Adding Shaders

Let’s make the ColorRect appear and disappear with a fancy shader. I’m by no means a shader expert; but I have put together a simple effect that will work for us.

Selecting the ColorRect node, we can go to MaterialNew ShaderMaterialNew Shader. Save the resulting file anywhere, and then switch to the Shader Editor tab (on the bottom of the window).

We can use the following shader code:

This is from nodes/screens/screen.gdshader
shader_type canvas_item;
uniform float amount : hint_range(0, 1) = 0.0;
uniform float bandSize = 40.0;
void fragment() {
    float yFraction = fract(FRAGCOORD.y / bandSize);
    float yDistance = abs(yFraction - 0.5);
    if (yDistance + UV.y > amount * 2.0) {
        discard;
    }
}

In short, this shader says that as the amount reaches 1.0, more of the screen should be covered in the black banding:

A screenshot of a new screen page with a 13-line code at the bottom. The inspector panel on the right has options for shader material, screen g d shader, amount, and band size highlighted. A rectangular box with lines is on the output screen in the middle.

Adjusting shader amount in the property inspector

We can tween this amount property during the screen transitions. When we’re hiding a screen, we should increase the amount to 1.0, and when we’re showing a screen, we should do the opposite.

This is from nodes/screens/screen.gd
@onready var _cover := %Cover as ColorRect
var duration := 1.0
func prepare_to_hide(next_screen : Types.screens) -> void:
    _cover.material.set_shader_parameter("amount", 0.1)
    did_prepare_to_hide.emit()
func hide_with_transition(next_screen : Types.screens) -> void:
    var tween = get_tree().create_tween()
    tween.tween_method(func(value): _cover.material.set_shader_parameter("amount", value), 0.0, 1.0, duration)
    await tween.finished
    did_hide_with_transition.emit()
func prepare_to_show(previous_screen : Types.screens) -> void:
    did_prepare_to_show.emit()
func show_with_transition(previous_screen : Types.screens) -> void:
    var tween = get_tree().create_tween()
    tween.tween_method(func(value): _cover.material.set_shader_parameter("amount", value), 1.0, 0.0, duration)
    await tween.finished
    did_show_with_transition.emit()

This is an interesting combination of lambda syntax and procedural tweening. We prepare to show Cover by setting its amount parameter to 0.0, and then we tween this value to 1.0 as we hide the screen.

Planning Room Generation

Each Invasion level has some connected rooms. We can use a lot of the same code we created for Bouncy Cars.

A screenshot of a game has 5 graphical blocks.

Layouts for rooms

We can use a layout image like this, where each room is 11 × 11 pixels:
  • Purple pixels are paths.

  • Red pixels are houses.

  • Green pixels are trees.

  • Gray pixels are gravestones.

The process for making a map from these layouts is as follows:
  1. 1.

    Convert pixel data into cell data.

     
  2. 2.

    Combine clusters of pixels into single cells.

     
  3. 3.

    Create node instances for each cell.

     

The added complication is that we want to generate adjoining “rooms” that the player can travel between. The player should spawn in one of the rooms. This starting room should also contain the “safe house” to lead survivors to.

A different room should contain the safe house, which acts as a way for the player to complete the level.

We can visualize the rooms like this:

An illustration with 7 linked square boxes out of which 2 are labeled. The middle box is labeled spawn and the box on the top right is labeled exit.

Connected rooms

Let’s start by creating a single room so that we can get the code for pixels → nodes out of the way. Here are the nodes I have in my Room scene:

We need to do quite a bit of setup for the rooms to function. I’m going to show you a hectic screenshot and then break down each part in sections:

A screenshot of the room page. The scene panel on the left has a list of options for exits, sanctuaries, arrows, and spawns under the room dropdown. The right side has an animated output highlighted by a square box.

Room scene

You can save this scene to the nodes folder and attach an empty script to it. I have the grid showing and the snap positions set to 12 × 12 pixels. The guides are at 66 pixels on each axis. The exit area colliders are 11 grid cells wide, and their center is on 66 pixels.

A screenshot has a grid icon selected on the top bar, with a list of options under it. The use pixel snap checkbox is checked and configure snap option is highlighted.

Snapping to the grid

Tile Map

I'm basing this tile set on a kenney.​nl asset pack that I’ve extracted and modified various tiles from:

A screenshot of the game page has a chequered pattern with various animated characters and components on it. A grid is superimposed on the screen.

Room sprites

Each of these modified sprites is 12 × 12 pixels; so I’ve set up a tile set sprite for those dimensions and created the TileSet to have a road terrain. I had planned to use more of these tiles in the pixel layouts, but I found these too noisy in the final game.

Feel free to use them in yours, as long as you can set up new pixel colors and adjust the generation code to match.

12 × 12 pixel tiles, in an 11 × 11 unit layout, means each room has a size of 132 × 132 pixels. This is smaller than our 180 × 180 screen size, but we’ll center the rooms and the Aspect settings will scale them up to fill the center of the screen.

Exits

As the player moves to the edges of the screen, we need to transition them into the adjacent room if this exists. I’ve set up four Area2D nodes, with CollisionShape2D children, to be able to detect this transition.

These can be on the edges of the level so that walking to the edge will start the transition. We still need to code that part, though.

Sanctuaries

When the level begins, the player should start in a room that has a sanctuary in it. This is where the player needs to bring survivors to rescue them.

We also need a visible sanctuary in the room through which the player exits. These can look different, but I decided to make them look the same in my build of the game.

They’re blue ColorRect nodes in the screenshot, but you’re welcome to make them custom nodes if you so choose. Keeping things simple to focus on the important parts of this chapter.

Arrows

If there are adjacent rooms, we want to indicate to the player that they can travel in the direction of the adjacent room. Rooms won’t have an adjacent room on every side, so the idea is to hide the arrows if they aren’t present on that edge of the room.

These overlapping nodes might look strange, but we’ll hide them by default and show them when nothing else is in that position.

Spawns

We need to show where the player can spawn when the level starts and when they move from room to room. This can be hard-coded in a script, but I prefer a visual indicator.

That’s why I’ve created a Spawns Node2D to hold four Marker2D nodes. Our scripts can use the position of these visual indicators to work out where the player should enter a room, or where they should spawn.

It’s best that these positions do not intersect any doodads in our pixel art layout image. Otherwise, the player might be stuck in the position they spawn in. Plan your layout images so there is open space for these markers.

The Remaining Nodes

All the remaining nodes are placeholders for where we’ll add doodads, soldiers, and survivors. We could add them all to the same parent node; but that makes runtime debugging a bit harder.

Generating One Room

Now that we’ve taken care of the structure for each room, we need to write some code to handle drawing the tiles and doodads into each room. This code should take a random room layout and work out which pixels are nodes and which are tiles.

The layouts in the pixel art image are already horizontally flipped. I don’t want to vertically flip them because it would complicate the buildings.

First, we need to set up some constants:

This is from nodes/globals/constants.gd
@export var tree_scene : PackedScene
@export var grave_scene : PackedScene
@export var house_scene : PackedScene
@export var grass_scene : PackedScene
enum drawables {
    tree,
    grave,
    house,
    grass,
    path,
}
@onready var drawable_scenes := {
    drawables.tree: tree_scene,
    drawables.grave: grave_scene,
    drawables.house: house_scene,
    drawables.grass: grass_scene,
}
var drawable_colors := {
    drawables.tree: "22c55e",
    drawables.grave: "71717a",
    drawables.house: "ef4444",
    drawables.grass: "fbbf24",
    drawables.path: "a855f7",
}
var drawable_tiles := [
    drawables.path,
]
var drawable_groups := [
    drawables.house,
]
var number_of_layouts := 8
var layout_width := 11
var sprite_width := 12

This is all quite like Chapter 7, but we’re also adding nodes into the mix. We need to make a scene for each of those exports.

All the drawables extend on this node and code:

This is from nodes/drawables/drawable.gd
extends Node2D
class_name GameDrawable
var drawable_size : Vector2i
func set_drawable_size(size : Vector2i) -> void:
    drawable_size = size

Normally, we’d use the property syntax to define this kind of setter. It’s simpler, instead, to use a method that we can override it in the child classes that need to.

The tree, grave, and grass scenes are all similar and simple. Let me show you what one of them looks like, and you can follow this pattern for the others.

A screenshot of the tree drawable page. The scene panel on the left has 3 options, sprites 1 through 3 selected and highlighted. The file system panel below has a list of saved items under the drawable folder. Lines of code are on the right.

Showing a random tree sprite

We followed a similar approach in Chapter 2. This gives roughly a 1/10 chance for the one tree design to show, a 4/10 chance for the next, and a 7/10 chance for the third.

The graves and grass follow the same approach, with different sprites and percentages. I use low probability for those, so the room isn't too noisy.

Here’s that code in a more readable format:

This is from nodes/drawables/tree_drawable.gd
extends GameDrawable
func _ready() -> void:
    var chance := randf()
    var sprites := get_children()
    for sprite in sprites:
        sprite.visible = false
    if chance >= 0.90:
        sprites[2].visible = true
    elif chance >= 0.60:
        sprites[1].visible = true
    elif chance >= 0.30:
        sprites[0].visible = true

The houses are a bit more complicated, but they follow the approach we learned about in the previous chapter:

A screenshot of the house drawable page. The scene panel on the left has various tiles options listed which are highlighted. The right side has an output animated house highlighted.

Showing variable-size houses

I went ahead and created a TileMap node in each of the sizes our layouts support. There are nine of them, most of which we need to hide via script. We should only be showing the one required by the size given during the generation phase. Here’s how we do that:

This is from nodes/drawables/house_drawable.gd
extends GameDrawable
func set_drawable_size(size : Vector2i) -> void:
    super.set_drawable_size(size)
    for group in get_children():
        for variation in group.get_children():
            variation.visible = false
    var intended_name := str(drawable_size.x) + "x" + str(drawable_size.y)
    var intended_node := get_node(intended_name)
    var index = randi() % intended_node.get_child_count()
    (intended_node.get_child(index) as TileMap).visible = true

We can call the set_drawable_size method, on the parent class, with the super keyword. Next, we can plan an instance of the Room in the PlayScreen:

A screenshot of the play screen page. The scene panel on the left has options for the center, stage, and room highlighted. The middle panel has an output animation of the room. The inspector panel on the right has the position option highlighted under the transform section.

Adding a test Room instance to PlayScreen

Notice how I’ve put the Room at -66 × -66 pixels. This is half the width and height, so it is in the center of the screen.

If we launch the game, we should see the sanctuaries and arrows. Let’s work on the code that draws doodads and tiles.

We’ll need three methods:
  • Generating a random room layout from the pixel art layout file

  • Drawing TileMap road tiles

  • Drawing drawables for everything else

The layout method looks much like the ones we’ve made before:

This is from nodes/globals/generation.gd
extends Node
@export var layout_texture : Texture2D
func _ready() -> void:
    randomize()
func get_room_layout() -> Array:
    var image := layout_texture.get_image()
    var offset : int = (randi() % Constants.number_of_layouts) * Constants.layout_width
    var room := []
    for y in range(Constants.layout_width):
        var row := []
        for x in range(Constants.layout_width):
            var drawable_type : Types.drawables
            var pixel_color = image.get_pixel(x + offset, y).to_html(false)
            for type in Constants.drawable_colors.keys():
                if pixel_color == Constants.drawable_colors[type]:
                    drawable_type = type
            row.push_back(drawable_type)
        room.push_back(row)
    return room

This gives us a 2D array of tile and node types. We can pass to the other methods we need to make. First up, the TileMap drawing method:

This is from nodes/globals/generation.gd
func add_room_tiles(tilemap : TileMap, layout : Array) -> void:
    var tiles : Array[Vector2i] = []
    for y in range(Constants.layout_width):
        for x in range(Constants.layout_width):
            if not layout[y][x] in Constants.drawable_tiles:
                continue
            tiles.push_back(Vector2i(x, y))
    tilemap.set_cells_terrain_connect(0, tiles, 0, 0, false)

This is like the one we made for Bouncy Cars. We can pair it with a method that creates and places drawable nodes:

This is from nodes/globals/generation.gd
func add_room_doodads(node : Node2D, layout : Array) -> void:
    var ignored : Array[Vector2i] = []
    for y in range(Constants.layout_width):
        for x in range(Constants.layout_width):
            var current : Types.drawables = layout[y][x]
            if ignored.has(Vector2i(x, y)):
                continue
            if not Constants.drawable_scenes.keys().has(current):
                continue
            var drawable_size : Vector2i
            if current in Constants.drawable_groups:
                var w := 0
                var h := 0
                for i in range(5):
                    if layout[y + i][x] != current:
                        break
                    for j in range(5):
                        if layout[y + i][x + j] != current:
                            break
                        ignored.append(Vector2i(x + j, y + i))
                        if i == 0:
                            w += 1
                    h += 1
                drawable_size = Vector2i(w, h)
            var drawable = Constants.drawable_scenes[current].instantiate() as GameDrawable
            node.add_child(drawable)
            drawable.set_drawable_size(drawable_size)
            drawable.position = Vector2(
                x * Constants.sprite_width,
                y * Constants.sprite_width,
            )
This is the code I hinted at, toward the end of the previous chapter:
  • We loop through the rows and columns (y → x), ignoring all TileMap types and pixels we’ve already accounted for as part of a cluster.

  • We do a bit more processing for each type in the drawable_groups array.
    • We loop from 04 to see if there are matching cell types to the right.

    • We loop from 04 to see if there are matching cell types downward.

  • We combine these into a new drawable_size variable and add individual cells to the ignore list.

  • Once we’ve gone through all the cells, we create new nodes and assign the drawable_size to each.

With these methods in place, we can adjust the Room script so that it generates itself:

This is from nodes/room.gd
extends Node2D
class_name GameRoom
@onready var _tiles := %Tiles as TileMap
@onready var _doodads := %Doodads as Node2D
func _ready() -> void:
    var layout : Array = Generation.get_room_layout()
    Generation.add_room_tiles(_tiles, layout)
    Generation.add_room_doodads(_doodads, layout)

The room is now responsible for adding its own tiles and nodes. The results are quite lovely:

A screenshot of the invasion game has different animated elements and rooms. 4 highlighted boxes with outward arrows are on each edge of the screen.

Rooms that draw themselves

Feel free to add as many other decorations as you like. One thing I like about our houses is that we can create any number of variations. These could include houses that have second floors and yards. The tile set has some lovely decorations to achieve this.

Generating Many Rooms

Now that we can create a single room, it’s time to create a few and link them together. This should include the following details:
  • The starting room must have a rescue sanctuary in it.

  • There should be a limited number of rooms, branching out from it.

  • The final room should have an exit sanctuary in it.

  • The arrows should only be visible on edges where there is an adjacent room present.

Let’s build this function in stages, starting with the code to generate the first room:

This is from nodes/globals/generation.gd
func make_rooms(parent) -> void:
    Variables.room_positions_available = []
    Variables.room_positions_taken = []
    Variables.rooms = []
    var first_room = Constants.room_scene.instantiate() as GameRoom
    parent.add_child(first_room)
    first_room.room_position = Vector2i(0, 0)
    first_room.room_type = Constants.rooms.first
    first_room.position = Vector2(-66, -66)
    var rooms_left = Constants.number_of_rooms - 1

This code begins by resetting variables in a new global: Variables. Here’s what the script for that global looks like:

This is from nodes/globals/variables.gd
extends Node
var room_positions_available : Array[Vector2i]= []
var room_positions_taken : Array[Vector2i] = []
var rooms : Array[GameRoom] = []

These arrays are typed to only allow values of the defined types. The make_rooms method continues by creating a new instance of the room scene. The Constants script also gets a few new properties:

This is from nodes/globals/constants.gd
@export var room_scene : PackedScene
var number_of_rooms := 8
enum rooms {
    first,
    other,
    last,
}
enum room_neighbors {
    top,
    right,
    bottom,
    left,
}

Don’t forget to reference the room scene through the property inspector or you’ll get a nasty error message when running this code, something like Nonexistent function ‘instantiate’ in base ‘Nil’.

Once we create and position the first room, we can get all the potential neighbors. This requires a few methods in the Room script:

This is from nodes/room.gd
var room_type : Types.rooms
var room_position : Vector2i
var sanctuary_side : Types.room_neighbors
func get_neighbor_positions() -> Dictionary:
    return {
        Types.room_neighbors.top: Vector2i(room_position.x, room_position.y - 1),
        Types.room_neighbors.right: Vector2i(room_position.x + 1, room_position.y),
        Types.room_neighbors.bottom: Vector2i(room_position.x, room_position.y + 1),
        Types.room_neighbors.left: Vector2i(room_position.x - 1, room_position.y),
    }
func get_neighbor_position(neighbor : Types.room_neighbors) -> Vector2i:
    return get_neighbor_positions()[neighbor]
func has_neighbor(neighbor : Types.room_neighbors) -> bool:
    var neighbor_position = get_neighbor_position(neighbor)
    return Variables.room_positions_taken.has(neighbor_position)
func get_neighbor(neighbor : Types.room_neighbors) -> GameRoom:
    var neighbor_position = get_neighbor_position(neighbor)
    for next_room in Variables.rooms:
        if next_room.room_position == neighbor_position:
            return next_room
    return null
func free_side() -> int:
    for neighbor in Types.room_neighbors.values():
        if not has_neighbor(neighbor):
            return neighbor
    return -1
These are all helpers we can use to work out whether there are rooms or could be rooms around this one. We need this for a couple of reasons:
  • When we’re generating the grid of rooms and need to figure out the available spots

  • When we’re showing or hiding arrows and sanctuaries

That first part looks like this:

This is from nodes/globals/generation.gd
func make_rooms(parent) -> void:
    # ...snip
    var rooms_left = Constants.number_of_rooms - 1
    Variables.room_positions_available += first_room.get_neighbor_positions().values()
    Variables.room_positions_taken.append(first_room.room_position)
    Variables.rooms.append(first_room)
    Variables.room_positions_available.erase(
        Variables.room_positions_available[randi() % Variables.room_positions_available.size()]
    )

We find all the neighbor positions and add them to the list from which we’ll generate the next room. Before we do that, we remove one of the potential neighbor positions. We want to have a sanctuary in the first room. This will be on the “free side.”

Now, we need to build the rest of the rooms. Each room follows a similar creation process, though they’re positioned off-screen.

This is from nodes/globals/generation.gd
func make_rooms(parent) -> void:
    # ...snip
    Variables.room_positions_available.erase(
        Variables.room_positions_available[randi() % Variables.room_positions_available.size()]
    )
    while rooms_left > 0:
        var next_room_position = Variables.room_positions_available[randi() % Variables.room_positions_available.size()]
        Variables.room_positions_available.erase(next_room_position)
        var next_room_type : Types.rooms
        if rooms_left == 1:
            next_room_type = Types.rooms.last
        else:
            next_room_type = Types.rooms.other
        var next_room = Constants.room_scene.instantiate() as GameRoom
        parent.add_child(next_room)
        next_room.room_position = next_room_position
        next_room.room_type = next_room_type
        next_room.position = Vector2(-999, -999)

Can you guess why we need to store the available room positions and created rooms? We need these in the Room helper methods, or we could have used local variables. We’re also setting the types of most of the rooms to other and the last one to last.

The final bit of code needs to set the sanctuary side of the first and last rooms and fetch any new potential room positions:

This is from nodes/globals/generation.gd
func make_rooms(parent) -> void:
        # ...snip
        next_room.position = Vector2(-999, -999)
        if next_room_type == Types.rooms.last:
            var free_side = next_room.free_side()
            next_room.sanctuary_side = free_side
        Variables.room_positions_taken.append(next_room_position)
        Variables.rooms.append(next_room)
        for neighbor_position in next_room.get_neighbor_positions().values():
            if not Variables.room_positions_taken.has(neighbor_position) and not Variables.room_positions_available.has(neighbor_position):
                Variables.room_positions_available.append(neighbor_position)
        rooms_left -= 1
    var free_side = first_room.free_side()
    first_room.sanctuary_side = free_side

Be sure to check out the sample project code for this full listing, since it’s too large for me to include here. We can now remove the instance of Room we manually placed in PlayScreen and call the make_rooms method to dynamically place rooms:

This is from nodes/screens/play_screen.gd
extends GameScreen
@onready var _stage := %Stage as Control
func _ready() -> void:
    Generation.make_rooms(_stage)

The only way to see this code in action, before the player can walk around in them, is to open the remote debugger. If you run the game and then look above the node tree, you’ll see a Remote and a Local button.

When you’re designing, then you should be looking at the Local view. If the game is running and you’d like to see the nodes and their values, then you can click Remote and inspect things.

A screenshot of the play screen. A scene panel on the left has a remote tab selected and options under stage are highlighted. The right side has a few lines of code and an output animation.

Inspecting rooms in the remote view

Hiding Invalid Arrows and Sanctuaries

Before we add player movement, let’s clean up the look of the rooms by hiding invalid sanctuaries and arrows. Let’s define a new Room method for this:

This is from nodes/room.gd
@onready var _sanctuaries := %Sanctuaries as Node2D
@onready var _arrows := %Arrows as Node2D
func hide_invalid_stuff() -> void:
    for node in _sanctuaries.get_children():
        node.visible = false
    for node in _arrows.get_children():
        node.visible = false
    for side in ["top", "right", "bottom", "left"]:
        var name = side.capitalize()
        if has_neighbor(Types.room_neighbors[side]):
            _arrows.get_node(name).visible = true
        if [Types.rooms.first, Types.rooms.last].has(room_type):
            if sanctuary_side == Types.room_neighbors[side]:
                _sanctuaries.get_node(name).visible = true

It’s interesting that we can use square-bracket syntax on enums, giving us the ability to use a dynamic string. We need to call this method in the generation code:

This is from nodes/globals/generation.gd
var free_side = first_room.free_side()
first_room.sanctuary_side = free_side
for room in Variables.rooms:
    room.hide_invalid_stuff()

Moving Around in the Rooms

We’re going to add click-to-move navigation. This means putting into practice some things we learned in Chapter 8.

Let’s create a player character, based on some of what we learned:

A screenshot of the player page. The scene panel on the left has 3 options under player highlighted. The file system panel below has 2 items under the screens folder highlighted. The right side has the output animation of the player.

Setting up the player

This Player node consists of the following things:
  • It is a CharacterBody2D node.

  • It has a Sprite2D node for visuals.

  • It has a CollisionShape2D node to work out collisions.

  • It has a NavigationAgent2D node to work out pathing.

The code for it differs from the code we wrote previously. We’re using the NavigationAgent2D node to calculate a path, and we’re using that path in a different way. It still works as expected, but now we have a bit more control over what we do with the path information.

Here’s the code in a format that’s easier to read:

This is from nodes/player.gd
extends CharacterBody2D
class_name GamePlayer
@onready var _agent := %Agent as NavigationAgent2D
var speed := 1000
func _ready() -> void:
    _agent.velocity_computed.connect(
        func(safe_velocity : Vector2) -> void:
            velocity = safe_velocity
            move_and_slide()
    )
    _agent.set_target_location(global_position)
func _unhandled_input(event: InputEvent) -> void:
    if event is InputEventMouseButton:
        if event.is_pressed():
            _agent.set_target_location(event.position)
            _agent.get_next_location()
func _physics_process(delta: float) -> void:
    if not _agent.is_navigation_finished():
        var target := _agent.get_next_location()
        velocity = global_position.direction_to(target) * speed
        _agent.set_velocity(velocity)

Go through each of the scenes and make sure that all the nodes with green icons have their Mouse Filter settings to Ignore. This is so that the _unhandled_input method is called on the GamePlayer class, without the other controls intercepting it.

Another important thing to note is that we must call the get_next_location method at least once so that the NavigationAgent2D node can determine whether the navigation is finished. We continue to call it inside the _physics_process method so that the path can update if colliders move.

Remember to check the Avoidance Enabled check box so that the velocity_computed signal is emitted.

We also need to change the Room scene so that it has navigation data and injects a player into the first room:

A screenshot of the room page. The scene panel on the left has the navigation option highlighted. The right side has the output animation of the room highlighted.

Adding navigation mesh data to Room

This is a NavigationRegion2D node, with a custom rectangle of navigation data drawn into the Room. Chapter 8 explains this in a bit more detail, but the gist is that this area is used to calculate where NavigationAgent2D nodes can navigate.

We can inject the GamePlayer node through the PlayScreen scene:

This is from nodes/screens/play_screen.gd
func _ready() -> void:
    Generation.make_rooms(_stage)
    Variables.player = Constants.player_scene.instantiate()
    Variables.rooms[0].add_child(Variables.player)

This depends on an exported player_scene reference, so don’t forget to also set that up. This code adds it to the first room. The player is also stored in a variable on the Variables global so that we can get it from within the rooms.

Launch the game and click around a bit. You should see the step layer move to your cursor.

Transitioning to Neighboring Rooms

It’s time we added the ability to move between rooms. In the beginning of the chapter, we added Area2D nodes that would serve as the doorways between rooms. We’re now going to put them to use.

When the player’s body collides with the exits, we want to start the transition into another room. The trouble is that they can’t just be added to the new room in the same position, or they’ll trigger the transition in that room as well.

We need to disable all the colliders so that only one transition happens. Let’s add the colliders to groups so that we can disable them without a lot of traversal code:

A screenshot of the room page. The scene panel on the left has the collider option highlighted. The middle panel has the output animation of the room, with the top portion highlighted. The node panel on the right has the groups tab selected and the exit colliders option under it is highlighted.

Adding nodes to groups for bulk actions

Next, let’s build a function to move the player from their current room to the new room:

This is from nodes/room.gd
func add_player(side : Types.room_neighbors) -> void:
    call_deferred("disable_colliders")
    await get_tree().create_timer(0.1).timeout
    var old_room = Variables.player.get_parent()
    if old_room:
        old_room.remove_child(Variables.player)
        old_room.position = Vector2(-999, -999)
    position = Vector2(-66, -66)
    add_child(Variables.player)
    Variables.player.reposition(get_spawn_position(side))
    await get_tree().create_timer(0.1).timeout
    call_deferred("enable_colliders")
func disable_colliders() -> void:
    get_tree().call_group("exit_colliders", "set_disabled", true)
func enable_colliders() -> void:
    get_tree().call_group("exit_colliders", "set_disabled", false)
func _on_top_body_entered(body : PhysicsBody2D) -> void:
    if not body is GamePlayer:
        return
    var from_side = Types.room_neighbors.top
    if has_neighbor(from_side):
        get_neighbor(from_side).add_player(Types.room_neighbors.bottom)
func _on_right_body_entered(body : PhysicsBody2D) -> void:
    if not body is GamePlayer:
        return
    var from_side = Types.room_neighbors.right
    if has_neighbor(from_side):
        get_neighbor(from_side).add_player(Types.room_neighbors.left)
func _on_bottom_body_entered(body : PhysicsBody2D) -> void:
    if not body is GamePlayer:
        return
    var from_side = Types.room_neighbors.bottom
    if has_neighbor(from_side):
        get_neighbor(from_side).add_player(Types.room_neighbors.top)
func _on_left_body_entered(body : PhysicsBody2D) -> void:
    if not body is GamePlayer:
        return
    var from_side = Types.room_neighbors.left
    if has_neighbor(from_side):
        get_neighbor(from_side).add_player(Types.room_neighbors.right)

The purpose of this function is to take the player from the current room they’re in to the next room. We can use the call_group method to run a method on every node in the exit_colliders group. It runs asynchronously, which means we need to wait for a short time for all the colliders to be disabled.

Godot 3 had a SceneTree.idle_frame signal, but I cannot find a suitable substitute for it in Godot 4. This code is the simplest way I could come up with to perform the scene transition.

The only downside is that waiting for a timer is generally not considered a good practice because it opens the code up to potential race conditions. I’m not as concerned about this because I know there are at most eight colliders that we need to disable. It's a quick process.

We could reduce the timeout to something far smaller, and this solution would still work.

If the player is already in a room, then we remove them from it and move it off-screen. We follow this up by adding them to the next room and position it in the center of the screen. We need to connect the body_entered signals of each Area2D to the listener methods we've defined here.

We also need to define those reposition and get_spawn_position methods, so let’s start with the latter:

This is from nodes/room.gd
@onready var _spawns := %Spawns as Node2D
func get_spawn_position(neighbor : Types.room_neighbors) -> Vector2:
    var spawn_name := Types.room_neighbors.keys()[neighbor].capitalize() as String
    var spawn_node := _spawns.get_node(spawn_name) as Marker2D
    return spawn_node.position

This method is a shortcut for finding the named SpawnsMarker2D node and returning its position. Here’s what the reposition method looks like:

This is from nodes/player.gd
func reposition(new_position : Vector2) -> void:
    position = new_position
    _agent.set_target_location(global_position)
    _agent.get_next_location()

We could expose the _agent variable so that other classes could set these properties manually. But the NavigationAgent2D’s target is linked to the GamePlayer’s position, so this helper makes sense.

Since we have the add_player method, we can use it when we add the player to the first room:

This is from nodes/screens/play_screen.gd
func _ready() -> void:
    Generation.make_rooms(_stage)
    Variables.player = Constants.player_scene.instantiate()
    Variables.rooms[0].add_player(Types.room_neighbors.top)

The player will now spawn at the same position as the Top Marker2D node. When they move transition to a room above their current one, they’ll be positioned at the Bottom Marker2D node.

Spawning Survivors

A game about rescuing survivors needs survivors to rescue. Let’s make a little survivor node and then spawn it into some of the rooms. It's like the player’s character:

A screenshot of the survivor page. The scene panel on the left has acquisition and collider options highlighted. The middle panel has the output animation for the survivor. The inspector panel on the right has the shape option selected.

Setting up the Survivor node

We need another Area2D node to act as the acquisition radius. When the player enters this radius, the survivor will attach themselves to the player. They'll continue to follow the player until they reach the sanctuary.

The Room node can be responsible for spawning these survivors. The best place to spawn them would be on top of a grass node, since these should always be navigable. Alternatively, you could create a new pixel color for the spots survivors should spawn.

We can also start the Survivor script off with most of the code in the Player script:

This is from nodes/survivor.gd
extends CharacterBody2D
class_name GameSurvivor
@onready var _agent := %Agent as NavigationAgent2D
var speed := 1000
func _ready() -> void:
    _agent.velocity_computed.connect(
        func(safe_velocity : Vector2) -> void:
            velocity = safe_velocity
            move_and_slide()
    )
    _agent.set_target_location(global_position)
func _physics_process(delta: float) -> void:
    if not _agent.is_navigation_finished():
        var target := _agent.get_next_location()
        velocity = global_position.direction_to(target) * speed
        _agent.set_velocity(velocity)
func reposition(new_position : Vector2) -> void:
    position = new_position
    _agent.set_target_location(global_position)
    _agent.get_next_location()

To allow the survivors to spawn on grass drawables, we need to make the layout available to other methods:

This is from nodes/room.gd
var layout : Array
func _ready() -> void:
    layout = Generation.get_room_layout()
    Generation.add_room_tiles(_tiles, layout)
    Generation.add_room_doodads(_doodads, layout)
    spawn_survivors()

The spawn_survivors method we follow this up with needs to randomly select a grass tile for the survivor spawn:

This is from nodes/room.gd
@onready var _survivors := %Survivors as Node2D
func spawn_survivors() -> void:
    var used_coordinates : Array[Vector2i] = []
    for i in randi_range(Constants.minimum_survivors_in_room, Constants.maximum_survivors_in_room):
        var survivor := Constants.survivor_scene.instantiate() as GameSurvivor
        _survivors.add_child(survivor)
        var coordinates = Vector2i(randi() % Constants.layout_width, randi() % Constants.layout_width)
        var drawable_type = layout[coordinates.y][coordinates.x]
        while drawable_type != Types.drawables.grass or used_coordinates.has(coordinates):
            coordinates = Vector2i(randi() % Constants.layout_width, randi() % Constants.layout_width)
            drawable_type = layout[coordinates.y][coordinates.x]
        used_coordinates.push_back(coordinates)
        survivor.reposition(coordinates * Constants.sprite_width)
This includes three new constants:
  • Constants.minimum_survivors_in_room

  • Constants.maximum_survivors_in_room

  • Constants.survivor_scene

I’ll leave it to you to set these up. After creating the GameSurvivor instance, we keep attempting to select an unoccupied position for them. Once found, we can spawn the survivor.

Rescuing Survivors

The last thing we’re going to do together is give the survivors more functionality:
  • They should be able to follow the player.

  • We should allow them to move between rooms.

  • The player should be able to drop them off at the sanctuary.

GameSurvivor already has an acquisition Area2D node, so we can tie into the signal emitted when a body enters it:

This is from nodes/survivor.gd
var following : GamePlayer
func _on_acquisition_body_entered(body : Node2D) -> void:
    if body is GamePlayer:
        body.survivors.push_back(self)
        following = body
func _on_follow_timer_timeout() -> void:
    if following:
        _agent.set_target_location(following.global_position)

I’ve attached a signal listener to the Survivor’s Acquisition node, in which I check if the body entering is a player. If so, I set the following variable to the player instance.

You’ll also notice I created a timer, called FollowTimer. The timeout signal is useful for updating the survivor’s NavigationAgent2D target location. The timer is set to Autostart and is not set as a One Shot. This means it will start automatically and keep timing out.

Don’t forget to add a survivors array to the player class, where we can store references to survivors following the player.

When we launch the game, we’re greeted by an unfortunate side effect of the current NavigationAgent2D system. Nested Area2D and CollisionShape2D nodes will be included in the avoidance detection logic.

This means that we cannot actually get inside the acquisition radius to acquire the survivor.

A screenshot of the invasion game has different graphical elements. 4 rectangular boxes highlight the edges of the screen with an outward arrow on the right edge. 2 player animations are also highlighted.

Nested CollisionShape2D nodes interfering with collision avoidance

I could have skipped over this part by going straight to the alternative. I chose, instead, to highlight this issue because it’s likely to cause you a lot of headaches if you don’t know it’s there.

One solution to this problem is not to use an Area2D node to detect acquisition. We can use the distance from the player’s position to the survivor’s position:

This is from nodes/survivor.gd
@onready var _follow_timer := %FollowTimer as Timer
var following : GamePlayer
func _on_follow_timer_timeout() -> void:
    if not following and Variables.player.global_position.distance_to(global_position) < 50:
        following = Variables.player
        Variables.player.survivors.push_back(self)
    if following:
        _agent.set_target_location(
            following.global_position
        )

We can delete the Acquisition Area2D node, since we don’t need it anymore.

I also spent some time fiddling with the Survivor → Agent Path Desired Distance and Target Desired Distance. I arrived at 5 pixels being a good setting for both of these. Since we’re not controlling the survivors, it’s ok if they aren’t as responsible or accurate as the player.

These increased values mean their movement will be smoother and their targeting more forgiving.

Additionally, I saw that the survivors were sometimes hidden behind the doodads. I moved the Doodads node above the Survivors node so that they display above the doodads.

We can make the survivors move to different rooms in the same method we use to allow the player to transition to different rooms:

This is from nodes/room.gd
func add_player(side : Types.room_neighbors) -> void:
    call_deferred("disable_colliders")
    await get_tree().create_timer(0.1).timeout
    var old_room = Variables.player.get_parent()
    if old_room:
        old_room.remove_child(Variables.player)
        old_room.position = Vector2(-999, -999)
        for survivor in Variables.player.survivors:
            survivor.get_parent().remove_child(survivor)
    position = Vector2(-66, -66)
    add_child(Variables.player)
    Variables.player.reposition(get_spawn_position(side))
    for survivor in Variables.player.survivors:
        _survivors.add_child(survivor)
        survivor.reposition(Variables.player.global_position)
    await get_tree().create_timer(0.1).timeout
    call_deferred("enable_colliders")

We need to remove the survivors from their parent before we can add them to the new room. Remember, they’re children of the Survivors node, so we can’t remove them from the room. If we added them to a group, then we can use the call_group method instead of needing to find their parents.

Once the player is in the new room, we can add the survivors to the new room. Since we can now take survivors back into our starting room, we can rescue them:

This is from nodes/room.gd
func _on_top_body_entered(body : PhysicsBody2D) -> void:
    if not body is GamePlayer:
        return
    var from_side = Types.room_neighbors.top
    if room_type == Types.rooms.first:
        rescue_survivors(from_side)
    if has_neighbor(from_side):
        get_neighbor(from_side).add_player(Types.room_neighbors.bottom)
func _on_right_body_entered(body : PhysicsBody2D) -> void:
    if not body is GamePlayer:
        return
    var from_side = Types.room_neighbors.right
    if room_type == Types.rooms.first:
        rescue_survivors(from_side)
    if has_neighbor(from_side):
        get_neighbor(from_side).add_player(Types.room_neighbors.left)
func _on_bottom_body_entered(body : PhysicsBody2D) -> void:
    if not body is GamePlayer:
        return
    var from_side = Types.room_neighbors.bottom
    if room_type == Types.rooms.first:
        rescue_survivors(from_side)
    if has_neighbor(from_side):
        get_neighbor(from_side).add_player(Types.room_neighbors.top)
func _on_left_body_entered(body : PhysicsBody2D) -> void:
    if not body is GamePlayer:
        return
    var from_side = Types.room_neighbors.left
    if room_type == Types.rooms.first:
        rescue_survivors(from_side)
    if has_neighbor(from_side):
        get_neighbor(from_side).add_player(Types.room_neighbors.right)
func rescue_survivors(side : Types.room_neighbors) -> void:
    if side != sanctuary_side:
        return
    for survivor in Variables.player.survivors:
        Variables.player.survivors.erase(survivor)
        survivor.queue_free()

This looks like a lot of code, but that’s only because there is some repetition for the different Area2D signals. The gist of it is that we have some special functionality if the current room is the first room.

If the player is in the first room and they are on the sanctuary side, then all the survivors following them are rescued.

We could collapse this code even further by using a common signal method, but that’s a bit more complicated than I want to make this code.

Taking Things Further

This is where we stop working on this project together, but there’s still loads more you can do to it. Here are some features that will bring it closer to my original build of Invasion:
  1. 1.

    Have a visual indicator that appears when you are close to a survivor, for how close you need to be to get them. In my version, I used a yellow circle, so the player can see how close they must get to the survivor.

     
  2. 2.

    Show how many survivors there are to rescue, somewhere on the screen, and track how many have already been rescued.

     
  3. 3.

    Spawn soldiers, and have them patrol between random road drawables. They can have a similar indicator for how close you can get to them before they start to chase you.

     
  4. 4.

    Add a dialog system to display conversations between the player, soldiers, and survivors.

     
  5. 5.

    Have a hope counter, which continually drains but can be increased when you rescue a survivor.

     
  6. 6.

    If soldiers catch you while you have no following survivors, decrease the player’s hope.

     
  7. 7.

    If soldiers catch you while you have following survivors, make them detain the survivors. You can decide what the hope penalty is for these, so it might be more favorable to be caught alone or with company.

     
  8. 8.

    Add an exit transition, and populate the summary screen with data about the current state of hope.

     
  9. 9.

    Add a game-over transition for when hope has completely run out.

     

Summary

This has been an ambitious chapter, wherein we built the majority of the functionality for Invasion. It’s not exactly what I released, but it includes everything fundamental. It gives you a good jumping-off point for customization and novel mechanics of your own.

I am so proud of what we’ve covered in this chapter and the book as a whole. This is our third game, and it showcases the majority of what I’d consider practical procedural content generation. I encourage you to take your time with this chapter and project. If you feel like there are topics that you don’t have a handle on, spend some time going over the code and researching specifics in the Godot community.

If you can produce a game like Invasion, you’re ready to use these skills in your own games.

In the following chapter, we’re going to look at generating and adhering to stricter paths of movement. This will be useful for games where you want richer NPC movement or less free player movement.

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset