Add customizable fur patterns

This commit is contained in:
Alexander Sellite 2026-08-12 16:29:58 -04:00
parent 9729c278dc
commit 1940ae13e5
21 changed files with 705 additions and 49 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

View file

@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

View file

@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

View file

@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 174 KiB

View file

@ -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

View file

@ -56,6 +56,10 @@ static func signature_fields(data: Dictionary) -> Array:
) )
), ),
str(appearance.get("fur_pattern", "")), 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("ears", "")),
str(appearance.get("eyes", "")), str(appearance.get("eyes", "")),
str(appearance.get("nose", "")), str(appearance.get("nose", "")),

View file

@ -268,6 +268,10 @@ static func client_profile_fields(data: Dictionary) -> Array:
) )
), ),
str(appearance.get("fur_pattern", "")), 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("ears", "")),
str(appearance.get("eyes", "")), str(appearance.get("eyes", "")),
str(appearance.get("nose", "")), str(appearance.get("nose", "")),

View file

@ -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
);
}

View file

@ -0,0 +1 @@
uid://eoeegcj00qy8

View file

@ -2,7 +2,11 @@ class_name PlayerVisualPresenter
extends RefCounted extends RefCounted
const PlayerScene: PackedScene = preload("res://player/player.tscn") 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_MATERIAL_META: StringName = &"netfishing_runtime_material"
const RUNTIME_FUR_PATTERN_MATERIAL_META: StringName = (
&"netfishing_runtime_fur_pattern_material"
)
const EAR_MESH_NAMES: Array[String] = [ const EAR_MESH_NAMES: Array[String] = [
"ears_antlers_round", "ears_antlers_round",
"ears_bear", "ears_bear",
@ -144,14 +148,17 @@ static func apply_appearance(
var fur_color: Color = CharacterCustomizationCatalog.option_color( var fur_color: Color = CharacterCustomizationCatalog.option_color(
"fur_pattern", str(snapshot.get("fur_pattern", "white")) "fur_pattern", str(snapshot.get("fur_pattern", "white"))
) )
_set_mesh_color(skeleton, "body_arms", fur_color) for fur_mesh_name: String in [
_set_mesh_color(skeleton, "body_main", fur_color) "body_main",
_set_mesh_color(skeleton, "head_pointy", fur_color) "body_arms",
_set_mesh_color(skeleton, "head_round", fur_color) "head_pointy",
"head_round",
]:
_set_fur_mesh(skeleton, fur_mesh_name, snapshot, fur_color)
for ear_name: String in EAR_MESH_NAMES: 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: 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( var eye_id: String = CharacterCustomizationCatalog.canonical_option_id(
"eyes", str(snapshot.get("eyes", "simple_shine")) "eyes", str(snapshot.get("eyes", "simple_shine"))
@ -202,17 +209,60 @@ static func _set_feature_texture(
material.albedo_texture = texture material.albedo_texture = texture
static func _set_mesh_color( static func _set_fur_mesh(
skeleton: Skeleton3D, skeleton: Skeleton3D,
mesh_name: String, mesh_name: String,
color: Color, snapshot: Dictionary,
base_color: Color,
) -> void: ) -> void:
var mesh_instance: MeshInstance3D = skeleton.get_node_or_null( var mesh_instance := skeleton.get_node_or_null(mesh_name) as MeshInstance3D
mesh_name if mesh_instance == null:
) as MeshInstance3D return
var material: StandardMaterial3D = _runtime_material(mesh_instance) var style_id := CharacterCustomizationCatalog.canonical_option_id(
if material != null: CharacterCustomizationCatalog.FUR_STYLE_ID,
material.albedo_color = color 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( static func _runtime_material(
@ -221,7 +271,9 @@ static func _runtime_material(
if mesh_instance == null or mesh_instance.mesh == null: if mesh_instance == null or mesh_instance.mesh == null:
return null return null
if mesh_instance.has_meta(RUNTIME_MATERIAL_META): 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) _configure_matte_material(runtime)
return runtime return runtime
var source: StandardMaterial3D = mesh_instance.mesh.surface_get_material( var source: StandardMaterial3D = mesh_instance.mesh.surface_get_material(
@ -232,7 +284,20 @@ static func _runtime_material(
var material: StandardMaterial3D = source.duplicate() as StandardMaterial3D var material: StandardMaterial3D = source.duplicate() as StandardMaterial3D
_configure_matte_material(material) _configure_matte_material(material)
mesh_instance.material_override = 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 return material

View file

@ -12,10 +12,28 @@ const CATEGORY_IDS: PackedStringArray = [
"tail", "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 = { const CATEGORY_LABELS: Dictionary = {
"species": "head", "species": "head",
"scale": "size", "scale": "size",
"fur_pattern": "fur color", "fur_pattern": "fur",
"ears": "ears", "ears": "ears",
"eyes": "eyes", "eyes": "eyes",
"nose": "nose", "nose": "nose",
@ -28,6 +46,20 @@ const MIN_CHARACTER_SCALE: float = 0.5
const MAX_CHARACTER_SCALE: float = 1.5 const MAX_CHARACTER_SCALE: float = 1.5
const DEFAULT_CHARACTER_SCALE: float = 1.0 const DEFAULT_CHARACTER_SCALE: float = 1.0
const CHARACTER_SCALE_STEP: float = 0.05 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_CATEGORIES: PackedStringArray = ["eyes", "nose", "mouth"]
const FEATURE_ASSET_ROOTS: Dictionary = { const FEATURE_ASSET_ROOTS: Dictionary = {
@ -41,28 +73,79 @@ const FEATURE_LABEL_OVERRIDES: Dictionary = {
"dog_round": "round", "dog_round": "round",
"three": "three", "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 = { const OPTIONS: Dictionary = {
"species": [ "species": [
{"id": "round", "label": "round"}, {"id": "round", "label": "round"},
{"id": "pointy", "label": "pointy"}, {"id": "pointy", "label": "pointy"},
], ],
# The serialized field remains `fur_pattern` for save and network # `fur_pattern` remains the primary color key for compatibility with
# compatibility. It holds the initial solid fur-color selection until the # existing appearance saves. The actual authored pattern has its own field.
# authored pattern layer is ready. "fur_pattern": FUR_COLOR_OPTIONS,
"fur_pattern": [ "fur_color_2": FUR_COLOR_OPTIONS,
{"id": "white", "label": "white", "color": Color("f2f0e8")}, "fur_color_3": FUR_COLOR_OPTIONS,
{"id": "cream", "label": "cream", "color": Color("e6d39b")}, "fur_color_4": FUR_COLOR_OPTIONS,
{"id": "gray", "label": "gray", "color": Color("819398")}, "fur_style": [
{"id": "charcoal", "label": "charcoal", "color": Color("34444a")}, {"id": "solid", "label": "solid"},
{"id": "brown", "label": "brown", "color": Color("7b4a32")}, {
{"id": "orange", "label": "orange", "color": Color("c86c36")}, "id": "spots_bengal",
{"id": "red", "label": "red", "color": Color("a9433f")}, "label": "bengal spots",
{"id": "pink", "label": "pink", "color": Color("d8899e")}, "textures": {
{"id": "yellow", "label": "yellow", "color": Color("d8c545")}, "body_main": (
{"id": "green", "label": "green", "color": Color("6f913c")}, "res://art/exported/characters/patterns/"
{"id": "teal", "label": "teal", "color": Color("3a8790")}, + "spots_bengal/body_main.png"
{"id": "blue", "label": "blue", "color": Color("4d76a8")}, ),
{"id": "purple", "label": "purple", "color": Color("76558f")}, "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": [ "ears": [
{"id": "none", "label": "none"}, {"id": "none", "label": "none"},
@ -96,6 +179,7 @@ const OPTIONS: Dictionary = {
const LEGACY_OPTION_ALIASES: Dictionary = { const LEGACY_OPTION_ALIASES: Dictionary = {
"species": {"default": "round"}, "species": {"default": "round"},
"fur_pattern": {"solid": "white"}, "fur_pattern": {"solid": "white"},
"fur_style": {"bengal": "spots_bengal"},
"ears": {"default": "none"}, "ears": {"default": "none"},
"tail": {"default": "none"}, "tail": {"default": "none"},
"eyes": {"default": "simple_shine"}, "eyes": {"default": "simple_shine"},
@ -106,6 +190,7 @@ const LEGACY_OPTION_ALIASES: Dictionary = {
static var _feature_assets_ready: bool = false static var _feature_assets_ready: bool = false
static var _feature_options: Dictionary = {} static var _feature_options: Dictionary = {}
static var _feature_textures: Dictionary = {} static var _feature_textures: Dictionary = {}
static var _fur_pattern_textures: Dictionary = {}
static func default_snapshot() -> Dictionary: static func default_snapshot() -> Dictionary:
@ -114,6 +199,10 @@ static func default_snapshot() -> Dictionary:
"species": "round", "species": "round",
"scale": DEFAULT_CHARACTER_SCALE, "scale": DEFAULT_CHARACTER_SCALE,
"fur_pattern": "white", "fur_pattern": "white",
"fur_style": DEFAULT_FUR_STYLE,
"fur_color_2": "cream",
"fur_color_3": "brown",
"fur_color_4": "red",
"ears": "none", "ears": "none",
"eyes": _default_feature_option("eyes", "simple_shine"), "eyes": _default_feature_option("eyes", "simple_shine"),
"nose": _default_feature_option("nose", "dog_round"), "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 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: static func validate_snapshot(value: Variant) -> bool:
if typeof(value) != TYPE_DICTIONARY: if typeof(value) != TYPE_DICTIONARY:
return false return false
var snapshot: Dictionary = value var snapshot: Dictionary = value
if snapshot.size() != CATEGORY_IDS.size(): if snapshot.size() != SNAPSHOT_IDS.size():
return false return false
for category_id: String in CATEGORY_IDS: for category_id: String in SNAPSHOT_IDS:
if category_id == SCALE_CATEGORY_ID: if category_id == SCALE_CATEGORY_ID:
if not is_valid_character_scale(snapshot.get(category_id)): if not is_valid_character_scale(snapshot.get(category_id)):
return false return false
@ -367,7 +484,7 @@ static func sanitized_snapshot(value: Variant) -> Dictionary:
if typeof(value) != TYPE_DICTIONARY: if typeof(value) != TYPE_DICTIONARY:
return result return result
var snapshot: Dictionary = value var snapshot: Dictionary = value
for category_id: String in CATEGORY_IDS: for category_id: String in SNAPSHOT_IDS:
if category_id == SCALE_CATEGORY_ID: if category_id == SCALE_CATEGORY_ID:
result[category_id] = character_scale( result[category_id] = character_scale(
snapshot.get(category_id, DEFAULT_CHARACTER_SCALE) snapshot.get(category_id, DEFAULT_CHARACTER_SCALE)

View file

@ -15,6 +15,7 @@ readonly -a QUICK_TESTS=(
"tests/fish_quality_validation.gd" "tests/fish_quality_validation.gd"
"tests/fishing_audio_validation.gd" "tests/fishing_audio_validation.gd"
"tests/fishing_surface_validation.gd" "tests/fishing_surface_validation.gd"
"tests/fur_pattern_validation.gd"
"tests/logbook_validation.gd" "tests/logbook_validation.gd"
"tests/player_experience_validation.gd" "tests/player_experience_validation.gd"
"tests/shoreline_ambience_validation.gd" "tests/shoreline_ambience_validation.gd"

View file

@ -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()

View file

@ -0,0 +1 @@
uid://bwdmyn34biahx

View file

@ -3,6 +3,8 @@ extends Control
const CHECK_DEBOUNCE_SECONDS: float = 0.4 const CHECK_DEBOUNCE_SECONDS: float = 0.4
const OPTION_GRID_COLUMNS: int = 6 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 FEATURE_DRAWER_ANIMATION_SECONDS: float = 0.16
const ControllerMappingManagerType = preload( const ControllerMappingManagerType = preload(
"res://settings/controller_mapping_manager.gd" "res://settings/controller_mapping_manager.gd"
@ -48,11 +50,13 @@ var _experience_level: Label
var _experience_progress: ProgressBar var _experience_progress: ProgressBar
var _experience_value: Label var _experience_value: Label
var _feature_preview_cache: Dictionary = {} var _feature_preview_cache: Dictionary = {}
var _fur_swatch_icon_cache: Dictionary = {}
var _expanded_feature_drawers: Dictionary = {} var _expanded_feature_drawers: Dictionary = {}
var _feature_drawer_animation_key: String = "" var _feature_drawer_animation_key: String = ""
var _scale_value_label: Label var _scale_value_label: Label
var _voice_preview: AnimaleseVoiceType var _voice_preview: AnimaleseVoiceType
var _voice_preview_tween: Tween var _voice_preview_tween: Tween
var _active_fur_color_id: String = "fur_pattern"
func _ready() -> void: func _ready() -> void:
@ -882,30 +886,167 @@ func _feature_preview_texture(
func _build_fur_color_options(options: Array) -> void: func _build_fur_color_options(options: Array) -> void:
var grid := GridContainer.new() var pattern_label := Label.new()
grid.columns = OPTION_GRID_COLUMNS pattern_label.text = "pattern"
grid.add_theme_constant_override("h_separation", 10) pattern_label.add_theme_color_override(
grid.add_theme_constant_override("v_separation", 10) "font_color", UtilityPageStyle.OCEAN_TEXT_PRIMARY
_option_list.add_child(grid)
var selected_id: String = CharacterCustomizationCatalog.canonical_option_id(
"fur_pattern", str(_draft_appearance.get("fur_pattern", "white"))
) )
_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: for option: Dictionary in options:
var option_id: String = str(option.get("id", "")) var option_id: String = str(option.get("id", ""))
var color: Color = CharacterCustomizationCatalog.option_color( var color: Color = CharacterCustomizationCatalog.option_color(
"fur_pattern", option_id category_id, option_id
) )
var button := Button.new() var button := Button.new()
button.text = "" 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.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.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) _apply_fur_swatch_style(button, color, button.button_pressed)
grid.add_child(button) 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( func _apply_fur_swatch_style(
button: Button, button: Button,
color: Color, color: Color,

View file

@ -143,7 +143,7 @@ func _notification(what: int) -> void:
func _rotate(amount: float) -> void: func _rotate(amount: float) -> void:
_camera_yaw = fposmod(_camera_yaw + amount, TAU) _camera_yaw = fposmod(_camera_yaw - amount, TAU)
_apply_camera_orbit() _apply_camera_orbit()