Load authored pattern masks by body component, add custom fur colors and the themed picker UI, and advance the signed profile protocol to version 6.
782 lines
24 KiB
GDScript
782 lines
24 KiB
GDScript
class_name CharacterCustomizationCatalog
|
|
extends RefCounted
|
|
|
|
const CATEGORY_IDS: PackedStringArray = [
|
|
"species",
|
|
"scale",
|
|
"fur_pattern",
|
|
"ears",
|
|
"eyes",
|
|
"nose",
|
|
"mouth",
|
|
"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_style_arms",
|
|
"fur_style_head",
|
|
"fur_style_ears",
|
|
"fur_style_tail",
|
|
"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",
|
|
"ears": "ears",
|
|
"eyes": "eyes",
|
|
"nose": "nose",
|
|
"mouth": "mouth",
|
|
"tail": "tail",
|
|
}
|
|
|
|
const SCALE_CATEGORY_ID: String = "scale"
|
|
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_STYLE_ARMS_ID: String = "fur_style_arms"
|
|
const FUR_STYLE_HEAD_ID: String = "fur_style_head"
|
|
const FUR_STYLE_EARS_ID: String = "fur_style_ears"
|
|
const FUR_STYLE_TAIL_ID: String = "fur_style_tail"
|
|
const FUR_STYLE_IDS: PackedStringArray = [
|
|
FUR_STYLE_ID,
|
|
FUR_STYLE_ARMS_ID,
|
|
FUR_STYLE_HEAD_ID,
|
|
FUR_STYLE_EARS_ID,
|
|
FUR_STYLE_TAIL_ID,
|
|
]
|
|
const FUR_STYLE_LABELS: Dictionary = {
|
|
FUR_STYLE_ID: "body",
|
|
FUR_STYLE_ARMS_ID: "arms",
|
|
FUR_STYLE_HEAD_ID: "head",
|
|
FUR_STYLE_EARS_ID: "ears",
|
|
FUR_STYLE_TAIL_ID: "tail",
|
|
}
|
|
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": "tertiary",
|
|
"fur_color_4": "quaternary",
|
|
}
|
|
const CUSTOM_FUR_COLOR_PREFIX: String = "custom_"
|
|
const DEFAULT_FUR_STYLE: String = "solid"
|
|
const FUR_PATTERN_ASSET_ROOT: String = (
|
|
"res://art/exported/characters/patterns"
|
|
)
|
|
const FEATURE_TEXTURE_CACHE_LIMIT: int = 24
|
|
|
|
const FEATURE_CATEGORIES: PackedStringArray = ["eyes", "nose", "mouth"]
|
|
const FEATURE_ASSET_ROOTS: Dictionary = {
|
|
"eyes": "res://art/exported/characters/faces/eyes",
|
|
"nose": "res://art/exported/characters/faces/noses",
|
|
"mouth": "res://art/exported/characters/faces/mouth",
|
|
}
|
|
const FEATURE_LABEL_OVERRIDES: Dictionary = {
|
|
"simple_shine": "simple shine",
|
|
"simple_shine_eyebrows": "simple shine + eyebrows",
|
|
"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"},
|
|
],
|
|
# `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,
|
|
"ears": [
|
|
{"id": "none", "label": "none"},
|
|
{"id": "antlers_round", "label": "antlers"},
|
|
{"id": "bear", "label": "bear"},
|
|
{"id": "bunny", "label": "bunny"},
|
|
{"id": "pointy_long", "label": "long"},
|
|
{"id": "pointy_short", "label": "short"},
|
|
{"id": "pointy_wide", "label": "wide"},
|
|
],
|
|
"eyes": [
|
|
{"id": "simple_shine", "label": "simple shine"},
|
|
{
|
|
"id": "simple_shine_eyebrows",
|
|
"label": "simple shine + eyebrows",
|
|
},
|
|
],
|
|
"nose": [{"id": "dog_round", "label": "round"}],
|
|
"mouth": [{"id": "three", "label": "three"}],
|
|
"tail": [
|
|
{"id": "none", "label": "none"},
|
|
{"id": "bear", "label": "bear"},
|
|
{"id": "bunny", "label": "bunny"},
|
|
{"id": "cat", "label": "cat"},
|
|
{"id": "fox", "label": "fox"},
|
|
{"id": "gator", "label": "gator"},
|
|
{"id": "pointy", "label": "pointy"},
|
|
],
|
|
}
|
|
|
|
const LEGACY_OPTION_ALIASES: Dictionary = {
|
|
"species": {"default": "round"},
|
|
"fur_pattern": {"solid": "white"},
|
|
"fur_style": {"spots_bengal": "bengal"},
|
|
"ears": {"default": "none"},
|
|
"tail": {"default": "none"},
|
|
"eyes": {"default": "simple_shine"},
|
|
"nose": {"default": "dog_round"},
|
|
"mouth": {"default": "three"},
|
|
}
|
|
|
|
static var _feature_assets_ready: bool = false
|
|
static var _feature_options: Dictionary = {}
|
|
static var _feature_resource_paths: Dictionary = {}
|
|
static var _feature_textures: Dictionary = {}
|
|
static var _feature_texture_cache_order: Array[String] = []
|
|
static var _fur_pattern_assets_ready: bool = false
|
|
static var _fur_pattern_options: Array = []
|
|
static var _fur_pattern_resource_paths: Dictionary = {}
|
|
static var _fur_pattern_textures: Dictionary = {}
|
|
|
|
|
|
static func default_snapshot() -> Dictionary:
|
|
_ensure_feature_assets()
|
|
return {
|
|
"species": "round",
|
|
"scale": DEFAULT_CHARACTER_SCALE,
|
|
"fur_pattern": "white",
|
|
"fur_style": DEFAULT_FUR_STYLE,
|
|
"fur_style_arms": DEFAULT_FUR_STYLE,
|
|
"fur_style_head": DEFAULT_FUR_STYLE,
|
|
"fur_style_ears": DEFAULT_FUR_STYLE,
|
|
"fur_style_tail": 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"),
|
|
"mouth": _default_feature_option("mouth", "three"),
|
|
"tail": "none",
|
|
}
|
|
|
|
|
|
static func _default_feature_option(
|
|
category_id: String,
|
|
legacy_id: String,
|
|
) -> String:
|
|
var options: Array = _feature_options.get(category_id, []) as Array
|
|
for option: Dictionary in options:
|
|
if str(option.get("id", "")) == legacy_id:
|
|
return legacy_id
|
|
for option: Dictionary in options:
|
|
var option_id := str(option.get("id", ""))
|
|
if option_id != "none":
|
|
return option_id
|
|
return "none"
|
|
|
|
|
|
static func options_for(category_id: String) -> Array:
|
|
if category_id in FEATURE_CATEGORIES:
|
|
_ensure_feature_assets()
|
|
return _feature_options.get(category_id, []) as Array
|
|
if category_id in FUR_STYLE_IDS:
|
|
_ensure_fur_pattern_assets()
|
|
return _fur_pattern_options
|
|
return OPTIONS.get(category_id, [])
|
|
|
|
|
|
static func feature_option_groups(category_id: String) -> Array:
|
|
if category_id not in FEATURE_CATEGORIES:
|
|
return []
|
|
var options: Array = options_for(category_id)
|
|
var option_ids: Array[String] = []
|
|
for option: Dictionary in options:
|
|
option_ids.append(str(option.get("id", "")))
|
|
var grouped_options: Dictionary = {}
|
|
var group_order: Array[String] = []
|
|
for option: Dictionary in options:
|
|
var option_id := str(option.get("id", ""))
|
|
var group_id := _feature_variant_root(option_id, option_ids)
|
|
if not grouped_options.has(group_id):
|
|
grouped_options[group_id] = []
|
|
group_order.append(group_id)
|
|
var group: Array = grouped_options[group_id] as Array
|
|
group.append(option)
|
|
grouped_options[group_id] = group
|
|
var result: Array = []
|
|
for group_id: String in group_order:
|
|
result.append({
|
|
"id": group_id,
|
|
"options": grouped_options[group_id],
|
|
})
|
|
return result
|
|
|
|
|
|
static func _feature_variant_root(
|
|
option_id: String,
|
|
option_ids: Array[String],
|
|
) -> String:
|
|
if option_id == "none":
|
|
return option_id
|
|
# Prefer the shortest authored base option. This keeps nested names such as
|
|
# `beady_extra_shine` in the `beady` drawer rather than creating drawers
|
|
# inside drawers.
|
|
var authored_root := ""
|
|
for candidate_id: String in option_ids:
|
|
if candidate_id == "none":
|
|
continue
|
|
if (
|
|
option_id != candidate_id
|
|
and not option_id.begins_with(candidate_id + "_")
|
|
):
|
|
continue
|
|
var has_descendant := false
|
|
for descendant_id: String in option_ids:
|
|
if descendant_id.begins_with(candidate_id + "_"):
|
|
has_descendant = true
|
|
break
|
|
if (
|
|
has_descendant
|
|
and (
|
|
authored_root.is_empty()
|
|
or candidate_id.length() < authored_root.length()
|
|
)
|
|
):
|
|
authored_root = candidate_id
|
|
if not authored_root.is_empty():
|
|
return authored_root
|
|
|
|
# Some families have no unqualified base asset. Group those siblings by
|
|
# their leading name segment, making the sorted first asset representative.
|
|
var separator_index := option_id.find("_")
|
|
if separator_index <= 0:
|
|
return option_id
|
|
var leading_segment := option_id.substr(0, separator_index)
|
|
var sibling_count := 0
|
|
for candidate_id: String in option_ids:
|
|
if candidate_id.begins_with(leading_segment + "_"):
|
|
sibling_count += 1
|
|
if sibling_count >= 2:
|
|
return leading_segment
|
|
return option_id
|
|
|
|
|
|
static func texture_for(category_id: String, option_id: String) -> Texture2D:
|
|
if category_id not in FEATURE_CATEGORIES:
|
|
return null
|
|
_ensure_feature_assets()
|
|
var canonical_id := canonical_option_id(category_id, option_id)
|
|
var cache_id := "%s:%s" % [category_id, canonical_id]
|
|
if _feature_textures.has(cache_id):
|
|
_touch_feature_texture(cache_id)
|
|
return _feature_textures[cache_id] as Texture2D
|
|
var resource_path := feature_texture_path(category_id, canonical_id)
|
|
if resource_path.is_empty():
|
|
return null
|
|
var texture := ResourceLoader.load(
|
|
resource_path,
|
|
"Texture2D",
|
|
ResourceLoader.CACHE_MODE_IGNORE,
|
|
) as Texture2D
|
|
if texture == null:
|
|
return null
|
|
_feature_textures[cache_id] = texture
|
|
_touch_feature_texture(cache_id)
|
|
_trim_feature_texture_cache()
|
|
return texture
|
|
|
|
|
|
static func feature_texture_path(category_id: String, option_id: String) -> String:
|
|
if category_id not in FEATURE_CATEGORIES:
|
|
return ""
|
|
_ensure_feature_assets()
|
|
var canonical_id := canonical_option_id(category_id, option_id)
|
|
var paths: Dictionary = (
|
|
_feature_resource_paths.get(category_id, {}) as Dictionary
|
|
)
|
|
return str(paths.get(canonical_id, ""))
|
|
|
|
|
|
static func _touch_feature_texture(cache_id: String) -> void:
|
|
var existing_index := _feature_texture_cache_order.find(cache_id)
|
|
if existing_index >= 0:
|
|
_feature_texture_cache_order.remove_at(existing_index)
|
|
_feature_texture_cache_order.append(cache_id)
|
|
|
|
|
|
static func _trim_feature_texture_cache() -> void:
|
|
while _feature_texture_cache_order.size() > FEATURE_TEXTURE_CACHE_LIMIT:
|
|
var oldest_id: String = str(_feature_texture_cache_order.pop_front())
|
|
_feature_textures.erase(oldest_id)
|
|
|
|
|
|
static func refresh_feature_assets() -> void:
|
|
_feature_assets_ready = false
|
|
_feature_options.clear()
|
|
_feature_resource_paths.clear()
|
|
_feature_textures.clear()
|
|
_feature_texture_cache_order.clear()
|
|
_ensure_feature_assets()
|
|
|
|
|
|
static func refresh_fur_pattern_assets() -> void:
|
|
_fur_pattern_assets_ready = false
|
|
_fur_pattern_options.clear()
|
|
_fur_pattern_resource_paths.clear()
|
|
_fur_pattern_textures.clear()
|
|
_ensure_fur_pattern_assets()
|
|
|
|
|
|
static func _ensure_fur_pattern_assets() -> void:
|
|
if _fur_pattern_assets_ready:
|
|
return
|
|
_fur_pattern_assets_ready = true
|
|
_fur_pattern_options = [{"id": DEFAULT_FUR_STYLE, "label": "solid"}]
|
|
var root_directory := DirAccess.open(FUR_PATTERN_ASSET_ROOT)
|
|
if root_directory == null:
|
|
return
|
|
var directory_names := root_directory.get_directories()
|
|
directory_names.sort()
|
|
for directory_name: String in directory_names:
|
|
var pattern_id := _normalized_asset_id(directory_name)
|
|
if pattern_id.is_empty() or pattern_id == DEFAULT_FUR_STYLE:
|
|
continue
|
|
var pattern_directory_path := FUR_PATTERN_ASSET_ROOT.path_join(
|
|
directory_name
|
|
)
|
|
var pattern_directory := DirAccess.open(pattern_directory_path)
|
|
if pattern_directory == null:
|
|
continue
|
|
var file_names := pattern_directory.get_files()
|
|
file_names.sort()
|
|
var canonical_files: Dictionary = {}
|
|
var found_pattern_texture := false
|
|
for file_name: String in file_names:
|
|
var canonical_file_name := _canonical_feature_filename(file_name)
|
|
if (
|
|
canonical_file_name.is_empty()
|
|
or canonical_files.has(canonical_file_name)
|
|
):
|
|
continue
|
|
canonical_files[canonical_file_name] = true
|
|
var file_base_name := canonical_file_name.get_basename().to_lower()
|
|
var pattern_suffix := "_" + pattern_id
|
|
if not file_base_name.ends_with(pattern_suffix):
|
|
continue
|
|
var component_id := file_base_name.substr(
|
|
0,
|
|
file_base_name.length() - pattern_suffix.length(),
|
|
)
|
|
component_id = _normalized_asset_id(component_id)
|
|
if component_id.is_empty():
|
|
continue
|
|
var resource_path := pattern_directory_path.path_join(
|
|
canonical_file_name
|
|
)
|
|
if not ResourceLoader.exists(resource_path, "Texture2D"):
|
|
push_warning(
|
|
"Ignoring fur mask without a loadable texture: "
|
|
+ resource_path
|
|
)
|
|
continue
|
|
_fur_pattern_resource_paths[
|
|
_fur_pattern_cache_id(pattern_id, component_id)
|
|
] = resource_path
|
|
found_pattern_texture = true
|
|
if found_pattern_texture:
|
|
_fur_pattern_options.append({
|
|
"id": pattern_id,
|
|
"label": pattern_id.replace("_", " "),
|
|
})
|
|
|
|
|
|
static func _normalized_asset_id(value: String) -> String:
|
|
var result := value.to_lower()
|
|
result = result.replace("-", "_")
|
|
result = result.replace(" ", "_")
|
|
result = result.replace(".", "_")
|
|
while result.contains("__"):
|
|
result = result.replace("__", "_")
|
|
while result.begins_with("_"):
|
|
result = result.substr(1)
|
|
while result.ends_with("_"):
|
|
result = result.substr(0, result.length() - 1)
|
|
return result
|
|
|
|
|
|
static func _fur_pattern_cache_id(
|
|
pattern_id: String,
|
|
component_id: String,
|
|
) -> String:
|
|
return "%s:%s" % [pattern_id, component_id]
|
|
|
|
|
|
static func _ensure_feature_assets() -> void:
|
|
if _feature_assets_ready:
|
|
return
|
|
_feature_assets_ready = true
|
|
for category_id: String in FEATURE_CATEGORIES:
|
|
var options: Array = [{"id": "none", "label": "none"}]
|
|
var resource_paths: Dictionary = {}
|
|
var root_path := str(FEATURE_ASSET_ROOTS[category_id])
|
|
var directory := DirAccess.open(root_path)
|
|
if directory != null:
|
|
var files := directory.get_files()
|
|
files.sort()
|
|
var normalized_paths: Dictionary = {}
|
|
for file_name: String in files:
|
|
var normalized_file_name := _canonical_feature_filename(file_name)
|
|
if (
|
|
normalized_file_name.is_empty()
|
|
or normalized_paths.has(normalized_file_name)
|
|
):
|
|
continue
|
|
normalized_paths[normalized_file_name] = true
|
|
var option_id := _feature_id_from_filename(
|
|
category_id, normalized_file_name
|
|
)
|
|
if option_id.is_empty() or resource_paths.has(option_id):
|
|
continue
|
|
var resource_path := root_path.path_join(normalized_file_name)
|
|
if not ResourceLoader.exists(resource_path, "Texture2D"):
|
|
push_warning(
|
|
"Ignoring facial feature without a loadable texture: "
|
|
+ resource_path
|
|
)
|
|
continue
|
|
resource_paths[option_id] = resource_path
|
|
options.append({
|
|
"id": option_id,
|
|
"label": _feature_label(option_id),
|
|
})
|
|
|
|
_feature_options[category_id] = options
|
|
_feature_resource_paths[category_id] = resource_paths
|
|
|
|
|
|
static func _canonical_feature_filename(file_name: String) -> String:
|
|
var lower_name := file_name.to_lower()
|
|
for sidecar_suffix: String in [".import", ".remap"]:
|
|
if lower_name.ends_with(".png" + sidecar_suffix):
|
|
return file_name.substr(
|
|
0, file_name.length() - sidecar_suffix.length()
|
|
)
|
|
if lower_name.ends_with(".png"):
|
|
return file_name
|
|
return ""
|
|
|
|
|
|
static func _feature_id_from_filename(
|
|
category_id: String,
|
|
file_name: String,
|
|
) -> String:
|
|
var option_id := file_name.get_basename().to_lower()
|
|
var prefix := category_id + "_"
|
|
if option_id.begins_with(prefix):
|
|
option_id = option_id.trim_prefix(prefix)
|
|
option_id = option_id.replace("-", "_")
|
|
option_id = option_id.replace(" ", "_")
|
|
option_id = option_id.replace(".", "_")
|
|
while option_id.contains("__"):
|
|
option_id = option_id.replace("__", "_")
|
|
while option_id.begins_with("_"):
|
|
option_id = option_id.substr(1)
|
|
while option_id.ends_with("_"):
|
|
option_id = option_id.substr(0, option_id.length() - 1)
|
|
return option_id
|
|
|
|
|
|
static func _feature_label(option_id: String) -> String:
|
|
if FEATURE_LABEL_OVERRIDES.has(option_id):
|
|
return str(FEATURE_LABEL_OVERRIDES[option_id])
|
|
return option_id.replace("_", " ")
|
|
|
|
|
|
static func category_label(category_id: String) -> String:
|
|
return str(CATEGORY_LABELS.get(category_id, category_id))
|
|
|
|
|
|
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):
|
|
return true
|
|
for option: Dictionary in options_for(category_id):
|
|
if str(option.get("id", "")) == option_id:
|
|
return true
|
|
return false
|
|
|
|
|
|
static func canonical_option_id(category_id: String, option_id: String) -> String:
|
|
if (
|
|
category_id in FUR_COLOR_IDS
|
|
and option_id.to_lower().begins_with(CUSTOM_FUR_COLOR_PREFIX)
|
|
):
|
|
return option_id.to_lower()
|
|
var alias_category_id := (
|
|
FUR_STYLE_ID if category_id in FUR_STYLE_IDS else category_id
|
|
)
|
|
var aliases: Dictionary = LEGACY_OPTION_ALIASES.get(alias_category_id, {})
|
|
return str(aliases.get(option_id, option_id))
|
|
|
|
|
|
static func option_color(category_id: String, option_id: String) -> Color:
|
|
var canonical_id: String = canonical_option_id(category_id, option_id)
|
|
if category_id in FUR_COLOR_IDS and is_custom_fur_color_id(canonical_id):
|
|
return Color(canonical_id.trim_prefix(CUSTOM_FUR_COLOR_PREFIX))
|
|
for option: Dictionary in options_for(category_id):
|
|
if str(option.get("id", "")) == canonical_id:
|
|
return option.get("color", Color.WHITE) as Color
|
|
return Color.WHITE
|
|
|
|
|
|
static func custom_fur_color_id(color: Color) -> String:
|
|
var opaque_color := Color(color.r, color.g, color.b, 1.0)
|
|
return CUSTOM_FUR_COLOR_PREFIX + opaque_color.to_html(false)
|
|
|
|
|
|
static func is_custom_fur_color_id(option_id: String) -> bool:
|
|
var canonical_id := option_id.to_lower()
|
|
if (
|
|
canonical_id.length() != CUSTOM_FUR_COLOR_PREFIX.length() + 6
|
|
or not canonical_id.begins_with(CUSTOM_FUR_COLOR_PREFIX)
|
|
):
|
|
return false
|
|
var hex_value := canonical_id.trim_prefix(CUSTOM_FUR_COLOR_PREFIX)
|
|
for character_index: int in range(hex_value.length()):
|
|
if hex_value[character_index] not in "0123456789abcdef":
|
|
return false
|
|
return true
|
|
|
|
|
|
static func fur_color_label(category_id: String) -> String:
|
|
return str(FUR_COLOR_LABELS.get(category_id, category_id))
|
|
|
|
|
|
static func fur_style_label(category_id: String) -> String:
|
|
return str(FUR_STYLE_LABELS.get(category_id, category_id))
|
|
|
|
|
|
static func fur_style_field_for_component(component_id: String) -> String:
|
|
if component_id == "body_arms":
|
|
return FUR_STYLE_ARMS_ID
|
|
if component_id.begins_with("head_"):
|
|
return FUR_STYLE_HEAD_ID
|
|
if component_id.begins_with("ears_"):
|
|
return FUR_STYLE_EARS_ID
|
|
if component_id.begins_with("tails_"):
|
|
return FUR_STYLE_TAIL_ID
|
|
return FUR_STYLE_ID
|
|
|
|
|
|
static func fur_component_for_style_field(
|
|
category_id: String,
|
|
snapshot: Dictionary,
|
|
) -> String:
|
|
match category_id:
|
|
FUR_STYLE_ID:
|
|
return "body_main"
|
|
FUR_STYLE_ARMS_ID:
|
|
return "body_arms"
|
|
FUR_STYLE_HEAD_ID:
|
|
var species_id := canonical_option_id(
|
|
"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 "" 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 "" if tail_id == "none" else "tails_" + tail_id
|
|
return ""
|
|
|
|
|
|
static func fur_pattern_options_for_field(
|
|
category_id: String,
|
|
snapshot: Dictionary,
|
|
) -> Array:
|
|
if category_id not in FUR_STYLE_IDS:
|
|
return []
|
|
_ensure_fur_pattern_assets()
|
|
var component_id := fur_component_for_style_field(category_id, snapshot)
|
|
var result: Array = []
|
|
for option: Dictionary in _fur_pattern_options:
|
|
var pattern_id := str(option.get("id", ""))
|
|
if (
|
|
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)
|
|
)
|
|
)
|
|
):
|
|
result.append(option)
|
|
return result
|
|
|
|
|
|
static func fur_pattern_texture(
|
|
option_id: String,
|
|
component_id: String = "body_main",
|
|
) -> Texture2D:
|
|
_ensure_fur_pattern_assets()
|
|
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)
|
|
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, ""))
|
|
if resource_path.is_empty():
|
|
return null
|
|
var texture := ResourceLoader.load(resource_path, "Texture2D") as Texture2D
|
|
if texture != null:
|
|
_fur_pattern_textures[cache_id] = texture
|
|
return texture
|
|
|
|
|
|
static func appearance_signature_values(snapshot: Dictionary) -> Array:
|
|
var result: Array = []
|
|
for category_id: String in SNAPSHOT_IDS:
|
|
if category_id == SCALE_CATEGORY_ID:
|
|
result.append(character_scale_percent(snapshot.get(
|
|
category_id,
|
|
DEFAULT_CHARACTER_SCALE,
|
|
)))
|
|
else:
|
|
result.append(str(snapshot.get(category_id, "")))
|
|
return result
|
|
|
|
|
|
static func validate_snapshot(value: Variant) -> bool:
|
|
if typeof(value) != TYPE_DICTIONARY:
|
|
return false
|
|
var snapshot: Dictionary = value
|
|
if snapshot.size() != SNAPSHOT_IDS.size():
|
|
return false
|
|
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
|
|
continue
|
|
if (
|
|
typeof(snapshot.get(category_id)) != TYPE_STRING
|
|
or not is_valid_option(category_id, str(snapshot[category_id]))
|
|
):
|
|
return false
|
|
return true
|
|
|
|
|
|
static func sanitized_snapshot(value: Variant) -> Dictionary:
|
|
var result := default_snapshot()
|
|
if typeof(value) != TYPE_DICTIONARY:
|
|
return result
|
|
var snapshot: Dictionary = value
|
|
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)
|
|
)
|
|
continue
|
|
var raw_option: Variant = snapshot.get(category_id, "")
|
|
if (
|
|
category_id in FUR_STYLE_IDS
|
|
and category_id != FUR_STYLE_ID
|
|
and not snapshot.has(category_id)
|
|
):
|
|
raw_option = snapshot.get(FUR_STYLE_ID, DEFAULT_FUR_STYLE)
|
|
var option_id: String = canonical_option_id(
|
|
category_id, str(raw_option)
|
|
)
|
|
if is_valid_option(category_id, option_id):
|
|
result[category_id] = option_id
|
|
return result
|
|
|
|
|
|
static func character_scale(value: Variant) -> float:
|
|
if typeof(value) not in [TYPE_FLOAT, TYPE_INT]:
|
|
return DEFAULT_CHARACTER_SCALE
|
|
var number: float = float(value)
|
|
if not is_finite(number):
|
|
return DEFAULT_CHARACTER_SCALE
|
|
return clampf(
|
|
snappedf(number, CHARACTER_SCALE_STEP),
|
|
MIN_CHARACTER_SCALE,
|
|
MAX_CHARACTER_SCALE,
|
|
)
|
|
|
|
|
|
static func is_valid_character_scale(value: Variant) -> bool:
|
|
if typeof(value) not in [TYPE_FLOAT, TYPE_INT]:
|
|
return false
|
|
var number: float = float(value)
|
|
return (
|
|
is_finite(number)
|
|
and number >= MIN_CHARACTER_SCALE
|
|
and number <= MAX_CHARACTER_SCALE
|
|
and is_equal_approx(number, character_scale(number))
|
|
)
|
|
|
|
|
|
static func character_scale_percent(value: Variant) -> int:
|
|
return roundi(character_scale(value) * 100.0)
|