821 lines
27 KiB
GDScript
821 lines
27 KiB
GDScript
class_name ProfilePage
|
||
extends Control
|
||
|
||
const CHECK_DEBOUNCE_SECONDS: float = 0.4
|
||
const ControllerMappingManagerType = preload(
|
||
"res://settings/controller_mapping_manager.gd"
|
||
)
|
||
|
||
var _service: NetworkProfileService
|
||
var _experience: PlayerExperience
|
||
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 _keep_editing_button: Button
|
||
var _confirmation_action: String = ""
|
||
var _debounce: Timer
|
||
var _experience_level: Label
|
||
var _experience_progress: ProgressBar
|
||
var _experience_value: Label
|
||
var _feature_preview_cache: Dictionary = {}
|
||
var _scale_value_label: Label
|
||
|
||
|
||
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,
|
||
experience: PlayerExperience,
|
||
world_environment: WorldEnvironment,
|
||
world_sun: DirectionalLight3D,
|
||
) -> void:
|
||
_service = service
|
||
_experience = experience
|
||
if not _service.conflict_result.is_connected(_on_conflict_result):
|
||
_service.conflict_result.connect(_on_conflict_result)
|
||
_service.apply_finished.connect(_on_apply_finished)
|
||
if (
|
||
_experience != null
|
||
and not _experience.experience_changed.is_connected(
|
||
_on_experience_changed
|
||
)
|
||
):
|
||
_experience.experience_changed.connect(_on_experience_changed)
|
||
_load_persisted()
|
||
_refresh_experience()
|
||
_preview.setup_world_lighting(world_environment, world_sun)
|
||
var identity_value := find_child("IdentityFingerprint", true, false) as Label
|
||
if identity_value != null:
|
||
var fingerprint := _service.get_identity_fingerprint()
|
||
identity_value.text = "Identity • %s • Stored on this device" % (
|
||
NetworkIdentityCrypto.compact_suffix(fingerprint)
|
||
)
|
||
identity_value.tooltip_text = (
|
||
NetworkIdentityCrypto.format_fingerprint(fingerprint)
|
||
)
|
||
|
||
|
||
func setup_controller_mapping(
|
||
mapping_manager: ControllerMappingManagerType,
|
||
) -> void:
|
||
if _preview != null:
|
||
_preview.setup_controller_mapping(mapping_manager)
|
||
|
||
|
||
func set_world_pixel_size(pixel_size: int) -> void:
|
||
if _preview != null:
|
||
_preview.set_world_pixel_size(pixel_size)
|
||
|
||
|
||
func activate() -> void:
|
||
visible = true
|
||
UtilityPageStyle.animate_in(self)
|
||
if _service != null:
|
||
_load_persisted()
|
||
# Keep controller page switches on the Profile navigation bubble. Focusing
|
||
# the name field here summons the Android keyboard during LB/RB traversal.
|
||
|
||
|
||
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 has_modal_confirmation() -> bool:
|
||
return (
|
||
_discard_confirmation != null
|
||
and _discard_confirmation.visible
|
||
)
|
||
|
||
|
||
func _build_ui() -> void:
|
||
UtilityPageStyle.apply_page(self)
|
||
var margin: MarginContainer = UtilityPageStyle.build_laptop_screen(self)
|
||
var layout := VBoxContainer.new()
|
||
layout.add_theme_constant_override("separation", 8)
|
||
margin.add_child(layout)
|
||
|
||
var account_row := HBoxContainer.new()
|
||
account_row.add_theme_constant_override("separation", 14)
|
||
layout.add_child(account_row)
|
||
var account_stack := VBoxContainer.new()
|
||
account_stack.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||
account_stack.add_theme_constant_override("separation", 2)
|
||
account_row.add_child(account_stack)
|
||
var name_line := HBoxContainer.new()
|
||
name_line.add_theme_constant_override("separation", 10)
|
||
account_stack.add_child(name_line)
|
||
var name_label := Label.new()
|
||
name_label.text = "player name"
|
||
name_label.custom_minimum_size.x = 98.0
|
||
name_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
||
name_label.add_theme_color_override(
|
||
"font_color", UtilityPageStyle.OCEAN_TEXT_PRIMARY
|
||
)
|
||
name_line.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(280, 34)
|
||
UtilityPageStyle.apply_ocean_line_edit(_name_edit)
|
||
_name_edit.add_theme_stylebox_override(
|
||
"normal", UtilityPageStyle.ocean_button_style(
|
||
UtilityPageStyle.OCEAN_PANEL_MID
|
||
)
|
||
)
|
||
_name_edit.text_changed.connect(_on_name_changed)
|
||
name_line.add_child(_name_edit)
|
||
var helper := Label.new()
|
||
helper.text = "Shown to other players in multiplayer."
|
||
helper.add_theme_font_size_override("font_size", 12)
|
||
helper.add_theme_color_override(
|
||
"font_color", UtilityPageStyle.OCEAN_TEXT_SECONDARY
|
||
)
|
||
account_stack.add_child(helper)
|
||
_name_status = Label.new()
|
||
_name_status.add_theme_font_size_override("font_size", 12)
|
||
_name_status.add_theme_color_override(
|
||
"font_color", UtilityPageStyle.OCEAN_TEXT_SECONDARY
|
||
)
|
||
account_stack.add_child(_name_status)
|
||
_suggestions = HBoxContainer.new()
|
||
_suggestions.add_theme_constant_override("separation", 6)
|
||
account_stack.add_child(_suggestions)
|
||
|
||
var identity_value := Label.new()
|
||
identity_value.name = "IdentityFingerprint"
|
||
identity_value.text = "identity • stored on this device"
|
||
identity_value.tooltip_text = (
|
||
"This identity helps other players recognize you between sessions."
|
||
)
|
||
identity_value.add_theme_font_size_override("font_size", 12)
|
||
identity_value.add_theme_color_override(
|
||
"font_color", UtilityPageStyle.OCEAN_TEXT_SECONDARY
|
||
)
|
||
account_stack.add_child(identity_value)
|
||
|
||
var experience_row := HBoxContainer.new()
|
||
experience_row.add_theme_constant_override("separation", 9)
|
||
account_stack.add_child(experience_row)
|
||
_experience_level = Label.new()
|
||
_experience_level.custom_minimum_size.x = 64.0
|
||
_experience_level.add_theme_font_size_override("font_size", 14)
|
||
_experience_level.add_theme_color_override(
|
||
"font_color", UtilityPageStyle.OCEAN_TEXT_PRIMARY
|
||
)
|
||
experience_row.add_child(_experience_level)
|
||
_experience_progress = ProgressBar.new()
|
||
_experience_progress.custom_minimum_size = Vector2(210.0, 14.0)
|
||
_experience_progress.max_value = 1.0
|
||
_experience_progress.show_percentage = false
|
||
_experience_progress.add_theme_stylebox_override(
|
||
"background", UtilityPageStyle.rounded_style(
|
||
UtilityPageStyle.OCEAN_PANEL_MID, 7
|
||
)
|
||
)
|
||
_experience_progress.add_theme_stylebox_override(
|
||
"fill", UtilityPageStyle.rounded_style(
|
||
UtilityPageStyle.OCEAN_SELECTED, 7
|
||
)
|
||
)
|
||
experience_row.add_child(_experience_progress)
|
||
_experience_value = Label.new()
|
||
_experience_value.add_theme_font_size_override("font_size", 14)
|
||
_experience_value.add_theme_color_override(
|
||
"font_color", UtilityPageStyle.OCEAN_TEXT_SECONDARY
|
||
)
|
||
experience_row.add_child(_experience_value)
|
||
|
||
var actions := HBoxContainer.new()
|
||
actions.alignment = BoxContainer.ALIGNMENT_END
|
||
actions.size_flags_vertical = Control.SIZE_SHRINK_BEGIN
|
||
actions.add_theme_constant_override("separation", 7)
|
||
account_row.add_child(actions)
|
||
_apply_button = Button.new()
|
||
_apply_button.text = "apply"
|
||
_apply_button.custom_minimum_size.x = 72.0
|
||
UtilityPageStyle.apply_compact_ocean_button(_apply_button)
|
||
_apply_button.pressed.connect(_apply)
|
||
actions.add_child(_apply_button)
|
||
_revert_button = Button.new()
|
||
_revert_button.text = "revert"
|
||
_revert_button.custom_minimum_size.x = 76.0
|
||
UtilityPageStyle.apply_compact_ocean_button(_revert_button)
|
||
_revert_button.pressed.connect(_revert)
|
||
actions.add_child(_revert_button)
|
||
var defaults_button := Button.new()
|
||
defaults_button.text = "defaults"
|
||
defaults_button.custom_minimum_size.x = 82.0
|
||
UtilityPageStyle.apply_compact_ocean_button(defaults_button)
|
||
defaults_button.pressed.connect(_show_confirmation.bind("defaults"))
|
||
actions.add_child(defaults_button)
|
||
|
||
var body_panel := PanelContainer.new()
|
||
body_panel.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
||
body_panel.add_theme_stylebox_override(
|
||
"panel", UtilityPageStyle.row_style(false)
|
||
)
|
||
layout.add_child(body_panel)
|
||
var body_margin := MarginContainer.new()
|
||
body_margin.add_theme_constant_override("margin_left", 14)
|
||
body_margin.add_theme_constant_override("margin_top", 10)
|
||
body_margin.add_theme_constant_override("margin_right", 14)
|
||
body_margin.add_theme_constant_override("margin_bottom", 10)
|
||
body_panel.add_child(body_margin)
|
||
var body := HBoxContainer.new()
|
||
body.alignment = BoxContainer.ALIGNMENT_BEGIN
|
||
body.add_theme_constant_override("separation", 16)
|
||
body_margin.add_child(body)
|
||
_category_list = VBoxContainer.new()
|
||
_category_list.custom_minimum_size = Vector2(120, 0)
|
||
_category_list.add_theme_constant_override("separation", 5)
|
||
body.add_child(_category_list)
|
||
_option_list = VBoxContainer.new()
|
||
_option_list.custom_minimum_size = Vector2(360, 0)
|
||
_option_list.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||
_option_list.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
||
_option_list.add_theme_constant_override("separation", 5)
|
||
body.add_child(_option_list)
|
||
var body_spacer := Control.new()
|
||
body_spacer.custom_minimum_size.x = 12.0
|
||
body_spacer.size_flags_horizontal = Control.SIZE_SHRINK_CENTER
|
||
body.add_child(body_spacer)
|
||
var preview_stack := VBoxContainer.new()
|
||
preview_stack.custom_minimum_size.x = 260.0
|
||
preview_stack.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
||
preview_stack.alignment = BoxContainer.ALIGNMENT_CENTER
|
||
preview_stack.add_theme_constant_override("separation", 7)
|
||
body.add_child(preview_stack)
|
||
var preview_frame := PanelContainer.new()
|
||
preview_frame.custom_minimum_size = Vector2(260.0, 0.0)
|
||
preview_frame.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
||
preview_frame.add_theme_stylebox_override(
|
||
"panel", UtilityPageStyle.rounded_style(
|
||
UtilityPageStyle.OCEAN_FIELD, 18
|
||
)
|
||
)
|
||
preview_stack.add_child(preview_frame)
|
||
var preview_layer := Control.new()
|
||
preview_layer.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||
preview_layer.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
||
preview_layer.mouse_filter = Control.MOUSE_FILTER_PASS
|
||
preview_frame.add_child(preview_layer)
|
||
_preview = preload("res://ui/profile_preview.tscn").instantiate()
|
||
_preview.custom_minimum_size = Vector2(260.0, 0.0)
|
||
_preview.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||
_preview.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
||
preview_layer.add_child(_preview)
|
||
_preview.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||
var reset_view := Button.new()
|
||
reset_view.text = "↶"
|
||
reset_view.tooltip_text = "reset view"
|
||
reset_view.custom_minimum_size = Vector2(36.0, 36.0)
|
||
reset_view.position = Vector2(-8.0, -8.0)
|
||
reset_view.z_index = 2
|
||
reset_view.pressed.connect(_preview.reset_view)
|
||
UtilityPageStyle.apply_compact_ocean_button(reset_view)
|
||
reset_view.add_theme_font_size_override("font_size", 22)
|
||
preview_layer.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)
|
||
_discard_confirmation.add_theme_stylebox_override(
|
||
"panel", UtilityPageStyle.rounded_style(
|
||
UtilityPageStyle.OCEAN_PANEL_MID, 14
|
||
)
|
||
)
|
||
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)
|
||
UtilityPageStyle.apply_ocean_button(_confirmation_confirm)
|
||
confirm_buttons.add_child(_confirmation_confirm)
|
||
_keep_editing_button = Button.new()
|
||
_keep_editing_button.text = "keep editing"
|
||
_keep_editing_button.pressed.connect(func() -> void:
|
||
_discard_confirmation.visible = false
|
||
_name_edit.grab_focus()
|
||
)
|
||
UtilityPageStyle.apply_ocean_button(_keep_editing_button)
|
||
confirm_buttons.add_child(_keep_editing_button)
|
||
|
||
_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.x = 120.0
|
||
button.button_pressed = category_id == _category_id
|
||
button.pressed.connect(_select_category.bind(category_id))
|
||
UtilityPageStyle.apply_compact_ocean_button(button)
|
||
_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", 18)
|
||
title.add_theme_color_override(
|
||
"font_color", UtilityPageStyle.OCEAN_TEXT_PRIMARY
|
||
)
|
||
_option_list.add_child(title)
|
||
if _category_id == CharacterCustomizationCatalog.SCALE_CATEGORY_ID:
|
||
_build_scale_option()
|
||
return
|
||
var options: Array = 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
|
||
if _category_id == "fur_pattern":
|
||
_build_fur_color_options(options)
|
||
return
|
||
if _category_id in CharacterCustomizationCatalog.FEATURE_CATEGORIES:
|
||
_build_feature_preview_options(options)
|
||
return
|
||
for option: Dictionary in options:
|
||
var option_id := str(option["id"])
|
||
var button := Button.new()
|
||
button.text = "×" if option_id == "none" else str(option["label"])
|
||
button.tooltip_text = "none" if option_id == "none" else str(
|
||
option["label"]
|
||
)
|
||
button.toggle_mode = true
|
||
button.custom_minimum_size.x = 170.0
|
||
button.button_pressed = _draft_appearance.get(_category_id) == option_id
|
||
button.pressed.connect(_select_option.bind(_category_id, option_id))
|
||
UtilityPageStyle.apply_compact_ocean_button(button)
|
||
if option_id == "none":
|
||
button.add_theme_font_size_override("font_size", 24)
|
||
_option_list.add_child(button)
|
||
|
||
|
||
func _build_scale_option() -> void:
|
||
var description := Label.new()
|
||
description.text = "Adjust your character's visual size."
|
||
description.add_theme_font_size_override("font_size", 14)
|
||
description.add_theme_color_override(
|
||
"font_color", UtilityPageStyle.OCEAN_TEXT_SECONDARY
|
||
)
|
||
_option_list.add_child(description)
|
||
|
||
var value_row := HBoxContainer.new()
|
||
value_row.add_theme_constant_override("separation", 10)
|
||
value_row.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||
_option_list.add_child(value_row)
|
||
|
||
var small_label := Label.new()
|
||
small_label.text = "small"
|
||
small_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
||
small_label.add_theme_color_override(
|
||
"font_color", UtilityPageStyle.OCEAN_TEXT_SECONDARY
|
||
)
|
||
value_row.add_child(small_label)
|
||
|
||
var slider := HSlider.new()
|
||
slider.name = "CharacterScaleSlider"
|
||
slider.min_value = CharacterCustomizationCatalog.MIN_CHARACTER_SCALE
|
||
slider.max_value = CharacterCustomizationCatalog.MAX_CHARACTER_SCALE
|
||
slider.step = CharacterCustomizationCatalog.CHARACTER_SCALE_STEP
|
||
slider.custom_minimum_size = Vector2(220.0, 40.0)
|
||
slider.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||
slider.tooltip_text = "character size"
|
||
slider.value = CharacterCustomizationCatalog.character_scale(
|
||
_draft_appearance.get(
|
||
CharacterCustomizationCatalog.SCALE_CATEGORY_ID,
|
||
CharacterCustomizationCatalog.DEFAULT_CHARACTER_SCALE,
|
||
)
|
||
)
|
||
slider.value_changed.connect(_select_scale)
|
||
value_row.add_child(slider)
|
||
|
||
var large_label := Label.new()
|
||
large_label.text = "large"
|
||
large_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
||
large_label.add_theme_color_override(
|
||
"font_color", UtilityPageStyle.OCEAN_TEXT_SECONDARY
|
||
)
|
||
value_row.add_child(large_label)
|
||
|
||
_scale_value_label = Label.new()
|
||
_scale_value_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||
_scale_value_label.add_theme_font_size_override("font_size", 20)
|
||
_scale_value_label.add_theme_color_override(
|
||
"font_color", UtilityPageStyle.OCEAN_TEXT_PRIMARY
|
||
)
|
||
_option_list.add_child(_scale_value_label)
|
||
_update_scale_value_label(float(slider.value))
|
||
|
||
var gameplay_note := Label.new()
|
||
gameplay_note.text = "Movement and collision stay the same."
|
||
gameplay_note.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||
gameplay_note.add_theme_font_size_override("font_size", 12)
|
||
gameplay_note.add_theme_color_override(
|
||
"font_color", UtilityPageStyle.OCEAN_TEXT_SECONDARY
|
||
)
|
||
_option_list.add_child(gameplay_note)
|
||
|
||
|
||
func _build_feature_preview_options(options: Array) -> void:
|
||
var scroll := ScrollContainer.new()
|
||
scroll.custom_minimum_size = Vector2(190.0, 0.0)
|
||
scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
||
scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
|
||
scroll.vertical_scroll_mode = ScrollContainer.SCROLL_MODE_AUTO
|
||
_option_list.add_child(scroll)
|
||
var grid := GridContainer.new()
|
||
grid.columns = 4
|
||
grid.add_theme_constant_override("h_separation", 8)
|
||
grid.add_theme_constant_override("v_separation", 8)
|
||
grid.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||
scroll.add_child(grid)
|
||
var selected_id := CharacterCustomizationCatalog.canonical_option_id(
|
||
_category_id, str(_draft_appearance.get(_category_id, ""))
|
||
)
|
||
for option: Dictionary in options:
|
||
var option_id := str(option.get("id", ""))
|
||
var button := Button.new()
|
||
var is_none := option_id == "none"
|
||
button.text = "×" if is_none else ""
|
||
button.tooltip_text = "none" if is_none else str(
|
||
option.get("label", option_id)
|
||
)
|
||
button.toggle_mode = true
|
||
button.button_pressed = selected_id == option_id
|
||
button.custom_minimum_size = Vector2(84.0, 64.0)
|
||
button.alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||
button.clip_contents = true
|
||
button.pressed.connect(_select_option.bind(_category_id, option_id))
|
||
UtilityPageStyle.apply_compact_ocean_button(button)
|
||
button.custom_minimum_size = Vector2(84.0, 64.0)
|
||
if is_none:
|
||
button.add_theme_font_size_override("font_size", 28)
|
||
else:
|
||
var preview := TextureRect.new()
|
||
preview.texture = _feature_preview_texture(
|
||
_category_id, option_id
|
||
)
|
||
preview.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
|
||
preview.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
|
||
preview.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
var preview_height := 50.0
|
||
var preview_width := clampf(
|
||
preview_height * PlayerVisualPresenter.feature_uv_aspect(
|
||
_category_id
|
||
),
|
||
32.0,
|
||
74.0,
|
||
)
|
||
preview.custom_minimum_size = Vector2(preview_width, preview_height)
|
||
preview.size = Vector2(preview_width, preview_height)
|
||
preview.position = Vector2((84.0 - preview_width) * 0.5, 7.0)
|
||
button.add_child(preview)
|
||
grid.add_child(button)
|
||
|
||
|
||
func _feature_preview_texture(
|
||
category_id: String,
|
||
option_id: String,
|
||
) -> Texture2D:
|
||
var cache_key := "%s:%s" % [category_id, option_id]
|
||
if _feature_preview_cache.has(cache_key):
|
||
return _feature_preview_cache[cache_key] as Texture2D
|
||
var source := CharacterCustomizationCatalog.texture_for(
|
||
category_id, option_id
|
||
)
|
||
if source == null:
|
||
return null
|
||
var image := source.get_image()
|
||
var used := image.get_used_rect()
|
||
if used.size.x <= 0 or used.size.y <= 0:
|
||
_feature_preview_cache[cache_key] = source
|
||
return source
|
||
var padding := 12
|
||
used.position -= Vector2i(padding, padding)
|
||
used.size += Vector2i(padding * 2, padding * 2)
|
||
used = used.intersection(Rect2i(Vector2i.ZERO, image.get_size()))
|
||
var cropped := image.get_region(used)
|
||
var preview := ImageTexture.create_from_image(cropped)
|
||
_feature_preview_cache[cache_key] = preview
|
||
return preview
|
||
|
||
|
||
func _build_fur_color_options(options: Array) -> void:
|
||
var grid := GridContainer.new()
|
||
grid.columns = 4
|
||
grid.add_theme_constant_override("h_separation", 10)
|
||
grid.add_theme_constant_override("v_separation", 10)
|
||
_option_list.add_child(grid)
|
||
var selected_id: String = CharacterCustomizationCatalog.canonical_option_id(
|
||
"fur_pattern", str(_draft_appearance.get("fur_pattern", "white"))
|
||
)
|
||
for option: Dictionary in options:
|
||
var option_id: String = str(option.get("id", ""))
|
||
var color: Color = CharacterCustomizationCatalog.option_color(
|
||
"fur_pattern", option_id
|
||
)
|
||
var button := Button.new()
|
||
button.text = ""
|
||
button.tooltip_text = str(option.get("label", option_id))
|
||
button.toggle_mode = true
|
||
button.custom_minimum_size = Vector2(44.0, 44.0)
|
||
button.button_pressed = selected_id == option_id
|
||
button.pressed.connect(_select_option.bind("fur_pattern", option_id))
|
||
_apply_fur_swatch_style(button, color, button.button_pressed)
|
||
grid.add_child(button)
|
||
|
||
|
||
func _apply_fur_swatch_style(
|
||
button: Button,
|
||
color: Color,
|
||
selected: bool,
|
||
) -> void:
|
||
var normal: StyleBoxFlat = UtilityPageStyle.rounded_style(color, 999)
|
||
var hover: StyleBoxFlat = UtilityPageStyle.rounded_style(
|
||
color.lightened(0.12), 999
|
||
)
|
||
var selected_style: StyleBoxFlat = UtilityPageStyle.rounded_style(color, 999)
|
||
selected_style.border_color = UtilityPageStyle.OCEAN_TEXT_PRIMARY
|
||
selected_style.set_border_width_all(3 if selected else 2)
|
||
button.add_theme_stylebox_override("normal", normal)
|
||
button.add_theme_stylebox_override("hover", hover)
|
||
button.add_theme_stylebox_override("pressed", selected_style)
|
||
button.add_theme_stylebox_override("focus", selected_style)
|
||
|
||
|
||
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 _select_scale(value: float) -> void:
|
||
var resolved_scale: float = CharacterCustomizationCatalog.character_scale(
|
||
value
|
||
)
|
||
_draft_appearance[CharacterCustomizationCatalog.SCALE_CATEGORY_ID] = (
|
||
resolved_scale
|
||
)
|
||
_update_scale_value_label(resolved_scale)
|
||
_preview.apply_appearance_profile(_draft_appearance)
|
||
_dirty = _draft_differs()
|
||
_refresh_actions()
|
||
|
||
|
||
func _update_scale_value_label(value: float) -> void:
|
||
if _scale_value_label != null:
|
||
_scale_value_label.text = "%d%%" % (
|
||
CharacterCustomizationCatalog.character_scale_percent(value)
|
||
)
|
||
|
||
|
||
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))
|
||
UtilityPageStyle.apply_compact_ocean_button(button)
|
||
_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."
|
||
)
|
||
UtilityPageStyle.apply_compact_ocean_button(anyway)
|
||
_suggestions.add_child(anyway)
|
||
|
||
|
||
func _on_experience_changed(_total_experience: int, _level: int) -> void:
|
||
_refresh_experience()
|
||
|
||
|
||
func _refresh_experience() -> void:
|
||
if _experience_level == null:
|
||
return
|
||
if _experience == null:
|
||
_experience_level.text = "level 1"
|
||
_experience_progress.value = 0.0
|
||
_experience_value.text = "0 / 100 xp"
|
||
return
|
||
var level: int = _experience.get_level()
|
||
var current: int = _experience.get_experience_in_level()
|
||
var required: int = _experience.get_experience_for_next_level()
|
||
_experience_level.text = "level %d" % level
|
||
_experience_progress.value = _experience.get_level_progress()
|
||
_experience_value.text = "%d / %d xp" % [current, required]
|
||
_experience_value.tooltip_text = "%d total xp" % (
|
||
_experience.get_total_experience()
|
||
)
|
||
|
||
|
||
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"
|
||
_keep_editing_button.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()
|