Add player profile and appearance foundation

This commit is contained in:
Alexander Sellite 2026-07-29 21:31:42 -04:00
parent e53ad1ac38
commit 0b5b78a553
25 changed files with 1424 additions and 76 deletions

View file

@ -64,6 +64,12 @@ const NetworkMailServiceType = preload(
const PlayerAssetReservationServiceType = preload( const PlayerAssetReservationServiceType = preload(
"res://progression/player_asset_reservation_service.gd" "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 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 = ( @onready var _asset_reservations: PlayerAssetReservationServiceType = (
%PlayerAssetReservationService %PlayerAssetReservationService
) )
@onready var _appearance_store: PlayerAppearanceStoreType = %PlayerAppearanceStore
@onready var _network_profile_service: NetworkProfileServiceType = (
%NetworkProfileService
)
@onready var _players_root: Node3D = $Players @onready var _players_root: Node3D = $Players
var _gameplay_started: bool = false var _gameplay_started: bool = false
@ -137,6 +147,15 @@ func _ready() -> void:
_saved_servers, _saved_servers,
_player_spawn_service _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( _network_session.join_authenticated.connect(
_on_network_join_authenticated _on_network_join_authenticated
) )
@ -271,7 +290,8 @@ func _ready() -> void:
_network_profile, _network_profile,
_player_spawn_service, _player_spawn_service,
_network_mail, _network_mail,
_asset_reservations _asset_reservations,
_network_profile_service
) )
_water_recovery.setup( _water_recovery.setup(
_player, _player,

View file

@ -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://world/test_world.tscn" id="1_world"]
[ext_resource type="PackedScene" path="res://player/player.tscn" id="2_player"] [ext_resource type="PackedScene" path="res://player/player.tscn" id="2_player"]
@ -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_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://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_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"] [node name="Main" type="Node3D"]
script = ExtResource("3_main") script = ExtResource("3_main")
@ -43,6 +45,14 @@ script = ExtResource("17_network_session")
unique_name_in_owner = true unique_name_in_owner = true
script = ExtResource("18_network_profile") 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="."] [node name="SavedServerStore" type="Node" parent="."]
unique_name_in_owner = true unique_name_in_owner = true
script = ExtResource("19_saved_servers") script = ExtResource("19_saved_servers")

View file

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

View file

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

View file

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

View file

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

View file

@ -33,6 +33,7 @@ static func make_client_hello(
profile_id: String, profile_id: String,
display_name: String, display_name: String,
client_nonce: String, client_nonce: String,
cosmetic_snapshot: Dictionary = {},
) -> Dictionary: ) -> Dictionary:
return { return {
"protocol_version": PROTOCOL_VERSION, "protocol_version": PROTOCOL_VERSION,
@ -41,7 +42,7 @@ static func make_client_hello(
"display_name": display_name, "display_name": display_name,
"client_nonce": client_nonce, "client_nonce": client_nonce,
"capability_flags": PackedStringArray(), "capability_flags": PackedStringArray(),
"cosmetic_snapshot": {}, "cosmetic_snapshot": cosmetic_snapshot,
} }
@ -118,6 +119,7 @@ static func make_server_hello(
"equipment_v1", "equipment_v1",
"chat_v1", "chat_v1",
"mail_v1", "mail_v1",
"profile_v1",
]), ]),
} }

View file

@ -17,6 +17,11 @@ signal peer_removed(peer_id: int)
signal host_openness_changed(is_open: bool) signal host_openness_changed(is_open: bool)
signal peer_count_changed(player_count: int, max_players: int) signal peer_count_changed(player_count: int, max_players: int)
signal peer_display_name_changed(peer_id: int, display_name: String) 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 join_authenticated
signal server_lost signal server_lost
signal remote_recovery_requested(peer_id: int, entry_position: Vector3) 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 _last_server_protocol_version: int = 0
var _server_capabilities: PackedStringArray = PackedStringArray() var _server_capabilities: PackedStringArray = PackedStringArray()
var _profile_ready: bool = false var _profile_ready: bool = false
var _local_appearance_snapshot: Dictionary = (
CharacterCustomizationCatalog.default_snapshot()
)
func _ready() -> void: func _ready() -> void:
@ -116,8 +124,12 @@ func start_private_host(port: int = DEFAULT_PORT) -> bool:
_profile.display_name, _profile.display_name,
NetworkProtocol.PROTOCOL_VERSION NetworkProtocol.PROTOCOL_VERSION
) )
_registry.update_appearance(1, _local_appearance_snapshot)
_spawn_service.clear_remote_players() _spawn_service.clear_remote_players()
_spawn_service.register_local_player(1) _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) _set_state(State.PRIVATE_HOST, "Private game • UDP %d" % port)
host_openness_changed.emit(false) host_openness_changed.emit(false)
_emit_peer_count() _emit_peer_count()
@ -278,6 +290,7 @@ func supports_server_capability(capability: StringName) -> bool:
"movement_v1", "fishing_v1", "sale_v1", "shop_v1", "movement_v1", "fishing_v1", "sale_v1", "shop_v1",
"item_use_v1", "equipment_v1", "chat_v1", "item_use_v1", "equipment_v1", "chat_v1",
"mail_v1", "mail_v1",
"profile_v1",
]) ])
return str(capability) in _server_capabilities return str(capability) in _server_capabilities
@ -302,6 +315,33 @@ func update_local_display_name(value: String) -> bool:
return true 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) @rpc("any_peer", "call_remote", "reliable", 0)
func submit_display_name(value: String) -> void: func submit_display_name(value: String) -> void:
var sender_id := multiplayer.get_remote_sender_id() 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( var hello: Dictionary = NetworkProtocol.make_client_hello(
_profile.profile_id, _profile.profile_id,
_profile.display_name, _profile.display_name,
_client_nonce _client_nonce,
_local_appearance_snapshot,
) )
submit_client_hello.rpc_id(1, hello) submit_client_hello.rpc_id(1, hello)
@ -477,6 +518,10 @@ func submit_client_hello(data: Dictionary) -> void:
NetworkProtocol.RejectionCode.MALFORMED_HANDSHAKE NetworkProtocol.RejectionCode.MALFORMED_HANDSHAKE
) )
return return
var submitted_appearance := CharacterCustomizationCatalog.sanitized_snapshot(
data["cosmetic_snapshot"]
)
_registry.update_appearance(sender_id, submitted_appearance)
_pending_authentication.erase(sender_id) _pending_authentication.erase(sender_id)
var spawn_index: int = _registry.get_peer_ids().find(sender_id) var spawn_index: int = _registry.get_peer_ids().find(sender_id)
var spawn_transform: Transform3D = ( var spawn_transform: Transform3D = (
@ -582,8 +627,12 @@ func receive_server_hello(data: Dictionary) -> void:
_profile.display_name, _profile.display_name,
NetworkProtocol.PROTOCOL_VERSION NetworkProtocol.PROTOCOL_VERSION
) )
_registry.update_appearance(local_peer_id, _local_appearance_snapshot)
_spawn_service.clear_remote_players() _spawn_service.clear_remote_players()
_spawn_service.register_local_player(local_peer_id) _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 _connection_deadline = 0.0
_set_state( _set_state(
State.JOINED_CLIENT, State.JOINED_CLIENT,
@ -652,6 +701,11 @@ func _apply_spawn_entry(entry: Dictionary) -> void:
entry["display_name"], entry["display_name"],
NetworkProtocol.PROTOCOL_VERSION 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 transform: Transform3D = _spawn_service.get_spawn_transform_for_index(0)
var position: Array = entry["position"] var position: Array = entry["position"]
if position.size() != 3: if position.size() != 3:
@ -662,7 +716,10 @@ func _apply_spawn_entry(entry: Dictionary) -> void:
float(position[2]) float(position[2])
) )
transform.basis = Basis(Vector3.UP, float(entry["yaw"])) 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]: func _build_spawn_list() -> Array[Dictionary]:
@ -695,6 +752,11 @@ func _make_spawn_entry(
transform.origin.z, transform.origin.z,
], ],
"yaw": transform.basis.get_euler().y, "yaw": transform.basis.get_euler().y,
"appearance": (
record.appearance_snapshot.duplicate(true)
if record != null
else CharacterCustomizationCatalog.default_snapshot()
),
} }

View file

