From 5dda74c9e4b3bf2d01f8c8c37b6ada17d2469393 Mon Sep 17 00:00:00 2001 From: Voyager Date: Wed, 29 Jul 2026 19:26:46 -0400 Subject: [PATCH] Add multiplayer item use and session chat --- fishing/fishing_spot.gd | 46 ++- main/main.gd | 34 ++- main/main.tscn | 12 +- network/direct_enet_transport.gd | 8 +- network/network_chat_protocol.gd | 61 ++++ network/network_chat_protocol.gd.uid | 1 + network/network_chat_service.gd | 235 ++++++++++++++ network/network_chat_service.gd.uid | 1 + network/network_fishing_service.gd | 27 +- network/network_item_protocol.gd | 83 +++++ network/network_item_protocol.gd.uid | 1 + network/network_item_use_service.gd | 388 ++++++++++++++++++++++++ network/network_item_use_service.gd.uid | 1 + network/network_profile_preferences.gd | 23 ++ network/network_protocol.gd | 11 +- network/network_session.gd | 47 +++ network/peer_registry.gd | 8 + player/player.gd | 4 + progression/player_item_effects.gd | 22 ++ ui/chat_ui.gd | 241 +++++++++++++++ ui/chat_ui.gd.uid | 1 + ui/game_ui.gd | 37 +++ ui/game_ui.tscn | 14 +- ui/pause_menu.gd | 9 + ui/pause_menu.tscn | 16 +- ui/settings_panel.gd | 36 +++ ui/settings_panel.tscn | 25 ++ 27 files changed, 1361 insertions(+), 31 deletions(-) create mode 100644 network/network_chat_protocol.gd create mode 100644 network/network_chat_protocol.gd.uid create mode 100644 network/network_chat_service.gd create mode 100644 network/network_chat_service.gd.uid create mode 100644 network/network_item_protocol.gd create mode 100644 network/network_item_protocol.gd.uid create mode 100644 network/network_item_use_service.gd create mode 100644 network/network_item_use_service.gd.uid create mode 100644 ui/chat_ui.gd create mode 100644 ui/chat_ui.gd.uid diff --git a/fishing/fishing_spot.gd b/fishing/fishing_spot.gd index ad271a7..fd2e01c 100644 --- a/fishing/fishing_spot.gd +++ b/fishing/fishing_spot.gd @@ -107,6 +107,7 @@ var _local_hotbar: PlayerHotbarType var _item_catalog: ItemCatalogType var _fishing_upgrades: PlayerFishingUpgradesType var _item_effects: PlayerItemEffectsType +var _network_item_use: NetworkItemUseService var _cooler_capacity: PlayerCoolerCapacityType var _network_session: NetworkSessionType var _network_fishing: NetworkFishingServiceType @@ -154,6 +155,7 @@ func setup( item_catalog: ItemCatalogType, fishing_upgrades: PlayerFishingUpgradesType, item_effects: PlayerItemEffectsType, + network_item_use: NetworkItemUseService, cooler_capacity: PlayerCoolerCapacityType, network_session: NetworkSessionType = null, network_fishing: NetworkFishingServiceType = null, @@ -166,6 +168,11 @@ func setup( _item_catalog = item_catalog _fishing_upgrades = fishing_upgrades _item_effects = item_effects + _network_item_use = network_item_use + if _network_item_use != null: + _network_item_use.local_item_use_finished.connect( + _on_network_item_use_finished + ) _cooler_capacity = cooler_capacity _network_session = network_session _network_fishing = network_fishing @@ -416,10 +423,12 @@ func _unhandled_input(event: InputEvent) -> void: active_item != null and active_item.category == ItemDataType.Category.CONSUMABLE ): - if _item_effects.use_consumable(active_item, _local_bag): - status_changed.emit( - _item_effects.get_feedback(active_item.item_id) - ) + if _network_item_use != null: + _network_item_use.request_use(active_item.item_id) + elif _item_effects.use_consumable(active_item, _local_bag): + status_changed.emit(_item_effects.get_feedback( + active_item.item_id + )) get_viewport().set_input_as_handled() return if not has_active_fishing_rod(): @@ -1112,11 +1121,7 @@ func build_network_context( func _build_network_evidence() -> Dictionary: var rarity_multipliers: Array[float] = [] for rarity: int in range(FishDataType.Rarity.size()): - rarity_multipliers.append( - _item_effects.get_rarity_weight_multiplier(rarity) - if _item_effects != null - else 1.0 - ) + rarity_multipliers.append(1.0) var discovered_ids: Array[String] = [] if _local_collection_log != null: for fish_id: StringName in _local_collection_log.get_discovered_ids(): @@ -1124,19 +1129,30 @@ func _build_network_evidence() -> Dictionary: var item: ItemDataType = _get_active_item() return { "rod_id": str(item.item_id) if item != null else "", - "reel_speed": _get_effective_reel_speed(), - "barrier_damage": _get_effective_barrier_damage(), - "bite_multiplier": ( - _item_effects.get_bite_time_multiplier() - if _item_effects != null - else 1.0 + "reel_speed": _active_player.reel_speed * ( + _fishing_upgrades.get_reel_speed_multiplier() + if _fishing_upgrades != null else 1.0 ), + "barrier_damage": ( + _fishing_upgrades.get_barrier_damage() + if _fishing_upgrades != null else _active_player.click_power + ), + "bite_multiplier": 1.0, "rarity_multipliers": rarity_multipliers, "discovered_fish_ids": discovered_ids, "capacity_available": not _is_cooler_full(), } +func _on_network_item_use_finished( + accepted: bool, + message: String, +) -> void: + status_changed.emit(message) + if accepted: + refresh_active_item_status() + + func _on_network_cast_accepted( _attempt_id: String, target: Vector3, diff --git a/main/main.gd b/main/main.gd index 979ea7e..4167238 100644 --- a/main/main.gd +++ b/main/main.gd @@ -52,6 +52,12 @@ const NetworkSaleServiceType = preload( const NetworkShopServiceType = preload( "res://network/network_shop_service.gd" ) +const NetworkItemUseServiceType = preload( + "res://network/network_item_use_service.gd" +) +const NetworkChatServiceType = preload( + "res://network/network_chat_service.gd" +) const TITLE_MUSIC_SILENCE_DB: float = -80.0 @@ -91,6 +97,10 @@ const TITLE_MUSIC_SILENCE_DB: float = -80.0 ) @onready var _network_sale: NetworkSaleServiceType = %NetworkSaleService @onready var _network_shop: NetworkShopServiceType = %NetworkShopService +@onready var _network_item_use: NetworkItemUseServiceType = ( + %NetworkItemUseService +) +@onready var _network_chat: NetworkChatServiceType = %NetworkChatService @onready var _players_root: Node3D = $Players var _gameplay_started: bool = false @@ -150,6 +160,15 @@ func _ready() -> void: _player.cooler_capacity ) _save_manager.set_autosave_enabled(false) + _network_item_use.setup( + _network_session, + _player_spawn_service, + item_catalog, + _player.bag, + _player.item_effects, + _save_manager + ) + _network_chat.setup(_network_session) _network_fishing.setup( _network_session, _player_spawn_service, @@ -159,7 +178,8 @@ func _ready() -> void: _player.cooler_capacity, _save_manager, item_catalog, - fish_catalog + fish_catalog, + _network_item_use ) _network_sale.setup( _network_session, @@ -194,6 +214,7 @@ func _ready() -> void: item_catalog, _player.fishing_upgrades, _player.item_effects, + _network_item_use, _player.cooler_capacity, _network_session, _network_fishing @@ -217,7 +238,10 @@ func _ready() -> void: _player.cooler_capacity, _network_session, _network_sale, - _network_shop + _network_shop, + _network_chat, + _network_profile, + _player_spawn_service ) _water_recovery.setup( _player, @@ -284,6 +308,7 @@ func _ready() -> void: _player.hotbar.selected_slot_changed.connect( _on_active_hotbar_item_changed ) + _player.bag.contents_changed.connect(_refresh_active_hotbar_item) _fishing_spot.ready_for_equipment_refresh.connect( _refresh_active_hotbar_item ) @@ -409,6 +434,8 @@ func _set_gameplay_active(active: bool) -> void: _water_recovery.set_recovery_enabled(active) _game_ui.set_gameplay_ui_enabled(active) _save_manager.set_autosave_enabled(active) + if active: + _refresh_active_hotbar_item() func _on_new_game_requested() -> void: @@ -670,6 +697,9 @@ func _on_active_hotbar_item_changed( and _player.bag.owns_item(item_id) ) _player.set_active_item_is_rod(active_is_rod) + _network_item_use.submit_local_equipped(item_id, active_is_rod or ( + item != null and _player.bag.owns_item(item_id) + )) _fishing_spot.refresh_active_item_status() diff --git a/main/main.tscn b/main/main.tscn index 7155d13..a897c17 100644 --- a/main/main.tscn +++ b/main/main.tscn @@ -1,4 +1,4 @@ -[gd_scene load_steps=24 format=3] +[gd_scene load_steps=26 format=3] [ext_resource type="PackedScene" path="res://world/test_world.tscn" id="1_world"] [ext_resource type="PackedScene" path="res://player/player.tscn" id="2_player"] @@ -23,6 +23,8 @@ [ext_resource type="Script" path="res://network/network_fishing_service.gd" id="21_network_fishing"] [ext_resource type="Script" path="res://network/network_sale_service.gd" id="22_network_sale"] [ext_resource type="Script" path="res://network/network_shop_service.gd" id="23_network_shop"] +[ext_resource type="Script" path="res://network/network_item_use_service.gd" id="24_network_item"] +[ext_resource type="Script" path="res://network/network_chat_service.gd" id="25_network_chat"] [node name="Main" type="Node3D"] script = ExtResource("3_main") @@ -59,6 +61,14 @@ script = ExtResource("22_network_sale") unique_name_in_owner = true script = ExtResource("23_network_shop") +[node name="NetworkItemUseService" type="Node" parent="."] +unique_name_in_owner = true +script = ExtResource("24_network_item") + +[node name="NetworkChatService" type="Node" parent="."] +unique_name_in_owner = true +script = ExtResource("25_network_chat") + [node name="TestWorld" parent="." instance=ExtResource("1_world")] [node name="Players" type="Node3D" parent="."] diff --git a/network/direct_enet_transport.gd b/network/direct_enet_transport.gd index ece4863..fdfa048 100644 --- a/network/direct_enet_transport.gd +++ b/network/direct_enet_transport.gd @@ -11,7 +11,9 @@ func start_host( var enet_peer := ENetMultiplayerPeer.new() if not bind_address.is_empty() and bind_address != "*": enet_peer.set_bind_ip(bind_address) - var error: Error = enet_peer.create_server(port, max_clients, 5) + var error: Error = enet_peer.create_server( + port, max_clients, NetworkProtocol.ENET_CHANNEL_COUNT + ) if error != OK: transport_error.emit("Unable to host UDP port %d." % port) return error @@ -27,7 +29,9 @@ func connect_to_route(route: ConnectionRoute) -> Error: return ERR_INVALID_PARAMETER var endpoint: ConnectionEndpoint = route.direct_endpoint var enet_peer := ENetMultiplayerPeer.new() - var error: Error = enet_peer.create_client(endpoint.host, endpoint.port, 5) + var error: Error = enet_peer.create_client( + endpoint.host, endpoint.port, NetworkProtocol.ENET_CHANNEL_COUNT + ) if error != OK: transport_error.emit( "Unable to connect to %s." % endpoint.normalized_display diff --git a/network/network_chat_protocol.gd b/network/network_chat_protocol.gd new file mode 100644 index 0000000..329ef20 --- /dev/null +++ b/network/network_chat_protocol.gd @@ -0,0 +1,61 @@ +class_name NetworkChatProtocol +extends RefCounted + +const CAPABILITY: StringName = &"chat_v1" +const RELIABLE_CHANNEL: int = NetworkProtocol.CHAT_RELIABLE_CHANNEL +const MAX_VISIBLE_CHARACTERS: int = 300 +const MAX_UTF8_BYTES: int = 1200 +const MAX_HISTORY: int = 100 +const LATE_JOIN_HISTORY: int = 50 +const MAX_ID_LENGTH: int = 96 + +enum Kind { PLAYER, SYSTEM } + + +static func sanitize_body(value: Variant) -> String: + if typeof(value) != TYPE_STRING: + return "" + var result: String = str(value).replace("\r", " ").replace("\n", " ") + result = result.replace("\t", " ").strip_edges() + if result.is_empty() or result.length() > MAX_VISIBLE_CHARACTERS: + return "" + if result.to_utf8_buffer().size() > MAX_UTF8_BYTES: + return "" + for index: int in result.length(): + var codepoint: int = result.unicode_at(index) + if codepoint < 32 or codepoint == 127: + return "" + return result + + +static func validate_request(data: Variant) -> bool: + return ( + typeof(data) == TYPE_DICTIONARY + and _valid_id(data.get("request_id")) + and _valid_id(data.get("session_id")) + and not sanitize_body(data.get("body")).is_empty() + ) + + +static func validate_message(data: Variant) -> bool: + return ( + typeof(data) == TYPE_DICTIONARY + and _valid_id(data.get("message_id")) + and _valid_id(data.get("session_id")) + and typeof(data.get("sequence")) == TYPE_INT + and int(data["sequence"]) >= 0 + and typeof(data.get("kind")) == TYPE_INT + and int(data["kind"]) in [Kind.PLAYER, Kind.SYSTEM] + and typeof(data.get("sender_peer_id")) == TYPE_INT + and typeof(data.get("sender_display_name")) == TYPE_STRING + and str(data["sender_display_name"]).length() <= 24 + and not sanitize_body(data.get("body")).is_empty() + ) + + +static func _valid_id(value: Variant) -> bool: + return ( + typeof(value) == TYPE_STRING + and not str(value).is_empty() + and str(value).length() <= MAX_ID_LENGTH + ) diff --git a/network/network_chat_protocol.gd.uid b/network/network_chat_protocol.gd.uid new file mode 100644 index 0000000..916bd77 --- /dev/null +++ b/network/network_chat_protocol.gd.uid @@ -0,0 +1 @@ +uid://c3hg65v27hi2g diff --git a/network/network_chat_service.gd b/network/network_chat_service.gd new file mode 100644 index 0000000..9e82f42 --- /dev/null +++ b/network/network_chat_service.gd @@ -0,0 +1,235 @@ +class_name NetworkChatService +extends Node + +const BURST_COUNT: int = 3 +const WINDOW_COUNT: int = 5 +const WINDOW_SECONDS: float = 10.0 + +signal message_received(message: Dictionary) +signal send_rejected(message: String) +signal history_replaced(messages: Array[Dictionary]) + +var _session: NetworkSession +var _history: Array[Dictionary] = [] +var _seen_messages: Dictionary[String, bool] = {} +var _request_ledgers: Dictionary[int, Dictionary] = {} +var _rate_times: Dictionary[int, Array] = {} +var _sequence: int = 0 +var _peer_names: Dictionary[int, String] = {} + + +func setup(session: NetworkSession) -> void: + _session = session + _session.peer_authenticated.connect(_on_peer_authenticated) + _session.peer_removed.connect(_on_peer_removed) + _session.state_changed.connect(_on_session_state_changed) + _session.peer_display_name_changed.connect( + func(peer_id: int, display_name: String) -> void: + _peer_names[peer_id] = display_name + ) + + +func send_local_message(body: String) -> bool: + if ( + _session == null + or not _session.is_gameplay_session_active() + or ( + not _session.is_host() + and not _session.supports_server_capability( + NetworkChatProtocol.CAPABILITY + ) + ) + ): + send_rejected.emit("Chat is unavailable.") + return false + var clean := NetworkChatProtocol.sanitize_body(body) + if clean.is_empty(): + send_rejected.emit("Message is empty or too long.") + return false + var request := { + "request_id": _new_id("chat_request"), + "session_id": _session.get_session_id(), + "body": clean, + } + if _session.is_host(): + _handle_request(_session.get_local_peer_id(), request) + else: + submit_chat_message.rpc_id(1, request) + return true + + +func get_history() -> Array[Dictionary]: + return _history.duplicate(true) + + +@rpc("any_peer", "call_remote", "reliable", NetworkChatProtocol.RELIABLE_CHANNEL) +func submit_chat_message(data: Dictionary) -> void: + var sender_id := multiplayer.get_remote_sender_id() + if _session.is_host() and _session.is_authenticated_peer(sender_id): + _handle_request(sender_id, data) + + +func _handle_request(peer_id: int, data: Dictionary) -> void: + if ( + not NetworkChatProtocol.validate_request(data) + or str(data["session_id"]) != _session.get_session_id() + ): + _send_rejection(peer_id, "Message could not be sent.") + return + var request_id: String = data["request_id"] + var ledger: Dictionary = _request_ledgers.get(peer_id, {}) + if ledger.has(request_id): + _send_message(peer_id, ledger[request_id]) + return + if not _consume_rate(peer_id): + _send_rejection(peer_id, "Slow down.") + return + var record := _session.get_peer_record(peer_id) + if record == null: + return + var message := _make_message( + NetworkChatProtocol.Kind.PLAYER, + peer_id, + record.display_name, + NetworkChatProtocol.sanitize_body(data["body"]) + ) + ledger[request_id] = message.duplicate(true) + while ledger.size() > 64: + ledger.erase(ledger.keys().front()) + _request_ledgers[peer_id] = ledger + _broadcast(message) + + +func _make_message( + kind: int, + peer_id: int, + display_name: String, + body: String, +) -> Dictionary: + _sequence += 1 + return { + "message_id": _new_id("chat"), + "session_id": _session.get_session_id(), + "sequence": _sequence, + "kind": kind, + "sender_peer_id": peer_id, + "sender_display_name": display_name.left(24), + "body": body, + } + + +func _broadcast(message: Dictionary) -> void: + _apply_message(message) + receive_chat_message.rpc(message) + + +func _send_message(peer_id: int, message: Dictionary) -> void: + if peer_id == _session.get_local_peer_id(): + _apply_message(message) + else: + receive_chat_message.rpc_id(peer_id, message) + + +@rpc("authority", "call_remote", "reliable", NetworkChatProtocol.RELIABLE_CHANNEL) +func receive_chat_message(data: Dictionary) -> void: + _apply_message(data) + + +func _apply_message(data: Dictionary) -> void: + if ( + not NetworkChatProtocol.validate_message(data) + or str(data["session_id"]) != _session.get_session_id() + or _seen_messages.has(str(data["message_id"])) + ): + return + _seen_messages[data["message_id"]] = true + _history.append(data.duplicate(true)) + while _history.size() > NetworkChatProtocol.MAX_HISTORY: + _history.pop_front() + message_received.emit(data.duplicate(true)) + + +func _send_rejection(peer_id: int, message: String) -> void: + if peer_id == _session.get_local_peer_id(): + send_rejected.emit(message) + else: + receive_chat_rejection.rpc_id(peer_id, message.left(80)) + + +@rpc("authority", "call_remote", "reliable", NetworkChatProtocol.RELIABLE_CHANNEL) +func receive_chat_rejection(message: String) -> void: + send_rejected.emit(message.left(80)) + + +func _on_peer_authenticated(peer_id: int, display_name: String) -> void: + if not _session.is_host(): + return + _peer_names[peer_id] = display_name + var start := maxi(0, _history.size() - NetworkChatProtocol.LATE_JOIN_HISTORY) + receive_chat_history.rpc_id(peer_id, _history.slice(start)) + _broadcast(_make_message( + NetworkChatProtocol.Kind.SYSTEM, 0, "", "%s joined." % display_name + )) + + +func _on_peer_removed(peer_id: int) -> void: + _request_ledgers.erase(peer_id) + _rate_times.erase(peer_id) + if not _session.is_host(): + return + var name: String = _peer_names.get(peer_id, "Player") + _peer_names.erase(peer_id) + _broadcast(_make_message( + NetworkChatProtocol.Kind.SYSTEM, 0, "", "%s left." % name + )) + + +@rpc("authority", "call_remote", "reliable", NetworkChatProtocol.RELIABLE_CHANNEL) +func receive_chat_history(values: Array) -> void: + if values.size() > NetworkChatProtocol.LATE_JOIN_HISTORY: + return + _history.clear() + _seen_messages.clear() + for value: Variant in values: + if NetworkChatProtocol.validate_message(value): + _apply_message(value) + history_replaced.emit(_history.duplicate(true)) + + +func _consume_rate(peer_id: int) -> bool: + var now := Time.get_ticks_msec() / 1000.0 + var times: Array = _rate_times.get(peer_id, []) + while not times.is_empty() and now - float(times.front()) > WINDOW_SECONDS: + times.pop_front() + var recent_burst := 0 + for value: Variant in times: + if now - float(value) <= 1.0: + recent_burst += 1 + if recent_burst >= BURST_COUNT or times.size() >= WINDOW_COUNT: + _rate_times[peer_id] = times + return false + times.append(now) + _rate_times[peer_id] = times + return true + + +func _on_session_state_changed(state: NetworkSession.State) -> void: + if state in [ + NetworkSession.State.INACTIVE, + NetworkSession.State.DISCONNECTING, + NetworkSession.State.CONNECTION_FAILED, + NetworkSession.State.SERVER_LOST, + ]: + _history.clear() + _seen_messages.clear() + _request_ledgers.clear() + _rate_times.clear() + _peer_names.clear() + _sequence = 0 + history_replaced.emit([]) + + +func _new_id(prefix: String) -> String: + return "%s:%s" % [ + prefix, Crypto.new().generate_random_bytes(16).hex_encode(), + ] diff --git a/network/network_chat_service.gd.uid b/network/network_chat_service.gd.uid new file mode 100644 index 0000000..228279d --- /dev/null +++ b/network/network_chat_service.gd.uid @@ -0,0 +1 @@ +uid://b10jayoog5dc2 diff --git a/network/network_fishing_service.gd b/network/network_fishing_service.gd index 9a9dcf4..23a8ce7 100644 --- a/network/network_fishing_service.gd +++ b/network/network_fishing_service.gd @@ -32,6 +32,7 @@ var _local_capacity: PlayerCoolerCapacity var _save_manager: PlayerSaveManager var _item_catalog: ItemCatalog var _fish_catalog: FishPoolType +var _item_use: NetworkItemUseService var _attempts: Dictionary[int, NetworkFishingAttempt] = {} var _request_ledgers: Dictionary[int, Dictionary] = {} var _result_ledgers: Dictionary[String, bool] = {} @@ -53,6 +54,7 @@ func setup( save_manager: PlayerSaveManager, item_catalog: ItemCatalog, fish_catalog: FishPoolType, + item_use: NetworkItemUseService, ) -> void: _session = session _spawn_service = spawn_service @@ -63,6 +65,7 @@ func setup( _save_manager = save_manager _item_catalog = item_catalog _fish_catalog = fish_catalog + _item_use = item_use if not _session.peer_removed.is_connected(_on_peer_removed): _session.peer_removed.connect(_on_peer_removed) if not _session.state_changed.is_connected(_on_session_state_changed): @@ -259,8 +262,12 @@ func _handle_cast_request(peer_id: int, data: Dictionary) -> void: if region == null or region.fish_pool == null: _record_and_reject(peer_id, request_id, "Cannot fish here.") return + var effects: PlayerItemEffects = ( + _item_use.get_effects_for_peer(peer_id) + if _item_use != null else null + ) var selected_fish: FishDataType = _select_authoritative_fish( - region, data + region, data, effects ) if selected_fish == null: _record_and_reject(peer_id, request_id, "Nothing is biting here.") @@ -275,10 +282,14 @@ func _handle_cast_request(peer_id: int, data: Dictionary) -> void: attempt.target = target attempt.bobber_position = target attempt.fish_id = selected_fish.id - attempt.reel_speed = float(data["reel_speed"]) - attempt.barrier_damage = int(data["barrier_damage"]) + attempt.reel_speed = float(data["reel_speed"]) * ( + effects.get_reel_multiplier() if effects != null else 1.0 + ) + attempt.barrier_damage = int(data["barrier_damage"]) + ( + effects.get_barrier_bonus() if effects != null else 0 + ) attempt.bite_time_remaining = _fishing_spot.wait_time * float( - data["bite_multiplier"] + effects.get_bite_time_multiplier() if effects != null else 1.0 ) attempt.controller = CatchController.new() add_child(attempt.controller) @@ -359,6 +370,7 @@ func _make_waiting_snapshot( func _select_authoritative_fish( region: FishableWaterRegion, data: Dictionary, + effects: PlayerItemEffects, ) -> FishDataType: var evidence_log := CollectionLogType.new() for value: Variant in data["discovered_fish_ids"]: @@ -370,8 +382,11 @@ func _select_authoritative_fish( selector.undiscovered_weight_multiplier = ( _fishing_spot.undiscovered_weight_multiplier ) - for value: Variant in data["rarity_multipliers"]: - selector.rarity_weight_multipliers.append(float(value)) + for rarity: int in range(FishDataType.Rarity.size()): + selector.rarity_weight_multipliers.append( + effects.get_rarity_weight_multiplier(rarity) + if effects != null else 1.0 + ) selector.begin_roll() var context: FishingContextType = _fishing_spot.build_network_context(region) return selector.select_fish(region.fish_pool, context, evidence_log) diff --git a/network/network_item_protocol.gd b/network/network_item_protocol.gd new file mode 100644 index 0000000..7ec5b7b --- /dev/null +++ b/network/network_item_protocol.gd @@ -0,0 +1,83 @@ +class_name NetworkItemProtocol +extends RefCounted + +const ITEM_USE_CAPABILITY: StringName = &"item_use_v1" +const EQUIPMENT_CAPABILITY: StringName = &"equipment_v1" +const RELIABLE_CHANNEL: int = NetworkProtocol.ITEM_RELIABLE_CHANNEL +const MAX_ID_LENGTH: int = 96 +const MAX_ITEM_ID_LENGTH: int = 96 +const MAX_MESSAGE_LENGTH: int = 160 + + +static func validate_use_request(data: Variant) -> String: + if typeof(data) != TYPE_DICTIONARY: + return "Item use could not be completed." + var value: Dictionary = data + for key: String in [ + "request_id", "session_id", "item_id", "quantity", + ]: + if not value.has(key): + return "Item use could not be completed." + if ( + not _valid_id(value["request_id"]) + or not _valid_id(value["session_id"]) + or typeof(value["item_id"]) not in [TYPE_STRING, TYPE_STRING_NAME] + or str(value["item_id"]).is_empty() + or str(value["item_id"]).length() > MAX_ITEM_ID_LENGTH + or typeof(value["quantity"]) != TYPE_INT + or int(value["quantity"]) < 1 + or int(value["quantity"]) > 99 + ): + return "Item use could not be completed." + return "" + + +static func validate_use_result(data: Variant) -> bool: + if typeof(data) != TYPE_DICTIONARY: + return false + var value: Dictionary = data + return ( + _valid_id(value.get("result_id")) + and _valid_id(value.get("request_id")) + and _valid_id(value.get("session_id")) + and typeof(value.get("target_peer_id")) == TYPE_INT + and typeof(value.get("accepted")) == TYPE_BOOL + and typeof(value.get("item_id")) in [TYPE_STRING, TYPE_STRING_NAME] + and not str(value["item_id"]).is_empty() + and str(value["item_id"]).length() <= MAX_ITEM_ID_LENGTH + and typeof(value.get("quantity")) == TYPE_INT + and int(value["quantity"]) >= 0 + and int(value["quantity"]) <= 1 + and typeof(value.get("duration")) in [TYPE_FLOAT, TYPE_INT] + and is_finite(float(value["duration"])) + and float(value["duration"]) >= 0.0 + and typeof(value.get("message")) == TYPE_STRING + and str(value["message"]).length() <= MAX_MESSAGE_LENGTH + ) + + +static func validate_equipped_state(data: Variant) -> bool: + if typeof(data) != TYPE_DICTIONARY: + return false + var value: Dictionary = data + return ( + _valid_id(value.get("session_id")) + and typeof(value.get("owner_peer_id")) == TYPE_INT + and int(value["owner_peer_id"]) > 0 + and typeof(value.get("item_id")) in [TYPE_STRING, TYPE_STRING_NAME] + and str(value["item_id"]).length() <= MAX_ITEM_ID_LENGTH + and typeof(value.get("category")) == TYPE_INT + and int(value["category"]) >= -1 + and int(value["category"]) < ItemData.Category.size() + and typeof(value.get("revision")) == TYPE_INT + and int(value["revision"]) >= 0 + and typeof(value.get("owns_item")) == TYPE_BOOL + ) + + +static func _valid_id(value: Variant) -> bool: + return ( + typeof(value) == TYPE_STRING + and not str(value).is_empty() + and str(value).length() <= MAX_ID_LENGTH + ) diff --git a/network/network_item_protocol.gd.uid b/network/network_item_protocol.gd.uid new file mode 100644 index 0000000..6d3a289 --- /dev/null +++ b/network/network_item_protocol.gd.uid @@ -0,0 +1 @@ +uid://bphibe6g81mhm diff --git a/network/network_item_use_service.gd b/network/network_item_use_service.gd new file mode 100644 index 0000000..53ef170 --- /dev/null +++ b/network/network_item_use_service.gd @@ -0,0 +1,388 @@ +class_name NetworkItemUseService +extends Node + +const MAX_LEDGER_ENTRIES: int = 64 + +signal local_item_use_pending(request_id: String) +signal local_item_use_finished(accepted: bool, message: String) +signal equipped_state_changed(peer_id: int, item_id: StringName, category: int) + +var _session: NetworkSession +var _spawn_service: PlayerSpawnService +var _catalog: ItemCatalog +var _local_bag: PlayerBag +var _local_effects: PlayerItemEffects +var _save_manager: PlayerSaveManager +var _requests: Dictionary[int, Dictionary] = {} +var _pending_by_peer: Dictionary[int, String] = {} +var _result_owners: Dictionary[String, int] = {} +var _received_results: Dictionary[String, bool] = {} +var _pending_local: Dictionary = {} +var _equipped_states: Dictionary[int, Dictionary] = {} +var _local_equipped_revision: int = 0 + + +func setup( + session: NetworkSession, + spawn_service: PlayerSpawnService, + catalog: ItemCatalog, + local_bag: PlayerBag, + local_effects: PlayerItemEffects, + save_manager: PlayerSaveManager, +) -> void: + _session = session + _spawn_service = spawn_service + _catalog = catalog + _local_bag = local_bag + _local_effects = local_effects + _save_manager = save_manager + _session.peer_removed.connect(_on_peer_removed) + _session.state_changed.connect(_on_session_state_changed) + _session.peer_authenticated.connect(_on_peer_authenticated) + + +func request_use(item_id: StringName) -> String: + if not _pending_local.is_empty(): + local_item_use_finished.emit(false, "An item use is already pending.") + return "" + if ( + _session == null + or not _session.is_gameplay_session_active() + or ( + not _session.is_host() + and not _session.supports_server_capability( + NetworkItemProtocol.ITEM_USE_CAPABILITY + ) + ) + ): + local_item_use_finished.emit(false, "Item use is unavailable.") + return "" + var request_id := _new_id("item") + var data := { + "request_id": request_id, + "session_id": _session.get_session_id(), + "item_id": str(item_id), + "quantity": _local_bag.get_quantity(item_id), + } + _pending_local = data.duplicate(true) + local_item_use_pending.emit(request_id) + if _session.is_host(): + _handle_use_request(_session.get_local_peer_id(), data) + else: + submit_item_use.rpc_id(1, data) + return request_id + + +@rpc("any_peer", "call_remote", "reliable", NetworkItemProtocol.RELIABLE_CHANNEL) +func submit_item_use(data: Dictionary) -> void: + var sender_id := multiplayer.get_remote_sender_id() + if _session.is_host() and _session.is_authenticated_peer(sender_id): + _handle_use_request(sender_id, data) + + +func _handle_use_request(peer_id: int, data: Dictionary) -> void: + var request_id := str(data.get("request_id", "")) + var ledger: Dictionary = _requests.get(peer_id, {}) + if ledger.has(request_id): + _send_result(peer_id, ledger[request_id]) + return + var error := NetworkItemProtocol.validate_use_request(data) + if ( + error.is_empty() + and str(data["session_id"]) != _session.get_session_id() + ): + error = "Item use could not be completed." + if error.is_empty() and _pending_by_peer.has(peer_id): + error = "An item use is already pending." + var item_id := StringName(str(data.get("item_id", ""))) + var item: ItemData = _catalog.get_item_by_id(item_id) + var avatar: Player = _spawn_service.get_avatar(peer_id) + var duration: float = 0.0 + if error.is_empty() and ( + item == null + or item.category != ItemData.Category.CONSUMABLE + or not item.usable + ): + error = "That item cannot be used now." + if error.is_empty() and int(data["quantity"]) < 1: + error = "You do not have that item." + if error.is_empty() and ( + avatar == null + or avatar.is_water_recovery_active() + ): + error = "That item cannot be used now." + if error.is_empty(): + duration = avatar.item_effects.get_effect_duration(item_id) + if duration <= 0.0: + error = "That item cannot be used now." + var result := { + "result_id": _new_id("item_result"), + "request_id": request_id if not request_id.is_empty() else "invalid", + "session_id": _session.get_session_id(), + "target_peer_id": peer_id, + "accepted": error.is_empty(), + "item_id": str(item_id) if not item_id.is_empty() else "invalid", + "quantity": 1 if error.is_empty() else 0, + "duration": duration if error.is_empty() else 0.0, + "message": "Used %s." % item.display_name if error.is_empty() else error, + } + ledger[request_id] = result.duplicate(true) + _bound(ledger) + _requests[peer_id] = ledger + _result_owners[result["result_id"]] = peer_id + _pending_by_peer[peer_id] = request_id + _send_result(peer_id, result) + + +func _send_result(peer_id: int, result: Dictionary) -> void: + if peer_id == _session.get_local_peer_id(): + _apply_result(result) + else: + receive_item_use_result.rpc_id(peer_id, result) + + +@rpc("authority", "call_remote", "reliable", NetworkItemProtocol.RELIABLE_CHANNEL) +func receive_item_use_result(data: Dictionary) -> void: + _apply_result(data) + + +func _apply_result(data: Dictionary) -> void: + if ( + not NetworkItemProtocol.validate_use_result(data) + or str(data["session_id"]) != _session.get_session_id() + or int(data["target_peer_id"]) != _session.get_local_peer_id() + ): + return + var result_id: String = data["result_id"] + if _received_results.has(result_id): + _acknowledge(data, true, "") + return + if ( + _pending_local.is_empty() + or str(data["request_id"]) != str(_pending_local.get("request_id", "")) + ): + return + if not bool(data["accepted"]): + _received_results[result_id] = true + _pending_local.clear() + local_item_use_finished.emit(false, str(data["message"])) + _acknowledge(data, false, str(data["message"])) + return + var item_id := StringName(str(data["item_id"])) + var bag_snapshot := _local_bag.get_all_items() + if ( + _local_bag.get_quantity(item_id) < 1 + or not _local_bag.remove_item(item_id, 1) + or not _save_manager.save_if_dirty() + ): + _local_bag.replace_all_items(bag_snapshot) + _save_manager.save_if_dirty() + _pending_local.clear() + local_item_use_finished.emit(false, "Item use could not be completed.") + _acknowledge(data, false, "Item use could not be completed.") + return + _received_results[result_id] = true + _bound(_received_results) + _pending_local.clear() + local_item_use_finished.emit(true, str(data["message"])) + _acknowledge(data, true, "") + + +func _acknowledge(data: Dictionary, applied: bool, message: String) -> void: + if _session.is_host(): + _handle_ack( + _session.get_local_peer_id(), + str(data["result_id"]), + str(data["request_id"]), + applied, + message + ) + else: + acknowledge_item_use.rpc_id( + 1, str(data["result_id"]), str(data["request_id"]), applied, + message.left(NetworkItemProtocol.MAX_MESSAGE_LENGTH) + ) + + +@rpc("any_peer", "call_remote", "reliable", NetworkItemProtocol.RELIABLE_CHANNEL) +func acknowledge_item_use( + result_id: String, + request_id: String, + applied: bool, + message: String, +) -> void: + var sender_id := multiplayer.get_remote_sender_id() + if _session.is_host() and _session.is_authenticated_peer(sender_id): + _handle_ack(sender_id, result_id, request_id, applied, message) + + +func _handle_ack( + peer_id: int, + result_id: String, + request_id: String, + applied: bool, + _message: String, +) -> void: + if ( + _result_owners.get(result_id, 0) != peer_id + or _pending_by_peer.get(peer_id, "") != request_id + ): + return + _pending_by_peer.erase(peer_id) + if not applied: + return + var result: Dictionary = _requests.get(peer_id, {}).get(request_id, {}) + var avatar := _spawn_service.get_avatar(peer_id) + if avatar == null or result.is_empty(): + return + var item_id := StringName(str(result["item_id"])) + avatar.item_effects.activate_authoritative(item_id, float(result["duration"])) + _broadcast_effect(peer_id, item_id, float(result["duration"])) + + +func get_effects_for_peer(peer_id: int) -> PlayerItemEffects: + var avatar := _spawn_service.get_avatar(peer_id) + return avatar.item_effects if avatar != null else null + + +func submit_local_equipped(item_id: StringName, owns_item: bool) -> void: + if _session == null or not _session.is_gameplay_session_active(): + return + _local_equipped_revision += 1 + var item: ItemData = _catalog.get_item_by_id(item_id) + var data := { + "session_id": _session.get_session_id(), + "owner_peer_id": _session.get_local_peer_id(), + "item_id": str(item_id) if item != null and owns_item else "", + "category": int(item.category) if item != null and owns_item else -1, + "revision": _local_equipped_revision, + "owns_item": owns_item and item != null, + } + if _session.is_host(): + _handle_equipped_state(_session.get_local_peer_id(), data) + elif _session.supports_server_capability( + NetworkItemProtocol.EQUIPMENT_CAPABILITY + ): + submit_equipped_state.rpc_id(1, data) + + +@rpc("any_peer", "call_remote", "reliable", NetworkItemProtocol.RELIABLE_CHANNEL) +func submit_equipped_state(data: Dictionary) -> void: + var sender_id := multiplayer.get_remote_sender_id() + if _session.is_host() and _session.is_authenticated_peer(sender_id): + _handle_equipped_state(sender_id, data) + + +func _handle_equipped_state(peer_id: int, data: Dictionary) -> void: + if ( + not NetworkItemProtocol.validate_equipped_state(data) + or str(data["session_id"]) != _session.get_session_id() + or int(data["owner_peer_id"]) != peer_id + ): + return + var previous: Dictionary = _equipped_states.get(peer_id, {}) + if int(data["revision"]) <= int(previous.get("revision", -1)): + return + var item_id := StringName(str(data["item_id"])) + var item: ItemData = _catalog.get_item_by_id(item_id) + if not item_id.is_empty() and ( + item == null + or not bool(data["owns_item"]) + or int(item.category) != int(data["category"]) + ): + return + _equipped_states[peer_id] = data.duplicate(true) + _apply_equipped(data) + receive_equipped_state.rpc(data) + + +@rpc("authority", "call_remote", "reliable", NetworkItemProtocol.RELIABLE_CHANNEL) +func receive_equipped_state(data: Dictionary) -> void: + if NetworkItemProtocol.validate_equipped_state(data): + _equipped_states[int(data["owner_peer_id"])] = data.duplicate(true) + _apply_equipped(data) + + +func _apply_equipped(data: Dictionary) -> void: + var peer_id: int = data["owner_peer_id"] + var avatar := _spawn_service.get_avatar(peer_id) + if avatar != null: + avatar.set_active_item_is_rod( + int(data["category"]) == ItemData.Category.ROD + ) + equipped_state_changed.emit( + peer_id, StringName(str(data["item_id"])), int(data["category"]) + ) + + +func _broadcast_effect(peer_id: int, item_id: StringName, duration: float) -> void: + var data := { + "session_id": _session.get_session_id(), + "owner_peer_id": peer_id, + "item_id": str(item_id), + "remaining": duration, + } + _apply_effect_snapshot(data) + receive_effect_snapshot.rpc(data) + + +@rpc("authority", "call_remote", "reliable", NetworkItemProtocol.RELIABLE_CHANNEL) +func receive_effect_snapshot(data: Dictionary) -> void: + _apply_effect_snapshot(data) + + +func _apply_effect_snapshot(data: Dictionary) -> void: + if ( + typeof(data.get("owner_peer_id")) != TYPE_INT + or typeof(data.get("item_id")) != TYPE_STRING + or typeof(data.get("remaining")) not in [TYPE_FLOAT, TYPE_INT] + or str(data.get("session_id", "")) != _session.get_session_id() + ): + return + var avatar := _spawn_service.get_avatar(int(data["owner_peer_id"])) + if avatar != null: + avatar.item_effects.activate_authoritative( + StringName(data["item_id"]), float(data["remaining"]) + ) + + +func _on_peer_authenticated(peer_id: int, _display_name: String) -> void: + if not _session.is_host(): + return + for state: Dictionary in _equipped_states.values(): + receive_equipped_state.rpc_id(peer_id, state) + + +func _on_peer_removed(peer_id: int) -> void: + _requests.erase(peer_id) + _pending_by_peer.erase(peer_id) + _equipped_states.erase(peer_id) + + +func _on_session_state_changed(state: NetworkSession.State) -> void: + if state in [ + NetworkSession.State.INACTIVE, + NetworkSession.State.DISCONNECTING, + NetworkSession.State.CONNECTION_FAILED, + NetworkSession.State.SERVER_LOST, + ]: + var pending := not _pending_local.is_empty() + _requests.clear() + _pending_by_peer.clear() + _result_owners.clear() + _received_results.clear() + _equipped_states.clear() + _pending_local.clear() + if pending: + local_item_use_finished.emit(false, "Connection lost.") + + +func _bound(values: Dictionary) -> void: + while values.size() > MAX_LEDGER_ENTRIES: + values.erase(values.keys().front()) + + +func _new_id(prefix: String) -> String: + return "%s:%s" % [ + prefix, Crypto.new().generate_random_bytes(16).hex_encode(), + ] diff --git a/network/network_item_use_service.gd.uid b/network/network_item_use_service.gd.uid new file mode 100644 index 0000000..7558e08 --- /dev/null +++ b/network/network_item_use_service.gd.uid @@ -0,0 +1 @@ +uid://dx21uu253nmpw diff --git a/network/network_profile_preferences.gd b/network/network_profile_preferences.gd index 56acbe8..ef69f9a 100644 --- a/network/network_profile_preferences.gd +++ b/network/network_profile_preferences.gd @@ -34,6 +34,29 @@ func load_or_create() -> bool: return _save_atomic() +func set_display_name(value: String) -> bool: + var clean_name: String = value.strip_edges() + if not is_valid_display_name(clean_name): + return false + var previous: String = display_name + display_name = clean_name + if _save_atomic(): + return true + display_name = previous + return false + + +static func is_valid_display_name(value: String) -> bool: + var clean_name := value.strip_edges() + if clean_name.is_empty() or clean_name.length() > 24: + return false + for index: int in clean_name.length(): + var codepoint: int = clean_name.unicode_at(index) + if codepoint < 32 or codepoint == 127: + return false + return true + + func _load_existing() -> bool: var file := FileAccess.open(PROFILE_PATH, FileAccess.READ) if file == null: diff --git a/network/network_protocol.gd b/network/network_protocol.gd index 402e265..419e0c1 100644 --- a/network/network_protocol.gd +++ b/network/network_protocol.gd @@ -3,14 +3,18 @@ extends RefCounted const PROTOCOL_VERSION: int = 2 const GAME_BUILD: String = "prealpha" -const MAX_DISPLAY_NAME_LENGTH: int = 48 +const MAX_DISPLAY_NAME_LENGTH: int = 24 const MAX_PROFILE_ID_LENGTH: int = 96 const MAX_NONCE_LENGTH: int = 96 # ENet channels: 0 reliable lifecycle, 1 movement input, 2 movement # snapshots, 3 fishing input, 4 fishing snapshots, 5 reliable sales, -# 6 reliable shop transactions. +# 6 reliable shop transactions, 7 reliable item/equipment lifecycle, +# 8 reliable ordered session chat. const SALE_RELIABLE_CHANNEL: int = 5 const SHOP_RELIABLE_CHANNEL: int = 6 +const ITEM_RELIABLE_CHANNEL: int = 7 +const CHAT_RELIABLE_CHANNEL: int = 8 +const ENET_CHANNEL_COUNT: int = 9 enum RejectionCode { NONE, @@ -109,6 +113,9 @@ static func make_server_hello( "fishing_v1", "sale_v1", "shop_v1", + "item_use_v1", + "equipment_v1", + "chat_v1", ]), } diff --git a/network/network_session.gd b/network/network_session.gd index bbb6f72..8948578 100644 --- a/network/network_session.gd +++ b/network/network_session.gd @@ -16,6 +16,7 @@ signal peer_authenticated(peer_id: int, display_name: String) signal peer_removed(peer_id: int) signal host_openness_changed(is_open: bool) signal peer_count_changed(player_count: int, max_players: int) +signal peer_display_name_changed(peer_id: int, display_name: String) signal join_authenticated signal server_lost signal remote_recovery_requested(peer_id: int, entry_position: Vector3) @@ -275,10 +276,50 @@ 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", ]) return str(capability) in _server_capabilities +func get_peer_record(peer_id: int) -> PeerRegistry.PeerRecord: + return _registry.get_peer(peer_id) + + +func update_local_display_name(value: String) -> bool: + if _profile == null or not _profile.set_display_name(value): + return false + var peer_id := get_local_peer_id() + if is_host(): + _apply_display_name(peer_id, _profile.display_name) + receive_display_name.rpc(peer_id, _profile.display_name) + elif is_joined_client() and supports_server_capability(&"chat_v1"): + submit_display_name.rpc_id(1, _profile.display_name) + return true + + +@rpc("any_peer", "call_remote", "reliable", 0) +func submit_display_name(value: String) -> void: + var sender_id := multiplayer.get_remote_sender_id() + if ( + is_host() + and is_authenticated_peer(sender_id) + and NetworkProfilePreferences.is_valid_display_name(value) + ): + _apply_display_name(sender_id, value.strip_edges()) + receive_display_name.rpc(sender_id, value.strip_edges()) + + +@rpc("authority", "call_remote", "reliable", 0) +func receive_display_name(peer_id: int, value: String) -> void: + if NetworkProfilePreferences.is_valid_display_name(value): + _apply_display_name(peer_id, value.strip_edges()) + + +func _apply_display_name(peer_id: int, value: String) -> void: + if _registry.update_display_name(peer_id, value): + peer_display_name_changed.emit(peer_id, value) + + func get_local_peer_id() -> int: return multiplayer.get_unique_id() if is_gameplay_session_active() else 0 @@ -414,6 +455,12 @@ func submit_client_hello(data: Dictionary) -> void: ) return var display_name: String = data["display_name"] + if not NetworkProfilePreferences.is_valid_display_name(display_name): + _reject_peer( + sender_id, + NetworkProtocol.RejectionCode.MALFORMED_HANDSHAKE + ) + return if not _registry.add_peer( sender_id, profile_id, diff --git a/network/peer_registry.gd b/network/peer_registry.gd index 2b6203a..55ec8a3 100644 --- a/network/peer_registry.gd +++ b/network/peer_registry.gd @@ -42,6 +42,14 @@ func get_peer(peer_id: int) -> PeerRecord: return _records.get(peer_id) +func update_display_name(peer_id: int, display_name: String) -> bool: + var record: PeerRecord = _records.get(peer_id) + if record == null or display_name.is_empty(): + return false + record.display_name = display_name + return true + + func has_peer(peer_id: int) -> bool: return _records.has(peer_id) diff --git a/player/player.gd b/player/player.gd index ba9952a..b7a85e7 100644 --- a/player/player.gd +++ b/player/player.gd @@ -536,6 +536,10 @@ func get_body_center_height() -> float: return body_center_height +func get_gameplay_camera() -> Camera3D: + return _camera + + func get_fishing_rod_tip() -> Marker3D: return _fishing_rod_tip diff --git a/progression/player_item_effects.gd b/progression/player_item_effects.gd index 47cdbb5..d7542fc 100644 --- a/progression/player_item_effects.gd +++ b/progression/player_item_effects.gd @@ -61,6 +61,28 @@ func use_consumable( return true +func activate_authoritative(item_id: StringName, duration: float) -> bool: + if not _remaining.has(item_id) or not is_finite(duration) or duration <= 0.0: + return false + _remaining[item_id] = duration + effects_changed.emit() + return true + + +func restore_remaining(snapshot: Dictionary[StringName, float]) -> void: + for item_id: StringName in _remaining: + _remaining[item_id] = maxf(snapshot.get(item_id, 0.0), 0.0) + effects_changed.emit() + + +func get_remaining_snapshot() -> Dictionary[StringName, float]: + return _remaining.duplicate() + + +func get_effect_duration(item_id: StringName) -> float: + return _get_duration(item_id) + + func reset_all() -> void: var changed: bool = false for item_id: StringName in _remaining: diff --git a/ui/chat_ui.gd b/ui/chat_ui.gd new file mode 100644 index 0000000..9b5dd65 --- /dev/null +++ b/ui/chat_ui.gd @@ -0,0 +1,241 @@ +class_name ChatUI +extends Control + +const INPUT_OWNER: StringName = &"chat" +const COMPACT_SECONDS: float = 8.0 +const SPEECH_SECONDS: float = 6.0 + +var _service: NetworkChatService +var _session: NetworkSession +var _spawn: PlayerSpawnService +var _player: Player +var _fishing_spot: FishingSpot +var _history: Label +var _entry: LineEdit +var _chat_button: Button +var _panel: PanelContainer +var _speech_layer: Control +var _speech: Dictionary[int, Dictionary] = {} +var _opened: bool = false +var _available: bool = false +var _last_message_time: float = -INF +var _prior_movement: bool = true +var _prior_camera: bool = true + + +func _ready() -> void: + set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + mouse_filter = Control.MOUSE_FILTER_IGNORE + _build_ui() + + +func setup( + service: NetworkChatService, + session: NetworkSession, + spawn: PlayerSpawnService, + player: Player, + fishing_spot: FishingSpot, +) -> void: + _service = service + _session = session + _spawn = spawn + _player = player + _fishing_spot = fishing_spot + _service.message_received.connect(_on_message) + _service.history_replaced.connect(_on_history) + _service.send_rejected.connect(_on_rejected) + _session.peer_removed.connect(_on_peer_removed) + _refresh_history() + + +func open_chat() -> void: + if ( + _opened or not _available or _service == null + or not _session.is_gameplay_session_active() + ): + return + _opened = true + _prior_movement = _player.is_movement_enabled() + _prior_camera = _player.is_camera_input_enabled() + _player.set_movement_enabled(false) + _player.set_camera_input_enabled(false) + _fishing_spot.set_local_menu_input_suppressed(INPUT_OWNER, true) + _panel.show() + _entry.show() + _entry.grab_focus() + Input.mouse_mode = Input.MOUSE_MODE_VISIBLE + + +func set_available(value: bool) -> void: + _available = value + _chat_button.visible = value + if not value: + close_chat() + + +func close_chat() -> void: + if not _opened: + return + _opened = false + _entry.release_focus() + _entry.hide() + _player.set_movement_enabled(_prior_movement) + _player.set_camera_input_enabled(_prior_camera) + _fishing_spot.set_local_menu_input_suppressed(INPUT_OWNER, false) + _refresh_visibility() + + +func _unhandled_input(event: InputEvent) -> void: + if event is InputEventKey and event.pressed and not event.echo: + if event.keycode in [KEY_ENTER, KEY_KP_ENTER]: + if _opened: + _send() + elif ( + _available + and get_viewport().gui_get_focus_owner() is not LineEdit + ): + open_chat() + get_viewport().set_input_as_handled() + elif event.keycode == KEY_ESCAPE and _opened: + close_chat() + get_viewport().set_input_as_handled() + + +func _process(_delta: float) -> void: + _refresh_visibility() + var now := Time.get_ticks_msec() / 1000.0 + for peer_id: int in _speech.keys(): + var state: Dictionary = _speech[peer_id] + var label := state.get("label") as Label + if label == null or now >= float(state.get("expires", 0.0)): + if label != null: + label.queue_free() + _speech.erase(peer_id) + continue + var avatar := _spawn.get_avatar(peer_id) + var camera := _player.get_gameplay_camera() + if avatar == null or camera == null: + label.hide() + continue + var world_position := avatar.get_body_center_position() + Vector3.UP * 1.2 + if camera.is_position_behind(world_position): + label.hide() + continue + var screen_position := camera.unproject_position(world_position) + label.position = screen_position - Vector2(label.size.x * 0.5, 42.0) + label.show() + + +func _build_ui() -> void: + _panel = PanelContainer.new() + _panel.position = Vector2(18, 390) + _panel.size = Vector2(390, 240) + _panel.mouse_filter = Control.MOUSE_FILTER_STOP + add_child(_panel) + var box := VBoxContainer.new() + box.add_theme_constant_override("separation", 6) + _panel.add_child(box) + _history = Label.new() + _history.custom_minimum_size = Vector2(360, 150) + _history.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + _history.vertical_alignment = VERTICAL_ALIGNMENT_BOTTOM + _history.mouse_filter = Control.MOUSE_FILTER_IGNORE + box.add_child(_history) + _entry = LineEdit.new() + _entry.placeholder_text = "type a message…" + _entry.max_length = NetworkChatProtocol.MAX_VISIBLE_CHARACTERS + _entry.text_submitted.connect(func(_value: String) -> void: _send()) + _entry.hide() + box.add_child(_entry) + _chat_button = Button.new() + _chat_button.text = "chat" + _chat_button.position = Vector2(18, 570) + _chat_button.size = Vector2(76, 54) + _chat_button.pressed.connect(open_chat) + add_child(_chat_button) + _speech_layer = Control.new() + _speech_layer.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + _speech_layer.mouse_filter = Control.MOUSE_FILTER_IGNORE + add_child(_speech_layer) + _panel.hide() + + +func _send() -> void: + var body := _entry.text + if _service.send_local_message(body): + _entry.clear() + close_chat() + + +func _on_message(message: Dictionary) -> void: + _last_message_time = Time.get_ticks_msec() / 1000.0 + _refresh_history() + if int(message["kind"]) != NetworkChatProtocol.Kind.PLAYER: + return + var peer_id: int = message["sender_peer_id"] + _on_peer_removed(peer_id) + var label := Label.new() + label.text = "%s: %s" % [ + str(message["sender_display_name"]), str(message["body"]).left(120), + ] + label.custom_minimum_size = Vector2(80, 34) + label.size = Vector2(280, 70) + label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + label.mouse_filter = Control.MOUSE_FILTER_IGNORE + _speech_layer.add_child(label) + _speech[peer_id] = { + "label": label, + "expires": Time.get_ticks_msec() / 1000.0 + SPEECH_SECONDS, + } + + +func _on_history(_messages: Array[Dictionary]) -> void: + if _messages.is_empty(): + for peer_id: int in _speech.keys(): + _on_peer_removed(peer_id) + _refresh_history() + + +func _on_rejected(message: String) -> void: + _entry.placeholder_text = message + if not _opened: + open_chat() + + +func _refresh_history() -> void: + if _service == null: + return + var lines: Array[String] = [] + var messages := _service.get_history() + var start := maxi(0, messages.size() - 8) + for message: Dictionary in messages.slice(start): + if int(message["kind"]) == NetworkChatProtocol.Kind.SYSTEM: + lines.append("• %s" % str(message["body"])) + else: + lines.append("%s: %s" % [ + str(message["sender_display_name"]), str(message["body"]), + ]) + _history.text = "\n".join(lines) + + +func _refresh_visibility() -> void: + if not _available: + _panel.hide() + return + if _opened: + _panel.show() + return + var recent := ( + Time.get_ticks_msec() / 1000.0 - _last_message_time + < COMPACT_SECONDS + ) + _panel.visible = recent and _service != null and not _service.get_history().is_empty() + + +func _on_peer_removed(peer_id: int) -> void: + var state: Dictionary = _speech.get(peer_id, {}) + var label := state.get("label") as Label + if label != null: + label.queue_free() + _speech.erase(peer_id) diff --git a/ui/chat_ui.gd.uid b/ui/chat_ui.gd.uid new file mode 100644 index 0000000..3b5a459 --- /dev/null +++ b/ui/chat_ui.gd.uid @@ -0,0 +1 @@ +uid://canbrvh1cqkie diff --git a/ui/game_ui.gd b/ui/game_ui.gd index 2bd16bb..3780ab0 100644 --- a/ui/game_ui.gd +++ b/ui/game_ui.gd @@ -31,6 +31,7 @@ const PlayerItemEffectsType = preload( const PlayerCoolerCapacityType = preload( "res://progression/player_cooler_capacity.gd" ) +const ChatUIType = preload("res://ui/chat_ui.gd") signal pixelation_settings_visibility_changed(is_visible: bool) signal crisp_reset_focus_requested @@ -53,6 +54,7 @@ signal interactive_pointer_ui_changed(is_open: bool) @onready var _fishing_shop: FishingShopType = %FishingShop @onready var _shop_prompt: PanelContainer = %ShopPrompt @onready var _effect_status: Label = %EffectStatus +@onready var _chat_ui: ChatUIType = %ChatUI @onready var _title_settings_panel: SettingsPanelType = ( $UIRoot/TitleScreen/ResponsiveTitleStage/TitlePresentationScaleRoot/SettingsPanel ) @@ -70,6 +72,9 @@ var _item_effects: PlayerItemEffectsType func _ready() -> void: + _pause_menu.chat_requested.connect( + func() -> void: call_deferred("_open_chat_from_pause") + ) _title_settings_panel.panel_visibility_changed.connect( _on_settings_visibility_changed ) @@ -84,6 +89,10 @@ func _ready() -> void: ) +func _open_chat_from_pause() -> void: + _chat_ui.open_chat() + + func setup( player: PlayerType, inventory: FishInventoryType, @@ -104,9 +113,22 @@ func setup( network_session: NetworkSessionType, network_sale_service: NetworkSaleService, network_shop_service: NetworkShopService, + network_chat_service: NetworkChatService, + network_profile: NetworkProfilePreferences, + spawn_service: PlayerSpawnService, ) -> void: _fishing_spot = fishing_spot _item_effects = item_effects + _chat_ui.setup( + network_chat_service, network_session, spawn_service, player, + fishing_spot + ) + _title_settings_panel.setup_network_profile( + network_profile, network_session + ) + _pause_settings_panel.setup_network_profile( + network_profile, network_session + ) fishing_spot.status_changed.connect(_on_fishing_status_changed) fishing_spot.catch_display_changed.connect(_on_catch_display_changed) fishing_spot.showcase_changed.connect(_on_showcase_changed) @@ -202,6 +224,7 @@ func get_pause_menu() -> PauseMenuType: func set_gameplay_ui_enabled(enabled: bool) -> void: _gameplay_ui_enabled = enabled + _refresh_chat_availability() if not enabled: close_player_menu_for_session_end() _fishing_shop.close_for_session_end() @@ -217,6 +240,7 @@ func set_gameplay_ui_enabled(enabled: bool) -> void: func set_system_menu_open(is_open: bool) -> void: _system_menu_open = is_open + _refresh_chat_availability() _hotbar_ui.visible = ( _gameplay_ui_enabled and not is_open and not _shop_open ) @@ -444,6 +468,7 @@ func _on_showcase_changed( func _on_player_menu_visibility_changed(is_open: bool) -> void: _player_menu_open = is_open + _refresh_chat_availability() _hotbar_ui.set_gameplay_input_enabled( _gameplay_ui_enabled and not is_open ) @@ -456,6 +481,7 @@ func _on_player_menu_visibility_changed(is_open: bool) -> void: func _on_shop_visibility_changed(is_open: bool) -> void: _shop_open = is_open + _refresh_chat_availability() _hotbar_ui.visible = ( _gameplay_ui_enabled and not is_open and not _system_menu_open ) @@ -474,3 +500,14 @@ func _emit_interactive_pointer_ui_changed() -> void: interactive_pointer_ui_changed.emit( _system_menu_open or _player_menu_open or _shop_open ) + + +func _refresh_chat_availability() -> void: + if _chat_ui == null: + return + _chat_ui.set_available( + _gameplay_ui_enabled + and not _system_menu_open + and not _player_menu_open + and not _shop_open + ) diff --git a/ui/game_ui.tscn b/ui/game_ui.tscn index 6c0740b..09c98e7 100644 --- a/ui/game_ui.tscn +++ b/ui/game_ui.tscn @@ -1,4 +1,4 @@ -[gd_scene load_steps=13 format=3] +[gd_scene load_steps=14 format=3] [ext_resource type="Script" path="res://ui/game_ui.gd" id="1_ui"] [ext_resource type="PackedScene" path="res://ui/player_menu.tscn" id="2_menu"] @@ -8,6 +8,7 @@ [ext_resource type="PackedScene" path="res://ui/pause_menu.tscn" id="6_pause"] [ext_resource type="PackedScene" path="res://ui/hotbar.tscn" id="7_hotbar"] [ext_resource type="PackedScene" path="res://ui/fishing_shop.tscn" id="8_shop"] +[ext_resource type="Script" path="res://ui/chat_ui.gd" id="9_chat"] [sub_resource type="StyleBoxFlat" id="StyleBox_chase_background"] bg_color = Color(0.055, 0.105, 0.125, 1) @@ -185,6 +186,17 @@ theme_override_colors/font_shadow_color = Color(0, 0, 0, 0.9) theme_override_constants/shadow_offset_x = 2 theme_override_constants/shadow_offset_y = 2 +[node name="ChatUI" type="Control" parent="UIRoot"] +unique_name_in_owner = true +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +mouse_filter = 2 +script = ExtResource("9_chat") + [node name="ScreenFade" type="ColorRect" parent="UIRoot"] unique_name_in_owner = true visible = false diff --git a/ui/pause_menu.gd b/ui/pause_menu.gd index 7091273..f743503 100644 --- a/ui/pause_menu.gd +++ b/ui/pause_menu.gd @@ -28,6 +28,7 @@ signal reset_progress_requested signal quit_requested signal menu_visibility_changed(is_open: bool) signal join_game_requested(endpoint: String) +signal chat_requested enum ConfirmationAction { NONE, @@ -87,6 +88,7 @@ func _ready() -> void: _save_button.pressed.connect(_save_now) %SettingsButton.pressed.connect(_open_settings) %JoinGameButton.pressed.connect(_open_join_game) + %ChatButton.pressed.connect(_request_chat) %ReturnToTitleButton.pressed.connect(_confirm_return_to_title) %ResetProgressButton.pressed.connect(_confirm_reset_progress) %QuitButton.pressed.connect(_request_quit) @@ -105,6 +107,13 @@ func _ready() -> void: call_deferred("_update_responsive_pause_stage") +func _request_chat() -> void: + if not visible or _action_in_progress or _root_transition_active: + return + close_menu(CloseReason.USER_RETURN, true) + chat_requested.emit() + + func setup( player: PlayerType, save_manager: SaveManagerType, diff --git a/ui/pause_menu.tscn b/ui/pause_menu.tscn index 3f51fd6..c896d4c 100644 --- a/ui/pause_menu.tscn +++ b/ui/pause_menu.tscn @@ -77,8 +77,8 @@ grow_horizontal = 2 grow_vertical = 2 script = ExtResource("4_page") page_id = &"pause" -bubble_paths = Array[NodePath]([NodePath("BubbleCluster/ResumeButton"), NodePath("BubbleCluster/SaveButton"), NodePath("BubbleCluster/JoinGameButton"), NodePath("BubbleCluster/SettingsButton"), NodePath("BubbleCluster/ReturnToTitleButton"), NodePath("BubbleCluster/ResetProgressButton"), NodePath("BubbleCluster/QuitButton")]) -focus_paths = Array[NodePath]([NodePath("BubbleCluster/ResumeButton"), NodePath("BubbleCluster/SaveButton"), NodePath("BubbleCluster/JoinGameButton"), NodePath("BubbleCluster/SettingsButton"), NodePath("BubbleCluster/ReturnToTitleButton"), NodePath("BubbleCluster/ResetProgressButton"), NodePath("BubbleCluster/QuitButton")]) +bubble_paths = Array[NodePath]([NodePath("BubbleCluster/ResumeButton"), NodePath("BubbleCluster/SaveButton"), NodePath("BubbleCluster/JoinGameButton"), NodePath("BubbleCluster/ChatButton"), NodePath("BubbleCluster/SettingsButton"), NodePath("BubbleCluster/ReturnToTitleButton"), NodePath("BubbleCluster/ResetProgressButton"), NodePath("BubbleCluster/QuitButton")]) +focus_paths = Array[NodePath]([NodePath("BubbleCluster/ResumeButton"), NodePath("BubbleCluster/SaveButton"), NodePath("BubbleCluster/JoinGameButton"), NodePath("BubbleCluster/ChatButton"), NodePath("BubbleCluster/SettingsButton"), NodePath("BubbleCluster/ReturnToTitleButton"), NodePath("BubbleCluster/ResetProgressButton"), NodePath("BubbleCluster/QuitButton")]) initial_focus_path = NodePath("BubbleCluster/ResumeButton") back_focus_path = NodePath("BubbleCluster/ResumeButton") maximum_layout_size = Vector2(720, 520) @@ -135,6 +135,18 @@ minimum_font_size = 16 maximum_font_size = 23 motion_phase = 2.05 +[node name="ChatButton" parent="ResponsivePauseStage/PausePresentationScaleRoot/RootPage/BubbleCluster" instance=ExtResource("5_bubble")] +unique_name_in_owner = true +custom_minimum_size = Vector2(0, 0) +text = "chat" +neutral_size = Vector2(112, 106) +desktop_anchor = Vector2(620, 255) +compact_anchor = Vector2(595, 270) +compact_minimum_size = Vector2(92, 88) +minimum_font_size = 15 +maximum_font_size = 20 +motion_phase = 2.3 + [node name="JoinGameButton" parent="ResponsivePauseStage/PausePresentationScaleRoot/RootPage/BubbleCluster" instance=ExtResource("5_bubble")] unique_name_in_owner = true custom_minimum_size = Vector2(0, 0) diff --git a/ui/settings_panel.gd b/ui/settings_panel.gd index 1b3d0d7..37e34fa 100644 --- a/ui/settings_panel.gd +++ b/ui/settings_panel.gd @@ -38,6 +38,7 @@ enum PresentationMode { @onready var _controls_page: SettingsBubblePage = %ControlsPage @onready var _accessibility_page: SettingsBubblePage = %AccessibilityPage @onready var _feedback: Label = %SettingsFeedback +@onready var _display_name_edit: LineEdit = %DisplayNameEdit @onready var _world_value: BubbleButton = %WorldValue @onready var _ui_value: BubbleButton = %UIValue @@ -75,6 +76,8 @@ var _auto_click_interval_value: float = 0.20 var _mouse_sensitivity: float = 0.005 var _controller_sensitivity: float = 2.5 var _invert_camera_y: bool = false +var _network_profile: NetworkProfilePreferences +var _network_session: NetworkSession func _ready() -> void: @@ -98,6 +101,10 @@ func _ready() -> void: %DisplayBackButton.gui_input.connect(_on_back_bubble_gui_input) %ControlsBackButton.gui_input.connect(_on_back_bubble_gui_input) %AccessibilityBackButton.gui_input.connect(_on_back_bubble_gui_input) + _display_name_edit.text_submitted.connect( + func(_value: String) -> void: _apply_display_name() + ) + _display_name_edit.focus_exited.connect(_apply_display_name) for index: int in _world_options.size(): _world_options[index].pressed.connect( _set_world_pixelation.bind(index + 1) @@ -143,6 +150,33 @@ func _ready() -> void: call_deferred("_refresh_panel_size") +func setup_network_profile( + profile: NetworkProfilePreferences, + session: NetworkSession, +) -> void: + _network_profile = profile + _network_session = session + if _network_profile != null: + _display_name_edit.text = _network_profile.display_name + + +func _apply_display_name() -> void: + if _network_profile == null: + return + var value := _display_name_edit.text.strip_edges() + var accepted := ( + _network_session.update_local_display_name(value) + if _network_session != null + else _network_profile.set_display_name(value) + ) + if accepted: + _display_name_edit.text = _network_profile.display_name + _feedback.text = "display name saved." + else: + _display_name_edit.text = _network_profile.display_name + _feedback.text = "display name must be 1–24 plain-text characters." + + func open_panel( settings_manager: SettingsManagerType, presentation_mode: PresentationMode = PresentationMode.GAMEPLAY_MODAL, @@ -158,6 +192,8 @@ func open_panel( ) _settings_manager = settings_manager _load_controls() + if _network_profile != null: + _display_name_edit.text = _network_profile.display_name _feedback.text = "" show() _page_stack.clear() diff --git a/ui/settings_panel.tscn b/ui/settings_panel.tscn index a6bc71c..1321275 100644 --- a/ui/settings_panel.tscn +++ b/ui/settings_panel.tscn @@ -144,6 +144,31 @@ minimum_font_size = 14 maximum_font_size = 17 motion_phase = 4.8 +[node name="DisplayNamePanel" type="PanelContainer" parent="RootPage"] +layout_mode = 0 +offset_left = 318.0 +offset_top = 430.0 +offset_right = 618.0 +offset_bottom = 510.0 + +[node name="VBox" type="VBoxContainer" parent="RootPage/DisplayNamePanel"] +layout_mode = 2 + +[node name="Label" type="Label" parent="RootPage/DisplayNamePanel/VBox"] +layout_mode = 2 +text = "display name" + +[node name="DisplayNameEdit" type="LineEdit" parent="RootPage/DisplayNamePanel/VBox"] +unique_name_in_owner = true +layout_mode = 2 +max_length = 24 +placeholder_text = "Player" + +[node name="Help" type="Label" parent="RootPage/DisplayNamePanel/VBox"] +layout_mode = 2 +text = "Shown to other players in multiplayer." +theme_override_font_sizes/font_size = 12 + [node name="DisplayPage" type="Control" parent="."] unique_name_in_owner = true visible = false