diff --git a/assets/avatars/base_human_male_01.glb b/assets/avatars/base_human_male_01.glb index 4fc9328..0ebe817 100644 Binary files a/assets/avatars/base_human_male_01.glb and b/assets/avatars/base_human_male_01.glb differ 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]]]