From d3f1058d791b01e0c5dd682ea76a51bb3c39c197 Mon Sep 17 00:00:00 2001 From: yvmou_ Date: Sat, 12 Sep 2026 22:18:23 +0800 Subject: [PATCH] =?UTF-8?q?=E6=A8=A1=E6=9D=BF=E8=84=9A=E6=9C=AC=E6=96=87?= =?UTF-8?q?=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/autoload/item_database.gd | 179 +++++ scripts/autoload/item_database.gd.uid | 1 + scripts/autoload/network.gd | 202 +++++ scripts/autoload/network.gd.uid | 1 + scripts/inventory/inventory_slot.gd | 57 ++ scripts/inventory/inventory_slot.gd.uid | 1 + scripts/inventory/item.gd | 27 + scripts/inventory/item.gd.uid | 1 + scripts/inventory/player_inventory.gd | 267 +++++++ scripts/inventory/player_inventory.gd.uid | 1 + scripts/items/item_rigid_body_3d.gd | 4 + scripts/items/item_rigid_body_3d.gd.uid | 1 + scripts/level/level.gd | 487 ++++++++++++ scripts/level/level.gd.uid | 1 + scripts/player/3d_godot_robot.gd | 42 ++ scripts/player/3d_godot_robot.gd.uid | 1 + scripts/player/player.gd | 872 ++++++++++++++++++++++ scripts/player/player.gd.uid | 1 + scripts/player/spring_arm_offset.gd | 86 +++ scripts/player/spring_arm_offset.gd.uid | 1 + scripts/ui/inventory_slot_ui.gd | 147 ++++ scripts/ui/inventory_slot_ui.gd.uid | 1 + scripts/ui/inventory_ui.gd | 422 +++++++++++ scripts/ui/inventory_ui.gd.uid | 1 + scripts/ui/main_menu_ui.gd | 71 ++ scripts/ui/main_menu_ui.gd.uid | 1 + scripts/ui/multiplayer_chat_ui.gd | 155 ++++ scripts/ui/multiplayer_chat_ui.gd.uid | 1 + scripts/ui/pause_menu_ui.gd | 57 ++ scripts/ui/pause_menu_ui.gd.uid | 1 + scripts/ui/player_list_ui.gd | 109 +++ scripts/ui/player_list_ui.gd.uid | 1 + 32 files changed, 3200 insertions(+) create mode 100644 scripts/autoload/item_database.gd create mode 100644 scripts/autoload/item_database.gd.uid create mode 100644 scripts/autoload/network.gd create mode 100644 scripts/autoload/network.gd.uid create mode 100644 scripts/inventory/inventory_slot.gd create mode 100644 scripts/inventory/inventory_slot.gd.uid create mode 100644 scripts/inventory/item.gd create mode 100644 scripts/inventory/item.gd.uid create mode 100644 scripts/inventory/player_inventory.gd create mode 100644 scripts/inventory/player_inventory.gd.uid create mode 100644 scripts/items/item_rigid_body_3d.gd create mode 100644 scripts/items/item_rigid_body_3d.gd.uid create mode 100644 scripts/level/level.gd create mode 100644 scripts/level/level.gd.uid create mode 100644 scripts/player/3d_godot_robot.gd create mode 100644 scripts/player/3d_godot_robot.gd.uid create mode 100644 scripts/player/player.gd create mode 100644 scripts/player/player.gd.uid create mode 100644 scripts/player/spring_arm_offset.gd create mode 100644 scripts/player/spring_arm_offset.gd.uid create mode 100644 scripts/ui/inventory_slot_ui.gd create mode 100644 scripts/ui/inventory_slot_ui.gd.uid create mode 100644 scripts/ui/inventory_ui.gd create mode 100644 scripts/ui/inventory_ui.gd.uid create mode 100644 scripts/ui/main_menu_ui.gd create mode 100644 scripts/ui/main_menu_ui.gd.uid create mode 100644 scripts/ui/multiplayer_chat_ui.gd create mode 100644 scripts/ui/multiplayer_chat_ui.gd.uid create mode 100644 scripts/ui/pause_menu_ui.gd create mode 100644 scripts/ui/pause_menu_ui.gd.uid create mode 100644 scripts/ui/player_list_ui.gd create mode 100644 scripts/ui/player_list_ui.gd.uid diff --git a/scripts/autoload/item_database.gd b/scripts/autoload/item_database.gd new file mode 100644 index 0000000..12f6a32 --- /dev/null +++ b/scripts/autoload/item_database.gd @@ -0,0 +1,179 @@ +extends Node + +const FEDORA_ICON: Texture2D = preload("res://assets/items/hats/icons/fedora.png") +const HEADPHONES_ICON: Texture2D = preload("res://assets/items/hats/icons/headphones.png") +const PIRATE_HAT_ICON: Texture2D = preload("res://assets/items/hats/icons/pirate_hat.png") +const SHERIFF_HAT_ICON: Texture2D = preload("res://assets/items/hats/icons/sheriff_hat.png") +const SOMBRERO_ICON: Texture2D = preload("res://assets/items/hats/icons/sombrero.png") +const WIZARD_HAT_ICON: Texture2D = preload("res://assets/items/hats/icons/wizard_hat.png") +const BACKPACK_ICON: Texture2D = preload("res://assets/items/backpacks/icons/backpack.png") +const SWORD_ICON: Texture2D = preload("res://assets/items/weapons/icons/sword.png") +const SWORD_BIG_ICON: Texture2D = preload("res://assets/items/weapons/icons/sword_big.png") +const AXE_ICON: Texture2D = preload("res://assets/items/weapons/icons/axe.png") +const CHICKEN_LEG_ICON: Texture2D = preload("res://assets/items/misc/icons/chicken_leg.png") +const BONE_ICON: Texture2D = preload("res://assets/items/misc/icons/bone.png") +const CHALICE_ICON: Texture2D = preload("res://assets/items/misc/icons/chalice.png") + +var items: Dictionary = {} + + +func _ready(): + _create_sample_items() + + +func get_item(item_id: String) -> Item: + return items.get(item_id) + + +func get_all_items() -> Dictionary: + return items + + +func _create_sample_items(): + _create_hat_item("fedora", "Fedora", "A timeless felt fedora.", "res://scenes/items/hats/fedora.tscn", FEDORA_ICON) + _create_hat_item( + "headphones", + "Headphones", + "Comfortable over-ear headphones.", + "res://scenes/items/hats/headphones.tscn", + HEADPHONES_ICON + ) + _create_hat_item( + "pirate_hat", + "Pirate Hat", + "A weathered hat for a daring pirate.", + "res://scenes/items/hats/pirate_hat.tscn", + PIRATE_HAT_ICON + ) + _create_hat_item( + "sheriff_hat", + "Sheriff Hat", + "A frontier hat with a sheriff badge.", + "res://scenes/items/hats/sheriff_hat.tscn", + SHERIFF_HAT_ICON + ) + _create_hat_item( + "sombrero", "Sombrero", "A broad and colorful sombrero.", "res://scenes/items/hats/sombrero.tscn", SOMBRERO_ICON + ) + _create_hat_item( + "wizard_hat", + "Wizard Hat", + "A pointed hat filled with arcane style.", + "res://scenes/items/hats/wizard_hat.tscn", + WIZARD_HAT_ICON + ) + _create_item( + "backpack", + "Backpack", + "A sturdy backpack worn on the back.", + Item.ItemType.BACKPACK, + "res://scenes/items/backpacks/backpack.tscn", + BACKPACK_ICON, + true, + 60 + ) + _create_item( + "sword", + "Sword", + "A balanced hand sword.", + Item.ItemType.WEAPON, + "res://scenes/items/weapons/sword.tscn", + SWORD_ICON, + true, + 80 + ) + _create_item( + "sword_big", + "Big Sword", + "A large two-handed sword.", + Item.ItemType.WEAPON, + "res://scenes/items/weapons/sword_big.tscn", + SWORD_BIG_ICON, + true, + 120 + ) + _create_item( + "axe", + "Small Axe", + "A compact axe with a sharp steel head.", + Item.ItemType.WEAPON, + "res://scenes/items/weapons/axe.tscn", + AXE_ICON, + true, + 95 + ) + _create_item( + "chicken_leg", + "Chicken Leg", + "A cooked chicken leg.", + Item.ItemType.MISC, + "res://scenes/items/misc/chicken_leg.tscn", + CHICKEN_LEG_ICON, + false, + 5 + ) + _create_item( + "bone", + "Bone", + "A weathered bone.", + Item.ItemType.MISC, + "res://scenes/items/misc/bone.tscn", + BONE_ICON, + false, + 2 + ) + _create_item( + "chalice", + "Chalice", + "A decorative golden chalice.", + Item.ItemType.MISC, + "res://scenes/items/misc/chalice.tscn", + CHALICE_ICON, + false, + 35 + ) + + +func _create_item( + item_id: String, + item_name: String, + item_description: String, + item_type: Item.ItemType, + scene_path: String, + item_icon: Texture2D, + equipable: bool, + item_value: int +) -> void: + var item := Item.new() + item.id = item_id + item.name = item_name + item.description = item_description + item.item_type = item_type + item.rarity = Item.ItemRarity.COMMON + item.stackable = not equipable + item.max_stack = 99 if item.stackable else 1 + item.value = item_value + item.icon = item_icon + item.scene_path = scene_path + if equipable: + item.context_options.append(Item.ContextOptions.EQUIP) + item.context_options.append(Item.ContextOptions.DROP) + items[item.id] = item + + +func _create_hat_item( + item_id: String, item_name: String, item_description: String, scene_path: String, item_icon: Texture2D +) -> void: + var hat := Item.new() + hat.id = item_id + hat.name = item_name + hat.description = item_description + hat.item_type = Item.ItemType.HAT + hat.rarity = Item.ItemRarity.COMMON + hat.stackable = false + hat.value = 25 + hat.icon = item_icon + hat.scene_path = scene_path + hat.context_options.append(Item.ContextOptions.EQUIP) + hat.context_options.append(Item.ContextOptions.DROP) + items[hat.id] = hat diff --git a/scripts/autoload/item_database.gd.uid b/scripts/autoload/item_database.gd.uid new file mode 100644 index 0000000..73c023d --- /dev/null +++ b/scripts/autoload/item_database.gd.uid @@ -0,0 +1 @@ +uid://bsraoqq3rst1j diff --git a/scripts/autoload/network.gd b/scripts/autoload/network.gd new file mode 100644 index 0000000..9271f70 --- /dev/null +++ b/scripts/autoload/network.gd @@ -0,0 +1,202 @@ +extends Node + +signal player_connected(peer_id, player_info) +signal server_disconnected + +const SERVER_ADDRESS: String = "127.0.0.1" +const SERVER_PORT: int = 8080 +const MAX_PLAYERS: int = 10 +const MAX_NICK_LENGTH := 24 +const MAX_ADDRESS_LENGTH := 253 + +var players = {} +var player_info = {"nick": "host", "skin": Character.SkinColor.BLUE} +var _session_active := false + + +func _ready() -> void: + multiplayer.server_disconnected.connect(_on_server_disconnected) + multiplayer.connection_failed.connect(_on_connection_failed) + multiplayer.peer_disconnected.connect(_on_player_disconnected) + multiplayer.peer_connected.connect(_on_player_connected) + multiplayer.connected_to_server.connect(_on_connected_ok) + + +func start_host(nickname: String, skin_color_str: String): + var peer = ENetMultiplayerPeer.new() + var error = peer.create_server(SERVER_PORT, MAX_PLAYERS) + if error: + return error + + peer.host.compress(ENetConnection.COMPRESS_RANGE_CODER) + multiplayer.multiplayer_peer = peer + _session_active = true + + player_info["nick"] = sanitize_nickname(nickname, "Host_" + str(multiplayer.get_unique_id())) + player_info["skin"] = skin_str_to_e(skin_color_str) + + if DisplayServer.get_name() == "headless": + return + + players[1] = player_info + player_connected.emit(1, player_info) + + +func join_game(nickname: String, skin_color_str: String, address: String = SERVER_ADDRESS): + address = sanitize_address(address) + if address.is_empty(): + return ERR_INVALID_PARAMETER + + var peer = ENetMultiplayerPeer.new() + var error = peer.create_client(address, SERVER_PORT) + if error: + return error + + peer.host.compress(ENetConnection.COMPRESS_RANGE_CODER) + multiplayer.multiplayer_peer = peer + _session_active = true + + player_info["nick"] = sanitize_nickname(nickname, "Player_" + str(multiplayer.get_unique_id())) + player_info["skin"] = skin_str_to_e(skin_color_str) + + +func _on_connected_ok(): + var peer_id = multiplayer.get_unique_id() + players[peer_id] = player_info + player_connected.emit(peer_id, player_info) + _register_player.rpc_id(1, player_info) + + +func _on_player_connected(id): + if not multiplayer.is_server(): + return + for peer_id in players: + _sync_registered_player.rpc_id(id, peer_id, players[peer_id]) + + +@rpc("any_peer", "reliable") +func _register_player(new_player_info): + if not multiplayer.is_server(): + return + if not (new_player_info is Dictionary): + return + var new_player_id = multiplayer.get_remote_sender_id() + if new_player_id == 0: + return + if players.has(new_player_id): + return + var sanitized_info = sanitize_player_info(new_player_info, "Player_" + str(new_player_id)) + players[new_player_id] = sanitized_info + player_connected.emit(new_player_id, sanitized_info) + _sync_registered_player.rpc(new_player_id, sanitized_info) + + +func _on_player_disconnected(id): + players.erase(id) + + +func _on_connection_failed(): + _finish_session() + + +func _on_server_disconnected(): + _finish_session() + + +func leave_game() -> void: + var peer := multiplayer.multiplayer_peer + if peer: + peer.close() + _finish_session() + + +func _finish_session() -> void: + var had_session := _session_active or multiplayer.multiplayer_peer != null or not players.is_empty() + _session_active = false + multiplayer.multiplayer_peer = null + players.clear() + if had_session: + server_disconnected.emit() + + +func skin_str_to_e(s): + match str(s).strip_edges().to_lower(): + "blue": + return Character.SkinColor.BLUE + "yellow": + return Character.SkinColor.YELLOW + "green": + return Character.SkinColor.GREEN + "red": + return Character.SkinColor.RED + _: + return Character.SkinColor.BLUE + + +@rpc("authority", "reliable") +func _sync_registered_player(peer_id: int, registered_player_info: Dictionary): + if multiplayer.is_server(): + return + if players.has(peer_id): + return + var sanitized_info = sanitize_player_info(registered_player_info, "Player_" + str(peer_id)) + players[peer_id] = sanitized_info + player_connected.emit(peer_id, sanitized_info) + + +func sanitize_player_info(info: Dictionary, fallback_nick: String) -> Dictionary: + return { + "nick": sanitize_nickname(str(info.get("nick", "")), fallback_nick), + "skin": sanitize_skin_value(info.get("skin", Character.SkinColor.BLUE)) + } + + +func sanitize_nickname(nickname: String, fallback: String) -> String: + var clean := "" + var last_was_space := false + for i in range(nickname.length()): + var codepoint := nickname.unicode_at(i) + if codepoint <= 31 or codepoint == 127: + if not last_was_space: + clean += " " + last_was_space = true + continue + + var character := nickname.substr(i, 1) + if character == " ": + if last_was_space: + continue + last_was_space = true + else: + last_was_space = false + clean += character + + clean = clean.strip_edges() + if clean.is_empty(): + clean = fallback.strip_edges() + if clean.length() > MAX_NICK_LENGTH: + clean = clean.substr(0, MAX_NICK_LENGTH).strip_edges() + if clean.is_empty(): + clean = "Player" + return clean + + +func sanitize_address(address: String) -> String: + var clean = address.strip_edges() + if clean.is_empty(): + return SERVER_ADDRESS + if clean.length() > MAX_ADDRESS_LENGTH: + return "" + if clean.contains("://") or clean.contains("/") or clean.contains("\\") or clean.contains(":"): + return "" + return clean + + +func sanitize_skin_value(value) -> Character.SkinColor: + if value is int: + match value: + Character.SkinColor.BLUE, Character.SkinColor.YELLOW, Character.SkinColor.GREEN, Character.SkinColor.RED: + return value + _: + return Character.SkinColor.BLUE + return skin_str_to_e(str(value)) diff --git a/scripts/autoload/network.gd.uid b/scripts/autoload/network.gd.uid new file mode 100644 index 0000000..5d7ca85 --- /dev/null +++ b/scripts/autoload/network.gd.uid @@ -0,0 +1 @@ +uid://cedubc87s78fo diff --git a/scripts/inventory/inventory_slot.gd b/scripts/inventory/inventory_slot.gd new file mode 100644 index 0000000..006d04f --- /dev/null +++ b/scripts/inventory/inventory_slot.gd @@ -0,0 +1,57 @@ +class_name InventorySlot +extends RefCounted + +var item_id: String = "" +var quantity: int = 0 + + +func is_empty() -> bool: + return item_id.is_empty() or quantity <= 0 + + +func add_item(item: Item, amount: int = 1) -> int: + var max_quantity = item.max_stack if item.stackable else 1 + if is_empty(): + item_id = item.id + quantity = min(amount, max_quantity) + return amount - quantity + if item_id == item.id and item.stackable: + var space_available = item.max_stack - quantity + var amount_to_add = min(amount, space_available) + quantity += amount_to_add + return amount - amount_to_add + return amount + + +func remove_item(amount: int = 1) -> int: + var removed = min(amount, quantity) + quantity -= removed + if quantity <= 0: + clear() + return removed + + +func clear() -> void: + item_id = "" + quantity = 0 + + +func to_dict() -> Dictionary: + return {"item_id": item_id, "quantity": quantity} + + +func from_dict(data: Dictionary) -> void: + item_id = data.get("item_id", "") + quantity = data.get("quantity", 0) + + if is_empty(): + clear() + return + + var item = ItemDatabase.get_item(item_id) + if not item: + clear() + return + + var max_quantity = item.max_stack if item.stackable else 1 + quantity = clampi(quantity, 1, max_quantity) diff --git a/scripts/inventory/inventory_slot.gd.uid b/scripts/inventory/inventory_slot.gd.uid new file mode 100644 index 0000000..15a27af --- /dev/null +++ b/scripts/inventory/inventory_slot.gd.uid @@ -0,0 +1 @@ +uid://c7dfwkeylor2j diff --git a/scripts/inventory/item.gd b/scripts/inventory/item.gd new file mode 100644 index 0000000..2ceb54b --- /dev/null +++ b/scripts/inventory/item.gd @@ -0,0 +1,27 @@ +class_name Item +extends Resource + +enum ItemType { WEAPON, CONSUMABLE, TOOL, MISC, HAT, BACKPACK } + +enum ItemRarity { COMMON, UNCOMMON, RARE, EPIC, LEGENDARY } + +enum ContextOptions { + DROP, + EQUIP, + UNEQUIP, +} + +@export var id: String = "" +@export var name: String = "" +@export var description: String = "" +@export var icon: Texture2D + +@export var stackable: bool = true +@export var max_stack: int = 99 + +@export var item_type: ItemType = ItemType.MISC +@export var rarity: ItemRarity = ItemRarity.COMMON +@export var value: int = 0 +@export var context_options: Array[Item.ContextOptions] = [] + +@export var scene_path: String = "" diff --git a/scripts/inventory/item.gd.uid b/scripts/inventory/item.gd.uid new file mode 100644 index 0000000..002ec4b --- /dev/null +++ b/scripts/inventory/item.gd.uid @@ -0,0 +1 @@ +uid://bbq3lkpgmwr1t diff --git a/scripts/inventory/player_inventory.gd b/scripts/inventory/player_inventory.gd new file mode 100644 index 0000000..5ad7fb1 --- /dev/null +++ b/scripts/inventory/player_inventory.gd @@ -0,0 +1,267 @@ +class_name PlayerInventory +extends RefCounted + +const BASE_INVENTORY_SIZE := 16 +const BACKPACK_BONUS_SLOTS := 4 +const MAX_INVENTORY_SIZE := BASE_INVENTORY_SIZE + BACKPACK_BONUS_SLOTS +var slots: Array[InventorySlot] = [] +var equipped_weapon: InventorySlot = InventorySlot.new() +var equipped_hat: InventorySlot = InventorySlot.new() +var equipped_backpack: InventorySlot = InventorySlot.new() + + +func _init(): + _initialize_slots() + + +func _initialize_slots(): + slots.clear() + for i in range(MAX_INVENTORY_SIZE): + slots.append(InventorySlot.new()) + + +func get_active_slot_count() -> int: + return MAX_INVENTORY_SIZE if not equipped_backpack.is_empty() else BASE_INVENTORY_SIZE + + +func is_slot_active(index: int) -> bool: + return index >= 0 and index < get_active_slot_count() + + +func get_slot(index: int) -> InventorySlot: + if index >= 0 and index < slots.size(): + return slots[index] + return null + + +func get_equipped_slot(item_type: Item.ItemType) -> InventorySlot: + match item_type: + Item.ItemType.WEAPON: + return equipped_weapon + Item.ItemType.HAT: + return equipped_hat + Item.ItemType.BACKPACK: + return equipped_backpack + _: + return null + + +func equip_from_slot(index: int, item_type: Item.ItemType) -> bool: + if not is_slot_active(index): + return false + + var backpack_slot: InventorySlot = get_slot(index) + var equipped_slot: InventorySlot = get_equipped_slot(item_type) + if not backpack_slot or backpack_slot.is_empty() or not equipped_slot: + return false + + var item: Item = ItemDatabase.get_item(backpack_slot.item_id) + if not item or item.item_type != item_type: + return false + + var new_item_id: String = backpack_slot.item_id + var new_quantity: int = backpack_slot.quantity + backpack_slot.item_id = equipped_slot.item_id + backpack_slot.quantity = equipped_slot.quantity + equipped_slot.item_id = new_item_id + equipped_slot.quantity = new_quantity + if backpack_slot.is_empty(): + backpack_slot.clear() + return true + + +func unequip_to_slot(item_type: Item.ItemType, destination_index: int = -1) -> bool: + var equipped_slot: InventorySlot = get_equipped_slot(item_type) + if not equipped_slot or equipped_slot.is_empty(): + return false + if item_type == Item.ItemType.BACKPACK: + return _unequip_backpack_to_slot(equipped_slot, destination_index) + + if destination_index < 0: + destination_index = get_first_empty_slot() + if not is_slot_active(destination_index): + return false + var destination: InventorySlot = get_slot(destination_index) + if not destination or not destination.is_empty(): + return false + + destination.item_id = equipped_slot.item_id + destination.quantity = equipped_slot.quantity + equipped_slot.clear() + return true + + +func _unequip_backpack_to_slot(equipped_slot: InventorySlot, destination_index: int) -> bool: + if destination_index < 0: + destination_index = _get_first_empty_base_slot() + if destination_index < 0 or destination_index >= BASE_INVENTORY_SIZE: + return false + + var destination := get_slot(destination_index) + if not destination or not destination.is_empty(): + return false + + var occupied_bonus_slots: Array[int] = [] + for i in range(BASE_INVENTORY_SIZE, MAX_INVENTORY_SIZE): + if not slots[i].is_empty(): + occupied_bonus_slots.append(i) + + var available_base_slots: Array[int] = [] + for i in range(BASE_INVENTORY_SIZE): + if i != destination_index and slots[i].is_empty(): + available_base_slots.append(i) + if occupied_bonus_slots.size() > available_base_slots.size(): + return false + + for i in range(occupied_bonus_slots.size()): + var source := slots[occupied_bonus_slots[i]] + var target := slots[available_base_slots[i]] + target.item_id = source.item_id + target.quantity = source.quantity + source.clear() + + destination.item_id = equipped_slot.item_id + destination.quantity = equipped_slot.quantity + equipped_slot.clear() + return true + + +func _get_first_empty_base_slot() -> int: + for i in range(BASE_INVENTORY_SIZE): + if slots[i].is_empty(): + return i + return -1 + + +func add_item(item: Item, quantity: int = 1) -> int: + var remaining = quantity + var active_slot_count := get_active_slot_count() + + if item.stackable: + for i in range(active_slot_count): + var slot := slots[i] + if slot.item_id == item.id: + remaining = slot.add_item(item, remaining) + if remaining <= 0: + break + + if remaining > 0: + for i in range(active_slot_count): + var slot := slots[i] + if slot.is_empty(): + remaining = slot.add_item(item, remaining) + if remaining <= 0: + break + + return remaining + + +func remove_item(item_id: String, quantity: int = 1) -> int: + var removed = 0 + for i in range(get_active_slot_count()): + var slot := slots[i] + if slot.item_id == item_id: + var slot_removed = slot.remove_item(quantity - removed) + removed += slot_removed + if removed >= quantity: + break + return removed + + +func move_item(from_index: int, to_index: int, quantity: int = -1) -> bool: + if ( + from_index == to_index + or not is_slot_active(from_index) + or not is_slot_active(to_index) + or quantity == 0 + or quantity < -1 + ): + return false + + var from_slot = get_slot(from_index) + var to_slot = get_slot(to_index) + + if not from_slot or not to_slot or from_slot.is_empty(): + return false + + var move_amount = from_slot.quantity if quantity == -1 else quantity + move_amount = min(move_amount, from_slot.quantity) + + var item = ItemDatabase.get_item(from_slot.item_id) + if not item: + return false + + var remaining = to_slot.add_item(item, move_amount) + var moved_amount = move_amount - remaining + if moved_amount > 0: + from_slot.remove_item(moved_amount) + + return moved_amount > 0 + + +func swap_items(from_index: int, to_index: int) -> bool: + if not is_slot_active(from_index) or not is_slot_active(to_index): + return false + + var from_slot = get_slot(from_index) + var to_slot = get_slot(to_index) + + if not from_slot or not to_slot: + return false + + if from_slot.item_id == to_slot.item_id and not from_slot.is_empty() and not to_slot.is_empty(): + var item = ItemDatabase.get_item(from_slot.item_id) + if item and item.stackable: + var total_quantity = from_slot.quantity + to_slot.quantity + if total_quantity <= item.max_stack: + to_slot.quantity = total_quantity + from_slot.clear() + return true + var space_available = item.max_stack - to_slot.quantity + var amount_to_move = min(space_available, from_slot.quantity) + to_slot.quantity += amount_to_move + from_slot.quantity -= amount_to_move + if from_slot.quantity <= 0: + from_slot.clear() + return true + + var temp_item_id = from_slot.item_id + var temp_quantity = from_slot.quantity + + from_slot.item_id = to_slot.item_id + from_slot.quantity = to_slot.quantity + + to_slot.item_id = temp_item_id + to_slot.quantity = temp_quantity + + return true + + +func get_first_empty_slot() -> int: + for i in range(get_active_slot_count()): + if slots[i].is_empty(): + return i + return -1 + + +func to_dict() -> Dictionary: + var data = [] + for slot in slots: + data.append(slot.to_dict()) + return { + "slots": data, + "equipped_weapon": equipped_weapon.to_dict(), + "equipped_hat": equipped_hat.to_dict(), + "equipped_backpack": equipped_backpack.to_dict() + } + + +func from_dict(data: Dictionary) -> void: + var slots_data = data.get("slots", []) + for i in range(min(slots_data.size(), slots.size())): + slots[i].from_dict(slots_data[i]) + for i in range(slots_data.size(), slots.size()): + slots[i].clear() + equipped_weapon.from_dict(data.get("equipped_weapon", {})) + equipped_hat.from_dict(data.get("equipped_hat", {})) + equipped_backpack.from_dict(data.get("equipped_backpack", {})) diff --git a/scripts/inventory/player_inventory.gd.uid b/scripts/inventory/player_inventory.gd.uid new file mode 100644 index 0000000..54e8924 --- /dev/null +++ b/scripts/inventory/player_inventory.gd.uid @@ -0,0 +1 @@ +uid://dncl3vbyhhee3 diff --git a/scripts/items/item_rigid_body_3d.gd b/scripts/items/item_rigid_body_3d.gd new file mode 100644 index 0000000..d5e599a --- /dev/null +++ b/scripts/items/item_rigid_body_3d.gd @@ -0,0 +1,4 @@ +class_name ItemRigidBody3D +extends RigidBody3D + +@export var item_id: String = "" diff --git a/scripts/items/item_rigid_body_3d.gd.uid b/scripts/items/item_rigid_body_3d.gd.uid new file mode 100644 index 0000000..f0c7956 --- /dev/null +++ b/scripts/items/item_rigid_body_3d.gd.uid @@ -0,0 +1 @@ +uid://co2jpexoehl55 diff --git a/scripts/level/level.gd b/scripts/level/level.gd new file mode 100644 index 0000000..4c5a054 --- /dev/null +++ b/scripts/level/level.gd @@ -0,0 +1,487 @@ +extends Node3D + +const MAX_CHAT_MESSAGE_LENGTH := 160 +const MIN_NICKNAME_HEIGHT := 2.0 +const MAX_NICKNAME_HEIGHT := 8.0 + +@export var player_scene: PackedScene + +var chat_visible := false +var inventory_visible := false +var player_list_visible := false + +var _player_nickname_heights: Dictionary = {} +var _nickname_heights_requested := false +var _nickname_height_requesters: Dictionary = {} + +@onready var players_container: Node3D = $PlayersContainer +@onready var main_menu: MainMenuUI = $MainMenuUI +@onready var multiplayer_chat: MultiplayerChatUI = $MultiplayerChatUI +@onready var inventory_ui: InventoryUI = $InventoryUI +@onready var player_list_ui: PlayerListUI = $PlayerListUI +@onready var pause_menu: PauseMenuUI = $PauseMenuUI + + +func _ready(): + after_ready() + + if DisplayServer.get_name() == "headless": + Network.start_host("", "") + + pause_menu.hide_menu() + main_menu.show_menu() + multiplayer_chat.set_process_input(true) + + main_menu.host_pressed.connect(_on_host_pressed) + main_menu.join_pressed.connect(_on_join_pressed) + main_menu.quit_pressed.connect(_on_quit_pressed) + pause_menu.resume_pressed.connect(_on_pause_resume_pressed) + pause_menu.main_menu_pressed.connect(_on_pause_main_menu_pressed) + pause_menu.quit_pressed.connect(_on_pause_quit_pressed) + + if inventory_ui: + inventory_ui.inventory_closed.connect(_on_inventory_closed) + + if multiplayer_chat: + multiplayer_chat.message_sent.connect(_on_chat_message_sent) + + Network.server_disconnected.connect(_on_server_disconnected) + Network.connect("player_connected", Callable(self, "_on_player_connected")) + multiplayer.peer_disconnected.connect(_remove_player) + _update_mouse_mode() + + +func _process(_delta: float) -> void: + var can_show_player_list := ( + not main_menu.is_menu_visible() and not pause_menu.is_menu_visible() and multiplayer.has_multiplayer_peer() + ) + if Input.is_key_pressed(KEY_TAB) and can_show_player_list: + if not player_list_visible: + _show_player_list() + elif player_list_visible: + _hide_player_list() + + +func after_ready(): + var ip_address: String + if OS.has_feature("windows"): + if OS.has_environment("COMPUTERNAME"): + ip_address = IP.resolve_hostname(str(OS.get_environment("COMPUTERNAME")), IP.TYPE_IPV4) + elif OS.has_feature("x11"): + if OS.has_environment("HOSTNAME"): + ip_address = IP.resolve_hostname(str(OS.get_environment("HOSTNAME")), IP.TYPE_IPV4) + elif OS.has_feature("OSX"): + if OS.has_environment("HOSTNAME"): + ip_address = IP.resolve_hostname(str(OS.get_environment("HOSTNAME")), IP.TYPE_IPV4) + main_menu.address_input.text = ip_address + + +func _on_server_disconnected(): + _reset_session_ui() + + +func _reset_session_ui() -> void: + for child in players_container.get_children(): + child.queue_free() + + var loot_root := get_node_or_null("Environment/ItemContainer") + if loot_root: + for child in loot_root.get_children(): + child.queue_free() + + chat_visible = false + inventory_visible = false + _hide_player_list() + _player_nickname_heights.clear() + _nickname_heights_requested = false + _nickname_height_requesters.clear() + multiplayer_chat.close_chat() + multiplayer_chat.clear_chat() + if inventory_ui: + inventory_ui.close_inventory() + inventory_ui.current_player = null + _hide_pause_menu(false) + main_menu.show_menu() + _update_mouse_mode() + + +func _on_player_connected(peer_id, player_info): + var player = _add_player(peer_id, player_info) + if multiplayer.is_server() and player: + player.call_deferred("_sync_inventory_to_owner") + if multiplayer.is_server(): + if peer_id != 1: + call_deferred("_sync_nickname_heights_to_peer", peer_id) + elif not _nickname_heights_requested: + _nickname_heights_requested = true + call_deferred("_request_nickname_heights") + _refresh_player_list() + + +func _on_host_pressed(nickname: String, skin: String): + var error = Network.start_host(nickname, skin) + if error: + push_warning("Failed to host game. Error: " + str(error)) + main_menu.show_menu() + _update_mouse_mode() + return + main_menu.hide_menu() + _update_mouse_mode() + + +func _on_join_pressed(nickname: String, skin: String, address: String): + var error = Network.join_game(nickname, skin, address) + if error: + push_warning("Failed to join game. Error: " + str(error)) + main_menu.show_menu() + _update_mouse_mode() + return + main_menu.hide_menu() + _update_mouse_mode() + + +func _add_player(id: int, player_info: Dictionary) -> Character: + if DisplayServer.get_name() == "headless" and id == 1: + return null + + if players_container.has_node(str(id)): + return players_container.get_node(str(id)) as Character + + var player = player_scene.instantiate() + player.name = str(id) + player.position = get_spawn_point(id) + players_container.add_child(player, true) + + var nick = Network.sanitize_nickname(str(player_info.get("nick", "")), "Player_" + str(id)) + player.nickname.text = nick + + var skin_enum = Network.sanitize_skin_value(player_info.get("skin", Character.SkinColor.BLUE)) + player.set_player_skin(skin_enum) + _apply_player_nickname_height(id) + return player + + +func get_spawn_point(id: int) -> Vector3: + var spawn_angle := fmod(float(id) * 2.399963229728653, 2.0 * PI) + var spawn_point := Vector2.from_angle(spawn_angle) * 10 + return Vector3(spawn_point.x, 0, spawn_point.y) + + +func _remove_player(id): + _player_nickname_heights.erase(id) + if not players_container.has_node(str(id)): + call_deferred("_refresh_player_list") + return + var player_node = players_container.get_node(str(id)) + if player_node: + player_node.queue_free() + call_deferred("_refresh_player_list") + + +func _on_quit_pressed() -> void: + Network.leave_game() + get_tree().quit() + + +func toggle_chat(): + if main_menu.is_menu_visible() or is_gameplay_input_blocked(): + return + + multiplayer_chat.toggle_chat() + chat_visible = multiplayer_chat.is_chat_visible() + _update_mouse_mode() + + +func is_chat_visible() -> bool: + return multiplayer_chat.is_chat_visible() + + +func _input(event): + if event.is_action_pressed("pause"): + _handle_pause_action() + get_viewport().set_input_as_handled() + return + + if is_gameplay_input_blocked(): + return + + if event.is_action_pressed("toggle_chat"): + toggle_chat() + elif chat_visible and multiplayer_chat.message.has_focus(): + pass + elif event.is_action_pressed("inventory"): + toggle_inventory() + elif event is InputEventKey and event.pressed and event.keycode == KEY_F1: + _debug_add_item() + elif event is InputEventKey and event.pressed and event.keycode == KEY_F2: + _debug_print_inventory() + + +func _on_chat_message_sent(message_text: String) -> void: + chat_visible = multiplayer_chat.is_chat_visible() + _update_mouse_mode() + + var trimmed_message = _sanitize_chat_message(message_text) + if trimmed_message.is_empty(): + return + + if multiplayer.is_server(): + _broadcast_chat_message(multiplayer.get_unique_id(), trimmed_message) + else: + submit_chat_message.rpc_id(1, trimmed_message) + + +@rpc("any_peer", "reliable") +func submit_chat_message(message_text: String): + if not multiplayer.is_server(): + return + var sender_id = multiplayer.get_remote_sender_id() + var trimmed_message = _sanitize_chat_message(message_text) + if trimmed_message.is_empty(): + return + _broadcast_chat_message(sender_id, trimmed_message) + + +func _broadcast_chat_message(sender_id: int, message_text: String): + var player_info = Network.players.get(sender_id, {}) + var nick = Network.sanitize_nickname(str(player_info.get("nick", "")), "Player_" + str(sender_id)) + show_chat_message.rpc(nick, message_text) + + +@rpc("authority", "call_local", "reliable") +func show_chat_message(nick: String, msg: String): + multiplayer_chat.add_message(nick, msg) + + +func _sanitize_chat_message(message_text: String) -> String: + var clean = message_text.strip_edges() + if clean.length() > MAX_CHAT_MESSAGE_LENGTH: + clean = clean.substr(0, MAX_CHAT_MESSAGE_LENGTH) + return clean + + +func toggle_inventory(): + if main_menu.is_menu_visible() or is_gameplay_input_blocked(): + return + + var local_player = _get_local_player() + if not local_player: + return + + inventory_visible = !inventory_visible + if inventory_visible: + inventory_ui.open_inventory(local_player) + else: + inventory_ui.close_inventory() + _update_mouse_mode() + + +func is_inventory_visible() -> bool: + return inventory_visible + + +func _show_player_list() -> void: + if main_menu.is_menu_visible() or pause_menu.is_menu_visible() or not multiplayer.has_multiplayer_peer(): + return + player_list_visible = true + player_list_ui.show_players(Network.players, multiplayer.get_unique_id()) + + +func _hide_player_list() -> void: + player_list_visible = false + if player_list_ui: + player_list_ui.hide_players() + + +func _refresh_player_list() -> void: + if player_list_visible and player_list_ui: + player_list_ui.refresh_players(Network.players, multiplayer.get_unique_id()) + + +func register_player_nickname_height(player_id: int, height: float) -> void: + if not multiplayer.is_server() or player_id <= 0: + return + var normalized_height := _normalize_nickname_height(height) + _store_and_apply_player_nickname_height(player_id, normalized_height) + sync_player_nickname_height.rpc(player_id, normalized_height) + + +@rpc("authority", "reliable") +func sync_player_nickname_height(player_id: int, height: float) -> void: + if multiplayer.get_remote_sender_id() != 1: + return + _store_and_apply_player_nickname_height(player_id, _normalize_nickname_height(height)) + + +func _request_nickname_heights() -> void: + if multiplayer.is_server() or not multiplayer.has_multiplayer_peer(): + return + request_nickname_heights.rpc_id(1) + + +@rpc("any_peer", "reliable") +func request_nickname_heights() -> void: + if not multiplayer.is_server(): + return + var requester_id := multiplayer.get_remote_sender_id() + if requester_id <= 0 or _nickname_height_requesters.has(requester_id): + return + _nickname_height_requesters[requester_id] = true + _sync_nickname_heights_to_peer(requester_id) + + +func _sync_nickname_heights_to_peer(peer_id: int) -> void: + if not multiplayer.is_server() or peer_id <= 0: + return + for child in players_container.get_children(): + var player := child as Character + if not player: + continue + var player_id := str(player.name).to_int() + var height := float(_player_nickname_heights.get(player_id, player.get_current_nickname_height())) + sync_player_nickname_height.rpc_id(peer_id, player_id, _normalize_nickname_height(height)) + + +func _store_and_apply_player_nickname_height(player_id: int, height: float) -> void: + if player_id <= 0: + return + _player_nickname_heights[player_id] = height + _apply_player_nickname_height(player_id) + + +func _apply_player_nickname_height(player_id: int) -> void: + if not _player_nickname_heights.has(player_id): + return + var player := players_container.get_node_or_null(str(player_id)) as Character + if player: + player.apply_synced_nickname_height(float(_player_nickname_heights[player_id])) + + +func _normalize_nickname_height(height: float) -> float: + if is_nan(height) or is_inf(height): + return MIN_NICKNAME_HEIGHT + return clampf(height, MIN_NICKNAME_HEIGHT, MAX_NICKNAME_HEIGHT) + + +func _on_inventory_closed(): + inventory_visible = false + _update_mouse_mode() + + +func is_gameplay_input_blocked() -> bool: + return pause_menu.is_menu_visible() + + +func is_camera_input_blocked() -> bool: + return is_gameplay_input_blocked() or inventory_visible or multiplayer_chat.is_chat_visible() + + +func _handle_pause_action() -> void: + if inventory_visible: + _close_inventory() + return + if multiplayer_chat.is_chat_visible(): + _close_chat() + return + if pause_menu.is_menu_visible(): + _hide_pause_menu() + return + if main_menu.is_menu_visible() or not multiplayer.has_multiplayer_peer(): + return + _show_pause_menu() + + +func _show_pause_menu() -> void: + pause_menu.show_menu() + _update_mouse_mode() + + +func _hide_pause_menu(restore_mouse_mode: bool = true) -> void: + if not pause_menu.is_menu_visible(): + return + pause_menu.hide_menu() + if restore_mouse_mode: + _update_mouse_mode() + + +func _close_chat() -> void: + multiplayer_chat.close_chat() + chat_visible = false + _update_mouse_mode() + + +func _close_inventory() -> void: + inventory_ui.close_inventory() + inventory_visible = false + _update_mouse_mode() + + +func _update_mouse_mode() -> void: + if DisplayServer.get_name() == "headless": + return + var ui_requires_cursor := ( + main_menu.is_menu_visible() + or pause_menu.is_menu_visible() + or inventory_visible + or not multiplayer.has_multiplayer_peer() + ) + if ui_requires_cursor: + Input.mouse_mode = Input.MOUSE_MODE_VISIBLE + else: + Input.mouse_mode = Input.MOUSE_MODE_CAPTURED + + +func _on_pause_resume_pressed() -> void: + _hide_pause_menu() + + +func _on_pause_main_menu_pressed() -> void: + _hide_pause_menu(false) + Network.leave_game() + if not main_menu.is_menu_visible(): + _reset_session_ui() + + +func _on_pause_quit_pressed() -> void: + Network.leave_game() + get_tree().quit() + + +func update_local_inventory_display(): + if inventory_ui: + inventory_ui.refresh_display() + + +func _get_local_player() -> Character: + var local_player_id = multiplayer.get_unique_id() + if players_container.has_node(str(local_player_id)): + return players_container.get_node(str(local_player_id)) as Character + return null + + +func _debug_add_item(): + if not OS.is_debug_build() or not multiplayer.is_server(): + return + var local_player = _get_local_player() + if local_player: + var test_items: Array[String] = [] + for item_id in ItemDatabase.get_all_items(): + test_items.append(str(item_id)) + if test_items.is_empty(): + return + var random_item = test_items[randi() % test_items.size()] + local_player.request_add_item.rpc_id(1, random_item, 1) + + +func _debug_print_inventory(): + var local_player = _get_local_player() + if local_player and local_player.get_inventory(): + var inventory = local_player.get_inventory() + print("=== Inventory Debug ===") + for i in range(inventory.slots.size()): + var slot = inventory.get_slot(i) + if slot and not slot.is_empty(): + print("Slot ", i, ": ", slot.item_id, " x", slot.quantity) + print("=====================") + else: + print("No inventory found for local player") diff --git a/scripts/level/level.gd.uid b/scripts/level/level.gd.uid new file mode 100644 index 0000000..40b945a --- /dev/null +++ b/scripts/level/level.gd.uid @@ -0,0 +1 @@ +uid://d0dgljwwl463n diff --git a/scripts/player/3d_godot_robot.gd b/scripts/player/3d_godot_robot.gd new file mode 100644 index 0000000..4c70bf6 --- /dev/null +++ b/scripts/player/3d_godot_robot.gd @@ -0,0 +1,42 @@ +class_name Body +extends Node3D + +const LERP_VELOCITY: float = 0.15 + +@export_category("Objects") +@export var _character: CharacterBody3D = null +@export var animation_player: AnimationPlayer = null + +var _current_state: StringName = &"" + + +func apply_rotation(_velocity: Vector3) -> void: + var new_rotation_y = lerp_angle(rotation.y, atan2(-_velocity.x, -_velocity.z), LERP_VELOCITY) + rotation.y = new_rotation_y + + +func get_movement_animation(_velocity: Vector3) -> StringName: + if not _character.is_on_floor(): + if _velocity.y < 0: + return &"Fall" + if _current_state == &"Jump2": + return &"Jump2" + return &"Jump" + + if _velocity: + if _character._is_running() and _character.is_on_floor(): + return &"Sprint" + return &"Run" + + return &"Idle" + + +func play_animation_state(state: StringName, restart: bool = false) -> void: + if not animation_player: + return + if not animation_player.has_animation(state): + return + if not restart and _current_state == state and animation_player.is_playing(): + return + _current_state = state + animation_player.play(state) diff --git a/scripts/player/3d_godot_robot.gd.uid b/scripts/player/3d_godot_robot.gd.uid new file mode 100644 index 0000000..aeb9531 --- /dev/null +++ b/scripts/player/3d_godot_robot.gd.uid @@ -0,0 +1 @@ +uid://v8j54rbc0ik diff --git a/scripts/player/player.gd b/scripts/player/player.gd new file mode 100644 index 0000000..25a73ae --- /dev/null +++ b/scripts/player/player.gd @@ -0,0 +1,872 @@ +class_name Character +extends CharacterBody3D + +enum SkinColor { BLUE, YELLOW, GREEN, RED } + +const NORMAL_SPEED = 6.0 +const SPRINT_SPEED = 10.0 +const JUMP_VELOCITY = 7.5 +const FALL_GRAVITY_MULTIPLIER = 1.6 +const BASE_NICKNAME_HEIGHT := 2.0 +const SERVER_ANIMATION_REQUESTS_PER_SECOND := 10.0 +const SERVER_ANIMATION_REQUEST_BURST := 6.0 +const PICKUP_ANIMATION_DELAY_MSEC := 1000 +const PICKUP_ANIMATION_WINDOW_MSEC := 2500 +const PICKUP_REQUEST_COOLDOWN_MSEC := 1000 +const ALLOWED_ANIMATION_STATES := { + &"Idle": true, + &"Run": true, + &"Sprint": true, + &"Jump": true, + &"Jump2": true, + &"Fall": true, + &"Attack1": true, + &"Emote2": true +} +const HAT_NODES_BY_ITEM := { + "fedora": "Fedora", + "headphones": "Headphones", + "pirate_hat": "PirateHat", + "sheriff_hat": "SheriffHat", + "sombrero": "Sombrero", + "wizard_hat": "WizardHat" +} +const WEAPON_NODES_BY_ITEM := {"sword": "Sword", "sword_big": "SwordBig", "axe": "Axe"} +const BACKPACK_NODES_BY_ITEM := {"backpack": "Backpack"} +const HEAD_EQUIPMENT_PATH := "GodotRobot3D/RobotArmature/Skeleton3D/HeadAttach/" +const HAND_EQUIPMENT_PATH := "GodotRobot3D/RobotArmature/Skeleton3D/LeftHandAttach/" +const BACK_EQUIPMENT_PATH := "GodotRobot3D/RobotArmature/Skeleton3D/BackAttach/" +const FIRST_PERSON_HIDDEN_BONES: Array[StringName] = [&"Head", &"HeadTop"] + +@export var skin_color: SkinColor = SkinColor.BLUE + +@export_category("Nickname") +@export_range(0.0, 1.0, 0.01) var nickname_clearance: float = 0.2 + +@export_category("Objects") +@export var _body: Node3D = null +@export var _spring_arm_offset: SpringArmCharacter = null + +@export_category("Skin Colors") +@export var blue_texture: CompressedTexture2D +@export var yellow_texture: CompressedTexture2D +@export var green_texture: CompressedTexture2D +@export var red_texture: CompressedTexture2D + +var player_inventory: PlayerInventory +var gravity = ProjectSettings.get_setting("physics/3d/default_gravity") +var can_double_jump = true +var has_double_jumped = false +var is_attacking := false +var is_collecting := false + +var _current_speed: float +var _spawn_point = Vector3(0, 5, 0) +var _animation_sequence := 0 +var _last_applied_animation_sequence := 0 +var _last_requested_animation: StringName = &"" +var _appearance_sync_requesters: Dictionary = {} +var _server_animation_request_tokens := SERVER_ANIMATION_REQUEST_BURST +var _last_server_animation_token_update_msec := 0 +var _server_pickup_animation_started_msec := -1 +var _last_server_pickup_request_msec := -PICKUP_REQUEST_COOLDOWN_MSEC +var _pickup_area_camera_yaw_offset := 0.0 +var _equipped_hat_visual_id := "" + +@onready var nickname: Label3D = $PlayerNick/Nickname + +@onready var _bottom_mesh: MeshInstance3D = get_node("GodotRobot3D/RobotArmature/Skeleton3D/Bottom") +@onready var _chest_mesh: MeshInstance3D = get_node("GodotRobot3D/RobotArmature/Skeleton3D/Chest") +@onready var _face_mesh: MeshInstance3D = get_node("GodotRobot3D/RobotArmature/Skeleton3D/Face") +@onready var _limbs_head_mesh: MeshInstance3D = get_node("GodotRobot3D/RobotArmature/Skeleton3D/LimbsAndHead") +@onready var _skeleton: Skeleton3D = get_node("GodotRobot3D/RobotArmature/Skeleton3D") +@onready var _pickup_area: Area3D = $GodotRobot3D/InfrontArea3D +@onready var _first_person_hud: CanvasLayer = $FirstPersonHUD + + +func _enter_tree(): + set_multiplayer_authority(str(name).to_int()) + $SpringArmOffset/SpringArm3D/Camera3D.current = is_multiplayer_authority() + + +func _ready(): + if multiplayer.is_server(): + player_inventory = PlayerInventory.new() + _add_starting_items() + call_deferred("_sync_equipment_appearance") + if not is_multiplayer_authority(): + call_deferred("_sync_inventory_to_owner") + + set_player_skin(skin_color) + var animation_player := get_node_or_null("GodotRobot3D/AnimationPlayer") as AnimationPlayer + if animation_player: + animation_player.animation_finished.connect(_on_animation_finished) + _body.play_animation_state(&"Idle") + nickname.visible = true + _set_nickname_height(BASE_NICKNAME_HEIGHT) + call_deferred("_update_nickname_height") + if not multiplayer.is_server(): + call_deferred("_request_equipment_appearance") + if is_multiplayer_authority() and _spring_arm_offset: + _pickup_area_camera_yaw_offset = wrapf( + _pickup_area.global_rotation.y - _spring_arm_offset.global_rotation.y, -PI, PI + ) + _spring_arm_offset.perspective_changed.connect(_on_camera_perspective_changed) + _on_camera_perspective_changed(_spring_arm_offset.is_first_person) + + +func _on_camera_perspective_changed(first_person: bool) -> void: + if not is_multiplayer_authority(): + return + _body.visible = true + _bottom_mesh.visible = not first_person + _chest_mesh.visible = not first_person + _face_mesh.visible = not first_person + _limbs_head_mesh.visible = true + nickname.visible = not first_person + _first_person_hud.visible = first_person + for bone_name in FIRST_PERSON_HIDDEN_BONES: + var bone_index := _skeleton.find_bone(bone_name) + if bone_index >= 0: + _skeleton.set_bone_pose_scale(bone_index, Vector3.ZERO if first_person else Vector3.ONE) + if first_person: + _align_pickup_area_with_camera() + else: + _pickup_area.rotation = Vector3.ZERO + call_deferred("_refresh_nickname_height_after_perspective_change") + + +func _refresh_nickname_height_after_perspective_change() -> void: + if not is_multiplayer_authority() or _is_local_first_person(): + return + var height := _calculate_nickname_height(_equipped_hat_visual_id) + _set_nickname_height(height) + nickname.visible = true + if multiplayer.is_server(): + _broadcast_nickname_height(height) + + +func _align_pickup_area_with_camera() -> void: + var pickup_rotation := _pickup_area.global_rotation + pickup_rotation.y = wrapf(_spring_arm_offset.global_rotation.y + _pickup_area_camera_yaw_offset, -PI, PI) + _pickup_area.global_rotation = pickup_rotation + + +func _physics_process(delta): + if not multiplayer.has_multiplayer_peer() or not is_multiplayer_authority(): + return + + var current_scene = get_tree().get_current_scene() + var should_freeze = false + if current_scene: + if current_scene.has_method("is_gameplay_input_blocked") and current_scene.is_gameplay_input_blocked(): + should_freeze = true + elif current_scene.has_method("is_chat_visible") and current_scene.is_chat_visible(): + should_freeze = true + elif current_scene.has_method("is_inventory_visible") and current_scene.is_inventory_visible(): + should_freeze = true + + if is_attacking or is_collecting: + velocity.x = 0 + velocity.z = 0 + _apply_gravity(delta) + move_and_slide() + return + + if should_freeze: + _freeze() + _apply_gravity(delta) + move_and_slide() + _request_animation(_body.get_movement_animation(velocity)) + return + + if Input.is_action_just_pressed("pickup") and is_on_floor() and _has_collectible_item_in_front(): + is_collecting = true + _request_animation(&"Emote2", true) + return + + if Input.is_action_just_pressed("attack") and is_on_floor(): + _start_attack() + return + + if is_on_floor(): + can_double_jump = true + has_double_jumped = false + + if Input.is_action_just_pressed("jump"): + velocity.y = JUMP_VELOCITY + can_double_jump = true + _request_animation(&"Jump", true) + else: + _apply_gravity(delta) + + if can_double_jump and not has_double_jumped and Input.is_action_just_pressed("jump"): + velocity.y = JUMP_VELOCITY + has_double_jumped = true + can_double_jump = false + _request_animation(&"Jump2", true) + + _move() + var collided = move_and_slide() + if collided: + _push_collided_items() + + _request_animation(_body.get_movement_animation(velocity)) + + +func _apply_gravity(delta: float) -> void: + if is_on_floor(): + return + var gravity_multiplier = FALL_GRAVITY_MULTIPLIER if velocity.y < 0 else 1.0 + velocity.y -= gravity * gravity_multiplier * delta + + +func _start_attack() -> void: + if is_attacking or is_collecting or not is_on_floor(): + return + is_attacking = true + velocity.x = 0 + velocity.z = 0 + _request_animation(&"Attack1", true) + + +func _on_animation_finished(animation_name: StringName) -> void: + match animation_name: + &"Attack1": + is_attacking = false + &"Emote2": + is_collecting = false + + +func _request_animation(state: StringName, restart: bool = false) -> void: + if not ALLOWED_ANIMATION_STATES.has(state): + return + if not restart and _last_requested_animation == state: + return + _last_requested_animation = state + _body.play_animation_state(state, restart) + if multiplayer.is_server(): + request_animation_state(state) + else: + request_animation_state.rpc_id(1, state) + + +@rpc("any_peer", "call_local", "reliable") +func request_animation_state(state: StringName) -> void: + if not multiplayer.is_server() or not _is_owner_request(): + return + if not ALLOWED_ANIMATION_STATES.has(state): + return + if not _server_consume_animation_request_token(): + return + if state == &"Emote2": + if not _is_grounded_on_server() or not _has_collectible_item_in_front(): + return + _server_pickup_animation_started_msec = Time.get_ticks_msec() + else: + _server_pickup_animation_started_msec = -1 + _server_publish_animation(state) + + +func _server_consume_animation_request_token() -> bool: + var now := Time.get_ticks_msec() + if _last_server_animation_token_update_msec == 0: + _last_server_animation_token_update_msec = now + else: + var elapsed_seconds := (now - _last_server_animation_token_update_msec) / 1000.0 + _server_animation_request_tokens = minf( + SERVER_ANIMATION_REQUEST_BURST, + _server_animation_request_tokens + elapsed_seconds * SERVER_ANIMATION_REQUESTS_PER_SECOND + ) + _last_server_animation_token_update_msec = now + + if _server_animation_request_tokens < 1.0: + return false + _server_animation_request_tokens -= 1.0 + return true + + +func _server_publish_animation(state: StringName) -> void: + if not multiplayer.is_server(): + return + _animation_sequence += 1 + sync_animation_state.rpc(state, _animation_sequence) + sync_animation_state(state, _animation_sequence) + + +@rpc("any_peer", "call_local", "reliable") +func sync_animation_state(state: StringName, sequence: int) -> void: + var sender_id := multiplayer.get_remote_sender_id() + if sender_id != 1 and not (sender_id == 0 and multiplayer.is_server()): + return + if sequence <= _last_applied_animation_sequence: + return + _last_applied_animation_sequence = sequence + + if not ALLOWED_ANIMATION_STATES.has(state): + return + + # The owner already played the input-driven state immediately. + if is_multiplayer_authority(): + return + _body.play_animation_state(state, true) + + +func _push_collided_items() -> void: + for i in get_slide_collision_count(): + var c = get_slide_collision(i) + if c.get_collider() is RigidBody3D: + apply_force_to_server_object.rpc_id(1, c.get_collider().name, -c.get_normal()) + + +func _process(_delta): + if not multiplayer.has_multiplayer_peer(): + return + if not is_multiplayer_authority(): + return + var first_person := _spring_arm_offset != null and _spring_arm_offset.is_first_person + if first_person: + _align_pickup_area_with_camera() + var camera_input_blocked := false + var current_scene := get_tree().get_current_scene() + if current_scene and current_scene.has_method("is_camera_input_blocked"): + camera_input_blocked = current_scene.is_camera_input_blocked() + _first_person_hud.visible = ( + first_person and not camera_input_blocked and Input.mouse_mode == Input.MOUSE_MODE_CAPTURED + ) + _check_out_of_bounds() + + +func _freeze(): + velocity.x = 0 + velocity.z = 0 + _current_speed = 0 + _request_animation(&"Idle") + + +func _move() -> void: + var input_direction: Vector2 = Vector2.ZERO + if is_multiplayer_authority(): + input_direction = Input.get_vector("move_left", "move_right", "move_forward", "move_backward") + + var direction: Vector3 = transform.basis * Vector3(input_direction.x, 0, input_direction.y).normalized() + + _is_running() + direction = direction.rotated(Vector3.UP, _spring_arm_offset.rotation.y) + + if direction: + velocity.x = direction.x * _current_speed + velocity.z = direction.z * _current_speed + _body.apply_rotation(velocity) + return + + velocity.x = move_toward(velocity.x, 0, _current_speed) + velocity.z = move_toward(velocity.z, 0, _current_speed) + + +func _is_running() -> bool: + if Input.is_action_pressed("shift"): + _current_speed = SPRINT_SPEED + return true + _current_speed = NORMAL_SPEED + return false + + +func _check_out_of_bounds(): + if global_transform.origin.y < -15.0: + _reset_position_after_fall() + + +func _reset_position_after_fall(): + global_transform.origin = _spawn_point + velocity = Vector3.ZERO + + +func _get_texture_from_name(color: SkinColor) -> CompressedTexture2D: + match color: + SkinColor.BLUE: + return blue_texture + SkinColor.GREEN: + return green_texture + SkinColor.RED: + return red_texture + SkinColor.YELLOW: + return yellow_texture + _: + return blue_texture + + +func set_player_skin(skin_name: SkinColor) -> void: + var texture = _get_texture_from_name(skin_name) + + _set_mesh_texture(_bottom_mesh, texture) + _set_mesh_texture(_chest_mesh, texture) + _set_mesh_texture(_face_mesh, texture) + _set_mesh_texture(_limbs_head_mesh, texture) + + +func _set_mesh_texture(mesh_instance: MeshInstance3D, texture: CompressedTexture2D) -> void: + if mesh_instance: + var new_material := StandardMaterial3D.new() + new_material.albedo_texture = texture + mesh_instance.set_surface_override_material(0, new_material) + + +@rpc("any_peer", "call_local", "reliable") +func sync_inventory_to_owner(inventory_data: Dictionary): + var sender_id = multiplayer.get_remote_sender_id() + if sender_id != 1 and not (sender_id == 0 and multiplayer.is_server()): + return + + if not is_multiplayer_authority(): + return + + if not player_inventory: + player_inventory = PlayerInventory.new() + player_inventory.from_dict(inventory_data) + + var level_scene = get_tree().get_current_scene() + if ( + level_scene + and get_multiplayer_authority() == multiplayer.get_unique_id() + and level_scene.has_method("update_local_inventory_display") + ): + level_scene.update_local_inventory_display() + + +@rpc("any_peer", "call_local", "reliable") +func request_move_item(from_slot: int, to_slot: int, quantity: int = -1): + if not multiplayer.is_server(): + return + + var requesting_client = multiplayer.get_remote_sender_id() + if not _is_owner_request(): + push_warning( + ( + "Client " + + str(requesting_client) + + " tried to modify inventory for player " + + str(get_multiplayer_authority()) + ) + ) + return + + if not player_inventory: + return + + if not player_inventory.is_slot_active(from_slot) or not player_inventory.is_slot_active(to_slot): + push_warning("Invalid slot indices: from=" + str(from_slot) + " to=" + str(to_slot)) + return + + if quantity != -1 and quantity <= 0: + push_warning("Invalid move quantity: " + str(quantity)) + return + + var success = false + if quantity == -1: + success = player_inventory.move_item(from_slot, to_slot) + if not success: + success = player_inventory.swap_items(from_slot, to_slot) + else: + success = player_inventory.move_item(from_slot, to_slot, quantity) + + if success: + _sync_inventory_to_owner() + + +@rpc("any_peer", "call_local", "reliable") +func request_add_item(item_id: String, quantity: int = 1): + if not multiplayer.is_server(): + return + + var requesting_client = multiplayer.get_remote_sender_id() + var is_local_server_call = requesting_client == 0 and multiplayer.get_unique_id() == 1 + if requesting_client != 1 and not is_local_server_call: + push_warning( + "Client " + str(requesting_client) + " tried to add items to player " + str(get_multiplayer_authority()) + ) + return + + if not player_inventory: + return + + if quantity <= 0: + push_warning("Invalid quantity: " + str(quantity)) + return + + var item = ItemDatabase.get_item(item_id) + if not item: + push_warning("Item not found: " + item_id) + return + + var remaining = player_inventory.add_item(item, quantity) + var added = quantity - remaining + + if added > 0: + _sync_inventory_to_owner() + + +func request_add_single_item(item_id: String) -> bool: + if not multiplayer.is_server(): + return false + + if player_inventory == null: + return false + + var item = ItemDatabase.get_item(item_id) + if not item: + push_warning("Item not found: " + item_id) + return false + + var remaining = player_inventory.add_item(item, 1) + + if remaining == 0: + _sync_inventory_to_owner() + return true + return false + + +@rpc("any_peer", "call_local", "reliable") +func request_remove_item(item_id: String, quantity: int = 1): + if not multiplayer.is_server(): + return + + var requesting_client = multiplayer.get_remote_sender_id() + if not _is_owner_request(): + push_warning( + ( + "Client " + + str(requesting_client) + + " tried to remove items from player " + + str(get_multiplayer_authority()) + ) + ) + return + + if not player_inventory: + return + + if quantity <= 0: + push_warning("Invalid quantity: " + str(quantity)) + return + + var removed = player_inventory.remove_item(item_id, quantity) + + if removed > 0: + _sync_inventory_to_owner() + + +@rpc("authority", "call_local", "reliable") +func add_world_item(scene_path: String, player_position: Vector3) -> void: + var item_container = get_node_or_null("/root/Level/Environment/ItemContainer") + if not item_container: + push_warning("ItemContainer not found at /root/Level/Environment/ItemContainer") + return + if scene_path.is_empty() or not ResourceLoader.exists(scene_path): + push_warning("Cannot add world item: invalid scene path '" + scene_path + "'") + return + var packed_scene = load(scene_path) as PackedScene + if not packed_scene: + push_warning("Cannot add world item: scene path is not a PackedScene '" + scene_path + "'") + return + var instance_item := packed_scene.instantiate() as Node3D + if not instance_item: + push_warning("Cannot add world item: scene root is not a Node3D '" + scene_path + "'") + return + item_container.add_child(instance_item, true) + instance_item.global_position = player_position + + +func get_inventory() -> PlayerInventory: + return player_inventory + + +func _sync_inventory_to_owner() -> void: + if not multiplayer.is_server() or not player_inventory: + return + var owner_id = get_multiplayer_authority() + if owner_id == 1: + sync_inventory_to_owner(player_inventory.to_dict()) + else: + sync_inventory_to_owner.rpc_id(owner_id, player_inventory.to_dict()) + + +@rpc("any_peer", "call_local", "reliable") +func request_equip_item(from_slot: int, item_type: Item.ItemType) -> void: + if not multiplayer.is_server() or not _is_owner_request(): + return + if not player_inventory or not player_inventory.is_slot_active(from_slot): + return + if item_type != Item.ItemType.WEAPON and item_type != Item.ItemType.HAT and item_type != Item.ItemType.BACKPACK: + return + if player_inventory.equip_from_slot(from_slot, item_type): + _sync_inventory_to_owner() + _sync_equipment_appearance() + + +@rpc("any_peer", "call_local", "reliable") +func request_unequip_item(item_type: Item.ItemType, destination_slot: int = -1) -> void: + if not multiplayer.is_server() or not _is_owner_request(): + return + if not player_inventory: + return + if item_type != Item.ItemType.WEAPON and item_type != Item.ItemType.HAT and item_type != Item.ItemType.BACKPACK: + return + if destination_slot < -1 or destination_slot >= PlayerInventory.MAX_INVENTORY_SIZE: + return + if destination_slot >= 0 and not player_inventory.is_slot_active(destination_slot): + return + if player_inventory.unequip_to_slot(item_type, destination_slot): + _sync_inventory_to_owner() + _sync_equipment_appearance() + + +func _is_owner_request() -> bool: + var sender := multiplayer.get_remote_sender_id() + return sender == get_multiplayer_authority() or (sender == 0 and multiplayer.is_server()) + + +func _sync_equipment_appearance() -> void: + if not multiplayer.is_server() or not player_inventory: + return + var weapon_id := player_inventory.equipped_weapon.item_id + var hat_id := player_inventory.equipped_hat.item_id + var backpack_id := player_inventory.equipped_backpack.item_id + var nickname_height := _calculate_nickname_height(hat_id) + _broadcast_nickname_height(nickname_height) + sync_equipment_appearance.rpc(weapon_id, hat_id, backpack_id) + sync_equipment_appearance(weapon_id, hat_id, backpack_id) + + +func _request_equipment_appearance() -> void: + if multiplayer.is_server() or not multiplayer.has_multiplayer_peer(): + return + request_equipment_appearance.rpc_id(1) + + +@rpc("any_peer", "reliable") +func request_equipment_appearance() -> void: + if not multiplayer.is_server() or not player_inventory: + return + var requester_id := multiplayer.get_remote_sender_id() + if requester_id <= 0: + return + if _appearance_sync_requesters.has(requester_id): + return + _appearance_sync_requesters[requester_id] = true + _sync_equipment_appearance_to_peer(requester_id) + + +func _sync_equipment_appearance_to_peer(peer_id: int) -> void: + if not multiplayer.is_server() or not player_inventory or peer_id <= 0: + return + var weapon_id := player_inventory.equipped_weapon.item_id + var hat_id := player_inventory.equipped_hat.item_id + var backpack_id := player_inventory.equipped_backpack.item_id + sync_equipment_appearance.rpc_id(peer_id, weapon_id, hat_id, backpack_id) + + +@rpc("any_peer", "reliable") +func sync_equipment_appearance(weapon_id: String, hat_id: String, backpack_id: String) -> void: + var sender := multiplayer.get_remote_sender_id() + if sender != 1 and not (sender == 0 and multiplayer.is_server()): + return + _set_equipment_visibility(weapon_id, hat_id, backpack_id) + + +func _set_equipment_visibility(weapon_id: String, hat_id: String, backpack_id: String) -> void: + _equipped_hat_visual_id = hat_id + _set_equipment_nodes_visibility(HEAD_EQUIPMENT_PATH, HAT_NODES_BY_ITEM, hat_id) + _set_equipment_nodes_visibility(HAND_EQUIPMENT_PATH, WEAPON_NODES_BY_ITEM, weapon_id) + _set_equipment_nodes_visibility(BACK_EQUIPMENT_PATH, BACKPACK_NODES_BY_ITEM, backpack_id) + + +func _broadcast_nickname_height(height: float) -> void: + if not multiplayer.is_server(): + return + var level_scene := get_tree().get_current_scene() + if level_scene and level_scene.has_method("register_player_nickname_height"): + level_scene.register_player_nickname_height(get_multiplayer_authority(), height) + + +func _set_equipment_nodes_visibility(parent_path: String, nodes_by_item: Dictionary, equipped_item_id: String) -> void: + for item_id in nodes_by_item: + var equipment := get_node_or_null(parent_path + str(nodes_by_item[item_id])) as Node3D + if equipment: + equipment.visible = item_id == equipped_item_id + + +func _update_nickname_height(hat_id: String = "") -> void: + if not nickname: + return + nickname.visible = not _is_local_first_person() + _set_nickname_height(_calculate_nickname_height(hat_id)) + + +func _calculate_nickname_height(hat_id: String) -> float: + var target_height := BASE_NICKNAME_HEIGHT + var equipped_hat := _get_hat_node(hat_id) + if equipped_hat: + var hat_top := _get_visual_top(equipped_hat) + if hat_top > -INF and hat_top < INF: + target_height = max(BASE_NICKNAME_HEIGHT, hat_top + nickname_clearance) + return target_height + + +func _set_nickname_height(height: float) -> void: + var nickname_position := nickname.position + nickname_position.y = height + nickname.position = nickname_position + + +func apply_synced_nickname_height(height: float) -> void: + if not nickname: + return + nickname.visible = not _is_local_first_person() + if height > -INF and height < INF: + _set_nickname_height(maxf(BASE_NICKNAME_HEIGHT, height)) + else: + _set_nickname_height(BASE_NICKNAME_HEIGHT) + + +func _is_local_first_person() -> bool: + return is_multiplayer_authority() and _spring_arm_offset != null and _spring_arm_offset.is_first_person + + +func get_current_nickname_height() -> float: + return nickname.position.y if nickname else BASE_NICKNAME_HEIGHT + + +func _get_hat_node(hat_id: String) -> Node3D: + if HAT_NODES_BY_ITEM.has(hat_id): + var requested_hat_path := HEAD_EQUIPMENT_PATH + str(HAT_NODES_BY_ITEM[hat_id]) + var requested_hat := get_node_or_null(requested_hat_path) as Node3D + if requested_hat: + return requested_hat + return null + + +func _get_visual_top(root: Node3D) -> float: + var visual_top := -INF + if root is MeshInstance3D: + visual_top = _get_mesh_top(root as MeshInstance3D) + + for child in root.find_children("*", "MeshInstance3D", true, false): + var mesh_instance := child as MeshInstance3D + if mesh_instance: + visual_top = max(visual_top, _get_mesh_top(mesh_instance)) + return visual_top + + +func _get_mesh_top(mesh_instance: MeshInstance3D) -> float: + var visual_top := -INF + var mesh_bounds := mesh_instance.get_aabb() + for endpoint_index in range(8): + var endpoint_global := mesh_instance.to_global(mesh_bounds.get_endpoint(endpoint_index)) + visual_top = max(visual_top, to_local(endpoint_global).y) + return visual_top + + +func _add_starting_items(): + if not player_inventory: + return + + var backpack := ItemDatabase.get_item("backpack") + if backpack: + player_inventory.add_item(backpack, 1) + + var starting_item_ids: Array[String] = [ + "fedora", + "headphones", + "pirate_hat", + "sheriff_hat", + "sombrero", + "wizard_hat", + "sword", + "sword_big", + "axe", + "chicken_leg", + "bone", + "chalice" + ] + + for item_id in starting_item_ids: + var item = ItemDatabase.get_item(item_id) + if item: + player_inventory.add_item(item, 1) + + +func pickup() -> void: + if multiplayer.is_server(): + request_pickup() + else: + request_pickup.rpc_id(1) + + +@rpc("any_peer", "call_local", "reliable") +func request_pickup() -> void: + if not multiplayer.is_server() or not _is_owner_request(): + return + var now := Time.get_ticks_msec() + if now - _last_server_pickup_request_msec < PICKUP_REQUEST_COOLDOWN_MSEC: + return + _last_server_pickup_request_msec = now + + var animation_elapsed := now - _server_pickup_animation_started_msec + if ( + _server_pickup_animation_started_msec < 0 + or animation_elapsed < PICKUP_ANIMATION_DELAY_MSEC + or animation_elapsed > PICKUP_ANIMATION_WINDOW_MSEC + ): + return + if not _is_grounded_on_server(): + return + if not _has_collectible_item_in_front(): + return + _server_pickup_animation_started_msec = -1 + _server_pickup() + + +func _server_pickup() -> void: + for item in _get_collectible_items_in_front(): + var result := request_add_single_item(item.item_id) + if result and item.is_inside_tree(): + item.queue_free() + + +func _has_collectible_item_in_front() -> bool: + return not _get_collectible_items_in_front().is_empty() + + +func _get_collectible_items_in_front() -> Array[ItemRigidBody3D]: + var collectible_items: Array[ItemRigidBody3D] = [] + var pickup_area := get_node_or_null("GodotRobot3D/InfrontArea3D") as Area3D + if not pickup_area: + return collectible_items + + for body in pickup_area.get_overlapping_bodies(): + var item := body as ItemRigidBody3D + if item and not item.item_id.is_empty() and ItemDatabase.get_item(item.item_id): + collectible_items.append(item) + return collectible_items + + +@rpc("any_peer", "call_local", "reliable") +func apply_force_to_server_object(object_name: String, normal: Vector3) -> void: + var object_node = get_node_or_null("/root/Level/Environment/ItemContainer") + if object_node: + for n in object_node.get_children(): + if n.name == object_name and n is RigidBody3D: + n.apply_force(normal * 100) + + +func _is_grounded_on_server() -> bool: + if not multiplayer.is_server() or not is_inside_tree(): + return false + var query := PhysicsRayQueryParameters3D.new() + query.from = global_position + Vector3.UP * 0.15 + query.to = global_position + Vector3.DOWN * 0.3 + query.collision_mask = 2 + query.exclude = [get_rid()] + query.collide_with_areas = false + return not get_world_3d().direct_space_state.intersect_ray(query).is_empty() diff --git a/scripts/player/player.gd.uid b/scripts/player/player.gd.uid new file mode 100644 index 0000000..bacd93f --- /dev/null +++ b/scripts/player/player.gd.uid @@ -0,0 +1 @@ +uid://c2si8gkbnde0c diff --git a/scripts/player/spring_arm_offset.gd b/scripts/player/spring_arm_offset.gd new file mode 100644 index 0000000..4362c2c --- /dev/null +++ b/scripts/player/spring_arm_offset.gd @@ -0,0 +1,86 @@ +class_name SpringArmCharacter +extends Node3D + +signal perspective_changed(first_person: bool) + +const MOUSE_SENSIBILITY: float = 0.005 + +@export_category("Objects") +@export var _spring_arm: SpringArm3D = null +@export var _first_person_anchor: Node3D = null + +@export_category("Camera Perspective") +@export_range(0.0, 10.0, 0.05) var third_person_distance := 5.0 +@export_range(0.0, 1.0, 0.05) var first_person_distance := 0.0 +@export_range(0.0, 3.0, 0.05) var third_person_height := 2.0 +@export_range(1.0, 179.0, 1.0) var third_person_fov := 75.0 +@export_range(1.0, 179.0, 1.0) var first_person_fov := 90.0 + +var is_first_person := false +var _yaw := 0.0 +var _pitch := 0.0 + +@onready var _camera: Camera3D = _spring_arm.get_node_or_null("Camera3D") as Camera3D + + +func _ready() -> void: + _yaw = rotation.y + _pitch = _spring_arm.rotation.x if _spring_arm else 0.0 + _apply_axis_lock() + _apply_perspective() + + +func _process(_delta: float) -> void: + _apply_axis_lock() + if is_first_person and _spring_arm and _first_person_anchor: + _spring_arm.global_position = _first_person_anchor.global_position + + +func _unhandled_input(event: InputEvent) -> void: + if not is_multiplayer_authority(): + return + + var current_scene := get_tree().get_current_scene() + if current_scene and current_scene.has_method("is_camera_input_blocked"): + if current_scene.is_camera_input_blocked(): + return + + if event.is_action_pressed("toggle_camera"): + is_first_person = not is_first_person + _apply_perspective() + perspective_changed.emit(is_first_person) + get_viewport().set_input_as_handled() + return + + if not (event is InputEventMouseMotion): + return + if Input.mouse_mode != Input.MOUSE_MODE_CAPTURED: + return + + _yaw = wrapf(_yaw - event.relative.x * MOUSE_SENSIBILITY, -PI, PI) + _pitch = clampf(_pitch - event.relative.y * MOUSE_SENSIBILITY, -PI / 4.0, PI / 24.0) + _apply_axis_lock() + + +func _apply_axis_lock() -> void: + rotation = Vector3(0.0, _yaw, 0.0) + if _spring_arm: + _spring_arm.rotation = Vector3(_pitch, 0.0, 0.0) + if _camera: + _camera.rotation = Vector3.ZERO + + +func _apply_perspective() -> void: + if not _spring_arm: + return + if is_first_person: + _spring_arm.spring_length = first_person_distance + if _first_person_anchor: + _spring_arm.global_position = _first_person_anchor.global_position + if _camera: + _camera.fov = first_person_fov + else: + _spring_arm.spring_length = third_person_distance + _spring_arm.position = Vector3(0.0, third_person_height, 0.0) + if _camera: + _camera.fov = third_person_fov diff --git a/scripts/player/spring_arm_offset.gd.uid b/scripts/player/spring_arm_offset.gd.uid new file mode 100644 index 0000000..9a19aec --- /dev/null +++ b/scripts/player/spring_arm_offset.gd.uid @@ -0,0 +1 @@ +uid://bj7yrijm7bppq diff --git a/scripts/ui/inventory_slot_ui.gd b/scripts/ui/inventory_slot_ui.gd new file mode 100644 index 0000000..292eeeb --- /dev/null +++ b/scripts/ui/inventory_slot_ui.gd @@ -0,0 +1,147 @@ +class_name InventorySlotUI +extends Control + +signal slot_clicked(slot_index: int, button: int) +signal slot_double_clicked(slot_index: int) +signal item_hovered(slot_index: int, item: Item) +signal item_unhovered + +enum TYPE { + INVENTORY, + WEAPON, + HAT, + BACKPACK, +} +var slot_index: int = 0 +var slot_type: TYPE = TYPE.INVENTORY +var inventory_data: InventorySlot +var parent_inventory: Control + +var _is_mouse_hovering := false + +@onready var background: Panel = $Background +@onready var item_icon: TextureRect = $ItemIcon +@onready var quantity_label: Label = $QuantityLabel + + +func _ready(): + gui_input.connect(_on_gui_input) + mouse_entered.connect(_on_mouse_entered) + mouse_exited.connect(_on_mouse_exited) + + update_display() + + +func set_slot_data(slot_data: InventorySlot, index: int): + inventory_data = slot_data + slot_index = index + update_display() + if is_node_ready() and _is_mouse_hovering: + _emit_current_hover_state() + + +func update_display(): + if not inventory_data or inventory_data.is_empty(): + _show_empty_slot() + else: + _show_item_slot() + + +func _show_empty_slot(): + if item_icon: + item_icon.texture = null + if quantity_label: + quantity_label.visible = false + if background: + background.modulate = Color.WHITE + + +func _show_item_slot(): + var item = ItemDatabase.get_item(inventory_data.item_id) + if not item: + _show_empty_slot() + return + + item_icon.texture = item.icon + + if item.stackable and inventory_data.quantity > 1: + quantity_label.text = str(inventory_data.quantity) + quantity_label.visible = true + else: + quantity_label.visible = false + + +func _on_gui_input(event: InputEvent): + if event is InputEventMouseButton: + if event.pressed: + if event.button_index == MOUSE_BUTTON_LEFT and event.double_click: + slot_double_clicked.emit(slot_index) + accept_event() + elif event.button_index == MOUSE_BUTTON_RIGHT: + slot_clicked.emit(slot_index, event.button_index) + accept_event() + + +func _on_mouse_entered(): + _is_mouse_hovering = true + _emit_current_hover_state() + background.modulate = Color(1.2, 1.2, 1.2) + + +func _on_mouse_exited(): + _is_mouse_hovering = false + item_unhovered.emit() + + background.modulate = Color.WHITE + + +func _emit_current_hover_state() -> void: + if inventory_data and not inventory_data.is_empty(): + var item := ItemDatabase.get_item(inventory_data.item_id) + if item: + item_hovered.emit(slot_index, item) + return + item_unhovered.emit() + + +func _can_drop_data(_position: Vector2, data) -> bool: + return data is Dictionary and data.has("slot_index") and data.has("inventory_type") + + +func _drop_data(_position: Vector2, data): + if parent_inventory and parent_inventory.has_method("handle_item_drop") and slot_type == TYPE.INVENTORY: + parent_inventory.handle_item_drop(data.slot_index, slot_index) + elif parent_inventory and parent_inventory.has_method("handle_weapon_equip") and slot_type == TYPE.WEAPON: + parent_inventory.handle_weapon_equip(data.slot_index, data) + elif parent_inventory and parent_inventory.has_method("handle_hat_equip") and slot_type == TYPE.HAT: + parent_inventory.handle_hat_equip(data.slot_index, data) + elif parent_inventory and parent_inventory.has_method("handle_backpack_equip") and slot_type == TYPE.BACKPACK: + parent_inventory.handle_backpack_equip(data.slot_index, data) + + +func _get_drag_data(_position: Vector2): + if not inventory_data or inventory_data.is_empty(): + return null + + var item = ItemDatabase.get_item(inventory_data.item_id) + if not item: + return null + + var preview = Control.new() + var preview_icon = TextureRect.new() + preview_icon.texture = item.icon + preview_icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE + preview_icon.size = Vector2(32, 32) + preview.add_child(preview_icon) + + preview.modulate = Color(1, 1, 1, 0.8) + set_drag_preview(preview) + + item_icon.modulate = Color(0.5, 0.5, 0.5) + + return {"slot_index": slot_index, "inventory_type": item.item_type} + + +func _notification(what): + if what == NOTIFICATION_DRAG_END: + item_icon.modulate = Color.WHITE diff --git a/scripts/ui/inventory_slot_ui.gd.uid b/scripts/ui/inventory_slot_ui.gd.uid new file mode 100644 index 0000000..3d96b9b --- /dev/null +++ b/scripts/ui/inventory_slot_ui.gd.uid @@ -0,0 +1 @@ +uid://bi0mlil0a0e5i diff --git a/scripts/ui/inventory_ui.gd b/scripts/ui/inventory_ui.gd new file mode 100644 index 0000000..9fa527c --- /dev/null +++ b/scripts/ui/inventory_ui.gd @@ -0,0 +1,422 @@ +class_name InventoryUI +extends Control + +signal inventory_closed + +const SAFE_AREA_MARGIN := 16.0 +const TOOLTIP_MAX_WIDTH := 280.0 +const TOOLTIP_MAX_HEIGHT := 240.0 +const TOOLTIP_SCREEN_MARGIN := 10.0 +const TOOLTIP_HORIZONTAL_PADDING := 16.0 +const TOOLTIP_VERTICAL_PADDING := 16.0 + +const SLOT_INDEX_WEAPON := -1 +const SLOT_INDEX_HAT := -2 +const SLOT_INDEX_BACKPACK := -3 + +var current_player: Character +var slot_ui_scene: PackedScene +var slot_uis: Array[InventorySlotUI] = [] +var current_item: Item +var current_slot_index: int +var weapon_slot_ui: InventorySlotUI +var hat_slot_ui: InventorySlotUI +var backpack_slot_ui: InventorySlotUI +var _tooltip_layout_request := 0 +var _tooltip_should_be_visible := false + +@onready var grid_container: GridContainer = get_node( + "SafeArea/CenterContainer/InventoryPanels/InventoryPanel/MarginContainer/VBoxContainer/GridContainer" +) +@onready var close_button: Button = get_node( + "SafeArea/CenterContainer/InventoryPanels/InventoryPanel/MarginContainer/VBoxContainer/TitleBar/CloseButton" +) +@onready var tooltip: Control = $ItemTooltip +@onready var tooltip_label: RichTextLabel = $ItemTooltip/Panel/MarginContainer/TooltipText +@onready var inventory_panels: HBoxContainer = $SafeArea/CenterContainer/InventoryPanels + + +func _ready(): + slot_ui_scene = preload("res://scenes/ui/inventory_slot_ui.tscn") + grid_container.columns = 4 + close_button.pressed.connect(_on_close_pressed) + tooltip.visible = false + _create_slot_uis() + _create_hat_slot_ui() + _create_weapon_slot_ui() + _create_backpack_slot_ui() + resized.connect(_update_responsive_layout) + call_deferred("_update_responsive_layout") + + +func _create_hat_slot_ui(): + hat_slot_ui = slot_ui_scene.instantiate() as InventorySlotUI + hat_slot_ui.custom_minimum_size = Vector2(64, 64) + hat_slot_ui.parent_inventory = self + hat_slot_ui.slot_type = InventorySlotUI.TYPE.HAT + hat_slot_ui.slot_clicked.connect(_on_slot_clicked) + hat_slot_ui.item_hovered.connect(_on_item_hovered) + hat_slot_ui.item_unhovered.connect(_on_item_unhovered) + ( + get_node( + "SafeArea/CenterContainer/InventoryPanels/EquipmentPanel/MarginContainer/VBoxContainer/HatSlotContainer" + ) + . add_child(hat_slot_ui) + ) + + +func _create_weapon_slot_ui(): + weapon_slot_ui = slot_ui_scene.instantiate() as InventorySlotUI + weapon_slot_ui.custom_minimum_size = Vector2(64, 64) + weapon_slot_ui.parent_inventory = self + weapon_slot_ui.slot_type = InventorySlotUI.TYPE.WEAPON + weapon_slot_ui.slot_clicked.connect(_on_slot_clicked) + weapon_slot_ui.item_hovered.connect(_on_item_hovered) + weapon_slot_ui.item_unhovered.connect(_on_item_unhovered) + ( + get_node( + "SafeArea/CenterContainer/InventoryPanels/EquipmentPanel/MarginContainer/VBoxContainer/WeaponSlotContainer" + ) + . add_child(weapon_slot_ui) + ) + + +func _create_backpack_slot_ui() -> void: + backpack_slot_ui = slot_ui_scene.instantiate() as InventorySlotUI + backpack_slot_ui.custom_minimum_size = Vector2(64, 64) + backpack_slot_ui.parent_inventory = self + backpack_slot_ui.slot_type = InventorySlotUI.TYPE.BACKPACK + backpack_slot_ui.slot_clicked.connect(_on_slot_clicked) + backpack_slot_ui.item_hovered.connect(_on_item_hovered) + backpack_slot_ui.item_unhovered.connect(_on_item_unhovered) + ( + get_node( + "SafeArea/CenterContainer/InventoryPanels/EquipmentPanel/MarginContainer/VBoxContainer/BackpackSlotContainer" + ) + . add_child(backpack_slot_ui) + ) + + +func _create_slot_uis(): + for child in grid_container.get_children(): + child.queue_free() + slot_uis.clear() + + for i in range(PlayerInventory.MAX_INVENTORY_SIZE): + var slot_ui = slot_ui_scene.instantiate() as InventorySlotUI + slot_ui.custom_minimum_size = Vector2(64, 64) + slot_ui.parent_inventory = self + slot_ui.visible = i < PlayerInventory.BASE_INVENTORY_SIZE + + slot_ui.slot_clicked.connect(_on_slot_clicked) + slot_ui.slot_double_clicked.connect(_on_slot_double_clicked) + slot_ui.item_hovered.connect(_on_item_hovered) + slot_ui.item_unhovered.connect(_on_item_unhovered) + + slot_ui.set_slot_data(null, i) + + grid_container.add_child(slot_ui) + slot_uis.append(slot_ui) + + +func update_inventory_display(): + if not current_player or not current_player.get_inventory(): + return + + var player_inventory = current_player.get_inventory() + var active_slot_count := player_inventory.get_active_slot_count() + for i in range(slot_uis.size()): + slot_uis[i].visible = i < active_slot_count + if i < active_slot_count: + slot_uis[i].set_slot_data(player_inventory.get_slot(i), i) + else: + slot_uis[i].set_slot_data(null, i) + weapon_slot_ui.set_slot_data(player_inventory.equipped_weapon, SLOT_INDEX_WEAPON) + hat_slot_ui.set_slot_data(player_inventory.equipped_hat, SLOT_INDEX_HAT) + backpack_slot_ui.set_slot_data(player_inventory.equipped_backpack, SLOT_INDEX_BACKPACK) + call_deferred("_update_responsive_layout") + + +func _on_slot_clicked(slot_index: int, button: int): + if button == MOUSE_BUTTON_RIGHT: + _handle_right_click(slot_index) + + +func _on_slot_double_clicked(slot_index: int) -> void: + if not current_player or not current_player.get_inventory(): + return + if slot_index < 0 or not current_player.get_inventory().is_slot_active(slot_index): + return + + var slot := current_player.get_inventory().get_slot(slot_index) + if not slot or slot.is_empty(): + return + + var item := ItemDatabase.get_item(slot.item_id) + if not item or not item.context_options.has(Item.ContextOptions.EQUIP): + return + if ( + item.item_type != Item.ItemType.WEAPON + and item.item_type != Item.ItemType.HAT + and item.item_type != Item.ItemType.BACKPACK + ): + return + + current_player.request_equip_item.rpc_id(1, slot_index, item.item_type) + + +func _handle_right_click(slot_index: int): + if not current_player or not current_player.get_inventory(): + return + + var player_inventory = current_player.get_inventory() + current_slot_index = slot_index + var slot: InventorySlot + if slot_index == SLOT_INDEX_WEAPON: + slot = player_inventory.equipped_weapon + elif slot_index == SLOT_INDEX_HAT: + slot = player_inventory.equipped_hat + elif slot_index == SLOT_INDEX_BACKPACK: + slot = player_inventory.equipped_backpack + else: + slot = player_inventory.get_slot(slot_index) + if slot and not slot.is_empty(): + current_item = ItemDatabase.get_item(slot.item_id) + if current_item: + _hide_tooltip() + var context_menu = PopupMenu.new() + add_child(context_menu) + context_menu.popup_hide.connect(context_menu.queue_free) + context_menu.id_pressed.connect(_on_item_selected) + if slot_index == SLOT_INDEX_WEAPON or slot_index == SLOT_INDEX_HAT or slot_index == SLOT_INDEX_BACKPACK: + context_menu.add_item( + _get_context_menu_string(Item.ContextOptions.UNEQUIP), Item.ContextOptions.UNEQUIP + ) + else: + for item_option in current_item.context_options: + context_menu.add_item(_get_context_menu_string(item_option), item_option) + + context_menu.set_position(get_viewport().get_mouse_position()) + context_menu.popup() + + +func _on_item_selected(index: int): + if not current_player or not current_player.get_inventory(): + return + + if index == Item.ContextOptions.EQUIP: + current_player.request_equip_item.rpc_id(1, current_slot_index, current_item.item_type) + elif index == Item.ContextOptions.UNEQUIP: + current_player.request_unequip_item.rpc_id(1, current_item.item_type) + elif index == Item.ContextOptions.DROP: + if current_item.scene_path.is_empty() or not ResourceLoader.exists(current_item.scene_path): + push_warning( + "Cannot drop item '" + current_item.id + "': invalid scene path '" + current_item.scene_path + "'" + ) + return + current_player.add_world_item.rpc_id( + 1, current_item.scene_path, current_player.get_node("GodotRobot3D/InfrontArea3D").global_position + ) + current_player.request_remove_item.rpc_id(1, current_item.id, 1) + refresh_display() + + +func _on_item_hovered(_slot_index: int, item: Item): + _show_tooltip(item) + + +func _on_item_unhovered(): + _hide_tooltip() + + +func _show_tooltip(item: Item): + if not item: + return + + var tooltip_content = "[font_size=18][b][color=#FFD700]" + item.name + "[/color][/b][/font_size]\n" + tooltip_content += "[color=#CCCCCC]" + item.description + "[/color]\n" + tooltip_content += "[color=#87CEEB]Type:[/color] " + _get_item_type_string(item.item_type) + "\n" + tooltip_content += "[color=#FF69B4]Rarity:[/color] " + _get_rarity_string(item.rarity) + "\n" + tooltip_content += "[color=#FFD700]Value:[/color] " + str(item.value) + " gold" + + if item.stackable: + tooltip_content += "\n[color=#98FB98]Max Stack:[/color] " + str(item.max_stack) + + tooltip_label.text = tooltip_content + _tooltip_should_be_visible = true + _queue_tooltip_layout() + + +func _hide_tooltip(): + _tooltip_should_be_visible = false + _tooltip_layout_request += 1 + tooltip.visible = false + tooltip.modulate = Color.WHITE + + +func _queue_tooltip_layout() -> void: + _set_tooltip_width() + _tooltip_layout_request += 1 + var request_id := _tooltip_layout_request + tooltip.modulate = Color(1.0, 1.0, 1.0, 0.0) + tooltip.visible = true + _apply_tooltip_layout_after_frame(request_id) + + +func _apply_tooltip_layout_after_frame(request_id: int) -> void: + await get_tree().process_frame + if request_id != _tooltip_layout_request or not _tooltip_should_be_visible or not tooltip or not tooltip_label: + return + + var tooltip_size := tooltip.size + var available_height := minf(TOOLTIP_MAX_HEIGHT, maxf(1.0, size.y - TOOLTIP_SCREEN_MARGIN * 2.0)) + tooltip_size.y = minf(float(tooltip_label.get_content_height()) + TOOLTIP_VERTICAL_PADDING, available_height) + tooltip.size = tooltip_size + _position_tooltip_smartly() + tooltip.modulate = Color.WHITE + + +func _set_tooltip_width() -> void: + var tooltip_size := tooltip.size + var available_width := maxf(1.0, size.x - TOOLTIP_SCREEN_MARGIN * 2.0) + tooltip_size.x = minf(TOOLTIP_MAX_WIDTH, available_width) + tooltip.size = tooltip_size + var label_minimum_size := tooltip_label.custom_minimum_size + label_minimum_size.x = maxf(1.0, tooltip_size.x - TOOLTIP_HORIZONTAL_PADDING) + tooltip_label.custom_minimum_size = label_minimum_size + var label_size := tooltip_label.size + label_size.x = label_minimum_size.x + tooltip_label.size = label_size + + +func _position_tooltip_smartly(): + var mouse_pos = get_global_mouse_position() + var tooltip_size = tooltip.size + + var viewport_size = get_viewport().get_visible_rect().size + var tooltip_pos = mouse_pos + Vector2.ONE * TOOLTIP_SCREEN_MARGIN + if tooltip_pos.x + tooltip_size.x > viewport_size.x - TOOLTIP_SCREEN_MARGIN: + tooltip_pos.x = mouse_pos.x - tooltip_size.x - TOOLTIP_SCREEN_MARGIN + if tooltip_pos.y + tooltip_size.y > viewport_size.y - TOOLTIP_SCREEN_MARGIN: + tooltip_pos.y = mouse_pos.y - tooltip_size.y - TOOLTIP_SCREEN_MARGIN + + var max_x := maxf(TOOLTIP_SCREEN_MARGIN, viewport_size.x - tooltip_size.x - TOOLTIP_SCREEN_MARGIN) + var max_y := maxf(TOOLTIP_SCREEN_MARGIN, viewport_size.y - tooltip_size.y - TOOLTIP_SCREEN_MARGIN) + tooltip_pos.x = clampf(tooltip_pos.x, TOOLTIP_SCREEN_MARGIN, max_x) + tooltip_pos.y = clampf(tooltip_pos.y, TOOLTIP_SCREEN_MARGIN, max_y) + + tooltip.global_position = tooltip_pos + + +func _update_responsive_layout() -> void: + if not inventory_panels or not tooltip: + return + var available_size := Vector2( + maxf(1.0, size.x - SAFE_AREA_MARGIN * 2.0), maxf(1.0, size.y - SAFE_AREA_MARGIN * 2.0) + ) + var content_size := inventory_panels.size + if content_size.x > 0.0 and content_size.y > 0.0: + var scale_factor := minf(1.0, minf(available_size.x / content_size.x, available_size.y / content_size.y)) + inventory_panels.pivot_offset = content_size * 0.5 + inventory_panels.scale = Vector2.ONE * scale_factor + + _set_tooltip_width() + if _tooltip_should_be_visible: + _queue_tooltip_layout() + + +func _get_item_type_string(type: Item.ItemType) -> String: + var type_name := "Unknown" + match type: + Item.ItemType.WEAPON: + type_name = "Weapon" + Item.ItemType.HAT: + type_name = "Hat" + Item.ItemType.BACKPACK: + type_name = "Backpack" + Item.ItemType.CONSUMABLE: + type_name = "Consumable" + Item.ItemType.TOOL: + type_name = "Tool" + Item.ItemType.MISC: + type_name = "Miscellaneous" + return type_name + + +func _get_rarity_string(rarity: Item.ItemRarity) -> String: + match rarity: + Item.ItemRarity.COMMON: + return "Common" + Item.ItemRarity.UNCOMMON: + return "Uncommon" + Item.ItemRarity.RARE: + return "Rare" + Item.ItemRarity.EPIC: + return "Epic" + Item.ItemRarity.LEGENDARY: + return "Legendary" + _: + return "Unknown" + + +func _get_context_menu_string(context: Item.ContextOptions) -> String: + match context: + Item.ContextOptions.DROP: + return "Drop" + Item.ContextOptions.EQUIP: + return "Equip" + Item.ContextOptions.UNEQUIP: + return "Unequip" + _: + return "Unknown" + + +func handle_item_drop(from_slot: int, to_slot: int): + if not current_player: + return + if from_slot == SLOT_INDEX_WEAPON: + current_player.request_unequip_item.rpc_id(1, Item.ItemType.WEAPON, to_slot) + elif from_slot == SLOT_INDEX_HAT: + current_player.request_unequip_item.rpc_id(1, Item.ItemType.HAT, to_slot) + elif from_slot == SLOT_INDEX_BACKPACK: + current_player.request_unequip_item.rpc_id(1, Item.ItemType.BACKPACK, to_slot) + else: + current_player.request_move_item.rpc_id(1, from_slot, to_slot) + + +func _on_close_pressed(): + _hide_tooltip() + inventory_closed.emit() + visible = false + + +func open_inventory(player: Character = null): + _hide_tooltip() + if player: + current_player = player + update_inventory_display() + visible = true + + +func close_inventory(): + _hide_tooltip() + visible = false + + +func refresh_display(): + update_inventory_display() + + +func handle_weapon_equip(from_slot: int, item: Dictionary): + if current_player and from_slot >= 0 and item.inventory_type == Item.ItemType.WEAPON: + current_player.request_equip_item.rpc_id(1, from_slot, Item.ItemType.WEAPON) + + +func handle_hat_equip(from_slot: int, item: Dictionary): + if current_player and from_slot >= 0 and item.inventory_type == Item.ItemType.HAT: + current_player.request_equip_item.rpc_id(1, from_slot, Item.ItemType.HAT) + + +func handle_backpack_equip(from_slot: int, item: Dictionary) -> void: + if current_player and from_slot >= 0 and item.inventory_type == Item.ItemType.BACKPACK: + current_player.request_equip_item.rpc_id(1, from_slot, Item.ItemType.BACKPACK) diff --git a/scripts/ui/inventory_ui.gd.uid b/scripts/ui/inventory_ui.gd.uid new file mode 100644 index 0000000..8d1b79f --- /dev/null +++ b/scripts/ui/inventory_ui.gd.uid @@ -0,0 +1 @@ +uid://c2w12kgcvtww4 diff --git a/scripts/ui/main_menu_ui.gd b/scripts/ui/main_menu_ui.gd new file mode 100644 index 0000000..b617201 --- /dev/null +++ b/scripts/ui/main_menu_ui.gd @@ -0,0 +1,71 @@ +class_name MainMenuUI +extends Control + +signal host_pressed(nickname: String, skin: String) +signal join_pressed(nickname: String, skin: String, address: String) +signal quit_pressed + +const SKIN_OPTIONS: Array[String] = ["Blue", "Yellow", "Green", "Red"] +const SAFE_AREA_MARGIN := 24.0 + +@onready var skin_input: OptionButton = $MainContainer/MainMenu/Option2/SkinInput +@onready var nick_input: LineEdit = $MainContainer/MainMenu/Option1/NickInput +@onready var address_input: LineEdit = $MainContainer/MainMenu/Option3/AddressInput +@onready var main_container: VBoxContainer = $MainContainer + + +func _ready() -> void: + skin_input.clear() + for skin_name in SKIN_OPTIONS: + skin_input.add_item(skin_name) + skin_input.select(0) + resized.connect(_update_responsive_layout) + call_deferred("_update_responsive_layout") + + +func _on_host_pressed() -> void: + var nickname = nick_input.text.strip_edges() + var skin = get_skin() + host_pressed.emit(nickname, skin) + + +func _on_join_pressed() -> void: + var nickname = nick_input.text.strip_edges() + var skin = get_skin() + var address = address_input.text.strip_edges() + join_pressed.emit(nickname, skin, address) + + +func _on_quit_pressed(): + quit_pressed.emit() + + +func show_menu(): + show() + call_deferred("_update_responsive_layout") + + +func hide_menu(): + hide() + + +func is_menu_visible() -> bool: + return visible + + +func _update_responsive_layout() -> void: + if not main_container: + return + var available_size := Vector2( + maxf(1.0, size.x - SAFE_AREA_MARGIN * 2.0), maxf(1.0, size.y - SAFE_AREA_MARGIN * 2.0) + ) + var content_size := main_container.get_combined_minimum_size() + if content_size.x <= 0.0 or content_size.y <= 0.0: + return + var scale_factor := minf(1.0, minf(available_size.x / content_size.x, available_size.y / content_size.y)) + main_container.pivot_offset = main_container.size * 0.5 + main_container.scale = Vector2.ONE * scale_factor + + +func get_skin() -> String: + return skin_input.get_item_text(skin_input.selected).to_lower() diff --git a/scripts/ui/main_menu_ui.gd.uid b/scripts/ui/main_menu_ui.gd.uid new file mode 100644 index 0000000..80cb1ff --- /dev/null +++ b/scripts/ui/main_menu_ui.gd.uid @@ -0,0 +1 @@ +uid://c9x8y7z6a5b4c3d2 diff --git a/scripts/ui/multiplayer_chat_ui.gd b/scripts/ui/multiplayer_chat_ui.gd new file mode 100644 index 0000000..b6ac27d --- /dev/null +++ b/scripts/ui/multiplayer_chat_ui.gd @@ -0,0 +1,155 @@ +class_name MultiplayerChatUI +extends Control + +signal message_sent(message_text: String) + +const MAX_CHAT_MESSAGES: int = 100 +const MAX_VISIBLE_MESSAGES: int = 8 +const FEED_VISIBLE_SECONDS: float = 6.0 +const FEED_FADE_SECONDS: float = 0.35 +const SAFE_AREA_MARGIN := 16.0 +const MIN_CHAT_WIDTH := 280.0 +const MAX_CHAT_WIDTH := 480.0 +const MAX_CHAT_HEIGHT := 268.0 + +var chat_visible: bool = false +var chat_history: Array[String] = [] +var fade_tween: Tween + +@onready var chat: RichTextLabel = $ChatLayout/Chat +@onready var message: LineEdit = $ChatLayout/Message +@onready var feed_timer: Timer = $FeedTimer + + +func _ready() -> void: + message.text_submitted.connect(_on_message_submitted) + feed_timer.timeout.connect(_on_feed_timer_timeout) + feed_timer.wait_time = FEED_VISIBLE_SECONDS + get_viewport().size_changed.connect(_update_responsive_layout) + _update_responsive_layout() + show() + clear_chat() + + +func _update_responsive_layout() -> void: + var viewport_size := get_viewport().get_visible_rect().size + var available_width := maxf(1.0, viewport_size.x - SAFE_AREA_MARGIN * 2.0) + var preferred_width := clampf(viewport_size.x * 0.42, MIN_CHAT_WIDTH, MAX_CHAT_WIDTH) + var target_width := minf(available_width, preferred_width) + var target_height := minf(MAX_CHAT_HEIGHT, maxf(1.0, viewport_size.y - SAFE_AREA_MARGIN * 2.0)) + + offset_left = SAFE_AREA_MARGIN + offset_right = SAFE_AREA_MARGIN + target_width + offset_bottom = -SAFE_AREA_MARGIN + offset_top = -SAFE_AREA_MARGIN - target_height + + +func toggle_chat() -> void: + if chat_visible: + close_chat() + return + + chat_visible = true + _stop_feed_animation() + _show_feed() + message.show() + await get_tree().process_frame + if not chat_visible: + return + message.grab_focus() + + +func close_chat() -> void: + chat_visible = false + message.text = "" + message.release_focus() + message.hide() + + if chat_history.is_empty(): + chat.hide() + else: + _show_feed_temporarily() + + +func is_chat_visible() -> bool: + return chat_visible + + +func _on_message_submitted(_submitted_text: String) -> void: + var message_text := message.text.strip_edges() + if message_text.is_empty(): + return + + close_chat() + message_sent.emit(message_text) + + +func add_message(nick: String, msg: String) -> void: + var formatted_message := _escape_bbcode("%s: %s" % [nick, msg]) + chat_history.append(formatted_message) + while chat_history.size() > MAX_CHAT_MESSAGES: + chat_history.pop_front() + + _render_chat_history() + if chat_visible: + _stop_feed_animation() + _show_feed() + else: + _show_feed_temporarily() + + +func _render_chat_history() -> void: + chat.clear() + var first_message := maxi(0, chat_history.size() - MAX_VISIBLE_MESSAGES) + for index in range(first_message, chat_history.size()): + chat.append_text(chat_history[index]) + if index < chat_history.size() - 1: + chat.append_text("\n") + + +func _show_feed_temporarily() -> void: + _stop_feed_animation() + _show_feed() + feed_timer.start() + + +func _show_feed() -> void: + chat.modulate.a = 1.0 + chat.show() + + +func _on_feed_timer_timeout() -> void: + if chat_visible: + return + + _stop_fade_tween() + fade_tween = create_tween() + fade_tween.tween_property(chat, "modulate:a", 0.0, FEED_FADE_SECONDS) + fade_tween.tween_callback(chat.hide) + + +func _stop_feed_animation() -> void: + feed_timer.stop() + _stop_fade_tween() + + +func _stop_fade_tween() -> void: + if fade_tween and fade_tween.is_valid(): + fade_tween.kill() + fade_tween = null + + +func _escape_bbcode(text: String) -> String: + return text.replace("[", "[lb]") + + +func clear_chat() -> void: + _stop_feed_animation() + chat_history.clear() + chat.clear() + chat.modulate.a = 1.0 + chat.hide() + message.text = "" + message.release_focus() + message.hide() + chat_visible = false diff --git a/scripts/ui/multiplayer_chat_ui.gd.uid b/scripts/ui/multiplayer_chat_ui.gd.uid new file mode 100644 index 0000000..9e54909 --- /dev/null +++ b/scripts/ui/multiplayer_chat_ui.gd.uid @@ -0,0 +1 @@ +uid://c8x9y2z1a3b4c5d6 diff --git a/scripts/ui/pause_menu_ui.gd b/scripts/ui/pause_menu_ui.gd new file mode 100644 index 0000000..66a4baa --- /dev/null +++ b/scripts/ui/pause_menu_ui.gd @@ -0,0 +1,57 @@ +class_name PauseMenuUI +extends Control + +signal resume_pressed +signal main_menu_pressed +signal quit_pressed + +const SAFE_AREA_MARGIN := 16.0 + +@onready var resume_button: Button = $Overlay/Panel/MarginContainer/Buttons/Resume +@onready var panel: PanelContainer = $Overlay/Panel + + +func _ready() -> void: + resized.connect(_update_responsive_layout) + call_deferred("_update_responsive_layout") + hide() + + +func show_menu() -> void: + show() + call_deferred("_update_responsive_layout") + resume_button.grab_focus() + + +func hide_menu() -> void: + hide() + + +func is_menu_visible() -> bool: + return visible + + +func _update_responsive_layout() -> void: + if not panel: + return + var available_size := Vector2( + maxf(1.0, size.x - SAFE_AREA_MARGIN * 2.0), maxf(1.0, size.y - SAFE_AREA_MARGIN * 2.0) + ) + var panel_size := panel.size + if panel_size.x <= 0.0 or panel_size.y <= 0.0: + return + var scale_factor := minf(1.0, minf(available_size.x / panel_size.x, available_size.y / panel_size.y)) + panel.pivot_offset = panel_size * 0.5 + panel.scale = Vector2.ONE * scale_factor + + +func _on_resume_pressed() -> void: + resume_pressed.emit() + + +func _on_main_menu_pressed() -> void: + main_menu_pressed.emit() + + +func _on_quit_pressed() -> void: + quit_pressed.emit() diff --git a/scripts/ui/pause_menu_ui.gd.uid b/scripts/ui/pause_menu_ui.gd.uid new file mode 100644 index 0000000..f66db2d --- /dev/null +++ b/scripts/ui/pause_menu_ui.gd.uid @@ -0,0 +1 @@ +uid://wbiqr2q6c47q diff --git a/scripts/ui/player_list_ui.gd b/scripts/ui/player_list_ui.gd new file mode 100644 index 0000000..2b392ce --- /dev/null +++ b/scripts/ui/player_list_ui.gd @@ -0,0 +1,109 @@ +class_name PlayerListUI +extends Control + +const PLAYER_ROW_HEIGHT := 28.0 +const MAX_VISIBLE_ROWS := 8 +const RESERVED_VERTICAL_SPACE := 190.0 +const SAFE_AREA_MARGIN := 16.0 + +var _player_count := 0 + +@onready var panel: PanelContainer = $SafeArea/PositionContainer/Panel +@onready var count_label: Label = $SafeArea/PositionContainer/Panel/MarginContainer/Content/Count +@onready var player_scroll: ScrollContainer = $SafeArea/PositionContainer/Panel/MarginContainer/Content/PlayerScroll +@onready +var players_label: RichTextLabel = $SafeArea/PositionContainer/Panel/MarginContainer/Content/PlayerScroll/Players + + +func _ready() -> void: + resized.connect(_update_layout) + _update_layout() + hide() + + +func _unhandled_input(event: InputEvent) -> void: + if not visible or not (event is InputEventMouseButton): + return + var mouse_event := event as InputEventMouseButton + if not mouse_event.pressed: + return + if mouse_event.button_index == MOUSE_BUTTON_WHEEL_UP: + player_scroll.scroll_vertical -= int(PLAYER_ROW_HEIGHT) + elif mouse_event.button_index == MOUSE_BUTTON_WHEEL_DOWN: + player_scroll.scroll_vertical += int(PLAYER_ROW_HEIGHT) + + +func show_players(players: Dictionary, local_peer_id: int) -> void: + refresh_players(players, local_peer_id) + player_scroll.scroll_vertical = 0 + show() + call_deferred("_apply_panel_scale") + + +func hide_players() -> void: + hide() + + +func refresh_players(players: Dictionary, local_peer_id: int) -> void: + var peer_ids: Array[int] = [] + for peer_id_value in players: + var peer_id := int(peer_id_value) + if peer_id > 0: + peer_ids.append(peer_id) + peer_ids.sort() + _player_count = peer_ids.size() + + count_label.text = "%d online" % _player_count + players_label.clear() + for index in range(peer_ids.size()): + var peer_id := peer_ids[index] + var player_info: Dictionary = players.get(peer_id, {}) + var nickname := Network.sanitize_nickname(str(player_info.get("nick", "")), "Player_" + str(peer_id)) + var local_marker := " [color=#c7ccd4](you)[/color]" if peer_id == local_peer_id else "" + players_label.append_text( + ( + "[color=#a8adb5]>[/color] [b]%s[/b]%s [color=#9aa0a8]#%d[/color]" + % [_escape_bbcode(nickname), local_marker, peer_id] + ) + ) + if index < peer_ids.size() - 1: + players_label.append_text("\n") + _update_layout() + + +func _update_layout() -> void: + if not panel or not player_scroll: + return + var panel_minimum_size := panel.custom_minimum_size + panel_minimum_size.x = maxf(0.0, minf(420.0, size.x - 32.0)) + panel.custom_minimum_size = panel_minimum_size + + var visible_rows := maxi(1, mini(_player_count, MAX_VISIBLE_ROWS)) + var desired_height := float(visible_rows) * PLAYER_ROW_HEIGHT + var viewport_limit := maxf(30.0, size.y - RESERVED_VERTICAL_SPACE) + var scroll_minimum_size := player_scroll.custom_minimum_size + scroll_minimum_size.y = minf(desired_height, viewport_limit) + player_scroll.custom_minimum_size = scroll_minimum_size + + var players_minimum_size := players_label.custom_minimum_size + players_minimum_size.y = float(maxi(1, _player_count)) * PLAYER_ROW_HEIGHT + players_label.custom_minimum_size = players_minimum_size + call_deferred("_apply_panel_scale") + + +func _apply_panel_scale() -> void: + if not panel: + return + var available_size := Vector2( + maxf(1.0, size.x - SAFE_AREA_MARGIN * 2.0), maxf(1.0, size.y - SAFE_AREA_MARGIN * 2.0) + ) + var panel_size := panel.size + if panel_size.x <= 0.0 or panel_size.y <= 0.0: + return + var scale_factor := minf(1.0, minf(available_size.x / panel_size.x, available_size.y / panel_size.y)) + panel.pivot_offset = panel_size * 0.5 + panel.scale = Vector2.ONE * scale_factor + + +func _escape_bbcode(text: String) -> String: + return text.replace("[", "[lb]") diff --git a/scripts/ui/player_list_ui.gd.uid b/scripts/ui/player_list_ui.gd.uid new file mode 100644 index 0000000..65adc49 --- /dev/null +++ b/scripts/ui/player_list_ui.gd.uid @@ -0,0 +1 @@ +uid://dpxhd38sedg8u