Add player profile and appearance foundation
This commit is contained in:
parent
e53ad1ac38
commit
0b5b78a553
25 changed files with 1424 additions and 76 deletions
22
main/main.gd
22
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,
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
37
network/network_profile_protocol.gd
Normal file
37
network/network_profile_protocol.gd
Normal 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
|
||||
)
|
||||
1
network/network_profile_protocol.gd.uid
Normal file
1
network/network_profile_protocol.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://dhv483bfl4get
|
||||
402
network/network_profile_service.gd
Normal file
402
network/network_profile_service.gd
Normal 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()
|
||||
1
network/network_profile_service.gd.uid
Normal file
1
network/network_profile_service.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://dc80t6nmksvm2
|
||||
|
|
@ -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",
|
||||
]),
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
86
progression/character_customization_catalog.gd
Normal file
86
progression/character_customization_catalog.gd
Normal 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
|
||||
1
progression/character_customization_catalog.gd.uid
Normal file
1
progression/character_customization_catalog.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://c08tu8ntuqig3
|
||||
123
progression/player_appearance_store.gd
Normal file
123
progression/player_appearance_store.gd
Normal 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))
|
||||
1
progression/player_appearance_store.gd.uid
Normal file
1
progression/player_appearance_store.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://ci07gm3e7r8p6
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
414
ui/profile_page.gd
Normal file
414
ui/profile_page.gd
Normal 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 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()
|
||||
1
ui/profile_page.gd.uid
Normal file
1
ui/profile_page.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://b6c2de8orrwfj
|
||||
8
ui/profile_page.tscn
Normal file
8
ui/profile_page.tscn
Normal 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
54
ui/profile_preview.gd
Normal 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)
|
||||
1
ui/profile_preview.gd.uid
Normal file
1
ui/profile_preview.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://3iu5mxqgsjrx
|
||||
57
ui/profile_preview.tscn
Normal file
57
ui/profile_preview.tscn
Normal 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
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue