© 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_4

4. Recreating Sokoban

Christopher Pitt1  
(1)
Durbanville, South Africa
 

We’re well on our way to learning how to generate great procedural content for our games. It’s a good time to cement what we’ve learned so far by using those skills to make a game.

In the previous two chapters, we learned about how to make and use nodes and tile sets. We’re going to use both of those to recreate a classic game, called Sokoban.

Sokoban is actually a whole genre of games, but I want us to focus on a simple implementation of the version published around 1980.

An image shows a page of the puzzle game, which has several tiles, crates, and dots, along with a sprite character.

Pushing crates onto dots

The game is about pushing crates on to dots. It’s a puzzle game, where the order of moves is important.

We’re not going to make a game that is ready for release, though you’re welcome to do that if you like. Instead, I want us to achieve the following few goals:
  • Creating a level selection menu

  • Designing two or three levels, representing the layout of each level in an array

  • Using these arrays to draw tiles and nodes

  • Implementing player movement

  • Allowing the player to move boxes

  • Detecting when the boxes are over the dots to signal a win state

Let’s get started.

Creating Levels

We can follow the same process we did when setting up the experiment project. After creating the new project, we can create the following nodes and scripts:
  1. 1.

    A template Screen node, with the corresponding GameScreen class script

     
  2. 2.

    A LevelSelectionScreen node, with a script that extends GameScreen

     
  3. 3.

    A PlayScreen node, with another script that extends GameScreen

     

Your folders and files should resemble this:

A screenshot shows the file system window that lists the options under favorites with folders including resources, images, nodes, and screens.

Starting with three screens

Next, let’s think about how we want to design each level. It would be good for us to have a template resource that defines a few types and properties common to each level. We do this by creating a new folder and adding a new script to it:

A screenshot shows the file system window that lists options under filter files, which has selected option for levels under resources, with the highlighted icon for new script.

Creating new scripts

This script should inherit from the Resource class, and we can save it as level.gd:

A screenshot shows the create script window, which has the language, class name, template, built-in script, and highlighted inherits and path, along with create and cancel buttons.

Inheriting from Resource

This class should have properties that describe each level. We can use enums (which are lists of possible values) to define the kinds of objects we can draw:

This is from resources/levels/level.gd
extends Resource
class_name GameLevel
enum types {
    wall_top_left,
    wall_top,
    wall_top_right,
    wall_right,
    wall_bottom_right,
    wall_bottom,
    wall_bottom_left,
    wall_left,
    empty,
    player,
    crate,
    dot,
    door,
}
@export var name := "New level"
@export var width := 7
@export var layout : Array[types] = [
    types.wall_top_left, types.wall_top, types.wall_top, types.wall_top, types.wall_top, types.wall_top, types.wall_top_right,
    types.wall_left, types.empty, types.empty, types.empty, types.empty, types.empty, types.wall_right,
    types.wall_left, types.empty, types.empty, types.empty, types.empty, types.empty, types.wall_right,
    types.wall_left, types.empty, types.empty, types.empty, types.empty, types.empty, types.wall_right,
    types.wall_left, types.empty, types.empty, types.empty, types.empty, types.empty, types.wall_right,
    types.wall_left, types.empty, types.empty, types.empty, types.empty, types.empty, types.wall_right,
    types.wall_bottom_left, types.wall_bottom, types.wall_bottom, types.wall_bottom, types.wall_bottom, types.wall_bottom, types.wall_bottom_right,
    ]

Each level needs a name so that exported property makes sense to add. The couple that follow it need some explanation, though. I want us to take a step back and think about how levels can be build using algorithms.

The algorithm we need is one that reads level layouts from an array and draws on a tile map or with nodes. That’s why each item in the layout array is a type of block, which can represent a wall or a crate or even the player. width tells our drawing code how many blocks are in each row of the layout.

This layout array depicts an empty room surrounded by walls. It’s an example to show what kind of data we expect level designers to come up with. Custom resources like this are useful to let us define a custom data type that we can reference in other nodes.

Instead of linking this script to a scene, we need to create instances of this custom resource with the data values customized. We can right-click the resources/levels folder and select the New Resource option:

A screenshot shows the file system window that lists options under filter files, which has selected option for levels under resources, with the highlighted icon for new resource.

Creating a new resource in the file explorer

And we can find the custom resource in the list of possible resources to create by searching for its name:

A screenshot shows the create new resource window, that lists the options under resource with the option for game level highlighted, along with the create and cancel buttons at the bottom.

Selecting our custom resource

This instance of our custom resource has properties we can set in the property inspector. We can decide how many blocks wide each level will be and create an array of block types that is a multiple of that Width. I’ve chosen to define a layout that is the same size as the example – seven blocks wide and seven blocks high:

A screenshot shows the inspector window that lists the options under filter properties, which has the selected layout of level 1 with several arrays.

Defining the layout of a level in blocks

It might be tricky to think of the layout in this way. I’d suggest, if you’re having trouble, that you use a bit of grid paper to design the level before creating this resource.

A 7 by 7 grid layout includes numbers ranging from 0 through 48 in row-wise order.

Array indices for your 7 × 7 grid layouts

Take some time to design two or three of these levels, and create their corresponding resource files. We’ll need them in the next section.

Selecting a Level

By now, we’ve created some levels and the placeholder for a level selection screen. Let’s connect the two so that we can launch our levels from the level selection screen.

First, we’ll need to export a list of levels from our level selection screen and draw buttons on the screen for each level. We can set up some nodes to make the layout of this easier:

A screenshot shows the scene dialog box that lists the options under filter nodes, with the V box container option highlighted.

Layout nodes for easier button placement

We need to write a script that will load each configured level resource as a button that we can use to start that level:

This is from nodes/screens/level_selection_screen.gd
extends GameScreen
@export var levels : Array[Resource]
@onready var _vbox := $CenterContainer/VBoxContainer
func _ready() -> void:
    for level in levels:
        var new_button = Button.new()
        new_button.text = level.name
        new_button.connect("pressed", func():
            print("load level: " + level.name)
        )
        _vbox.add_child(new_button)

We can export an array of the levels we’ve designed, so we can link them through the property inspector. When the level selection screen loads, we loop through each of the linked levels. We create a new button for each, adding it to the VBoxContainer we set up.

We can also define a lambda to execute when the player presses a button. Switch back to the 2D tab, go to the property inspector, and link the levels you’ve designed:

A screenshot shows the inspector dialog box that lists the options under filter properties, which has a level selection screen, with highlighted level 1.

Linking our levels

Now is a good time to launch the game to see if everything is working as expected. Select the level selection screen as the default screen to load on startup, and click on the buttons!

A screenshot shows the Sokoban window, which has a dark background with the text, level 1.

Clicking all the buttons

If you don’t see any buttons, or they don’t print text to the console when you click on them, then something’s wrong. Go back and look for syntax errors.

Switching Screens

Changing screens can be a bit of a mission if you’ve never done it before. It’s a balance between flexibility and simplicity. We want the mechanism we create and use to be extensible, so we can add more screens without many changes to code. We also want the functions we call to be simple to use.

A good way to achieve this is to store a lookup table of screens in a global Constants class. Let's make a new folder and a new scene from a Node node:

A screenshot shows the file system window that lists the options under the globals folder, with the option for new scene highlighted.

Creating a new Node scene

A screenshot shows the create new scene window, which has a root type, scene name, and root name. The scene name is highlighted and reads constants.

Selecting the main node of the new scene

This is from nodes/globals/constants.gd
extends Node
class_name Types
enum screens {
    none,
    level_selection,
    play,
}
@export var level_selection_scene : PackedScene
@export var play_scene : PackedScene
@onready var screen_scenes := {
    screens.level_selection: level_selection_scene,
    screens.play: play_scene,
}

We can attach this script to the main node of a new scene file, which we’ll autoload in a minute. This main node is where we can link the two scenes we’ve exported using the property inspector.

A screenshot shows the inspector dialog box that lists the options under constants with the highlighted filter properties and script, along with the process and editor description.

