feat(characters): add modular woodland traveler kit
This commit is contained in:
@@ -1,34 +1,89 @@
|
||||
class_name AnimalAppearance
|
||||
extends RefCounted
|
||||
## Disposable woodland identities, rebuilt from stable NPC IDs. No simulation RNG.
|
||||
## Shared Blender meshes, per-character palettes and shape weights; no simulation RNG.
|
||||
|
||||
const CEL_SHADER := preload("res://world/presentation/materials/storybook_cel.gdshader")
|
||||
const SPECIES: Array[StringName] = [&"fox", &"rabbit", &"badger", &"otter"]
|
||||
const MODELS: Array[PackedScene] = [
|
||||
preload("res://assets/storybook/fox.glb"),
|
||||
preload("res://assets/storybook/rabbit.glb"),
|
||||
preload("res://assets/storybook/badger.glb"),
|
||||
preload("res://assets/storybook/otter.glb"),
|
||||
]
|
||||
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) -> void:
|
||||
var index := posmod(identity, MODELS.size())
|
||||
var source := MODELS[index].instantiate()
|
||||
var material := ShaderMaterial.new()
|
||||
material.shader = CEL_SHADER
|
||||
root.set_meta("animal_species", SPECIES[index])
|
||||
for part in PARTS:
|
||||
var source_mesh := source.find_child(String(part), true, false) as MeshInstance3D
|
||||
var target_name := "MeshInstance3D" if npc_body and part == &"Body" else String(part)
|
||||
|
||||
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
|
||||
assert(source_mesh != null, "Missing Blender animation part: " + String(part))
|
||||
target.mesh = source_mesh.mesh
|
||||
target.material_override = material
|
||||
source.free()
|
||||
_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:
|
||||
@@ -36,3 +91,71 @@ static func set_profession_color(root: Node3D, color: Color) -> void:
|
||||
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
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
@tool
|
||||
class_name CharacterAppearanceProfile
|
||||
extends Resource
|
||||
## Authored, cosmetic building blocks. Never part of authoritative simulation saves.
|
||||
|
||||
const SPECIES: Array[StringName] = [&"cat", &"fox", &"rabbit", &"badger", &"otter"]
|
||||
const PART_SPECIES: Array[String] = ["Cat", "Fox", "Rabbit", "Badger", "Otter"]
|
||||
const OUTFITS: Array[String] = ["Vest", "Coat", "Tunic"]
|
||||
const EARS: Array[String] = ["", "Cat", "Fox", "Rabbit", "Lop", "Badger", "Otter"]
|
||||
const HATS: Array[String] = ["", "Beret", "Brim"]
|
||||
const BAGS: Array[String] = ["", "Satchel", "Travel"]
|
||||
|
||||
@export_enum("Cat", "Fox", "Rabbit", "Badger", "Otter") var species := 0
|
||||
@export_group("Proportions")
|
||||
@export_range(0.80, 1.15, 0.01) var height := 1.0
|
||||
@export_range(0.0, 1.0, 0.01) var body_fullness := 0.68
|
||||
@export_range(0.88, 1.12, 0.01) var head_size := 1.0
|
||||
@export_range(0.85, 1.15, 0.01) var leg_length := 1.0
|
||||
@export_range(0.65, 1.20, 0.01) var ear_length := 1.0
|
||||
@export_group("Building blocks")
|
||||
@export_enum("Vest", "Long coat", "Work tunic") var outfit := 0
|
||||
@export_enum("Relaxed", "Holding pack straps") var arm_pose := 1
|
||||
@export_enum("Species default", "Cat", "Fox", "Rabbit", "Lop", "Badger", "Otter") var ears := 0
|
||||
@export_enum("None", "Beret", "Brimmed hat") var hat := 0
|
||||
@export_enum("None", "Satchel", "Travel pack") var bag := 2
|
||||
@export var scarf := true
|
||||
@export var spectacles := false
|
||||
@export_group("Palette")
|
||||
@export var fur_color := Color("c78a4e")
|
||||
@export var muzzle_color := Color("eee4d1")
|
||||
@export var outfit_color := Color("496a9d")
|
||||
@export var linen_color := Color("efe6d4")
|
||||
@export var scarf_color := Color("7295bf")
|
||||
@export var trousers_color := Color("7995b6")
|
||||
@export var leather_color := Color("6f4937")
|
||||
|
||||
|
||||
static func for_identity(identity: int) -> CharacterAppearanceProfile:
|
||||
var result := CharacterAppearanceProfile.new()
|
||||
if identity == 0:
|
||||
return result
|
||||
# Integer arithmetic is stable across rebuilds and never consumes an RNG stream.
|
||||
var seed_value := posmod(identity, 100003)
|
||||
result.species = seed_value % SPECIES.size()
|
||||
result.height = 0.85 + float((seed_value * 17) % 29) / 100.0
|
||||
result.body_fullness = float((seed_value * 37) % 101) / 100.0
|
||||
result.head_size = 0.94 + float((seed_value * 7) % 15) / 100.0
|
||||
result.leg_length = 0.90 + float((seed_value * 11) % 22) / 100.0
|
||||
result.ear_length = 0.80 + float((seed_value * 13) % 36) / 100.0
|
||||
result.outfit = (seed_value / 3) % OUTFITS.size()
|
||||
result.arm_pose = 0
|
||||
result.bag = 1 if seed_value % 3 == 0 else 0
|
||||
result.hat = 1 if seed_value % 4 == 0 else 0
|
||||
result.scarf = seed_value % 3 != 1
|
||||
result.spectacles = seed_value % 7 == 0
|
||||
if result.species == 2 and seed_value % 2 == 0:
|
||||
result.ears = 4
|
||||
var furs := [
|
||||
Color("c78a4e"), Color("b77949"), Color("c4b7a4"), Color("c9c4b5"), Color("826c55")
|
||||
]
|
||||
var coats := [Color("496a9d"), Color("798362"), Color("986347"), Color("686b84")]
|
||||
result.fur_color = furs[result.species]
|
||||
result.outfit_color = coats[(seed_value / 5) % coats.size()]
|
||||
result.scarf_color = coats[(seed_value / 7 + 1) % coats.size()].lightened(0.2)
|
||||
return result
|
||||
|
||||
|
||||
func normalized_copy() -> CharacterAppearanceProfile:
|
||||
var result := duplicate() as CharacterAppearanceProfile
|
||||
result.species = clampi(species, 0, SPECIES.size() - 1)
|
||||
result.height = clampf(height, 0.80, 1.15)
|
||||
result.body_fullness = clampf(body_fullness, 0.0, 1.0)
|
||||
result.head_size = clampf(head_size, 0.88, 1.12)
|
||||
result.leg_length = clampf(leg_length, 0.85, 1.15)
|
||||
result.ear_length = clampf(ear_length, 0.65, 1.20)
|
||||
result.outfit = clampi(outfit, 0, OUTFITS.size() - 1)
|
||||
result.arm_pose = clampi(arm_pose, 0, 1)
|
||||
result.ears = clampi(ears, 0, EARS.size() - 1)
|
||||
result.hat = clampi(hat, 0, HATS.size() - 1)
|
||||
result.bag = clampi(bag, 0, BAGS.size() - 1)
|
||||
return result
|
||||
|
||||
|
||||
func ear_part() -> String:
|
||||
return PART_SPECIES[species] if ears == 0 else EARS[ears]
|
||||
@@ -0,0 +1 @@
|
||||
uid://dnrvywwi3t8d1
|
||||
@@ -0,0 +1,57 @@
|
||||
shader_type spatial;
|
||||
render_mode specular_disabled;
|
||||
|
||||
// Blender UV2.x is a discrete palette role. Every part is one opaque surface.
|
||||
uniform vec4 fur_color : source_color = vec4(0.78, 0.54, 0.31, 1.0);
|
||||
uniform vec4 muzzle_color : source_color = vec4(0.93, 0.89, 0.82, 1.0);
|
||||
uniform vec4 outfit_color : source_color = vec4(0.29, 0.42, 0.62, 1.0);
|
||||
uniform vec4 linen_color : source_color = vec4(0.94, 0.90, 0.83, 1.0);
|
||||
uniform vec4 scarf_color : source_color = vec4(0.45, 0.58, 0.75, 1.0);
|
||||
uniform vec4 trousers_color : source_color = vec4(0.47, 0.58, 0.71, 1.0);
|
||||
uniform vec4 leather_color : source_color = vec4(0.44, 0.29, 0.22, 1.0);
|
||||
uniform vec4 coat_color : source_color = vec4(1.0);
|
||||
uniform float profession_tint : hint_range(0.0, 1.0) = 0.0;
|
||||
|
||||
vec3 paint_to_srgb(vec3 linear_color) {
|
||||
return mix(12.92 * linear_color, 1.055 * pow(max(linear_color, vec3(0.0)), vec3(1.0 / 2.4)) - 0.055,
|
||||
step(vec3(0.0031308), linear_color));
|
||||
}
|
||||
|
||||
vec3 paint_to_linear(vec3 srgb_color) {
|
||||
return mix(srgb_color / 12.92, pow((srgb_color + 0.055) / 1.055, vec3(2.4)),
|
||||
step(vec3(0.04045), srgb_color));
|
||||
}
|
||||
|
||||
void fragment() {
|
||||
int role = int(round(UV2.x));
|
||||
vec3 coat = mix(outfit_color.rgb, coat_color.rgb, profession_tint);
|
||||
// glTF vertex paint is linear; Compatibility expects fragment ALBEDO in sRGB.
|
||||
vec3 paint = OUTPUT_IS_SRGB ? paint_to_srgb(COLOR.rgb) : COLOR.rgb;
|
||||
if (role == 1) { paint = fur_color.rgb; }
|
||||
else if (role == 2) { paint = muzzle_color.rgb; }
|
||||
else if (role == 3) { paint = coat; }
|
||||
else if (role == 4) { paint = linen_color.rgb; }
|
||||
else if (role == 5) { paint = scarf_color.rgb; }
|
||||
else if (role == 6) { paint = trousers_color.rgb; }
|
||||
else if (role == 7) { paint = leather_color.rgb; }
|
||||
else if (role == 8) {
|
||||
paint = OUTPUT_IS_SRGB
|
||||
? paint_to_srgb(paint_to_linear(fur_color.rgb) * vec3(0.42, 0.36, 0.31))
|
||||
: fur_color.rgb * vec3(0.42, 0.36, 0.31);
|
||||
}
|
||||
else if (role == 9) {
|
||||
paint = OUTPUT_IS_SRGB ? paint_to_srgb(paint_to_linear(coat) * 0.45) : coat * 0.45;
|
||||
}
|
||||
ALBEDO = paint;
|
||||
ROUGHNESS = 1.0;
|
||||
}
|
||||
|
||||
void light() {
|
||||
float facing = dot(NORMAL, LIGHT);
|
||||
float mid = smoothstep(-0.10, 0.01, facing);
|
||||
float bright = smoothstep(0.48, 0.55, facing);
|
||||
vec3 tone = mix(vec3(0.25, 0.30, 0.37), vec3(0.65, 0.68, 0.71), mid);
|
||||
tone = mix(tone, vec3(1.0, 0.97, 0.88), bright);
|
||||
// Godot multiplies the accumulated diffuse light by ALBEDO after light().
|
||||
DIFFUSE_LIGHT += tone * LIGHT_COLOR * ATTENUATION / PI;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
uid://hkr8f00r8f2d
|
||||
Reference in New Issue
Block a user