As we’ve seen in the previous lesson, we have a working inventory. We can move stacks of items around, split them, and swap them with one another. But:
In this lesson, we’ll address these issues.
The BlueprintEntity
previews don’t fit in the InventoryPanel
because we sized them to fit cells in the game world, not in the inventory.
We need to add a way to switch preview for the blueprints between the inventory and the world.
Whenever we move the blueprint in the inventory, it should turn into a small icon, changing its size and position offset. When we place it over the world’s grid, it should look the way it is now.
To do so, we’ll offset the node’s position and change its size on the fly. Another valid solution would be to have two previews for the blueprint and show and hide them as the mouse cursor hovers or leaves the inventory. Either would work.
It would also be nice to have a spot in the project where we can set the inventory slots’ size for any script to access it without requiring a reference to InventoryPanel
.
We can use the project settings to store values that are accessible project-wide. Godot’s settings window not only allows you to update predefined options, but you can also add any settings you’d like and access them in code.
Open the Project Settings… window. At the top of the editor, enter game_gui
in Category, inventory_size
in Property, set the Type to float
and click Add. This will create a setting accessible under the path game_gui/inventory_size
in our code. We can access it from any script, anytime.
The window should jump to your new setting. I set the size to 75
pixels.
Now, we can use this new setting to size blueprints, panels, and fix the label. Open BlueprintEntity.gd
and add the following functions.
## Sets the position and scale of the blueprint to fit inventory panels and hides ## world-specific sprites like the power direction indicators. func display_as_inventory_icon() -> void: # We can retrieve the panel size from the project settings by calling `ProjectSettings.get_setting()` # It takes a path to the property as its argument, which works like node property paths. var panel_size: float = ProjectSettings.get_setting("game_gui/inventory_size") # Set the position. Horizontally, it's halfway across. Vertically, # we move the graphics and collision so that the machine's origin is on the # tile's floor. With our isometric graphic style, this corresponds to 75% of the height. position = Vector2(panel_size * 0.5, panel_size * 0.75) # The sprites for blueprints are 100x100, so the scale is the desired size divided by 100. scale = Vector2(panel_size / 100.0, panel_size / 100.0) # We modulate the blueprint when it's in an invalid location, but we don't want it to stay # that color in the inventory. So we reset the modulate color to white. modulate = Color.white # In the inventory, we need to hide the power indicator if this node has one. if _power_direction: _power_direction.hide() ## This function resets the blueprint's scale, position offset, and power indicators to display ## in the game world. func display_as_world_entity() -> void: scale = Vector2.ONE position = Vector2.ZERO if _power_direction: _power_direction.show()
We need a place to call this mode change for the inventory. We do our inventory management inside of InventoryPanel.gd
, so open that script. In the held_item
setter, _set_held_item()
, we call display_as_inventory_icon()
on the held_item
when we add it as a child.
func _set_held_item(value: BlueprintEntity) -> void: #... if held_item: #... held_item.display_as_inventory_icon()
This looks much better.
The label for the stack size is in the top left in a default position. It looks fine in the inventory, but it ends up under the mouse cursor when we pick up an item. You can still read it, but it’s still a little thing we can fix.
Go to InventoryPanel.tscn
and select the Label
. In the Inspector, set its Align property to Right. That way, if we make the label bounding box bigger, the text will grow in from the right. Also, apply the Layout -> Top Right option to it and offset the label from the parent’s bounding box a bit. This will anchor it in the panel’s top-right corner.
Go to the InventoryPanel.gd
script. We can use the game_gui/inventory_size
setting to force the panel size to follow our global setting. We’ll also use it for the mouse’s preview.
func _ready() -> void: var panel_size: float = ProjectSettings.get_setting("game_gui/inventory_size") # Force the panel's size and min size to match the project setting. rect_min_size = Vector2(panel_size, panel_size) rect_size = rect_min_size
To fix it for the mouse’s inventory, go to DragPreview.tscn
and select the Label
. Set its Align property to Right, and apply Layout -> Top Right to the node.
Then, open DragPreview.gd
and repeat what we’ve done in the InventoryPanel.gd
’s _ready()
function.
# func _ready() -> void: #... var panel_size: float = ProjectSettings.get_setting("game_gui/inventory_size") rect_min_size = Vector2(panel_size, panel_size) rect_size = rect_min_size
You can now make the inventory as big or as small as you like by changing the project settings.
And the label is no longer obscured by the mouse.
At the moment, we generate blueprints and store them in the EntityPlacer
with keyboard shortcuts.
But we now have a working inventory, so we should get rid of the temporary code that’s in EntityPlacer
and replace the placer’s blueprint variable with the mouse’s inventory.
Head to EntityPlacer.gd
, and let’s start with some spring cleaning. Get rid of the elif event.is_action_pressed("quickbar_...")
blocks in _unhandled_input()
. We don’t need them anymore.
To access the mouse’s inventory, we need the GUI
node since it’s the one that has a getter for DragPreview
. Add a new _gui: Control
variable, and inside of setup()
, add a new parameter for the GUI to store it there.
var _gui: Control func setup( # Don't forget to add the `gui` argument! gui: Control, #... ) -> void: _gui = gui #...
In turn, we need to get the GUI node in Simulation.gd
and to pass it along to EntityPlacer
.
onready var _gui := $CanvasLayer/GUI func _ready() -> void: #$Timer.start(simulation_speed) _entity_placer.setup(_gui, _tracker, _ground, _flat_entities, _player) #...
Now, in EntityPlacer.gd
, wherever we refer to the _blueprint
variable, we can replace it with _gui.blueprint
and remove the temporary _blueprint
variable.
Remove the line that says var _blueprint: BlueprintEntity
at the top of the script and press Ctrl+R to open the script editor’s search and replace function.
In the first field, type in _blueprint
, in the second _gui.blueprint
. Make sure to turn on the Whole Words option on the right so the tool doesn’t replace variable names like has_placeable_blueprint
!
Click Replace All to fix all the errors.
Now, if you run the game, you’ll notice something is wrong. The blueprint isn’t the right size, and it’s floating some distance away from the mouse.
We can fix the size by calling display_as_world_entity()
on the blueprint. But what about this strange position offset? The reason this is happening is because of the CanvasLayer
node we added for the user interface.
When the blueprint was a child of EntityPlacer
, its global position took the camera into account. But under a separate CanvasLayer
, there is no camera to influence it. We need to take the camera-modified position and transform it back into coordinates relative to the viewport.
Thankfully, we can get the current viewport’s Transform2D
to do that using get_viewport_transform()
, and use Transform2D
’s xform()
function. The xform()
function transforms coordinates using this transform matrix, converting them from the CanvasLayer
’s coordinate system to the root viewport’s.
At the start of EntityPlacer._move_blueprint_in_world()
, replace the line where we set the blueprint’s global position with the following.
func _move_blueprint_in_world(cellv: Vector2) -> void: # Set the blueprint's position and scale back to origin _gui.blueprint.display_as_world_entity() # Snap the blueprint's position to the mouse with an offset, transformed into # viewport coordinates using `Transform2D.xform()`. _gui.blueprint.global_position = get_viewport_transform().xform( map_to_world(cellv) + POSITION_OFFSET ) #...
You can now grab one of your items and place it in the world, so long as you move the cursor outside the inventory window.
That brings us to three more problems:
It feels like we’re playing whack-a-mole, doesn’t it? This is typical of UI programming, where we have to tackle many details.
Like we did in the InventoryPanel
script, when we place an entity, we need to reduce the stack count and destroy the blueprint when its number falls to 0. In EntityPlacer
’s _place_entity()
, let’s do that and detect the case of having none left.
Add the following lines at the bottom of the function.
func _place_entity(cellv: Vector2) -> void: #... #_tracker.place_entity(new_entity, cellv) if _gui.blueprint.stack_count == 1: _gui.destroy_blueprint() else: _gui.blueprint.stack_count -= 1 _gui.update_label()
With that, when you place all the items you have in a stack, the blueprint should disappear completely.
Let’s add a keyboard input to toggle the inventory window’s visibility.
Go into GUI.tscn
and hide the InventoryWindow
node. Open up GUI.gd
and add a new function for _unhandled_input()
.
Instead of only showing and hiding the inventory window, we create helper functions that we’ll extend later as we get into crafting and automation.
## If `true`, it means the GUI window is open. onready var _is_open: bool = $HBoxContainer/InventoryWindow.visible func _unhandled_input(event: InputEvent) -> void: if event.is_action_pressed("toggle_inventory"): if _is_open: _close_inventories() else: _open_inventories() ## Shows the inventory window, crafting window func _open_inventories() -> void: _is_open = true player_inventory.visible = true ## Hides the inventory window, crafting window, and any currently open machine GUI func _close_inventories() -> void: _is_open = false player_inventory.visible = false
The functions look simple and maybe unnecessary for now. However, we’ll write more complex rules for them later: we’ll need to open crafting menus and close machine GUIs on top of the main inventory. So they’ll get bigger.
At least, now, you can press E to toggle the inventory’s visibility.
We want to address one last issue here: the blueprint snaps to the world grid even when our mouse cursor is over the inventory window.
This is because of the _process()
function in EntityPlacer.gd
. Every frame, whether it’s in inventory or not, we force the blueprint to update its position and snap to the grid.
We do this to keep the blueprint snapped to the grid even when the camera moves.
To fix this, we need a way of detecting when the mouse is over the inventory window. In GUI.gd
, we need to get the parent that holds the inventory window. In _process()
, we can do a bit of checking to test for if the mouse is currently in the GUI.
## If `true`, it means the mouse is over the `GUI` at the moment. var mouse_in_gui := false #... ## The parent container that holds the inventory window onready var _gui_rect := $HBoxContainer func _process(delta: float) -> void: var mouse_position := get_global_mouse_position() # if the mouse is inside the GUI rect and the GUI is open, set it true. mouse_in_gui = _is_open and _gui_rect.get_rect().has_point(mouse_position)
And back in EntityPlacer.gd
’s _process()
function, we can add an extra check for this new boolean that’s part of GUI.
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()))
And now, the blueprint will move fluidly when hovering the mouse, and snap to the grid when the mouse is over the game’s tilemap.
The last thing to fix is that the blueprint still looks like a world entity if it goes outside the window even once. We can fix that inside of DragPreview.gd
’s _input()
function. When it detects mouse movement, we can ensure the blueprint is an inventory icon and not a world entity.
func _input(event: InputEvent) -> void: if event is InputEventMouseMotion: if blueprint: blueprint.display_as_inventory_icon() #rect_global_position = event.global_position
Now, it should look correct in all cases.
Here are the complete scripts we modified in this lesson.
BlueprintEntity.gd
class_name BlueprintEntity extends Node2D export var placeable := true export var stack_size := 1 var stack_count := 1 onready var _power_direction := find_node("PowerDirection") func rotate_blueprint() -> void: if not _power_direction: return var directions: int = _power_direction.output_directions var new_directions := 0 if directions & Types.Direction.LEFT != 0: new_directions |= Types.Direction.UP if directions & Types.Direction.UP != 0: new_directions |= Types.Direction.RIGHT if directions & Types.Direction.RIGHT != 0: new_directions |= Types.Direction.DOWN if directions & Types.Direction.DOWN != 0: new_directions |= Types.Direction.LEFT _power_direction.output_directions = new_directions func display_as_inventory_icon() -> void: var panel_size: float = ProjectSettings.get_setting("game_gui/inventory_size") position = Vector2(panel_size * 0.5, panel_size * 0.75) scale = Vector2(panel_size / 100.0, panel_size / 100.0) modulate = Color.white if _power_direction: _power_direction.hide() func display_as_world_entity() -> void: scale = Vector2.ONE position = Vector2.ZERO if _power_direction: _power_direction.show()
InventoryPanel.gd
class_name InventoryPanel extends Panel signal held_item_changed(panel, item) var held_item: BlueprintEntity setget _set_held_item var gui: Control onready var count_label := $Label func _ready() -> void: var panel_size: float = ProjectSettings.get_setting("game_gui/inventory_size") # Force the panel's size and min size to match the project setting and apply # the same size to the label. rect_min_size = Vector2(panel_size, panel_size) rect_size = rect_min_size func _gui_input(event: InputEvent) -> void: var left_click := event.is_action_pressed("left_click") var right_click := event.is_action_pressed("right_click") if not (left_click or right_click): return if gui.blueprint: var blueprint_name := Library.get_entity_name_from(gui.blueprint) if held_item: var held_item_name := Library.get_entity_name_from(held_item) var item_is_same_type: bool = held_item_name == blueprint_name var stack_has_space: bool = held_item.stack_count < held_item.stack_size if item_is_same_type and stack_has_space: if left_click: _stack_items() elif right_click: _stack_items(true) else: if left_click: _swap_items() else: if left_click: _grab_item() elif right_click: if gui.blueprint.stack_count > 1: _grab_split_items() else: _grab_item() elif held_item: if left_click: _release_item() elif right_click: if held_item.stack_count == 1: _release_item() else: _split_items() func setup(_gui: Control) -> void: gui = _gui func _set_held_item(value: BlueprintEntity) -> void: if held_item and held_item.get_parent() == self: remove_child(held_item) held_item = value if held_item: add_child(held_item) move_child(held_item, 0) held_item.display_as_inventory_icon() _update_label() emit_signal("held_item_changed", self, held_item) func _update_label() -> void: var can_be_stacked := held_item and held_item.stack_size > 1 if can_be_stacked: count_label.text = str(held_item.stack_count) count_label.show() else: count_label.text = str(1) count_label.hide() func _stack_items(split := false) -> void: var count := int( min( gui.blueprint.stack_count / (2 if split else 1), held_item.stack_size - held_item.stack_count ) ) if split: gui.blueprint.stack_count -= count gui.update_label() else: if count < gui.blueprint.stack_count: gui.blueprint.stack_count -= count gui.update_label() else: gui.destroy_blueprint() held_item.stack_count += count _update_label() func _swap_items() -> void: var item: BlueprintEntity = gui.blueprint gui.blueprint = null var current_item := held_item self.held_item = item gui.blueprint = current_item func _grab_item() -> void: var item: BlueprintEntity = gui.blueprint gui.blueprint = null self.held_item = item func _release_item() -> void: var item := held_item self.held_item = null gui.blueprint = item func _split_items() -> void: var count := int(held_item.stack_count / 2.0) var new_stack := held_item.duplicate() new_stack.stack_count = count held_item.stack_count -= count gui.blueprint = new_stack _update_label() func _grab_split_items() -> void: var count := int(gui.blueprint.stack_count / 2.0) var new_stack: BlueprintEntity = gui.blueprint.duplicate() new_stack.stack_count = count gui.blueprint.stack_count -= count gui.update_label() self.held_item = new_stack
DragPreview.gd
extends Control var blueprint: BlueprintEntity setget _set_blueprint onready var count_label := $Label func _ready() -> void: set_as_toplevel(true) var panel_size: float = ProjectSettings.get_setting("game_gui/inventory_size") rect_min_size = Vector2(panel_size, panel_size) rect_size = rect_min_size func _input(event: InputEvent) -> void: if event is InputEventMouseMotion: if blueprint: blueprint.display_as_inventory_icon() rect_global_position = event.global_position func update_label() -> void: if blueprint and blueprint.stack_size > 1: count_label.text = str(blueprint.stack_count) count_label.show() else: count_label.hide() func destroy_blueprint() -> void: if blueprint: remove_child(blueprint) blueprint.queue_free() blueprint = null update_label() func _set_blueprint(value: BlueprintEntity) -> void: if blueprint and blueprint.get_parent() == self: remove_child(blueprint) blueprint = value if blueprint: add_child(blueprint) move_child(blueprint, 0) update_label()
EntityPlacer.gd
extends TileMap const MAXIMUM_WORK_DISTANCE := 275.0 const POSITION_OFFSET := Vector2(0,25) const DECONSTRUCT_TIME := 0.3 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("drop") and _gui.blueprint: remove_child(_gui.blueprint) _gui.blueprint = null elif event.is_action_pressed("rotate_blueprint") and _gui.blueprint: _gui.blueprint.rotate_blueprint() 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) _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)
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() onready var _gui := $CanvasLayer/GUI func _ready() -> void: $Timer.start(simulation_speed) _entity_placer.setup(_gui, _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)
GUI.gd
extends CenterContainer var blueprint: BlueprintEntity setget _set_blueprint, _get_blueprint var mouse_in_gui := false onready var _is_open: bool = $HBoxContainer/InventoryWindow.visible onready var player_inventory := $HBoxContainer/InventoryWindow onready var _drag_preview := $DragPreview onready var _gui_rect := $HBoxContainer func _ready() -> void: player_inventory.setup(self) func _unhandled_input(event: InputEvent) -> void: if event.is_action_pressed("toggle_inventory"): if _is_open: _close_inventories() else: _open_inventories() func _process(delta: float) -> void: var mouse_position := get_global_mouse_position() mouse_in_gui = _is_open and _gui_rect.get_rect().has_point(mouse_position) func destroy_blueprint() -> void: _drag_preview.destroy_blueprint() func update_label() -> void: _drag_preview.update_label() func _set_blueprint(value: BlueprintEntity) -> void: if not is_inside_tree(): yield(self, "ready") _drag_preview.blueprint = value func _get_blueprint() -> BlueprintEntity: return _drag_preview.blueprint func _open_inventories() -> void: _is_open = true player_inventory.visible = true func _close_inventories() -> void: _is_open = false player_inventory.visible = false