From 1b1260f3713e09d917bef62a2987110d43553c91 Mon Sep 17 00:00:00 2001 From: daddy Date: Sun, 6 Sep 2026 13:07:27 +0000 Subject: [PATCH] Avatar: fix inside-out black shell + add a procedural walk cycle 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 Claude-Session: https://claude.ai/code/session_015jtxAiu5dAW4WxCNdGkrkJ --- assets/avatars/base_human_male_01.glb | Bin 24908 -> 24908 bytes scenes/worlds/test_level.tscn | 6 +- scripts/avatar/limb_animator.gd | 70 +++++++++++++++++++++++ scripts/game.gd | 16 ++++++ scripts/player.gd | 12 ++++ scripts/tools/gen_placeholder_avatars.gd | 15 +++-- 6 files changed, 110 insertions(+), 9 deletions(-) create mode 100644 scripts/avatar/limb_animator.gd diff --git a/assets/avatars/base_human_male_01.glb b/assets/avatars/base_human_male_01.glb index 4fc93286760f2e2297dccc809937ef5bdbc3c246..0ebe817147cc4b9e087e96311b77687cb8fc9776 100644 GIT binary patch delta 1519 zcmb7EUuaup6epUHTkjWo!xD2#(5IdMfdMUo z?VE}ZMicuh$YfxJB9o4-(3gVgZOz=1Q3Qp77FKbxm%#>B988$!c1i8T4SYC!=lgx% zIltdI_dB=S33NMwm6QZc67Yh|2E5d}%ZB1Mbu^@5I5G?R2!585aLB%t2O!G=R^(Xt zle1^_v9aILl#>z9-}FK~WF-lAC5A&}N8|`LB_2~!1SjP%UYDX+k`DxQEXXpt5{K_) z5q~L+U7Y$%|FCn|cyaBZj$?|5O@-&Y9*XysBzR>AErrKbC5mY^5hS6PV5mgj?8J>; ztcNW*gYycbmEpP~pssM(k%f6Rj8Td_rTXlXs*E|x^|l(v1&uMvu(8K}Qx#Fyh`op6 zs7AG`L&$49-qWHGvav8J^LzofG!o!%_5?K43|`h48Y;trCLpbGSW>BLS{Og5Q3SKT zf*nmpmQM1|eT7$^`5Iq5-NtX(&Hwagj%V<}SO)83MRX>cIDFzL>f?jBdpw8K_>-6! z`{;k*@ztFpH#pqL3*ZYZ;LR5jFiZ~Lm@MQi)z>`;rbsr5PYM0ij3QTHSTw_=x@8Wd zR^ZSyL#Uf(U#*|G_ZVi2 z5uCFaYjtLhrLT^*FaIIy*eZ&cw)g?Bk0NUgW4SnloW`o{LVA6|GO%q8N4(Rg zKi7M~9_ES}JZ~{t8Q!r32o{HhqJTv!jLXF+b}awPT(@K-DcPtUN2$aZrSSIn8#cA3 zMErdeTQ*IKJ%nwWhg6DUr4-{xTzgkTt3(?7-JXW5oxvTOAxk!160m7=n6w3)EYWn> zG##bg=_t{3(8ORay?BTTUbI1Rtk3Y&db;bQrcB5w62wr5rGk@$dN_>AKe{ee=;uJOb8Z^9-TS&; ztHg1x!qBRSU= zy*}t`2SYUe8@oLtU*GRHJ6HPn19u3=t!g~%-}QT0t0kdTGeP>#TtTWvudT+AuZd_^ zc^s%l(W-Jt)_h;&`k63XO~R;!cDuNb8Af#kKUNcPsx*hSA+E=wuP)Xu1Ry*c{2P<1 BOOXHo delta 1745 zcmb7EO=uit7-n@tM&}!L7I)cMX3{LUu(LnbKq4*J%ofp`ccorJXo!L`ky7_dFWgY?jV1r-lD#P?fgl2um0!^ii` z`#$gUKHobtU%M4)w<1e}Im!Rz@@0SUon|@j&Krp8IlP_A>c;T!ubl|b2ADJd8HcEA zuyq-$`cwTu9@9AuF`c1F%gHfZ)a7J%p%Jw6k;$nzt|zdXi^oC_fgju_m8Kp?J0~bN zPJU**-+H0^%E}?X3mf=4pT$r6yli4i;;D_UdW(PRIpvh7Ri$z z`XjmZ&|i-@&K2q4MF~oY^#^&}Eohi7lD!hcD%pHkl=x8gH96k5I9pI~wU~lhJg~@{ za*HpLxLAs#R1{D-ejBeHUc=|lZQ$q9t)BSh@dbQ1@&U$997bbA#qRhJ21inOb3Bj5 z;i>7(Q$^EEgN=!_OPzg@NM%#NcP2|4 z*BdweAFeHx-T5(&jcbk34#pW%gJz1jPwYn(29@?{GtEK*FGR&mp+1%tLWRH0p!Zpm z%Z(FzPe}ek>y2ZXt!LN1`CT_~+R9>cB3qi8`od_8T4f&OgaG@!;&3e$pIQYA|)IdXQ)V~^|6z<OiVZn`J#Z6$g7R43*Oz|04 z(D;QH`Xk>ryD=D%Zn-jKD%v1{mud+t)tDF(x!GUbB(_}vS<21T{xTcp`M|c^0ko;% zv6_G@Wcu6Mf!UQqeRRRB^Fd+H)9}bspw<~)rQNPesMHmdJTV=T3@R{!f)2(SG0%H4 zHoSiP;VB8ak8gM85Hh*NMK6g3PryAdg>t>GGtV&3TVM1BFhgy3JQ1fnh70wy5b7*n Lt$!JTd^GweAVIhp diff --git a/scenes/worlds/test_level.tscn b/scenes/worlds/test_level.tscn index 21b4354..64f2eac 100644 --- a/scenes/worlds/test_level.tscn +++ b/scenes/worlds/test_level.tscn @@ -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"] diff --git a/scripts/avatar/limb_animator.gd b/scripts/avatar/limb_animator.gd new file mode 100644 index 0000000..568acd0 --- /dev/null +++ b/scripts/avatar/limb_animator.gd @@ -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)) diff --git a/scripts/game.gd b/scripts/game.gd index af6cbf5..a54dcba 100644 --- a/scripts/game.gd +++ b/scripts/game.gd @@ -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() diff --git a/scripts/player.gd b/scripts/player.gd index eb78528..6281eeb 100644 --- a/scripts/player.gd +++ b/scripts/player.gd @@ -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 diff --git a/scripts/tools/gen_placeholder_avatars.gd b/scripts/tools/gen_placeholder_avatars.gd index bc9be0f..7089e2d 100644 --- a/scripts/tools/gen_placeholder_avatars.gd +++ b/scripts/tools/gen_placeholder_avatars.gd @@ -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]]]