The EntityPlacer

In this lesson, we’ll work on the EntityPlacer, a workhorse of a class.

Its purpose is to control the blueprint in the player’s hand to follow the mouse, snap to grid cells, and place the associated entity on the grid when the player clicks on a cell. Since the class controls the blueprint, it will also handle rotating and dropping items, and other tasks related to blueprints and placing entities.

Attach a script EntityPlacer.gd to the EntityPlacer node in the Scene dock to begin coding.

Setting up the system for entity placing

The first thing that should happen when the game launches is to provide the EntityPlacer with what it needs to function. Those are the variables and references it needs and any entity we place ahead of time, like trees, rocks, or existing machines.

We’ll do all this inside a setup() function that the Simulation node will call.

When parents or ancestors control what child nodes know, it keeps your code structure clean. If you change the scene tree’s layout, you won’t need to find every child and update node paths in each script. Instead, you’ll mainly need to update the parent.

Objects the player places will come from the player’s inventory. But until we add the inventory, we’ll hard-code values that point back to our scenes to test entity placement. We will use a temporary dictionary and replace it with another system once we get to work with the GUI.

Here’s the EntityPlacer’s script.

extends TileMap

## Distance from the player when the mouse stops being able to interact.
const MAXIMUM_WORK_DISTANCE := 275.0

## When using `world_to_map()` or `map_to_world()`, `TileMap` reports values from the
## top-left corner of the tile. In isometric perspective, it's the top corner
## from the middle. Since we want our entities to be in the middle of the tile,
## we must add an offset to any world position that comes from the map that is
## half the vertical height of our tiles, 25 pixels on the Y-axis here.
const POSITION_OFFSET := Vector2(0,25)

## Temporary variable to hold the active blueprint.
## For testing purposes, we hold it here until we build the inventory.
var _blueprint: BlueprintEntity

## The simulation's entity tracker. We use its functions to know if a cell is available or it
## already has an entity.
var _tracker: EntityTracker

## The ground tiles. We can check the position we're trying to put an entity down on
## to see if the mouse is over the tilemap.
var _ground: TileMap

## The player entity. We can use it to check the distance from the mouse to prevent
## the player from interacting with entities that are too far away.
var _player: KinematicBody2D

## Temporary variable to store references to entities and blueprint scenes.
## We split it in two: blueprints keyed by their names and entities keyed by their blueprints.
## See the `_ready()` function below for an example of how we map a blueprint to a scene.
## Replace the `preload()` resource paths below with the paths where you saved your scenes.
onready var Library := {
    "StirlingEngine": preload("res://Entities/Blueprints/StirlingEngineBlueprint.tscn").instance(),
}


func _ready() -> void:
    # Use the existing blueprint to act as a key for the entity scene, so we can instance
    # entities given their blueprint.
    Library[Library.StirlingEngine] = preload("res://Entities/Entities/StirlingEngineEntity.tscn")


## Since we are temporarily instancing blueprints for the library until we have
## an inventory system, we must clean up the blueprints when the object leaves the tree.
func _exit_tree() -> void:
    Library.StirlingEngine.queue_free()


## Here's our setup() function. It sets the placer up with the data that it needs to function,
## and adds any pre-placed entities to the tracker.
func setup(tracker: EntityTracker, ground: TileMap, player: KinematicBody2D) -> void:
    # We use the function to initialize our private references. As mentioned before, this approach
    # makes refactoring easier, as the EntityPlacer doesn't need hard-coded paths to the EntityTracker,
    # GroundTiles, and Player nodes.
    _tracker = tracker
    _ground = ground
    _player = player

    # For each child of EntityPlacer, if it extends Entity, add it to the tracker
    # and ensure its position snaps to the isometric grid.
    for child in get_children():
        if child is Entity:
            # Get the world position of the child into map coordinates. These are
            # integer coordinates, which makes them ideal for repeatable
            # Dictionary keys, instead of the more rounding-error prone
            # decimal numbers of world coordinates.
            var map_position := world_to_map(child.global_position)

            # Report the entity to the tracker to add it to the dictionary.
            _tracker.place_entity(child, map_position)

Back in Simulation.gd, we get references to the nodes the EntityPlacer needs using onready variables and call EntityPlacer.setup() to initialize it. We’ll revisit the script later when adding the user interface.

#...
onready var _entity_placer := $GameWorld/YSort/EntityPlacer
onready var _player := $GameWorld/YSort/Player


func _ready() -> void:
    _entity_placer.setup(_tracker, _ground, _player)
    #...

Responding to user input

Everything in EntityPlacer has to do with placing entities based on the user’s input. That makes _unhandled_input the main point for the logic that follows. To keep the code clean and readable, we’ll call small functions based on the player’s input.

What needs to happen to place an entity? Reminding ourselves of the game’s rules informs the coding.

When the player selects an object from their inventory, a preview blueprint should appear under the mouse. The preview follows the mouse and snaps to the isometric grid. The preview turns red when too far from the player or when trying to place it on an occupied tile or a tile with no ground. Clicking on an empty grid cell replaces the blueprint with the entity and places it.

We will add more rules when adding power, linking wires together, and orientation. But for now, let’s get the input’s foundations down.

Note that in the code below, we call some functions we have yet to define. We’ll add them in the next section.

Open EntityPlacer.gd and add the following code.

# Below, we start by storing the result of calculations and comparisons in variables. Doing so makes
# the code easy to read.
func _unhandled_input(event: InputEvent) -> void:
    # Get the mouse position in world coordinates relative to world entities.
    # event.global_position and event.position return mouse positions relative
    # to the screen, but we have a camera that can move around the world.
    # That's why we call `get_global_mouse_position()`
    var global_mouse_position := get_global_mouse_position()

    # We check whether we have a blueprint in hand and that the player can place it in the world.
    var has_placeable_blueprint: bool = _blueprint and _blueprint.placeable

    # We check if the mouse is close enough to the Player node.
    var is_close_to_player := (
        global_mouse_position.distance_to(_player.global_position)
        < MAXIMUM_WORK_DISTANCE
    )

    # Here, we calculate the coordinates of the cell the mouse is hovering.
    var cellv := world_to_map(global_mouse_position)

    # We check whether an entity exists at that map coordinate or not, to not
    # add entities in occupied cells.
    var cell_is_occupied := _tracker.is_cell_occupied(cellv)

    # We check whether there is a ground tile underneath the current map coordinates.
    # We don't want to place entities out in the air.
    var is_on_ground := _ground.get_cellv(cellv) == 0

    # When left-clicking, we use all our boolean variables to check the player can place an entity.
    # Using variables with clear names helps to write code that reads *almost* like English.
    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)
    # If the mouse moved and we have a blueprint in hand, we update the blueprint's ghost so it
    # follows the mouse cursor.
    elif event is InputEventMouseMotion:
        if has_placeable_blueprint:
            _move_blueprint_in_world(cellv)
    # When the user presses the drop button and we are holding a blueprint, we would
    # drop the entity as a dropable entity that the player can pick up.
    # For testing purposes, the following code clears the blueprint from the active slot instead.
    elif event.is_action_pressed("drop") and _blueprint:
        remove_child(_blueprint)
        _blueprint = null
    # We put our quickbar actions for testing purposes and hardcode them to specific entities.
    elif event.is_action_pressed("quickbar_1"):
        if _blueprint:
            remove_child(_blueprint)
        _blueprint = Library.StirlingEngine
        add_child(_blueprint)
        _move_blueprint_in_world(cellv)

Moving blueprints in the world

We have the input handling in, but we’re missing the functions it calls, like _move_blueprint_in_world(). Let’s write them.

The function _move_blueprint_in_world() does what its name describes: when the mouse moves, the blueprint should move with it so that the player gets a constant preview of where his entity will land and whether the place they want is valid or not. The preview should tint red if the cell is invalid.

