Godot Version
4.3 / 4.2
Question
Hi! My game features a relatively unorthodox inventory system similar to Dredge or Resident Evil (sometimes generically referred to as a ‘Tetris’ inventory). To my knowledge, my system stands out a little among those examples by featuring a Hotbar, and boy 'o boy is it giving me trouble. Firstly, it’s important to have the context that items added to the Hotbar still take up space within the inventory, this is intended. Currently, the biggest and most pressing bug that I’ve yet to fully solve after a few hours of debugging is that, when you try to set an item into an inventory slot that is already occupied by an item, the item being added to the inventory snaps to the grid space(s) of the item last moved, here is a video!
Alright here are the scripts that are essential to my dilemma:
The inventory.gd script (essentially the grid code and item pickup):
extends Control
@export var inventory_size = 150
@onready var slot_scene = preload("res://UI/Inventory/inventory_slot.tscn")
@onready var grid_container = $ColorRect/MarginContainer/VBoxContainer/ScrollContainer/GridContainer
@onready var item_scene = preload("res://UI/Inventory/item.tscn")
@onready var scroll_container = $ColorRect/MarginContainer/VBoxContainer/ScrollContainer
@onready var col_count = grid_container.columns
var grid_array := []
var item_held = null
var current_slot = null
var can_place := false
var icon_anchor : Vector2
var inventory_open := false
var item_force_highlighted:= false
var calculated_grid_cache
var current_slot_cache
signal inventory_opened()
signal inventory_closed()
signal report_inventory_hotbar(hotbar : Array)
# Called when the node enters the scene tree for the first time.
func _ready():
MenuHandler.change_state.connect(_on_menu_handler_change_state)
MenuHandler.main()
for i in range(inventory_size):
create_slot()
for i in $Container.get_children():
i.remove_item.connect(_remove_item)
i.highlight_item.connect(_highlight_item)
InventoryData.report_held_item.connect(held_item_report)
InventoryData.force_held_item.connect(force_held_item)
func _process(delta):
if item_held:
if Input.is_action_just_pressed("rotate item"):
rotate_item()
if Input.is_action_just_pressed("select"):
if scroll_container.get_global_rect().has_point(get_global_mouse_position()):
place_item()
else:
if Input.is_action_just_pressed(("select")):
if scroll_container.get_global_rect().has_point(get_global_mouse_position()):
pick_item()
func create_slot():
var new_slot = slot_scene.instantiate()
new_slot.slot_ID = grid_array.size()
grid_container.add_child(new_slot)
grid_array.push_back(new_slot)
new_slot.slot_entered.connect(_on_slot_mouse_entered)
new_slot.slot_exited.connect(_on_slot_mouse_exited)
pass
func _on_slot_mouse_entered(a_Slot):
#arbitrary values because they are always supposed to be changed before being used for calculations
icon_anchor = Vector2(10000,100000)
current_slot = a_Slot
if item_held:
check_slot_availability(current_slot)
set_grids.call_deferred(current_slot)
func _on_slot_mouse_exited(a_Slot):
clear_grid()
if not grid_container.get_global_rect().has_point(get_global_mouse_position()):
current_slot = null
func _on_button_spawn_pressed():
var new_item = item_scene.instantiate()
add_child(new_item)
new_item.load_item(InventoryData.get_random_item()) #randomize this for different items to spawn
new_item.selected = true
item_held = new_item
InventoryData.is_item_held = true
InventoryData.item_held = item_held.get_item()
func check_slot_availability(a_Slot):
for grid in item_held.item_grids:
var grid_to_check = a_Slot.slot_ID + grid[0] + grid[1] * col_count
var line_switch_check = a_Slot.slot_ID % col_count + grid[0]
if line_switch_check < 0 or line_switch_check >= col_count:
can_place = false
return
if grid_to_check < 0 or grid_to_check >= grid_array.size():
can_place = false
return
if grid_array[grid_to_check].state == grid_array[grid_to_check].States.TAKEN ||grid_array[grid_to_check].state == grid_array[grid_to_check].States.HOTBAR:
can_place = false
return
can_place = true
func set_grids(a_Slot):
for grid in item_held.item_grids:
var grid_to_check = a_Slot.slot_ID + grid[0] + grid[1] * col_count
if grid_to_check < 0 or grid_to_check >= grid_array.size():
continue
#make sure the check don't wrap around boarders
var line_switch_check = a_Slot.slot_ID % col_count + grid[0]
if line_switch_check <0 or line_switch_check >= col_count:
continue
if can_place:
grid_array[grid_to_check].set_color(grid_array[grid_to_check].States.FREE)
#save anchor for snapping
if grid[1] < icon_anchor.x: icon_anchor.x = grid[1]
if grid[0] < icon_anchor.y: icon_anchor.y = grid[0]
else:
grid_array[grid_to_check].set_color(grid_array[grid_to_check].States.TAKEN)
func clear_grid():
for grid in grid_array:
grid.set_color(grid.States.DEFAULT)
func rotate_item():
item_held.rotate_item()
clear_grid()
if current_slot:
_on_slot_mouse_entered(current_slot)
func place_item():
if not can_place or not current_slot:
return #put indication of placement failed, sound or visual here
#for changing scene tree
item_held.get_parent().remove_child(item_held)
grid_container.add_child(item_held)
item_held.global_position = get_global_mouse_position()
####
var calculated_grid_id = current_slot.slot_ID + icon_anchor.x * col_count + icon_anchor.y
item_held._snap_to(grid_array[calculated_grid_id].global_position)
item_held.grid_anchor = current_slot
current_slot_cache = item_held.grid_anchor
item_held.icon_anchor_cache = item_held.grid_anchor
for grid in item_held.item_grids:
var grid_to_check = current_slot.slot_ID + grid[0] + grid[1] * col_count
grid_array[grid_to_check].state = grid_array[grid_to_check].States.TAKEN
grid_array[grid_to_check].item_stored = item_held
#put item into a data storage here
item_held = null
InventoryData.is_item_held = false
InventoryData.item_held = null
clear_grid()
func pick_item():
if not current_slot or not current_slot.item_stored:
return
item_held = current_slot.item_stored
item_held.selected = true
InventoryData.is_item_held = true
InventoryData.item_held = item_held.get_item()
InventoryData.grid_cache = calculated_grid_cache
item_held.rotation_cache = item_held.rotation
item_held.get_parent().remove_child(item_held)
add_child(item_held)
item_held.global_position = get_global_mouse_position()
####
for grid in item_held.item_grids:
var grid_to_check = item_held.grid_anchor.slot_ID + grid[0] + grid[1] * col_count # use grid anchor instead of current slot to prevent bug
grid_array[grid_to_check].state = grid_array[grid_to_check].States.FREE
grid_array[grid_to_check].item_stored = null
check_slot_availability(current_slot)
set_grids.call_deferred(current_slot)
func _on_add_slot_pressed():
create_slot()
func held_item_report():
pass
func _remove_item():
item_held.get_parent().remove_child(item_held)
grid_container.add_child(item_held)
if (calculated_grid_cache != InventoryData.grid_cache) && InventoryData.grid_cache != null:
calculated_grid_cache = InventoryData.grid_cache
while item_held.rotation != item_held.rotation_cache:
item_held.rotate_item()
clear_grid()
item_held.grid_anchor = item_held.icon_anchor_cache
item_held._snap_to(grid_array[calculated_grid_cache].global_position)
for grid in item_held.item_grids:
var grid_to_check = current_slot_cache.slot_ID + grid[0] + grid[1] * col_count
grid_array[grid_to_check].state = grid_array[grid_to_check].States.TAKEN
grid_array[grid_to_check].item_stored = item_held
item_held = null
clear_grid()
func _highlight_item(a_Slot):
pass
# if item_force_highlighted:
# for i in a_Slot
# grid_array.find(a_Slot)
func force_held_item():
pass
func _on_menu_handler_change_state(MenuState: Variant) -> void:
if MenuState == MenuHandler.MenuStates.INVENTORY:
set_visible(true)
set_mouse_filter(Control.MOUSE_FILTER_STOP)
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
else:
set_visible(false)
set_mouse_filter(Control.MOUSE_FILTER_IGNORE)
The hotbar slots for the inventory, inventory_hotbar_slots.gd:
extends TextureButton
@onready var hovered_slot = preload("res://Assets/Hotbar Icons/Selected Hotbar Icon.svg")
@onready var slot = preload("res://Assets/Hotbar Icons/Hotbar Icon.svg")
@onready var item = preload("res://UI/Inventory/item.tscn")
@onready var item_texture: TextureRect = $item_texture
var grid_cache
var slot_item : ITEM
signal remove_item()
signal highlight_item(a_item)
signal changed(item_slot)
# Called when the node enters the scene tree for the first time.
# Called every frame. 'delta' is the elapsed time since the previous frame.
func _input(event: InputEvent) -> void:
if event.is_action_released("select"):
if get_global_rect().has_point(get_global_mouse_position()):
if InventoryData.is_item_held == true && slot_item == null:
InventoryData.is_item_held = false
grid_cache = InventoryData.grid_cache
slot_item = InventoryData.item_held
item_texture.texture = slot_item.ITEM_TEXTURE_HOTBAR
InventoryData.item_held = null
remove_item.emit()
elif slot_item != null && InventoryData.is_item_held == false:
slot_item = null
item_texture.texture = null
elif InventoryData.is_item_held == true && slot_item != null && slot_item != InventoryData.item_held:
slot_item = InventoryData.item_held
item_texture.texture = slot_item.ITEM_TEXTURE_HOTBAR
InventoryData.item_held = null
InventoryData.is_item_held = false
InventoryData.grid_cache = grid_cache
remove_item.emit()
changed.emit(self)
func _on_mouse_entered() -> void:
if slot_item != null:
highlight_item.emit(slot_item)
func _on_mouse_exited() -> void:
if slot_item != null:
highlight_item.emit(slot_item)
The slot controller, inventory_hotbar.gd:
extends BoxContainer
var inventory_array : Array
var counter : int
func _ready() -> void:
inventory_array.resize(9)
inventory_array = get_children()
for i in get_children():
i.connect("changed", update_hotbar)
func update_hotbar(slot):
var index_to_change = inventory_array.find(slot)
InventoryData.hotbar_data[index_to_change] = inventory_array[index_to_change].slot_item
InventoryData.hotbar_updated.emit(index_to_change)
The inventory singleton (Inventory_Data.gd):
extends Node
class_name inventory_data
var hotbar_data : Array[ITEM]
var dir_name := "res://Systems and Logic/item/repo/"
var dir := DirAccess.open(dir_name)
var file_names : Array
var is_item_held : bool
var item_held : ITEM
var grid_cache
signal report_held_item()
signal highlight_grid(a_slot)
signal hotbar_updated()
signal force_held_item()
# Called when the node enters the scene tree for the first time.
func _ready() -> void:
#we need to load the player's hotbar here, isaac
hotbar_data.resize(9)
file_names = dir.get_files()
# Called every frame. 'delta' is the elapsed time since the previous frame.
func get_random_item() -> ITEM:
var random_file : String
random_file = dir_name + file_names.pick_random()
return load(random_file)
func highlight():
highlight_grid.emit()
The code for the item node itself, item.gd:
extends Node2D
@onready var IconRect_path = $Icon
var item_ID : int
var item_grids := []
var selected = false
var grid_anchor = null
var current_item : ITEM
var rotation_cache
var icon_anchor_cache
# Called when the node enters the scene tree for the first time.
func _ready():
pass
# Called every frame. 'delta' is the elapsed time since the previous frame.
func _process(delta):
if selected:
global_position = lerp(global_position, get_global_mouse_position(), 25 * delta)
func load_item(a_Item : ITEM) -> void:
IconRect_path.texture = a_Item.ITEM_TEXTURE_GRID
current_item = a_Item
$Area2D/CollisionShape2D.scale = Vector2(IconRect_path.texture.get_width(), IconRect_path.texture.get_height())
for grid in a_Item.ITEM_GRID:
var converter_array := []
converter_array.append(int(grid.x))
converter_array.append(int(grid.y))
item_grids.push_back(converter_array)
#print(item_grids)
#rotate 90 degress CW
func rotate_item():
for grid in item_grids:
var temp_y = grid[0]
grid[0] = -grid[1]
grid[1] = temp_y
rotation_degrees += 90
if rotation_degrees>=360:
rotation_degrees = 0
func _snap_to(destination):
var tween = get_tree().create_tween()
#separate cases to avoid snapping errors
if int(rotation_degrees) % 180 == 0:
destination += IconRect_path.size/2
else:
var temp_xy_switch = Vector2(IconRect_path.size.y,IconRect_path.size.x)
destination += temp_xy_switch/2
tween.tween_property(self, "global_position", destination, 0.15).set_trans(Tween.TRANS_SINE)
selected = false
func get_item() -> ITEM:
return current_item
Now the actual error I am getting currently is that after explicitly checking if a value is null (InventoryData.grid_cache), it somehow is null by the time it is used a handful of lines later. This results in an error at line 184 of the inventory.gd script. I am not entirely sure that solving this issue would fully eliminate my overarching bug, but its a step in the right direction.
I have a previous GitHub commit that features the same basic logic error; however, it is free of my attempts to fix it. which is both good and bad depending on how you want to look at it, I will provide it upon request.
Any help would be greatly appreciated!