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
|
# Blender backup files
|
||||||
*.blend1
|
*.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 WorldLayoutType = preload("res://world/world_layout.gd")
|
||||||
|
|
||||||
const PROTOCOL_VERSION: int = 13
|
const PROTOCOL_VERSION: int = 14
|
||||||
const GAME_BUILD: String = "prealpha"
|
const GAME_BUILD: String = "prealpha"
|
||||||
const MAX_GAME_VERSION_LENGTH: int = 64
|
const MAX_GAME_VERSION_LENGTH: int = 64
|
||||||
const MAX_DISPLAY_NAME_LENGTH: int = 24
|
const MAX_DISPLAY_NAME_LENGTH: int = 24
|
||||||
|
|
@ -274,6 +274,13 @@ static func validate_client_hello(data: Variant) -> String:
|
||||||
return "Capabilities are invalid."
|
return "Capabilities are invalid."
|
||||||
if typeof(payload["cosmetic_snapshot"]) != TYPE_DICTIONARY:
|
if typeof(payload["cosmetic_snapshot"]) != TYPE_DICTIONARY:
|
||||||
return "Cosmetic snapshot is invalid."
|
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 (
|
if (
|
||||||
not NetworkIdentityCrypto.valid_fingerprint(
|
not NetworkIdentityCrypto.valid_fingerprint(
|
||||||
payload["identity_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("active_slot_id")) == TYPE_STRING
|
||||||
and typeof(data.get("slots")) == TYPE_ARRAY
|
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 (
|
return (
|
||||||
filename not in LEGACY_FILES
|
filename not in LEGACY_FILES
|
||||||
or int(data.get("format_version", -1)) == 1
|
or int(data.get("format_version", -1)) == 1
|
||||||
|
|
|
||||||
|
|
@ -3587,7 +3587,9 @@ func get_nameplate_anchor_position() -> Vector3:
|
||||||
var skeleton := get_node_or_null(
|
var skeleton := get_node_or_null(
|
||||||
"Visuals/CharacterRig/CharacterRig/Skeleton3D"
|
"Visuals/CharacterRig/CharacterRig/Skeleton3D"
|
||||||
) as 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 := (
|
var mesh_instance := (
|
||||||
skeleton.get_node_or_null(head_name) as MeshInstance3D
|
skeleton.get_node_or_null(head_name) as MeshInstance3D
|
||||||
if skeleton != null
|
if skeleton != null
|
||||||
|
|
|
||||||
|
|
@ -7,22 +7,31 @@ const RUNTIME_MATERIAL_META: StringName = &"straywild_runtime_material"
|
||||||
const RUNTIME_FUR_PATTERN_MATERIAL_META: StringName = (
|
const RUNTIME_FUR_PATTERN_MATERIAL_META: StringName = (
|
||||||
&"straywild_runtime_fur_pattern_material"
|
&"straywild_runtime_fur_pattern_material"
|
||||||
)
|
)
|
||||||
const EAR_MESH_NAMES: Array[String] = [
|
const APPLIED_APPEARANCE_META: StringName = &"straywild_applied_appearance"
|
||||||
"ears_antlers_round",
|
const MODEL_CATEGORY_IDS: PackedStringArray = [
|
||||||
"ears_bear",
|
"species",
|
||||||
"ears_bunny",
|
"arms",
|
||||||
"ears_pointy_long",
|
"ears",
|
||||||
"ears_pointy_short",
|
"tail",
|
||||||
"ears_pointy_wide",
|
"special_back",
|
||||||
]
|
"special_beak",
|
||||||
const TAIL_MESH_NAMES: Array[String] = [
|
"special_head",
|
||||||
"tails_bear",
|
"special_horns",
|
||||||
"tails_bunny",
|
"special_tusks",
|
||||||
"tails_cat",
|
"special_wings",
|
||||||
"tails_fox",
|
|
||||||
"tails_gator",
|
|
||||||
"tails_pointy",
|
|
||||||
]
|
]
|
||||||
|
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 = {}
|
static var _feature_uv_aspects: Dictionary = {}
|
||||||
|
|
||||||
|
|
@ -33,7 +42,9 @@ static func feature_uv_aspect(feature_name: String) -> float:
|
||||||
var visuals := instantiate_visuals()
|
var visuals := instantiate_visuals()
|
||||||
var skeleton := visuals.find_child("Skeleton3D", true, false) as Skeleton3D
|
var skeleton := visuals.find_child("Skeleton3D", true, false) as Skeleton3D
|
||||||
var aspects: Array[float] = []
|
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(
|
var mesh_instance := skeleton.get_node_or_null(
|
||||||
"%s_%s" % [head_name, feature_name]
|
"%s_%s" % [head_name, feature_name]
|
||||||
) as MeshInstance3D
|
) as MeshInstance3D
|
||||||
|
|
@ -108,57 +119,67 @@ static func apply_appearance(
|
||||||
var skeleton: Skeleton3D = visuals.find_child("Skeleton3D", true, false) as Skeleton3D
|
var skeleton: Skeleton3D = visuals.find_child("Skeleton3D", true, false) as Skeleton3D
|
||||||
if skeleton == null:
|
if skeleton == null:
|
||||||
return
|
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(
|
var head_changed := first_application or "species" in changed_model_categories
|
||||||
"species", str(snapshot.get("species", "round"))
|
if head_changed:
|
||||||
)
|
_set_head_feature_visibility(skeleton, snapshot)
|
||||||
var head_id: String = "head_pointy" if species_id == "pointy" else "head_round"
|
else:
|
||||||
var head_ids: Array[String] = ["head_pointy", "head_round"]
|
for feature_name: String in ["eyes", "mouth", "nose"]:
|
||||||
for head_name: String in head_ids:
|
if _appearance_option_changed(previous, snapshot, feature_name):
|
||||||
var head: Node3D = skeleton.get_node_or_null(head_name) as Node3D
|
_set_selected_head_feature_visibility(
|
||||||
if head != null:
|
skeleton,
|
||||||
head.visible = head_name == head_id
|
snapshot,
|
||||||
for feature: String in ["eyes", "mouth", "nose"]:
|
feature_name,
|
||||||
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"))
|
|
||||||
)
|
)
|
||||||
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(
|
var fur_color: Color = CharacterCustomizationCatalog.option_color(
|
||||||
"fur_pattern", str(snapshot.get("fur_pattern", "white"))
|
"fur_pattern", str(snapshot.get("fur_pattern", "white"))
|
||||||
)
|
)
|
||||||
for fur_mesh_name: String in [
|
var fur_changed := first_application
|
||||||
"body_main",
|
for category_id: String in CharacterCustomizationCatalog.FUR_COLOR_IDS:
|
||||||
"body_arms",
|
fur_changed = (
|
||||||
"head_pointy",
|
fur_changed
|
||||||
"head_round",
|
or _appearance_option_changed(previous, snapshot, category_id)
|
||||||
]:
|
)
|
||||||
_set_fur_mesh(skeleton, fur_mesh_name, snapshot, fur_color)
|
for category_id: String in CharacterCustomizationCatalog.FUR_STYLE_IDS:
|
||||||
for ear_name: String in EAR_MESH_NAMES:
|
fur_changed = (
|
||||||
_set_fur_mesh(skeleton, ear_name, snapshot, fur_color)
|
fur_changed
|
||||||
for tail_name: String in TAIL_MESH_NAMES:
|
or _appearance_option_changed(previous, snapshot, category_id)
|
||||||
_set_fur_mesh(skeleton, tail_name, snapshot, fur_color)
|
)
|
||||||
|
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(
|
var eye_id: String = CharacterCustomizationCatalog.canonical_option_id(
|
||||||
"eyes", str(snapshot.get("eyes", "simple_shine"))
|
"eyes", str(snapshot.get("eyes", "simple_shine"))
|
||||||
|
|
@ -169,44 +190,153 @@ static func apply_appearance(
|
||||||
var nose_id: String = CharacterCustomizationCatalog.canonical_option_id(
|
var nose_id: String = CharacterCustomizationCatalog.canonical_option_id(
|
||||||
"nose", str(snapshot.get("nose", "dog_round"))
|
"nose", str(snapshot.get("nose", "dog_round"))
|
||||||
)
|
)
|
||||||
_set_feature_texture(
|
if head_changed or _appearance_option_changed(previous, snapshot, "eyes"):
|
||||||
skeleton,
|
_set_selected_head_feature_texture(
|
||||||
"eyes",
|
skeleton,
|
||||||
CharacterCustomizationCatalog.texture_for("eyes", eye_id),
|
snapshot,
|
||||||
)
|
"eyes",
|
||||||
_set_feature_texture(
|
CharacterCustomizationCatalog.texture_for("eyes", eye_id),
|
||||||
skeleton,
|
)
|
||||||
"mouth",
|
if head_changed or _appearance_option_changed(previous, snapshot, "mouth"):
|
||||||
CharacterCustomizationCatalog.texture_for("mouth", mouth_id),
|
_set_selected_head_feature_texture(
|
||||||
)
|
skeleton,
|
||||||
_set_feature_texture(
|
snapshot,
|
||||||
skeleton,
|
"mouth",
|
||||||
"nose",
|
CharacterCustomizationCatalog.texture_for("mouth", mouth_id),
|
||||||
CharacterCustomizationCatalog.texture_for("nose", nose_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,
|
skeleton: Skeleton3D,
|
||||||
|
snapshot: Dictionary,
|
||||||
feature_name: String,
|
feature_name: String,
|
||||||
texture: Texture2D,
|
texture: Texture2D,
|
||||||
) -> void:
|
) -> void:
|
||||||
for head_name: String in ["head_pointy", "head_round"]:
|
var head_name := _model_category_selection(snapshot, "species")
|
||||||
var mesh_instance: MeshInstance3D = skeleton.get_node_or_null(
|
var mesh_instance: MeshInstance3D = skeleton.get_node_or_null(
|
||||||
"%s_%s" % [head_name, feature_name]
|
"%s_%s" % [head_name, feature_name]
|
||||||
) as MeshInstance3D
|
) as MeshInstance3D
|
||||||
if texture == null:
|
if texture == null:
|
||||||
if mesh_instance != null:
|
if mesh_instance != null:
|
||||||
mesh_instance.visible = false
|
mesh_instance.visible = false
|
||||||
continue
|
return
|
||||||
var material: StandardMaterial3D = _runtime_material(mesh_instance)
|
var material: StandardMaterial3D = _runtime_material(mesh_instance)
|
||||||
if material != null:
|
if material != null:
|
||||||
# Feature plates are transparent geometry. The exported base model may
|
# Feature plates are transparent geometry. The exported base model may
|
||||||
# have no preview texture, so enforce the runtime alpha mode when a
|
# have no preview texture, so enforce the runtime alpha mode when a
|
||||||
# drop-in PNG is assigned.
|
# drop-in PNG is assigned.
|
||||||
material.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
|
material.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
|
||||||
material.albedo_color = Color.WHITE
|
material.albedo_color = Color.WHITE
|
||||||
material.albedo_texture = texture
|
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(
|
static func _set_fur_mesh(
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ extends RefCounted
|
||||||
|
|
||||||
const CATEGORY_IDS: PackedStringArray = [
|
const CATEGORY_IDS: PackedStringArray = [
|
||||||
"species",
|
"species",
|
||||||
|
"arms",
|
||||||
"scale",
|
"scale",
|
||||||
"fur_pattern",
|
"fur_pattern",
|
||||||
"ears",
|
"ears",
|
||||||
|
|
@ -10,13 +11,15 @@ const CATEGORY_IDS: PackedStringArray = [
|
||||||
"nose",
|
"nose",
|
||||||
"mouth",
|
"mouth",
|
||||||
"tail",
|
"tail",
|
||||||
|
"special",
|
||||||
]
|
]
|
||||||
|
|
||||||
# CATEGORY_IDS controls the visible Profile navigation. The additional fur
|
# CATEGORY_IDS controls the visible Profile navigation. The component fur and
|
||||||
# fields are edited together on the existing fur page, but remain explicit in
|
# independent special-slot fields are edited within grouped pages, but remain
|
||||||
# saves and signed multiplayer appearance snapshots.
|
# explicit in saves and signed multiplayer appearance snapshots.
|
||||||
const SNAPSHOT_IDS: PackedStringArray = [
|
const SNAPSHOT_IDS: PackedStringArray = [
|
||||||
"species",
|
"species",
|
||||||
|
"arms",
|
||||||
"scale",
|
"scale",
|
||||||
"fur_pattern",
|
"fur_pattern",
|
||||||
"fur_style",
|
"fur_style",
|
||||||
|
|
@ -32,10 +35,17 @@ const SNAPSHOT_IDS: PackedStringArray = [
|
||||||
"nose",
|
"nose",
|
||||||
"mouth",
|
"mouth",
|
||||||
"tail",
|
"tail",
|
||||||
|
"special_back",
|
||||||
|
"special_beak",
|
||||||
|
"special_head",
|
||||||
|
"special_horns",
|
||||||
|
"special_tusks",
|
||||||
|
"special_wings",
|
||||||
]
|
]
|
||||||
|
|
||||||
const CATEGORY_LABELS: Dictionary = {
|
const CATEGORY_LABELS: Dictionary = {
|
||||||
"species": "head",
|
"species": "head",
|
||||||
|
"arms": "arms",
|
||||||
"scale": "size",
|
"scale": "size",
|
||||||
"fur_pattern": "fur",
|
"fur_pattern": "fur",
|
||||||
"ears": "ears",
|
"ears": "ears",
|
||||||
|
|
@ -43,6 +53,120 @@ const CATEGORY_LABELS: Dictionary = {
|
||||||
"nose": "nose",
|
"nose": "nose",
|
||||||
"mouth": "mouth",
|
"mouth": "mouth",
|
||||||
"tail": "tail",
|
"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"
|
const SCALE_CATEGORY_ID: String = "scale"
|
||||||
|
|
@ -86,6 +210,12 @@ const DEFAULT_FUR_STYLE: String = "solid"
|
||||||
const FUR_PATTERN_ASSET_ROOT: String = (
|
const FUR_PATTERN_ASSET_ROOT: String = (
|
||||||
"res://art/exported/characters/patterns"
|
"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_TEXTURE_CACHE_LIMIT: int = 24
|
||||||
|
|
||||||
const FEATURE_CATEGORIES: PackedStringArray = ["eyes", "nose", "mouth"]
|
const FEATURE_CATEGORIES: PackedStringArray = ["eyes", "nose", "mouth"]
|
||||||
|
|
@ -134,8 +264,32 @@ const FUR_COLOR_OPTIONS: Array = [
|
||||||
]
|
]
|
||||||
const OPTIONS: Dictionary = {
|
const OPTIONS: Dictionary = {
|
||||||
"species": [
|
"species": [
|
||||||
{"id": "round", "label": "round"},
|
{"id": "round", "label": "cat"},
|
||||||
{"id": "pointy", "label": "pointy"},
|
{"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
|
# `fur_pattern` remains the primary color key for compatibility with
|
||||||
# existing appearance saves. The actual authored pattern has its own field.
|
# existing appearance saves. The actual authored pattern has its own field.
|
||||||
|
|
@ -146,11 +300,26 @@ const OPTIONS: Dictionary = {
|
||||||
"ears": [
|
"ears": [
|
||||||
{"id": "none", "label": "none"},
|
{"id": "none", "label": "none"},
|
||||||
{"id": "antlers_round", "label": "antlers"},
|
{"id": "antlers_round", "label": "antlers"},
|
||||||
|
{"id": "anteater", "label": "anteater"},
|
||||||
|
{"id": "axolotl", "label": "axolotl"},
|
||||||
|
{"id": "bat", "label": "bat"},
|
||||||
{"id": "bear", "label": "bear"},
|
{"id": "bear", "label": "bear"},
|
||||||
|
{"id": "boar", "label": "boar"},
|
||||||
{"id": "bunny", "label": "bunny"},
|
{"id": "bunny", "label": "bunny"},
|
||||||
{"id": "pointy_long", "label": "long"},
|
{"id": "butterfly", "label": "butterfly"},
|
||||||
{"id": "pointy_short", "label": "short"},
|
{"id": "cat", "label": "cat"},
|
||||||
{"id": "pointy_wide", "label": "wide"},
|
{"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": [
|
"eyes": [
|
||||||
{"id": "simple_shine", "label": "simple shine"},
|
{"id": "simple_shine", "label": "simple shine"},
|
||||||
|
|
@ -163,24 +332,76 @@ const OPTIONS: Dictionary = {
|
||||||
"mouth": [{"id": "three", "label": "three"}],
|
"mouth": [{"id": "three", "label": "three"}],
|
||||||
"tail": [
|
"tail": [
|
||||||
{"id": "none", "label": "none"},
|
{"id": "none", "label": "none"},
|
||||||
|
{"id": "anteater", "label": "anteater"},
|
||||||
|
{"id": "axolotl", "label": "axolotl"},
|
||||||
{"id": "bear", "label": "bear"},
|
{"id": "bear", "label": "bear"},
|
||||||
|
{"id": "bird", "label": "bird"},
|
||||||
|
{"id": "boar", "label": "boar"},
|
||||||
{"id": "bunny", "label": "bunny"},
|
{"id": "bunny", "label": "bunny"},
|
||||||
|
{"id": "butterfly", "label": "butterfly"},
|
||||||
{"id": "cat", "label": "cat"},
|
{"id": "cat", "label": "cat"},
|
||||||
|
{"id": "donkey", "label": "donkey"},
|
||||||
{"id": "fox", "label": "fox"},
|
{"id": "fox", "label": "fox"},
|
||||||
{"id": "gator", "label": "gator"},
|
{"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": "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 = {
|
const LEGACY_OPTION_ALIASES: Dictionary = {
|
||||||
"species": {"default": "round"},
|
"species": {"default": "round"},
|
||||||
|
"arms": {"default": "mammal", "body_arms": "mammal"},
|
||||||
"fur_pattern": {"solid": "white"},
|
"fur_pattern": {"solid": "white"},
|
||||||
"fur_style": {"spots_bengal": "bengal"},
|
"fur_style": {"spots_bengal": "bengal"},
|
||||||
"ears": {"default": "none"},
|
"ears": {
|
||||||
|
"default": "none",
|
||||||
|
"pointy_long": "long",
|
||||||
|
"pointy_short": "cat",
|
||||||
|
"pointy_wide": "fox",
|
||||||
|
},
|
||||||
"tail": {"default": "none"},
|
"tail": {"default": "none"},
|
||||||
"eyes": {"default": "simple_shine"},
|
"eyes": {"default": "simple_shine"},
|
||||||
"nose": {"default": "dog_round"},
|
"nose": {"default": "dog_round"},
|
||||||
"mouth": {"default": "three"},
|
"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
|
static var _feature_assets_ready: bool = false
|
||||||
|
|
@ -198,6 +419,7 @@ static func default_snapshot() -> Dictionary:
|
||||||
_ensure_feature_assets()
|
_ensure_feature_assets()
|
||||||
return {
|
return {
|
||||||
"species": "round",
|
"species": "round",
|
||||||
|
"arms": "mammal",
|
||||||
"scale": DEFAULT_CHARACTER_SCALE,
|
"scale": DEFAULT_CHARACTER_SCALE,
|
||||||
"fur_pattern": "white",
|
"fur_pattern": "white",
|
||||||
"fur_style": DEFAULT_FUR_STYLE,
|
"fur_style": DEFAULT_FUR_STYLE,
|
||||||
|
|
@ -213,6 +435,12 @@ static func default_snapshot() -> Dictionary:
|
||||||
"nose": _default_feature_option("nose", "dog_round"),
|
"nose": _default_feature_option("nose", "dog_round"),
|
||||||
"mouth": _default_feature_option("mouth", "three"),
|
"mouth": _default_feature_option("mouth", "three"),
|
||||||
"tail": "none",
|
"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]
|
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:
|
static func _ensure_feature_assets() -> void:
|
||||||
if _feature_assets_ready:
|
if _feature_assets_ready:
|
||||||
return
|
return
|
||||||
|
|
@ -552,6 +796,38 @@ static func category_label(category_id: String) -> String:
|
||||||
return str(CATEGORY_LABELS.get(category_id, category_id))
|
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:
|
static func is_valid_option(category_id: String, option_id: String) -> bool:
|
||||||
option_id = canonical_option_id(category_id, option_id)
|
option_id = canonical_option_id(category_id, option_id)
|
||||||
if category_id in FUR_COLOR_IDS and is_custom_fur_color_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:
|
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
|
return FUR_STYLE_ARMS_ID
|
||||||
if component_id.begins_with("head_"):
|
if component_id in model_mesh_names("species"):
|
||||||
return FUR_STYLE_HEAD_ID
|
return FUR_STYLE_HEAD_ID
|
||||||
if component_id.begins_with("ears_"):
|
if component_id in model_mesh_names("ears"):
|
||||||
return FUR_STYLE_EARS_ID
|
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_TAIL_ID
|
||||||
return FUR_STYLE_ID
|
return FUR_STYLE_ID
|
||||||
|
|
||||||
|
|
@ -632,22 +911,25 @@ static func fur_component_for_style_field(
|
||||||
FUR_STYLE_ID:
|
FUR_STYLE_ID:
|
||||||
return "body_main"
|
return "body_main"
|
||||||
FUR_STYLE_ARMS_ID:
|
FUR_STYLE_ARMS_ID:
|
||||||
return "body_arms"
|
return model_mesh_name(
|
||||||
|
"arms",
|
||||||
|
str(snapshot.get("arms", "mammal")),
|
||||||
|
)
|
||||||
FUR_STYLE_HEAD_ID:
|
FUR_STYLE_HEAD_ID:
|
||||||
var species_id := canonical_option_id(
|
return model_mesh_name(
|
||||||
"species", str(snapshot.get("species", "round"))
|
"species",
|
||||||
|
str(snapshot.get("species", "round")),
|
||||||
)
|
)
|
||||||
return "head_pointy" if species_id == "pointy" else "head_round"
|
|
||||||
FUR_STYLE_EARS_ID:
|
FUR_STYLE_EARS_ID:
|
||||||
var ears_id := canonical_option_id(
|
return model_mesh_name(
|
||||||
"ears", str(snapshot.get("ears", "none"))
|
"ears",
|
||||||
|
str(snapshot.get("ears", "none")),
|
||||||
)
|
)
|
||||||
return "" if ears_id == "none" else "ears_" + ears_id
|
|
||||||
FUR_STYLE_TAIL_ID:
|
FUR_STYLE_TAIL_ID:
|
||||||
var tail_id := canonical_option_id(
|
return model_mesh_name(
|
||||||
"tail", str(snapshot.get("tail", "none"))
|
"tail",
|
||||||
|
str(snapshot.get("tail", "none")),
|
||||||
)
|
)
|
||||||
return "" if tail_id == "none" else "tails_" + tail_id
|
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -666,9 +948,10 @@ static func fur_pattern_options_for_field(
|
||||||
pattern_id == DEFAULT_FUR_STYLE
|
pattern_id == DEFAULT_FUR_STYLE
|
||||||
or (
|
or (
|
||||||
not component_id.is_empty()
|
not component_id.is_empty()
|
||||||
and _fur_pattern_resource_paths.has(
|
and not _fur_pattern_resource_cache_id(
|
||||||
_fur_pattern_cache_id(pattern_id, component_id)
|
pattern_id,
|
||||||
)
|
component_id,
|
||||||
|
).is_empty()
|
||||||
)
|
)
|
||||||
):
|
):
|
||||||
result.append(option)
|
result.append(option)
|
||||||
|
|
@ -683,7 +966,12 @@ static func fur_pattern_texture(
|
||||||
var canonical_id := canonical_option_id(FUR_STYLE_ID, option_id)
|
var canonical_id := canonical_option_id(FUR_STYLE_ID, option_id)
|
||||||
if canonical_id == DEFAULT_FUR_STYLE:
|
if canonical_id == DEFAULT_FUR_STYLE:
|
||||||
return null
|
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):
|
if _fur_pattern_textures.has(cache_id):
|
||||||
return _fur_pattern_textures[cache_id] as Texture2D
|
return _fur_pattern_textures[cache_id] as Texture2D
|
||||||
var resource_path := str(_fur_pattern_resource_paths.get(cache_id, ""))
|
var resource_path := str(_fur_pattern_resource_paths.get(cache_id, ""))
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ extends Node
|
||||||
|
|
||||||
signal appearance_changed
|
signal appearance_changed
|
||||||
|
|
||||||
const FORMAT_VERSION: int = 1
|
const FORMAT_VERSION: int = 2
|
||||||
var _snapshot: Dictionary = CharacterCustomizationCatalog.default_snapshot()
|
var _snapshot: Dictionary = CharacterCustomizationCatalog.default_snapshot()
|
||||||
var _loaded: bool = false
|
var _loaded: bool = false
|
||||||
var _future_version: bool = false
|
var _future_version: bool = false
|
||||||
|
|
@ -53,9 +53,17 @@ func load_preferences() -> bool:
|
||||||
_future_version = true
|
_future_version = true
|
||||||
_loaded = false
|
_loaded = false
|
||||||
return 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()
|
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
|
_loaded = true
|
||||||
_expected_hash = PortableFileGuard.hash_file(_profile_path)
|
_expected_hash = PortableFileGuard.hash_file(_profile_path)
|
||||||
return true
|
return true
|
||||||
|
|
@ -83,10 +91,9 @@ func restore_from_save_data(
|
||||||
value: Variant,
|
value: Variant,
|
||||||
persist_to_profile_file: bool = true,
|
persist_to_profile_file: bool = true,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
if (
|
if typeof(value) != TYPE_DICTIONARY:
|
||||||
typeof(value) != TYPE_DICTIONARY
|
return false
|
||||||
or not CharacterCustomizationCatalog.validate_snapshot(value)
|
if not CharacterCustomizationCatalog.validate_snapshot(value):
|
||||||
):
|
|
||||||
return false
|
return false
|
||||||
var sanitized: Dictionary = (
|
var sanitized: Dictionary = (
|
||||||
CharacterCustomizationCatalog.sanitized_snapshot(value)
|
CharacterCustomizationCatalog.sanitized_snapshot(value)
|
||||||
|
|
|
||||||
|
|
@ -46,7 +46,7 @@ const PlayerBottleMessageStateType = preload(
|
||||||
"res://messages/player_bottle_message_state.gd"
|
"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 LEGACY_SAVE_FILENAME := "player_save.json"
|
||||||
const ARCHIVE_EXTENSION := ".nfsave"
|
const ARCHIVE_EXTENSION := ".nfsave"
|
||||||
const BASIC_ROD_ID: StringName = &"basic_fishing_rod"
|
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:
|
if typeof(save_data.get("character")) != TYPE_DICTIONARY:
|
||||||
return {}
|
return {}
|
||||||
var character: Dictionary = save_data["character"]
|
var character: Dictionary = save_data["character"]
|
||||||
|
var appearance_value: Variant = character.get("appearance")
|
||||||
if (
|
if (
|
||||||
not NetworkProfilePreferencesType.validate_save_data(
|
not NetworkProfilePreferencesType.validate_save_data(
|
||||||
character.get("profile")
|
character.get("profile")
|
||||||
)
|
)
|
||||||
or typeof(character.get("appearance")) != TYPE_DICTIONARY
|
or typeof(appearance_value) != TYPE_DICTIONARY
|
||||||
or not CharacterCustomizationCatalog.validate_snapshot(
|
or not CharacterCustomizationCatalog.validate_snapshot(appearance_value)
|
||||||
character.get("appearance")
|
|
||||||
)
|
|
||||||
):
|
):
|
||||||
return {}
|
return {}
|
||||||
|
var appearance := CharacterCustomizationCatalog.sanitized_snapshot(
|
||||||
|
appearance_value
|
||||||
|
)
|
||||||
return {
|
return {
|
||||||
"profile": (character["profile"] as Dictionary).duplicate(true),
|
"profile": (character["profile"] as Dictionary).duplicate(true),
|
||||||
"appearance": CharacterCustomizationCatalog.sanitized_snapshot(
|
"appearance": appearance,
|
||||||
character["appearance"]
|
|
||||||
),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1610,6 +1610,8 @@ func _migrate_save(
|
||||||
migrated = _migrate_version_10_to_11(migrated)
|
migrated = _migrate_version_10_to_11(migrated)
|
||||||
11:
|
11:
|
||||||
migrated = _migrate_version_11_to_12(migrated)
|
migrated = _migrate_version_11_to_12(migrated)
|
||||||
|
12:
|
||||||
|
migrated = _migrate_version_12_to_13(migrated)
|
||||||
_:
|
_:
|
||||||
return {}
|
return {}
|
||||||
if migrated.is_empty():
|
if migrated.is_empty():
|
||||||
|
|
@ -1883,6 +1885,23 @@ func _migrate_version_11_to_12(data: Dictionary) -> Dictionary:
|
||||||
return migrated
|
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:
|
func _mark_dirty() -> void:
|
||||||
if (
|
if (
|
||||||
_is_restoring
|
_is_restoring
|
||||||
|
|
|
||||||
|
|
@ -59,7 +59,7 @@ func _validate_player_save_migration() -> void:
|
||||||
{"save_version": 11},
|
{"save_version": 11},
|
||||||
11,
|
11,
|
||||||
)
|
)
|
||||||
assert(int(migrated.get("save_version", -1)) == 12)
|
assert(int(migrated.get("save_version", -1)) == 13)
|
||||||
assert(
|
assert(
|
||||||
migrated.get("message_bottles", {})
|
migrated.get("message_bottles", {})
|
||||||
== PlayerBottleMessageStateType.default_save_data()
|
== 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",
|
||||||
&"walking_show",
|
&"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:
|
func _initialize() -> void:
|
||||||
|
|
@ -57,6 +167,21 @@ func _run() -> void:
|
||||||
) as AnimationPlayer
|
) as AnimationPlayer
|
||||||
assert(skeleton != null)
|
assert(skeleton != null)
|
||||||
assert(animation_player != 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")
|
var socket_index := skeleton.find_bone("rod_socket")
|
||||||
assert(socket_index >= 0)
|
assert(socket_index >= 0)
|
||||||
|
|
@ -79,26 +204,65 @@ func _run() -> void:
|
||||||
"Animation %s lost its authored rod socket pose." % expected,
|
"Animation %s lost its authored rod socket pose." % expected,
|
||||||
)
|
)
|
||||||
|
|
||||||
var arms := character.find_child(
|
var expected_mesh_names := _expected_runtime_mesh_names()
|
||||||
"body_arms",
|
assert(expected_mesh_names.size() == 137)
|
||||||
true,
|
var face_decal_count := 0
|
||||||
false,
|
for head_name: String in EXPECTED_HEAD_MESHES:
|
||||||
) as MeshInstance3D
|
for feature_name: String in FACE_FEATURES:
|
||||||
assert(arms != null)
|
assert(expected_mesh_names.has(
|
||||||
var blended_arm_vertices := _validate_mesh_weights(arms)
|
"%s_%s" % [head_name, feature_name]
|
||||||
assert(blended_arm_vertices > 0, "body_arms has no blended joint weights.")
|
))
|
||||||
var body := character.find_child(
|
face_decal_count += 1
|
||||||
"body_main",
|
assert(face_decal_count == 63)
|
||||||
true,
|
|
||||||
false,
|
var runtime_meshes: Array[MeshInstance3D] = []
|
||||||
) as MeshInstance3D
|
_collect_mesh_instances(character, runtime_meshes)
|
||||||
assert(body != null)
|
assert(runtime_meshes.size() == expected_mesh_names.size())
|
||||||
_validate_mesh_weights(body)
|
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(
|
print(
|
||||||
"Character rig validation: PASS "
|
(
|
||||||
+ "(animations=%d, blended arm vertices=%d)"
|
"Character rig validation: PASS "
|
||||||
% [EXPECTED_ANIMATIONS.size(), blended_arm_vertices]
|
+ "(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()
|
character.queue_free()
|
||||||
quit()
|
quit()
|
||||||
|
|
@ -125,12 +289,39 @@ func _validate_mesh_weights(mesh_instance: MeshInstance3D) -> int:
|
||||||
if weight > 0.000001:
|
if weight > 0.000001:
|
||||||
influence_count += 1
|
influence_count += 1
|
||||||
assert(absf(total - 1.0) <= 0.0001)
|
assert(absf(total - 1.0) <= 0.0001)
|
||||||
assert(influence_count <= 2)
|
assert(influence_count <= 4)
|
||||||
if influence_count == 2:
|
if influence_count > 1:
|
||||||
blended_vertices += 1
|
blended_vertices += 1
|
||||||
return blended_vertices
|
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:
|
func _find_first(parent: Node, class_name_to_find: String) -> Node:
|
||||||
if parent.is_class(class_name_to_find):
|
if parent.is_class(class_name_to_find):
|
||||||
return parent
|
return parent
|
||||||
|
|
|
||||||
|
|
@ -104,7 +104,7 @@ func _run() -> void:
|
||||||
var hotbar_data: Dictionary = parsed["hotbar"]
|
var hotbar_data: Dictionary = parsed["hotbar"]
|
||||||
assert(typeof(hotbar_data.get("fish_slots")) == TYPE_ARRAY)
|
assert(typeof(hotbar_data.get("fish_slots")) == TYPE_ARRAY)
|
||||||
assert(str((hotbar_data["fish_slots"] as Array)[1]) == fish_catch.catch_id)
|
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(
|
assert(not PlayerHomeState.sanitize_save_data(
|
||||||
parsed.get("home", {})
|
parsed.get("home", {})
|
||||||
).is_empty())
|
).is_empty())
|
||||||
|
|
@ -204,7 +204,7 @@ func _run() -> void:
|
||||||
var enet_channel_count: int = NetworkProtocol.ENET_CHANNEL_COUNT
|
var enet_channel_count: int = NetworkProtocol.ENET_CHANNEL_COUNT
|
||||||
var fish_quality_capability: String = NetworkProtocol.FISH_QUALITY_CAPABILITY
|
var fish_quality_capability: String = NetworkProtocol.FISH_QUALITY_CAPABILITY
|
||||||
var showcase_capability: StringName = NetworkFishShowcaseProtocol.CAPABILITY
|
var showcase_capability: StringName = NetworkFishShowcaseProtocol.CAPABILITY
|
||||||
assert(protocol_version == 13)
|
assert(protocol_version == 14)
|
||||||
assert(enet_channel_count == 18)
|
assert(enet_channel_count == 18)
|
||||||
assert(
|
assert(
|
||||||
fish_quality_capability == "fish_quality_v1"
|
fish_quality_capability == "fish_quality_v1"
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,7 @@ func _run() -> void:
|
||||||
NetworkProtocol.FISHING_REPLICATION_CAPABILITY
|
NetworkProtocol.FISHING_REPLICATION_CAPABILITY
|
||||||
)
|
)
|
||||||
var fish_quality_capability: String = NetworkProtocol.FISH_QUALITY_CAPABILITY
|
var fish_quality_capability: String = NetworkProtocol.FISH_QUALITY_CAPABILITY
|
||||||
assert(protocol_version == 13)
|
assert(protocol_version == 14)
|
||||||
assert(enet_channel_count == 18)
|
assert(enet_channel_count == 18)
|
||||||
assert(movement_animation_channel == 11)
|
assert(movement_animation_channel == 11)
|
||||||
assert(
|
assert(
|
||||||
|
|
@ -505,7 +505,7 @@ func _validate_version_four_migration() -> void:
|
||||||
version_four,
|
version_four,
|
||||||
4,
|
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(int((migrated["experience"] as Dictionary)["total_experience"]) == 0)
|
||||||
assert(
|
assert(
|
||||||
is_equal_approx(
|
is_equal_approx(
|
||||||
|
|
|
||||||
|
|
@ -156,9 +156,9 @@ func _run() -> void:
|
||||||
)
|
)
|
||||||
|
|
||||||
var body_texture := _assert_pattern_texture("calico", "body_main")
|
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 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")
|
var tail_texture := _assert_pattern_texture("fox", "tails_fox")
|
||||||
_assert_pattern_texture("tummy", "body_main")
|
_assert_pattern_texture("tummy", "body_main")
|
||||||
_assert_pattern_texture("stripes", "body_main")
|
_assert_pattern_texture("stripes", "body_main")
|
||||||
|
|
@ -186,13 +186,13 @@ func _run() -> void:
|
||||||
skeleton, "body_main", body_texture
|
skeleton, "body_main", body_texture
|
||||||
)
|
)
|
||||||
var arms_material := _assert_mesh_pattern(
|
var arms_material := _assert_mesh_pattern(
|
||||||
skeleton, "body_arms", arms_texture
|
skeleton, "arms_mammal", arms_texture
|
||||||
)
|
)
|
||||||
var head_material := _assert_mesh_pattern(
|
var head_material := _assert_mesh_pattern(
|
||||||
skeleton, "head_round", head_texture
|
skeleton, "head_round", head_texture
|
||||||
)
|
)
|
||||||
var ears_material := _assert_mesh_pattern(
|
var ears_material := _assert_mesh_pattern(
|
||||||
skeleton, "ears_pointy_short", ears_texture
|
skeleton, "ears_cat", ears_texture
|
||||||
)
|
)
|
||||||
var tail_material := _assert_mesh_pattern(
|
var tail_material := _assert_mesh_pattern(
|
||||||
skeleton, "tails_fox", tail_texture
|
skeleton, "tails_fox", tail_texture
|
||||||
|
|
|
||||||
|
|
@ -209,7 +209,7 @@ func _run() -> void:
|
||||||
)
|
)
|
||||||
assert(bool(decoded.get("ok", false)))
|
assert(bool(decoded.get("ok", false)))
|
||||||
var save_data: Dictionary = decoded["data"]
|
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(PlayerJobService.validate_save_data(save_data.get("jobs", {})))
|
||||||
assert(not PlayerHomeState.sanitize_save_data(
|
assert(not PlayerHomeState.sanitize_save_data(
|
||||||
save_data.get("home", {})
|
save_data.get("home", {})
|
||||||
|
|
|
||||||
|
|
@ -143,7 +143,7 @@ func _validate_save_migration() -> void:
|
||||||
version_five,
|
version_five,
|
||||||
5,
|
5,
|
||||||
)
|
)
|
||||||
assert(int(migrated.get("save_version", -1)) == 12)
|
assert(int(migrated.get("save_version", -1)) == 13)
|
||||||
var experience_data: Dictionary = migrated.get("experience", {})
|
var experience_data: Dictionary = migrated.get("experience", {})
|
||||||
assert(int(experience_data.get("total_experience", -1)) == 0)
|
assert(int(experience_data.get("total_experience", -1)) == 0)
|
||||||
var world_data: Dictionary = migrated.get("world", {})
|
var world_data: Dictionary = migrated.get("world", {})
|
||||||
|
|
|
||||||
|
|
@ -177,6 +177,11 @@ func _run_client() -> void:
|
||||||
var service := main.get_node(
|
var service := main.get_node(
|
||||||
"%NetworkProfileService"
|
"%NetworkProfileService"
|
||||||
) as 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 original: Dictionary = service.get_persisted_appearance()
|
||||||
var changed: Dictionary = _changed_appearance(original)
|
var changed: Dictionary = _changed_appearance(original)
|
||||||
assert(service.preview_appearance(changed))
|
assert(service.preview_appearance(changed))
|
||||||
|
|
@ -189,6 +194,11 @@ func _run_client() -> void:
|
||||||
service.apply_finished.connect(func(accepted: bool, message: String) -> void:
|
service.apply_finished.connect(func(accepted: bool, message: String) -> void:
|
||||||
apply_results.append([accepted, message])
|
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(
|
assert(service.apply_profile(
|
||||||
service.get_persisted_name(),
|
service.get_persisted_name(),
|
||||||
changed,
|
changed,
|
||||||
|
|
@ -213,7 +223,6 @@ func _run_client() -> void:
|
||||||
== "trail guide"
|
== "trail guide"
|
||||||
)
|
)
|
||||||
assert(Dictionary(session.get("_local_appearance_snapshot")) == changed)
|
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_voice_id() == "deep")
|
||||||
assert(player.get_animalese_sample_set_id() == "kim")
|
assert(player.get_animalese_sample_set_id() == "kim")
|
||||||
var host_voice_messages: Array[Dictionary] = []
|
var host_voice_messages: Array[Dictionary] = []
|
||||||
|
|
@ -265,18 +274,17 @@ func _run_client() -> void:
|
||||||
|
|
||||||
func _changed_appearance(original: Dictionary) -> Dictionary:
|
func _changed_appearance(original: Dictionary) -> Dictionary:
|
||||||
var changed := original.duplicate(true)
|
var changed := original.duplicate(true)
|
||||||
for category_id: String in CharacterCustomizationCatalog.CATEGORY_IDS:
|
changed["species"] = "snail"
|
||||||
if category_id in CharacterCustomizationCatalog.FUR_STYLE_IDS:
|
changed["arms"] = "avian"
|
||||||
continue
|
changed["ears"] = "owl"
|
||||||
var options: Array = CharacterCustomizationCatalog.options_for(category_id)
|
changed["tail"] = "donkey"
|
||||||
for option: Dictionary in options:
|
changed["special_back"] = "shell"
|
||||||
var option_id: Variant = option.get("id")
|
changed["special_beak"] = "bird"
|
||||||
if option_id != changed.get(category_id):
|
changed["special_head"] = "fin"
|
||||||
changed[category_id] = option_id
|
changed["special_horns"] = "goat"
|
||||||
if CharacterCustomizationCatalog.validate_snapshot(changed):
|
changed["special_tusks"] = "elephant"
|
||||||
return changed
|
changed["special_wings"] = "moth"
|
||||||
changed[category_id] = original.get(category_id)
|
assert(CharacterCustomizationCatalog.validate_snapshot(changed))
|
||||||
assert(false, "No alternate appearance option is available.")
|
|
||||||
return changed
|
return changed
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,33 @@ func _validate_network_payloads() -> void:
|
||||||
var invalid_hello := hello.duplicate(true)
|
var invalid_hello := hello.duplicate(true)
|
||||||
invalid_hello["player_title"] = ""
|
invalid_hello["player_title"] = ""
|
||||||
assert(not NetworkProtocol.validate_client_hello(invalid_hello).is_empty())
|
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 := {
|
var check_request := {
|
||||||
"request_id": "check",
|
"request_id": "check",
|
||||||
|
|
|
||||||
|
|
@ -72,7 +72,7 @@ func _run() -> void:
|
||||||
_assert_opaque(save_path)
|
_assert_opaque(save_path)
|
||||||
var decoded: Dictionary = ProgressionSaveCodec.read_local_save(save_path)
|
var decoded: Dictionary = ProgressionSaveCodec.read_local_save(save_path)
|
||||||
assert(bool(decoded.get("ok", false)))
|
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 = (
|
var saved_character: Dictionary = (
|
||||||
(decoded["data"] as Dictionary).get("character", {})
|
(decoded["data"] as Dictionary).get("character", {})
|
||||||
)
|
)
|
||||||
|
|
@ -292,6 +292,55 @@ func _run() -> void:
|
||||||
assert(bool(unchanged_migration.get("ok", false)))
|
assert(bool(unchanged_migration.get("ok", false)))
|
||||||
assert(not bool(unchanged_migration.get("changed", true)))
|
assert(not bool(unchanged_migration.get("changed", true)))
|
||||||
assert(data_root.root_path == unchanged_root)
|
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(
|
var migrated_root: String = data_root.root_path.get_base_dir().path_join(
|
||||||
"progression-migrated-data"
|
"progression-migrated-data"
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,7 @@ func _run() -> void:
|
||||||
var snapshot_entities_per_envelope: int = (
|
var snapshot_entities_per_envelope: int = (
|
||||||
NetworkWorldSpawnProtocol.SNAPSHOT_ENTITIES_PER_ENVELOPE
|
NetworkWorldSpawnProtocol.SNAPSHOT_ENTITIES_PER_ENVELOPE
|
||||||
)
|
)
|
||||||
assert(protocol_version == 13)
|
assert(protocol_version == 14)
|
||||||
assert(
|
assert(
|
||||||
world_spawn_capability == protocol_world_spawn_capability
|
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 CHECK_DEBOUNCE_SECONDS: float = 0.4
|
||||||
const APPEARANCE_PREVIEW_INTERVAL_SECONDS: float = 0.08
|
const APPEARANCE_PREVIEW_INTERVAL_SECONDS: float = 0.08
|
||||||
const OPTION_GRID_COLUMNS: int = 3
|
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_PALETTE_GRID_COLUMNS: int = 6
|
||||||
const FUR_PATTERN_GRID_COLUMNS: int = 2
|
const FUR_PATTERN_GRID_COLUMNS: int = 2
|
||||||
const FUR_CHANNEL_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_label: Label
|
||||||
var _name_status: Label
|
var _name_status: Label
|
||||||
var _suggestions: HBoxContainer
|
var _suggestions: HBoxContainer
|
||||||
|
var _category_scroll: ScrollContainer
|
||||||
var _category_list: VBoxContainer
|
var _category_list: VBoxContainer
|
||||||
var _option_list: VBoxContainer
|
var _option_list: VBoxContainer
|
||||||
var _preview: ProfilePreview
|
var _preview: ProfilePreview
|
||||||
|
|
@ -107,6 +110,7 @@ var _option_scroll: ScrollContainer
|
||||||
var _active_option_scroll_key: String = ""
|
var _active_option_scroll_key: String = ""
|
||||||
var _option_scroll_positions: Dictionary[String, int] = {}
|
var _option_scroll_positions: Dictionary[String, int] = {}
|
||||||
var _option_scroll_restore_generation: int = 0
|
var _option_scroll_restore_generation: int = 0
|
||||||
|
var _option_focus_keys: Dictionary[String, String] = {}
|
||||||
var _controller_mapping_manager: ControllerMappingManagerType
|
var _controller_mapping_manager: ControllerMappingManagerType
|
||||||
var _controller_zone: ControllerZone = ControllerZone.ACCOUNT
|
var _controller_zone: ControllerZone = ControllerZone.ACCOUNT
|
||||||
var _controller_option_depth: int = 0
|
var _controller_option_depth: int = 0
|
||||||
|
|
@ -448,6 +452,23 @@ func _focus_controller_zone() -> void:
|
||||||
):
|
):
|
||||||
_customize_button.grab_focus()
|
_customize_button.grab_focus()
|
||||||
return
|
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:
|
for control: Control in controls:
|
||||||
var button := control as BaseButton
|
var button := control as BaseButton
|
||||||
if (
|
if (
|
||||||
|
|
@ -805,17 +826,18 @@ func _build_ui() -> void:
|
||||||
"separation", BODY_COLUMN_SEPARATION
|
"separation", BODY_COLUMN_SEPARATION
|
||||||
)
|
)
|
||||||
body_margin.add_child(body)
|
body_margin.add_child(body)
|
||||||
var category_scroll := ScrollContainer.new()
|
_category_scroll = ScrollContainer.new()
|
||||||
category_scroll.name = "CategoryScroll"
|
_category_scroll.name = "CategoryScroll"
|
||||||
category_scroll.custom_minimum_size.x = 120.0
|
_category_scroll.custom_minimum_size.x = 120.0
|
||||||
category_scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
_category_scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
||||||
category_scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
|
_category_scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
|
||||||
category_scroll.vertical_scroll_mode = ScrollContainer.SCROLL_MODE_AUTO
|
_category_scroll.vertical_scroll_mode = ScrollContainer.SCROLL_MODE_AUTO
|
||||||
body.add_child(category_scroll)
|
_category_scroll.follow_focus = true
|
||||||
|
body.add_child(_category_scroll)
|
||||||
_category_list = VBoxContainer.new()
|
_category_list = VBoxContainer.new()
|
||||||
_category_list.custom_minimum_size = Vector2(120, 0)
|
_category_list.custom_minimum_size = Vector2(120, 0)
|
||||||
_category_list.add_theme_constant_override("separation", 5)
|
_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 = VBoxContainer.new()
|
||||||
_option_list.custom_minimum_size = Vector2(326, 0)
|
_option_list.custom_minimum_size = Vector2(326, 0)
|
||||||
_option_list.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
_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.custom_minimum_size.x = 120.0
|
||||||
button.button_pressed = category_id == _category_id
|
button.button_pressed = category_id == _category_id
|
||||||
button.pressed.connect(_select_category.bind(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)
|
UtilityPageStyle.apply_compact_ocean_button(button)
|
||||||
_category_list.add_child(button)
|
_category_list.add_child(button)
|
||||||
_refresh_options()
|
_refresh_options()
|
||||||
|
|
@ -971,6 +996,9 @@ func _refresh_options() -> void:
|
||||||
if _category_id == CharacterCustomizationCatalog.SCALE_CATEGORY_ID:
|
if _category_id == CharacterCustomizationCatalog.SCALE_CATEGORY_ID:
|
||||||
_build_scale_option()
|
_build_scale_option()
|
||||||
return
|
return
|
||||||
|
if _category_id == "special":
|
||||||
|
_build_special_options()
|
||||||
|
return
|
||||||
var options: Array = CharacterCustomizationCatalog.options_for(_category_id)
|
var options: Array = CharacterCustomizationCatalog.options_for(_category_id)
|
||||||
if options.is_empty():
|
if options.is_empty():
|
||||||
var empty := Label.new()
|
var empty := Label.new()
|
||||||
|
|
@ -984,21 +1012,129 @@ func _refresh_options() -> void:
|
||||||
if _category_id in CharacterCustomizationCatalog.FEATURE_CATEGORIES:
|
if _category_id in CharacterCustomizationCatalog.FEATURE_CATEGORIES:
|
||||||
_build_feature_preview_options(options)
|
_build_feature_preview_options(options)
|
||||||
return
|
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:
|
for option: Dictionary in options:
|
||||||
var option_id := str(option["id"])
|
var button := _build_model_option_button(category_id, option)
|
||||||
var button := Button.new()
|
grid.add_child(button)
|
||||||
button.text = "×" if option_id == "none" else str(option["label"])
|
_register_scroll_focus(button, scroll)
|
||||||
button.tooltip_text = "none" if option_id == "none" else str(
|
|
||||||
option["label"]
|
|
||||||
|
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
|
row.add_child(label)
|
||||||
button.custom_minimum_size.x = 170.0
|
var choices := HBoxContainer.new()
|
||||||
button.button_pressed = _draft_appearance.get(_category_id) == option_id
|
choices.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||||
button.pressed.connect(_select_option.bind(_category_id, option_id))
|
choices.add_theme_constant_override("separation", 5)
|
||||||
UtilityPageStyle.apply_compact_ocean_button(button)
|
row.add_child(choices)
|
||||||
if option_id == "none":
|
for option: Dictionary in options:
|
||||||
button.add_theme_font_size_override("font_size", 24)
|
var button := _build_model_option_button(field_id, option)
|
||||||
_option_list.add_child(button)
|
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:
|
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_DISTANCE: float = 1.9
|
||||||
const DEFAULT_CAMERA_PITCH: float = 0.12
|
const DEFAULT_CAMERA_PITCH: float = 0.12
|
||||||
const MIN_CAMERA_DISTANCE: float = 1.05
|
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_ZOOM_STEP: float = 0.14
|
||||||
const CAMERA_TARGET_HEIGHT: float = 0.95
|
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, 2.0, 0.05) var drag_sensitivity: float = 0.012
|
||||||
@export_range(0.1, 4.0, 0.1) var keyboard_speed: float = 1.8
|
@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_yaw: float = 0.0
|
||||||
var _camera_pitch: float = DEFAULT_CAMERA_PITCH
|
var _camera_pitch: float = DEFAULT_CAMERA_PITCH
|
||||||
var _camera_distance: float = DEFAULT_CAMERA_DISTANCE
|
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 _visuals: Node3D
|
||||||
var _controller_mapping_manager: ControllerMappingManagerType
|
var _controller_mapping_manager: ControllerMappingManagerType
|
||||||
var _source_environment: WorldEnvironment
|
var _source_environment: WorldEnvironment
|
||||||
|
|
@ -81,13 +88,15 @@ func _ready() -> void:
|
||||||
func apply_appearance_profile(profile: Dictionary) -> void:
|
func apply_appearance_profile(profile: Dictionary) -> void:
|
||||||
if _visuals != null:
|
if _visuals != null:
|
||||||
PlayerVisualPresenter.apply_appearance(_visuals, profile)
|
PlayerVisualPresenter.apply_appearance(_visuals, profile)
|
||||||
|
_fit_camera_to_visible_parts()
|
||||||
|
|
||||||
|
|
||||||
func reset_view() -> void:
|
func reset_view() -> void:
|
||||||
_preview_root.rotation.y = FRONT_FACING_YAW
|
_preview_root.rotation.y = FRONT_FACING_YAW
|
||||||
_camera_yaw = 0.0
|
_camera_yaw = 0.0
|
||||||
_camera_pitch = DEFAULT_CAMERA_PITCH
|
_camera_pitch = DEFAULT_CAMERA_PITCH
|
||||||
_camera_distance = DEFAULT_CAMERA_DISTANCE
|
_camera_zoom_offset = 0.0
|
||||||
|
_camera_distance = _camera_fit_distance
|
||||||
_apply_camera_orbit()
|
_apply_camera_orbit()
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -150,6 +159,7 @@ func _zoom_preview(amount: float) -> void:
|
||||||
MIN_CAMERA_DISTANCE,
|
MIN_CAMERA_DISTANCE,
|
||||||
MAX_CAMERA_DISTANCE,
|
MAX_CAMERA_DISTANCE,
|
||||||
)
|
)
|
||||||
|
_camera_zoom_offset = _camera_distance - _camera_fit_distance
|
||||||
_apply_camera_orbit()
|
_apply_camera_orbit()
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -159,15 +169,115 @@ func _apply_camera_orbit() -> void:
|
||||||
var horizontal_distance := cos(_camera_pitch) * _camera_distance
|
var horizontal_distance := cos(_camera_pitch) * _camera_distance
|
||||||
_preview_camera.position = Vector3(
|
_preview_camera.position = Vector3(
|
||||||
sin(_camera_yaw) * horizontal_distance,
|
sin(_camera_yaw) * horizontal_distance,
|
||||||
CAMERA_TARGET_HEIGHT + sin(_camera_pitch) * _camera_distance,
|
sin(_camera_pitch) * _camera_distance,
|
||||||
cos(_camera_yaw) * horizontal_distance,
|
cos(_camera_yaw) * horizontal_distance,
|
||||||
)
|
) + _camera_target
|
||||||
_preview_camera.look_at(
|
_preview_camera.look_at(
|
||||||
Vector3(0.0, CAMERA_TARGET_HEIGHT, 0.0),
|
_camera_target,
|
||||||
Vector3.UP,
|
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:
|
func _sync_world_lighting() -> void:
|
||||||
# The customization viewport deliberately does not follow gameplay time of
|
# The customization viewport deliberately does not follow gameplay time of
|
||||||
# day. Keep this method as a compatibility seam for existing setup callers.
|
# day. Keep this method as a compatibility seam for existing setup callers.
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ radius = 1.5
|
||||||
script = ExtResource("3_display")
|
script = ExtResource("3_display")
|
||||||
species_id = "pointy"
|
species_id = "pointy"
|
||||||
fur_color_id = "white"
|
fur_color_id = "white"
|
||||||
ears_id = "pointy_wide"
|
ears_id = "fox"
|
||||||
tail_id = "fox"
|
tail_id = "fox"
|
||||||
eyes_id = "punk_purple"
|
eyes_id = "punk_purple"
|
||||||
nose_id = "triangle_small"
|
nose_id = "triangle_small"
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ radius = 1.5
|
||||||
script = ExtResource("3_display")
|
script = ExtResource("3_display")
|
||||||
species_id = "pointy"
|
species_id = "pointy"
|
||||||
fur_color_id = "teal"
|
fur_color_id = "teal"
|
||||||
ears_id = "pointy_long"
|
ears_id = "long"
|
||||||
tail_id = "fox"
|
tail_id = "fox"
|
||||||
eyes_id = "interested"
|
eyes_id = "interested"
|
||||||
nose_id = "triangle_small"
|
nose_id = "triangle_small"
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,30 @@ const PlayerVisualPresenterType = preload(
|
||||||
"res://player/player_visual_presenter.gd"
|
"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(
|
@export_enum(
|
||||||
"white",
|
"white",
|
||||||
"gray",
|
"gray",
|
||||||
|
|
@ -21,19 +44,54 @@ const PlayerVisualPresenterType = preload(
|
||||||
) var fur_color_id: String = "white"
|
) var fur_color_id: String = "white"
|
||||||
@export_enum(
|
@export_enum(
|
||||||
"none",
|
"none",
|
||||||
"pointy_long",
|
"antlers_round",
|
||||||
"pointy_short",
|
"anteater",
|
||||||
"pointy_wide",
|
"axolotl",
|
||||||
|
"bat",
|
||||||
|
"bear",
|
||||||
|
"boar",
|
||||||
|
"bunny",
|
||||||
|
"butterfly",
|
||||||
|
"cat",
|
||||||
|
"donkey",
|
||||||
|
"elephant",
|
||||||
|
"fox",
|
||||||
|
"goat",
|
||||||
|
"hamster",
|
||||||
|
"horse",
|
||||||
|
"long",
|
||||||
|
"moth",
|
||||||
|
"opossum",
|
||||||
|
"owl",
|
||||||
|
"panda",
|
||||||
|
"pig",
|
||||||
) var ears_id: String = "none"
|
) var ears_id: String = "none"
|
||||||
@export_enum(
|
@export_enum(
|
||||||
"none",
|
"none",
|
||||||
|
"anteater",
|
||||||
|
"axolotl",
|
||||||
"bear",
|
"bear",
|
||||||
|
"bird",
|
||||||
|
"boar",
|
||||||
"bunny",
|
"bunny",
|
||||||
|
"butterfly",
|
||||||
"cat",
|
"cat",
|
||||||
|
"donkey",
|
||||||
"fox",
|
"fox",
|
||||||
"gator",
|
"gator",
|
||||||
|
"horse",
|
||||||
|
"moth",
|
||||||
|
"opossum",
|
||||||
|
"pig",
|
||||||
"pointy",
|
"pointy",
|
||||||
|
"shark",
|
||||||
) var tail_id: String = "none"
|
) 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 eyes_id: String = "simple_shine"
|
||||||
@export var nose_id: String = "dog_round"
|
@export var nose_id: String = "dog_round"
|
||||||
@export var mouth_id: String = "three"
|
@export var mouth_id: String = "three"
|
||||||
|
|
@ -57,9 +115,16 @@ var _locomotion_walking: bool = false
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
var appearance := CharacterCustomizationCatalogType.default_snapshot()
|
var appearance := CharacterCustomizationCatalogType.default_snapshot()
|
||||||
appearance["species"] = species_id
|
appearance["species"] = species_id
|
||||||
|
appearance["arms"] = arms_id
|
||||||
appearance["fur_pattern"] = fur_color_id
|
appearance["fur_pattern"] = fur_color_id
|
||||||
appearance["ears"] = ears_id
|
appearance["ears"] = ears_id
|
||||||
appearance["tail"] = tail_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["eyes"] = eyes_id
|
||||||
appearance["nose"] = nose_id
|
appearance["nose"] = nose_id
|
||||||
appearance["mouth"] = mouth_id
|
appearance["mouth"] = mouth_id
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue