feat: expand progression and multiplayer systems

Add named save slots, progression import/export, and a unified play flow. Add live friend requests, presence, invitations, and relationship controls without durable discovery-server social storage. Advance the network protocol with isolated channels, movement reconciliation, late-join recovery, fishing replication, and animation synchronization. Preserve per-species catch totals, refine generated-world startup and water recovery, and complete the related input and interface improvements.
This commit is contained in:
Alexander Sellite 2026-08-23 20:48:38 -04:00
parent 1db1a5b754
commit 3b84bfe3a0
97 changed files with 7869 additions and 982 deletions

View file

@ -13,6 +13,9 @@ const WaterRecoveryControllerType = preload(
const PlayerSaveManagerType = preload(
"res://save/player_save_manager.gd"
)
const PlayerSaveSlotCatalogType = preload(
"res://save/player_save_slot_catalog.gd"
)
const PlayerSettingsManagerType = preload(
"res://settings/player_settings_manager.gd"
)
@ -131,6 +134,8 @@ const TIME_CROSSING_EPSILON_HOURS: float = 0.000001
const PLAYER_MENU_PATTERN_SCALE: float = 0.85
const PLAYER_MENU_PATTERN_SCROLL_VELOCITY := Vector2(-7.0, -5.0)
const SHOP_PATTERN_SCALE: float = 1.75
const DEDICATED_IDLE_PHYSICS_TICKS_PER_SECOND: int = 10
const DEDICATED_ACTIVE_PHYSICS_TICKS_PER_SECOND: int = 30
@export var fish_catalog: FishPoolType
@export var pelican_buyer_profile: FishBuyerProfileType
@ -234,6 +239,7 @@ const SHOP_PATTERN_SCALE: float = 1.75
@onready var _shop_backdrop: ColorRect = %ShopBackdrop
var _gameplay_started: bool = false
var _save_slots := PlayerSaveSlotCatalogType.new()
var _shop_interaction: FishingShopInteractionType
var _storage_interaction: PlayerStorageInteractionType
var _title_music_tween: Tween
@ -246,6 +252,10 @@ var _join_requested_from_pause: bool = false
var _player_menu_backdrop_tween: Tween
var _shop_backdrop_tween: Tween
var _pending_join_endpoint: String = ""
var _social_prompt_queue: Array[Dictionary] = []
var _active_social_prompt: Dictionary = {}
var _pending_social_invite_join: bool = false
var _pending_social_invite_gameplay: bool = false
var _server_trust_dialog: ConfirmationDialog
var _pending_trust_changed: bool = false
var _identity_notice_dialog: AcceptDialog
@ -260,6 +270,7 @@ var _performance_profile: RuntimePerformanceProfileType
var _pending_existing_root_path := ""
var _local_recovery_attempt_id: String = ""
var _dedicated_runtime: bool = false
var _dedicated_metrics_accumulator: float = 0.0
@onready var _shoreline_ambience: ShorelineAmbience = %ShorelineAmbience
var _rain_ambience: RainAmbienceType
@ -269,6 +280,9 @@ func _ready() -> void:
_performance_profile = RuntimePerformanceProfileType.from_environment()
_dedicated_runtime = _is_dedicated_server_runtime()
if _dedicated_runtime:
Engine.max_fps = 30
Engine.physics_ticks_per_second = 30
_disable_dedicated_presentation_tree()
call_deferred("_start_dedicated_server")
return
_world_pixelation.set_light_performance_profile(
@ -382,6 +396,74 @@ func _is_dedicated_server_runtime() -> bool:
return "--dedicated-server" in OS.get_cmdline_user_args()
func _disable_dedicated_presentation_tree() -> void:
for path: NodePath in [
NodePath("TitleBackgroundLayer"),
NodePath("PlayerMenuBackdrop"),
NodePath("ShopBackdrop"),
NodePath("WorldPixelationPostprocess"),
NodePath("UIPresentation"),
NodePath("PixelationResetOverlay"),
NodePath("WorldTimeVisualController"),
NodePath("GatheringController"),
NodePath("FishingSpot"),
NodePath("WaterRecovery"),
NodePath("ControllerMappingManager"),
NodePath("KeyboardMouseMappingManager"),
]:
var presentation_node: Node = get_node_or_null(path)
if presentation_node == null:
continue
presentation_node.process_mode = Node.PROCESS_MODE_DISABLED
if presentation_node is CanvasItem:
(presentation_node as CanvasItem).visible = false
elif presentation_node is CanvasLayer:
(presentation_node as CanvasLayer).visible = false
for audio_path: NodePath in [
NodePath("TitleMusic"),
NodePath("NewGameMusic"),
NodePath("DuskMusic"),
NodePath("ShorelineAmbience"),
]:
var audio_node: Node = get_node_or_null(audio_path)
if audio_node == null:
continue
if audio_node.has_method("stop"):
audio_node.call("stop")
audio_node.process_mode = Node.PROCESS_MODE_DISABLED
func _print_dedicated_metrics() -> void:
var metrics: Dictionary = {
"players": _network_session.get_player_count(),
"fps": Engine.get_frames_per_second(),
"frame_process_ms": Performance.get_monitor(
Performance.TIME_PROCESS
) * 1000.0,
"frame_physics_ms": Performance.get_monitor(
Performance.TIME_PHYSICS_PROCESS
) * 1000.0,
"memory_mib": Performance.get_monitor(
Performance.MEMORY_STATIC
) / (1024.0 * 1024.0),
"nodes": int(Performance.get_monitor(Performance.OBJECT_NODE_COUNT)),
"physics_active_objects": int(Performance.get_monitor(
Performance.PHYSICS_3D_ACTIVE_OBJECTS
)),
"physics_collision_pairs": int(Performance.get_monitor(
Performance.PHYSICS_3D_COLLISION_PAIRS
)),
"movement": _network_session.get_movement_metrics(),
}
if _network_fishing.has_method("get_network_metrics"):
metrics["fishing"] = _network_fishing.call("get_network_metrics")
if _network_world_spawns.has_method("get_network_metrics"):
metrics["gatherables"] = _network_world_spawns.call(
"get_network_metrics"
)
print("NETfishing server metrics: %s" % JSON.stringify(metrics))
func _start_dedicated_server() -> void:
var config: DedicatedServerConfigType = DedicatedServerConfigType.from_runtime()
if not config.is_valid():
@ -429,6 +511,7 @@ func _start_dedicated_server() -> void:
):
_fail_dedicated_server("The configured world seed could not be generated.")
return
_test_world.set_dedicated_simulation(true)
if not _network_session.set_host_world(
WorldLayoutType.GENERATED,
config.world_seed,
@ -441,10 +524,17 @@ func _start_dedicated_server() -> void:
return
_initialize_application(true)
_player.set_local_control(false)
_player.set_network_simulation_only(true)
_player.visible = false
_player.collision_layer = 0
_player.collision_mask = 0
_player.set_physics_process(false)
if not _network_session.peer_count_changed.is_connected(
_on_dedicated_peer_count_changed
):
_network_session.peer_count_changed.connect(
_on_dedicated_peer_count_changed
)
if not _network_session.start_dedicated_host(
config.port,
config.max_players,
@ -456,6 +546,10 @@ func _start_dedicated_server() -> void:
if not _network_session.set_host_open(true):
_fail_dedicated_server("Could not open the dedicated server.")
return
_on_dedicated_peer_count_changed(
_network_session.get_player_count(),
config.max_players,
)
_player_jobs.begin_progression_session()
if config.public_listing and not _discovery.set_discoverable(true):
_network_session.disconnect_session("Discovery setup failed.")
@ -478,15 +572,34 @@ func _start_dedicated_server() -> void:
)
func _on_dedicated_peer_count_changed(
player_count: int,
_max_players: int,
) -> void:
if not _dedicated_runtime:
return
Engine.physics_ticks_per_second = (
DEDICATED_ACTIVE_PHYSICS_TICKS_PER_SECOND
if player_count > 0
else DEDICATED_IDLE_PHYSICS_TICKS_PER_SECOND
)
func _fail_dedicated_server(message: String) -> void:
push_error("Dedicated server startup failed: %s" % message)
get_tree().quit(1)
func _configure_portable_stores() -> void:
_save_manager.configure_storage(
_data_root.path_for(&"player_save"), _data_root
)
if _dedicated_runtime:
_save_manager.configure_storage(
_data_root.path_for(&"player_save"), _data_root
)
elif not _save_slots.configure(_data_root, _save_manager):
push_error(
"Save-slot catalog setup failed: %s"
% _save_slots.get_error_message()
)
_network_profile.configure_storage(
_data_root.path_for(&"network_profile"), _data_root
)
@ -523,7 +636,8 @@ func _initialize_application(dedicated: bool) -> void:
_player_spawn_service.setup(
_players_root,
_player,
_test_world.get_player_spawn_transform()
_test_world.get_player_spawn_transform(),
dedicated,
)
_network_session.setup(
_network_profile,
@ -536,7 +650,7 @@ func _initialize_application(dedicated: bool) -> void:
_host_bans,
dedicated,
)
_discovery.setup(_network_session)
_discovery.setup(_network_session, _relationships)
if not dedicated:
_world_time_visuals.setup(
_world_time,
@ -699,6 +813,7 @@ func _initialize_application(dedicated: bool) -> void:
_save_manager,
_network_item_use,
_world_time,
not dedicated,
)
_gathering_controller.setup(
_player,
@ -747,7 +862,8 @@ func _initialize_application(dedicated: bool) -> void:
_save_manager,
item_catalog,
fish_catalog,
_network_item_use
_network_item_use,
not dedicated,
)
var sale_buyers: Array[FishBuyerProfileType] = [
pelican_buyer_profile,
@ -850,7 +966,6 @@ func _initialize_application(dedicated: bool) -> void:
)
_game_ui.setup_data_and_identity(
_data_root,
_save_manager,
_identity_backups,
_player_identity,
_host_identity,
@ -860,6 +975,18 @@ func _initialize_application(dedicated: bool) -> void:
_game_ui.setup_controller_mapping(_controller_mapping_manager)
_ui_pixelation.setup_controller_mapping(_controller_mapping_manager)
_game_ui.setup_keyboard_mouse_mapping(_keyboard_mouse_mapping_manager)
_network_player_list.friend_request_received.connect(
_on_friend_request_received
)
_discovery.friend_invite_received.connect(_on_friend_invite_received)
_discovery.public_join_prepared.connect(
_on_social_public_join_prepared
)
_discovery.public_join_status_changed.connect(
_on_social_public_join_status_changed
)
_game_ui.social_prompt_accepted.connect(_on_social_prompt_accepted)
_game_ui.social_prompt_declined.connect(_on_social_prompt_declined)
_data_root.conflict_detected.connect(_on_portable_conflict)
if (
PortableFileGuard.has_syncthing_conflict(
@ -909,16 +1036,19 @@ func _initialize_application(dedicated: bool) -> void:
_apply_runtime_settings(_settings_manager.current_settings)
_set_gameplay_active(false)
var title_screen: TitleScreenType = _game_ui.get_title_screen()
title_screen.new_game_requested.connect(_on_new_game_requested)
title_screen.continue_game_requested.connect(_on_continue_game_requested)
title_screen.slot_play_requested.connect(_on_slot_play_requested)
title_screen.new_slot_requested.connect(_on_new_slot_requested)
title_screen.quit_requested.connect(_on_quit_requested)
title_screen.setup(
_save_manager,
_save_slots,
_settings_manager,
_network_session,
_saved_servers,
_server_trust,
_discovery,
_data_root,
_interface_fonts,
)
var pause_menu: PauseMenuType = _game_ui.get_pause_menu()
pause_menu.setup(
@ -1454,8 +1584,15 @@ func _unhandled_input(event: InputEvent) -> void:
get_viewport().set_input_as_handled()
func _process(_delta: float) -> void:
func _process(delta: float) -> void:
if _dedicated_runtime:
_dedicated_metrics_accumulator += delta
if _dedicated_metrics_accumulator >= 60.0:
_dedicated_metrics_accumulator = fmod(
_dedicated_metrics_accumulator,
60.0,
)
_print_dedicated_metrics()
return
var show_shop_prompt := _can_show_shop_prompt()
var shop_prompt_anchor := (
@ -1500,7 +1637,8 @@ func _apply_runtime_settings(settings: PlayerSettingsType) -> void:
_player.apply_camera_settings(
settings.mouse_camera_sensitivity,
settings.controller_camera_sensitivity,
settings.invert_camera_y
settings.invert_camera_y,
settings.swap_hotbar_camera_scroll,
)
_fishing_spot.configure_accessibility_auto_click(
settings.auto_click_enabled,
@ -1760,6 +1898,97 @@ func _on_continue_game_requested() -> void:
_enter_gameplay()
func _on_slot_play_requested(slot_id: String) -> void:
if _gameplay_started or _quit_in_progress:
return
if not _save_slots.activate_slot(slot_id):
_game_ui.get_title_screen().report_network_error(
"The selected save slot could not be opened."
)
return
if not _save_manager.load_player_data():
_game_ui.get_title_screen().report_network_error(
"Failed to load that save slot. The original was preserved."
)
return
var world_layout: StringName = _save_manager.get_world_layout()
var world_seed: int = _save_manager.get_world_seed()
if (
not _apply_world(world_layout, world_seed, true)
or not _prepare_host_world(world_layout, world_seed)
):
_game_ui.get_title_screen().report_network_error(
"The saved world could not be loaded. The save was preserved."
)
return
if not _prepare_private_host():
return
_save_slots.mark_played(slot_id)
_enter_gameplay()
func _on_new_slot_requested(
display_name: String,
world_layout: StringName,
world_seed: int,
duplicate_source_slot_id: String,
) -> void:
if _gameplay_started or _quit_in_progress:
return
var created: Dictionary = (
_save_slots.duplicate_slot(
duplicate_source_slot_id,
display_name,
world_layout,
world_seed,
)
if not duplicate_source_slot_id.is_empty()
else _save_slots.create_empty_slot(display_name)
)
if not bool(created.get("ok", false)):
_game_ui.get_title_screen().report_network_error(
str(created.get("message", "The save slot could not be created."))
)
return
var slot_id: String = str(created.get("slot_id", ""))
if not _save_slots.activate_slot(slot_id):
_game_ui.get_title_screen().report_network_error(
"The new save slot could not be selected."
)
return
if not duplicate_source_slot_id.is_empty():
_on_slot_play_requested(slot_id)
return
_start_new_game_music()
if not _apply_world(world_layout, world_seed, true):
_show_title_music(true)
_game_ui.get_title_screen().report_network_error(
"Could not prepare that world. Try another seed or the starter island."
)
return
if not _prepare_host_world(world_layout, world_seed):
_show_title_music(true)
_game_ui.get_title_screen().report_network_error(
"Could not prepare that world for hosting."
)
return
if not _prepare_private_host():
_show_title_music(true)
return
if (
not _save_manager.delete_progression_save()
or not _save_manager.initialize_new_game(world_seed, world_layout)
):
_network_session.disconnect_session("New Game setup failed.")
_show_title_music(true)
_game_ui.get_title_screen().report_network_error(
"Could not initialize local progression."
)
return
_save_slots.mark_played(slot_id)
_enter_gameplay()
func _prepare_private_host() -> bool:
_join_requested_from_title = false
_join_requested_from_pause = false
@ -1816,6 +2045,12 @@ func _on_return_to_title_requested() -> void:
func _on_title_join_game_requested(endpoint: String) -> void:
if _quit_in_progress or _gameplay_started:
return
var active_slot: Dictionary = _save_slots.ensure_active_slot()
if not bool(active_slot.get("ok", false)):
_game_ui.get_title_screen().report_network_error(
str(active_slot.get("message", "Local progression is unavailable."))
)
return
if _network_session.state != NetworkSessionType.State.INACTIVE:
_network_session.disconnect_session("Preparing direct connection.")
_join_requested_from_title = true
@ -1849,6 +2084,135 @@ func _on_pause_join_game_requested(endpoint: String) -> void:
)
func _on_friend_request_received(
request_id: String,
fingerprint: String,
display_name: String,
) -> void:
_queue_social_prompt({
"kind": "friend_request",
"request_id": request_id,
"fingerprint": fingerprint,
"display_name": display_name,
})
func _on_friend_invite_received(
fingerprint: String,
display_name: String,
room: Dictionary,
) -> void:
if (
not _relationships.is_friend(fingerprint)
or _relationships.is_blocked(fingerprint)
):
return
_queue_social_prompt({
"kind": "friend_invite",
"fingerprint": fingerprint,
"display_name": display_name,
"room": room.duplicate(true),
})
func _queue_social_prompt(details: Dictionary) -> void:
_social_prompt_queue.append(details.duplicate(true))
_show_next_social_prompt()
func _show_next_social_prompt() -> void:
if (
not _active_social_prompt.is_empty()
or _game_ui.is_social_prompt_open()
or _social_prompt_queue.is_empty()
):
return
_active_social_prompt = _social_prompt_queue.pop_front()
var kind := str(_active_social_prompt.get("kind", ""))
var display_name := str(
_active_social_prompt.get("display_name", "Player")
)
var shown: bool = false
if kind == "friend_request":
shown = _game_ui.show_social_prompt(
"%s wants to add you as a friend.\n" % display_name
+ "Friendships are saved on this device.",
"accept",
"decline",
)
elif kind == "friend_invite":
var room: Dictionary = _active_social_prompt.get("room", {})
var room_name := str(room.get("room_name", "their room"))
shown = _game_ui.show_social_prompt(
"%s invited you to join %s." % [display_name, room_name],
"join",
"not now",
)
if not shown:
_active_social_prompt.clear()
call_deferred("_show_next_social_prompt")
func _on_social_prompt_accepted() -> void:
var details: Dictionary = _active_social_prompt.duplicate(true)
_active_social_prompt.clear()
match str(details.get("kind", "")):
"friend_request":
_network_player_list.respond_friend_request(
str(details.get("request_id", "")), true
)
"friend_invite":
var room: Dictionary = details.get("room", {})
_pending_social_invite_join = true
_pending_social_invite_gameplay = _gameplay_started
if room.is_empty() or not _discovery.prepare_public_join(room):
_pending_social_invite_join = false
_report_social_join_error(
"This person needs to be online to do this."
)
call_deferred("_show_next_social_prompt")
func _on_social_prompt_declined() -> void:
var details: Dictionary = _active_social_prompt.duplicate(true)
_active_social_prompt.clear()
if str(details.get("kind", "")) == "friend_request":
_network_player_list.respond_friend_request(
str(details.get("request_id", "")), false
)
call_deferred("_show_next_social_prompt")
func _on_social_public_join_prepared(endpoint: String) -> void:
if not _pending_social_invite_join:
return
var from_gameplay := _pending_social_invite_gameplay
_pending_social_invite_join = false
_pending_social_invite_gameplay = false
if from_gameplay:
_on_pause_join_game_requested(endpoint)
else:
_on_title_join_game_requested(endpoint)
func _on_social_public_join_status_changed(
message: String,
is_error: bool,
) -> void:
if not _pending_social_invite_join or not is_error:
return
_pending_social_invite_join = false
_pending_social_invite_gameplay = false
_report_social_join_error(message)
func _report_social_join_error(message: String) -> void:
if _gameplay_started:
_game_ui.get_pause_menu().report_network_error(message)
else:
_game_ui.get_title_screen().report_network_error(message)
func _on_network_join_authenticated() -> void:
var connected_endpoint: ConnectionEndpoint = (
_network_session.get_current_endpoint()
@ -1880,6 +2244,7 @@ func _on_network_join_authenticated() -> void:
)
_join_requested_from_title = false
return
_save_slots.mark_played(_save_slots.get_active_slot_id())
if not _apply_joined_world(server_metadata):
return
_fade_out_title_music()
@ -2049,6 +2414,7 @@ func _apply_world(
_player.velocity = Vector3.ZERO
if _application_initialized:
_refresh_active_world_bindings()
_network_world_spawns.refresh_world_context()
if _application_initialized and not _dedicated_runtime:
_water_recovery.update_world_context(
spawn_transform,