From 2d986d08ac9802682e5371203a401718f83bcdf7 Mon Sep 17 00:00:00 2001 From: Foxyz Date: Fri, 2 Oct 2026 20:14:11 +0300 Subject: [PATCH] Ground under the body: sphere footprint, clamber by pressure (Nimso), physics objects climbable, arms keep colliding - Ground = centre ray + stance-wide sphere cast (face normal from a ray inside the contact): planks and lips keep support. - Higher ground is stood on when the hips are over it and the body presses onto it (hands braced, a just-finished press, legs tucked over it, or airborne) - no hips-well-above rule. - Bracing counts heavy/frozen physics objects (>= 15 kg), not what the hand carries. - Knee up: braced + hips held back tucks the legs collider. - Support goal capped at straight legs (tiptoe/over-tall head floated the body). - Arms never get collision exceptions with held objects (they disabled whole crag rocks). - Spike: heavy-box mantle, 15 cm plank, chest-high ledge two hands yes / one hand no. Co-Authored-By: Claude Opus 5.5 --- scripts/PlayerRagdoll.cs | 139 +++++++++++++++++++++++++++------------ scripts/RagdollSpike.cs | 85 +++++++++++++++++++++--- 2 files changed, 172 insertions(+), 52 deletions(-) diff --git a/scripts/PlayerRagdoll.cs b/scripts/PlayerRagdoll.cs index de077a9..8925ed0 100644 --- a/scripts/PlayerRagdoll.cs +++ b/scripts/PlayerRagdoll.cs @@ -187,7 +187,7 @@ public partial class PlayerRagdoll : Node3D private float _supportGoalY; private RigidBody3D _groundBody; private Vector3 _groundPoint; - private float _tuckFree, _dt; + private float _tuckFree, _dt, _sinceBraced = float.MaxValue; private PhysicsShapeQueryParameters3D _legsQuery; private bool LegsSpaceFree(Vector3 center, Basis basis) @@ -599,6 +599,7 @@ public partial class PlayerRagdoll : Node3D HandGuide(a, _targets[a.Hand], side == 0 ? leftVelocity : rightVelocity); Braced |= IsBraced(a); } + _sinceBraced = Braced ? 0f : _sinceBraced + dt; GroundControl(pelvisTarget, floorY, dt); HeadGuide(s, dt); LegsDrive(s); @@ -720,15 +721,24 @@ public partial class PlayerRagdoll : Node3D j.SetParamZ(Generic6DofJoint3D.Param.LinearMotorForceLimit, cap); } - // A hand pressing on or holding static world (a ledge, a wall, a grabbed bar) — not the floor: - // hands on the floor while crawling don't lift the body. + // A hand pressing on or holding something solid enough to push yourself up on (a ledge, a wall, + // a grabbed bar, a heavy crate) — not the floor: hands on the floor while crawling don't lift the + // body. Nimso: physics objects are climbed "the same" as the world. A light prop just gets pushed, + // and something you're carrying isn't support. + public const float BraceMinMass = 15f; + public const float ClamberMemory = 0.6f; // s a hand press still counts after the hands leave + private static bool IsBraced(Arm a) { - if (a.Held is StaticBody3D) + if (a.Held is StaticBody3D || a.Held is RigidBody3D { Freeze: true }) return true; foreach (Node3D n in a.Hand.GetCollidingBodies()) - if (n is StaticBody3D && !n.IsInGroup(PropGrabber.NoGrabGroup)) + { + if (n == a.Held || n.IsInGroup(PropGrabber.NoGrabGroup)) + continue; + if (n is StaticBody3D || n is RigidBody3D { Freeze: true } || n is RigidBody3D { Mass: >= BraceMinMass }) return true; + } return false; } @@ -748,7 +758,10 @@ public partial class PlayerRagdoll : Node3D bool climbingHold = _arms[0].Held is StaticBody3D || _arms[1].Held is StaticBody3D; _lifted = Braced && (Pelvis.GlobalPosition.Y > _supportGoalY + MantleLift || climbingHold); bool deep = center + _legsHeight * 0.5f >= hip.Y + LegsOffAbove; - bool wantTuck = deep || _lifted || (!_legsSolid && Braced); + // Hands braced while the hips are held back by an obstacle = getting a knee up onto it (a + // thigh-high crate or ledge the straight arms can't press down on while standing). + bool kneeUp = Braced && HeldBack != Vector3.Zero; + bool wantTuck = deep || _lifted || kneeUp || (!_legsSolid && Braced); _tuckFree = wantTuck ? 0f : _tuckFree + _dt; Vector3 untucked = new(hip.X, center, hip.Z); bool solid = _legsSolid ? !wantTuck : _tuckFree > UntuckDelay && LegsSpaceFree(untucked, s.YawBasis); @@ -845,16 +858,12 @@ public partial class PlayerRagdoll : Node3D { Vector3 pos = Pelvis.GlobalPosition; float hipAboveFloor = target.Origin.Y - floorY; - var query = PhysicsRayQueryParameters3D.Create(pos, pos + Vector3.Down * (hipAboveFloor + GroundProbeExtra + 0.5f), WorldMask); - Godot.Collections.Dictionary hit = GetWorld3D().DirectSpaceState.IntersectRay(query); bool wasGrounded = Grounded; Grounded = false; _groundBody = null; - if (hit.Count > 0) + if (ProbeGround(pos, hipAboveFloor + GroundProbeExtra + 0.5f, wasGrounded, out Vector3 point, out Vector3 normal, out GodotObject groundObject, out bool underHips)) { - Vector3 point = (Vector3)hit["position"]; - Vector3 normal = (Vector3)hit["normal"]; - _groundBody = GodotObject.InstanceFromId((ulong)hit["collider_id"]) as RigidBody3D; // Box3D: "collider" is null + _groundBody = groundObject as RigidBody3D; _groundPoint = point; // Standing (or walking down a step) the legs keep contact over GroundProbeExtra; falling, // they touch down only within their reach — the rest of the fall is stopped by the legs. @@ -873,14 +882,17 @@ public partial class PlayerRagdoll : Node3D } else if (inReach && !steep) { - // Step rule: ground more than a step above the feet is only stood on once the - // hips are already well above it (climbed/mantled/landed there) — otherwise it's an - // obstacle the legs collider holds us back from, and the feet stay where they were. - // Higher ground climbed onto must be under the hips' back edge too (not just a - // pelvis centre hanging over the lip). - bool clear = point.Y <= _footY + MaxStepHeight || GroundBehindAt(pos, point.Y); - bool reachable = point.Y <= _footY + MaxStepHeight - || ((!wasGrounded || _lifted || pos.Y - point.Y > 0.6f * hipAboveFloor) && clear); + // Step rule (Nimso: higher ground "doesn't move my body up … until I push on it with enough + // pressure, at which point I actually clamber over"): ground more than a step above the + // feet is stood on once the hips are over it AND the body is pressing onto it — hands + // braced on anything solid (ledge, crate), or already airborne + // (landing, hanging). Otherwise it's an obstacle the legs collider holds us back from. + // No "hips well above it" requirement: that made a ledge need a full press-up. + bool step = point.Y <= _footY + MaxStepHeight; + // The press lasts a moment after the hands leave (you push up, then move over it), and legs + // tucked up with the hips over it are already on top of it. + bool pressing = !wasGrounded || Braced || _lifted || _sinceBraced < ClamberMemory || !_legsSolid; + bool reachable = step || (underHips && pressing); Grounded = true; if (reachable) _footY = point.Y; @@ -915,6 +927,9 @@ public partial class PlayerRagdoll : Node3D { // Pose heights are relative to the tracking floor; stand on the ground actually under us. Vector3 goal = target.Origin + Vector3.Up * (GroundY - floorY); + // Legs can't hold the hips higher than straight legs (head above the calibrated height — + // tiptoes, a reach up — would otherwise float the body off the ground). + goal.Y = Mathf.Min(goal.Y, _footY + _standingHip + JumpExtension); _supportGoalY = goal.Y; if (_jump == JumpPhase.Dip) goal.Y = Mathf.Min(goal.Y, _jumpFromY); @@ -1070,17 +1085,65 @@ public partial class PlayerRagdoll : Node3D private PhysicsShapeQueryParameters3D _cameraQuery; - // Second ground ray 12 cm behind the hips (toward the body's back): ground at least `groundY`. - private bool GroundBehindAt(Vector3 pelvisPos, float groundY) + // Ground under the body (Nimso: "ground detection uses the objects underneath the body"), not + // under one point: a sphere the width of the stance is cast down from the hips, plus the + // centre ray. One ray lost the ground as soon as the hips swayed off a plank or past a ledge lip. + // The sphere's contact can be a corner, whose normal is useless — the surface normal comes from a + // short ray just inside the contact. Same-level ground (within a step) from either probe is + // stood on; higher ground only from the centre ray (the hips must really be over it), and it's + // reported via underHips for the pressure rule. + public const float FootprintRadius = 0.12f; + + private bool ProbeGround(Vector3 pos, float length, bool wasGrounded, out Vector3 point, out Vector3 normal, out GodotObject collider, out bool underHips) { - Vector3 back = Pelvis.GlobalTransform.Basis.Z; - back.Y = 0f; - back = back.LengthSquared() > 1e-6f ? back.Normalized() * 0.12f : Vector3.Zero; - var query = PhysicsRayQueryParameters3D.Create(pelvisPos + back, pelvisPos + back + Vector3.Down * 3f, WorldMask); - Godot.Collections.Dictionary hit = GetWorld3D().DirectSpaceState.IntersectRay(query); - return hit.Count > 0 && ((Vector3)hit["position"]).Y > groundY - 0.05f; + PhysicsDirectSpaceState3D space = GetWorld3D().DirectSpaceState; + point = normal = Vector3.Zero; + collider = null; + underHips = false; + Godot.Collections.Dictionary ray = space.IntersectRay(PhysicsRayQueryParameters3D.Create(pos, pos + Vector3.Down * length, WorldMask)); + bool haveRay = ray.Count > 0; + if (haveRay) + { + point = (Vector3)ray["position"]; + normal = (Vector3)ray["normal"]; + collider = GodotObject.InstanceFromId((ulong)ray["collider_id"]); // Box3D: "collider" is null + underHips = true; + } + _footprintQuery ??= new PhysicsShapeQueryParameters3D { Shape = new SphereShape3D { Radius = FootprintRadius }, CollisionMask = WorldMask }; + _footprintQuery.Transform = new Transform3D(Basis.Identity, pos); + _footprintQuery.Motion = Vector3.Down * length; + Godot.Collections.Dictionary rest = space.GetRestInfo(_footprintQuery); + if (rest.Count == 0) + return haveRay; + Vector3 contact = (Vector3)rest["point"]; + if (contact.Y >= pos.Y - 0.05f) + return haveRay; // touching a wall beside the hips, not ground + Vector3 inward = new(contact.X - pos.X, 0f, contact.Z - pos.Z); + inward = inward.LengthSquared() > 1e-6f ? inward.Normalized() * 0.02f : Vector3.Zero; + Vector3 probe = contact + inward; + Godot.Collections.Dictionary face = space.IntersectRay(PhysicsRayQueryParameters3D.Create( + new Vector3(probe.X, contact.Y + 0.05f, probe.Z), new Vector3(probe.X, contact.Y - 0.05f, probe.Z), WorldMask)); + if (face.Count == 0) + return haveRay; + Vector3 sPoint = (Vector3)face["position"], sNormal = (Vector3)face["normal"]; + if (sNormal.Y <= Mathf.Cos(Mathf.DegToRad(MaxSlopeDegrees))) + return haveRay; + // The sphere wins where the centre ray found nothing, nothing walkable, or only lower ground + // within the stance (a plank's edge under the hips) — but never higher ground than a step. + bool sameLevel = sPoint.Y <= _footY + MaxStepHeight || !wasGrounded; + bool better = !haveRay || normal.Y <= Mathf.Cos(Mathf.DegToRad(MaxSlopeDegrees)) || sPoint.Y > point.Y + 0.01f; + if (sameLevel && better) + { + point = sPoint; + normal = sNormal; + collider = GodotObject.InstanceFromId((ulong)face["collider_id"]); + underHips = true; // under the footprint + } + return true; } + private PhysicsShapeQueryParameters3D _footprintQuery; + public void TeleportBy(Transform3D delta) { if (!Built) @@ -1118,23 +1181,15 @@ public partial class PlayerRagdoll : Node3D b.ResetPhysicsInterpolation(); } - // Collision exceptions between a held prop and that arm's segments (its hand is excluded by - // the grab weld itself) — a forearm wrapped around a held object must not fight it. + // What each hand holds (bracing, climbing). Only the hand itself is excluded from the held object + // (by the grab weld). public void SetHeld(int side, PhysicsBody3D prop) { if (!Built) return; - Arm a = _arms[side]; - if (a.Held != null && IsInstanceValid(a.Held) && a.Held != _arms[1 - side].Held) - { - a.Upper.RemoveCollisionExceptionWith(a.Held); - a.Fore.RemoveCollisionExceptionWith(a.Held); - } - a.Held = prop; - if (prop != null) - { - a.Upper.AddCollisionExceptionWith(prop); - a.Fore.AddCollisionExceptionWith(prop); - } + // The arm keeps colliding with what the hand holds (owner: real arms don't pass through things). + // The old per-object exception switched the arm off against a whole crag rock while + // gripping one of its holds — arms "randomly" phased through the world mid-climb. + _arms[side].Held = prop; } } diff --git a/scripts/RagdollSpike.cs b/scripts/RagdollSpike.cs index 7ec3a7b..9fcfc1c 100644 --- a/scripts/RagdollSpike.cs +++ b/scripts/RagdollSpike.cs @@ -100,7 +100,10 @@ public partial class RagdollSpike : Node3D _phases.Add(("a table can't be walked over", SetupTableObstacle, _ => _stickVelocity = new Vector3(0f, 0f, -1.5f), 3f, FinishBlockedLow)); _phases.Add(("walks up a 0.25 m step", () => SetupObstacle(0.25f, 8f), _ => _stickVelocity = new Vector3(0f, 0f, -1.2f), 3f, FinishStep)); _phases.Add(("two-handed mantle onto a 0.9 m ledge (crouch, hands on the edge, stand and press)", () => SetupObstacle(0.9f), _ => TickHumanMantle(both: true), 7f, () => FinishMantle(true))); - _phases.Add(("one hand can't mantle", () => SetupObstacle(0.9f), _ => TickHumanMantle(both: false), 7f, () => FinishMantle(false))); + _phases.Add(("two-handed mantle onto a chest-high 1.2 m ledge", () => SetupObstacle(1.2f), _ => TickHumanMantle(both: true), 7f, () => FinishMantle(true))); + _phases.Add(("one hand can't mantle the 1.2 m ledge (the hips must be lifted)", () => SetupObstacle(1.2f), _ => TickHumanMantle(both: false), 7f, () => FinishMantle(false))); + _phases.Add(("mantles onto the playground's heavy box (25 kg physics object, 0.7 m)", SetupCrateLedge, _ => TickHumanMantle(both: true), 8.5f, FinishCrateMantle)); + _phases.Add(("walks a 15 cm plank swaying the hips without falling off", SetupPlank, TickPlank, 6f, FinishPlank)); _phases.Add(("two-handed pull-up on a grabbed bar", () => SetupBarHang(), t => TickPullUp(both: true), 3.5f, () => FinishPullUp(true))); _phases.Add(("one-handed pull-up is weaker", () => SetupBarHang(), t => TickPullUp(both: false), 3.5f, () => FinishPullUp(false))); _phases.Add(("crawls through a 0.6 m tunnel", SetupTunnel, TickCrawl, 7f, FinishCrawl)); @@ -531,9 +534,59 @@ public partial class RagdollSpike : Node3D private void SetupObstacle(float height, float depth = 2f) { Rest(); + _ledgeBox = null; + _ledgeTop = height; + if (height >= 1f) + _origin = new Transform3D(Basis.Identity, new Vector3(0f, 0f, ObstacleFront + 0.4f)); _stage.AddChild(MakeStatic(new Vector3(3f, height, depth), new Vector3(0f, height * 0.5f, ObstacleFront - depth * 0.5f), 29)); } + // Playground.HeavyBox (25 kg, 0.7 m cube) as the ledge, same human mantle as onto the static ledge. + private void SetupCrateLedge() + { + Rest(); + _ledgeBox = MakeBox(Vector3.One * 0.7f, 25f, new Vector3(0f, 0.35f, ObstacleFront - 0.35f)); + _ledgeTop = 0.7f; + } + + private RigidBody3D _ledgeBox; + private float _ledgeTop = 0.9f; + + private bool FinishCrateMantle() + { + GD.Print($"RAGDOLL: crate mantle: pelvis rose up to {_maxPelvisRise:F3} m, feet y {_ragdoll.GroundY:F3}, origin y {_origin.Origin.Y:F3}, snaps {NewSnaps}"); + _maxPelvisRise = 0f; + return _finite && NewSnaps == 0 && _ragdoll.GroundY > 0.6f && _origin.Origin.Y > 0.55f; + } + + private const float PlankY = 0.3f; + private float _plankMinFoot; + + private void SetupPlank() + { + Rest(); + _origin = new Transform3D(Basis.Identity, new Vector3(0f, PlankY, 0f)); + _stage.AddChild(MakeStatic(new Vector3(0.15f, PlankY, 8f), new Vector3(0f, PlankY * 0.5f, -3f), 29)); + _plankMinFoot = float.MaxValue; + } + + // Walk along it with the head (and so the hips) swaying ±9 cm side to side. + private void TickPlank(float dt) + { + float sway = _t > 1f ? 0.09f * Mathf.Sin(Mathf.Tau * 0.7f * (_t - 1f)) : 0f; + _headLocal = new Transform3D(Basis.Identity, new Vector3(sway, EyeHeight, 0f)); + _stickVelocity = _t > 1f && _t < 5f ? new Vector3(0f, 0f, -0.6f) : Vector3.Zero; + _measureFrom = float.MaxValue; + if (_ragdoll.Built && !_needBuild && _t > 0.5f) + _plankMinFoot = Mathf.Min(_plankMinFoot, _ragdoll.Grounded ? _ragdoll.GroundY : -1f); + } + + private bool FinishPlank() + { + GD.Print($"RAGDOLL: plank: lowest supported foot level {_plankMinFoot:F3} (plank top {PlankY}), pelvis {_ragdoll.Pelvis.GlobalPosition}, eye error {_ragdoll.Eye.DistanceTo(Camera.Origin):F3}, snaps {NewSnaps}"); + return _finite && NewSnaps == 0 && _plankMinFoot > PlankY - 0.03f && _ragdoll.Pelvis.GlobalPosition.Z < -1.8f; + } + private void SetupTableObstacle() { Rest(); @@ -562,7 +615,7 @@ public partial class RagdollSpike : Node3D private bool FinishMantle(bool both) { GD.Print($"RAGDOLL: mantle ({(both ? "two hands" : "one hand")}): pelvis rose up to {_maxPelvisRise:F3} m, feet y {_ragdoll.GroundY:F3}, origin y {_origin.Origin.Y:F3}, snaps {NewSnaps}"); - bool onTop = _ragdoll.GroundY > 0.85f && _origin.Origin.Y > 0.8f; + bool onTop = _ragdoll.GroundY > _ledgeTop - 0.05f && _origin.Origin.Y > _ledgeTop - 0.1f; _lastRise = _maxPelvisRise; _maxPelvisRise = 0f; return _finite && NewSnaps == 0 && (both ? onTop : !onTop); // standing tall also lifts the hips; one hand never gets you up @@ -949,20 +1002,32 @@ public partial class RagdollSpike : Node3D private void TickHumanMantle(bool both) { float t = _t; - _stickVelocity = t < 1f || (t > 4.5f && t < 5.5f) ? new Vector3(0f, 0f, -0.8f) : Vector3.Zero; // stop before the ledge's far end - float crouch = Mathf.SmoothStep(0f, 1f, Mathf.Clamp((t - 1f) / 0.8f, 0f, 1f)); + // Walk up to a waist-high ledge (stop before its far end); a chest-high one starts within reach. + _stickVelocity = (t < 1f && _ledgeTop < 1f) || (t > 4.5f && t < 5.5f) ? new Vector3(0f, 0f, -0.8f) : Vector3.Zero; + // Crouch to load the legs for a waist-high ledge; at a chest-high one you reach up instead. + float crouch = Mathf.SmoothStep(0f, 1f, Mathf.Clamp((t - 1f) / 0.8f, 0f, 1f)) * (_ledgeTop < 1f ? 1f : 0f); float rise = Mathf.SmoothStep(0f, 1f, Mathf.Clamp((t - 2.2f) / 1.6f, 0f, 1f)); float eye = EyeHeight - 0.25f * crouch + 0.35f * rise; - float lean = 0.25f * rise; + float lean = (_ledgeTop < 1f ? 0.25f : 0.55f) * rise; // over a chest-high edge you put your head well over it _headLocal = new Transform3D(new Basis(Vector3.Right, -0.3f * crouch), new Vector3(0f, eye, -lean)); - float handZ = ObstacleFront - 0.08f - _origin.Origin.Z; - float handY = t < 1f ? 1.0f : Mathf.Lerp(0.95f, 0.55f, rise) - 0.05f * crouch; + // Hands on the top just past the near edge (of the box itself when it's a movable one). + float front = _ledgeBox != null ? _ledgeBox.GlobalPosition.Z + 0.35f : ObstacleFront; + float handZ = front - 0.08f - _origin.Origin.Z; + float handY = t < 1f ? 1.0f : Mathf.Lerp(_ledgeTop + 0.05f, _ledgeTop - 0.35f, rise) - 0.05f * crouch - _origin.Origin.Y; Basis down = Aim(Vector3.Down + Vector3.Forward * 0.3f); - _leftLocal = both ? new Transform3D(down, new Vector3(-0.2f, handY, handZ)) : new Transform3D(Basis.Identity, new Vector3(-0.2f, 1.0f, -0.3f)); - _rightLocal = new Transform3D(down, new Vector3(0.2f, handY, handZ)); + // Walking up, the hands are down at the sides; then they go up over the edge onto the top. + float reach = Mathf.Clamp((t - 0.8f) / 0.4f, 0f, 1f); + Vector3 rest = new(0.2f, 1.0f, -0.3f), onTop = new(0.2f, Mathf.Max(handY, _ledgeTop + 0.08f - _origin.Origin.Y), handZ); + Vector3 r = reach < 1f ? rest.Lerp(new Vector3(0.2f, _ledgeTop + 0.12f - _origin.Origin.Y, -0.3f), reach) : new Vector3(0.2f, handY, handZ); + if (reach >= 1f && t < 1.4f) + r = onTop; + _leftLocal = both ? new Transform3D(down, new Vector3(-r.X, r.Y, r.Z)) : new Transform3D(Basis.Identity, new Vector3(-0.2f, 1.0f, -0.3f)); + _rightLocal = new Transform3D(down, r); if (_ragdoll.Built && !_needBuild) _maxPelvisRise = Mathf.Max(_maxPelvisRise, _ragdoll.Pelvis.GlobalPosition.Y - 0.99f); - if (t > 4.5f) + if (OS.HasEnvironment("MANTLETRACE") && _ragdoll.Built && ((int)(t * 144f)) % 14 == 0) + GD.Print($"MT t {t:F2} pelvis {_ragdoll.Pelvis.GlobalPosition} v {_ragdoll.Pelvis.LinearVelocity.Y:F2} o {_origin.Origin.Y:F2} j {_ragdoll.Jumping} braced {_ragdoll.Braced} lifted {_ragdoll.Lifted} legs {_ragdoll.LegsSolid} g {_ragdoll.Grounded} foot {_ragdoll.GroundY:F2} hands {_ragdoll.Hand(0).GlobalPosition.Y:F2}/{_ragdoll.Hand(1).GlobalPosition.Y:F2} {(_stage.GetChildCount() > 0 && _stage.GetChild(0) is RigidBody3D rb ? rb.GlobalPosition.ToString() : "")}"); + if (t > (_ledgeTop < 1f ? 4.5f : 5.5f)) { _leftLocal = new Transform3D(Basis.Identity, new Vector3(-0.2f, 1.0f, -0.3f)); _rightLocal = new Transform3D(Basis.Identity, new Vector3(0.2f, 1.0f, -0.3f));