diff --git a/art/exported/characters/base/netfishing_base_character.glb b/art/exported/characters/base/netfishing_base_character.glb index b4bb5d1..c7fcf83 100644 Binary files a/art/exported/characters/base/netfishing_base_character.glb and b/art/exported/characters/base/netfishing_base_character.glb differ diff --git a/art/source/characters/base/netfishing_character_source.blend b/art/source/characters/base/netfishing_character_source.blend index 8fde03b..2b345d1 100644 Binary files a/art/source/characters/base/netfishing_character_source.blend and b/art/source/characters/base/netfishing_character_source.blend differ diff --git a/art/source/characters/base/netfishing_character_source.blend1 b/art/source/characters/base/netfishing_character_source.blend1 index e4808eb..c6f3af2 100644 Binary files a/art/source/characters/base/netfishing_character_source.blend1 and b/art/source/characters/base/netfishing_character_source.blend1 differ diff --git a/player/player_visual_presenter.gd b/player/player_visual_presenter.gd index 8dd9f93..f29cff7 100644 --- a/player/player_visual_presenter.gd +++ b/player/player_visual_presenter.gd @@ -3,6 +3,22 @@ extends RefCounted const PlayerScene: PackedScene = preload("res://player/player.tscn") const RUNTIME_MATERIAL_META: StringName = &"netfishing_runtime_material" +const EAR_MESH_NAMES: Array[String] = [ + "ears_antlers_round", + "ears_bear", + "ears_bunny", + "ears_pointy_long", + "ears_pointy_short", + "ears_pointy_wide", +] +const TAIL_MESH_NAMES: Array[String] = [ + "tails_bear", + "tails_bunny", + "tails_cat", + "tails_fox", + "tails_gator", + "tails_pointy", +] static var _feature_uv_aspects: Dictionary = {} @@ -113,15 +129,17 @@ static func apply_appearance( var selected_ears: String = CharacterCustomizationCatalog.canonical_option_id( "ears", str(snapshot.get("ears", "none")) ) - var ear_names: Array[String] = [ - "ears_pointy_long", - "ears_pointy_short", - "ears_pointy_wide", - ] - for ear_name: String in ear_names: + for ear_name: String in EAR_MESH_NAMES: var ears: Node3D = skeleton.get_node_or_null(ear_name) as Node3D if ears != null: ears.visible = selected_ears == ear_name.trim_prefix("ears_") + var selected_tail: String = CharacterCustomizationCatalog.canonical_option_id( + "tail", str(snapshot.get("tail", "none")) + ) + for tail_name: String in TAIL_MESH_NAMES: + var tail: Node3D = skeleton.get_node_or_null(tail_name) as Node3D + if tail != null: + tail.visible = selected_tail == tail_name.trim_prefix("tails_") var fur_color: Color = CharacterCustomizationCatalog.option_color( "fur_pattern", str(snapshot.get("fur_pattern", "white")) @@ -130,8 +148,10 @@ static func apply_appearance( _set_mesh_color(skeleton, "body_main", fur_color) _set_mesh_color(skeleton, "head_pointy", fur_color) _set_mesh_color(skeleton, "head_round", fur_color) - for ear_name: String in ear_names: + for ear_name: String in EAR_MESH_NAMES: _set_mesh_color(skeleton, ear_name, fur_color) + for tail_name: String in TAIL_MESH_NAMES: + _set_mesh_color(skeleton, tail_name, fur_color) var eye_id: String = CharacterCustomizationCatalog.canonical_option_id( "eyes", str(snapshot.get("eyes", "simple_shine")) diff --git a/progression/character_customization_catalog.gd b/progression/character_customization_catalog.gd index ddcf0b3..7350425 100644 --- a/progression/character_customization_catalog.gd +++ b/progression/character_customization_catalog.gd @@ -24,8 +24,8 @@ const CATEGORY_LABELS: Dictionary = { } const SCALE_CATEGORY_ID: String = "scale" -const MIN_CHARACTER_SCALE: float = 0.75 -const MAX_CHARACTER_SCALE: float = 1.25 +const MIN_CHARACTER_SCALE: float = 0.5 +const MAX_CHARACTER_SCALE: float = 1.5 const DEFAULT_CHARACTER_SCALE: float = 1.0 const CHARACTER_SCALE_STEP: float = 0.05 @@ -51,16 +51,24 @@ const OPTIONS: Dictionary = { # authored pattern layer is ready. "fur_pattern": [ {"id": "white", "label": "white", "color": Color("f2f0e8")}, + {"id": "cream", "label": "cream", "color": Color("e6d39b")}, {"id": "gray", "label": "gray", "color": Color("819398")}, {"id": "charcoal", "label": "charcoal", "color": Color("34444a")}, {"id": "brown", "label": "brown", "color": Color("7b4a32")}, {"id": "orange", "label": "orange", "color": Color("c86c36")}, + {"id": "red", "label": "red", "color": Color("a9433f")}, + {"id": "pink", "label": "pink", "color": Color("d8899e")}, {"id": "yellow", "label": "yellow", "color": Color("d8c545")}, {"id": "green", "label": "green", "color": Color("6f913c")}, {"id": "teal", "label": "teal", "color": Color("3a8790")}, + {"id": "blue", "label": "blue", "color": Color("4d76a8")}, + {"id": "purple", "label": "purple", "color": Color("76558f")}, ], "ears": [ {"id": "none", "label": "none"}, + {"id": "antlers_round", "label": "antlers"}, + {"id": "bear", "label": "bear"}, + {"id": "bunny", "label": "bunny"}, {"id": "pointy_long", "label": "long"}, {"id": "pointy_short", "label": "short"}, {"id": "pointy_wide", "label": "wide"}, @@ -74,13 +82,22 @@ const OPTIONS: Dictionary = { ], "nose": [{"id": "dog_round", "label": "round"}], "mouth": [{"id": "three", "label": "three"}], - "tail": [{"id": "none", "label": "none"}], + "tail": [ + {"id": "none", "label": "none"}, + {"id": "bear", "label": "bear"}, + {"id": "bunny", "label": "bunny"}, + {"id": "cat", "label": "cat"}, + {"id": "fox", "label": "fox"}, + {"id": "gator", "label": "gator"}, + {"id": "pointy", "label": "pointy"}, + ], } const LEGACY_OPTION_ALIASES: Dictionary = { "species": {"default": "round"}, "fur_pattern": {"solid": "white"}, "ears": {"default": "none"}, + "tail": {"default": "none"}, "eyes": {"default": "simple_shine"}, "nose": {"default": "dog_round"}, "mouth": {"default": "three"}, @@ -127,6 +144,82 @@ static func options_for(category_id: String) -> Array: return OPTIONS.get(category_id, []) +static func feature_option_groups(category_id: String) -> Array: + if category_id not in FEATURE_CATEGORIES: + return [] + var options: Array = options_for(category_id) + var option_ids: Array[String] = [] + for option: Dictionary in options: + option_ids.append(str(option.get("id", ""))) + var grouped_options: Dictionary = {} + var group_order: Array[String] = [] + for option: Dictionary in options: + var option_id := str(option.get("id", "")) + var group_id := _feature_variant_root(option_id, option_ids) + if not grouped_options.has(group_id): + grouped_options[group_id] = [] + group_order.append(group_id) + var group: Array = grouped_options[group_id] as Array + group.append(option) + grouped_options[group_id] = group + var result: Array = [] + for group_id: String in group_order: + result.append({ + "id": group_id, + "options": grouped_options[group_id], + }) + return result + + +static func _feature_variant_root( + option_id: String, + option_ids: Array[String], +) -> String: + if option_id == "none": + return option_id + # Prefer the shortest authored base option. This keeps nested names such as + # `beady_extra_shine` in the `beady` drawer rather than creating drawers + # inside drawers. + var authored_root := "" + for candidate_id: String in option_ids: + if candidate_id == "none": + continue + if ( + option_id != candidate_id + and not option_id.begins_with(candidate_id + "_") + ): + continue + var has_descendant := false + for descendant_id: String in option_ids: + if descendant_id.begins_with(candidate_id + "_"): + has_descendant = true + break + if ( + has_descendant + and ( + authored_root.is_empty() + or candidate_id.length() < authored_root.length() + ) + ): + authored_root = candidate_id + if not authored_root.is_empty(): + return authored_root + + # Some families have no unqualified base asset. Group those siblings by + # their leading name segment, making the sorted first asset representative. + var separator_index := option_id.find("_") + if separator_index <= 0: + return option_id + var leading_segment := option_id.substr(0, separator_index) + var sibling_count := 0 + for candidate_id: String in option_ids: + if candidate_id.begins_with(leading_segment + "_"): + sibling_count += 1 + if sibling_count >= 2: + return leading_segment + return option_id + + static func texture_for(category_id: String, option_id: String) -> Texture2D: if category_id not in FEATURE_CATEGORIES: return null diff --git a/ui/profile_page.gd b/ui/profile_page.gd index b895bac..4a9f074 100644 --- a/ui/profile_page.gd +++ b/ui/profile_page.gd @@ -3,6 +3,7 @@ extends Control const CHECK_DEBOUNCE_SECONDS: float = 0.4 const OPTION_GRID_COLUMNS: int = 6 +const FEATURE_DRAWER_ANIMATION_SECONDS: float = 0.16 const ControllerMappingManagerType = preload( "res://settings/controller_mapping_manager.gd" ) @@ -45,6 +46,8 @@ var _experience_level: Label var _experience_progress: ProgressBar var _experience_value: Label var _feature_preview_cache: Dictionary = {} +var _expanded_feature_drawers: Dictionary = {} +var _feature_drawer_animation_key: String = "" var _scale_value_label: Label var _voice_preview: AnimaleseVoiceType var _voice_preview_tween: Tween @@ -630,7 +633,7 @@ func _build_scale_option() -> void: _option_list.add_child(gameplay_note) -func _build_feature_preview_options(options: Array) -> void: +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 @@ -646,45 +649,153 @@ func _build_feature_preview_options(options: Array) -> void: var selected_id := CharacterCustomizationCatalog.canonical_option_id( _category_id, str(_draft_appearance.get(_category_id, "")) ) - for option: Dictionary in options: - var option_id := str(option.get("id", "")) - var button := Button.new() - var is_none := option_id == "none" - button.text = "×" if is_none else "" - button.tooltip_text = "none" if is_none else str( - option.get("label", option_id) + var groups: Array = CharacterCustomizationCatalog.feature_option_groups( + _category_id + ) + var expanded_drawer_id := str( + _expanded_feature_drawers.get(_category_id, "") + ) + if expanded_drawer_id.is_empty(): + for group: Dictionary in groups: + var group_options: Array = group.get("options", []) as Array + if group_options.size() <= 1: + continue + var representative_id := str( + (group_options[0] as Dictionary).get("id", "") + ) + for option: Dictionary in group_options: + if ( + str(option.get("id", "")) == selected_id + and selected_id != representative_id + ): + expanded_drawer_id = str(group.get("id", "")) + _expanded_feature_drawers[_category_id] = ( + expanded_drawer_id + ) + break + if not expanded_drawer_id.is_empty(): + break + + var animation_key := _feature_drawer_animation_key + for group: Dictionary in groups: + var group_id := str(group.get("id", "")) + var group_options: Array = group.get("options", []) as Array + if group_options.is_empty(): + continue + var has_variants := group_options.size() > 1 + var is_expanded := has_variants and group_id == expanded_drawer_id + for option_index: int in range(group_options.size()): + if option_index > 0 and not is_expanded: + continue + var option: Dictionary = group_options[option_index] as Dictionary + var button := _build_feature_option_button( + option, + selected_id, + group_id, + option_index == 0, + has_variants, + is_expanded, + ) + grid.add_child(button) + if ( + option_index > 0 + and animation_key == "%s:%s" % [_category_id, group_id] + ): + _animate_feature_drawer_button(button) + _feature_drawer_animation_key = "" + + +func _build_feature_option_button( + option: Dictionary, + selected_id: String, + drawer_id: String, + is_representative: bool, + has_variants: bool, + is_expanded: bool, +) -> Button: + var option_id := str(option.get("id", "")) + var button := Button.new() + var is_none := option_id == "none" + button.text = "×" if is_none else "" + button.tooltip_text = "none" if is_none else str( + option.get("label", option_id) + ) + if is_representative and has_variants: + button.tooltip_text += ( + " (hide variants)" if is_expanded else " (show variants)" ) - button.toggle_mode = true - button.button_pressed = selected_id == option_id - button.custom_minimum_size = Vector2(84.0, 64.0) - button.alignment = HORIZONTAL_ALIGNMENT_CENTER - button.clip_contents = true + button.toggle_mode = true + button.button_pressed = selected_id == option_id + button.custom_minimum_size = Vector2(84.0, 64.0) + button.alignment = HORIZONTAL_ALIGNMENT_CENTER + button.clip_contents = true + if is_representative and has_variants: + button.pressed.connect( + _select_feature_drawer.bind(_category_id, drawer_id, option_id) + ) + else: button.pressed.connect(_select_option.bind(_category_id, option_id)) - UtilityPageStyle.apply_compact_ocean_button(button) - button.custom_minimum_size = Vector2(84.0, 64.0) - if is_none: - button.add_theme_font_size_override("font_size", 28) - else: - var preview := TextureRect.new() - preview.texture = _feature_preview_texture( - _category_id, option_id - ) - preview.expand_mode = TextureRect.EXPAND_IGNORE_SIZE - preview.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED - preview.mouse_filter = Control.MOUSE_FILTER_IGNORE - var preview_height := 50.0 - var preview_width := clampf( - preview_height * PlayerVisualPresenter.feature_uv_aspect( - _category_id - ), - 32.0, - 74.0, - ) - preview.custom_minimum_size = Vector2(preview_width, preview_height) - preview.size = Vector2(preview_width, preview_height) - preview.position = Vector2((84.0 - preview_width) * 0.5, 7.0) - button.add_child(preview) - grid.add_child(button) + UtilityPageStyle.apply_compact_ocean_button(button) + button.custom_minimum_size = Vector2(84.0, 64.0) + if is_none: + button.add_theme_font_size_override("font_size", 28) + else: + var preview := TextureRect.new() + preview.texture = _feature_preview_texture(_category_id, option_id) + preview.expand_mode = TextureRect.EXPAND_IGNORE_SIZE + preview.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED + preview.mouse_filter = Control.MOUSE_FILTER_IGNORE + var preview_height := 50.0 + var preview_width := clampf( + preview_height * PlayerVisualPresenter.feature_uv_aspect(_category_id), + 32.0, + 74.0, + ) + preview.custom_minimum_size = Vector2(preview_width, preview_height) + preview.size = Vector2(preview_width, preview_height) + preview.position = Vector2((84.0 - preview_width) * 0.5, 7.0) + button.add_child(preview) + if is_representative and has_variants: + var indicator := Label.new() + indicator.text = "<" if is_expanded else ">" + indicator.position = Vector2(66.0, 1.0) + indicator.size = Vector2(14.0, 16.0) + indicator.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + indicator.mouse_filter = Control.MOUSE_FILTER_IGNORE + indicator.add_theme_font_size_override("font_size", 13) + indicator.add_theme_color_override( + "font_color", UtilityPageStyle.OCEAN_TEXT_PRIMARY + ) + button.add_child(indicator) + return button + + +func _select_feature_drawer( + category_id: String, + drawer_id: String, + option_id: String, +) -> void: + if str(_expanded_feature_drawers.get(category_id, "")) == drawer_id: + _expanded_feature_drawers.erase(category_id) + else: + _expanded_feature_drawers[category_id] = drawer_id + _feature_drawer_animation_key = "%s:%s" % [category_id, drawer_id] + _select_option(category_id, option_id) + + +func _animate_feature_drawer_button(button: Button) -> void: + button.scale = Vector2(0.05, 1.0) + var faded := button.modulate + faded.a = 0.0 + button.modulate = faded + var tween := button.create_tween() + tween.set_parallel(true) + tween.tween_property( + button, "scale", Vector2.ONE, FEATURE_DRAWER_ANIMATION_SECONDS + ).set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_OUT) + tween.tween_property( + button, "modulate:a", 1.0, FEATURE_DRAWER_ANIMATION_SECONDS + ).set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_OUT) func _feature_preview_texture( diff --git a/ui/profile_preview.gd b/ui/profile_preview.gd index 5076cd3..713a6ba 100644 --- a/ui/profile_preview.gd +++ b/ui/profile_preview.gd @@ -9,6 +9,7 @@ const UIReferencePresentationType = preload( ) const FRONT_FACING_YAW: float = PI const DEFAULT_CAMERA_DISTANCE: float = 1.9 +const DEFAULT_CAMERA_PITCH: float = 0.12 const MIN_CAMERA_DISTANCE: float = 1.05 const MAX_CAMERA_DISTANCE: float = 2.8 const CAMERA_ZOOM_STEP: float = 0.14 @@ -25,7 +26,7 @@ const CAMERA_TARGET_HEIGHT: float = 0.95 var _dragging: bool = false var _camera_yaw: float = 0.0 -var _camera_pitch: float = 0.0 +var _camera_pitch: float = DEFAULT_CAMERA_PITCH var _camera_distance: float = DEFAULT_CAMERA_DISTANCE var _visuals: Node3D var _controller_mapping_manager: ControllerMappingManagerType @@ -85,11 +86,9 @@ func apply_appearance_profile(profile: Dictionary) -> void: func reset_view() -> void: _preview_root.rotation.y = FRONT_FACING_YAW _camera_yaw = 0.0 - _camera_pitch = 0.0 + _camera_pitch = DEFAULT_CAMERA_PITCH _camera_distance = DEFAULT_CAMERA_DISTANCE _apply_camera_orbit() - if _preview_camera != null: - _preview_camera.position.z = DEFAULT_CAMERA_DISTANCE func _process(delta: float) -> void: diff --git a/world/interactables/fishing_shop_world.tscn b/world/interactables/fishing_shop_world.tscn index a1eaaa9..7221005 100644 --- a/world/interactables/fishing_shop_world.tscn +++ b/world/interactables/fishing_shop_world.tscn @@ -15,6 +15,7 @@ script = ExtResource("3_display") species_id = "pointy" fur_color_id = "white" ears_id = "pointy_wide" +tail_id = "fox" eyes_id = "punk_purple" nose_id = "triangle_small" mouth_id = "slanted" diff --git a/world/world_character_display.gd b/world/world_character_display.gd index 024551d..a145f3b 100644 --- a/world/world_character_display.gd +++ b/world/world_character_display.gd @@ -25,6 +25,15 @@ const PlayerVisualPresenterType = preload( "pointy_short", "pointy_wide", ) var ears_id: String = "none" +@export_enum( + "none", + "bear", + "bunny", + "cat", + "fox", + "gator", + "pointy", +) var tail_id: String = "none" @export var eyes_id: String = "simple_shine" @export var nose_id: String = "dog_round" @export var mouth_id: String = "three" @@ -48,6 +57,7 @@ func _ready() -> void: appearance["species"] = species_id appearance["fur_pattern"] = fur_color_id appearance["ears"] = ears_id + appearance["tail"] = tail_id appearance["eyes"] = eyes_id appearance["nose"] = nose_id appearance["mouth"] = mouth_id