diff --git a/.gitignore b/.gitignore index 33ba5fe..da5544a 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,7 @@ # Blender backup files *.blend1 + +# Python cache files generated by Blender helper scripts +__pycache__/ +*.py[cod] diff --git a/art/exported/characters/base/straywild_base_character.glb b/art/exported/characters/base/straywild_base_character.glb index 58eb7cd..c62241a 100644 Binary files a/art/exported/characters/base/straywild_base_character.glb and b/art/exported/characters/base/straywild_base_character.glb differ diff --git a/art/source/characters/base/straywild_character_source.blend b/art/source/characters/base/straywild_character_source.blend index 17a1b4d..7c0adc3 100644 Binary files a/art/source/characters/base/straywild_character_source.blend and b/art/source/characters/base/straywild_character_source.blend differ diff --git a/network/network_protocol.gd b/network/network_protocol.gd index d586c23..2083849 100644 --- a/network/network_protocol.gd +++ b/network/network_protocol.gd @@ -3,7 +3,7 @@ extends RefCounted const WorldLayoutType = preload("res://world/world_layout.gd") -const PROTOCOL_VERSION: int = 13 +const PROTOCOL_VERSION: int = 14 const GAME_BUILD: String = "prealpha" const MAX_GAME_VERSION_LENGTH: int = 64 const MAX_DISPLAY_NAME_LENGTH: int = 24 @@ -274,6 +274,13 @@ static func validate_client_hello(data: Variant) -> String: return "Capabilities are invalid." if typeof(payload["cosmetic_snapshot"]) != TYPE_DICTIONARY: return "Cosmetic snapshot is invalid." + var cosmetic_snapshot: Dictionary = payload["cosmetic_snapshot"] + if ( + not CharacterCustomizationCatalog.validate_snapshot(cosmetic_snapshot) + or CharacterCustomizationCatalog.sanitized_snapshot(cosmetic_snapshot) + != cosmetic_snapshot + ): + return "Cosmetic snapshot is invalid." if ( not NetworkIdentityCrypto.valid_fingerprint( payload["identity_fingerprint"] diff --git a/network/portable_data_migration.gd b/network/portable_data_migration.gd index 2de0acb..ec74de9 100644 --- a/network/portable_data_migration.gd +++ b/network/portable_data_migration.gd @@ -299,6 +299,23 @@ static func _valid_owned_data(filename: String, data: Dictionary) -> bool: and typeof(data.get("active_slot_id")) == TYPE_STRING and typeof(data.get("slots")) == TYPE_ARRAY ) + if filename == "player_appearance.json": + var version_value: Variant = data.get("format_version") + if typeof(version_value) not in [TYPE_INT, TYPE_FLOAT]: + return false + var version: int = int(version_value) + if ( + version < 1 + or version > PlayerAppearanceStore.FORMAT_VERSION + or typeof(data.get("appearance")) != TYPE_DICTIONARY + ): + return false + var appearance: Dictionary = data["appearance"] + if version < PlayerAppearanceStore.FORMAT_VERSION: + appearance = CharacterCustomizationCatalog.sanitized_snapshot( + appearance + ) + return CharacterCustomizationCatalog.validate_snapshot(appearance) return ( filename not in LEGACY_FILES or int(data.get("format_version", -1)) == 1 diff --git a/player/player.gd b/player/player.gd index 6a3fdec..c52ef21 100644 --- a/player/player.gd +++ b/player/player.gd @@ -3587,7 +3587,9 @@ func get_nameplate_anchor_position() -> Vector3: var skeleton := get_node_or_null( "Visuals/CharacterRig/CharacterRig/Skeleton3D" ) as Skeleton3D - for head_name: String in ["head_round", "head_pointy"]: + for head_name: String in CharacterCustomizationCatalog.model_mesh_names( + "species" + ): var mesh_instance := ( skeleton.get_node_or_null(head_name) as MeshInstance3D if skeleton != null diff --git a/player/player_visual_presenter.gd b/player/player_visual_presenter.gd index b071b2c..024c2b4 100644 --- a/player/player_visual_presenter.gd +++ b/player/player_visual_presenter.gd @@ -7,22 +7,31 @@ const RUNTIME_MATERIAL_META: StringName = &"straywild_runtime_material" const RUNTIME_FUR_PATTERN_MATERIAL_META: StringName = ( &"straywild_runtime_fur_pattern_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", +const APPLIED_APPEARANCE_META: StringName = &"straywild_applied_appearance" +const MODEL_CATEGORY_IDS: PackedStringArray = [ + "species", + "arms", + "ears", + "tail", + "special_back", + "special_beak", + "special_head", + "special_horns", + "special_tusks", + "special_wings", ] +const MODEL_CATEGORY_DEFAULTS: Dictionary = { + "species": "round", + "arms": "mammal", + "ears": "none", + "tail": "none", + "special_back": "none", + "special_beak": "none", + "special_head": "none", + "special_horns": "none", + "special_tusks": "none", + "special_wings": "none", +} static var _feature_uv_aspects: Dictionary = {} @@ -33,7 +42,9 @@ static func feature_uv_aspect(feature_name: String) -> float: var visuals := instantiate_visuals() var skeleton := visuals.find_child("Skeleton3D", true, false) as Skeleton3D var aspects: Array[float] = [] - for head_name: String in ["head_round", "head_pointy"]: + for head_name: String in CharacterCustomizationCatalog.model_mesh_names( + "species" + ): var mesh_instance := skeleton.get_node_or_null( "%s_%s" % [head_name, feature_name] ) as MeshInstance3D @@ -108,57 +119,67 @@ static func apply_appearance( var skeleton: Skeleton3D = visuals.find_child("Skeleton3D", true, false) as Skeleton3D if skeleton == null: return + var previous: Dictionary = visuals.get_meta( + APPLIED_APPEARANCE_META, + {}, + ) as Dictionary + var first_application := ( + previous.is_empty() + or not CharacterCustomizationCatalog.validate_snapshot(previous) + ) + var changed_model_categories: Array[String] = [] + for category_id: String in MODEL_CATEGORY_IDS: + if ( + first_application + or _model_category_selection(previous, category_id) + != _model_category_selection(snapshot, category_id) + ): + _set_model_category_visibility(skeleton, snapshot, category_id) + changed_model_categories.append(category_id) - var species_id: String = CharacterCustomizationCatalog.canonical_option_id( - "species", str(snapshot.get("species", "round")) - ) - var head_id: String = "head_pointy" if species_id == "pointy" else "head_round" - var head_ids: Array[String] = ["head_pointy", "head_round"] - for head_name: String in head_ids: - var head: Node3D = skeleton.get_node_or_null(head_name) as Node3D - if head != null: - head.visible = head_name == head_id - for feature: String in ["eyes", "mouth", "nose"]: - var decal: Node3D = skeleton.get_node_or_null( - "%s_%s" % [head_name, feature] - ) as Node3D - if decal != null: - var feature_id := CharacterCustomizationCatalog.canonical_option_id( - feature, str(snapshot.get(feature, "none")) + var head_changed := first_application or "species" in changed_model_categories + if head_changed: + _set_head_feature_visibility(skeleton, snapshot) + else: + for feature_name: String in ["eyes", "mouth", "nose"]: + if _appearance_option_changed(previous, snapshot, feature_name): + _set_selected_head_feature_visibility( + skeleton, + snapshot, + feature_name, ) - decal.visible = ( - head_name == head_id and feature_id != "none" - ) - - var selected_ears: String = CharacterCustomizationCatalog.canonical_option_id( - "ears", str(snapshot.get("ears", "none")) - ) - 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")) ) - for fur_mesh_name: String in [ - "body_main", - "body_arms", - "head_pointy", - "head_round", - ]: - _set_fur_mesh(skeleton, fur_mesh_name, snapshot, fur_color) - for ear_name: String in EAR_MESH_NAMES: - _set_fur_mesh(skeleton, ear_name, snapshot, fur_color) - for tail_name: String in TAIL_MESH_NAMES: - _set_fur_mesh(skeleton, tail_name, snapshot, fur_color) + var fur_changed := first_application + for category_id: String in CharacterCustomizationCatalog.FUR_COLOR_IDS: + fur_changed = ( + fur_changed + or _appearance_option_changed(previous, snapshot, category_id) + ) + for category_id: String in CharacterCustomizationCatalog.FUR_STYLE_IDS: + fur_changed = ( + fur_changed + or _appearance_option_changed(previous, snapshot, category_id) + ) + if fur_changed: + _set_fur_mesh(skeleton, "body_main", snapshot, fur_color) + for category_id: String in MODEL_CATEGORY_IDS: + _set_selected_category_fur( + skeleton, + snapshot, + category_id, + fur_color, + ) + else: + for category_id: String in changed_model_categories: + _set_selected_category_fur( + skeleton, + snapshot, + category_id, + fur_color, + ) var eye_id: String = CharacterCustomizationCatalog.canonical_option_id( "eyes", str(snapshot.get("eyes", "simple_shine")) @@ -169,44 +190,153 @@ static func apply_appearance( var nose_id: String = CharacterCustomizationCatalog.canonical_option_id( "nose", str(snapshot.get("nose", "dog_round")) ) - _set_feature_texture( - skeleton, - "eyes", - CharacterCustomizationCatalog.texture_for("eyes", eye_id), - ) - _set_feature_texture( - skeleton, - "mouth", - CharacterCustomizationCatalog.texture_for("mouth", mouth_id), - ) - _set_feature_texture( - skeleton, - "nose", - CharacterCustomizationCatalog.texture_for("nose", nose_id), - ) + if head_changed or _appearance_option_changed(previous, snapshot, "eyes"): + _set_selected_head_feature_texture( + skeleton, + snapshot, + "eyes", + CharacterCustomizationCatalog.texture_for("eyes", eye_id), + ) + if head_changed or _appearance_option_changed(previous, snapshot, "mouth"): + _set_selected_head_feature_texture( + skeleton, + snapshot, + "mouth", + CharacterCustomizationCatalog.texture_for("mouth", mouth_id), + ) + if head_changed or _appearance_option_changed(previous, snapshot, "nose"): + _set_selected_head_feature_texture( + skeleton, + snapshot, + "nose", + CharacterCustomizationCatalog.texture_for("nose", nose_id), + ) + visuals.set_meta(APPLIED_APPEARANCE_META, snapshot.duplicate(true)) -static func _set_feature_texture( +static func _set_selected_head_feature_texture( skeleton: Skeleton3D, + snapshot: Dictionary, feature_name: String, texture: Texture2D, ) -> void: - for head_name: String in ["head_pointy", "head_round"]: - var mesh_instance: MeshInstance3D = skeleton.get_node_or_null( - "%s_%s" % [head_name, feature_name] - ) as MeshInstance3D - if texture == null: - if mesh_instance != null: - mesh_instance.visible = false - continue - var material: StandardMaterial3D = _runtime_material(mesh_instance) - if material != null: - # Feature plates are transparent geometry. The exported base model may - # have no preview texture, so enforce the runtime alpha mode when a - # drop-in PNG is assigned. - material.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA - material.albedo_color = Color.WHITE - material.albedo_texture = texture + var head_name := _model_category_selection(snapshot, "species") + var mesh_instance: MeshInstance3D = skeleton.get_node_or_null( + "%s_%s" % [head_name, feature_name] + ) as MeshInstance3D + if texture == null: + if mesh_instance != null: + mesh_instance.visible = false + return + var material: StandardMaterial3D = _runtime_material(mesh_instance) + if material != null: + # Feature plates are transparent geometry. The exported base model may + # have no preview texture, so enforce the runtime alpha mode when a + # drop-in PNG is assigned. + material.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA + material.albedo_color = Color.WHITE + material.albedo_texture = texture + + +static func _set_model_category_visibility( + skeleton: Skeleton3D, + snapshot: Dictionary, + category_id: String, +) -> void: + var selected_mesh := CharacterCustomizationCatalog.model_mesh_name( + category_id, + str(snapshot.get(category_id, "none")), + ) + for mesh_name: String in CharacterCustomizationCatalog.model_mesh_names( + category_id + ): + var mesh_node := skeleton.get_node_or_null(mesh_name) as Node3D + if mesh_node != null: + mesh_node.visible = mesh_name == selected_mesh + + +static func _set_head_feature_visibility( + skeleton: Skeleton3D, + snapshot: Dictionary, +) -> void: + var selected_head := _model_category_selection(snapshot, "species") + for head_name: String in CharacterCustomizationCatalog.model_mesh_names( + "species" + ): + for feature_name: String in ["eyes", "mouth", "nose"]: + var decal := skeleton.get_node_or_null( + "%s_%s" % [head_name, feature_name] + ) as Node3D + if decal != null: + decal.visible = ( + head_name == selected_head + and _appearance_option(snapshot, feature_name, "none") + != "none" + ) + + +static func _set_selected_head_feature_visibility( + skeleton: Skeleton3D, + snapshot: Dictionary, + feature_name: String, +) -> void: + var head_name := _model_category_selection(snapshot, "species") + var decal := skeleton.get_node_or_null( + "%s_%s" % [head_name, feature_name] + ) as Node3D + if decal != null: + decal.visible = _appearance_option( + snapshot, + feature_name, + "none", + ) != "none" + + +static func _set_selected_category_fur( + skeleton: Skeleton3D, + snapshot: Dictionary, + category_id: String, + base_color: Color, +) -> void: + var mesh_name := _model_category_selection(snapshot, category_id) + if not mesh_name.is_empty(): + _set_fur_mesh(skeleton, mesh_name, snapshot, base_color) + + +static func _model_category_selection( + snapshot: Dictionary, + category_id: String, +) -> String: + return CharacterCustomizationCatalog.model_mesh_name( + category_id, + _appearance_option( + snapshot, + category_id, + str(MODEL_CATEGORY_DEFAULTS.get(category_id, "none")), + ), + ) + + +static func _appearance_option( + snapshot: Dictionary, + category_id: String, + fallback: String = "", +) -> String: + return CharacterCustomizationCatalog.canonical_option_id( + category_id, + str(snapshot.get(category_id, fallback)), + ) + + +static func _appearance_option_changed( + previous: Dictionary, + current: Dictionary, + category_id: String, +) -> bool: + return _appearance_option(previous, category_id) != _appearance_option( + current, + category_id, + ) static func _set_fur_mesh( diff --git a/progression/character_customization_catalog.gd b/progression/character_customization_catalog.gd index 5a02f09..b634964 100644 --- a/progression/character_customization_catalog.gd +++ b/progression/character_customization_catalog.gd @@ -3,6 +3,7 @@ extends RefCounted const CATEGORY_IDS: PackedStringArray = [ "species", + "arms", "scale", "fur_pattern", "ears", @@ -10,13 +11,15 @@ const CATEGORY_IDS: PackedStringArray = [ "nose", "mouth", "tail", + "special", ] -# CATEGORY_IDS controls the visible Profile navigation. The additional fur -# fields are edited together on the existing fur page, but remain explicit in -# saves and signed multiplayer appearance snapshots. +# CATEGORY_IDS controls the visible Profile navigation. The component fur and +# independent special-slot fields are edited within grouped pages, but remain +# explicit in saves and signed multiplayer appearance snapshots. const SNAPSHOT_IDS: PackedStringArray = [ "species", + "arms", "scale", "fur_pattern", "fur_style", @@ -32,10 +35,17 @@ const SNAPSHOT_IDS: PackedStringArray = [ "nose", "mouth", "tail", + "special_back", + "special_beak", + "special_head", + "special_horns", + "special_tusks", + "special_wings", ] const CATEGORY_LABELS: Dictionary = { "species": "head", + "arms": "arms", "scale": "size", "fur_pattern": "fur", "ears": "ears", @@ -43,6 +53,120 @@ const CATEGORY_LABELS: Dictionary = { "nose": "nose", "mouth": "mouth", "tail": "tail", + "special": "special", +} + +const SPECIAL_SLOT_IDS: PackedStringArray = [ + "special_back", + "special_beak", + "special_head", + "special_horns", + "special_tusks", + "special_wings", +] +const SPECIAL_SLOT_LABELS: Dictionary = { + "special_back": "back", + "special_beak": "beak", + "special_head": "head", + "special_horns": "horns", + "special_tusks": "tusks", + "special_wings": "wings", +} + +const MODEL_OPTION_MESH_NAMES: Dictionary = { + "species": { + "round": "head_round", + "pointy": "head_pointy", + "anteater": "head_anteater", + "axolotl": "head_axolotl", + "bat": "head_bat", + "bird": "head_bird", + "boar": "head_boar", + "butterfly": "head_butterfly", + "donkey": "head_donkey", + "elephant": "head_elephant", + "goat": "head_goat", + "hamster": "head_hamster", + "horse": "head_horse", + "moth": "head_moth", + "opossum": "head_opossum", + "owl": "head_owl", + "panda": "head_panda", + "pig": "head_pig", + "shark": "head_shark", + "sheep": "head_sheep", + "snail": "head_snail", + }, + "arms": { + "mammal": "arms_mammal", + "fish": "arms_fish", + "avian": "arms_avian", + }, + "ears": { + "antlers_round": "ears_antlers_round", + "anteater": "ears_anteater", + "axolotl": "ears_axolotl", + "bat": "ears_bat", + "bear": "ears_bear", + "boar": "ears_boar", + "bunny": "ears_bunny", + "butterfly": "ears_butterfly", + "cat": "ears_cat", + "donkey": "ears_donkey", + "elephant": "ears_elephant", + "fox": "ears_fox", + "goat": "ears_goat", + "hamster": "ears_hamster", + "horse": "ears_horse", + "long": "ears_long", + "moth": "ears_moth", + "opossum": "ears_opossum", + "owl": "ears_owl", + "panda": "ears_panda", + "pig": "ears_pig", + }, + "tail": { + "anteater": "tails_anteater", + "axolotl": "tails_axolotl", + "bear": "tails_bear", + "bird": "tails_bird", + "boar": "tails_boar", + "bunny": "tails_bunny", + "butterfly": "tails_butterfly", + "cat": "tails_cat", + "donkey": "tail_donkey", + "fox": "tails_fox", + "gator": "tails_gator", + "horse": "tails_horse", + "moth": "tails_moth", + "opossum": "tails_opossum", + "pig": "tails_pig", + "pointy": "tails_pointy", + "shark": "tails_shark", + }, + "special_back": { + "shell": "back_shell", + }, + "special_beak": { + "bird": "beak_bird", + "owl": "beak_owl", + }, + "special_head": { + "fin": "head_fin", + }, + "special_horns": { + "goat": "horns_goat", + "sheep": "horns_sheep", + }, + "special_tusks": { + "boar": "tusks_boar", + "elephant": "tusks_elephant", + }, + "special_wings": { + "bat": "wings_bat", + "butterfly": "wings_butterfly", + "moth": "wings_moth", + }, } const SCALE_CATEGORY_ID: String = "scale" @@ -86,6 +210,12 @@ const DEFAULT_FUR_STYLE: String = "solid" const FUR_PATTERN_ASSET_ROOT: String = ( "res://art/exported/characters/patterns" ) +const FUR_PATTERN_COMPONENT_ALIASES: Dictionary = { + "arms_mammal": "body_arms", + "ears_cat": "ears_pointy_short", + "ears_fox": "ears_pointy_wide", + "ears_long": "ears_pointy_long", +} const FEATURE_TEXTURE_CACHE_LIMIT: int = 24 const FEATURE_CATEGORIES: PackedStringArray = ["eyes", "nose", "mouth"] @@ -134,8 +264,32 @@ const FUR_COLOR_OPTIONS: Array = [ ] const OPTIONS: Dictionary = { "species": [ - {"id": "round", "label": "round"}, - {"id": "pointy", "label": "pointy"}, + {"id": "round", "label": "cat"}, + {"id": "pointy", "label": "fox"}, + {"id": "anteater", "label": "anteater"}, + {"id": "axolotl", "label": "axolotl"}, + {"id": "bat", "label": "bat"}, + {"id": "bird", "label": "bird"}, + {"id": "boar", "label": "boar"}, + {"id": "butterfly", "label": "butterfly"}, + {"id": "donkey", "label": "donkey"}, + {"id": "elephant", "label": "elephant"}, + {"id": "goat", "label": "goat"}, + {"id": "hamster", "label": "hamster"}, + {"id": "horse", "label": "horse"}, + {"id": "moth", "label": "moth"}, + {"id": "opossum", "label": "opossum"}, + {"id": "owl", "label": "owl"}, + {"id": "panda", "label": "panda"}, + {"id": "pig", "label": "pig"}, + {"id": "shark", "label": "shark"}, + {"id": "sheep", "label": "sheep"}, + {"id": "snail", "label": "snail"}, + ], + "arms": [ + {"id": "mammal", "label": "mammal"}, + {"id": "fish", "label": "fish"}, + {"id": "avian", "label": "avian"}, ], # `fur_pattern` remains the primary color key for compatibility with # existing appearance saves. The actual authored pattern has its own field. @@ -146,11 +300,26 @@ const OPTIONS: Dictionary = { "ears": [ {"id": "none", "label": "none"}, {"id": "antlers_round", "label": "antlers"}, + {"id": "anteater", "label": "anteater"}, + {"id": "axolotl", "label": "axolotl"}, + {"id": "bat", "label": "bat"}, {"id": "bear", "label": "bear"}, + {"id": "boar", "label": "boar"}, {"id": "bunny", "label": "bunny"}, - {"id": "pointy_long", "label": "long"}, - {"id": "pointy_short", "label": "short"}, - {"id": "pointy_wide", "label": "wide"}, + {"id": "butterfly", "label": "butterfly"}, + {"id": "cat", "label": "cat"}, + {"id": "donkey", "label": "donkey"}, + {"id": "elephant", "label": "elephant"}, + {"id": "fox", "label": "fox"}, + {"id": "goat", "label": "goat"}, + {"id": "hamster", "label": "hamster"}, + {"id": "horse", "label": "horse"}, + {"id": "long", "label": "long"}, + {"id": "moth", "label": "moth"}, + {"id": "opossum", "label": "opossum"}, + {"id": "owl", "label": "owl"}, + {"id": "panda", "label": "panda"}, + {"id": "pig", "label": "pig"}, ], "eyes": [ {"id": "simple_shine", "label": "simple shine"}, @@ -163,24 +332,76 @@ const OPTIONS: Dictionary = { "mouth": [{"id": "three", "label": "three"}], "tail": [ {"id": "none", "label": "none"}, + {"id": "anteater", "label": "anteater"}, + {"id": "axolotl", "label": "axolotl"}, {"id": "bear", "label": "bear"}, + {"id": "bird", "label": "bird"}, + {"id": "boar", "label": "boar"}, {"id": "bunny", "label": "bunny"}, + {"id": "butterfly", "label": "butterfly"}, {"id": "cat", "label": "cat"}, + {"id": "donkey", "label": "donkey"}, {"id": "fox", "label": "fox"}, {"id": "gator", "label": "gator"}, + {"id": "horse", "label": "horse"}, + {"id": "moth", "label": "moth"}, + {"id": "opossum", "label": "opossum"}, + {"id": "pig", "label": "pig"}, {"id": "pointy", "label": "pointy"}, + {"id": "shark", "label": "shark"}, + ], + "special_back": [ + {"id": "none", "label": "none"}, + {"id": "shell", "label": "shell"}, + ], + "special_beak": [ + {"id": "none", "label": "none"}, + {"id": "bird", "label": "bird"}, + {"id": "owl", "label": "owl"}, + ], + "special_head": [ + {"id": "none", "label": "none"}, + {"id": "fin", "label": "fin"}, + ], + "special_horns": [ + {"id": "none", "label": "none"}, + {"id": "goat", "label": "goat"}, + {"id": "sheep", "label": "sheep"}, + ], + "special_tusks": [ + {"id": "none", "label": "none"}, + {"id": "boar", "label": "boar"}, + {"id": "elephant", "label": "elephant"}, + ], + "special_wings": [ + {"id": "none", "label": "none"}, + {"id": "bat", "label": "bat"}, + {"id": "butterfly", "label": "butterfly"}, + {"id": "moth", "label": "moth"}, ], } const LEGACY_OPTION_ALIASES: Dictionary = { "species": {"default": "round"}, + "arms": {"default": "mammal", "body_arms": "mammal"}, "fur_pattern": {"solid": "white"}, "fur_style": {"spots_bengal": "bengal"}, - "ears": {"default": "none"}, + "ears": { + "default": "none", + "pointy_long": "long", + "pointy_short": "cat", + "pointy_wide": "fox", + }, "tail": {"default": "none"}, "eyes": {"default": "simple_shine"}, "nose": {"default": "dog_round"}, "mouth": {"default": "three"}, + "special_back": {"default": "none"}, + "special_beak": {"default": "none"}, + "special_head": {"default": "none"}, + "special_horns": {"default": "none"}, + "special_tusks": {"default": "none"}, + "special_wings": {"default": "none"}, } static var _feature_assets_ready: bool = false @@ -198,6 +419,7 @@ static func default_snapshot() -> Dictionary: _ensure_feature_assets() return { "species": "round", + "arms": "mammal", "scale": DEFAULT_CHARACTER_SCALE, "fur_pattern": "white", "fur_style": DEFAULT_FUR_STYLE, @@ -213,6 +435,12 @@ static func default_snapshot() -> Dictionary: "nose": _default_feature_option("nose", "dog_round"), "mouth": _default_feature_option("mouth", "three"), "tail": "none", + "special_back": "none", + "special_beak": "none", + "special_head": "none", + "special_horns": "none", + "special_tusks": "none", + "special_wings": "none", } @@ -467,6 +695,22 @@ static func _fur_pattern_cache_id( return "%s:%s" % [pattern_id, component_id] +static func _fur_pattern_resource_cache_id( + pattern_id: String, + component_id: String, +) -> String: + var direct_id := _fur_pattern_cache_id(pattern_id, component_id) + if _fur_pattern_resource_paths.has(direct_id): + return direct_id + var legacy_component := str( + FUR_PATTERN_COMPONENT_ALIASES.get(component_id, "") + ) + if legacy_component.is_empty(): + return "" + var legacy_id := _fur_pattern_cache_id(pattern_id, legacy_component) + return legacy_id if _fur_pattern_resource_paths.has(legacy_id) else "" + + static func _ensure_feature_assets() -> void: if _feature_assets_ready: return @@ -552,6 +796,38 @@ static func category_label(category_id: String) -> String: return str(CATEGORY_LABELS.get(category_id, category_id)) +static func special_slot_label(category_id: String) -> String: + return str(SPECIAL_SLOT_LABELS.get(category_id, category_id)) + + +static func model_mesh_name(category_id: String, option_id: String) -> String: + var category_meshes: Dictionary = MODEL_OPTION_MESH_NAMES.get( + category_id, + {}, + ) as Dictionary + var canonical_id := canonical_option_id(category_id, option_id) + return str(category_meshes.get(canonical_id, "")) + + +static func model_mesh_names(category_id: String) -> Array[String]: + var result: Array[String] = [] + if category_id == "special": + for special_slot_id: String in SPECIAL_SLOT_IDS: + for mesh_name: String in model_mesh_names(special_slot_id): + if mesh_name not in result: + result.append(mesh_name) + return result + var category_meshes: Dictionary = MODEL_OPTION_MESH_NAMES.get( + category_id, + {}, + ) as Dictionary + for mesh_name: Variant in category_meshes.values(): + var normalized_name := str(mesh_name) + if not normalized_name.is_empty() and normalized_name not in result: + result.append(normalized_name) + return result + + static func is_valid_option(category_id: String, option_id: String) -> bool: option_id = canonical_option_id(category_id, option_id) if category_id in FUR_COLOR_IDS and is_custom_fur_color_id(option_id): @@ -613,13 +889,16 @@ static func fur_style_label(category_id: String) -> String: static func fur_style_field_for_component(component_id: String) -> String: - if component_id == "body_arms": + if ( + component_id == "body_arms" + or component_id in model_mesh_names("arms") + ): return FUR_STYLE_ARMS_ID - if component_id.begins_with("head_"): + if component_id in model_mesh_names("species"): return FUR_STYLE_HEAD_ID - if component_id.begins_with("ears_"): + if component_id in model_mesh_names("ears"): return FUR_STYLE_EARS_ID - if component_id.begins_with("tails_"): + if component_id in model_mesh_names("tail"): return FUR_STYLE_TAIL_ID return FUR_STYLE_ID @@ -632,22 +911,25 @@ static func fur_component_for_style_field( FUR_STYLE_ID: return "body_main" FUR_STYLE_ARMS_ID: - return "body_arms" + return model_mesh_name( + "arms", + str(snapshot.get("arms", "mammal")), + ) FUR_STYLE_HEAD_ID: - var species_id := canonical_option_id( - "species", str(snapshot.get("species", "round")) + return model_mesh_name( + "species", + str(snapshot.get("species", "round")), ) - return "head_pointy" if species_id == "pointy" else "head_round" FUR_STYLE_EARS_ID: - var ears_id := canonical_option_id( - "ears", str(snapshot.get("ears", "none")) + return model_mesh_name( + "ears", + str(snapshot.get("ears", "none")), ) - return "" if ears_id == "none" else "ears_" + ears_id FUR_STYLE_TAIL_ID: - var tail_id := canonical_option_id( - "tail", str(snapshot.get("tail", "none")) + return model_mesh_name( + "tail", + str(snapshot.get("tail", "none")), ) - return "" if tail_id == "none" else "tails_" + tail_id return "" @@ -666,9 +948,10 @@ static func fur_pattern_options_for_field( pattern_id == DEFAULT_FUR_STYLE or ( not component_id.is_empty() - and _fur_pattern_resource_paths.has( - _fur_pattern_cache_id(pattern_id, component_id) - ) + and not _fur_pattern_resource_cache_id( + pattern_id, + component_id, + ).is_empty() ) ): result.append(option) @@ -683,7 +966,12 @@ static func fur_pattern_texture( var canonical_id := canonical_option_id(FUR_STYLE_ID, option_id) if canonical_id == DEFAULT_FUR_STYLE: return null - var cache_id := _fur_pattern_cache_id(canonical_id, component_id) + var cache_id := _fur_pattern_resource_cache_id( + canonical_id, + component_id, + ) + if cache_id.is_empty(): + return null if _fur_pattern_textures.has(cache_id): return _fur_pattern_textures[cache_id] as Texture2D var resource_path := str(_fur_pattern_resource_paths.get(cache_id, "")) diff --git a/progression/player_appearance_store.gd b/progression/player_appearance_store.gd index cca4436..fe76498 100644 --- a/progression/player_appearance_store.gd +++ b/progression/player_appearance_store.gd @@ -3,7 +3,7 @@ extends Node signal appearance_changed -const FORMAT_VERSION: int = 1 +const FORMAT_VERSION: int = 2 var _snapshot: Dictionary = CharacterCustomizationCatalog.default_snapshot() var _loaded: bool = false var _future_version: bool = false @@ -53,9 +53,17 @@ func load_preferences() -> bool: _future_version = true _loaded = false return false - if version != FORMAT_VERSION or typeof(data.get("appearance")) != TYPE_DICTIONARY: + if ( + version < 1 + or typeof(data.get("appearance")) != TYPE_DICTIONARY + ): return _recover_backup() - _snapshot = CharacterCustomizationCatalog.sanitized_snapshot(data["appearance"]) + var appearance: Dictionary = data["appearance"] + if version < FORMAT_VERSION: + appearance = CharacterCustomizationCatalog.sanitized_snapshot(appearance) + if not CharacterCustomizationCatalog.validate_snapshot(appearance): + return _recover_backup() + _snapshot = CharacterCustomizationCatalog.sanitized_snapshot(appearance) _loaded = true _expected_hash = PortableFileGuard.hash_file(_profile_path) return true @@ -83,10 +91,9 @@ func restore_from_save_data( value: Variant, persist_to_profile_file: bool = true, ) -> bool: - if ( - typeof(value) != TYPE_DICTIONARY - or not CharacterCustomizationCatalog.validate_snapshot(value) - ): + if typeof(value) != TYPE_DICTIONARY: + return false + if not CharacterCustomizationCatalog.validate_snapshot(value): return false var sanitized: Dictionary = ( CharacterCustomizationCatalog.sanitized_snapshot(value) diff --git a/save/player_save_manager.gd b/save/player_save_manager.gd index e6ef426..5dd7bc0 100644 --- a/save/player_save_manager.gd +++ b/save/player_save_manager.gd @@ -46,7 +46,7 @@ const PlayerBottleMessageStateType = preload( "res://messages/player_bottle_message_state.gd" ) -const SAVE_VERSION: int = 12 +const SAVE_VERSION: int = 13 const LEGACY_SAVE_FILENAME := "player_save.json" const ARCHIVE_EXTENSION := ".nfsave" const BASIC_ROD_ID: StringName = &"basic_fishing_rod" @@ -370,21 +370,21 @@ func _character_data_from_save(save_data: Dictionary) -> Dictionary: if typeof(save_data.get("character")) != TYPE_DICTIONARY: return {} var character: Dictionary = save_data["character"] + var appearance_value: Variant = character.get("appearance") if ( not NetworkProfilePreferencesType.validate_save_data( character.get("profile") ) - or typeof(character.get("appearance")) != TYPE_DICTIONARY - or not CharacterCustomizationCatalog.validate_snapshot( - character.get("appearance") - ) + or typeof(appearance_value) != TYPE_DICTIONARY + or not CharacterCustomizationCatalog.validate_snapshot(appearance_value) ): return {} + var appearance := CharacterCustomizationCatalog.sanitized_snapshot( + appearance_value + ) return { "profile": (character["profile"] as Dictionary).duplicate(true), - "appearance": CharacterCustomizationCatalog.sanitized_snapshot( - character["appearance"] - ), + "appearance": appearance, } @@ -1610,6 +1610,8 @@ func _migrate_save( migrated = _migrate_version_10_to_11(migrated) 11: migrated = _migrate_version_11_to_12(migrated) + 12: + migrated = _migrate_version_12_to_13(migrated) _: return {} if migrated.is_empty(): @@ -1883,6 +1885,23 @@ func _migrate_version_11_to_12(data: Dictionary) -> Dictionary: return migrated +func _migrate_version_12_to_13(data: Dictionary) -> Dictionary: + var migrated: Dictionary = data.duplicate(true) + if typeof(migrated.get("character")) == TYPE_DICTIONARY: + var character: Dictionary = ( + migrated["character"] as Dictionary + ).duplicate(true) + if typeof(character.get("appearance")) == TYPE_DICTIONARY: + character["appearance"] = ( + CharacterCustomizationCatalog.sanitized_snapshot( + character["appearance"] + ) + ) + migrated["character"] = character + migrated["save_version"] = 13 + return migrated + + func _mark_dirty() -> void: if ( _is_restoring diff --git a/tests/bottle_message_state_validation.gd b/tests/bottle_message_state_validation.gd index f989aa3..b799d66 100644 --- a/tests/bottle_message_state_validation.gd +++ b/tests/bottle_message_state_validation.gd @@ -59,7 +59,7 @@ func _validate_player_save_migration() -> void: {"save_version": 11}, 11, ) - assert(int(migrated.get("save_version", -1)) == 12) + assert(int(migrated.get("save_version", -1)) == 13) assert( migrated.get("message_bottles", {}) == PlayerBottleMessageStateType.default_save_data() diff --git a/tests/character_customization_expansion_validation.gd b/tests/character_customization_expansion_validation.gd new file mode 100644 index 0000000..c7b311c --- /dev/null +++ b/tests/character_customization_expansion_validation.gd @@ -0,0 +1,278 @@ +extends SceneTree + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + _validate_catalog_and_legacy_names() + _validate_version_12_migration() + _validate_appearance_profile_migration_and_strictness() + _validate_signatures_cover_independent_accessories() + await _validate_presenter_selection() + print("Character customization expansion validation: PASS") + quit() + + +func _validate_catalog_and_legacy_names() -> void: + var defaults := CharacterCustomizationCatalog.default_snapshot() + assert(CharacterCustomizationCatalog.validate_snapshot(defaults)) + assert(defaults.size() == CharacterCustomizationCatalog.SNAPSHOT_IDS.size()) + assert(CharacterCustomizationCatalog.SNAPSHOT_IDS.size() == 23) + assert(CharacterCustomizationCatalog.CATEGORY_IDS.count("special") == 1) + assert("special" not in CharacterCustomizationCatalog.SNAPSHOT_IDS) + assert(CharacterCustomizationCatalog.SPECIAL_SLOT_IDS == PackedStringArray([ + "special_back", + "special_beak", + "special_head", + "special_horns", + "special_tusks", + "special_wings", + ])) + + var expected_counts := { + "species": 21, + "arms": 3, + "ears": 21, + "tail": 17, + "special_back": 1, + "special_beak": 2, + "special_head": 1, + "special_horns": 2, + "special_tusks": 2, + "special_wings": 3, + } + var all_mesh_names: Array[String] = [] + for category_id: String in expected_counts: + var options := CharacterCustomizationCatalog.options_for(category_id) + var option_ids: Array[String] = [] + var mesh_names: Array[String] = [] + for option: Dictionary in options: + var option_id := str(option.get("id", "")) + assert(not option_id.is_empty()) + assert(option_id not in option_ids) + option_ids.append(option_id) + var mesh_name := CharacterCustomizationCatalog.model_mesh_name( + category_id, + option_id, + ) + if option_id == "none": + assert(mesh_name.is_empty()) + continue + assert(not mesh_name.is_empty()) + assert(mesh_name not in mesh_names) + assert(mesh_name not in all_mesh_names) + mesh_names.append(mesh_name) + all_mesh_names.append(mesh_name) + assert(mesh_names.size() == int(expected_counts[category_id])) + assert( + CharacterCustomizationCatalog.model_mesh_names(category_id).size() + == int(expected_counts[category_id]) + ) + assert( + CharacterCustomizationCatalog.model_mesh_names("special").size() + == 11 + ) + + assert( + CharacterCustomizationCatalog.model_mesh_name("arms", "body_arms") + == "arms_mammal" + ) + assert( + CharacterCustomizationCatalog.model_mesh_name("ears", "pointy_long") + == "ears_long" + ) + assert( + CharacterCustomizationCatalog.model_mesh_name("ears", "pointy_short") + == "ears_cat" + ) + assert( + CharacterCustomizationCatalog.model_mesh_name("ears", "pointy_wide") + == "ears_fox" + ) + + var legacy := defaults.duplicate(true) + legacy.erase("arms") + for special_slot_id: String in CharacterCustomizationCatalog.SPECIAL_SLOT_IDS: + legacy.erase(special_slot_id) + legacy["ears"] = "pointy_wide" + var sanitized := CharacterCustomizationCatalog.sanitized_snapshot(legacy) + assert(CharacterCustomizationCatalog.validate_snapshot(sanitized)) + assert(sanitized["arms"] == "mammal") + assert(sanitized["ears"] == "fox") + for special_slot_id: String in CharacterCustomizationCatalog.SPECIAL_SLOT_IDS: + assert(sanitized[special_slot_id] == "none") + + +func _validate_version_12_migration() -> void: + var legacy_appearance := CharacterCustomizationCatalog.default_snapshot() + legacy_appearance.erase("arms") + for special_slot_id: String in CharacterCustomizationCatalog.SPECIAL_SLOT_IDS: + legacy_appearance.erase(special_slot_id) + legacy_appearance["ears"] = "pointy_short" + var manager := PlayerSaveManager.new() + root.add_child(manager) + var migrated: Dictionary = manager.call( + "_migrate_save", + { + "save_version": 12, + "character": { + "appearance": legacy_appearance, + }, + }, + 12, + ) + assert(int(migrated.get("save_version", -1)) == 13) + var appearance: Dictionary = migrated["character"]["appearance"] + assert(CharacterCustomizationCatalog.validate_snapshot(appearance)) + assert(appearance["arms"] == "mammal") + assert(appearance["ears"] == "cat") + for special_slot_id: String in CharacterCustomizationCatalog.SPECIAL_SLOT_IDS: + assert(appearance[special_slot_id] == "none") + manager.queue_free() + + +func _validate_appearance_profile_migration_and_strictness() -> void: + var legacy := CharacterCustomizationCatalog.default_snapshot() + legacy.erase("arms") + for special_slot_id: String in CharacterCustomizationCatalog.SPECIAL_SLOT_IDS: + legacy.erase(special_slot_id) + legacy["ears"] = "pointy_wide" + var legacy_path := "user://appearance-v1-%d.json" % Time.get_ticks_usec() + var legacy_file := FileAccess.open(legacy_path, FileAccess.WRITE) + assert(legacy_file != null) + legacy_file.store_string(JSON.stringify({ + "format_version": 1, + "appearance": legacy, + })) + legacy_file.close() + var legacy_store := PlayerAppearanceStore.new() + root.add_child(legacy_store) + legacy_store.configure_storage(legacy_path, null) + assert(legacy_store.load_preferences()) + var migrated := legacy_store.get_snapshot() + assert(CharacterCustomizationCatalog.validate_snapshot(migrated)) + assert(migrated["arms"] == "mammal") + assert(migrated["ears"] == "fox") + legacy_store.queue_free() + DirAccess.remove_absolute(ProjectSettings.globalize_path(legacy_path)) + + var malformed := CharacterCustomizationCatalog.default_snapshot() + malformed["species"] = "definitely_not_a_head" + var current_path := "user://appearance-v2-%d.json" % Time.get_ticks_usec() + var current_file := FileAccess.open(current_path, FileAccess.WRITE) + assert(current_file != null) + current_file.store_string(JSON.stringify({ + "format_version": PlayerAppearanceStore.FORMAT_VERSION, + "appearance": malformed, + })) + current_file.close() + var current_store := PlayerAppearanceStore.new() + root.add_child(current_store) + current_store.configure_storage(current_path, null) + assert(not current_store.load_preferences()) + assert(not current_store.restore_from_save_data(malformed, false)) + current_store.queue_free() + DirAccess.remove_absolute(ProjectSettings.globalize_path(current_path)) + + +func _validate_signatures_cover_independent_accessories() -> void: + var baseline := CharacterCustomizationCatalog.default_snapshot() + var previous_signature := NetworkProfileProtocol.signature_fields({ + "appearance": baseline, + }) + var selections := { + "special_back": "shell", + "special_beak": "bird", + "special_head": "fin", + "special_horns": "goat", + "special_tusks": "boar", + "special_wings": "bat", + } + for category_id: String in CharacterCustomizationCatalog.SPECIAL_SLOT_IDS: + var changed := baseline.duplicate(true) + changed[category_id] = selections[category_id] + assert(CharacterCustomizationCatalog.validate_snapshot(changed)) + var changed_signature := NetworkProfileProtocol.signature_fields({ + "appearance": changed, + }) + assert(changed_signature != previous_signature) + previous_signature = changed_signature + + +func _validate_presenter_selection() -> void: + var visuals := PlayerVisualPresenter.instantiate_visuals() + root.add_child(visuals) + var skeleton := visuals.find_child("Skeleton3D", true, false) as Skeleton3D + assert(skeleton != null) + var snapshot := CharacterCustomizationCatalog.default_snapshot() + PlayerVisualPresenter.apply_appearance(visuals, snapshot) + _assert_exact_selection(skeleton, "species", "head_round") + _assert_exact_selection(skeleton, "arms", "arms_mammal") + _assert_exact_selection(skeleton, "ears", "") + _assert_exact_selection(skeleton, "tail", "") + for special_slot_id: String in CharacterCustomizationCatalog.SPECIAL_SLOT_IDS: + _assert_exact_selection(skeleton, special_slot_id, "") + + for species_option: Dictionary in CharacterCustomizationCatalog.options_for( + "species" + ): + var species_id := str(species_option.get("id", "")) + snapshot["species"] = species_id + PlayerVisualPresenter.apply_appearance(visuals, snapshot) + var selected_head := CharacterCustomizationCatalog.model_mesh_name( + "species", + species_id, + ) + _assert_exact_selection(skeleton, "species", selected_head) + for feature_name: String in ["eyes", "nose", "mouth"]: + var feature := skeleton.get_node_or_null( + "%s_%s" % [selected_head, feature_name] + ) as MeshInstance3D + assert(feature != null and feature.visible) + var feature_material := feature.material_override as StandardMaterial3D + assert(feature_material != null) + assert(feature_material.albedo_texture != null) + + snapshot["arms"] = "avian" + snapshot["ears"] = "owl" + snapshot["tail"] = "donkey" + snapshot["special_back"] = "shell" + snapshot["special_beak"] = "bird" + snapshot["special_head"] = "fin" + snapshot["special_horns"] = "goat" + snapshot["special_tusks"] = "elephant" + snapshot["special_wings"] = "moth" + assert(CharacterCustomizationCatalog.validate_snapshot(snapshot)) + PlayerVisualPresenter.apply_appearance(visuals, snapshot) + _assert_exact_selection(skeleton, "arms", "arms_avian") + _assert_exact_selection(skeleton, "ears", "ears_owl") + _assert_exact_selection(skeleton, "tail", "tail_donkey") + _assert_exact_selection(skeleton, "special_back", "back_shell") + _assert_exact_selection(skeleton, "special_beak", "beak_bird") + _assert_exact_selection(skeleton, "special_head", "head_fin") + _assert_exact_selection(skeleton, "special_horns", "horns_goat") + _assert_exact_selection(skeleton, "special_tusks", "tusks_elephant") + _assert_exact_selection(skeleton, "special_wings", "wings_moth") + + var wings := skeleton.get_node("wings_moth") as MeshInstance3D + var cached_material := wings.material_override + assert(cached_material != null) + PlayerVisualPresenter.apply_appearance(visuals, snapshot) + assert(wings.material_override == cached_material) + visuals.queue_free() + await process_frame + + +func _assert_exact_selection( + skeleton: Skeleton3D, + category_id: String, + expected_mesh_name: String, +) -> void: + for mesh_name: String in CharacterCustomizationCatalog.model_mesh_names( + category_id + ): + var mesh := skeleton.get_node_or_null(mesh_name) as Node3D + assert(mesh != null) + assert(mesh.visible == (mesh_name == expected_mesh_name)) diff --git a/tests/character_customization_expansion_validation.gd.uid b/tests/character_customization_expansion_validation.gd.uid new file mode 100644 index 0000000..1a05601 --- /dev/null +++ b/tests/character_customization_expansion_validation.gd.uid @@ -0,0 +1 @@ +uid://drj5amsurmywk diff --git a/tests/character_customizer_ui_validation.gd b/tests/character_customizer_ui_validation.gd new file mode 100644 index 0000000..ba7aa5d --- /dev/null +++ b/tests/character_customizer_ui_validation.gd @@ -0,0 +1,211 @@ +extends SceneTree + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + var page := ProfilePage.new() + page.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + root.add_child(page) + await process_frame + await process_frame + page.set( + "_draft_appearance", + CharacterCustomizationCatalog.default_snapshot(), + ) + var preview := page.get("_preview") as ProfilePreview + var default_appearance := ( + CharacterCustomizationCatalog.default_snapshot() + ) + preview.apply_appearance_profile(default_appearance) + var visible_bounds_result: Dictionary = preview.call( + "_visible_mesh_bounds" + ) + assert(bool(visible_bounds_result.get("has_bounds", false))) + var visible_bounds: AABB = visible_bounds_result.get("bounds", AABB()) + assert( + (preview.get("_camera_target") as Vector3).is_equal_approx( + visible_bounds.get_center() + ) + ) + var stable_fit := float(preview.get("_camera_fit_distance")) + preview.apply_appearance_profile(default_appearance) + assert(is_equal_approx( + float(preview.get("_camera_fit_distance")), stable_fit + )) + var shell := page.find_child("UtilityMainBox", true, false) as Control + assert(shell != null) + var shell_position := shell.position + var shell_size := shell.size + for category_id: String in ["species", "arms", "ears", "tail"]: + page.call("_select_category", category_id) + await process_frame + await process_frame + var scroll := page.find_child( + "ModelOptionScroll", true, false + ) as ScrollContainer + var grid := page.find_child( + "ModelOptionGrid", true, false + ) as GridContainer + assert(scroll != null) + assert(grid != null) + assert(grid.columns == ProfilePage.MODEL_OPTION_GRID_COLUMNS) + assert( + grid.get_child_count() + == CharacterCustomizationCatalog.options_for(category_id).size() + ) + assert( + scroll.horizontal_scroll_mode + == ScrollContainer.SCROLL_MODE_DISABLED + ) + assert( + scroll.vertical_scroll_mode + == ScrollContainer.SCROLL_MODE_AUTO + ) + assert(shell.position.is_equal_approx(shell_position)) + assert(shell.size.is_equal_approx(shell_size)) + + page.call("_select_category", "species") + await process_frame + await process_frame + var species_scroll := page.find_child( + "ModelOptionScroll", true, false + ) as ScrollContainer + assert(species_scroll != null) + var species_scroll_max := int( + maxf( + species_scroll.get_v_scroll_bar().max_value + - species_scroll.get_v_scroll_bar().page, + 0.0, + ) + ) + assert(species_scroll_max > 0) + var saved_position := mini(species_scroll_max, 36) + species_scroll.scroll_vertical = saved_position + page.call("_select_category", "arms") + await process_frame + page.call("_select_category", "species") + await process_frame + await process_frame + species_scroll = page.find_child( + "ModelOptionScroll", true, false + ) as ScrollContainer + assert(species_scroll != null) + assert(species_scroll.scroll_vertical == saved_position) + page.call("activate") + page.call("set_interactive", true) + var category_scroll := page.find_child( + "CategoryScroll", true, false + ) as ScrollContainer + assert(category_scroll != null and category_scroll.follow_focus) + page.set("_controller_zone", ProfilePage.ControllerZone.CATEGORIES) + page.call("_apply_controller_zone_focus") + var category_list := page.get("_category_list") as VBoxContainer + var last_category_button := category_list.get_child( + category_list.get_child_count() - 1 + ) as Button + assert(last_category_button != null) + last_category_button.grab_focus() + await process_frame + assert(category_scroll.scroll_vertical > 0) + + page.set("_controller_zone", ProfilePage.ControllerZone.OPTIONS) + page.call("_apply_controller_zone_focus") + var species_grid := page.find_child( + "ModelOptionGrid", true, false + ) as GridContainer + var last_species_button := species_grid.get_child( + species_grid.get_child_count() - 1 + ) as Button + assert(last_species_button != null) + last_species_button.grab_focus() + await process_frame + assert(species_scroll.scroll_vertical > saved_position) + + page.call("_select_category", "special") + await process_frame + await process_frame + var special_scroll := page.find_child( + "SpecialOptionScroll", true, false + ) as ScrollContainer + assert(special_scroll != null) + for field_id: String in CharacterCustomizationCatalog.SPECIAL_SLOT_IDS: + var slot_name := "SpecialSlot_%s" % field_id.trim_prefix("special_") + var slot := page.find_child(slot_name, true, false) as HBoxContainer + assert(slot != null) + assert(slot.get_child_count() == 2) + var slot_label := slot.get_child(0) as Label + var choices := slot.get_child(1) as HBoxContainer + assert(slot_label != null and choices != null) + assert( + slot_label.text + == CharacterCustomizationCatalog.special_slot_label(field_id) + ) + assert( + choices.get_child_count() + == CharacterCustomizationCatalog.options_for(field_id).size() + ) + assert(shell.position.is_equal_approx(shell_position)) + assert(shell.size.is_equal_approx(shell_size)) + + var back_slot := page.find_child( + "SpecialSlot_back", true, false + ) as HBoxContainer + var back_choices := back_slot.get_child(1) as HBoxContainer + var shell_button := back_choices.get_child(1) as Button + assert(shell_button != null) + shell_button.pressed.emit() + await process_frame + await process_frame + var draft: Dictionary = page.get("_draft_appearance") + assert(str(draft.get("special_back", "")) == "shell") + for other_id: String in CharacterCustomizationCatalog.SPECIAL_SLOT_IDS: + if other_id != "special_back": + assert(str(draft.get(other_id, "")) == "none") + var wings_slot := page.find_child( + "SpecialSlot_wings", true, false + ) as HBoxContainer + var wings_choices := wings_slot.get_child(1) as HBoxContainer + var moth_button := wings_choices.get_child( + wings_choices.get_child_count() - 1 + ) as Button + assert(moth_button != null) + moth_button.grab_focus() + await process_frame + moth_button.pressed.emit() + await process_frame + await process_frame + draft = page.get("_draft_appearance") + assert(str(draft.get("special_back", "")) == "shell") + assert(str(draft.get("special_wings", "")) == "moth") + var restored_focus := page.get_viewport().gui_get_focus_owner() as Button + assert(restored_focus != null) + assert( + str(restored_focus.get_meta(&"appearance_focus_key", "")) + == "special_wings:moth" + ) + + var ordinary_bounds := AABB(Vector3.ZERO, Vector3(1.0, 2.0, 1.0)) + var wide_bounds := AABB(Vector3.ZERO, Vector3(4.0, 2.0, 1.0)) + var tall_bounds := AABB(Vector3.ZERO, Vector3(1.0, 4.0, 1.0)) + var viewport_size := Vector2(320.0, 330.0) + var ordinary_fit := ProfilePreview.calculate_camera_fit_distance( + ordinary_bounds, viewport_size, 75.0 + ) + assert( + ProfilePreview.calculate_camera_fit_distance( + wide_bounds, viewport_size, 75.0 + ) > ordinary_fit + ) + assert( + ProfilePreview.calculate_camera_fit_distance( + tall_bounds, viewport_size, 75.0 + ) > ordinary_fit + ) + + print("Character customizer UI validation: PASS") + page.queue_free() + await process_frame + quit() diff --git a/tests/character_customizer_ui_validation.gd.uid b/tests/character_customizer_ui_validation.gd.uid new file mode 100644 index 0000000..0e1860f --- /dev/null +++ b/tests/character_customizer_ui_validation.gd.uid @@ -0,0 +1 @@ +uid://by314ps17phf3 diff --git a/tests/character_rig_validation.gd b/tests/character_rig_validation.gd index 95af5b7..94aa908 100644 --- a/tests/character_rig_validation.gd +++ b/tests/character_rig_validation.gd @@ -41,6 +41,116 @@ const EXPECTED_ANIMATIONS: Array[StringName] = [ &"walking", &"walking_show", ] +const EXPECTED_HEAD_MESHES: PackedStringArray = [ + "head_round", + "head_pointy", + "head_anteater", + "head_axolotl", + "head_bat", + "head_bird", + "head_boar", + "head_butterfly", + "head_donkey", + "head_elephant", + "head_goat", + "head_hamster", + "head_horse", + "head_moth", + "head_opossum", + "head_owl", + "head_panda", + "head_pig", + "head_shark", + "head_sheep", + "head_snail", +] +const EXPECTED_ARM_MESHES: PackedStringArray = [ + "arms_mammal", "arms_fish", "arms_avian", +] +const EXPECTED_EAR_MESHES: PackedStringArray = [ + "ears_cat", + "ears_long", + "ears_fox", + "ears_bunny", + "ears_antlers_round", + "ears_bear", + "ears_anteater", + "ears_bat", + "ears_boar", + "ears_butterfly", + "ears_donkey", + "ears_axolotl", + "ears_elephant", + "ears_goat", + "ears_hamster", + "ears_horse", + "ears_opossum", + "ears_moth", + "ears_pig", + "ears_owl", + "ears_panda", +] +const EXPECTED_TAIL_MESHES: PackedStringArray = [ + "tails_gator", + "tails_fox", + "tails_bunny", + "tails_cat", + "tails_bear", + "tails_pointy", + "tails_anteater", + "tails_boar", + "tails_butterfly", + "tail_donkey", + "tails_axolotl", + "tails_horse", + "tails_opossum", + "tails_moth", + "tails_pig", + "tails_shark", + "tails_bird", +] +const EXPECTED_SPECIAL_MESHES: PackedStringArray = [ + "tusks_boar", + "tusks_elephant", + "wings_butterfly", + "wings_bat", + "wings_moth", + "horns_goat", + "horns_sheep", + "back_shell", + "head_fin", + "beak_owl", + "beak_bird", +] +const FACE_FEATURES: PackedStringArray = ["eyes", "nose", "mouth"] +const EXCLUDED_AUTHORING_MESHES: PackedStringArray = [ + "frog_eyes_1", + "frog_eyes_2", + "frog_head_1", + "frog_head_2", + "rod_socket_preview-noimp", +] +const EXPECTED_BONE_PARENTS: Dictionary = { + "root": "", + "hips": "root", + "spine": "hips", + "clavicle.L": "spine", + "upper_arm.L": "clavicle.L", + "forearm.L": "upper_arm.L", + "hand.L": "forearm.L", + "clavicle.R": "spine", + "upper_arm.R": "clavicle.R", + "forearm.R": "upper_arm.R", + "hand.R": "forearm.R", + "rod_socket": "hand.R", + "rod_socket.001": "rod_socket", + "neck": "spine", + "head": "neck", + "thigh.L": "hips", + "shin.L": "thigh.L", + "thigh.R": "hips", + "shin.R": "thigh.R", +} func _initialize() -> void: @@ -57,6 +167,21 @@ func _run() -> void: ) as AnimationPlayer assert(skeleton != null) assert(animation_player != null) + assert(character.get_path_to(skeleton) == ^"CharacterRig/Skeleton3D") + assert(skeleton.get_bone_count() == EXPECTED_BONE_PARENTS.size()) + for bone_name: String in EXPECTED_BONE_PARENTS: + var bone_index := skeleton.find_bone(bone_name) + assert(bone_index >= 0, "Missing bone: %s" % bone_name) + var parent_index := skeleton.get_bone_parent(bone_index) + var actual_parent := ( + str(skeleton.get_bone_name(parent_index)) + if parent_index >= 0 + else "" + ) + assert( + actual_parent == str(EXPECTED_BONE_PARENTS[bone_name]), + "Unexpected parent for bone %s: %s" % [bone_name, actual_parent], + ) var socket_index := skeleton.find_bone("rod_socket") assert(socket_index >= 0) @@ -79,26 +204,65 @@ func _run() -> void: "Animation %s lost its authored rod socket pose." % expected, ) - var arms := character.find_child( - "body_arms", - true, - false, - ) as MeshInstance3D - assert(arms != null) - var blended_arm_vertices := _validate_mesh_weights(arms) - assert(blended_arm_vertices > 0, "body_arms has no blended joint weights.") - var body := character.find_child( - "body_main", - true, - false, - ) as MeshInstance3D - assert(body != null) - _validate_mesh_weights(body) + var expected_mesh_names := _expected_runtime_mesh_names() + assert(expected_mesh_names.size() == 137) + var face_decal_count := 0 + for head_name: String in EXPECTED_HEAD_MESHES: + for feature_name: String in FACE_FEATURES: + assert(expected_mesh_names.has( + "%s_%s" % [head_name, feature_name] + )) + face_decal_count += 1 + assert(face_decal_count == 63) + + var runtime_meshes: Array[MeshInstance3D] = [] + _collect_mesh_instances(character, runtime_meshes) + assert(runtime_meshes.size() == expected_mesh_names.size()) + var actual_mesh_names: Dictionary = {} + var total_blended_vertices := 0 + for mesh_instance: MeshInstance3D in runtime_meshes: + assert(not actual_mesh_names.has(mesh_instance.name)) + actual_mesh_names[mesh_instance.name] = true + assert( + expected_mesh_names.has(mesh_instance.name), + "Unexpected runtime character mesh: %s" % mesh_instance.name, + ) + total_blended_vertices += _validate_mesh_weights(mesh_instance) + assert(actual_mesh_names.size() == expected_mesh_names.size()) + for mesh_name: String in expected_mesh_names: + assert(actual_mesh_names.has(mesh_name), "Missing mesh: %s" % mesh_name) + for mesh_name: String in EXCLUDED_AUTHORING_MESHES: + assert(not actual_mesh_names.has(mesh_name)) + + var blended_arm_vertices := 0 + for mesh_name: String in EXPECTED_ARM_MESHES: + var arms := character.find_child( + mesh_name, + true, + false, + ) as MeshInstance3D + assert(arms != null) + var blended_vertices := _validate_mesh_weights(arms) + assert( + blended_vertices > 0, + "%s has no blended joint weights." % mesh_name, + ) + blended_arm_vertices += blended_vertices + assert(total_blended_vertices >= blended_arm_vertices) print( - "Character rig validation: PASS " - + "(animations=%d, blended arm vertices=%d)" - % [EXPECTED_ANIMATIONS.size(), blended_arm_vertices] + ( + "Character rig validation: PASS " + + "(meshes=%d, face decals=%d, bones=%d, animations=%d, " + + "blended arm vertices=%d)" + ) + % [ + runtime_meshes.size(), + face_decal_count, + skeleton.get_bone_count(), + EXPECTED_ANIMATIONS.size(), + blended_arm_vertices, + ] ) character.queue_free() quit() @@ -125,12 +289,39 @@ func _validate_mesh_weights(mesh_instance: MeshInstance3D) -> int: if weight > 0.000001: influence_count += 1 assert(absf(total - 1.0) <= 0.0001) - assert(influence_count <= 2) - if influence_count == 2: + assert(influence_count <= 4) + if influence_count > 1: blended_vertices += 1 return blended_vertices +func _expected_runtime_mesh_names() -> Dictionary: + var result: Dictionary = {"body_main": true} + for mesh_name: String in EXPECTED_ARM_MESHES: + result[mesh_name] = true + for head_name: String in EXPECTED_HEAD_MESHES: + result[head_name] = true + for feature_name: String in FACE_FEATURES: + result["%s_%s" % [head_name, feature_name]] = true + for mesh_name: String in EXPECTED_EAR_MESHES: + result[mesh_name] = true + for mesh_name: String in EXPECTED_TAIL_MESHES: + result[mesh_name] = true + for mesh_name: String in EXPECTED_SPECIAL_MESHES: + result[mesh_name] = true + return result + + +func _collect_mesh_instances( + parent: Node, + result: Array[MeshInstance3D], +) -> void: + if parent is MeshInstance3D: + result.append(parent as MeshInstance3D) + for child: Node in parent.get_children(): + _collect_mesh_instances(child, result) + + func _find_first(parent: Node, class_name_to_find: String) -> Node: if parent.is_class(class_name_to_find): return parent diff --git a/tests/fish_hotbar_showcase_validation.gd b/tests/fish_hotbar_showcase_validation.gd index 9f7e51b..8bf0a5b 100644 --- a/tests/fish_hotbar_showcase_validation.gd +++ b/tests/fish_hotbar_showcase_validation.gd @@ -104,7 +104,7 @@ func _run() -> void: var hotbar_data: Dictionary = parsed["hotbar"] assert(typeof(hotbar_data.get("fish_slots")) == TYPE_ARRAY) assert(str((hotbar_data["fish_slots"] as Array)[1]) == fish_catch.catch_id) - assert(int(parsed["save_version"]) == 12) + assert(int(parsed["save_version"]) == 13) assert(not PlayerHomeState.sanitize_save_data( parsed.get("home", {}) ).is_empty()) @@ -204,7 +204,7 @@ func _run() -> void: var enet_channel_count: int = NetworkProtocol.ENET_CHANNEL_COUNT var fish_quality_capability: String = NetworkProtocol.FISH_QUALITY_CAPABILITY var showcase_capability: StringName = NetworkFishShowcaseProtocol.CAPABILITY - assert(protocol_version == 13) + assert(protocol_version == 14) assert(enet_channel_count == 18) assert( fish_quality_capability == "fish_quality_v1" diff --git a/tests/fish_quality_validation.gd b/tests/fish_quality_validation.gd index cf49c6f..2bdaed5 100644 --- a/tests/fish_quality_validation.gd +++ b/tests/fish_quality_validation.gd @@ -38,7 +38,7 @@ func _run() -> void: NetworkProtocol.FISHING_REPLICATION_CAPABILITY ) var fish_quality_capability: String = NetworkProtocol.FISH_QUALITY_CAPABILITY - assert(protocol_version == 13) + assert(protocol_version == 14) assert(enet_channel_count == 18) assert(movement_animation_channel == 11) assert( @@ -505,7 +505,7 @@ func _validate_version_four_migration() -> void: version_four, 4, ) - assert(int(migrated.get("save_version", -1)) == 12) + assert(int(migrated.get("save_version", -1)) == 13) assert(int((migrated["experience"] as Dictionary)["total_experience"]) == 0) assert( is_equal_approx( diff --git a/tests/fur_pattern_validation.gd b/tests/fur_pattern_validation.gd index a795d53..961fc82 100644 --- a/tests/fur_pattern_validation.gd +++ b/tests/fur_pattern_validation.gd @@ -156,9 +156,9 @@ func _run() -> void: ) var body_texture := _assert_pattern_texture("calico", "body_main") - var arms_texture := _assert_pattern_texture("paws", "body_arms") + var arms_texture := _assert_pattern_texture("paws", "arms_mammal") var head_texture := _assert_pattern_texture("tiger", "head_round") - var ears_texture := _assert_pattern_texture("fox", "ears_pointy_short") + var ears_texture := _assert_pattern_texture("fox", "ears_cat") var tail_texture := _assert_pattern_texture("fox", "tails_fox") _assert_pattern_texture("tummy", "body_main") _assert_pattern_texture("stripes", "body_main") @@ -186,13 +186,13 @@ func _run() -> void: skeleton, "body_main", body_texture ) var arms_material := _assert_mesh_pattern( - skeleton, "body_arms", arms_texture + skeleton, "arms_mammal", arms_texture ) var head_material := _assert_mesh_pattern( skeleton, "head_round", head_texture ) var ears_material := _assert_mesh_pattern( - skeleton, "ears_pointy_short", ears_texture + skeleton, "ears_cat", ears_texture ) var tail_material := _assert_mesh_pattern( skeleton, "tails_fox", tail_texture diff --git a/tests/job_system_validation.gd b/tests/job_system_validation.gd index 96ac8bb..423d15d 100644 --- a/tests/job_system_validation.gd +++ b/tests/job_system_validation.gd @@ -209,7 +209,7 @@ func _run() -> void: ) assert(bool(decoded.get("ok", false))) var save_data: Dictionary = decoded["data"] - assert(int(save_data.get("save_version", -1)) == 12) + assert(int(save_data.get("save_version", -1)) == 13) assert(PlayerJobService.validate_save_data(save_data.get("jobs", {}))) assert(not PlayerHomeState.sanitize_save_data( save_data.get("home", {}) diff --git a/tests/player_experience_validation.gd b/tests/player_experience_validation.gd index 03e4b84..ee1abb3 100644 --- a/tests/player_experience_validation.gd +++ b/tests/player_experience_validation.gd @@ -143,7 +143,7 @@ func _validate_save_migration() -> void: version_five, 5, ) - assert(int(migrated.get("save_version", -1)) == 12) + assert(int(migrated.get("save_version", -1)) == 13) var experience_data: Dictionary = migrated.get("experience", {}) assert(int(experience_data.get("total_experience", -1)) == 0) var world_data: Dictionary = migrated.get("world", {}) diff --git a/tests/profile_multiplayer_validation.gd b/tests/profile_multiplayer_validation.gd index b4ff5d0..bd2327f 100644 --- a/tests/profile_multiplayer_validation.gd +++ b/tests/profile_multiplayer_validation.gd @@ -177,6 +177,11 @@ func _run_client() -> void: var service := main.get_node( "%NetworkProfileService" ) as NetworkProfileService + var spawn := main.get_node("%PlayerSpawnService") as PlayerSpawnService + var player := spawn.get_local_player() as Player + var host_avatar := spawn.get_avatar(1) as Player + assert(player != null) + assert(host_avatar != null) var original: Dictionary = service.get_persisted_appearance() var changed: Dictionary = _changed_appearance(original) assert(service.preview_appearance(changed)) @@ -189,6 +194,11 @@ func _run_client() -> void: service.apply_finished.connect(func(accepted: bool, message: String) -> void: apply_results.append([accepted, message]) ) + # Generated spawn points can be farther apart than audible speech range. + # Establish proximity after initial replication settles so this voice test + # does not depend on the terrain seed's peer spawn spacing. + player.global_position = host_avatar.global_position + Vector3.RIGHT + assert(player.global_position.distance_to(host_avatar.global_position) < 4.0) assert(service.apply_profile( service.get_persisted_name(), changed, @@ -213,7 +223,6 @@ func _run_client() -> void: == "trail guide" ) assert(Dictionary(session.get("_local_appearance_snapshot")) == changed) - var player := main.get_node("%PlayerSpawnService").get_local_player() as Player assert(player.get_animalese_voice_id() == "deep") assert(player.get_animalese_sample_set_id() == "kim") var host_voice_messages: Array[Dictionary] = [] @@ -265,18 +274,17 @@ func _run_client() -> void: 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.") + changed["species"] = "snail" + changed["arms"] = "avian" + changed["ears"] = "owl" + changed["tail"] = "donkey" + changed["special_back"] = "shell" + changed["special_beak"] = "bird" + changed["special_head"] = "fin" + changed["special_horns"] = "goat" + changed["special_tusks"] = "elephant" + changed["special_wings"] = "moth" + assert(CharacterCustomizationCatalog.validate_snapshot(changed)) return changed diff --git a/tests/profile_title_validation.gd b/tests/profile_title_validation.gd index 188c7e4..8cdb05c 100644 --- a/tests/profile_title_validation.gd +++ b/tests/profile_title_validation.gd @@ -55,6 +55,33 @@ func _validate_network_payloads() -> void: var invalid_hello := hello.duplicate(true) invalid_hello["player_title"] = "" assert(not NetworkProtocol.validate_client_hello(invalid_hello).is_empty()) + var missing_appearance_field := hello.duplicate(true) + missing_appearance_field["cosmetic_snapshot"] = appearance.duplicate(true) + missing_appearance_field["cosmetic_snapshot"].erase("special_wings") + assert(not NetworkProtocol.validate_client_hello( + missing_appearance_field + ).is_empty()) + var extra_appearance_field := hello.duplicate(true) + extra_appearance_field["cosmetic_snapshot"] = appearance.duplicate(true) + extra_appearance_field["cosmetic_snapshot"]["unexpected"] = "value" + assert(not NetworkProtocol.validate_client_hello( + extra_appearance_field + ).is_empty()) + var invalid_appearance_option := hello.duplicate(true) + invalid_appearance_option["cosmetic_snapshot"] = appearance.duplicate(true) + invalid_appearance_option["cosmetic_snapshot"]["species"] = "unknown" + assert(not NetworkProtocol.validate_client_hello( + invalid_appearance_option + ).is_empty()) + var noncanonical_appearance := hello.duplicate(true) + noncanonical_appearance["cosmetic_snapshot"] = appearance.duplicate(true) + noncanonical_appearance["cosmetic_snapshot"]["ears"] = "pointy_wide" + assert(CharacterCustomizationCatalog.validate_snapshot( + noncanonical_appearance["cosmetic_snapshot"] + )) + assert(not NetworkProtocol.validate_client_hello( + noncanonical_appearance + ).is_empty()) var check_request := { "request_id": "check", diff --git a/tests/progression_archive_validation.gd b/tests/progression_archive_validation.gd index 9c9ad3a..65c1141 100644 --- a/tests/progression_archive_validation.gd +++ b/tests/progression_archive_validation.gd @@ -72,7 +72,7 @@ func _run() -> void: _assert_opaque(save_path) var decoded: Dictionary = ProgressionSaveCodec.read_local_save(save_path) assert(bool(decoded.get("ok", false))) - assert(int((decoded["data"] as Dictionary)["save_version"]) == 12) + assert(int((decoded["data"] as Dictionary)["save_version"]) == 13) var saved_character: Dictionary = ( (decoded["data"] as Dictionary).get("character", {}) ) @@ -292,6 +292,55 @@ func _run() -> void: assert(bool(unchanged_migration.get("ok", false))) assert(not bool(unchanged_migration.get("changed", true))) assert(data_root.root_path == unchanged_root) + var appearance_path: String = data_root.root_path.path_join( + "player/player_appearance.json" + ) + assert(appearance_store.save_snapshot(appearance_store.get_snapshot())) + var appearance_file := FileAccess.open(appearance_path, FileAccess.READ) + assert(appearance_file != null) + var parsed_appearance_data: Variant = JSON.parse_string( + appearance_file.get_as_text() + ) + appearance_file.close() + assert(typeof(parsed_appearance_data) == TYPE_DICTIONARY) + var current_appearance_data := parsed_appearance_data as Dictionary + assert( + int(current_appearance_data.get("format_version", -1)) + == PlayerAppearanceStore.FORMAT_VERSION + ) + assert(PortableDataMigration._valid_owned_data( + "player_appearance.json", current_appearance_data + )) + var legacy_appearance_data: Dictionary = current_appearance_data.duplicate( + true + ) + legacy_appearance_data["format_version"] = 1 + var legacy_appearance: Dictionary = legacy_appearance_data["appearance"] + for added_id: String in [ + "arms", + "special_back", + "special_beak", + "special_head", + "special_horns", + "special_tusks", + "special_wings", + ]: + legacy_appearance.erase(added_id) + assert(PortableDataMigration._valid_owned_data( + "player_appearance.json", legacy_appearance_data + )) + var future_appearance_data := current_appearance_data.duplicate(true) + future_appearance_data["format_version"] = ( + PlayerAppearanceStore.FORMAT_VERSION + 1 + ) + assert(not PortableDataMigration._valid_owned_data( + "player_appearance.json", future_appearance_data + )) + var malformed_appearance_data := current_appearance_data.duplicate(true) + malformed_appearance_data["format_version"] = "2" + assert(not PortableDataMigration._valid_owned_data( + "player_appearance.json", malformed_appearance_data + )) var migrated_root: String = data_root.root_path.get_base_dir().path_join( "progression-migrated-data" diff --git a/tests/world_spawn_protocol_validation.gd b/tests/world_spawn_protocol_validation.gd index d828fdf..38fe6ee 100644 --- a/tests/world_spawn_protocol_validation.gd +++ b/tests/world_spawn_protocol_validation.gd @@ -32,7 +32,7 @@ func _run() -> void: var snapshot_entities_per_envelope: int = ( NetworkWorldSpawnProtocol.SNAPSHOT_ENTITIES_PER_ENVELOPE ) - assert(protocol_version == 13) + assert(protocol_version == 14) assert( world_spawn_capability == protocol_world_spawn_capability ) diff --git a/tools/blender/export_character.py b/tools/blender/export_character.py new file mode 100755 index 0000000..76c26e0 --- /dev/null +++ b/tools/blender/export_character.py @@ -0,0 +1,657 @@ +#!/usr/bin/env python3 +"""Validate and export the authoritative straywild character as a GLB. + +Run this script through Blender with the authoritative source file open: + + blender --background straywild_character_source.blend \ + --python tools/blender/export_character.py -- \ + --output art/exported/characters/base/straywild_base_character.glb \ + --verify-determinism + +The exporter deliberately uses an explicit object manifest. It exports every +runtime character mesh even when the object is hidden in the authoring file, +while excluding the ``unused`` collection and the rod-socket preview mesh. +The loaded blend is only modified in memory and is never saved. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import struct +import sys +import tempfile +from pathlib import Path + +import bpy + + +ARMATURE_NAME = "CharacterRig" +GODOT_SKELETON_NODE_PATH = "CharacterRig/Skeleton3D" +PREVIEW_MESH_NAME = "rod_socket_preview-noimp" +UNUSED_COLLECTION_NAME = "unused" +UNUSED_MESH_NAMES = { + "frog_eyes_1", + "frog_eyes_2", + "frog_head_1", + "frog_head_2", +} +EXPECTED_BONE_PARENTS = { + "root": None, + "hips": "root", + "spine": "hips", + "clavicle.L": "spine", + "upper_arm.L": "clavicle.L", + "forearm.L": "upper_arm.L", + "hand.L": "forearm.L", + "clavicle.R": "spine", + "upper_arm.R": "clavicle.R", + "forearm.R": "upper_arm.R", + "hand.R": "forearm.R", + "rod_socket": "hand.R", + "rod_socket.001": "rod_socket", + "neck": "spine", + "head": "neck", + "thigh.L": "hips", + "shin.L": "thigh.L", + "thigh.R": "hips", + "shin.R": "thigh.R", +} + +HEAD_MESH_NAMES = ( + "head_round", + "head_pointy", + "head_anteater", + "head_axolotl", + "head_bat", + "head_bird", + "head_boar", + "head_butterfly", + "head_donkey", + "head_elephant", + "head_goat", + "head_hamster", + "head_horse", + "head_moth", + "head_opossum", + "head_owl", + "head_panda", + "head_pig", + "head_shark", + "head_sheep", + "head_snail", +) +FACE_FEATURE_SUFFIXES = ("eyes", "nose", "mouth") +FACE_DECAL_MESH_NAMES = tuple( + f"{head_name}.{suffix}" + for head_name in HEAD_MESH_NAMES + for suffix in FACE_FEATURE_SUFFIXES +) +ARM_MESH_NAMES = ("arms_mammal", "arms_fish", "arms_avian") +EAR_MESH_NAMES = ( + "ears_cat", + "ears_long", + "ears_fox", + "ears_bunny", + "ears_antlers_round", + "ears_bear", + "ears_anteater", + "ears_bat", + "ears_boar", + "ears_butterfly", + "ears_donkey", + "ears_axolotl", + "ears_elephant", + "ears_goat", + "ears_hamster", + "ears_horse", + "ears_opossum", + "ears_moth", + "ears_pig", + "ears_owl", + "ears_panda", +) +TAIL_MESH_NAMES = ( + "tails_gator", + "tails_fox", + "tails_bunny", + "tails_cat", + "tails_bear", + "tails_pointy", + "tails_anteater", + "tails_boar", + "tails_butterfly", + "tail_donkey", + "tails_axolotl", + "tails_horse", + "tails_opossum", + "tails_moth", + "tails_pig", + "tails_shark", + "tails_bird", +) +SPECIAL_MESH_NAMES = ( + "tusks_boar", + "tusks_elephant", + "wings_butterfly", + "wings_bat", + "wings_moth", + "horns_goat", + "horns_sheep", + "back_shell", + "head_fin", + "beak_owl", + "beak_bird", +) +RUNTIME_MESH_NAMES = { + "body_main", + *ARM_MESH_NAMES, + *HEAD_MESH_NAMES, + *FACE_DECAL_MESH_NAMES, + *EAR_MESH_NAMES, + *TAIL_MESH_NAMES, + *SPECIAL_MESH_NAMES, +} + +SOURCE_ACTION_NAMES = { + "casting", + "casting_sit", + "draw", + "fighting_loop", + "fighting_sit_loop", + "fishing_loop", + "fishing_sit_loop", + "idle_loop", + "idle_show_loop", + "idle_sit_loop", + "idle_sit_show_loop", + "idle_sneak_loop", + "pocket_idle_idle", + "pocket_idle_show", + "pocket_show_idle", + "pocket_show_show", + "pocket_sit_idle_idle", + "pocket_sit_idle_show", + "pocket_sit_show_idle", + "pocket_sit_show_show", + "pocket_walking_idle_idle", + "pocket_walking_idle_show", + "pocket_walking_show_idle", + "pocket_walking_show_show", + "release", + "release_sit", + "retract", + "retract_sit", + "running_loop", + "running_show_loop", + "sneaking_loop", + "strike", + "walking_loop", + "walking_show_loop", +} + + +class ExportValidationError(RuntimeError): + """Raised when the source or exported GLB violates the contract.""" + + +def _parse_arguments() -> argparse.Namespace: + script_arguments: list[str] = [] + if "--" in sys.argv: + script_arguments = sys.argv[sys.argv.index("--") + 1 :] + parser = argparse.ArgumentParser( + description="Export the authoritative straywild character to GLB." + ) + parser.add_argument( + "--output", + type=Path, + required=True, + help="Runtime .glb file to write.", + ) + parser.add_argument( + "--verify-determinism", + action="store_true", + help="Export twice and require byte-identical output.", + ) + return parser.parse_args(script_arguments) + + +def _sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as source_file: + for block in iter(lambda: source_file.read(1024 * 1024), b""): + digest.update(block) + return digest.hexdigest() + + +def _validate_source() -> tuple[Path, bpy.types.Object]: + if not bpy.data.filepath: + raise ExportValidationError("Open a character .blend before exporting.") + source_path = Path(bpy.data.filepath).resolve() + + armatures = [ + item for item in bpy.data.objects if item.type == "ARMATURE" + ] + if [item.name for item in armatures] != [ARMATURE_NAME]: + raise ExportValidationError( + "Expected only the CharacterRig armature; found " + f"{sorted(item.name for item in armatures)}." + ) + armature = armatures[0] + actual_bone_parents = { + bone.name: bone.parent.name if bone.parent is not None else None + for bone in armature.data.bones + } + if set(actual_bone_parents) != set(EXPECTED_BONE_PARENTS): + missing = sorted(set(EXPECTED_BONE_PARENTS) - set(actual_bone_parents)) + unexpected = sorted(set(actual_bone_parents) - set(EXPECTED_BONE_PARENTS)) + raise ExportValidationError( + f"{ARMATURE_NAME} bone manifest mismatch; missing={missing}, " + f"unexpected={unexpected}." + ) + incorrect_bone_parents = { + bone_name: { + "expected": expected_parent, + "actual": actual_bone_parents[bone_name], + } + for bone_name, expected_parent in EXPECTED_BONE_PARENTS.items() + if actual_bone_parents[bone_name] != expected_parent + } + if incorrect_bone_parents: + raise ExportValidationError( + f"{ARMATURE_NAME} bone parent mismatch: " + f"{incorrect_bone_parents}." + ) + + unused_collection = bpy.data.collections.get(UNUSED_COLLECTION_NAME) + if unused_collection is None: + raise ExportValidationError("The exact unused collection is missing.") + unused_names = {item.name for item in unused_collection.all_objects} + if unused_names != UNUSED_MESH_NAMES: + raise ExportValidationError( + "The unused collection must contain exactly " + f"{sorted(UNUSED_MESH_NAMES)}; found {sorted(unused_names)}." + ) + + actual_mesh_names = { + item.name for item in bpy.data.objects if item.type == "MESH" + } + expected_mesh_names = ( + RUNTIME_MESH_NAMES | UNUSED_MESH_NAMES | {PREVIEW_MESH_NAME} + ) + if actual_mesh_names != expected_mesh_names: + missing = sorted(expected_mesh_names - actual_mesh_names) + unexpected = sorted(actual_mesh_names - expected_mesh_names) + raise ExportValidationError( + f"Character mesh manifest mismatch; missing={missing}, " + f"unexpected={unexpected}." + ) + if len(RUNTIME_MESH_NAMES) != 137: + raise ExportValidationError( + "Internal runtime mesh manifest is not exactly 137 unique names." + ) + if len(FACE_DECAL_MESH_NAMES) != 63: + raise ExportValidationError( + "Internal face decal manifest is not exactly 63 unique names." + ) + + action_names = {action.name for action in bpy.data.actions} + if action_names != SOURCE_ACTION_NAMES: + missing = sorted(SOURCE_ACTION_NAMES - action_names) + unexpected = sorted(action_names - SOURCE_ACTION_NAMES) + raise ExportValidationError( + f"Character action manifest mismatch; missing={missing}, " + f"unexpected={unexpected}." + ) + + for mesh_name in sorted(RUNTIME_MESH_NAMES): + mesh_object = bpy.data.objects[mesh_name] + armature_modifiers = [ + modifier + for modifier in mesh_object.modifiers + if modifier.type == "ARMATURE" + ] + if mesh_object.parent is not armature: + raise ExportValidationError( + f"{mesh_name} is not parented to {ARMATURE_NAME}." + ) + if len(armature_modifiers) != 1: + raise ExportValidationError( + f"{mesh_name} has {len(armature_modifiers)} armature modifiers; " + "expected exactly one." + ) + if armature_modifiers[0].object is not armature: + raise ExportValidationError( + f"{mesh_name} armature modifier does not target {ARMATURE_NAME}." + ) + + return source_path, armature + + +def _prepare_in_memory_export() -> None: + excluded_names = UNUSED_MESH_NAMES | {PREVIEW_MESH_NAME} + for object_name in sorted(excluded_names): + bpy.data.objects.remove(bpy.data.objects[object_name], do_unlink=True) + + remaining_mesh_names = { + item.name for item in bpy.context.scene.objects if item.type == "MESH" + } + if remaining_mesh_names != RUNTIME_MESH_NAMES: + raise ExportValidationError( + "Active scene membership differs from the runtime manifest after " + "excluding authoring-only meshes." + ) + + +def _export(output_path: Path) -> None: + result = bpy.ops.export_scene.gltf( + filepath=str(output_path), + check_existing=False, + export_format="GLB", + export_texcoords=True, + export_normals=True, + export_tangents=False, + export_materials="EXPORT", + export_attributes=False, + use_selection=False, + use_visible=False, + use_renderable=False, + use_active_collection=False, + use_active_collection_with_nested=False, + use_active_scene=True, + export_cameras=False, + export_lights=False, + export_yup=True, + export_apply=False, + export_extras=False, + export_animations=True, + export_frame_range=False, + export_force_sampling=True, + export_animation_mode="ACTIONS", + export_def_bones=False, + export_leaf_bone=False, + export_reset_pose_bones=True, + export_rest_position_armature=True, + export_skins=True, + export_influence_nb=4, + export_all_influences=False, + export_morph=False, + will_save_settings=False, + ) + if result != {"FINISHED"}: + raise RuntimeError(f"Blender failed to export the character: {result}") + + +def _read_glb_document(output_path: Path) -> dict[str, object]: + with output_path.open("rb") as binary_file: + header = binary_file.read(20) + if len(header) != 20 or header[:4] != b"glTF": + raise ExportValidationError( + f"{output_path} is not a valid GLB file." + ) + json_length, json_kind = struct.unpack_from(" dict[int, int]: + parents: dict[int, int] = {} + for parent_index, node in enumerate(nodes): + for child_index in node.get("children", []): + if not isinstance(child_index, int) or not 0 <= child_index < len(nodes): + raise ExportValidationError( + f"GLB node {parent_index} has invalid child {child_index}." + ) + if child_index in parents: + raise ExportValidationError( + f"GLB node {child_index} has more than one parent." + ) + parents[child_index] = parent_index + return parents + + +def _validate_export(output_path: Path) -> None: + document = _read_glb_document(output_path) + nodes = document.get("nodes", []) + if not isinstance(nodes, list) or not all( + isinstance(node, dict) for node in nodes + ): + raise ExportValidationError("GLB nodes must be an array of objects.") + expected_node_count = ( + 1 + len(EXPECTED_BONE_PARENTS) + len(RUNTIME_MESH_NAMES) + ) + if len(nodes) != expected_node_count: + raise ExportValidationError( + f"GLB has {len(nodes)} nodes; expected {expected_node_count} " + "(one armature, 19 joints, and 137 meshes)." + ) + + scenes = document.get("scenes", []) + if not isinstance(scenes, list) or len(scenes) != 1: + raise ExportValidationError("GLB must contain exactly one scene.") + scene_index = document.get("scene", 0) + if scene_index != 0: + raise ExportValidationError( + f"GLB default scene index is {scene_index}; expected 0." + ) + scene_roots = scenes[0].get("nodes", []) + if not isinstance(scene_roots, list) or len(scene_roots) != 1: + raise ExportValidationError( + "GLB scene must contain exactly one CharacterRig root node." + ) + armature_node_index = scene_roots[0] + if ( + not isinstance(armature_node_index, int) + or not 0 <= armature_node_index < len(nodes) + or str(nodes[armature_node_index].get("name", "")) != ARMATURE_NAME + ): + raise ExportValidationError( + "GLB scene root must be CharacterRig so Godot imports the skeleton " + f"at {GODOT_SKELETON_NODE_PATH}." + ) + parents = _node_parent_indices(nodes) + if armature_node_index in parents: + raise ExportValidationError("GLB CharacterRig scene root has a parent.") + + mesh_node_indices = [ + node_index + for node_index, node in enumerate(nodes) + if "mesh" in node + ] + exported_mesh_names = [ + str(nodes[node_index].get("name", "")) + for node_index in mesh_node_indices + ] + duplicate_mesh_names = sorted( + { + mesh_name + for mesh_name in exported_mesh_names + if exported_mesh_names.count(mesh_name) > 1 + } + ) + if duplicate_mesh_names: + raise ExportValidationError( + f"GLB has duplicate mesh node names: {duplicate_mesh_names}." + ) + exported_mesh_name_set = set(exported_mesh_names) + if exported_mesh_name_set != RUNTIME_MESH_NAMES: + missing = sorted(RUNTIME_MESH_NAMES - exported_mesh_name_set) + unexpected = sorted(exported_mesh_name_set - RUNTIME_MESH_NAMES) + raise ExportValidationError( + f"Exported mesh membership mismatch; missing={missing}, " + f"unexpected={unexpected}." + ) + meshes = document.get("meshes", []) + if not isinstance(meshes, list) or len(meshes) != len(RUNTIME_MESH_NAMES): + raise ExportValidationError( + f"GLB has {len(meshes)} mesh resources; " + f"expected {len(RUNTIME_MESH_NAMES)}." + ) + for node_index in mesh_node_indices: + node = nodes[node_index] + if node.get("skin") != 0: + raise ExportValidationError( + f"Mesh node {node.get('name', '')} targets skin " + f"{node.get('skin')}; expected skin 0." + ) + if parents.get(node_index) != armature_node_index: + raise ExportValidationError( + f"Mesh node {node.get('name', '')} is not a direct child of " + f"{ARMATURE_NAME}; Godot would not import the expected skeleton " + f"path {GODOT_SKELETON_NODE_PATH}." + ) + mesh_index = node.get("mesh") + if not isinstance(mesh_index, int) or not 0 <= mesh_index < len(meshes): + raise ExportValidationError( + f"Mesh node {node.get('name', '')} has invalid mesh index " + f"{mesh_index}." + ) + referenced_mesh_indices = [ + int(nodes[node_index]["mesh"]) for node_index in mesh_node_indices + ] + if ( + len(referenced_mesh_indices) != len(meshes) + or set(referenced_mesh_indices) != set(range(len(meshes))) + ): + raise ExportValidationError( + "GLB mesh resources must each be referenced by exactly one mesh node." + ) + + if len(document.get("animations", [])) != len(SOURCE_ACTION_NAMES): + raise ExportValidationError( + f"GLB has {len(document.get('animations', []))} animations; " + f"expected {len(SOURCE_ACTION_NAMES)}." + ) + if len(document.get("skins", [])) != 1: + raise ExportValidationError("GLB must contain exactly one skin.") + skin = document["skins"][0] + if str(skin.get("name", "")) != ARMATURE_NAME: + raise ExportValidationError( + f"GLB skin must be named {ARMATURE_NAME}." + ) + joints = skin.get("joints", []) + if not isinstance(joints, list) or len(joints) != len(EXPECTED_BONE_PARENTS): + raise ExportValidationError( + f"GLB skin has {len(joints)} joints; " + f"expected {len(EXPECTED_BONE_PARENTS)}." + ) + if len(set(joints)) != len(joints) or not all( + isinstance(joint_index, int) and 0 <= joint_index < len(nodes) + for joint_index in joints + ): + raise ExportValidationError("GLB skin contains invalid or duplicate joints.") + joint_indices_by_name = { + str(nodes[joint_index].get("name", "")): joint_index + for joint_index in joints + } + if set(joint_indices_by_name) != set(EXPECTED_BONE_PARENTS): + missing = sorted( + set(EXPECTED_BONE_PARENTS) - set(joint_indices_by_name) + ) + unexpected = sorted( + set(joint_indices_by_name) - set(EXPECTED_BONE_PARENTS) + ) + raise ExportValidationError( + f"GLB joint manifest mismatch; missing={missing}, " + f"unexpected={unexpected}." + ) + root_joint_index = joint_indices_by_name["root"] + if skin.get("skeleton", root_joint_index) != root_joint_index: + raise ExportValidationError( + "GLB skin skeleton must resolve to the root joint." + ) + for bone_name, expected_parent_name in EXPECTED_BONE_PARENTS.items(): + joint_index = joint_indices_by_name[bone_name] + expected_parent_index = ( + armature_node_index + if expected_parent_name is None + else joint_indices_by_name[expected_parent_name] + ) + if parents.get(joint_index) != expected_parent_index: + actual_parent_index = parents.get(joint_index) + actual_parent_name = ( + str(nodes[actual_parent_index].get("name", "")) + if actual_parent_index is not None + else None + ) + raise ExportValidationError( + f"GLB joint {bone_name} parent is {actual_parent_name}; " + f"expected {expected_parent_name or ARMATURE_NAME}." + ) + animation_names = { + str(animation.get("name", "")) + for animation in document.get("animations", []) + } + if animation_names != SOURCE_ACTION_NAMES: + raise ExportValidationError( + "Exported animation names differ from the source action manifest." + ) + + +def _temporary_export_path(output_path: Path, label: str) -> Path: + descriptor, raw_path = tempfile.mkstemp( + prefix=f".{output_path.stem}.{label}.", + suffix=".glb", + dir=output_path.parent, + ) + os.close(descriptor) + os.unlink(raw_path) + return Path(raw_path) + + +def _run() -> int: + arguments = _parse_arguments() + output_path = arguments.output.expanduser().resolve() + if output_path.suffix.lower() != ".glb": + raise ExportValidationError("--output must name a .glb file.") + output_path.parent.mkdir(parents=True, exist_ok=True) + + source_path, _armature = _validate_source() + source_sha256 = _sha256(source_path) + _prepare_in_memory_export() + temporary_paths = [_temporary_export_path(output_path, "first")] + try: + first_export_path = temporary_paths[0] + _export(first_export_path) + _validate_export(first_export_path) + + if arguments.verify_determinism: + second_export_path = _temporary_export_path(output_path, "second") + temporary_paths.append(second_export_path) + _export(second_export_path) + _validate_export(second_export_path) + if second_export_path.read_bytes() != first_export_path.read_bytes(): + raise ExportValidationError( + "Repeated character exports were not byte-identical." + ) + + os.replace(first_export_path, output_path) + output_sha256 = _sha256(output_path) + finally: + for temporary_path in temporary_paths: + try: + temporary_path.unlink() + except FileNotFoundError: + pass + + print( + "Character export: PASS " + f"(source={source_sha256}, output={output_sha256}, " + f"meshes={len(RUNTIME_MESH_NAMES)}, " + f"face_decals={len(FACE_DECAL_MESH_NAMES)}, " + f"animations={len(SOURCE_ACTION_NAMES)}, bones=19)" + ) + return 0 + + +if __name__ == "__main__": + try: + raise SystemExit(_run()) + except ExportValidationError as error: + print(f"CHARACTER EXPORT FAILED:\n{error}", file=sys.stderr) + raise SystemExit(2) from error diff --git a/ui/profile_page.gd b/ui/profile_page.gd index a90e2da..d9fd9eb 100644 --- a/ui/profile_page.gd +++ b/ui/profile_page.gd @@ -4,6 +4,8 @@ extends Control const CHECK_DEBOUNCE_SECONDS: float = 0.4 const APPEARANCE_PREVIEW_INTERVAL_SECONDS: float = 0.08 const OPTION_GRID_COLUMNS: int = 3 +const MODEL_OPTION_GRID_COLUMNS: int = 2 +const MODEL_OPTION_BUTTON_HEIGHT: float = 36.0 const FUR_PALETTE_GRID_COLUMNS: int = 6 const FUR_PATTERN_GRID_COLUMNS: int = 2 const FUR_CHANNEL_GRID_COLUMNS: int = 2 @@ -68,6 +70,7 @@ var _title_edit: LineEdit var _name_label: Label var _name_status: Label var _suggestions: HBoxContainer +var _category_scroll: ScrollContainer var _category_list: VBoxContainer var _option_list: VBoxContainer var _preview: ProfilePreview @@ -107,6 +110,7 @@ var _option_scroll: ScrollContainer var _active_option_scroll_key: String = "" var _option_scroll_positions: Dictionary[String, int] = {} var _option_scroll_restore_generation: int = 0 +var _option_focus_keys: Dictionary[String, String] = {} var _controller_mapping_manager: ControllerMappingManagerType var _controller_zone: ControllerZone = ControllerZone.ACCOUNT var _controller_option_depth: int = 0 @@ -448,6 +452,23 @@ func _focus_controller_zone() -> void: ): _customize_button.grab_focus() return + if _controller_zone == ControllerZone.OPTIONS: + var preferred_focus_key := str( + _option_focus_keys.get(_category_id, "") + ) + if not preferred_focus_key.is_empty(): + for control: Control in controls: + var button := control as BaseButton + if ( + button != null + and str(button.get_meta(&"appearance_focus_key", "")) + == preferred_focus_key + and button.focus_mode != Control.FOCUS_NONE + and button.is_visible_in_tree() + and not button.disabled + ): + button.grab_focus() + return for control: Control in controls: var button := control as BaseButton if ( @@ -805,17 +826,18 @@ func _build_ui() -> void: "separation", BODY_COLUMN_SEPARATION ) 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_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 + _category_scroll.follow_focus = true + 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) + _category_scroll.add_child(_category_list) _option_list = VBoxContainer.new() _option_list.custom_minimum_size = Vector2(326, 0) _option_list.size_flags_horizontal = Control.SIZE_EXPAND_FILL @@ -931,6 +953,9 @@ func _build_categories() -> void: button.custom_minimum_size.x = 120.0 button.button_pressed = category_id == _category_id button.pressed.connect(_select_category.bind(category_id)) + button.focus_entered.connect( + _scroll_control_into_view.bind(_category_scroll, button) + ) UtilityPageStyle.apply_compact_ocean_button(button) _category_list.add_child(button) _refresh_options() @@ -971,6 +996,9 @@ func _refresh_options() -> void: if _category_id == CharacterCustomizationCatalog.SCALE_CATEGORY_ID: _build_scale_option() return + if _category_id == "special": + _build_special_options() + return var options: Array = CharacterCustomizationCatalog.options_for(_category_id) if options.is_empty(): var empty := Label.new() @@ -984,21 +1012,129 @@ func _refresh_options() -> void: if _category_id in CharacterCustomizationCatalog.FEATURE_CATEGORIES: _build_feature_preview_options(options) return + _build_model_option_grid(_category_id, options) + + +func _build_model_option_grid(category_id: String, options: Array) -> void: + var scroll := ScrollContainer.new() + scroll.name = "ModelOptionScroll" + scroll.size_flags_horizontal = Control.SIZE_EXPAND_FILL + 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) + _register_option_scroll(scroll) + var grid := GridContainer.new() + grid.name = "ModelOptionGrid" + grid.columns = MODEL_OPTION_GRID_COLUMNS + grid.size_flags_horizontal = Control.SIZE_EXPAND_FILL + grid.add_theme_constant_override("h_separation", 8) + grid.add_theme_constant_override("v_separation", 7) + scroll.add_child(grid) 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"] + var button := _build_model_option_button(category_id, option) + grid.add_child(button) + _register_scroll_focus(button, scroll) + + +func _build_model_option_button( + category_id: String, + option: Dictionary, +) -> Button: + var option_id := str(option.get("id", "")) + var option_label := str(option.get("label", option_id)) + var button := Button.new() + button.text = "×" if option_id == "none" else option_label + button.tooltip_text = "none" if option_id == "none" else option_label + button.toggle_mode = true + button.size_flags_horizontal = Control.SIZE_EXPAND_FILL + button.custom_minimum_size.y = MODEL_OPTION_BUTTON_HEIGHT + button.button_pressed = ( + str(_draft_appearance.get(category_id, "")) == option_id + ) + button.set_meta( + &"appearance_focus_key", + "%s:%s" % [category_id, option_id], + ) + button.focus_entered.connect( + _remember_option_focus.bind(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) + return button + + +func _build_special_options() -> void: + var scroll := ScrollContainer.new() + scroll.name = "SpecialOptionScroll" + scroll.size_flags_horizontal = Control.SIZE_EXPAND_FILL + 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) + _register_option_scroll(scroll) + var selectors := VBoxContainer.new() + selectors.name = "SpecialOptionSelectors" + selectors.size_flags_horizontal = Control.SIZE_EXPAND_FILL + selectors.add_theme_constant_override("separation", 6) + scroll.add_child(selectors) + for field_id: String in CharacterCustomizationCatalog.SPECIAL_SLOT_IDS: + var options: Array = CharacterCustomizationCatalog.options_for(field_id) + if options.is_empty(): + continue + var row := HBoxContainer.new() + row.name = "SpecialSlot_%s" % field_id.trim_prefix("special_") + row.size_flags_horizontal = Control.SIZE_EXPAND_FILL + row.add_theme_constant_override("separation", 6) + selectors.add_child(row) + var label := Label.new() + label.text = CharacterCustomizationCatalog.special_slot_label(field_id) + label.custom_minimum_size.x = 50.0 + label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER + label.add_theme_color_override( + "font_color", UtilityPageStyle.OCEAN_TEXT_SECONDARY ) - 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) + row.add_child(label) + var choices := HBoxContainer.new() + choices.size_flags_horizontal = Control.SIZE_EXPAND_FILL + choices.add_theme_constant_override("separation", 5) + row.add_child(choices) + for option: Dictionary in options: + var button := _build_model_option_button(field_id, option) + button.custom_minimum_size.y = 34.0 + button.add_theme_font_size_override( + "font_size", + 20 if str(option.get("id", "")) == "none" else 16, + ) + choices.add_child(button) + _register_scroll_focus(button, scroll) + + +func _register_scroll_focus( + control: Control, + scroll: ScrollContainer, +) -> void: + control.focus_entered.connect( + _scroll_control_into_view.bind(scroll, control) + ) + + +func _scroll_control_into_view( + scroll: ScrollContainer, + control: Control, +) -> void: + if ( + is_instance_valid(scroll) + and is_instance_valid(control) + and control.is_visible_in_tree() + ): + scroll.ensure_control_visible(control) + + +func _remember_option_focus(category_id: String, option_id: String) -> void: + _option_focus_keys[_category_id] = "%s:%s" % [category_id, option_id] func _option_scroll_context_key() -> String: diff --git a/ui/profile_preview.gd b/ui/profile_preview.gd index 55026a4..b27b6e0 100644 --- a/ui/profile_preview.gd +++ b/ui/profile_preview.gd @@ -11,9 +11,11 @@ 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 MAX_CAMERA_DISTANCE: float = 4.5 const CAMERA_ZOOM_STEP: float = 0.14 const CAMERA_TARGET_HEIGHT: float = 0.95 +const CAMERA_FIT_MARGIN: float = 1.12 +const CAMERA_FIT_MIN_RADIUS: float = 0.08 @export_range(0.1, 2.0, 0.05) var drag_sensitivity: float = 0.012 @export_range(0.1, 4.0, 0.1) var keyboard_speed: float = 1.8 @@ -28,6 +30,11 @@ var _dragging: bool = false var _camera_yaw: float = 0.0 var _camera_pitch: float = DEFAULT_CAMERA_PITCH var _camera_distance: float = DEFAULT_CAMERA_DISTANCE +var _camera_fit_distance: float = DEFAULT_CAMERA_DISTANCE +var _camera_zoom_offset: float = 0.0 +var _camera_target: Vector3 = Vector3(0.0, CAMERA_TARGET_HEIGHT, 0.0) +var _last_visible_bounds: AABB = AABB() +var _has_visible_bounds: bool = false var _visuals: Node3D var _controller_mapping_manager: ControllerMappingManagerType var _source_environment: WorldEnvironment @@ -81,13 +88,15 @@ func _ready() -> void: func apply_appearance_profile(profile: Dictionary) -> void: if _visuals != null: PlayerVisualPresenter.apply_appearance(_visuals, profile) + _fit_camera_to_visible_parts() func reset_view() -> void: _preview_root.rotation.y = FRONT_FACING_YAW _camera_yaw = 0.0 _camera_pitch = DEFAULT_CAMERA_PITCH - _camera_distance = DEFAULT_CAMERA_DISTANCE + _camera_zoom_offset = 0.0 + _camera_distance = _camera_fit_distance _apply_camera_orbit() @@ -150,6 +159,7 @@ func _zoom_preview(amount: float) -> void: MIN_CAMERA_DISTANCE, MAX_CAMERA_DISTANCE, ) + _camera_zoom_offset = _camera_distance - _camera_fit_distance _apply_camera_orbit() @@ -159,15 +169,115 @@ func _apply_camera_orbit() -> void: var horizontal_distance := cos(_camera_pitch) * _camera_distance _preview_camera.position = Vector3( sin(_camera_yaw) * horizontal_distance, - CAMERA_TARGET_HEIGHT + sin(_camera_pitch) * _camera_distance, + sin(_camera_pitch) * _camera_distance, cos(_camera_yaw) * horizontal_distance, - ) + ) + _camera_target _preview_camera.look_at( - Vector3(0.0, CAMERA_TARGET_HEIGHT, 0.0), + _camera_target, Vector3.UP, ) +func _fit_camera_to_visible_parts() -> void: + if _visuals == null or _preview_camera == null: + return + var bounds_result: Dictionary = _visible_mesh_bounds() + if not bool(bounds_result.get("has_bounds", false)): + return + var bounds: AABB = bounds_result.get("bounds", AABB()) as AABB + if ( + _has_visible_bounds + and bounds.position.is_equal_approx(_last_visible_bounds.position) + and bounds.size.is_equal_approx(_last_visible_bounds.size) + ): + return + _last_visible_bounds = bounds + _has_visible_bounds = true + _camera_target = bounds.get_center() + var viewport_size := Vector2( + _preview_camera.get_viewport().get_visible_rect().size + ) + _camera_fit_distance = clampf( + calculate_camera_fit_distance( + bounds, + viewport_size, + _preview_camera.fov, + ), + MIN_CAMERA_DISTANCE, + MAX_CAMERA_DISTANCE, + ) + _camera_distance = clampf( + _camera_fit_distance + _camera_zoom_offset, + MIN_CAMERA_DISTANCE, + MAX_CAMERA_DISTANCE, + ) + _apply_camera_orbit() + + +func _visible_mesh_bounds() -> Dictionary: + var has_bounds := false + var bounds := AABB() + for descendant: Node in _visuals.find_children( + "*", "MeshInstance3D", true, false + ): + var mesh_instance := descendant as MeshInstance3D + if ( + mesh_instance == null + or mesh_instance.mesh == null + or not _is_visible_within_preview(mesh_instance) + ): + continue + var mesh_bounds := mesh_instance.get_aabb() + var world_transform := mesh_instance.global_transform + for corner_index: int in 8: + var corner := mesh_bounds.position + Vector3( + mesh_bounds.size.x if (corner_index & 1) != 0 else 0.0, + mesh_bounds.size.y if (corner_index & 2) != 0 else 0.0, + mesh_bounds.size.z if (corner_index & 4) != 0 else 0.0, + ) + var point := world_transform * corner + if not has_bounds: + bounds = AABB(point, Vector3.ZERO) + has_bounds = true + else: + bounds = bounds.expand(point) + return {"has_bounds": has_bounds, "bounds": bounds} + + +func _is_visible_within_preview(node: Node3D) -> bool: + var current: Node = node + while current != null and current != _visuals: + var current_3d := current as Node3D + if current_3d != null and not current_3d.visible: + return false + current = current.get_parent() + return current == _visuals + + +static func calculate_camera_fit_distance( + bounds: AABB, + viewport_size: Vector2, + vertical_fov_degrees: float, +) -> float: + var radius := maxf( + bounds.size.length() * 0.5, + CAMERA_FIT_MIN_RADIUS, + ) + var safe_height := maxf(viewport_size.y, 1.0) + var aspect := maxf(viewport_size.x, 1.0) / safe_height + var vertical_half_angle := deg_to_rad(vertical_fov_degrees) * 0.5 + var horizontal_half_angle := atan(tan(vertical_half_angle) * aspect) + var limiting_half_angle := minf( + vertical_half_angle, + horizontal_half_angle, + ) + return ( + radius + / maxf(sin(limiting_half_angle), 0.001) + * CAMERA_FIT_MARGIN + ) + + func _sync_world_lighting() -> void: # The customization viewport deliberately does not follow gameplay time of # day. Keep this method as a compatibility seam for existing setup callers. diff --git a/world/interactables/fishing_shop_world.tscn b/world/interactables/fishing_shop_world.tscn index 4daee51..91c6adc 100644 --- a/world/interactables/fishing_shop_world.tscn +++ b/world/interactables/fishing_shop_world.tscn @@ -14,7 +14,7 @@ radius = 1.5 script = ExtResource("3_display") species_id = "pointy" fur_color_id = "white" -ears_id = "pointy_wide" +ears_id = "fox" tail_id = "fox" eyes_id = "punk_purple" nose_id = "triangle_small" diff --git a/world/interactables/rv_upgrade_shop_world.tscn b/world/interactables/rv_upgrade_shop_world.tscn index 83f9126..154a447 100644 --- a/world/interactables/rv_upgrade_shop_world.tscn +++ b/world/interactables/rv_upgrade_shop_world.tscn @@ -14,7 +14,7 @@ radius = 1.5 script = ExtResource("3_display") species_id = "pointy" fur_color_id = "teal" -ears_id = "pointy_long" +ears_id = "long" tail_id = "fox" eyes_id = "interested" nose_id = "triangle_small" diff --git a/world/world_character_display.gd b/world/world_character_display.gd index 5a5a91c..eceb43e 100644 --- a/world/world_character_display.gd +++ b/world/world_character_display.gd @@ -8,7 +8,30 @@ const PlayerVisualPresenterType = preload( "res://player/player_visual_presenter.gd" ) -@export_enum("round", "pointy") var species_id: String = "round" +@export_enum( + "round", + "pointy", + "anteater", + "axolotl", + "bat", + "bird", + "boar", + "butterfly", + "donkey", + "elephant", + "goat", + "hamster", + "horse", + "moth", + "opossum", + "owl", + "panda", + "pig", + "shark", + "sheep", + "snail", +) var species_id: String = "round" +@export_enum("mammal", "fish", "avian") var arms_id: String = "mammal" @export_enum( "white", "gray", @@ -21,19 +44,54 @@ const PlayerVisualPresenterType = preload( ) var fur_color_id: String = "white" @export_enum( "none", - "pointy_long", - "pointy_short", - "pointy_wide", + "antlers_round", + "anteater", + "axolotl", + "bat", + "bear", + "boar", + "bunny", + "butterfly", + "cat", + "donkey", + "elephant", + "fox", + "goat", + "hamster", + "horse", + "long", + "moth", + "opossum", + "owl", + "panda", + "pig", ) var ears_id: String = "none" @export_enum( "none", + "anteater", + "axolotl", "bear", + "bird", + "boar", "bunny", + "butterfly", "cat", + "donkey", "fox", "gator", + "horse", + "moth", + "opossum", + "pig", "pointy", + "shark", ) var tail_id: String = "none" +@export_enum("none", "shell") var special_back_id: String = "none" +@export_enum("none", "bird", "owl") var special_beak_id: String = "none" +@export_enum("none", "fin") var special_head_id: String = "none" +@export_enum("none", "goat", "sheep") var special_horns_id: String = "none" +@export_enum("none", "boar", "elephant") var special_tusks_id: String = "none" +@export_enum("none", "bat", "butterfly", "moth") var special_wings_id: String = "none" @export var eyes_id: String = "simple_shine" @export var nose_id: String = "dog_round" @export var mouth_id: String = "three" @@ -57,9 +115,16 @@ var _locomotion_walking: bool = false func _ready() -> void: var appearance := CharacterCustomizationCatalogType.default_snapshot() appearance["species"] = species_id + appearance["arms"] = arms_id appearance["fur_pattern"] = fur_color_id appearance["ears"] = ears_id appearance["tail"] = tail_id + appearance["special_back"] = special_back_id + appearance["special_beak"] = special_beak_id + appearance["special_head"] = special_head_id + appearance["special_horns"] = special_horns_id + appearance["special_tusks"] = special_tusks_id + appearance["special_wings"] = special_wings_id appearance["eyes"] = eyes_id appearance["nose"] = nose_id appearance["mouth"] = mouth_id