diff --git a/art/exported/characters/base/netfishing_base_character.glb b/art/exported/characters/base/netfishing_base_character.glb index 15135f4..54b3042 100644 Binary files a/art/exported/characters/base/netfishing_base_character.glb and b/art/exported/characters/base/netfishing_base_character.glb differ diff --git a/art/exported/characters/patterns/fox/body_arms.png b/art/exported/characters/patterns/fox/body_arms.png new file mode 100644 index 0000000..6fcccd8 Binary files /dev/null and b/art/exported/characters/patterns/fox/body_arms.png differ diff --git a/art/exported/characters/patterns/fox/body_arms.png.import b/art/exported/characters/patterns/fox/body_arms.png.import new file mode 100644 index 0000000..1518ce5 --- /dev/null +++ b/art/exported/characters/patterns/fox/body_arms.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bdwqv502so784" +path="res://.godot/imported/body_arms.png-a4119ca84faf9d8bfa9bc0ab6f8ca133.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://art/exported/characters/patterns/fox/body_arms.png" +dest_files=["res://.godot/imported/body_arms.png-a4119ca84faf9d8bfa9bc0ab6f8ca133.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=true +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/art/exported/characters/patterns/fox/body_main.png b/art/exported/characters/patterns/fox/body_main.png new file mode 100644 index 0000000..efe8299 Binary files /dev/null and b/art/exported/characters/patterns/fox/body_main.png differ diff --git a/art/exported/characters/patterns/fox/body_main.png.import b/art/exported/characters/patterns/fox/body_main.png.import new file mode 100644 index 0000000..ddfb990 --- /dev/null +++ b/art/exported/characters/patterns/fox/body_main.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://ccbaq4d4nt3bm" +path="res://.godot/imported/body_main.png-c83ac58a68a1485819fba3e07de784cd.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://art/exported/characters/patterns/fox/body_main.png" +dest_files=["res://.godot/imported/body_main.png-c83ac58a68a1485819fba3e07de784cd.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=true +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/art/exported/characters/patterns/spots_bengal/body_arms.png b/art/exported/characters/patterns/spots_bengal/body_arms.png new file mode 100644 index 0000000..dd4c735 Binary files /dev/null and b/art/exported/characters/patterns/spots_bengal/body_arms.png differ diff --git a/art/exported/characters/patterns/spots_bengal/body_arms.png.import b/art/exported/characters/patterns/spots_bengal/body_arms.png.import new file mode 100644 index 0000000..74164ab --- /dev/null +++ b/art/exported/characters/patterns/spots_bengal/body_arms.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://m2un0oahhss3" +path="res://.godot/imported/body_arms.png-a55eeb0787397c58508a7c65cc2d04f8.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://art/exported/characters/patterns/spots_bengal/body_arms.png" +dest_files=["res://.godot/imported/body_arms.png-a55eeb0787397c58508a7c65cc2d04f8.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=true +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/art/exported/characters/patterns/spots_bengal/body_main.png b/art/exported/characters/patterns/spots_bengal/body_main.png new file mode 100644 index 0000000..23eed8e Binary files /dev/null and b/art/exported/characters/patterns/spots_bengal/body_main.png differ diff --git a/art/exported/characters/patterns/spots_bengal/body_main.png.import b/art/exported/characters/patterns/spots_bengal/body_main.png.import new file mode 100644 index 0000000..bf26452 --- /dev/null +++ b/art/exported/characters/patterns/spots_bengal/body_main.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://cybpx6ua1vaw" +path="res://.godot/imported/body_main.png-eff221076f4e25415158ecf38fdf03da.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://art/exported/characters/patterns/spots_bengal/body_main.png" +dest_files=["res://.godot/imported/body_main.png-eff221076f4e25415158ecf38fdf03da.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=true +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/art/source/characters/base/netfishing_character_source.blend b/art/source/characters/base/netfishing_character_source.blend index 3d513a5..805da6d 100644 Binary files a/art/source/characters/base/netfishing_character_source.blend and b/art/source/characters/base/netfishing_character_source.blend differ diff --git a/network/network_profile_protocol.gd b/network/network_profile_protocol.gd index b4c2d3a..32c44b6 100644 --- a/network/network_profile_protocol.gd +++ b/network/network_profile_protocol.gd @@ -56,6 +56,10 @@ static func signature_fields(data: Dictionary) -> Array: ) ), str(appearance.get("fur_pattern", "")), + str(appearance.get("fur_style", "")), + str(appearance.get("fur_color_2", "")), + str(appearance.get("fur_color_3", "")), + str(appearance.get("fur_color_4", "")), str(appearance.get("ears", "")), str(appearance.get("eyes", "")), str(appearance.get("nose", "")), diff --git a/network/network_protocol.gd b/network/network_protocol.gd index b07d220..46192b6 100644 --- a/network/network_protocol.gd +++ b/network/network_protocol.gd @@ -268,6 +268,10 @@ static func client_profile_fields(data: Dictionary) -> Array: ) ), str(appearance.get("fur_pattern", "")), + str(appearance.get("fur_style", "")), + str(appearance.get("fur_color_2", "")), + str(appearance.get("fur_color_3", "")), + str(appearance.get("fur_color_4", "")), str(appearance.get("ears", "")), str(appearance.get("eyes", "")), str(appearance.get("nose", "")), diff --git a/player/fur_pattern.gdshader b/player/fur_pattern.gdshader new file mode 100644 index 0000000..b000f67 --- /dev/null +++ b/player/fur_pattern.gdshader @@ -0,0 +1,27 @@ +shader_type spatial; +render_mode unshaded, cull_back; + +// Pattern PNGs are data maps rather than colored artwork. Black selects the +// base color and the red, green, and blue channels select the three authored +// accent colors. Alpha is intentionally ignored so artists may leave unused +// UV space transparent. +uniform sampler2D pattern_texture : filter_linear_mipmap_anisotropic, repeat_disable; +uniform vec4 base_color : source_color = vec4(1.0); +uniform vec4 red_zone_color : source_color = vec4(0.9, 0.8, 0.6, 1.0); +uniform vec4 green_zone_color : source_color = vec4(0.4, 0.2, 0.1, 1.0); +uniform vec4 blue_zone_color : source_color = vec4(0.7, 0.2, 0.15, 1.0); + +void fragment() { + vec3 zone_weights = max(texture(pattern_texture, UV).rgb, vec3(0.0)); + float zone_total = zone_weights.r + zone_weights.g + zone_weights.b; + // Filtering between two authored zone colors can produce a sum near one. + // Normalize only an overfull sample, retaining smooth black-to-zone edges. + zone_weights /= max(zone_total, 1.0); + float base_weight = 1.0 - min(zone_total, 1.0); + ALBEDO = ( + base_color.rgb * base_weight + + red_zone_color.rgb * zone_weights.r + + green_zone_color.rgb * zone_weights.g + + blue_zone_color.rgb * zone_weights.b + ); +} diff --git a/player/fur_pattern.gdshader.uid b/player/fur_pattern.gdshader.uid new file mode 100644 index 0000000..e16692e --- /dev/null +++ b/player/fur_pattern.gdshader.uid @@ -0,0 +1 @@ +uid://eoeegcj00qy8 diff --git a/player/player_visual_presenter.gd b/player/player_visual_presenter.gd index f29cff7..f0a9c45 100644 --- a/player/player_visual_presenter.gd +++ b/player/player_visual_presenter.gd @@ -2,7 +2,11 @@ class_name PlayerVisualPresenter extends RefCounted const PlayerScene: PackedScene = preload("res://player/player.tscn") +const FurPatternShader: Shader = preload("res://player/fur_pattern.gdshader") const RUNTIME_MATERIAL_META: StringName = &"netfishing_runtime_material" +const RUNTIME_FUR_PATTERN_MATERIAL_META: StringName = ( + &"netfishing_runtime_fur_pattern_material" +) const EAR_MESH_NAMES: Array[String] = [ "ears_antlers_round", "ears_bear", @@ -144,14 +148,17 @@ static func apply_appearance( var fur_color: Color = CharacterCustomizationCatalog.option_color( "fur_pattern", str(snapshot.get("fur_pattern", "white")) ) - _set_mesh_color(skeleton, "body_arms", fur_color) - _set_mesh_color(skeleton, "body_main", fur_color) - _set_mesh_color(skeleton, "head_pointy", fur_color) - _set_mesh_color(skeleton, "head_round", fur_color) + for 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_mesh_color(skeleton, ear_name, fur_color) + _set_fur_mesh(skeleton, ear_name, snapshot, fur_color) for tail_name: String in TAIL_MESH_NAMES: - _set_mesh_color(skeleton, tail_name, fur_color) + _set_fur_mesh(skeleton, tail_name, snapshot, fur_color) var eye_id: String = CharacterCustomizationCatalog.canonical_option_id( "eyes", str(snapshot.get("eyes", "simple_shine")) @@ -202,17 +209,60 @@ static func _set_feature_texture( material.albedo_texture = texture -static func _set_mesh_color( +static func _set_fur_mesh( skeleton: Skeleton3D, mesh_name: String, - color: Color, + snapshot: Dictionary, + base_color: Color, ) -> void: - var mesh_instance: MeshInstance3D = skeleton.get_node_or_null( - mesh_name - ) as MeshInstance3D - var material: StandardMaterial3D = _runtime_material(mesh_instance) - if material != null: - material.albedo_color = color + var mesh_instance := skeleton.get_node_or_null(mesh_name) as MeshInstance3D + if mesh_instance == null: + return + var style_id := CharacterCustomizationCatalog.canonical_option_id( + CharacterCustomizationCatalog.FUR_STYLE_ID, + str(snapshot.get( + CharacterCustomizationCatalog.FUR_STYLE_ID, + CharacterCustomizationCatalog.DEFAULT_FUR_STYLE, + )), + ) + var pattern_texture := CharacterCustomizationCatalog.fur_pattern_texture( + style_id, + mesh_name, + ) + if pattern_texture == null: + var solid_material := _runtime_material(mesh_instance) + if solid_material != null: + solid_material.albedo_color = base_color + mesh_instance.material_override = solid_material + return + + var pattern_material := _runtime_fur_pattern_material(mesh_instance) + pattern_material.set_shader_parameter("pattern_texture", pattern_texture) + pattern_material.set_shader_parameter("base_color", base_color) + pattern_material.set_shader_parameter( + "red_zone_color", + _fur_snapshot_color(snapshot, "fur_color_2", "cream"), + ) + pattern_material.set_shader_parameter( + "green_zone_color", + _fur_snapshot_color(snapshot, "fur_color_3", "brown"), + ) + pattern_material.set_shader_parameter( + "blue_zone_color", + _fur_snapshot_color(snapshot, "fur_color_4", "red"), + ) + mesh_instance.material_override = pattern_material + + +static func _fur_snapshot_color( + snapshot: Dictionary, + category_id: String, + default_id: String, +) -> Color: + return CharacterCustomizationCatalog.option_color( + category_id, + str(snapshot.get(category_id, default_id)), + ) static func _runtime_material( @@ -221,7 +271,9 @@ static func _runtime_material( if mesh_instance == null or mesh_instance.mesh == null: return null if mesh_instance.has_meta(RUNTIME_MATERIAL_META): - var runtime := mesh_instance.material_override as StandardMaterial3D + var runtime := mesh_instance.get_meta( + RUNTIME_MATERIAL_META + ) as StandardMaterial3D _configure_matte_material(runtime) return runtime var source: StandardMaterial3D = mesh_instance.mesh.surface_get_material( @@ -232,7 +284,20 @@ static func _runtime_material( var material: StandardMaterial3D = source.duplicate() as StandardMaterial3D _configure_matte_material(material) mesh_instance.material_override = material - mesh_instance.set_meta(RUNTIME_MATERIAL_META, true) + mesh_instance.set_meta(RUNTIME_MATERIAL_META, material) + return material + + +static func _runtime_fur_pattern_material( + mesh_instance: MeshInstance3D, +) -> ShaderMaterial: + if mesh_instance.has_meta(RUNTIME_FUR_PATTERN_MATERIAL_META): + return mesh_instance.get_meta( + RUNTIME_FUR_PATTERN_MATERIAL_META + ) as ShaderMaterial + var material := ShaderMaterial.new() + material.shader = FurPatternShader + mesh_instance.set_meta(RUNTIME_FUR_PATTERN_MATERIAL_META, material) return material diff --git a/progression/character_customization_catalog.gd b/progression/character_customization_catalog.gd index 7350425..f3ca297 100644 --- a/progression/character_customization_catalog.gd +++ b/progression/character_customization_catalog.gd @@ -12,10 +12,28 @@ const CATEGORY_IDS: PackedStringArray = [ "tail", ] +# 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. +const SNAPSHOT_IDS: PackedStringArray = [ + "species", + "scale", + "fur_pattern", + "fur_style", + "fur_color_2", + "fur_color_3", + "fur_color_4", + "ears", + "eyes", + "nose", + "mouth", + "tail", +] + const CATEGORY_LABELS: Dictionary = { "species": "head", "scale": "size", - "fur_pattern": "fur color", + "fur_pattern": "fur", "ears": "ears", "eyes": "eyes", "nose": "nose", @@ -28,6 +46,20 @@ const MIN_CHARACTER_SCALE: float = 0.5 const MAX_CHARACTER_SCALE: float = 1.5 const DEFAULT_CHARACTER_SCALE: float = 1.0 const CHARACTER_SCALE_STEP: float = 0.05 +const FUR_STYLE_ID: String = "fur_style" +const FUR_COLOR_IDS: PackedStringArray = [ + "fur_pattern", + "fur_color_2", + "fur_color_3", + "fur_color_4", +] +const FUR_COLOR_LABELS: Dictionary = { + "fur_pattern": "primary", + "fur_color_2": "secondary", + "fur_color_3": "accent", + "fur_color_4": "detail", +} +const DEFAULT_FUR_STYLE: String = "solid" const FEATURE_CATEGORIES: PackedStringArray = ["eyes", "nose", "mouth"] const FEATURE_ASSET_ROOTS: Dictionary = { @@ -41,28 +73,79 @@ const FEATURE_LABEL_OVERRIDES: Dictionary = { "dog_round": "round", "three": "three", } +const FUR_COLOR_OPTIONS: Array = [ + {"id": "white", "label": "white", "color": Color("f2f0e8")}, + {"id": "ivory", "label": "ivory", "color": Color("f0e2c2")}, + {"id": "cream", "label": "cream", "color": Color("e6d39b")}, + {"id": "sand", "label": "sand", "color": Color("c6a66f")}, + {"id": "tan", "label": "tan", "color": Color("ad7c55")}, + {"id": "taupe", "label": "taupe", "color": Color("89756a")}, + {"id": "silver", "label": "silver", "color": Color("b8c3c1")}, + {"id": "gray", "label": "gray", "color": Color("819398")}, + {"id": "slate", "label": "slate", "color": Color("58686c")}, + {"id": "charcoal", "label": "charcoal", "color": Color("34444a")}, + {"id": "black", "label": "black", "color": Color("192126")}, + {"id": "dark_brown", "label": "dark brown", "color": Color("432a25")}, + {"id": "chocolate", "label": "chocolate", "color": Color("603b2c")}, + {"id": "brown", "label": "brown", "color": Color("7b4a32")}, + {"id": "chestnut", "label": "chestnut", "color": Color("98513d")}, + {"id": "ginger", "label": "ginger", "color": Color("d28742")}, + {"id": "orange", "label": "orange", "color": Color("c86c36")}, + {"id": "red", "label": "red", "color": Color("a9433f")}, + {"id": "pink", "label": "pink", "color": Color("d8899e")}, + {"id": "yellow", "label": "yellow", "color": Color("d8c545")}, + {"id": "gold", "label": "gold", "color": Color("c29a3d")}, + {"id": "sage", "label": "sage", "color": Color("8fa16b")}, + {"id": "green", "label": "green", "color": Color("6f913c")}, + {"id": "forest", "label": "forest", "color": Color("3f6945")}, + {"id": "aqua", "label": "aqua", "color": Color("69aeb0")}, + {"id": "teal", "label": "teal", "color": Color("3a8790")}, + {"id": "blue", "label": "blue", "color": Color("4d76a8")}, + {"id": "navy", "label": "navy", "color": Color("344b78")}, + {"id": "lavender", "label": "lavender", "color": Color("9a7eaa")}, + {"id": "purple", "label": "purple", "color": Color("76558f")}, +] const OPTIONS: Dictionary = { "species": [ {"id": "round", "label": "round"}, {"id": "pointy", "label": "pointy"}, ], - # The serialized field remains `fur_pattern` for save and network - # compatibility. It holds the initial solid fur-color selection until the - # authored pattern layer is ready. - "fur_pattern": [ - {"id": "white", "label": "white", "color": Color("f2f0e8")}, - {"id": "cream", "label": "cream", "color": Color("e6d39b")}, - {"id": "gray", "label": "gray", "color": Color("819398")}, - {"id": "charcoal", "label": "charcoal", "color": Color("34444a")}, - {"id": "brown", "label": "brown", "color": Color("7b4a32")}, - {"id": "orange", "label": "orange", "color": Color("c86c36")}, - {"id": "red", "label": "red", "color": Color("a9433f")}, - {"id": "pink", "label": "pink", "color": Color("d8899e")}, - {"id": "yellow", "label": "yellow", "color": Color("d8c545")}, - {"id": "green", "label": "green", "color": Color("6f913c")}, - {"id": "teal", "label": "teal", "color": Color("3a8790")}, - {"id": "blue", "label": "blue", "color": Color("4d76a8")}, - {"id": "purple", "label": "purple", "color": Color("76558f")}, + # `fur_pattern` remains the primary color key for compatibility with + # existing appearance saves. The actual authored pattern has its own field. + "fur_pattern": FUR_COLOR_OPTIONS, + "fur_color_2": FUR_COLOR_OPTIONS, + "fur_color_3": FUR_COLOR_OPTIONS, + "fur_color_4": FUR_COLOR_OPTIONS, + "fur_style": [ + {"id": "solid", "label": "solid"}, + { + "id": "spots_bengal", + "label": "bengal spots", + "textures": { + "body_main": ( + "res://art/exported/characters/patterns/" + + "spots_bengal/body_main.png" + ), + "body_arms": ( + "res://art/exported/characters/patterns/" + + "spots_bengal/body_arms.png" + ), + }, + }, + { + "id": "fox", + "label": "fox", + "textures": { + "body_main": ( + "res://art/exported/characters/patterns/" + + "fox/body_main.png" + ), + "body_arms": ( + "res://art/exported/characters/patterns/" + + "fox/body_arms.png" + ), + }, + }, ], "ears": [ {"id": "none", "label": "none"}, @@ -96,6 +179,7 @@ const OPTIONS: Dictionary = { const LEGACY_OPTION_ALIASES: Dictionary = { "species": {"default": "round"}, "fur_pattern": {"solid": "white"}, + "fur_style": {"bengal": "spots_bengal"}, "ears": {"default": "none"}, "tail": {"default": "none"}, "eyes": {"default": "simple_shine"}, @@ -106,6 +190,7 @@ const LEGACY_OPTION_ALIASES: Dictionary = { static var _feature_assets_ready: bool = false static var _feature_options: Dictionary = {} static var _feature_textures: Dictionary = {} +static var _fur_pattern_textures: Dictionary = {} static func default_snapshot() -> Dictionary: @@ -114,6 +199,10 @@ static func default_snapshot() -> Dictionary: "species": "round", "scale": DEFAULT_CHARACTER_SCALE, "fur_pattern": "white", + "fur_style": DEFAULT_FUR_STYLE, + "fur_color_2": "cream", + "fur_color_3": "brown", + "fur_color_4": "red", "ears": "none", "eyes": _default_feature_option("eyes", "simple_shine"), "nose": _default_feature_option("nose", "dog_round"), @@ -343,13 +432,41 @@ static func option_color(category_id: String, option_id: String) -> Color: return Color.WHITE +static func fur_color_label(category_id: String) -> String: + return str(FUR_COLOR_LABELS.get(category_id, category_id)) + + +static func fur_pattern_texture( + option_id: String, + component_id: String = "body_main", +) -> Texture2D: + var canonical_id := canonical_option_id(FUR_STYLE_ID, option_id) + if canonical_id == DEFAULT_FUR_STYLE: + return null + var cache_id := "%s:%s" % [canonical_id, component_id] + if _fur_pattern_textures.has(cache_id): + return _fur_pattern_textures[cache_id] as Texture2D + for option: Dictionary in options_for(FUR_STYLE_ID): + if str(option.get("id", "")) != canonical_id: + continue + var textures := option.get("textures", {}) as Dictionary + var resource_path := str(textures.get(component_id, "")) + if resource_path.is_empty(): + return null + var texture := ResourceLoader.load(resource_path) as Texture2D + if texture != null: + _fur_pattern_textures[cache_id] = texture + return texture + return null + + static func validate_snapshot(value: Variant) -> bool: if typeof(value) != TYPE_DICTIONARY: return false var snapshot: Dictionary = value - if snapshot.size() != CATEGORY_IDS.size(): + if snapshot.size() != SNAPSHOT_IDS.size(): return false - for category_id: String in CATEGORY_IDS: + for category_id: String in SNAPSHOT_IDS: if category_id == SCALE_CATEGORY_ID: if not is_valid_character_scale(snapshot.get(category_id)): return false @@ -367,7 +484,7 @@ static func sanitized_snapshot(value: Variant) -> Dictionary: if typeof(value) != TYPE_DICTIONARY: return result var snapshot: Dictionary = value - for category_id: String in CATEGORY_IDS: + for category_id: String in SNAPSHOT_IDS: if category_id == SCALE_CATEGORY_ID: result[category_id] = character_scale( snapshot.get(category_id, DEFAULT_CHARACTER_SCALE) diff --git a/scripts/run_validations.sh b/scripts/run_validations.sh index 17252ba..c3b311e 100755 --- a/scripts/run_validations.sh +++ b/scripts/run_validations.sh @@ -15,6 +15,7 @@ readonly -a QUICK_TESTS=( "tests/fish_quality_validation.gd" "tests/fishing_audio_validation.gd" "tests/fishing_surface_validation.gd" + "tests/fur_pattern_validation.gd" "tests/logbook_validation.gd" "tests/player_experience_validation.gd" "tests/shoreline_ambience_validation.gd" diff --git a/tests/fur_pattern_validation.gd b/tests/fur_pattern_validation.gd new file mode 100644 index 0000000..088dc63 --- /dev/null +++ b/tests/fur_pattern_validation.gd @@ -0,0 +1,135 @@ +extends SceneTree + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + var legacy_snapshot := CharacterCustomizationCatalog.default_snapshot() + for added_id: String in [ + "fur_style", + "fur_color_2", + "fur_color_3", + "fur_color_4", + ]: + legacy_snapshot.erase(added_id) + var migrated := CharacterCustomizationCatalog.sanitized_snapshot( + legacy_snapshot + ) + assert(CharacterCustomizationCatalog.validate_snapshot(migrated)) + assert(migrated["fur_style"] == "solid") + var fur_colors := CharacterCustomizationCatalog.options_for("fur_pattern") + assert(fur_colors.size() == 30) + for natural_color_id: String in [ + "ivory", + "sand", + "tan", + "taupe", + "black", + "dark_brown", + "chocolate", + "chestnut", + "ginger", + "sage", + "forest", + ]: + assert(CharacterCustomizationCatalog.is_valid_option( + "fur_pattern", + natural_color_id, + )) + + var pattern_texture := CharacterCustomizationCatalog.fur_pattern_texture( + "spots_bengal" + ) + assert(pattern_texture != null) + assert(pattern_texture.get_width() == 1024) + assert(pattern_texture.get_height() == 1024) + var arm_pattern_texture := CharacterCustomizationCatalog.fur_pattern_texture( + "spots_bengal", + "body_arms", + ) + assert(arm_pattern_texture != null) + assert(arm_pattern_texture.get_width() == 1024) + assert(arm_pattern_texture.get_height() == 1024) + var fox_pattern_texture := CharacterCustomizationCatalog.fur_pattern_texture( + "fox", + "body_main", + ) + assert(fox_pattern_texture != null) + assert(fox_pattern_texture.get_width() == 1024) + assert(fox_pattern_texture.get_height() == 1024) + var fox_arm_pattern_texture := ( + CharacterCustomizationCatalog.fur_pattern_texture( + "fox", "body_arms" + ) + ) + assert(fox_arm_pattern_texture != null) + assert(fox_arm_pattern_texture.get_width() == 1024) + assert(fox_arm_pattern_texture.get_height() == 1024) + assert( + CharacterCustomizationCatalog.fur_pattern_texture( + "spots_bengal", + "head_round", + ) == null + ) + + var patterned := migrated.duplicate(true) + patterned["fur_pattern"] = "orange" + patterned["fur_style"] = "spots_bengal" + patterned["fur_color_2"] = "cream" + patterned["fur_color_3"] = "brown" + patterned["fur_color_4"] = "red" + assert(CharacterCustomizationCatalog.validate_snapshot(patterned)) + + var visuals := PlayerVisualPresenter.instantiate_visuals() + root.add_child(visuals) + PlayerVisualPresenter.apply_appearance(visuals, patterned) + var skeleton := visuals.find_child("Skeleton3D", true, false) as Skeleton3D + assert(skeleton != null) + var body := skeleton.get_node_or_null("body_main") as MeshInstance3D + assert(body != null) + var body_arrays := body.mesh.surface_get_arrays(0) + var body_uvs := body_arrays[Mesh.ARRAY_TEX_UV] as PackedVector2Array + assert(not body_uvs.is_empty()) + var uv_min := Vector2(INF, INF) + var uv_max := Vector2(-INF, -INF) + for uv: Vector2 in body_uvs: + uv_min = uv_min.min(uv) + uv_max = uv_max.max(uv) + assert(uv_min.x < 0.05 and uv_min.y < 0.05) + assert(uv_max.x > 0.95 and uv_max.y > 0.95) + var material := body.material_override as ShaderMaterial + assert(material != null) + assert(material.shader == preload("res://player/fur_pattern.gdshader")) + assert(material.get_shader_parameter("pattern_texture") == pattern_texture) + assert(material.get_shader_parameter("base_color") == Color("c86c36")) + assert(material.get_shader_parameter("red_zone_color") == Color("e6d39b")) + assert(material.get_shader_parameter("green_zone_color") == Color("7b4a32")) + assert(material.get_shader_parameter("blue_zone_color") == Color("a9433f")) + var arms := skeleton.get_node_or_null("body_arms") as MeshInstance3D + assert(arms != null) + var arm_material := arms.material_override as ShaderMaterial + assert(arm_material != null) + assert( + arm_material.get_shader_parameter("pattern_texture") + == arm_pattern_texture + ) + var round_head := skeleton.get_node_or_null("head_round") as MeshInstance3D + assert(round_head != null) + assert(round_head.material_override is StandardMaterial3D) + + var recolored := patterned.duplicate(true) + recolored["fur_color_3"] = "teal" + assert( + NetworkProfileProtocol.signature_fields({"appearance": patterned}) + != NetworkProfileProtocol.signature_fields({"appearance": recolored}) + ) + + PlayerVisualPresenter.apply_appearance(visuals, migrated) + assert(body.material_override is StandardMaterial3D) + + print("Fur pattern validation: PASS") + visuals.queue_free() + await process_frame + quit() diff --git a/tests/fur_pattern_validation.gd.uid b/tests/fur_pattern_validation.gd.uid new file mode 100644 index 0000000..7333f9d --- /dev/null +++ b/tests/fur_pattern_validation.gd.uid @@ -0,0 +1 @@ +uid://bwdmyn34biahx diff --git a/ui/profile_page.gd b/ui/profile_page.gd index fe3e719..3fc9fb4 100644 --- a/ui/profile_page.gd +++ b/ui/profile_page.gd @@ -3,6 +3,8 @@ extends Control const CHECK_DEBOUNCE_SECONDS: float = 0.4 const OPTION_GRID_COLUMNS: int = 6 +const FUR_PALETTE_GRID_COLUMNS: int = 10 +const FUR_CHANNEL_ICON_SIZE: int = 20 const FEATURE_DRAWER_ANIMATION_SECONDS: float = 0.16 const ControllerMappingManagerType = preload( "res://settings/controller_mapping_manager.gd" @@ -48,11 +50,13 @@ var _experience_level: Label var _experience_progress: ProgressBar var _experience_value: Label var _feature_preview_cache: Dictionary = {} +var _fur_swatch_icon_cache: Dictionary = {} var _expanded_feature_drawers: Dictionary = {} var _feature_drawer_animation_key: String = "" var _scale_value_label: Label var _voice_preview: AnimaleseVoiceType var _voice_preview_tween: Tween +var _active_fur_color_id: String = "fur_pattern" func _ready() -> void: @@ -882,30 +886,167 @@ func _feature_preview_texture( func _build_fur_color_options(options: Array) -> void: - var grid := GridContainer.new() - grid.columns = OPTION_GRID_COLUMNS - grid.add_theme_constant_override("h_separation", 10) - grid.add_theme_constant_override("v_separation", 10) - _option_list.add_child(grid) - var selected_id: String = CharacterCustomizationCatalog.canonical_option_id( - "fur_pattern", str(_draft_appearance.get("fur_pattern", "white")) + var pattern_label := Label.new() + pattern_label.text = "pattern" + pattern_label.add_theme_color_override( + "font_color", UtilityPageStyle.OCEAN_TEXT_PRIMARY ) + _option_list.add_child(pattern_label) + var pattern_row := HBoxContainer.new() + pattern_row.add_theme_constant_override("separation", 8) + _option_list.add_child(pattern_row) + var selected_style_id := str(_draft_appearance.get( + CharacterCustomizationCatalog.FUR_STYLE_ID, + CharacterCustomizationCatalog.DEFAULT_FUR_STYLE, + )) + for pattern_option: Dictionary in CharacterCustomizationCatalog.options_for( + CharacterCustomizationCatalog.FUR_STYLE_ID + ): + var pattern_id := str(pattern_option.get("id", "")) + var pattern_button := Button.new() + pattern_button.text = str(pattern_option.get("label", pattern_id)) + pattern_button.toggle_mode = true + pattern_button.button_pressed = selected_style_id == pattern_id + pattern_button.custom_minimum_size = Vector2(118.0, 32.0) + pattern_button.pressed.connect(_select_option.bind( + CharacterCustomizationCatalog.FUR_STYLE_ID, + pattern_id, + )) + UtilityPageStyle.apply_compact_ocean_button(pattern_button) + pattern_row.add_child(pattern_button) + + var channel_label := Label.new() + channel_label.text = "color channel" + channel_label.add_theme_color_override( + "font_color", UtilityPageStyle.OCEAN_TEXT_PRIMARY + ) + _option_list.add_child(channel_label) + var channel_grid := GridContainer.new() + channel_grid.columns = 2 + channel_grid.add_theme_constant_override("h_separation", 8) + channel_grid.add_theme_constant_override("v_separation", 8) + _option_list.add_child(channel_grid) + for color_category_id: String in CharacterCustomizationCatalog.FUR_COLOR_IDS: + var selected_color_id := str(_draft_appearance.get( + color_category_id, + "white", + )) + var selected_color := CharacterCustomizationCatalog.option_color( + color_category_id, + selected_color_id, + ) + var channel_button := Button.new() + channel_button.text = CharacterCustomizationCatalog.fur_color_label( + color_category_id + ) + channel_button.icon = _fur_swatch_icon(selected_color) + channel_button.icon_alignment = HORIZONTAL_ALIGNMENT_LEFT + channel_button.alignment = HORIZONTAL_ALIGNMENT_LEFT + channel_button.add_theme_constant_override( + "icon_max_width", FUR_CHANNEL_ICON_SIZE + ) + channel_button.add_theme_constant_override("h_separation", 8) + channel_button.tooltip_text = "Edit %s: %s" % [ + CharacterCustomizationCatalog.fur_color_label(color_category_id), + _fur_option_label(options, selected_color_id), + ] + channel_button.toggle_mode = true + channel_button.custom_minimum_size = Vector2(140.0, 34.0) + channel_button.button_pressed = ( + _active_fur_color_id == color_category_id + ) + channel_button.pressed.connect( + _select_fur_color_slot.bind(color_category_id) + ) + UtilityPageStyle.apply_compact_ocean_button(channel_button) + channel_grid.add_child(channel_button) + + _build_fur_palette(_active_fur_color_id, options) + + +func _build_fur_palette(category_id: String, options: Array) -> void: + var selected_id: String = CharacterCustomizationCatalog.canonical_option_id( + category_id, + str(_draft_appearance.get(category_id, "white")), + ) + var label := Label.new() + label.text = "%s palette" % CharacterCustomizationCatalog.fur_color_label( + category_id + ) + label.add_theme_font_size_override("font_size", 14) + label.add_theme_color_override( + "font_color", UtilityPageStyle.OCEAN_TEXT_PRIMARY + ) + _option_list.add_child(label) + + var grid := GridContainer.new() + grid.columns = FUR_PALETTE_GRID_COLUMNS + grid.add_theme_constant_override("h_separation", 6) + grid.add_theme_constant_override("v_separation", 6) + _option_list.add_child(grid) for option: Dictionary in options: var option_id: String = str(option.get("id", "")) var color: Color = CharacterCustomizationCatalog.option_color( - "fur_pattern", option_id + category_id, option_id ) var button := Button.new() button.text = "" - button.tooltip_text = str(option.get("label", option_id)) + button.tooltip_text = "%s: %s" % [ + CharacterCustomizationCatalog.fur_color_label(category_id), + str(option.get("label", option_id)), + ] button.toggle_mode = true - button.custom_minimum_size = Vector2(44.0, 44.0) + button.custom_minimum_size = Vector2(30.0, 30.0) button.button_pressed = selected_id == option_id - button.pressed.connect(_select_option.bind("fur_pattern", option_id)) + button.pressed.connect(_select_option.bind(category_id, option_id)) _apply_fur_swatch_style(button, color, button.button_pressed) grid.add_child(button) +func _fur_option_label(options: Array, option_id: String) -> String: + for option: Dictionary in options: + if str(option.get("id", "")) == option_id: + return str(option.get("label", option_id)) + return option_id + + +func _fur_swatch_icon(color: Color) -> Texture2D: + var cache_id := color.to_html(true) + if _fur_swatch_icon_cache.has(cache_id): + return _fur_swatch_icon_cache[cache_id] as Texture2D + var image := Image.create( + FUR_CHANNEL_ICON_SIZE, + FUR_CHANNEL_ICON_SIZE, + false, + Image.FORMAT_RGBA8, + ) + var center := Vector2.ONE * (float(FUR_CHANNEL_ICON_SIZE) * 0.5) + var radius := float(FUR_CHANNEL_ICON_SIZE) * 0.39 + for y: int in range(FUR_CHANNEL_ICON_SIZE): + for x: int in range(FUR_CHANNEL_ICON_SIZE): + var sample_position := Vector2(float(x) + 0.5, float(y) + 0.5) + var alpha := clampf( + radius + 0.8 - sample_position.distance_to(center), + 0.0, + 1.0, + ) + image.set_pixel( + x, + y, + Color(color.r, color.g, color.b, color.a * alpha), + ) + var texture := ImageTexture.create_from_image(image) + _fur_swatch_icon_cache[cache_id] = texture + return texture + + +func _select_fur_color_slot(category_id: String) -> void: + if category_id not in CharacterCustomizationCatalog.FUR_COLOR_IDS: + return + _active_fur_color_id = category_id + _refresh_options() + + func _apply_fur_swatch_style( button: Button, color: Color, diff --git a/ui/profile_preview.gd b/ui/profile_preview.gd index 713a6ba..4239ea0 100644 --- a/ui/profile_preview.gd +++ b/ui/profile_preview.gd @@ -143,7 +143,7 @@ func _notification(what: int) -> void: func _rotate(amount: float) -> void: - _camera_yaw = fposmod(_camera_yaw + amount, TAU) + _camera_yaw = fposmod(_camera_yaw - amount, TAU) _apply_camera_orbit()