Expand character customization assets
This commit is contained in:
parent
23cf53fa14
commit
df2a4d7d77
31 changed files with 2423 additions and 215 deletions
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -5,3 +5,7 @@
|
|||
|
||||
# Blender backup files
|
||||
*.blend1
|
||||
|
||||
# Python cache files generated by Blender helper scripts
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
|
|
|
|||
Binary file not shown.
Binary file not shown.
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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, ""))
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
278
tests/character_customization_expansion_validation.gd
Normal file
278
tests/character_customization_expansion_validation.gd
Normal 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))
|
||||
|
|
@ -0,0 +1 @@
|
|||
uid://drj5amsurmywk
|
||||
211
tests/character_customizer_ui_validation.gd
Normal file
211
tests/character_customizer_ui_validation.gd
Normal 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()
|
||||
1
tests/character_customizer_ui_validation.gd.uid
Normal file
1
tests/character_customizer_ui_validation.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://by314ps17phf3
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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", {})
|
||||
|
|
|
|||
|
|
@ -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", {})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
657
tools/blender/export_character.py
Executable 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
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue