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

@ -7,6 +7,7 @@ signal back_requested
enum Mode {
DISCOVER,
FRIENDS,
DIRECT,
SAVED,
RECENT,
@ -26,6 +27,12 @@ const DIRECT_WORKFLOW_HELP: String = (
% ADDRESS_FORMAT_HELP
)
@onready var _main_panel: PanelContainer = %MainPanel
@onready var _content_panel: PanelContainer = %ContentPanel
@onready var _direct_content: Control = %DirectContent
@onready var _list_content: Control = %ListContent
@onready var _list_title: Label = %ListTitle
@onready var _details_panel: PanelContainer = %DetailsPanel
@onready var _address: LineEdit = %Address
@onready var _address_label: Label = %AddressLabel
@onready var _address_helper: Label = %AddressHelper
@ -34,10 +41,11 @@ const DIRECT_WORKFLOW_HELP: String = (
@onready var _name_helper: Label = %NameHelper
@onready var _server_list: ItemList = %ServerList
@onready var _details: Label = %Details
@onready var _discover_button: Button = %DiscoverButton
@onready var _direct_button: Button = %DirectButton
@onready var _saved_button: Button = %SavedButton
@onready var _recent_button: Button = %RecentButton
@onready var _discover_button: OrganizerTab = %DiscoverButton
@onready var _friends_button: OrganizerTab = %FriendsButton
@onready var _direct_button: OrganizerTab = %DirectButton
@onready var _saved_button: OrganizerTab = %SavedButton
@onready var _recent_button: OrganizerTab = %RecentButton
@onready var _join_button: Button = %JoinButton
@onready var _refresh_button: Button = %RefreshButton
@onready var _save_button: Button = %SaveButton
@ -62,6 +70,8 @@ var _visible_entries: Array[SavedServerEntry] = []
var _selected_entry: SavedServerEntry
var _discovery_rooms: Array[Dictionary] = []
var _selected_discovery_index: int = -1
var _friend_entries: Array[Dictionary] = []
var _selected_friend_index: int = -1
var _discovery_refresh_timer: Timer
var _editing_entry_id: String = ""
var _name_entry_active: bool = false
@ -69,24 +79,13 @@ var _delete_armed: bool = false
var _connection_error_latched: bool = false
var _pending_confirmation_endpoint: String = ""
var _pending_confirmation_room: Dictionary = {}
var _owns_pending_public_join: bool = false
func _ready() -> void:
UtilityPageStyle.apply_page(self)
var paper := get_node("Paper") as PanelContainer
paper.add_theme_stylebox_override(
"panel", UtilityPageStyle.panel_style()
)
for button: BaseButton in [
_discover_button, _direct_button, _saved_button, _recent_button,
_refresh_button, _join_button,
_save_button, _edit_button, _favorite_button, _delete_button,
_cancel_button, _back_button,
]:
UtilityPageStyle.apply_ocean_button(button)
UtilityPageStyle.apply_ocean_line_edit(_address)
UtilityPageStyle.apply_ocean_line_edit(_name_edit)
_configure_style()
_discover_button.pressed.connect(_set_mode.bind(Mode.DISCOVER))
_friends_button.pressed.connect(_set_mode.bind(Mode.FRIENDS))
_direct_button.pressed.connect(_set_mode.bind(Mode.DIRECT))
_saved_button.pressed.connect(_set_mode.bind(Mode.SAVED))
_recent_button.pressed.connect(_set_mode.bind(Mode.RECENT))
@ -121,6 +120,96 @@ func _ready() -> void:
hide()
func _configure_style() -> void:
UtilityPageStyle.apply_page(self)
_main_panel.add_theme_stylebox_override(
"panel",
UtilityPageStyle.rounded_style(
UtilityPageStyle.OCEAN_PANEL_MID,
28,
),
)
_content_panel.add_theme_stylebox_override(
"panel",
UtilityPageStyle.rounded_style(
UtilityPageStyle.OCEAN_FIELD,
20,
),
)
_details_panel.add_theme_stylebox_override(
"panel",
UtilityPageStyle.row_style(),
)
_server_list.add_theme_stylebox_override(
"panel",
UtilityPageStyle.rounded_style(
UtilityPageStyle.OCEAN_PANEL_MID,
12,
),
)
_server_list.add_theme_stylebox_override(
"focus",
StyleBoxEmpty.new(),
)
_server_list.add_theme_stylebox_override(
"selected",
UtilityPageStyle.row_style(true),
)
_server_list.add_theme_stylebox_override(
"selected_focus",
UtilityPageStyle.row_style(true),
)
_server_list.add_theme_color_override(
"font_color",
UtilityPageStyle.OCEAN_TEXT_PRIMARY,
)
_server_list.add_theme_color_override(
"font_selected_color",
UtilityPageStyle.OCEAN_TEXT_PRIMARY,
)
for node: Node in find_children("*", "Label", true, false):
var label := node as Label
label.add_theme_color_override(
"font_color",
UtilityPageStyle.OCEAN_TEXT_PRIMARY,
)
for node: Node in find_children("*", "Button", true, false):
if node is OrganizerTab:
continue
UtilityPageStyle.apply_ocean_button(node as BaseButton)
UtilityPageStyle.apply_ocean_line_edit(_address)
UtilityPageStyle.apply_ocean_line_edit(_name_edit)
_join_button.add_theme_stylebox_override(
"normal",
UtilityPageStyle.ocean_button_style(
UtilityPageStyle.GREEN,
),
)
_delete_button.add_theme_stylebox_override(
"normal",
UtilityPageStyle.ocean_button_style(
UtilityPageStyle.OCEAN_DANGER,
),
)
_back_button.add_theme_stylebox_override(
"normal",
UtilityPageStyle.ocean_button_style(
UtilityPageStyle.OCEAN_PANEL_DEEP,
),
)
for secondary_label: Label in [
_address_helper,
_name_helper,
_details,
_status,
_session_summary,
]:
secondary_label.add_theme_color_override(
"font_color",
UtilityPageStyle.OCEAN_TEXT_SECONDARY,
)
func setup(
network_session: NetworkSession,
saved_servers: SavedServerStore,
@ -177,6 +266,19 @@ func setup(
_discovery.public_join_status_changed.connect(
_on_public_join_status_changed
)
if not _discovery.friend_presence_updated.is_connected(
_on_friend_presence_updated
):
_discovery.friend_presence_updated.connect(
_on_friend_presence_updated
)
if not _discovery.social_status_changed.is_connected(
_on_social_status_changed
):
_discovery.social_status_changed.connect(
_on_social_status_changed
)
_friend_entries = _discovery.get_friend_presence()
_refresh()
@ -232,7 +334,9 @@ func confirm_pending_join() -> bool:
var room: Dictionary = _pending_confirmation_room.duplicate(true)
cancel_pending_join_confirmation()
if not room.is_empty():
_owns_pending_public_join = true
if _discovery == null or not _discovery.prepare_public_join(room):
_owns_pending_public_join = false
_set_status("Could not prepare the public connection.", true)
return false
return true
@ -244,15 +348,19 @@ func confirm_pending_join() -> bool:
func _set_mode(mode: Mode, clear_connection_error: bool = true) -> void:
if clear_connection_error:
_connection_error_latched = false
_set_status(_default_mode_status(mode))
_mode = mode
_selected_entry = null
_selected_discovery_index = -1
_selected_friend_index = -1
_clear_edit_state()
_refresh_entries()
if mode == Mode.DISCOVER and not _discovery_rooms.is_empty():
_select_discovery_index(0)
elif mode == Mode.FRIENDS and not _friend_entries.is_empty():
_select_friend_index(0)
_refresh()
if mode == Mode.DISCOVER:
if mode in [Mode.DISCOVER, Mode.FRIENDS]:
_discovery_refresh_timer.start()
_request_discovery_refresh()
else:
@ -262,6 +370,19 @@ func _set_mode(mode: Mode, clear_connection_error: bool = true) -> void:
_defer_focus_control(_address if mode == Mode.DIRECT else _server_list)
func _default_mode_status(mode: Mode) -> String:
match mode:
Mode.FRIENDS:
return "friend status is live and not stored by discovery"
Mode.DIRECT:
return "direct connection • default port 7777"
Mode.SAVED:
return "saved servers are stored on this device"
Mode.RECENT:
return "recent connections are stored on this device"
return "looking for public rooms…"
func _request_join() -> void:
_connection_error_latched = false
if _network_session.state in [
@ -271,8 +392,22 @@ func _request_join() -> void:
_network_session.reset_failure()
var endpoint_text: String = _address.text
var discovery_room: Dictionary = {}
if _mode == Mode.DISCOVER:
var room: Dictionary = _selected_discovery_room()
if _mode in [Mode.DISCOVER, Mode.FRIENDS]:
var room: Dictionary = (
_selected_discovery_room()
if _mode == Mode.DISCOVER
else _selected_friend_room()
)
if _mode == Mode.FRIENDS:
var friend := _selected_friend()
if not bool(friend.get("online", false)):
_set_status("This person needs to be online to do this.", true)
return
if room.is_empty():
_set_status(
"This friend is online but is not in a joinable room.", true
)
return
if _discovery != null and _discovery.is_own_room(room):
_set_status("You are already hosting this room.", true)
return
@ -296,7 +431,9 @@ func _request_join() -> void:
join_confirmation_requested.emit(endpoint.normalized_display)
return
if not discovery_room.is_empty():
_owns_pending_public_join = true
if not _discovery.prepare_public_join(discovery_room):
_owns_pending_public_join = false
_set_status("Could not prepare the public connection.", true)
return
_set_status("Connecting…")
@ -305,7 +442,10 @@ func _request_join() -> void:
func _on_public_join_prepared(endpoint_text: String) -> void:
if _mode != Mode.DISCOVER or not is_visible_in_tree():
if not _owns_pending_public_join:
return
_owns_pending_public_join = false
if _mode not in [Mode.DISCOVER, Mode.FRIENDS] or not is_visible_in_tree():
return
var endpoint: ConnectionEndpoint = EndpointParser.parse(endpoint_text)
if not endpoint.is_valid():
@ -317,8 +457,13 @@ func _on_public_join_prepared(endpoint_text: String) -> void:
func _on_public_join_status_changed(message: String, is_error: bool) -> void:
if _mode == Mode.DISCOVER and is_visible_in_tree():
if (
_owns_pending_public_join
and _mode in [Mode.DISCOVER, Mode.FRIENDS]
and is_visible_in_tree()
):
if is_error:
_owns_pending_public_join = false
_connection_error_latched = true
elif _connection_error_latched:
return
@ -473,6 +618,11 @@ func _on_list_item_selected(index: int) -> void:
return
_refresh()
return
if _mode == Mode.FRIENDS:
if not _select_friend_index(index):
return
_refresh()
return
if index < 0 or index >= _visible_entries.size():
return
_selected_entry = _visible_entries[index]
@ -518,6 +668,8 @@ func _restore_entry_selection_and_focus(
func _current_mode_button() -> Button:
match _mode:
Mode.FRIENDS:
return _friends_button
Mode.DIRECT:
return _direct_button
Mode.SAVED:
@ -536,6 +688,15 @@ func _select_discovery_index(index: int) -> bool:
return true
func _select_friend_index(index: int) -> bool:
if index < 0 or index >= _friend_entries.size():
return false
_selected_friend_index = index
_selected_entry = null
_server_list.select(index)
return true
func _refresh_entries() -> void:
_visible_entries.clear()
_server_list.clear()
@ -556,6 +717,25 @@ func _refresh_entries() -> void:
]
)
return
if _mode == Mode.FRIENDS:
for friend: Dictionary in _friend_entries:
var room: Dictionary = (
friend.get("room", {})
if typeof(friend.get("room", {})) == TYPE_DICTIONARY
else {}
)
var state := "offline"
if bool(friend.get("online", false)):
state = (
"playing in %s"
% str(room.get("room_name", "a public room"))
if not room.is_empty()
else "online"
)
_server_list.add_item("%s%s" % [
str(friend.get("display_name", "Player")), state,
])
return
if _saved_servers == null or _mode == Mode.DIRECT:
return
_visible_entries = (
@ -594,15 +774,24 @@ func _refresh() -> void:
] or (_discovery != null and _discovery.is_public_join_preparing())
var direct: bool = _mode == Mode.DIRECT
var discovery_mode: bool = _mode == Mode.DISCOVER
var friends_mode: bool = _mode == Mode.FRIENDS
var selected: bool = (
_selected_discovery_index >= 0
if discovery_mode
else _selected_friend_index >= 0
if friends_mode
else _selected_entry != null
)
_discover_button.button_pressed = discovery_mode
_direct_button.button_pressed = direct
_saved_button.button_pressed = _mode == Mode.SAVED
_recent_button.button_pressed = _mode == Mode.RECENT
_discover_button.set_selected(discovery_mode, false)
_friends_button.set_selected(friends_mode, false)
_direct_button.set_selected(direct, false)
_saved_button.set_selected(_mode == Mode.SAVED, false)
_recent_button.set_selected(_mode == Mode.RECENT, false)
var direct_content_visible: bool = direct or _name_entry_active
var list_content_visible: bool = not direct_content_visible
_direct_content.visible = direct_content_visible
_list_content.visible = list_content_visible
_list_title.text = _current_list_title()
_address.visible = direct or _name_entry_active
_address_label.visible = _address.visible
_address_helper.visible = _address.visible
@ -613,15 +802,20 @@ func _refresh() -> void:
_name_edit.visible = _name_entry_active
_name_label.visible = _name_entry_active
_name_helper.visible = _name_entry_active
_server_list.visible = not direct and not _name_entry_active
_details.visible = not direct and not _name_entry_active
_server_list.visible = list_content_visible
_details_panel.visible = list_content_visible
_details.visible = list_content_visible
_join_button.disabled = (
connecting
or (not direct and not selected)
or (
discovery_mode
(discovery_mode or friends_mode)
and selected
and _discovery_room_is_full(_selected_discovery_room())
and _discovery_room_is_full(
_selected_discovery_room()
if discovery_mode
else _selected_friend_room()
)
)
or (
discovery_mode
@ -629,16 +823,19 @@ func _refresh() -> void:
and _discovery != null
and _discovery.is_own_room(_selected_discovery_room())
)
or (friends_mode and selected and _selected_friend_room().is_empty())
)
_join_button.text = "join now" if direct else "join"
_refresh_button.visible = (
(discovery_mode or friends_mode) and not _name_entry_active
)
_join_button.text = "join\nnow" if direct else "join"
_refresh_button.visible = discovery_mode and not _name_entry_active
_save_button.visible = (
direct or _mode == Mode.RECENT or _name_entry_active
)
_save_button.disabled = connecting or (
_mode == Mode.RECENT and not selected and not _name_entry_active
)
_save_button.text = "save" if _name_entry_active else "save\nserver"
_save_button.text = "save" if _name_entry_active else "save server"
_edit_button.visible = _mode == Mode.SAVED and selected
_favorite_button.visible = _mode == Mode.SAVED and selected
_favorite_button.text = (
@ -676,16 +873,22 @@ func _refresh() -> void:
_details.text = (
_format_discovery_details(_selected_discovery_room())
if discovery_mode
else _format_friend_details(_selected_friend())
if friends_mode
else _format_entry_details(_selected_entry)
)
elif (
_discovery_rooms.is_empty()
if discovery_mode
else _friend_entries.is_empty()
if friends_mode
else _visible_entries.is_empty()
):
_details.text = (
"No public rooms are available."
if discovery_mode
else "No friends added yet."
if friends_mode
else "No saved servers yet."
if _mode == Mode.SAVED
else "No recent connections yet."
@ -702,9 +905,21 @@ func _refresh() -> void:
_configure_controller_navigation()
func _current_list_title() -> String:
match _mode:
Mode.FRIENDS:
return "friends"
Mode.SAVED:
return "saved servers"
Mode.RECENT:
return "recent connections"
return "public rooms"
func _configure_controller_navigation() -> void:
var mode_buttons: Array[Control] = [
_discover_button,
_friends_button,
_direct_button,
_saved_button,
_recent_button,
@ -722,7 +937,6 @@ func _configure_controller_navigation() -> void:
_favorite_button,
_delete_button,
_cancel_button,
_back_button,
]:
if _controller_focus_eligible(control):
action_controls.append(control)
@ -730,6 +944,7 @@ func _configure_controller_navigation() -> void:
all_controls.append_array(mode_buttons)
all_controls.append_array(content_controls)
all_controls.append_array(action_controls)
all_controls.append(_back_button)
for control: Control in all_controls:
control.focus_mode = Control.FOCUS_ALL
for control: Control in [
@ -743,10 +958,10 @@ func _configure_controller_navigation() -> void:
_favorite_button,
_delete_button,
_cancel_button,
_back_button,
]:
if control not in all_controls:
control.focus_mode = Control.FOCUS_NONE
_back_button.focus_mode = Control.FOCUS_ALL
var primary_content: Control = (
content_controls.front()
if not content_controls.is_empty()
@ -775,7 +990,7 @@ func _configure_controller_navigation() -> void:
if index < content_controls.size() - 1
else action_controls.front()
if not action_controls.is_empty()
else content
else _back_button
)
_set_controller_neighbors(content, content, content, above, below)
for index: int in action_controls.size():
@ -787,8 +1002,22 @@ func _configure_controller_navigation() -> void:
content_controls.back()
if not content_controls.is_empty()
else mode_buttons[int(_mode)],
action,
_back_button,
)
var back_above: Control = (
action_controls.back()
if not action_controls.is_empty()
else content_controls.back()
if not content_controls.is_empty()
else mode_buttons[int(_mode)]
)
_set_controller_neighbors(
_back_button,
_back_button,
_back_button,
back_above,
_back_button,
)
ControllerFocusNavigationType.configure_traversal(all_controls)
_recover_controller_focus(all_controls, primary_content)
@ -920,6 +1149,29 @@ func _format_discovery_details(room: Dictionary) -> String:
return "\n".join(lines)
func _format_friend_details(friend: Dictionary) -> String:
if friend.is_empty():
return "Select a friend."
var room := _selected_friend_room()
var lines: Array[String] = [
"Friend: %s" % str(friend.get("display_name", "Player")),
"Status: %s" % (
"online" if bool(friend.get("online", false)) else "offline"
),
]
if not room.is_empty():
lines.append("Room: %s" % str(room.get("room_name", "Public room")))
lines.append("Players: %d / %d" % [
int(room.get("current_players", 0)),
int(room.get("max_players", 0)),
])
elif bool(friend.get("online", false)):
lines.append("This friend is not in a joinable public room.")
else:
lines.append("This person needs to be online to join them.")
return "\n".join(lines)
func _selected_discovery_room() -> Dictionary:
if (
_selected_discovery_index < 0
@ -929,6 +1181,25 @@ func _selected_discovery_room() -> Dictionary:
return _discovery_rooms[_selected_discovery_index]
func _selected_friend() -> Dictionary:
if (
_selected_friend_index < 0
or _selected_friend_index >= _friend_entries.size()
):
return {}
return _friend_entries[_selected_friend_index]
func _selected_friend_room() -> Dictionary:
var friend := _selected_friend()
var room: Variant = friend.get("room", {})
return (
(room as Dictionary)
if typeof(room) == TYPE_DICTIONARY
else {}
)
func _discovery_room_is_full(room: Dictionary) -> bool:
if room.is_empty():
return false
@ -938,11 +1209,14 @@ func _discovery_room_is_full(room: Dictionary) -> bool:
func _request_discovery_refresh() -> void:
if (
_discovery == null
or _mode != Mode.DISCOVER
or _mode not in [Mode.DISCOVER, Mode.FRIENDS]
or not is_visible_in_tree()
):
return
_discovery.request_rooms()
if _mode == Mode.DISCOVER:
_discovery.request_rooms()
else:
_discovery.request_friend_presence()
func _on_discovery_rooms_updated(rooms: Array[Dictionary]) -> void:
@ -972,6 +1246,30 @@ func _on_discovery_status_changed(message: String, is_error: bool) -> void:
_set_status(message, is_error)
func _on_friend_presence_updated(friends: Array[Dictionary]) -> void:
var selected_fingerprint := str(
_selected_friend().get("fingerprint", "")
)
_friend_entries = friends.duplicate(true)
_selected_friend_index = -1
if _mode != Mode.FRIENDS:
return
_refresh_entries()
if not selected_fingerprint.is_empty():
for index: int in _friend_entries.size():
if str(_friend_entries[index].get("fingerprint", "")) == selected_fingerprint:
_select_friend_index(index)
break
if _selected_friend_index < 0 and not _friend_entries.is_empty():
_select_friend_index(0)
_refresh()
func _on_social_status_changed(message: String, is_error: bool) -> void:
if _mode == Mode.FRIENDS and is_visible_in_tree():
_set_status(message, is_error)
func _format_result_code(result_code: String) -> String:
match result_code.strip_edges().to_upper():
"SUCCESS":