Files
modbison/scripts/tools/gen_placeholder_avatars.gd
T
daddy 9731615c34
checkpoint / checkpoint (push) Successful in 1m52s
Roblox-style blocky avatar + actually-visible build stamp
Avatar: rebuild the placeholder base body as a Roblox-classic (R6) figure --
six chunky solid box parts (head, torso, two arms, two legs) skinned one
part per bone onto the same 24-bone canonical rig, arms hanging at the
sides instead of a T-pose, plus a painted-on smile face. Classic
yellow/blue/green "noob" palette, flat single-colour surfaces (the old
capsule-segment chain read as a banded scarecrow). Attachments resized to
match the new proportions; head/hair/glove primitives are now boxes too
(dropped ~130KB spheres to ~3KB each).

Build stamp: it never rendered because `set_anchors_and_offsets_preset()`
plus `.position` on a zero-size Label collapses the rect to negative width.
Switched to a bottom-anchored full-width strip with explicit offsets and
text alignment, brighter colour + black outline. Shows on the main menu
(bottom-right) and in-world HUD (bottom-left) as `build PC3 <commit>`.

Verified: avatar smoke 14/14 from source and against the exported pack;
game.tscn + main_menu.tscn boot headless with no script errors.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015jtxAiu5dAW4WxCNdGkrkJ
2026-09-06 02:00:48 +00:00

304 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),
]
var faces := [
[0, 3, 2, 1], # bottom (-Y)
[4, 5, 6, 7], # top (+Y)
[0, 1, 5, 4], # -Z
[2, 3, 7, 6], # +Z
[1, 2, 6, 5], # +X
[3, 0, 4, 7], # -X
]
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()])