1b1260f371
checkpoint / checkpoint (push) Successful in 1m52s
The blocky body rendered fully black from every angle: _append_box wound its faces clockwise-from-outside, so every computed normal pointed inward -- an inside-out shell, lit from behind on all sides, and the level had ambient light disabled so nothing lifted it. Fixed the winding (normals now verify outward 234:36) and switched test_level's environment to a soft colour ambient so shaded sides read grey, not pure black. Limbs were static because the rig had no animation at all. Added scripts/avatar/limb_animator.gd: a clip-free walk cycle that swings the upper arm/leg bones with ground speed, folds the knees on the rear stride, and eases back to rest when idle. Wired into the local player (speed from velocity) and remote players in game.gd (speed from the per-snapshot authoritative position delta). Verified: avatar smoke 14/14 from source and exported pack; fresh-clone boot PASS; game + menu boot headless clean. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015jtxAiu5dAW4WxCNdGkrkJ
307 lines
12 KiB
GDScript
307 lines
12 KiB
GDScript
extends SceneTree
|
|
## Generates the placeholder avatar asset pack (GLB files) referenced by
|
|
## assets/avatars/catalog.json.
|
|
##
|
|
## These are deliberately crude primitive stand-ins so the avatar loader /
|
|
## customization / in-world rendering can be built and tested now; real art
|
|
## swaps in later as a pure content change (catalog paths are unchanged).
|
|
##
|
|
## The base body is a Roblox-classic (R6) style blocky figure: six chunky box
|
|
## parts (head, torso, two arms, two legs) skinned one-part-per-bone onto the
|
|
## 24-bone canonical rig, arms hanging at the sides, plus a painted-on face.
|
|
##
|
|
## Run: godot --headless --path . --script res://scripts/tools/gen_placeholder_avatars.gd
|
|
## Output: res://assets/avatars/*.glb (13 attachments + 1 rigged base body)
|
|
|
|
const OUT_DIR := "res://assets/avatars/"
|
|
|
|
# Canonical rig -- must match avatar_loader.gd CANONICAL_BONES. Each entry:
|
|
# name -> [parent_name, global_rest_position] (A-pose, arms down, Y-up, metres)
|
|
const BONES := {
|
|
"Root": ["", Vector3(0.00, 0.00, 0.00)],
|
|
"Hips": ["Root", Vector3(0.00, 0.80, 0.00)],
|
|
"Spine": ["Hips", Vector3(0.00, 1.00, 0.00)],
|
|
"Chest": ["Spine", Vector3(0.00, 1.22, 0.00)],
|
|
"Neck": ["Chest", Vector3(0.00, 1.36, 0.00)],
|
|
"Head": ["Neck", Vector3(0.00, 1.44, 0.00)],
|
|
"HeadTop": ["Head", Vector3(0.00, 1.92, 0.00)],
|
|
"Jaw": ["Head", Vector3(0.00, 1.54, 0.14)],
|
|
"LeftEye": ["Head", Vector3(0.12, 1.66, 0.28)],
|
|
"RightEye": ["Head", Vector3(-0.12, 1.66, 0.28)],
|
|
"LeftShoulder": ["Chest", Vector3(0.22, 1.34, 0.00)],
|
|
"LeftUpperArm": ["LeftShoulder", Vector3(0.45, 1.30, 0.00)],
|
|
"LeftLowerArm": ["LeftUpperArm", Vector3(0.45, 1.02, 0.00)],
|
|
"LeftHand": ["LeftLowerArm", Vector3(0.45, 0.74, 0.00)],
|
|
"RightShoulder": ["Chest", Vector3(-0.22, 1.34, 0.00)],
|
|
"RightUpperArm": ["RightShoulder", Vector3(-0.45, 1.30, 0.00)],
|
|
"RightLowerArm": ["RightUpperArm", Vector3(-0.45, 1.02, 0.00)],
|
|
"RightHand": ["RightLowerArm", Vector3(-0.45, 0.74, 0.00)],
|
|
"LeftUpperLeg": ["Hips", Vector3(0.17, 0.78, 0.00)],
|
|
"LeftLowerLeg": ["LeftUpperLeg", Vector3(0.17, 0.40, 0.00)],
|
|
"LeftFoot": ["LeftLowerLeg", Vector3(0.17, 0.05, 0.05)],
|
|
"RightUpperLeg": ["Hips", Vector3(-0.17, 0.78, 0.00)],
|
|
"RightLowerLeg": ["RightUpperLeg", Vector3(-0.17, 0.40, 0.00)],
|
|
"RightFoot": ["RightLowerLeg", Vector3(-0.17, 0.05, 0.05)],
|
|
}
|
|
|
|
# Order matters: parents before children.
|
|
const BONE_ORDER := [
|
|
"Root", "Hips", "Spine", "Chest", "Neck", "Head", "HeadTop", "Jaw",
|
|
"LeftEye", "RightEye",
|
|
"LeftShoulder", "LeftUpperArm", "LeftLowerArm", "LeftHand",
|
|
"RightShoulder", "RightUpperArm", "RightLowerArm", "RightHand",
|
|
"LeftUpperLeg", "LeftLowerLeg", "LeftFoot",
|
|
"RightUpperLeg", "RightLowerLeg", "RightFoot",
|
|
]
|
|
|
|
# Roblox-classic colour blocks.
|
|
const C_SKIN := Color(0.96, 0.80, 0.19) # bright yellow (head + arms)
|
|
const C_SHIRT := Color(0.05, 0.41, 0.67) # bright blue (torso)
|
|
const C_PANTS := Color(0.18, 0.49, 0.30) # bright green (legs)
|
|
const C_FACE := Color(0.05, 0.05, 0.06) # near-black (eyes + smile)
|
|
|
|
# Body parts: [bone, centre, size, colour]. Each part is one rigid box, fully
|
|
# weighted to its bone -- so the rig still drives it, but it reads as a clean
|
|
# solid block (no banding, no capsule taper).
|
|
const PARTS := [
|
|
["Head", Vector3(0.00, 1.62, 0.00), Vector3(0.60, 0.56, 0.56), C_SKIN],
|
|
["Spine", Vector3(0.00, 1.06, 0.00), Vector3(0.74, 0.64, 0.38), C_SHIRT],
|
|
["LeftUpperArm", Vector3(0.45, 1.04, 0.00), Vector3(0.27, 0.66, 0.31), C_SKIN],
|
|
["RightUpperArm", Vector3(-0.45, 1.04, 0.00), Vector3(0.27, 0.66, 0.31), C_SKIN],
|
|
["LeftUpperLeg", Vector3(0.17, 0.39, 0.00), Vector3(0.31, 0.80, 0.33), C_PANTS],
|
|
["RightUpperLeg", Vector3(-0.17, 0.39, 0.00), Vector3(0.31, 0.80, 0.33), C_PANTS],
|
|
# painted-on face -- thin plates on the front of the head (+Z), rigged to Head
|
|
["Head", Vector3(0.12, 1.66, 0.285), Vector3(0.07, 0.10, 0.02), C_FACE],
|
|
["Head", Vector3(-0.12, 1.66, 0.285), Vector3(0.07, 0.10, 0.02), C_FACE],
|
|
["Head", Vector3(0.00, 1.52, 0.285), Vector3(0.26, 0.05, 0.02), C_FACE],
|
|
]
|
|
|
|
# Attachments: id -> [Mesh, local_offset_from_mount_bone, colour]
|
|
# Mesh is built in _attachment_mesh().
|
|
const ATTACHMENTS := {
|
|
"head_cyber_01": ["head", Vector3(0, 0.04, 0.0), Color(0.20, 0.85, 0.95)],
|
|
"hair_mohawk_01": ["mohawk", Vector3(0, 0.0, 0.0), Color(0.90, 0.20, 0.35)],
|
|
"hair_ponytail_01": ["ponytail", Vector3(0, -0.06, -0.16), Color(0.45, 0.30, 0.15)],
|
|
"face_glasses_01": ["glasses", Vector3(0, 0.12, 0.16), Color(0.10, 0.10, 0.12)],
|
|
"chest_bandit_01": ["torso", Vector3(0, -0.12, 0.0), Color(0.55, 0.20, 0.20)],
|
|
"back_backpack_01": ["box", Vector3(0, -0.06, -0.28), Color(0.30, 0.35, 0.25)],
|
|
"pants_cargo_01": ["hips", Vector3(0, -0.04, 0.0), Color(0.30, 0.35, 0.45)],
|
|
"left_hand_glove_01": ["blob", Vector3(0, 0.0, 0.0), Color(0.15, 0.15, 0.18)],
|
|
"right_hand_glove_01": ["blob", Vector3(0, 0.0, 0.0), Color(0.15, 0.15, 0.18)],
|
|
"left_foot_boot_01": ["boot", Vector3(0, -0.02, 0.05), Color(0.12, 0.10, 0.09)],
|
|
"right_foot_boot_01": ["boot", Vector3(0, -0.02, 0.05), Color(0.12, 0.10, 0.09)],
|
|
"accessory_belt_01": ["belt", Vector3(0, 0.80, 0.0), Color(0.20, 0.18, 0.12)],
|
|
"accessory_necklace_01": ["necklace", Vector3(0, 1.30, 0.06), Color(0.85, 0.75, 0.20)],
|
|
}
|
|
|
|
var _fail := 0
|
|
|
|
|
|
func _initialize() -> void:
|
|
var abs_dir := ProjectSettings.globalize_path(OUT_DIR)
|
|
DirAccess.make_dir_recursive_absolute(abs_dir)
|
|
|
|
_gen_base_body()
|
|
for id in ATTACHMENTS:
|
|
var spec: Array = ATTACHMENTS[id]
|
|
_gen_attachment(id, spec[0], spec[1], spec[2])
|
|
|
|
print("\n%s" % ("DONE — all placeholder avatar GLBs written" if _fail == 0
|
|
else "FAILED (%d errors)" % _fail))
|
|
quit(_fail)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Base body -- rigged, skinned
|
|
# ---------------------------------------------------------------------------
|
|
|
|
func _global_rest(bone: String) -> Transform3D:
|
|
return Transform3D(Basis.IDENTITY, BONES[bone][1])
|
|
|
|
|
|
func _gen_base_body() -> void:
|
|
var root := Node3D.new()
|
|
root.name = "AvatarRoot"
|
|
|
|
var skel := Skeleton3D.new()
|
|
skel.name = "Skeleton3D"
|
|
root.add_child(skel)
|
|
|
|
var idx := {}
|
|
for bone in BONE_ORDER:
|
|
var i := skel.add_bone(bone)
|
|
idx[bone] = i
|
|
for bone in BONE_ORDER:
|
|
var parent: String = BONES[bone][0]
|
|
var g := _global_rest(bone)
|
|
if parent == "":
|
|
skel.set_bone_rest(idx[bone], g)
|
|
else:
|
|
skel.set_bone_parent(idx[bone], idx[parent])
|
|
skel.set_bone_rest(idx[bone], _global_rest(parent).affine_inverse() * g)
|
|
for bone in BONE_ORDER:
|
|
skel.set_bone_pose_position(idx[bone], skel.get_bone_rest(idx[bone]).origin)
|
|
|
|
# Skin: one bind per bone, bind pose = inverse global rest.
|
|
var skin := Skin.new()
|
|
for bone in BONE_ORDER:
|
|
skin.add_named_bind(bone, _global_rest(bone).affine_inverse())
|
|
|
|
var bind_index := {}
|
|
for b in range(BONE_ORDER.size()):
|
|
bind_index[BONE_ORDER[b]] = b
|
|
|
|
# One surface per distinct colour so parts stay flat-shaded solids.
|
|
var groups: Dictionary = {} # Color (as key string) -> {color, verts, normals, bones, weights}
|
|
for part in PARTS:
|
|
var bone: String = part[0]
|
|
var centre: Vector3 = part[1]
|
|
var size: Vector3 = part[2]
|
|
var col: Color = part[3]
|
|
var key := col.to_html()
|
|
if not groups.has(key):
|
|
groups[key] = {
|
|
"color": col,
|
|
"verts": PackedVector3Array(),
|
|
"normals": PackedVector3Array(),
|
|
"bones": PackedInt32Array(),
|
|
"weights": PackedFloat32Array(),
|
|
}
|
|
var gg = groups[key]
|
|
_append_box(gg.verts, gg.normals, gg.bones, gg.weights,
|
|
centre, size, bind_index[bone])
|
|
|
|
var mesh := ArrayMesh.new()
|
|
var surf := 0
|
|
for key in groups:
|
|
var gg = groups[key]
|
|
var arr := []
|
|
arr.resize(Mesh.ARRAY_MAX)
|
|
arr[Mesh.ARRAY_VERTEX] = gg.verts
|
|
arr[Mesh.ARRAY_NORMAL] = gg.normals
|
|
arr[Mesh.ARRAY_BONES] = gg.bones
|
|
arr[Mesh.ARRAY_WEIGHTS] = gg.weights
|
|
mesh.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, arr)
|
|
var mat := StandardMaterial3D.new()
|
|
mat.albedo_color = gg.color
|
|
mat.roughness = 0.9
|
|
mesh.surface_set_material(surf, mat)
|
|
surf += 1
|
|
|
|
var mi := MeshInstance3D.new()
|
|
mi.name = "Body"
|
|
mi.mesh = mesh
|
|
mi.skin = skin
|
|
skel.add_child(mi)
|
|
mi.skeleton = mi.get_path_to(skel)
|
|
|
|
_write_glb(root, "base_human_male_01")
|
|
root.free()
|
|
|
|
|
|
# Append an axis-aligned box (centre, size) with every vert weighted 1.0 to bone_idx.
|
|
func _append_box(verts: PackedVector3Array, normals: PackedVector3Array,
|
|
bones: PackedInt32Array, weights: PackedFloat32Array,
|
|
centre: Vector3, size: Vector3, bone_idx: int) -> void:
|
|
var h := size * 0.5
|
|
# 8 corners
|
|
var c: Array[Vector3] = [
|
|
centre + Vector3(-h.x, -h.y, -h.z), centre + Vector3(h.x, -h.y, -h.z),
|
|
centre + Vector3(h.x, -h.y, h.z), centre + Vector3(-h.x, -h.y, h.z),
|
|
centre + Vector3(-h.x, h.y, -h.z), centre + Vector3(h.x, h.y, -h.z),
|
|
centre + Vector3(h.x, h.y, h.z), centre + Vector3(-h.x, h.y, h.z),
|
|
]
|
|
# Vertex order per face is CCW seen from outside -> the computed normal
|
|
# points outward. (Getting this backwards renders the whole mesh as an
|
|
# inside-out black shell: every face is lit from behind.)
|
|
var faces := [
|
|
[3, 2, 6, 7], # +Z front
|
|
[1, 0, 4, 5], # -Z back
|
|
[7, 6, 5, 4], # +Y top
|
|
[0, 1, 2, 3], # -Y bottom
|
|
[2, 1, 5, 6], # +X right
|
|
[3, 7, 4, 0], # -X left
|
|
]
|
|
for f in faces:
|
|
var q: Array[Vector3] = [c[f[0]], c[f[1]], c[f[2]], c[f[3]]]
|
|
var n: Vector3 = (q[1] - q[0]).cross(q[2] - q[0]).normalized()
|
|
for tri in [[0, 1, 2], [0, 2, 3]]:
|
|
for vi in tri:
|
|
verts.append(q[vi])
|
|
normals.append(n)
|
|
bones.append_array([bone_idx, 0, 0, 0])
|
|
weights.append_array([1.0, 0.0, 0.0, 0.0])
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Attachments -- plain static meshes
|
|
# ---------------------------------------------------------------------------
|
|
|
|
func _gen_attachment(id: String, kind: String, offset: Vector3, col: Color) -> void:
|
|
var root := Node3D.new()
|
|
root.name = "AttachmentRoot"
|
|
var mi := MeshInstance3D.new()
|
|
mi.name = id
|
|
mi.mesh = _attachment_mesh(kind)
|
|
mi.position = offset
|
|
var mat := StandardMaterial3D.new()
|
|
mat.albedo_color = col
|
|
mat.roughness = 0.9
|
|
mi.material_override = mat
|
|
root.add_child(mi)
|
|
_write_glb(root, id)
|
|
root.free()
|
|
|
|
|
|
func _attachment_mesh(kind: String) -> Mesh:
|
|
match kind:
|
|
"head":
|
|
var m := BoxMesh.new(); m.size = Vector3(0.60, 0.56, 0.56); return m
|
|
"mohawk":
|
|
var m := PrismMesh.new(); m.size = Vector3(0.08, 0.20, 0.44); return m
|
|
"ponytail":
|
|
var m := BoxMesh.new(); m.size = Vector3(0.14, 0.30, 0.16); return m
|
|
"glasses":
|
|
var m := BoxMesh.new(); m.size = Vector3(0.44, 0.10, 0.03); return m
|
|
"torso":
|
|
var m := BoxMesh.new(); m.size = Vector3(0.80, 0.66, 0.42); return m
|
|
"box":
|
|
var m := BoxMesh.new(); m.size = Vector3(0.34, 0.40, 0.16); return m
|
|
"hips":
|
|
var m := BoxMesh.new(); m.size = Vector3(0.66, 0.34, 0.40); return m
|
|
"blob":
|
|
var m := BoxMesh.new(); m.size = Vector3(0.30, 0.24, 0.34); return m
|
|
"boot":
|
|
var m := BoxMesh.new(); m.size = Vector3(0.34, 0.16, 0.42); return m
|
|
"belt":
|
|
var m := TorusMesh.new(); m.inner_radius = 0.22; m.outer_radius = 0.30; return m
|
|
"necklace":
|
|
var m := TorusMesh.new(); m.inner_radius = 0.10; m.outer_radius = 0.14; return m
|
|
_:
|
|
return BoxMesh.new()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# GLB export
|
|
# ---------------------------------------------------------------------------
|
|
|
|
func _write_glb(scene_root: Node, basename: String) -> void:
|
|
# append_from_scene needs the node in a tree.
|
|
get_root().add_child(scene_root)
|
|
var doc := GLTFDocument.new()
|
|
var state := GLTFState.new()
|
|
var err := doc.append_from_scene(scene_root, state)
|
|
if err != OK:
|
|
push_error("append_from_scene failed for %s: %s" % [basename, error_string(err)])
|
|
_fail += 1
|
|
get_root().remove_child(scene_root)
|
|
return
|
|
var out_path := ProjectSettings.globalize_path("%s%s.glb" % [OUT_DIR, basename])
|
|
err = doc.write_to_filesystem(state, out_path)
|
|
get_root().remove_child(scene_root)
|
|
if err != OK:
|
|
push_error("write_to_filesystem failed for %s: %s" % [basename, error_string(err)])
|
|
_fail += 1
|
|
return
|
|
print(" wrote %s.glb (%d bytes)" % [basename, FileAccess.get_file_as_bytes(out_path).size()])
|