Linking to our different screen scenes

Linking them as exported properties makes it easy to replace the scenes or rename the files, without breaking hard-coded paths in code. We can use these constants in another global scene. This new scene remembers the current screen and swaps it out with new screens. Create another Node scene, called Screens, and attach another script to it:

This is from nodes/globals/screens.gd
extends Node
var root = null
var current_screen : Types.screens
var current_screen_node : GameScreen
var is_changing_screen := false
func _ready() -> void:
    root = get_tree().get_root()
    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()
    load_new_screen(new_screen_node, new_screen)
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
    is_changing_screen = false

We’ll call the change_screen method shortly. Before we do, we need one last global scene. It needs to store the current level we intend to play so that the play screen can draw the appropriate play space. Create yet another Node scene and attached script, called Variables:

This is from nodes/globals/variables.gd
extends Node
var current_level : GameLevel

We need to autoload these three scenes by going to ProjectProject SettingsAutoload:

A screenshot shows the project settings that list the options under autoload with the name, path, and global variable, along with its highlighted data and add button.

Loading our globals

To be super clear about the structure of these files, here is what my file explorer looks like:

A screenshot shows the nodes and globals folder with the settings and videos of constants, screens, and variables.

Globals in the file explorer

Instead of printing to the debug console; we can now store the desired level, and change to the play screen:

This is from nodes/screens/level_selection_screen.gd
new_button.connect("pressed", func():
    Variables.current_level = level
    Screens.change_screen(Types.screens.play)
)

Globals and Other Mischief

Before we move on, I want to talk a bit about globals in our code. Some people will tell you to avoid autoloaded scenes because they can cause problems. I don’t think this practice is as bad as they say.

The approach we’ve used is perfect for our needs, and we should continue to use it. This book is about procedural content generation, and not about the perils of using globals, after all.

The only other thing I want to mention is that we can’t refer to types defined in a global to type-hint variables in another script. That’s why I gave the Constants script a class name of Types.

The following would have resulted in an error:
func change_screen(new_screen: Constants.screens) -> void:

…because Godot cannot verify the type of Constants.screens at compile time. We can autoload a scene with one global name and reference its types using a different class name.

It’s a trick I’ve only found useful in this situation. It might be cleaner to separate the types out from the constants so that we autoload one and not the other, but I’m not going to do that.

Drawing Levels

Our play screen can use the current level data to draw the level’s tiles and nodes. I think it would be fun to use another kenney.​nl asset pack for this project. You can find this one at https://kenney.nl/assets/sokoban:

An image shows a screen with several patterns of tiles, Sokoban sprites, and diamond-shaped structures.

Kenney's take on Sokoban sprites

Download and extract the asset pack, and copy Tilesheet/sokoban_tilesheet.png into the project's images folder. Next, create a TileMap and a Node2D. You can even create the TileSet resource using your knowledge from the previous chapter:

A screenshot shows the window for Godot engine that lists the options under the play screen, which has the highlighted scene, and the Sokoban tile sheet with several tiles in a gradient of colors.

Setting the stage for drawing our levels

I typically change the default node names so they are more descriptive or less verbose. In this case, I’ve chosen the following names:
  • CenterContainerCenter

  • ControlStage

  • TileMapTiles

  • Node2DNodes

If your tiles are fuzzy after importing, set CanvasItemFilter to Nearest. You'll also need to adjust the tile size to 64 × 64 pixels, where I've highlighted.

By now, we’ve covered the basics of creating nodes and tile maps. I don’t want to repeat too much of this. If you’re having trouble, check out the example project code and refer to the previous chapters.

We need to identify the atlas coordinates of each of the block types we care about and store them in Constants. While we’re at it, we should move the levels' blocks enum too:

This is from nodes/globals/constants.gd
enum blocks {
    wall_top_left,
    wall_top,
    wall_top_right,
    wall_right,
    wall_bottom_right,
    wall_bottom,
    wall_bottom_left,
    wall_left,
    empty,
    player,
    crate,
    dot,
    door,
}
const _wall_coordinates := Vector2i(8, 7)
const _door_coordinates := Vector2i(10, 0)
const tile_coordinates := {
    blocks.wall_top_left: _wall_coordinates,
    blocks.wall_top: _wall_coordinates,
    blocks.wall_top_right: _wall_coordinates,
    blocks.wall_right: _wall_coordinates,
    blocks.wall_bottom_right: _wall_coordinates,
    blocks.wall_bottom: _wall_coordinates,
    blocks.wall_bottom_left: _wall_coordinates,
    blocks.wall_left: _wall_coordinates,
    blocks.door: _door_coordinates,
}

This means our custom GameLevel resource must also change:

This is from resources/levels/level.gd
extends Resource
class_name GameLevel
@export var name := "New level"
@export var width := 7
@export var layout : Array[Types.blocks] = [
    Types.blocks.wall_top_left, Types.blocks.wall_top, Types.blocks.wall_top, Types.blocks.wall_top, Types.blocks.wall_top, Types.blocks.wall_top, Types.blocks.wall_top_right,
    Types.blocks.wall_left, Types.blocks.empty, Types.blocks.empty, Types.blocks.empty, Types.blocks.empty, Types.blocks.empty, Types.blocks.wall_right,
    Types.blocks.wall_left, Types.blocks.empty, Types.blocks.empty, Types.blocks.empty, Types.blocks.empty, Types.blocks.empty, Types.blocks.wall_right,
    Types.blocks.wall_left, Types.blocks.empty, Types.blocks.empty, Types.blocks.empty, Types.blocks.empty, Types.blocks.empty, Types.blocks.wall_right,
    Types.blocks.wall_left, Types.blocks.empty, Types.blocks.empty, Types.blocks.empty, Types.blocks.empty, Types.blocks.empty, Types.blocks.wall_right,
    Types.blocks.wall_left, Types.blocks.empty, Types.blocks.empty, Types.blocks.empty, Types.blocks.empty, Types.blocks.empty, Types.blocks.wall_right,
    Types.blocks.wall_bottom_left, Types.blocks.wall_bottom, Types.blocks.wall_bottom, Types.blocks.wall_bottom, Types.blocks.wall_bottom, Types.blocks.wall_bottom, Types.blocks.wall_bottom_right,
]

You could delete the example array if you’re not keen on maintaining this long list of block types. I like to keep these examples around so that it’s easier to figure out how to code for the resource.

You might be wondering why I have so many block types when I'm using the same sprite for all of them. It would be simpler to have a single wall type, but that would be less flexible. We could have used any number of tile sets that had different sides and corners of walls; and this enum would account for them all. You're free to simplify the code if the tile set you use doesn't have this level of detail.

We can use these new constants and atlas positions to set the blocks of our tile map on the play screen:

This is from nodes/screens/play_screen.gd
extends GameScreen
@onready var _tiles := $Center/Stage/Tiles as TileMap
func _ready() -> void:
    var i := 0
    var level : GameLevel = Variables.current_level
    var half = floor(level.width / 2)
    var remainder = level.width % 2
    for y in range(-half - remainder, half):
        for x in range(-half - remainder, half):
            if Types.tile_coordinates.has(level.layout[i]):
                _tiles.set_cell(0, Vector2i(x, y), 0, Types.tile_coordinates[level.layout[i]])
            i += 1

This is a strange loop we’re using. The tile map’s 0,0 coordinate is in the center of the screen; so we want to set blocks on either side of it. If we started at 0,0, then the top-left corner of each level would be in the center of the screen:

A tile map plots y versus x. It has numbers from 0 to 48 that are chronologically arranged row-wise, with various coordinates.

Drawing blocks to the left and top of 0,0

Drawing Nodes

Next, we need to create the various level nodes and draw them on the play screen:
  1. 1.

    The player → CharacterBody2D

     
  2. 2.

    The crates → CharacterBody2D

     
  3. 3.

    The dots → Area2D

     
  4. 4.

    The doors → Area2D

     

Create each of these, with their own attached scripts, until your files look like this:

