Add multiplayer fish showcases

This commit is contained in:
Alexander Sellite 2026-08-01 17:12:29 -04:00
parent 3f4392fdd3
commit 904a9c2d9e
23 changed files with 966 additions and 46 deletions

View file

@ -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

View file

@ -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

View file

@ -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
)

View file

@ -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")

View file

@ -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"]))
)

View file

@ -0,0 +1 @@
uid://b3h0h47ilh3nm

View file

@ -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)

View file

@ -0,0 +1 @@
uid://m4n0bn38f34i

View file

@ -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",

View file

@ -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",

View file

@ -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

View file

@ -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)

View file

@ -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,

View file

@ -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()

View file

@ -0,0 +1 @@
uid://cpnfrron1g65q

View file

@ -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

View file

@ -0,0 +1 @@
uid://ckta4mcodq0hg

View file

@ -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:

View file

@ -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:

View file

@ -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,

View file

@ -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

View file

@ -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,

View file

@ -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