Add direct-connect multiplayer foundation

This commit is contained in:
Alexander Sellite 2026-07-29 15:01:46 -04:00
parent 9e9850dd20
commit 24f8263175
36 changed files with 2633 additions and 45 deletions

View file

@ -26,6 +26,7 @@ const PlayerType = preload("res://player/player.gd")
const FishableWaterRegionType = preload( const FishableWaterRegionType = preload(
"res://world/fishable_water_region.gd" "res://world/fishable_water_region.gd"
) )
const NetworkSessionType = preload("res://network/network_session.gd")
signal status_changed(status: String) signal status_changed(status: String)
signal catch_display_changed( signal catch_display_changed(
@ -104,6 +105,7 @@ var _item_catalog: ItemCatalogType
var _fishing_upgrades: PlayerFishingUpgradesType var _fishing_upgrades: PlayerFishingUpgradesType
var _item_effects: PlayerItemEffectsType var _item_effects: PlayerItemEffectsType
var _cooler_capacity: PlayerCoolerCapacityType var _cooler_capacity: PlayerCoolerCapacityType
var _network_session: NetworkSessionType
var _active_player: PlayerType var _active_player: PlayerType
var _state_time_remaining: float = 0.0 var _state_time_remaining: float = 0.0
var _cast_charge: float = 0.0 var _cast_charge: float = 0.0
@ -147,6 +149,7 @@ func setup(
fishing_upgrades: PlayerFishingUpgradesType, fishing_upgrades: PlayerFishingUpgradesType,
item_effects: PlayerItemEffectsType, item_effects: PlayerItemEffectsType,
cooler_capacity: PlayerCoolerCapacityType, cooler_capacity: PlayerCoolerCapacityType,
network_session: NetworkSessionType = null,
) -> void: ) -> void:
_local_player = local_player _local_player = local_player
_local_inventory = local_inventory _local_inventory = local_inventory
@ -157,6 +160,7 @@ func setup(
_fishing_upgrades = fishing_upgrades _fishing_upgrades = fishing_upgrades
_item_effects = item_effects _item_effects = item_effects
_cooler_capacity = cooler_capacity _cooler_capacity = cooler_capacity
_network_session = network_session
if not _local_inventory.catches_changed.is_connected( if not _local_inventory.catches_changed.is_connected(
_on_cooler_availability_changed _on_cooler_availability_changed
): ):
@ -195,6 +199,8 @@ func can_open_system_menu() -> bool:
func can_open_fishing_shop() -> bool: func can_open_fishing_shop() -> bool:
return ( return (
_can_use_shared_gameplay()
and
_gameplay_input_enabled _gameplay_input_enabled
and not _external_input_blocked and not _external_input_blocked
and _local_menu_input_owners.is_empty() and _local_menu_input_owners.is_empty()
@ -204,6 +210,8 @@ func can_open_fishing_shop() -> bool:
func is_ready_for_shop_transaction() -> bool: func is_ready_for_shop_transaction() -> bool:
return ( return (
_can_use_shared_gameplay()
and
_gameplay_input_enabled _gameplay_input_enabled
and not _external_input_blocked and not _external_input_blocked
and state == FishingState.READY and state == FishingState.READY
@ -367,6 +375,13 @@ func _unhandled_input(event: InputEvent) -> void:
return return
if not event.is_action("fish_primary"): if not event.is_action("fish_primary"):
return 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(): if event.is_pressed():
match state: 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: func has_active_fishing_rod() -> bool:
if ( if (
_local_bag == null _local_bag == null

View file

@ -33,6 +33,16 @@ const PixelationResetOverlayType = preload(
const WorldPixelationPostprocessType = preload( const WorldPixelationPostprocessType = preload(
"res://main/world_pixelation_postprocess.gd" "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 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 = ( @onready var _world_pixelation: WorldPixelationPostprocessType = (
%WorldPixelationPostprocess %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 _gameplay_started: bool = false
var _shop_interaction: FishingShopInteractionType var _shop_interaction: FishingShopInteractionType
@ -66,11 +85,34 @@ var _title_music_tween: Tween
var _title_music_transition_generation: int = 0 var _title_music_transition_generation: int = 0
var _title_music_requested: bool = false var _title_music_requested: bool = false
var _quit_in_progress: 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: func _ready() -> void:
_player.global_transform = _test_world.get_player_spawn_transform() _player.global_transform = _test_world.get_player_spawn_transform()
_player.velocity = Vector3.ZERO _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.fish_sale_service.setup(
_player.inventory, _player.inventory,
_player.wallet _player.wallet
@ -103,7 +145,8 @@ func _ready() -> void:
item_catalog, item_catalog,
_player.fishing_upgrades, _player.fishing_upgrades,
_player.item_effects, _player.item_effects,
_player.cooler_capacity _player.cooler_capacity,
_network_session
) )
_game_ui.setup( _game_ui.setup(
_player, _player,
@ -121,7 +164,8 @@ func _ready() -> void:
_player.fishing_upgrades, _player.fishing_upgrades,
_shop_interaction, _shop_interaction,
_player.item_effects, _player.item_effects,
_player.cooler_capacity _player.cooler_capacity,
_network_session
) )
_water_recovery.setup( _water_recovery.setup(
_player, _player,
@ -155,16 +199,26 @@ func _ready() -> void:
_apply_runtime_settings(_settings_manager.current_settings) _apply_runtime_settings(_settings_manager.current_settings)
_set_gameplay_active(false) _set_gameplay_active(false)
var title_screen: TitleScreenType = _game_ui.get_title_screen() 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.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() var pause_menu: PauseMenuType = _game_ui.get_pause_menu()
pause_menu.setup( pause_menu.setup(
_player, _player,
_save_manager, _save_manager,
_settings_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( pause_menu.return_to_title_requested.connect(
_on_return_to_title_requested _on_return_to_title_requested
) )
@ -184,6 +238,9 @@ func _ready() -> void:
_water_recovery.recovery_starting.connect( _water_recovery.recovery_starting.connect(
_on_water_recovery_starting _on_water_recovery_starting
) )
_water_recovery.local_respawn_completed.connect(
_on_local_respawn_completed
)
_on_active_hotbar_item_changed( _on_active_hotbar_item_changed(
_player.hotbar.get_selected_slot(), _player.hotbar.get_selected_slot(),
_player.hotbar.get_selected_item_id() _player.hotbar.get_selected_item_id()
@ -302,9 +359,54 @@ func _set_gameplay_active(active: bool) -> void:
_save_manager.set_autosave_enabled(active) _save_manager.set_autosave_enabled(active)
func _on_gameplay_requested() -> void: func _on_new_game_requested() -> void:
if _gameplay_started or _quit_in_progress: if _gameplay_started or _quit_in_progress:
return 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() _fade_out_title_music()
_game_ui.get_title_screen().hide() _game_ui.get_title_screen().hide()
_set_gameplay_active(true) _set_gameplay_active(true)
@ -315,11 +417,133 @@ func _on_return_to_title_requested() -> void:
return return
var pause_menu: PauseMenuType = _game_ui.get_pause_menu() var pause_menu: PauseMenuType = _game_ui.get_pause_menu()
pause_menu.close_for_title_transition() pause_menu.close_for_title_transition()
_network_session.disconnect_session("Returned to title.")
_set_gameplay_active(false) _set_gameplay_active(false)
_game_ui.get_title_screen().reopen() _game_ui.get_title_screen().reopen()
_show_title_music(true) _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: func _on_reset_progress_requested() -> void:
if _quit_in_progress: if _quit_in_progress:
return return
@ -372,6 +596,7 @@ func _on_quit_requested() -> void:
_settings_manager.save_if_dirty() _settings_manager.save_if_dirty()
if _gameplay_started: if _gameplay_started:
_save_manager.save_if_dirty() _save_manager.save_if_dirty()
_network_session.disconnect_session("Application closing.")
_title_music_requested = false _title_music_requested = false
_replace_title_music_transition() _replace_title_music_transition()
_finish_quit() _finish_quit()

View file

@ -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://world/test_world.tscn" id="1_world"]
[ext_resource type="PackedScene" path="res://player/player.tscn" id="2_player"] [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="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://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="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"] [node name="Main" type="Node3D"]
script = ExtResource("3_main") script = ExtResource("3_main")
@ -24,6 +28,22 @@ pelican_buyer_profile = ExtResource("7_pelicans")
main_shop_buyer_profile = ExtResource("12_main_shop") main_shop_buyer_profile = ExtResource("12_main_shop")
item_catalog = ExtResource("11_items") 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="TestWorld" parent="." instance=ExtResource("1_world")]
[node name="Players" type="Node3D" parent="."] [node name="Players" type="Node3D" parent="."]

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1 @@
uid://2drengcleq7e

View file

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

View file

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

121
network/network_protocol.gd Normal file
View file

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

View file

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

816
network/network_session.gd Normal file
View file

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

View file

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

View file

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

View file

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

69
network/peer_registry.gd Normal file
View file

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

View file

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

View file

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

View file

@ -0,0 +1 @@
uid://8w70d0nrf4a7

View file

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

View file

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

View file

@ -105,6 +105,20 @@ var _showcase_restore_tween: Tween
var _showcase_camera_tween: Tween var _showcase_camera_tween: Tween
var _showcase_camera_snapshot: ShowcaseCameraSnapshot var _showcase_camera_snapshot: ShowcaseCameraSnapshot
var _showcase_restore_generation: int = 0 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: func _ready() -> void:
@ -114,6 +128,9 @@ func _ready() -> void:
func _physics_process(delta: float) -> void: func _physics_process(delta: float) -> void:
if _network_interpolation_enabled:
_update_network_interpolation(delta)
return
if _water_recovery_active: if _water_recovery_active:
velocity = Vector3.ZERO velocity = Vector3.ZERO
return return
@ -125,34 +142,50 @@ func _physics_process(delta: float) -> void:
else fall_gravity_multiplier else fall_gravity_multiplier
) )
velocity.y -= _gravity * gravity_multiplier * delta velocity.y -= _gravity * gravity_multiplier * delta
elif ( elif _movement_enabled and (
local_control_enabled (
and _movement_enabled local_control_enabled
and Input.is_action_just_pressed("jump") and Input.is_action_just_pressed("jump")
)
or (
_network_authoritative_simulation
and _network_jump_pending
)
): ):
velocity.y = jump_velocity 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.x = 0.0
velocity.z = 0.0 velocity.z = 0.0
move_and_slide() move_and_slide()
return return
var input_vector: Vector2 = Input.get_vector( var input_vector: Vector2
"move_left", var camera_basis: Basis
"move_right", if local_control_enabled:
"move_forward", input_vector = Input.get_vector(
"move_backward" "move_left",
) "move_right",
var camera_basis: Basis = _camera_yaw.global_basis "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 var move_direction: Vector3 = camera_basis.x * input_vector.x + camera_basis.z * input_vector.y
move_direction.y = 0.0 move_direction.y = 0.0
var input_strength: float = minf(input_vector.length(), 1.0) var input_strength: float = minf(input_vector.length(), 1.0)
move_direction = move_direction.normalized() move_direction = move_direction.normalized()
var speed: float = ( var speed: float = _get_network_aware_speed()
_get_current_speed() * item_effects.get_movement_multiplier() if item_effects != null:
) speed *= item_effects.get_movement_multiplier()
velocity.x = move_direction.x * speed * input_strength velocity.x = move_direction.x * speed * input_strength
velocity.z = move_direction.z * speed * input_strength velocity.z = move_direction.z * speed * input_strength
@ -243,6 +276,18 @@ func _get_current_speed() -> float:
return walk_speed 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: func _rotate_camera(delta_rotation: Vector2) -> void:
_camera_yaw.rotation.y -= delta_rotation.x _camera_yaw.rotation.y -= delta_rotation.x
var vertical_direction: float = -1.0 if invert_camera_y else 1.0 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 _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: func is_local_control_enabled() -> bool:
return local_control_enabled return local_control_enabled

View file

@ -16,6 +16,7 @@ const PlayerHotbarType = preload("res://inventory/player_hotbar.gd")
const HotbarUIType = preload("res://ui/hotbar.gd") const HotbarUIType = preload("res://ui/hotbar.gd")
const TitleScreenType = preload("res://ui/title_screen.gd") const TitleScreenType = preload("res://ui/title_screen.gd")
const PauseMenuType = preload("res://ui/pause_menu.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 FishingShopType = preload("res://ui/fishing_shop.gd")
const SettingsPanelType = preload("res://ui/settings_panel.gd") const SettingsPanelType = preload("res://ui/settings_panel.gd")
const PlayerFishingUpgradesType = preload( const PlayerFishingUpgradesType = preload(
@ -100,6 +101,7 @@ func setup(
shop_interaction: ShopInteractionType, shop_interaction: ShopInteractionType,
item_effects: PlayerItemEffectsType, item_effects: PlayerItemEffectsType,
cooler_capacity: PlayerCoolerCapacityType, cooler_capacity: PlayerCoolerCapacityType,
network_session: NetworkSessionType,
) -> void: ) -> void:
_fishing_spot = fishing_spot _fishing_spot = fishing_spot
_item_effects = item_effects _item_effects = item_effects
@ -121,7 +123,8 @@ func setup(
bag, bag,
hotbar, hotbar,
item_catalog, item_catalog,
cooler_capacity cooler_capacity,
network_session
) )
_hotbar_ui.setup(hotbar, bag, item_catalog, fishing_spot) _hotbar_ui.setup(hotbar, bag, item_catalog, fishing_spot)
_fishing_shop.setup( _fishing_shop.setup(

View file

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

View file

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

View file

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

View file

@ -19,17 +19,22 @@ const BubbleConfirmationPageType = preload(
"res://ui/components/bubble_menu/bubble_confirmation_page.gd" "res://ui/components/bubble_menu/bubble_confirmation_page.gd"
) )
const FishingSpotType = preload("res://fishing/fishing_spot.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 return_to_title_requested
signal reset_progress_requested signal reset_progress_requested
signal quit_requested signal quit_requested
signal menu_visibility_changed(is_open: bool) signal menu_visibility_changed(is_open: bool)
signal join_game_requested(endpoint: String)
enum ConfirmationAction { enum ConfirmationAction {
NONE, NONE,
RETURN_TO_TITLE, RETURN_TO_TITLE,
RESET_PROGRESS, RESET_PROGRESS,
QUIT_ANYWAY, QUIT_ANYWAY,
JOIN_ANOTHER,
} }
enum CloseReason { enum CloseReason {
@ -54,11 +59,14 @@ enum CloseReason {
) )
@onready var _feedback: Label = %FeedbackLabel @onready var _feedback: Label = %FeedbackLabel
@onready var _save_button: BubbleButton = %SaveButton @onready var _save_button: BubbleButton = %SaveButton
@onready var _join_game_page: JoinGamePageType = %JoinGamePage
var _player: PlayerType var _player: PlayerType
var _save_manager: SaveManagerType var _save_manager: SaveManagerType
var _settings_manager: SettingsManagerType var _settings_manager: SettingsManagerType
var _fishing_spot: FishingSpotType var _fishing_spot: FishingSpotType
var _network_session: NetworkSessionType
var _saved_servers: SavedServerStoreType
var _prior_movement_enabled: bool = true var _prior_movement_enabled: bool = true
var _prior_camera_enabled: bool = true var _prior_camera_enabled: bool = true
var _prior_mouse_mode: Input.MouseMode = Input.MOUSE_MODE_VISIBLE 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 _closing_menu: bool = false
var _backdrop_fade: Tween var _backdrop_fade: Tween
var _backdrop_fade_generation: int = 0 var _backdrop_fade_generation: int = 0
var _pending_join_endpoint: String = ""
func _ready() -> void: func _ready() -> void:
%ResumeButton.pressed.connect(resume) %ResumeButton.pressed.connect(resume)
_save_button.pressed.connect(_save_now) _save_button.pressed.connect(_save_now)
%SettingsButton.pressed.connect(_open_settings) %SettingsButton.pressed.connect(_open_settings)
%JoinGameButton.pressed.connect(_open_join_game)
%ReturnToTitleButton.pressed.connect(_confirm_return_to_title) %ReturnToTitleButton.pressed.connect(_confirm_return_to_title)
%ResetProgressButton.pressed.connect(_confirm_reset_progress) %ResetProgressButton.pressed.connect(_confirm_reset_progress)
%QuitButton.pressed.connect(_request_quit) %QuitButton.pressed.connect(_request_quit)
@ -87,6 +97,8 @@ func _ready() -> void:
_settings_panel.navigation_transition_started.connect( _settings_panel.navigation_transition_started.connect(
_emit_transition_flurry _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 _dim_background.color.a = 0.0
_confirmation_page.hide_page() _confirmation_page.hide_page()
resized.connect(_update_responsive_pause_stage) resized.connect(_update_responsive_pause_stage)
@ -98,11 +110,16 @@ func setup(
save_manager: SaveManagerType, save_manager: SaveManagerType,
settings_manager: SettingsManagerType, settings_manager: SettingsManagerType,
fishing_spot: FishingSpotType, fishing_spot: FishingSpotType,
network_session: NetworkSessionType,
saved_servers: SavedServerStoreType,
) -> void: ) -> void:
_player = player _player = player
_save_manager = save_manager _save_manager = save_manager
_settings_manager = settings_manager _settings_manager = settings_manager
_fishing_spot = fishing_spot _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): if not _fishing_spot.bite_activated.is_connected(_on_bite_activated):
_fishing_spot.bite_activated.connect(_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 Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
_feedback.text = "" _feedback.text = ""
_settings_panel.hide() _settings_panel.hide()
_join_game_page.close_page()
_confirmation_page.hide_page() _confirmation_page.hide_page()
_confirmation_action = ConfirmationAction.NONE _confirmation_action = ConfirmationAction.NONE
_action_in_progress = false _action_in_progress = false
@ -169,6 +187,8 @@ func handle_escape() -> bool:
return true return true
if _confirmation_page.visible: if _confirmation_page.visible:
_close_confirmation() _close_confirmation()
elif _join_game_page.visible:
_close_join_game()
elif _settings_panel.visible: elif _settings_panel.visible:
_settings_panel.handle_back() _settings_panel.handle_back()
else: else:
@ -203,6 +223,47 @@ func _open_settings() -> void:
_begin_root_exit(_finish_open_settings) _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: func _finish_open_settings() -> void:
_settings_panel.open_panel( _settings_panel.open_panel(
_settings_manager, _settings_manager,
@ -347,6 +408,10 @@ func _finish_confirmation_accept(action: ConfirmationAction) -> void:
reset_progress_requested.emit() reset_progress_requested.emit()
ConfirmationAction.QUIT_ANYWAY: ConfirmationAction.QUIT_ANYWAY:
quit_requested.emit() 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 _action_in_progress = false
_begin_root_entry(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.color.a = 0.0
_dim_background.hide() _dim_background.hide()
_settings_panel.hide() _settings_panel.hide()
_join_game_page.close_page()
_root_page.hide_page() _root_page.hide_page()
_confirmation_page.hide_page() _confirmation_page.hide_page()
_confirmation_action = ConfirmationAction.NONE _confirmation_action = ConfirmationAction.NONE

View file

@ -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="Script" path="res://ui/pause_menu.gd" id="1_script"]
[ext_resource type="PackedScene" path="res://ui/settings_panel.tscn" id="2_settings"] [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="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="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/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"] [node name="PauseMenu" type="Control"]
unique_name_in_owner = true unique_name_in_owner = true
@ -76,8 +77,8 @@ grow_horizontal = 2
grow_vertical = 2 grow_vertical = 2
script = ExtResource("4_page") script = ExtResource("4_page")
page_id = &"pause" 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")]) 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/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") initial_focus_path = NodePath("BubbleCluster/ResumeButton")
back_focus_path = NodePath("BubbleCluster/ResumeButton") back_focus_path = NodePath("BubbleCluster/ResumeButton")
maximum_layout_size = Vector2(720, 520) maximum_layout_size = Vector2(720, 520)
@ -134,6 +135,19 @@ minimum_font_size = 16
maximum_font_size = 23 maximum_font_size = 23
motion_phase = 2.05 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")] [node name="ReturnToTitleButton" parent="ResponsivePauseStage/PausePresentationScaleRoot/RootPage/BubbleCluster" instance=ExtResource("5_bubble")]
unique_name_in_owner = true unique_name_in_owner = true
custom_minimum_size = Vector2(0, 0) custom_minimum_size = Vector2(0, 0)
@ -209,3 +223,13 @@ anchor_right = 1.0
anchor_bottom = 1.0 anchor_bottom = 1.0
grow_horizontal = 2 grow_horizontal = 2
grow_vertical = 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

View file

@ -8,6 +8,7 @@ const FishDataType = preload("res://fish/fish_data.gd")
const FishBuyerProfileType = preload("res://economy/fish_buyer_profile.gd") const FishBuyerProfileType = preload("res://economy/fish_buyer_profile.gd")
const FishSaleResultType = preload("res://economy/fish_sale_result.gd") const FishSaleResultType = preload("res://economy/fish_sale_result.gd")
const FishSaleServiceType = preload("res://economy/fish_sale_service.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 FishInventoryType = preload("res://inventory/fish_inventory.gd")
const FishPoolType = preload("res://fish/fish_pool.gd") const FishPoolType = preload("res://fish/fish_pool.gd")
const FishingSpotType = preload("res://fishing/fishing_spot.gd") const FishingSpotType = preload("res://fishing/fishing_spot.gd")
@ -225,6 +226,7 @@ var _inventory: FishInventoryType
var _collection_log: CollectionLogType var _collection_log: CollectionLogType
var _wallet: PlayerWalletType var _wallet: PlayerWalletType
var _sale_service: FishSaleServiceType var _sale_service: FishSaleServiceType
var _network_session: NetworkSessionType
var _default_buyer: FishBuyerProfileType var _default_buyer: FishBuyerProfileType
var _catalog: FishPoolType var _catalog: FishPoolType
var _fishing_spot: FishingSpotType var _fishing_spot: FishingSpotType
@ -353,6 +355,7 @@ func setup(
hotbar: PlayerHotbarType, hotbar: PlayerHotbarType,
item_catalog: ItemCatalogType, item_catalog: ItemCatalogType,
cooler_capacity: PlayerCoolerCapacityType, cooler_capacity: PlayerCoolerCapacityType,
network_session: NetworkSessionType,
) -> void: ) -> void:
_player = player _player = player
_inventory = inventory _inventory = inventory
@ -366,6 +369,7 @@ func setup(
_hotbar = hotbar _hotbar = hotbar
_item_catalog = item_catalog _item_catalog = item_catalog
_cooler_capacity = cooler_capacity _cooler_capacity = cooler_capacity
_network_session = network_session
_fish_selection.clear() _fish_selection.clear()
if not _inventory.catches_changed.is_connected(_on_inventory_changed): if not _inventory.catches_changed.is_connected(_on_inventory_changed):
_inventory.catches_changed.connect(_on_inventory_changed) _inventory.catches_changed.connect(_on_inventory_changed)
@ -2337,6 +2341,23 @@ func _update_sale_summary() -> void:
_sale_unavailable.text = "" _sale_unavailable.text = ""
_sale_unavailable.visible = false _sale_unavailable.visible = false
return 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 = ( var preview: FishSaleResultType = (
_sale_service.preview_batch(selected_ids, _default_buyer) _sale_service.preview_batch(selected_ids, _default_buyer)
if _sale_service != null if _sale_service != null
@ -2407,6 +2428,11 @@ func _on_favorite_pressed() -> void:
func _on_sell_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() var selected_ids: Array[StringName] = _fish_selection.get_selected_ids()
if ( if (
_inventory == null _inventory == null
@ -2455,6 +2481,7 @@ func _on_sell_pressed() -> void:
func _on_confirm_sale_pressed() -> void: func _on_confirm_sale_pressed() -> void:
if ( if (
_sale_in_progress _sale_in_progress
or not _can_use_shared_world_actions()
or _sale_service == null or _sale_service == null
or _confirmation_catch_ids.is_empty() or _confirmation_catch_ids.is_empty()
or _confirmation_buyer == null or _confirmation_buyer == null
@ -2484,6 +2511,13 @@ func _on_confirm_sale_pressed() -> void:
_inventory_tab.grab_focus() _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: func _close_sale_confirmation() -> void:
var was_visible: bool = _sale_confirmation.visible var was_visible: bool = _sale_confirmation.visible
_confirmation_catch_ids.clear() _confirmation_catch_ids.clear()

View file

@ -16,6 +16,9 @@ const BubbleClusterType = preload(
const TitleConfirmationBubblePageType = preload( const TitleConfirmationBubblePageType = preload(
"res://ui/title_confirmation_bubble_page.gd" "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] = [ const DECORATIVE_FISH_TEXTURES: Array[Texture2D] = [
preload("res://fish/species/bass/fish_bass_striped.png"), preload("res://fish/species/bass/fish_bass_striped.png"),
preload("res://fish/species/bluegill/fish_bluegill.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_PRESENTATION_IN_DURATION: float = 1.85
const HOST_CLUSTER_SAFE_MARGIN: float = 24.0 const HOST_CLUSTER_SAFE_MARGIN: float = 24.0
signal gameplay_requested signal new_game_requested
signal continue_game_requested
signal quit_requested signal quit_requested
signal join_game_requested(endpoint: String)
enum ConfirmationAction { enum ConfirmationAction {
NONE, NONE,
@ -98,6 +103,7 @@ enum ConfirmationAction {
@onready var _settings_button: BubbleButtonType = %SettingsButton @onready var _settings_button: BubbleButtonType = %SettingsButton
@onready var _delete_button: BubbleButtonType = %DeleteSaveButton @onready var _delete_button: BubbleButtonType = %DeleteSaveButton
@onready var _quit_button: BubbleButtonType = %QuitButton @onready var _quit_button: BubbleButtonType = %QuitButton
@onready var _join_game_button: BubbleButtonType = %JoinGameButton
@onready var _new_game_label: Label = %NewGameLabel @onready var _new_game_label: Label = %NewGameLabel
@onready var _delete_save_label: Label = %DeleteSaveLabel @onready var _delete_save_label: Label = %DeleteSaveLabel
@onready var _feedback_label: Label = %FeedbackLabel @onready var _feedback_label: Label = %FeedbackLabel
@ -129,6 +135,7 @@ enum ConfirmationAction {
@onready var _bubble_field: BubbleClusterType = %BubbleField @onready var _bubble_field: BubbleClusterType = %BubbleField
@onready var _start_prompt_center: CenterContainer = %StartPromptCenter @onready var _start_prompt_center: CenterContainer = %StartPromptCenter
@onready var _start_prompt_label: Label = %StartPromptLabel @onready var _start_prompt_label: Label = %StartPromptLabel
@onready var _join_game_page: JoinGamePageType = %JoinGamePage
var _save_manager: SaveManagerType var _save_manager: SaveManagerType
var _settings_manager: SettingsManagerType var _settings_manager: SettingsManagerType
@ -188,6 +195,7 @@ func _ready() -> void:
_on_continue_stats_focus_changed.bind(false) _on_continue_stats_focus_changed.bind(false)
) )
_new_game_button.pressed.connect(_on_new_game_pressed) _new_game_button.pressed.connect(_on_new_game_pressed)
_join_game_button.pressed.connect(_open_join_game)
_settings_button.pressed.connect(_open_settings) _settings_button.pressed.connect(_open_settings)
_delete_button.pressed.connect(_on_delete_pressed) _delete_button.pressed.connect(_on_delete_pressed)
%QuitButton.pressed.connect(_on_quit_pressed) %QuitButton.pressed.connect(_on_quit_pressed)
@ -221,9 +229,14 @@ func _ready() -> void:
func setup( func setup(
save_manager: SaveManagerType, save_manager: SaveManagerType,
settings_manager: SettingsManagerType, settings_manager: SettingsManagerType,
network_session: NetworkSessionType,
saved_servers: SavedServerStoreType,
) -> void: ) -> void:
_save_manager = save_manager _save_manager = save_manager
_settings_manager = settings_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 Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
_refresh_save_inspection() _refresh_save_inspection()
show() show()
@ -239,12 +252,45 @@ func reopen() -> void:
_prepare_awaiting_start_input() _prepare_awaiting_start_input()
_reset_confirmation() _reset_confirmation()
_settings_panel.hide() _settings_panel.hide()
_join_game_page.close_page()
_refresh_save_inspection() _refresh_save_inspection()
show() show()
_start_decorative_presentation() _start_decorative_presentation()
_start_entry_prompt_animation() _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: func is_awaiting_start_input() -> bool:
return _awaiting_start_input return _awaiting_start_input
@ -347,6 +393,7 @@ func _get_title_buttons() -> Array[BubbleButton]:
return [ return [
_continue_button, _continue_button,
_new_game_button, _new_game_button,
_join_game_button,
_settings_button, _settings_button,
_delete_button, _delete_button,
_quit_button, _quit_button,
@ -438,6 +485,11 @@ func _process(delta: float) -> void:
func _input(event: InputEvent) -> void: func _input(event: InputEvent) -> void:
if not visible: if not visible:
return return
if _join_game_page.visible:
if event.is_action_pressed("ui_cancel"):
_close_join_game()
get_viewport().set_input_as_handled()
return
if ( if (
_title_settings_transition_active _title_settings_transition_active
or _title_entry_transition_active or _title_entry_transition_active
@ -851,12 +903,7 @@ func _on_continue_pressed() -> void:
return return
_hide_continue_stats_context() _hide_continue_stats_context()
_action_in_progress = true _action_in_progress = true
if _save_manager.load_player_data(): continue_game_requested.emit()
_feedback_label.text = "save loaded."
gameplay_requested.emit()
else:
_refresh_save_inspection()
_feedback_label.text = "failed to load save. the original was preserved."
_action_in_progress = false _action_in_progress = false
@ -870,8 +917,7 @@ func _on_new_game_pressed() -> void:
return return
_hide_continue_stats_context() _hide_continue_stats_context()
if _inspection.status == SaveInspectionType.Status.MISSING: if _inspection.status == SaveInspectionType.Status.MISSING:
if _save_manager.initialize_new_game(): new_game_requested.emit()
gameplay_requested.emit()
return return
if _inspection.status == SaveInspectionType.Status.UNSUPPORTED_VERSION: if _inspection.status == SaveInspectionType.Status.UNSUPPORTED_VERSION:
_feedback_label.text = ( _feedback_label.text = (
@ -917,6 +963,14 @@ func _on_confirmation_accepted() -> void:
var action: ConfirmationAction = _confirmation_action var action: ConfirmationAction = _confirmation_action
_confirmation_page.lock_interaction() _confirmation_page.lock_interaction()
_action_in_progress = true _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(): if not _save_manager.delete_progression_save():
_feedback_label.text = "failed to delete saved progression." _feedback_label.text = "failed to delete saved progression."
_action_in_progress = false _action_in_progress = false
@ -925,15 +979,8 @@ func _on_confirmation_accepted() -> void:
return return
_save_manager.initialize_new_game() _save_manager.initialize_new_game()
_refresh_save_inspection() _refresh_save_inspection()
if action == ConfirmationAction.NEW_GAME: _feedback_label.text = "saved progression deleted."
_confirmation_action = ConfirmationAction.NONE _begin_confirmation_return()
_confirmation_transition_generation += 1
_cancel_confirmation_transition()
_confirmation_page.hide_page()
gameplay_requested.emit()
else:
_feedback_label.text = "saved progression deleted."
_begin_confirmation_return()
_action_in_progress = false _action_in_progress = false

View file

@ -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="Script" path="res://ui/title_screen.gd" id="1_script"]
[ext_resource type="Theme" path="res://ui/game_theme.tres" id="2_theme"] [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="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="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/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"] [sub_resource type="ShaderMaterial" id="ShaderMaterial_title_water"]
shader = ExtResource("4_water_shader") shader = ExtResource("4_water_shader")
@ -314,6 +315,28 @@ motion_period = 4.9
motion_phase = 2.5 motion_phase = 2.5
deformation_period = 6.3 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"] [node name="DeleteSaveButton" type="Button" parent="ResponsiveTitleStage/TitlePresentationScaleRoot/Center/MainContent/ButtonCenter/BubbleLayoutSlot/BubbleMotionRoot/BubbleField"]
unique_name_in_owner = true unique_name_in_owner = true
offset_left = -11.0 offset_left = -11.0
@ -407,3 +430,13 @@ offset_right = 360.0
offset_bottom = 300.0 offset_bottom = 300.0
grow_horizontal = 2 grow_horizontal = 2
grow_vertical = 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

View file

@ -2,6 +2,7 @@ class_name WaterRecoveryController
extends Node extends Node
signal recovery_starting signal recovery_starting
signal local_respawn_completed(entry_position: Vector3)
enum RecoveryState { enum RecoveryState {
IDLE, IDLE,
@ -165,6 +166,7 @@ func _respawn_player() -> void:
_player.global_transform = respawn_transform _player.global_transform = respawn_transform
_player.restore_gameplay_orientation_after_recovery() _player.restore_gameplay_orientation_after_recovery()
_player.velocity = Vector3.ZERO _player.velocity = Vector3.ZERO
local_respawn_completed.emit(_entry_position)
func _finish_recovery() -> void: func _finish_recovery() -> void: