Avatar: fix inside-out black shell + add a procedural walk cycle
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
This commit is contained in:
2026-09-06 13:07:27 +00:00
parent 9731615c34
commit 1b1260f371
6 changed files with 110 additions and 9 deletions
Binary file not shown.
+3 -3
View File
@@ -47,9 +47,9 @@ subdivisions_height = 8
[sub_resource type="Environment" id="WorldEnvironment_env"]
background_mode = 1
background_color = Color(0.4, 0.6, 0.8, 1)
ambient_light_source = 1
ambient_light_energy = 0.4
ambient_light_sky_energy = 1.0
ambient_light_source = 2
ambient_light_color = Color(0.55, 0.6, 0.68, 1)
ambient_light_energy = 0.5
[node name="TestLevel" type="Node3D"]
+70
View File
@@ -0,0 +1,70 @@
extends Node
## Procedural locomotion for the placeholder blocky avatar.
##
## There are no animation clips yet -- this just swings the upper arm/leg bones
## around their local X by a sine wave whose phase advances with distance
## travelled, bends the knees on the rear half of the stride, and eases the
## whole thing back to the rest pose when the character stops.
##
## The owner sets `speed` (horizontal m/s) every frame; setup() is given the
## avatar's Skeleton3D. Bones are addressed by canonical name, so this works
## for the local player and for snapshot-driven remote players alike.
const STRIDE := 0.7 ## peak hip/shoulder swing, radians
const KNEE := 1.0 ## peak knee bend, radians
const CYCLE := 1.9 ## phase (radians) advanced per metre travelled
const BLEND := 12.0 ## ease rate toward the target pose
const MOVE_EPS := 0.35 ## m/s below which we treat the avatar as idle
var speed: float = 0.0
var _skel: Skeleton3D = null
var _idx := {} # bone name -> skeleton index
var _phase := 0.0
var _amount := 0.0 # 0 = idle, 1 = full walk
const _BONES := [
"LeftUpperLeg", "RightUpperLeg", "LeftLowerLeg", "RightLowerLeg",
"LeftUpperArm", "RightUpperArm",
]
func setup(skeleton: Skeleton3D) -> void:
_skel = skeleton
_idx.clear()
if skeleton == null:
return
for b in _BONES:
var i := skeleton.find_bone(b)
if i != -1:
_idx[b] = i
func _process(delta: float) -> void:
if _skel == null or _idx.is_empty():
return
var moving := speed > MOVE_EPS
_amount = lerpf(_amount, 1.0 if moving else 0.0, clampf(BLEND * delta, 0.0, 1.0))
_phase += maxf(speed, 0.0) * delta * CYCLE
if _phase > TAU:
_phase -= TAU
var s := sin(_phase)
var swing := s * STRIDE * _amount
_pitch("LeftUpperLeg", swing)
_pitch("RightUpperLeg", -swing)
_pitch("LeftUpperArm", -swing)
_pitch("RightUpperArm", swing)
# Knee folds only while that leg is swinging back (negative pitch phase).
_pitch("LeftLowerLeg", maxf(0.0, -s) * KNEE * _amount)
_pitch("RightLowerLeg", maxf(0.0, s) * KNEE * _amount)
func _pitch(bone: String, angle: float) -> void:
if not _idx.has(bone):
return
var i: int = _idx[bone]
var rest_q := _skel.get_bone_rest(i).basis.get_rotation_quaternion()
_skel.set_bone_pose_rotation(i, rest_q * Quaternion(Vector3.RIGHT, angle))
+16
View File
@@ -24,6 +24,7 @@ var _net: NetworkClient = null
var _networked := false
var _aligned := false # snapped to the server spawn yet?
var _remotes: Dictionary = {} # peer_id -> Node3D
var _remote_prev: Dictionary = {} # peer_id -> Vector3 (last snapshot position)
var _status_label: Label = null
@@ -132,10 +133,19 @@ func _sync_remotes(states: Dictionary, delta: float) -> void:
node.global_position = node.global_position.lerp(target, clampf(REMOTE_LERP * delta, 0.0, 1.0))
node.rotation.y = st.get("rotation_y", node.rotation.y)
# Drive the remote's walk cycle from how far its authoritative
# position moved this snapshot.
var prev: Vector3 = _remote_prev.get(pid, target)
var anim := node.get_node_or_null("LimbAnim")
if anim != null and delta > 0.0:
anim.set("speed", (target - prev).length() / delta)
_remote_prev[pid] = target
for pid in _remotes.keys():
if not states.has(pid):
_remotes[pid].queue_free()
_remotes.erase(pid)
_remote_prev.erase(pid)
func _make_remote(pid: int) -> Node3D:
@@ -157,6 +167,12 @@ func _make_remote(pid: int) -> Node3D:
m.albedo_color = tint
mi.material_override = m
root.add_child(visual)
var skel := visual.find_child("Skeleton3D", true, false)
if skel is Skeleton3D:
var anim := preload("res://scripts/avatar/limb_animator.gd").new()
anim.name = "LimbAnim"
root.add_child(anim)
anim.call("setup", skel)
else:
var body := MeshInstance3D.new()
var capsule := CapsuleMesh.new()
+12
View File
@@ -42,6 +42,7 @@ const PITCH_MAX: float = 0.45
@onready var _capsule_mesh: MeshInstance3D = $CapsuleMesh
var _avatar_view: Node3D = null
var _limb_anim: Node = null
# ---------------------------------------------------------------------------
@@ -107,6 +108,14 @@ func _apply_avatar() -> void:
if _capsule_mesh:
_capsule_mesh.visible = false
# Procedural walk cycle -- no clips yet, just swing the limb bones with
# ground speed.
var skel := avatar.find_child("Skeleton3D", true, false)
if skel is Skeleton3D:
_limb_anim = preload("res://scripts/avatar/limb_animator.gd").new()
add_child(_limb_anim)
_limb_anim.call("setup", skel)
# ---------------------------------------------------------------------------
# _unhandled_input — capture keyboard / mouse for movement
@@ -194,6 +203,9 @@ func _physics_process(delta: float) -> void:
move_and_slide()
if _limb_anim != null:
_limb_anim.set("speed", Vector2(velocity.x, velocity.z).length())
# Safety net -- never fall forever if something goes wrong.
if global_position.y < -10.0:
global_position = _spawn_position
+9 -6
View File
@@ -211,13 +211,16 @@ func _append_box(verts: PackedVector3Array, normals: PackedVector3Array,
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 := [
[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
[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]]]