From 904a9c2d9ea730905daa3f3449cb14c80aadb3af Mon Sep 17 00:00:00 2001 From: Voyager Date: Sat, 1 Aug 2026 17:12:29 -0400 Subject: [PATCH] Add multiplayer fish showcases --- fishing/fishing_spot.gd | 8 + inventory/player_hotbar.gd | 169 ++++++++++-- main/main.gd | 18 +- main/main.tscn | 5 + network/network_fish_showcase_protocol.gd | 56 ++++ network/network_fish_showcase_protocol.gd.uid | 1 + network/network_fish_showcase_service.gd | 253 ++++++++++++++++++ network/network_fish_showcase_service.gd.uid | 1 + network/network_protocol.gd | 3 +- network/network_session.gd | 3 +- player/player.gd | 22 ++ player/player.tscn | 15 ++ save/player_save_manager.gd | 24 +- tests/fish_hotbar_showcase_validation.gd | 138 ++++++++++ tests/fish_hotbar_showcase_validation.gd.uid | 1 + tests/fish_showcase_multiplayer_validation.gd | 153 +++++++++++ ...ish_showcase_multiplayer_validation.gd.uid | 1 + .../bubble_hotbar/bubble_hotbar_slot.gd | 71 ++++- .../bubble_menu/cooler_fish_sprite.gd | 31 +++ ui/game_ui.gd | 2 +- ui/hotbar.gd | 34 ++- ui/player_menu.gd | 1 + ui/player_menu.tscn | 2 +- 23 files changed, 966 insertions(+), 46 deletions(-) create mode 100644 network/network_fish_showcase_protocol.gd create mode 100644 network/network_fish_showcase_protocol.gd.uid create mode 100644 network/network_fish_showcase_service.gd create mode 100644 network/network_fish_showcase_service.gd.uid create mode 100644 tests/fish_hotbar_showcase_validation.gd create mode 100644 tests/fish_hotbar_showcase_validation.gd.uid create mode 100644 tests/fish_showcase_multiplayer_validation.gd create mode 100644 tests/fish_showcase_multiplayer_validation.gd.uid diff --git a/fishing/fishing_spot.gd b/fishing/fishing_spot.gd index 09955ba..e00ff98 100644 --- a/fishing/fishing_spot.gd +++ b/fishing/fishing_spot.gd @@ -55,6 +55,7 @@ signal showcase_changed( ) signal bite_activated signal ready_for_equipment_refresh +signal fish_showcase_toggle_requested enum FishingState { READY, @@ -449,6 +450,13 @@ func _unhandled_input(event: InputEvent) -> void: FishingState.READY: if not _new_cast_press_armed: return + if ( + _local_hotbar != null + and not _local_hotbar.get_selected_fish_catch_id().is_empty() + ): + fish_showcase_toggle_requested.emit() + get_viewport().set_input_as_handled() + return var active_item: ItemDataType = _get_active_item() if ( active_item != null diff --git a/inventory/player_hotbar.gd b/inventory/player_hotbar.gd index 94cbb79..e9dbcec 100644 --- a/inventory/player_hotbar.gd +++ b/inventory/player_hotbar.gd @@ -5,28 +5,56 @@ const SLOT_COUNT: int = 9 const ItemCatalogType = preload("res://items/item_catalog.gd") const ItemDataType = preload("res://items/item_data.gd") const PlayerBagType = preload("res://inventory/player_bag.gd") +const FishInventoryType = preload("res://inventory/fish_inventory.gd") + +enum AssignmentKind { + EMPTY, + ITEM, + FISH, +} signal slots_changed signal selected_slot_changed(slot_index: int, item_id: StringName) +signal selected_assignment_changed( + slot_index: int, + kind: AssignmentKind, + identity: StringName, +) var _bag: PlayerBagType var _catalog: ItemCatalogType +var _fish_inventory: FishInventoryType var _slots: Array[StringName] = [] +var _fish_slots: Array[StringName] = [] var _selected_slot: int = 0 func _init() -> void: _slots.resize(SLOT_COUNT) _slots.fill(StringName()) + _fish_slots.resize(SLOT_COUNT) + _fish_slots.fill(StringName()) -func setup(bag: PlayerBagType, catalog: ItemCatalogType) -> void: +func setup( + bag: PlayerBagType, + catalog: ItemCatalogType, + fish_inventory: FishInventoryType = null, +) -> void: _bag = bag _catalog = catalog + _fish_inventory = fish_inventory if _bag != null and not _bag.contents_changed.is_connected( _on_bag_contents_changed ): _bag.contents_changed.connect(_on_bag_contents_changed) + if ( + _fish_inventory != null + and not _fish_inventory.catches_changed.is_connected( + _on_fish_inventory_changed + ) + ): + _fish_inventory.catches_changed.connect(_on_fish_inventory_changed) _validate_assignments() @@ -35,23 +63,55 @@ func assign_item(slot_index: int, item_id: StringName) -> bool: return false if _slots[slot_index] == item_id: return true + var selected_slot_was_affected: bool = slot_index == _selected_slot for index: int in range(SLOT_COUNT): if index != slot_index and _slots[index] == item_id: _slots[index] = StringName() + selected_slot_was_affected = ( + selected_slot_was_affected or index == _selected_slot + ) _slots[slot_index] = item_id + _fish_slots[slot_index] = StringName() slots_changed.emit() - if slot_index == _selected_slot: - selected_slot_changed.emit(_selected_slot, item_id) + if selected_slot_was_affected: + _emit_selected_assignment() + return true + + +func assign_fish(slot_index: int, catch_id: StringName) -> bool: + if not _is_slot_valid(slot_index) or not _can_assign_fish(catch_id): + return false + if _fish_slots[slot_index] == catch_id: + return true + var selected_slot_was_affected: bool = slot_index == _selected_slot + for index: int in range(SLOT_COUNT): + if index != slot_index and _fish_slots[index] == catch_id: + _fish_slots[index] = StringName() + selected_slot_was_affected = ( + selected_slot_was_affected or index == _selected_slot + ) + _slots[slot_index] = StringName() + _fish_slots[slot_index] = catch_id + slots_changed.emit() + if selected_slot_was_affected: + _emit_selected_assignment() return true func clear_slot(slot_index: int) -> bool: - if not _is_slot_valid(slot_index) or _slots[slot_index].is_empty(): + if ( + not _is_slot_valid(slot_index) + or ( + _slots[slot_index].is_empty() + and _fish_slots[slot_index].is_empty() + ) + ): return false _slots[slot_index] = StringName() + _fish_slots[slot_index] = StringName() slots_changed.emit() if slot_index == _selected_slot: - selected_slot_changed.emit(_selected_slot, StringName()) + _emit_selected_assignment() return true @@ -65,12 +125,12 @@ func swap_slots(from_index: int, to_index: int) -> bool: var temporary: StringName = _slots[from_index] _slots[from_index] = _slots[to_index] _slots[to_index] = temporary + temporary = _fish_slots[from_index] + _fish_slots[from_index] = _fish_slots[to_index] + _fish_slots[to_index] = temporary slots_changed.emit() if from_index == _selected_slot or to_index == _selected_slot: - selected_slot_changed.emit( - _selected_slot, - _slots[_selected_slot] - ) + _emit_selected_assignment() return true @@ -80,7 +140,7 @@ func select_slot(slot_index: int) -> bool: if _selected_slot == slot_index: return true _selected_slot = slot_index - selected_slot_changed.emit(_selected_slot, _slots[_selected_slot]) + _emit_selected_assignment() return true @@ -100,21 +160,55 @@ func get_selected_item_id() -> StringName: return _slots[_selected_slot] +func get_selected_fish_catch_id() -> StringName: + return _fish_slots[_selected_slot] + + +func get_selected_assignment_kind() -> AssignmentKind: + return get_assignment_kind(_selected_slot) + + func get_item_id(slot_index: int) -> StringName: return _slots[slot_index] if _is_slot_valid(slot_index) else StringName() +func get_fish_catch_id(slot_index: int) -> StringName: + return ( + _fish_slots[slot_index] + if _is_slot_valid(slot_index) + else StringName() + ) + + +func get_assignment_kind(slot_index: int) -> AssignmentKind: + if not _is_slot_valid(slot_index): + return AssignmentKind.EMPTY + if not _fish_slots[slot_index].is_empty(): + return AssignmentKind.FISH + if not _slots[slot_index].is_empty(): + return AssignmentKind.ITEM + return AssignmentKind.EMPTY + + func get_slots() -> Array[StringName]: return _slots.duplicate() +func get_fish_slots() -> Array[StringName]: + return _fish_slots.duplicate() + + func replace_state( slots: Array[StringName], selected_slot: int, + fish_slots: Array[StringName] = [], ) -> bool: var normalized: Array[StringName] = [] normalized.resize(SLOT_COUNT) normalized.fill(StringName()) + var normalized_fish: Array[StringName] = [] + normalized_fish.resize(SLOT_COUNT) + normalized_fish.fill(StringName()) var seen_assignments: Dictionary[StringName, bool] = {} for index: int in range(mini(slots.size(), SLOT_COUNT)): var item_id: StringName = slots[index] @@ -123,19 +217,28 @@ func replace_state( if _can_assign(item_id) and not seen_assignments.has(item_id): normalized[index] = item_id seen_assignments[item_id] = true + var seen_fish: Dictionary[StringName, bool] = {} + for index: int in range(mini(fish_slots.size(), SLOT_COUNT)): + if not normalized[index].is_empty(): + continue + var catch_id: StringName = fish_slots[index] + if _can_assign_fish(catch_id) and not seen_fish.has(catch_id): + normalized_fish[index] = catch_id + seen_fish[catch_id] = true _slots = normalized + _fish_slots = normalized_fish _selected_slot = clampi(selected_slot, 0, SLOT_COUNT - 1) slots_changed.emit() - selected_slot_changed.emit( - _selected_slot, - _slots[_selected_slot] - ) + _emit_selected_assignment() return true func has_any_assignment() -> bool: - for item_id: StringName in _slots: - if not item_id.is_empty(): + for index: int in range(SLOT_COUNT): + if ( + not _slots[index].is_empty() + or not _fish_slots[index].is_empty() + ): return true return false @@ -149,23 +252,49 @@ func _can_assign(item_id: StringName) -> bool: return item != null and item.is_valid() and item.hotbar_allowed +func _can_assign_fish(catch_id: StringName) -> bool: + return ( + not catch_id.is_empty() + and _fish_inventory != null + and _fish_inventory.contains_catch_id(catch_id) + ) + + func _validate_assignments() -> void: var changed: bool = false for index: int in range(SLOT_COUNT): if not _slots[index].is_empty() and not _can_assign(_slots[index]): _slots[index] = StringName() changed = true + if ( + not _fish_slots[index].is_empty() + and not _can_assign_fish(_fish_slots[index]) + ): + _fish_slots[index] = StringName() + changed = true if changed: slots_changed.emit() - selected_slot_changed.emit( - _selected_slot, - _slots[_selected_slot] - ) + _emit_selected_assignment() func _on_bag_contents_changed() -> void: _validate_assignments() +func _on_fish_inventory_changed() -> void: + _validate_assignments() + + +func _emit_selected_assignment() -> void: + var kind: AssignmentKind = get_selected_assignment_kind() + var identity: StringName = StringName() + if kind == AssignmentKind.ITEM: + identity = _slots[_selected_slot] + elif kind == AssignmentKind.FISH: + identity = _fish_slots[_selected_slot] + selected_slot_changed.emit(_selected_slot, _slots[_selected_slot]) + selected_assignment_changed.emit(_selected_slot, kind, identity) + + func _is_slot_valid(slot_index: int) -> bool: return slot_index >= 0 and slot_index < SLOT_COUNT diff --git a/main/main.gd b/main/main.gd index f845ae3..65ccc52 100644 --- a/main/main.gd +++ b/main/main.gd @@ -55,6 +55,9 @@ const NetworkShopServiceType = preload( const NetworkItemUseServiceType = preload( "res://network/network_item_use_service.gd" ) +const NetworkFishShowcaseServiceType = preload( + "res://network/network_fish_showcase_service.gd" +) const NetworkChatServiceType = preload( "res://network/network_chat_service.gd" ) @@ -124,6 +127,9 @@ const SHOP_PATTERN_SCALE: float = 1.75 @onready var _network_item_use: NetworkItemUseServiceType = ( %NetworkItemUseService ) +@onready var _network_fish_showcase: NetworkFishShowcaseServiceType = ( + %NetworkFishShowcaseService +) @onready var _network_chat: NetworkChatServiceType = %NetworkChatService @onready var _network_mail: NetworkMailServiceType = %NetworkMailService @onready var _network_player_list: NetworkPlayerListService = %NetworkPlayerListService @@ -295,7 +301,7 @@ func _initialize_after_data_root() -> void: _asset_reservations ) _player.bag.setup(item_catalog) - _player.hotbar.setup(_player.bag, item_catalog) + _player.hotbar.setup(_player.bag, item_catalog, _player.inventory) _shop_interaction = _test_world.get_fishing_shop() _shop_interaction.setup_local_player(_player) _shop_interaction.local_player_range_changed.connect( @@ -338,6 +344,13 @@ func _initialize_after_data_root() -> void: _save_manager, _asset_reservations ) + _network_fish_showcase.setup( + _network_session, + _player_spawn_service, + fish_catalog, + _player.inventory, + _player.hotbar, + ) _network_chat.setup(_network_session) _network_fishing.setup( _network_session, @@ -521,6 +534,9 @@ func _initialize_after_data_root() -> void: _fishing_spot.ready_for_equipment_refresh.connect( _refresh_active_hotbar_item ) + _fishing_spot.fish_showcase_toggle_requested.connect( + _network_fish_showcase.toggle_selected_fish + ) _water_recovery.recovery_starting.connect( _on_water_recovery_starting ) diff --git a/main/main.tscn b/main/main.tscn index 16fbac6..ea69081 100644 --- a/main/main.tscn +++ b/main/main.tscn @@ -44,6 +44,7 @@ [ext_resource type="Shader" uid="uid://byw3iub66qvog" path="res://ui/player_menu_pattern.gdshader" id="42_menu_pattern"] [ext_resource type="Texture2D" uid="uid://b6xws1d2dnbnn" path="res://art/patterns/pattern_moneyfish.png" id="43_shop_pattern"] [ext_resource type="PackedScene" uid="uid://w7n4gjqq1juc" path="res://art/exported/characters/base/netfishing_base_character.glb" id="44_4pcu1"] +[ext_resource type="Script" path="res://network/network_fish_showcase_service.gd" id="45_fish_showcase"] [sub_resource type="ShaderMaterial" id="ShaderMaterial_title_water_native"] shader = ExtResource("40_title_water") @@ -183,6 +184,10 @@ script = ExtResource("23_network_shop") unique_name_in_owner = true script = ExtResource("24_network_item") +[node name="NetworkFishShowcaseService" type="Node" parent="."] +unique_name_in_owner = true +script = ExtResource("45_fish_showcase") + [node name="NetworkChatService" type="Node" parent="." unique_id=1956959711] unique_name_in_owner = true script = ExtResource("25_network_chat") diff --git a/network/network_fish_showcase_protocol.gd b/network/network_fish_showcase_protocol.gd new file mode 100644 index 0000000..2028976 --- /dev/null +++ b/network/network_fish_showcase_protocol.gd @@ -0,0 +1,56 @@ +class_name NetworkFishShowcaseProtocol +extends RefCounted + +const CAPABILITY: StringName = &"fish_showcase_v1" +const RELIABLE_CHANNEL: int = NetworkProtocol.ITEM_RELIABLE_CHANNEL +const MAX_SESSION_ID_LENGTH: int = 96 +const MAX_FISH_ID_LENGTH: int = 96 +const MAX_WEIGHT_LB: float = 1000.0 +const MAX_DISPLAY_SCALE: float = 20.0 + + +static func validate_state(data: Variant) -> bool: + if typeof(data) != TYPE_DICTIONARY: + return false + var value: Dictionary = data + for key: String in [ + "session_id", + "owner_peer_id", + "visible", + "fish_id", + "weight_lb", + "display_scale", + "revision", + ]: + if not value.has(key): + return false + if ( + typeof(value["session_id"]) != TYPE_STRING + or str(value["session_id"]).is_empty() + or str(value["session_id"]).length() > MAX_SESSION_ID_LENGTH + or typeof(value["owner_peer_id"]) != TYPE_INT + or int(value["owner_peer_id"]) <= 0 + or typeof(value["visible"]) != TYPE_BOOL + or typeof(value["fish_id"]) not in [TYPE_STRING, TYPE_STRING_NAME] + or str(value["fish_id"]).length() > MAX_FISH_ID_LENGTH + or typeof(value["weight_lb"]) not in [TYPE_FLOAT, TYPE_INT] + or not is_finite(float(value["weight_lb"])) + or typeof(value["display_scale"]) not in [TYPE_FLOAT, TYPE_INT] + or not is_finite(float(value["display_scale"])) + or typeof(value["revision"]) != TYPE_INT + or int(value["revision"]) < 0 + ): + return false + if bool(value["visible"]): + return ( + not str(value["fish_id"]).is_empty() + and float(value["weight_lb"]) > 0.0 + and float(value["weight_lb"]) <= MAX_WEIGHT_LB + and float(value["display_scale"]) > 0.0 + and float(value["display_scale"]) <= MAX_DISPLAY_SCALE + ) + return ( + str(value["fish_id"]).is_empty() + and is_zero_approx(float(value["weight_lb"])) + and is_zero_approx(float(value["display_scale"])) + ) diff --git a/network/network_fish_showcase_protocol.gd.uid b/network/network_fish_showcase_protocol.gd.uid new file mode 100644 index 0000000..322418d --- /dev/null +++ b/network/network_fish_showcase_protocol.gd.uid @@ -0,0 +1 @@ +uid://b3h0h47ilh3nm diff --git a/network/network_fish_showcase_service.gd b/network/network_fish_showcase_service.gd new file mode 100644 index 0000000..f4670c5 --- /dev/null +++ b/network/network_fish_showcase_service.gd @@ -0,0 +1,253 @@ +class_name NetworkFishShowcaseService +extends Node + +const FishPoolType = preload("res://fish/fish_pool.gd") +const FishDataType = preload("res://fish/fish_data.gd") +const FishCatchType = preload("res://fish/fish_catch.gd") +const FishInventoryType = preload("res://inventory/fish_inventory.gd") +const PlayerHotbarType = preload("res://inventory/player_hotbar.gd") + +var _session: NetworkSession +var _spawn_service: PlayerSpawnService +var _fish_catalog: FishPoolType +var _local_inventory: FishInventoryType +var _local_hotbar: PlayerHotbarType +var _states: Dictionary[int, Dictionary] = {} +var _local_revision: int = 0 +var _local_visible: bool = false +var _local_catch_id: StringName + + +func setup( + session: NetworkSession, + spawn_service: PlayerSpawnService, + fish_catalog: FishPoolType, + local_inventory: FishInventoryType, + local_hotbar: PlayerHotbarType, +) -> void: + _session = session + _spawn_service = spawn_service + _fish_catalog = fish_catalog + _local_inventory = local_inventory + _local_hotbar = local_hotbar + _session.peer_authenticated.connect(_on_peer_authenticated) + _session.peer_removed.connect(_on_peer_removed) + _session.state_changed.connect(_on_session_state_changed) + _spawn_service.avatar_spawned.connect(_on_avatar_spawned) + _local_inventory.catches_changed.connect(_on_local_inventory_changed) + _local_hotbar.selected_assignment_changed.connect( + _on_selected_assignment_changed + ) + + +func toggle_selected_fish() -> bool: + if _local_hotbar == null or _local_inventory == null: + return false + var catch_id: StringName = _local_hotbar.get_selected_fish_catch_id() + var fish_catch: FishCatchType = _local_inventory.get_catch_by_id(catch_id) + if fish_catch == null or not fish_catch.is_valid(): + return false + if _local_visible and _local_catch_id == catch_id: + _submit_local_state(null, false) + else: + _submit_local_state(fish_catch, true) + return true + + +func is_local_showcase_visible() -> bool: + return _local_visible + + +func get_local_showcase_catch_id() -> StringName: + return _local_catch_id + + +func _submit_local_state(fish_catch: FishCatchType, should_show: bool) -> void: + _local_revision += 1 + var local_peer_id: int = ( + _session.get_local_peer_id() if _session != null else 1 + ) + var data: Dictionary = { + "session_id": ( + _session.get_session_id() + if _session != null and not _session.get_session_id().is_empty() + else "local" + ), + "owner_peer_id": local_peer_id, + "visible": should_show and fish_catch != null, + "fish_id": String(fish_catch.fish_id) if fish_catch != null else "", + "weight_lb": fish_catch.weight_lb if fish_catch != null else 0.0, + "display_scale": ( + fish_catch.display_scale if fish_catch != null else 0.0 + ), + "revision": _local_revision, + } + _local_visible = bool(data["visible"]) + _local_catch_id = ( + fish_catch.catch_id if _local_visible else StringName() + ) + _apply_state(data) + if _session == null or not _session.is_gameplay_session_active(): + return + data["session_id"] = _session.get_session_id() + if _session.is_host(): + _handle_showcase_state(local_peer_id, data) + elif _session.supports_server_capability( + NetworkFishShowcaseProtocol.CAPABILITY + ): + submit_showcase_state.rpc_id(1, data) + + +@rpc( + "any_peer", + "call_remote", + "reliable", + NetworkFishShowcaseProtocol.RELIABLE_CHANNEL, +) +func submit_showcase_state(data: Dictionary) -> void: + var sender_id: int = multiplayer.get_remote_sender_id() + if _session.is_host() and _session.is_authenticated_peer(sender_id): + _handle_showcase_state(sender_id, data) + + +func _handle_showcase_state(peer_id: int, data: Dictionary) -> void: + if ( + not _session.is_host() + or not NetworkFishShowcaseProtocol.validate_state(data) + or str(data["session_id"]) != _session.get_session_id() + or int(data["owner_peer_id"]) != peer_id + or ( + peer_id != _session.get_local_peer_id() + and not _session.is_authenticated_peer(peer_id) + ) + ): + return + var previous: Dictionary = _states.get(peer_id, {}) + if int(data["revision"]) <= int(previous.get("revision", -1)): + return + if not _state_matches_catalog(data): + return + var sanitized: Dictionary = data.duplicate(true) + _states[peer_id] = sanitized + _apply_state(sanitized) + receive_showcase_state.rpc(sanitized) + + +@rpc( + "authority", + "call_remote", + "reliable", + NetworkFishShowcaseProtocol.RELIABLE_CHANNEL, +) +func receive_showcase_state(data: Dictionary) -> void: + if ( + not NetworkFishShowcaseProtocol.validate_state(data) + or str(data["session_id"]) != _session.get_session_id() + or not _state_matches_catalog(data) + ): + return + var peer_id: int = int(data["owner_peer_id"]) + var previous: Dictionary = _states.get(peer_id, {}) + if int(data["revision"]) <= int(previous.get("revision", -1)): + return + _states[peer_id] = data.duplicate(true) + _apply_state(data) + + +func _state_matches_catalog(data: Dictionary) -> bool: + if not bool(data["visible"]): + return true + var fish_id: StringName = StringName(str(data["fish_id"])) + var fish: FishDataType = _fish_catalog.get_fish_by_id(fish_id) + if fish == null or not fish.is_selectable(): + return false + var weight_lb: float = float(data["weight_lb"]) + var display_scale: float = float(data["display_scale"]) + return ( + weight_lb >= fish.get_minimum_weight() + and weight_lb <= fish.get_maximum_weight() + and is_equal_approx( + display_scale, + fish.get_display_scale_for_weight(weight_lb), + ) + ) + + +func _apply_state(data: Dictionary) -> void: + var peer_id: int = int(data["owner_peer_id"]) + var avatar: Player = _spawn_service.get_avatar(peer_id) + if avatar == null: + return + if not bool(data["visible"]): + avatar.set_held_fish(null, 1.0, false) + return + var fish: FishDataType = _fish_catalog.get_fish_by_id( + StringName(str(data["fish_id"])) + ) + avatar.set_held_fish(fish, float(data["display_scale"]), true) + + +func _on_selected_assignment_changed( + _slot_index: int, + kind: int, + identity: StringName, +) -> void: + if ( + _local_visible + and ( + kind != PlayerHotbarType.AssignmentKind.FISH + or identity != _local_catch_id + ) + ): + _submit_local_state(null, false) + + +func _on_local_inventory_changed() -> void: + if ( + _local_visible + and not _local_inventory.contains_catch_id(_local_catch_id) + ): + _submit_local_state(null, false) + + +func _on_peer_authenticated(peer_id: int, _display_name: String) -> void: + if not _session.is_host(): + return + for state: Dictionary in _states.values(): + if bool(state.get("visible", false)): + receive_showcase_state.rpc_id(peer_id, state) + + +func _on_avatar_spawned(peer_id: int, _avatar: Player) -> void: + var state: Dictionary = _states.get(peer_id, {}) + if not state.is_empty(): + _apply_state(state) + + +func _on_peer_removed(peer_id: int) -> void: + var avatar: Player = _spawn_service.get_avatar(peer_id) + if avatar != null: + avatar.set_held_fish(null, 1.0, false) + _states.erase(peer_id) + + +func _on_session_state_changed(state: NetworkSession.State) -> void: + if state not in [ + NetworkSession.State.INACTIVE, + NetworkSession.State.DISCONNECTING, + NetworkSession.State.CONNECTION_FAILED, + NetworkSession.State.SERVER_LOST, + ]: + return + for peer_id: int in _states: + var avatar: Player = _spawn_service.get_avatar(peer_id) + if avatar != null: + avatar.set_held_fish(null, 1.0, false) + _states.clear() + _local_visible = false + _local_catch_id = StringName() + var local_avatar: Player = _spawn_service.get_avatar( + _session.get_local_peer_id() + ) + if local_avatar != null: + local_avatar.set_held_fish(null, 1.0, false) diff --git a/network/network_fish_showcase_service.gd.uid b/network/network_fish_showcase_service.gd.uid new file mode 100644 index 0000000..0a6c086 --- /dev/null +++ b/network/network_fish_showcase_service.gd.uid @@ -0,0 +1 @@ +uid://m4n0bn38f34i diff --git a/network/network_protocol.gd b/network/network_protocol.gd index e52705f..f0892f7 100644 --- a/network/network_protocol.gd +++ b/network/network_protocol.gd @@ -10,7 +10,7 @@ const MAX_PUBLIC_KEY_LENGTH: int = 8192 const MAX_SIGNATURE_LENGTH: int = 2048 # ENet channels: 0 reliable lifecycle, 1 movement input, 2 movement # snapshots, 3 fishing input, 4 fishing snapshots, 5 reliable sales, -# 6 reliable shop transactions, 7 reliable item/equipment lifecycle, +# 6 reliable shop transactions, 7 reliable item/equipment/showcase lifecycle, # 8 reliable ordered session chat, 9 reliable private session mail. const SALE_RELIABLE_CHANNEL: int = 5 const SHOP_RELIABLE_CHANNEL: int = 6 @@ -187,6 +187,7 @@ static func make_server_hello( "shop_v1", "item_use_v1", "equipment_v1", + "fish_showcase_v1", "chat_v1", "mail_v1", "profile_v1", diff --git a/network/network_session.gd b/network/network_session.gd index a3e9bb3..d2a9477 100644 --- a/network/network_session.gd +++ b/network/network_session.gd @@ -360,7 +360,8 @@ func supports_server_capability(capability: StringName) -> bool: if is_host(): return str(capability) in PackedStringArray([ "movement_v1", "fishing_v1", "sale_v1", "shop_v1", - "item_use_v1", "equipment_v1", "chat_v1", + "item_use_v1", "equipment_v1", "fish_showcase_v1", + "chat_v1", "mail_v1", "profile_v1", "identity_v1", diff --git a/player/player.gd b/player/player.gd index 3891fef..a4bdb2b 100644 --- a/player/player.gd +++ b/player/player.gd @@ -4,6 +4,7 @@ extends CharacterBody3D const FishInventoryType = preload("res://inventory/fish_inventory.gd") const CollectionLogType = preload("res://collection/collection_log.gd") const FishCatchType = preload("res://fish/fish_catch.gd") +const FishDataType = preload("res://fish/fish_data.gd") const FishSaleServiceType = preload("res://economy/fish_sale_service.gd") const PlayerWalletType = preload("res://economy/player_wallet.gd") const PlayerBagType = preload("res://inventory/player_bag.gd") @@ -106,6 +107,8 @@ class ShowcaseCameraSnapshot: @onready var _fishing_rod_tip: Marker3D = %FishingRodTip @onready var _catch_display: Node3D = %CatchDisplay @onready var _catch_sprite: Sprite3D = %CatchSprite +@onready var _held_fish_display: Node3D = %HeldFishDisplay +@onready var _held_fish_sprite: Sprite3D = %HeldFishSprite var _gravity: float = float(ProjectSettings.get_setting("physics/3d/default_gravity")) var _camera_dragging: bool = false @@ -696,6 +699,25 @@ func set_active_item_is_rod(active_is_rod: bool) -> void: _fishing_rod.visible = active_is_rod +func set_held_fish( + fish: FishDataType, + display_scale: float, + should_show: bool, +) -> void: + if not should_show or fish == null or fish.display_texture == null: + _held_fish_display.visible = false + _held_fish_display.scale = Vector3.ONE + _held_fish_sprite.texture = null + return + _held_fish_sprite.texture = fish.display_texture + _held_fish_display.scale = ( + Vector3.ONE + * maxf(display_scale, 0.01) + * catch_presentation_base_scale + ) + _held_fish_display.visible = true + + func get_cast_origin_position() -> Vector3: return _cast_origin.global_position diff --git a/player/player.tscn b/player/player.tscn index a337217..df954fd 100644 --- a/player/player.tscn +++ b/player/player.tscn @@ -140,6 +140,21 @@ shaded = false double_sided = true texture_filter = 0 +[node name="HeldFishAnchor" type="Marker3D" parent="Visuals"] +position = Vector3(0, 1.15, -0.9) + +[node name="HeldFishDisplay" type="Node3D" parent="Visuals/HeldFishAnchor"] +unique_name_in_owner = true +visible = false + +[node name="HeldFishSprite" type="Sprite3D" parent="Visuals/HeldFishAnchor/HeldFishDisplay"] +unique_name_in_owner = true +pixel_size = 0.012 +billboard = 1 +shaded = false +double_sided = true +texture_filter = 0 + [node name="CameraYaw" type="Node3D" parent="."] unique_name_in_owner = true position = Vector3(0, 1.35, 0) diff --git a/save/player_save_manager.gd b/save/player_save_manager.gd index fa270fa..dc4bf62 100644 --- a/save/player_save_manager.gd +++ b/save/player_save_manager.gd @@ -31,6 +31,7 @@ class LoadSnapshot: var next_catch_sequence: int = 1 var bag_items: Array[OwnedItemType] = [] var hotbar_slots: Array[StringName] = [] + var fish_hotbar_slots: Array[StringName] = [] var selected_hotbar_slot: int = 0 var reel_speed_level: int = 0 var barrier_power_level: int = 0 @@ -205,7 +206,8 @@ func load_player_data() -> bool: var bag_restored: bool = _bag.replace_all_items(snapshot.bag_items) var hotbar_restored: bool = _hotbar.replace_state( snapshot.hotbar_slots, - snapshot.selected_hotbar_slot + snapshot.selected_hotbar_slot, + snapshot.fish_hotbar_slots, ) var upgrades_restored: bool = _fishing_upgrades.restore_levels( snapshot.reel_speed_level, @@ -393,6 +395,9 @@ func _build_save_dictionary() -> Dictionary: var serialized_slots: Array[String] = [] for item_id: StringName in _hotbar.get_slots(): serialized_slots.append(String(item_id)) + var serialized_fish_slots: Array[String] = [] + for catch_id: StringName in _hotbar.get_fish_slots(): + serialized_fish_slots.append(String(catch_id)) if ( _wallet.get_balance() < 0 or _wallet.get_balance() > MAX_SAFE_BALANCE @@ -422,6 +427,7 @@ func _build_save_dictionary() -> Dictionary: "hotbar": { "selected_slot": _hotbar.get_selected_slot(), "slots": serialized_slots, + "fish_slots": serialized_fish_slots, }, "upgrades": _fishing_upgrades.to_save_data(), "cooler": _cooler_capacity.to_save_data(), @@ -610,6 +616,22 @@ func _build_load_snapshot(save_data: Dictionary) -> LoadSnapshot: and seen_items.has(slot_item_id) ): snapshot.hotbar_slots[slot_index] = slot_item_id + var fish_slot_values: Array = [] + if typeof(hotbar_data.get("fish_slots")) == TYPE_ARRAY: + fish_slot_values = hotbar_data["fish_slots"] + snapshot.fish_hotbar_slots.resize(PlayerHotbarType.SLOT_COUNT) + snapshot.fish_hotbar_slots.fill(StringName()) + for slot_index: int in range( + mini(fish_slot_values.size(), PlayerHotbarType.SLOT_COUNT) + ): + if not snapshot.hotbar_slots[slot_index].is_empty(): + continue + var slot_value: Variant = fish_slot_values[slot_index] + if typeof(slot_value) not in [TYPE_STRING, TYPE_STRING_NAME]: + continue + var catch_id: StringName = StringName(str(slot_value)) + if not catch_id.is_empty() and seen_ids.has(catch_id): + snapshot.fish_hotbar_slots[slot_index] = catch_id var selected_slot: int = _read_integer( hotbar_data["selected_slot"], 0, diff --git a/tests/fish_hotbar_showcase_validation.gd b/tests/fish_hotbar_showcase_validation.gd new file mode 100644 index 0000000..81f648d --- /dev/null +++ b/tests/fish_hotbar_showcase_validation.gd @@ -0,0 +1,138 @@ +extends SceneTree + +const MainScene = preload("res://main/main.tscn") +const FishCatchType = preload("res://fish/fish_catch.gd") +const FishDataType = preload("res://fish/fish_data.gd") +const FishingSpotType = preload("res://fishing/fishing_spot.gd") + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + root.size = Vector2i(1280, 720) + var main := MainScene.instantiate() + root.add_child(main) + for _frame: int in 4: + await process_frame + if not bool(main.get("_application_initialized")): + main.call("_activate_selected_data_path", "", true) + for _frame: int in 8: + await process_frame + assert(bool(main.get("_application_initialized"))) + var session := main.get_node("%NetworkSession") as NetworkSession + assert(session.start_private_host(17977)) + var save_manager := main.get("_save_manager") as PlayerSaveManager + assert(save_manager.initialize_new_game()) + main.call("_enter_gameplay") + for _frame: int in 8: + await process_frame + + var player := main.get("_player") as Player + var fish_catalog := main.get("fish_catalog") as FishPool + var service := main.get_node( + "%NetworkFishShowcaseService" + ) as NetworkFishShowcaseService + var fishing_spot := main.get_node("%FishingSpot") as FishingSpotType + assert(player != null and fish_catalog != null and service != null) + var fish: FishDataType = fish_catalog.get_fish_by_id(&"bluegill") + assert(fish != null) + var fish_catch := FishCatchType.new() + fish_catch.fish = fish + fish_catch.fish_id = fish.id + fish_catch.weight_lb = fish.get_minimum_weight() + fish_catch.display_scale = fish.get_display_scale_for_weight( + fish_catch.weight_lb + ) + fish_catch.sale_value = fish.get_sale_value_for_weight( + fish_catch.weight_lb + ) + fish_catch.ensure_identity() + player.inventory.add_catch(fish_catch) + var game_ui := main.get_node("%GameUI") as GameUI + var hotbar_ui := game_ui.get_node("%Hotbar") as HotbarUI + assert(hotbar_ui != null) + hotbar_ui.set_drag_enabled(true) + var slots: Array = hotbar_ui.get("_slots") + var fish_payload: Dictionary = { + "kind": "cooler_fish", + "catch_id": String(fish_catch.catch_id), + } + assert(slots.size() == PlayerHotbar.SLOT_COUNT) + assert(bool(slots[1].call("_can_drop_data", Vector2.ZERO, fish_payload))) + slots[1].call("_drop_data", Vector2.ZERO, fish_payload) + assert(player.hotbar.get_fish_catch_id(1) == fish_catch.catch_id) + assert(player.hotbar.select_slot(1)) + assert( + player.hotbar.get_selected_assignment_kind() + == PlayerHotbar.AssignmentKind.FISH + ) + assert(player.hotbar.get_selected_fish_catch_id() == fish_catch.catch_id) + assert(player.hotbar.get_selected_item_id().is_empty()) + + var press := InputEventMouseButton.new() + press.button_index = MOUSE_BUTTON_LEFT + press.pressed = true + fishing_spot.call("_unhandled_input", press) + await process_frame + assert(service.is_local_showcase_visible()) + assert(service.get_local_showcase_catch_id() == fish_catch.catch_id) + assert((player.get_node("%HeldFishDisplay") as Node3D).visible) + fishing_spot.call("_unhandled_input", press) + await process_frame + assert(not service.is_local_showcase_visible()) + assert(not (player.get_node("%HeldFishDisplay") as Node3D).visible) + + assert(save_manager.save_now()) + var save_path: String = str(save_manager.get("_save_path")) + var save_file := FileAccess.open(save_path, FileAccess.READ) + assert(save_file != null) + var parsed: Variant = JSON.parse_string(save_file.get_as_text()) + save_file.close() + assert(typeof(parsed) == TYPE_DICTIONARY) + var hotbar_data: Dictionary = (parsed as Dictionary)["hotbar"] + assert(typeof(hotbar_data.get("fish_slots")) == TYPE_ARRAY) + assert(str((hotbar_data["fish_slots"] as Array)[1]) == fish_catch.catch_id) + assert(int((parsed as Dictionary)["save_version"]) == 4) + + assert(player.hotbar.clear_slot(1)) + assert(save_manager.load_player_data()) + assert(player.hotbar.get_fish_catch_id(1) == fish_catch.catch_id) + assert(player.hotbar.get_selected_slot() == 1) + assert(not service.is_local_showcase_visible()) + assert(service.toggle_selected_fish()) + assert(service.is_local_showcase_visible()) + assert(player.inventory.remove_catch_by_id(fish_catch.catch_id) != null) + await process_frame + assert(player.hotbar.get_fish_catch_id(1).is_empty()) + assert(not service.is_local_showcase_visible()) + assert(not (player.get_node("%HeldFishDisplay") as Node3D).visible) + + var valid_state: Dictionary = { + "session_id": "session", + "owner_peer_id": 2, + "visible": true, + "fish_id": String(fish.id), + "weight_lb": fish.get_minimum_weight(), + "display_scale": fish.get_display_scale_for_weight( + fish.get_minimum_weight() + ), + "revision": 1, + } + assert(NetworkFishShowcaseProtocol.validate_state(valid_state)) + var invalid_state: Dictionary = valid_state.duplicate(true) + invalid_state["display_scale"] = 1000.0 + assert(not NetworkFishShowcaseProtocol.validate_state(invalid_state)) + assert(NetworkProtocol.PROTOCOL_VERSION == 3) + assert(NetworkProtocol.ENET_CHANNEL_COUNT == 10) + assert( + NetworkFishShowcaseProtocol.CAPABILITY + == &"fish_showcase_v1" + ) + + print("Fish hotbar showcase validation: PASS") + session.disconnect_session("") + main.queue_free() + await process_frame + quit() diff --git a/tests/fish_hotbar_showcase_validation.gd.uid b/tests/fish_hotbar_showcase_validation.gd.uid new file mode 100644 index 0000000..3fe842e --- /dev/null +++ b/tests/fish_hotbar_showcase_validation.gd.uid @@ -0,0 +1 @@ +uid://cpnfrron1g65q diff --git a/tests/fish_showcase_multiplayer_validation.gd b/tests/fish_showcase_multiplayer_validation.gd new file mode 100644 index 0000000..3b1ab54 --- /dev/null +++ b/tests/fish_showcase_multiplayer_validation.gd @@ -0,0 +1,153 @@ +extends SceneTree + +const MainScene = preload("res://main/main.tscn") +const FishCatchType = preload("res://fish/fish_catch.gd") +const TEST_PORT: int = 17978 + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + var arguments: PackedStringArray = OS.get_cmdline_user_args() + if arguments.has("host"): + await _run_host() + return + if arguments.has("client"): + await _run_client() + return + push_error("Fish showcase multiplayer validation needs host or client mode.") + quit(1) + + +func _run_host() -> void: + var main: Node = await _create_initialized_main() + var session := main.get_node("%NetworkSession") as NetworkSession + assert(session.start_private_host(TEST_PORT)) + var save_manager := main.get("_save_manager") as PlayerSaveManager + assert(save_manager.initialize_new_game()) + main.call("_enter_gameplay") + var player := main.get("_player") as Player + var service := main.get_node( + "%NetworkFishShowcaseService" + ) as NetworkFishShowcaseService + var fish_catch: FishCatch = _add_bluegill(main, player) + assert(player.hotbar.assign_fish(1, fish_catch.catch_id)) + assert(player.hotbar.select_slot(1)) + assert(service.toggle_selected_fish()) + assert(service.is_local_showcase_visible()) + assert(session.set_host_open(true)) + + var remote_peer_id: int = 0 + var join_deadline: int = Time.get_ticks_msec() + 20000 + while Time.get_ticks_msec() < join_deadline and remote_peer_id == 0: + await process_frame + for peer_id: int in session.get_authenticated_peer_ids(): + if peer_id != session.get_local_peer_id(): + remote_peer_id = peer_id + break + assert(remote_peer_id > 1) + var spawn_service := main.get_node( + "%PlayerSpawnService" + ) as PlayerSpawnService + var remote_avatar: Player = spawn_service.get_avatar(remote_peer_id) + assert(remote_avatar != null) + var remote_display := remote_avatar.get_node("%HeldFishDisplay") as Node3D + var visible_deadline: int = Time.get_ticks_msec() + 10000 + while Time.get_ticks_msec() < visible_deadline and not remote_display.visible: + await process_frame + assert(remote_display.visible) + var hidden_deadline: int = Time.get_ticks_msec() + 10000 + while Time.get_ticks_msec() < hidden_deadline and remote_display.visible: + await process_frame + assert(not remote_display.visible) + service.toggle_selected_fish() + print("Fish showcase multiplayer host validation: PASS") + session.disconnect_session("") + main.queue_free() + await process_frame + quit() + + +func _run_client() -> void: + var main: Node = await _create_initialized_main() + main.call( + "_on_title_join_game_requested", + "127.0.0.1:%d" % TEST_PORT, + ) + var session := main.get_node("%NetworkSession") as NetworkSession + var join_deadline: int = Time.get_ticks_msec() + 20000 + while Time.get_ticks_msec() < join_deadline: + await process_frame + if session.state == NetworkSession.State.VERIFYING_SERVER_IDENTITY: + main.call("_confirm_server_trust") + if session.is_joined_client() and bool(main.get("_gameplay_started")): + break + assert(session.is_joined_client()) + assert( + session.supports_server_capability( + NetworkFishShowcaseProtocol.CAPABILITY + ) + ) + var spawn_service := main.get_node( + "%PlayerSpawnService" + ) as PlayerSpawnService + var host_avatar: Player = spawn_service.get_avatar(1) + assert(host_avatar != null) + var host_display := host_avatar.get_node("%HeldFishDisplay") as Node3D + var snapshot_deadline: int = Time.get_ticks_msec() + 10000 + while Time.get_ticks_msec() < snapshot_deadline and not host_display.visible: + await process_frame + assert(host_display.visible) + + var player := main.get("_player") as Player + var service := main.get_node( + "%NetworkFishShowcaseService" + ) as NetworkFishShowcaseService + var fish_catch: FishCatch = _add_bluegill(main, player) + assert(player.hotbar.assign_fish(1, fish_catch.catch_id)) + assert(player.hotbar.select_slot(1)) + assert(service.toggle_selected_fish()) + assert((player.get_node("%HeldFishDisplay") as Node3D).visible) + await create_timer(2.0).timeout + assert(service.toggle_selected_fish()) + assert(not (player.get_node("%HeldFishDisplay") as Node3D).visible) + await create_timer(1.0).timeout + print("Fish showcase multiplayer client validation: PASS") + session.disconnect_session("") + main.queue_free() + await process_frame + quit() + + +func _add_bluegill(main: Node, player: Player) -> FishCatch: + var catalog := main.get("fish_catalog") as FishPool + var fish: FishData = catalog.get_fish_by_id(&"bluegill") + var fish_catch := FishCatchType.new() + fish_catch.fish = fish + fish_catch.fish_id = fish.id + fish_catch.weight_lb = fish.get_minimum_weight() + fish_catch.display_scale = fish.get_display_scale_for_weight( + fish_catch.weight_lb + ) + fish_catch.sale_value = fish.get_sale_value_for_weight( + fish_catch.weight_lb + ) + fish_catch.ensure_identity() + player.inventory.add_catch(fish_catch) + return fish_catch + + +func _create_initialized_main() -> Node: + root.size = Vector2i(1280, 720) + var main := MainScene.instantiate() + root.add_child(main) + for _frame: int in 4: + await process_frame + if not bool(main.get("_application_initialized")): + main.call("_activate_selected_data_path", "", true) + for _frame: int in 8: + await process_frame + assert(bool(main.get("_application_initialized"))) + return main diff --git a/tests/fish_showcase_multiplayer_validation.gd.uid b/tests/fish_showcase_multiplayer_validation.gd.uid new file mode 100644 index 0000000..fb1bfeb --- /dev/null +++ b/tests/fish_showcase_multiplayer_validation.gd.uid @@ -0,0 +1 @@ +uid://ckta4mcodq0hg diff --git a/ui/components/bubble_hotbar/bubble_hotbar_slot.gd b/ui/components/bubble_hotbar/bubble_hotbar_slot.gd index 0bbaf7f..6b9e9c2 100644 --- a/ui/components/bubble_hotbar/bubble_hotbar_slot.gd +++ b/ui/components/bubble_hotbar/bubble_hotbar_slot.gd @@ -10,6 +10,8 @@ const ItemCatalogType = preload("res://items/item_catalog.gd") const ItemDataType = preload("res://items/item_data.gd") const PlayerBagType = preload("res://inventory/player_bag.gd") const PlayerHotbarType = preload("res://inventory/player_hotbar.gd") +const FishInventoryType = preload("res://inventory/fish_inventory.gd") +const FishCatchType = preload("res://fish/fish_catch.gd") const SELECTED_SCALE: float = 1.12 const HOVER_SCALE: float = 1.025 @@ -41,6 +43,7 @@ const DEFORMATION_Y_AMPLITUDE: float = 0.007 var _hotbar: PlayerHotbarType var _bag: PlayerBagType var _catalog: ItemCatalogType +var _fish_inventory: FishInventoryType var _drag_enabled: bool = false var _drag_in_progress: bool = false var _selected: bool = false @@ -69,10 +72,12 @@ func setup( hotbar: PlayerHotbarType, bag: PlayerBagType, catalog: ItemCatalogType, + fish_inventory: FishInventoryType, ) -> void: _hotbar = hotbar _bag = bag _catalog = catalog + _fish_inventory = fish_inventory refresh() @@ -144,12 +149,22 @@ func refresh() -> void: if _hotbar == null: return var item_id: StringName = _hotbar.get_item_id(slot_index) + var catch_id: StringName = _hotbar.get_fish_catch_id(slot_index) var item: ItemDataType = ( _catalog.get_item_by_id(item_id) if _catalog != null and not item_id.is_empty() else null ) - _item_icon.texture = item.icon if item != null else null + var fish_catch: FishCatchType = ( + _fish_inventory.get_catch_by_id(catch_id) + if _fish_inventory != null and not catch_id.is_empty() + else null + ) + _item_icon.texture = ( + fish_catch.fish.display_texture + if fish_catch != null + else item.icon if item != null else null + ) var quantity: int = ( _bag.get_quantity(item_id) if _bag != null and not item_id.is_empty() @@ -163,12 +178,17 @@ func refresh() -> void: _quantity_label.text = quantity_text _quantity_label.visible = not quantity_text.is_empty() tooltip_text = ( - item.display_name if item != null else "empty hotbar slot" + "%s · %.1f lb" % [ + fish_catch.fish.display_name, + fish_catch.weight_lb, + ] + if fish_catch != null + else item.display_name if item != null else "empty hotbar slot" ) var was_selected: bool = _selected var was_empty: bool = _empty _selected = slot_index == _hotbar.get_selected_slot() - _empty = item == null + _empty = item == null and fish_catch == null if was_selected != _selected or was_empty != _empty: _apply_style() @@ -283,12 +303,22 @@ func _get_drag_data(_at_position: Vector2) -> Variant: if not _drag_enabled or _hotbar == null: return null var item_id: StringName = _hotbar.get_item_id(slot_index) - if item_id.is_empty(): + var catch_id: StringName = _hotbar.get_fish_catch_id(slot_index) + if item_id.is_empty() and catch_id.is_empty(): return null var item: ItemDataType = _catalog.get_item_by_id(item_id) + var fish_catch: FishCatchType = ( + _fish_inventory.get_catch_by_id(catch_id) + if _fish_inventory != null and not catch_id.is_empty() + else null + ) var preview := TextureRect.new() preview.custom_minimum_size = Vector2(44.0, 44.0) - preview.texture = item.icon if item != null else null + preview.texture = ( + fish_catch.fish.display_texture + if fish_catch != null + else item.icon if item != null else null + ) preview.expand_mode = TextureRect.EXPAND_IGNORE_SIZE preview.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED preview.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST @@ -299,7 +329,6 @@ func _get_drag_data(_at_position: Vector2) -> Variant: return { "kind": "hotbar_slot", "slot_index": slot_index, - "item_id": String(item_id), } @@ -315,13 +344,19 @@ func _can_drop_data(_at_position: Vector2, data: Variant) -> bool: and source_index < PlayerHotbarType.SLOT_COUNT and source_index != slot_index ) - if kind != "bag_item": - return false - var item_id: StringName = StringName(str(payload.get("item_id", ""))) - if _bag == null or not _bag.owns_item(item_id) or _catalog == null: - return false - var item: ItemDataType = _catalog.get_item_by_id(item_id) - return item != null and item.is_valid() and item.hotbar_allowed + if kind == "bag_item": + var item_id: StringName = StringName(str(payload.get("item_id", ""))) + if _bag == null or not _bag.owns_item(item_id) or _catalog == null: + return false + var item: ItemDataType = _catalog.get_item_by_id(item_id) + return item != null and item.is_valid() and item.hotbar_allowed + if kind == "cooler_fish": + var catch_id: StringName = StringName(str(payload.get("catch_id", ""))) + return ( + _fish_inventory != null + and _fish_inventory.contains_catch_id(catch_id) + ) + return false func _drop_data(_at_position: Vector2, data: Variant) -> void: @@ -330,6 +365,11 @@ func _drop_data(_at_position: Vector2, data: Variant) -> void: var payload: Dictionary = data if str(payload.get("kind", "")) == "hotbar_slot": _hotbar.swap_slots(int(payload["slot_index"]), slot_index) + elif str(payload.get("kind", "")) == "cooler_fish": + _hotbar.assign_fish( + slot_index, + StringName(str(payload["catch_id"])), + ) else: _hotbar.assign_item( slot_index, @@ -346,7 +386,10 @@ func _select_slot() -> void: func _on_mouse_entered() -> void: _hovered = true if _hotbar != null: - item_hovered.emit(slot_index, _hotbar.get_item_id(slot_index)) + var identity: StringName = _hotbar.get_item_id(slot_index) + if identity.is_empty(): + identity = _hotbar.get_fish_catch_id(slot_index) + item_hovered.emit(slot_index, identity) func _on_mouse_exited() -> void: diff --git a/ui/components/bubble_menu/cooler_fish_sprite.gd b/ui/components/bubble_menu/cooler_fish_sprite.gd index d092dcc..0e7384f 100644 --- a/ui/components/bubble_menu/cooler_fish_sprite.gd +++ b/ui/components/bubble_menu/cooler_fish_sprite.gd @@ -7,6 +7,7 @@ extends Button @onready var _favorite_marker: Label = %FavoriteMarker var catch_id: StringName +var _display_name: String = "" var _neutral_position := Vector2.ZERO var _target_position := Vector2.ZERO var _motion_phase: float = 0.0 @@ -35,18 +36,48 @@ func _ready() -> void: func configure( identity: StringName, + display_name: String, texture: Texture2D, phase: float, depth_scale: float, rarity_color: Color, ) -> void: catch_id = identity + _display_name = display_name _fish_texture.texture = texture _fish_shadow.texture = texture _motion_phase = phase _depth_scale = depth_scale _rarity_color = rarity_color _refresh_style() + tooltip_text = "%s · drag to a hotbar slot" % _display_name + + +func _get_drag_data(_at_position: Vector2) -> Variant: + if catch_id.is_empty() or disabled or _fish_texture.texture == null: + return null + var preview := VBoxContainer.new() + preview.mouse_filter = Control.MOUSE_FILTER_IGNORE + preview.add_theme_constant_override("separation", 2) + var texture := TextureRect.new() + texture.custom_minimum_size = Vector2(72.0, 52.0) + texture.texture = _fish_texture.texture + texture.expand_mode = TextureRect.EXPAND_IGNORE_SIZE + texture.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED + texture.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST + texture.mouse_filter = Control.MOUSE_FILTER_IGNORE + preview.add_child(texture) + var label := Label.new() + label.text = _display_name + label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + label.mouse_filter = Control.MOUSE_FILTER_IGNORE + label.add_theme_font_size_override("font_size", 12) + preview.add_child(label) + set_drag_preview(preview) + return { + "kind": "cooler_fish", + "catch_id": String(catch_id), + } func set_target_position(target: Vector2, immediate: bool = false) -> void: diff --git a/ui/game_ui.gd b/ui/game_ui.gd index 9ca05b0..22d8351 100644 --- a/ui/game_ui.gd +++ b/ui/game_ui.gd @@ -183,7 +183,7 @@ func setup( network_profile_service, network_player_list, ) - _hotbar_ui.setup(hotbar, bag, item_catalog, fishing_spot) + _hotbar_ui.setup(hotbar, bag, item_catalog, fishing_spot, inventory) _fishing_shop.setup( player, wallet, diff --git a/ui/hotbar.gd b/ui/hotbar.gd index ca388a9..a1bbf6e 100644 --- a/ui/hotbar.gd +++ b/ui/hotbar.gd @@ -6,6 +6,8 @@ signal presentation_transition_finished(is_visible: bool) const ItemCatalogType = preload("res://items/item_catalog.gd") const PlayerBagType = preload("res://inventory/player_bag.gd") const PlayerHotbarType = preload("res://inventory/player_hotbar.gd") +const FishInventoryType = preload("res://inventory/fish_inventory.gd") +const FishCatchType = preload("res://fish/fish_catch.gd") const BubbleHotbarSlotType = preload( "res://ui/components/bubble_hotbar/bubble_hotbar_slot.gd" ) @@ -28,6 +30,7 @@ const HOTBAR_MENU_Z_INDEX: int = 90 var _hotbar: PlayerHotbarType var _bag: PlayerBagType var _catalog: ItemCatalogType +var _fish_inventory: FishInventoryType var _fishing_spot: FishingSpotType var _slots: Array[BubbleHotbarSlotType] = [] var _gameplay_input_enabled: bool = false @@ -59,11 +62,13 @@ func setup( bag: PlayerBagType, catalog: ItemCatalogType, fishing_spot: FishingSpotType, + fish_inventory: FishInventoryType, ) -> void: _hotbar = hotbar _bag = bag _catalog = catalog _fishing_spot = fishing_spot + _fish_inventory = fish_inventory if not _hotbar.slots_changed.is_connected(_refresh): _hotbar.slots_changed.connect(_refresh) if not _hotbar.selected_slot_changed.is_connected( @@ -72,8 +77,10 @@ func setup( _hotbar.selected_slot_changed.connect(_on_selected_slot_changed) if not _bag.contents_changed.is_connected(_refresh): _bag.contents_changed.connect(_refresh) + if not _fish_inventory.catches_changed.is_connected(_refresh): + _fish_inventory.catches_changed.connect(_refresh) for slot: BubbleHotbarSlotType in _slots: - slot.setup(_hotbar, _bag, _catalog) + slot.setup(_hotbar, _bag, _catalog, _fish_inventory) slot.set_drag_enabled(_drag_enabled) _refresh() @@ -274,7 +281,7 @@ func _on_slot_item_hovered( return _hovered_slot_index = slot_index _item_name_timer.stop() - _show_item_name(item_id) + _show_assignment_name(slot_index, item_id) func _on_slot_item_hover_ended(slot_index: int) -> void: @@ -297,16 +304,31 @@ func _show_selected_item_briefly() -> void: if _item_name_suppressed or _hotbar == null: _hide_item_name() return - _show_item_name(_hotbar.get_selected_item_id()) + var selected_slot: int = _hotbar.get_selected_slot() + var identity: StringName = _hotbar.get_selected_item_id() + if identity.is_empty(): + identity = _hotbar.get_selected_fish_catch_id() + _show_assignment_name(selected_slot, identity) if _selected_item_label.visible: _item_name_timer.start() -func _show_item_name(item_id: StringName) -> void: - if item_id.is_empty() or _catalog == null: +func _show_assignment_name(slot_index: int, identity: StringName) -> void: + if identity.is_empty() or _hotbar == null: _hide_item_name() return - var item := _catalog.get_item_by_id(item_id) + var catch_id: StringName = _hotbar.get_fish_catch_id(slot_index) + if not catch_id.is_empty() and _fish_inventory != null: + var fish_catch: FishCatchType = _fish_inventory.get_catch_by_id(catch_id) + if fish_catch != null: + _selected_item_label.text = fish_catch.fish.display_name + _selected_item_label.visible = true + return + var item = ( + _catalog.get_item_by_id(identity) + if _catalog != null + else null + ) if item == null: _hide_item_name() return diff --git a/ui/player_menu.gd b/ui/player_menu.gd index 0687e9d..54cd109 100644 --- a/ui/player_menu.gd +++ b/ui/player_menu.gd @@ -2609,6 +2609,7 @@ func _sync_cooler_fish_nodes(catches: Array[FishCatchType]) -> void: var identity_hash: int = absi(String(fish_catch.catch_id).hash()) fish_node.configure( fish_catch.catch_id, + fish_catch.fish.display_name, fish_catch.fish.display_texture, float(identity_hash % 628) / 100.0, 0.96 + float(identity_hash % 9) * 0.01, diff --git a/ui/player_menu.tscn b/ui/player_menu.tscn index cd0171b..aac0884 100644 --- a/ui/player_menu.tscn +++ b/ui/player_menu.tscn @@ -1215,7 +1215,7 @@ theme_override_constants/separation = 7 [node name="BagHint" type="Label" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/MenuPanel/Margin/Layout/Content/BagSection"] layout_mode = 2 -text = "drag hotbar-compatible items onto a slot below. right-click a slot to clear it." +text = "drag fish or hotbar-compatible items onto a slot below. right-click a slot to clear it." horizontal_alignment = 1 autowrap_mode = 2