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

+188 -26
View File
@@ -94,13 +94,16 @@ public partial class RagdollSpike : Node3D
_phases.Add(("a knee-high box blocks walking (no stepping onto it)", () => SetupObstacle(0.6f), _ => _stickVelocity = new Vector3(0f, 0f, -1.5f), 3f, FinishBlockedLow));
_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", () => SetupObstacle(0.9f), t => TickMantle(both: true), 6f, () => FinishMantle(true)));
_phases.Add(("one hand can't mantle", () => SetupObstacle(0.9f), t => TickMantle(both: false), 6f, () => FinishMantle(false)));
_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 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));
_phases.Add(("holds a crouch (eyes 1.0 m) without sliding or spinning", Rest, t => TickCrouchHold(1.0f), 5f, FinishCrouchHold));
_phases.Add(("holds a deep crouch (eyes 0.7 m) without sliding or spinning", Rest, t => TickCrouchHold(0.7f), 5f, FinishCrouchHold));
_phases.Add(("holds poses (stand, crouch, deep crouch, lean, reach, kneel) without view/body drift", Rest, TickPoseHold, 18f, FinishPoseHold));
_phases.Add(("hanging from a bar keeps the view on the body", () => SetupBarHang(), TickHang, 4f, FinishHang));
_phases.Add(("climbs a wall hand over hand (grab, pull, regrab higher)", SetupClimbWall, TickClimbWall, 6.5f, FinishClimbWall));
_phases.Add(("walks off a 1 m ledge and lands", SetupLedgeTop, _ => _stickVelocity = _t > 0.5f && _t < 2f ? new Vector3(0f, 0f, -1.2f) : Vector3.Zero, 4f, FinishDrop));
_phases.Add(("walks up an 18° ramp", () => SetupRamp(18f), _ => _stickVelocity = new Vector3(0f, 0f, -1.2f), 4f, () => FinishRamp(true)));
_phases.Add(("can't walk up a 40° ramp", () => SetupRamp(40f), _ => _stickVelocity = new Vector3(0f, 0f, -1.2f), 4f, () => FinishRamp(false)));
@@ -540,35 +543,13 @@ public partial class RagdollSpike : Node3D
// Put both hands on the ledge, press down (controllers sink as the real arms straighten), then
// walk forward onto it.
// Walk up to the ledge (the legs collider stops you), hands on top just ahead, press down.
private void TickMantle(bool both)
{
float t = _t;
_stickVelocity = t < 1f ? new Vector3(0f, 0f, -0.8f) : t > 3.5f ? new Vector3(0f, 0f, -0.8f) : Vector3.Zero;
float down = Mathf.Clamp((t - 1.6f) / 1.5f, 0f, 1f);
float y = t < 1.6f ? Mathf.Lerp(1.0f, 0.95f, Mathf.Clamp(t - 1f, 0f, 0.6f) / 0.6f) : Mathf.Lerp(0.95f, 0.35f, down);
// Hands just past the ledge edge (aimed in the room, like a real player looking at it).
float z = t < 1f ? -0.4f : ObstacleFront - 0.1f - _origin.Origin.Z;
Vector3 l = new(-0.22f, y, z), r = new(0.22f, y, z);
_rightLocal = new Transform3D(Aim(Vector3.Down + Vector3.Forward * 0.3f), r);
_leftLocal = both ? new Transform3D(Aim(Vector3.Down + Vector3.Forward * 0.3f), l) : new Transform3D(Basis.Identity, new Vector3(-0.2f, 1.0f, -0.3f));
if (t > 3.6f)
{
// Up and walking on: hands come back to the body like a real player's.
_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));
}
if (_ragdoll.Built && !_needBuild)
_maxPelvisRise = Mathf.Max(_maxPelvisRise, _ragdoll.Pelvis.GlobalPosition.Y - 0.99f);
}
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;
_lastRise = _maxPelvisRise;
_maxPelvisRise = 0f;
return _finite && NewSnaps == 0 && (both ? onTop : !onTop && _lastRise < 0.25f);
return _finite && NewSnaps == 0 && (both ? onTop : !onTop); // standing tall also lifts the hips; one hand never gets you up
}
private StaticBody3D _bar;
@@ -595,7 +576,10 @@ public partial class RagdollSpike : Node3D
Basis grip = Aim(Vector3.Up + Vector3.Forward * 0.2f);
_rightLocal = new Transform3D(grip, new Vector3(0.22f, y, BarHangZ + 0.03f));
_leftLocal = both ? new Transform3D(grip, new Vector3(-0.22f, y, BarHangZ + 0.03f)) : new Transform3D(Basis.Identity, new Vector3(-0.2f, 1.0f, -0.3f));
if (!_barGrabbed && t > 0.6f && _ragdoll.Built)
bool handsAtBar = _ragdoll.Built && !_needBuild
&& _ragdoll.Hand(1).GlobalPosition.DistanceTo(_bar.GlobalPosition + new Vector3(0.22f, 0f, 0f)) < 0.1f
&& (!both || _ragdoll.Hand(0).GlobalPosition.DistanceTo(_bar.GlobalPosition + new Vector3(-0.22f, 0f, 0f)) < 0.1f);
if (!_barGrabbed && t > 0.3f && handsAtBar)
{
// The game's own world-grab detection must find the bar under the palm.
if (both)
@@ -725,6 +709,184 @@ public partial class RagdollSpike : Node3D
&& _ragdoll.Eye.DistanceTo(Camera.Origin) < 0.05f;
}
private float _poseDrift, _poseEyeErr;
// Six held poses, 3 s each (1 s blend + 2 s hold): the view rides the body, so any pose the body
// can't match would slowly walk the play space away — that must not happen.
private void TickPoseHold(float dt)
{
_measureFrom = float.MaxValue;
(float eye, float pitch, float fwd, Vector3 l, Vector3 r)[] poses =
{
(EyeHeight, 0f, 0f, new(-0.2f, 1.0f, -0.3f), new(0.2f, 1.0f, -0.3f)),
(1.0f, -0.3f, -0.1f, new(-0.2f, 0.4f, -0.35f), new(0.2f, 0.4f, -0.35f)),
(0.7f, -0.3f, -0.1f, new(-0.2f, 0.2f, -0.4f), new(0.2f, 0.2f, -0.4f)),
(1.35f, -0.6f, -0.35f, new(-0.2f, 0.9f, -0.6f), new(0.2f, 0.9f, -0.6f)),
(EyeHeight, 0f, 0f, new(-0.2f, 1.45f, -0.9f), new(0.25f, 1.6f, -0.85f)),
(0.9f, 0f, 0.05f, new(-0.25f, 0.6f, -0.2f), new(0.25f, 0.6f, -0.2f)),
};
int i = Mathf.Min((int)(_t / 3f), poses.Length - 1);
int prev = Mathf.Max(i - 1, 0);
float k = Mathf.SmoothStep(0f, 1f, Mathf.Clamp((_t - i * 3f) / 1f, 0f, 1f));
var a = poses[prev];
var b = poses[i];
_headLocal = new Transform3D(new Basis(Vector3.Right, Mathf.Lerp(a.pitch, b.pitch, k)), new Vector3(0f, Mathf.Lerp(a.eye, b.eye, k), Mathf.Lerp(a.fwd, b.fwd, k)));
_leftLocal = new Transform3D(Basis.Identity, a.l.Lerp(b.l, k));
_rightLocal = new Transform3D(Basis.Identity, a.r.Lerp(b.r, k));
if (_ragdoll.Built && !_needBuild && _t - i * 3f > 2.5f)
_poseEyeErr = Mathf.Max(_poseEyeErr, _ragdoll.Eye.DistanceTo(Camera.Origin));
_poseDrift = Mathf.Max(_poseDrift, new Vector2(_origin.Origin.X, _origin.Origin.Z).Length());
}
private bool FinishPoseHold()
{
GD.Print($"RAGDOLL: pose hold: max play-space drift {_poseDrift:F3} m (limit 0.05), eye vs camera at rest max {_poseEyeErr:F3} m (limit 0.08), snaps {NewSnaps}");
bool ok = _finite && NewSnaps == 0 && _poseDrift < 0.05f && _poseEyeErr < 0.08f;
_poseDrift = _poseEyeErr = 0f;
return ok;
}
private float _hangEyeErr;
// Grab the bar and just hang (controllers stay up): the view must stay on the character's eye.
private void TickHang(float dt)
{
TickPullUp(both: true);
float y = BarY - 0.03f;
Basis grip = Aim(Vector3.Up + Vector3.Forward * 0.2f);
_rightLocal = new Transform3D(grip, new Vector3(0.22f, y, BarHangZ + 0.03f));
_leftLocal = new Transform3D(grip, new Vector3(-0.22f, y, BarHangZ + 0.03f));
_headLocal = new Transform3D(Basis.Identity, new Vector3(0f, EyeHeight + 0.15f * Mathf.Sin(_t * 2f), 0.05f * Mathf.Sin(_t * 1.3f)));
if (_barGrabbed && _t > 1.5f)
_hangEyeErr = Mathf.Max(_hangEyeErr, _ragdoll.Eye.DistanceTo(Camera.Origin));
}
private bool FinishHang()
{
GD.Print($"RAGDOLL: hang: eye vs camera max {_hangEyeErr:F3} m (limit 0.08), climbing {_ragdoll.Climbing}, snaps {NewSnaps}");
_ragdoll.SetHeld(0, null);
_ragdoll.SetHeld(1, null);
bool ok = _finite && NewSnaps == 0 && _hangEyeErr < 0.08f;
_hangEyeErr = 0f;
return ok;
}
// Walk to the waist-high ledge, crouch a little with the hands on the edge close to the body,
// then stand up while pressing the hands down, lean in, walk on.
private void TickHumanMantle(bool both)
{
float t = _t;
_stickVelocity = t < 1f || t > 4.5f ? new Vector3(0f, 0f, -0.8f) : Vector3.Zero;
float crouch = Mathf.SmoothStep(0f, 1f, Mathf.Clamp((t - 1f) / 0.8f, 0f, 1f));
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;
_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;
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));
if (_ragdoll.Built && !_needBuild)
_maxPelvisRise = Mathf.Max(_maxPelvisRise, _ragdoll.Pelvis.GlobalPosition.Y - 0.99f);
if (t > 4.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));
_headLocal = new Transform3D(Basis.Identity, new Vector3(0f, EyeHeight, 0f));
}
}
private const float ClimbFace = -0.4f;
private StaticBody3D _wall;
private readonly ConeTwistJoint3D[] _climbWeld = new ConeTwistJoint3D[2];
private readonly Vector3[] _gripWorld = new Vector3[2];
private float _climbStartPelvis, _climbEyeErr;
private void SetupClimbWall()
{
Rest();
_wall = MakeStatic(new Vector3(3f, 4f, 0.5f), new Vector3(0f, 2f, ClimbFace - 0.25f), 29);
_stage.AddChild(_wall);
_climbWeld[0] = _climbWeld[1] = null;
_gripWorld[0] = new Vector3(-0.2f, 1.75f, ClimbFace + 0.05f);
_gripWorld[1] = new Vector3(0.2f, 1.75f, ClimbFace + 0.05f);
_climbEyeErr = 0f;
}
private void Grip(int side, bool on)
{
if (on && _climbWeld[side] == null)
{
RigidBody3D hand = _ragdoll.Hand(side);
_climbWeld[side] = new ConeTwistJoint3D { Position = hand.GlobalPosition, NodeA = hand.GetPath(), NodeB = _wall.GetPath(), SwingSpan = 0f, TwistSpan = 0f, Bias = 0f };
_stage.AddChild(_climbWeld[side]);
_ragdoll.SetHeld(side, _wall);
}
else if (!on && _climbWeld[side] != null)
{
_climbWeld[side].QueueFree();
_climbWeld[side] = null;
_ragdoll.SetHeld(side, null);
}
}
// Hold targets live in the world (the holds); while gripping, the player pulls the controllers
// down in the room. Free hands reach for the next hold 45 cm higher.
private void TickClimbWall(float dt)
{
float t = _t;
_measureFrom = float.MaxValue;
if (!_ragdoll.Built || _needBuild)
return;
float pull1 = Mathf.Clamp((t - 1.0f) / 1.2f, 0f, 1f), pull2 = Mathf.Clamp((t - 4.0f) / 1.2f, 0f, 1f);
if (t > 0.95f && t < 1.0f)
{
Grip(0, true);
Grip(1, true);
_climbStartPelvis = _ragdoll.Pelvis.GlobalPosition.Y;
}
for (int side = 0; side < 2; side++)
{
float releaseAt = side == 1 ? 2.3f : 3.1f, regripAt = releaseAt + 0.6f;
if (t > releaseAt && t < regripAt && _climbWeld[side] != null)
{
Grip(side, false);
// Next hold: 30 cm above the hand that is still holding (as a climber reaches).
_gripWorld[side] = _ragdoll.Hand(1 - side).GlobalPosition + Vector3.Up * 0.3f + Vector3.Right * (side == 0 ? -0.4f : 0.4f);
_gripWorld[side].Z = ClimbFace + 0.05f;
}
if (t >= regripAt && _climbWeld[side] == null && _ragdoll.Hand(side).GlobalPosition.DistanceTo(_gripWorld[side]) < 0.08f)
Grip(side, true);
// Gripping: the controller is pulled down in the room by the pull amount; reaching: it
// goes to the next hold.
Vector3 target = _climbWeld[side] != null || t < 1.0f
? _gripWorld[side] - _origin.Origin - Vector3.Up * (0.5f * (pull1 + pull2))
: _gripWorld[side] - _origin.Origin;
// Reach like a person: ease from where the hand is to the hold over ~0.5 s.
Vector3 current = side == 0 ? _leftLocal.Origin : _rightLocal.Origin;
if (_climbWeld[side] == null)
target = current.MoveToward(target, 1.2f * dt);
Basis aim = Aim(Vector3.Up + Vector3.Forward * 0.15f);
if (side == 0)
_leftLocal = new Transform3D(aim, target);
else
_rightLocal = new Transform3D(aim, target);
}
if (t > 1f)
_climbEyeErr = Mathf.Max(_climbEyeErr, _ragdoll.Eye.DistanceTo(Camera.Origin));
}
private bool FinishClimbWall()
{
float rise = _ragdoll.Pelvis.GlobalPosition.Y - _climbStartPelvis;
GD.Print($"RAGDOLL: wall climb: pelvis rose {rise:F3} m (> 0.6), holding {_climbWeld[0] != null}/{_climbWeld[1] != null}, eye vs camera max {_climbEyeErr:F3} (< {PlayerRagdoll.MaxViewLead + 0.03f:F2}), snaps {NewSnaps}");
Grip(0, false);
Grip(1, false);
// MaxViewLead 0.08 is enforced before each step; this is measured after it (+ one tick of motion).
return _finite && NewSnaps == 0 && rise > 0.6f && _climbEyeErr < PlayerRagdoll.MaxViewLead + 0.03f;
}
// A slope rising away (−Z) from ObstacleFront up to 1 m, then a platform.
private void SetupRamp(float degrees)
{