Sending power from sources to receivers

In this lesson, we’ll implement sending electricity from power sources to receivers, following wires.

Our power system has two main jobs:

  1. It keeps track of power providers and total available power in the system.
  2. It notifies receivers to tell them how much power is available to them.

We’ll keep track of the total power in the system by way of “power paths” (the PowerSystem.paths variable), an array of map positions that begin with a power source followed by one or more power receivers.

The power system runs a loop over those paths, notifying entities about the power in use and traveling from entity to entity. It also keeps a tally of the energy used in that particular path.

Building power paths

Let’s write the code to trace paths between power sources and receivers. To do so, we need to start from each source and loop over all the cells that contain wires.

We’ll use two functions to do so: _retrace_paths(), that re-calculates all power paths, and _trace_path_from(), which traces one path recursively from a given cell.

Note we’re still working in PowerSystem.gd.

Here’s how the code generally works:

  1. For every power source, we’ll add them to an array and begin to travel outwards into the neighboring cells in a loop.
  2. As long as there is a power mover or a power receiver, we keep traveling outwards until there are no more cells to visit.
  3. Power receivers go into the array which creates a path that goes from source directly to receivers.

Here’s the code.

## Replace all paths with new ones based on the components' current state.
func _retrace_paths() -> void:
    # Clear old paths.
    paths.clear()

    # For each power source...
    for source in power_sources.keys():
        # ...start a brand new path trace so all cells are possible contenders,
        cells_travelled.clear()
        
        # trace the path the current cell location, with an array with the source's cell as index 0.
        var path := _trace_path_from(source, [source])

        # And we add the result to the `paths` array.
        paths.push_back(path)

We use a recursive function to trace individual paths. We keep track of a current cell and cells that are already visited and check the neighbors one cell at a time.

It will be a big function, so we’ll break its code down into several steps. The initial code starts filling the cells_travelled array and then looks for power receivers in neighbor cells.

## Recursively trace a path from the source cell outwards, skipping already
## visited cells, going through cells recognized by the power system.
func _trace_path_from(cellv: Vector2, path: Array) -> Array:
    # As soon as we reach any given cell, we keep track that we've already visited it.
    # Recursive functions are sensitive to overflowing, so this ensures we won't
    # travel back and forth between two cells forever until the game crashes.
    cells_travelled.push_back(cellv)

    # The default direction for most components, like the generator, is omni-directional,
    # that's UP + LEFT + RIGHT + DOWN in our Types.
    var direction := 15
    # If the current cell is a power source component, use _its_ direction instead.
    if power_sources.has(cellv):
        direction = power_sources[cellv].output_direction

    # Get the power receivers that are neighbors to this cell, if there are any,
    # based on the direction.
    var receivers := _find_neighbors_in(cellv, power_receivers, direction)

Let’s pause the implementation of _trace_path_from() for a second and add the missing _find_neighbors_in() function

To find the neighbors, we can use our trusty Types class’ direction enum while specifying the dictionary we’re actually trying to look inside of.

## For each neighbor in the given direction, check if it exists in the collection we specify,
## and return an array of map positions with those that do.
func _find_neighbors_in(cellv: Vector2, collection: Dictionary, output_directions: int = 15) -> Array:
    var neighbors := []
    # For each of UP, DOWN, LEFT and RIGHT
    for neighbor in Types.NEIGHBORS.keys():
        
        # With binary numbers, comparing two values with the "&" operator compares binary bit of the two numbers,
        # resulting in a number whose bits that match are 1 and those that don't are 0.
        # For example, in binary, 1 is 0001, 2 is 0010, 3 is 0011, and 4 is 0100.
        # We can say that 3 contains 1 and 2 because 3 has both rightmost bits set to `1`,
        # but `4 & 3` results in 0 because none of their bits match.
        # We can leverage these properties when working with flags to compare them. This is a common pattern, and we'll use it for our direction values.
        
        # This condition means "if the current neighbor flag has bits that match the
        # specified direction":
        if neighbor & output_directions != 0:
            
            # Calculate its map coordinate
            var key: Vector2 = cellv + Types.NEIGHBORS[neighbor]
            
            # If it's in the specified collection, add it to the neighbors array
            if collection.has(key):
                neighbors.push_back(key)

    # Return the array of neighbors that match the collection
    return neighbors

With this helper function completed, we can go back to the _trace_path_from() function and start looping over each of the power receivers we found, if any.

We need to compare the direction power is traveling in to the direction the power receiver can receive power from. A power receiver that takes power from its left side needs the power to be traveling left to right and vice versa. We code a helper function to get the integer out of the current traveling direction.

We add the following code at the end of _trace_path_from().

func _trace_path_from(cellv: Vector2, path: Array) -> Array:
    # ...
    for receiver in receivers:
        if not receiver in cells_travelled and not receiver in path:
            # Create an integer that indicates the direction power is
            # traveling in to compare it to the receiver's direction.
            # For example, if the power is traveling from left to right but the receiver
            # does not accept power coming from _its_ left, it should not be in the list.
            var combined_direction := _combine_directions(receiver, cellv)


## Compare a source to a target map position and return a direction integer
## that indicates the direction power is traveling in.
func _combine_directions(receiver: Vector2, cellv: Vector2) -> int:
    if receiver.x < cellv.x:
        return Types.Direction.LEFT
    elif receiver.x > cellv.x:
        return Types.Direction.RIGHT
    elif receiver.y < cellv.y:
        return Types.Direction.UP
    elif receiver.y > cellv.y:
        return Types.Direction.DOWN

    return 0

Which we can then use the combined direction value to see if the receiver is valid and add it to the path or if we should skip it.

Then, we can exit out of the loop and start planning to travel down any other wires around the current cell and repeat the process all over again.

func _trace_path_from(cellv: Vector2, path: Array) -> Array:
    # ...
    #for receiver in receivers:
        #if not receiver in cells_travelled:
            # var combined_direction := _combine_directions(receiver, cellv)
            # Get the power receiver
            var power_receiver: PowerReceiver = power_receivers[receiver]
            # If the current direction does not match any of the receiver's possible
            # directions (using the binary and operator, &, to check if the number fits
            # inside the other), skip this receiver and move on to the next one.
            if (
                (
                    combined_direction & Types.Direction.RIGHT != 0
                    and power_receiver.input_direction & Types.Direction.LEFT == 0
                )
                or (
                    combined_direction & Types.Direction.DOWN != 0
                    and power_receiver.input_direction & Types.Direction.UP == 0
                )
                or (
                    combined_direction & Types.Direction.LEFT != 0
                    and power_receiver.input_direction & Types.Direction.RIGHT == 0
                )
                or (
                    combined_direction & Types.Direction.UP != 0
                    and power_receiver.input_direction & Types.Direction.DOWN == 0
                )
            ):
                continue
                
            # Otherwise, add it to the path.
            path.push_back(receiver)

    # We've done the receivers. Now, we check for any possible wires so we can keep
    # traveling.
    var movers := _find_neighbors_in(cellv, power_movers, direction)

    # Call this same function again from the new cell position for any wire that
    # found and travel from there, and return the result, so long as we
    # did not visit it already.
    # This is what makes the function recursive, that is to say, calling itself.
    for mover in movers:
        if not mover in cells_travelled:
            path = _trace_path_from(mover, path)

    # Return the final array
    return path

The result is an array like [(17, 7), (24, 7), (22, 3)] that represents the 2D location on our map of a power source followed by the location of one or more power receivers. The power movers are not in the list to keep the arrays small and easy to iterate over.

Steps and theory to provide power

To provide power, we need to iterate over each path and keep a running tally of how much power is in the system at a given time.

We go over each path, which has its own power source, then iterate over each receiver to provide it power. At least, so long as we have some still available. Otherwise, we send 0. That means we need to keep a running tally of the available power and how much power the receivers request.

To notify the power sources and receivers of this power draw and provision, we use their signals, which will notify the entities to do something, like storing power or burning fuel.

