Rewriting the entity library

In EntityPlacer.gd, we have code that maps blueprint to entity scenes. For testing purposes, we hard-coded the paths to our entities.

In this lesson, we will rewrite our hardcoded “library” of entities and blueprints.

This is perfect when prototyping, while we only have two or three kinds of machines, but now we have some base gameplay and systems in place, we’ll want to keep adding or changing machines. And we don’t want to keep writing file paths by hand moving forward.

We’ll automate the process by loading all the scenes automatically from a given folder.

Each InventoryPanel will be responsible for keeping track of the item they’re holding onto. The item itself is a BlueprintEntity and the InventoryPanel stores it as a child node, making it appear inside of the panel. Here, we’ll leverage the fact that BlueprintEntity is both the item and offers a visual preview of it.

To notify the greater GUI entity that something’s happened with the inventory, we’ll use a signal and bubble it up so everyone that needs to know about the player’s inventory can use it.

There’s a lot to cover, so let’s get started.

A better library

In the previous chapter, we hardcoded the link between a blueprint and an entity we can place in the world. That was in EntityPlacer.gd.

The first thing we need is to have a way of associating a blueprint to its entity. We have a library dictionary in the entity placer we’ve been using temporarily, but needing to provide the EntityPlacer to everything that may use the blueprint goes counter to defensive coding. The library should be accessible without stepping onto another node’s job.

We’ll move the code that preloads entities and blueprints to a dedicated, globally accessible node.

We’ll make it global because we’ll end up using it in every module: the inventory, the entity placer, simulation, recipes, crafting, and machines. All those may need to spawn new items, like when machines smelt ore and output an ingot.

To do so, we’ll use an Autoload, which implements the singleton pattern. While we generally recommend avoiding the pattern, it’s fine to use in this case because our library won’t have mutable state. All it’ll do is provide “read-only” access to PackedScene resources and some useful functions. Without mutable properties, we don’t risk introducing hard-to-solve bugs with it.

We’ll also update the code to automatically detect all entities and blueprints in the project, instead of loading them by hand. We can use the Directory class to do so.

To avoid needing to keep editing the result every time we create a new entity, we will have the class analyze the /Entities folder, look at the filenames, and create the association library. It will also provide some useful functions we can use to get blueprint names and entities from blueprints, which you might want to display in labels in the interface, for example.

Create a new script, Library.gd, inside the /Autoload folder.

## Autoloaded class that associates blueprints to entities based on their
## filenames.
extends Node

## Path to the directory in which we store all the entities and blueprints. The
## node will loop over all the files in that directory and associate a given
## entity with a corresponding blueprint.
const BASE_PATH := "res://Entities"

## The following two constants store the suffixes we use on every blueprint and
## entities' filename. We will use them to check the end of a filename to
## distinguish entities from their blueprint.
const BLUEPRINT := "Blueprint.tscn"
const ENTITY := "Entity.tscn"

## This dictionary holds the entities keyed to their names.
var entities := {}
## The dictionary holds blueprints keyed to their names.
var blueprints := {}


func _ready() -> void:
    # Begin the search through the filesystem to find all blueprints and
    # entities.
    _find_entities_in(BASE_PATH)


## Find out what the name of a given node is to look it up in the blueprints or
## entities dictionary. Returns a blank string for nodes that are null or do not
## have an associated scene.
func get_entity_name_from(node: Node) -> String:
    # If the provided node is not null
    if node:
        # First, check if it already has a provided name with an overriden function
        # `get_entity_name()`. This will allow something like a TreeEntity to drop
        # lumber even if it's called TreeEntity.
        if node.has_method("get_entity_name"):
            return node.get_entity_name()

        # If it does not have an overridden name, then take its actual scene
        # filename, which comes in the format `res://...scene.tscn`, and get
        # only the name.
        # We find the last `/` and get everything after that. Then, we remove
        # `Blueprint.tscn` and `Entity.tscn` from the string to get a name like
        # our dictionaries expect.
        var filename := node.filename.substr(node.filename.rfind("/") + 1)
        filename = filename.replace(BLUEPRINT, "").replace(ENTITY, "")

        return filename
    return ""


## Recursively searches the provided directory and finds all files that end
## with `BLUEPRINT` or `ENTITY`. Populates the `blueprints` and `entities`
## dictionaries with them.
func _find_entities_in(path: String) -> void:
    # Open a `Directory` object to the provided path. The `Directory` object lets us
    # analyze filenames.
    var directory := Directory.new()
    var error := directory.open(path)

    # If we encounter an error, it's likely because the directory does not exist.
    if error != OK:
        print("Library Error: %s" % error)
        return

    # `list_dir_begin()` prepares the directory for scanning files one at a time.
    error = directory.list_dir_begin(true, true)

    # If we encounter an error, there might be something wrong with the directory.
    if error != OK:
        print("Library Error: %s" % error)
        return

    # We get the first filename in the list.
    var filename := directory.get_next()

    # `Directory.get_next()` returns an empty string when we reached the end of
    # the directory. We can use that to keep our loop going until we have no
    # more files to scan.
    while not filename.empty():
        # If the current object in directory is a directory, then we recursively
        # call this function to find all the files in _that_ sub-directory.
        if directory.current_is_dir():
            _find_entities_in("%s/%s" % [directory.get_current_dir(), filename])
        else:
            # If the file ends with `Blueprint.tscn`
            if filename.ends_with(BLUEPRINT):
                # We take the entire filename (e.g. StirlingEngineBlueprint.tscn)
                # and create a string that only contains the name
                # (StirlingEngine). We use that name as the key for an entry in
                # the dictionary. The value is a PackedScene resource we load so
                # we can instance it later.
                blueprints[filename.replace(BLUEPRINT, "")] = load(
                    "%s/%s" % [directory.get_current_dir(), filename]
                )
            # We do the same if the file ends with `Entity.tscn`
            if filename.ends_with(ENTITY):
                entities[filename.replace(ENTITY, "")] = load(
                    "%s/%s" % [directory.get_current_dir(), filename]
                )
        # To keep the loop going, we get the next filename in the directory and
        # repeat, until we reached the last entry.
        filename = directory.get_next()

Then, in the project settings, add this new node as an autoload to have it accessible everywhere.

Now we can refer to any blueprint or entity by their proper pascal case names as if they were classes using this new class. So long, at least, as you’ve named them consistently.

To test the library, we need to make changes to EntityPlacer.gd to accommodate the new Autoload.

Open EntityPlacer.gd and remove the old Library dictionary, the _ready() function and the _exit_tree() function.

We have to make some more changes to address errors that pop up, starting with _place_entity(). The Library object is now a collection of named PackedScenes, so we need to index the dictionary using the entity’s name.

func _place_entity(cellv: Vector2) -> void:
    # Replace the function's first line.
    # We get the blueprint's name using the blueprint and use it to get an instance
    # of the corresponding entity.
    var entity_name := Library.get_entity_name_from(_blueprint)
    var new_entity: Node2D = Library.entities[entity_name].instance()

The second change is in _unhandled_input(), where we need to temporarily free the old blueprint instead of removing it from the scene tree, and replace how we get the new one. We also need to access Library.blueprints to get blueprints to instantiate,

This is still temporary code: once inventory management is working, we’ll get rid of this, but until then, we need the code for testing purposes.

func _unhandled_input(event: InputEvent) -> void:
    #...
    elif event.is_action_pressed("quickbar_1"):
        if _blueprint:
            # We replace the lines that say `remove_child(_blueprint)`.
            _blueprint.queue_free()
        # And we replace the lines where we access blueprints.
        # As they're now `PackedScene` resources, we need to instantiate them.
        _blueprint = Library.blueprints.StirlingEngine.instance()
        #...
    elif event.is_action_pressed("quickbar_2"):
        if _blueprint:
            _blueprint.queue_free()
        #...
    elif event.is_action_pressed("quickbar_3"):
        if _blueprint:
            _blueprint.queue_free()
        #...

If you run the game now, so long as you named the blueprints and entities, everything should work as it used to, and you can place the engine, battery, and wires.

You are going to have to hide the GUI node for now to test it, as we haven’t prepared it to let mouse inputs through yet and the inventory window will get in the way.

Making the GUI allow mouse events

While we have the problem highlighted, let’s address mouse input in our user interface before we add more nodes to it.

If you make the GUI node visible again, you cannot place anything in the game anymore because the GUI node spans the whole screen. Even though it’s transparent, it intercepts and consumes all mouse events by default, like all Control nodes.

We can configure Control nodes to ignore or handle mouse events in the Inspector, by changing their Mouse -> Filter property.

The default value of Stop prevents the propagation of mouse events. While Pass lets mouse clicks through other UI nodes, it still automatically handles mouse events. This is undesirable, as it means mouse events never make it to _unhandled_input() in the EntityPlacer.

That means we need to set anything that does not care about the mouse to Ignore mouse events and let them through. At the moment, the only thing that should intercept mouse clicks is the InventoryPanel.

Go to GUI.tscn and set GUI and HBoxContainer’s Mouse Filter to Ignore.

In InventoryWindow.tscn, set InventoryWindow’s Mouse Filter to Ignore, with the rest on Pass. We use Pass in this case because we want the entire window to act as one whole.

Note that you can select multiple nodes in the Scene tab and change the property on all of them at once.

In InventoryBar.tscn, set the InventoryBar’s Mouse Filter to Pass.

Make sure that in InventoryPanel.tscn, InventoryPanel’s Mouse Filter is still set to Stop so it intercepts mouse events within its bounding box.

Now if you run the game again with the GUI shown, you should still be able to put down machines around or through the inventory window.

In future lessons, I will be noting what nodes should be ignoring or passing mouse events and which should be stopping them. An unfortunate reality of mouse-based games!

Code reference

Here are the complete scripts after modifying them in this lesson.

First, Library.gd.

extends Node

const BASE_PATH := "res://Entities"

const BLUEPRINT := "Blueprint.tscn"
const ENTITY := "Entity.tscn"

var entities := {}
var blueprints := {}


func _ready() -> void:
    _find_entities_in(BASE_PATH)


func get_entity_name_from(node: Node) -> String:
    if node:
        if node.has_method("get_entity_name"):
            return node.get_entity_name()

        var filename := node.filename.substr(node.filename.rfind("/") + 1)
        filename = filename.replace(BLUEPRINT, "").replace(ENTITY, "")

        return filename
    return ""


func _find_entities_in(path: String) -> void:
    var directory := Directory.new()
    var error := directory.open(path)

    if error != OK:
        print("Library Error: %s" % error)
        return

    error = directory.list_dir_begin(true, true)

    if error != OK:
        print("Library Error: %s" % error)
        return

    var filename := directory.get_next()

    while not filename.empty():
        if directory.current_is_dir():
            _find_entities_in("%s/%s" % [directory.get_current_dir(), filename])
        else:
            if filename.ends_with(BLUEPRINT):
                blueprints[filename.replace(BLUEPRINT, "")] = load(
                    "%s/%s" % [directory.get_current_dir(), filename]
                )
            if filename.ends_with(ENTITY):
                entities[filename.replace(ENTITY, "")] = load(
                    "%s/%s" % [directory.get_current_dir(), filename]
                )
        filename = directory.get_next()

And EntityPlacer.gd.

extends TileMap

const MAXIMUM_WORK_DISTANCE := 275.0
const POSITION_OFFSET := Vector2(0, 25)
const DECONSTRUCT_TIME := 0.3

var _blueprint: BlueprintEntity
var _tracker: EntityTracker
var _ground: TileMap
var _flat_entities: Node2D
var _player: KinematicBody2D
var _current_deconstruct_location := Vector2.ZERO

onready var _deconstruct_timer := $Timer


func _unhandled_input(event: InputEvent) -> void:
    var global_mouse_position := get_global_mouse_position()

    var has_placeable_blueprint: bool = _blueprint and _blueprint.placeable
    var is_close_to_player := (
        global_mouse_position.distance_to(_player.global_position)
        < MAXIMUM_WORK_DISTANCE
    )

    var cellv := world_to_map(global_mouse_position)
    var cell_is_occupied := _tracker.is_cell_occupied(cellv)
    var is_on_ground := _ground.get_cellv(cellv) == 0

    if event is InputEventMouseButton:
        _abort_deconstruct()

    if event.is_action_pressed("left_click"):
        if has_placeable_blueprint:
            if not cell_is_occupied and is_close_to_player and is_on_ground:
                _place_entity(cellv)
                _update_neighboring_flat_entities(cellv)

    elif event.is_action_pressed("right_click") and not has_placeable_blueprint:
        if cell_is_occupied and is_close_to_player:
            _deconstruct(global_mouse_position, cellv)

    elif event is InputEventMouseMotion:
        if cellv != _current_deconstruct_location:
            _abort_deconstruct()
        if has_placeable_blueprint:
            _move_blueprint_in_world(cellv)

    elif event.is_action_pressed("drop") and _blueprint:
        remove_child(_blueprint)
        _blueprint = null

    elif event.is_action_pressed("rotate_blueprint") and _blueprint:
        _blueprint.rotate_blueprint()

    elif event.is_action_pressed("quickbar_1"):
        if _blueprint:
            _blueprint.queue_free()
        _blueprint = Library.blueprints.StirlingEngine.instance()
        add_child(_blueprint)
        _move_blueprint_in_world(cellv)

    elif event.is_action_pressed("quickbar_2"):
        if _blueprint:
            _blueprint.queue_free()
        _blueprint = Library.blueprints.Wire.instance()
        add_child(_blueprint)
        _move_blueprint_in_world(cellv)

    elif event.is_action_pressed("quickbar_3"):
        if _blueprint:
            _blueprint.queue_free()
        _blueprint = Library.blueprints.Battery.instance()
        add_child(_blueprint)
        _move_blueprint_in_world(cellv)


func _process(_delta: float) -> void:
    var has_placeable_blueprint: bool = _blueprint and _blueprint.placeable
    if has_placeable_blueprint:
        _move_blueprint_in_world(world_to_map(get_global_mouse_position()))


func setup(tracker: EntityTracker, ground: TileMap, flat_entities: YSort, player: KinematicBody2D) -> void:
    _tracker = tracker
    _ground = ground
    _player = player
    _flat_entities = flat_entities

    for child in get_children():
        if child is Entity:
            var map_position := world_to_map(child.global_position)
            _tracker.place_entity(child, map_position)


func _move_blueprint_in_world(cellv: Vector2) -> void:
    _blueprint.global_position = map_to_world(cellv) + POSITION_OFFSET

    var is_close_to_player := (
        get_global_mouse_position().distance_to(_player.global_position)
        < MAXIMUM_WORK_DISTANCE
    )
    var is_on_ground: bool = _ground.get_cellv(cellv) == 0
    var cell_is_occupied := _tracker.is_cell_occupied(cellv)

    if not cell_is_occupied and is_close_to_player and is_on_ground:
        _blueprint.modulate = Color.white
    else:
        _blueprint.modulate = Color.red

    if _blueprint is WireBlueprint:
        WireBlueprint.set_sprite_for_direction(_blueprint.sprite, _get_powered_neighbors(cellv))


func _place_entity(cellv: Vector2) -> void:
    var entity_name := Library.get_entity_name_from(_blueprint)
    var new_entity: Node2D = Library.entities[entity_name].instance()

    if _blueprint is WireBlueprint:
        var directions := _get_powered_neighbors(cellv)
        _flat_entities.add_child(new_entity)
        WireBlueprint.set_sprite_for_direction(new_entity.sprite, directions)
    else:
        add_child(new_entity)

    new_entity.global_position = map_to_world(cellv) + POSITION_OFFSET
    new_entity._setup(_blueprint)
    _tracker.place_entity(new_entity, cellv)


func _deconstruct(event_position: Vector2, cellv: Vector2) -> void:
    _deconstruct_timer.connect("timeout", self, "_finish_deconstruct", [cellv], CONNECT_ONESHOT)
    _deconstruct_timer.start(DECONSTRUCT_TIME)
    _current_deconstruct_location = cellv


func _finish_deconstruct(cellv: Vector2) -> void:
    var entity := _tracker.get_entity_at(cellv)
    _tracker.remove_entity(cellv)
    _update_neighboring_flat_entities(cellv)


func _abort_deconstruct() -> void:
    if _deconstruct_timer.is_connected("timeout", self, "_finish_deconstruct"):
        _deconstruct_timer.disconnect("timeout", self, "_finish_deconstruct")
    _deconstruct_timer.stop()


func _get_powered_neighbors(cellv: Vector2) -> int:
    var direction := 0

    for neighbor in Types.NEIGHBORS.keys():
        var key: Vector2 = cellv + Types.NEIGHBORS[neighbor]

        if _tracker.is_cell_occupied(key):
            var entity: Node = _tracker.get_entity_at(key)
            if (
                entity.is_in_group(Types.POWER_MOVERS)
                or entity.is_in_group(Types.POWER_RECEIVERS)
                or entity.is_in_group(Types.POWER_SOURCES)
            ):
                direction |= neighbor

    return direction


func _update_neighboring_flat_entities(cellv: Vector2) -> void:
    for neighbor in Types.NEIGHBORS.keys():
        var key: Vector2 = cellv + Types.NEIGHBORS[neighbor]
        var object = _tracker.get_entity_at(key)

        if object and object is WireEntity:
            var tile_directions := _get_powered_neighbors(key)
            WireBlueprint.set_sprite_for_direction(object.sprite, tile_directions)