Add direct-connect multiplayer foundation
This commit is contained in:
parent
9e9850dd20
commit
24f8263175
36 changed files with 2633 additions and 45 deletions
|
|
@ -26,6 +26,7 @@ const PlayerType = preload("res://player/player.gd")
|
|||
const FishableWaterRegionType = preload(
|
||||
"res://world/fishable_water_region.gd"
|
||||
)
|
||||
const NetworkSessionType = preload("res://network/network_session.gd")
|
||||
|
||||
signal status_changed(status: String)
|
||||
signal catch_display_changed(
|
||||
|
|
@ -104,6 +105,7 @@ var _item_catalog: ItemCatalogType
|
|||
var _fishing_upgrades: PlayerFishingUpgradesType
|
||||
var _item_effects: PlayerItemEffectsType
|
||||
var _cooler_capacity: PlayerCoolerCapacityType
|
||||
var _network_session: NetworkSessionType
|
||||
var _active_player: PlayerType
|
||||
var _state_time_remaining: float = 0.0
|
||||
var _cast_charge: float = 0.0
|
||||
|
|
@ -147,6 +149,7 @@ func setup(
|
|||
fishing_upgrades: PlayerFishingUpgradesType,
|
||||
item_effects: PlayerItemEffectsType,
|
||||
cooler_capacity: PlayerCoolerCapacityType,
|
||||
network_session: NetworkSessionType = null,
|
||||
) -> void:
|
||||
_local_player = local_player
|
||||
_local_inventory = local_inventory
|
||||
|
|
@ -157,6 +160,7 @@ func setup(
|
|||
_fishing_upgrades = fishing_upgrades
|
||||
_item_effects = item_effects
|
||||
_cooler_capacity = cooler_capacity
|
||||
_network_session = network_session
|
||||
if not _local_inventory.catches_changed.is_connected(
|
||||
_on_cooler_availability_changed
|
||||
):
|
||||
|
|
@ -195,6 +199,8 @@ func can_open_system_menu() -> bool:
|
|||
|
||||
func can_open_fishing_shop() -> bool:
|
||||
return (
|
||||
_can_use_shared_gameplay()
|
||||
and
|
||||
_gameplay_input_enabled
|
||||
and not _external_input_blocked
|
||||
and _local_menu_input_owners.is_empty()
|
||||
|
|
@ -204,6 +210,8 @@ func can_open_fishing_shop() -> bool:
|
|||
|
||||
func is_ready_for_shop_transaction() -> bool:
|
||||
return (
|
||||
_can_use_shared_gameplay()
|
||||
and
|
||||
_gameplay_input_enabled
|
||||
and not _external_input_blocked
|
||||
and state == FishingState.READY
|
||||
|
|
@ -367,6 +375,13 @@ func _unhandled_input(event: InputEvent) -> void:
|
|||
return
|
||||
if not event.is_action("fish_primary"):
|
||||
return
|
||||
if not _can_use_shared_gameplay():
|
||||
if event.is_pressed():
|
||||
status_changed.emit(
|
||||
"Fishing in joined games is coming in the next multiplayer phase."
|
||||
)
|
||||
get_viewport().set_input_as_handled()
|
||||
return
|
||||
|
||||
if event.is_pressed():
|
||||
match state:
|
||||
|
|
@ -450,6 +465,10 @@ func _begin_aiming(player: PlayerType) -> void:
|
|||
)
|
||||
|
||||
|
||||
func _can_use_shared_gameplay() -> bool:
|
||||
return _network_session == null or _network_session.can_use_host_gameplay()
|
||||
|
||||
|
||||
func has_active_fishing_rod() -> bool:
|
||||
if (
|
||||
_local_bag == null
|
||||
|
|
|
|||
237
main/main.gd
237
main/main.gd
|
|
@ -33,6 +33,16 @@ const PixelationResetOverlayType = preload(
|
|||
const WorldPixelationPostprocessType = preload(
|
||||
"res://main/world_pixelation_postprocess.gd"
|
||||
)
|
||||
const NetworkSessionType = preload("res://network/network_session.gd")
|
||||
const NetworkProfilePreferencesType = preload(
|
||||
"res://network/network_profile_preferences.gd"
|
||||
)
|
||||
const SavedServerStoreType = preload(
|
||||
"res://network/saved_server_store.gd"
|
||||
)
|
||||
const PlayerSpawnServiceType = preload(
|
||||
"res://network/player_spawn_service.gd"
|
||||
)
|
||||
|
||||
const TITLE_MUSIC_SILENCE_DB: float = -80.0
|
||||
|
||||
|
|
@ -59,6 +69,15 @@ const TITLE_MUSIC_SILENCE_DB: float = -80.0
|
|||
@onready var _world_pixelation: WorldPixelationPostprocessType = (
|
||||
%WorldPixelationPostprocess
|
||||
)
|
||||
@onready var _network_session: NetworkSessionType = %NetworkSession
|
||||
@onready var _network_profile: NetworkProfilePreferencesType = (
|
||||
%NetworkProfilePreferences
|
||||
)
|
||||
@onready var _saved_servers: SavedServerStoreType = %SavedServerStore
|
||||
@onready var _player_spawn_service: PlayerSpawnServiceType = (
|
||||
%PlayerSpawnService
|
||||
)
|
||||
@onready var _players_root: Node3D = $Players
|
||||
|
||||
var _gameplay_started: bool = false
|
||||
var _shop_interaction: FishingShopInteractionType
|
||||
|
|
@ -66,11 +85,34 @@ var _title_music_tween: Tween
|
|||
var _title_music_transition_generation: int = 0
|
||||
var _title_music_requested: bool = false
|
||||
var _quit_in_progress: bool = false
|
||||
var _join_requested_from_title: bool = false
|
||||
var _join_requested_from_pause: bool = false
|
||||
var _pending_join_endpoint: String = ""
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
_player.global_transform = _test_world.get_player_spawn_transform()
|
||||
_player.velocity = Vector3.ZERO
|
||||
_player_spawn_service.setup(
|
||||
_players_root,
|
||||
_player,
|
||||
_test_world.get_player_spawn_transform()
|
||||
)
|
||||
_network_session.setup(
|
||||
_network_profile,
|
||||
_saved_servers,
|
||||
_player_spawn_service
|
||||
)
|
||||
_network_session.join_authenticated.connect(
|
||||
_on_network_join_authenticated
|
||||
)
|
||||
_network_session.connection_error.connect(
|
||||
_on_network_connection_error
|
||||
)
|
||||
_network_session.server_lost.connect(_on_network_server_lost)
|
||||
_network_session.remote_recovery_requested.connect(
|
||||
_on_remote_recovery_requested
|
||||
)
|
||||
_player.fish_sale_service.setup(
|
||||
_player.inventory,
|
||||
_player.wallet
|
||||
|
|
@ -103,7 +145,8 @@ func _ready() -> void:
|
|||
item_catalog,
|
||||
_player.fishing_upgrades,
|
||||
_player.item_effects,
|
||||
_player.cooler_capacity
|
||||
_player.cooler_capacity,
|
||||
_network_session
|
||||
)
|
||||
_game_ui.setup(
|
||||
_player,
|
||||
|
|
@ -121,7 +164,8 @@ func _ready() -> void:
|
|||
_player.fishing_upgrades,
|
||||
_shop_interaction,
|
||||
_player.item_effects,
|
||||
_player.cooler_capacity
|
||||
_player.cooler_capacity,
|
||||
_network_session
|
||||
)
|
||||
_water_recovery.setup(
|
||||
_player,
|
||||
|
|
@ -155,16 +199,26 @@ func _ready() -> void:
|
|||
_apply_runtime_settings(_settings_manager.current_settings)
|
||||
_set_gameplay_active(false)
|
||||
var title_screen: TitleScreenType = _game_ui.get_title_screen()
|
||||
title_screen.gameplay_requested.connect(_on_gameplay_requested)
|
||||
title_screen.new_game_requested.connect(_on_new_game_requested)
|
||||
title_screen.continue_game_requested.connect(_on_continue_game_requested)
|
||||
title_screen.quit_requested.connect(_on_quit_requested)
|
||||
title_screen.setup(_save_manager, _settings_manager)
|
||||
title_screen.setup(
|
||||
_save_manager,
|
||||
_settings_manager,
|
||||
_network_session,
|
||||
_saved_servers
|
||||
)
|
||||
var pause_menu: PauseMenuType = _game_ui.get_pause_menu()
|
||||
pause_menu.setup(
|
||||
_player,
|
||||
_save_manager,
|
||||
_settings_manager,
|
||||
_fishing_spot
|
||||
_fishing_spot,
|
||||
_network_session,
|
||||
_saved_servers
|
||||
)
|
||||
title_screen.join_game_requested.connect(_on_title_join_game_requested)
|
||||
pause_menu.join_game_requested.connect(_on_pause_join_game_requested)
|
||||
pause_menu.return_to_title_requested.connect(
|
||||
_on_return_to_title_requested
|
||||
)
|
||||
|
|
@ -184,6 +238,9 @@ func _ready() -> void:
|
|||
_water_recovery.recovery_starting.connect(
|
||||
_on_water_recovery_starting
|
||||
)
|
||||
_water_recovery.local_respawn_completed.connect(
|
||||
_on_local_respawn_completed
|
||||
)
|
||||
_on_active_hotbar_item_changed(
|
||||
_player.hotbar.get_selected_slot(),
|
||||
_player.hotbar.get_selected_item_id()
|
||||
|
|
@ -302,9 +359,54 @@ func _set_gameplay_active(active: bool) -> void:
|
|||
_save_manager.set_autosave_enabled(active)
|
||||
|
||||
|
||||
func _on_gameplay_requested() -> void:
|
||||
func _on_new_game_requested() -> void:
|
||||
if _gameplay_started or _quit_in_progress:
|
||||
return
|
||||
if not _prepare_private_host():
|
||||
return
|
||||
if (
|
||||
not _save_manager.delete_progression_save()
|
||||
or not _save_manager.initialize_new_game()
|
||||
):
|
||||
_network_session.disconnect_session("New Game setup failed.")
|
||||
_game_ui.get_title_screen().report_network_error(
|
||||
"Could not initialize local progression. Existing data was preserved where possible."
|
||||
)
|
||||
return
|
||||
_enter_gameplay()
|
||||
|
||||
|
||||
func _on_continue_game_requested() -> void:
|
||||
if _gameplay_started or _quit_in_progress:
|
||||
return
|
||||
if not _prepare_private_host():
|
||||
return
|
||||
if not _save_manager.load_player_data():
|
||||
_network_session.disconnect_session("Continue failed.")
|
||||
_game_ui.get_title_screen().report_network_error(
|
||||
"Failed to load save. The original was preserved."
|
||||
)
|
||||
return
|
||||
_enter_gameplay()
|
||||
|
||||
|
||||
func _prepare_private_host() -> bool:
|
||||
_join_requested_from_title = false
|
||||
_join_requested_from_pause = false
|
||||
if _network_session.state in [
|
||||
NetworkSessionType.State.CONNECTION_FAILED,
|
||||
NetworkSessionType.State.SERVER_LOST,
|
||||
]:
|
||||
_network_session.reset_failure()
|
||||
if not _network_session.start_private_host():
|
||||
_game_ui.get_title_screen().report_network_error(
|
||||
"Could not start the private multiplayer session."
|
||||
)
|
||||
return false
|
||||
return true
|
||||
|
||||
|
||||
func _enter_gameplay() -> void:
|
||||
_fade_out_title_music()
|
||||
_game_ui.get_title_screen().hide()
|
||||
_set_gameplay_active(true)
|
||||
|
|
@ -315,11 +417,133 @@ func _on_return_to_title_requested() -> void:
|
|||
return
|
||||
var pause_menu: PauseMenuType = _game_ui.get_pause_menu()
|
||||
pause_menu.close_for_title_transition()
|
||||
_network_session.disconnect_session("Returned to title.")
|
||||
_set_gameplay_active(false)
|
||||
_game_ui.get_title_screen().reopen()
|
||||
_show_title_music(true)
|
||||
|
||||
|
||||
func _on_title_join_game_requested(endpoint: String) -> void:
|
||||
if _quit_in_progress or _gameplay_started:
|
||||
return
|
||||
if _network_session.state != NetworkSessionType.State.INACTIVE:
|
||||
_network_session.disconnect_session("Preparing direct connection.")
|
||||
_join_requested_from_title = true
|
||||
_join_requested_from_pause = false
|
||||
_pending_join_endpoint = endpoint
|
||||
if not _network_session.join_direct(endpoint):
|
||||
_join_requested_from_title = false
|
||||
|
||||
|
||||
func _on_pause_join_game_requested(endpoint: String) -> void:
|
||||
if _quit_in_progress or not _gameplay_started:
|
||||
return
|
||||
if not _save_manager.save_if_dirty():
|
||||
_game_ui.get_pause_menu().report_network_error(
|
||||
"Could not save progression before leaving this session."
|
||||
)
|
||||
return
|
||||
_join_requested_from_pause = true
|
||||
_join_requested_from_title = false
|
||||
_pending_join_endpoint = endpoint
|
||||
_game_ui.get_pause_menu().close_for_title_transition()
|
||||
_network_session.disconnect_session("Connecting to another game.")
|
||||
if not _network_session.join_direct(endpoint):
|
||||
_handle_failed_session_switch(
|
||||
"Could not begin the direct connection."
|
||||
)
|
||||
|
||||
|
||||
func _on_network_join_authenticated() -> void:
|
||||
if _join_requested_from_title:
|
||||
var inspection = _save_manager.inspect_save()
|
||||
var progression_ready: bool = (
|
||||
_save_manager.load_player_data()
|
||||
if inspection.can_continue()
|
||||
else _save_manager.initialize_new_game()
|
||||
)
|
||||
if not progression_ready:
|
||||
_network_session.disconnect_session(
|
||||
"Local progression could not be prepared."
|
||||
)
|
||||
_game_ui.get_title_screen().report_network_error(
|
||||
"Your local progression could not be prepared."
|
||||
)
|
||||
_join_requested_from_title = false
|
||||
return
|
||||
_fade_out_title_music()
|
||||
_game_ui.get_title_screen().hide()
|
||||
_set_gameplay_active(true)
|
||||
elif _join_requested_from_pause:
|
||||
_set_gameplay_active(true)
|
||||
_join_requested_from_title = false
|
||||
_join_requested_from_pause = false
|
||||
_pending_join_endpoint = ""
|
||||
|
||||
|
||||
func _on_network_connection_error(message: String) -> void:
|
||||
if _join_requested_from_title:
|
||||
_game_ui.get_title_screen().report_network_error(message)
|
||||
elif _join_requested_from_pause:
|
||||
_handle_failed_session_switch(message)
|
||||
|
||||
|
||||
func _handle_failed_session_switch(message: String) -> void:
|
||||
_join_requested_from_pause = false
|
||||
_set_gameplay_active(false)
|
||||
var title_screen: TitleScreenType = _game_ui.get_title_screen()
|
||||
title_screen.reopen()
|
||||
title_screen.open_join_game_page(_pending_join_endpoint)
|
||||
title_screen.report_network_error(message)
|
||||
_show_title_music(true)
|
||||
|
||||
|
||||
func _on_network_server_lost() -> void:
|
||||
if not _gameplay_started:
|
||||
return
|
||||
_set_gameplay_active(false)
|
||||
var title_screen: TitleScreenType = _game_ui.get_title_screen()
|
||||
title_screen.reopen()
|
||||
title_screen.open_join_game_page(_pending_join_endpoint)
|
||||
title_screen.report_network_error("The server connection was lost.")
|
||||
_show_title_music(true)
|
||||
|
||||
|
||||
func _on_local_respawn_completed(entry_position: Vector3) -> void:
|
||||
if _network_session.is_host():
|
||||
_network_session.publish_authoritative_teleport(
|
||||
_player.get_network_peer_id()
|
||||
)
|
||||
elif _network_session.is_joined_client():
|
||||
_network_session.request_safe_respawn(entry_position)
|
||||
|
||||
|
||||
func _on_remote_recovery_requested(
|
||||
peer_id: int,
|
||||
entry_position: Vector3,
|
||||
) -> void:
|
||||
if not _network_session.is_host():
|
||||
return
|
||||
var avatar: PlayerType = _player_spawn_service.get_avatar(peer_id)
|
||||
if avatar == null:
|
||||
return
|
||||
var target_position: Vector3 = _test_world.get_player_spawn_transform().origin
|
||||
var nearest_distance: float = INF
|
||||
for point: SafeRespawnPoint in _test_world.get_safe_respawn_points():
|
||||
if point == null or not point.enabled:
|
||||
continue
|
||||
var distance: float = point.get_horizontal_distance_squared(
|
||||
entry_position
|
||||
)
|
||||
if distance < nearest_distance:
|
||||
nearest_distance = distance
|
||||
target_position = point.global_position
|
||||
target_position.y += _water_recovery.respawn_height_offset
|
||||
avatar.global_position = target_position
|
||||
avatar.velocity = Vector3.ZERO
|
||||
_network_session.publish_authoritative_teleport(peer_id)
|
||||
|
||||
|
||||
func _on_reset_progress_requested() -> void:
|
||||
if _quit_in_progress:
|
||||
return
|
||||
|
|
@ -372,6 +596,7 @@ func _on_quit_requested() -> void:
|
|||
_settings_manager.save_if_dirty()
|
||||
if _gameplay_started:
|
||||
_save_manager.save_if_dirty()
|
||||
_network_session.disconnect_session("Application closing.")
|
||||
_title_music_requested = false
|
||||
_replace_title_music_transition()
|
||||
_finish_quit()
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
[gd_scene load_steps=17 format=3]
|
||||
[gd_scene load_steps=21 format=3]
|
||||
|
||||
[ext_resource type="PackedScene" path="res://world/test_world.tscn" id="1_world"]
|
||||
[ext_resource type="PackedScene" path="res://player/player.tscn" id="2_player"]
|
||||
|
|
@ -16,6 +16,10 @@
|
|||
[ext_resource type="Script" path="res://ui/ui_pixelation_presenter.gd" id="14_pixelation"]
|
||||
[ext_resource type="PackedScene" path="res://ui/pixelation_reset_overlay.tscn" id="15_reset"]
|
||||
[ext_resource type="PackedScene" path="res://main/world_pixelation_postprocess.tscn" id="16_world_pixelation"]
|
||||
[ext_resource type="Script" path="res://network/network_session.gd" id="17_network_session"]
|
||||
[ext_resource type="Script" path="res://network/network_profile_preferences.gd" id="18_network_profile"]
|
||||
[ext_resource type="Script" path="res://network/saved_server_store.gd" id="19_saved_servers"]
|
||||
[ext_resource type="Script" path="res://network/player_spawn_service.gd" id="20_spawn_service"]
|
||||
|
||||
[node name="Main" type="Node3D"]
|
||||
script = ExtResource("3_main")
|
||||
|
|
@ -24,6 +28,22 @@ pelican_buyer_profile = ExtResource("7_pelicans")
|
|||
main_shop_buyer_profile = ExtResource("12_main_shop")
|
||||
item_catalog = ExtResource("11_items")
|
||||
|
||||
[node name="NetworkSession" type="Node" parent="."]
|
||||
unique_name_in_owner = true
|
||||
script = ExtResource("17_network_session")
|
||||
|
||||
[node name="NetworkProfilePreferences" type="Node" parent="."]
|
||||
unique_name_in_owner = true
|
||||
script = ExtResource("18_network_profile")
|
||||
|
||||
[node name="SavedServerStore" type="Node" parent="."]
|
||||
unique_name_in_owner = true
|
||||
script = ExtResource("19_saved_servers")
|
||||
|
||||
[node name="PlayerSpawnService" type="Node" parent="."]
|
||||
unique_name_in_owner = true
|
||||
script = ExtResource("20_spawn_service")
|
||||
|
||||
[node name="TestWorld" parent="." instance=ExtResource("1_world")]
|
||||
|
||||
[node name="Players" type="Node3D" parent="."]
|
||||
|
|
|
|||
12
network/connection_endpoint.gd
Normal file
12
network/connection_endpoint.gd
Normal 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)
|
||||
1
network/connection_endpoint.gd.uid
Normal file
1
network/connection_endpoint.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://c0ssdkdd35qgf
|
||||
24
network/connection_route.gd
Normal file
24
network/connection_route.gd
Normal 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()
|
||||
1
network/connection_route.gd.uid
Normal file
1
network/connection_route.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://dquhcebc57bg8
|
||||
38
network/direct_enet_transport.gd
Normal file
38
network/direct_enet_transport.gd
Normal 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
|
||||
1
network/direct_enet_transport.gd.uid
Normal file
1
network/direct_enet_transport.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://d4jn8ubxqkqa4
|
||||
69
network/endpoint_parser.gd
Normal file
69
network/endpoint_parser.gd
Normal 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
|
||||
1
network/endpoint_parser.gd.uid
Normal file
1
network/endpoint_parser.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://2drengcleq7e
|
||||
120
network/network_profile_preferences.gd
Normal file
120
network/network_profile_preferences.gd
Normal 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
|
||||
)
|
||||
1
network/network_profile_preferences.gd.uid
Normal file
1
network/network_profile_preferences.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://db8ua5psoql0w
|
||||
121
network/network_protocol.gd
Normal file
121
network/network_protocol.gd
Normal 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."
|
||||
1
network/network_protocol.gd.uid
Normal file
1
network/network_protocol.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://dghw7u3pdrpsf
|
||||
816
network/network_session.gd
Normal file
816
network/network_session.gd
Normal 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
|
||||
1
network/network_session.gd.uid
Normal file
1
network/network_session.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://u62a32ktf0y5
|
||||
34
network/network_transport.gd
Normal file
34
network/network_transport.gd
Normal 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
|
||||
1
network/network_transport.gd.uid
Normal file
1
network/network_transport.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://pcqgk35ifhxc
|
||||
69
network/peer_registry.gd
Normal file
69
network/peer_registry.gd
Normal 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()
|
||||
1
network/peer_registry.gd.uid
Normal file
1
network/peer_registry.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://de3jtu2e0ut65
|
||||
96
network/player_spawn_service.gd
Normal file
96
network/player_spawn_service.gd
Normal 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
|
||||
1
network/player_spawn_service.gd.uid
Normal file
1
network/player_spawn_service.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://8w70d0nrf4a7
|
||||
187
network/saved_server_store.gd
Normal file
187
network/saved_server_store.gd
Normal 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
|
||||
)
|
||||
1
network/saved_server_store.gd.uid
Normal file
1
network/saved_server_store.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://b7j0er0u1nwy7
|
||||
213
player/player.gd
213
player/player.gd
|
|
@ -105,6 +105,20 @@ var _showcase_restore_tween: Tween
|
|||
var _showcase_camera_tween: Tween
|
||||
var _showcase_camera_snapshot: ShowcaseCameraSnapshot
|
||||
var _showcase_restore_generation: int = 0
|
||||
var _network_peer_id: int = 0
|
||||
var _network_authoritative_simulation: bool = false
|
||||
var _network_interpolation_enabled: bool = false
|
||||
var _network_axis: Vector2 = Vector2.ZERO
|
||||
var _network_camera_yaw: float = 0.0
|
||||
var _network_jump_pending: bool = false
|
||||
var _network_sprint: bool = false
|
||||
var _network_sneak: bool = false
|
||||
var _network_slow_walk: bool = false
|
||||
var _last_network_input_sequence: int = 0
|
||||
var _network_target_position: Vector3
|
||||
var _network_target_velocity: Vector3
|
||||
var _network_target_visual_yaw: float = 0.0
|
||||
var _network_snapshot_ready: bool = false
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
|
|
@ -114,6 +128,9 @@ func _ready() -> void:
|
|||
|
||||
|
||||
func _physics_process(delta: float) -> void:
|
||||
if _network_interpolation_enabled:
|
||||
_update_network_interpolation(delta)
|
||||
return
|
||||
if _water_recovery_active:
|
||||
velocity = Vector3.ZERO
|
||||
return
|
||||
|
|
@ -125,34 +142,50 @@ func _physics_process(delta: float) -> void:
|
|||
else fall_gravity_multiplier
|
||||
)
|
||||
velocity.y -= _gravity * gravity_multiplier * delta
|
||||
elif (
|
||||
elif _movement_enabled and (
|
||||
(
|
||||
local_control_enabled
|
||||
and _movement_enabled
|
||||
and Input.is_action_just_pressed("jump")
|
||||
)
|
||||
or (
|
||||
_network_authoritative_simulation
|
||||
and _network_jump_pending
|
||||
)
|
||||
):
|
||||
velocity.y = jump_velocity
|
||||
_network_jump_pending = false
|
||||
|
||||
if not local_control_enabled or not _movement_enabled:
|
||||
if not _movement_enabled:
|
||||
velocity.x = 0.0
|
||||
velocity.z = 0.0
|
||||
move_and_slide()
|
||||
return
|
||||
|
||||
var input_vector: Vector2 = Input.get_vector(
|
||||
var input_vector: Vector2
|
||||
var camera_basis: Basis
|
||||
if local_control_enabled:
|
||||
input_vector = Input.get_vector(
|
||||
"move_left",
|
||||
"move_right",
|
||||
"move_forward",
|
||||
"move_backward"
|
||||
)
|
||||
var camera_basis: Basis = _camera_yaw.global_basis
|
||||
camera_basis = _camera_yaw.global_basis
|
||||
elif _network_authoritative_simulation:
|
||||
input_vector = _network_axis
|
||||
camera_basis = Basis(Vector3.UP, _network_camera_yaw)
|
||||
else:
|
||||
velocity.x = 0.0
|
||||
velocity.z = 0.0
|
||||
return
|
||||
var move_direction: Vector3 = camera_basis.x * input_vector.x + camera_basis.z * input_vector.y
|
||||
move_direction.y = 0.0
|
||||
var input_strength: float = minf(input_vector.length(), 1.0)
|
||||
move_direction = move_direction.normalized()
|
||||
|
||||
var speed: float = (
|
||||
_get_current_speed() * item_effects.get_movement_multiplier()
|
||||
)
|
||||
var speed: float = _get_network_aware_speed()
|
||||
if item_effects != null:
|
||||
speed *= item_effects.get_movement_multiplier()
|
||||
velocity.x = move_direction.x * speed * input_strength
|
||||
velocity.z = move_direction.z * speed * input_strength
|
||||
|
||||
|
|
@ -243,6 +276,18 @@ func _get_current_speed() -> float:
|
|||
return walk_speed
|
||||
|
||||
|
||||
func _get_network_aware_speed() -> float:
|
||||
if local_control_enabled:
|
||||
return _get_current_speed()
|
||||
if _network_sneak:
|
||||
return sneak_speed
|
||||
if _network_slow_walk:
|
||||
return slow_walk_speed
|
||||
if _network_sprint:
|
||||
return sprint_speed
|
||||
return walk_speed
|
||||
|
||||
|
||||
func _rotate_camera(delta_rotation: Vector2) -> void:
|
||||
_camera_yaw.rotation.y -= delta_rotation.x
|
||||
var vertical_direction: float = -1.0 if invert_camera_y else 1.0
|
||||
|
|
@ -265,6 +310,158 @@ func set_local_control(enabled: bool) -> void:
|
|||
_camera_dragging = false
|
||||
|
||||
|
||||
func set_network_peer_id(peer_id: int) -> void:
|
||||
_network_peer_id = peer_id
|
||||
|
||||
|
||||
func get_network_peer_id() -> int:
|
||||
return _network_peer_id
|
||||
|
||||
|
||||
func configure_network_remote(authoritative_simulation: bool) -> void:
|
||||
set_local_control(false)
|
||||
_network_authoritative_simulation = authoritative_simulation
|
||||
_network_interpolation_enabled = not authoritative_simulation
|
||||
_network_snapshot_ready = false
|
||||
_camera.current = false
|
||||
|
||||
|
||||
func capture_network_input(sequence: int) -> Dictionary:
|
||||
var axis: Vector2 = Input.get_vector(
|
||||
"move_left",
|
||||
"move_right",
|
||||
"move_forward",
|
||||
"move_backward"
|
||||
)
|
||||
return {
|
||||
"sequence": sequence,
|
||||
"axis": [axis.x, axis.y],
|
||||
"camera_yaw": _camera_yaw.global_rotation.y,
|
||||
"jump": Input.is_action_just_pressed("jump"),
|
||||
"sprint": Input.is_action_pressed("sprint"),
|
||||
"sneak": Input.is_action_pressed("sneak"),
|
||||
"slow_walk": Input.is_action_pressed("slow_walk"),
|
||||
}
|
||||
|
||||
|
||||
func apply_authoritative_network_input(data: Dictionary) -> void:
|
||||
var sequence: int = int(data.get("sequence", 0))
|
||||
if sequence <= _last_network_input_sequence:
|
||||
return
|
||||
var axis: Array = data.get("axis", [])
|
||||
if axis.size() != 2:
|
||||
return
|
||||
_last_network_input_sequence = sequence
|
||||
_network_axis = Vector2(float(axis[0]), float(axis[1])).limit_length(1.0)
|
||||
_network_camera_yaw = float(data.get("camera_yaw", 0.0))
|
||||
_network_jump_pending = bool(data.get("jump", false))
|
||||
_network_sprint = bool(data.get("sprint", false))
|
||||
_network_sneak = bool(data.get("sneak", false))
|
||||
_network_slow_walk = bool(data.get("slow_walk", false))
|
||||
|
||||
|
||||
func make_network_snapshot(peer_id: int) -> Dictionary:
|
||||
return {
|
||||
"peer_id": peer_id,
|
||||
"acknowledged_input": _last_network_input_sequence,
|
||||
"position": [global_position.x, global_position.y, global_position.z],
|
||||
"velocity": [velocity.x, velocity.y, velocity.z],
|
||||
"visual_yaw": _visuals.rotation.y,
|
||||
"grounded": is_on_floor(),
|
||||
}
|
||||
|
||||
|
||||
func push_network_snapshot(snapshot: Dictionary) -> void:
|
||||
var parsed: Dictionary = _parse_network_snapshot(snapshot)
|
||||
if parsed.is_empty():
|
||||
return
|
||||
_network_target_position = parsed["position"]
|
||||
_network_target_velocity = parsed["velocity"]
|
||||
_network_target_visual_yaw = parsed["visual_yaw"]
|
||||
if not _network_snapshot_ready:
|
||||
global_position = _network_target_position
|
||||
velocity = _network_target_velocity
|
||||
_visuals.rotation.y = _network_target_visual_yaw
|
||||
_network_snapshot_ready = true
|
||||
|
||||
|
||||
func apply_local_prediction_correction(snapshot: Dictionary) -> void:
|
||||
var parsed: Dictionary = _parse_network_snapshot(snapshot)
|
||||
if parsed.is_empty():
|
||||
return
|
||||
var authoritative_position: Vector3 = parsed["position"]
|
||||
var error_distance: float = global_position.distance_to(
|
||||
authoritative_position
|
||||
)
|
||||
if error_distance > 2.0:
|
||||
global_position = authoritative_position
|
||||
elif error_distance > 0.05:
|
||||
global_position = global_position.lerp(authoritative_position, 0.18)
|
||||
|
||||
|
||||
func apply_network_teleport(snapshot: Dictionary) -> void:
|
||||
var parsed: Dictionary = _parse_network_snapshot(snapshot)
|
||||
if parsed.is_empty():
|
||||
return
|
||||
global_position = parsed["position"]
|
||||
velocity = parsed["velocity"]
|
||||
_visuals.rotation.y = parsed["visual_yaw"]
|
||||
_network_target_position = global_position
|
||||
_network_target_velocity = velocity
|
||||
_network_target_visual_yaw = _visuals.rotation.y
|
||||
_network_snapshot_ready = true
|
||||
|
||||
|
||||
func _parse_network_snapshot(snapshot: Dictionary) -> Dictionary:
|
||||
var position: Variant = snapshot.get("position")
|
||||
var network_velocity: Variant = snapshot.get("velocity")
|
||||
if (
|
||||
typeof(position) != TYPE_ARRAY
|
||||
or typeof(network_velocity) != TYPE_ARRAY
|
||||
or position.size() != 3
|
||||
or network_velocity.size() != 3
|
||||
):
|
||||
return {}
|
||||
var parsed_position := Vector3(
|
||||
float(position[0]),
|
||||
float(position[1]),
|
||||
float(position[2])
|
||||
)
|
||||
var parsed_velocity := Vector3(
|
||||
float(network_velocity[0]),
|
||||
float(network_velocity[1]),
|
||||
float(network_velocity[2])
|
||||
)
|
||||
var visual_yaw: float = float(snapshot.get("visual_yaw", 0.0))
|
||||
if (
|
||||
not parsed_position.is_finite()
|
||||
or not parsed_velocity.is_finite()
|
||||
or not is_finite(visual_yaw)
|
||||
):
|
||||
return {}
|
||||
return {
|
||||
"position": parsed_position,
|
||||
"velocity": parsed_velocity,
|
||||
"visual_yaw": visual_yaw,
|
||||
}
|
||||
|
||||
|
||||
func _update_network_interpolation(delta: float) -> void:
|
||||
if not _network_snapshot_ready:
|
||||
return
|
||||
var position_weight: float = 1.0 - exp(-12.0 * delta)
|
||||
global_position = global_position.lerp(
|
||||
_network_target_position,
|
||||
position_weight
|
||||
)
|
||||
velocity = _network_target_velocity
|
||||
_visuals.rotation.y = lerp_angle(
|
||||
_visuals.rotation.y,
|
||||
_network_target_visual_yaw,
|
||||
1.0 - exp(-14.0 * delta)
|
||||
)
|
||||
|
||||
|
||||
func is_local_control_enabled() -> bool:
|
||||
return local_control_enabled
|
||||
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ const PlayerHotbarType = preload("res://inventory/player_hotbar.gd")
|
|||
const HotbarUIType = preload("res://ui/hotbar.gd")
|
||||
const TitleScreenType = preload("res://ui/title_screen.gd")
|
||||
const PauseMenuType = preload("res://ui/pause_menu.gd")
|
||||
const NetworkSessionType = preload("res://network/network_session.gd")
|
||||
const FishingShopType = preload("res://ui/fishing_shop.gd")
|
||||
const SettingsPanelType = preload("res://ui/settings_panel.gd")
|
||||
const PlayerFishingUpgradesType = preload(
|
||||
|
|
@ -100,6 +101,7 @@ func setup(
|
|||
shop_interaction: ShopInteractionType,
|
||||
item_effects: PlayerItemEffectsType,
|
||||
cooler_capacity: PlayerCoolerCapacityType,
|
||||
network_session: NetworkSessionType,
|
||||
) -> void:
|
||||
_fishing_spot = fishing_spot
|
||||
_item_effects = item_effects
|
||||
|
|
@ -121,7 +123,8 @@ func setup(
|
|||
bag,
|
||||
hotbar,
|
||||
item_catalog,
|
||||
cooler_capacity
|
||||
cooler_capacity,
|
||||
network_session
|
||||
)
|
||||
_hotbar_ui.setup(hotbar, bag, item_catalog, fishing_spot)
|
||||
_fishing_shop.setup(
|
||||
|
|
|
|||
175
ui/network/join_game_page.gd
Normal file
175
ui/network/join_game_page.gd
Normal 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(),
|
||||
]
|
||||
1
ui/network/join_game_page.gd.uid
Normal file
1
ui/network/join_game_page.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://dch42c44t4rj5
|
||||
145
ui/network/join_game_page.tscn
Normal file
145
ui/network/join_game_page.tscn
Normal 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
|
||||
|
|
@ -19,17 +19,22 @@ const BubbleConfirmationPageType = preload(
|
|||
"res://ui/components/bubble_menu/bubble_confirmation_page.gd"
|
||||
)
|
||||
const FishingSpotType = preload("res://fishing/fishing_spot.gd")
|
||||
const NetworkSessionType = preload("res://network/network_session.gd")
|
||||
const SavedServerStoreType = preload("res://network/saved_server_store.gd")
|
||||
const JoinGamePageType = preload("res://ui/network/join_game_page.gd")
|
||||
|
||||
signal return_to_title_requested
|
||||
signal reset_progress_requested
|
||||
signal quit_requested
|
||||
signal menu_visibility_changed(is_open: bool)
|
||||
signal join_game_requested(endpoint: String)
|
||||
|
||||
enum ConfirmationAction {
|
||||
NONE,
|
||||
RETURN_TO_TITLE,
|
||||
RESET_PROGRESS,
|
||||
QUIT_ANYWAY,
|
||||
JOIN_ANOTHER,
|
||||
}
|
||||
|
||||
enum CloseReason {
|
||||
|
|
@ -54,11 +59,14 @@ enum CloseReason {
|
|||
)
|
||||
@onready var _feedback: Label = %FeedbackLabel
|
||||
@onready var _save_button: BubbleButton = %SaveButton
|
||||
@onready var _join_game_page: JoinGamePageType = %JoinGamePage
|
||||
|
||||
var _player: PlayerType
|
||||
var _save_manager: SaveManagerType
|
||||
var _settings_manager: SettingsManagerType
|
||||
var _fishing_spot: FishingSpotType
|
||||
var _network_session: NetworkSessionType
|
||||
var _saved_servers: SavedServerStoreType
|
||||
var _prior_movement_enabled: bool = true
|
||||
var _prior_camera_enabled: bool = true
|
||||
var _prior_mouse_mode: Input.MouseMode = Input.MOUSE_MODE_VISIBLE
|
||||
|
|
@ -71,12 +79,14 @@ var _root_transition_generation: int = 0
|
|||
var _closing_menu: bool = false
|
||||
var _backdrop_fade: Tween
|
||||
var _backdrop_fade_generation: int = 0
|
||||
var _pending_join_endpoint: String = ""
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
%ResumeButton.pressed.connect(resume)
|
||||
_save_button.pressed.connect(_save_now)
|
||||
%SettingsButton.pressed.connect(_open_settings)
|
||||
%JoinGameButton.pressed.connect(_open_join_game)
|
||||
%ReturnToTitleButton.pressed.connect(_confirm_return_to_title)
|
||||
%ResetProgressButton.pressed.connect(_confirm_reset_progress)
|
||||
%QuitButton.pressed.connect(_request_quit)
|
||||
|
|
@ -87,6 +97,8 @@ func _ready() -> void:
|
|||
_settings_panel.navigation_transition_started.connect(
|
||||
_emit_transition_flurry
|
||||
)
|
||||
_join_game_page.join_requested.connect(_on_join_game_requested)
|
||||
_join_game_page.back_requested.connect(_close_join_game)
|
||||
_dim_background.color.a = 0.0
|
||||
_confirmation_page.hide_page()
|
||||
resized.connect(_update_responsive_pause_stage)
|
||||
|
|
@ -98,11 +110,16 @@ func setup(
|
|||
save_manager: SaveManagerType,
|
||||
settings_manager: SettingsManagerType,
|
||||
fishing_spot: FishingSpotType,
|
||||
network_session: NetworkSessionType,
|
||||
saved_servers: SavedServerStoreType,
|
||||
) -> void:
|
||||
_player = player
|
||||
_save_manager = save_manager
|
||||
_settings_manager = settings_manager
|
||||
_fishing_spot = fishing_spot
|
||||
_network_session = network_session
|
||||
_saved_servers = saved_servers
|
||||
_join_game_page.setup(network_session, saved_servers, true)
|
||||
if not _fishing_spot.bite_activated.is_connected(_on_bite_activated):
|
||||
_fishing_spot.bite_activated.connect(_on_bite_activated)
|
||||
|
||||
|
|
@ -121,6 +138,7 @@ func open_menu() -> void:
|
|||
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
|
||||
_feedback.text = ""
|
||||
_settings_panel.hide()
|
||||
_join_game_page.close_page()
|
||||
_confirmation_page.hide_page()
|
||||
_confirmation_action = ConfirmationAction.NONE
|
||||
_action_in_progress = false
|
||||
|
|
@ -169,6 +187,8 @@ func handle_escape() -> bool:
|
|||
return true
|
||||
if _confirmation_page.visible:
|
||||
_close_confirmation()
|
||||
elif _join_game_page.visible:
|
||||
_close_join_game()
|
||||
elif _settings_panel.visible:
|
||||
_settings_panel.handle_back()
|
||||
else:
|
||||
|
|
@ -203,6 +223,47 @@ func _open_settings() -> void:
|
|||
_begin_root_exit(_finish_open_settings)
|
||||
|
||||
|
||||
func _open_join_game() -> void:
|
||||
if (
|
||||
_action_in_progress
|
||||
or _root_transition_active
|
||||
or _confirmation_action != ConfirmationAction.NONE
|
||||
):
|
||||
return
|
||||
_begin_root_exit(_finish_open_join_game)
|
||||
|
||||
|
||||
func _finish_open_join_game() -> void:
|
||||
_join_game_page.open_page()
|
||||
|
||||
|
||||
func _close_join_game() -> void:
|
||||
_join_game_page.close_page()
|
||||
_begin_root_entry(true)
|
||||
|
||||
|
||||
func _on_join_game_requested(endpoint: String) -> void:
|
||||
if _action_in_progress or _root_transition_active:
|
||||
return
|
||||
_pending_join_endpoint = endpoint
|
||||
_open_confirmation(
|
||||
ConfirmationAction.JOIN_ANOTHER,
|
||||
"join another game?",
|
||||
(
|
||||
"your progression will be saved first. "
|
||||
+ "current players will be disconnected if you are hosting."
|
||||
),
|
||||
"save and join",
|
||||
false
|
||||
)
|
||||
|
||||
|
||||
func report_network_error(message: String) -> void:
|
||||
_feedback.text = message
|
||||
if _join_game_page.visible:
|
||||
_join_game_page.set_status(message)
|
||||
|
||||
|
||||
func _finish_open_settings() -> void:
|
||||
_settings_panel.open_panel(
|
||||
_settings_manager,
|
||||
|
|
@ -347,6 +408,10 @@ func _finish_confirmation_accept(action: ConfirmationAction) -> void:
|
|||
reset_progress_requested.emit()
|
||||
ConfirmationAction.QUIT_ANYWAY:
|
||||
quit_requested.emit()
|
||||
ConfirmationAction.JOIN_ANOTHER:
|
||||
_join_game_page.close_page()
|
||||
join_game_requested.emit(_pending_join_endpoint)
|
||||
_pending_join_endpoint = ""
|
||||
_:
|
||||
_action_in_progress = false
|
||||
_begin_root_entry(false)
|
||||
|
|
@ -421,6 +486,7 @@ func _finish_close(reason: CloseReason, restore_controls: bool) -> void:
|
|||
_dim_background.color.a = 0.0
|
||||
_dim_background.hide()
|
||||
_settings_panel.hide()
|
||||
_join_game_page.close_page()
|
||||
_root_page.hide_page()
|
||||
_confirmation_page.hide_page()
|
||||
_confirmation_action = ConfirmationAction.NONE
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
[gd_scene load_steps=10 format=3]
|
||||
[gd_scene load_steps=11 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://ui/pause_menu.gd" id="1_script"]
|
||||
[ext_resource type="PackedScene" path="res://ui/settings_panel.tscn" id="2_settings"]
|
||||
|
|
@ -9,6 +9,7 @@
|
|||
[ext_resource type="Resource" path="res://ui/components/bubble_menu/bubble_menu_profile.tres" id="7_profile"]
|
||||
[ext_resource type="Script" path="res://ui/components/bubble_menu/bubble_transition_flurry.gd" id="8_flurry"]
|
||||
[ext_resource type="PackedScene" path="res://ui/components/bubble_menu/bubble_confirmation_page.tscn" id="9_confirmation"]
|
||||
[ext_resource type="PackedScene" path="res://ui/network/join_game_page.tscn" id="10_join_page"]
|
||||
|
||||
[node name="PauseMenu" type="Control"]
|
||||
unique_name_in_owner = true
|
||||
|
|
@ -76,8 +77,8 @@ grow_horizontal = 2
|
|||
grow_vertical = 2
|
||||
script = ExtResource("4_page")
|
||||
page_id = &"pause"
|
||||
bubble_paths = Array[NodePath]([NodePath("BubbleCluster/ResumeButton"), NodePath("BubbleCluster/SaveButton"), NodePath("BubbleCluster/SettingsButton"), NodePath("BubbleCluster/ReturnToTitleButton"), NodePath("BubbleCluster/ResetProgressButton"), NodePath("BubbleCluster/QuitButton")])
|
||||
focus_paths = Array[NodePath]([NodePath("BubbleCluster/ResumeButton"), NodePath("BubbleCluster/SaveButton"), NodePath("BubbleCluster/SettingsButton"), NodePath("BubbleCluster/ReturnToTitleButton"), NodePath("BubbleCluster/ResetProgressButton"), NodePath("BubbleCluster/QuitButton")])
|
||||
bubble_paths = Array[NodePath]([NodePath("BubbleCluster/ResumeButton"), NodePath("BubbleCluster/SaveButton"), NodePath("BubbleCluster/JoinGameButton"), NodePath("BubbleCluster/SettingsButton"), NodePath("BubbleCluster/ReturnToTitleButton"), NodePath("BubbleCluster/ResetProgressButton"), NodePath("BubbleCluster/QuitButton")])
|
||||
focus_paths = Array[NodePath]([NodePath("BubbleCluster/ResumeButton"), NodePath("BubbleCluster/SaveButton"), NodePath("BubbleCluster/JoinGameButton"), NodePath("BubbleCluster/SettingsButton"), NodePath("BubbleCluster/ReturnToTitleButton"), NodePath("BubbleCluster/ResetProgressButton"), NodePath("BubbleCluster/QuitButton")])
|
||||
initial_focus_path = NodePath("BubbleCluster/ResumeButton")
|
||||
back_focus_path = NodePath("BubbleCluster/ResumeButton")
|
||||
maximum_layout_size = Vector2(720, 520)
|
||||
|
|
@ -134,6 +135,19 @@ minimum_font_size = 16
|
|||
maximum_font_size = 23
|
||||
motion_phase = 2.05
|
||||
|
||||
[node name="JoinGameButton" parent="ResponsivePauseStage/PausePresentationScaleRoot/RootPage/BubbleCluster" instance=ExtResource("5_bubble")]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(0, 0)
|
||||
text = "join\ngame"
|
||||
accessibility_name = "join game"
|
||||
neutral_size = Vector2(126, 120)
|
||||
desktop_anchor = Vector2(105, 320)
|
||||
compact_anchor = Vector2(105, 320)
|
||||
compact_minimum_size = Vector2(96, 92)
|
||||
minimum_font_size = 15
|
||||
maximum_font_size = 21
|
||||
motion_phase = 2.55
|
||||
|
||||
[node name="ReturnToTitleButton" parent="ResponsivePauseStage/PausePresentationScaleRoot/RootPage/BubbleCluster" instance=ExtResource("5_bubble")]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(0, 0)
|
||||
|
|
@ -209,3 +223,13 @@ anchor_right = 1.0
|
|||
anchor_bottom = 1.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
|
||||
[node name="JoinGamePage" parent="ResponsivePauseStage/PausePresentationScaleRoot" instance=ExtResource("10_join_page")]
|
||||
unique_name_in_owner = true
|
||||
z_index = 4
|
||||
layout_mode = 1
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ const FishDataType = preload("res://fish/fish_data.gd")
|
|||
const FishBuyerProfileType = preload("res://economy/fish_buyer_profile.gd")
|
||||
const FishSaleResultType = preload("res://economy/fish_sale_result.gd")
|
||||
const FishSaleServiceType = preload("res://economy/fish_sale_service.gd")
|
||||
const NetworkSessionType = preload("res://network/network_session.gd")
|
||||
const FishInventoryType = preload("res://inventory/fish_inventory.gd")
|
||||
const FishPoolType = preload("res://fish/fish_pool.gd")
|
||||
const FishingSpotType = preload("res://fishing/fishing_spot.gd")
|
||||
|
|
@ -225,6 +226,7 @@ var _inventory: FishInventoryType
|
|||
var _collection_log: CollectionLogType
|
||||
var _wallet: PlayerWalletType
|
||||
var _sale_service: FishSaleServiceType
|
||||
var _network_session: NetworkSessionType
|
||||
var _default_buyer: FishBuyerProfileType
|
||||
var _catalog: FishPoolType
|
||||
var _fishing_spot: FishingSpotType
|
||||
|
|
@ -353,6 +355,7 @@ func setup(
|
|||
hotbar: PlayerHotbarType,
|
||||
item_catalog: ItemCatalogType,
|
||||
cooler_capacity: PlayerCoolerCapacityType,
|
||||
network_session: NetworkSessionType,
|
||||
) -> void:
|
||||
_player = player
|
||||
_inventory = inventory
|
||||
|
|
@ -366,6 +369,7 @@ func setup(
|
|||
_hotbar = hotbar
|
||||
_item_catalog = item_catalog
|
||||
_cooler_capacity = cooler_capacity
|
||||
_network_session = network_session
|
||||
_fish_selection.clear()
|
||||
if not _inventory.catches_changed.is_connected(_on_inventory_changed):
|
||||
_inventory.catches_changed.connect(_on_inventory_changed)
|
||||
|
|
@ -2337,6 +2341,23 @@ func _update_sale_summary() -> void:
|
|||
_sale_unavailable.text = ""
|
||||
_sale_unavailable.visible = false
|
||||
return
|
||||
if not _can_use_shared_world_actions():
|
||||
_selection_summary.text = (
|
||||
"1 fish selected"
|
||||
if selected_count == 1
|
||||
else "%d fish selected" % selected_count
|
||||
)
|
||||
_selection_status.set_content("selected", str(selected_count))
|
||||
_offer_status.set_content("pelican offer", "host only")
|
||||
_sell_button.disabled = true
|
||||
_sell_bubble.disabled = true
|
||||
_sell_bubble.persistent_mark = false
|
||||
_sell_bubble.refresh_ink_state()
|
||||
_sale_unavailable.text = (
|
||||
"Selling in joined games is coming in a later multiplayer phase."
|
||||
)
|
||||
_sale_unavailable.visible = true
|
||||
return
|
||||
var preview: FishSaleResultType = (
|
||||
_sale_service.preview_batch(selected_ids, _default_buyer)
|
||||
if _sale_service != null
|
||||
|
|
@ -2407,6 +2428,11 @@ func _on_favorite_pressed() -> void:
|
|||
|
||||
|
||||
func _on_sell_pressed() -> void:
|
||||
if not _can_use_shared_world_actions():
|
||||
_transaction_feedback.text = (
|
||||
"Selling in joined games is coming in a later multiplayer phase."
|
||||
)
|
||||
return
|
||||
var selected_ids: Array[StringName] = _fish_selection.get_selected_ids()
|
||||
if (
|
||||
_inventory == null
|
||||
|
|
@ -2455,6 +2481,7 @@ func _on_sell_pressed() -> void:
|
|||
func _on_confirm_sale_pressed() -> void:
|
||||
if (
|
||||
_sale_in_progress
|
||||
or not _can_use_shared_world_actions()
|
||||
or _sale_service == null
|
||||
or _confirmation_catch_ids.is_empty()
|
||||
or _confirmation_buyer == null
|
||||
|
|
@ -2484,6 +2511,13 @@ func _on_confirm_sale_pressed() -> void:
|
|||
_inventory_tab.grab_focus()
|
||||
|
||||
|
||||
func _can_use_shared_world_actions() -> bool:
|
||||
return (
|
||||
_network_session == null
|
||||
or _network_session.can_use_host_gameplay()
|
||||
)
|
||||
|
||||
|
||||
func _close_sale_confirmation() -> void:
|
||||
var was_visible: bool = _sale_confirmation.visible
|
||||
_confirmation_catch_ids.clear()
|
||||
|
|
|
|||
|
|
@ -16,6 +16,9 @@ const BubbleClusterType = preload(
|
|||
const TitleConfirmationBubblePageType = preload(
|
||||
"res://ui/title_confirmation_bubble_page.gd"
|
||||
)
|
||||
const NetworkSessionType = preload("res://network/network_session.gd")
|
||||
const SavedServerStoreType = preload("res://network/saved_server_store.gd")
|
||||
const JoinGamePageType = preload("res://ui/network/join_game_page.gd")
|
||||
const DECORATIVE_FISH_TEXTURES: Array[Texture2D] = [
|
||||
preload("res://fish/species/bass/fish_bass_striped.png"),
|
||||
preload("res://fish/species/bluegill/fish_bluegill.png"),
|
||||
|
|
@ -84,8 +87,10 @@ const HOST_PRESENTATION_OUT_DURATION: float = 1.70
|
|||
const HOST_PRESENTATION_IN_DURATION: float = 1.85
|
||||
const HOST_CLUSTER_SAFE_MARGIN: float = 24.0
|
||||
|
||||
signal gameplay_requested
|
||||
signal new_game_requested
|
||||
signal continue_game_requested
|
||||
signal quit_requested
|
||||
signal join_game_requested(endpoint: String)
|
||||
|
||||
enum ConfirmationAction {
|
||||
NONE,
|
||||
|
|
@ -98,6 +103,7 @@ enum ConfirmationAction {
|
|||
@onready var _settings_button: BubbleButtonType = %SettingsButton
|
||||
@onready var _delete_button: BubbleButtonType = %DeleteSaveButton
|
||||
@onready var _quit_button: BubbleButtonType = %QuitButton
|
||||
@onready var _join_game_button: BubbleButtonType = %JoinGameButton
|
||||
@onready var _new_game_label: Label = %NewGameLabel
|
||||
@onready var _delete_save_label: Label = %DeleteSaveLabel
|
||||
@onready var _feedback_label: Label = %FeedbackLabel
|
||||
|
|
@ -129,6 +135,7 @@ enum ConfirmationAction {
|
|||
@onready var _bubble_field: BubbleClusterType = %BubbleField
|
||||
@onready var _start_prompt_center: CenterContainer = %StartPromptCenter
|
||||
@onready var _start_prompt_label: Label = %StartPromptLabel
|
||||
@onready var _join_game_page: JoinGamePageType = %JoinGamePage
|
||||
|
||||
var _save_manager: SaveManagerType
|
||||
var _settings_manager: SettingsManagerType
|
||||
|
|
@ -188,6 +195,7 @@ func _ready() -> void:
|
|||
_on_continue_stats_focus_changed.bind(false)
|
||||
)
|
||||
_new_game_button.pressed.connect(_on_new_game_pressed)
|
||||
_join_game_button.pressed.connect(_open_join_game)
|
||||
_settings_button.pressed.connect(_open_settings)
|
||||
_delete_button.pressed.connect(_on_delete_pressed)
|
||||
%QuitButton.pressed.connect(_on_quit_pressed)
|
||||
|
|
@ -221,9 +229,14 @@ func _ready() -> void:
|
|||
func setup(
|
||||
save_manager: SaveManagerType,
|
||||
settings_manager: SettingsManagerType,
|
||||
network_session: NetworkSessionType,
|
||||
saved_servers: SavedServerStoreType,
|
||||
) -> void:
|
||||
_save_manager = save_manager
|
||||
_settings_manager = settings_manager
|
||||
_join_game_page.setup(network_session, saved_servers, false)
|
||||
_join_game_page.join_requested.connect(join_game_requested.emit)
|
||||
_join_game_page.back_requested.connect(_close_join_game)
|
||||
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
|
||||
_refresh_save_inspection()
|
||||
show()
|
||||
|
|
@ -239,12 +252,45 @@ func reopen() -> void:
|
|||
_prepare_awaiting_start_input()
|
||||
_reset_confirmation()
|
||||
_settings_panel.hide()
|
||||
_join_game_page.close_page()
|
||||
_refresh_save_inspection()
|
||||
show()
|
||||
_start_decorative_presentation()
|
||||
_start_entry_prompt_animation()
|
||||
|
||||
|
||||
func open_join_game_page(endpoint: String = "") -> void:
|
||||
_cancel_title_entry_transition()
|
||||
_awaiting_start_input = false
|
||||
_start_prompt_center.hide()
|
||||
_presentation_center.hide()
|
||||
_settings_panel.hide()
|
||||
_confirmation_page.hide_page()
|
||||
_join_game_page.open_page(endpoint)
|
||||
|
||||
|
||||
func report_network_error(message: String) -> void:
|
||||
_join_game_page.set_status(message)
|
||||
if not _join_game_page.visible:
|
||||
_feedback_label.text = message
|
||||
_feedback_label.show()
|
||||
_feedback_label.modulate.a = 1.0
|
||||
|
||||
|
||||
func _open_join_game() -> void:
|
||||
if _action_in_progress or _is_confirmation_active():
|
||||
return
|
||||
open_join_game_page()
|
||||
|
||||
|
||||
func _close_join_game() -> void:
|
||||
_join_game_page.close_page()
|
||||
_presentation_center.show()
|
||||
_button_center.show()
|
||||
_start_prompt_center.hide()
|
||||
_focus_initial_button()
|
||||
|
||||
|
||||
func is_awaiting_start_input() -> bool:
|
||||
return _awaiting_start_input
|
||||
|
||||
|
|
@ -347,6 +393,7 @@ func _get_title_buttons() -> Array[BubbleButton]:
|
|||
return [
|
||||
_continue_button,
|
||||
_new_game_button,
|
||||
_join_game_button,
|
||||
_settings_button,
|
||||
_delete_button,
|
||||
_quit_button,
|
||||
|
|
@ -438,6 +485,11 @@ func _process(delta: float) -> void:
|
|||
func _input(event: InputEvent) -> void:
|
||||
if not visible:
|
||||
return
|
||||
if _join_game_page.visible:
|
||||
if event.is_action_pressed("ui_cancel"):
|
||||
_close_join_game()
|
||||
get_viewport().set_input_as_handled()
|
||||
return
|
||||
if (
|
||||
_title_settings_transition_active
|
||||
or _title_entry_transition_active
|
||||
|
|
@ -851,12 +903,7 @@ func _on_continue_pressed() -> void:
|
|||
return
|
||||
_hide_continue_stats_context()
|
||||
_action_in_progress = true
|
||||
if _save_manager.load_player_data():
|
||||
_feedback_label.text = "save loaded."
|
||||
gameplay_requested.emit()
|
||||
else:
|
||||
_refresh_save_inspection()
|
||||
_feedback_label.text = "failed to load save. the original was preserved."
|
||||
continue_game_requested.emit()
|
||||
_action_in_progress = false
|
||||
|
||||
|
||||
|
|
@ -870,8 +917,7 @@ func _on_new_game_pressed() -> void:
|
|||
return
|
||||
_hide_continue_stats_context()
|
||||
if _inspection.status == SaveInspectionType.Status.MISSING:
|
||||
if _save_manager.initialize_new_game():
|
||||
gameplay_requested.emit()
|
||||
new_game_requested.emit()
|
||||
return
|
||||
if _inspection.status == SaveInspectionType.Status.UNSUPPORTED_VERSION:
|
||||
_feedback_label.text = (
|
||||
|
|
@ -917,6 +963,14 @@ func _on_confirmation_accepted() -> void:
|
|||
var action: ConfirmationAction = _confirmation_action
|
||||
_confirmation_page.lock_interaction()
|
||||
_action_in_progress = true
|
||||
if action == ConfirmationAction.NEW_GAME:
|
||||
_confirmation_action = ConfirmationAction.NONE
|
||||
_confirmation_transition_generation += 1
|
||||
_cancel_confirmation_transition()
|
||||
_confirmation_page.hide_page()
|
||||
new_game_requested.emit()
|
||||
_action_in_progress = false
|
||||
return
|
||||
if not _save_manager.delete_progression_save():
|
||||
_feedback_label.text = "failed to delete saved progression."
|
||||
_action_in_progress = false
|
||||
|
|
@ -925,13 +979,6 @@ func _on_confirmation_accepted() -> void:
|
|||
return
|
||||
_save_manager.initialize_new_game()
|
||||
_refresh_save_inspection()
|
||||
if action == ConfirmationAction.NEW_GAME:
|
||||
_confirmation_action = ConfirmationAction.NONE
|
||||
_confirmation_transition_generation += 1
|
||||
_cancel_confirmation_transition()
|
||||
_confirmation_page.hide_page()
|
||||
gameplay_requested.emit()
|
||||
else:
|
||||
_feedback_label.text = "saved progression deleted."
|
||||
_begin_confirmation_return()
|
||||
_action_in_progress = false
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
[gd_scene load_steps=13 format=3]
|
||||
[gd_scene load_steps=14 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://ui/title_screen.gd" id="1_script"]
|
||||
[ext_resource type="Theme" path="res://ui/game_theme.tres" id="2_theme"]
|
||||
|
|
@ -10,6 +10,7 @@
|
|||
[ext_resource type="Script" path="res://ui/components/bubble_menu/bubble_cluster.gd" id="8_bubble_cluster"]
|
||||
[ext_resource type="Resource" path="res://ui/components/bubble_menu/bubble_menu_profile.tres" id="9_bubble_profile"]
|
||||
[ext_resource type="PackedScene" path="res://ui/title_confirmation_bubble_page.tscn" id="10_confirmation_page"]
|
||||
[ext_resource type="PackedScene" path="res://ui/network/join_game_page.tscn" id="11_join_page"]
|
||||
|
||||
[sub_resource type="ShaderMaterial" id="ShaderMaterial_title_water"]
|
||||
shader = ExtResource("4_water_shader")
|
||||
|
|
@ -314,6 +315,28 @@ motion_period = 4.9
|
|||
motion_phase = 2.5
|
||||
deformation_period = 6.3
|
||||
|
||||
[node name="JoinGameButton" type="Button" parent="ResponsiveTitleStage/TitlePresentationScaleRoot/Center/MainContent/ButtonCenter/BubbleLayoutSlot/BubbleMotionRoot/BubbleField"]
|
||||
unique_name_in_owner = true
|
||||
offset_left = 126.0
|
||||
offset_top = 224.0
|
||||
offset_right = 248.0
|
||||
offset_bottom = 342.0
|
||||
text = "join\ngame"
|
||||
script = ExtResource("7_bubble_button")
|
||||
profile = ExtResource("9_bubble_profile")
|
||||
neutral_size = Vector2(122, 118)
|
||||
desktop_anchor = Vector2(187, 268)
|
||||
compact_anchor = Vector2(187, 268)
|
||||
compact_minimum_size = Vector2(82, 80)
|
||||
minimum_font_size = 15
|
||||
maximum_font_size = 22
|
||||
horizontal_amplitude = 1.7
|
||||
vertical_amplitude = 3.8
|
||||
motion_period = 5.2
|
||||
motion_phase = 3.1
|
||||
deformation_amplitude = 0.015
|
||||
deformation_period = 5.7
|
||||
|
||||
[node name="DeleteSaveButton" type="Button" parent="ResponsiveTitleStage/TitlePresentationScaleRoot/Center/MainContent/ButtonCenter/BubbleLayoutSlot/BubbleMotionRoot/BubbleField"]
|
||||
unique_name_in_owner = true
|
||||
offset_left = -11.0
|
||||
|
|
@ -407,3 +430,13 @@ offset_right = 360.0
|
|||
offset_bottom = 300.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
|
||||
[node name="JoinGamePage" parent="ResponsiveTitleStage/TitlePresentationScaleRoot" instance=ExtResource("11_join_page")]
|
||||
unique_name_in_owner = true
|
||||
z_index = 220
|
||||
layout_mode = 1
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ class_name WaterRecoveryController
|
|||
extends Node
|
||||
|
||||
signal recovery_starting
|
||||
signal local_respawn_completed(entry_position: Vector3)
|
||||
|
||||
enum RecoveryState {
|
||||
IDLE,
|
||||
|
|
@ -165,6 +166,7 @@ func _respawn_player() -> void:
|
|||
_player.global_transform = respawn_transform
|
||||
_player.restore_gameplay_orientation_after_recovery()
|
||||
_player.velocity = Vector3.ZERO
|
||||
local_respawn_completed.emit(_entry_position)
|
||||
|
||||
|
||||
func _finish_recovery() -> void:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue