diff --git a/fishing/fishing_spot.gd b/fishing/fishing_spot.gd index e010a4a..18be08a 100644 --- a/fishing/fishing_spot.gd +++ b/fishing/fishing_spot.gd @@ -26,6 +26,7 @@ const PlayerType = preload("res://player/player.gd") const FishableWaterRegionType = preload( "res://world/fishable_water_region.gd" ) +const NetworkSessionType = preload("res://network/network_session.gd") signal status_changed(status: String) signal catch_display_changed( @@ -104,6 +105,7 @@ var _item_catalog: ItemCatalogType var _fishing_upgrades: PlayerFishingUpgradesType var _item_effects: PlayerItemEffectsType var _cooler_capacity: PlayerCoolerCapacityType +var _network_session: NetworkSessionType var _active_player: PlayerType var _state_time_remaining: float = 0.0 var _cast_charge: float = 0.0 @@ -147,6 +149,7 @@ func setup( fishing_upgrades: PlayerFishingUpgradesType, item_effects: PlayerItemEffectsType, cooler_capacity: PlayerCoolerCapacityType, + network_session: NetworkSessionType = null, ) -> void: _local_player = local_player _local_inventory = local_inventory @@ -157,6 +160,7 @@ func setup( _fishing_upgrades = fishing_upgrades _item_effects = item_effects _cooler_capacity = cooler_capacity + _network_session = network_session if not _local_inventory.catches_changed.is_connected( _on_cooler_availability_changed ): @@ -195,6 +199,8 @@ func can_open_system_menu() -> bool: func can_open_fishing_shop() -> bool: return ( + _can_use_shared_gameplay() + and _gameplay_input_enabled and not _external_input_blocked and _local_menu_input_owners.is_empty() @@ -204,6 +210,8 @@ func can_open_fishing_shop() -> bool: func is_ready_for_shop_transaction() -> bool: return ( + _can_use_shared_gameplay() + and _gameplay_input_enabled and not _external_input_blocked and state == FishingState.READY @@ -367,6 +375,13 @@ func _unhandled_input(event: InputEvent) -> void: return if not event.is_action("fish_primary"): return + if not _can_use_shared_gameplay(): + if event.is_pressed(): + status_changed.emit( + "Fishing in joined games is coming in the next multiplayer phase." + ) + get_viewport().set_input_as_handled() + return if event.is_pressed(): match state: @@ -450,6 +465,10 @@ func _begin_aiming(player: PlayerType) -> void: ) +func _can_use_shared_gameplay() -> bool: + return _network_session == null or _network_session.can_use_host_gameplay() + + func has_active_fishing_rod() -> bool: if ( _local_bag == null diff --git a/main/main.gd b/main/main.gd index 51e645b..d2f5542 100644 --- a/main/main.gd +++ b/main/main.gd @@ -33,6 +33,16 @@ const PixelationResetOverlayType = preload( const WorldPixelationPostprocessType = preload( "res://main/world_pixelation_postprocess.gd" ) +const NetworkSessionType = preload("res://network/network_session.gd") +const NetworkProfilePreferencesType = preload( + "res://network/network_profile_preferences.gd" +) +const SavedServerStoreType = preload( + "res://network/saved_server_store.gd" +) +const PlayerSpawnServiceType = preload( + "res://network/player_spawn_service.gd" +) const TITLE_MUSIC_SILENCE_DB: float = -80.0 @@ -59,6 +69,15 @@ const TITLE_MUSIC_SILENCE_DB: float = -80.0 @onready var _world_pixelation: WorldPixelationPostprocessType = ( %WorldPixelationPostprocess ) +@onready var _network_session: NetworkSessionType = %NetworkSession +@onready var _network_profile: NetworkProfilePreferencesType = ( + %NetworkProfilePreferences +) +@onready var _saved_servers: SavedServerStoreType = %SavedServerStore +@onready var _player_spawn_service: PlayerSpawnServiceType = ( + %PlayerSpawnService +) +@onready var _players_root: Node3D = $Players var _gameplay_started: bool = false var _shop_interaction: FishingShopInteractionType @@ -66,11 +85,34 @@ var _title_music_tween: Tween var _title_music_transition_generation: int = 0 var _title_music_requested: bool = false var _quit_in_progress: bool = false +var _join_requested_from_title: bool = false +var _join_requested_from_pause: bool = false +var _pending_join_endpoint: String = "" func _ready() -> void: _player.global_transform = _test_world.get_player_spawn_transform() _player.velocity = Vector3.ZERO + _player_spawn_service.setup( + _players_root, + _player, + _test_world.get_player_spawn_transform() + ) + _network_session.setup( + _network_profile, + _saved_servers, + _player_spawn_service + ) + _network_session.join_authenticated.connect( + _on_network_join_authenticated + ) + _network_session.connection_error.connect( + _on_network_connection_error + ) + _network_session.server_lost.connect(_on_network_server_lost) + _network_session.remote_recovery_requested.connect( + _on_remote_recovery_requested + ) _player.fish_sale_service.setup( _player.inventory, _player.wallet @@ -103,7 +145,8 @@ func _ready() -> void: item_catalog, _player.fishing_upgrades, _player.item_effects, - _player.cooler_capacity + _player.cooler_capacity, + _network_session ) _game_ui.setup( _player, @@ -121,7 +164,8 @@ func _ready() -> void: _player.fishing_upgrades, _shop_interaction, _player.item_effects, - _player.cooler_capacity + _player.cooler_capacity, + _network_session ) _water_recovery.setup( _player, @@ -155,16 +199,26 @@ func _ready() -> void: _apply_runtime_settings(_settings_manager.current_settings) _set_gameplay_active(false) var title_screen: TitleScreenType = _game_ui.get_title_screen() - title_screen.gameplay_requested.connect(_on_gameplay_requested) + title_screen.new_game_requested.connect(_on_new_game_requested) + title_screen.continue_game_requested.connect(_on_continue_game_requested) title_screen.quit_requested.connect(_on_quit_requested) - title_screen.setup(_save_manager, _settings_manager) + title_screen.setup( + _save_manager, + _settings_manager, + _network_session, + _saved_servers + ) var pause_menu: PauseMenuType = _game_ui.get_pause_menu() pause_menu.setup( _player, _save_manager, _settings_manager, - _fishing_spot + _fishing_spot, + _network_session, + _saved_servers ) + title_screen.join_game_requested.connect(_on_title_join_game_requested) + pause_menu.join_game_requested.connect(_on_pause_join_game_requested) pause_menu.return_to_title_requested.connect( _on_return_to_title_requested ) @@ -184,6 +238,9 @@ func _ready() -> void: _water_recovery.recovery_starting.connect( _on_water_recovery_starting ) + _water_recovery.local_respawn_completed.connect( + _on_local_respawn_completed + ) _on_active_hotbar_item_changed( _player.hotbar.get_selected_slot(), _player.hotbar.get_selected_item_id() @@ -302,9 +359,54 @@ func _set_gameplay_active(active: bool) -> void: _save_manager.set_autosave_enabled(active) -func _on_gameplay_requested() -> void: +func _on_new_game_requested() -> void: if _gameplay_started or _quit_in_progress: return + if not _prepare_private_host(): + return + if ( + not _save_manager.delete_progression_save() + or not _save_manager.initialize_new_game() + ): + _network_session.disconnect_session("New Game setup failed.") + _game_ui.get_title_screen().report_network_error( + "Could not initialize local progression. Existing data was preserved where possible." + ) + return + _enter_gameplay() + + +func _on_continue_game_requested() -> void: + if _gameplay_started or _quit_in_progress: + return + if not _prepare_private_host(): + return + if not _save_manager.load_player_data(): + _network_session.disconnect_session("Continue failed.") + _game_ui.get_title_screen().report_network_error( + "Failed to load save. The original was preserved." + ) + return + _enter_gameplay() + + +func _prepare_private_host() -> bool: + _join_requested_from_title = false + _join_requested_from_pause = false + if _network_session.state in [ + NetworkSessionType.State.CONNECTION_FAILED, + NetworkSessionType.State.SERVER_LOST, + ]: + _network_session.reset_failure() + if not _network_session.start_private_host(): + _game_ui.get_title_screen().report_network_error( + "Could not start the private multiplayer session." + ) + return false + return true + + +func _enter_gameplay() -> void: _fade_out_title_music() _game_ui.get_title_screen().hide() _set_gameplay_active(true) @@ -315,11 +417,133 @@ func _on_return_to_title_requested() -> void: return var pause_menu: PauseMenuType = _game_ui.get_pause_menu() pause_menu.close_for_title_transition() + _network_session.disconnect_session("Returned to title.") _set_gameplay_active(false) _game_ui.get_title_screen().reopen() _show_title_music(true) +func _on_title_join_game_requested(endpoint: String) -> void: + if _quit_in_progress or _gameplay_started: + return + if _network_session.state != NetworkSessionType.State.INACTIVE: + _network_session.disconnect_session("Preparing direct connection.") + _join_requested_from_title = true + _join_requested_from_pause = false + _pending_join_endpoint = endpoint + if not _network_session.join_direct(endpoint): + _join_requested_from_title = false + + +func _on_pause_join_game_requested(endpoint: String) -> void: + if _quit_in_progress or not _gameplay_started: + return + if not _save_manager.save_if_dirty(): + _game_ui.get_pause_menu().report_network_error( + "Could not save progression before leaving this session." + ) + return + _join_requested_from_pause = true + _join_requested_from_title = false + _pending_join_endpoint = endpoint + _game_ui.get_pause_menu().close_for_title_transition() + _network_session.disconnect_session("Connecting to another game.") + if not _network_session.join_direct(endpoint): + _handle_failed_session_switch( + "Could not begin the direct connection." + ) + + +func _on_network_join_authenticated() -> void: + if _join_requested_from_title: + var inspection = _save_manager.inspect_save() + var progression_ready: bool = ( + _save_manager.load_player_data() + if inspection.can_continue() + else _save_manager.initialize_new_game() + ) + if not progression_ready: + _network_session.disconnect_session( + "Local progression could not be prepared." + ) + _game_ui.get_title_screen().report_network_error( + "Your local progression could not be prepared." + ) + _join_requested_from_title = false + return + _fade_out_title_music() + _game_ui.get_title_screen().hide() + _set_gameplay_active(true) + elif _join_requested_from_pause: + _set_gameplay_active(true) + _join_requested_from_title = false + _join_requested_from_pause = false + _pending_join_endpoint = "" + + +func _on_network_connection_error(message: String) -> void: + if _join_requested_from_title: + _game_ui.get_title_screen().report_network_error(message) + elif _join_requested_from_pause: + _handle_failed_session_switch(message) + + +func _handle_failed_session_switch(message: String) -> void: + _join_requested_from_pause = false + _set_gameplay_active(false) + var title_screen: TitleScreenType = _game_ui.get_title_screen() + title_screen.reopen() + title_screen.open_join_game_page(_pending_join_endpoint) + title_screen.report_network_error(message) + _show_title_music(true) + + +func _on_network_server_lost() -> void: + if not _gameplay_started: + return + _set_gameplay_active(false) + var title_screen: TitleScreenType = _game_ui.get_title_screen() + title_screen.reopen() + title_screen.open_join_game_page(_pending_join_endpoint) + title_screen.report_network_error("The server connection was lost.") + _show_title_music(true) + + +func _on_local_respawn_completed(entry_position: Vector3) -> void: + if _network_session.is_host(): + _network_session.publish_authoritative_teleport( + _player.get_network_peer_id() + ) + elif _network_session.is_joined_client(): + _network_session.request_safe_respawn(entry_position) + + +func _on_remote_recovery_requested( + peer_id: int, + entry_position: Vector3, +) -> void: + if not _network_session.is_host(): + return + var avatar: PlayerType = _player_spawn_service.get_avatar(peer_id) + if avatar == null: + return + var target_position: Vector3 = _test_world.get_player_spawn_transform().origin + var nearest_distance: float = INF + for point: SafeRespawnPoint in _test_world.get_safe_respawn_points(): + if point == null or not point.enabled: + continue + var distance: float = point.get_horizontal_distance_squared( + entry_position + ) + if distance < nearest_distance: + nearest_distance = distance + target_position = point.global_position + target_position.y += _water_recovery.respawn_height_offset + avatar.global_position = target_position + avatar.velocity = Vector3.ZERO + _network_session.publish_authoritative_teleport(peer_id) + + func _on_reset_progress_requested() -> void: if _quit_in_progress: return @@ -372,6 +596,7 @@ func _on_quit_requested() -> void: _settings_manager.save_if_dirty() if _gameplay_started: _save_manager.save_if_dirty() + _network_session.disconnect_session("Application closing.") _title_music_requested = false _replace_title_music_transition() _finish_quit() diff --git a/main/main.tscn b/main/main.tscn index 192178b..bcb80fb 100644 --- a/main/main.tscn +++ b/main/main.tscn @@ -1,4 +1,4 @@ -[gd_scene load_steps=17 format=3] +[gd_scene load_steps=21 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"] @@ -16,6 +16,10 @@ [ext_resource type="Script" path="res://ui/ui_pixelation_presenter.gd" id="14_pixelation"] [ext_resource type="PackedScene" path="res://ui/pixelation_reset_overlay.tscn" id="15_reset"] [ext_resource type="PackedScene" path="res://main/world_pixelation_postprocess.tscn" id="16_world_pixelation"] +[ext_resource type="Script" path="res://network/network_session.gd" id="17_network_session"] +[ext_resource type="Script" path="res://network/network_profile_preferences.gd" id="18_network_profile"] +[ext_resource type="Script" path="res://network/saved_server_store.gd" id="19_saved_servers"] +[ext_resource type="Script" path="res://network/player_spawn_service.gd" id="20_spawn_service"] [node name="Main" type="Node3D"] script = ExtResource("3_main") @@ -24,6 +28,22 @@ pelican_buyer_profile = ExtResource("7_pelicans") main_shop_buyer_profile = ExtResource("12_main_shop") item_catalog = ExtResource("11_items") +[node name="NetworkSession" type="Node" parent="."] +unique_name_in_owner = true +script = ExtResource("17_network_session") + +[node name="NetworkProfilePreferences" type="Node" parent="."] +unique_name_in_owner = true +script = ExtResource("18_network_profile") + +[node name="SavedServerStore" type="Node" parent="."] +unique_name_in_owner = true +script = ExtResource("19_saved_servers") + +[node name="PlayerSpawnService" type="Node" parent="."] +unique_name_in_owner = true +script = ExtResource("20_spawn_service") + [node name="TestWorld" parent="." instance=ExtResource("1_world")] [node name="Players" type="Node3D" parent="."] diff --git a/network/connection_endpoint.gd b/network/connection_endpoint.gd new file mode 100644 index 0000000..a11eb36 --- /dev/null +++ b/network/connection_endpoint.gd @@ -0,0 +1,12 @@ +class_name ConnectionEndpoint +extends RefCounted + +var host: String = "" +var port: int = 7777 +var normalized_display: String = "" +var original_display: String = "" +var error_message: String = "" + + +func is_valid() -> bool: + return error_message.is_empty() and not host.is_empty() and port in range(1, 65536) diff --git a/network/connection_endpoint.gd.uid b/network/connection_endpoint.gd.uid new file mode 100644 index 0000000..c0103c2 --- /dev/null +++ b/network/connection_endpoint.gd.uid @@ -0,0 +1 @@ +uid://c0ssdkdd35qgf diff --git a/network/connection_route.gd b/network/connection_route.gd new file mode 100644 index 0000000..af9e625 --- /dev/null +++ b/network/connection_route.gd @@ -0,0 +1,24 @@ +class_name ConnectionRoute +extends RefCounted + +enum Kind { + DIRECT, +} + +var kind: Kind = Kind.DIRECT +var direct_endpoint: ConnectionEndpoint +var display_description: String = "" + + +static func direct(endpoint: ConnectionEndpoint) -> ConnectionRoute: + var route := ConnectionRoute.new() + route.kind = Kind.DIRECT + route.direct_endpoint = endpoint + route.display_description = ( + endpoint.normalized_display if endpoint != null else "" + ) + return route + + +func is_valid() -> bool: + return kind == Kind.DIRECT and direct_endpoint != null and direct_endpoint.is_valid() diff --git a/network/connection_route.gd.uid b/network/connection_route.gd.uid new file mode 100644 index 0000000..2f2ca91 --- /dev/null +++ b/network/connection_route.gd.uid @@ -0,0 +1 @@ +uid://dquhcebc57bg8 diff --git a/network/direct_enet_transport.gd b/network/direct_enet_transport.gd new file mode 100644 index 0000000..3d6201d --- /dev/null +++ b/network/direct_enet_transport.gd @@ -0,0 +1,38 @@ +class_name DirectEnetTransport +extends NetworkTransport + + +func start_host( + port: int, + max_clients: int, + bind_address: String = "*", +) -> Error: + disconnect_transport() + 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, 3) + if error != OK: + transport_error.emit("Unable to host UDP port %d." % port) + return error + _peer = enet_peer + _route_description = "UDP *:%d" % port + return OK + + +func connect_to_route(route: ConnectionRoute) -> Error: + disconnect_transport() + if route == null or not route.is_valid(): + transport_error.emit("The direct connection route is invalid.") + 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, 3) + if error != OK: + transport_error.emit( + "Unable to connect to %s." % endpoint.normalized_display + ) + return error + _peer = enet_peer + _route_description = endpoint.normalized_display + return OK diff --git a/network/direct_enet_transport.gd.uid b/network/direct_enet_transport.gd.uid new file mode 100644 index 0000000..09f5d1b --- /dev/null +++ b/network/direct_enet_transport.gd.uid @@ -0,0 +1 @@ +uid://d4jn8ubxqkqa4 diff --git a/network/endpoint_parser.gd b/network/endpoint_parser.gd new file mode 100644 index 0000000..fdaebbe --- /dev/null +++ b/network/endpoint_parser.gd @@ -0,0 +1,69 @@ +class_name EndpointParser +extends RefCounted + +const DEFAULT_PORT: int = 7777 + + +static func parse(value: String, default_port: int = DEFAULT_PORT) -> ConnectionEndpoint: + var result := ConnectionEndpoint.new() + result.original_display = value.strip_edges() + result.port = default_port + if default_port < 1 or default_port > 65535: + result.error_message = "The configured default port is invalid." + return result + var input: String = result.original_display + if input.is_empty(): + result.error_message = "Enter a hostname or IP address." + return result + + var host: String = "" + var port_text: String = "" + if input.begins_with("["): + var closing: int = input.find("]") + if closing < 0: + result.error_message = "The IPv6 address is missing a closing bracket." + return result + host = input.substr(1, closing - 1).strip_edges() + var suffix: String = input.substr(closing + 1).strip_edges() + if not suffix.is_empty(): + if not suffix.begins_with(":") or suffix.length() == 1: + result.error_message = "Use [IPv6]:port for an IPv6 port." + return result + port_text = suffix.substr(1).strip_edges() + elif input.contains("[") or input.contains("]"): + result.error_message = "IPv6 brackets are malformed." + return result + else: + var colon_count: int = input.count(":") + if colon_count == 1: + var separator: int = input.rfind(":") + host = input.substr(0, separator).strip_edges() + port_text = input.substr(separator + 1).strip_edges() + else: + # Zero colons is a hostname/IPv4 host. More than one is a raw + # IPv6 host; an explicit port for it must use brackets. + host = input.strip_edges() + + if host.is_empty(): + result.error_message = "The hostname or IP address is empty." + return result + if host.contains(" ") or host.contains("\t") or host.contains("\n"): + result.error_message = "The hostname or IP address contains whitespace." + return result + if not port_text.is_empty(): + if not port_text.is_valid_int(): + result.error_message = "The port must be a number from 1 to 65535." + return result + result.port = int(port_text) + if result.port < 1 or result.port > 65535: + result.error_message = "The port must be from 1 to 65535." + return result + + result.host = host.to_lower() + var is_ipv6: bool = result.host.contains(":") + result.normalized_display = ( + "[%s]:%d" % [result.host, result.port] + if is_ipv6 + else "%s:%d" % [result.host, result.port] + ) + return result diff --git a/network/endpoint_parser.gd.uid b/network/endpoint_parser.gd.uid new file mode 100644 index 0000000..6b14810 --- /dev/null +++ b/network/endpoint_parser.gd.uid @@ -0,0 +1 @@ +uid://2drengcleq7e diff --git a/network/network_profile_preferences.gd b/network/network_profile_preferences.gd new file mode 100644 index 0000000..56acbe8 --- /dev/null +++ b/network/network_profile_preferences.gd @@ -0,0 +1,120 @@ +class_name NetworkProfilePreferences +extends Node + +const FORMAT_VERSION: int = 1 +const PROFILE_PATH: String = "user://network_profile.json" +const TEMP_PATH: String = "user://network_profile.json.tmp" +const BACKUP_PATH: String = "user://network_profile.json.backup" + +var profile_id: String = "" +var display_name: String = "Player" +var created_at_unix: int = 0 + + +func load_or_create() -> bool: + _recover_interrupted_write() + if FileAccess.file_exists(PROFILE_PATH): + if _load_existing(): + return true + var corrupt_path: String = ( + "user://network_profile.corrupt.%d.json" + % int(Time.get_unix_time_from_system()) + ) + if not _rename(PROFILE_PATH, corrupt_path): + push_warning("Invalid network profile was preserved and not overwritten.") + return false + profile_id = Crypto.new().generate_random_bytes(16).hex_encode() + if profile_id.is_empty(): + profile_id = "%d-%d" % [ + int(Time.get_unix_time_from_system()), + Time.get_ticks_usec(), + ] + display_name = "Player" + created_at_unix = int(Time.get_unix_time_from_system()) + return _save_atomic() + + +func _load_existing() -> bool: + var file := FileAccess.open(PROFILE_PATH, FileAccess.READ) + if file == null: + return false + var json := JSON.new() + var error: Error = json.parse(file.get_as_text()) + file.close() + if error != OK or typeof(json.data) != TYPE_DICTIONARY: + return false + var data: Dictionary = json.data + if ( + data.get("format_version") != FORMAT_VERSION + or typeof(data.get("profile_id")) != TYPE_STRING + or typeof(data.get("display_name")) != TYPE_STRING + or typeof(data.get("created_at_unix")) not in [TYPE_INT, TYPE_FLOAT] + ): + return false + var loaded_id: String = data["profile_id"] + var loaded_name: String = data["display_name"] + if ( + loaded_id.is_empty() + or loaded_id.length() > NetworkProtocol.MAX_PROFILE_ID_LENGTH + or loaded_name.is_empty() + or loaded_name.length() > NetworkProtocol.MAX_DISPLAY_NAME_LENGTH + ): + return false + profile_id = loaded_id + display_name = loaded_name + created_at_unix = int(data["created_at_unix"]) + return true + + +func _save_atomic() -> bool: + var data: Dictionary = { + "format_version": FORMAT_VERSION, + "profile_id": profile_id, + "display_name": display_name, + "created_at_unix": created_at_unix, + } + var file := FileAccess.open(TEMP_PATH, FileAccess.WRITE) + if file == null: + return false + file.store_string(JSON.stringify(data, "\t")) + file.flush() + var error: Error = file.get_error() + file.close() + if error != OK: + _remove_if_present(TEMP_PATH) + return false + _remove_if_present(BACKUP_PATH) + var had_primary: bool = FileAccess.file_exists(PROFILE_PATH) + if had_primary and not _rename(PROFILE_PATH, BACKUP_PATH): + _remove_if_present(TEMP_PATH) + return false + if not _rename(TEMP_PATH, PROFILE_PATH): + if had_primary: + _rename(BACKUP_PATH, PROFILE_PATH) + return false + _remove_if_present(BACKUP_PATH) + return true + + +func _recover_interrupted_write() -> void: + if FileAccess.file_exists(PROFILE_PATH): + _remove_if_present(TEMP_PATH) + _remove_if_present(BACKUP_PATH) + return + if FileAccess.file_exists(BACKUP_PATH): + _rename(BACKUP_PATH, PROFILE_PATH) + _remove_if_present(TEMP_PATH) + + +func _rename(from_path: String, to_path: String) -> bool: + return DirAccess.rename_absolute( + ProjectSettings.globalize_path(from_path), + ProjectSettings.globalize_path(to_path) + ) == OK + + +func _remove_if_present(path: String) -> bool: + return ( + not FileAccess.file_exists(path) + or DirAccess.remove_absolute(ProjectSettings.globalize_path(path)) == OK + ) diff --git a/network/network_profile_preferences.gd.uid b/network/network_profile_preferences.gd.uid new file mode 100644 index 0000000..8e27e50 --- /dev/null +++ b/network/network_profile_preferences.gd.uid @@ -0,0 +1 @@ +uid://db8ua5psoql0w diff --git a/network/network_protocol.gd b/network/network_protocol.gd new file mode 100644 index 0000000..7d093f6 --- /dev/null +++ b/network/network_protocol.gd @@ -0,0 +1,121 @@ +class_name NetworkProtocol +extends RefCounted + +const PROTOCOL_VERSION: int = 1 +const GAME_BUILD: String = "prealpha" +const MAX_DISPLAY_NAME_LENGTH: int = 48 +const MAX_PROFILE_ID_LENGTH: int = 96 +const MAX_NONCE_LENGTH: int = 96 + +enum RejectionCode { + NONE, + MALFORMED_HANDSHAKE, + PROTOCOL_MISMATCH, + SERVER_FULL, + DUPLICATE_PROFILE, + AUTHENTICATION_TIMEOUT, + SERVER_SHUTTING_DOWN, + UNSUPPORTED_CLIENT, +} + + +static func make_client_hello( + profile_id: String, + display_name: String, + client_nonce: String, +) -> Dictionary: + return { + "protocol_version": PROTOCOL_VERSION, + "game_build": GAME_BUILD, + "local_profile_id": profile_id, + "display_name": display_name, + "client_nonce": client_nonce, + "capability_flags": PackedStringArray(), + "cosmetic_snapshot": {}, + } + + +static func validate_client_hello(data: Variant) -> String: + if typeof(data) != TYPE_DICTIONARY: + return "Handshake payload is not a dictionary." + var payload: Dictionary = data + for key: String in [ + "protocol_version", + "game_build", + "local_profile_id", + "display_name", + "client_nonce", + "capability_flags", + "cosmetic_snapshot", + ]: + if not payload.has(key): + return "Handshake is missing %s." % key + if typeof(payload["protocol_version"]) != TYPE_INT: + return "Protocol version is invalid." + if typeof(payload["game_build"]) != TYPE_STRING: + return "Game build is invalid." + if typeof(payload["local_profile_id"]) != TYPE_STRING: + return "Profile ID is invalid." + if typeof(payload["display_name"]) != TYPE_STRING: + return "Display name is invalid." + if typeof(payload["client_nonce"]) != TYPE_STRING: + return "Client nonce is invalid." + if typeof(payload["capability_flags"]) not in [ + TYPE_PACKED_STRING_ARRAY, + TYPE_ARRAY, + ]: + return "Capabilities are invalid." + if typeof(payload["cosmetic_snapshot"]) != TYPE_DICTIONARY: + return "Cosmetic snapshot is invalid." + var profile_id: String = payload["local_profile_id"] + var display_name: String = payload["display_name"] + var nonce: String = payload["client_nonce"] + if ( + profile_id.is_empty() + or profile_id.length() > MAX_PROFILE_ID_LENGTH + or display_name.is_empty() + or display_name.length() > MAX_DISPLAY_NAME_LENGTH + or nonce.is_empty() + or nonce.length() > MAX_NONCE_LENGTH + ): + return "Handshake strings are outside allowed limits." + return "" + + +static func make_server_hello( + accepted: bool, + rejection_code: RejectionCode, + session_id: String, + assigned_peer_id: int, + player_count: int, + max_players: int, +) -> Dictionary: + return { + "accepted": accepted, + "rejection_code": int(rejection_code), + "protocol_version": PROTOCOL_VERSION, + "session_id": session_id, + "assigned_peer_id": assigned_peer_id, + "server_display_name": "NETFISHING", + "player_count": player_count, + "max_players": max_players, + "capability_flags": PackedStringArray(["movement_v1"]), + } + + +static func rejection_text(code: int) -> String: + match code: + RejectionCode.PROTOCOL_MISMATCH: + return "The server uses a different network protocol." + RejectionCode.SERVER_FULL: + return "The server is full." + RejectionCode.DUPLICATE_PROFILE: + return "This local profile is already connected." + RejectionCode.AUTHENTICATION_TIMEOUT: + return "The server did not finish authentication." + RejectionCode.SERVER_SHUTTING_DOWN: + return "The server is shutting down." + RejectionCode.UNSUPPORTED_CLIENT: + return "This game build is not supported by the server." + _: + return "The server rejected the connection." diff --git a/network/network_protocol.gd.uid b/network/network_protocol.gd.uid new file mode 100644 index 0000000..aecfaef --- /dev/null +++ b/network/network_protocol.gd.uid @@ -0,0 +1 @@ +uid://dghw7u3pdrpsf diff --git a/network/network_session.gd b/network/network_session.gd new file mode 100644 index 0000000..0bb8255 --- /dev/null +++ b/network/network_session.gd @@ -0,0 +1,816 @@ +class_name NetworkSession +extends Node + +const DEFAULT_PORT: int = 7777 +const DEFAULT_SESSION_MAX_PLAYERS: int = 8 +const DEFAULT_TRANSPORT_MAX_CLIENTS: int = 31 +const CONNECTION_TIMEOUT_SECONDS: float = 10.0 +const AUTHENTICATION_TIMEOUT_SECONDS: float = 8.0 +const INPUT_INTERVAL: float = 1.0 / 25.0 +const SNAPSHOT_INTERVAL: float = 1.0 / 15.0 + +signal state_changed(state: State) +signal status_message_changed(message: String) +signal connection_error(message: String) +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 join_authenticated +signal server_lost +signal remote_recovery_requested(peer_id: int, entry_position: Vector3) + +enum State { + INACTIVE, + STARTING_PRIVATE_HOST, + PRIVATE_HOST, + OPEN_HOST, + CONNECTING, + AUTHENTICATING, + JOINED_CLIENT, + DISCONNECTING, + CONNECTION_FAILED, + SERVER_LOST, +} + +@export_range(2, 128, 1) var session_max_players: int = ( + DEFAULT_SESSION_MAX_PLAYERS +) +@export_range(1, 256, 1) var transport_max_clients: int = ( + DEFAULT_TRANSPORT_MAX_CLIENTS +) + +var state: State = State.INACTIVE +var _transport: DirectEnetTransport +var _profile: NetworkProfilePreferences +var _saved_servers: SavedServerStore +var _spawn_service: PlayerSpawnService +var _registry := PeerRegistry.new() +var _pending_authentication: Dictionary[int, float] = {} +var _session_id: String = "" +var _operation_generation: int = 0 +var _connection_deadline: float = 0.0 +var _client_nonce: String = "" +var _current_route: ConnectionRoute +var _input_sequence: int = 0 +var _input_accumulator: float = 0.0 +var _snapshot_accumulator: float = 0.0 +var _last_server_max_players: int = DEFAULT_SESSION_MAX_PLAYERS +var _profile_ready: bool = false + + +func _ready() -> void: + transport_max_clients = maxi( + transport_max_clients, + session_max_players - 1 + ) + _connect_multiplayer_signals() + + +func setup( + profile: NetworkProfilePreferences, + saved_servers: SavedServerStore, + spawn_service: PlayerSpawnService, +) -> void: + _profile = profile + _saved_servers = saved_servers + _spawn_service = spawn_service + if _profile != null: + _profile_ready = _profile.load_or_create() + + +func start_private_host(port: int = DEFAULT_PORT) -> bool: + if ( + state != State.INACTIVE + or not _profile_ready + or _profile == null + or _spawn_service == null + ): + return false + if port < 1 or port > 65535: + _fail("The hosting port must be from 1 to 65535.") + return false + _operation_generation += 1 + _set_state(State.STARTING_PRIVATE_HOST, "Starting private game...") + _replace_transport() + var error: Error = _transport.start_host( + port, + transport_max_clients, + ) + if error != OK: + _fail("Could not start a private game on UDP port %d." % port) + return false + var peer: MultiplayerPeer = _transport.get_multiplayer_peer() + peer.refuse_new_connections = true + multiplayer.multiplayer_peer = peer + _session_id = Crypto.new().generate_random_bytes(16).hex_encode() + _registry.clear() + _registry.add_peer( + 1, + _profile.profile_id, + _profile.display_name, + NetworkProtocol.PROTOCOL_VERSION + ) + _spawn_service.clear_remote_players() + _spawn_service.register_local_player(1) + _set_state(State.PRIVATE_HOST, "Private game • UDP %d" % port) + host_openness_changed.emit(false) + _emit_peer_count() + return true + + +func join_direct(endpoint_text: String) -> bool: + if ( + state != State.INACTIVE + or not _profile_ready + or _profile == null + or _spawn_service == null + ): + return false + var endpoint: ConnectionEndpoint = EndpointParser.parse(endpoint_text) + if not endpoint.is_valid(): + _fail(endpoint.error_message) + return false + _operation_generation += 1 + var generation: int = _operation_generation + _current_route = ConnectionRoute.direct(endpoint) + _set_state( + State.CONNECTING, + "Connecting to %s..." % endpoint.normalized_display + ) + _replace_transport() + var error: Error = _transport.connect_to_route(_current_route) + if error != OK: + _fail("Could not begin the direct connection.") + return false + multiplayer.multiplayer_peer = _transport.get_multiplayer_peer() + _connection_deadline = ( + Time.get_ticks_msec() / 1000.0 + CONNECTION_TIMEOUT_SECONDS + ) + # The generation is checked by the process timeout and all state-gated + # multiplayer callbacks. + if generation != _operation_generation: + return false + return true + + +func cancel_connection() -> void: + if state not in [State.CONNECTING, State.AUTHENTICATING]: + return + _operation_generation += 1 + _teardown_peer() + _set_state(State.INACTIVE, "Connection cancelled.") + + +func reset_failure() -> void: + if state not in [State.CONNECTION_FAILED, State.SERVER_LOST]: + return + _teardown_peer() + _set_state(State.INACTIVE, "Ready for a direct connection.") + + +func disconnect_session(message: String = "Disconnected.") -> void: + if state == State.INACTIVE: + return + if state in [State.CONNECTION_FAILED, State.SERVER_LOST]: + _teardown_peer() + _set_state(State.INACTIVE, message) + return + _operation_generation += 1 + _set_state(State.DISCONNECTING, "Disconnecting...") + _teardown_peer() + _set_state(State.INACTIVE, message) + + +func set_host_open(is_open: bool) -> bool: + if state not in [State.PRIVATE_HOST, State.OPEN_HOST]: + return false + var peer: MultiplayerPeer = multiplayer.multiplayer_peer + if peer == null: + return false + peer.refuse_new_connections = not is_open + _set_state( + State.OPEN_HOST if is_open else State.PRIVATE_HOST, + ( + "Open game • %d / %d players" + if is_open + else "Private game • %d / %d players" + ) % [_registry.size(), session_max_players] + ) + host_openness_changed.emit(is_open) + return true + + +func is_host() -> bool: + return state in [State.PRIVATE_HOST, State.OPEN_HOST] + + +func is_open_host() -> bool: + return state == State.OPEN_HOST + + +func is_joined_client() -> bool: + return state == State.JOINED_CLIENT + + +func get_player_count() -> int: + return _registry.size() + + +func get_session_max_players() -> int: + return session_max_players + + +func get_current_route_display() -> String: + return ( + _current_route.display_description + if _current_route != null + else _transport.get_route_description() if _transport != null else "" + ) + + +func can_use_host_gameplay() -> bool: + return is_host() + + +func _process(delta: float) -> void: + var now: float = Time.get_ticks_msec() / 1000.0 + if ( + state in [State.CONNECTING, State.AUTHENTICATING] + and _connection_deadline > 0.0 + and now >= _connection_deadline + ): + _fail("Connection timed out.") + _teardown_peer() + return + if is_host(): + _expire_pending_authentication(now) + if state not in [State.OPEN_HOST, State.PRIVATE_HOST, State.JOINED_CLIENT]: + return + _input_accumulator += delta + _snapshot_accumulator += delta + if state == State.JOINED_CLIENT and _input_accumulator >= INPUT_INTERVAL: + _input_accumulator = fmod(_input_accumulator, INPUT_INTERVAL) + _send_local_input() + if is_host() and _snapshot_accumulator >= SNAPSHOT_INTERVAL: + _snapshot_accumulator = fmod(_snapshot_accumulator, SNAPSHOT_INTERVAL) + _broadcast_movement_snapshots() + + +func _connect_multiplayer_signals() -> void: + if not multiplayer.peer_connected.is_connected(_on_peer_connected): + multiplayer.peer_connected.connect(_on_peer_connected) + if not multiplayer.peer_disconnected.is_connected(_on_peer_disconnected): + multiplayer.peer_disconnected.connect(_on_peer_disconnected) + if not multiplayer.connected_to_server.is_connected( + _on_connected_to_server + ): + multiplayer.connected_to_server.connect(_on_connected_to_server) + if not multiplayer.connection_failed.is_connected(_on_connection_failed): + multiplayer.connection_failed.connect(_on_connection_failed) + if not multiplayer.server_disconnected.is_connected( + _on_server_disconnected + ): + multiplayer.server_disconnected.connect(_on_server_disconnected) + + +func _replace_transport() -> void: + if _transport != null: + _transport.disconnect_transport() + _transport.queue_free() + _transport = DirectEnetTransport.new() + add_child(_transport) + _transport.transport_error.connect(connection_error.emit) + + +func _on_peer_connected(peer_id: int) -> void: + if not is_host(): + return + if state != State.OPEN_HOST: + multiplayer.multiplayer_peer.disconnect_peer(peer_id) + return + _pending_authentication[peer_id] = ( + Time.get_ticks_msec() / 1000.0 + AUTHENTICATION_TIMEOUT_SECONDS + ) + + +func _on_connected_to_server() -> void: + if state != State.CONNECTING: + return + _set_state(State.AUTHENTICATING, "Authenticating...") + _client_nonce = Crypto.new().generate_random_bytes(16).hex_encode() + var hello: Dictionary = NetworkProtocol.make_client_hello( + _profile.profile_id, + _profile.display_name, + _client_nonce + ) + submit_client_hello.rpc_id(1, hello) + + +func _on_connection_failed() -> void: + if state not in [State.CONNECTING, State.AUTHENTICATING]: + return + _teardown_peer() + _fail("Could not connect. The server may be private, unavailable, or unreachable.") + + +func _on_server_disconnected() -> void: + if state in [State.INACTIVE, State.DISCONNECTING]: + return + _operation_generation += 1 + _teardown_peer() + _set_state(State.SERVER_LOST, "The server connection was lost.") + server_lost.emit() + connection_error.emit("The server connection was lost.") + + +func _on_peer_disconnected(peer_id: int) -> void: + _pending_authentication.erase(peer_id) + if _registry.has_peer(peer_id): + _registry.remove_peer(peer_id) + _spawn_service.remove_peer(peer_id) + if is_host(): + receive_peer_despawn.rpc(peer_id) + peer_removed.emit(peer_id) + _emit_peer_count() + + +@rpc("any_peer", "call_remote", "reliable", 0) +func submit_client_hello(data: Dictionary) -> void: + var sender_id: int = multiplayer.get_remote_sender_id() + if not is_host() or sender_id <= 1 or not _pending_authentication.has(sender_id): + return + var validation_error: String = NetworkProtocol.validate_client_hello(data) + if not validation_error.is_empty(): + _reject_peer( + sender_id, + NetworkProtocol.RejectionCode.MALFORMED_HANDSHAKE + ) + return + if int(data["protocol_version"]) != NetworkProtocol.PROTOCOL_VERSION: + _reject_peer( + sender_id, + NetworkProtocol.RejectionCode.PROTOCOL_MISMATCH + ) + return + if _registry.size() >= session_max_players: + _reject_peer(sender_id, NetworkProtocol.RejectionCode.SERVER_FULL) + return + var profile_id: String = data["local_profile_id"] + if _registry.has_profile(profile_id): + _reject_peer( + sender_id, + NetworkProtocol.RejectionCode.DUPLICATE_PROFILE + ) + return + var display_name: String = data["display_name"] + if not _registry.add_peer( + sender_id, + profile_id, + display_name, + NetworkProtocol.PROTOCOL_VERSION + ): + _reject_peer( + sender_id, + NetworkProtocol.RejectionCode.MALFORMED_HANDSHAKE + ) + return + _pending_authentication.erase(sender_id) + var spawn_index: int = _registry.get_peer_ids().find(sender_id) + var spawn_transform: Transform3D = ( + _spawn_service.get_spawn_transform_for_index(spawn_index) + ) + _spawn_service.spawn_remote_player(sender_id, spawn_transform, true) + receive_server_hello.rpc_id( + sender_id, + NetworkProtocol.make_server_hello( + true, + NetworkProtocol.RejectionCode.NONE, + _session_id, + sender_id, + _registry.size(), + session_max_players + ) + ) + receive_spawn_list.rpc_id(sender_id, _build_spawn_list()) + receive_peer_spawn.rpc( + _make_spawn_entry(sender_id, display_name, spawn_transform) + ) + peer_authenticated.emit(sender_id, display_name) + _emit_peer_count() + + +func _reject_peer( + peer_id: int, + code: NetworkProtocol.RejectionCode, +) -> void: + receive_server_hello.rpc_id( + peer_id, + NetworkProtocol.make_server_hello( + false, + code, + _session_id, + peer_id, + _registry.size(), + session_max_players + ) + ) + _pending_authentication.erase(peer_id) + call_deferred("_disconnect_rejected_peer", peer_id) + + +func _disconnect_rejected_peer(peer_id: int) -> void: + if ( + is_host() + and multiplayer.multiplayer_peer != null + and multiplayer.multiplayer_peer.get_connection_status() + == MultiplayerPeer.CONNECTION_CONNECTED + ): + multiplayer.multiplayer_peer.disconnect_peer(peer_id) + + +@rpc("authority", "call_remote", "reliable", 0) +func receive_server_hello(data: Dictionary) -> void: + if state != State.AUTHENTICATING: + return + if ( + typeof(data.get("accepted")) != TYPE_BOOL + or typeof(data.get("protocol_version")) != TYPE_INT + or typeof(data.get("rejection_code")) != TYPE_INT + ): + _teardown_peer() + _fail("The server sent an invalid handshake response.") + return + if not bool(data["accepted"]): + var message: String = NetworkProtocol.rejection_text( + int(data["rejection_code"]) + ) + _teardown_peer() + _fail(message) + return + if int(data["protocol_version"]) != NetworkProtocol.PROTOCOL_VERSION: + _teardown_peer() + _fail("The server uses a different network protocol.") + return + _session_id = str(data.get("session_id", "")) + _last_server_max_players = int(data.get( + "max_players", + DEFAULT_SESSION_MAX_PLAYERS + )) + var local_peer_id: int = multiplayer.get_unique_id() + _registry.clear() + _registry.add_peer( + local_peer_id, + _profile.profile_id, + _profile.display_name, + NetworkProtocol.PROTOCOL_VERSION + ) + _spawn_service.clear_remote_players() + _spawn_service.register_local_player(local_peer_id) + _connection_deadline = 0.0 + _set_state( + State.JOINED_CLIENT, + "Connected • %d / %d players" % [ + int(data.get("player_count", 1)), + _last_server_max_players, + ] + ) + if _current_route != null and _saved_servers != null: + _saved_servers.record_successful_connection( + _current_route.direct_endpoint, + _last_server_max_players + ) + join_authenticated.emit() + + +@rpc("authority", "call_remote", "reliable", 0) +func receive_spawn_list(entries: Array) -> void: + if state != State.JOINED_CLIENT: + return + for value: Variant in entries: + if typeof(value) == TYPE_DICTIONARY: + _apply_spawn_entry(value) + _emit_peer_count() + + +@rpc("authority", "call_remote", "reliable", 0) +func receive_peer_spawn(entry: Dictionary) -> void: + if state != State.JOINED_CLIENT: + return + _apply_spawn_entry(entry) + _emit_peer_count() + + +@rpc("authority", "call_remote", "reliable", 0) +func receive_peer_despawn(peer_id: int) -> void: + if state != State.JOINED_CLIENT: + return + _registry.remove_peer(peer_id) + _spawn_service.remove_peer(peer_id) + peer_removed.emit(peer_id) + _emit_peer_count() + + +func _apply_spawn_entry(entry: Dictionary) -> void: + if ( + typeof(entry.get("peer_id")) != TYPE_INT + or typeof(entry.get("profile_id")) != TYPE_STRING + or typeof(entry.get("display_name")) != TYPE_STRING + or typeof(entry.get("position")) != TYPE_ARRAY + or typeof(entry.get("yaw")) not in [TYPE_FLOAT, TYPE_INT] + ): + return + var peer_id: int = entry["peer_id"] + if peer_id == multiplayer.get_unique_id(): + var own_position: Array = entry["position"] + if own_position.size() == 3: + var own_avatar: Player = _spawn_service.get_avatar(peer_id) + if own_avatar != null: + var own_snapshot: Dictionary = own_avatar.make_network_snapshot( + peer_id + ) + own_snapshot["position"] = own_position + own_snapshot["visual_yaw"] = float(entry["yaw"]) + own_avatar.apply_network_teleport(own_snapshot) + return + if not _registry.has_peer(peer_id): + _registry.add_peer( + peer_id, + entry["profile_id"], + entry["display_name"], + NetworkProtocol.PROTOCOL_VERSION + ) + var transform: Transform3D = _spawn_service.get_spawn_transform_for_index(0) + var position: Array = entry["position"] + if position.size() != 3: + return + transform.origin = Vector3( + float(position[0]), + float(position[1]), + float(position[2]) + ) + transform.basis = Basis(Vector3.UP, float(entry["yaw"])) + _spawn_service.spawn_remote_player(peer_id, transform, false) + + +func _build_spawn_list() -> Array[Dictionary]: + var entries: Array[Dictionary] = [] + for peer_id: int in _registry.get_peer_ids(): + var record: PeerRegistry.PeerRecord = _registry.get_peer(peer_id) + var avatar: Player = _spawn_service.get_avatar(peer_id) + if record != null and avatar != null: + entries.append(_make_spawn_entry( + peer_id, + record.display_name, + avatar.global_transform + )) + return entries + + +func _make_spawn_entry( + peer_id: int, + display_name: String, + transform: Transform3D, +) -> Dictionary: + var record: PeerRegistry.PeerRecord = _registry.get_peer(peer_id) + return { + "peer_id": peer_id, + "profile_id": record.profile_id if record != null else "", + "display_name": display_name, + "position": [ + transform.origin.x, + transform.origin.y, + transform.origin.z, + ], + "yaw": transform.basis.get_euler().y, + } + + +func _send_local_input() -> void: + var peer_id: int = multiplayer.get_unique_id() + var avatar: Player = _spawn_service.get_avatar(peer_id) + if avatar == null: + return + _input_sequence += 1 + var input: Dictionary = avatar.capture_network_input(_input_sequence) + submit_movement_input.rpc_id(1, input) + + +@rpc("any_peer", "call_remote", "unreliable_ordered", 1) +func submit_movement_input(data: Dictionary) -> void: + var sender_id: int = multiplayer.get_remote_sender_id() + if not is_host() or not _registry.has_peer(sender_id): + return + var avatar: Player = _spawn_service.get_avatar(sender_id) + if avatar == null or not _is_valid_movement_input(data): + return + avatar.apply_authoritative_network_input(data) + + +func _is_valid_movement_input(data: Dictionary) -> bool: + if ( + typeof(data.get("sequence")) != TYPE_INT + or typeof(data.get("axis")) != TYPE_ARRAY + or typeof(data.get("jump")) != TYPE_BOOL + or typeof(data.get("sprint")) != TYPE_BOOL + or typeof(data.get("sneak")) != TYPE_BOOL + or typeof(data.get("slow_walk")) != TYPE_BOOL + or typeof(data.get("camera_yaw")) not in [TYPE_FLOAT, TYPE_INT] + ): + return false + var axis: Array = data["axis"] + if axis.size() != 2: + return false + var x: float = float(axis[0]) + var y: float = float(axis[1]) + var camera_yaw: float = float(data["camera_yaw"]) + return ( + is_finite(x) + and is_finite(y) + and is_finite(camera_yaw) + and absf(x) <= 1.01 + and absf(y) <= 1.01 + and absf(camera_yaw) <= TAU * 100.0 + ) + + +func _broadcast_movement_snapshots() -> void: + var snapshots: Array[Dictionary] = [] + for peer_id: int in _registry.get_peer_ids(): + var avatar: Player = _spawn_service.get_avatar(peer_id) + if avatar == null: + continue + snapshots.append(avatar.make_network_snapshot(peer_id)) + receive_movement_snapshots.rpc(snapshots) + + +@rpc("authority", "call_remote", "unreliable_ordered", 2) +func receive_movement_snapshots(snapshots: Array) -> void: + if state != State.JOINED_CLIENT: + return + var local_peer_id: int = multiplayer.get_unique_id() + for value: Variant in snapshots: + if typeof(value) != TYPE_DICTIONARY: + continue + var snapshot: Dictionary = value + if typeof(snapshot.get("peer_id")) != TYPE_INT: + continue + var peer_id: int = snapshot["peer_id"] + var avatar: Player = _spawn_service.get_avatar(peer_id) + if avatar == null: + continue + if peer_id == local_peer_id: + avatar.apply_local_prediction_correction(snapshot) + else: + avatar.push_network_snapshot(snapshot) + + +func publish_authoritative_teleport(peer_id: int) -> void: + if not is_host(): + return + var avatar: Player = _spawn_service.get_avatar(peer_id) + if avatar == null: + return + receive_authoritative_teleport.rpc( + avatar.make_network_snapshot(peer_id) + ) + + +func request_safe_respawn(entry_position: Vector3) -> void: + if not entry_position.is_finite(): + return + if is_host(): + remote_recovery_requested.emit(1, entry_position) + elif state == State.JOINED_CLIENT: + submit_safe_respawn_request.rpc_id( + 1, + [entry_position.x, entry_position.y, entry_position.z] + ) + + +@rpc("any_peer", "call_remote", "reliable", 0) +func submit_safe_respawn_request(position_data: Array) -> void: + var sender_id: int = multiplayer.get_remote_sender_id() + if ( + not is_host() + or not _registry.has_peer(sender_id) + or position_data.size() != 3 + ): + return + var entry_position := Vector3( + float(position_data[0]), + float(position_data[1]), + float(position_data[2]) + ) + if not entry_position.is_finite(): + return + remote_recovery_requested.emit(sender_id, entry_position) + + +@rpc("authority", "call_remote", "reliable", 0) +func receive_authoritative_teleport(snapshot: Dictionary) -> void: + if state != State.JOINED_CLIENT: + return + var peer_id: int = int(snapshot.get("peer_id", 0)) + var avatar: Player = _spawn_service.get_avatar(peer_id) + if avatar != null: + avatar.apply_network_teleport(snapshot) + + +func _expire_pending_authentication(now: float) -> void: + for peer_id: int in _pending_authentication.keys(): + if now >= float(_pending_authentication[peer_id]): + _reject_peer( + peer_id, + NetworkProtocol.RejectionCode.AUTHENTICATION_TIMEOUT + ) + + +func _emit_peer_count() -> void: + peer_count_changed.emit(_registry.size(), session_max_players) + + +func _set_state(new_state: State, message: String) -> void: + if not _is_transition_allowed(state, new_state): + push_warning( + "Rejected invalid network state transition %s -> %s." + % [State.keys()[state], State.keys()[new_state]] + ) + return + state = new_state + state_changed.emit(state) + status_message_changed.emit(message) + + +func _is_transition_allowed(from_state: State, to_state: State) -> bool: + if from_state == to_state: + return true + var allowed: Dictionary[State, Array] = { + State.INACTIVE: [ + State.STARTING_PRIVATE_HOST, + State.CONNECTING, + State.CONNECTION_FAILED, + ], + State.STARTING_PRIVATE_HOST: [ + State.PRIVATE_HOST, + State.CONNECTION_FAILED, + State.DISCONNECTING, + ], + State.PRIVATE_HOST: [ + State.OPEN_HOST, + State.DISCONNECTING, + ], + State.OPEN_HOST: [ + State.PRIVATE_HOST, + State.DISCONNECTING, + ], + State.CONNECTING: [ + State.AUTHENTICATING, + State.CONNECTION_FAILED, + State.DISCONNECTING, + State.INACTIVE, + ], + State.AUTHENTICATING: [ + State.JOINED_CLIENT, + State.CONNECTION_FAILED, + State.DISCONNECTING, + State.INACTIVE, + ], + State.JOINED_CLIENT: [ + State.DISCONNECTING, + State.SERVER_LOST, + ], + State.DISCONNECTING: [ + State.INACTIVE, + State.CONNECTING, + ], + State.CONNECTION_FAILED: [ + State.INACTIVE, + State.CONNECTING, + State.DISCONNECTING, + ], + State.SERVER_LOST: [ + State.INACTIVE, + State.CONNECTING, + State.DISCONNECTING, + ], + } + return to_state in allowed.get(from_state, []) + + +func _fail(message: String) -> void: + _set_state(State.CONNECTION_FAILED, message) + connection_error.emit(message) + + +func _teardown_peer() -> void: + _pending_authentication.clear() + _registry.clear() + if _spawn_service != null: + _spawn_service.clear_remote_players() + if _transport != null: + _transport.disconnect_transport() + multiplayer.multiplayer_peer = OfflineMultiplayerPeer.new() + _connection_deadline = 0.0 + _input_accumulator = 0.0 + _snapshot_accumulator = 0.0 diff --git a/network/network_session.gd.uid b/network/network_session.gd.uid new file mode 100644 index 0000000..1a423e8 --- /dev/null +++ b/network/network_session.gd.uid @@ -0,0 +1 @@ +uid://u62a32ktf0y5 diff --git a/network/network_transport.gd b/network/network_transport.gd new file mode 100644 index 0000000..8b0cd05 --- /dev/null +++ b/network/network_transport.gd @@ -0,0 +1,34 @@ +class_name NetworkTransport +extends Node + +signal transport_error(message: String) + +var _peer: MultiplayerPeer +var _route_description: String = "" + + +func start_host( + _port: int, + _max_clients: int, + _bind_address: String = "*", +) -> Error: + return ERR_UNAVAILABLE + + +func connect_to_route(_route: ConnectionRoute) -> Error: + return ERR_UNAVAILABLE + + +func disconnect_transport() -> void: + if _peer != null: + _peer.close() + _peer = null + _route_description = "" + + +func get_multiplayer_peer() -> MultiplayerPeer: + return _peer + + +func get_route_description() -> String: + return _route_description diff --git a/network/network_transport.gd.uid b/network/network_transport.gd.uid new file mode 100644 index 0000000..42076b6 --- /dev/null +++ b/network/network_transport.gd.uid @@ -0,0 +1 @@ +uid://pcqgk35ifhxc diff --git a/network/peer_registry.gd b/network/peer_registry.gd new file mode 100644 index 0000000..2b6203a --- /dev/null +++ b/network/peer_registry.gd @@ -0,0 +1,69 @@ +class_name PeerRegistry +extends RefCounted + +class PeerRecord: + extends RefCounted + + var peer_id: int = 0 + var profile_id: String = "" + var display_name: String = "" + var protocol_version: int = 0 + var joined_at_unix: int = 0 + + +var _records: Dictionary[int, PeerRecord] = {} + + +func add_peer( + peer_id: int, + profile_id: String, + display_name: String, + protocol_version: int, +) -> bool: + if peer_id <= 0 or profile_id.is_empty() or has_profile(profile_id): + return false + var record := PeerRecord.new() + record.peer_id = peer_id + record.profile_id = profile_id + record.display_name = display_name + record.protocol_version = protocol_version + record.joined_at_unix = int(Time.get_unix_time_from_system()) + _records[peer_id] = record + return true + + +func remove_peer(peer_id: int) -> PeerRecord: + var record: PeerRecord = _records.get(peer_id) + _records.erase(peer_id) + return record + + +func get_peer(peer_id: int) -> PeerRecord: + return _records.get(peer_id) + + +func has_peer(peer_id: int) -> bool: + return _records.has(peer_id) + + +func has_profile(profile_id: String) -> bool: + for record: PeerRecord in _records.values(): + if record.profile_id == profile_id: + return true + return false + + +func get_peer_ids() -> Array[int]: + var result: Array[int] = [] + for peer_id: int in _records: + result.append(peer_id) + result.sort() + return result + + +func size() -> int: + return _records.size() + + +func clear() -> void: + _records.clear() diff --git a/network/peer_registry.gd.uid b/network/peer_registry.gd.uid new file mode 100644 index 0000000..da096b2 --- /dev/null +++ b/network/peer_registry.gd.uid @@ -0,0 +1 @@ +uid://de3jtu2e0ut65 diff --git a/network/player_spawn_service.gd b/network/player_spawn_service.gd new file mode 100644 index 0000000..bf62dd0 --- /dev/null +++ b/network/player_spawn_service.gd @@ -0,0 +1,96 @@ +class_name PlayerSpawnService +extends Node + +const PlayerScene: PackedScene = preload("res://player/player.tscn") + +signal avatar_spawned(peer_id: int, avatar: Player) +signal avatar_removed(peer_id: int) + +var _players_root: Node3D +var _local_player: Player +var _spawn_transform: Transform3D +var _avatars: Dictionary[int, Player] = {} +var _local_peer_id: int = 1 + + +func setup( + players_root: Node3D, + local_player: Player, + spawn_transform: Transform3D, +) -> void: + _players_root = players_root + _local_player = local_player + _spawn_transform = spawn_transform + + +func register_local_player(peer_id: int) -> void: + if _local_player == null: + return + for existing_id: int in _avatars.keys(): + if _avatars[existing_id] == _local_player: + _avatars.erase(existing_id) + _local_peer_id = peer_id + _avatars[peer_id] = _local_player + _local_player.name = "Player_%d" % peer_id + _local_player.set_local_control(true) + _local_player.set_network_peer_id(peer_id) + + +func spawn_remote_player( + peer_id: int, + transform: Transform3D, + authoritative_simulation: bool = false, +) -> Player: + if peer_id <= 0 or peer_id == _local_peer_id: + return null + var existing: Player = _avatars.get(peer_id) + if existing != null and is_instance_valid(existing): + return existing + var avatar := PlayerScene.instantiate() as Player + avatar.name = "Player_%d" % peer_id + avatar.set_local_control(false) + avatar.set_network_peer_id(peer_id) + _players_root.add_child(avatar) + avatar.global_transform = transform + avatar.configure_network_remote(authoritative_simulation) + _avatars[peer_id] = avatar + avatar_spawned.emit(peer_id, avatar) + return avatar + + +func remove_peer(peer_id: int) -> void: + if peer_id == _local_peer_id: + return + var avatar: Player = _avatars.get(peer_id) + _avatars.erase(peer_id) + if avatar != null and is_instance_valid(avatar): + avatar.queue_free() + avatar_removed.emit(peer_id) + + +func clear_remote_players() -> void: + for peer_id: int in _avatars.keys(): + if peer_id != _local_peer_id: + remove_peer(peer_id) + + +func get_avatar(peer_id: int) -> Player: + return _avatars.get(peer_id) + + +func get_peer_ids() -> Array[int]: + var result: Array[int] = [] + for peer_id: int in _avatars: + result.append(peer_id) + result.sort() + return result + + +func get_spawn_transform_for_index(index: int) -> Transform3D: + if index <= 0: + return _spawn_transform + var angle: float = float(index) * 2.3999632297 + var ring: float = 1.4 + floor(float(index - 1) / 8.0) * 1.2 + var result: Transform3D = _spawn_transform + result.origin += Vector3(cos(angle) * ring, 0.0, sin(angle) * ring) + return result diff --git a/network/player_spawn_service.gd.uid b/network/player_spawn_service.gd.uid new file mode 100644 index 0000000..93d44f8 --- /dev/null +++ b/network/player_spawn_service.gd.uid @@ -0,0 +1 @@ +uid://8w70d0nrf4a7 diff --git a/network/saved_server_store.gd b/network/saved_server_store.gd new file mode 100644 index 0000000..ca2f5b8 --- /dev/null +++ b/network/saved_server_store.gd @@ -0,0 +1,187 @@ +class_name SavedServerStore +extends Node + +const FORMAT_VERSION: int = 1 +const STORE_PATH: String = "user://saved_servers.json" +const TEMP_PATH: String = "user://saved_servers.json.tmp" +const BACKUP_PATH: String = "user://saved_servers.json.backup" +const MAX_ENTRIES: int = 100 + +var _entries: Array[Dictionary] = [] +var _loaded: bool = false + + +func list_entries() -> Array[Dictionary]: + _ensure_loaded() + return _entries.duplicate(true) + + +func find_by_normalized_endpoint(value: String) -> Dictionary: + _ensure_loaded() + for entry: Dictionary in _entries: + if entry.get("normalized_endpoint", "") == value: + return entry.duplicate(true) + return {} + + +func save_entry( + display_name: String, + endpoint: ConnectionEndpoint, +) -> bool: + _ensure_loaded() + if ( + endpoint == null + or not endpoint.is_valid() + or display_name.strip_edges().is_empty() + or display_name.length() > 80 + ): + return false + var now: int = int(Time.get_unix_time_from_system()) + for entry: Dictionary in _entries: + if entry.get("normalized_endpoint", "") == endpoint.normalized_display: + entry["display_name"] = display_name.strip_edges() + entry["host"] = endpoint.host + entry["port"] = endpoint.port + entry["updated_at_unix"] = now + return _save_atomic() + if _entries.size() >= MAX_ENTRIES: + return false + _entries.append({ + "entry_id": Crypto.new().generate_random_bytes(16).hex_encode(), + "display_name": display_name.strip_edges(), + "route_kind": "DIRECT", + "host": endpoint.host, + "port": endpoint.port, + "normalized_endpoint": endpoint.normalized_display, + "favorite": false, + "created_at_unix": now, + "updated_at_unix": now, + "last_success_at_unix": 0, + "last_observed_max_players": 0, + }) + return _save_atomic() + + +func remove_entry(entry_id: String) -> bool: + _ensure_loaded() + for index: int in range(_entries.size()): + if _entries[index].get("entry_id", "") == entry_id: + _entries.remove_at(index) + return _save_atomic() + return false + + +func record_successful_connection( + endpoint: ConnectionEndpoint, + observed_max_players: int, +) -> bool: + _ensure_loaded() + if endpoint == null or not endpoint.is_valid(): + return false + for entry: Dictionary in _entries: + if entry.get("normalized_endpoint", "") == endpoint.normalized_display: + entry["last_success_at_unix"] = int(Time.get_unix_time_from_system()) + entry["last_observed_max_players"] = maxi(observed_max_players, 0) + entry["updated_at_unix"] = int(Time.get_unix_time_from_system()) + return _save_atomic() + # Successful manual connections are deliberately not auto-saved. + return true + + +func _ensure_loaded() -> void: + if _loaded: + return + _loaded = true + _recover_interrupted_write() + if not FileAccess.file_exists(STORE_PATH): + return + var file := FileAccess.open(STORE_PATH, FileAccess.READ) + if file == null: + return + var json := JSON.new() + var error: Error = json.parse(file.get_as_text()) + file.close() + if error != OK or typeof(json.data) != TYPE_DICTIONARY: + return + var data: Dictionary = json.data + if data.get("format_version") != FORMAT_VERSION: + return + var raw_entries: Variant = data.get("entries") + if typeof(raw_entries) != TYPE_ARRAY: + return + var seen: Dictionary[String, bool] = {} + for value: Variant in raw_entries: + if typeof(value) != TYPE_DICTIONARY: + continue + var entry: Dictionary = value + var stored_host: String = str(entry.get("host", "")) + var stored_port: int = int(entry.get("port", 0)) + var endpoint_text: String = ( + "[%s]:%d" % [stored_host, stored_port] + if stored_host.contains(":") + else "%s:%d" % [stored_host, stored_port] + ) + var endpoint: ConnectionEndpoint = EndpointParser.parse(endpoint_text) + if ( + not endpoint.is_valid() + or seen.has(endpoint.normalized_display) + or typeof(entry.get("entry_id")) != TYPE_STRING + or typeof(entry.get("display_name")) != TYPE_STRING + ): + continue + entry["normalized_endpoint"] = endpoint.normalized_display + seen[endpoint.normalized_display] = true + _entries.append(entry.duplicate(true)) + if _entries.size() >= MAX_ENTRIES: + break + + +func _save_atomic() -> bool: + var file := FileAccess.open(TEMP_PATH, FileAccess.WRITE) + if file == null: + return false + file.store_string(JSON.stringify({ + "format_version": FORMAT_VERSION, + "entries": _entries, + }, "\t")) + file.flush() + var error: Error = file.get_error() + file.close() + if error != OK: + _remove_if_present(TEMP_PATH) + return false + _remove_if_present(BACKUP_PATH) + var had_primary: bool = FileAccess.file_exists(STORE_PATH) + if had_primary and not _rename(STORE_PATH, BACKUP_PATH): + _remove_if_present(TEMP_PATH) + return false + if not _rename(TEMP_PATH, STORE_PATH): + if had_primary: + _rename(BACKUP_PATH, STORE_PATH) + return false + _remove_if_present(BACKUP_PATH) + return true + + +func _recover_interrupted_write() -> void: + if FileAccess.file_exists(STORE_PATH): + _remove_if_present(TEMP_PATH) + _remove_if_present(BACKUP_PATH) + return + if FileAccess.file_exists(BACKUP_PATH): + _rename(BACKUP_PATH, STORE_PATH) + _remove_if_present(TEMP_PATH) + + +func _rename(from_path: String, to_path: String) -> bool: + return DirAccess.rename_absolute( + ProjectSettings.globalize_path(from_path), + ProjectSettings.globalize_path(to_path) + ) == OK + + +func _remove_if_present(path: String) -> bool: + return ( + not FileAccess.file_exists(path) + or DirAccess.remove_absolute(ProjectSettings.globalize_path(path)) == OK + ) diff --git a/network/saved_server_store.gd.uid b/network/saved_server_store.gd.uid new file mode 100644 index 0000000..b07aac2 --- /dev/null +++ b/network/saved_server_store.gd.uid @@ -0,0 +1 @@ +uid://b7j0er0u1nwy7 diff --git a/player/player.gd b/player/player.gd index 705cb1c..d7e3452 100644 --- a/player/player.gd +++ b/player/player.gd @@ -105,6 +105,20 @@ var _showcase_restore_tween: Tween var _showcase_camera_tween: Tween var _showcase_camera_snapshot: ShowcaseCameraSnapshot var _showcase_restore_generation: int = 0 +var _network_peer_id: int = 0 +var _network_authoritative_simulation: bool = false +var _network_interpolation_enabled: bool = false +var _network_axis: Vector2 = Vector2.ZERO +var _network_camera_yaw: float = 0.0 +var _network_jump_pending: bool = false +var _network_sprint: bool = false +var _network_sneak: bool = false +var _network_slow_walk: bool = false +var _last_network_input_sequence: int = 0 +var _network_target_position: Vector3 +var _network_target_velocity: Vector3 +var _network_target_visual_yaw: float = 0.0 +var _network_snapshot_ready: bool = false func _ready() -> void: @@ -114,6 +128,9 @@ func _ready() -> void: func _physics_process(delta: float) -> void: + if _network_interpolation_enabled: + _update_network_interpolation(delta) + return if _water_recovery_active: velocity = Vector3.ZERO return @@ -125,34 +142,50 @@ func _physics_process(delta: float) -> void: else fall_gravity_multiplier ) velocity.y -= _gravity * gravity_multiplier * delta - elif ( - local_control_enabled - and _movement_enabled - and Input.is_action_just_pressed("jump") + elif _movement_enabled and ( + ( + local_control_enabled + and Input.is_action_just_pressed("jump") + ) + or ( + _network_authoritative_simulation + and _network_jump_pending + ) ): velocity.y = jump_velocity + _network_jump_pending = false - if not local_control_enabled or not _movement_enabled: + if not _movement_enabled: velocity.x = 0.0 velocity.z = 0.0 move_and_slide() return - var input_vector: Vector2 = Input.get_vector( - "move_left", - "move_right", - "move_forward", - "move_backward" - ) - var camera_basis: Basis = _camera_yaw.global_basis + var input_vector: Vector2 + var camera_basis: Basis + if local_control_enabled: + input_vector = Input.get_vector( + "move_left", + "move_right", + "move_forward", + "move_backward" + ) + camera_basis = _camera_yaw.global_basis + elif _network_authoritative_simulation: + input_vector = _network_axis + camera_basis = Basis(Vector3.UP, _network_camera_yaw) + else: + velocity.x = 0.0 + velocity.z = 0.0 + return var move_direction: Vector3 = camera_basis.x * input_vector.x + camera_basis.z * input_vector.y move_direction.y = 0.0 var input_strength: float = minf(input_vector.length(), 1.0) move_direction = move_direction.normalized() - var speed: float = ( - _get_current_speed() * item_effects.get_movement_multiplier() - ) + var speed: float = _get_network_aware_speed() + if item_effects != null: + speed *= item_effects.get_movement_multiplier() velocity.x = move_direction.x * speed * input_strength velocity.z = move_direction.z * speed * input_strength @@ -243,6 +276,18 @@ func _get_current_speed() -> float: return walk_speed +func _get_network_aware_speed() -> float: + if local_control_enabled: + return _get_current_speed() + if _network_sneak: + return sneak_speed + if _network_slow_walk: + return slow_walk_speed + if _network_sprint: + return sprint_speed + return walk_speed + + func _rotate_camera(delta_rotation: Vector2) -> void: _camera_yaw.rotation.y -= delta_rotation.x var vertical_direction: float = -1.0 if invert_camera_y else 1.0 @@ -265,6 +310,158 @@ func set_local_control(enabled: bool) -> void: _camera_dragging = false +func set_network_peer_id(peer_id: int) -> void: + _network_peer_id = peer_id + + +func get_network_peer_id() -> int: + return _network_peer_id + + +func configure_network_remote(authoritative_simulation: bool) -> void: + set_local_control(false) + _network_authoritative_simulation = authoritative_simulation + _network_interpolation_enabled = not authoritative_simulation + _network_snapshot_ready = false + _camera.current = false + + +func capture_network_input(sequence: int) -> Dictionary: + var axis: Vector2 = Input.get_vector( + "move_left", + "move_right", + "move_forward", + "move_backward" + ) + return { + "sequence": sequence, + "axis": [axis.x, axis.y], + "camera_yaw": _camera_yaw.global_rotation.y, + "jump": Input.is_action_just_pressed("jump"), + "sprint": Input.is_action_pressed("sprint"), + "sneak": Input.is_action_pressed("sneak"), + "slow_walk": Input.is_action_pressed("slow_walk"), + } + + +func apply_authoritative_network_input(data: Dictionary) -> void: + var sequence: int = int(data.get("sequence", 0)) + if sequence <= _last_network_input_sequence: + return + var axis: Array = data.get("axis", []) + if axis.size() != 2: + return + _last_network_input_sequence = sequence + _network_axis = Vector2(float(axis[0]), float(axis[1])).limit_length(1.0) + _network_camera_yaw = float(data.get("camera_yaw", 0.0)) + _network_jump_pending = bool(data.get("jump", false)) + _network_sprint = bool(data.get("sprint", false)) + _network_sneak = bool(data.get("sneak", false)) + _network_slow_walk = bool(data.get("slow_walk", false)) + + +func make_network_snapshot(peer_id: int) -> Dictionary: + return { + "peer_id": peer_id, + "acknowledged_input": _last_network_input_sequence, + "position": [global_position.x, global_position.y, global_position.z], + "velocity": [velocity.x, velocity.y, velocity.z], + "visual_yaw": _visuals.rotation.y, + "grounded": is_on_floor(), + } + + +func push_network_snapshot(snapshot: Dictionary) -> void: + var parsed: Dictionary = _parse_network_snapshot(snapshot) + if parsed.is_empty(): + return + _network_target_position = parsed["position"] + _network_target_velocity = parsed["velocity"] + _network_target_visual_yaw = parsed["visual_yaw"] + if not _network_snapshot_ready: + global_position = _network_target_position + velocity = _network_target_velocity + _visuals.rotation.y = _network_target_visual_yaw + _network_snapshot_ready = true + + +func apply_local_prediction_correction(snapshot: Dictionary) -> void: + var parsed: Dictionary = _parse_network_snapshot(snapshot) + if parsed.is_empty(): + return + var authoritative_position: Vector3 = parsed["position"] + var error_distance: float = global_position.distance_to( + authoritative_position + ) + if error_distance > 2.0: + global_position = authoritative_position + elif error_distance > 0.05: + global_position = global_position.lerp(authoritative_position, 0.18) + + +func apply_network_teleport(snapshot: Dictionary) -> void: + var parsed: Dictionary = _parse_network_snapshot(snapshot) + if parsed.is_empty(): + return + global_position = parsed["position"] + velocity = parsed["velocity"] + _visuals.rotation.y = parsed["visual_yaw"] + _network_target_position = global_position + _network_target_velocity = velocity + _network_target_visual_yaw = _visuals.rotation.y + _network_snapshot_ready = true + + +func _parse_network_snapshot(snapshot: Dictionary) -> Dictionary: + var position: Variant = snapshot.get("position") + var network_velocity: Variant = snapshot.get("velocity") + if ( + typeof(position) != TYPE_ARRAY + or typeof(network_velocity) != TYPE_ARRAY + or position.size() != 3 + or network_velocity.size() != 3 + ): + return {} + var parsed_position := Vector3( + float(position[0]), + float(position[1]), + float(position[2]) + ) + var parsed_velocity := Vector3( + float(network_velocity[0]), + float(network_velocity[1]), + float(network_velocity[2]) + ) + var visual_yaw: float = float(snapshot.get("visual_yaw", 0.0)) + if ( + not parsed_position.is_finite() + or not parsed_velocity.is_finite() + or not is_finite(visual_yaw) + ): + return {} + return { + "position": parsed_position, + "velocity": parsed_velocity, + "visual_yaw": visual_yaw, + } + + +func _update_network_interpolation(delta: float) -> void: + if not _network_snapshot_ready: + return + var position_weight: float = 1.0 - exp(-12.0 * delta) + global_position = global_position.lerp( + _network_target_position, + position_weight + ) + velocity = _network_target_velocity + _visuals.rotation.y = lerp_angle( + _visuals.rotation.y, + _network_target_visual_yaw, + 1.0 - exp(-14.0 * delta) + ) + + func is_local_control_enabled() -> bool: return local_control_enabled diff --git a/ui/game_ui.gd b/ui/game_ui.gd index d63f940..e8f9e46 100644 --- a/ui/game_ui.gd +++ b/ui/game_ui.gd @@ -16,6 +16,7 @@ const PlayerHotbarType = preload("res://inventory/player_hotbar.gd") const HotbarUIType = preload("res://ui/hotbar.gd") const TitleScreenType = preload("res://ui/title_screen.gd") const PauseMenuType = preload("res://ui/pause_menu.gd") +const NetworkSessionType = preload("res://network/network_session.gd") const FishingShopType = preload("res://ui/fishing_shop.gd") const SettingsPanelType = preload("res://ui/settings_panel.gd") const PlayerFishingUpgradesType = preload( @@ -100,6 +101,7 @@ func setup( shop_interaction: ShopInteractionType, item_effects: PlayerItemEffectsType, cooler_capacity: PlayerCoolerCapacityType, + network_session: NetworkSessionType, ) -> void: _fishing_spot = fishing_spot _item_effects = item_effects @@ -121,7 +123,8 @@ func setup( bag, hotbar, item_catalog, - cooler_capacity + cooler_capacity, + network_session ) _hotbar_ui.setup(hotbar, bag, item_catalog, fishing_spot) _fishing_shop.setup( diff --git a/ui/network/join_game_page.gd b/ui/network/join_game_page.gd new file mode 100644 index 0000000..9952e0c --- /dev/null +++ b/ui/network/join_game_page.gd @@ -0,0 +1,175 @@ +class_name JoinGamePage +extends Control + +signal join_requested(endpoint: String) +signal back_requested + +@onready var _address: LineEdit = %Address +@onready var _join_button: BubbleButton = %JoinButton +@onready var _cancel_button: BubbleButton = %CancelButton +@onready var _back_button: BubbleButton = %BackButton +@onready var _open_close_button: BubbleButton = %OpenCloseButton +@onready var _status: Label = %Status +@onready var _session_summary: Label = %SessionSummary + +var _network_session: NetworkSession +var _saved_servers: SavedServerStore +var _gameplay_context: bool = false + + +func _ready() -> void: + _join_button.pressed.connect(_on_join_pressed) + _cancel_button.pressed.connect(_on_cancel_pressed) + _back_button.pressed.connect(_on_back_pressed) + _open_close_button.pressed.connect(_on_open_close_pressed) + _address.text_submitted.connect(_on_address_submitted) + hide() + + +func setup( + network_session: NetworkSession, + saved_servers: SavedServerStore, + gameplay_context: bool, +) -> void: + _network_session = network_session + _saved_servers = saved_servers + _gameplay_context = gameplay_context + if not _network_session.state_changed.is_connected(_on_state_changed): + _network_session.state_changed.connect(_on_state_changed) + if not _network_session.status_message_changed.is_connected( + _on_status_message_changed + ): + _network_session.status_message_changed.connect( + _on_status_message_changed + ) + if not _network_session.connection_error.is_connected( + _on_connection_error + ): + _network_session.connection_error.connect(_on_connection_error) + if not _network_session.peer_count_changed.is_connected( + _on_peer_count_changed + ): + _network_session.peer_count_changed.connect(_on_peer_count_changed) + _refresh() + + +func open_page(preserved_endpoint: String = "") -> void: + if not preserved_endpoint.is_empty(): + _address.text = preserved_endpoint + elif _address.text.is_empty(): + _address.text = "127.0.0.1:7777" + show() + _refresh() + _address.grab_focus() + _address.select_all() + + +func close_page() -> void: + hide() + get_viewport().gui_release_focus() + + +func get_endpoint_text() -> String: + return _address.text + + +func set_status(message: String) -> void: + _status.text = message + + +func _on_join_pressed() -> void: + _request_join() + + +func _on_address_submitted(_value: String) -> void: + _request_join() + + +func _request_join() -> void: + if _network_session.state in [ + NetworkSession.State.CONNECTION_FAILED, + NetworkSession.State.SERVER_LOST, + ]: + _network_session.reset_failure() + var endpoint: ConnectionEndpoint = EndpointParser.parse(_address.text) + if not endpoint.is_valid(): + _status.text = endpoint.error_message + return + _address.text = endpoint.normalized_display + join_requested.emit(endpoint.normalized_display) + + +func _on_cancel_pressed() -> void: + if _network_session != null: + _network_session.cancel_connection() + _refresh() + + +func _on_back_pressed() -> void: + if ( + _network_session != null + and _network_session.state in [ + NetworkSession.State.CONNECTING, + NetworkSession.State.AUTHENTICATING, + ] + ): + _network_session.cancel_connection() + back_requested.emit() + + +func _on_open_close_pressed() -> void: + if _network_session == null or not _network_session.is_host(): + return + _network_session.set_host_open(not _network_session.is_open_host()) + _refresh() + + +func _on_state_changed(_state: NetworkSession.State) -> void: + _refresh() + + +func _on_status_message_changed(message: String) -> void: + _status.text = message + _refresh() + + +func _on_connection_error(message: String) -> void: + _status.text = message + _refresh() + + +func _on_peer_count_changed(player_count: int, max_players: int) -> void: + _session_summary.text = "%d / %d players" % [ + player_count, + max_players, + ] + + +func _refresh() -> void: + if not is_node_ready() or _network_session == null: + return + var connecting: bool = _network_session.state in [ + NetworkSession.State.CONNECTING, + NetworkSession.State.AUTHENTICATING, + ] + _address.editable = not connecting + _join_button.disabled = connecting + _cancel_button.visible = connecting + _open_close_button.visible = ( + _gameplay_context and _network_session.is_host() + ) + if _open_close_button.visible: + _open_close_button.text = ( + "close\ngame" + if _network_session.is_open_host() + else "open\ngame" + ) + _session_summary.visible = ( + _gameplay_context + and _network_session.state != NetworkSession.State.INACTIVE + ) + if _session_summary.visible: + _session_summary.text = "%d / %d players" % [ + _network_session.get_player_count(), + _network_session.get_session_max_players(), + ] diff --git a/ui/network/join_game_page.gd.uid b/ui/network/join_game_page.gd.uid new file mode 100644 index 0000000..c1bdf89 --- /dev/null +++ b/ui/network/join_game_page.gd.uid @@ -0,0 +1 @@ +uid://dch42c44t4rj5 diff --git a/ui/network/join_game_page.tscn b/ui/network/join_game_page.tscn new file mode 100644 index 0000000..75e7e8f --- /dev/null +++ b/ui/network/join_game_page.tscn @@ -0,0 +1,145 @@ +[gd_scene load_steps=5 format=3] + +[ext_resource type="Script" path="res://ui/network/join_game_page.gd" id="1_script"] +[ext_resource type="Theme" path="res://ui/game_theme.tres" id="2_theme"] +[ext_resource type="PackedScene" path="res://ui/components/bubble_menu/bubble_button.tscn" id="3_bubble"] + +[sub_resource type="StyleBoxFlat" id="StyleBox_paper"] +bg_color = Color(0.93, 0.885, 0.73, 1) +shadow_color = Color(0.02, 0.075, 0.11, 0.42) +shadow_size = 10 +shadow_offset = Vector2(7, 8) +corner_radius_top_left = 54 +corner_radius_top_right = 46 +corner_radius_bottom_right = 58 +corner_radius_bottom_left = 48 + +[node name="JoinGamePage" type="Control"] +visible = false +layout_mode = 3 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +theme = ExtResource("2_theme") +script = ExtResource("1_script") + +[node name="Paper" type="PanelContainer" parent="."] +layout_mode = 0 +offset_left = 340.0 +offset_top = 132.0 +offset_right = 940.0 +offset_bottom = 570.0 +theme_override_styles/panel = SubResource("StyleBox_paper") + +[node name="Margin" type="MarginContainer" parent="Paper"] +layout_mode = 2 +theme_override_constants/margin_left = 70 +theme_override_constants/margin_top = 52 +theme_override_constants/margin_right = 70 +theme_override_constants/margin_bottom = 52 + +[node name="Layout" type="VBoxContainer" parent="Paper/Margin"] +layout_mode = 2 +theme_override_constants/separation = 18 +alignment = 1 + +[node name="Title" type="Label" parent="Paper/Margin/Layout"] +layout_mode = 2 +theme_override_colors/font_color = Color(0.035, 0.145, 0.22, 1) +theme_override_font_sizes/font_size = 34 +text = "join game" +horizontal_alignment = 1 + +[node name="Hint" type="Label" parent="Paper/Margin/Layout"] +layout_mode = 2 +theme_override_colors/font_color = Color(0.035, 0.145, 0.22, 1) +theme_override_font_sizes/font_size = 17 +text = "hostname or IP address • optional port" +horizontal_alignment = 1 + +[node name="Address" type="LineEdit" parent="Paper/Margin/Layout"] +unique_name_in_owner = true +custom_minimum_size = Vector2(0, 52) +layout_mode = 2 +theme_override_colors/font_color = Color(0.035, 0.145, 0.22, 1) +theme_override_font_sizes/font_size = 22 +placeholder_text = "example.net:7777" +alignment = 1 +max_length = 300 + +[node name="Status" type="Label" parent="Paper/Margin/Layout"] +unique_name_in_owner = true +custom_minimum_size = Vector2(0, 42) +layout_mode = 2 +theme_override_colors/font_color = Color(0.035, 0.145, 0.22, 1) +theme_override_font_sizes/font_size = 17 +text = "Direct UDP connection • default port 7777" +horizontal_alignment = 1 +vertical_alignment = 1 +autowrap_mode = 2 + +[node name="SessionSummary" type="Label" parent="Paper/Margin/Layout"] +unique_name_in_owner = true +visible = false +layout_mode = 2 +theme_override_colors/font_color = Color(0.035, 0.145, 0.22, 1) +theme_override_font_sizes/font_size = 17 +text = "1 / 8 players" +horizontal_alignment = 1 + +[node name="Actions" type="HBoxContainer" parent="Paper/Margin/Layout"] +layout_mode = 2 +theme_override_constants/separation = 12 +alignment = 1 + +[node name="JoinButton" parent="Paper/Margin/Layout/Actions" instance=ExtResource("3_bubble")] +unique_name_in_owner = true +custom_minimum_size = Vector2(112, 106) +layout_mode = 2 +text = "join" +neutral_size = Vector2(112, 106) +minimum_font_size = 18 +maximum_font_size = 24 +horizontal_amplitude = 0.0 +vertical_amplitude = 0.0 +deformation_amplitude = 0.0 + +[node name="OpenCloseButton" parent="Paper/Margin/Layout/Actions" instance=ExtResource("3_bubble")] +unique_name_in_owner = true +visible = false +custom_minimum_size = Vector2(112, 106) +layout_mode = 2 +text = "open\ngame" +neutral_size = Vector2(112, 106) +minimum_font_size = 17 +maximum_font_size = 22 +horizontal_amplitude = 0.0 +vertical_amplitude = 0.0 +deformation_amplitude = 0.0 + +[node name="CancelButton" parent="Paper/Margin/Layout/Actions" instance=ExtResource("3_bubble")] +unique_name_in_owner = true +visible = false +custom_minimum_size = Vector2(98, 94) +layout_mode = 2 +text = "cancel" +neutral_size = Vector2(98, 94) +minimum_font_size = 16 +maximum_font_size = 20 +horizontal_amplitude = 0.0 +vertical_amplitude = 0.0 +deformation_amplitude = 0.0 + +[node name="BackButton" parent="Paper/Margin/Layout/Actions" instance=ExtResource("3_bubble")] +unique_name_in_owner = true +custom_minimum_size = Vector2(98, 94) +layout_mode = 2 +text = "back" +neutral_size = Vector2(98, 94) +minimum_font_size = 16 +maximum_font_size = 20 +horizontal_amplitude = 0.0 +vertical_amplitude = 0.0 +deformation_amplitude = 0.0 diff --git a/ui/pause_menu.gd b/ui/pause_menu.gd index ab68d67..7091273 100644 --- a/ui/pause_menu.gd +++ b/ui/pause_menu.gd @@ -19,17 +19,22 @@ const BubbleConfirmationPageType = preload( "res://ui/components/bubble_menu/bubble_confirmation_page.gd" ) const FishingSpotType = preload("res://fishing/fishing_spot.gd") +const NetworkSessionType = preload("res://network/network_session.gd") +const SavedServerStoreType = preload("res://network/saved_server_store.gd") +const JoinGamePageType = preload("res://ui/network/join_game_page.gd") signal return_to_title_requested signal reset_progress_requested signal quit_requested signal menu_visibility_changed(is_open: bool) +signal join_game_requested(endpoint: String) enum ConfirmationAction { NONE, RETURN_TO_TITLE, RESET_PROGRESS, QUIT_ANYWAY, + JOIN_ANOTHER, } enum CloseReason { @@ -54,11 +59,14 @@ enum CloseReason { ) @onready var _feedback: Label = %FeedbackLabel @onready var _save_button: BubbleButton = %SaveButton +@onready var _join_game_page: JoinGamePageType = %JoinGamePage var _player: PlayerType var _save_manager: SaveManagerType var _settings_manager: SettingsManagerType var _fishing_spot: FishingSpotType +var _network_session: NetworkSessionType +var _saved_servers: SavedServerStoreType var _prior_movement_enabled: bool = true var _prior_camera_enabled: bool = true var _prior_mouse_mode: Input.MouseMode = Input.MOUSE_MODE_VISIBLE @@ -71,12 +79,14 @@ var _root_transition_generation: int = 0 var _closing_menu: bool = false var _backdrop_fade: Tween var _backdrop_fade_generation: int = 0 +var _pending_join_endpoint: String = "" func _ready() -> void: %ResumeButton.pressed.connect(resume) _save_button.pressed.connect(_save_now) %SettingsButton.pressed.connect(_open_settings) + %JoinGameButton.pressed.connect(_open_join_game) %ReturnToTitleButton.pressed.connect(_confirm_return_to_title) %ResetProgressButton.pressed.connect(_confirm_reset_progress) %QuitButton.pressed.connect(_request_quit) @@ -87,6 +97,8 @@ func _ready() -> void: _settings_panel.navigation_transition_started.connect( _emit_transition_flurry ) + _join_game_page.join_requested.connect(_on_join_game_requested) + _join_game_page.back_requested.connect(_close_join_game) _dim_background.color.a = 0.0 _confirmation_page.hide_page() resized.connect(_update_responsive_pause_stage) @@ -98,11 +110,16 @@ func setup( save_manager: SaveManagerType, settings_manager: SettingsManagerType, fishing_spot: FishingSpotType, + network_session: NetworkSessionType, + saved_servers: SavedServerStoreType, ) -> void: _player = player _save_manager = save_manager _settings_manager = settings_manager _fishing_spot = fishing_spot + _network_session = network_session + _saved_servers = saved_servers + _join_game_page.setup(network_session, saved_servers, true) if not _fishing_spot.bite_activated.is_connected(_on_bite_activated): _fishing_spot.bite_activated.connect(_on_bite_activated) @@ -121,6 +138,7 @@ func open_menu() -> void: Input.mouse_mode = Input.MOUSE_MODE_VISIBLE _feedback.text = "" _settings_panel.hide() + _join_game_page.close_page() _confirmation_page.hide_page() _confirmation_action = ConfirmationAction.NONE _action_in_progress = false @@ -169,6 +187,8 @@ func handle_escape() -> bool: return true if _confirmation_page.visible: _close_confirmation() + elif _join_game_page.visible: + _close_join_game() elif _settings_panel.visible: _settings_panel.handle_back() else: @@ -203,6 +223,47 @@ func _open_settings() -> void: _begin_root_exit(_finish_open_settings) +func _open_join_game() -> void: + if ( + _action_in_progress + or _root_transition_active + or _confirmation_action != ConfirmationAction.NONE + ): + return + _begin_root_exit(_finish_open_join_game) + + +func _finish_open_join_game() -> void: + _join_game_page.open_page() + + +func _close_join_game() -> void: + _join_game_page.close_page() + _begin_root_entry(true) + + +func _on_join_game_requested(endpoint: String) -> void: + if _action_in_progress or _root_transition_active: + return + _pending_join_endpoint = endpoint + _open_confirmation( + ConfirmationAction.JOIN_ANOTHER, + "join another game?", + ( + "your progression will be saved first. " + + "current players will be disconnected if you are hosting." + ), + "save and join", + false + ) + + +func report_network_error(message: String) -> void: + _feedback.text = message + if _join_game_page.visible: + _join_game_page.set_status(message) + + func _finish_open_settings() -> void: _settings_panel.open_panel( _settings_manager, @@ -347,6 +408,10 @@ func _finish_confirmation_accept(action: ConfirmationAction) -> void: reset_progress_requested.emit() ConfirmationAction.QUIT_ANYWAY: quit_requested.emit() + ConfirmationAction.JOIN_ANOTHER: + _join_game_page.close_page() + join_game_requested.emit(_pending_join_endpoint) + _pending_join_endpoint = "" _: _action_in_progress = false _begin_root_entry(false) @@ -421,6 +486,7 @@ func _finish_close(reason: CloseReason, restore_controls: bool) -> void: _dim_background.color.a = 0.0 _dim_background.hide() _settings_panel.hide() + _join_game_page.close_page() _root_page.hide_page() _confirmation_page.hide_page() _confirmation_action = ConfirmationAction.NONE diff --git a/ui/pause_menu.tscn b/ui/pause_menu.tscn index 318caa5..3f51fd6 100644 --- a/ui/pause_menu.tscn +++ b/ui/pause_menu.tscn @@ -1,4 +1,4 @@ -[gd_scene load_steps=10 format=3] +[gd_scene load_steps=11 format=3] [ext_resource type="Script" path="res://ui/pause_menu.gd" id="1_script"] [ext_resource type="PackedScene" path="res://ui/settings_panel.tscn" id="2_settings"] @@ -9,6 +9,7 @@ [ext_resource type="Resource" path="res://ui/components/bubble_menu/bubble_menu_profile.tres" id="7_profile"] [ext_resource type="Script" path="res://ui/components/bubble_menu/bubble_transition_flurry.gd" id="8_flurry"] [ext_resource type="PackedScene" path="res://ui/components/bubble_menu/bubble_confirmation_page.tscn" id="9_confirmation"] +[ext_resource type="PackedScene" path="res://ui/network/join_game_page.tscn" id="10_join_page"] [node name="PauseMenu" type="Control"] unique_name_in_owner = true @@ -76,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/SettingsButton"), NodePath("BubbleCluster/ReturnToTitleButton"), NodePath("BubbleCluster/ResetProgressButton"), NodePath("BubbleCluster/QuitButton")]) -focus_paths = Array[NodePath]([NodePath("BubbleCluster/ResumeButton"), NodePath("BubbleCluster/SaveButton"), 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/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")]) initial_focus_path = NodePath("BubbleCluster/ResumeButton") back_focus_path = NodePath("BubbleCluster/ResumeButton") maximum_layout_size = Vector2(720, 520) @@ -134,6 +135,19 @@ minimum_font_size = 16 maximum_font_size = 23 motion_phase = 2.05 +[node name="JoinGameButton" parent="ResponsivePauseStage/PausePresentationScaleRoot/RootPage/BubbleCluster" instance=ExtResource("5_bubble")] +unique_name_in_owner = true +custom_minimum_size = Vector2(0, 0) +text = "join\ngame" +accessibility_name = "join game" +neutral_size = Vector2(126, 120) +desktop_anchor = Vector2(105, 320) +compact_anchor = Vector2(105, 320) +compact_minimum_size = Vector2(96, 92) +minimum_font_size = 15 +maximum_font_size = 21 +motion_phase = 2.55 + [node name="ReturnToTitleButton" parent="ResponsivePauseStage/PausePresentationScaleRoot/RootPage/BubbleCluster" instance=ExtResource("5_bubble")] unique_name_in_owner = true custom_minimum_size = Vector2(0, 0) @@ -209,3 +223,13 @@ anchor_right = 1.0 anchor_bottom = 1.0 grow_horizontal = 2 grow_vertical = 2 + +[node name="JoinGamePage" parent="ResponsivePauseStage/PausePresentationScaleRoot" instance=ExtResource("10_join_page")] +unique_name_in_owner = true +z_index = 4 +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 diff --git a/ui/player_menu.gd b/ui/player_menu.gd index 83bfa9c..7759634 100644 --- a/ui/player_menu.gd +++ b/ui/player_menu.gd @@ -8,6 +8,7 @@ const FishDataType = preload("res://fish/fish_data.gd") const FishBuyerProfileType = preload("res://economy/fish_buyer_profile.gd") const FishSaleResultType = preload("res://economy/fish_sale_result.gd") const FishSaleServiceType = preload("res://economy/fish_sale_service.gd") +const NetworkSessionType = preload("res://network/network_session.gd") const FishInventoryType = preload("res://inventory/fish_inventory.gd") const FishPoolType = preload("res://fish/fish_pool.gd") const FishingSpotType = preload("res://fishing/fishing_spot.gd") @@ -225,6 +226,7 @@ var _inventory: FishInventoryType var _collection_log: CollectionLogType var _wallet: PlayerWalletType var _sale_service: FishSaleServiceType +var _network_session: NetworkSessionType var _default_buyer: FishBuyerProfileType var _catalog: FishPoolType var _fishing_spot: FishingSpotType @@ -353,6 +355,7 @@ func setup( hotbar: PlayerHotbarType, item_catalog: ItemCatalogType, cooler_capacity: PlayerCoolerCapacityType, + network_session: NetworkSessionType, ) -> void: _player = player _inventory = inventory @@ -366,6 +369,7 @@ func setup( _hotbar = hotbar _item_catalog = item_catalog _cooler_capacity = cooler_capacity + _network_session = network_session _fish_selection.clear() if not _inventory.catches_changed.is_connected(_on_inventory_changed): _inventory.catches_changed.connect(_on_inventory_changed) @@ -2337,6 +2341,23 @@ func _update_sale_summary() -> void: _sale_unavailable.text = "" _sale_unavailable.visible = false return + if not _can_use_shared_world_actions(): + _selection_summary.text = ( + "1 fish selected" + if selected_count == 1 + else "%d fish selected" % selected_count + ) + _selection_status.set_content("selected", str(selected_count)) + _offer_status.set_content("pelican offer", "host only") + _sell_button.disabled = true + _sell_bubble.disabled = true + _sell_bubble.persistent_mark = false + _sell_bubble.refresh_ink_state() + _sale_unavailable.text = ( + "Selling in joined games is coming in a later multiplayer phase." + ) + _sale_unavailable.visible = true + return var preview: FishSaleResultType = ( _sale_service.preview_batch(selected_ids, _default_buyer) if _sale_service != null @@ -2407,6 +2428,11 @@ func _on_favorite_pressed() -> void: func _on_sell_pressed() -> void: + if not _can_use_shared_world_actions(): + _transaction_feedback.text = ( + "Selling in joined games is coming in a later multiplayer phase." + ) + return var selected_ids: Array[StringName] = _fish_selection.get_selected_ids() if ( _inventory == null @@ -2455,6 +2481,7 @@ func _on_sell_pressed() -> void: func _on_confirm_sale_pressed() -> void: if ( _sale_in_progress + or not _can_use_shared_world_actions() or _sale_service == null or _confirmation_catch_ids.is_empty() or _confirmation_buyer == null @@ -2484,6 +2511,13 @@ func _on_confirm_sale_pressed() -> void: _inventory_tab.grab_focus() +func _can_use_shared_world_actions() -> bool: + return ( + _network_session == null + or _network_session.can_use_host_gameplay() + ) + + func _close_sale_confirmation() -> void: var was_visible: bool = _sale_confirmation.visible _confirmation_catch_ids.clear() diff --git a/ui/title_screen.gd b/ui/title_screen.gd index a5d5a9f..ee1062e 100644 --- a/ui/title_screen.gd +++ b/ui/title_screen.gd @@ -16,6 +16,9 @@ const BubbleClusterType = preload( const TitleConfirmationBubblePageType = preload( "res://ui/title_confirmation_bubble_page.gd" ) +const NetworkSessionType = preload("res://network/network_session.gd") +const SavedServerStoreType = preload("res://network/saved_server_store.gd") +const JoinGamePageType = preload("res://ui/network/join_game_page.gd") const DECORATIVE_FISH_TEXTURES: Array[Texture2D] = [ preload("res://fish/species/bass/fish_bass_striped.png"), preload("res://fish/species/bluegill/fish_bluegill.png"), @@ -84,8 +87,10 @@ const HOST_PRESENTATION_OUT_DURATION: float = 1.70 const HOST_PRESENTATION_IN_DURATION: float = 1.85 const HOST_CLUSTER_SAFE_MARGIN: float = 24.0 -signal gameplay_requested +signal new_game_requested +signal continue_game_requested signal quit_requested +signal join_game_requested(endpoint: String) enum ConfirmationAction { NONE, @@ -98,6 +103,7 @@ enum ConfirmationAction { @onready var _settings_button: BubbleButtonType = %SettingsButton @onready var _delete_button: BubbleButtonType = %DeleteSaveButton @onready var _quit_button: BubbleButtonType = %QuitButton +@onready var _join_game_button: BubbleButtonType = %JoinGameButton @onready var _new_game_label: Label = %NewGameLabel @onready var _delete_save_label: Label = %DeleteSaveLabel @onready var _feedback_label: Label = %FeedbackLabel @@ -129,6 +135,7 @@ enum ConfirmationAction { @onready var _bubble_field: BubbleClusterType = %BubbleField @onready var _start_prompt_center: CenterContainer = %StartPromptCenter @onready var _start_prompt_label: Label = %StartPromptLabel +@onready var _join_game_page: JoinGamePageType = %JoinGamePage var _save_manager: SaveManagerType var _settings_manager: SettingsManagerType @@ -188,6 +195,7 @@ func _ready() -> void: _on_continue_stats_focus_changed.bind(false) ) _new_game_button.pressed.connect(_on_new_game_pressed) + _join_game_button.pressed.connect(_open_join_game) _settings_button.pressed.connect(_open_settings) _delete_button.pressed.connect(_on_delete_pressed) %QuitButton.pressed.connect(_on_quit_pressed) @@ -221,9 +229,14 @@ func _ready() -> void: func setup( save_manager: SaveManagerType, settings_manager: SettingsManagerType, + network_session: NetworkSessionType, + saved_servers: SavedServerStoreType, ) -> void: _save_manager = save_manager _settings_manager = settings_manager + _join_game_page.setup(network_session, saved_servers, false) + _join_game_page.join_requested.connect(join_game_requested.emit) + _join_game_page.back_requested.connect(_close_join_game) Input.mouse_mode = Input.MOUSE_MODE_VISIBLE _refresh_save_inspection() show() @@ -239,12 +252,45 @@ func reopen() -> void: _prepare_awaiting_start_input() _reset_confirmation() _settings_panel.hide() + _join_game_page.close_page() _refresh_save_inspection() show() _start_decorative_presentation() _start_entry_prompt_animation() +func open_join_game_page(endpoint: String = "") -> void: + _cancel_title_entry_transition() + _awaiting_start_input = false + _start_prompt_center.hide() + _presentation_center.hide() + _settings_panel.hide() + _confirmation_page.hide_page() + _join_game_page.open_page(endpoint) + + +func report_network_error(message: String) -> void: + _join_game_page.set_status(message) + if not _join_game_page.visible: + _feedback_label.text = message + _feedback_label.show() + _feedback_label.modulate.a = 1.0 + + +func _open_join_game() -> void: + if _action_in_progress or _is_confirmation_active(): + return + open_join_game_page() + + +func _close_join_game() -> void: + _join_game_page.close_page() + _presentation_center.show() + _button_center.show() + _start_prompt_center.hide() + _focus_initial_button() + + func is_awaiting_start_input() -> bool: return _awaiting_start_input @@ -347,6 +393,7 @@ func _get_title_buttons() -> Array[BubbleButton]: return [ _continue_button, _new_game_button, + _join_game_button, _settings_button, _delete_button, _quit_button, @@ -438,6 +485,11 @@ func _process(delta: float) -> void: func _input(event: InputEvent) -> void: if not visible: return + if _join_game_page.visible: + if event.is_action_pressed("ui_cancel"): + _close_join_game() + get_viewport().set_input_as_handled() + return if ( _title_settings_transition_active or _title_entry_transition_active @@ -851,12 +903,7 @@ func _on_continue_pressed() -> void: return _hide_continue_stats_context() _action_in_progress = true - if _save_manager.load_player_data(): - _feedback_label.text = "save loaded." - gameplay_requested.emit() - else: - _refresh_save_inspection() - _feedback_label.text = "failed to load save. the original was preserved." + continue_game_requested.emit() _action_in_progress = false @@ -870,8 +917,7 @@ func _on_new_game_pressed() -> void: return _hide_continue_stats_context() if _inspection.status == SaveInspectionType.Status.MISSING: - if _save_manager.initialize_new_game(): - gameplay_requested.emit() + new_game_requested.emit() return if _inspection.status == SaveInspectionType.Status.UNSUPPORTED_VERSION: _feedback_label.text = ( @@ -917,6 +963,14 @@ func _on_confirmation_accepted() -> void: var action: ConfirmationAction = _confirmation_action _confirmation_page.lock_interaction() _action_in_progress = true + if action == ConfirmationAction.NEW_GAME: + _confirmation_action = ConfirmationAction.NONE + _confirmation_transition_generation += 1 + _cancel_confirmation_transition() + _confirmation_page.hide_page() + new_game_requested.emit() + _action_in_progress = false + return if not _save_manager.delete_progression_save(): _feedback_label.text = "failed to delete saved progression." _action_in_progress = false @@ -925,15 +979,8 @@ func _on_confirmation_accepted() -> void: return _save_manager.initialize_new_game() _refresh_save_inspection() - if action == ConfirmationAction.NEW_GAME: - _confirmation_action = ConfirmationAction.NONE - _confirmation_transition_generation += 1 - _cancel_confirmation_transition() - _confirmation_page.hide_page() - gameplay_requested.emit() - else: - _feedback_label.text = "saved progression deleted." - _begin_confirmation_return() + _feedback_label.text = "saved progression deleted." + _begin_confirmation_return() _action_in_progress = false diff --git a/ui/title_screen.tscn b/ui/title_screen.tscn index 4f7e080..e591a0f 100644 --- a/ui/title_screen.tscn +++ b/ui/title_screen.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/title_screen.gd" id="1_script"] [ext_resource type="Theme" path="res://ui/game_theme.tres" id="2_theme"] @@ -10,6 +10,7 @@ [ext_resource type="Script" path="res://ui/components/bubble_menu/bubble_cluster.gd" id="8_bubble_cluster"] [ext_resource type="Resource" path="res://ui/components/bubble_menu/bubble_menu_profile.tres" id="9_bubble_profile"] [ext_resource type="PackedScene" path="res://ui/title_confirmation_bubble_page.tscn" id="10_confirmation_page"] +[ext_resource type="PackedScene" path="res://ui/network/join_game_page.tscn" id="11_join_page"] [sub_resource type="ShaderMaterial" id="ShaderMaterial_title_water"] shader = ExtResource("4_water_shader") @@ -314,6 +315,28 @@ motion_period = 4.9 motion_phase = 2.5 deformation_period = 6.3 +[node name="JoinGameButton" type="Button" parent="ResponsiveTitleStage/TitlePresentationScaleRoot/Center/MainContent/ButtonCenter/BubbleLayoutSlot/BubbleMotionRoot/BubbleField"] +unique_name_in_owner = true +offset_left = 126.0 +offset_top = 224.0 +offset_right = 248.0 +offset_bottom = 342.0 +text = "join\ngame" +script = ExtResource("7_bubble_button") +profile = ExtResource("9_bubble_profile") +neutral_size = Vector2(122, 118) +desktop_anchor = Vector2(187, 268) +compact_anchor = Vector2(187, 268) +compact_minimum_size = Vector2(82, 80) +minimum_font_size = 15 +maximum_font_size = 22 +horizontal_amplitude = 1.7 +vertical_amplitude = 3.8 +motion_period = 5.2 +motion_phase = 3.1 +deformation_amplitude = 0.015 +deformation_period = 5.7 + [node name="DeleteSaveButton" type="Button" parent="ResponsiveTitleStage/TitlePresentationScaleRoot/Center/MainContent/ButtonCenter/BubbleLayoutSlot/BubbleMotionRoot/BubbleField"] unique_name_in_owner = true offset_left = -11.0 @@ -407,3 +430,13 @@ offset_right = 360.0 offset_bottom = 300.0 grow_horizontal = 2 grow_vertical = 2 + +[node name="JoinGamePage" parent="ResponsiveTitleStage/TitlePresentationScaleRoot" instance=ExtResource("11_join_page")] +unique_name_in_owner = true +z_index = 220 +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 diff --git a/world/water_recovery_controller.gd b/world/water_recovery_controller.gd index 1333b6d..d5f46ae 100644 --- a/world/water_recovery_controller.gd +++ b/world/water_recovery_controller.gd @@ -2,6 +2,7 @@ class_name WaterRecoveryController extends Node signal recovery_starting +signal local_respawn_completed(entry_position: Vector3) enum RecoveryState { IDLE, @@ -165,6 +166,7 @@ func _respawn_player() -> void: _player.global_transform = respawn_transform _player.restore_gameplay_orientation_after_recovery() _player.velocity = Vector3.ZERO + local_respawn_completed.emit(_entry_position) func _finish_recovery() -> void: