class_name AnimalAppearance extends RefCounted ## Shared Blender meshes, per-character palettes and shape weights; no simulation RNG. const CEL_SHADER := preload("res://world/presentation/materials/character_cel.gdshader") const KIT := preload("res://assets/characters/woodland_parts.glb") const PARTS: Array[StringName] = [ &"Body", &"Head", &"Hair", &"ArmLeft", &"ArmRight", &"LegLeft", &"LegRight", &"Tail" ] static var _meshes: Dictionary[String, Mesh] = {} static func apply_to( root: Node3D, identity: int, npc_body := false, authored: CharacterAppearanceProfile = null ) -> void: _load_kit() var profile := ( authored if authored != null else CharacterAppearanceProfile.for_identity(identity) ) profile = profile.normalized_copy() var material := _palette(profile) root.set_meta("animal_species", CharacterAppearanceProfile.SPECIES[profile.species]) root.set_meta("character_appearance", profile) var species := CharacterAppearanceProfile.PART_SPECIES[profile.species] var arm := "Hiker" if profile.arm_pose == 1 else "Relaxed" var choices := [ "Torso" + CharacterAppearanceProfile.OUTFITS[profile.outfit], "Head" + species, "Hat" + CharacterAppearanceProfile.HATS[profile.hat] if profile.hat > 0 else "", "Arm" + arm + "Left", "Arm" + arm + "Right", "LegLeft", "LegRight", "Tail" + species, ] var height := profile.height var lift := 0.61 * (profile.leg_length - 1.0) * height var shoulder := 0.44 + 0.10 * profile.body_fullness var hip := 0.20 + 0.05 * profile.body_fullness var pivots := [ Vector3(0, 1.05, 0), Vector3(0, 1.68, 0), Vector3(0, 1.68 + 0.22 * profile.head_size, 0), Vector3(-shoulder, 1.30, 0), Vector3(shoulder, 1.30, 0), Vector3(-hip, 0.61, 0), Vector3(hip, 0.61, 0), Vector3(0, 0.80, -0.22), ] for index in PARTS.size(): var target_name := "MeshInstance3D" if npc_body and index == 0 else String(PARTS[index]) var target := root.get_node(target_name) as MeshInstance3D _set_part(target, choices[index], material, profile.body_fullness) target.position = pivots[index] * height + Vector3.UP * lift target.rotation = Vector3.ZERO target.scale = Vector3.ONE * height if index == 1 or index == 2: target.scale *= profile.head_size elif index == 3 or index == 4: target.scale.x *= 1.0 + 0.15 * profile.body_fullness elif index == 5 or index == 6: target.scale.x *= 1.0 + 0.20 * profile.body_fullness target.scale.y *= profile.leg_length target.set_meta("rest_height", target.position.y) var body := root.get_node("MeshInstance3D" if npc_body else "Body") as MeshInstance3D _child_part(body, "Scarf", "Scarf" if profile.scarf else "", material, profile.body_fullness) _child_part( body, "Bag", "Bag" + CharacterAppearanceProfile.BAGS[profile.bag] if profile.bag > 0 else "", material, profile.body_fullness ) var head := root.get_node("Head") as MeshInstance3D var ears := _child_part(head, "Ears", "Ears" + profile.ear_part(), material) ears.position.y = 0.52 ears.scale.y = profile.ear_length _child_part(head, "Spectacles", "Spectacles" if profile.spectacles else "", material) # Derived from imported bounds, including elongated ears and hats. var top := 0.0 for part in PARTS: var path := "MeshInstance3D" if npc_body and part == &"Body" else String(part) top = maxf(top, _top(root.get_node(path), Transform3D.IDENTITY)) root.set_meta("character_top", top) root.set_meta("arm_swing", 0.23 if profile.arm_pose == 1 else 1.0) static func set_profession_color(root: Node3D, color: Color) -> void: var body := root.get_node("MeshInstance3D") as MeshInstance3D var material := body.material_override as ShaderMaterial material.set_shader_parameter("coat_color", color) material.set_shader_parameter("profession_tint", 0.28) static func rest_heights(root: Node3D, npc_body := false) -> Vector3: return Vector3( root.get_node("MeshInstance3D" if npc_body else "Body").get_meta("rest_height"), root.get_node("Head").get_meta("rest_height"), root.get_node("Hair").get_meta("rest_height") ) static func _load_kit() -> void: if not _meshes.is_empty(): return var source := KIT.instantiate() for child in source.find_children("*", "MeshInstance3D", true, false): _meshes[String(child.name)] = (child as MeshInstance3D).mesh source.free() static func _set_part( target: MeshInstance3D, part: String, material: ShaderMaterial, fullness := 0.0 ) -> void: assert(part.is_empty() or _meshes.has(part), "Missing Blender character part: " + part) target.mesh = _meshes.get(part) target.visible = target.mesh != null target.material_override = material if target.mesh != null: var shape := target.find_blend_shape_by_name("Stout") if shape >= 0: target.set_blend_shape_value(shape, fullness) static func _child_part( parent: MeshInstance3D, node_name: String, part: String, material: ShaderMaterial, fullness := 0.0 ) -> MeshInstance3D: var target := parent.get_node_or_null(node_name) as MeshInstance3D if target == null: target = MeshInstance3D.new() target.name = node_name parent.add_child(target) _set_part(target, part, material, fullness) target.transform = Transform3D.IDENTITY return target static func _palette(profile: CharacterAppearanceProfile) -> ShaderMaterial: var material := ShaderMaterial.new() material.shader = CEL_SHADER for channel in ["fur", "muzzle", "outfit", "linen", "scarf", "trousers", "leather"]: var parameter: String = channel + "_color" material.set_shader_parameter(parameter, profile.get(parameter)) return material static func _top(node: Node3D, parent_transform: Transform3D) -> float: var transform := parent_transform * node.transform var top := 0.0 if node is MeshInstance3D and node.mesh != null and node.visible: var bounds: AABB = transform * node.get_aabb() top = bounds.end.y for child in node.get_children(): if child is Node3D: top = maxf(top, _top(child, transform)) return top