feat(inventory): hotbar + inventory UI + 5 crafting recipes

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Floppyrj45
2026-04-19 17:16:13 +02:00
parent af94645d4c
commit d39a55dc45
7 changed files with 524 additions and 3 deletions

View File

@@ -5,21 +5,41 @@ extends Node3D
@onready var hud: CanvasLayer = $Dolphin/HUD
@onready var plankton_follower: Node3D = $PlanktonFollower
var inventory: Inventory = Inventory.new()
var _inventory_ui: CanvasLayer = null
func _ready() -> void:
dolphin.block_break_requested.connect(_on_block_break)
dolphin.block_place_requested.connect(_on_block_place)
dolphin.echolocation_triggered.connect(_on_echolocation)
if dolphin.has_signal("hotbar_scroll"):
dolphin.hotbar_scroll.connect(inventory.scroll_hotbar)
if is_instance_valid(hud) and hud.has_method("connect_to_dolphin"):
hud.connect_to_dolphin(dolphin)
# Load and attach InventoryUI
var ui_scene: PackedScene = load("res://scenes/InventoryUI.tscn")
if ui_scene != null:
_inventory_ui = ui_scene.instantiate() as CanvasLayer
add_child(_inventory_ui)
_inventory_ui.setup(inventory)
else:
push_error("Main: could not load InventoryUI.tscn")
AudioManager.play_ambient_loop("underwater_ambient")
AudioManager.play_music("underwater_theme")
AudioManager.play_whale_call_random()
world.world_seed = 12345
# Starting inventory
inventory.add_item(2, 10) # 10 SAND
inventory.add_item(6, 5) # 5 KELP
inventory.add_item(8, 2) # 2 ICE
func _process(_delta: float) -> void:
world.update_player_position(dolphin.global_position)
@@ -27,15 +47,21 @@ func _process(_delta: float) -> void:
func _on_block_break(hit_position: Vector3, _normal: Vector3) -> void:
var broken_id := world.break_block(hit_position)
var broken_id: int = world.break_block(hit_position)
if broken_id > 0:
inventory.add_item(broken_id, 1)
AudioManager.play_bubble_sfx(hit_position)
func _on_block_place(hit_position: Vector3, normal: Vector3) -> void:
var selected: Variant = inventory.get_selected_item()
if selected == null:
return
if not ItemDatabase.is_placeable(selected["item_id"]):
return
var place_pos := hit_position + normal * 0.5
var current_selected_block := 2
world.place_block(place_pos, current_selected_block)
if world.place_block(place_pos, selected["item_id"]):
inventory.remove_item_from_slot(inventory.selected_hotbar, 1)
func _on_echolocation(position: Vector3, _radius: float) -> void:

View File

@@ -0,0 +1,50 @@
class_name CraftingRecipes
extends RefCounted
static var RECIPES: Array = [
{
"name": "Lampe bioluminescente",
"inputs": [{"item_id": 5, "count": 2}, {"item_id": 6, "count": 1}],
"output": {"item_id": 100, "count": 1}
},
{
"name": "Harpon",
"inputs": [{"item_id": 3, "count": 2}, {"item_id": 7, "count": 2}],
"output": {"item_id": 101, "count": 1}
},
{
"name": "Bulle d'air",
"inputs": [{"item_id": 6, "count": 3}, {"item_id": 8, "count": 1}],
"output": {"item_id": 102, "count": 1}
},
{
"name": "Algue cuisinee",
"inputs": [{"item_id": 6, "count": 2}],
"output": {"item_id": 103, "count": 2}
},
{
"name": "Armure ecailles",
"inputs": [{"item_id": 4, "count": 4}, {"item_id": 7, "count": 2}],
"output": {"item_id": 104, "count": 1}
},
]
static func find_available_recipes(inventory: Inventory) -> Array:
var available: Array = []
for i: int in range(RECIPES.size()):
if inventory.has_items(RECIPES[i]["inputs"]):
available.append(i)
return available
static func craft(inventory: Inventory, recipe_index: int) -> bool:
if recipe_index < 0 or recipe_index >= RECIPES.size():
return false
var recipe: Dictionary = RECIPES[recipe_index]
if not inventory.consume_items(recipe["inputs"]):
return false
var leftover: int = inventory.add_item(recipe["output"]["item_id"], recipe["output"]["count"])
if leftover > 0:
push_warning("CraftingRecipes: craft overflow — %d items lost" % leftover)
return true

View File