## Moves the active blueprint in the world according to mouse movement,
## and tints the blueprint based on whether the tile is valid.
func _move_blueprint_in_world(cellv: Vector2) -> void:
    # Snap the blueprint's position to the mouse with an offset
    _blueprint.global_position = map_to_world(cellv) + POSITION_OFFSET

    # Determine each of the placeable conditions
    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)

    # Tint according to whether the current tile is valid or not.
    if not cell_is_occupied and is_close_to_player and is_on_ground:
        _blueprint.modulate = Color.white
    else:
        _blueprint.modulate = Color.red

Placing entities in the world

Next is _place_entity(). With a blueprint in place and a valid location, when the user clicks, an entity should appear in its place and become part of the game world. We get the corresponding scene resource from our Library variable and use it to instantiate the new entity.

## Places the entity corresponding to the active `_blueprint` in the world at the specified
## location, and informs the `EntityTracker`.
func _place_entity(cellv: Vector2) -> void:
    # Use the blueprint we prepared in _ready to instance a new entity.
    var new_entity: Node2D = Library[_blueprint].instance()

    # Add it to the tilemap as a child so it gets sorted properly.
    add_child(new_entity)

    # Snap its position to the map, adding `POSITION_OFFSET` to get the center of the grid cell.
    new_entity.global_position = map_to_world(cellv) + POSITION_OFFSET

    # Call `setup()` on the entity so it can use any data the blueprint holds to configure itself.
    new_entity._setup(_blueprint)

    # Register the new entity in the `EntityTracker` so all the signals can go up, as with systems.
    _tracker.place_entity(new_entity, cellv)

With this, you can already place Stirling engines in the world.

Fixing the camera view

If you hold a blueprint and then start to move around the map without moving the mouse, the blueprint stays behind until you move the mouse cursor.

We can use the _process() callback to ensure that if we have a blueprint, it’s keeping up with the mouse position at all times.

func _process(_delta: float) -> void:
    var has_placeable_blueprint: bool = _blueprint and _blueprint.placeable
    # If we have a blueprint in hand, keep it updated and snapped to the grid
    if has_placeable_blueprint:
        _move_blueprint_in_world(world_to_map(get_global_mouse_position()))

In the next lesson, we’ll write code to deconstruct entities.

Code reference

Here’s the complete Simulation.gd script.

extends Node

const BARRIER_ID := 1
const INVISIBLE_BARRIER_ID := 2

var _tracker := EntityTracker.new()

onready var _ground := $GameWorld/GroundTiles
onready var _entity_placer := $GameWorld/YSort/EntityPlacer
onready var _player := $GameWorld/YSort/Player


func _ready() -> void:
    _entity_placer.setup(_tracker, _ground, _player)
    var barriers: Array = _ground.get_used_cells_by_id(BARRIER_ID)

    for cellv in barriers:
        _ground.set_cellv(cellv, INVISIBLE_BARRIER_ID)

And here’s EntityPlacer.gd.

extends TileMap

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

var _blueprint: BlueprintEntity
var _tracker: EntityTracker
var _ground: TileMap
var _player: KinematicBody2D

onready var Library := {
    "StirlingEngine": preload("res://Entities/Blueprints/StirlingEngineBlueprint.tscn").instance(),
}


func _ready() -> void:
    Library[Library.StirlingEngine] = preload("res://Entities/Entities/StirlingEngineEntity.tscn")


func _exit_tree() -> void:
    Library.StirlingEngine.queue_free()


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_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)
    elif event is InputEventMouseMotion:
        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("quickbar_1"):
        if _blueprint:
            remove_child(_blueprint)
        _blueprint = Library.StirlingEngine
        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, player: KinematicBody2D) -> void:
    _tracker = tracker
    _ground = ground
    _player = player

    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


func _place_entity(cellv: Vector2) -> void:
    var new_entity: Node2D = Library[_blueprint].instance()
    add_child(new_entity)
    new_entity.global_position = map_to_world(cellv) + POSITION_OFFSET
    new_entity._setup(_blueprint)
    _tracker.place_entity(new_entity, cellv)