Add public-alias credits and a tested in-game credits page, centralize project guidance and asset provenance, import the complete authored fur channel maps, and keep starter-island collision synchronized with imported props.
539 lines
16 KiB
GDScript
539 lines
16 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_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_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 = {
|
|
"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,
|
|
"fur_style": [
|
|
{"id": "solid", "label": "solid"},
|
|
{
|
|
"id": "spots_bengal",
|
|
"label": "bengal spots",
|
|
"textures": {
|
|
"body_main": (
|
|
"res://art/exported/characters/patterns/"
|
|
+ "bengal/body_main_bengal.png"
|
|
),
|
|
"body_arms": (
|
|
"res://art/exported/characters/patterns/"
|
|
+ "bengal/body_arms_bengal.png"
|
|
),
|
|
"head_round": (
|
|
"res://art/exported/characters/patterns/"
|
|
+ "bengal/head_round_bengal.png"
|
|
),
|
|
},
|
|
},
|
|
{
|
|
"id": "fox",
|
|
"label": "fox",
|
|
"textures": {
|
|
"body_main": (
|
|
"res://art/exported/characters/patterns/"
|
|
+ "fox/body_main_fox.png"
|
|
),
|
|
"body_arms": (
|
|
"res://art/exported/characters/patterns/"
|
|
+ "fox/body_arms_fox.png"
|
|
),
|
|
"head_pointy": (
|
|
"res://art/exported/characters/patterns/"
|
|
+ "fox/head_pointy_fox.png"
|
|
),
|
|
"tails_fox": (
|
|
"res://art/exported/characters/patterns/"
|
|
+ "fox/tails_fox_fox.png"
|
|
),
|
|
},
|
|
},
|
|
],
|
|
"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": {"bengal": "spots_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_textures: 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_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
|
|
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 textures: Dictionary = _feature_textures.get(category_id, {}) as Dictionary
|
|
return textures.get(canonical_id) as Texture2D
|
|
|
|
|
|
static func refresh_feature_assets() -> void:
|
|
_feature_assets_ready = false
|
|
_feature_options.clear()
|
|
_feature_textures.clear()
|
|
_ensure_feature_assets()
|
|
|
|
|
|
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 textures: 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 textures.has(option_id):
|
|
continue
|
|
var resource_path := root_path.path_join(normalized_file_name)
|
|
var texture := ResourceLoader.load(resource_path) as Texture2D
|
|
if texture == null:
|
|
push_warning(
|
|
"Ignoring facial feature without a loadable texture: "
|
|
+ resource_path
|
|
)
|
|
continue
|
|
textures[option_id] = texture
|
|
options.append({
|
|
"id": option_id,
|
|
"label": _feature_label(option_id),
|
|
})
|
|
|
|
_feature_options[category_id] = options
|
|
_feature_textures[category_id] = textures
|
|
|
|
|
|
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)
|
|
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:
|
|
var aliases: Dictionary = LEGACY_OPTION_ALIASES.get(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)
|
|
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 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() != 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 option_id: String = canonical_option_id(
|
|
category_id, str(snapshot.get(category_id, ""))
|
|
)
|
|
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)
|