From d39a55dc4581e36eece110bddce62ef329f2cfc9 Mon Sep 17 00:00:00 2001 From: Floppyrj45 Date: Sun, 19 Apr 2026 17:16:13 +0200 Subject: [PATCH] feat(inventory): hotbar + inventory UI + 5 crafting recipes Co-Authored-By: Claude Sonnet 4.6 --- project.godot | 1 + scenes/InventoryUI.tscn | 7 + scripts/Main.gd | 32 ++- scripts/inventory/CraftingRecipes.gd | 50 +++++ scripts/inventory/Inventory.gd | 93 +++++++++ scripts/inventory/InventoryUI.gd | 279 +++++++++++++++++++++++++++ scripts/inventory/ItemDatabase.gd | 65 +++++++ 7 files changed, 524 insertions(+), 3 deletions(-) create mode 100644 scenes/InventoryUI.tscn create mode 100644 scripts/inventory/CraftingRecipes.gd create mode 100644 scripts/inventory/Inventory.gd create mode 100644 scripts/inventory/InventoryUI.gd create mode 100644 scripts/inventory/ItemDatabase.gd diff --git a/project.godot b/project.godot index ac520d3..4720d43 100644 --- a/project.godot +++ b/project.godot @@ -90,6 +90,7 @@ escape={ [autoload] BlockDatabase="*res://scripts/world/BlockDatabase.gd" AudioManager="*res://scripts/ambience/AudioManager.gd" +ItemDatabase="*res://scripts/inventory/ItemDatabase.gd" [rendering] environment/defaults/default_clear_color=Color(0.05, 0.15, 0.25, 1) diff --git a/scenes/InventoryUI.tscn b/scenes/InventoryUI.tscn new file mode 100644 index 0000000..6d397e3 --- /dev/null +++ b/scenes/InventoryUI.tscn @@ -0,0 +1,7 @@ +[gd_scene load_steps=2 format=3 uid="uid://dauphincraft_inventoryui"] + +[ext_resource type="Script" path="res://scripts/inventory/InventoryUI.gd" id="1_invui"] + +[node name="InventoryUI" type="CanvasLayer"] +layer = 10 +script = ExtResource("1_invui") diff --git a/scripts/Main.gd b/scripts/Main.gd index a45234e..93d7268 100644 --- a/scripts/Main.gd +++ b/scripts/Main.gd @@ -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: diff --git a/scripts/inventory/CraftingRecipes.gd b/scripts/inventory/CraftingRecipes.gd new file mode 100644 index 0000000..8668c86 --- /dev/null +++ b/scripts/inventory/CraftingRecipes.gd @@ -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 diff --git a/scripts/inventory/Inventory.gd b/scripts/inventory/Inventory.gd new file mode 100644 index 0000000..5e615da --- /dev/null +++ b/scripts/inventory/Inventory.gd @@ -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 diff --git a/scripts/inventory/InventoryUI.gd b/scripts/inventory/InventoryUI.gd new file mode 100644 index 0000000..54f4f47 --- /dev/null +++ b/scripts/inventory/InventoryUI.gd @@ -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 diff --git a/scripts/inventory/ItemDatabase.gd b/scripts/inventory/ItemDatabase.gd new file mode 100644 index 0000000..a7329e8 --- /dev/null +++ b/scripts/inventory/ItemDatabase.gd @@ -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