netfishing/ui/profile_page.gd

1676 lines
58 KiB
GDScript3
Raw Normal View History

class_name ProfilePage
extends Control
const CHECK_DEBOUNCE_SECONDS: float = 0.4
const OPTION_GRID_COLUMNS: int = 6
2026-08-12 16:29:58 -04:00
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"
2026-08-08 10:25:17 -04:00
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
2026-08-04 22:34:43 -04:00
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
2026-08-08 10:25:17 -04:00
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
2026-08-12 16:29:58 -04:00
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,
2026-08-04 22:34:43 -04:00
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()
2026-08-04 22:34:43 -04:00
_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
2026-07-30 23:16:28 -04:00
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)
2026-08-04 22:34:43 -04:00
_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()
)
2026-08-04 22:34:43 -04:00
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
2026-08-04 22:34:43 -04:00
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
2026-08-04 22:34:43 -04:00
if _category_id == "fur_pattern":
_build_fur_options(options)
2026-08-04 22:34:43 -04:00
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))
2026-08-08 10:25:17 -04:00
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, ""))
)
2026-08-08 10:25:17 -04:00
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", "")
)
2026-08-08 10:25:17 -04:00
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,
)
2026-08-08 10:25:17 -04:00
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)
2026-08-08 10:25:17 -04:00
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),
2026-08-12 16:29:58 -04:00
)
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)
2026-08-12 16:29:58 -04:00
var selected_style_id := str(_draft_appearance.get(
_active_fur_pattern_id,
2026-08-12 16:29:58 -04:00
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:
2026-08-12 16:29:58 -04:00
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
2026-08-12 16:29:58 -04:00
pattern_button.custom_minimum_size = Vector2(118.0, 32.0)
pattern_button.pressed.connect(_select_option.bind(
_active_fur_pattern_id,
2026-08-12 16:29:58 -04:00
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),
2026-08-12 16:29:58 -04:00
)
_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)
2026-08-12 16:29:58 -04:00
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)
2026-08-12 16:29:58 -04:00
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]
)
2026-08-12 16:29:58 -04:00
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)
2026-08-12 16:29:58 -04:00
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
2026-08-12 16:29:58 -04:00
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
2026-08-12 16:29:58 -04:00
_build_fur_palette(_active_fur_color_id, options, color_stack)
2026-08-12 16:29:58 -04:00
func _build_fur_palette(
category_id: String,
options: Array,
parent: VBoxContainer,
) -> void:
2026-08-04 22:34:43 -04:00
var selected_id: String = CharacterCustomizationCatalog.canonical_option_id(
2026-08-12 16:29:58 -04:00
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)
2026-08-12 16:29:58 -04:00
var grid := GridContainer.new()
grid.name = "FurPaletteGrid"
2026-08-12 16:29:58 -04:00
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)
2026-08-04 22:34:43 -04:00
for option: Dictionary in options:
var option_id: String = str(option.get("id", ""))
var color: Color = CharacterCustomizationCatalog.option_color(
2026-08-12 16:29:58 -04:00
category_id, option_id
2026-08-04 22:34:43 -04:00
)
var button := Button.new()
button.text = ""
2026-08-12 16:29:58 -04:00
button.tooltip_text = "%s: %s" % [
CharacterCustomizationCatalog.fur_color_label(category_id),
str(option.get("label", option_id)),
]
2026-08-04 22:34:43 -04:00
button.toggle_mode = true
button.custom_minimum_size = (
Vector2.ONE * FUR_PALETTE_SWATCH_SIZE
)
2026-08-04 22:34:43 -04:00
button.button_pressed = selected_id == option_id
2026-08-12 16:29:58 -04:00
button.pressed.connect(_select_option.bind(category_id, option_id))
2026-08-04 22:34:43 -04:00
_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)
2026-08-04 22:34:43 -04:00
2026-08-12 16:29:58 -04:00
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
)
2026-08-12 16:29:58 -04:00
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()
2026-08-04 22:34:43 -04:00
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"
2026-08-04 22:34:43 -04:00
_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()