@@ -0,0 +1,93 @@
class_name Inventory
extends RefCounted
const MAX_STACK: int = 64
const TOTAL_SLOTS: int = 36
const HOTBAR_SIZE: int = 9
signal inventory_changed
var slots: Array = []
var selected_hotbar: int = 0
func _init() -> void:
slots.resize(TOTAL_SLOTS)
for i: int in range(TOTAL_SLOTS):
slots[i] = null
func add_item(item_id: int, count: int = 1) -> int:
var remaining: int = count
# First pass: stack into existing slots
for i: int in range(TOTAL_SLOTS):
if remaining <= 0:
break
if slots[i] != null and slots[i]["item_id"] == item_id:
var space: int = MAX_STACK - slots[i]["count"]
if space > 0:
var add_amount: int = mini(space, remaining)
slots[i]["count"] += add_amount
remaining -= add_amount
# Second pass: fill empty slots
for i: int in range(TOTAL_SLOTS):
if remaining <= 0:
break
if slots[i] == null:
var add_amount: int = mini(MAX_STACK, remaining)
slots[i] = {"item_id": item_id, "count": add_amount}
remaining -= add_amount
inventory_changed.emit()
return remaining
func remove_item_from_slot(slot_index: int, count: int = 1) -> bool:
if slot_index < 0 or slot_index >= TOTAL_SLOTS:
return false
if slots[slot_index] == null:
return false
if slots[slot_index]["count"] < count:
return false
slots[slot_index]["count"] -= count
if slots[slot_index]["count"] <= 0:
slots[slot_index] = null
inventory_changed.emit()
return true
func get_selected_item() -> Variant:
return slots[selected_hotbar]
func scroll_hotbar(direction: int) -> void:
selected_hotbar = (selected_hotbar + direction + HOTBAR_SIZE) % HOTBAR_SIZE
inventory_changed.emit()
func has_items(requirements: Array) -> bool:
for req: Dictionary in requirements:
var needed: int = req["count"]
for i: int in range(TOTAL_SLOTS):
if slots[i] != null and slots[i]["item_id"] == req["item_id"]:
needed -= slots[i]["count"]
if needed > 0:
return false
return true
func consume_items(requirements: Array) -> bool:
if not has_items(requirements):
return false
for req: Dictionary in requirements:
var to_consume: int = req["count"]
for i: int in range(TOTAL_SLOTS):
if to_consume <= 0:
break
if slots[i] != null and slots[i]["item_id"] == req["item_id"]:
var take: int = mini(slots[i]["count"], to_consume)
slots[i]["count"] -= take
to_consume -= take
if slots[i]["count"] <= 0:
slots[i] = null
inventory_changed.emit()
return true

View File

@@ -0,0 +1,279 @@
extends CanvasLayer
const SLOT_SIZE: int = 48
const SLOT_MARGIN: int = 4
var inventory: Inventory = null
var _hotbar_slots: Array = []
var _inv_slots: Array = []
var _inv_window: Control = null
var _recipe_list: VBoxContainer = null
var _is_open: bool = false
func _ready() -> void:
_build_hotbar()
_build_inventory_window()
func setup(inv: Inventory) -> void:
inventory = inv
inventory.inventory_changed.connect(_refresh)
_refresh()
func _build_hotbar() -> void:
var hotbar_root := HBoxContainer.new()
hotbar_root.name = "HotbarRoot"
hotbar_root.add_theme_constant_override("separation", SLOT_MARGIN)
var total_width: int = SLOT_SIZE * 9 + SLOT_MARGIN * 8
var anchor_node := Control.new()
anchor_node.name = "HotbarAnchor"
anchor_node.set_anchors_preset(Control.PRESET_BOTTOM_WIDE)
anchor_node.custom_minimum_size = Vector2(total_width, SLOT_SIZE + 8)
anchor_node.set_offset(SIDE_BOTTOM, -8)
anchor_node.set_offset(SIDE_TOP, -(SLOT_SIZE + 16))
add_child(anchor_node)
hotbar_root.set_anchors_preset(Control.PRESET_CENTER)
hotbar_root.position = Vector2(-total_width / 2.0, 0)
anchor_node.add_child(hotbar_root)
for i: int in range(9):
var slot := _make_slot_panel(i)
hotbar_root.add_child(slot)
_hotbar_slots.append(slot)
func _make_slot_panel(index: int) -> PanelContainer:
var panel := PanelContainer.new()
panel.custom_minimum_size = Vector2(SLOT_SIZE, SLOT_SIZE)
panel.set_meta("slot_index", index)
var style_normal := StyleBoxFlat.new()
style_normal.bg_color = Color(0.05, 0.1, 0.2, 0.85)
style_normal.border_color = Color(0.0, 0.8, 0.8)
style_normal.set_border_width_all(2)
style_normal.set_corner_radius_all(3)
panel.add_theme_stylebox_override("panel", style_normal)
panel.set_meta("style_normal", style_normal)
var style_selected := StyleBoxFlat.new()
style_selected.bg_color = Color(0.05, 0.1, 0.2, 0.85)
style_selected.border_color = Color(1.0, 0.9, 0.1)
style_selected.set_border_width_all(3)
style_selected.set_corner_radius_all(3)
panel.set_meta("style_selected", style_selected)
var vbox := VBoxContainer.new()
vbox.set_anchors_preset(Control.PRESET_FULL_RECT)
panel.add_child(vbox)
var color_rect := ColorRect.new()
color_rect.name = "ColorRect"
color_rect.custom_minimum_size = Vector2(SLOT_SIZE - 8, SLOT_SIZE - 18)
color_rect.color = Color(0, 0, 0, 0)
vbox.add_child(color_rect)
var count_label := Label.new()
count_label.name = "CountLabel"
count_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
count_label.add_theme_font_size_override("font_size", 10)
count_label.text = ""
vbox.add_child(count_label)
return panel
func _build_inventory_window() -> void:
_inv_window = Control.new()
_inv_window.name = "InventoryWindow"
_inv_window.set_anchors_preset(Control.PRESET_FULL_RECT)
_inv_window.visible = false
add_child(_inv_window)
# Dim background
var bg := ColorRect.new()
bg.set_anchors_preset(Control.PRESET_FULL_RECT)
bg.color = Color(0, 0, 0, 0.55)
_inv_window.add_child(bg)
# Main HBox: left = slots, right = craft panel
var hbox := HBoxContainer.new()
hbox.set_anchors_preset(Control.PRESET_CENTER)
hbox.add_theme_constant_override("separation", 16)
_inv_window.add_child(hbox)
# Center it
hbox.set_offset(SIDE_LEFT, -360)
hbox.set_offset(SIDE_RIGHT, 360)
hbox.set_offset(SIDE_TOP, -240)
hbox.set_offset(SIDE_BOTTOM, 240)
# Left panel: inventory grid + hotbar row
var left_vbox := VBoxContainer.new()
left_vbox.add_theme_constant_override("separation", 8)
hbox.add_child(left_vbox)
var inv_label := Label.new()
inv_label.text = "Inventaire"
inv_label.add_theme_font_size_override("font_size", 14)
left_vbox.add_child(inv_label)
# 3 rows × 9 cols = 27 slots (indices 9..35)
var inv_grid := GridContainer.new()
inv_grid.columns = 9
inv_grid.add_theme_constant_override("h_separation", SLOT_MARGIN)
inv_grid.add_theme_constant_override("v_separation", SLOT_MARGIN)
left_vbox.add_child(inv_grid)
for i: int in range(9, 36):
var slot := _make_slot_panel(i)
inv_grid.add_child(slot)
_inv_slots.append(slot)
# Separator
var sep := HSeparator.new()
left_vbox.add_child(sep)
var hbar_label := Label.new()
hbar_label.text = "Hotbar"
hbar_label.add_theme_font_size_override("font_size", 12)
left_vbox.add_child(hbar_label)
# Hotbar row inside inventory (mirror of bottom hotbar)
var hotbar_grid := HBoxContainer.new()
hotbar_grid.add_theme_constant_override("separation", SLOT_MARGIN)
left_vbox.add_child(hotbar_grid)
for i: int in range(9):
var slot := _make_slot_panel(i)
hotbar_grid.add_child(slot)
_inv_slots.append(slot) # also tracked here for refresh
# Right panel: craft
var right_vbox := VBoxContainer.new()
right_vbox.custom_minimum_size = Vector2(200, 0)
right_vbox.add_theme_constant_override("separation", 6)
hbox.add_child(right_vbox)
var craft_label := Label.new()
craft_label.text = "Craft"
craft_label.add_theme_font_size_override("font_size", 14)
right_vbox.add_child(craft_label)
var scroll := ScrollContainer.new()
scroll.custom_minimum_size = Vector2(200, 380)
right_vbox.add_child(scroll)
_recipe_list = VBoxContainer.new()
_recipe_list.add_theme_constant_override("separation", 6)
scroll.add_child(_recipe_list)
func _refresh() -> void:
if inventory == null:
return
_refresh_hotbar_slots()
if _is_open:
_refresh_inv_slots()
_refresh_recipes()
func _refresh_hotbar_slots() -> void:
for i: int in range(_hotbar_slots.size()):
_update_slot_visual(_hotbar_slots[i], i)
func _refresh_inv_slots() -> void:
# _inv_slots contains: 27 inv slots (9..35) + 9 hotbar mirror (0..8)
for i: int in range(27):
_update_slot_visual(_inv_slots[i], i + 9)
for i: int in range(9):
_update_slot_visual(_inv_slots[27 + i], i)
func _update_slot_visual(panel: PanelContainer, slot_index: int) -> void:
var is_selected: bool = (slot_index == inventory.selected_hotbar and slot_index < 9)
if is_selected:
panel.add_theme_stylebox_override("panel", panel.get_meta("style_selected"))
panel.scale = Vector2(1.1, 1.1)
else:
panel.add_theme_stylebox_override("panel", panel.get_meta("style_normal"))
panel.scale = Vector2(1.0, 1.0)
var slot_data: Variant = inventory.slots[slot_index]
var color_rect: ColorRect = panel.get_node("VBoxContainer/ColorRect")
var count_label: Label = panel.get_node("VBoxContainer/CountLabel")
if slot_data == null:
color_rect.color = Color(0, 0, 0, 0)
count_label.text = ""
else:
color_rect.color = ItemDatabase.get_item_color(slot_data["item_id"])
if slot_data["count"] > 1:
count_label.text = str(slot_data["count"])
else:
count_label.text = ""
func _refresh_recipes() -> void:
for child in _recipe_list.get_children():
child.queue_free()
for i: int in range(CraftingRecipes.RECIPES.size()):
var recipe: Dictionary = CraftingRecipes.RECIPES[i]
var can_craft: bool = inventory.has_items(recipe["inputs"])
var recipe_panel := VBoxContainer.new()
recipe_panel.add_theme_constant_override("separation", 2)
_recipe_list.add_child(recipe_panel)
var name_label := Label.new()
name_label.text = recipe["name"]
name_label.add_theme_font_size_override("font_size", 11)
recipe_panel.add_child(name_label)
# Inputs summary
var inputs_text: String = ""
for inp: Dictionary in recipe["inputs"]:
inputs_text += "%s x%d " % [ItemDatabase.get_item_name(inp["item_id"]), inp["count"]]
var inp_label := Label.new()
inp_label.text = inputs_text.strip_edges()
inp_label.add_theme_font_size_override("font_size", 9)
inp_label.modulate = Color(0.7, 0.7, 0.7)
recipe_panel.add_child(inp_label)
var craft_btn := Button.new()
craft_btn.text = "Creer"
craft_btn.disabled = not can_craft
var idx: int = i
craft_btn.pressed.connect(func() -> void: _on_craft_pressed(idx))
recipe_panel.add_child(craft_btn)
var sep := HSeparator.new()
_recipe_list.add_child(sep)
func _on_craft_pressed(recipe_index: int) -> void:
CraftingRecipes.craft(inventory, recipe_index)
func _input(event: InputEvent) -> void:
if event.is_action_pressed("toggle_inventory"):
_toggle_inventory()
get_viewport().set_input_as_handled()
func _toggle_inventory() -> void:
_is_open = not _is_open
_inv_window.visible = _is_open
if _is_open:
get_tree().paused = true
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
_refresh_inv_slots()
_refresh_recipes()
else:
get_tree().paused = false
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED

View File

@@ -0,0 +1,65 @@
extends Node
const ITEM_NAMES: Dictionary = {
0: "Air",
1: "Water",
2: "Sand",
3: "Rock",
4: "Coral Rouge",
5: "Coral Bleu",
6: "Kelp",
7: "Epave",
8: "Glace",
9: "Bedrock",
100: "Lampe Bio",
101: "Harpon",
102: "Bulle d'air",
103: "Algue cuisinee",
104: "Armure ecailles",
}
const ITEM_COLORS: Dictionary = {
0: Color(0, 0, 0, 0),
1: Color(0.1, 0.3, 0.8, 0.5),
2: Color(0.76, 0.70, 0.50),
3: Color(0.45, 0.45, 0.45),
4: Color(0.9, 0.2, 0.2),
5: Color(0.2, 0.4, 0.9),
6: Color(0.1, 0.6, 0.1),
7: Color(0.55, 0.35, 0.15),
8: Color(0.7, 0.9, 1.0),
9: Color(0.15, 0.15, 0.15),
100: Color(1.0, 0.95, 0.2),
101: Color(0.6, 0.6, 0.6),
102: Color(0.2, 0.9, 0.9),
103: Color(0.05, 0.4, 0.05),
104: Color(0.1, 0.55, 0.5),
}
const PLACEABLE_IDS: Array = [2, 3, 4, 5, 6, 7, 8]
const CONSUMABLE_IDS: Array = [102, 103]
const TOOL_IDS: Array = [100, 101, 104]
func get_item_name(id: int) -> String:
if ITEM_NAMES.has(id):
return ITEM_NAMES[id]
return "Inconnu"
func get_item_color(id: int) -> Color:
if ITEM_COLORS.has(id):
return ITEM_COLORS[id]
return Color(0.5, 0.5, 0.5)
func is_placeable(id: int) -> bool:
return id in PLACEABLE_IDS
func is_consumable(id: int) -> bool:
return id in CONSUMABLE_IDS
func is_tool(id: int) -> bool:
return id in TOOL_IDS