Expand character customization assets

This commit is contained in:
Alexander Sellite 2026-09-02 17:20:50 -04:00
parent 23cf53fa14
commit df2a4d7d77
31 changed files with 2423 additions and 215 deletions

4
.gitignore vendored
View file

@ -5,3 +5,7 @@
# Blender backup files
*.blend1
# Python cache files generated by Blender helper scripts
__pycache__/
*.py[cod]

View file

@ -3,7 +3,7 @@ extends RefCounted
const WorldLayoutType = preload("res://world/world_layout.gd")
const PROTOCOL_VERSION: int = 13
const PROTOCOL_VERSION: int = 14
const GAME_BUILD: String = "prealpha"
const MAX_GAME_VERSION_LENGTH: int = 64
const MAX_DISPLAY_NAME_LENGTH: int = 24
@ -274,6 +274,13 @@ static func validate_client_hello(data: Variant) -> String:
return "Capabilities are invalid."
if typeof(payload["cosmetic_snapshot"]) != TYPE_DICTIONARY:
return "Cosmetic snapshot is invalid."
var cosmetic_snapshot: Dictionary = payload["cosmetic_snapshot"]
if (
not CharacterCustomizationCatalog.validate_snapshot(cosmetic_snapshot)
or CharacterCustomizationCatalog.sanitized_snapshot(cosmetic_snapshot)
!= cosmetic_snapshot
):
return "Cosmetic snapshot is invalid."
if (
not NetworkIdentityCrypto.valid_fingerprint(
payload["identity_fingerprint"]

View file

@ -299,6 +299,23 @@ static func _valid_owned_data(filename: String, data: Dictionary) -> bool:
and typeof(data.get("active_slot_id")) == TYPE_STRING
and typeof(data.get("slots")) == TYPE_ARRAY
)
if filename == "player_appearance.json":
var version_value: Variant = data.get("format_version")
if typeof(version_value) not in [TYPE_INT, TYPE_FLOAT]:
return false
var version: int = int(version_value)
if (
version < 1
or version > PlayerAppearanceStore.FORMAT_VERSION
or typeof(data.get("appearance")) != TYPE_DICTIONARY
):
return false
var appearance: Dictionary = data["appearance"]
if version < PlayerAppearanceStore.FORMAT_VERSION:
appearance = CharacterCustomizationCatalog.sanitized_snapshot(
appearance
)
return CharacterCustomizationCatalog.validate_snapshot(appearance)
return (
filename not in LEGACY_FILES
or int(data.get("format_version", -1)) == 1

View file

@ -3587,7 +3587,9 @@ func get_nameplate_anchor_position() -> Vector3:
var skeleton := get_node_or_null(
"Visuals/CharacterRig/CharacterRig/Skeleton3D"
) as Skeleton3D
for head_name: String in ["head_round", "head_pointy"]:
for head_name: String in CharacterCustomizationCatalog.model_mesh_names(
"species"
):
var mesh_instance := (
skeleton.get_node_or_null(head_name) as MeshInstance3D
if skeleton != null

View file

@ -7,22 +7,31 @@ const RUNTIME_MATERIAL_META: StringName = &"straywild_runtime_material"
const RUNTIME_FUR_PATTERN_MATERIAL_META: StringName = (
&"straywild_runtime_fur_pattern_material"
)
const EAR_MESH_NAMES: Array[String] = [
"ears_antlers_round",
"ears_bear",
"ears_bunny",
"ears_pointy_long",
"ears_pointy_short",
"ears_pointy_wide",
]
const TAIL_MESH_NAMES: Array[String] = [
"tails_bear",
"tails_bunny",
"tails_cat",
"tails_fox",
"tails_gator",
"tails_pointy",
const APPLIED_APPEARANCE_META: StringName = &"straywild_applied_appearance"
const MODEL_CATEGORY_IDS: PackedStringArray = [
"species",
"arms",
"ears",
"tail",
"special_back",
"special_beak",
"special_head",
"special_horns",
"special_tusks",
"special_wings",
]
const MODEL_CATEGORY_DEFAULTS: Dictionary = {
"species": "round",
"arms": "mammal",
"ears": "none",
"tail": "none",
"special_back": "none",
"special_beak": "none",
"special_head": "none",
"special_horns": "none",
"special_tusks": "none",
"special_wings": "none",
}
static var _feature_uv_aspects: Dictionary = {}
@ -33,7 +42,9 @@ static func feature_uv_aspect(feature_name: String) -> float:
var visuals := instantiate_visuals()
var skeleton := visuals.find_child("Skeleton3D", true, false) as Skeleton3D
var aspects: Array[float] = []
for head_name: String in ["head_round", "head_pointy"]:
for head_name: String in CharacterCustomizationCatalog.model_mesh_names(
"species"
):
var mesh_instance := skeleton.get_node_or_null(
"%s_%s" % [head_name, feature_name]
) as MeshInstance3D
@ -108,57 +119,67 @@ static func apply_appearance(
var skeleton: Skeleton3D = visuals.find_child("Skeleton3D", true, false) as Skeleton3D
if skeleton == null:
return
var previous: Dictionary = visuals.get_meta(
APPLIED_APPEARANCE_META,
{},
) as Dictionary
var first_application := (
previous.is_empty()
or not CharacterCustomizationCatalog.validate_snapshot(previous)
)
var changed_model_categories: Array[String] = []
for category_id: String in MODEL_CATEGORY_IDS:
if (
first_application
or _model_category_selection(previous, category_id)
!= _model_category_selection(snapshot, category_id)
):
_set_model_category_visibility(skeleton, snapshot, category_id)
changed_model_categories.append(category_id)
var species_id: String = CharacterCustomizationCatalog.canonical_option_id(
"species", str(snapshot.get("species", "round"))
)
var head_id: String = "head_pointy" if species_id == "pointy" else "head_round"
var head_ids: Array[String] = ["head_pointy", "head_round"]
for head_name: String in head_ids:
var head: Node3D = skeleton.get_node_or_null(head_name) as Node3D
if head != null:
head.visible = head_name == head_id
for feature: String in ["eyes", "mouth", "nose"]:
var decal: Node3D = skeleton.get_node_or_null(
"%s_%s" % [head_name, feature]
) as Node3D
if decal != null:
var feature_id := CharacterCustomizationCatalog.canonical_option_id(
feature, str(snapshot.get(feature, "none"))
var head_changed := first_application or "species" in changed_model_categories
if head_changed:
_set_head_feature_visibility(skeleton, snapshot)
else:
for feature_name: String in ["eyes", "mouth", "nose"]:
if _appearance_option_changed(previous, snapshot, feature_name):
_set_selected_head_feature_visibility(
skeleton,
snapshot,
feature_name,
)
decal.visible = (
head_name == head_id and feature_id != "none"
)
var selected_ears: String = CharacterCustomizationCatalog.canonical_option_id(
"ears", str(snapshot.get("ears", "none"))
)
for ear_name: String in EAR_MESH_NAMES:
var ears: Node3D = skeleton.get_node_or_null(ear_name) as Node3D
if ears != null:
ears.visible = selected_ears == ear_name.trim_prefix("ears_")
var selected_tail: String = CharacterCustomizationCatalog.canonical_option_id(
"tail", str(snapshot.get("tail", "none"))
)
for tail_name: String in TAIL_MESH_NAMES:
var tail: Node3D = skeleton.get_node_or_null(tail_name) as Node3D
if tail != null:
tail.visible = selected_tail == tail_name.trim_prefix("tails_")
var fur_color: Color = CharacterCustomizationCatalog.option_color(
"fur_pattern", str(snapshot.get("fur_pattern", "white"))
)
for fur_mesh_name: String in [
"body_main",
"body_arms",
"head_pointy",
"head_round",
]:
_set_fur_mesh(skeleton, fur_mesh_name, snapshot, fur_color)
for ear_name: String in EAR_MESH_NAMES:
_set_fur_mesh(skeleton, ear_name, snapshot, fur_color)
for tail_name: String in TAIL_MESH_NAMES:
_set_fur_mesh(skeleton, tail_name, snapshot, fur_color)
var fur_changed := first_application
for category_id: String in CharacterCustomizationCatalog.FUR_COLOR_IDS:
fur_changed = (
fur_changed
or _appearance_option_changed(previous, snapshot, category_id)
)
for category_id: String in CharacterCustomizationCatalog.FUR_STYLE_IDS:
fur_changed = (
fur_changed
or _appearance_option_changed(previous, snapshot, category_id)
)
if fur_changed:
_set_fur_mesh(skeleton, "body_main", snapshot, fur_color)
for category_id: String in MODEL_CATEGORY_IDS:
_set_selected_category_fur(
skeleton,
snapshot,
category_id,
fur_color,
)
else:
for category_id: String in changed_model_categories:
_set_selected_category_fur(
skeleton,
snapshot,
category_id,
fur_color,
)
var eye_id: String = CharacterCustomizationCatalog.canonical_option_id(
"eyes", str(snapshot.get("eyes", "simple_shine"))
@ -169,44 +190,153 @@ static func apply_appearance(
var nose_id: String = CharacterCustomizationCatalog.canonical_option_id(
"nose", str(snapshot.get("nose", "dog_round"))
)
_set_feature_texture(
skeleton,
"eyes",
CharacterCustomizationCatalog.texture_for("eyes", eye_id),
)
_set_feature_texture(
skeleton,
"mouth",
CharacterCustomizationCatalog.texture_for("mouth", mouth_id),
)
_set_feature_texture(
skeleton,
"nose",
CharacterCustomizationCatalog.texture_for("nose", nose_id),
)
if head_changed or _appearance_option_changed(previous, snapshot, "eyes"):
_set_selected_head_feature_texture(
skeleton,
snapshot,
"eyes",
CharacterCustomizationCatalog.texture_for("eyes", eye_id),
)
if head_changed or _appearance_option_changed(previous, snapshot, "mouth"):
_set_selected_head_feature_texture(
skeleton,
snapshot,
"mouth",
CharacterCustomizationCatalog.texture_for("mouth", mouth_id),
)
if head_changed or _appearance_option_changed(previous, snapshot, "nose"):
_set_selected_head_feature_texture(
skeleton,
snapshot,
"nose",
CharacterCustomizationCatalog.texture_for("nose", nose_id),
)
visuals.set_meta(APPLIED_APPEARANCE_META, snapshot.duplicate(true))
static func _set_feature_texture(
static func _set_selected_head_feature_texture(
skeleton: Skeleton3D,
snapshot: Dictionary,
feature_name: String,
texture: Texture2D,
) -> void:
for head_name: String in ["head_pointy", "head_round"]:
var mesh_instance: MeshInstance3D = skeleton.get_node_or_null(
"%s_%s" % [head_name, feature_name]
) as MeshInstance3D
if texture == null:
if mesh_instance != null:
mesh_instance.visible = false
continue
var material: StandardMaterial3D = _runtime_material(mesh_instance)
if material != null:
# Feature plates are transparent geometry. The exported base model may
# have no preview texture, so enforce the runtime alpha mode when a
# drop-in PNG is assigned.
material.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
material.albedo_color = Color.WHITE
material.albedo_texture = texture
var head_name := _model_category_selection(snapshot, "species")
var mesh_instance: MeshInstance3D = skeleton.get_node_or_null(
"%s_%s" % [head_name, feature_name]
) as MeshInstance3D
if texture == null:
if mesh_instance != null:
mesh_instance.visible = false
return
var material: StandardMaterial3D = _runtime_material(mesh_instance)
if material != null:
# Feature plates are transparent geometry. The exported base model may
# have no preview texture, so enforce the runtime alpha mode when a
# drop-in PNG is assigned.
material.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
material.albedo_color = Color.WHITE
material.albedo_texture = texture
static func _set_model_category_visibility(
skeleton: Skeleton3D,
snapshot: Dictionary,
category_id: String,
) -> void:
var selected_mesh := CharacterCustomizationCatalog.model_mesh_name(
category_id,
str(snapshot.get(category_id, "none")),
)
for mesh_name: String in CharacterCustomizationCatalog.model_mesh_names(
category_id
):
var mesh_node := skeleton.get_node_or_null(mesh_name) as Node3D
if mesh_node != null:
mesh_node.visible = mesh_name == selected_mesh
static func _set_head_feature_visibility(
skeleton: Skeleton3D,
snapshot: Dictionary,
) -> void:
var selected_head := _model_category_selection(snapshot, "species")
for head_name: String in CharacterCustomizationCatalog.model_mesh_names(
"species"
):
for feature_name: String in ["eyes", "mouth", "nose"]:
var decal := skeleton.get_node_or_null(
"%s_%s" % [head_name, feature_name]
) as Node3D
if decal != null:
decal.visible = (
head_name == selected_head
and _appearance_option(snapshot, feature_name, "none")
!= "none"
)
static func _set_selected_head_feature_visibility(
skeleton: Skeleton3D,
snapshot: Dictionary,
feature_name: String,
) -> void:
var head_name := _model_category_selection(snapshot, "species")
var decal := skeleton.get_node_or_null(
"%s_%s" % [head_name, feature_name]
) as Node3D
if decal != null:
decal.visible = _appearance_option(
snapshot,
feature_name,
"none",
) != "none"
static func _set_selected_category_fur(
skeleton: Skeleton3D,
snapshot: Dictionary,
category_id: String,
base_color: Color,
) -> void:
var mesh_name := _model_category_selection(snapshot, category_id)
if not mesh_name.is_empty():
_set_fur_mesh(skeleton, mesh_name, snapshot, base_color)
static func _model_category_selection(
snapshot: Dictionary,
category_id: String,
) -> String:
return CharacterCustomizationCatalog.model_mesh_name(
category_id,
_appearance_option(
snapshot,
category_id,
str(MODEL_CATEGORY_DEFAULTS.get(category_id, "none")),
),
)
static func _appearance_option(
snapshot: Dictionary,
category_id: String,
fallback: String = "",
) -> String:
return CharacterCustomizationCatalog.canonical_option_id(
category_id,
str(snapshot.get(category_id, fallback)),
)
static func _appearance_option_changed(
previous: Dictionary,
current: Dictionary,
category_id: String,
) -> bool:
return _appearance_option(previous, category_id) != _appearance_option(
current,
category_id,
)
static func _set_fur_mesh(

View file

@ -3,6 +3,7 @@ extends RefCounted
const CATEGORY_IDS: PackedStringArray = [
"species",
"arms",
"scale",
"fur_pattern",
"ears",
@ -10,13 +11,15 @@ const CATEGORY_IDS: PackedStringArray = [
"nose",
"mouth",
"tail",
"special",
]
# CATEGORY_IDS controls the visible Profile navigation. The additional fur
# fields are edited together on the existing fur page, but remain explicit in
# saves and signed multiplayer appearance snapshots.
# CATEGORY_IDS controls the visible Profile navigation. The component fur and
# independent special-slot fields are edited within grouped pages, but remain
# explicit in saves and signed multiplayer appearance snapshots.
const SNAPSHOT_IDS: PackedStringArray = [
"species",
"arms",
"scale",
"fur_pattern",
"fur_style",
@ -32,10 +35,17 @@ const SNAPSHOT_IDS: PackedStringArray = [
"nose",
"mouth",
"tail",
"special_back",
"special_beak",
"special_head",
"special_horns",
"special_tusks",
"special_wings",
]
const CATEGORY_LABELS: Dictionary = {
"species": "head",
"arms": "arms",
"scale": "size",
"fur_pattern": "fur",
"ears": "ears",
@ -43,6 +53,120 @@ const CATEGORY_LABELS: Dictionary = {
"nose": "nose",
"mouth": "mouth",
"tail": "tail",
"special": "special",
}
const SPECIAL_SLOT_IDS: PackedStringArray = [
"special_back",
"special_beak",
"special_head",
"special_horns",
"special_tusks",
"special_wings",
]
const SPECIAL_SLOT_LABELS: Dictionary = {
"special_back": "back",
"special_beak": "beak",
"special_head": "head",
"special_horns": "horns",
"special_tusks": "tusks",
"special_wings": "wings",
}
const MODEL_OPTION_MESH_NAMES: Dictionary = {
"species": {
"round": "head_round",
"pointy": "head_pointy",
"anteater": "head_anteater",
"axolotl": "head_axolotl",
"bat": "head_bat",
"bird": "head_bird",
"boar": "head_boar",
"butterfly": "head_butterfly",
"donkey": "head_donkey",
"elephant": "head_elephant",
"goat": "head_goat",
"hamster": "head_hamster",
"horse": "head_horse",
"moth": "head_moth",
"opossum": "head_opossum",
"owl": "head_owl",
"panda": "head_panda",
"pig": "head_pig",
"shark": "head_shark",
"sheep": "head_sheep",
"snail": "head_snail",
},
"arms": {
"mammal": "arms_mammal",
"fish": "arms_fish",
"avian": "arms_avian",
},
"ears": {
"antlers_round": "ears_antlers_round",
"anteater": "ears_anteater",
"axolotl": "ears_axolotl",
"bat": "ears_bat",
"bear": "ears_bear",
"boar": "ears_boar",
"bunny": "ears_bunny",
"butterfly": "ears_butterfly",
"cat": "ears_cat",
"donkey": "ears_donkey",
"elephant": "ears_elephant",
"fox": "ears_fox",
"goat": "ears_goat",
"hamster": "ears_hamster",
"horse": "ears_horse",
"long": "ears_long",
"moth": "ears_moth",
"opossum": "ears_opossum",
"owl": "ears_owl",
"panda": "ears_panda",
"pig": "ears_pig",
},
"tail": {
"anteater": "tails_anteater",
"axolotl": "tails_axolotl",
"bear": "tails_bear",
"bird": "tails_bird",
"boar": "tails_boar",
"bunny": "tails_bunny",
"butterfly": "tails_butterfly",
"cat": "tails_cat",
"donkey": "tail_donkey",
"fox": "tails_fox",
"gator": "tails_gator",
"horse": "tails_horse",
"moth": "tails_moth",
"opossum": "tails_opossum",
"pig": "tails_pig",
"pointy": "tails_pointy",
"shark": "tails_shark",
},
"special_back": {
"shell": "back_shell",
},
"special_beak": {
"bird": "beak_bird",
"owl": "beak_owl",
},
"special_head": {
"fin": "head_fin",
},
"special_horns": {
"goat": "horns_goat",
"sheep": "horns_sheep",
},
"special_tusks": {
"boar": "tusks_boar",
"elephant": "tusks_elephant",
},
"special_wings": {
"bat": "wings_bat",
"butterfly": "wings_butterfly",
"moth": "wings_moth",
},
}
const SCALE_CATEGORY_ID: String = "scale"
@ -86,6 +210,12 @@ const DEFAULT_FUR_STYLE: String = "solid"
const FUR_PATTERN_ASSET_ROOT: String = (
"res://art/exported/characters/patterns"
)
const FUR_PATTERN_COMPONENT_ALIASES: Dictionary = {
"arms_mammal": "body_arms",
"ears_cat": "ears_pointy_short",
"ears_fox": "ears_pointy_wide",
"ears_long": "ears_pointy_long",
}
const FEATURE_TEXTURE_CACHE_LIMIT: int = 24
const FEATURE_CATEGORIES: PackedStringArray = ["eyes", "nose", "mouth"]
@ -134,8 +264,32 @@ const FUR_COLOR_OPTIONS: Array = [
]
const OPTIONS: Dictionary = {
"species": [
{"id": "round", "label": "round"},
{"id": "pointy", "label": "pointy"},
{"id": "round", "label": "cat"},
{"id": "pointy", "label": "fox"},
{"id": "anteater", "label": "anteater"},
{"id": "axolotl", "label": "axolotl"},
{"id": "bat", "label": "bat"},
{"id": "bird", "label": "bird"},
{"id": "boar", "label": "boar"},
{"id": "butterfly", "label": "butterfly"},
{"id": "donkey", "label": "donkey"},
{"id": "elephant", "label": "elephant"},
{"id": "goat", "label": "goat"},
{"id": "hamster", "label": "hamster"},
{"id": "horse", "label": "horse"},
{"id": "moth", "label": "moth"},
{"id": "opossum", "label": "opossum"},
{"id": "owl", "label": "owl"},
{"id": "panda", "label": "panda"},
{"id": "pig", "label": "pig"},
{"id": "shark", "label": "shark"},
{"id": "sheep", "label": "sheep"},
{"id": "snail", "label": "snail"},
],
"arms": [
{"id": "mammal", "label": "mammal"},
{"id": "fish", "label": "fish"},
{"id": "avian", "label": "avian"},
],
# `fur_pattern` remains the primary color key for compatibility with
# existing appearance saves. The actual authored pattern has its own field.
@ -146,11 +300,26 @@ const OPTIONS: Dictionary = {
"ears": [
{"id": "none", "label": "none"},
{"id": "antlers_round", "label": "antlers"},
{"id": "anteater", "label": "anteater"},
{"id": "axolotl", "label": "axolotl"},
{"id": "bat", "label": "bat"},
{"id": "bear", "label": "bear"},
{"id": "boar", "label": "boar"},
{"id": "bunny", "label": "bunny"},
{"id": "pointy_long", "label": "long"},
{"id": "pointy_short", "label": "short"},
{"id": "pointy_wide", "label": "wide"},
{"id": "butterfly", "label": "butterfly"},
{"id": "cat", "label": "cat"},
{"id": "donkey", "label": "donkey"},
{"id": "elephant", "label": "elephant"},
{"id": "fox", "label": "fox"},
{"id": "goat", "label": "goat"},
{"id": "hamster", "label": "hamster"},
{"id": "horse", "label": "horse"},
{"id": "long", "label": "long"},
{"id": "moth", "label": "moth"},
{"id": "opossum", "label": "opossum"},
{"id": "owl", "label": "owl"},
{"id": "panda", "label": "panda"},
{"id": "pig", "label": "pig"},
],
"eyes": [
{"id": "simple_shine", "label": "simple shine"},
@ -163,24 +332,76 @@ const OPTIONS: Dictionary = {
"mouth": [{"id": "three", "label": "three"}],
"tail": [
{"id": "none", "label": "none"},
{"id": "anteater", "label": "anteater"},
{"id": "axolotl", "label": "axolotl"},
{"id": "bear", "label": "bear"},
{"id": "bird", "label": "bird"},
{"id": "boar", "label": "boar"},
{"id": "bunny", "label": "bunny"},
{"id": "butterfly", "label": "butterfly"},
{"id": "cat", "label": "cat"},
{"id": "donkey", "label": "donkey"},
{"id": "fox", "label": "fox"},
{"id": "gator", "label": "gator"},
{"id": "horse", "label": "horse"},
{"id": "moth", "label": "moth"},
{"id": "opossum", "label": "opossum"},
{"id": "pig", "label": "pig"},
{"id": "pointy", "label": "pointy"},
{"id": "shark", "label": "shark"},
],
"special_back": [
{"id": "none", "label": "none"},
{"id": "shell", "label": "shell"},
],
"special_beak": [
{"id": "none", "label": "none"},
{"id": "bird", "label": "bird"},
{"id": "owl", "label": "owl"},
],
"special_head": [
{"id": "none", "label": "none"},
{"id": "fin", "label": "fin"},
],
"special_horns": [
{"id": "none", "label": "none"},
{"id": "goat", "label": "goat"},
{"id": "sheep", "label": "sheep"},
],
"special_tusks": [
{"id": "none", "label": "none"},
{"id": "boar", "label": "boar"},
{"id": "elephant", "label": "elephant"},
],
"special_wings": [
{"id": "none", "label": "none"},
{"id": "bat", "label": "bat"},
{"id": "butterfly", "label": "butterfly"},
{"id": "moth", "label": "moth"},
],
}
const LEGACY_OPTION_ALIASES: Dictionary = {
"species": {"default": "round"},
"arms": {"default": "mammal", "body_arms": "mammal"},
"fur_pattern": {"solid": "white"},
"fur_style": {"spots_bengal": "bengal"},
"ears": {"default": "none"},
"ears": {
"default": "none",
"pointy_long": "long",
"pointy_short": "cat",
"pointy_wide": "fox",
},
"tail": {"default": "none"},
"eyes": {"default": "simple_shine"},
"nose": {"default": "dog_round"},
"mouth": {"default": "three"},
"special_back": {"default": "none"},
"special_beak": {"default": "none"},
"special_head": {"default": "none"},
"special_horns": {"default": "none"},
"special_tusks": {"default": "none"},
"special_wings": {"default": "none"},
}
static var _feature_assets_ready: bool = false
@ -198,6 +419,7 @@ static func default_snapshot() -> Dictionary:
_ensure_feature_assets()
return {
"species": "round",
"arms": "mammal",
"scale": DEFAULT_CHARACTER_SCALE,
"fur_pattern": "white",
"fur_style": DEFAULT_FUR_STYLE,
@ -213,6 +435,12 @@ static func default_snapshot() -> Dictionary:
"nose": _default_feature_option("nose", "dog_round"),
"mouth": _default_feature_option("mouth", "three"),
"tail": "none",
"special_back": "none",
"special_beak": "none",
"special_head": "none",
"special_horns": "none",
"special_tusks": "none",
"special_wings": "none",
}
@ -467,6 +695,22 @@ static func _fur_pattern_cache_id(
return "%s:%s" % [pattern_id, component_id]
static func _fur_pattern_resource_cache_id(
pattern_id: String,
component_id: String,
) -> String:
var direct_id := _fur_pattern_cache_id(pattern_id, component_id)
if _fur_pattern_resource_paths.has(direct_id):
return direct_id
var legacy_component := str(
FUR_PATTERN_COMPONENT_ALIASES.get(component_id, "")
)
if legacy_component.is_empty():
return ""
var legacy_id := _fur_pattern_cache_id(pattern_id, legacy_component)
return legacy_id if _fur_pattern_resource_paths.has(legacy_id) else ""
static func _ensure_feature_assets() -> void:
if _feature_assets_ready:
return
@ -552,6 +796,38 @@ static func category_label(category_id: String) -> String:
return str(CATEGORY_LABELS.get(category_id, category_id))
static func special_slot_label(category_id: String) -> String:
return str(SPECIAL_SLOT_LABELS.get(category_id, category_id))
static func model_mesh_name(category_id: String, option_id: String) -> String:
var category_meshes: Dictionary = MODEL_OPTION_MESH_NAMES.get(
category_id,
{},
) as Dictionary
var canonical_id := canonical_option_id(category_id, option_id)
return str(category_meshes.get(canonical_id, ""))
static func model_mesh_names(category_id: String) -> Array[String]:
var result: Array[String] = []
if category_id == "special":
for special_slot_id: String in SPECIAL_SLOT_IDS:
for mesh_name: String in model_mesh_names(special_slot_id):
if mesh_name not in result:
result.append(mesh_name)
return result
var category_meshes: Dictionary = MODEL_OPTION_MESH_NAMES.get(
category_id,
{},
) as Dictionary
for mesh_name: Variant in category_meshes.values():
var normalized_name := str(mesh_name)
if not normalized_name.is_empty() and normalized_name not in result:
result.append(normalized_name)
return result
static func is_valid_option(category_id: String, option_id: String) -> bool:
option_id = canonical_option_id(category_id, option_id)
if category_id in FUR_COLOR_IDS and is_custom_fur_color_id(option_id):
@ -613,13 +889,16 @@ static func fur_style_label(category_id: String) -> String:
static func fur_style_field_for_component(component_id: String) -> String:
if component_id == "body_arms":
if (
component_id == "body_arms"
or component_id in model_mesh_names("arms")
):
return FUR_STYLE_ARMS_ID
if component_id.begins_with("head_"):
if component_id in model_mesh_names("species"):
return FUR_STYLE_HEAD_ID
if component_id.begins_with("ears_"):
if component_id in model_mesh_names("ears"):
return FUR_STYLE_EARS_ID
if component_id.begins_with("tails_"):
if component_id in model_mesh_names("tail"):
return FUR_STYLE_TAIL_ID
return FUR_STYLE_ID
@ -632,22 +911,25 @@ static func fur_component_for_style_field(
FUR_STYLE_ID:
return "body_main"
FUR_STYLE_ARMS_ID:
return "body_arms"
return model_mesh_name(
"arms",
str(snapshot.get("arms", "mammal")),
)
FUR_STYLE_HEAD_ID:
var species_id := canonical_option_id(
"species", str(snapshot.get("species", "round"))
return model_mesh_name(
"species",
str(snapshot.get("species", "round")),
)
return "head_pointy" if species_id == "pointy" else "head_round"
FUR_STYLE_EARS_ID:
var ears_id := canonical_option_id(
"ears", str(snapshot.get("ears", "none"))
return model_mesh_name(
"ears",
str(snapshot.get("ears", "none")),
)
return "" if ears_id == "none" else "ears_" + ears_id
FUR_STYLE_TAIL_ID:
var tail_id := canonical_option_id(
"tail", str(snapshot.get("tail", "none"))
return model_mesh_name(
"tail",
str(snapshot.get("tail", "none")),
)
return "" if tail_id == "none" else "tails_" + tail_id
return ""
@ -666,9 +948,10 @@ static func fur_pattern_options_for_field(
pattern_id == DEFAULT_FUR_STYLE
or (
not component_id.is_empty()
and _fur_pattern_resource_paths.has(
_fur_pattern_cache_id(pattern_id, component_id)
)
and not _fur_pattern_resource_cache_id(
pattern_id,
component_id,
).is_empty()
)
):
result.append(option)
@ -683,7 +966,12 @@ static func fur_pattern_texture(
var canonical_id := canonical_option_id(FUR_STYLE_ID, option_id)
if canonical_id == DEFAULT_FUR_STYLE:
return null
var cache_id := _fur_pattern_cache_id(canonical_id, component_id)
var cache_id := _fur_pattern_resource_cache_id(
canonical_id,
component_id,
)
if cache_id.is_empty():
return null
if _fur_pattern_textures.has(cache_id):
return _fur_pattern_textures[cache_id] as Texture2D
var resource_path := str(_fur_pattern_resource_paths.get(cache_id, ""))

View file

@ -3,7 +3,7 @@ extends Node
signal appearance_changed
const FORMAT_VERSION: int = 1
const FORMAT_VERSION: int = 2
var _snapshot: Dictionary = CharacterCustomizationCatalog.default_snapshot()
var _loaded: bool = false
var _future_version: bool = false
@ -53,9 +53,17 @@ func load_preferences() -> bool:
_future_version = true
_loaded = false
return false
if version != FORMAT_VERSION or typeof(data.get("appearance")) != TYPE_DICTIONARY:
if (
version < 1
or typeof(data.get("appearance")) != TYPE_DICTIONARY
):
return _recover_backup()
_snapshot = CharacterCustomizationCatalog.sanitized_snapshot(data["appearance"])
var appearance: Dictionary = data["appearance"]
if version < FORMAT_VERSION:
appearance = CharacterCustomizationCatalog.sanitized_snapshot(appearance)
if not CharacterCustomizationCatalog.validate_snapshot(appearance):
return _recover_backup()
_snapshot = CharacterCustomizationCatalog.sanitized_snapshot(appearance)
_loaded = true
_expected_hash = PortableFileGuard.hash_file(_profile_path)
return true
@ -83,10 +91,9 @@ func restore_from_save_data(
value: Variant,
persist_to_profile_file: bool = true,
) -> bool:
if (
typeof(value) != TYPE_DICTIONARY
or not CharacterCustomizationCatalog.validate_snapshot(value)
):
if typeof(value) != TYPE_DICTIONARY:
return false
if not CharacterCustomizationCatalog.validate_snapshot(value):
return false
var sanitized: Dictionary = (
CharacterCustomizationCatalog.sanitized_snapshot(value)

View file

@ -46,7 +46,7 @@ const PlayerBottleMessageStateType = preload(
"res://messages/player_bottle_message_state.gd"
)
const SAVE_VERSION: int = 12
const SAVE_VERSION: int = 13
const LEGACY_SAVE_FILENAME := "player_save.json"
const ARCHIVE_EXTENSION := ".nfsave"
const BASIC_ROD_ID: StringName = &"basic_fishing_rod"
@ -370,21 +370,21 @@ func _character_data_from_save(save_data: Dictionary) -> Dictionary:
if typeof(save_data.get("character")) != TYPE_DICTIONARY:
return {}
var character: Dictionary = save_data["character"]
var appearance_value: Variant = character.get("appearance")
if (
not NetworkProfilePreferencesType.validate_save_data(
character.get("profile")
)
or typeof(character.get("appearance")) != TYPE_DICTIONARY
or not CharacterCustomizationCatalog.validate_snapshot(
character.get("appearance")
)
or typeof(appearance_value) != TYPE_DICTIONARY
or not CharacterCustomizationCatalog.validate_snapshot(appearance_value)
):
return {}
var appearance := CharacterCustomizationCatalog.sanitized_snapshot(
appearance_value
)
return {
"profile": (character["profile"] as Dictionary).duplicate(true),
"appearance": CharacterCustomizationCatalog.sanitized_snapshot(
character["appearance"]
),
"appearance": appearance,
}
@ -1610,6 +1610,8 @@ func _migrate_save(
migrated = _migrate_version_10_to_11(migrated)
11:
migrated = _migrate_version_11_to_12(migrated)
12:
migrated = _migrate_version_12_to_13(migrated)
_:
return {}
if migrated.is_empty():
@ -1883,6 +1885,23 @@ func _migrate_version_11_to_12(data: Dictionary) -> Dictionary:
return migrated
func _migrate_version_12_to_13(data: Dictionary) -> Dictionary:
var migrated: Dictionary = data.duplicate(true)
if typeof(migrated.get("character")) == TYPE_DICTIONARY:
var character: Dictionary = (
migrated["character"] as Dictionary
).duplicate(true)
if typeof(character.get("appearance")) == TYPE_DICTIONARY:
character["appearance"] = (
CharacterCustomizationCatalog.sanitized_snapshot(
character["appearance"]
)
)
migrated["character"] = character
migrated["save_version"] = 13
return migrated
func _mark_dirty() -> void:
if (
_is_restoring

View file

@ -59,7 +59,7 @@ func _validate_player_save_migration() -> void:
{"save_version": 11},
11,
)
assert(int(migrated.get("save_version", -1)) == 12)
assert(int(migrated.get("save_version", -1)) == 13)
assert(
migrated.get("message_bottles", {})
== PlayerBottleMessageStateType.default_save_data()

View file

@ -0,0 +1,278 @@
extends SceneTree
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
_validate_catalog_and_legacy_names()
_validate_version_12_migration()
_validate_appearance_profile_migration_and_strictness()
_validate_signatures_cover_independent_accessories()
await _validate_presenter_selection()
print("Character customization expansion validation: PASS")
quit()
func _validate_catalog_and_legacy_names() -> void:
var defaults := CharacterCustomizationCatalog.default_snapshot()
assert(CharacterCustomizationCatalog.validate_snapshot(defaults))
assert(defaults.size() == CharacterCustomizationCatalog.SNAPSHOT_IDS.size())
assert(CharacterCustomizationCatalog.SNAPSHOT_IDS.size() == 23)
assert(CharacterCustomizationCatalog.CATEGORY_IDS.count("special") == 1)
assert("special" not in CharacterCustomizationCatalog.SNAPSHOT_IDS)
assert(CharacterCustomizationCatalog.SPECIAL_SLOT_IDS == PackedStringArray([
"special_back",
"special_beak",
"special_head",
"special_horns",
"special_tusks",
"special_wings",
]))
var expected_counts := {
"species": 21,
"arms": 3,
"ears": 21,
"tail": 17,
"special_back": 1,
"special_beak": 2,
"special_head": 1,
"special_horns": 2,
"special_tusks": 2,
"special_wings": 3,
}
var all_mesh_names: Array[String] = []
for category_id: String in expected_counts:
var options := CharacterCustomizationCatalog.options_for(category_id)
var option_ids: Array[String] = []
var mesh_names: Array[String] = []
for option: Dictionary in options:
var option_id := str(option.get("id", ""))
assert(not option_id.is_empty())
assert(option_id not in option_ids)
option_ids.append(option_id)
var mesh_name := CharacterCustomizationCatalog.model_mesh_name(
category_id,
option_id,
)
if option_id == "none":
assert(mesh_name.is_empty())
continue
assert(not mesh_name.is_empty())
assert(mesh_name not in mesh_names)
assert(mesh_name not in all_mesh_names)
mesh_names.append(mesh_name)
all_mesh_names.append(mesh_name)
assert(mesh_names.size() == int(expected_counts[category_id]))
assert(
CharacterCustomizationCatalog.model_mesh_names(category_id).size()
== int(expected_counts[category_id])
)
assert(
CharacterCustomizationCatalog.model_mesh_names("special").size()
== 11
)
assert(
CharacterCustomizationCatalog.model_mesh_name("arms", "body_arms")
== "arms_mammal"
)
assert(
CharacterCustomizationCatalog.model_mesh_name("ears", "pointy_long")
== "ears_long"
)
assert(
CharacterCustomizationCatalog.model_mesh_name("ears", "pointy_short")
== "ears_cat"
)
assert(
CharacterCustomizationCatalog.model_mesh_name("ears", "pointy_wide")
== "ears_fox"
)
var legacy := defaults.duplicate(true)
legacy.erase("arms")
for special_slot_id: String in CharacterCustomizationCatalog.SPECIAL_SLOT_IDS:
legacy.erase(special_slot_id)
legacy["ears"] = "pointy_wide"
var sanitized := CharacterCustomizationCatalog.sanitized_snapshot(legacy)
assert(CharacterCustomizationCatalog.validate_snapshot(sanitized))
assert(sanitized["arms"] == "mammal")
assert(sanitized["ears"] == "fox")
for special_slot_id: String in CharacterCustomizationCatalog.SPECIAL_SLOT_IDS:
assert(sanitized[special_slot_id] == "none")
func _validate_version_12_migration() -> void:
var legacy_appearance := CharacterCustomizationCatalog.default_snapshot()
legacy_appearance.erase("arms")
for special_slot_id: String in CharacterCustomizationCatalog.SPECIAL_SLOT_IDS:
legacy_appearance.erase(special_slot_id)
legacy_appearance["ears"] = "pointy_short"
var manager := PlayerSaveManager.new()
root.add_child(manager)
var migrated: Dictionary = manager.call(
"_migrate_save",
{
"save_version": 12,
"character": {
"appearance": legacy_appearance,
},
},
12,
)
assert(int(migrated.get("save_version", -1)) == 13)
var appearance: Dictionary = migrated["character"]["appearance"]
assert(CharacterCustomizationCatalog.validate_snapshot(appearance))
assert(appearance["arms"] == "mammal")
assert(appearance["ears"] == "cat")
for special_slot_id: String in CharacterCustomizationCatalog.SPECIAL_SLOT_IDS:
assert(appearance[special_slot_id] == "none")
manager.queue_free()
func _validate_appearance_profile_migration_and_strictness() -> void:
var legacy := CharacterCustomizationCatalog.default_snapshot()
legacy.erase("arms")
for special_slot_id: String in CharacterCustomizationCatalog.SPECIAL_SLOT_IDS:
legacy.erase(special_slot_id)
legacy["ears"] = "pointy_wide"
var legacy_path := "user://appearance-v1-%d.json" % Time.get_ticks_usec()
var legacy_file := FileAccess.open(legacy_path, FileAccess.WRITE)
assert(legacy_file != null)
legacy_file.store_string(JSON.stringify({
"format_version": 1,
"appearance": legacy,
}))
legacy_file.close()
var legacy_store := PlayerAppearanceStore.new()
root.add_child(legacy_store)
legacy_store.configure_storage(legacy_path, null)
assert(legacy_store.load_preferences())
var migrated := legacy_store.get_snapshot()
assert(CharacterCustomizationCatalog.validate_snapshot(migrated))
assert(migrated["arms"] == "mammal")
assert(migrated["ears"] == "fox")
legacy_store.queue_free()
DirAccess.remove_absolute(ProjectSettings.globalize_path(legacy_path))
var malformed := CharacterCustomizationCatalog.default_snapshot()
malformed["species"] = "definitely_not_a_head"
var current_path := "user://appearance-v2-%d.json" % Time.get_ticks_usec()
var current_file := FileAccess.open(current_path, FileAccess.WRITE)
assert(current_file != null)
current_file.store_string(JSON.stringify({
"format_version": PlayerAppearanceStore.FORMAT_VERSION,
"appearance": malformed,
}))
current_file.close()
var current_store := PlayerAppearanceStore.new()
root.add_child(current_store)
current_store.configure_storage(current_path, null)
assert(not current_store.load_preferences())
assert(not current_store.restore_from_save_data(malformed, false))
current_store.queue_free()
DirAccess.remove_absolute(ProjectSettings.globalize_path(current_path))
func _validate_signatures_cover_independent_accessories() -> void:
var baseline := CharacterCustomizationCatalog.default_snapshot()
var previous_signature := NetworkProfileProtocol.signature_fields({
"appearance": baseline,
})
var selections := {
"special_back": "shell",
"special_beak": "bird",
"special_head": "fin",
"special_horns": "goat",
"special_tusks": "boar",
"special_wings": "bat",
}
for category_id: String in CharacterCustomizationCatalog.SPECIAL_SLOT_IDS:
var changed := baseline.duplicate(true)
changed[category_id] = selections[category_id]
assert(CharacterCustomizationCatalog.validate_snapshot(changed))
var changed_signature := NetworkProfileProtocol.signature_fields({
"appearance": changed,
})
assert(changed_signature != previous_signature)
previous_signature = changed_signature
func _validate_presenter_selection() -> void:
var visuals := PlayerVisualPresenter.instantiate_visuals()
root.add_child(visuals)
var skeleton := visuals.find_child("Skeleton3D", true, false) as Skeleton3D
assert(skeleton != null)
var snapshot := CharacterCustomizationCatalog.default_snapshot()
PlayerVisualPresenter.apply_appearance(visuals, snapshot)
_assert_exact_selection(skeleton, "species", "head_round")
_assert_exact_selection(skeleton, "arms", "arms_mammal")
_assert_exact_selection(skeleton, "ears", "")
_assert_exact_selection(skeleton, "tail", "")
for special_slot_id: String in CharacterCustomizationCatalog.SPECIAL_SLOT_IDS:
_assert_exact_selection(skeleton, special_slot_id, "")
for species_option: Dictionary in CharacterCustomizationCatalog.options_for(
"species"
):
var species_id := str(species_option.get("id", ""))
snapshot["species"] = species_id
PlayerVisualPresenter.apply_appearance(visuals, snapshot)
var selected_head := CharacterCustomizationCatalog.model_mesh_name(
"species",
species_id,
)
_assert_exact_selection(skeleton, "species", selected_head)
for feature_name: String in ["eyes", "nose", "mouth"]:
var feature := skeleton.get_node_or_null(
"%s_%s" % [selected_head, feature_name]
) as MeshInstance3D
assert(feature != null and feature.visible)
var feature_material := feature.material_override as StandardMaterial3D
assert(feature_material != null)
assert(feature_material.albedo_texture != null)
snapshot["arms"] = "avian"
snapshot["ears"] = "owl"
snapshot["tail"] = "donkey"
snapshot["special_back"] = "shell"
snapshot["special_beak"] = "bird"
snapshot["special_head"] = "fin"
snapshot["special_horns"] = "goat"
snapshot["special_tusks"] = "elephant"
snapshot["special_wings"] = "moth"
assert(CharacterCustomizationCatalog.validate_snapshot(snapshot))
PlayerVisualPresenter.apply_appearance(visuals, snapshot)
_assert_exact_selection(skeleton, "arms", "arms_avian")
_assert_exact_selection(skeleton, "ears", "ears_owl")
_assert_exact_selection(skeleton, "tail", "tail_donkey")
_assert_exact_selection(skeleton, "special_back", "back_shell")
_assert_exact_selection(skeleton, "special_beak", "beak_bird")
_assert_exact_selection(skeleton, "special_head", "head_fin")
_assert_exact_selection(skeleton, "special_horns", "horns_goat")
_assert_exact_selection(skeleton, "special_tusks", "tusks_elephant")
_assert_exact_selection(skeleton, "special_wings", "wings_moth")
var wings := skeleton.get_node("wings_moth") as MeshInstance3D
var cached_material := wings.material_override
assert(cached_material != null)
PlayerVisualPresenter.apply_appearance(visuals, snapshot)
assert(wings.material_override == cached_material)
visuals.queue_free()
await process_frame
func _assert_exact_selection(
skeleton: Skeleton3D,
category_id: String,
expected_mesh_name: String,
) -> void:
for mesh_name: String in CharacterCustomizationCatalog.model_mesh_names(
category_id
):
var mesh := skeleton.get_node_or_null(mesh_name) as Node3D
assert(mesh != null)
assert(mesh.visible == (mesh_name == expected_mesh_name))

View file

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

View file

@ -0,0 +1,211 @@
extends SceneTree
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
var page := ProfilePage.new()
page.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
root.add_child(page)
await process_frame
await process_frame
page.set(
"_draft_appearance",
CharacterCustomizationCatalog.default_snapshot(),
)
var preview := page.get("_preview") as ProfilePreview
var default_appearance := (
CharacterCustomizationCatalog.default_snapshot()
)
preview.apply_appearance_profile(default_appearance)
var visible_bounds_result: Dictionary = preview.call(
"_visible_mesh_bounds"
)
assert(bool(visible_bounds_result.get("has_bounds", false)))
var visible_bounds: AABB = visible_bounds_result.get("bounds", AABB())
assert(
(preview.get("_camera_target") as Vector3).is_equal_approx(
visible_bounds.get_center()
)
)
var stable_fit := float(preview.get("_camera_fit_distance"))
preview.apply_appearance_profile(default_appearance)
assert(is_equal_approx(
float(preview.get("_camera_fit_distance")), stable_fit
))
var shell := page.find_child("UtilityMainBox", true, false) as Control
assert(shell != null)
var shell_position := shell.position
var shell_size := shell.size
for category_id: String in ["species", "arms", "ears", "tail"]:
page.call("_select_category", category_id)
await process_frame
await process_frame
var scroll := page.find_child(
"ModelOptionScroll", true, false
) as ScrollContainer
var grid := page.find_child(
"ModelOptionGrid", true, false
) as GridContainer
assert(scroll != null)
assert(grid != null)
assert(grid.columns == ProfilePage.MODEL_OPTION_GRID_COLUMNS)
assert(
grid.get_child_count()
== CharacterCustomizationCatalog.options_for(category_id).size()
)
assert(
scroll.horizontal_scroll_mode
== ScrollContainer.SCROLL_MODE_DISABLED
)
assert(
scroll.vertical_scroll_mode
== ScrollContainer.SCROLL_MODE_AUTO
)
assert(shell.position.is_equal_approx(shell_position))
assert(shell.size.is_equal_approx(shell_size))
page.call("_select_category", "species")
await process_frame
await process_frame
var species_scroll := page.find_child(
"ModelOptionScroll", true, false
) as ScrollContainer
assert(species_scroll != null)
var species_scroll_max := int(
maxf(
species_scroll.get_v_scroll_bar().max_value
- species_scroll.get_v_scroll_bar().page,
0.0,
)
)
assert(species_scroll_max > 0)
var saved_position := mini(species_scroll_max, 36)
species_scroll.scroll_vertical = saved_position
page.call("_select_category", "arms")
await process_frame
page.call("_select_category", "species")
await process_frame
await process_frame
species_scroll = page.find_child(
"ModelOptionScroll", true, false
) as ScrollContainer
assert(species_scroll != null)
assert(species_scroll.scroll_vertical == saved_position)
page.call("activate")
page.call("set_interactive", true)
var category_scroll := page.find_child(
"CategoryScroll", true, false
) as ScrollContainer
assert(category_scroll != null and category_scroll.follow_focus)
page.set("_controller_zone", ProfilePage.ControllerZone.CATEGORIES)
page.call("_apply_controller_zone_focus")
var category_list := page.get("_category_list") as VBoxContainer
var last_category_button := category_list.get_child(
category_list.get_child_count() - 1
) as Button
assert(last_category_button != null)
last_category_button.grab_focus()
await process_frame
assert(category_scroll.scroll_vertical > 0)
page.set("_controller_zone", ProfilePage.ControllerZone.OPTIONS)
page.call("_apply_controller_zone_focus")
var species_grid := page.find_child(
"ModelOptionGrid", true, false
) as GridContainer
var last_species_button := species_grid.get_child(
species_grid.get_child_count() - 1
) as Button
assert(last_species_button != null)
last_species_button.grab_focus()
await process_frame
assert(species_scroll.scroll_vertical > saved_position)
page.call("_select_category", "special")
await process_frame
await process_frame
var special_scroll := page.find_child(
"SpecialOptionScroll", true, false
) as ScrollContainer
assert(special_scroll != null)
for field_id: String in CharacterCustomizationCatalog.SPECIAL_SLOT_IDS:
var slot_name := "SpecialSlot_%s" % field_id.trim_prefix("special_")
var slot := page.find_child(slot_name, true, false) as HBoxContainer
assert(slot != null)
assert(slot.get_child_count() == 2)
var slot_label := slot.get_child(0) as Label
var choices := slot.get_child(1) as HBoxContainer
assert(slot_label != null and choices != null)
assert(
slot_label.text
== CharacterCustomizationCatalog.special_slot_label(field_id)
)
assert(
choices.get_child_count()
== CharacterCustomizationCatalog.options_for(field_id).size()
)
assert(shell.position.is_equal_approx(shell_position))
assert(shell.size.is_equal_approx(shell_size))
var back_slot := page.find_child(
"SpecialSlot_back", true, false
) as HBoxContainer
var back_choices := back_slot.get_child(1) as HBoxContainer
var shell_button := back_choices.get_child(1) as Button
assert(shell_button != null)
shell_button.pressed.emit()
await process_frame
await process_frame
var draft: Dictionary = page.get("_draft_appearance")
assert(str(draft.get("special_back", "")) == "shell")
for other_id: String in CharacterCustomizationCatalog.SPECIAL_SLOT_IDS:
if other_id != "special_back":
assert(str(draft.get(other_id, "")) == "none")
var wings_slot := page.find_child(
"SpecialSlot_wings", true, false
) as HBoxContainer
var wings_choices := wings_slot.get_child(1) as HBoxContainer
var moth_button := wings_choices.get_child(
wings_choices.get_child_count() - 1
) as Button
assert(moth_button != null)
moth_button.grab_focus()
await process_frame
moth_button.pressed.emit()
await process_frame
await process_frame
draft = page.get("_draft_appearance")
assert(str(draft.get("special_back", "")) == "shell")
assert(str(draft.get("special_wings", "")) == "moth")
var restored_focus := page.get_viewport().gui_get_focus_owner() as Button
assert(restored_focus != null)
assert(
str(restored_focus.get_meta(&"appearance_focus_key", ""))
== "special_wings:moth"
)
var ordinary_bounds := AABB(Vector3.ZERO, Vector3(1.0, 2.0, 1.0))
var wide_bounds := AABB(Vector3.ZERO, Vector3(4.0, 2.0, 1.0))
var tall_bounds := AABB(Vector3.ZERO, Vector3(1.0, 4.0, 1.0))
var viewport_size := Vector2(320.0, 330.0)
var ordinary_fit := ProfilePreview.calculate_camera_fit_distance(
ordinary_bounds, viewport_size, 75.0
)
assert(
ProfilePreview.calculate_camera_fit_distance(
wide_bounds, viewport_size, 75.0
) > ordinary_fit
)
assert(
ProfilePreview.calculate_camera_fit_distance(
tall_bounds, viewport_size, 75.0
) > ordinary_fit
)
print("Character customizer UI validation: PASS")
page.queue_free()
await process_frame
quit()

View file

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

View file

@ -41,6 +41,116 @@ const EXPECTED_ANIMATIONS: Array[StringName] = [
&"walking",
&"walking_show",
]
const EXPECTED_HEAD_MESHES: PackedStringArray = [
"head_round",
"head_pointy",
"head_anteater",
"head_axolotl",
"head_bat",
"head_bird",
"head_boar",
"head_butterfly",
"head_donkey",
"head_elephant",
"head_goat",
"head_hamster",
"head_horse",
"head_moth",
"head_opossum",
"head_owl",
"head_panda",
"head_pig",
"head_shark",
"head_sheep",
"head_snail",
]
const EXPECTED_ARM_MESHES: PackedStringArray = [
"arms_mammal", "arms_fish", "arms_avian",
]
const EXPECTED_EAR_MESHES: PackedStringArray = [
"ears_cat",
"ears_long",
"ears_fox",
"ears_bunny",
"ears_antlers_round",
"ears_bear",
"ears_anteater",
"ears_bat",
"ears_boar",
"ears_butterfly",
"ears_donkey",
"ears_axolotl",
"ears_elephant",
"ears_goat",
"ears_hamster",
"ears_horse",
"ears_opossum",
"ears_moth",
"ears_pig",
"ears_owl",
"ears_panda",
]
const EXPECTED_TAIL_MESHES: PackedStringArray = [
"tails_gator",
"tails_fox",
"tails_bunny",
"tails_cat",
"tails_bear",
"tails_pointy",
"tails_anteater",
"tails_boar",
"tails_butterfly",
"tail_donkey",
"tails_axolotl",
"tails_horse",
"tails_opossum",
"tails_moth",
"tails_pig",
"tails_shark",
"tails_bird",
]
const EXPECTED_SPECIAL_MESHES: PackedStringArray = [
"tusks_boar",
"tusks_elephant",
"wings_butterfly",
"wings_bat",
"wings_moth",
"horns_goat",
"horns_sheep",
"back_shell",
"head_fin",
"beak_owl",
"beak_bird",
]
const FACE_FEATURES: PackedStringArray = ["eyes", "nose", "mouth"]
const EXCLUDED_AUTHORING_MESHES: PackedStringArray = [
"frog_eyes_1",
"frog_eyes_2",
"frog_head_1",
"frog_head_2",
"rod_socket_preview-noimp",
]
const EXPECTED_BONE_PARENTS: Dictionary = {
"root": "",
"hips": "root",
"spine": "hips",
"clavicle.L": "spine",
"upper_arm.L": "clavicle.L",
"forearm.L": "upper_arm.L",
"hand.L": "forearm.L",
"clavicle.R": "spine",
"upper_arm.R": "clavicle.R",
"forearm.R": "upper_arm.R",
"hand.R": "forearm.R",
"rod_socket": "hand.R",
"rod_socket.001": "rod_socket",
"neck": "spine",
"head": "neck",
"thigh.L": "hips",
"shin.L": "thigh.L",
"thigh.R": "hips",
"shin.R": "thigh.R",
}
func _initialize() -> void:
@ -57,6 +167,21 @@ func _run() -> void:
) as AnimationPlayer
assert(skeleton != null)
assert(animation_player != null)
assert(character.get_path_to(skeleton) == ^"CharacterRig/Skeleton3D")
assert(skeleton.get_bone_count() == EXPECTED_BONE_PARENTS.size())
for bone_name: String in EXPECTED_BONE_PARENTS:
var bone_index := skeleton.find_bone(bone_name)
assert(bone_index >= 0, "Missing bone: %s" % bone_name)
var parent_index := skeleton.get_bone_parent(bone_index)
var actual_parent := (
str(skeleton.get_bone_name(parent_index))
if parent_index >= 0
else ""
)
assert(
actual_parent == str(EXPECTED_BONE_PARENTS[bone_name]),
"Unexpected parent for bone %s: %s" % [bone_name, actual_parent],
)
var socket_index := skeleton.find_bone("rod_socket")
assert(socket_index >= 0)
@ -79,26 +204,65 @@ func _run() -> void:
"Animation %s lost its authored rod socket pose." % expected,
)
var arms := character.find_child(
"body_arms",
true,
false,
) as MeshInstance3D
assert(arms != null)
var blended_arm_vertices := _validate_mesh_weights(arms)
assert(blended_arm_vertices > 0, "body_arms has no blended joint weights.")
var body := character.find_child(
"body_main",
true,
false,
) as MeshInstance3D
assert(body != null)
_validate_mesh_weights(body)
var expected_mesh_names := _expected_runtime_mesh_names()
assert(expected_mesh_names.size() == 137)
var face_decal_count := 0
for head_name: String in EXPECTED_HEAD_MESHES:
for feature_name: String in FACE_FEATURES:
assert(expected_mesh_names.has(
"%s_%s" % [head_name, feature_name]
))
face_decal_count += 1
assert(face_decal_count == 63)
var runtime_meshes: Array[MeshInstance3D] = []
_collect_mesh_instances(character, runtime_meshes)
assert(runtime_meshes.size() == expected_mesh_names.size())
var actual_mesh_names: Dictionary = {}
var total_blended_vertices := 0
for mesh_instance: MeshInstance3D in runtime_meshes:
assert(not actual_mesh_names.has(mesh_instance.name))
actual_mesh_names[mesh_instance.name] = true
assert(
expected_mesh_names.has(mesh_instance.name),
"Unexpected runtime character mesh: %s" % mesh_instance.name,
)
total_blended_vertices += _validate_mesh_weights(mesh_instance)
assert(actual_mesh_names.size() == expected_mesh_names.size())
for mesh_name: String in expected_mesh_names:
assert(actual_mesh_names.has(mesh_name), "Missing mesh: %s" % mesh_name)
for mesh_name: String in EXCLUDED_AUTHORING_MESHES:
assert(not actual_mesh_names.has(mesh_name))
var blended_arm_vertices := 0
for mesh_name: String in EXPECTED_ARM_MESHES:
var arms := character.find_child(
mesh_name,
true,
false,
) as MeshInstance3D
assert(arms != null)
var blended_vertices := _validate_mesh_weights(arms)
assert(
blended_vertices > 0,
"%s has no blended joint weights." % mesh_name,
)
blended_arm_vertices += blended_vertices
assert(total_blended_vertices >= blended_arm_vertices)
print(
"Character rig validation: PASS "
+ "(animations=%d, blended arm vertices=%d)"
% [EXPECTED_ANIMATIONS.size(), blended_arm_vertices]
(
"Character rig validation: PASS "
+ "(meshes=%d, face decals=%d, bones=%d, animations=%d, "
+ "blended arm vertices=%d)"
)
% [
runtime_meshes.size(),
face_decal_count,
skeleton.get_bone_count(),
EXPECTED_ANIMATIONS.size(),
blended_arm_vertices,
]
)
character.queue_free()
quit()
@ -125,12 +289,39 @@ func _validate_mesh_weights(mesh_instance: MeshInstance3D) -> int:
if weight > 0.000001:
influence_count += 1
assert(absf(total - 1.0) <= 0.0001)
assert(influence_count <= 2)
if influence_count == 2:
assert(influence_count <= 4)
if influence_count > 1:
blended_vertices += 1
return blended_vertices
func _expected_runtime_mesh_names() -> Dictionary:
var result: Dictionary = {"body_main": true}
for mesh_name: String in EXPECTED_ARM_MESHES:
result[mesh_name] = true
for head_name: String in EXPECTED_HEAD_MESHES:
result[head_name] = true
for feature_name: String in FACE_FEATURES:
result["%s_%s" % [head_name, feature_name]] = true
for mesh_name: String in EXPECTED_EAR_MESHES:
result[mesh_name] = true
for mesh_name: String in EXPECTED_TAIL_MESHES:
result[mesh_name] = true
for mesh_name: String in EXPECTED_SPECIAL_MESHES:
result[mesh_name] = true
return result
func _collect_mesh_instances(
parent: Node,
result: Array[MeshInstance3D],
) -> void:
if parent is MeshInstance3D:
result.append(parent as MeshInstance3D)
for child: Node in parent.get_children():
_collect_mesh_instances(child, result)
func _find_first(parent: Node, class_name_to_find: String) -> Node:
if parent.is_class(class_name_to_find):
return parent

View file

@ -104,7 +104,7 @@ func _run() -> void:
var hotbar_data: Dictionary = parsed["hotbar"]
assert(typeof(hotbar_data.get("fish_slots")) == TYPE_ARRAY)
assert(str((hotbar_data["fish_slots"] as Array)[1]) == fish_catch.catch_id)
assert(int(parsed["save_version"]) == 12)
assert(int(parsed["save_version"]) == 13)
assert(not PlayerHomeState.sanitize_save_data(
parsed.get("home", {})
).is_empty())
@ -204,7 +204,7 @@ func _run() -> void:
var enet_channel_count: int = NetworkProtocol.ENET_CHANNEL_COUNT
var fish_quality_capability: String = NetworkProtocol.FISH_QUALITY_CAPABILITY
var showcase_capability: StringName = NetworkFishShowcaseProtocol.CAPABILITY
assert(protocol_version == 13)
assert(protocol_version == 14)
assert(enet_channel_count == 18)
assert(
fish_quality_capability == "fish_quality_v1"

View file

@ -38,7 +38,7 @@ func _run() -> void:
NetworkProtocol.FISHING_REPLICATION_CAPABILITY
)
var fish_quality_capability: String = NetworkProtocol.FISH_QUALITY_CAPABILITY
assert(protocol_version == 13)
assert(protocol_version == 14)
assert(enet_channel_count == 18)
assert(movement_animation_channel == 11)
assert(
@ -505,7 +505,7 @@ func _validate_version_four_migration() -> void:
version_four,
4,
)
assert(int(migrated.get("save_version", -1)) == 12)
assert(int(migrated.get("save_version", -1)) == 13)
assert(int((migrated["experience"] as Dictionary)["total_experience"]) == 0)
assert(
is_equal_approx(

View file

@ -156,9 +156,9 @@ func _run() -> void:
)
var body_texture := _assert_pattern_texture("calico", "body_main")
var arms_texture := _assert_pattern_texture("paws", "body_arms")
var arms_texture := _assert_pattern_texture("paws", "arms_mammal")
var head_texture := _assert_pattern_texture("tiger", "head_round")
var ears_texture := _assert_pattern_texture("fox", "ears_pointy_short")
var ears_texture := _assert_pattern_texture("fox", "ears_cat")
var tail_texture := _assert_pattern_texture("fox", "tails_fox")
_assert_pattern_texture("tummy", "body_main")
_assert_pattern_texture("stripes", "body_main")
@ -186,13 +186,13 @@ func _run() -> void:
skeleton, "body_main", body_texture
)
var arms_material := _assert_mesh_pattern(
skeleton, "body_arms", arms_texture
skeleton, "arms_mammal", arms_texture
)
var head_material := _assert_mesh_pattern(
skeleton, "head_round", head_texture
)
var ears_material := _assert_mesh_pattern(
skeleton, "ears_pointy_short", ears_texture
skeleton, "ears_cat", ears_texture
)
var tail_material := _assert_mesh_pattern(
skeleton, "tails_fox", tail_texture

View file

@ -209,7 +209,7 @@ func _run() -> void:
)
assert(bool(decoded.get("ok", false)))
var save_data: Dictionary = decoded["data"]
assert(int(save_data.get("save_version", -1)) == 12)
assert(int(save_data.get("save_version", -1)) == 13)
assert(PlayerJobService.validate_save_data(save_data.get("jobs", {})))
assert(not PlayerHomeState.sanitize_save_data(
save_data.get("home", {})

View file

@ -143,7 +143,7 @@ func _validate_save_migration() -> void:
version_five,
5,
)
assert(int(migrated.get("save_version", -1)) == 12)
assert(int(migrated.get("save_version", -1)) == 13)
var experience_data: Dictionary = migrated.get("experience", {})
assert(int(experience_data.get("total_experience", -1)) == 0)
var world_data: Dictionary = migrated.get("world", {})

View file

@ -177,6 +177,11 @@ func _run_client() -> void:
var service := main.get_node(
"%NetworkProfileService"
) as NetworkProfileService
var spawn := main.get_node("%PlayerSpawnService") as PlayerSpawnService
var player := spawn.get_local_player() as Player
var host_avatar := spawn.get_avatar(1) as Player
assert(player != null)
assert(host_avatar != null)
var original: Dictionary = service.get_persisted_appearance()
var changed: Dictionary = _changed_appearance(original)
assert(service.preview_appearance(changed))
@ -189,6 +194,11 @@ func _run_client() -> void:
service.apply_finished.connect(func(accepted: bool, message: String) -> void:
apply_results.append([accepted, message])
)
# Generated spawn points can be farther apart than audible speech range.
# Establish proximity after initial replication settles so this voice test
# does not depend on the terrain seed's peer spawn spacing.
player.global_position = host_avatar.global_position + Vector3.RIGHT
assert(player.global_position.distance_to(host_avatar.global_position) < 4.0)
assert(service.apply_profile(
service.get_persisted_name(),
changed,
@ -213,7 +223,6 @@ func _run_client() -> void:
== "trail guide"
)
assert(Dictionary(session.get("_local_appearance_snapshot")) == changed)
var player := main.get_node("%PlayerSpawnService").get_local_player() as Player
assert(player.get_animalese_voice_id() == "deep")
assert(player.get_animalese_sample_set_id() == "kim")
var host_voice_messages: Array[Dictionary] = []
@ -265,18 +274,17 @@ func _run_client() -> void:
func _changed_appearance(original: Dictionary) -> Dictionary:
var changed := original.duplicate(true)
for category_id: String in CharacterCustomizationCatalog.CATEGORY_IDS:
if category_id in CharacterCustomizationCatalog.FUR_STYLE_IDS:
continue
var options: Array = CharacterCustomizationCatalog.options_for(category_id)
for option: Dictionary in options:
var option_id: Variant = option.get("id")
if option_id != changed.get(category_id):
changed[category_id] = option_id
if CharacterCustomizationCatalog.validate_snapshot(changed):
return changed
changed[category_id] = original.get(category_id)
assert(false, "No alternate appearance option is available.")
changed["species"] = "snail"
changed["arms"] = "avian"
changed["ears"] = "owl"
changed["tail"] = "donkey"
changed["special_back"] = "shell"
changed["special_beak"] = "bird"
changed["special_head"] = "fin"
changed["special_horns"] = "goat"
changed["special_tusks"] = "elephant"
changed["special_wings"] = "moth"
assert(CharacterCustomizationCatalog.validate_snapshot(changed))
return changed

View file

@ -55,6 +55,33 @@ func _validate_network_payloads() -> void:
var invalid_hello := hello.duplicate(true)
invalid_hello["player_title"] = ""
assert(not NetworkProtocol.validate_client_hello(invalid_hello).is_empty())
var missing_appearance_field := hello.duplicate(true)
missing_appearance_field["cosmetic_snapshot"] = appearance.duplicate(true)
missing_appearance_field["cosmetic_snapshot"].erase("special_wings")
assert(not NetworkProtocol.validate_client_hello(
missing_appearance_field
).is_empty())
var extra_appearance_field := hello.duplicate(true)
extra_appearance_field["cosmetic_snapshot"] = appearance.duplicate(true)
extra_appearance_field["cosmetic_snapshot"]["unexpected"] = "value"
assert(not NetworkProtocol.validate_client_hello(
extra_appearance_field
).is_empty())
var invalid_appearance_option := hello.duplicate(true)
invalid_appearance_option["cosmetic_snapshot"] = appearance.duplicate(true)
invalid_appearance_option["cosmetic_snapshot"]["species"] = "unknown"
assert(not NetworkProtocol.validate_client_hello(
invalid_appearance_option
).is_empty())
var noncanonical_appearance := hello.duplicate(true)
noncanonical_appearance["cosmetic_snapshot"] = appearance.duplicate(true)
noncanonical_appearance["cosmetic_snapshot"]["ears"] = "pointy_wide"
assert(CharacterCustomizationCatalog.validate_snapshot(
noncanonical_appearance["cosmetic_snapshot"]
))
assert(not NetworkProtocol.validate_client_hello(
noncanonical_appearance
).is_empty())
var check_request := {
"request_id": "check",

View file

@ -72,7 +72,7 @@ func _run() -> void:
_assert_opaque(save_path)
var decoded: Dictionary = ProgressionSaveCodec.read_local_save(save_path)
assert(bool(decoded.get("ok", false)))
assert(int((decoded["data"] as Dictionary)["save_version"]) == 12)
assert(int((decoded["data"] as Dictionary)["save_version"]) == 13)
var saved_character: Dictionary = (
(decoded["data"] as Dictionary).get("character", {})
)
@ -292,6 +292,55 @@ func _run() -> void:
assert(bool(unchanged_migration.get("ok", false)))
assert(not bool(unchanged_migration.get("changed", true)))
assert(data_root.root_path == unchanged_root)
var appearance_path: String = data_root.root_path.path_join(
"player/player_appearance.json"
)
assert(appearance_store.save_snapshot(appearance_store.get_snapshot()))
var appearance_file := FileAccess.open(appearance_path, FileAccess.READ)
assert(appearance_file != null)
var parsed_appearance_data: Variant = JSON.parse_string(
appearance_file.get_as_text()
)
appearance_file.close()
assert(typeof(parsed_appearance_data) == TYPE_DICTIONARY)
var current_appearance_data := parsed_appearance_data as Dictionary
assert(
int(current_appearance_data.get("format_version", -1))
== PlayerAppearanceStore.FORMAT_VERSION
)
assert(PortableDataMigration._valid_owned_data(
"player_appearance.json", current_appearance_data
))
var legacy_appearance_data: Dictionary = current_appearance_data.duplicate(
true
)
legacy_appearance_data["format_version"] = 1
var legacy_appearance: Dictionary = legacy_appearance_data["appearance"]
for added_id: String in [
"arms",
"special_back",
"special_beak",
"special_head",
"special_horns",
"special_tusks",
"special_wings",
]:
legacy_appearance.erase(added_id)
assert(PortableDataMigration._valid_owned_data(
"player_appearance.json", legacy_appearance_data
))
var future_appearance_data := current_appearance_data.duplicate(true)
future_appearance_data["format_version"] = (
PlayerAppearanceStore.FORMAT_VERSION + 1
)
assert(not PortableDataMigration._valid_owned_data(
"player_appearance.json", future_appearance_data
))
var malformed_appearance_data := current_appearance_data.duplicate(true)
malformed_appearance_data["format_version"] = "2"
assert(not PortableDataMigration._valid_owned_data(
"player_appearance.json", malformed_appearance_data
))
var migrated_root: String = data_root.root_path.get_base_dir().path_join(
"progression-migrated-data"

View file

@ -32,7 +32,7 @@ func _run() -> void:
var snapshot_entities_per_envelope: int = (
NetworkWorldSpawnProtocol.SNAPSHOT_ENTITIES_PER_ENVELOPE
)
assert(protocol_version == 13)
assert(protocol_version == 14)
assert(
world_spawn_capability == protocol_world_spawn_capability
)

657
tools/blender/export_character.py Executable file
View file

@ -0,0 +1,657 @@
#!/usr/bin/env python3
"""Validate and export the authoritative straywild character as a GLB.
Run this script through Blender with the authoritative source file open:
blender --background straywild_character_source.blend \
--python tools/blender/export_character.py -- \
--output art/exported/characters/base/straywild_base_character.glb \
--verify-determinism
The exporter deliberately uses an explicit object manifest. It exports every
runtime character mesh even when the object is hidden in the authoring file,
while excluding the ``unused`` collection and the rod-socket preview mesh.
The loaded blend is only modified in memory and is never saved.
"""
from __future__ import annotations
import argparse
import hashlib
import json
import os
import struct
import sys
import tempfile
from pathlib import Path
import bpy
ARMATURE_NAME = "CharacterRig"
GODOT_SKELETON_NODE_PATH = "CharacterRig/Skeleton3D"
PREVIEW_MESH_NAME = "rod_socket_preview-noimp"
UNUSED_COLLECTION_NAME = "unused"
UNUSED_MESH_NAMES = {
"frog_eyes_1",
"frog_eyes_2",
"frog_head_1",
"frog_head_2",
}
EXPECTED_BONE_PARENTS = {
"root": None,
"hips": "root",
"spine": "hips",
"clavicle.L": "spine",
"upper_arm.L": "clavicle.L",
"forearm.L": "upper_arm.L",
"hand.L": "forearm.L",
"clavicle.R": "spine",
"upper_arm.R": "clavicle.R",
"forearm.R": "upper_arm.R",
"hand.R": "forearm.R",
"rod_socket": "hand.R",
"rod_socket.001": "rod_socket",
"neck": "spine",
"head": "neck",
"thigh.L": "hips",
"shin.L": "thigh.L",
"thigh.R": "hips",
"shin.R": "thigh.R",
}
HEAD_MESH_NAMES = (
"head_round",
"head_pointy",
"head_anteater",
"head_axolotl",
"head_bat",
"head_bird",
"head_boar",
"head_butterfly",
"head_donkey",
"head_elephant",
"head_goat",
"head_hamster",
"head_horse",
"head_moth",
"head_opossum",
"head_owl",
"head_panda",
"head_pig",
"head_shark",
"head_sheep",
"head_snail",
)
FACE_FEATURE_SUFFIXES = ("eyes", "nose", "mouth")
FACE_DECAL_MESH_NAMES = tuple(
f"{head_name}.{suffix}"
for head_name in HEAD_MESH_NAMES
for suffix in FACE_FEATURE_SUFFIXES
)
ARM_MESH_NAMES = ("arms_mammal", "arms_fish", "arms_avian")
EAR_MESH_NAMES = (
"ears_cat",
"ears_long",
"ears_fox",
"ears_bunny",
"ears_antlers_round",
"ears_bear",
"ears_anteater",
"ears_bat",
"ears_boar",
"ears_butterfly",
"ears_donkey",
"ears_axolotl",
"ears_elephant",
"ears_goat",
"ears_hamster",
"ears_horse",
"ears_opossum",
"ears_moth",
"ears_pig",
"ears_owl",
"ears_panda",
)
TAIL_MESH_NAMES = (
"tails_gator",
"tails_fox",
"tails_bunny",
"tails_cat",
"tails_bear",
"tails_pointy",
"tails_anteater",
"tails_boar",
"tails_butterfly",
"tail_donkey",
"tails_axolotl",
"tails_horse",
"tails_opossum",
"tails_moth",
"tails_pig",
"tails_shark",
"tails_bird",
)
SPECIAL_MESH_NAMES = (
"tusks_boar",
"tusks_elephant",
"wings_butterfly",
"wings_bat",
"wings_moth",
"horns_goat",
"horns_sheep",
"back_shell",
"head_fin",
"beak_owl",
"beak_bird",
)
RUNTIME_MESH_NAMES = {
"body_main",
*ARM_MESH_NAMES,
*HEAD_MESH_NAMES,
*FACE_DECAL_MESH_NAMES,
*EAR_MESH_NAMES,
*TAIL_MESH_NAMES,
*SPECIAL_MESH_NAMES,
}
SOURCE_ACTION_NAMES = {
"casting",
"casting_sit",
"draw",
"fighting_loop",
"fighting_sit_loop",
"fishing_loop",
"fishing_sit_loop",
"idle_loop",
"idle_show_loop",
"idle_sit_loop",
"idle_sit_show_loop",
"idle_sneak_loop",
"pocket_idle_idle",
"pocket_idle_show",
"pocket_show_idle",
"pocket_show_show",
"pocket_sit_idle_idle",
"pocket_sit_idle_show",
"pocket_sit_show_idle",
"pocket_sit_show_show",
"pocket_walking_idle_idle",
"pocket_walking_idle_show",
"pocket_walking_show_idle",
"pocket_walking_show_show",
"release",
"release_sit",
"retract",
"retract_sit",
"running_loop",
"running_show_loop",
"sneaking_loop",
"strike",
"walking_loop",
"walking_show_loop",
}
class ExportValidationError(RuntimeError):
"""Raised when the source or exported GLB violates the contract."""
def _parse_arguments() -> argparse.Namespace:
script_arguments: list[str] = []
if "--" in sys.argv:
script_arguments = sys.argv[sys.argv.index("--") + 1 :]
parser = argparse.ArgumentParser(
description="Export the authoritative straywild character to GLB."
)
parser.add_argument(
"--output",
type=Path,
required=True,
help="Runtime .glb file to write.",
)
parser.add_argument(
"--verify-determinism",
action="store_true",
help="Export twice and require byte-identical output.",
)
return parser.parse_args(script_arguments)
def _sha256(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as source_file:
for block in iter(lambda: source_file.read(1024 * 1024), b""):
digest.update(block)
return digest.hexdigest()
def _validate_source() -> tuple[Path, bpy.types.Object]:
if not bpy.data.filepath:
raise ExportValidationError("Open a character .blend before exporting.")
source_path = Path(bpy.data.filepath).resolve()
armatures = [
item for item in bpy.data.objects if item.type == "ARMATURE"
]
if [item.name for item in armatures] != [ARMATURE_NAME]:
raise ExportValidationError(
"Expected only the CharacterRig armature; found "
f"{sorted(item.name for item in armatures)}."
)
armature = armatures[0]
actual_bone_parents = {
bone.name: bone.parent.name if bone.parent is not None else None
for bone in armature.data.bones
}
if set(actual_bone_parents) != set(EXPECTED_BONE_PARENTS):
missing = sorted(set(EXPECTED_BONE_PARENTS) - set(actual_bone_parents))
unexpected = sorted(set(actual_bone_parents) - set(EXPECTED_BONE_PARENTS))
raise ExportValidationError(
f"{ARMATURE_NAME} bone manifest mismatch; missing={missing}, "
f"unexpected={unexpected}."
)
incorrect_bone_parents = {
bone_name: {
"expected": expected_parent,
"actual": actual_bone_parents[bone_name],
}
for bone_name, expected_parent in EXPECTED_BONE_PARENTS.items()
if actual_bone_parents[bone_name] != expected_parent
}
if incorrect_bone_parents:
raise ExportValidationError(
f"{ARMATURE_NAME} bone parent mismatch: "
f"{incorrect_bone_parents}."
)
unused_collection = bpy.data.collections.get(UNUSED_COLLECTION_NAME)
if unused_collection is None:
raise ExportValidationError("The exact unused collection is missing.")
unused_names = {item.name for item in unused_collection.all_objects}
if unused_names != UNUSED_MESH_NAMES:
raise ExportValidationError(
"The unused collection must contain exactly "
f"{sorted(UNUSED_MESH_NAMES)}; found {sorted(unused_names)}."
)
actual_mesh_names = {
item.name for item in bpy.data.objects if item.type == "MESH"
}
expected_mesh_names = (
RUNTIME_MESH_NAMES | UNUSED_MESH_NAMES | {PREVIEW_MESH_NAME}
)
if actual_mesh_names != expected_mesh_names:
missing = sorted(expected_mesh_names - actual_mesh_names)
unexpected = sorted(actual_mesh_names - expected_mesh_names)
raise ExportValidationError(
f"Character mesh manifest mismatch; missing={missing}, "
f"unexpected={unexpected}."
)
if len(RUNTIME_MESH_NAMES) != 137:
raise ExportValidationError(
"Internal runtime mesh manifest is not exactly 137 unique names."
)
if len(FACE_DECAL_MESH_NAMES) != 63:
raise ExportValidationError(
"Internal face decal manifest is not exactly 63 unique names."
)
action_names = {action.name for action in bpy.data.actions}
if action_names != SOURCE_ACTION_NAMES:
missing = sorted(SOURCE_ACTION_NAMES - action_names)
unexpected = sorted(action_names - SOURCE_ACTION_NAMES)
raise ExportValidationError(
f"Character action manifest mismatch; missing={missing}, "
f"unexpected={unexpected}."
)
for mesh_name in sorted(RUNTIME_MESH_NAMES):
mesh_object = bpy.data.objects[mesh_name]
armature_modifiers = [
modifier
for modifier in mesh_object.modifiers
if modifier.type == "ARMATURE"
]
if mesh_object.parent is not armature:
raise ExportValidationError(
f"{mesh_name} is not parented to {ARMATURE_NAME}."
)
if len(armature_modifiers) != 1:
raise ExportValidationError(
f"{mesh_name} has {len(armature_modifiers)} armature modifiers; "
"expected exactly one."
)
if armature_modifiers[0].object is not armature:
raise ExportValidationError(
f"{mesh_name} armature modifier does not target {ARMATURE_NAME}."
)
return source_path, armature
def _prepare_in_memory_export() -> None:
excluded_names = UNUSED_MESH_NAMES | {PREVIEW_MESH_NAME}
for object_name in sorted(excluded_names):
bpy.data.objects.remove(bpy.data.objects[object_name], do_unlink=True)
remaining_mesh_names = {
item.name for item in bpy.context.scene.objects if item.type == "MESH"
}
if remaining_mesh_names != RUNTIME_MESH_NAMES:
raise ExportValidationError(
"Active scene membership differs from the runtime manifest after "
"excluding authoring-only meshes."
)
def _export(output_path: Path) -> None:
result = bpy.ops.export_scene.gltf(
filepath=str(output_path),
check_existing=False,
export_format="GLB",
export_texcoords=True,
export_normals=True,
export_tangents=False,
export_materials="EXPORT",
export_attributes=False,
use_selection=False,
use_visible=False,
use_renderable=False,
use_active_collection=False,
use_active_collection_with_nested=False,
use_active_scene=True,
export_cameras=False,
export_lights=False,
export_yup=True,
export_apply=False,
export_extras=False,
export_animations=True,
export_frame_range=False,
export_force_sampling=True,
export_animation_mode="ACTIONS",
export_def_bones=False,
export_leaf_bone=False,
export_reset_pose_bones=True,
export_rest_position_armature=True,
export_skins=True,
export_influence_nb=4,
export_all_influences=False,
export_morph=False,
will_save_settings=False,
)
if result != {"FINISHED"}:
raise RuntimeError(f"Blender failed to export the character: {result}")
def _read_glb_document(output_path: Path) -> dict[str, object]:
with output_path.open("rb") as binary_file:
header = binary_file.read(20)
if len(header) != 20 or header[:4] != b"glTF":
raise ExportValidationError(
f"{output_path} is not a valid GLB file."
)
json_length, json_kind = struct.unpack_from("<II", header, 12)
if json_kind != 0x4E4F534A:
raise ExportValidationError(
f"{output_path} does not begin with a GLB JSON chunk."
)
return json.loads(binary_file.read(json_length))
def _node_parent_indices(nodes: list[dict[str, object]]) -> dict[int, int]:
parents: dict[int, int] = {}
for parent_index, node in enumerate(nodes):
for child_index in node.get("children", []):
if not isinstance(child_index, int) or not 0 <= child_index < len(nodes):
raise ExportValidationError(
f"GLB node {parent_index} has invalid child {child_index}."
)
if child_index in parents:
raise ExportValidationError(
f"GLB node {child_index} has more than one parent."
)
parents[child_index] = parent_index
return parents
def _validate_export(output_path: Path) -> None:
document = _read_glb_document(output_path)
nodes = document.get("nodes", [])
if not isinstance(nodes, list) or not all(
isinstance(node, dict) for node in nodes
):
raise ExportValidationError("GLB nodes must be an array of objects.")
expected_node_count = (
1 + len(EXPECTED_BONE_PARENTS) + len(RUNTIME_MESH_NAMES)
)
if len(nodes) != expected_node_count:
raise ExportValidationError(
f"GLB has {len(nodes)} nodes; expected {expected_node_count} "
"(one armature, 19 joints, and 137 meshes)."
)
scenes = document.get("scenes", [])
if not isinstance(scenes, list) or len(scenes) != 1:
raise ExportValidationError("GLB must contain exactly one scene.")
scene_index = document.get("scene", 0)
if scene_index != 0:
raise ExportValidationError(
f"GLB default scene index is {scene_index}; expected 0."
)
scene_roots = scenes[0].get("nodes", [])
if not isinstance(scene_roots, list) or len(scene_roots) != 1:
raise ExportValidationError(
"GLB scene must contain exactly one CharacterRig root node."
)
armature_node_index = scene_roots[0]
if (
not isinstance(armature_node_index, int)
or not 0 <= armature_node_index < len(nodes)
or str(nodes[armature_node_index].get("name", "")) != ARMATURE_NAME
):
raise ExportValidationError(
"GLB scene root must be CharacterRig so Godot imports the skeleton "
f"at {GODOT_SKELETON_NODE_PATH}."
)
parents = _node_parent_indices(nodes)
if armature_node_index in parents:
raise ExportValidationError("GLB CharacterRig scene root has a parent.")
mesh_node_indices = [
node_index
for node_index, node in enumerate(nodes)
if "mesh" in node
]
exported_mesh_names = [
str(nodes[node_index].get("name", ""))
for node_index in mesh_node_indices
]
duplicate_mesh_names = sorted(
{
mesh_name
for mesh_name in exported_mesh_names
if exported_mesh_names.count(mesh_name) > 1
}
)
if duplicate_mesh_names:
raise ExportValidationError(
f"GLB has duplicate mesh node names: {duplicate_mesh_names}."
)
exported_mesh_name_set = set(exported_mesh_names)
if exported_mesh_name_set != RUNTIME_MESH_NAMES:
missing = sorted(RUNTIME_MESH_NAMES - exported_mesh_name_set)
unexpected = sorted(exported_mesh_name_set - RUNTIME_MESH_NAMES)
raise ExportValidationError(
f"Exported mesh membership mismatch; missing={missing}, "
f"unexpected={unexpected}."
)
meshes = document.get("meshes", [])
if not isinstance(meshes, list) or len(meshes) != len(RUNTIME_MESH_NAMES):
raise ExportValidationError(
f"GLB has {len(meshes)} mesh resources; "
f"expected {len(RUNTIME_MESH_NAMES)}."
)
for node_index in mesh_node_indices:
node = nodes[node_index]
if node.get("skin") != 0:
raise ExportValidationError(
f"Mesh node {node.get('name', '')} targets skin "
f"{node.get('skin')}; expected skin 0."
)
if parents.get(node_index) != armature_node_index:
raise ExportValidationError(
f"Mesh node {node.get('name', '')} is not a direct child of "
f"{ARMATURE_NAME}; Godot would not import the expected skeleton "
f"path {GODOT_SKELETON_NODE_PATH}."
)
mesh_index = node.get("mesh")
if not isinstance(mesh_index, int) or not 0 <= mesh_index < len(meshes):
raise ExportValidationError(
f"Mesh node {node.get('name', '')} has invalid mesh index "
f"{mesh_index}."
)
referenced_mesh_indices = [
int(nodes[node_index]["mesh"]) for node_index in mesh_node_indices
]
if (
len(referenced_mesh_indices) != len(meshes)
or set(referenced_mesh_indices) != set(range(len(meshes)))
):
raise ExportValidationError(
"GLB mesh resources must each be referenced by exactly one mesh node."
)
if len(document.get("animations", [])) != len(SOURCE_ACTION_NAMES):
raise ExportValidationError(
f"GLB has {len(document.get('animations', []))} animations; "
f"expected {len(SOURCE_ACTION_NAMES)}."
)
if len(document.get("skins", [])) != 1:
raise ExportValidationError("GLB must contain exactly one skin.")
skin = document["skins"][0]
if str(skin.get("name", "")) != ARMATURE_NAME:
raise ExportValidationError(
f"GLB skin must be named {ARMATURE_NAME}."
)
joints = skin.get("joints", [])
if not isinstance(joints, list) or len(joints) != len(EXPECTED_BONE_PARENTS):
raise ExportValidationError(
f"GLB skin has {len(joints)} joints; "
f"expected {len(EXPECTED_BONE_PARENTS)}."
)
if len(set(joints)) != len(joints) or not all(
isinstance(joint_index, int) and 0 <= joint_index < len(nodes)
for joint_index in joints
):
raise ExportValidationError("GLB skin contains invalid or duplicate joints.")
joint_indices_by_name = {
str(nodes[joint_index].get("name", "")): joint_index
for joint_index in joints
}
if set(joint_indices_by_name) != set(EXPECTED_BONE_PARENTS):
missing = sorted(
set(EXPECTED_BONE_PARENTS) - set(joint_indices_by_name)
)
unexpected = sorted(
set(joint_indices_by_name) - set(EXPECTED_BONE_PARENTS)
)
raise ExportValidationError(
f"GLB joint manifest mismatch; missing={missing}, "
f"unexpected={unexpected}."
)
root_joint_index = joint_indices_by_name["root"]
if skin.get("skeleton", root_joint_index) != root_joint_index:
raise ExportValidationError(
"GLB skin skeleton must resolve to the root joint."
)
for bone_name, expected_parent_name in EXPECTED_BONE_PARENTS.items():
joint_index = joint_indices_by_name[bone_name]
expected_parent_index = (
armature_node_index
if expected_parent_name is None
else joint_indices_by_name[expected_parent_name]
)
if parents.get(joint_index) != expected_parent_index:
actual_parent_index = parents.get(joint_index)
actual_parent_name = (
str(nodes[actual_parent_index].get("name", ""))
if actual_parent_index is not None
else None
)
raise ExportValidationError(
f"GLB joint {bone_name} parent is {actual_parent_name}; "
f"expected {expected_parent_name or ARMATURE_NAME}."
)
animation_names = {
str(animation.get("name", ""))
for animation in document.get("animations", [])
}
if animation_names != SOURCE_ACTION_NAMES:
raise ExportValidationError(
"Exported animation names differ from the source action manifest."
)
def _temporary_export_path(output_path: Path, label: str) -> Path:
descriptor, raw_path = tempfile.mkstemp(
prefix=f".{output_path.stem}.{label}.",
suffix=".glb",
dir=output_path.parent,
)
os.close(descriptor)
os.unlink(raw_path)
return Path(raw_path)
def _run() -> int:
arguments = _parse_arguments()
output_path = arguments.output.expanduser().resolve()
if output_path.suffix.lower() != ".glb":
raise ExportValidationError("--output must name a .glb file.")
output_path.parent.mkdir(parents=True, exist_ok=True)
source_path, _armature = _validate_source()
source_sha256 = _sha256(source_path)
_prepare_in_memory_export()
temporary_paths = [_temporary_export_path(output_path, "first")]
try:
first_export_path = temporary_paths[0]
_export(first_export_path)
_validate_export(first_export_path)
if arguments.verify_determinism:
second_export_path = _temporary_export_path(output_path, "second")
temporary_paths.append(second_export_path)
_export(second_export_path)
_validate_export(second_export_path)
if second_export_path.read_bytes() != first_export_path.read_bytes():
raise ExportValidationError(
"Repeated character exports were not byte-identical."
)
os.replace(first_export_path, output_path)
output_sha256 = _sha256(output_path)
finally:
for temporary_path in temporary_paths:
try:
temporary_path.unlink()
except FileNotFoundError:
pass
print(
"Character export: PASS "
f"(source={source_sha256}, output={output_sha256}, "
f"meshes={len(RUNTIME_MESH_NAMES)}, "
f"face_decals={len(FACE_DECAL_MESH_NAMES)}, "
f"animations={len(SOURCE_ACTION_NAMES)}, bones=19)"
)
return 0
if __name__ == "__main__":
try:
raise SystemExit(_run())
except ExportValidationError as error:
print(f"CHARACTER EXPORT FAILED:\n{error}", file=sys.stderr)
raise SystemExit(2) from error

View file

@ -4,6 +4,8 @@ extends Control
const CHECK_DEBOUNCE_SECONDS: float = 0.4
const APPEARANCE_PREVIEW_INTERVAL_SECONDS: float = 0.08
const OPTION_GRID_COLUMNS: int = 3
const MODEL_OPTION_GRID_COLUMNS: int = 2
const MODEL_OPTION_BUTTON_HEIGHT: float = 36.0
const FUR_PALETTE_GRID_COLUMNS: int = 6
const FUR_PATTERN_GRID_COLUMNS: int = 2
const FUR_CHANNEL_GRID_COLUMNS: int = 2
@ -68,6 +70,7 @@ var _title_edit: LineEdit
var _name_label: Label
var _name_status: Label
var _suggestions: HBoxContainer
var _category_scroll: ScrollContainer
var _category_list: VBoxContainer
var _option_list: VBoxContainer
var _preview: ProfilePreview
@ -107,6 +110,7 @@ var _option_scroll: ScrollContainer
var _active_option_scroll_key: String = ""
var _option_scroll_positions: Dictionary[String, int] = {}
var _option_scroll_restore_generation: int = 0
var _option_focus_keys: Dictionary[String, String] = {}
var _controller_mapping_manager: ControllerMappingManagerType
var _controller_zone: ControllerZone = ControllerZone.ACCOUNT
var _controller_option_depth: int = 0
@ -448,6 +452,23 @@ func _focus_controller_zone() -> void:
):
_customize_button.grab_focus()
return
if _controller_zone == ControllerZone.OPTIONS:
var preferred_focus_key := str(
_option_focus_keys.get(_category_id, "")
)
if not preferred_focus_key.is_empty():
for control: Control in controls:
var button := control as BaseButton
if (
button != null
and str(button.get_meta(&"appearance_focus_key", ""))
== preferred_focus_key
and button.focus_mode != Control.FOCUS_NONE
and button.is_visible_in_tree()
and not button.disabled
):
button.grab_focus()
return
for control: Control in controls:
var button := control as BaseButton
if (
@ -805,17 +826,18 @@ func _build_ui() -> void:
"separation", BODY_COLUMN_SEPARATION
)
body_margin.add_child(body)
var category_scroll := ScrollContainer.new()
category_scroll.name = "CategoryScroll"
category_scroll.custom_minimum_size.x = 120.0
category_scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL
category_scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
category_scroll.vertical_scroll_mode = ScrollContainer.SCROLL_MODE_AUTO
body.add_child(category_scroll)
_category_scroll = ScrollContainer.new()
_category_scroll.name = "CategoryScroll"
_category_scroll.custom_minimum_size.x = 120.0
_category_scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL
_category_scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
_category_scroll.vertical_scroll_mode = ScrollContainer.SCROLL_MODE_AUTO
_category_scroll.follow_focus = true
body.add_child(_category_scroll)
_category_list = VBoxContainer.new()
_category_list.custom_minimum_size = Vector2(120, 0)
_category_list.add_theme_constant_override("separation", 5)
category_scroll.add_child(_category_list)
_category_scroll.add_child(_category_list)
_option_list = VBoxContainer.new()
_option_list.custom_minimum_size = Vector2(326, 0)
_option_list.size_flags_horizontal = Control.SIZE_EXPAND_FILL
@ -931,6 +953,9 @@ func _build_categories() -> void:
button.custom_minimum_size.x = 120.0
button.button_pressed = category_id == _category_id
button.pressed.connect(_select_category.bind(category_id))
button.focus_entered.connect(
_scroll_control_into_view.bind(_category_scroll, button)
)
UtilityPageStyle.apply_compact_ocean_button(button)
_category_list.add_child(button)
_refresh_options()
@ -971,6 +996,9 @@ func _refresh_options() -> void:
if _category_id == CharacterCustomizationCatalog.SCALE_CATEGORY_ID:
_build_scale_option()
return
if _category_id == "special":
_build_special_options()
return
var options: Array = CharacterCustomizationCatalog.options_for(_category_id)
if options.is_empty():
var empty := Label.new()
@ -984,21 +1012,129 @@ func _refresh_options() -> void:
if _category_id in CharacterCustomizationCatalog.FEATURE_CATEGORIES:
_build_feature_preview_options(options)
return
_build_model_option_grid(_category_id, options)
func _build_model_option_grid(category_id: String, options: Array) -> void:
var scroll := ScrollContainer.new()
scroll.name = "ModelOptionScroll"
scroll.size_flags_horizontal = Control.SIZE_EXPAND_FILL
scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL
scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
scroll.vertical_scroll_mode = ScrollContainer.SCROLL_MODE_AUTO
_option_list.add_child(scroll)
_register_option_scroll(scroll)
var grid := GridContainer.new()
grid.name = "ModelOptionGrid"
grid.columns = MODEL_OPTION_GRID_COLUMNS
grid.size_flags_horizontal = Control.SIZE_EXPAND_FILL
grid.add_theme_constant_override("h_separation", 8)
grid.add_theme_constant_override("v_separation", 7)
scroll.add_child(grid)
for option: Dictionary in options:
var option_id := str(option["id"])
var button := Button.new()
button.text = "×" if option_id == "none" else str(option["label"])
button.tooltip_text = "none" if option_id == "none" else str(
option["label"]
var button := _build_model_option_button(category_id, option)
grid.add_child(button)
_register_scroll_focus(button, scroll)
func _build_model_option_button(
category_id: String,
option: Dictionary,
) -> Button:
var option_id := str(option.get("id", ""))
var option_label := str(option.get("label", option_id))
var button := Button.new()
button.text = "×" if option_id == "none" else option_label
button.tooltip_text = "none" if option_id == "none" else option_label
button.toggle_mode = true
button.size_flags_horizontal = Control.SIZE_EXPAND_FILL
button.custom_minimum_size.y = MODEL_OPTION_BUTTON_HEIGHT
button.button_pressed = (
str(_draft_appearance.get(category_id, "")) == option_id
)
button.set_meta(
&"appearance_focus_key",
"%s:%s" % [category_id, option_id],
)
button.focus_entered.connect(
_remember_option_focus.bind(category_id, option_id)
)
button.pressed.connect(_select_option.bind(category_id, option_id))
UtilityPageStyle.apply_compact_ocean_button(button)
if option_id == "none":
button.add_theme_font_size_override("font_size", 24)
return button
func _build_special_options() -> void:
var scroll := ScrollContainer.new()
scroll.name = "SpecialOptionScroll"
scroll.size_flags_horizontal = Control.SIZE_EXPAND_FILL
scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL
scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
scroll.vertical_scroll_mode = ScrollContainer.SCROLL_MODE_AUTO
_option_list.add_child(scroll)
_register_option_scroll(scroll)
var selectors := VBoxContainer.new()
selectors.name = "SpecialOptionSelectors"
selectors.size_flags_horizontal = Control.SIZE_EXPAND_FILL
selectors.add_theme_constant_override("separation", 6)
scroll.add_child(selectors)
for field_id: String in CharacterCustomizationCatalog.SPECIAL_SLOT_IDS:
var options: Array = CharacterCustomizationCatalog.options_for(field_id)
if options.is_empty():
continue
var row := HBoxContainer.new()
row.name = "SpecialSlot_%s" % field_id.trim_prefix("special_")
row.size_flags_horizontal = Control.SIZE_EXPAND_FILL
row.add_theme_constant_override("separation", 6)
selectors.add_child(row)
var label := Label.new()
label.text = CharacterCustomizationCatalog.special_slot_label(field_id)
label.custom_minimum_size.x = 50.0
label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
label.add_theme_color_override(
"font_color", UtilityPageStyle.OCEAN_TEXT_SECONDARY
)
button.toggle_mode = true
button.custom_minimum_size.x = 170.0
button.button_pressed = _draft_appearance.get(_category_id) == option_id
button.pressed.connect(_select_option.bind(_category_id, option_id))
UtilityPageStyle.apply_compact_ocean_button(button)
if option_id == "none":
button.add_theme_font_size_override("font_size", 24)
_option_list.add_child(button)
row.add_child(label)
var choices := HBoxContainer.new()
choices.size_flags_horizontal = Control.SIZE_EXPAND_FILL
choices.add_theme_constant_override("separation", 5)
row.add_child(choices)
for option: Dictionary in options:
var button := _build_model_option_button(field_id, option)
button.custom_minimum_size.y = 34.0
button.add_theme_font_size_override(
"font_size",
20 if str(option.get("id", "")) == "none" else 16,
)
choices.add_child(button)
_register_scroll_focus(button, scroll)
func _register_scroll_focus(
control: Control,
scroll: ScrollContainer,
) -> void:
control.focus_entered.connect(
_scroll_control_into_view.bind(scroll, control)
)
func _scroll_control_into_view(
scroll: ScrollContainer,
control: Control,
) -> void:
if (
is_instance_valid(scroll)
and is_instance_valid(control)
and control.is_visible_in_tree()
):
scroll.ensure_control_visible(control)
func _remember_option_focus(category_id: String, option_id: String) -> void:
_option_focus_keys[_category_id] = "%s:%s" % [category_id, option_id]
func _option_scroll_context_key() -> String:

View file

@ -11,9 +11,11 @@ const FRONT_FACING_YAW: float = PI
const DEFAULT_CAMERA_DISTANCE: float = 1.9
const DEFAULT_CAMERA_PITCH: float = 0.12
const MIN_CAMERA_DISTANCE: float = 1.05
const MAX_CAMERA_DISTANCE: float = 2.8
const MAX_CAMERA_DISTANCE: float = 4.5
const CAMERA_ZOOM_STEP: float = 0.14
const CAMERA_TARGET_HEIGHT: float = 0.95
const CAMERA_FIT_MARGIN: float = 1.12
const CAMERA_FIT_MIN_RADIUS: float = 0.08
@export_range(0.1, 2.0, 0.05) var drag_sensitivity: float = 0.012
@export_range(0.1, 4.0, 0.1) var keyboard_speed: float = 1.8
@ -28,6 +30,11 @@ var _dragging: bool = false
var _camera_yaw: float = 0.0
var _camera_pitch: float = DEFAULT_CAMERA_PITCH
var _camera_distance: float = DEFAULT_CAMERA_DISTANCE
var _camera_fit_distance: float = DEFAULT_CAMERA_DISTANCE
var _camera_zoom_offset: float = 0.0
var _camera_target: Vector3 = Vector3(0.0, CAMERA_TARGET_HEIGHT, 0.0)
var _last_visible_bounds: AABB = AABB()
var _has_visible_bounds: bool = false
var _visuals: Node3D
var _controller_mapping_manager: ControllerMappingManagerType
var _source_environment: WorldEnvironment
@ -81,13 +88,15 @@ func _ready() -> void:
func apply_appearance_profile(profile: Dictionary) -> void:
if _visuals != null:
PlayerVisualPresenter.apply_appearance(_visuals, profile)
_fit_camera_to_visible_parts()
func reset_view() -> void:
_preview_root.rotation.y = FRONT_FACING_YAW
_camera_yaw = 0.0
_camera_pitch = DEFAULT_CAMERA_PITCH
_camera_distance = DEFAULT_CAMERA_DISTANCE
_camera_zoom_offset = 0.0
_camera_distance = _camera_fit_distance
_apply_camera_orbit()
@ -150,6 +159,7 @@ func _zoom_preview(amount: float) -> void:
MIN_CAMERA_DISTANCE,
MAX_CAMERA_DISTANCE,
)
_camera_zoom_offset = _camera_distance - _camera_fit_distance
_apply_camera_orbit()
@ -159,15 +169,115 @@ func _apply_camera_orbit() -> void:
var horizontal_distance := cos(_camera_pitch) * _camera_distance
_preview_camera.position = Vector3(
sin(_camera_yaw) * horizontal_distance,
CAMERA_TARGET_HEIGHT + sin(_camera_pitch) * _camera_distance,
sin(_camera_pitch) * _camera_distance,
cos(_camera_yaw) * horizontal_distance,
)
) + _camera_target
_preview_camera.look_at(
Vector3(0.0, CAMERA_TARGET_HEIGHT, 0.0),
_camera_target,
Vector3.UP,
)
func _fit_camera_to_visible_parts() -> void:
if _visuals == null or _preview_camera == null:
return
var bounds_result: Dictionary = _visible_mesh_bounds()
if not bool(bounds_result.get("has_bounds", false)):
return
var bounds: AABB = bounds_result.get("bounds", AABB()) as AABB
if (
_has_visible_bounds
and bounds.position.is_equal_approx(_last_visible_bounds.position)
and bounds.size.is_equal_approx(_last_visible_bounds.size)
):
return
_last_visible_bounds = bounds
_has_visible_bounds = true
_camera_target = bounds.get_center()
var viewport_size := Vector2(
_preview_camera.get_viewport().get_visible_rect().size
)
_camera_fit_distance = clampf(
calculate_camera_fit_distance(
bounds,
viewport_size,
_preview_camera.fov,
),
MIN_CAMERA_DISTANCE,
MAX_CAMERA_DISTANCE,
)
_camera_distance = clampf(
_camera_fit_distance + _camera_zoom_offset,
MIN_CAMERA_DISTANCE,
MAX_CAMERA_DISTANCE,
)
_apply_camera_orbit()
func _visible_mesh_bounds() -> Dictionary:
var has_bounds := false
var bounds := AABB()
for descendant: Node in _visuals.find_children(
"*", "MeshInstance3D", true, false
):
var mesh_instance := descendant as MeshInstance3D
if (
mesh_instance == null
or mesh_instance.mesh == null
or not _is_visible_within_preview(mesh_instance)
):
continue
var mesh_bounds := mesh_instance.get_aabb()
var world_transform := mesh_instance.global_transform
for corner_index: int in 8:
var corner := mesh_bounds.position + Vector3(
mesh_bounds.size.x if (corner_index & 1) != 0 else 0.0,
mesh_bounds.size.y if (corner_index & 2) != 0 else 0.0,
mesh_bounds.size.z if (corner_index & 4) != 0 else 0.0,
)
var point := world_transform * corner
if not has_bounds:
bounds = AABB(point, Vector3.ZERO)
has_bounds = true
else:
bounds = bounds.expand(point)
return {"has_bounds": has_bounds, "bounds": bounds}
func _is_visible_within_preview(node: Node3D) -> bool:
var current: Node = node
while current != null and current != _visuals:
var current_3d := current as Node3D
if current_3d != null and not current_3d.visible:
return false
current = current.get_parent()
return current == _visuals
static func calculate_camera_fit_distance(
bounds: AABB,
viewport_size: Vector2,
vertical_fov_degrees: float,
) -> float:
var radius := maxf(
bounds.size.length() * 0.5,
CAMERA_FIT_MIN_RADIUS,
)
var safe_height := maxf(viewport_size.y, 1.0)
var aspect := maxf(viewport_size.x, 1.0) / safe_height
var vertical_half_angle := deg_to_rad(vertical_fov_degrees) * 0.5
var horizontal_half_angle := atan(tan(vertical_half_angle) * aspect)
var limiting_half_angle := minf(
vertical_half_angle,
horizontal_half_angle,
)
return (
radius
/ maxf(sin(limiting_half_angle), 0.001)
* CAMERA_FIT_MARGIN
)
func _sync_world_lighting() -> void:
# The customization viewport deliberately does not follow gameplay time of
# day. Keep this method as a compatibility seam for existing setup callers.

View file

@ -14,7 +14,7 @@ radius = 1.5
script = ExtResource("3_display")
species_id = "pointy"
fur_color_id = "white"
ears_id = "pointy_wide"
ears_id = "fox"
tail_id = "fox"
eyes_id = "punk_purple"
nose_id = "triangle_small"

View file

@ -14,7 +14,7 @@ radius = 1.5
script = ExtResource("3_display")
species_id = "pointy"
fur_color_id = "teal"
ears_id = "pointy_long"
ears_id = "long"
tail_id = "fox"
eyes_id = "interested"
nose_id = "triangle_small"

View file

@ -8,7 +8,30 @@ const PlayerVisualPresenterType = preload(
"res://player/player_visual_presenter.gd"
)
@export_enum("round", "pointy") var species_id: String = "round"
@export_enum(
"round",
"pointy",
"anteater",
"axolotl",
"bat",
"bird",
"boar",
"butterfly",
"donkey",
"elephant",
"goat",
"hamster",
"horse",
"moth",
"opossum",
"owl",
"panda",
"pig",
"shark",
"sheep",
"snail",
) var species_id: String = "round"
@export_enum("mammal", "fish", "avian") var arms_id: String = "mammal"
@export_enum(
"white",
"gray",
@ -21,19 +44,54 @@ const PlayerVisualPresenterType = preload(
) var fur_color_id: String = "white"
@export_enum(
"none",
"pointy_long",
"pointy_short",
"pointy_wide",
"antlers_round",
"anteater",
"axolotl",
"bat",
"bear",
"boar",
"bunny",
"butterfly",
"cat",
"donkey",
"elephant",
"fox",
"goat",
"hamster",
"horse",
"long",
"moth",
"opossum",
"owl",
"panda",
"pig",
) var ears_id: String = "none"
@export_enum(
"none",
"anteater",
"axolotl",
"bear",
"bird",
"boar",
"bunny",
"butterfly",
"cat",
"donkey",
"fox",
"gator",
"horse",
"moth",
"opossum",
"pig",
"pointy",
"shark",
) var tail_id: String = "none"
@export_enum("none", "shell") var special_back_id: String = "none"
@export_enum("none", "bird", "owl") var special_beak_id: String = "none"
@export_enum("none", "fin") var special_head_id: String = "none"
@export_enum("none", "goat", "sheep") var special_horns_id: String = "none"
@export_enum("none", "boar", "elephant") var special_tusks_id: String = "none"
@export_enum("none", "bat", "butterfly", "moth") var special_wings_id: String = "none"
@export var eyes_id: String = "simple_shine"
@export var nose_id: String = "dog_round"
@export var mouth_id: String = "three"
@ -57,9 +115,16 @@ var _locomotion_walking: bool = false
func _ready() -> void:
var appearance := CharacterCustomizationCatalogType.default_snapshot()
appearance["species"] = species_id
appearance["arms"] = arms_id
appearance["fur_pattern"] = fur_color_id
appearance["ears"] = ears_id
appearance["tail"] = tail_id
appearance["special_back"] = special_back_id
appearance["special_beak"] = special_beak_id
appearance["special_head"] = special_head_id
appearance["special_horns"] = special_horns_id
appearance["special_tusks"] = special_tusks_id
appearance["special_wings"] = special_wings_id
appearance["eyes"] = eyes_id
appearance["nose"] = nose_id
appearance["mouth"] = mouth_id