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

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

View file

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

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="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
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 _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 124 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()

View file

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