That’s where the signal we defined in the previous lesson, Events.systems_ticked, comes into play.

We add the following callback function to the PowerSystem.gd script.

## For each system tick, calculate the power for each path and notify
## the components.
func _on_systems_ticked(delta: float) -> void:
    # We're in a new tick, so clear all power received and start over.
    receivers_already_provided.clear()

    # For each power path,
    for path in paths:
        # Get the path's power source, which is the first element
        var power_source: PowerSource = power_sources[path[0]]

        # Get the effective power the source has to give. It cannot provide more than that amount.
        var source_power := power_source.get_effective_power()
        # In this variable, we keep a tally of how much power remains in the path. It begins at the
        # source power.
        var remaining_power := source_power
        
        # A running tally of the power drawn by receivers in this path.
        var power_draw := 0.0

        # For each power receiver in the path (elements of the `path` array after index 0)
        for cell in path.slice(1, path.size()-1):
            # If, for some reason, the element is not in our list, skip it.
            if not power_receivers.has(cell):
                continue

            # Get the actual power receiver component and calculate how much power
            # it desires.
            var power_receiver: PowerReceiver = power_receivers[cell]
            var power_required := power_receiver.get_effective_power()

            # Keep track of the total amount of power each receiver has already
            # received, in case of more than one power source. Subtract the power
            # the receiver still needs so we don't draw more than necessary.
            if receivers_already_provided.has(cell):
                var receiver_total: float = receivers_already_provided[cell]
                if receiver_total >= power_required:
                    continue
                else:
                    power_required -= receiver_total

            # Notify the receiver of the power available to it from this source.
            power_receiver.emit_signal(
                "received_power", min(remaining_power, power_required), delta
            )

            # Add to the tally of the power required from this power source.
            # We keep it clamped so that the machine cannot draw more power than
            # the machine can provide.
            power_draw = min(source_power, power_draw + power_required)

            # Add to the running tally for this particular cell for
            # any future power source. Add it if it does not exist.
            if not receivers_already_provided.has(cell):
                receivers_already_provided[cell] = min(remaining_power, power_required)
            else:
                receivers_already_provided[cell] += min(remaining_power, power_required)

            # Reduce the amount of power still available to other receivers by the
            # amount that _this_ receiver took. We clamp it to 0 so it cannot provide
            # negative power.
            remaining_power = max(0, remaining_power - power_required)
            
            # At this point, if we have no power left, then just break.
            # Any other machine on the path will not be able to update with 0
            # power anyway, so we can skip them.
            if remaining_power == 0:
                break

        # Notify the power source of the amount of power that it provided.
        power_source.emit_signal("power_updated", power_draw, delta)

Triggering updates from simulation

All we have left is to update the simulation at regular intervals.

We could do that from _process() or _physics_process(), but we don’t need it to update 60 or more times per second. Running it less often will give us better performance as the player’s system, and factory grows in size and complexity.

In that case, we’ll update 30 times per second.

If you code a heavy simulation game, an option is to code a smart system that keeps track of effective frame times and adapts the update frequency to keep the player experience running smoothly, even if the simulation itself runs a bit slow.

We head back to Simulation.gd, where we add a configurable update time and instantiate our PowerSystem.

#...
export var simulation_speed := 1.0 / 30.0

#...
onready var _power_system := PowerSystem.new()

To actually trigger the signal, we can add a Timer node as a child of Simulation, connect its timeout to the Simulation node, and emit the Events.systems_ticked signal in the _on_Timer_timeout() callback.

func _ready():
    $Timer.start(simulation_speed)
    #...


func _on_Timer_timeout() -> void:
    Events.emit_signal("systems_ticked", simulation_speed)

Make sure that the timer is not set to one_shot.

If you add an engine and go into the power system and set a breakpoint or print(remaining_power) call in PowerSystem._on_system_ticked(), you should be able to follow the update cycle and see the available power as the engine primes itself from zero to full power!

Code reference

Here are the new and modified scripts in this lesson.

First is PowerSystem.gd.