@ -9,6 +9,9 @@ class PeerRecord:
var display_name: String = "" var display_name: String = ""
var protocol_version: int = 0 var protocol_version: int = 0
var joined_at_unix: int = 0 var joined_at_unix: int = 0
var appearance_snapshot: Dictionary = (
CharacterCustomizationCatalog.default_snapshot()
)
var _records: Dictionary[int, PeerRecord] = {} var _records: Dictionary[int, PeerRecord] = {}
@ -50,6 +53,14 @@ func update_display_name(peer_id: int, display_name: String) -> bool:
return true 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: func has_peer(peer_id: int) -> bool:
return _records.has(peer_id) return _records.has(peer_id)

View file

@ -18,6 +18,17 @@ const PlayerCoolerCapacityType = preload(
"res://progression/player_cooler_capacity.gd" "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: class ShowcaseCameraSnapshot:
extends RefCounted extends RefCounted

View file

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

View file

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

View file

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

View file

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

View file

@ -118,6 +118,7 @@ func setup(
spawn_service: PlayerSpawnService, spawn_service: PlayerSpawnService,
network_mail_service: NetworkMailService, network_mail_service: NetworkMailService,
reservations: PlayerAssetReservationService, reservations: PlayerAssetReservationService,
network_profile_service: NetworkProfileService,
) -> void: ) -> void:
_fishing_spot = fishing_spot _fishing_spot = fishing_spot
_item_effects = item_effects _item_effects = item_effects
@ -153,7 +154,8 @@ func setup(
network_session, network_session,
network_sale_service, network_sale_service,
network_mail_service, network_mail_service,
reservations reservations,
network_profile_service
) )
_hotbar_ui.setup(hotbar, bag, item_catalog, fishing_spot) _hotbar_ui.setup(hotbar, bag, item_catalog, fishing_spot)
_fishing_shop.setup( _fishing_shop.setup(

View file

@ -87,6 +87,7 @@ enum Section {
BAG, BAG,
LOGBOOK, LOGBOOK,
MAIL, MAIL,
PROFILE,
} }
enum SortMode { enum SortMode {
@ -156,6 +157,7 @@ enum CloseReason {
@onready var _bag_sprite_detail_data: Label = %BagSpriteDetailData @onready var _bag_sprite_detail_data: Label = %BagSpriteDetailData
@onready var _logbook_page: Control = %LogbookPage @onready var _logbook_page: Control = %LogbookPage
@onready var _mail_page: MailPage = %MailPage @onready var _mail_page: MailPage = %MailPage
@onready var _profile_page: ProfilePage = %ProfilePage
@onready var _book_backing: PanelContainer = %BookBacking @onready var _book_backing: PanelContainer = %BookBacking
@onready var _book_spread: BoxContainer = %BookSpread @onready var _book_spread: BoxContainer = %BookSpread
@onready var _left_page: PanelContainer = %LeftPage @onready var _left_page: PanelContainer = %LeftPage
@ -173,6 +175,7 @@ enum CloseReason {
@onready var _bag_tab: BubbleButtonType = %BagTab @onready var _bag_tab: BubbleButtonType = %BagTab
@onready var _logbook_tab: BubbleButtonType = %LogbookTab @onready var _logbook_tab: BubbleButtonType = %LogbookTab
@onready var _mail_tab: BubbleButtonType = %MailTab @onready var _mail_tab: BubbleButtonType = %MailTab
@onready var _profile_tab: BubbleButtonType = %ProfileTab
@onready var _mail_unread_badge: Label = %MailUnreadBadge @onready var _mail_unread_badge: Label = %MailUnreadBadge
@onready var _close_button: BubbleButtonType = %CloseButton @onready var _close_button: BubbleButtonType = %CloseButton
@onready var _content_shell: BubbleContentShellType = %MenuPanel @onready var _content_shell: BubbleContentShellType = %MenuPanel
@ -233,6 +236,7 @@ var _sale_service: FishSaleServiceType
var _network_session: NetworkSessionType var _network_session: NetworkSessionType
var _network_sale_service: NetworkSaleService var _network_sale_service: NetworkSaleService
var _network_mail_service: NetworkMailService var _network_mail_service: NetworkMailService
var _network_profile_service: NetworkProfileService
var _default_buyer: FishBuyerProfileType var _default_buyer: FishBuyerProfileType
var _catalog: FishPoolType var _catalog: FishPoolType
var _fishing_spot: FishingSpotType var _fishing_spot: FishingSpotType
@ -268,6 +272,7 @@ var _cooler_rest_position: Vector2 = Vector2.ZERO
var _bag_rest_position: Vector2 = Vector2.ZERO var _bag_rest_position: Vector2 = Vector2.ZERO
var _logbook_rest_position: Vector2 = Vector2.ZERO var _logbook_rest_position: Vector2 = Vector2.ZERO
var _mail_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_outgoing_root: Control
var _page_incoming_root: Control var _page_incoming_root: Control
var _page_hosts_shared: bool = false var _page_hosts_shared: bool = false
@ -294,6 +299,7 @@ func _ready() -> void:
_show_section.bind(Section.LOGBOOK) _show_section.bind(Section.LOGBOOK)
) )
_mail_tab.pressed.connect(_show_section.bind(Section.MAIL)) _mail_tab.pressed.connect(_show_section.bind(Section.MAIL))
_profile_tab.pressed.connect(_show_section.bind(Section.PROFILE))
_close_button.pressed.connect(close_menu) _close_button.pressed.connect(close_menu)
_sort_option.item_selected.connect(_on_sort_selected.bind(_sort_option)) _sort_option.item_selected.connect(_on_sort_selected.bind(_sort_option))
_sort_direction.pressed.connect(_on_sort_direction_pressed) _sort_direction.pressed.connect(_on_sort_direction_pressed)
@ -320,6 +326,7 @@ func _ready() -> void:
_bag_tab, _bag_tab,
_logbook_tab, _logbook_tab,
_mail_tab, _mail_tab,
_profile_tab,
_close_button, _close_button,
]) ])
_configure_navigation_focus() _configure_navigation_focus()
@ -368,6 +375,7 @@ func setup(
network_sale_service: NetworkSaleService, network_sale_service: NetworkSaleService,
network_mail_service: NetworkMailService, network_mail_service: NetworkMailService,
reservations: PlayerAssetReservationService, reservations: PlayerAssetReservationService,
network_profile_service: NetworkProfileService,
) -> void: ) -> void:
_player = player _player = player
_inventory = inventory _inventory = inventory
@ -384,9 +392,11 @@ func setup(
_network_session = network_session _network_session = network_session
_network_sale_service = network_sale_service _network_sale_service = network_sale_service
_network_mail_service = network_mail_service _network_mail_service = network_mail_service
_network_profile_service = network_profile_service
_mail_page.setup( _mail_page.setup(
network_mail_service, reservations, inventory, wallet, bag, item_catalog network_mail_service, reservations, inventory, wallet, bag, item_catalog
) )
_profile_page.setup(network_profile_service)
_network_mail_service.unread_count_changed.connect( _network_mail_service.unread_count_changed.connect(
_on_mail_unread_count_changed _on_mail_unread_count_changed
) )
@ -448,6 +458,8 @@ func consume_escape() -> bool:
_close_sale_confirmation() _close_sale_confirmation()
elif _current_section == Section.MAIL and _mail_page.consume_escape(): elif _current_section == Section.MAIL and _mail_page.consume_escape():
pass pass
elif _current_section == Section.PROFILE and _profile_page.consume_escape():
pass
else: else:
close_menu() close_menu()
return true return true
@ -488,6 +500,12 @@ func close_menu(
) -> void: ) -> void:
if not visible: if not visible:
return return
if (
reason == CloseReason.USER
and _current_section == Section.PROFILE
and _profile_page.request_close_confirmation()
):
return
if _transitioning: if _transitioning:
if reason != CloseReason.USER: if reason != CloseReason.USER:
_finish_close(reason, restore_controls, _menu_generation) _finish_close(reason, restore_controls, _menu_generation)
@ -572,6 +590,11 @@ func _show_section(section: Section) -> void:
or get_viewport().gui_is_dragging() or get_viewport().gui_is_dragging()
): ):
return return
if (
_current_section == Section.PROFILE
and _profile_page.request_close_confirmation()
):
return
_begin_page_transition(section) _begin_page_transition(section)
@ -584,6 +607,7 @@ func _show_section_immediate(section: Section) -> void:
_bag_page.visible = section == Section.BAG _bag_page.visible = section == Section.BAG
_logbook_page.visible = section == Section.LOGBOOK _logbook_page.visible = section == Section.LOGBOOK
_mail_page.visible = section == Section.MAIL _mail_page.visible = section == Section.MAIL
_profile_page.visible = section == Section.PROFILE
_content_shell.visible = false _content_shell.visible = false
_inventory_section.visible = false _inventory_section.visible = false
_bag_section.visible = false _bag_section.visible = false
@ -606,10 +630,15 @@ func _show_section_immediate(section: Section) -> void:
_mail_page.activate() _mail_page.activate()
else: else:
_mail_page.deactivate() _mail_page.deactivate()
if section == Section.PROFILE:
_profile_page.activate()
else:
_profile_page.deactivate()
_inventory_tab.button_pressed = section == Section.COOLER _inventory_tab.button_pressed = section == Section.COOLER
_bag_tab.button_pressed = section == Section.BAG _bag_tab.button_pressed = section == Section.BAG
_logbook_tab.button_pressed = section == Section.LOGBOOK _logbook_tab.button_pressed = section == Section.LOGBOOK
_mail_tab.button_pressed = section == Section.MAIL _mail_tab.button_pressed = section == Section.MAIL
_profile_tab.button_pressed = section == Section.PROFILE
_update_navigation_selection() _update_navigation_selection()
_configure_active_page_focus() _configure_active_page_focus()
@ -621,8 +650,10 @@ func _focus_current_section() -> void:
_bag_tab.grab_focus() _bag_tab.grab_focus()
elif _current_section == Section.LOGBOOK: elif _current_section == Section.LOGBOOK:
_logbook_tab.grab_focus() _logbook_tab.grab_focus()
else: elif _current_section == Section.MAIL:
_mail_tab.grab_focus() _mail_tab.grab_focus()
else:
_profile_tab.grab_focus()
func _process(delta: float) -> void: func _process(delta: float) -> void:
@ -650,6 +681,7 @@ func _configure_navigation_focus() -> void:
_bag_tab, _bag_tab,
_logbook_tab, _logbook_tab,
_mail_tab, _mail_tab,
_profile_tab,
_close_button, _close_button,
] ]
for index: int in navigation.size(): for index: int in navigation.size():
@ -707,6 +739,8 @@ func _configure_active_page_focus() -> void:
_configure_logbook_focus() _configure_logbook_focus()
Section.MAIL: Section.MAIL:
_mail_page.activate() _mail_page.activate()
Section.PROFILE:
_profile_page.activate()
func _apply_cooler_control_styles() -> void: func _apply_cooler_control_styles() -> void:
@ -800,6 +834,7 @@ func _apply_navigation_styles() -> void:
_bag_tab, _bag_tab,
_logbook_tab, _logbook_tab,
_mail_tab, _mail_tab,
_profile_tab,
_close_button, _close_button,
]: ]:
bubble.add_theme_stylebox_override("normal", normal) bubble.add_theme_stylebox_override("normal", normal)
@ -830,6 +865,7 @@ func _apply_navigation_selection_presentation() -> void:
_bag_tab, _bag_tab,
_logbook_tab, _logbook_tab,
_mail_tab, _mail_tab,
_profile_tab,
]: ]:
if not bubble.button_pressed: if not bubble.button_pressed:
continue continue
@ -938,6 +974,7 @@ func _set_navigation_target(section: Section) -> void:
_bag_tab.button_pressed = section == Section.BAG _bag_tab.button_pressed = section == Section.BAG
_logbook_tab.button_pressed = section == Section.LOGBOOK _logbook_tab.button_pressed = section == Section.LOGBOOK
_mail_tab.button_pressed = section == Section.MAIL _mail_tab.button_pressed = section == Section.MAIL
_profile_tab.button_pressed = section == Section.PROFILE
func _update_shell_layout() -> void: 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) _content_shell.size = Vector2(612.0, 286.0) if compact else Vector2(1196.0, 478.0)
_presentation_rest_position = _content_shell.position _presentation_rest_position = _content_shell.position
var navigation_size := ( 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.position = NAVIGATION_CANONICAL_POSITION
_navigation_cluster.size = navigation_size _navigation_cluster.size = navigation_size
@ -1126,6 +1163,10 @@ func _update_shell_layout() -> void:
_mail_page.size = reference_size _mail_page.size = reference_size
_mail_page.position = Vector2.ZERO _mail_page.position = Vector2.ZERO
_mail_rest_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 = ( _book_backing.position = (
Vector2(14.0, 164.0) if compact else Vector2(58.0, 128.0) 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 _bag_page.position = _bag_rest_position
_logbook_page.position = _logbook_rest_position _logbook_page.position = _logbook_rest_position
_mail_page.position = _mail_rest_position _mail_page.position = _mail_rest_position
_profile_page.position = _profile_rest_position
_cooler_page.modulate.a = 1.0 _cooler_page.modulate.a = 1.0
_bag_page.modulate.a = 1.0 _bag_page.modulate.a = 1.0
_logbook_page.modulate.a = 1.0 _logbook_page.modulate.a = 1.0
_mail_page.modulate.a = 1.0 _mail_page.modulate.a = 1.0
_profile_page.modulate.a = 1.0
if not _page_transitioning: if not _page_transitioning:
_content_stage.position = _content_rest_position _content_stage.position = _content_rest_position
_layout_cooler_fish(false) _layout_cooler_fish(false)
@ -1274,6 +1317,7 @@ func _begin_menu_entry() -> void:
_bag_page.position = _bag_rest_position + Vector2.DOWN * start_offset _bag_page.position = _bag_rest_position + Vector2.DOWN * start_offset
_logbook_page.position = _logbook_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 _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 var navigation_rest: Vector2 = _navigation_cluster.position
_navigation_cluster.position = navigation_rest + Vector2.DOWN * start_offset _navigation_cluster.position = navigation_rest + Vector2.DOWN * start_offset
_presentation_tween = create_tween().set_parallel(true) _presentation_tween = create_tween().set_parallel(true)
@ -1307,6 +1351,12 @@ func _begin_menu_entry() -> void:
_mail_rest_position, _mail_rest_position,
MENU_ENTER_DURATION MENU_ENTER_DURATION
).set_trans(Tween.TRANS_SINE).set_ease(Tween.EASE_IN_OUT) ).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( _presentation_tween.tween_property(
_navigation_cluster, _navigation_cluster,
"position", "position",
@ -1373,6 +1423,12 @@ func _begin_menu_exit(reason: CloseReason, restore_controls: bool) -> void:
-_mail_page.size.y - TRANSITION_SAFE_MARGIN, -_mail_page.size.y - TRANSITION_SAFE_MARGIN,
MENU_EXIT_DURATION MENU_EXIT_DURATION
).set_trans(Tween.TRANS_SINE).set_ease(Tween.EASE_IN_OUT) ).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( _presentation_tween.tween_property(
_navigation_cluster, _navigation_cluster,
"position:y", "position:y",
@ -1436,8 +1492,10 @@ func _get_section_root(section: Section) -> Control:
return _bag_page return _bag_page
Section.LOGBOOK: Section.LOGBOOK:
return _logbook_page return _logbook_page
_: Section.MAIL:
return _mail_page return _mail_page
_:
return _profile_page
func _get_section_rest_position(section: Section) -> Vector2: func _get_section_rest_position(section: Section) -> Vector2:
@ -1448,8 +1506,10 @@ func _get_section_rest_position(section: Section) -> Vector2:
return _bag_rest_position return _bag_rest_position
Section.LOGBOOK: Section.LOGBOOK:
return _logbook_rest_position return _logbook_rest_position
_: Section.MAIL:
return _mail_rest_position return _mail_rest_position
_:
return _profile_rest_position
func _begin_page_transition(section: Section) -> void: func _begin_page_transition(section: Section) -> void:
@ -1530,6 +1590,7 @@ func _set_shell_interactive(interactive: bool) -> void:
_bag_tab, _bag_tab,
_logbook_tab, _logbook_tab,
_mail_tab, _mail_tab,
_profile_tab,
_close_button, _close_button,
]: ]:
bubble.focus_mode = Control.FOCUS_ALL if interactive else Control.FOCUS_NONE 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( _mail_page.set_interactive(
interactive and _current_section == Section.MAIL interactive and _current_section == Section.MAIL
) )
_profile_page.set_interactive(
interactive and _current_section == Section.PROFILE
)
var cooler_interactive: bool = ( var cooler_interactive: bool = (
interactive and _current_section == Section.COOLER interactive and _current_section == Section.COOLER
) )
@ -1636,6 +1700,7 @@ func _cancel_page_tween() -> void:
func _reset_page_transition_visuals() -> void: func _reset_page_transition_visuals() -> void:
for section: Section in [ for section: Section in [
Section.COOLER, Section.BAG, Section.LOGBOOK, Section.MAIL, Section.COOLER, Section.BAG, Section.LOGBOOK, Section.MAIL,
Section.PROFILE,
]: ]:
var page: Control = _get_section_root(section) var page: Control = _get_section_root(section)
page.position = _get_section_rest_position(section) page.position = _get_section_rest_position(section)

View file

@ -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="Script" path="res://ui/player_menu.gd" id="1_menu"]
[ext_resource type="Theme" path="res://ui/game_theme.tres" id="2_theme"] [ext_resource type="Theme" path="res://ui/game_theme.tres" id="2_theme"]
@ -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/bubble2.png" id="16_bubble2"]
[ext_resource type="Texture2D" path="res://ui/assets/title/bubbles/bubble3.png" id="17_bubble3"] [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="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"] [sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_collection"]
@ -583,6 +584,14 @@ offset_bottom = 720.0
mouse_filter = 1 mouse_filter = 1
script = ExtResource("18_mail_page") 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"] [node name="BookBacking" type="PanelContainer" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/LogbookPage"]
unique_name_in_owner = true unique_name_in_owner = true
layout_mode = 0 layout_mode = 0
@ -1206,15 +1215,15 @@ custom_minimum_size = Vector2(170, 64)
[node name="NavigationCluster" type="Control" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot"] [node name="NavigationCluster" type="Control" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot"]
unique_name_in_owner = true unique_name_in_owner = true
layout_mode = 0 layout_mode = 0
offset_left = 390.0 offset_left = 330.0
offset_top = 44.0 offset_top = 44.0
offset_right = 1110.0 offset_right = 1170.0
offset_bottom = 144.0 offset_bottom = 144.0
mouse_filter = 1 mouse_filter = 1
script = ExtResource("5_cluster") script = ExtResource("5_cluster")
profile = ExtResource("4_profile") profile = ExtResource("4_profile")
desktop_reference_size = Vector2(720, 100) desktop_reference_size = Vector2(840, 100)
compact_reference_size = Vector2(720, 100) compact_reference_size = Vector2(840, 100)
[node name="InventoryTab" type="Button" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/NavigationCluster"] [node name="InventoryTab" type="Button" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/NavigationCluster"]
unique_name_in_owner = true unique_name_in_owner = true
@ -1224,8 +1233,8 @@ text = "cooler"
script = ExtResource("6_bubble") script = ExtResource("6_bubble")
profile = ExtResource("4_profile") profile = ExtResource("4_profile")
neutral_size = Vector2(124, 120) neutral_size = Vector2(124, 120)
desktop_anchor = Vector2(116, 60) desktop_anchor = Vector2(92, 60)
compact_anchor = Vector2(118, 73.3333) compact_anchor = Vector2(92, 73.3333)
compact_minimum_size = Vector2(84, 82) compact_minimum_size = Vector2(84, 82)
minimum_font_size = 25 minimum_font_size = 25
maximum_font_size = 25 maximum_font_size = 25
@ -1244,8 +1253,8 @@ text = "bag"
script = ExtResource("6_bubble") script = ExtResource("6_bubble")
profile = ExtResource("4_profile") profile = ExtResource("4_profile")
neutral_size = Vector2(124, 120) neutral_size = Vector2(124, 120)
desktop_anchor = Vector2(244, 60) desktop_anchor = Vector2(220, 60)
compact_anchor = Vector2(251.333, 73.3333) compact_anchor = Vector2(220, 73.3333)
compact_minimum_size = Vector2(84, 82) compact_minimum_size = Vector2(84, 82)
minimum_font_size = 25 minimum_font_size = 25
maximum_font_size = 25 maximum_font_size = 25
@ -1264,8 +1273,8 @@ text = "logbook"
script = ExtResource("6_bubble") script = ExtResource("6_bubble")
profile = ExtResource("4_profile") profile = ExtResource("4_profile")
neutral_size = Vector2(124, 120) neutral_size = Vector2(124, 120)
desktop_anchor = Vector2(372, 60) desktop_anchor = Vector2(348, 60)
compact_anchor = Vector2(384.667, 73.3333) compact_anchor = Vector2(348, 73.3333)
compact_minimum_size = Vector2(84, 82) compact_minimum_size = Vector2(84, 82)
minimum_font_size = 25 minimum_font_size = 25
maximum_font_size = 25 maximum_font_size = 25
@ -1284,8 +1293,8 @@ text = "mail"
script = ExtResource("6_bubble") script = ExtResource("6_bubble")
profile = ExtResource("4_profile") profile = ExtResource("4_profile")
neutral_size = Vector2(124, 120) neutral_size = Vector2(124, 120)
desktop_anchor = Vector2(500, 60) desktop_anchor = Vector2(476, 60)
compact_anchor = Vector2(501, 73.3333) compact_anchor = Vector2(476, 73.3333)
compact_minimum_size = Vector2(84, 82) compact_minimum_size = Vector2(84, 82)
minimum_font_size = 25 minimum_font_size = 25
maximum_font_size = 25 maximum_font_size = 25
@ -1312,6 +1321,26 @@ text = "1"
horizontal_alignment = 1 horizontal_alignment = 1
vertical_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"] [node name="CloseButton" type="Button" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/NavigationCluster"]
unique_name_in_owner = true unique_name_in_owner = true
layout_mode = 0 layout_mode = 0
@ -1319,8 +1348,8 @@ text = "close"
script = ExtResource("6_bubble") script = ExtResource("6_bubble")
profile = ExtResource("4_profile") profile = ExtResource("4_profile")
neutral_size = Vector2(96, 94) neutral_size = Vector2(96, 94)
desktop_anchor = Vector2(626, 57) desktop_anchor = Vector2(744, 57)
compact_anchor = Vector2(616, 73.3333) compact_anchor = Vector2(744, 73.3333)
compact_minimum_size = Vector2(76, 74) compact_minimum_size = Vector2(76, 74)
minimum_font_size = 23 minimum_font_size = 23
maximum_font_size = 23 maximum_font_size = 23

