netfishing/ui/profile_page.gd
Voyager 84695c473b Expand per-part fur customization
Load authored pattern masks by body component, add custom fur colors and the themed picker UI, and advance the signed profile protocol to version 6.
2026-08-15 14:27:53 -04:00

1675 lines
58 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

class_name ProfilePage
extends Control
const CHECK_DEBOUNCE_SECONDS: float = 0.4
const OPTION_GRID_COLUMNS: int = 6
const FUR_PALETTE_GRID_COLUMNS: int = 10
const FUR_PATTERN_GRID_COLUMNS: int = 4
const FUR_PALETTE_SWATCH_SIZE: float = 38.0
const FUR_CHANNEL_SWATCH_SIZE: float = 22.0
const FUR_COLOR_PICKER_POPUP_SIZE: Vector2i = Vector2i(720, 560)
const FUR_COLOR_PICKER_SV_SIZE: Vector2i = Vector2i(520, 300)
const FUR_SECTION_PATTERNS: String = "patterns"
const FUR_SECTION_COLORS: String = "colors"
const FEATURE_DRAWER_ANIMATION_SECONDS: float = 0.16
const FEATURE_PREVIEW_SCAN_MAX_SIZE: int = 256
const FEATURE_PREVIEW_TEXTURE_MAX_SIZE: int = 128
const FEATURE_PREVIEW_PADDING: int = 3
const ControllerMappingManagerType = preload(
"res://settings/controller_mapping_manager.gd"
)
const OrganizerTabType = preload("res://ui/components/organizer_tab.gd")
const GameTheme: Theme = preload("res://ui/game_theme.tres")
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 _feature_preview_requests: Array[Dictionary] = []
var _feature_preview_generation: int = 0
var _feature_preview_worker_active: bool = false
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
var _active_fur_section: String = FUR_SECTION_PATTERNS
var _active_fur_pattern_id: String = CharacterCustomizationCatalog.FUR_STYLE_ID
var _active_fur_color_id: String = "fur_pattern"
var _fur_color_channel_buttons: Dictionary[String, Button] = {}
var _fur_color_channel_swatches: Dictionary[String, Panel] = {}
var _fur_custom_color_display: Panel
var _fur_custom_color_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 _exit_tree() -> void:
_cancel_feature_preview_requests()
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
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()
_cancel_feature_preview_requests()
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)
var category_scroll := ScrollContainer.new()
category_scroll.name = "CategoryScroll"
category_scroll.custom_minimum_size.x = 120.0
category_scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL
category_scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
category_scroll.vertical_scroll_mode = ScrollContainer.SCROLL_MODE_AUTO
body.add_child(category_scroll)
_category_list = VBoxContainer.new()
_category_list.custom_minimum_size = Vector2(120, 0)
_category_list.add_theme_constant_override("separation", 5)
category_scroll.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()
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:
_cancel_feature_preview_requests()
_fur_color_channel_buttons.clear()
_fur_color_channel_swatches.clear()
_fur_custom_color_display = null
_fur_custom_color_label = null
for child: Node in _option_list.get_children():
child.queue_free()
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_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()
if not is_instance_valid(_voice_preview):
_voice_preview = AnimaleseVoiceType.new()
_voice_preview.name = "ProfileVoicePreview"
add_child(_voice_preview)
_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))
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.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)
var cached_preview := _cached_feature_preview_texture(
_category_id, option_id
)
if cached_preview != null:
preview.texture = cached_preview
else:
_queue_feature_preview(preview, _category_id, option_id)
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 _cached_feature_preview_texture(
category_id: String,
option_id: String,
) -> Texture2D:
var cache_key := "%s:%s" % [category_id, option_id]
return _feature_preview_cache.get(cache_key) as Texture2D
func _queue_feature_preview(
target: TextureRect,
category_id: String,
option_id: String,
) -> void:
_feature_preview_requests.append({
"generation": _feature_preview_generation,
"target": weakref(target),
"category_id": category_id,
"option_id": option_id,
})
if _feature_preview_worker_active:
return
_feature_preview_worker_active = true
_process_feature_preview_requests.call_deferred()
func _cancel_feature_preview_requests() -> void:
_feature_preview_generation += 1
_feature_preview_requests.clear()
func _process_feature_preview_requests() -> void:
while not _feature_preview_requests.is_empty():
var request: Dictionary = _feature_preview_requests.pop_front()
await get_tree().process_frame
if int(request.get("generation", -1)) != _feature_preview_generation:
continue
var target_reference := request.get("target") as WeakRef
if target_reference == null:
continue
var target := target_reference.get_ref() as TextureRect
if target == null or not is_instance_valid(target):
continue
var category_id := str(request.get("category_id", ""))
var option_id := str(request.get("option_id", ""))
var preview := _cached_feature_preview_texture(category_id, option_id)
if preview == null:
preview = _create_feature_preview_texture(category_id, option_id)
if (
preview != null
and int(request.get("generation", -1)) == _feature_preview_generation
and is_instance_valid(target)
):
target.texture = preview
_feature_preview_worker_active = false
func _create_feature_preview_texture(
category_id: String,
option_id: String,
) -> Texture2D:
var cache_key := "%s:%s" % [category_id, option_id]
var resource_path := CharacterCustomizationCatalog.feature_texture_path(
category_id, option_id
)
if resource_path.is_empty():
return null
var source := ResourceLoader.load(
resource_path,
"Texture2D",
ResourceLoader.CACHE_MODE_IGNORE,
) as Texture2D
if source == null:
return null
var image := source.get_image()
if image == null or image.is_empty():
return null
if image.is_compressed() and image.decompress() != OK:
return null
_resize_feature_preview_image(image, FEATURE_PREVIEW_SCAN_MAX_SIZE)
var used := image.get_used_rect()
if used.size.x <= 0 or used.size.y <= 0:
return null
used.position -= Vector2i(FEATURE_PREVIEW_PADDING, FEATURE_PREVIEW_PADDING)
used.size += Vector2i(
FEATURE_PREVIEW_PADDING * 2,
FEATURE_PREVIEW_PADDING * 2,
)
used = used.intersection(Rect2i(Vector2i.ZERO, image.get_size()))
var cropped := image.get_region(used)
_resize_feature_preview_image(cropped, FEATURE_PREVIEW_TEXTURE_MAX_SIZE)
var preview := ImageTexture.create_from_image(cropped)
_feature_preview_cache[cache_key] = preview
return preview
func _resize_feature_preview_image(image: Image, max_dimension: int) -> void:
var source_size := image.get_size()
var largest_dimension := maxi(source_size.x, source_size.y)
if largest_dimension <= max_dimension:
return
var scale := float(max_dimension) / float(largest_dimension)
image.resize(
maxi(roundi(float(source_size.x) * scale), 1),
maxi(roundi(float(source_size.y) * scale), 1),
Image.INTERPOLATE_NEAREST,
)
func _build_fur_options(options: Array) -> void:
var section_tabs := HBoxContainer.new()
section_tabs.add_theme_constant_override("separation", 8)
_option_list.add_child(section_tabs)
for section_id: String in [FUR_SECTION_PATTERNS, FUR_SECTION_COLORS]:
var section_button := Button.new()
section_button.text = section_id
section_button.toggle_mode = true
section_button.button_pressed = _active_fur_section == section_id
section_button.custom_minimum_size = Vector2(132.0, 34.0)
section_button.pressed.connect(_select_fur_section.bind(section_id))
UtilityPageStyle.apply_compact_ocean_button(section_button)
section_tabs.add_child(section_button)
if _active_fur_section == FUR_SECTION_COLORS:
_build_fur_color_channels(options)
return
_build_fur_pattern_options()
func _build_fur_pattern_options() -> void:
if _active_fur_pattern_id not in CharacterCustomizationCatalog.FUR_STYLE_IDS:
_active_fur_pattern_id = CharacterCustomizationCatalog.FUR_STYLE_ID
var pattern_stack := VBoxContainer.new()
pattern_stack.name = "FurPatternStack"
pattern_stack.size_flags_horizontal = Control.SIZE_EXPAND_FILL
pattern_stack.size_flags_vertical = Control.SIZE_EXPAND_FILL
pattern_stack.add_theme_constant_override("separation", -10)
_option_list.add_child(pattern_stack)
var part_tab_margin := MarginContainer.new()
part_tab_margin.name = "FurPatternPartTabMargin"
part_tab_margin.custom_minimum_size.y = 38.0
part_tab_margin.add_theme_constant_override("margin_left", 14)
pattern_stack.add_child(part_tab_margin)
var part_tabs := HBoxContainer.new()
part_tabs.name = "FurPatternPartTabs"
part_tabs.custom_minimum_size.y = 38.0
part_tabs.add_theme_constant_override("separation", 4)
part_tab_margin.add_child(part_tabs)
for style_index: int in range(
CharacterCustomizationCatalog.FUR_STYLE_IDS.size()
):
var style_field: String = (
CharacterCustomizationCatalog.FUR_STYLE_IDS[style_index]
)
var part_tab: OrganizerTab = OrganizerTabType.new()
part_tab.text = CharacterCustomizationCatalog.fur_style_label(
style_field
)
part_tab.palette_index = mini(style_index, 2)
part_tab.custom_minimum_size = Vector2(90.0, 38.0)
part_tab.focus_mode = Control.FOCUS_ALL
part_tab.mouse_filter = Control.MOUSE_FILTER_STOP
part_tab.pressed.connect(_select_fur_pattern_part.bind(style_field))
part_tabs.add_child(part_tab)
part_tab.set_selected(
_active_fur_pattern_id == style_field,
false,
)
var pattern_panel := PanelContainer.new()
pattern_panel.name = "FurPatternOptionsPanel"
pattern_panel.custom_minimum_size.y = 180.0
pattern_panel.size_flags_horizontal = Control.SIZE_EXPAND_FILL
pattern_panel.size_flags_vertical = Control.SIZE_EXPAND_FILL
pattern_panel.add_theme_stylebox_override(
"panel",
UtilityPageStyle.rounded_style(UtilityPageStyle.OCEAN_FIELD, 12),
)
pattern_stack.add_child(pattern_panel)
var pattern_margin := MarginContainer.new()
pattern_margin.add_theme_constant_override("margin_left", 12)
pattern_margin.add_theme_constant_override("margin_top", 10)
pattern_margin.add_theme_constant_override("margin_right", 12)
pattern_margin.add_theme_constant_override("margin_bottom", 10)
pattern_panel.add_child(pattern_margin)
var pattern_scroll := ScrollContainer.new()
pattern_scroll.size_flags_horizontal = Control.SIZE_EXPAND_FILL
pattern_scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL
pattern_scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
pattern_scroll.vertical_scroll_mode = ScrollContainer.SCROLL_MODE_AUTO
pattern_margin.add_child(pattern_scroll)
var pattern_grid := GridContainer.new()
pattern_grid.columns = FUR_PATTERN_GRID_COLUMNS
pattern_grid.size_flags_horizontal = Control.SIZE_EXPAND_FILL
pattern_grid.add_theme_constant_override("h_separation", 8)
pattern_grid.add_theme_constant_override("v_separation", 8)
pattern_scroll.add_child(pattern_grid)
var selected_style_id := str(_draft_appearance.get(
_active_fur_pattern_id,
CharacterCustomizationCatalog.DEFAULT_FUR_STYLE,
))
var pattern_options := (
CharacterCustomizationCatalog.fur_pattern_options_for_field(
_active_fur_pattern_id,
_draft_appearance,
)
)
var selected_style_available := false
for pattern_option: Dictionary in pattern_options:
if str(pattern_option.get("id", "")) == selected_style_id:
selected_style_available = true
break
var displayed_style_id := (
selected_style_id
if selected_style_available
else CharacterCustomizationCatalog.DEFAULT_FUR_STYLE
)
for pattern_option: Dictionary in pattern_options:
var pattern_id := str(pattern_option.get("id", ""))
var pattern_button := Button.new()
pattern_button.text = str(pattern_option.get("label", pattern_id))
pattern_button.toggle_mode = true
pattern_button.button_pressed = displayed_style_id == pattern_id
pattern_button.custom_minimum_size = Vector2(118.0, 32.0)
pattern_button.pressed.connect(_select_option.bind(
_active_fur_pattern_id,
pattern_id,
))
UtilityPageStyle.apply_compact_ocean_button(pattern_button)
pattern_grid.add_child(pattern_button)
func _build_fur_color_channels(options: Array) -> void:
var color_panel := PanelContainer.new()
color_panel.name = "FurColorOptionsPanel"
color_panel.custom_minimum_size.y = 250.0
color_panel.size_flags_horizontal = Control.SIZE_EXPAND_FILL
color_panel.size_flags_vertical = Control.SIZE_EXPAND_FILL
color_panel.add_theme_stylebox_override(
"panel",
UtilityPageStyle.rounded_style(UtilityPageStyle.OCEAN_FIELD, 12),
)
_option_list.add_child(color_panel)
var color_margin := MarginContainer.new()
color_margin.add_theme_constant_override("margin_left", 14)
color_margin.add_theme_constant_override("margin_top", 12)
color_margin.add_theme_constant_override("margin_right", 14)
color_margin.add_theme_constant_override("margin_bottom", 12)
color_panel.add_child(color_margin)
var color_stack := VBoxContainer.new()
color_stack.name = "FurColorStack"
color_stack.size_flags_horizontal = Control.SIZE_EXPAND_FILL
color_stack.size_flags_vertical = Control.SIZE_EXPAND_FILL
color_stack.add_theme_constant_override("separation", 10)
color_margin.add_child(color_stack)
var channel_grid := GridContainer.new()
channel_grid.name = "FurColorChannelGrid"
channel_grid.columns = CharacterCustomizationCatalog.FUR_COLOR_IDS.size()
channel_grid.size_flags_horizontal = Control.SIZE_EXPAND_FILL
channel_grid.add_theme_constant_override("h_separation", 10)
channel_grid.add_theme_constant_override("v_separation", 8)
color_stack.add_child(channel_grid)
for color_index: int in range(
CharacterCustomizationCatalog.FUR_COLOR_IDS.size()
):
var color_category_id: String = (
CharacterCustomizationCatalog.FUR_COLOR_IDS[color_index]
)
var selected_color_id := str(_draft_appearance.get(
color_category_id,
"white",
))
var selected_color := CharacterCustomizationCatalog.option_color(
color_category_id,
selected_color_id,
)
var channel_button := Button.new()
channel_button.name = "FurColorSlot%d" % (color_index + 1)
channel_button.text = ""
channel_button.accessibility_name = "fur color %d" % (color_index + 1)
channel_button.tooltip_text = "Edit %s: %s" % [
CharacterCustomizationCatalog.fur_color_label(color_category_id),
_fur_option_label(options, selected_color_id),
]
channel_button.toggle_mode = true
channel_button.custom_minimum_size = Vector2(104.0, 42.0)
channel_button.size_flags_horizontal = Control.SIZE_EXPAND_FILL
channel_button.button_pressed = (
_active_fur_color_id == color_category_id
)
channel_button.pressed.connect(
_select_fur_color_slot.bind(color_category_id)
)
UtilityPageStyle.apply_compact_ocean_button(channel_button)
channel_grid.add_child(channel_button)
var channel_contents := HBoxContainer.new()
channel_contents.name = "ChannelContents"
channel_contents.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
channel_contents.mouse_filter = Control.MOUSE_FILTER_IGNORE
channel_contents.alignment = BoxContainer.ALIGNMENT_CENTER
channel_contents.add_theme_constant_override("separation", 8)
channel_button.add_child(channel_contents)
var channel_label := Label.new()
channel_label.name = "ChannelNumber"
channel_label.text = str(color_index + 1)
channel_label.mouse_filter = Control.MOUSE_FILTER_IGNORE
channel_label.size_flags_vertical = Control.SIZE_SHRINK_CENTER
channel_label.add_theme_font_override(
"font", UtilityPageStyle.TuffyFont
)
channel_label.add_theme_color_override(
"font_color", UtilityPageStyle.OCEAN_TEXT_PRIMARY
)
channel_contents.add_child(channel_label)
var channel_swatch := Panel.new()
channel_swatch.name = "ActiveColorSwatch"
channel_swatch.custom_minimum_size = (
Vector2.ONE * FUR_CHANNEL_SWATCH_SIZE
)
channel_swatch.size_flags_horizontal = Control.SIZE_SHRINK_CENTER
channel_swatch.size_flags_vertical = Control.SIZE_SHRINK_CENTER
channel_swatch.mouse_filter = Control.MOUSE_FILTER_IGNORE
_set_fur_color_swatch(channel_swatch, selected_color)
channel_contents.add_child(channel_swatch)
_fur_color_channel_buttons[color_category_id] = channel_button
_fur_color_channel_swatches[color_category_id] = channel_swatch
_build_fur_palette(_active_fur_color_id, options, color_stack)
func _build_fur_palette(
category_id: String,
options: Array,
parent: VBoxContainer,
) -> void:
var selected_id: String = CharacterCustomizationCatalog.canonical_option_id(
category_id,
str(_draft_appearance.get(category_id, "white")),
)
var palette_center := CenterContainer.new()
palette_center.name = "FurPaletteCenter"
palette_center.size_flags_horizontal = Control.SIZE_EXPAND_FILL
palette_center.size_flags_vertical = Control.SIZE_EXPAND_FILL
parent.add_child(palette_center)
var grid := GridContainer.new()
grid.name = "FurPaletteGrid"
grid.columns = FUR_PALETTE_GRID_COLUMNS
grid.add_theme_constant_override("h_separation", 8)
grid.add_theme_constant_override("v_separation", 8)
palette_center.add_child(grid)
for option: Dictionary in options:
var option_id: String = str(option.get("id", ""))
var color: Color = CharacterCustomizationCatalog.option_color(
category_id, option_id
)
var button := Button.new()
button.text = ""
button.tooltip_text = "%s: %s" % [
CharacterCustomizationCatalog.fur_color_label(category_id),
str(option.get("label", option_id)),
]
button.toggle_mode = true
button.custom_minimum_size = (
Vector2.ONE * FUR_PALETTE_SWATCH_SIZE
)
button.button_pressed = selected_id == option_id
button.pressed.connect(_select_option.bind(category_id, option_id))
_apply_fur_swatch_style(button, color, button.button_pressed)
grid.add_child(button)
var selected_color := CharacterCustomizationCatalog.option_color(
category_id,
selected_id,
)
var custom_picker := ColorPickerButton.new()
custom_picker.name = "FurCustomColorPicker"
custom_picker.text = ""
custom_picker.accessibility_name = "custom color"
custom_picker.color = selected_color
custom_picker.edit_alpha = false
custom_picker.edit_intensity = false
custom_picker.custom_minimum_size = Vector2(0.0, 44.0)
custom_picker.size_flags_horizontal = Control.SIZE_EXPAND_FILL
custom_picker.tooltip_text = "Choose a custom color for channel %d." % (
CharacterCustomizationCatalog.FUR_COLOR_IDS.find(category_id) + 1
)
custom_picker.color_changed.connect(
_select_custom_fur_color.bind(category_id)
)
custom_picker.popup_closed.connect(_refresh_options)
UtilityPageStyle.apply_compact_ocean_button(custom_picker)
_round_fur_color_picker_button(custom_picker)
_theme_fur_color_picker_popup(custom_picker)
parent.add_child(custom_picker)
var picker_display := Panel.new()
picker_display.name = "FurCustomColorDisplay"
picker_display.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
picker_display.offset_left = 4.0
picker_display.offset_top = 4.0
picker_display.offset_right = -4.0
picker_display.offset_bottom = -4.0
picker_display.mouse_filter = Control.MOUSE_FILTER_IGNORE
picker_display.z_index = 1
custom_picker.add_child(picker_display)
var picker_label := Label.new()
picker_label.name = "FurCustomColorLabel"
picker_label.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
picker_label.text = "custom color"
picker_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
picker_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
picker_label.mouse_filter = Control.MOUSE_FILTER_IGNORE
picker_label.add_theme_font_override("font", UtilityPageStyle.TuffyFont)
picker_display.add_child(picker_label)
_fur_custom_color_display = picker_display
_fur_custom_color_label = picker_label
_update_fur_custom_color_display(selected_color)
func _fur_option_label(options: Array, option_id: String) -> String:
if CharacterCustomizationCatalog.is_custom_fur_color_id(option_id):
return "custom #%s" % option_id.trim_prefix(
CharacterCustomizationCatalog.CUSTOM_FUR_COLOR_PREFIX
)
for option: Dictionary in options:
if str(option.get("id", "")) == option_id:
return str(option.get("label", option_id))
return option_id
func _select_fur_color_slot(category_id: String) -> void:
if category_id not in CharacterCustomizationCatalog.FUR_COLOR_IDS:
return
_active_fur_color_id = category_id
_refresh_options()
func _select_fur_section(section_id: String) -> void:
if section_id not in [FUR_SECTION_PATTERNS, FUR_SECTION_COLORS]:
return
_active_fur_section = section_id
_refresh_options()
func _select_custom_fur_color(color: Color, category_id: String) -> void:
if category_id not in CharacterCustomizationCatalog.FUR_COLOR_IDS:
return
var option_id := CharacterCustomizationCatalog.custom_fur_color_id(color)
_draft_appearance[category_id] = option_id
_update_fur_channel_color(
category_id,
color,
"custom #%s" % option_id.trim_prefix(
CharacterCustomizationCatalog.CUSTOM_FUR_COLOR_PREFIX
),
)
_update_fur_custom_color_display(color)
_preview.apply_appearance_profile(_draft_appearance)
_dirty = _draft_differs()
_refresh_actions()
func _select_fur_pattern_part(category_id: String) -> void:
if category_id not in CharacterCustomizationCatalog.FUR_STYLE_IDS:
return
_active_fur_pattern_id = category_id
_refresh_options()
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 _set_fur_color_swatch(swatch: Panel, color: Color) -> void:
var swatch_style := UtilityPageStyle.rounded_style(
color,
roundi(FUR_CHANNEL_SWATCH_SIZE * 0.5),
)
swatch_style.anti_aliasing = true
swatch.add_theme_stylebox_override(
"panel", swatch_style
)
func _update_fur_channel_color(
category_id: String,
color: Color,
option_label: String,
) -> void:
var swatch: Panel = _fur_color_channel_swatches.get(category_id)
if swatch != null and is_instance_valid(swatch):
_set_fur_color_swatch(swatch, color)
var button: Button = _fur_color_channel_buttons.get(category_id)
if button != null and is_instance_valid(button):
button.tooltip_text = "Edit %s: %s" % [
CharacterCustomizationCatalog.fur_color_label(category_id),
option_label,
]
func _round_fur_color_picker_button(button: ColorPickerButton) -> void:
for state: StringName in [
&"normal", &"hover", &"pressed", &"focus", &"disabled",
]:
var style := button.get_theme_stylebox(state).duplicate() as StyleBoxFlat
if style == null:
continue
style.set_corner_radius_all(14)
style.content_margin_left = 14.0
style.content_margin_top = 14.0
style.content_margin_right = 14.0
style.content_margin_bottom = 14.0
button.add_theme_stylebox_override(state, style)
func _theme_fur_color_picker_popup(button: ColorPickerButton) -> void:
var popup: PopupPanel = button.get_popup()
var picker: ColorPicker = button.get_picker()
popup.accessibility_name = "custom fur color picker"
popup.theme = GameTheme
popup.min_size = FUR_COLOR_PICKER_POPUP_SIZE
popup.size = FUR_COLOR_PICKER_POPUP_SIZE
var popup_style := UtilityPageStyle.rounded_style(
UtilityPageStyle.OCEAN_PANEL_DEEP,
20,
)
popup_style.content_margin_left = 24.0
popup_style.content_margin_top = 24.0
popup_style.content_margin_right = 24.0
popup_style.content_margin_bottom = 24.0
popup.add_theme_stylebox_override("panel", popup_style)
picker.custom_minimum_size = Vector2(
FUR_COLOR_PICKER_POPUP_SIZE.x - 48,
FUR_COLOR_PICKER_POPUP_SIZE.y - 48,
)
picker.edit_alpha = false
picker.edit_intensity = false
picker.can_add_swatches = false
picker.presets_visible = false
picker.add_theme_constant_override("margin", 16)
picker.add_theme_constant_override("sv_width", FUR_COLOR_PICKER_SV_SIZE.x)
picker.add_theme_constant_override("sv_height", FUR_COLOR_PICKER_SV_SIZE.y)
picker.add_theme_constant_override("h_width", 38)
picker.add_theme_constant_override("label_width", 28)
func _update_fur_custom_color_display(color: Color) -> void:
if (
_fur_custom_color_display == null
or not is_instance_valid(_fur_custom_color_display)
or _fur_custom_color_label == null
or not is_instance_valid(_fur_custom_color_label)
):
return
_fur_custom_color_display.add_theme_stylebox_override(
"panel", UtilityPageStyle.rounded_style(color, 11)
)
_fur_custom_color_label.add_theme_color_override(
"font_color",
UtilityPageStyle.NAVY
if color.get_luminance() >= 0.52
else UtilityPageStyle.OCEAN_TEXT_PRIMARY,
)
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 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))
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()