Files
gamedev-the-steward/world/presentation/AnimalAppearance.gd
T
2026-09-05 22:54:54 +02:00

162 lines
5.8 KiB
GDScript

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