Logo

Braindead.bzh

Game Dev Hobbyist

A blog about my game dev hobby and various stuff.

Menu
Logo

Braindead.bzh

Game Dev Hobbyist

A blog about my game dev hobby and various stuff.

Creating a game with Godot Engine - Ep.8 - Levels

In this eighth article we will talk about levels. We will see how to use Godot to create them and also how to load them. (source code)

Disclaimer: the following article represents one way to do it and, it may not be the best way. Please adapt this technique using your best judgement.

Introduction

Once you have created all the different elements of your game, the last step is to combine them. This process is called level design. Thanks to Godot, no need to create a custom level editor, at least for basic game. Using its unique architecture, a level is simply a scene where you instance other scenes.

Creating a level

The first step is to add all the elements into your scene by using the instance scene button right next to the add node button. This include the floor, the walls, the player, the emitters, the receivers and the boxes. As you might noticed, the instanced scenes have the name of their root node. I would strongly recommend changing this name, as Godot allows, to a proper name.

For the floor and walls, you have to activate the option "Editable children" by clicking the scene icon next to the corresponding node. This will allows you to access the underlying tilemap to create the level layout. Using the Godot's integrated tilemap editor, there is nothing easier to draw the floor and the walls. Make sure to not create a gap in the walls so balls and the player cannot go beyond the boundaries of your level.

For the other elements, you can simply move them around using the Godot editor. If you want to be precise, use a grid to snap elements into place (see  the "Edit" menu of the 2D editor). Placing the boxes requires a little bit more work. What you want to do is to place them, and also rotate them, where the player has to put them to finish the level. Then, just move them around to create the puzzle.

To give the player a visual hint on where a ball should go, we use color. What you have to do, is to apply a color to the emitter. To do so, you have to make the emitters "Editable children", then change the "Modulate" property of the Sprite node to the desired color. Make sure to use distinguishable colors between different emitters.

The last touch is to set an ambient light to the level. By using a CanvasModulate node, you are able to apply a color to all the pixels. In our case, I used a dark grey to make the level barely visible so the player has to rely on his flashlight to see around.

# abstract_level.gd
extends Node

signal failed()
signal victory()

var player

func _ready():
    player = find_node("player")
    if(player == null):
        printerr("Warning: you must have a player in your level")
        return
    player.connect("killed", self, "_on_player_killed")
    
func _on_player_killed():
    emit_signal("failed")
extends "../abstract_level.gd"

var emitter1
var receiver1

var emitter2
var receiver2

func _ready():
    emitter1 = find_node("emitter1")
    receiver1 = find_node("receiver1")
    emitter2 = find_node("emitter2")
    receiver2 = find_node("receiver2")
    
    receiver1.set_emitter(emitter1)
    receiver2.set_emitter(emitter2)

func _on_emitter1_stopped():
    if emitter2.is_stopped:
        emit_signal("victory")

func _on_emitter2_stopped():
    if emitter1.is_stopped:
        emit_signal("victory")

To make the level work a little bit of script is necessary. The first thing is to link the emitters with their corresponding receiver, which is done by calling  the set_emitter function on the receiver. The second thing is to look for the victory condition. On reason you might want to code this for each level is that it gives you the flexibility to create different gameplay for each level where you might want specific winning conditions. Using the signal editor, you have to create a signal handler for the "stopped" signal of each emitter. In our example, we want victory when all the emitters are stopped, to stop an emitter the player has to put the corresponding ball in. When an emitter is stopped, we look if the other emitters are stopped, if so we just emit the "victory" signal which trigger the the game for victory.

On the other hand the failed condition is common to all the levels and therefore not customizable. It is manage by the abstract level class where we connect the character's death to the failed condition. Using the same signal technique, we can trigger the game for failure. Note in this case how we check for the presence of a player's character in the level.

Loading a level

extends "../abstract_screen.gd"

var content
var retry_dlg
var victory_dlg
var level_select
var current_level

var levels = {
        "level1": preload("res://levels/level1/level1.tscn"),
        "level2": preload("res://levels/level2/level2.tscn"),
        "level3": preload("res://levels/level3/level3.tscn"),
    }

func _ready():
    content = find_node("content")
    retry_dlg = find_node("retry_dlg")
    victory_dlg = find_node("victory_dlg")
    level_select = find_node("level_select")
    level_select.popup()
    
func _load_level(name):
    if name in levels:
        var old_level = null
        if content.get_child_count() > 0:
            old_level = content.get_child(0)
        if old_level != null:
            content.remove_child(old_level)

        var level = levels[name].instance()
        level.connect("failed", self, "_on_level_failed")
        level.connect("victory", self, "_on_level_victory")
        content.add_child(level)
        
        current_level = name
    else:
        print("[ERROR] Cannot load screen: ", name)

func _retry():
    print("RETRY")
    _load_level(current_level)
    get_tree().set_pause(false)

func _on_level_failed():
    var timer = Timer.new()
    add_child(timer)
    timer.connect("timeout", self, "_show_retry_dlg")
    timer.set_wait_time(1.0)
    timer.set_one_shot(true)
    timer.start()
    
func _on_level_victory():
    get_tree().set_pause(true)
    victory_dlg.popup()
    
func _show_retry_dlg():
    get_tree().set_pause(true)
    retry_dlg.popup()

func _on_exit_pressed():
    get_tree().set_pause(false)
    emit_signal("next_screen", "main_menu")

func _on_retry_pressed():
    retry_dlg.hide()

func _on_retry_dlg_popup_hide():
    _retry()

func _on_victory_retry_pressed():
    victory_dlg.hide()

func _on_victory_dlg_popup_hide():
    _retry()

func _on_change_level_pressed():
    victory_dlg.hide()
    level_select.popup()

func _on_level1_btn_pressed():
    _load_level("level1")
    level_select.hide()

func _on_level2_btn_pressed():
    _load_level("level2")
    level_select.hide()

func _on_level3_btn_pressed():
    _load_level("level3")
    level_select.hide()

As a level is a scene, loading it is straightforward and only use basic Godot functions. To better understand the logic of how to manage multiple levels, we have to take a look at the game screen scene and script. The structure of screen scene is simple, you have a Node2D called "content" where the level will be loaded and three dialog boxes inside the UI layer, for failure, for victory and for level selection.

When arriving on the screen the player is immediately invited to select a level by calling the popup function on level_select dialog. The actual level loading code is in the _load_level function. This function remove any previously loaded level and instanciate the new level. It also takes care of connecting the "failed" and "victory" signal to properly manage them.

A failure or a success display the corresponding dialog. In case of failure, we set a timer for one second before showing the dialog to allow the death animation to be played. As you might have noticed, the script make use of the pause function of Godot. The reason for that is that we want the game to be blocked while a dialog box is on display. To avoid the dialog box to be blocked by the pause, you have to set the "Pause Mode" property of each dialog box to "Process". This means that even when the game is paused this node is able to process events. As nodes are by default on an "Inherit" "Pause Mode", all the children of the dialog boxes will also continue to process the events.

Conclusion

This was the last article in this series, but probably not my last article on Godot. I'm waiting for Godot 3.0 to see what could be future topics. Of course I also left a lot of functionality aside, not that I did not wanted to talk about it, but because I still have a lot to discover within Godot. As I continue to develop new game idea, I might come up with extra articles.

Written by Olivier on Thursday February 23, 2017

« Godot Quick Tip #1 - Interactive Camera2D Godot Quick Tip #2 - Getting the mouse cursor position in your 2D fragment shader »

comments powered by Disqus