From the first chapter, we have a system where a removed entity pops out of existence, gone forever.
In an actual game, that feels a little unfair. Instead, the player should be able to pick up an entity they dropped. When their inventory is full, we should also drop a blueprint on the ground to pick it up later.
To do this, we add a new entity type: the ground item. This special entity holds a reference to a blueprint and applies its graphic to a sprite. When the player walks into range, if there’s space in their inventory, the item animates jumping to him, and the blueprint goes into the slot.
Create a new scene, GroundItem.tscn
, with a Node2D
named GroundItem as its root.
Remember that our Library
will load anything that ends with the “Entity.tscn” suffix, but we don’t need ground items in it. So with the scene’s name, “GroundItem”, we ensure the library won’t register it as a regular entity.
For the ground item’s graphic, we add a Sprite
with a Scale of 0.25 x 0.25. This keeps ground entities distinct from ghosts by how small they are, and it makes it obvious the player can pick them up.
We’ll code its texture programmatically, but you can assign it a texture for preview purposes. I chose the battery’s sprite.
To detect when the player comes into range of the item, we can use an Area2D
with a circular collision shape. Add an Area2D
as a child of GroundItem and a CollisionShape2D
as a child of the area with a CircleShape2D
in its Shape property. A 10-pixel radius circle will do and wrap around the whole sprite.
To give it a little flavor, we can add an AnimationPlayer
we will use to make the item float up and down off the ground. It will look like it’s hovering and draw attention to itself so the player doesn’t miss its presence.
I gave it an animation named “Float” that raises the Sprite up and down over 2 seconds. The animation’s set to cycle.
We also add a Tween
node. When the player does pick up the item, we want it to fling itself into their inventory pleasingly and satisfyingly. Since we don’t know where the player will be, Tween
lets us set that programmatically.
Add a new script, GroundItem.gd
, to the GroundItem
node. Its job is to store a reference to the blueprint we provide it, set its graphics, and do some animation.
class_name GroundItem extends Node2D ## Reference to the blueprint that was just dropped. Grabbed by the inventory ## GUI system when picked up. var blueprint: BlueprintEntity ## Reference to the nodes that make up this scene so we can animate, tween, or ## toggle whether collisions work. onready var collision_shape := $Area2D/CollisionShape2D onready var animation := $AnimationPlayer onready var sprite := $Sprite onready var tween := $Tween ## Assigns a blueprint, sets graphics, and positions the item func setup(_blueprint: BlueprintEntity, location: Vector2) -> void: blueprint = _blueprint # We configure the sprite to be exactly how the blueprint entity's sprite is # so it looks the same, just scaled down. # Note that for this code to work, every blueprint scene should have a `Sprite` # node named "Sprite". var blueprint_sprite := blueprint.get_node("Sprite") sprite.texture = blueprint_sprite.texture sprite.region_enabled = blueprint_sprite.region_enabled sprite.region_rect = blueprint_sprite.region_rect sprite.centered = blueprint_sprite.centered global_position = location # Trigger the "pop" animation, where the item goes flying out of where the # entity was deconstructed. _pop() ## Animates the item so it flies to the player's position before being erased. func do_pickup(target: KinematicBody2D) -> void: # We start with a speed of 10% of the distance. The item starts slow. var travel_distance := 0.1 # Prevent the collision of the shape from working. Otherwise, we might pick up # the same item twice in a row! collision_shape.set_deferred("disabled", true) # We'll manually break out of the loop when it's time, so keep looping. while true: # Calculate the distance to the player. var distance_to_target := global_position.distance_to(target.global_position) # Break out of the loop once we're inside of 5 pixels, which is sufficiently "on top." if distance_to_target < 5.0: break # Interpolate the current position of the ground item by a percentage # of the way to the target's position. global_position = global_position.move_toward(target.global_position, travel_distance) # In our case, starting at 10% and increasing by another 10 every frame, # Ramping slow to fast. travel_distance += 0.1 # Yield out of the function call until the next frame where we can keep animating. yield(get_tree(), "idle_frame") # Erase the ground item now that it's reached the player. queue_free() ## Animates the item flying out of its starting position in an arc up and down, ## like popcorn. func _pop() -> void: # PI in radians is half a circle, or 180 degrees. So this takes the up direction # and rotates it a random amount left and right. var direction := Vector2.UP.rotated(rand_range(-PI, PI)) # In our isometric perspective, every vertical pixel is half a horizontal pixel. direction.y /= 2.0 # Pick a random distance between 20 and 70 pixels. direction *= rand_range(20, 70) # Pre-calculate the final position var target_position := global_position + direction # Pre-calculate a point halfway horizontally between start and end point, # but twice as high. `sign()` returns -1 if the value is negative and 1 if # positive, so we can use it to keep the vertical direction upwards. var height_position := global_position + direction * Vector2(0.5, 2 * -sign(direction.y)) # Interpolate from the start to the middle point tween.interpolate_property( self, "global_position", global_position, height_position, 0.15, Tween.TRANS_SINE, Tween.EASE_OUT ) # then middle point to end point. We delay this tween by the duration of the # previous tween. tween.interpolate_property( self, "global_position", height_position, target_position, 0.25, 0, Tween.EASE_IN, 0.15 ) tween.start() # Wait until all tweens that we created have finished yield(tween, "tween_all_completed") animation.play("Float")
The player needs a way to detect if it got close enough to a ground item to tell it we’re picking it up. So open Player.tscn
and add a new Area2D
node named PickupRadius
with a CollisionShape2D
child. Assign it a CircleShape2D
with a healthy radius. I went with a 100-pixel radius, though how far items can be is a matter of game balance and testing.
Select the PickupRadius
area and in the Node dock, connect the area_entered
signal to a function on the Player
node. In this function, we’ll trigger a new global event the GUI can detect and trigger the inventory pick up.
func _on_PickupRadius_area_entered(area: Area2D) -> void: # Get the area's parent - that's the actual blueprint entity class. var parent: GroundItem = area.get_parent() if parent: # Triggers an event on our event bus pattern about an item getting # picked up. This signal can be connected to by the GUI. Events.emit_signal("entered_pickup_area", parent, self)
In Events.gd
, make sure to add the new signal.
## Signal emitted when the player has arrived at an item that can be picked up signal entered_pickup_area(entity, player)
In the next lesson, we’ll finish this up by adding some helper functions to the GUI to find the nearest available inventory panel that could contain the item and triggering the pickup animation.
Now we’re back in EntityPlacer.gd
and we need to change what happens when we deconstruct an item. We need to check if it has a blueprint it can drop. If it does, we create a new ground item and set it up.
## The ground item packed scene we instance when dropping items var GroundItemScene := preload("res://Entities/GroundItem.tscn") func _unhandled_input(event: InputEvent) -> void: #... # Replace the existing code block where we checked for the "drop" input action with the following. elif event.is_action_pressed("drop") and _gui.blueprint: if is_on_ground: _drop_entity(_gui.blueprint, global_mouse_position) _gui.blueprint = null func _finish_deconstruct(cellv: Vector2) -> void: #... # Get the entity's name so we can check if we have access to a blueprint. var entity_name := Library.get_entity_name_from(entity) # We convert the map position to a global position. var location := map_to_world(cellv) # If we do have a blueprint, we get it as a packed scene. if Library.blueprints.has(entity_name): var Blueprint: PackedScene = Library.blueprints[entity_name] _drop_entity(Blueprint.instance(), location) #_tracker.remove_entity(cellv) #... ## Creates a new ground item with the given blueprint and sets it up at the ## deconstructed entity's location. func _drop_entity(entity: BlueprintEntity, location: Vector2) -> void: # We instance a new ground item, add it, and set it up var ground_item := GroundItemScene.instance() add_child(ground_item) ground_item.setup(entity, location)
If you run the game now and place down some objects, then deconstruct them, you’ll see their little items popping into existence.
You can’t pick them up yet, though. We’ll add that feature in the next lesson. Also, note that you can’t drop them by pressing the Q key either yet.
GroundItem.gd
class_name GroundItem extends Node2D var blueprint: BlueprintEntity onready var collision_shape := $Area2D/CollisionShape2D onready var animation := $AnimationPlayer onready var sprite := $Sprite onready var tween := $Tween func setup(_blueprint: BlueprintEntity, location: Vector2) -> void: blueprint = _blueprint var blueprint_sprite := blueprint.get_node("Sprite") sprite.texture = blueprint_sprite.texture sprite.region_enabled = blueprint_sprite.region_enabled sprite.region_rect = blueprint_sprite.region_rect sprite.centered = blueprint_sprite.centered global_position = location _pop() func do_pickup(target: KinematicBody2D) -> void: var travel_distance := 0.1 collision_shape.set_deferred("disabled", true) while true: var distance_to_target := global_position.distance_to(target.global_position) if distance_to_target < 5.0: break global_position = global_position.move_toward(target.global_position, travel_distance) travel_distance += 0.1 yield(get_tree(), "idle_frame") queue_free() func _pop() -> void: var direction := Vector2.UP.rotated(rand_range(-PI, PI)) direction.y /= 2.0 direction *= rand_range(20, 70) var target_position := global_position + direction var height_position := global_position + direction * Vector2(0.5, 2 * -sign(direction.y)) tween.interpolate_property( self, "global_position", global_position, height_position, 0.15, Tween.TRANS_SINE, Tween.EASE_OUT ) tween.interpolate_property( self, "global_position", height_position, target_position, 0.25, 0, Tween.EASE_IN, 0.15 ) tween.start() yield(tween, "tween_all_completed") animation.play("Float")
Player.gd
extends KinematicBody2D export var movement_speed := 200.0 func _physics_process(_delta: float) -> void: var direction := _get_direction() move_and_slide(direction * movement_speed) func _get_direction() -> Vector2: return Vector2( (Input.get_action_strength("right") - Input.get_action_strength("left")) * 2.0, Input.get_action_strength("down") - Input.get_action_strength("up") ).normalized() func _on_PickupRadius_area_entered(area: Area2D) -> void: var parent: GroundItem = area.get_parent() if parent: Events.emit_signal("entered_pickup_area", parent, self)
Events.gd
extends Node signal entity_placed(entity, cellv) signal entity_removed(entity, cellv) signal systems_ticked(delta) signal entered_pickup_area(entity, player)
EntityPlacer.gd
extends TileMap const MAXIMUM_WORK_DISTANCE := 275.0 const POSITION_OFFSET := Vector2(0,25) const DECONSTRUCT_TIME := 0.3 var GroundItemScene := preload("res://Entities/GroundItem.tscn") var _gui: Control 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 = _gui.blueprint and _gui.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("rotate_blueprint") and _gui.blueprint: _gui.blueprint.rotate_blueprint() elif event.is_action_pressed("drop") and _gui.blueprint: if is_on_ground: _drop_entity(_gui.blueprint, global_mouse_position) _gui.blueprint = null func _process(_delta: float) -> void: var has_placeable_blueprint: bool = _gui.blueprint and _gui.blueprint.placeable if has_placeable_blueprint and not _gui.mouse_in_gui: _move_blueprint_in_world(world_to_map(get_global_mouse_position())) func setup(gui: Control, tracker: EntityTracker, ground: TileMap, flat_entities: YSort, player: KinematicBody2D) -> void: _gui = gui _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: _gui.blueprint.display_as_world_entity() _gui.blueprint.global_position = get_viewport_transform().xform( 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: _gui.blueprint.modulate = Color.white else: _gui.blueprint.modulate = Color.red if _gui.blueprint is WireBlueprint: WireBlueprint.set_sprite_for_direction(_gui.blueprint.sprite, _get_powered_neighbors(cellv)) func _place_entity(cellv: Vector2) -> void: var entity_name := Library.get_entity_name_from(_gui.blueprint) var new_entity: Node2D = Library.entities[entity_name].instance() if _gui.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(_gui.blueprint) _tracker.place_entity(new_entity, cellv) if _gui.blueprint.stack_count == 1: _gui.destroy_blueprint() else: _gui.blueprint.stack_count -= 1 _gui.update_label() 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) var entity_name := Library.get_entity_name_from(entity) var location := map_to_world(cellv) if Library.blueprints.has(entity_name): var Blueprint: PackedScene = Library.blueprints[entity_name] _drop_entity(Blueprint.instance(), location) _tracker.remove_entity(cellv) _update_neighboring_flat_entities(cellv) func _drop_entity(entity: BlueprintEntity, location: Vector2) -> void: var ground_item := GroundItemScene.instance() add_child(ground_item) ground_item.setup(entity, location) 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)