A screenshot shows the file system window that lists the options under filter files with the folders of resources, images, nodes, globals, and screens.

Level nodes

The sprite sheet we’ve been using to draw walls has visual indicators for all these. I’ve given the scenes a CollisionShape2D and (except for the door node) a Sprite2D chosen from the sprite sheet. Taking sprites from a sprite sheet can be tricky if you've never done it before. Here are the steps I usually follow:
  1. 1.

    Create the Sprite2D node

     
  2. 2.

    Set TextureAtlasTexture

     
  3. 3.

    Click Edit Region

     
  4. 4.

    Change Snap Mode to Grid Snap

     
  5. 5.

    Change the Step values to the size of the sprite (64 × 64 pixels in this case)

     
  6. 6.

    Select the sprite

     

A screenshot shows the Godot window that lists the options under Sprite 2 D, which has a region editor window with a texture of tiles and sprite characters, in a gradient of colors.

Selecting sprites from an AtlasTexture

The top-left corner of the sprites and collision shapes should be at 32 × 32 pixels so that their top-left corners align with the top-left corner of each floor square. I’ve given the scenes custom class names, so we can type-hint against them later on. For now, we need to add them to Constants:

This is from nodes/globals/constants.gd
@export var crate_scene : PackedScene
@export var door_scene : PackedScene
@export var dot_scene : PackedScene
@export var player_scene : PackedScene
@onready var node_scenes := {
    blocks.crate: crate_scene,
    blocks.door: door_scene,
    blocks.dot: dot_scene,
    blocks.player: player_scene,
}

Link these scenes in the property inspector so that we can use them in the play screen code:

This is from nodes/screens/play_screen.gd
extends GameScreen
@onready var _tiles := $Center/Stage/Tiles as TileMap
@onready var _nodes := $Center/Stage/Nodes as Node2D
func _ready() -> void:
    var i := 0
    var level : GameLevel = Variables.current_level
    var half = floor(level.width / 2)
    var remainder = level.width % 2
    for y in range(-half - remainder, half):
        for x in range(-half - remainder, half):
            if Types.tile_coordinates.has(level.layout[i]):
                _tiles.set_cell(0, Vector2i(x, y), 0, Types.tile_coordinates[level.layout[i]])
            if Constants.node_scenes.has(level.layout[i]):
                var new_node = Constants.node_scenes[level.layout[i]].instantiate()
                _nodes.add_child(new_node)
                new_node.position = Vector2(x * 64, y * 64)
            i += 1

This new code is similar to the tile map code in terms of how we get the correct data for the type of block. The differences are to do with us creating nodes instead of drawing on a tile map.

We already have x = -42 and y = -42; so we can multiply that by the block size to get the top-left position for each node. We could put this pixel size in Constants, but we’re already hard-coding the 64 × 64 size by what we’ve selected as the tile map block size. We’d need to dynamically construct the tile map’s source if we wanted it to be completely dynamic.

I don't think it's worth the hassle.

If you find yourself repeating a lot of magic numbers, consider making them constants.

Your levels should now contain any node blocks you’ve specified in their layout:

A screenshot shows the Sokoban window, which has square-shaped tiles with a sprite character, a cube, and a diamond-shaped structure.

Tiles and nodes in one of my levels

Moving the Player

It’s time to add some interactivity! We’re going to add some code to listen for player input and move the player… assuming they aren’t trying to walk into a wall:

This is from nodes/player.gd
extends CharacterBody2D
class_name GamePlayer
@onready var level : GameLevel = Variables.current_level
var walls_blocks := [
    Types.blocks.wall_top_left,
    Types.blocks.wall_top,
    Types.blocks.wall_top_right,
    Types.blocks.wall_right,
    Types.blocks.wall_bottom_right,
    Types.blocks.wall_bottom,
    Types.blocks.wall_bottom_left,
    Types.blocks.wall_left,
]
func _unhandled_key_input(event: InputEvent) -> void:
    if event.is_pressed():
        var offset = Vector2(0, 0)
        if event.is_action_pressed("ui_right"):
            offset.x = 64
        if event.is_action_pressed("ui_down"):
            offset.y = 64
        if event.is_action_pressed("ui_left"):
            offset.x = -64
        if event.is_action_pressed("ui_up"):
            offset.y = -64
        var target_block = block_at_position(position + offset)
        if not walls_blocks.has(target_block):
            position = position + offset
func block_at_position(position : Vector2) -> int:
    var i := 0
    var half = floor(level.width / 2)
    var remainder = level.width % 2
    for y in range(-half - remainder, half):
        for x in range(-half - remainder, half):
            if position.x == x * 64 and position.y == y * 64:
                return level.layout[i]
            i += 1
    return -1

A great way to listen for keyboard input is to add an _unhandled_key_input method to our player class. In our recreation of Sokoban, a single press of the left key means the player tries to move 64 pixels to the left.

Thus, we change the offset (which is a change to the player’s position) depending on which of the ui_* keys the player presses. We figure out if there’s a wall in the new position using a form of the same strange loops we used before in drawing.

If there’s no wall, we add the offset position to the player’s current position.

Now is a good time to relaunch the game to see if pressing the arrow keys on your keyboard will move the player. Even if you can move, there are some strange things you’ll notice.

You can walk through a crate, dot, and door without restriction. You’ll even walk underneath the crate and dot if the player appears above them in the node tree.

Avoiding Closed Doors

The player should not be able to leave through the door until all crates are on dots. Let’s add a check for this. First, we need to remember how many crates there are in a level’s layout and how many are covered by crates:

This is from nodes/globals/variables.gd
var total_crates : int
var covered_crates : int

We can reset and increment these when we draw a level layout on the play screen:

This is from nodes/screens/play_screen.gd
Variables.total_crates = 0
Variables.covered_crates = 0
for y in range(-half - remainder, half):
    for x in range(-half - remainder, half):
        if Types.tile_coordinates.has(level.layout[i]):
            # ...snip
        if Constants.node_scenes.has(level.layout[i]):
            # ...snip
        if level.layout[i] == Types.blocks.crate:
            Variables.total_crates += 1
        i += 1

Using these (and a bit of refactoring), we can prevent a player from leaving through a closed door:

This is from nodes/player.gd
var is_wall : bool = walls_blocks.has(target_block)
var is_closed_door : bool = target_block == Types.blocks.door and
Variables.covered_crates < Variables.total_crates
if not is_wall and not is_closed_door:
    position = position + offset

Moving Crates

The last bit of movement we need to add is the ability to move crates. This is an extension of wall checking on the player. If the player is next to a crate and moves in the direction of the crate and there is space to move the crate….

This is from nodes/player.gd
func _unhandled_key_input(event: InputEvent) -> void:
    if event.is_pressed():
        var offset = Vector2(0, 0)
        if event.is_action_pressed("ui_right"):
            offset.x = 64
        if event.is_action_pressed("ui_down"):
            offset.y = 64
        if event.is_action_pressed("ui_left"):
            offset.x = -64
        if event.is_action_pressed("ui_up"):
            offset.y = -64
        var target_block := block_at_position(position + offset)
        var further_target_block := block_at_position(position + offset + offset)
        var is_wall : bool = walls_blocks.has(target_block)
        var is_closed_door : bool = target_block == Types.blocks.door and 
Variables.covered_crates < Variables.total_crates
        var crate := crate_at_position(position + offset)
        var is_crate_blocked_by_wall : bool = walls_blocks.has(further_target_block)
        if crate and not is_crate_blocked_by_wall:
            crate.position = crate.position + offset
            position = position + offset
        elif not crate and not is_wall and not is_closed_door:
            position = position + offset
func crate_at_position(position: Vector2) -> GameCrate:
    for child in get_parent().get_children():
        if child is GameCrate and child.position.x == position.x and child.position.y == position.y:
            return child
    return null

We start by checking the space we want the player to move into and the space beyond it. That’s because the player might be trying to move into a space occupied by a crate; and we’d want to know if the crate can move.

