class_name ProfilePage extends Control const CHECK_DEBOUNCE_SECONDS: float = 0.4 const OPTION_GRID_COLUMNS: int = 6 const FEATURE_DRAWER_ANIMATION_SECONDS: float = 0.16 const ControllerMappingManagerType = preload( "res://settings/controller_mapping_manager.gd" ) const AnimaleseVoiceType = preload("res://ui/animalese_voice.gd") const TypewriterRevealType = preload("res://ui/typewriter_reveal.gd") const VoiceProfilesType = preload( "res://player/animalese_voice_profiles.gd" ) const VOICE_CATEGORY_ID: String = "voice" var _service: NetworkProfileService var _experience: PlayerExperience var _draft_name: String = "" var _draft_appearance: Dictionary = {} var _persisted_name: String = "" var _persisted_appearance: Dictionary = {} var _draft_voice_id: String = VoiceProfilesType.DEFAULT_ID var _persisted_voice_id: String = VoiceProfilesType.DEFAULT_ID var _draft_speech_speed_id: String = VoiceProfilesType.DEFAULT_SPEED_ID var _persisted_speech_speed_id: String = VoiceProfilesType.DEFAULT_SPEED_ID var _draft_call_id: String = VoiceProfilesType.DEFAULT_CALL_ID var _persisted_call_id: String = VoiceProfilesType.DEFAULT_CALL_ID 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 _expanded_feature_drawers: Dictionary = {} var _feature_drawer_animation_key: String = "" var _scale_value_label: Label var _voice_preview: AnimaleseVoiceType var _voice_preview_tween: Tween 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() if _voice_preview_tween != null and _voice_preview_tween.is_valid(): _voice_preview_tween.kill() _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) _voice_preview = AnimaleseVoiceType.new() _voice_preview.name = "ProfileVoicePreview" add_child(_voice_preview) _build_categories() func _build_categories() -> void: for child: Node in _category_list.get_children(): child.queue_free() var category_ids: Array[String] = [] for appearance_category: String in CharacterCustomizationCatalog.CATEGORY_IDS: category_ids.append(appearance_category) category_ids.append(VOICE_CATEGORY_ID) for category_id: String in category_ids: var button := Button.new() button.text = _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 == ( _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 = _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 == VOICE_CATEGORY_ID: _build_voice_options() return 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 _category_label(category_id: String) -> String: return ( "voice" if category_id == VOICE_CATEGORY_ID else CharacterCustomizationCatalog.category_label(category_id) ) func _build_voice_options() -> void: var description := Label.new() description.text = "Choose how your character sounds in chat." description.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART description.size_flags_horizontal = Control.SIZE_EXPAND_FILL description.add_theme_font_size_override("font_size", 12) description.add_theme_color_override( "font_color", UtilityPageStyle.OCEAN_TEXT_SECONDARY ) _option_list.add_child(description) var grid := GridContainer.new() grid.columns = 3 grid.add_theme_constant_override("h_separation", 6) grid.add_theme_constant_override("v_separation", 4) grid.size_flags_horizontal = Control.SIZE_EXPAND_FILL _option_list.add_child(grid) for option: Dictionary in VoiceProfilesType.OPTIONS: var option_id := str(option.get("id", "")) var button := Button.new() button.text = str(option.get("label", option_id)) button.toggle_mode = true button.custom_minimum_size = Vector2(108.0, 32.0) button.size_flags_horizontal = Control.SIZE_EXPAND_FILL button.button_pressed = _draft_voice_id == option_id button.pressed.connect(_select_voice_option.bind(option_id)) UtilityPageStyle.apply_compact_ocean_button(button) grid.add_child(button) var divider := HSeparator.new() divider.custom_minimum_size.y = 2.0 _option_list.add_child(divider) var speed_title := Label.new() speed_title.text = "speech speed" speed_title.add_theme_font_size_override("font_size", 13) speed_title.add_theme_color_override( "font_color", UtilityPageStyle.OCEAN_TEXT_PRIMARY ) _option_list.add_child(speed_title) var speed_description := Label.new() speed_description.text = "Controls chat speech on this device only." speed_description.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART speed_description.size_flags_horizontal = Control.SIZE_EXPAND_FILL speed_description.add_theme_font_size_override("font_size", 12) speed_description.add_theme_color_override( "font_color", UtilityPageStyle.OCEAN_TEXT_SECONDARY ) _option_list.add_child(speed_description) var speed_grid := GridContainer.new() speed_grid.columns = 3 speed_grid.add_theme_constant_override("h_separation", 6) speed_grid.add_theme_constant_override("v_separation", 4) speed_grid.size_flags_horizontal = Control.SIZE_EXPAND_FILL _option_list.add_child(speed_grid) for option: Dictionary in VoiceProfilesType.SPEED_OPTIONS: var speed_id := str(option.get("id", "")) var speed_button := Button.new() speed_button.text = str(option.get("label", speed_id)) speed_button.tooltip_text = "%d characters per second" % roundi( float(option.get("characters_per_second", 28.0)) ) speed_button.toggle_mode = true speed_button.custom_minimum_size = Vector2(108.0, 32.0) speed_button.size_flags_horizontal = Control.SIZE_EXPAND_FILL speed_button.button_pressed = _draft_speech_speed_id == speed_id speed_button.pressed.connect( _select_speech_speed_option.bind(speed_id) ) UtilityPageStyle.apply_compact_ocean_button(speed_button) speed_grid.add_child(speed_button) var call_divider := HSeparator.new() call_divider.custom_minimum_size.y = 2.0 _option_list.add_child(call_divider) var call_title := Label.new() call_title.text = "call" call_title.add_theme_font_size_override("font_size", 13) call_title.add_theme_color_override( "font_color", UtilityPageStyle.OCEAN_TEXT_PRIMARY ) _option_list.add_child(call_title) var call_description := Label.new() call_description.text = "Press G to make this sound." call_description.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART call_description.size_flags_horizontal = Control.SIZE_EXPAND_FILL call_description.add_theme_font_size_override("font_size", 12) call_description.add_theme_color_override( "font_color", UtilityPageStyle.OCEAN_TEXT_SECONDARY ) _option_list.add_child(call_description) var call_grid := GridContainer.new() call_grid.columns = 2 call_grid.add_theme_constant_override("h_separation", 8) call_grid.add_theme_constant_override("v_separation", 8) _option_list.add_child(call_grid) for option: Dictionary in VoiceProfilesType.CALL_OPTIONS: var call_id := str(option.get("id", "")) var call_button := Button.new() call_button.text = str(option.get("label", call_id)) call_button.toggle_mode = true call_button.custom_minimum_size = Vector2(108.0, 32.0) call_button.size_flags_horizontal = Control.SIZE_EXPAND_FILL call_button.button_pressed = _draft_call_id == call_id call_button.pressed.connect(_select_call_option.bind(call_id)) UtilityPageStyle.apply_compact_ocean_button(call_button) call_grid.add_child(call_button) func _select_voice_option(voice_id: String) -> void: if not VoiceProfilesType.is_valid(voice_id): return _draft_voice_id = voice_id _dirty = _draft_differs() _refresh_options() _refresh_actions() _play_voice_preview() func _select_speech_speed_option(speed_id: String) -> void: if not VoiceProfilesType.is_valid_speed(speed_id): return _draft_speech_speed_id = speed_id _dirty = _draft_differs() _refresh_options() _refresh_actions() _play_voice_preview() func _select_call_option(call_id: String) -> void: if not VoiceProfilesType.is_valid_call(call_id): return _draft_call_id = call_id _dirty = _draft_differs() _refresh_options() _refresh_actions() func _play_voice_preview() -> void: if _voice_preview_tween != null and _voice_preview_tween.is_valid(): _voice_preview_tween.kill() _voice_preview_tween = _voice_preview.speak_text( self, "hello there!", "profile-preview", _draft_voice_id, VoiceProfilesType.speed_for(_draft_speech_speed_id), ) 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 = OPTION_GRID_COLUMNS 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, "")) ) var groups: Array = CharacterCustomizationCatalog.feature_option_groups( _category_id ) var expanded_drawer_id := str( _expanded_feature_drawers.get(_category_id, "") ) if expanded_drawer_id.is_empty(): for group: Dictionary in groups: var group_options: Array = group.get("options", []) as Array if group_options.size() <= 1: continue var representative_id := str( (group_options[0] as Dictionary).get("id", "") ) for option: Dictionary in group_options: if ( str(option.get("id", "")) == selected_id and selected_id != representative_id ): expanded_drawer_id = str(group.get("id", "")) _expanded_feature_drawers[_category_id] = ( expanded_drawer_id ) break if not expanded_drawer_id.is_empty(): break var animation_key := _feature_drawer_animation_key for group: Dictionary in groups: var group_id := str(group.get("id", "")) var group_options: Array = group.get("options", []) as Array if group_options.is_empty(): continue var has_variants := group_options.size() > 1 var is_expanded := has_variants and group_id == expanded_drawer_id for option_index: int in range(group_options.size()): if option_index > 0 and not is_expanded: continue var option: Dictionary = group_options[option_index] as Dictionary var button := _build_feature_option_button( option, selected_id, group_id, option_index == 0, has_variants, is_expanded, ) grid.add_child(button) if ( option_index > 0 and animation_key == "%s:%s" % [_category_id, group_id] ): _animate_feature_drawer_button(button) _feature_drawer_animation_key = "" func _build_feature_option_button( option: Dictionary, selected_id: String, drawer_id: String, is_representative: bool, has_variants: bool, is_expanded: bool, ) -> Button: 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) ) if is_representative and has_variants: button.tooltip_text += ( " (hide variants)" if is_expanded else " (show variants)" ) 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 if is_representative and has_variants: button.pressed.connect( _select_feature_drawer.bind(_category_id, drawer_id, option_id) ) else: 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) if is_representative and has_variants: var indicator := Label.new() indicator.text = "<" if is_expanded else ">" indicator.position = Vector2(66.0, 1.0) indicator.size = Vector2(14.0, 16.0) indicator.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER indicator.mouse_filter = Control.MOUSE_FILTER_IGNORE indicator.add_theme_font_size_override("font_size", 13) indicator.add_theme_color_override( "font_color", UtilityPageStyle.OCEAN_TEXT_PRIMARY ) button.add_child(indicator) return button func _select_feature_drawer( category_id: String, drawer_id: String, option_id: String, ) -> void: if str(_expanded_feature_drawers.get(category_id, "")) == drawer_id: _expanded_feature_drawers.erase(category_id) else: _expanded_feature_drawers[category_id] = drawer_id _feature_drawer_animation_key = "%s:%s" % [category_id, drawer_id] _select_option(category_id, option_id) func _animate_feature_drawer_button(button: Button) -> void: button.scale = Vector2(0.05, 1.0) var faded := button.modulate faded.a = 0.0 button.modulate = faded var tween := button.create_tween() tween.set_parallel(true) tween.tween_property( button, "scale", Vector2.ONE, FEATURE_DRAWER_ANIMATION_SECONDS ).set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_OUT) tween.tween_property( button, "modulate:a", 1.0, FEATURE_DRAWER_ANIMATION_SECONDS ).set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_OUT) 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 = OPTION_GRID_COLUMNS 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, _draft_voice_id, _draft_speech_speed_id, _draft_call_id, ) 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() _persisted_voice_id = _service.get_persisted_voice_id() _persisted_speech_speed_id = ( _service.get_persisted_speech_speed_id() ) _persisted_call_id = _service.get_persisted_call_id() _draft_name = _persisted_name _draft_appearance = _persisted_appearance.duplicate(true) _draft_voice_id = _persisted_voice_id _draft_speech_speed_id = _persisted_speech_speed_id _draft_call_id = _persisted_call_id TypewriterRevealType.set_characters_per_second( VoiceProfilesType.speed_for(_persisted_speech_speed_id) ) _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() _draft_voice_id = VoiceProfilesType.DEFAULT_ID _draft_speech_speed_id = VoiceProfilesType.DEFAULT_SPEED_ID _draft_call_id = VoiceProfilesType.DEFAULT_CALL_ID _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 or _draft_voice_id != _persisted_voice_id or _draft_speech_speed_id != _persisted_speech_speed_id or _draft_call_id != _persisted_call_id ) 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()