diff --git a/network/network_profile_protocol.gd b/network/network_profile_protocol.gd index 104112d..5c1e879 100644 --- a/network/network_profile_protocol.gd +++ b/network/network_profile_protocol.gd @@ -41,6 +41,21 @@ static func valid_apply_request(data: Variant) -> bool: ) +static func valid_appearance_preview_request(data: Variant) -> bool: + return ( + typeof(data) == TYPE_DICTIONARY + and valid_request_id(data.get("request_id")) + and typeof(data.get("session_id")) == TYPE_STRING + and not str(data.get("session_id", "")).is_empty() + and typeof(data.get("appearance")) == TYPE_DICTIONARY + and valid_snapshot(data["appearance"]) + and NetworkIdentityCrypto.valid_fingerprint( + data.get("sender_fingerprint") + ) + and typeof(data.get("sender_signature")) == TYPE_PACKED_BYTE_ARRAY + ) + + static func signature_fields(data: Dictionary) -> Array: var appearance: Dictionary = data.get("appearance", {}) var result: Array = [ @@ -54,3 +69,16 @@ static func signature_fields(data: Dictionary) -> Array: ) result.append(bool(data.get("use_anyway", false))) return result + + +static func appearance_preview_signature_fields(data: Dictionary) -> Array: + var appearance: Dictionary = data.get("appearance", {}) + var result: Array = [ + str(data.get("session_id", "")), + str(data.get("request_id", "")), + str(data.get("sender_fingerprint", "")), + ] + result.append_array( + CharacterCustomizationCatalog.appearance_signature_values(appearance) + ) + return result diff --git a/network/network_profile_service.gd b/network/network_profile_service.gd index 86b1be1..029af65 100644 --- a/network/network_profile_service.gd +++ b/network/network_profile_service.gd @@ -77,6 +77,47 @@ func get_identity_fingerprint() -> String: ) +func preview_appearance(appearance: Dictionary) -> bool: + if not CharacterCustomizationCatalog.validate_snapshot(appearance): + return false + var snapshot := CharacterCustomizationCatalog.sanitized_snapshot(appearance) + var local_peer_id: int = ( + _session.get_local_peer_id() if _session != null else 1 + ) + _apply_to_avatar(local_peer_id, snapshot) + if _session == null or not _session.is_gameplay_session_active(): + return true + _session.apply_canonical_profile( + local_peer_id, + _preferences.display_name, + snapshot, + ) + if _session.is_host(): + _broadcast_appearance_preview_to_supported_peers( + local_peer_id, + snapshot, + ) + elif _session.supports_server_capability( + NetworkProtocol.APPEARANCE_PREVIEW_CAPABILITY + ): + var request: Dictionary = { + "request_id": _new_id(), + "session_id": _session.get_session_id(), + "appearance": snapshot.duplicate(true), + "sender_fingerprint": _session.get_local_identity_fingerprint(), + } + request["sender_signature"] = _session.sign_local_action( + "appearance_preview", + NetworkProfileProtocol.appearance_preview_signature_fields(request), + ) + submit_appearance_preview.rpc_id(1, request) + return true + + +func restore_persisted_appearance() -> void: + preview_appearance(_appearance_store.get_snapshot()) + + func request_name_check(display_name: String) -> String: var request_id := _new_id() _latest_check_id = request_id @@ -200,6 +241,40 @@ func submit_profile_apply(data: Dictionary) -> void: _process_apply_request(sender_id, data) +@rpc("any_peer", "call_remote", "reliable", NetworkProfileProtocol.RELIABLE_CHANNEL) +func submit_appearance_preview(data: Dictionary) -> void: + var sender_id := multiplayer.get_remote_sender_id() + if ( + not _session.is_host() + or not _session.is_authenticated_peer(sender_id) + or not NetworkProfileProtocol.valid_appearance_preview_request(data) + or str(data["session_id"]) != _session.get_session_id() + ): + return + var record := _session.get_peer_record(sender_id) + if ( + record == null + or record.identity_fingerprint != str(data["sender_fingerprint"]) + or not _session.verify_peer_action( + sender_id, + "appearance_preview", + NetworkProfileProtocol.appearance_preview_signature_fields(data), + data["sender_signature"], + ) + ): + return + var appearance := CharacterCustomizationCatalog.sanitized_snapshot( + data["appearance"] + ) + _session.apply_canonical_profile( + sender_id, + record.display_name, + appearance, + ) + _apply_to_avatar(sender_id, appearance) + _broadcast_appearance_preview_to_supported_peers(sender_id, appearance) + + func _process_apply_request(peer_id: int, data: Dictionary) -> void: var display_name: String = str(data["display_name"]).strip_edges() var conflict: bool = _name_conflicts(peer_id, display_name) @@ -256,7 +331,7 @@ func confirm_profile_saved(request_id: String) -> void: _apply_to_avatar(sender_id, appearance) broadcast_profile_snapshot.rpc( sender_id, - name, + display_name, appearance, pending.get("authorization", {}), ) @@ -344,6 +419,8 @@ func _apply_local_result( _pending_voice_ids.erase(request_id) _pending_speech_speed_ids.erase(request_id) _pending_call_ids.erase(request_id) + if _session != null: + _session.set_local_appearance_snapshot(_appearance_store.get_snapshot()) if _session != null and _session.is_gameplay_session_active(): if _session.is_host(): _session.apply_canonical_profile( @@ -408,6 +485,48 @@ func broadcast_profile_snapshot( profile_snapshot_changed.emit(peer_id, display_name, appearance.duplicate(true)) +@rpc("authority", "call_remote", "reliable", NetworkProfileProtocol.RELIABLE_CHANNEL) +func broadcast_appearance_preview( + peer_id: int, + appearance: Dictionary, +) -> void: + if not CharacterCustomizationCatalog.validate_snapshot(appearance): + return + var record := _session.get_peer_record(peer_id) + if record == null: + return + var snapshot := CharacterCustomizationCatalog.sanitized_snapshot(appearance) + _session.apply_canonical_profile(peer_id, record.display_name, snapshot) + _apply_to_avatar(peer_id, snapshot) + profile_snapshot_changed.emit( + peer_id, + record.display_name, + snapshot.duplicate(true), + ) + + +func _broadcast_appearance_preview_to_supported_peers( + peer_id: int, + appearance: Dictionary, +) -> void: + if _session == null or not _session.is_host(): + return + for recipient_id: int in _session.get_authenticated_peer_ids(): + if ( + recipient_id == _session.get_local_peer_id() + or not _session.peer_supports_capability( + recipient_id, + NetworkProtocol.APPEARANCE_PREVIEW_CAPABILITY, + ) + ): + continue + broadcast_appearance_preview.rpc_id( + recipient_id, + peer_id, + appearance, + ) + + func _on_peer_authenticated(peer_id: int, _display_name: String) -> void: if not _session.is_host(): return @@ -422,9 +541,19 @@ func _on_peer_authenticated(peer_id: int, _display_name: String) -> void: record.appearance_snapshot, record.profile_authorization, ) + if _session.peer_supports_capability( + peer_id, + NetworkProtocol.APPEARANCE_PREVIEW_CAPABILITY, + ): + broadcast_appearance_preview.rpc_id( + peer_id, + existing_id, + record.appearance_snapshot, + ) func _on_join_authenticated() -> void: + _session.set_local_appearance_snapshot(_appearance_store.get_snapshot()) _apply_to_avatar( _session.get_local_peer_id(), _appearance_store.get_snapshot() ) @@ -474,6 +603,8 @@ func _on_session_state_changed(state: NetworkSession.State) -> void: _host_pending_apply.clear() _latest_check_id = "" _latest_check_name = "" + _session.set_local_appearance_snapshot(_appearance_store.get_snapshot()) + _apply_to_avatar(1, _appearance_store.get_snapshot()) func _emit_conflict_result(data: Dictionary) -> void: diff --git a/network/network_protocol.gd b/network/network_protocol.gd index bb99d62..c4496f3 100644 --- a/network/network_protocol.gd +++ b/network/network_protocol.gd @@ -26,6 +26,7 @@ const WORLD_WEATHER_CAPABILITY: String = "world_weather_v1" const FISH_QUALITY_CAPABILITY: String = "fish_quality_v1" const JOBS_CAPABILITY: String = "jobs_v1" const WORLD_SPAWN_CAPABILITY: String = "world_spawn_envelope_v1" +const APPEARANCE_PREVIEW_CAPABILITY: String = "appearance_preview_v1" enum RejectionCode { NONE, @@ -174,6 +175,7 @@ static func make_client_hello( WORLD_WEATHER_CAPABILITY, JOBS_CAPABILITY, WORLD_SPAWN_CAPABILITY, + APPEARANCE_PREVIEW_CAPABILITY, ]), "cosmetic_snapshot": cosmetic_snapshot, "identity_fingerprint": identity_fingerprint, @@ -303,6 +305,7 @@ static func make_server_hello( WORLD_WEATHER_CAPABILITY, JOBS_CAPABILITY, WORLD_SPAWN_CAPABILITY, + APPEARANCE_PREVIEW_CAPABILITY, "chat_v1", "mail_v1", "profile_v1", diff --git a/network/network_session.gd b/network/network_session.gd index 8af4f4a..99c77bc 100644 --- a/network/network_session.gd +++ b/network/network_session.gd @@ -559,6 +559,7 @@ func supports_server_capability(capability: StringName) -> bool: NetworkProtocol.WORLD_TIME_CAPABILITY, NetworkProtocol.WORLD_WEATHER_CAPABILITY, NetworkProtocol.WORLD_SPAWN_CAPABILITY, + NetworkProtocol.APPEARANCE_PREVIEW_CAPABILITY, "chat_v1", "mail_v1", "profile_v1", diff --git a/tests/fur_pattern_validation.gd b/tests/fur_pattern_validation.gd index 40f6cdd..135c507 100644 --- a/tests/fur_pattern_validation.gd +++ b/tests/fur_pattern_validation.gd @@ -331,7 +331,18 @@ func _validate_fur_color_ui(snapshot: Dictionary) -> void: )) var picker_style := picker.get_theme_stylebox("normal") as StyleBoxFlat assert(picker_style != null and picker_style.corner_radius_top_left == 14) - assert(picker_popup.min_size == ProfilePage.FUR_COLOR_PICKER_POPUP_SIZE) + var picker_window_size: Vector2i = profile_page.get_window().size + var expected_picker_size := Vector2i( + mini( + ProfilePage.FUR_COLOR_PICKER_POPUP_SIZE.x, + maxi(picker_window_size.x - 32, 420), + ), + mini( + ProfilePage.FUR_COLOR_PICKER_POPUP_SIZE.y, + maxi(picker_window_size.y - 32, 340), + ), + ) + assert(picker_popup.min_size == expected_picker_size) assert(picker_popup.theme.default_font == UtilityPageStyle.TuffyFont) var popup_style := picker_popup.get_theme_stylebox( "panel" @@ -341,11 +352,17 @@ func _validate_fur_color_ui(snapshot: Dictionary) -> void: assert(popup_style.corner_radius_top_left == 20) assert( picker_control.get_theme_constant("sv_width") - == ProfilePage.FUR_COLOR_PICKER_SV_SIZE.x + == mini( + ProfilePage.FUR_COLOR_PICKER_SV_SIZE.x, + expected_picker_size.x - 110, + ) ) assert( picker_control.get_theme_constant("sv_height") - == ProfilePage.FUR_COLOR_PICKER_SV_SIZE.y + == mini( + ProfilePage.FUR_COLOR_PICKER_SV_SIZE.y, + expected_picker_size.y - 180, + ) ) assert(not picker_control.edit_alpha) assert(not picker_control.edit_intensity) diff --git a/tests/profile_multiplayer_validation.gd b/tests/profile_multiplayer_validation.gd new file mode 100644 index 0000000..bc969e2 --- /dev/null +++ b/tests/profile_multiplayer_validation.gd @@ -0,0 +1,175 @@ +extends SceneTree + +const MainScene: PackedScene = preload("res://main/main.tscn") +const TEST_PORT: int = 18144 + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + var arguments: PackedStringArray = OS.get_cmdline_user_args() + if arguments.has("host"): + await _run_host() + return + if arguments.has("client"): + await _run_client() + return + push_error("Profile multiplayer validation needs host or client mode.") + quit(1) + + +func _run_host() -> void: + var main: Node = await _create_initialized_main() + var session := main.get_node("%NetworkSession") as NetworkSession + var save_manager := main.get("_save_manager") as PlayerSaveManager + assert(session.start_private_host(TEST_PORT)) + assert(save_manager.initialize_new_game()) + main.call("_enter_gameplay") + for _frame: int in 4: + await physics_frame + assert(session.set_host_open(true)) + var remote_peer_id: int = await _wait_for_remote_peer(session) + assert(remote_peer_id > 1) + var record := session.get_peer_record(remote_peer_id) + assert(record != null) + assert( + NetworkProtocol.APPEARANCE_PREVIEW_CAPABILITY + in record.capability_flags + ) + var original: Dictionary = record.appearance_snapshot.duplicate(true) + var changed: Dictionary = _changed_appearance(original) + assert(changed != original) + await _wait_for_remote_appearance(session, remote_peer_id, changed) + await _wait_for_remote_appearance(session, remote_peer_id, original) + await _wait_for_remote_appearance(session, remote_peer_id, changed) + var avatar := main.get_node("%PlayerSpawnService").get_avatar( + remote_peer_id + ) as Player + assert(avatar != null) + assert(avatar.appearance_snapshot == changed) + assert(record.display_name != "NetworkProfileService") + var disconnect_deadline: int = Time.get_ticks_msec() + 8000 + while ( + Time.get_ticks_msec() < disconnect_deadline + and session.is_authenticated_peer(remote_peer_id) + ): + await process_frame + assert(not session.is_authenticated_peer(remote_peer_id)) + print("Profile multiplayer host validation: PASS") + await _session_cleanup(main, session) + + +func _run_client() -> void: + var main: Node = await _create_initialized_main() + main.call("_on_title_join_game_requested", "127.0.0.1:%d" % TEST_PORT) + var session := main.get_node("%NetworkSession") as NetworkSession + var join_deadline: int = Time.get_ticks_msec() + 20000 + while Time.get_ticks_msec() < join_deadline: + await process_frame + if session.state == NetworkSession.State.VERIFYING_SERVER_IDENTITY: + main.call("_confirm_server_trust") + if session.is_joined_client() and bool(main.get("_gameplay_started")): + break + assert(session.is_joined_client()) + assert(session.supports_server_capability( + NetworkProtocol.APPEARANCE_PREVIEW_CAPABILITY + )) + var service := main.get_node( + "%NetworkProfileService" + ) as NetworkProfileService + var original: Dictionary = service.get_persisted_appearance() + var changed: Dictionary = _changed_appearance(original) + assert(service.preview_appearance(changed)) + await create_timer(0.75).timeout + service.restore_persisted_appearance() + await create_timer(0.75).timeout + assert(service.preview_appearance(changed)) + await create_timer(0.75).timeout + var apply_results: Array = [] + service.apply_finished.connect(func(accepted: bool, message: String) -> void: + apply_results.append([accepted, message]) + ) + assert(service.apply_profile( + service.get_persisted_name(), + changed, + true, + service.get_persisted_voice_id(), + service.get_persisted_speech_speed_id(), + service.get_persisted_call_id(), + )) + var apply_deadline: int = Time.get_ticks_msec() + 8000 + while Time.get_ticks_msec() < apply_deadline and apply_results.is_empty(): + await process_frame + assert(not apply_results.is_empty()) + assert(bool(apply_results[0][0])) + assert(service.get_persisted_appearance() == changed) + assert(Dictionary(session.get("_local_appearance_snapshot")) == changed) + await create_timer(0.75).timeout + print("Profile multiplayer client validation: PASS") + await _session_cleanup(main, session) + + +func _changed_appearance(original: Dictionary) -> Dictionary: + var changed := original.duplicate(true) + for category_id: String in CharacterCustomizationCatalog.CATEGORY_IDS: + if category_id in CharacterCustomizationCatalog.FUR_STYLE_IDS: + continue + var options: Array = CharacterCustomizationCatalog.options_for(category_id) + for option: Dictionary in options: + var option_id: Variant = option.get("id") + if option_id != changed.get(category_id): + changed[category_id] = option_id + if CharacterCustomizationCatalog.validate_snapshot(changed): + return changed + changed[category_id] = original.get(category_id) + assert(false, "No alternate appearance option is available.") + return changed + + +func _wait_for_remote_appearance( + session: NetworkSession, + peer_id: int, + expected: Dictionary, +) -> void: + var deadline: int = Time.get_ticks_msec() + 8000 + while Time.get_ticks_msec() < deadline: + await process_frame + var record := session.get_peer_record(peer_id) + if record != null and record.appearance_snapshot == expected: + return + assert(false, "Timed out waiting for a remote appearance update.") + + +func _create_initialized_main() -> Node: + root.size = Vector2i(1280, 720) + var main: Node = MainScene.instantiate() + root.add_child(main) + for _frame: int in 4: + await process_frame + if not bool(main.get("_application_initialized")): + main.call("_activate_selected_data_path", "", true) + for _frame: int in 8: + await process_frame + assert(bool(main.get("_application_initialized"))) + return main + + +func _wait_for_remote_peer(session: NetworkSession) -> int: + var deadline: int = Time.get_ticks_msec() + 20000 + while Time.get_ticks_msec() < deadline: + await process_frame + for peer_id: int in session.get_authenticated_peer_ids(): + if peer_id != session.get_local_peer_id(): + return peer_id + return 0 + + +func _session_cleanup(main: Node, session: NetworkSession) -> void: + session.disconnect_session("") + main.queue_free() + for _frame: int in 4: + await process_frame + await create_timer(0.1).timeout + quit() diff --git a/tests/profile_multiplayer_validation.gd.uid b/tests/profile_multiplayer_validation.gd.uid new file mode 100644 index 0000000..d561da1 --- /dev/null +++ b/tests/profile_multiplayer_validation.gd.uid @@ -0,0 +1 @@ +uid://qvk8y0fd7o2j diff --git a/ui/profile_page.gd b/ui/profile_page.gd index d380656..82ece0c 100644 --- a/ui/profile_page.gd +++ b/ui/profile_page.gd @@ -2,6 +2,7 @@ class_name ProfilePage extends Control const CHECK_DEBOUNCE_SECONDS: float = 0.4 +const APPEARANCE_PREVIEW_INTERVAL_SECONDS: float = 0.08 const OPTION_GRID_COLUMNS: int = 6 const FUR_PALETTE_GRID_COLUMNS: int = 10 const FUR_PATTERN_GRID_COLUMNS: int = 4 @@ -27,6 +28,13 @@ const VoiceProfilesType = preload( ) const VOICE_CATEGORY_ID: String = "voice" +enum ControllerZone { + ACCOUNT, + CATEGORIES, + OPTIONS, + COLOR_PICKER, +} + var _service: NetworkProfileService var _experience: PlayerExperience var _draft_name: String = "" @@ -49,14 +57,18 @@ var _suggestions: HBoxContainer var _category_list: VBoxContainer var _option_list: VBoxContainer var _preview: ProfilePreview +var _customize_button: Button var _apply_button: Button var _revert_button: Button +var _defaults_button: Button +var _reset_view_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 _appearance_preview_timer: Timer var _experience_level: Label var _experience_progress: ProgressBar var _experience_value: Label @@ -76,6 +88,13 @@ 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 +var _controller_mapping_manager: ControllerMappingManagerType +var _controller_zone: ControllerZone = ControllerZone.ACCOUNT +var _controller_option_depth: int = 0 +var _active_color_picker_button: ColorPickerButton +var _active_color_picker_return_depth: int = 0 +var _profile_active: bool = false +var _profile_interactive: bool = false func _ready() -> void: @@ -86,10 +105,17 @@ func _ready() -> void: _debounce.wait_time = CHECK_DEBOUNCE_SECONDS _debounce.timeout.connect(_request_conflict_check) add_child(_debounce) + _appearance_preview_timer = Timer.new() + _appearance_preview_timer.one_shot = true + _appearance_preview_timer.wait_time = APPEARANCE_PREVIEW_INTERVAL_SECONDS + _appearance_preview_timer.timeout.connect(_publish_draft_appearance) + add_child(_appearance_preview_timer) func _exit_tree() -> void: _cancel_feature_preview_requests() + if _service != null: + _service.restore_persisted_appearance() func setup( @@ -127,6 +153,7 @@ func setup( func setup_controller_mapping( mapping_manager: ControllerMappingManagerType, ) -> void: + _controller_mapping_manager = mapping_manager if _preview != null: _preview.setup_controller_mapping(mapping_manager) @@ -137,6 +164,7 @@ func set_world_pixel_size(pixel_size: int) -> void: func activate() -> void: + _profile_active = true visible = true if _service != null: _load_persisted() @@ -145,8 +173,12 @@ func activate() -> void: func deactivate() -> void: + _profile_active = false visible = false _debounce.stop() + _appearance_preview_timer.stop() + if _service != null: + _service.restore_persisted_appearance() _cancel_feature_preview_requests() if _voice_preview_tween != null and _voice_preview_tween.is_valid(): _voice_preview_tween.kill() @@ -154,16 +186,24 @@ func deactivate() -> void: func set_interactive(interactive: bool) -> void: + _profile_interactive = interactive and _profile_active mouse_filter = ( - Control.MOUSE_FILTER_PASS if interactive else Control.MOUSE_FILTER_IGNORE + Control.MOUSE_FILTER_PASS + if _profile_interactive else Control.MOUSE_FILTER_IGNORE ) + _apply_controller_zone_focus() + + +func reset_controller_zone() -> void: + _controller_zone = ControllerZone.ACCOUNT + _controller_option_depth = 0 + _apply_controller_zone_focus() + call_deferred("_focus_controller_zone") func consume_escape() -> bool: if _discard_confirmation.visible: - _discard_confirmation.visible = false - _confirmation_action = "" - _name_edit.grab_focus() + _close_discard_confirmation() return true if _name_edit.has_focus(): _name_edit.release_focus() @@ -174,6 +214,257 @@ func consume_escape() -> bool: return false +func handle_controller_input(event: InputEvent) -> bool: + if not _profile_active or not _profile_interactive: + return false + if event.is_action_pressed("ui_cancel"): + if _discard_confirmation.visible: + _close_discard_confirmation() + return true + match _controller_zone: + ControllerZone.COLOR_PICKER: + _close_controller_color_picker() + ControllerZone.OPTIONS: + if _controller_option_depth > 0: + _controller_option_depth -= 1 + _apply_controller_zone_focus() + call_deferred("_focus_controller_zone") + else: + _controller_zone = ControllerZone.CATEGORIES + _apply_controller_zone_focus() + call_deferred("_focus_controller_zone") + ControllerZone.CATEGORIES: + _controller_zone = ControllerZone.ACCOUNT + _apply_controller_zone_focus() + call_deferred("_focus_controller_zone") + ControllerZone.ACCOUNT: + return false + return true + if _controller_zone == ControllerZone.ACCOUNT: + return false + if ( + _controller_zone == ControllerZone.CATEGORIES + and event.is_action_pressed("ui_accept") + ): + var focused_category := get_viewport().gui_get_focus_owner() as Button + if focused_category != null and _category_list.is_ancestor_of( + focused_category + ): + focused_category.pressed.emit() + _controller_zone = ControllerZone.OPTIONS + _controller_option_depth = 0 + _apply_controller_zone_focus.call_deferred() + _focus_controller_zone.call_deferred() + return true + if ( + _controller_zone == ControllerZone.OPTIONS + and event.is_action_pressed("ui_accept") + ): + var groups: Array = _controller_option_groups() + if _controller_option_depth < groups.size() - 1: + var focused_option := get_viewport().gui_get_focus_owner() as BaseButton + _controller_option_depth += 1 + if focused_option != null: + focused_option.pressed.emit() + _apply_controller_zone_focus.call_deferred() + _focus_controller_zone.call_deferred() + return true + return false + + +func _process(delta: float) -> void: + if ( + not _profile_active + or not _profile_interactive + ): + return + var right_stick := Vector2( + _controller_mapping_manager.get_role_axis( + ControllerMappingManagerType.ROLE_RIGHT_STICK_X + ) + if _controller_mapping_manager != null + else Input.get_joy_axis(0, JOY_AXIS_RIGHT_X), + _controller_mapping_manager.get_role_axis( + ControllerMappingManagerType.ROLE_RIGHT_STICK_Y + ) + if _controller_mapping_manager != null + else Input.get_joy_axis(0, JOY_AXIS_RIGHT_Y), + ) + if right_stick.length() < 0.16: + return + if _controller_zone == ControllerZone.COLOR_PICKER: + _adjust_controller_color_gamut(right_stick, delta) + else: + _preview.apply_controller_orbit(right_stick, delta) + + +func _apply_controller_zone_focus() -> void: + if not is_node_ready(): + return + var account_controls: Array[Control] = _account_controller_controls() + var category_controls: Array[Control] = _controls_under(_category_list) + var option_groups: Array = _controller_option_groups() + var all_option_controls: Array[Control] = [] + for group_value: Variant in option_groups: + var group := group_value as Array + for item: Variant in group: + var control := item as Control + if control != null and control not in all_option_controls: + all_option_controls.append(control) + for control: Control in account_controls + category_controls + all_option_controls: + control.focus_mode = Control.FOCUS_NONE + for control: Control in [_confirmation_confirm, _keep_editing_button]: + if control != null: + control.focus_mode = Control.FOCUS_NONE + if _preview != null: + _preview.focus_mode = Control.FOCUS_NONE + if _reset_view_button != null: + _reset_view_button.focus_mode = Control.FOCUS_NONE + if not _profile_interactive: + return + if _discard_confirmation != null and _discard_confirmation.visible: + var confirmation_controls: Array[Control] = [ + _confirmation_confirm, + _keep_editing_button, + ] + for control: Control in confirmation_controls: + control.focus_mode = Control.FOCUS_ALL + ControllerFocusNavigation.configure_spatial_neighbors( + confirmation_controls + ) + return + var active_controls: Array[Control] = [] + match _controller_zone: + ControllerZone.ACCOUNT: + active_controls = account_controls + ControllerZone.CATEGORIES: + active_controls = category_controls + ControllerZone.OPTIONS: + if not option_groups.is_empty(): + _controller_option_depth = clampi( + _controller_option_depth, + 0, + option_groups.size() - 1, + ) + for item: Variant in option_groups[_controller_option_depth]: + var control := item as Control + if control != null: + active_controls.append(control) + ControllerZone.COLOR_PICKER: + active_controls = _color_picker_controller_controls() + for control: Control in active_controls: + var button := control as BaseButton + control.focus_mode = ( + Control.FOCUS_ALL + if button == null or not button.disabled + else Control.FOCUS_NONE + ) + ControllerFocusNavigation.configure_spatial_neighbors(active_controls) + + +func _focus_controller_zone() -> void: + if not _profile_interactive: + return + var controls: Array[Control] = [] + match _controller_zone: + ControllerZone.ACCOUNT: + controls = _account_controller_controls() + ControllerZone.CATEGORIES: + controls = _controls_under(_category_list) + ControllerZone.OPTIONS: + var groups: Array = _controller_option_groups() + if not groups.is_empty(): + for item: Variant in groups[clampi( + _controller_option_depth, 0, groups.size() - 1 + )]: + var option_control := item as Control + if option_control != null: + controls.append(option_control) + ControllerZone.COLOR_PICKER: + controls = _color_picker_controller_controls() + for control: Control in controls: + var button := control as BaseButton + if button != null and button.button_pressed: + button.grab_focus() + return + for control: Control in controls: + if control.focus_mode != Control.FOCUS_NONE: + control.grab_focus() + return + + +func _account_controller_controls() -> Array[Control]: + var controls: Array[Control] = [] + for control: Control in [ + _name_edit, + _customize_button, + _apply_button, + _revert_button, + _defaults_button, + ]: + if control != null and control.is_visible_in_tree(): + controls.append(control) + for control: Control in _controls_under(_suggestions): + if control not in controls: + controls.append(control) + return controls + + +func _controller_option_groups() -> Array: + var groups: Array = [] + if _option_list == null: + return groups + if _category_id != "fur_pattern": + groups.append(_controls_under(_option_list)) + return groups + var section_tabs := _option_list.find_child( + "FurSectionTabs", true, false + ) as Node + groups.append(_controls_under(section_tabs)) + if _active_fur_section == FUR_SECTION_PATTERNS: + groups.append(_controls_under(_option_list.find_child( + "FurPatternPartTabs", true, false + ))) + groups.append(_controls_under(_option_list.find_child( + "FurPatternGrid", true, false + ))) + else: + groups.append(_controls_under(_option_list.find_child( + "FurColorChannelGrid", true, false + ))) + var palette_controls: Array[Control] = _controls_under( + _option_list.find_child("FurPaletteGrid", true, false) + ) + var custom_picker := _option_list.find_child( + "FurCustomColorPicker", true, false + ) as Control + if custom_picker != null: + palette_controls.append(custom_picker) + groups.append(palette_controls) + return groups + + +func _controls_under(root: Node) -> Array[Control]: + var controls: Array[Control] = [] + if root == null: + return controls + _collect_controller_controls(root, controls) + return controls + + +func _collect_controller_controls( + root: Node, + output: Array[Control], +) -> void: + for child: Node in root.get_children(): + var control := child as Control + if control != null and not control.is_visible_in_tree(): + continue + if control is BaseButton or control is Slider or control is LineEdit: + output.append(control) + _collect_controller_controls(child, output) + + func has_unsaved_changes() -> bool: return _dirty @@ -295,6 +586,12 @@ func _build_ui() -> void: actions.size_flags_vertical = Control.SIZE_SHRINK_BEGIN actions.add_theme_constant_override("separation", 7) account_row.add_child(actions) + _customize_button = Button.new() + _customize_button.text = "customize" + _customize_button.custom_minimum_size.x = 96.0 + UtilityPageStyle.apply_compact_ocean_button(_customize_button) + _customize_button.pressed.connect(_enter_controller_customization) + actions.add_child(_customize_button) _apply_button = Button.new() _apply_button.text = "apply" _apply_button.custom_minimum_size.x = 72.0 @@ -307,12 +604,12 @@ func _build_ui() -> void: 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) + _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 @@ -377,16 +674,17 @@ func _build_ui() -> void: _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) + _reset_view_button = Button.new() + _reset_view_button.text = "↶" + _reset_view_button.tooltip_text = "reset view" + _reset_view_button.custom_minimum_size = Vector2(36.0, 36.0) + _reset_view_button.position = Vector2(-8.0, -8.0) + _reset_view_button.z_index = 2 + _reset_view_button.focus_mode = Control.FOCUS_NONE + _reset_view_button.pressed.connect(_preview.reset_view) + UtilityPageStyle.apply_compact_ocean_button(_reset_view_button) + _reset_view_button.add_theme_font_size_override("font_size", 22) + preview_layer.add_child(_reset_view_button) _discard_confirmation = PanelContainer.new() _discard_confirmation.visible = false @@ -414,16 +712,22 @@ func _build_ui() -> void: 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() - ) + _keep_editing_button.pressed.connect(_close_discard_confirmation) UtilityPageStyle.apply_ocean_button(_keep_editing_button) confirm_buttons.add_child(_keep_editing_button) _build_categories() +func _enter_controller_customization() -> void: + if not _profile_active or not _profile_interactive: + return + _controller_zone = ControllerZone.CATEGORIES + _controller_option_depth = 0 + _apply_controller_zone_focus() + call_deferred("_focus_controller_zone") + + func _build_categories() -> void: for child: Node in _category_list.get_children(): child.queue_free() @@ -461,7 +765,9 @@ func _refresh_options() -> void: _fur_custom_color_display = null _fur_custom_color_label = null for child: Node in _option_list.get_children(): + _option_list.remove_child(child) child.queue_free() + _refresh_controller_zone_after_options.call_deferred() if _category_id == VOICE_CATEGORY_ID: _build_voice_options() return @@ -994,6 +1300,7 @@ func _resize_feature_preview_image(image: Image, max_dimension: int) -> void: func _build_fur_options(options: Array) -> void: var section_tabs := HBoxContainer.new() + section_tabs.name = "FurSectionTabs" 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]: @@ -1075,6 +1382,7 @@ func _build_fur_pattern_options() -> void: pattern_scroll.vertical_scroll_mode = ScrollContainer.SCROLL_MODE_AUTO pattern_margin.add_child(pattern_scroll) var pattern_grid := GridContainer.new() + pattern_grid.name = "FurPatternGrid" 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) @@ -1270,6 +1578,7 @@ func _build_fur_palette( custom_picker.tooltip_text = "Choose a custom color for channel %d." % ( CharacterCustomizationCatalog.FUR_COLOR_IDS.find(category_id) + 1 ) + custom_picker.set_meta(&"fur_color_category_id", category_id) custom_picker.color_changed.connect( _select_custom_fur_color.bind(category_id) ) @@ -1341,6 +1650,7 @@ func _select_custom_fur_color(color: Color, category_id: String) -> void: ) _update_fur_custom_color_display(color) _preview.apply_appearance_profile(_draft_appearance) + _queue_draft_appearance_preview() _dirty = _draft_differs() _refresh_actions() @@ -1417,8 +1727,7 @@ func _theme_fur_color_picker_popup(button: ColorPickerButton) -> void: 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 + _fit_color_picker_popup(button) var popup_style := UtilityPageStyle.rounded_style( UtilityPageStyle.OCEAN_PANEL_DEEP, 20, @@ -1428,19 +1737,119 @@ func _theme_fur_color_picker_popup(button: ColorPickerButton) -> void: 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) + if not popup.about_to_popup.is_connected( + _on_controller_color_picker_opened.bind(button) + ): + popup.about_to_popup.connect( + _on_controller_color_picker_opened.bind(button) + ) + if not popup.popup_hide.is_connected( + _on_controller_color_picker_closed.bind(button) + ): + popup.popup_hide.connect( + _on_controller_color_picker_closed.bind(button) + ) + + +func _fit_color_picker_popup(button: ColorPickerButton) -> void: + var popup: PopupPanel = button.get_popup() + var picker: ColorPicker = button.get_picker() + var window_size: Vector2i = get_window().size + var available := Vector2i( + maxi(window_size.x - 32, 420), + maxi(window_size.y - 32, 340), + ) + var target := Vector2i( + mini(FUR_COLOR_PICKER_POPUP_SIZE.x, available.x), + mini(FUR_COLOR_PICKER_POPUP_SIZE.y, available.y), + ) + popup.min_size = target + popup.size = target + picker.custom_minimum_size = Vector2( + target.x - 48, + target.y - 48, + ) + picker.add_theme_constant_override( + "sv_width", + mini(FUR_COLOR_PICKER_SV_SIZE.x, target.x - 110), + ) + picker.add_theme_constant_override( + "sv_height", + mini(FUR_COLOR_PICKER_SV_SIZE.y, target.y - 180), + ) + + +func _on_controller_color_picker_opened(button: ColorPickerButton) -> void: + _active_color_picker_button = button + _active_color_picker_return_depth = _controller_option_depth + _controller_zone = ControllerZone.COLOR_PICKER + _fit_color_picker_popup(button) + _apply_controller_zone_focus.call_deferred() + _focus_controller_zone.call_deferred() + + +func _on_controller_color_picker_closed(button: ColorPickerButton) -> void: + if _active_color_picker_button != button: + return + _active_color_picker_button = null + _controller_zone = ControllerZone.OPTIONS + _controller_option_depth = _active_color_picker_return_depth + _apply_controller_zone_focus.call_deferred() + _focus_controller_zone.call_deferred() + + +func _close_controller_color_picker() -> void: + if _active_color_picker_button != null: + _active_color_picker_button.get_popup().hide() + else: + _controller_zone = ControllerZone.OPTIONS + _controller_option_depth = _active_color_picker_return_depth + _apply_controller_zone_focus() + call_deferred("_focus_controller_zone") + + +func _color_picker_controller_controls() -> Array[Control]: + if _active_color_picker_button == null: + return [] + return _controls_under(_active_color_picker_button.get_picker()) + + +func _adjust_controller_color_gamut(stick: Vector2, delta: float) -> void: + if _active_color_picker_button == null: + return + var color: Color = _active_color_picker_button.color + var saturation: float = clampf(color.s + stick.x * delta * 0.72, 0.0, 1.0) + var value: float = clampf(color.v - stick.y * delta * 0.72, 0.0, 1.0) + var adjusted := Color.from_hsv(color.h, saturation, value, 1.0) + _active_color_picker_button.color = adjusted + var category_id := str(_active_color_picker_button.get_meta( + &"fur_color_category_id", + _active_fur_color_id, + )) + _select_custom_fur_color(adjusted, category_id) + + +func _refresh_controller_zone_after_options() -> void: + if _controller_zone != ControllerZone.OPTIONS: + return + var groups: Array = _controller_option_groups() + if groups.is_empty(): + _controller_zone = ControllerZone.CATEGORIES + else: + _controller_option_depth = clampi( + _controller_option_depth, + 0, + groups.size() - 1, + ) + _apply_controller_zone_focus() + _focus_controller_zone() func _update_fur_custom_color_display(color: Color) -> void: @@ -1465,6 +1874,7 @@ func _update_fur_custom_color_display(color: Color) -> void: func _select_option(category_id: String, option_id: String) -> void: _draft_appearance[category_id] = option_id _preview.apply_appearance_profile(_draft_appearance) + _queue_draft_appearance_preview() _dirty = _draft_differs() _refresh_options() _refresh_actions() @@ -1479,6 +1889,7 @@ func _select_scale(value: float) -> void: ) _update_scale_value_label(resolved_scale) _preview.apply_appearance_profile(_draft_appearance) + _queue_draft_appearance_preview() _dirty = _draft_differs() _refresh_actions() @@ -1532,6 +1943,7 @@ func _on_conflict_result( ) UtilityPageStyle.apply_compact_ocean_button(anyway) _suggestions.add_child(anyway) + _apply_controller_zone_focus() func _on_experience_changed(_total_experience: int, _level: int) -> void: @@ -1607,6 +2019,8 @@ func _load_persisted() -> void: ) _name_edit.text = _draft_name _preview.apply_appearance_profile(_draft_appearance) + _appearance_preview_timer.stop() + _service.preview_appearance(_persisted_appearance) _dirty = false _allow_duplicate = false _name_status.text = "" @@ -1633,9 +2047,17 @@ func _show_confirmation(action: String) -> void: else: _confirmation_label.text = "Discard unsaved profile changes?" _confirmation_confirm.text = "discard changes" + _apply_controller_zone_focus() _keep_editing_button.grab_focus() +func _close_discard_confirmation() -> void: + _discard_confirmation.visible = false + _confirmation_action = "" + _apply_controller_zone_focus() + call_deferred("_focus_controller_zone") + + func _confirm_pending_action() -> void: _discard_confirmation.visible = false if _confirmation_action == "defaults": @@ -1644,12 +2066,30 @@ func _confirm_pending_action() -> void: _draft_speech_speed_id = VoiceProfilesType.DEFAULT_SPEED_ID _draft_call_id = VoiceProfilesType.DEFAULT_CALL_ID _preview.apply_appearance_profile(_draft_appearance) + _queue_draft_appearance_preview() _dirty = _draft_differs() _refresh_options() _refresh_actions() else: _confirm_discard() _confirmation_action = "" + _apply_controller_zone_focus() + call_deferred("_focus_controller_zone") + + +func _queue_draft_appearance_preview() -> void: + if _service == null or _appearance_preview_timer == null: + return + # Throttle continuous controls such as the custom color picker instead of + # debouncing them. Remote players should see an in-progress drag, while the + # reliable channel remains capped at one current snapshot per interval. + if _appearance_preview_timer.is_stopped(): + _appearance_preview_timer.start() + + +func _publish_draft_appearance() -> void: + if _service != null: + _service.preview_appearance(_draft_appearance) func _draft_differs() -> bool: @@ -1672,4 +2112,6 @@ func _refresh_actions() -> void: func _clear_suggestions() -> void: for child: Node in _suggestions.get_children(): + _suggestions.remove_child(child) child.queue_free() + _apply_controller_zone_focus() diff --git a/ui/profile_preview.gd b/ui/profile_preview.gd index 4239ea0..55026a4 100644 --- a/ui/profile_preview.gd +++ b/ui/profile_preview.gd @@ -60,7 +60,7 @@ func set_world_pixel_size(pixel_size: int) -> void: func _ready() -> void: - focus_mode = Control.FOCUS_ALL + focus_mode = Control.FOCUS_NONE texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST gui_input.connect(_on_gui_input) resized.connect(_refresh_pixelation_filter) @@ -91,20 +91,18 @@ func reset_view() -> void: _apply_camera_orbit() +func apply_controller_orbit(stick: Vector2, delta: float) -> void: + if absf(stick.x) > 0.1: + _rotate(stick.x * keyboard_speed * delta) + if absf(stick.y) > 0.1: + _zoom_preview(stick.y * CAMERA_ZOOM_STEP * 4.0 * delta) + + func _process(delta: float) -> void: _sync_world_lighting() if not has_focus(): return var axis := Input.get_axis("ui_left", "ui_right") - var right_stick: float = ( - _controller_mapping_manager.get_role_axis( - ControllerMappingManagerType.ROLE_RIGHT_STICK_X - ) - if _controller_mapping_manager != null - else Input.get_joy_axis(0, JOY_AXIS_RIGHT_X) - ) - if absf(right_stick) > 0.2: - axis = right_stick if absf(axis) > 0.1: _rotate(axis * keyboard_speed * delta) @@ -113,18 +111,15 @@ func _on_gui_input(event: InputEvent) -> void: if event is InputEventMouseButton and event.shift_pressed: if event.button_index == MOUSE_BUTTON_WHEEL_UP and event.pressed: _zoom_preview(-CAMERA_ZOOM_STEP) - grab_focus() accept_event() return if event.button_index == MOUSE_BUTTON_WHEEL_DOWN and event.pressed: _zoom_preview(CAMERA_ZOOM_STEP) - grab_focus() accept_event() return if event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_RIGHT: _dragging = event.pressed if event.pressed: - grab_focus() accept_event() elif event is InputEventMouseMotion and _dragging: _rotate(event.relative.x * drag_sensitivity)