The player movement code now needs to account for
  1. 1.

    If the player is trying to push a crate

     
  2. 2.

    …and there’s enough space for the crate to be pushed by the player

     
or
  1. 1.

    If the player is not trying to push a crate

     
  2. 2.

    …or trying to walk into a wall

     
  3. 3.

    …or trying to walk through a closed door

     

Relaunch the game and try this movement code out. It’s pretty cool. Unfortunately, we still have the issue of the player and crate disappearing behind dots if the player and crate are above the dots in the node tree.

The simplest way to sort this out is to go to the Node2D properties (of the crate and player) and make the Z Index values greater than those of the dot:

A screenshot shows the node 2 D dialog box that lists the options under the Z Index, with the highlighted number 1, and the on button selected.

Higher Z Index value means closer to the screen.

Winning a Level

All that remains is to show that the player has covered all the dots with crates by allowing the player to leave.

First, we need to connect to signals on the dots:

A screenshot shows the connect a signal to a method window, that lists the options under connect to script with the highlighted dot option.

Connecting to the body events on dots

The best events for this are on_body_entered and on_body_existed. These are emitted when a CharacterBody2D or StaticBody2D collides with this Area2D. This is what that code looks like:

This is from nodes/dot.gd
extends Area2D
class_name GameDot
func _on_dot_body_entered(body) -> void:
    if body is GameCrate:
        Variables.covered_crates += 1
func _on_dot_body_exited(body) -> void:
    if body is GameCrate:
        Variables.covered_crates -= 1

There are two bodies that could collide with each dot – a player or a crate. So we need to make sure that we only add to the covered crate count when the body that is colliding is a crate.

Before these collisions will work, we need to set up collision layers and assign the dots and crates to them:

A screenshot shows the project settings that list the options under general, with the layer names, which has the selected option of 2 D physics with the highlighted layer 1, and 2.

Defining new collision layers

We assign these layers through the property inspector:

A screenshot shows the inspector window that lists the options under the dot, which has the highlighted collision layers with the selected dot option.

Assigning nodes to collision layers

Set the following things:
  1. 1.

    In Crate, set Layer to crates.

     
  2. 2.

    In Crate, set Mask to dots.

     
  3. 3.

    In Dot, set Layer to dots.

     
  4. 4.

    In Dot, set Mask to crates.

     

Layers and masks can be tricky to understand. Layer is “what layers this collider is in,” and Mask is “what layers this collider will collide with.” After the preceding changes, crates will collide with dots and vice versa. We only need one side of that for our code to work, but I like to set both sides up unless there's a good reason not to.

To test this, relaunch the game and move the crate over the top of the dot. You should now be able to walk through the door. We can take this a step further by connecting to the on_body_entered signal:

A screenshot shows the connect a signal to a method window that lists the options under the body entered with the selected door option, along with the highlighted receiver method, and node 2 D.

Listening for door events

Now, we can respond to the player moving through the open doorway:

This is from nodes/door.gd
extends Area2D
class_name GameDoor
func _on_door_body_entered(body) -> void:
    if body is GamePlayer:
        body.queue_free()
        print("You win!")

If the player leaves through the open door, we remove their avatar from the level and print a console message. It’s not the most flashy ending; but it’s a starting point for anything more elaborate you’d like to do with it.

One thing I noticed was that using collision shapes that are exactly 64 × 64 pixels would cause the player to collide with the door even when they are in the block next to the door. This meant the player would exit the stage even when the player's code prevented them from entering the door's space. I adjusted all my collision shapes to be 60 × 60 pixels big and to start at 30 × 30 pixels; so there's always four pixels of space between the collision shapes.

Summary

This has been a whirlwind of a chapter. We’ve used all the skills gained so far to build out a real game. It might need a bit of polish, but you’re welcome to do that so that you can release it as a game of your own.

Take a bit of time to review the mechanics you built for this game. Swap the sprites and tiles out for your own art, or a different art pack from kenny.nl. Add a more flashy win message, with particles and overlays and everything. This is a time for you to be creative and excited about what you have achieved.

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

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