For now, we have an unlimited power source with our Stirling engine, but the power is not going anywhere. We want to generate power using engines and transport it over long distances, as illustrated in the image below.
We have a Stirling engine filling up batteries which, in turn, power two furnaces.
To do so, we’ll start by coding wires. They will look like the pipes running along the ground that the player can walk over. That means they will have no collision and will live underneath all other entities.
The wires need to connect to nearby entities when placed. We’ll need to add exceptions and functions to the EntityPlacer
to do that.
Here’s an illustration of the result we should get by the end of this lesson. When placing a wire next to cells with wires, we update their sprites so they connect.
To do so, we’ll code a system similar to the TileMap
node’s autotile feature.
We’ll start by coding a static class, Types.gd
. By adding the line a class_name Types
at the top, we can access the class’s constants from anywhere in our codebase, making it so we don’t have to re-define the same constants in different places.
The first constants we define in Types
are an enum and dictionary to help look for neighboring entities around a given cell.
We also define constants for some node groups: we can use those to identify and find specific nodes using group functions. For example, we’ll use is_in_group()
to know whether an entity is a power source, receives power, or a tile that power goes through.
class_name Types extends Reference ## We store constants for possible directions: up, down, left, and right. ## We'll use bitwise operators to combine them, allowing us to map directions ## to different wire sprites. ## For example, if we combine RIGHT and DOWN below, we will get the number `3`. ## We write these numbers in base 10. If you prefer, you can write them in binary using the ## prefix 0b. For example, 0b0001 is the number `1` in binary, and 0b1000 is the number `8`. enum Direction { RIGHT = 1, DOWN = 2, LEFT = 4, UP = 8 } ## This dictionary maps our `Direction` values to `Vector2` coordinates, to loop over neighbors of ## a given cell. const NEIGHBORS := { Direction.RIGHT: Vector2.RIGHT, Direction.DOWN: Vector2.DOWN, Direction.LEFT: Vector2.LEFT, Direction.UP: Vector2.UP } # Group name constants. Storing them as constants help prevent typos. const POWER_MOVERS := "power_movers" const POWER_RECEIVERS := "power_receivers" const POWER_SOURCES := "power_sources"
Regarding the Direction
enum above, if it’s your first time using numbers to represent directions, the benefit of doing so might be unclear.
Using the powers of two allows us to map every possible combination of directions to a unique number. For example, RIGHT + LEFT + UP = 1 + 4 + 8 = 13
. You can do this for each of the fifteen possible combinations to get unique numbers from 1
to 15
.
This is how the Godot engine manages flags, like the physics layers and masks. As you’ll see moving forward, we can export these flags as a list of checkboxes in the Inspector.
Also, using bit-wise operators, we can combine and compare directions in interesting ways. You’ll get a couple examples by the end of the series.
When a wire has other wires next to it, we’ll combine the Direction
constant of these neighboring tiles and use the result to find the sprite we should use for our newly placed wire.
With our group constants defined, go back to the StirlingEngineEntity scene and assign the power_sources
group to the scene’s root node.
When we lay down a wire, we want to see if there is a machine or another wire in the adjacent cell to connect to them. In the sprite sheet for both entities and blueprints, I lay down wires in the same place so they work like a sprite sheet.
Since blueprints are always available in the EntityPlacer
instance, we can define and store the wire sprites’ region data in their blueprint. Using some helper functions, we can get the right sprite based on a direction value.
Create a new scene with a Node2D named WireBlueprint as its root and a Sprite node as its child.
We assign the blueprints.svg
texture to the Sprite and set its Region -> Enabled to true
.
You want to use the Snap Mode -> Grid Snap with the properties in the image below and click and drag to define a square region for the wire.
The sprites fit in a grid of 100
by 100
pixels with a 10
pixels gutter, as illustrated below. We’ll reuse these properties for the WireBlueprint.gd
script and create a matching sprite in the WireEntity scene.
Doing so will make the wire seamlessly turn from a blueprint into a world entity when the player places it in the world.
Attach a new script to WireBlueprint that inherits from BlueprintEntity
.
## Blueprint for a wire. Provides functions and data to calculate the sprite's region to use ## depending on where the player places the wire and whether there are wires and machines ## in adjacent cells. class_name WireBlueprint extends BlueprintEntity ## Constant dictionary that holds the sprite region information for the wire's spritesheet. ## The numbers used as keys represent combinations of the direction values we ## wrote in `Types.Directions`. ## Note how some of them repeat: `LEFT`, `RIGHT`, and `LEFT+RIGHT` are all the same region. ## This keeps the helper functions below input safe. In case the user ever passes in a ## single direction, Godot will not crash because of a missing dictionary key. ## All 15 possible numbers have a corresponding sprite region we've chosen. const DIRECTIONS_DATA := { # The `Rect2` values below correspond to different wire sprites in our `tileset.svg` sprite sheet. # As shown in the image above, in the `tileset.svg` and `blueprints.svg` textures, each sprite # fits in a `100` by `100` pixels square. # Also, I separated them from other cells by `10` pixels, and there's a `10` # pixels margin from the edge of the texture. # That's why the width and height of the rectangles below are always `100`, but their start # position is a multiple of 110 + 10. 1: Rect2(120, 10, 100, 100), 4: Rect2(120, 10, 100, 100), 5: Rect2(120, 10, 100, 100), 2: Rect2(230, 10, 100, 100), 8: Rect2(230, 10, 100, 100), 10: Rect2(230, 10, 100, 100), 15: Rect2(340, 10, 100, 100), 6: Rect2(450, 10, 100, 100), 12: Rect2(560, 10, 100, 100), 3: Rect2(670, 10, 100, 100), 9: Rect2(780, 10, 100, 100), 7: Rect2(890, 10, 100, 100), 14: Rect2(10, 120, 100, 100), 13: Rect2(120, 120, 100, 100), 11: Rect2(230, 120, 100, 100) } onready var sprite := $Sprite ## Helper function to set the sprite based on the provided combined value for `directions` ## in which there are neighboring wires or machines to connect wire to. static func set_sprite_for_direction(sprite: Sprite, directions: int) -> void: sprite.region_rect = get_region_for_direction(directions) ## Static function to get an appropriate value from `DIRECTIONS_DATA`. static func get_region_for_direction(directions: int) -> Rect2: # If the `directions` value is invalid, default to `10`, which is UP + DOWN. if not DIRECTIONS_DATA.has(directions): directions = 10 return DIRECTIONS_DATA[directions]
We need an entity to place in the world for our wires, to go along with their blueprint.
We create a new WireEntity scene with a Node2D
as its root named WireEntity and a Sprite
node. We assign the power_movers
group to WireEntity so we can identify it in the power system later.
The wire entity does not have much to do. We are using it as a way to access its sprite and to identify it using the is
keyword thanks to its class_name
.
However, we need to be careful with its sprite, as we’ll assign one of the Rect2
regions we defined in the WireBlueprint
class.
We assign the tileset.svg
texture to the Sprite node, set its Region -> Enabled to true
, and use the TextureRegion bottom panel to pick its sprite.
You want to use the Snap Mode -> Grid Snap with the properties in the image below and click and drag
Like the blueprint, the position of the sprite is at (0, 27).
## Wire. Moves power through it. class_name WireEntity extends Entity onready var sprite := $Sprite
Back in the main scene, Simulation.tscn
, we need a new sorting node for entities that are in the world but that the player can walk over. We create a new Ysort
named FlatEntities above the existing YSort node.
To provide it as an option for the EntityPlacer, we need to update the Simulation.gd
script to fetch it, then EntityPlacer
’s setup()
function to receive it and store it.
Here’s the code to add to Simulation.gd
.
#... onready var _flat_entities := $GameWorld/FlatEntities func _ready() -> void: # Replace the existing call to `_entity_placer.setup()` _entity_placer.setup(_tracker, _ground, _flat_entities, _player) #...
And to EntityPlacer.gd
’s setup()
.
#... var _flat_entities: Node2D #... # Don't forget to add the `flat_entities` argument to the function definition. func setup(tracker: EntityTracker, ground: TileMap, flat_entities: YSort, player: KinematicBody2D) -> void: #... _flat_entities = flat_entities #...
The rest of the chapter focuses on EntityPlacer.gd
: placing the wire on the map and connecting them with machines or other wires that surround them.
Until we have a dynamic library system, we need to update our test Library
variable with the wire’s blueprint and entity. We also need to free it the instance when the entity placer exits the tree.
onready var Library := { #... "Wire": preload("res://Entities/Blueprints/WireBlueprint.tscn").instance() } func _ready() -> void: #... Library[Library.Wire] = preload("res://Entities/Entities/WireEntity.tscn") func _exit_tree() -> void: #... Library.Wire.queue_free()
Next, we need to select the wire blueprint and begin the process of placing it. The code works the same as the Stirling engine’s keyboard shortcut, except that we map the input to the Library.Wire
instead of the Library.StirlingEngine
.
func _unhandled_input(event: InputEvent) -> void: #elif event.is_action_pressed("quickbar_1"): #... # We duplicate the temporary hard-coded shortcut above. elif event.is_action_pressed("quickbar_2"): if _blueprint: remove_child(_blueprint) # This is the only difference: we assign the `WireBlueprint` to the `_blueprint` variable variable _blueprint = Library.Wire add_child(_blueprint) _move_blueprint_in_world(cellv)
Note that we’re only duplicating code while developing the game and until we have the inventory implemented. It takes us no time and works well for testing purposes.
What should we do when we put down a wire and have it connect it to others around it?
We need to inspect entities in each of the four neighbor cells and determine if they are part of any node groups related to the power system. Those are the constants we defined in the Types
class.
We can convert this into a direction integer using the Types
class. We do this inside of a new helper function, _get_powered_neighbors()
.
## Returns a bit-wise integer based on whether the nearby objects can carry power. func _get_powered_neighbors(cellv: Vector2) -> int: # Begin with a blank direction of 0 var direction := 0 # We loop over each neighboring direction from our `Types.NEIGHBORS` dictionary. for neighbor in Types.NEIGHBORS.keys(): # We calculate the neighbor cell's coordinates. var key: Vector2 = cellv + Types.NEIGHBORS[neighbor] # We get the entity in that cell if there is one. if _tracker.is_cell_occupied(key): var entity: Node = _tracker.get_entity_at(key) # If the entity is part of any of the power groups, if ( entity.is_in_group(Types.POWER_MOVERS) or entity.is_in_group(Types.POWER_RECEIVERS) or entity.is_in_group(Types.POWER_SOURCES) ): # We combine the number with the OR bitwise operator. # It's like using +=, but | prevents the same number from adding to itself. # Types.Direction.RIGHT (1) + Types.Direction.RIGHT (1) results in DOWN (2), which is wrong. # Types.Direction.RIGHT (1) | Types.Direction.RIGHT (1) still results in RIGHT (1). # Since we are iterating over all four directions and will not repeat, you can use +, # but I use the | operator to be more explicit about comparing bitwise enum FLAGS. direction |= neighbor return direction
We have everything we need to put down wires now and have them connect. Beginning with the blueprint, we can add a stipulation that is specific to WireBlueprint
with an if statement at the end of _move_blueprint_in_world()
.
func _move_blueprint_in_world(cellv: Vector2) -> void: #... if _blueprint is WireBlueprint: WireBlueprint.set_sprite_for_direction(_blueprint.sprite, _get_powered_neighbors(cellv))
When placing the entity itself in the world in _place_entity()
, we can test the blueprint and add the entity as a child of the FlatEntities
node instead of EntityPlacer
so it will be in its own sorting group.
We replace the line add_child(new_entity)
with the following.
func _place_entity(cellv: Vector2) -> void: #... # Add it to the tilemap as a child so it gets sorted properly 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 #...
Our wire now connects to the wires and machines around it, but other wires do not connect to the newly added one.
We need neighbor wires to update their sprite to connect to the new wire. We can write a helper function for that. We define the new function at the bottom of EntityPlacer.gd
## Looks at each of the neighboring tiles and updates each of them to use the ## correct graphics based on their own neighbors. func _update_neighboring_flat_entities(cellv: Vector2) -> void: # For each neighboring tile, for neighbor in Types.NEIGHBORS.keys(): # We get the entity, if there is one var key: Vector2 = cellv + Types.NEIGHBORS[neighbor] var object = _tracker.get_entity_at(key) # If it's a wire, we have that wire update its graphics to connect to the new # entity. if object and object is WireEntity: var tile_directions := _get_powered_neighbors(key) WireBlueprint.set_sprite_for_direction(object.sprite, tile_directions)
We need to call the new function in two places:
Let’s start with adding an entity. We need to add a line to the _unhandled_input()
function, right after calling _place_entity()
func _unhandled_input(event: InputEvent) -> void: #... #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)
We add the other call at the end of _finish_deconstruct()
.
func _finish_deconstruct(cellv: Vector2) -> void: #... _update_neighboring_flat_entities(cellv)
This should allow you to create any combination of wire patterns you need.
In the next lesson, we’ll start working on the power system, that’ll send energy from power sources like the Stirling engine through the wires.
Here are the complete scripts for the files we worked on in this lesson.
First, Types.gd
.
class_name Types extends Reference enum Direction { RIGHT = 1, DOWN = 2, LEFT = 4, UP = 8 } const NEIGHBORS := { Direction.RIGHT: Vector2.RIGHT, Direction.DOWN: Vector2.DOWN, Direction.LEFT: Vector2.LEFT, Direction.UP: Vector2.UP } const POWER_MOVERS := "power_movers" const POWER_RECEIVERS := "power_receivers" const POWER_SOURCES := "power_sources"
Second, WireBlueprint.gd
.
class_name WireBlueprint extends BlueprintEntity const DIRECTIONS_DATA := { 1: Rect2(120, 10, 100, 100), 4: Rect2(120, 10, 100, 100), 5: Rect2(120, 10, 100, 100), 2: Rect2(230, 10, 100, 100), 8: Rect2(230, 10, 100, 100), 10: Rect2(230, 10, 100, 100), 15: Rect2(340, 10, 100, 100), 6: Rect2(450, 10, 100, 100), 12: Rect2(560, 10, 100, 100), 3: Rect2(670, 10, 100, 100), 9: Rect2(780, 10, 100, 100), 7: Rect2(890, 10, 100, 100), 14: Rect2(10, 120, 100, 100), 13: Rect2(120, 120, 100, 100), 11: Rect2(230, 120, 100, 100) } onready var sprite := $Sprite static func set_sprite_for_direction(sprite: Sprite, directions: int) -> void: sprite.region_rect = get_region_for_direction(directions) static func get_region_for_direction(directions: int) -> Rect2: if not DIRECTIONS_DATA.has(directions): directions = 10 return DIRECTIONS_DATA[directions]
Finally, EntityPlacer.gd
, that is getting big!
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 Library := { "StirlingEngine": preload("res://Entities/Blueprints/StirlingEngineBlueprint.tscn").instance(), "Wire": preload("res://Entities/Blueprints/WireBlueprint.tscn").instance() } onready var _deconstruct_timer := $Timer func _ready() -> void: Library[Library.StirlingEngine] = preload("res://Entities/Entities/StirlingEngineEntity.tscn") Library[Library.Wire] = preload("res://Entities/Entities/WireEntity.tscn") func _exit_tree() -> void: Library.StirlingEngine.queue_free() Library.Wire.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 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("quickbar_1"): if _blueprint: remove_child(_blueprint) _blueprint = Library.StirlingEngine add_child(_blueprint) _move_blueprint_in_world(cellv) elif event.is_action_pressed("quickbar_2"): if _blueprint: remove_child(_blueprint) _blueprint = Library.Wire 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 new_entity: Node2D = Library[_blueprint].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) print(tile_directions) WireBlueprint.set_sprite_for_direction(object.sprite, tile_directions)