From 0b5b78a553763992b60fbce833460f956e265eee Mon Sep 17 00:00:00 2001 From: Voyager Date: Wed, 29 Jul 2026 21:31:42 -0400 Subject: [PATCH] Add player profile and appearance foundation --- main/main.gd | 22 +- main/main.tscn | 12 +- network/network_profile_protocol.gd | 37 ++ network/network_profile_protocol.gd.uid | 1 + network/network_profile_service.gd | 402 +++++++++++++++++ network/network_profile_service.gd.uid | 1 + network/network_protocol.gd | 4 +- network/network_session.gd | 66 ++- network/peer_registry.gd | 11 + player/player.gd | 11 + .../character_customization_catalog.gd | 86 ++++ .../character_customization_catalog.gd.uid | 1 + progression/player_appearance_store.gd | 123 ++++++ progression/player_appearance_store.gd.uid | 1 + ui/game_ui.gd | 4 +- ui/player_menu.gd | 73 ++- ui/player_menu.tscn | 59 ++- ui/profile_page.gd | 414 ++++++++++++++++++ ui/profile_page.gd.uid | 1 + ui/profile_page.tscn | 8 + ui/profile_preview.gd | 54 +++ ui/profile_preview.gd.uid | 1 + ui/profile_preview.tscn | 57 +++ ui/settings_panel.gd | 26 -- ui/settings_panel.tscn | 25 -- 25 files changed, 1424 insertions(+), 76 deletions(-) create mode 100644 network/network_profile_protocol.gd create mode 100644 network/network_profile_protocol.gd.uid create mode 100644 network/network_profile_service.gd create mode 100644 network/network_profile_service.gd.uid create mode 100644 progression/character_customization_catalog.gd create mode 100644 progression/character_customization_catalog.gd.uid create mode 100644 progression/player_appearance_store.gd create mode 100644 progression/player_appearance_store.gd.uid create mode 100644 ui/profile_page.gd create mode 100644 ui/profile_page.gd.uid create mode 100644 ui/profile_page.tscn create mode 100644 ui/profile_preview.gd create mode 100644 ui/profile_preview.gd.uid create mode 100644 ui/profile_preview.tscn diff --git a/main/main.gd b/main/main.gd index 3671356..1e4b1aa 100644 --- a/main/main.gd +++ b/main/main.gd @@ -64,6 +64,12 @@ const NetworkMailServiceType = preload( const PlayerAssetReservationServiceType = preload( "res://progression/player_asset_reservation_service.gd" ) +const PlayerAppearanceStoreType = preload( + "res://progression/player_appearance_store.gd" +) +const NetworkProfileServiceType = preload( + "res://network/network_profile_service.gd" +) const TITLE_MUSIC_SILENCE_DB: float = -80.0 @@ -111,6 +117,10 @@ const TITLE_MUSIC_SILENCE_DB: float = -80.0 @onready var _asset_reservations: PlayerAssetReservationServiceType = ( %PlayerAssetReservationService ) +@onready var _appearance_store: PlayerAppearanceStoreType = %PlayerAppearanceStore +@onready var _network_profile_service: NetworkProfileServiceType = ( + %NetworkProfileService +) @onready var _players_root: Node3D = $Players var _gameplay_started: bool = false @@ -137,6 +147,15 @@ func _ready() -> void: _saved_servers, _player_spawn_service ) + _network_profile_service.setup( + _network_session, + _network_profile, + _appearance_store, + _player_spawn_service, + ) + _network_session.set_local_appearance_snapshot( + _appearance_store.get_snapshot() + ) _network_session.join_authenticated.connect( _on_network_join_authenticated ) @@ -271,7 +290,8 @@ func _ready() -> void: _network_profile, _player_spawn_service, _network_mail, - _asset_reservations + _asset_reservations, + _network_profile_service ) _water_recovery.setup( _player, diff --git a/main/main.tscn b/main/main.tscn index b1b17a5..c2ef599 100644 --- a/main/main.tscn +++ b/main/main.tscn @@ -1,4 +1,4 @@ -[gd_scene load_steps=28 format=3] +[gd_scene load_steps=30 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"] @@ -27,6 +27,8 @@ [ext_resource type="Script" path="res://network/network_chat_service.gd" id="25_network_chat"] [ext_resource type="Script" path="res://network/network_mail_service.gd" id="26_network_mail"] [ext_resource type="Script" path="res://progression/player_asset_reservation_service.gd" id="27_reservations"] +[ext_resource type="Script" path="res://progression/player_appearance_store.gd" id="28_appearance"] +[ext_resource type="Script" path="res://network/network_profile_service.gd" id="29_profile_service"] [node name="Main" type="Node3D"] script = ExtResource("3_main") @@ -43,6 +45,14 @@ script = ExtResource("17_network_session") unique_name_in_owner = true script = ExtResource("18_network_profile") +[node name="PlayerAppearanceStore" type="Node" parent="."] +unique_name_in_owner = true +script = ExtResource("28_appearance") + +[node name="NetworkProfileService" type="Node" parent="."] +unique_name_in_owner = true +script = ExtResource("29_profile_service") + [node name="SavedServerStore" type="Node" parent="."] unique_name_in_owner = true script = ExtResource("19_saved_servers") diff --git a/network/network_profile_protocol.gd b/network/network_profile_protocol.gd new file mode 100644 index 0000000..e9e5b45 --- /dev/null +++ b/network/network_profile_protocol.gd @@ -0,0 +1,37 @@ +class_name NetworkProfileProtocol +extends RefCounted + +const RELIABLE_CHANNEL: int = 0 +const MAX_SUGGESTIONS: int = 3 +const MAX_REQUEST_ID_LENGTH: int = 64 + + +static func valid_request_id(value: Variant) -> bool: + return ( + typeof(value) == TYPE_STRING + and not str(value).is_empty() + and str(value).length() <= MAX_REQUEST_ID_LENGTH + ) + + +static func valid_snapshot(value: Variant) -> bool: + return CharacterCustomizationCatalog.validate_snapshot(value) + + +static func valid_check_request(data: Variant) -> bool: + return ( + typeof(data) == TYPE_DICTIONARY + and valid_request_id(data.get("request_id")) + and typeof(data.get("session_id")) == TYPE_STRING + and typeof(data.get("display_name")) == TYPE_STRING + and NetworkProfilePreferences.is_valid_display_name(data["display_name"]) + ) + + +static func valid_apply_request(data: Variant) -> bool: + return ( + valid_check_request(data) + and typeof(data.get("appearance")) == TYPE_DICTIONARY + and valid_snapshot(data["appearance"]) + and typeof(data.get("use_anyway")) == TYPE_BOOL + ) diff --git a/network/network_profile_protocol.gd.uid b/network/network_profile_protocol.gd.uid new file mode 100644 index 0000000..8a5943c --- /dev/null +++ b/network/network_profile_protocol.gd.uid @@ -0,0 +1 @@ +uid://dhv483bfl4get diff --git a/network/network_profile_service.gd b/network/network_profile_service.gd new file mode 100644 index 0000000..bf5729c --- /dev/null +++ b/network/network_profile_service.gd @@ -0,0 +1,402 @@ +class_name NetworkProfileService +extends Node + +signal conflict_result( + request_id: String, + has_conflict: bool, + suggestions: PackedStringArray, +) +signal apply_finished(accepted: bool, message: String) +signal profile_snapshot_changed( + peer_id: int, + display_name: String, + appearance: Dictionary, +) + +var _session: NetworkSession +var _preferences: NetworkProfilePreferences +var _appearance_store: PlayerAppearanceStore +var _spawn_service: PlayerSpawnService +var _pending_apply: Dictionary[String, Dictionary] = {} +var _host_pending_apply: Dictionary[String, Dictionary] = {} +var _latest_check_id: String = "" +var _latest_check_name: String = "" + + +func setup( + session: NetworkSession, + preferences: NetworkProfilePreferences, + appearance_store: PlayerAppearanceStore, + spawn_service: PlayerSpawnService, +) -> void: + _session = session + _preferences = preferences + _appearance_store = appearance_store + _spawn_service = spawn_service + _appearance_store.load_preferences() + _session.peer_authenticated.connect(_on_peer_authenticated) + _session.peer_removed.connect(_on_peer_removed) + _session.peer_display_name_changed.connect(_on_peer_display_name_changed) + _session.join_authenticated.connect(_on_join_authenticated) + _session.state_changed.connect(_on_session_state_changed) + _apply_to_avatar(1, _appearance_store.get_snapshot()) + + +func get_persisted_name() -> String: + return _preferences.display_name + + +func get_persisted_appearance() -> Dictionary: + return _appearance_store.get_snapshot() + + +func request_name_check(display_name: String) -> String: + var request_id := _new_id() + _latest_check_id = request_id + var clean_name := display_name.strip_edges() + _latest_check_name = clean_name + if not NetworkProfilePreferences.is_valid_display_name(clean_name): + conflict_result.emit(request_id, false, PackedStringArray()) + return request_id + if _session == null or not _session.is_gameplay_session_active(): + conflict_result.emit(request_id, false, PackedStringArray()) + elif _session.is_host(): + var result := _make_conflict_result( + _session.get_local_peer_id(), clean_name, request_id + ) + _emit_conflict_result(result) + elif _session.supports_server_capability(&"profile_v1"): + submit_name_check.rpc_id(1, { + "request_id": request_id, + "session_id": _session.get_session_id(), + "display_name": clean_name, + }) + else: + conflict_result.emit(request_id, false, PackedStringArray()) + return request_id + + +func apply_profile( + display_name: String, + appearance: Dictionary, + use_anyway: bool, +) -> bool: + var clean_name := display_name.strip_edges() + if ( + not NetworkProfilePreferences.is_valid_display_name(clean_name) + or not CharacterCustomizationCatalog.validate_snapshot(appearance) + ): + apply_finished.emit(false, "Check the player name and appearance choices.") + return false + var request_id := _new_id() + var request := { + "request_id": request_id, + "session_id": _session.get_session_id() if _session != null else "", + "display_name": clean_name, + "appearance": appearance.duplicate(true), + "use_anyway": use_anyway, + } + _pending_apply[request_id] = request + if _session == null or not _session.is_gameplay_session_active(): + _apply_local_result(request_id, true, "", false, PackedStringArray()) + elif _session.is_host(): + _process_apply_request(_session.get_local_peer_id(), request) + elif _session.supports_server_capability(&"profile_v1"): + submit_profile_apply.rpc_id(1, request) + else: + _apply_local_result(request_id, true, "", false, PackedStringArray()) + return true + + +@rpc("any_peer", "call_remote", "reliable", NetworkProfileProtocol.RELIABLE_CHANNEL) +func submit_name_check(data: Dictionary) -> void: + var sender_id := multiplayer.get_remote_sender_id() + if ( + not _session.is_host() + or not _session.is_authenticated_peer(sender_id) + or not NetworkProfileProtocol.valid_check_request(data) + or str(data["session_id"]) != _session.get_session_id() + ): + return + receive_name_check.rpc_id( + sender_id, + _make_conflict_result(sender_id, str(data["display_name"]), str(data["request_id"])), + ) + + +@rpc("authority", "call_remote", "reliable", NetworkProfileProtocol.RELIABLE_CHANNEL) +func receive_name_check(data: Dictionary) -> void: + if ( + typeof(data.get("request_id")) != TYPE_STRING + or typeof(data.get("has_conflict")) != TYPE_BOOL + or typeof(data.get("suggestions")) != TYPE_ARRAY + ): + return + _emit_conflict_result(data) + + +@rpc("any_peer", "call_remote", "reliable", NetworkProfileProtocol.RELIABLE_CHANNEL) +func submit_profile_apply(data: Dictionary) -> void: + var sender_id := multiplayer.get_remote_sender_id() + if ( + not _session.is_host() + or not _session.is_authenticated_peer(sender_id) + or not NetworkProfileProtocol.valid_apply_request(data) + or str(data["session_id"]) != _session.get_session_id() + ): + return + _process_apply_request(sender_id, data) + + +func _process_apply_request(peer_id: int, data: Dictionary) -> void: + var name := str(data["display_name"]).strip_edges() + var conflict := _name_conflicts(peer_id, name) + var accepted := not conflict or bool(data["use_anyway"]) + var suggestions := _make_suggestions(peer_id, name) if conflict else PackedStringArray() + if peer_id == _session.get_local_peer_id(): + _apply_local_result( + str(data["request_id"]), accepted, "", conflict, suggestions + ) + else: + if accepted: + _host_pending_apply[str(data["request_id"])] = { + "peer_id": peer_id, + "display_name": name, + "appearance": Dictionary(data["appearance"]).duplicate(true), + } + receive_profile_result.rpc_id(peer_id, { + "request_id": data["request_id"], + "accepted": accepted, + "conflict": conflict, + "suggestions": Array(suggestions), + "message": "" if accepted else "That name is already in use in this game.", + }) + + +@rpc("any_peer", "call_remote", "reliable", NetworkProfileProtocol.RELIABLE_CHANNEL) +func confirm_profile_saved(request_id: String) -> void: + var sender_id := multiplayer.get_remote_sender_id() + var pending: Dictionary = _host_pending_apply.get(request_id, {}) + if ( + not _session.is_host() + or pending.is_empty() + or int(pending["peer_id"]) != sender_id + ): + return + _host_pending_apply.erase(request_id) + var name := str(pending["display_name"]) + var appearance := Dictionary(pending["appearance"]) + _session.apply_canonical_profile(sender_id, name, appearance) + _apply_to_avatar(sender_id, appearance) + broadcast_profile_snapshot.rpc(sender_id, name, appearance) + + +@rpc("authority", "call_remote", "reliable", NetworkProfileProtocol.RELIABLE_CHANNEL) +func receive_profile_result(data: Dictionary) -> void: + if ( + typeof(data.get("request_id")) != TYPE_STRING + or typeof(data.get("accepted")) != TYPE_BOOL + or typeof(data.get("conflict")) != TYPE_BOOL + or typeof(data.get("suggestions")) != TYPE_ARRAY + ): + return + var suggestions := PackedStringArray(data["suggestions"]) + _apply_local_result( + str(data["request_id"]), + bool(data["accepted"]), + str(data.get("message", "")), + bool(data["conflict"]), + suggestions, + ) + + +func _apply_local_result( + request_id: String, + accepted: bool, + message: String, + conflict: bool, + suggestions: PackedStringArray, +) -> void: + var request: Dictionary = _pending_apply.get(request_id, {}) + if request.is_empty(): + return + if not accepted: + _pending_apply.erase(request_id) + conflict_result.emit(request_id, conflict, suggestions) + apply_finished.emit(false, message) + return + var previous_name := _preferences.display_name + if not _preferences.set_display_name(str(request["display_name"])): + _pending_apply.erase(request_id) + apply_finished.emit(false, "Profile could not be saved.") + return + if not _appearance_store.save_snapshot(request["appearance"]): + _preferences.set_display_name(previous_name) + _pending_apply.erase(request_id) + apply_finished.emit(false, "Profile could not be saved.") + return + _pending_apply.erase(request_id) + if _session != null and _session.is_gameplay_session_active(): + if _session.is_host(): + _session.apply_canonical_profile( + _session.get_local_peer_id(), + _preferences.display_name, + _appearance_store.get_snapshot(), + ) + broadcast_profile_snapshot.rpc( + _session.get_local_peer_id(), + _preferences.display_name, + _appearance_store.get_snapshot(), + ) + elif _session.supports_server_capability(&"profile_v1"): + confirm_profile_saved.rpc_id(1, request_id) + elif _session.supports_server_capability(&"chat_v1"): + _session.update_local_display_name(_preferences.display_name) + _apply_to_avatar( + _session.get_local_peer_id() if _session != null else 1, + _appearance_store.get_snapshot(), + ) + apply_finished.emit(true, "Profile saved.") + + +@rpc("authority", "call_remote", "reliable", NetworkProfileProtocol.RELIABLE_CHANNEL) +func broadcast_profile_snapshot( + peer_id: int, + display_name: String, + appearance: Dictionary, +) -> void: + if ( + not NetworkProfilePreferences.is_valid_display_name(display_name) + or not CharacterCustomizationCatalog.validate_snapshot(appearance) + ): + return + _session.apply_canonical_profile(peer_id, display_name, appearance) + _apply_to_avatar(peer_id, appearance) + profile_snapshot_changed.emit(peer_id, display_name, appearance.duplicate(true)) + + +func _on_peer_authenticated(peer_id: int, _display_name: String) -> void: + if not _session.is_host(): + return + for existing_id: int in _session.get_authenticated_peer_ids(): + var record := _session.get_peer_record(existing_id) + if record == null: + continue + broadcast_profile_snapshot.rpc_id( + peer_id, + existing_id, + record.display_name, + record.appearance_snapshot, + ) + + +func _on_join_authenticated() -> void: + _apply_to_avatar( + _session.get_local_peer_id(), _appearance_store.get_snapshot() + ) + + +func _on_peer_removed(_peer_id: int) -> void: + for request_id: String in _host_pending_apply.keys(): + if int(_host_pending_apply[request_id].get("peer_id", 0)) == _peer_id: + _host_pending_apply.erase(request_id) + call_deferred("_refresh_latest_check") + + +func _on_peer_display_name_changed( + _peer_id: int, + _display_name: String, +) -> void: + call_deferred("_refresh_latest_check") + + +func _refresh_latest_check() -> void: + if ( + _latest_check_name.is_empty() + or _session == null + or not _session.is_gameplay_session_active() + ): + return + var peer := multiplayer.multiplayer_peer + if ( + _session.is_joined_client() + and ( + peer == null + or peer.get_connection_status() + != MultiplayerPeer.CONNECTION_CONNECTED + ) + ): + return + request_name_check(_latest_check_name) + + +func _on_session_state_changed(state: NetworkSession.State) -> void: + if state == NetworkSession.State.INACTIVE: + _pending_apply.clear() + _host_pending_apply.clear() + _latest_check_id = "" + _latest_check_name = "" + + +func _emit_conflict_result(data: Dictionary) -> void: + var request_id := str(data["request_id"]) + if request_id != _latest_check_id: + return + conflict_result.emit( + request_id, + bool(data["has_conflict"]), + PackedStringArray(data["suggestions"]), + ) + + +func _make_conflict_result( + peer_id: int, + display_name: String, + request_id: String, +) -> Dictionary: + var conflict := _name_conflicts(peer_id, display_name) + return { + "request_id": request_id, + "has_conflict": conflict, + "suggestions": Array( + _make_suggestions(peer_id, display_name) + if conflict else PackedStringArray() + ), + } + + +func _name_conflicts(peer_id: int, display_name: String) -> bool: + var normalized := display_name.strip_edges().to_lower() + for other_id: int in _session.get_authenticated_peer_ids(): + if other_id == peer_id: + continue + var record := _session.get_peer_record(other_id) + if record != null and record.display_name.strip_edges().to_lower() == normalized: + return true + return false + + +func _make_suggestions(peer_id: int, base_name: String) -> PackedStringArray: + var result := PackedStringArray() + var suffix := 2 + while result.size() < NetworkProfileProtocol.MAX_SUGGESTIONS and suffix < 1000: + var suffix_text := " %d" % suffix + var candidate := base_name.left( + NetworkProtocol.MAX_DISPLAY_NAME_LENGTH - suffix_text.length() + ) + suffix_text + if not _name_conflicts(peer_id, candidate) and candidate not in result: + result.append(candidate) + suffix += 1 + return result + + +func _apply_to_avatar(peer_id: int, appearance: Dictionary) -> void: + if _spawn_service == null: + return + var avatar := _spawn_service.get_avatar(peer_id) + if avatar != null: + avatar.apply_appearance_snapshot(appearance) + + +func _new_id() -> String: + return Crypto.new().generate_random_bytes(16).hex_encode() diff --git a/network/network_profile_service.gd.uid b/network/network_profile_service.gd.uid new file mode 100644 index 0000000..4b3fcf7 --- /dev/null +++ b/network/network_profile_service.gd.uid @@ -0,0 +1 @@ +uid://dc80t6nmksvm2 diff --git a/network/network_protocol.gd b/network/network_protocol.gd index f4ede30..8b07cc1 100644 --- a/network/network_protocol.gd +++ b/network/network_protocol.gd @@ -33,6 +33,7 @@ static func make_client_hello( profile_id: String, display_name: String, client_nonce: String, + cosmetic_snapshot: Dictionary = {}, ) -> Dictionary: return { "protocol_version": PROTOCOL_VERSION, @@ -41,7 +42,7 @@ static func make_client_hello( "display_name": display_name, "client_nonce": client_nonce, "capability_flags": PackedStringArray(), - "cosmetic_snapshot": {}, + "cosmetic_snapshot": cosmetic_snapshot, } @@ -118,6 +119,7 @@ static func make_server_hello( "equipment_v1", "chat_v1", "mail_v1", + "profile_v1", ]), } diff --git a/network/network_session.gd b/network/network_session.gd index d2e9c83..31e3d0f 100644 --- a/network/network_session.gd +++ b/network/network_session.gd @@ -17,6 +17,11 @@ signal peer_removed(peer_id: int) signal host_openness_changed(is_open: bool) signal peer_count_changed(player_count: int, max_players: int) signal peer_display_name_changed(peer_id: int, display_name: String) +signal peer_profile_changed( + peer_id: int, + display_name: String, + appearance: Dictionary, +) signal join_authenticated signal server_lost signal remote_recovery_requested(peer_id: int, entry_position: Vector3) @@ -62,6 +67,9 @@ var _last_server_display_name: String = "" var _last_server_protocol_version: int = 0 var _server_capabilities: PackedStringArray = PackedStringArray() var _profile_ready: bool = false +var _local_appearance_snapshot: Dictionary = ( + CharacterCustomizationCatalog.default_snapshot() +) func _ready() -> void: @@ -116,8 +124,12 @@ func start_private_host(port: int = DEFAULT_PORT) -> bool: _profile.display_name, NetworkProtocol.PROTOCOL_VERSION ) + _registry.update_appearance(1, _local_appearance_snapshot) _spawn_service.clear_remote_players() _spawn_service.register_local_player(1) + var host_avatar := _spawn_service.get_avatar(1) + if host_avatar != null: + host_avatar.apply_appearance_snapshot(_local_appearance_snapshot) _set_state(State.PRIVATE_HOST, "Private game • UDP %d" % port) host_openness_changed.emit(false) _emit_peer_count() @@ -278,6 +290,7 @@ func supports_server_capability(capability: StringName) -> bool: "movement_v1", "fishing_v1", "sale_v1", "shop_v1", "item_use_v1", "equipment_v1", "chat_v1", "mail_v1", + "profile_v1", ]) return str(capability) in _server_capabilities @@ -302,6 +315,33 @@ func update_local_display_name(value: String) -> bool: return true +func set_local_appearance_snapshot(snapshot: Dictionary) -> void: + if CharacterCustomizationCatalog.validate_snapshot(snapshot): + _local_appearance_snapshot = snapshot.duplicate(true) + var local_id := get_local_peer_id() + if local_id > 0: + _registry.update_appearance(local_id, _local_appearance_snapshot) + + +func apply_canonical_profile( + peer_id: int, + display_name: String, + appearance: Dictionary, +) -> bool: + if ( + not NetworkProfilePreferences.is_valid_display_name(display_name) + or not CharacterCustomizationCatalog.validate_snapshot(appearance) + ): + return false + var name_changed := _registry.update_display_name(peer_id, display_name) + var appearance_changed := _registry.update_appearance(peer_id, appearance) + if name_changed: + peer_display_name_changed.emit(peer_id, display_name) + if name_changed and appearance_changed: + peer_profile_changed.emit(peer_id, display_name, appearance.duplicate(true)) + return name_changed and appearance_changed + + @rpc("any_peer", "call_remote", "reliable", 0) func submit_display_name(value: String) -> void: var sender_id := multiplayer.get_remote_sender_id() @@ -398,7 +438,8 @@ func _on_connected_to_server() -> void: var hello: Dictionary = NetworkProtocol.make_client_hello( _profile.profile_id, _profile.display_name, - _client_nonce + _client_nonce, + _local_appearance_snapshot, ) submit_client_hello.rpc_id(1, hello) @@ -477,6 +518,10 @@ func submit_client_hello(data: Dictionary) -> void: NetworkProtocol.RejectionCode.MALFORMED_HANDSHAKE ) return + var submitted_appearance := CharacterCustomizationCatalog.sanitized_snapshot( + data["cosmetic_snapshot"] + ) + _registry.update_appearance(sender_id, submitted_appearance) _pending_authentication.erase(sender_id) var spawn_index: int = _registry.get_peer_ids().find(sender_id) var spawn_transform: Transform3D = ( @@ -582,8 +627,12 @@ func receive_server_hello(data: Dictionary) -> void: _profile.display_name, NetworkProtocol.PROTOCOL_VERSION ) + _registry.update_appearance(local_peer_id, _local_appearance_snapshot) _spawn_service.clear_remote_players() _spawn_service.register_local_player(local_peer_id) + var local_avatar := _spawn_service.get_avatar(local_peer_id) + if local_avatar != null: + local_avatar.apply_appearance_snapshot(_local_appearance_snapshot) _connection_deadline = 0.0 _set_state( State.JOINED_CLIENT, @@ -652,6 +701,11 @@ func _apply_spawn_entry(entry: Dictionary) -> void: entry["display_name"], NetworkProtocol.PROTOCOL_VERSION ) + if typeof(entry.get("appearance")) == TYPE_DICTIONARY: + var appearance := CharacterCustomizationCatalog.sanitized_snapshot( + entry["appearance"] + ) + _registry.update_appearance(peer_id, appearance) var transform: Transform3D = _spawn_service.get_spawn_transform_for_index(0) var position: Array = entry["position"] if position.size() != 3: @@ -662,7 +716,10 @@ func _apply_spawn_entry(entry: Dictionary) -> void: float(position[2]) ) transform.basis = Basis(Vector3.UP, float(entry["yaw"])) - _spawn_service.spawn_remote_player(peer_id, transform, false) + var avatar := _spawn_service.spawn_remote_player(peer_id, transform, false) + var record := _registry.get_peer(peer_id) + if avatar != null and record != null: + avatar.apply_appearance_snapshot(record.appearance_snapshot) func _build_spawn_list() -> Array[Dictionary]: @@ -695,6 +752,11 @@ func _make_spawn_entry( transform.origin.z, ], "yaw": transform.basis.get_euler().y, + "appearance": ( + record.appearance_snapshot.duplicate(true) + if record != null + else CharacterCustomizationCatalog.default_snapshot() + ), } diff --git a/network/peer_registry.gd b/network/peer_registry.gd index 55ec8a3..f68f71c 100644 --- a/network/peer_registry.gd +++ b/network/peer_registry.gd @@ -9,6 +9,9 @@ class PeerRecord: var display_name: String = "" var protocol_version: int = 0 var joined_at_unix: int = 0 + var appearance_snapshot: Dictionary = ( + CharacterCustomizationCatalog.default_snapshot() + ) var _records: Dictionary[int, PeerRecord] = {} @@ -50,6 +53,14 @@ func update_display_name(peer_id: int, display_name: String) -> bool: return true +func update_appearance(peer_id: int, snapshot: Dictionary) -> bool: + var record: PeerRecord = _records.get(peer_id) + if record == null or not CharacterCustomizationCatalog.validate_snapshot(snapshot): + return false + record.appearance_snapshot = snapshot.duplicate(true) + return true + + func has_peer(peer_id: int) -> bool: return _records.has(peer_id) diff --git a/player/player.gd b/player/player.gd index b7a85e7..eb7dad3 100644 --- a/player/player.gd +++ b/player/player.gd @@ -18,6 +18,17 @@ const PlayerCoolerCapacityType = preload( "res://progression/player_cooler_capacity.gd" ) +var appearance_snapshot: Dictionary = ( + CharacterCustomizationCatalog.default_snapshot() +) + + +func apply_appearance_snapshot(snapshot: Dictionary) -> void: + if CharacterCustomizationCatalog.validate_snapshot(snapshot): + appearance_snapshot = snapshot.duplicate(true) + # Current gameplay art is monolithic. This seam intentionally stores the + # validated state until modular visual parts are available. + class ShowcaseCameraSnapshot: extends RefCounted diff --git a/progression/character_customization_catalog.gd b/progression/character_customization_catalog.gd new file mode 100644 index 0000000..fc34417 --- /dev/null +++ b/progression/character_customization_catalog.gd @@ -0,0 +1,86 @@ +class_name CharacterCustomizationCatalog +extends RefCounted + +const CATEGORY_IDS: PackedStringArray = [ + "species", + "fur_pattern", + "ears", + "eyes", + "nose", + "mouth", + "tail", +] + +const CATEGORY_LABELS: Dictionary = { + "species": "species", + "fur_pattern": "fur pattern", + "ears": "ears", + "eyes": "eyes", + "nose": "nose", + "mouth": "mouth", + "tail": "tail", +} + +const OPTIONS: Dictionary = { + "species": [{"id": "default", "label": "default"}], + "fur_pattern": [{"id": "solid", "label": "solid"}], + "ears": [{"id": "default", "label": "default"}], + "eyes": [{"id": "default", "label": "default"}], + "nose": [{"id": "default", "label": "default"}], + "mouth": [{"id": "default", "label": "default"}], + "tail": [{"id": "none", "label": "none"}], +} + + +static func default_snapshot() -> Dictionary: + return { + "species": "default", + "fur_pattern": "solid", + "ears": "default", + "eyes": "default", + "nose": "default", + "mouth": "default", + "tail": "none", + } + + +static func options_for(category_id: String) -> Array: + return OPTIONS.get(category_id, []) + + +static func category_label(category_id: String) -> String: + return str(CATEGORY_LABELS.get(category_id, category_id)) + + +static func is_valid_option(category_id: String, option_id: String) -> bool: + for option: Dictionary in options_for(category_id): + if str(option.get("id", "")) == option_id: + return true + return false + + +static func validate_snapshot(value: Variant) -> bool: + if typeof(value) != TYPE_DICTIONARY: + return false + var snapshot: Dictionary = value + if snapshot.size() != CATEGORY_IDS.size(): + return false + for category_id: String in CATEGORY_IDS: + if ( + typeof(snapshot.get(category_id)) != TYPE_STRING + or not is_valid_option(category_id, str(snapshot[category_id])) + ): + return false + return true + + +static func sanitized_snapshot(value: Variant) -> Dictionary: + var result := default_snapshot() + if typeof(value) != TYPE_DICTIONARY: + return result + var snapshot: Dictionary = value + for category_id: String in CATEGORY_IDS: + var option_id := str(snapshot.get(category_id, "")) + if is_valid_option(category_id, option_id): + result[category_id] = option_id + return result diff --git a/progression/character_customization_catalog.gd.uid b/progression/character_customization_catalog.gd.uid new file mode 100644 index 0000000..e2f3303 --- /dev/null +++ b/progression/character_customization_catalog.gd.uid @@ -0,0 +1 @@ +uid://c08tu8ntuqig3 diff --git a/progression/player_appearance_store.gd b/progression/player_appearance_store.gd new file mode 100644 index 0000000..bab34b0 --- /dev/null +++ b/progression/player_appearance_store.gd @@ -0,0 +1,123 @@ +class_name PlayerAppearanceStore +extends Node + +const FORMAT_VERSION: int = 1 +const PROFILE_PATH: String = "user://player_appearance.json" +const TEMP_PATH: String = "user://player_appearance.json.tmp" +const BACKUP_PATH: String = "user://player_appearance.json.backup" + +var _snapshot: Dictionary = CharacterCustomizationCatalog.default_snapshot() +var _loaded: bool = false +var _future_version: bool = false + + +func load_preferences() -> bool: + _recover_interrupted_write() + if not FileAccess.file_exists(PROFILE_PATH): + _snapshot = CharacterCustomizationCatalog.default_snapshot() + _loaded = true + return true + var file := FileAccess.open(PROFILE_PATH, FileAccess.READ) + if file == null: + return false + var json := JSON.new() + var error := json.parse(file.get_as_text()) + file.close() + if error != OK or typeof(json.data) != TYPE_DICTIONARY: + return _recover_backup() + var data: Dictionary = json.data + if typeof(data.get("format_version")) not in [TYPE_INT, TYPE_FLOAT]: + return _recover_backup() + var version := int(data["format_version"]) + if version > FORMAT_VERSION: + _future_version = true + _loaded = false + return false + if version != FORMAT_VERSION or typeof(data.get("appearance")) != TYPE_DICTIONARY: + return _recover_backup() + _snapshot = CharacterCustomizationCatalog.sanitized_snapshot(data["appearance"]) + _loaded = true + return true + + +func get_snapshot() -> Dictionary: + return _snapshot.duplicate(true) + + +func save_snapshot(value: Dictionary) -> bool: + if _future_version or not CharacterCustomizationCatalog.validate_snapshot(value): + return false + var previous := _snapshot.duplicate(true) + _snapshot = value.duplicate(true) + if _save_atomic(): + _loaded = true + return true + _snapshot = previous + return false + + +func is_loaded() -> bool: + return _loaded + + +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, + "appearance": _snapshot, + }, "\t")) + file.flush() + var error := file.get_error() + file.close() + if error != OK: + _remove(TEMP_PATH) + return false + _remove(BACKUP_PATH) + var had_primary := FileAccess.file_exists(PROFILE_PATH) + if had_primary and not _rename(PROFILE_PATH, BACKUP_PATH): + _remove(TEMP_PATH) + return false + if not _rename(TEMP_PATH, PROFILE_PATH): + if had_primary: + _rename(BACKUP_PATH, PROFILE_PATH) + return false + _remove(BACKUP_PATH) + return true + + +func _recover_interrupted_write() -> void: + if FileAccess.file_exists(PROFILE_PATH): + _remove(TEMP_PATH) + return + if FileAccess.file_exists(BACKUP_PATH): + _rename(BACKUP_PATH, PROFILE_PATH) + _remove(TEMP_PATH) + + +func _recover_backup() -> bool: + if not FileAccess.file_exists(BACKUP_PATH): + return false + var primary_path := ProjectSettings.globalize_path(PROFILE_PATH) + var backup_path := ProjectSettings.globalize_path(BACKUP_PATH) + var corrupt_path := ProjectSettings.globalize_path( + "user://player_appearance.corrupt.%d.json" + % int(Time.get_unix_time_from_system()) + ) + DirAccess.rename_absolute(primary_path, corrupt_path) + if DirAccess.rename_absolute(backup_path, primary_path) != OK: + return false + return load_preferences() + + +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(path: String) -> void: + if FileAccess.file_exists(path): + DirAccess.remove_absolute(ProjectSettings.globalize_path(path)) diff --git a/progression/player_appearance_store.gd.uid b/progression/player_appearance_store.gd.uid new file mode 100644 index 0000000..35c2e85 --- /dev/null +++ b/progression/player_appearance_store.gd.uid @@ -0,0 +1 @@ +uid://ci07gm3e7r8p6 diff --git a/ui/game_ui.gd b/ui/game_ui.gd index d635269..e76beff 100644 --- a/ui/game_ui.gd +++ b/ui/game_ui.gd @@ -118,6 +118,7 @@ func setup( spawn_service: PlayerSpawnService, network_mail_service: NetworkMailService, reservations: PlayerAssetReservationService, + network_profile_service: NetworkProfileService, ) -> void: _fishing_spot = fishing_spot _item_effects = item_effects @@ -153,7 +154,8 @@ func setup( network_session, network_sale_service, network_mail_service, - reservations + reservations, + network_profile_service ) _hotbar_ui.setup(hotbar, bag, item_catalog, fishing_spot) _fishing_shop.setup( diff --git a/ui/player_menu.gd b/ui/player_menu.gd index 30f664a..53b3e5a 100644 --- a/ui/player_menu.gd +++ b/ui/player_menu.gd @@ -87,6 +87,7 @@ enum Section { BAG, LOGBOOK, MAIL, + PROFILE, } enum SortMode { @@ -156,6 +157,7 @@ enum CloseReason { @onready var _bag_sprite_detail_data: Label = %BagSpriteDetailData @onready var _logbook_page: Control = %LogbookPage @onready var _mail_page: MailPage = %MailPage +@onready var _profile_page: ProfilePage = %ProfilePage @onready var _book_backing: PanelContainer = %BookBacking @onready var _book_spread: BoxContainer = %BookSpread @onready var _left_page: PanelContainer = %LeftPage @@ -173,6 +175,7 @@ enum CloseReason { @onready var _bag_tab: BubbleButtonType = %BagTab @onready var _logbook_tab: BubbleButtonType = %LogbookTab @onready var _mail_tab: BubbleButtonType = %MailTab +@onready var _profile_tab: BubbleButtonType = %ProfileTab @onready var _mail_unread_badge: Label = %MailUnreadBadge @onready var _close_button: BubbleButtonType = %CloseButton @onready var _content_shell: BubbleContentShellType = %MenuPanel @@ -233,6 +236,7 @@ var _sale_service: FishSaleServiceType var _network_session: NetworkSessionType var _network_sale_service: NetworkSaleService var _network_mail_service: NetworkMailService +var _network_profile_service: NetworkProfileService var _default_buyer: FishBuyerProfileType var _catalog: FishPoolType var _fishing_spot: FishingSpotType @@ -268,6 +272,7 @@ var _cooler_rest_position: Vector2 = Vector2.ZERO var _bag_rest_position: Vector2 = Vector2.ZERO var _logbook_rest_position: Vector2 = Vector2.ZERO var _mail_rest_position: Vector2 = Vector2.ZERO +var _profile_rest_position: Vector2 = Vector2.ZERO var _page_outgoing_root: Control var _page_incoming_root: Control var _page_hosts_shared: bool = false @@ -294,6 +299,7 @@ func _ready() -> void: _show_section.bind(Section.LOGBOOK) ) _mail_tab.pressed.connect(_show_section.bind(Section.MAIL)) + _profile_tab.pressed.connect(_show_section.bind(Section.PROFILE)) _close_button.pressed.connect(close_menu) _sort_option.item_selected.connect(_on_sort_selected.bind(_sort_option)) _sort_direction.pressed.connect(_on_sort_direction_pressed) @@ -320,6 +326,7 @@ func _ready() -> void: _bag_tab, _logbook_tab, _mail_tab, + _profile_tab, _close_button, ]) _configure_navigation_focus() @@ -368,6 +375,7 @@ func setup( network_sale_service: NetworkSaleService, network_mail_service: NetworkMailService, reservations: PlayerAssetReservationService, + network_profile_service: NetworkProfileService, ) -> void: _player = player _inventory = inventory @@ -384,9 +392,11 @@ func setup( _network_session = network_session _network_sale_service = network_sale_service _network_mail_service = network_mail_service + _network_profile_service = network_profile_service _mail_page.setup( network_mail_service, reservations, inventory, wallet, bag, item_catalog ) + _profile_page.setup(network_profile_service) _network_mail_service.unread_count_changed.connect( _on_mail_unread_count_changed ) @@ -448,6 +458,8 @@ func consume_escape() -> bool: _close_sale_confirmation() elif _current_section == Section.MAIL and _mail_page.consume_escape(): pass + elif _current_section == Section.PROFILE and _profile_page.consume_escape(): + pass else: close_menu() return true @@ -488,6 +500,12 @@ func close_menu( ) -> void: if not visible: return + if ( + reason == CloseReason.USER + and _current_section == Section.PROFILE + and _profile_page.request_close_confirmation() + ): + return if _transitioning: if reason != CloseReason.USER: _finish_close(reason, restore_controls, _menu_generation) @@ -572,6 +590,11 @@ func _show_section(section: Section) -> void: or get_viewport().gui_is_dragging() ): return + if ( + _current_section == Section.PROFILE + and _profile_page.request_close_confirmation() + ): + return _begin_page_transition(section) @@ -584,6 +607,7 @@ func _show_section_immediate(section: Section) -> void: _bag_page.visible = section == Section.BAG _logbook_page.visible = section == Section.LOGBOOK _mail_page.visible = section == Section.MAIL + _profile_page.visible = section == Section.PROFILE _content_shell.visible = false _inventory_section.visible = false _bag_section.visible = false @@ -606,10 +630,15 @@ func _show_section_immediate(section: Section) -> void: _mail_page.activate() else: _mail_page.deactivate() + if section == Section.PROFILE: + _profile_page.activate() + else: + _profile_page.deactivate() _inventory_tab.button_pressed = section == Section.COOLER _bag_tab.button_pressed = section == Section.BAG _logbook_tab.button_pressed = section == Section.LOGBOOK _mail_tab.button_pressed = section == Section.MAIL + _profile_tab.button_pressed = section == Section.PROFILE _update_navigation_selection() _configure_active_page_focus() @@ -621,8 +650,10 @@ func _focus_current_section() -> void: _bag_tab.grab_focus() elif _current_section == Section.LOGBOOK: _logbook_tab.grab_focus() - else: + elif _current_section == Section.MAIL: _mail_tab.grab_focus() + else: + _profile_tab.grab_focus() func _process(delta: float) -> void: @@ -650,6 +681,7 @@ func _configure_navigation_focus() -> void: _bag_tab, _logbook_tab, _mail_tab, + _profile_tab, _close_button, ] for index: int in navigation.size(): @@ -707,6 +739,8 @@ func _configure_active_page_focus() -> void: _configure_logbook_focus() Section.MAIL: _mail_page.activate() + Section.PROFILE: + _profile_page.activate() func _apply_cooler_control_styles() -> void: @@ -800,6 +834,7 @@ func _apply_navigation_styles() -> void: _bag_tab, _logbook_tab, _mail_tab, + _profile_tab, _close_button, ]: bubble.add_theme_stylebox_override("normal", normal) @@ -830,6 +865,7 @@ func _apply_navigation_selection_presentation() -> void: _bag_tab, _logbook_tab, _mail_tab, + _profile_tab, ]: if not bubble.button_pressed: continue @@ -938,6 +974,7 @@ func _set_navigation_target(section: Section) -> void: _bag_tab.button_pressed = section == Section.BAG _logbook_tab.button_pressed = section == Section.LOGBOOK _mail_tab.button_pressed = section == Section.MAIL + _profile_tab.button_pressed = section == Section.PROFILE func _update_shell_layout() -> void: @@ -954,7 +991,7 @@ func _update_shell_layout() -> void: _content_shell.size = Vector2(612.0, 286.0) if compact else Vector2(1196.0, 478.0) _presentation_rest_position = _content_shell.position var navigation_size := ( - Vector2(720.0, 75.0) if compact else Vector2(720.0, 100.0) + Vector2(840.0, 75.0) if compact else Vector2(840.0, 100.0) ) _navigation_cluster.position = NAVIGATION_CANONICAL_POSITION _navigation_cluster.size = navigation_size @@ -1126,6 +1163,10 @@ func _update_shell_layout() -> void: _mail_page.size = reference_size _mail_page.position = Vector2.ZERO _mail_rest_position = Vector2.ZERO + _profile_page.set_anchors_preset(Control.PRESET_TOP_LEFT) + _profile_page.position = Vector2(42.0, 104.0) + _profile_page.size = Vector2(1196.0, 608.0) + _profile_rest_position = _profile_page.position _book_backing.position = ( Vector2(14.0, 164.0) if compact else Vector2(58.0, 128.0) ) @@ -1187,10 +1228,12 @@ func _update_shell_layout() -> void: _bag_page.position = _bag_rest_position _logbook_page.position = _logbook_rest_position _mail_page.position = _mail_rest_position + _profile_page.position = _profile_rest_position _cooler_page.modulate.a = 1.0 _bag_page.modulate.a = 1.0 _logbook_page.modulate.a = 1.0 _mail_page.modulate.a = 1.0 + _profile_page.modulate.a = 1.0 if not _page_transitioning: _content_stage.position = _content_rest_position _layout_cooler_fish(false) @@ -1274,6 +1317,7 @@ func _begin_menu_entry() -> void: _bag_page.position = _bag_rest_position + Vector2.DOWN * start_offset _logbook_page.position = _logbook_rest_position + Vector2.DOWN * start_offset _mail_page.position = _mail_rest_position + Vector2.DOWN * start_offset + _profile_page.position = _profile_rest_position + Vector2.DOWN * start_offset var navigation_rest: Vector2 = _navigation_cluster.position _navigation_cluster.position = navigation_rest + Vector2.DOWN * start_offset _presentation_tween = create_tween().set_parallel(true) @@ -1307,6 +1351,12 @@ func _begin_menu_entry() -> void: _mail_rest_position, MENU_ENTER_DURATION ).set_trans(Tween.TRANS_SINE).set_ease(Tween.EASE_IN_OUT) + _presentation_tween.tween_property( + _profile_page, + "position", + _profile_rest_position, + MENU_ENTER_DURATION + ).set_trans(Tween.TRANS_SINE).set_ease(Tween.EASE_IN_OUT) _presentation_tween.tween_property( _navigation_cluster, "position", @@ -1373,6 +1423,12 @@ func _begin_menu_exit(reason: CloseReason, restore_controls: bool) -> void: -_mail_page.size.y - TRANSITION_SAFE_MARGIN, MENU_EXIT_DURATION ).set_trans(Tween.TRANS_SINE).set_ease(Tween.EASE_IN_OUT) + _presentation_tween.tween_property( + _profile_page, + "position:y", + -_profile_page.size.y - TRANSITION_SAFE_MARGIN, + MENU_EXIT_DURATION + ).set_trans(Tween.TRANS_SINE).set_ease(Tween.EASE_IN_OUT) _presentation_tween.tween_property( _navigation_cluster, "position:y", @@ -1436,8 +1492,10 @@ func _get_section_root(section: Section) -> Control: return _bag_page Section.LOGBOOK: return _logbook_page - _: + Section.MAIL: return _mail_page + _: + return _profile_page func _get_section_rest_position(section: Section) -> Vector2: @@ -1448,8 +1506,10 @@ func _get_section_rest_position(section: Section) -> Vector2: return _bag_rest_position Section.LOGBOOK: return _logbook_rest_position - _: + Section.MAIL: return _mail_rest_position + _: + return _profile_rest_position func _begin_page_transition(section: Section) -> void: @@ -1530,6 +1590,7 @@ func _set_shell_interactive(interactive: bool) -> void: _bag_tab, _logbook_tab, _mail_tab, + _profile_tab, _close_button, ]: bubble.focus_mode = Control.FOCUS_ALL if interactive else Control.FOCUS_NONE @@ -1564,6 +1625,9 @@ func _set_content_interactive(interactive: bool) -> void: _mail_page.set_interactive( interactive and _current_section == Section.MAIL ) + _profile_page.set_interactive( + interactive and _current_section == Section.PROFILE + ) var cooler_interactive: bool = ( interactive and _current_section == Section.COOLER ) @@ -1636,6 +1700,7 @@ func _cancel_page_tween() -> void: func _reset_page_transition_visuals() -> void: for section: Section in [ Section.COOLER, Section.BAG, Section.LOGBOOK, Section.MAIL, + Section.PROFILE, ]: var page: Control = _get_section_root(section) page.position = _get_section_rest_position(section) diff --git a/ui/player_menu.tscn b/ui/player_menu.tscn index 48e1e62..cfb2a49 100644 --- a/ui/player_menu.tscn +++ b/ui/player_menu.tscn @@ -1,4 +1,4 @@ -[gd_scene load_steps=20 format=3] +[gd_scene load_steps=21 format=3] [ext_resource type="Script" path="res://ui/player_menu.gd" id="1_menu"] [ext_resource type="Theme" path="res://ui/game_theme.tres" id="2_theme"] @@ -18,6 +18,7 @@ [ext_resource type="Texture2D" path="res://ui/assets/title/bubbles/bubble2.png" id="16_bubble2"] [ext_resource type="Texture2D" path="res://ui/assets/title/bubbles/bubble3.png" id="17_bubble3"] [ext_resource type="Script" path="res://ui/mail_page.gd" id="18_mail_page"] +[ext_resource type="PackedScene" path="res://ui/profile_page.tscn" id="19_profile_page"] [sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_collection"] @@ -583,6 +584,14 @@ offset_bottom = 720.0 mouse_filter = 1 script = ExtResource("18_mail_page") +[node name="ProfilePage" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot" instance=ExtResource("19_profile_page")] +unique_name_in_owner = true +visible = false +layout_mode = 0 +offset_top = 112.0 +offset_right = 1280.0 +offset_bottom = 710.0 + [node name="BookBacking" type="PanelContainer" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/LogbookPage"] unique_name_in_owner = true layout_mode = 0 @@ -1206,15 +1215,15 @@ custom_minimum_size = Vector2(170, 64) [node name="NavigationCluster" type="Control" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot"] unique_name_in_owner = true layout_mode = 0 -offset_left = 390.0 +offset_left = 330.0 offset_top = 44.0 -offset_right = 1110.0 +offset_right = 1170.0 offset_bottom = 144.0 mouse_filter = 1 script = ExtResource("5_cluster") profile = ExtResource("4_profile") -desktop_reference_size = Vector2(720, 100) -compact_reference_size = Vector2(720, 100) +desktop_reference_size = Vector2(840, 100) +compact_reference_size = Vector2(840, 100) [node name="InventoryTab" type="Button" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/NavigationCluster"] unique_name_in_owner = true @@ -1224,8 +1233,8 @@ text = "cooler" script = ExtResource("6_bubble") profile = ExtResource("4_profile") neutral_size = Vector2(124, 120) -desktop_anchor = Vector2(116, 60) -compact_anchor = Vector2(118, 73.3333) +desktop_anchor = Vector2(92, 60) +compact_anchor = Vector2(92, 73.3333) compact_minimum_size = Vector2(84, 82) minimum_font_size = 25 maximum_font_size = 25 @@ -1244,8 +1253,8 @@ text = "bag" script = ExtResource("6_bubble") profile = ExtResource("4_profile") neutral_size = Vector2(124, 120) -desktop_anchor = Vector2(244, 60) -compact_anchor = Vector2(251.333, 73.3333) +desktop_anchor = Vector2(220, 60) +compact_anchor = Vector2(220, 73.3333) compact_minimum_size = Vector2(84, 82) minimum_font_size = 25 maximum_font_size = 25 @@ -1264,8 +1273,8 @@ text = "logbook" script = ExtResource("6_bubble") profile = ExtResource("4_profile") neutral_size = Vector2(124, 120) -desktop_anchor = Vector2(372, 60) -compact_anchor = Vector2(384.667, 73.3333) +desktop_anchor = Vector2(348, 60) +compact_anchor = Vector2(348, 73.3333) compact_minimum_size = Vector2(84, 82) minimum_font_size = 25 maximum_font_size = 25 @@ -1284,8 +1293,8 @@ text = "mail" script = ExtResource("6_bubble") profile = ExtResource("4_profile") neutral_size = Vector2(124, 120) -desktop_anchor = Vector2(500, 60) -compact_anchor = Vector2(501, 73.3333) +desktop_anchor = Vector2(476, 60) +compact_anchor = Vector2(476, 73.3333) compact_minimum_size = Vector2(84, 82) minimum_font_size = 25 maximum_font_size = 25 @@ -1312,6 +1321,26 @@ text = "1" horizontal_alignment = 1 vertical_alignment = 1 +[node name="ProfileTab" type="Button" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/NavigationCluster"] +unique_name_in_owner = true +layout_mode = 0 +toggle_mode = true +text = "profile" +script = ExtResource("6_bubble") +profile = ExtResource("4_profile") +neutral_size = Vector2(124, 120) +desktop_anchor = Vector2(604, 60) +compact_anchor = Vector2(604, 73.3333) +compact_minimum_size = Vector2(84, 82) +minimum_font_size = 23 +maximum_font_size = 25 +horizontal_amplitude = 1.2 +vertical_amplitude = 2.1 +motion_period = 5.7 +motion_phase = 4.05 +deformation_amplitude = 0.011 +deformation_period = 6.5 + [node name="CloseButton" type="Button" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/NavigationCluster"] unique_name_in_owner = true layout_mode = 0 @@ -1319,8 +1348,8 @@ text = "close" script = ExtResource("6_bubble") profile = ExtResource("4_profile") neutral_size = Vector2(96, 94) -desktop_anchor = Vector2(626, 57) -compact_anchor = Vector2(616, 73.3333) +desktop_anchor = Vector2(744, 57) +compact_anchor = Vector2(744, 73.3333) compact_minimum_size = Vector2(76, 74) minimum_font_size = 23 maximum_font_size = 23 diff --git a/ui/profile_page.gd b/ui/profile_page.gd new file mode 100644 index 0000000..bd94937 --- /dev/null +++ b/ui/profile_page.gd @@ -0,0 +1,414 @@ +class_name ProfilePage +extends Control + +const CHECK_DEBOUNCE_SECONDS: float = 0.4 + +var _service: NetworkProfileService +var _draft_name: String = "" +var _draft_appearance: Dictionary = {} +var _persisted_name: String = "" +var _persisted_appearance: Dictionary = {} +var _category_id: String = "species" +var _dirty: bool = false +var _allow_duplicate: bool = false + +var _name_edit: LineEdit +var _name_status: Label +var _suggestions: HBoxContainer +var _category_list: VBoxContainer +var _option_list: VBoxContainer +var _preview: ProfilePreview +var _apply_button: Button +var _revert_button: Button +var _discard_confirmation: PanelContainer +var _confirmation_label: Label +var _confirmation_confirm: Button +var _confirmation_action: String = "" +var _debounce: Timer + + +func _ready() -> void: + mouse_filter = Control.MOUSE_FILTER_PASS + _build_ui() + _debounce = Timer.new() + _debounce.one_shot = true + _debounce.wait_time = CHECK_DEBOUNCE_SECONDS + _debounce.timeout.connect(_request_conflict_check) + add_child(_debounce) + + +func setup(service: NetworkProfileService) -> void: + _service = service + if not _service.conflict_result.is_connected(_on_conflict_result): + _service.conflict_result.connect(_on_conflict_result) + _service.apply_finished.connect(_on_apply_finished) + _load_persisted() + + +func activate() -> void: + visible = true + if _service != null: + _load_persisted() + _name_edit.grab_focus() + + +func deactivate() -> void: + visible = false + _debounce.stop() + _preview.reset_view() + + +func set_interactive(interactive: bool) -> void: + mouse_filter = ( + Control.MOUSE_FILTER_PASS if interactive else Control.MOUSE_FILTER_IGNORE + ) + + +func consume_escape() -> bool: + if _discard_confirmation.visible: + _discard_confirmation.visible = false + _confirmation_action = "" + _name_edit.grab_focus() + return true + if _name_edit.has_focus(): + _name_edit.release_focus() + return true + if _dirty: + _show_confirmation("discard") + return true + return false + + +func has_unsaved_changes() -> bool: + return _dirty + + +func request_close_confirmation() -> bool: + if not _dirty: + return false + _show_confirmation("discard") + return true + + +func _build_ui() -> void: + var paper := PanelContainer.new() + paper.set_anchors_preset(Control.PRESET_FULL_RECT) + paper.offset_left = 42.0 + paper.offset_top = 14.0 + paper.offset_right = -42.0 + paper.offset_bottom = -14.0 + var style := StyleBoxFlat.new() + style.bg_color = Color("f2ead3") + style.border_color = Color("4a4238") + style.set_border_width_all(4) + style.set_corner_radius_all(18) + paper.add_theme_stylebox_override("panel", style) + add_child(paper) + + var margin := MarginContainer.new() + for side: String in ["left", "right", "top", "bottom"]: + margin.add_theme_constant_override("margin_%s" % side, 28) + paper.add_child(margin) + var layout := VBoxContainer.new() + layout.add_theme_constant_override("separation", 8) + margin.add_child(layout) + + var heading := Label.new() + heading.text = "player profile" + heading.add_theme_font_size_override("font_size", 28) + heading.add_theme_color_override("font_color", Color("302b27")) + layout.add_child(heading) + + var name_row := HBoxContainer.new() + name_row.add_theme_constant_override("separation", 10) + layout.add_child(name_row) + var name_stack := VBoxContainer.new() + name_stack.size_flags_horizontal = Control.SIZE_EXPAND_FILL + name_row.add_child(name_stack) + var name_label := Label.new() + name_label.text = "player name" + name_label.add_theme_color_override("font_color", Color("302b27")) + name_stack.add_child(name_label) + _name_edit = LineEdit.new() + _name_edit.max_length = NetworkProtocol.MAX_DISPLAY_NAME_LENGTH + _name_edit.placeholder_text = "Player" + _name_edit.custom_minimum_size = Vector2(360, 42) + _name_edit.text_changed.connect(_on_name_changed) + name_stack.add_child(_name_edit) + var helper := Label.new() + helper.text = "Shown to other players in multiplayer." + helper.add_theme_color_override("font_color", Color("665e52")) + name_stack.add_child(helper) + _apply_button = Button.new() + _apply_button.text = "apply" + _apply_button.custom_minimum_size = Vector2(118, 50) + _apply_button.pressed.connect(_apply) + name_row.add_child(_apply_button) + _revert_button = Button.new() + _revert_button.text = "revert" + _revert_button.custom_minimum_size = Vector2(108, 50) + _revert_button.pressed.connect(_revert) + name_row.add_child(_revert_button) + var defaults_button := Button.new() + defaults_button.text = "defaults" + defaults_button.custom_minimum_size = Vector2(108, 50) + defaults_button.pressed.connect(_show_confirmation.bind("defaults")) + name_row.add_child(defaults_button) + + _name_status = Label.new() + _name_status.add_theme_color_override("font_color", Color("704c36")) + layout.add_child(_name_status) + _suggestions = HBoxContainer.new() + _suggestions.add_theme_constant_override("separation", 8) + layout.add_child(_suggestions) + + var body := HBoxContainer.new() + body.size_flags_vertical = Control.SIZE_EXPAND_FILL + body.add_theme_constant_override("separation", 18) + layout.add_child(body) + _category_list = VBoxContainer.new() + _category_list.custom_minimum_size = Vector2(170, 0) + body.add_child(_category_list) + _option_list = VBoxContainer.new() + _option_list.custom_minimum_size = Vector2(230, 0) + body.add_child(_option_list) + var preview_stack := VBoxContainer.new() + preview_stack.size_flags_horizontal = Control.SIZE_EXPAND_FILL + preview_stack.alignment = BoxContainer.ALIGNMENT_CENTER + body.add_child(preview_stack) + _preview = preload("res://ui/profile_preview.tscn").instantiate() + preview_stack.add_child(_preview) + var preview_note := Label.new() + preview_note.text = "capsule preview • drag or use left / right" + preview_note.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + preview_note.add_theme_color_override("font_color", Color("514a42")) + preview_stack.add_child(preview_note) + var reset_view := Button.new() + reset_view.text = "reset view" + reset_view.pressed.connect(_preview.reset_view) + preview_stack.add_child(reset_view) + + _discard_confirmation = PanelContainer.new() + _discard_confirmation.visible = false + _discard_confirmation.set_anchors_and_offsets_preset(Control.PRESET_CENTER) + _discard_confirmation.custom_minimum_size = Vector2(430, 190) + add_child(_discard_confirmation) + var confirm_stack := VBoxContainer.new() + confirm_stack.alignment = BoxContainer.ALIGNMENT_CENTER + confirm_stack.add_theme_constant_override("separation", 16) + _discard_confirmation.add_child(confirm_stack) + _confirmation_label = Label.new() + _confirmation_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + confirm_stack.add_child(_confirmation_label) + var confirm_buttons := HBoxContainer.new() + confirm_buttons.alignment = BoxContainer.ALIGNMENT_CENTER + confirm_stack.add_child(confirm_buttons) + _confirmation_confirm = Button.new() + _confirmation_confirm.pressed.connect(_confirm_pending_action) + confirm_buttons.add_child(_confirmation_confirm) + var keep := Button.new() + keep.name = "KeepEditing" + keep.unique_name_in_owner = true + keep.text = "keep editing" + keep.pressed.connect(func() -> void: + _discard_confirmation.visible = false + _name_edit.grab_focus() + ) + confirm_buttons.add_child(keep) + + _build_categories() + + +func _build_categories() -> void: + for child: Node in _category_list.get_children(): + child.queue_free() + for category_id: String in CharacterCustomizationCatalog.CATEGORY_IDS: + var button := Button.new() + button.text = CharacterCustomizationCatalog.category_label(category_id) + button.toggle_mode = true + button.custom_minimum_size = Vector2(0, 34) + button.button_pressed = category_id == _category_id + button.pressed.connect(_select_category.bind(category_id)) + _category_list.add_child(button) + _refresh_options() + + +func _select_category(category_id: String) -> void: + _category_id = category_id + for child: Node in _category_list.get_children(): + var button := child as Button + if button != null: + button.button_pressed = button.text == ( + CharacterCustomizationCatalog.category_label(category_id) + ) + _refresh_options() + + +func _refresh_options() -> void: + for child: Node in _option_list.get_children(): + child.queue_free() + var title := Label.new() + title.text = CharacterCustomizationCatalog.category_label(_category_id) + title.add_theme_font_size_override("font_size", 22) + title.add_theme_color_override("font_color", Color("302b27")) + _option_list.add_child(title) + var options := CharacterCustomizationCatalog.options_for(_category_id) + if options.is_empty(): + var empty := Label.new() + empty.text = "More options coming later." + empty.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + _option_list.add_child(empty) + return + for option: Dictionary in options: + var option_id := str(option["id"]) + var button := Button.new() + button.text = str(option["label"]) + button.toggle_mode = true + button.custom_minimum_size = Vector2(0, 34) + button.button_pressed = _draft_appearance.get(_category_id) == option_id + button.pressed.connect(_select_option.bind(_category_id, option_id)) + _option_list.add_child(button) + + +func _select_option(category_id: String, option_id: String) -> void: + _draft_appearance[category_id] = option_id + _preview.apply_appearance_profile(_draft_appearance) + _dirty = _draft_differs() + _refresh_options() + _refresh_actions() + + +func _on_name_changed(value: String) -> void: + _draft_name = value + _allow_duplicate = false + _dirty = _draft_differs() + _refresh_actions() + _clear_suggestions() + if NetworkProfilePreferences.is_valid_display_name(value): + _name_status.text = "Checking this game…" + _debounce.start() + else: + _name_status.text = "Use 1–24 plain-text characters." + + +func _request_conflict_check() -> void: + if _service != null: + _service.request_name_check(_draft_name) + + +func _on_conflict_result( + _request_id: String, + has_conflict: bool, + suggestion_names: PackedStringArray, +) -> void: + _clear_suggestions() + if not has_conflict: + _name_status.text = "Name is available in this game." + return + _name_status.text = "That name is already in use in this game." + for suggestion: String in suggestion_names: + var button := Button.new() + button.text = suggestion + button.pressed.connect(_use_suggestion.bind(suggestion)) + _suggestions.add_child(button) + var anyway := Button.new() + anyway.text = "use anyway" + anyway.pressed.connect(func() -> void: + _allow_duplicate = true + _name_status.text = "Duplicate name allowed for this apply." + ) + _suggestions.add_child(anyway) + + +func _use_suggestion(value: String) -> void: + _name_edit.text = value + _draft_name = value + _allow_duplicate = false + _dirty = _draft_differs() + _clear_suggestions() + _request_conflict_check() + + +func _apply() -> void: + if _service == null: + return + _apply_button.disabled = true + _service.apply_profile(_draft_name, _draft_appearance, _allow_duplicate) + + +func _on_apply_finished(accepted: bool, message: String) -> void: + _apply_button.disabled = false + _name_status.text = message + if accepted: + _load_persisted() + + +func _load_persisted() -> void: + if _service == null: + return + _persisted_name = _service.get_persisted_name() + _persisted_appearance = _service.get_persisted_appearance() + _draft_name = _persisted_name + _draft_appearance = _persisted_appearance.duplicate(true) + _name_edit.text = _draft_name + _preview.apply_appearance_profile(_draft_appearance) + _dirty = false + _allow_duplicate = false + _name_status.text = "" + _clear_suggestions() + _refresh_options() + _refresh_actions() + + +func _revert() -> void: + _load_persisted() + + +func _confirm_discard() -> void: + _discard_confirmation.visible = false + _load_persisted() + + +func _show_confirmation(action: String) -> void: + _confirmation_action = action + _discard_confirmation.visible = true + if action == "defaults": + _confirmation_label.text = "Reset the profile draft to defaults?" + _confirmation_confirm.text = "reset draft" + else: + _confirmation_label.text = "Discard unsaved profile changes?" + _confirmation_confirm.text = "discard changes" + _discard_confirmation.get_node("%KeepEditing").grab_focus() + + +func _confirm_pending_action() -> void: + _discard_confirmation.visible = false + if _confirmation_action == "defaults": + _draft_appearance = CharacterCustomizationCatalog.default_snapshot() + _preview.apply_appearance_profile(_draft_appearance) + _dirty = _draft_differs() + _refresh_options() + _refresh_actions() + else: + _confirm_discard() + _confirmation_action = "" + + +func _draft_differs() -> bool: + return ( + _draft_name.strip_edges() != _persisted_name + or _draft_appearance != _persisted_appearance + ) + + +func _refresh_actions() -> void: + _apply_button.disabled = ( + not _dirty + or not NetworkProfilePreferences.is_valid_display_name(_draft_name) + ) + _revert_button.disabled = not _dirty + + +func _clear_suggestions() -> void: + for child: Node in _suggestions.get_children(): + child.queue_free() diff --git a/ui/profile_page.gd.uid b/ui/profile_page.gd.uid new file mode 100644 index 0000000..752af13 --- /dev/null +++ b/ui/profile_page.gd.uid @@ -0,0 +1 @@ +uid://b6c2de8orrwfj diff --git a/ui/profile_page.tscn b/ui/profile_page.tscn new file mode 100644 index 0000000..da22b8b --- /dev/null +++ b/ui/profile_page.tscn @@ -0,0 +1,8 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://ui/profile_page.gd" id="1"] + +[node name="ProfilePage" type="Control"] +custom_minimum_size = Vector2(320, 280) +layout_mode = 0 +script = ExtResource("1") diff --git a/ui/profile_preview.gd b/ui/profile_preview.gd new file mode 100644 index 0000000..b2e0cd9 --- /dev/null +++ b/ui/profile_preview.gd @@ -0,0 +1,54 @@ +class_name ProfilePreview +extends SubViewportContainer + +@export_range(0.1, 2.0, 0.05) var drag_sensitivity: float = 0.012 +@export_range(0.1, 4.0, 0.1) var keyboard_speed: float = 1.8 + +@onready var _preview_root: Node3D = %PreviewRoot + +var _dragging: bool = false + + +func _ready() -> void: + focus_mode = Control.FOCUS_ALL + gui_input.connect(_on_gui_input) + + +func apply_appearance_profile(_profile: Dictionary) -> void: + # The capsule is deliberately neutral until modular character assets exist. + pass + + +func reset_view() -> void: + _preview_root.rotation.y = 0.0 + + +func _process(delta: float) -> void: + if not has_focus(): + return + var axis := Input.get_axis("ui_left", "ui_right") + var right_stick := Input.get_joy_axis(0, JOY_AXIS_RIGHT_X) + if absf(right_stick) > 0.2: + axis = right_stick + if absf(axis) > 0.1: + _rotate(axis * keyboard_speed * delta) + + +func _on_gui_input(event: InputEvent) -> void: + if event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT: + _dragging = event.pressed + if event.pressed: + grab_focus() + accept_event() + elif event is InputEventMouseMotion and _dragging: + _rotate(event.relative.x * drag_sensitivity) + accept_event() + + +func _notification(what: int) -> void: + if what == NOTIFICATION_VISIBILITY_CHANGED and not is_visible_in_tree(): + _dragging = false + + +func _rotate(amount: float) -> void: + _preview_root.rotation.y = fposmod(_preview_root.rotation.y + amount, TAU) diff --git a/ui/profile_preview.gd.uid b/ui/profile_preview.gd.uid new file mode 100644 index 0000000..5377ed7 --- /dev/null +++ b/ui/profile_preview.gd.uid @@ -0,0 +1 @@ +uid://3iu5mxqgsjrx diff --git a/ui/profile_preview.tscn b/ui/profile_preview.tscn new file mode 100644 index 0000000..9c84a77 --- /dev/null +++ b/ui/profile_preview.tscn @@ -0,0 +1,57 @@ +[gd_scene load_steps=6 format=3] + +[ext_resource type="Script" path="res://ui/profile_preview.gd" id="1"] + +[sub_resource type="CapsuleMesh" id="Capsule"] +radius = 0.62 +height = 2.25 + +[sub_resource type="StandardMaterial3D" id="Material"] +albedo_color = Color(0.38, 0.67, 0.74, 1) +roughness = 0.72 + +[sub_resource type="Environment" id="Environment"] +background_mode = 1 +background_color = Color(0.075, 0.13, 0.16, 1) +ambient_light_source = 3 +ambient_light_color = Color(0.72, 0.82, 0.86, 1) +ambient_light_energy = 0.65 + +[sub_resource type="World3D" id="PreviewWorld"] +environment = SubResource("Environment") + +[node name="ProfilePreview" type="SubViewportContainer"] +custom_minimum_size = Vector2(360, 250) +stretch = true +script = ExtResource("1") + +[node name="Viewport" type="SubViewport" parent="."] +transparent_bg = false +size = Vector2i(360, 390) +render_target_update_mode = 4 +world_3d = SubResource("PreviewWorld") + +[node name="WorldEnvironment" type="WorldEnvironment" parent="Viewport"] +environment = SubResource("Environment") + +[node name="KeyLight" type="DirectionalLight3D" parent="Viewport"] +rotation_degrees = Vector3(-38, -32, 0) +light_energy = 1.15 +shadow_enabled = true + +[node name="FillLight" type="OmniLight3D" parent="Viewport"] +position = Vector3(-1.8, 1.6, 2.2) +light_color = Color(0.72, 0.84, 1, 1) +omni_range = 5.0 +light_energy = 1.3 + +[node name="PreviewRoot" type="Node3D" parent="Viewport"] +unique_name_in_owner = true + +[node name="Capsule" type="MeshInstance3D" parent="Viewport/PreviewRoot"] +mesh = SubResource("Capsule") +material_override = SubResource("Material") + +[node name="Camera3D" type="Camera3D" parent="Viewport"] +position = Vector3(0, 0.15, 4.25) +current = true diff --git a/ui/settings_panel.gd b/ui/settings_panel.gd index 37e34fa..b315214 100644 --- a/ui/settings_panel.gd +++ b/ui/settings_panel.gd @@ -38,7 +38,6 @@ enum PresentationMode { @onready var _controls_page: SettingsBubblePage = %ControlsPage @onready var _accessibility_page: SettingsBubblePage = %AccessibilityPage @onready var _feedback: Label = %SettingsFeedback -@onready var _display_name_edit: LineEdit = %DisplayNameEdit @onready var _world_value: BubbleButton = %WorldValue @onready var _ui_value: BubbleButton = %UIValue @@ -101,10 +100,6 @@ func _ready() -> void: %DisplayBackButton.gui_input.connect(_on_back_bubble_gui_input) %ControlsBackButton.gui_input.connect(_on_back_bubble_gui_input) %AccessibilityBackButton.gui_input.connect(_on_back_bubble_gui_input) - _display_name_edit.text_submitted.connect( - func(_value: String) -> void: _apply_display_name() - ) - _display_name_edit.focus_exited.connect(_apply_display_name) for index: int in _world_options.size(): _world_options[index].pressed.connect( _set_world_pixelation.bind(index + 1) @@ -156,25 +151,6 @@ func setup_network_profile( ) -> void: _network_profile = profile _network_session = session - if _network_profile != null: - _display_name_edit.text = _network_profile.display_name - - -func _apply_display_name() -> void: - if _network_profile == null: - return - var value := _display_name_edit.text.strip_edges() - var accepted := ( - _network_session.update_local_display_name(value) - if _network_session != null - else _network_profile.set_display_name(value) - ) - if accepted: - _display_name_edit.text = _network_profile.display_name - _feedback.text = "display name saved." - else: - _display_name_edit.text = _network_profile.display_name - _feedback.text = "display name must be 1–24 plain-text characters." func open_panel( @@ -192,8 +168,6 @@ func open_panel( ) _settings_manager = settings_manager _load_controls() - if _network_profile != null: - _display_name_edit.text = _network_profile.display_name _feedback.text = "" show() _page_stack.clear() diff --git a/ui/settings_panel.tscn b/ui/settings_panel.tscn index 1321275..a6bc71c 100644 --- a/ui/settings_panel.tscn +++ b/ui/settings_panel.tscn @@ -144,31 +144,6 @@ minimum_font_size = 14 maximum_font_size = 17 motion_phase = 4.8 -[node name="DisplayNamePanel" type="PanelContainer" parent="RootPage"] -layout_mode = 0 -offset_left = 318.0 -offset_top = 430.0 -offset_right = 618.0 -offset_bottom = 510.0 - -[node name="VBox" type="VBoxContainer" parent="RootPage/DisplayNamePanel"] -layout_mode = 2 - -[node name="Label" type="Label" parent="RootPage/DisplayNamePanel/VBox"] -layout_mode = 2 -text = "display name" - -[node name="DisplayNameEdit" type="LineEdit" parent="RootPage/DisplayNamePanel/VBox"] -unique_name_in_owner = true -layout_mode = 2 -max_length = 24 -placeholder_text = "Player" - -[node name="Help" type="Label" parent="RootPage/DisplayNamePanel/VBox"] -layout_mode = 2 -text = "Shown to other players in multiplayer." -theme_override_font_sizes/font_size = 12 - [node name="DisplayPage" type="Control" parent="."] unique_name_in_owner = true visible = false