func _retrace_paths() -> void:
    paths.clear()

    for source in power_sources.keys():
        cells_travelled.clear()
        var path := _trace_path_from(source, [source])
        paths.push_back(path)


func _trace_path_from(cellv: Vector2, path: Array) -> Array:
    cells_travelled.push_back(cellv)

    var direction := 15
    if power_sources.has(cellv):
        direction = power_sources[cellv].output_direction

    var receivers := _find_neighbors_in(cellv, power_receivers, direction)
    for receiver in receivers:
        if not receiver in cells_travelled and not receiver in path:
            var combined_direction := _combine_directions(receiver, cellv)
            var power_receiver: PowerReceiver = power_receivers[receiver]
            if (
                (
                    combined_direction & Types.Direction.RIGHT != 0
                    and power_receiver.input_direction & Types.Direction.LEFT == 0
                )
                or (
                    combined_direction & Types.Direction.DOWN != 0
                    and power_receiver.input_direction & Types.Direction.UP == 0
                )
                or (
                    combined_direction & Types.Direction.LEFT != 0
                    and power_receiver.input_direction & Types.Direction.RIGHT == 0
                )
                or (
                    combined_direction & Types.Direction.UP != 0
                    and power_receiver.input_direction & Types.Direction.DOWN == 0
                )
            ):
                continue
                
            path.push_back(receiver)

    var movers := _find_neighbors_in(cellv, power_movers, direction)
    for mover in movers:
        if not mover in cells_travelled:
            path = _trace_path_from(mover, path)

    return path


func _combine_directions(receiver: Vector2, cellv: Vector2) -> int:
    if receiver.x < cellv.x:
        return Types.Direction.LEFT
    elif receiver.x > cellv.x:
        return Types.Direction.RIGHT
    elif receiver.y < cellv.y:
        return Types.Direction.UP
    elif receiver.y > cellv.y:
        return Types.Direction.DOWN
    return 0


func _find_neighbors_in(cellv: Vector2, collection: Dictionary, output_directions: int = 15) -> Array:
    var neighbors := []

    for neighbor in Types.NEIGHBORS.keys():
        if neighbor & output_directions != 0:
            var key: Vector2 = cellv + Types.NEIGHBORS[neighbor]
            if collection.has(key):
                neighbors.push_back(key)

    return neighbors


func _on_systems_ticked(delta: float) -> void:
    receivers_already_provided.clear()

    for path in paths:
        var power_source: PowerSource = power_sources[path[0]]

        var source_power := power_source.get_effective_power()
        var remaining_power := source_power
        
        var power_draw := 0.0

        for cell in path.slice(1, path.size()-1):
            if not power_receivers.has(cell):
                continue

            var power_receiver: PowerReceiver = power_receivers[cell]
            var power_required := power_receiver.get_effective_power()

            if receivers_already_provided.has(cell):
                var receiver_total: float = receivers_already_provided[cell]
                if receiver_total >= power_required:
                    continue
                else:
                    power_required -= receiver_total

            power_receiver.emit_signal(
                "received_power", min(remaining_power, power_required), delta
            )

            power_draw = min(source_power, power_draw + power_required)

            if not receivers_already_provided.has(cell):
                receivers_already_provided[cell] = min(remaining_power, power_required)
            else:
                receivers_already_provided[cell] += min(remaining_power, power_required)

            remaining_power = max(0, remaining_power - power_required)
            
            if remaining_power == 0:
                break

        power_source.emit_signal("power_updated", power_draw, delta)

And Simulation.gd.

extends Node

const BARRIER_ID := 1
const INVISIBLE_BARRIER_ID := 2

var _tracker := EntityTracker.new()

export var simulation_speed := 1.0 / 30.0

onready var _ground := $GameWorld/GroundTiles
onready var _entity_placer := $GameWorld/YSort/EntityPlacer
onready var _player := $GameWorld/YSort/Player
onready var _flat_entities := $GameWorld/FlatEntities
onready var _power_system := PowerSystem.new()


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

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


func _on_Timer_timeout() -> void:
    Events.emit_signal("systems_ticked", simulation_speed)