Am I stupid? - Debugging an unorthodox inventory system

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!

Sorry to much to parse ATM. I would start writing unit tests if I were in your shoes. I like GDUnit.

Whoah. SOmebody is typing 18 words a minute. It’s would be nice to actually find out which lines 184 is. Any thoughts?

The exact error is here; however, the erroring value (calculated_grid_cache) is being set a few lines above it.

Too much stuff here. All I can say is use the debugger to single step starting at the initial null check. Failing that I would sprinkle some prints() and debug logic statements. In other words divide and conquer.

yeah, you and Pennyloafers are right, I did really bloat the initial message beyond a reasonable amount. I will take some time to clear my head and then tackle the code again. I’ll document anything major that lets me provide a much more self-contained dilemma here. I’m sorry for sending such a huge message that’s just bad forum etiquette on my part.