Body step 2.3: view rides the body, mantling and climbing work

- view follows the body (deadzone, smoothed, hard 8 cm limit); no drift over
  six held poses; hanging keeps the view on the character's head
- hold-back measured at the hips, capped leg push while held, stiffer balance
- sticky legs tuck with free-space check (fixes the mantle shove loop)
- strength asymmetric by direction; structural guide strength push-only
- limb/torso friction set before AddChild (Box3D reads it only at creation)
- new phases: pose hold, hang, human-motion mantle, hand-over-hand wall climb

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
FoxyzandClaude Opus 5.5 committed 2026-10-02 09:25:47 +03:00
1 parent 6006c5de98
commit b5f598aae9
6 files changed
+327 -79

No files matched your search

+107 -52
View File
@@ -26,8 +26,11 @@ public partial class PlayerRagdoll : Node3D
// Muscles: PD gain (1/s), max commanded spin (rad/s), torque cap (N·m).
public const float SpineKp = 15f, SpineTorque = 400f;
public const float NeckKp = 20f, NeckTorque = 60f;
public const float ShoulderKp = 20f, ShoulderTorque = 45f; // ~human shoulder flexion strength
public const float ElbowKp = 20f, ElbowTorque = 45f;
// Asymmetric by direction (Nimso; human strength tables): pulling the arm DOWN (lats/back —
// pull-ups, hanging, climbing) is roughly twice as strong as raising it (front delts — a weight
// held out front), and elbow flexion (biceps) beats extension.
public const float ShoulderKp = 20f, ShoulderTorque = 35f, ShoulderPullTorque = 100f;
public const float ElbowKp = 20f, ElbowTorque = 45f, ElbowFlexTorque = 70f;
public const float MuscleMaxSpin = 15f;
// Feed-forwards are derivatives of tracking: low-pass them or tracking jitter (±1–2 mm per
// sample) becomes hundreds of newtons of buzz (RagdollSpike REAL input mode).
@@ -55,25 +58,31 @@ public partial class PlayerRagdoll : Node3D
public const float MaxStepHeight = 0.35f;
public const float MaxSlopeDegrees = 35f; // steeper ground gives no support (Nimso: "slopes beyond an angle can't be run up") // higher ground under the hips isn't stood on unless the body got up there
public const float BracedAirControl = 150f; // N of horizontal hip carry while airborne but braced on the hands (mantling)
public const float CrawlCarry = 300f; // N: the torso lies on the floor in a crawl; knees/hands would push (not simulated)
public const float CrawlCarry = 300f;
public const float BlockedPush = 150f; // N horizontal leg push while the hips are held back // N: the torso lies on the floor in a crawl; knees/hands would push (not simulated)
public const float TorsoFriction = 0.3f; // clothing on floor: lets a crawl slide
public const float LimbFriction = 0.25f;
// Lower-body collider: a box from the hips down to MaxStepHeight above the feet, so knee-high
// obstacles block walking (no walking over tables) while lower steps pass under it. It slides
// up into the torso when crouching and switches off when crawling (Nimso: legs don't snag).
public const float LegsMass = 6f;
public const float LegsKp = 30f, LegsForce = 3000f, LegsTorque = 400f;
public const float LegsOffAbove = 0.25f; // collider disabled once slid this far above the hips
public const float MantleLift = 0.08f; // braced + hips this far above the support goal = being lifted (legs tuck)
public const float MantleLift = 0.03f; // braced + hips this far above the support goal = being lifted (legs tuck)
public const float UntuckDelay = 0.3f; // s the tuck must be unneeded before the collider returns
// Balance: an implicit torque-capped motor from a world anchor to the pelvis (explicit torques
// let the arms' reaction torques set the whole trunk swinging).
public const float BalanceKp = 10f;
public const float BalanceTorque = 400f;
public const float BalanceTorque = 800f;
public const float AirBalanceShare = 0.3f;
// Play-space reconciliation.
public const float CameraRadius = 0.03f; // sphere swept from the head's eye to the camera
public const float FreeSlack = 0.2f; // camera leading the body's eye by more than this = body held back: stick stops pushing that way
public const float AirSlack = 0.05f; // climbing/hanging (braced): the view rides the body tightly
public const float FallSlack = 0.25f; // free airborne: brief ground loss (sliding off a steep slope) mustn't sink the view
public const float FreeSlack = 0.12f; // camera leading the body's eye by more than this = body held back: stick stops pushing that way
public const float ViewDeadzone = 0.06f; // m the head may lead the body before the view is eased back onto it
public const float ViewFollowTau = 0.12f; // s
public const float ClimbViewDeadzone = 0.02f; // hanging/mantling: the view rides the body tightly
public const float ClimbViewFollowTau = 0.03f;
public const float MaxViewLead = 0.08f;
public const float GroundFollowRate = 8f;
public const float ResetDistance = 1.5f; // pelvis this far from its target → snap the whole body to the pose
// Geometry.
@@ -123,6 +132,7 @@ public partial class PlayerRagdoll : Node3D
public float PrevFlexTarget = -1f;
public float FlexFf;
public float GuideCap = HandGuideForce;
public float ShoulderCap = ShoulderTorque, ElbowCap = ElbowTorque;
public PhysicsBody3D Held;
}
@@ -133,6 +143,8 @@ public partial class PlayerRagdoll : Node3D
public bool Grounded { get; private set; }
public float GroundY => _footY;
public bool Braced { get; private set; } // a hand touches or holds static world
public bool LegsSolid => _legsSolid;
public bool Lifted => _lifted;
// Horizontal direction in which the body is held back from the camera (zero if not): the stick
// must not keep moving the play space that way.
public Vector3 HeldBack { get; private set; }
@@ -154,6 +166,15 @@ public partial class PlayerRagdoll : Node3D
private readonly Vector3[] _girdle = new Vector3[2]; // True Body shoulder offset, chest frame
private float _footY;
private float _supportGoalY;
private float _tuckFree, _dt;
private PhysicsShapeQueryParameters3D _legsQuery;
private bool LegsSpaceFree(Vector3 center, Basis basis)
{
_legsQuery ??= new PhysicsShapeQueryParameters3D { Shape = Legs.GetChild<CollisionShape3D>(0).Shape, CollisionMask = WorldMask };
_legsQuery.Transform = new Transform3D(basis, center);
return GetWorld3D().DirectSpaceState.IntersectShape(_legsQuery, 1).Count == 0;
}
private Vector3 _goalVelocity;
private static float LowPass(float dt, float tau) => 1f - Mathf.Exp(-dt / tau);
@@ -206,7 +227,7 @@ public partial class PlayerRagdoll : Node3D
Color skin = new(0.85f, 0.72f, 0.6f), shirt = new(0.3f, 0.45f, 0.7f), pants = new(0.25f, 0.27f, 0.32f);
Pelvis = MakeBody("Pelvis", new BoxShape3D { Size = pelvisSize }, (0.142f + 0.139f + 0.322f) * m - LegsMass, BodyLayer, WorldMask, pants,
c * new Transform3D(Basis.Identity, hip + _pelvisCenterLocal));
c * new Transform3D(Basis.Identity, hip + _pelvisCenterLocal), friction: TorsoFriction);
float standingHip = pose.PelvisTarget.Y - (pose.Eye.Y - p.EyeHeight);
_legsHeight = Mathf.Max(0.2f, standingHip - MaxStepHeight);
Legs = MakeBody("Legs", new BoxShape3D { Size = new Vector3(0.28f * s, _legsHeight, 0.2f * s) }, LegsMass, BodyLayer, WorldMask, pants,
@@ -217,13 +238,11 @@ public partial class PlayerRagdoll : Node3D
Legs.Inertia = new Vector3(_legsHeight * _legsHeight + 0.04f, 0.28f * 0.28f + 0.04f, _legsHeight * _legsHeight + 0.08f) * (LegsMass / 12f);
_footY = pose.Eye.Y - p.EyeHeight;
Chest = MakeBody("Chest", new BoxShape3D { Size = chestSize }, 0.216f * m, BodyLayer, WorldMask, shirt,
c * new Transform3D(Basis.Identity, lumbar + _chestCenterLocal));
c * new Transform3D(Basis.Identity, lumbar + _chestCenterLocal), friction: TorsoFriction);
Head = MakeBody("Head", new BoxShape3D { Size = HeadSize * s }, 0.081f * m, BodyLayer, WorldMask, skin,
c * new Transform3D(Basis.Identity, neckPivot + headCenterFromPivot));
c * new Transform3D(Basis.Identity, neckPivot + headCenterFromPivot), friction: TorsoFriction);
// The neck: a box hanging under the head, same body.
Vector3 neckCenter = (neckBase + neckPivot) * 0.5f - (neckPivot + headCenterFromPivot);
foreach (RigidBody3D torso in new[] { Pelvis, Chest, Head, Legs })
torso.PhysicsMaterialOverride = new PhysicsMaterial { Friction = TorsoFriction };
Head.AddChild(new CollisionShape3D { Shape = new BoxShape3D { Size = new Vector3(0.09f * s, _neckLen, 0.09f * s) }, Position = neckCenter });
MeshInstance3D neckMesh = new() { Mesh = new BoxMesh { Size = new Vector3(0.09f * s, _neckLen, 0.09f * s) }, Position = neckCenter };
neckMesh.SetSurfaceOverrideMaterial(0, new StandardMaterial3D { AlbedoColor = skin, Roughness = 0.6f });
@@ -263,11 +282,11 @@ public partial class PlayerRagdoll : Node3D
string n = side == 0 ? "Left" : "Right";
a.Upper = MakeBody(n + "UpperArm", new BoxShape3D { Size = new Vector3(0.085f * s, a.UpperLen, 0.085f * s) }, 0.028f * m, ArmLayer, WorldMask, shirt,
c * new Transform3D(bone, shoulder + dir * (a.UpperLen * 0.5f)));
c * new Transform3D(bone, shoulder + dir * (a.UpperLen * 0.5f)), friction: LimbFriction);
a.Fore = MakeBody(n + "Forearm", new BoxShape3D { Size = new Vector3(0.07f * s, a.ForeLen - 0.01f, 0.07f * s) }, 0.016f * m, ArmLayer, WorldMask, skin,
c * new Transform3D(bone, elbow + dir * (a.ForeLen * 0.5f)));
c * new Transform3D(bone, elbow + dir * (a.ForeLen * 0.5f)), friction: LimbFriction);
a.Hand = MakeBody(n + "Hand", new BoxShape3D { Size = HandSize }, 0.006f * m, HandLayer, WorldMask | HandLayer,
side == 0 ? new Color(0.9f, 0.3f, 0.35f) : new Color(0.3f, 0.5f, 0.9f), c * new Transform3D(handBasis, handOrigin));
side == 0 ? new Color(0.9f, 0.3f, 0.35f) : new Color(0.3f, 0.5f, 0.9f), c * new Transform3D(handBasis, handOrigin), friction: LimbFriction);
a.Hand.ContinuousCd = true;
a.Hand.ContactMonitor = true;
a.Hand.MaxContactsReported = 8;
@@ -315,9 +334,12 @@ public partial class PlayerRagdoll : Node3D
return new Basis(x, boneDir, x.Cross(boneDir));
}
private RigidBody3D MakeBody(string name, Shape3D shape, float mass, uint layer, uint mask, Color color, Transform3D at, bool visible = true)
// NOTE (Box3D wrapper): friction is read only when the body's shapes are created — the material
// must be set before AddChild (setting it later silently does nothing).
private RigidBody3D MakeBody(string name, Shape3D shape, float mass, uint layer, uint mask, Color color, Transform3D at, bool visible = true, float friction = 1f)
{
RigidBody3D body = new() { Name = name, Mass = mass, CollisionLayer = layer, CollisionMask = mask, Transform = at, ContactMonitor = true, MaxContactsReported = 4 };
RigidBody3D body = new() { Name = name, Mass = mass, CollisionLayer = layer, CollisionMask = mask, Transform = at, ContactMonitor = true, MaxContactsReported = 4,
PhysicsMaterialOverride = new PhysicsMaterial { Friction = friction } };
body.AddChild(new CollisionShape3D { Shape = shape });
if (shape is BoxShape3D box && layer != 0 && visible)
{
@@ -477,6 +499,7 @@ public partial class PlayerRagdoll : Node3D
{
if (!Built)
return;
_dt = dt;
ComputeTargets(s, left, right, physicalArms: true, dt);
foreach (RigidBody3D b in _bodies)
{
@@ -565,6 +588,12 @@ public partial class PlayerRagdoll : Node3D
a.PrevFlexTarget = target;
float w = Mathf.Clamp(a.FlexFf + (target - ElbowFlex(side)) * ElbowKp, -MuscleMaxSpin, MuscleMaxSpin);
a.Elbow.SetParam(HingeJoint3D.Param.MotorTargetVelocity, HingeSign * w);
float cap = (w > 0f ? ElbowFlexTorque : ElbowTorque) * Mathf.Max(0.05f, Strength);
if (Mathf.Abs(cap - a.ElbowCap) > 0.5f)
{
a.ElbowCap = cap;
a.Elbow.SetParam(HingeJoint3D.Param.MotorMaxImpulse, cap);
}
}
private void ApplyStrength()
@@ -576,6 +605,8 @@ public partial class PlayerRagdoll : Node3D
foreach (Arm a in _arms)
{
SetAngularCap(a.Shoulder.Joint, ShoulderTorque * k);
a.ShoulderCap = ShoulderTorque * k;
a.ElbowCap = ElbowTorque * k;
SetAngularCap(a.Guide.Joint, HandGuideTorque * k);
a.Elbow.SetParam(HingeJoint3D.Param.MotorMaxImpulse, ElbowTorque * k);
}
@@ -614,9 +645,18 @@ public partial class PlayerRagdoll : Node3D
Vector3 hip = pelvis * (-_pelvisCenterLocal);
float minCenter = _footY + MaxStepHeight + _legsHeight * 0.5f;
float center = Mathf.Max(hip.Y - _legsHeight * 0.5f, minCenter);
// Tucked: deep crouch/crawl, or being lifted by the arms (mantling — the knee comes up).
_lifted = Braced && Pelvis.GlobalPosition.Y > _supportGoalY + MantleLift;
bool solid = center + _legsHeight * 0.5f < hip.Y + LegsOffAbove && !_lifted;
// Tuck (collider off, folded into the pelvis): deep crouch/crawl, or the hands bracing to
// lift the body (mantle, climbing — the knees come up). It STAYS tucked until it has been
// unneeded for a while AND its untucked spot is free: re-enabling inside a ledge face is
// what shoved the body off again and again (owner 2.2 "legs trying to get off the
// ground and instantly coming back").
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);
_tuckFree = wantTuck ? 0f : _tuckFree + _dt;
Vector3 untucked = new(hip.X, center, hip.Z);
bool solid = _legsSolid ? !wantTuck : _tuckFree > UntuckDelay && LegsSpaceFree(untucked, s.YawBasis);
if (solid != _legsSolid)
{
_legsSolid = solid;
@@ -654,7 +694,17 @@ public partial class PlayerRagdoll : Node3D
d.Ff = d.Ff.Lerp(raw, LowPass(dt, MuscleFeedForwardTau));
d.PrevTarget = target;
d.HasPrev = true;
SetAngularVelocity(d.Joint, (d.Ff + BodyMath.RotationError(target, current) * ShoulderKp).LimitLength(MuscleMaxSpin));
Vector3 spin = (d.Ff + BodyMath.RotationError(target, current) * ShoulderKp).LimitLength(MuscleMaxSpin);
SetAngularVelocity(d.Joint, spin);
// Which way is the muscle moving the arm? Rotating the bone direction downward = pulling.
Vector3 bone = (a.Upper.GlobalTransform.Basis.Y).Normalized();
Vector3 boneMotion = (frameA * spin).Cross(bone);
float cap = (boneMotion.Y < 0f ? ShoulderPullTorque : ShoulderTorque) * Mathf.Max(0.05f, Strength);
if (Mathf.Abs(cap - a.ShoulderCap) > 0.5f)
{
a.ShoulderCap = cap;
SetAngularCap(d.Joint, cap);
}
}
// Force-capped pull of the hand onto the controller, reacting into the chest (arm muscles).
@@ -670,7 +720,10 @@ public partial class PlayerRagdoll : Node3D
Vector3 axis = a.Hand.GlobalPosition - shoulder;
// Axial share of the error, with a 3 cm floor so a nearly-reached target's noisy error
// direction can't flip a held load into "structural".
float align = axis.LengthSquared() > 1e-8f ? Mathf.Abs(err.Dot(axis.Normalized())) / (err.Length() + StructuralDeadband) : 0f;
// PUSH only (the hand wants to go further from the shoulder — straight arms pressing on a
// ledge): bone in compression. Pulling (shortening the arm: pull-ups, climbing) is muscle
// work — the asymmetric shoulder/elbow muscles do it, and one arm can't lift the body.
float align = axis.LengthSquared() > 1e-8f ? Mathf.Max(0f, err.Dot(axis.Normalized())) / (err.Length() + StructuralDeadband) : 0f;
// Structure only helps against something: a hand in free air reaching past its arm's
// length must not haul the whole body after it.
if (a.Held == null && a.Hand.GetContactCount() == 0)
@@ -720,7 +773,7 @@ public partial class PlayerRagdoll : Node3D
// pelvis centre hanging over the lip).
bool clear = point.Y <= _footY + MaxStepHeight || GroundBehindAt(pos, point.Y);
bool reachable = point.Y <= _footY + MaxStepHeight
|| ((!wasGrounded || !_legsSolid || pos.Y - point.Y > 0.6f * hipAboveFloor) && clear);
|| ((!wasGrounded || _lifted || pos.Y - point.Y > 0.6f * hipAboveFloor) && clear);
Grounded = true;
if (reachable)
_footY = point.Y;
@@ -737,6 +790,11 @@ public partial class PlayerRagdoll : Node3D
if (rawVelocity.Length() > 10f)
rawVelocity = Vector3.Zero;
_goalVelocity = _goalVelocity.Lerp(rawVelocity, LowPass(dt, GroundFeedForwardTau));
// Held back = the hips can't get to where the pose wants them (knee-high box, table edge,
// wall). Measured at the hips, not the head: the body tips over an obstacle, so the head
// may be right where the camera is while the hips are stuck.
Vector3 hipLag = new(target.Origin.X - pos.X, 0f, target.Origin.Z - pos.Z);
HeldBack = Grounded && hipLag.Length() > FreeSlack ? hipLag.Normalized() : Vector3.Zero;
Vector3 targetVelocity = _goalVelocity;
float weight = BodyMass * 9.8f;
@@ -750,6 +808,10 @@ public partial class PlayerRagdoll : Node3D
Vector3 force = accel * BodyMass;
float support = Mathf.Clamp(force.Y + weight, 0f, LegStrength * weight);
float carry = Mathf.Max(GroundFriction * support, _legsSolid ? 0f : CrawlCarry);
// Hips held back by an obstacle: lean on it, don't bulldoze — full leg force tipped the
// body over knee-high boxes and steep slopes (and the riding view tipped with it).
if (HeldBack != Vector3.Zero)
carry = Mathf.Min(carry, BlockedPush);
Vector3 horizontal = new Vector3(force.X, 0f, force.Z).LimitLength(carry);
Pelvis.ApplyCentralForce(horizontal + Vector3.Up * support);
}
@@ -782,12 +844,13 @@ public partial class PlayerRagdoll : Node3D
// ---------------------------------------------------------------- play space
// How the driver should move its play-space origin after the last physics step (Bonelab-style
// push-back): a sphere swept from the head body's eye to the camera — if geometry is in the way
// the camera is somewhere the head can't follow (a wall, a bar, a table it walked into), so the
// view is put back at the last free point. With nothing in between, the head may lag or be
// held up to FreeSlack (leaning over a table with the hips blocked) before the view follows.
// Plus: the floor follows the ground under the body; airborne, the view rides the body (falls).
// How the driver should move its play-space origin after the last physics step. The view RIDES
// THE BODY: beyond a small deadzone (normal head motion leads the body a little) the play space
// eases so the camera sits on the character's eye — hanging, mantling, being shoved, standing up
// under a table. On top, a sphere swept eye→camera puts the view straight back when geometry
// is in the way (no peeking through walls). The floor follows the ground under the body.
// Requirement for this not to drift: the body must be able to match the pose (RagdollSpike
// "holds poses without drift"); 2.2's "never follow while standing" left the view behind the body.
public Vector3 OriginCorrection(Vector3 camera, float floorY, float dt)
{
if (!Built)
@@ -803,32 +866,24 @@ public partial class PlayerRagdoll : Node3D
float[] fractions = GetWorld3D().DirectSpaceState.CastMotion(_cameraQuery);
float safe = fractions.Length > 0 ? fractions[0] : 1f;
if (safe < 1f)
{
correction = -lead * (1f - safe);
// Only a climbing body drags the view vertically through geometry; otherwise a
// camera-to-eye line grazing a slope would sink the view (and with it the pose).
if (!Climbing)
correction.Y = 0f;
}
else if (!Grounded)
{
// Free path: the view never chases the body while standing — any pose mismatch
// (a crouch the body can't match exactly) would otherwise walk the play space away
// forever (body → view → pose target → body; owner 2.1 "sliding around").
// Airborne it rides the body: vertically in a fall, fully while climbing.
Vector3 follow = Climbing ? lead : new Vector3(0f, lead.Y, 0f);
float slack = Climbing ? AirSlack : FallSlack;
if (follow.Length() > slack)
correction = -follow * (1f - slack / follow.Length());
}
Vector3 rest = lead + correction;
if (Grounded && !Climbing)
rest.Y = 0f; // standing: height belongs to the ground follow (and the blocked sweep)
float deadzone = Climbing ? ClimbViewDeadzone : ViewDeadzone;
float tau = Climbing ? ClimbViewFollowTau : ViewFollowTau;
if (rest.Length() > deadzone)
correction -= rest * (1f - deadzone / rest.Length()) * (1f - Mathf.Exp(-dt / tau));
// Hard limit: the view is never more than MaxViewLead off the character's head (a
// smoothed follow always trails a sudden drop — letting go with one hand, a fall).
rest = lead + correction;
if (Grounded && !Climbing)
rest.Y = 0f;
if (rest.Length() > MaxViewLead)
correction -= rest * (1f - MaxViewLead / rest.Length());
}
Vector3 flatLead = new(lead.X, 0f, lead.Z);
HeldBack = flatLead.Length() > FreeSlack ? flatLead.Normalized() : Vector3.Zero;
if (Grounded)
{
correction.Y = 0f;
correction.Y += (GroundY - floorY) * (1f - Mathf.Exp(-dt * GroundFollowRate));
}
// The pose target moves with the play space: a correction is not intended motion, so it
// must not show up in ground control's feed-forward (that loop ran away at 7 m/s).
_pelvisTargetPrev += correction;