414
ui/profile_page.gd Normal file
View file

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

1
ui/profile_page.gd.uid Normal file
View file

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

8
ui/profile_page.tscn Normal file
View file

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

54
ui/profile_preview.gd Normal file
View file

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

View file

@ -0,0 +1 @@
uid://3iu5mxqgsjrx

57
ui/profile_preview.tscn Normal file
View file

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

View file

@ -38,7 +38,6 @@ enum PresentationMode {
@onready var _controls_page: SettingsBubblePage = %ControlsPage @onready var _controls_page: SettingsBubblePage = %ControlsPage
@onready var _accessibility_page: SettingsBubblePage = %AccessibilityPage @onready var _accessibility_page: SettingsBubblePage = %AccessibilityPage
@onready var _feedback: Label = %SettingsFeedback @onready var _feedback: Label = %SettingsFeedback
@onready var _display_name_edit: LineEdit = %DisplayNameEdit
@onready var _world_value: BubbleButton = %WorldValue @onready var _world_value: BubbleButton = %WorldValue
@onready var _ui_value: BubbleButton = %UIValue @onready var _ui_value: BubbleButton = %UIValue
@ -101,10 +100,6 @@ func _ready() -> void:
%DisplayBackButton.gui_input.connect(_on_back_bubble_gui_input) %DisplayBackButton.gui_input.connect(_on_back_bubble_gui_input)
%ControlsBackButton.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) %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(): for index: int in _world_options.size():
_world_options[index].pressed.connect( _world_options[index].pressed.connect(
_set_world_pixelation.bind(index + 1) _set_world_pixelation.bind(index + 1)
@ -156,25 +151,6 @@ func setup_network_profile(
) -> void: ) -> void:
_network_profile = profile _network_profile = profile
_network_session = session _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 124 plain-text characters."
func open_panel( func open_panel(
@ -192,8 +168,6 @@ func open_panel(
) )
_settings_manager = settings_manager _settings_manager = settings_manager
_load_controls() _load_controls()
if _network_profile != null:
_display_name_edit.text = _network_profile.display_name
_feedback.text = "" _feedback.text = ""
show() show()
_page_stack.clear() _page_stack.clear()

View file

@ -144,31 +144,6 @@ minimum_font_size = 14
maximum_font_size = 17 maximum_font_size = 17
motion_phase = 4.8 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="."] [node name="DisplayPage" type="Control" parent="."]
unique_name_in_owner = true unique_name_in_owner = true
visible = false visible = false