Body step 2.4: landing, movable ground, playground fixes

- legs brake landings at 4.5 g; vertical support is a rate-limited servo
  (no launch when stepping onto a mantled ledge)
- ground control pushes back on movable ground (seesaw tips under the player)
- playground hinges use absolute paths (joint paths resolve from the joint)
- big props grabbable via surface query; Reset props keeps playground pieces
- spike: landing, seesaw, playground joints/grab, person-like 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 10:10:38 +03:00
1 parent b5f598aae9
commit 0b8f2131c1
6 files changed
+208 -42

No files matched your search

+23
View File
@@ -155,3 +155,26 @@ Rules added:
- Measure "blocked" where the body is blocked.
- Hysteresis plus a free-space check before re-enabling any collider.
- In the Box3D wrapper, physics materials must be set before AddChild.
## Step 2.4 — playtest polish (2026-10-02)
Owner, after 2.3: "Climbing actually feels very solid now. Mantling is almost effortless … There wasn't anything I
wasn't able to climb." Fixed from the same session:
- **Landing sink.** Support capped at 2× body weight leaves 1 g of braking, so a 1 m drop (4.4 m/s) took ~0.4 m to
stop and the body sank out from under the view. `LegStrength` is now 4.5× (real legs brake landings at 3–5 g).
The vertical support is now a rate-limited velocity servo, `MaxRiseSpeed` 1.2 m/s: full strength to brake, but the
legs raise the body no faster than that. Legs can push but not pull, so a hard push toward a higher stance (just
mantled onto a ledge) overshot into a 0.67 m jump. Spike: landing keeps the pelvis at standing height, eye within
12 cm.
- **Body weight on movable ground.** The feet aren't physical, so standing on a seesaw/plank/crate never pushed it.
Ground control now applies the opposite of its support + carry force to a `RigidBody3D` under the ground ray, at the
hit point. Box3D ray results also carry a null `collider`, so it uses `collider_id`. Spike: a seesaw tips 17°.
- **Playground hinges.** `Joint3D.NodeA/NodeB` resolve relative to the JOINT node. `GetPathTo` from the parent
pointed at nonexistent children, so the seesaw and swing board were never hinged. Absolute paths now.
- **Big props weren't grabbable.** Prop grabbing measured distance to the prop ORIGIN, far from the hand on a 2–3 m
plank. `PropGrabber.BodyUnderPalm` queries the surface (a prop first, else static world).
- **Reset props removed every "prop".** `PropShop.Respawn` now frees only its own children; the playground resets
in place.
- The climbing spike now climbs like a person: hand moves are sequential (never release the second hand before the
first re-grips), and pulls are relative to the body/room.
+17 -2
View File
@@ -52,10 +52,11 @@ public partial class PlayerRagdoll : Node3D
// Ground control (world-space support on the pelvis, only while grounded).
public const float GroundFrequency = 4f; // Hz
public const float GroundDamping = 1f;
public const float LegStrength = 2f; // max support, × body weight
public const float LegStrength = 4.5f; // max support, × body weight (real legs brake a landing at 3–5 g; 2× sank ~0.4 m)
public const float GroundFriction = 1f; // horizontal force ≤ this × support
public const float GroundProbeExtra = 0.3f; // ground counts while within hip height + this
public const float MaxStepHeight = 0.35f;
public const float MaxRiseSpeed = 1.2f; // m/s the legs lift the body toward its stance
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;
@@ -166,6 +167,8 @@ 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 RigidBody3D _groundBody;
private Vector3 _groundPoint;
private float _tuckFree, _dt;
private PhysicsShapeQueryParameters3D _legsQuery;
@@ -752,10 +755,13 @@ public partial class PlayerRagdoll : Node3D
Godot.Collections.Dictionary hit = GetWorld3D().DirectSpaceState.IntersectRay(query);
bool wasGrounded = Grounded;
Grounded = false;
_groundBody = null;
if (hit.Count > 0)
{
Vector3 point = (Vector3)hit["position"];
Vector3 normal = (Vector3)hit["normal"];
_groundBody = GodotObject.InstanceFromId((ulong)hit["collider_id"]) as RigidBody3D; // Box3D: "collider" is null
_groundPoint = point;
bool inReach = pos.Y - point.Y < hipAboveFloor + GroundProbeExtra;
bool steep = normal.Y <= Mathf.Cos(Mathf.DegToRad(MaxSlopeDegrees));
if (inReach && steep && wasGrounded && point.Y <= _footY + MaxStepHeight)
@@ -806,7 +812,12 @@ public partial class PlayerRagdoll : Node3D
float w = Mathf.Tau * GroundFrequency;
Vector3 accel = (goal - pos) * (w * w) + (targetVelocity - Pelvis.LinearVelocity) * (2f * GroundDamping * w);
Vector3 force = accel * BodyMass;
float support = Mathf.Clamp(force.Y + weight, 0f, LegStrength * weight);
// Vertical as a rate-limited velocity servo: full leg strength brakes a landing, but
// the legs raise the body at most MaxRiseSpeed — they can push, not pull, so a hard push
// toward a higher stance (onto a ledge) would otherwise overshoot into a jump.
float riseVelocity = Mathf.Clamp((goal.Y - pos.Y) * w, -3f, MaxRiseSpeed) + targetVelocity.Y;
float verticalAccel = (riseVelocity - Pelvis.LinearVelocity.Y) * (2f * GroundDamping * w);
float support = Mathf.Clamp(verticalAccel * BodyMass + 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).
@@ -814,6 +825,10 @@ public partial class PlayerRagdoll : Node3D
carry = Mathf.Min(carry, BlockedPush);
Vector3 horizontal = new Vector3(force.X, 0f, force.Z).LimitLength(carry);
Pelvis.ApplyCentralForce(horizontal + Vector3.Up * support);
// Newton's third law: standing on something movable (seesaw, plank, crate) pushes it
// down — the feet aren't physical, so the reaction is applied where the ground ray hit.
if (_groundBody != null && IsInstanceValid(_groundBody))
_groundBody.ApplyForce(-(horizontal + Vector3.Up * support), _groundPoint - _groundBody.GlobalPosition);
}
else if (Braced)
{
+4 -4
View File
@@ -186,8 +186,8 @@ public partial class Playground : Node3D
HingeJoint3D pivot = new()
{
Transform = new Transform3D(new Basis(Vector3.Right, Vector3.Up, Vector3.Back), new Vector3(-8.5f, 0.48f, -1.5f)),
NodeA = GetPathTo(GetNode("SeesawPivot")),
NodeB = GetPathTo(seesaw),
NodeA = GetNode<Node>("SeesawPivot").GetPath(), // absolute: joint paths resolve relative to the joint
NodeB = seesaw.GetPath(),
};
AddChild(pivot);
Sign("Seesaw", new Vector3(-8.5f, 1.3f, -1.5f));
@@ -200,8 +200,8 @@ public partial class Playground : Node3D
HingeJoint3D swing = new()
{
Transform = new Transform3D(new Basis(Vector3.Back, Vector3.Up, Vector3.Left), new Vector3(-6f, 2.35f, 1.5f)),
NodeA = GetPathTo(beam),
NodeB = GetPathTo(board),
NodeA = beam.GetPath(),
NodeB = board.GetPath(),
};
AddChild(swing);
Sign("Hanging board", new Vector3(-6f, 2.9f, 1.5f));
+24 -1
View File
@@ -214,7 +214,30 @@ public partial class PropGrabber : Node
nearest = NearestProp(handBody.GlobalPosition, CenterGrabRadius);
if (nearest != null)
return nearest;
return WorldUnderPalm(palm, handBody.GetWorld3D()) ?? WorldUnderPalm(handBody.GlobalPosition, handBody.GetWorld3D());
// Big props (planks, seesaw, boards): their origin is far from the hand — use the surface.
World3D world = handBody.GetWorld3D();
return BodyUnderPalm(palm, world) ?? BodyUnderPalm(handBody.GlobalPosition, world);
}
// The body whose surface is near the point: a "prop" first, else static world (climbing).
public static PhysicsBody3D BodyUnderPalm(Vector3 point, World3D world)
{
PhysicsShapeQueryParameters3D query = new()
{
Shape = new SphereShape3D { Radius = WorldGrabRadius },
Transform = new Transform3D(Basis.Identity, point),
CollisionMask = 1,
};
StaticBody3D found = null;
foreach (Godot.Collections.Dictionary hit in world.DirectSpaceState.IntersectShape(query, 8))
{
GodotObject o = GodotObject.InstanceFromId((ulong)hit["collider_id"]);
if (o is RigidBody3D prop && prop.IsInGroup("prop"))
return prop;
if (o is StaticBody3D body && !body.IsInGroup(NoGrabGroup))
found ??= body;
}
return found;
}
// Climbing: any static body near the palm (except the "no_grab" group) can be held.
+5 -2
View File
@@ -34,8 +34,11 @@ public partial class PropShop : Node3D
public void Respawn()
{
foreach (Node node in GetTree().GetNodesInGroup("prop"))
node.QueueFree();
// Only the table's own props (the playground's dynamic pieces are props too — they reset
// in place via Playground.Reset).
foreach (Node node in GetChildren())
if (node is RigidBody3D && node.IsInGroup("prop"))
node.QueueFree();
foreach (PropSpec spec in Specs)
SpawnProp(spec);
GD.Print("PropShop: table reset");
+135 -33
View File
@@ -103,8 +103,10 @@ public partial class RagdollSpike : Node3D
_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(("climbs a wall hand over hand (grab, pull, regrab higher)", SetupClimbWall, TickClimbWall, 8f, FinishClimbWall));
_phases.Add(("walks off a 1 m ledge and lands without sinking away from the view", SetupLedgeTop, TickDrop, 4f, FinishDrop));
_phases.Add(("standing on a seesaw tips it (body weight reaches movable ground)", SetupSeesaw, _ => { }, 3f, FinishSeesaw));
_phases.Add(("playground: swing board hangs, seesaw on its pivot, big plank grabbable", SetupPlayground, _ => { }, 2f, FinishPlayground));
_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)));
Next();
@@ -666,8 +668,27 @@ public partial class RagdollSpike : Node3D
_stage.AddChild(MakeStatic(new Vector3(3f, 1f, 3f), new Vector3(0f, 0.5f, 1f), 29));
}
private float _dropEyeErr, _dropMinPelvis = float.MaxValue;
private void TickDrop(float dt)
{
_stickVelocity = _t > 0.5f && _t < 2f ? new Vector3(0f, 0f, -1.2f) : Vector3.Zero;
if (_ragdoll.Built && !_needBuild && _t > 0.5f)
{
_dropEyeErr = Mathf.Max(_dropEyeErr, _ragdoll.Eye.DistanceTo(Camera.Origin));
if (_ragdoll.Grounded && _ragdoll.GroundY < 0.5f)
_dropMinPelvis = Mathf.Min(_dropMinPelvis, _ragdoll.Pelvis.GlobalPosition.Y);
}
}
private bool FinishDrop()
{
GD.Print($"RAGDOLL: landing: eye vs camera max {_dropEyeErr:F3} m (limit 0.15), lowest pelvis after touchdown {_dropMinPelvis:F3} m (standing 0.99, limit > 0.7)");
bool landed = _dropEyeErr < 0.15f && _dropMinPelvis > 0.7f;
_dropEyeErr = 0f;
_dropMinPelvis = float.MaxValue;
if (!landed)
return false;
GD.Print($"RAGDOLL: drop: feet y {_ragdoll.GroundY:F3}, origin y {_origin.Origin.Y:F3}, pelvis {_ragdoll.Pelvis.GlobalPosition}, eye error {_ragdoll.Eye.DistanceTo(Camera.Origin):F3}, snaps {NewSnaps}");
return _finite && NewSnaps == 0 && Mathf.Abs(_ragdoll.GroundY) < 0.05f && Mathf.Abs(_origin.Origin.Y) < 0.1f
&& _ragdoll.Eye.DistanceTo(Camera.Origin) < 0.1f && _ragdoll.Pelvis.GlobalPosition.Z > -3f;
@@ -776,7 +797,7 @@ public partial class RagdollSpike : Node3D
private void TickHumanMantle(bool both)
{
float t = _t;
_stickVelocity = t < 1f || t > 4.5f ? new Vector3(0f, 0f, -0.8f) : Vector3.Zero;
_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));
float rise = Mathf.SmoothStep(0f, 1f, Mathf.Clamp((t - 2.2f) / 1.6f, 0f, 1f));
float eye = EyeHeight - 0.25f * crouch + 0.35f * rise;
@@ -833,58 +854,139 @@ public partial class RagdollSpike : Node3D
// 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 readonly Vector3[] _gripLocal = new Vector3[2];
private readonly float[] _pullAtGrip = new float[2];
private int _climbStep; // 0 reach+grip both, 1 pull, 2 right moves up, 3 left moves up, 4 pull, 5 done
private float _climbStepT, _pulled;
// Like a climber: grip two holds, pull (hands go down ~45 cm relative to the BODY/room — the
// play space rides up with the body), move one hand to a higher hold and only then the other,
// pull again.
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)
if (t < dt * 1.5f)
{
Grip(0, true);
Grip(1, true);
_climbStartPelvis = _ragdoll.Pelvis.GlobalPosition.Y;
_climbStep = 0;
_climbStepT = 0f;
_pulled = 0f;
}
_climbStepT += dt;
bool pulling = _climbStep == 1 || _climbStep == 4;
if (pulling)
_pulled = Mathf.MoveToward(_pulled, _climbStep == 1 ? 0.45f : 0.9f, 0.4f * dt);
Basis aim = Aim(Vector3.Up + Vector3.Forward * 0.15f);
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);
Vector3 local;
if (_climbWeld[side] != null)
local = _gripLocal[side] - Vector3.Up * (_pulled - _pullAtGrip[side]);
else
_rightLocal = new Transform3D(aim, target);
{
Vector3 current = side == 0 ? _leftLocal.Origin : _rightLocal.Origin;
local = current.MoveToward(_gripWorld[side] - _origin.Origin, 1.2f * dt);
if (_ragdoll.Hand(side).GlobalPosition.DistanceTo(_gripWorld[side]) < 0.08f && (_climbStep == 0 || _climbStep == 2 + (1 - side)))
{
Grip(side, true);
_gripLocal[side] = local;
_pullAtGrip[side] = _pulled;
}
}
if (side == 0)
_leftLocal = new Transform3D(aim, local);
else
_rightLocal = new Transform3D(aim, local);
}
switch (_climbStep)
{
case 0 when _climbWeld[0] != null && _climbWeld[1] != null:
_climbStartPelvis = _ragdoll.Pelvis.GlobalPosition.Y;
Next(1);
break;
case 1 when _pulled >= 0.45f:
MoveUp(1);
Next(2);
break;
case 2 when _climbWeld[1] != null && _climbStepT > 0.2f:
MoveUp(0);
Next(3);
break;
case 3 when _climbWeld[0] != null && _climbStepT > 0.2f:
Next(4);
break;
case 4 when _pulled >= 0.9f:
Next(5);
break;
}
if (t > 1f)
_climbEyeErr = Mathf.Max(_climbEyeErr, _ragdoll.Eye.DistanceTo(Camera.Origin));
void Next(int step)
{
_climbStep = step;
_climbStepT = 0f;
}
void MoveUp(int side)
{
Grip(side, false);
// Next hold: 30 cm above the hand that keeps holding.
_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;
}
}
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}");
GD.Print($"RAGDOLL: wall climb: reached step {_climbStep}/5, pelvis rose {rise:F3} m (> 0.35), 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;
// Two 45 cm pulls minus the sag while hanging one-handed during the two hand moves.
return _finite && NewSnaps == 0 && _climbStep == 5 && rise > 0.35f && _climbEyeErr < PlayerRagdoll.MaxViewLead + 0.03f;
}
private RigidBody3D _seesaw;
// A 3 m plank along Z hinged 0.45 m up at z = −1; the player stands on its near half.
private void SetupSeesaw()
{
Rest();
StaticBody3D pivot = MakeStatic(new Vector3(0.6f, 0.45f, 0.3f), new Vector3(0f, 0.225f, -1f), 29);
_stage.AddChild(pivot);
_seesaw = MakeBox(new Vector3(0.6f, 0.06f, 3f), 12f, new Vector3(0f, 0.48f, -1f));
HingeJoint3D hinge = new() { Transform = new Transform3D(new Basis(Vector3.Back, Vector3.Up, Vector3.Left), new Vector3(0f, 0.48f, -1f)), NodeA = pivot.GetPath(), NodeB = _seesaw.GetPath() };
_stage.AddChild(hinge);
_origin = new Transform3D(Basis.Identity, new Vector3(0f, 0.51f, -0.2f)); // standing on the plank, 0.8 m from the pivot
}
private bool FinishSeesaw()
{
float tilt = Mathf.RadToDeg(_seesaw.GlobalTransform.Basis.Z.AngleTo(Vector3.Back));
GD.Print($"RAGDOLL: seesaw tilted {tilt:F1}° under the player (> 8), player feet y {_ragdoll.GroundY:F2}, snaps {NewSnaps}");
return _finite && NewSnaps == 0 && tilt > 8f;
}
private Playground _playground;
private void SetupPlayground()
{
Rest();
_playground = new Playground { Position = new Vector3(0f, 0f, 40f) }; // far from the player
_stage.AddChild(_playground);
}
private bool FinishPlayground()
{
RigidBody3D board = _playground.GetNode<RigidBody3D>("SwingBoard"), seesaw = _playground.GetNode<RigidBody3D>("Seesaw"), plank = _playground.GetNode<RigidBody3D>("Plank0");
Vector3 plankEnd = plank.GlobalTransform * new Vector3(0.9f, 0.05f, 0f);
PhysicsBody3D grabbed = PropGrabber.BodyUnderPalm(plankEnd, plank.GetWorld3D());
GD.Print($"RAGDOLL: playground: swing board y {board.GlobalPosition.Y:F2} (hangs ~1.75), seesaw y {seesaw.GlobalPosition.Y:F2} (on pivot ~0.48), plank end grab finds {(grabbed == null ? "nothing" : grabbed.Name)}");
return board.GlobalPosition.Y > 1.5f && seesaw.GlobalPosition.Y > 0.35f && grabbed is RigidBody3D && grabbed.Name.ToString().StartsWith("Plank"); // planks are stacked
}
// A slope rising away (−Z) from ObstacleFront up to 1 m, then a platform.