Body step 2.1: climbing, mantling, crawling, legs, playground, 144 Hz

- Step rule + lower-body collider: no walking onto tables, steps <= 0.35 m
- Structural hand strength: two-hand mantle/pull-up works, one hand can't
- Arms aim from the physical shoulder horizontally, keep True Body pose vertically
- Crawl pose, crawl carry, 35 deg slope limit, ground feed-forward ignores
  play-space corrections (fixes a runaway feedback loop)
- Grab static world (climbing), procedural visual legs
- Playground sandbox; menu strength slider, spectator (old drag) and turn toggles
- Physics 144 Hz with interpolation; legacy chase-body spikes pin 90 Hz
- RagdollSpike: 27 phases incl. mantle, pull-up, crawl, ledge drop, ramps

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
FoxyzandClaude Opus 5.5 committed 2026-10-02 07:56:08 +03:00
1 parent d83273dd2f
commit 2aff2b61da
26 files changed
+1243 -70

No files matched your search

+12
View File
@@ -1,7 +1,12 @@
using Godot;
// The old grab-the-air movement. Since the ragdoll player (body step 2) it is only the SPECTATOR
// mode (menu → Player → Spectator), where there is no body; off by default. Known issues kept as-is
// (owner, 2026-10-02): two-hand turning isn't 2-anchor based, simultaneous two-hand moves act weird.
public partial class AnchorMoveMode : Node
{
public static bool Enabled;
private const float GripThreshold = 0.6f;
private XROrigin3D _origin;
@@ -25,6 +30,13 @@ public partial class AnchorMoveMode : Node
public override void _Process(double delta)
{
if (!Enabled)
{
_wasTwoHand = false;
_leftPrev = _left.Position;
_rightPrev = _right.Position;
return;
}
bool leftGrip = _left.GetFloat("grip") > GripThreshold && !PropGrabber.IsGrabbing(_left);
bool rightGrip = _right.GetFloat("grip") > GripThreshold && !PropGrabber.IsGrabbing(_right);
+3
View File
@@ -73,6 +73,9 @@ public partial class ArmSpike : Node3D
public override void _Ready()
{
// Legacy chase bodies (PhysicsHands/Torso/Arms) were tuned at 90 Hz; the project runs 144 Hz
// since body step 2, so this regression pins their design rate.
Engine.PhysicsTicksPerSecond = 90;
StaticBody3D floor = new() { CollisionMask = 0b11101 };
floor.AddChild(new CollisionShape3D { Shape = new BoxShape3D { Size = new Vector3(20f, 0.1f, 20f) }, Position = new Vector3(0f, -0.05f, 0f) });
AddChild(floor);
+3
View File
@@ -53,6 +53,9 @@ public partial class BodySpike : Node3D
public override void _Ready()
{
// Legacy chase bodies (PhysicsHands/Torso/Arms) were tuned at 90 Hz; the project runs 144 Hz
// since body step 2, so this regression pins their design rate.
Engine.PhysicsTicksPerSecond = 90;
BuildWorld();
_head = new Node3D { Position = new Vector3(0f, 1.6f, 0f) };
AddChild(_head);
+3
View File
@@ -58,6 +58,9 @@ public partial class CollapseSpike : Node3D
public override void _Ready()
{
// Legacy chase bodies (PhysicsHands/Torso/Arms) were tuned at 90 Hz; the project runs 144 Hz
// since body step 2, so this regression pins their design rate.
Engine.PhysicsTicksPerSecond = 90;
// A real profile is measured standing once (T-pose) and does not shrink when seated:
// every scenario, seated or not, uses the same standing 1.65 m-eye profile.
BodyProfile.Current = BodyProfile.Estimate(1.65f);
+3
View File
@@ -89,6 +89,9 @@ public partial class GrabSpike : Node3D
public override void _Ready()
{
// Legacy chase bodies (PhysicsHands/Torso/Arms) were tuned at 90 Hz; the project runs 144 Hz
// since body step 2, so this regression pins their design rate.
Engine.PhysicsTicksPerSecond = 90;
StaticBody3D floor = new() { Position = new Vector3(0, -0.5f, 0) };
floor.AddChild(new CollisionShape3D { Shape = new BoxShape3D { Size = new Vector3(20, 1, 20) } });
AddChild(floor);
+6 -1
View File
@@ -14,6 +14,8 @@ public partial class Main : Node3D
[Export]
public int MaximumRefreshRate { get; set; } = 90;
public const int PhysicsRate = 144;
private OpenXRInterface _xrInterface;
private bool _xrIsFocused;
@@ -70,7 +72,10 @@ public partial class Main : Node3D
currentRefreshRate = newRate;
}
Engine.PhysicsTicksPerSecond = (int)currentRefreshRate;
// Physics runs at a fixed PhysicsRate (the ragdoll's joints like small steps), decoupled from
// the display; physics interpolation smooths rendering (XR origin subtree excluded — the
// runtime and locomotion place it every frame).
Engine.PhysicsTicksPerSecond = PhysicsRate;
}
private void OnSessionVisible()
+58
View File
@@ -18,6 +18,9 @@ public partial class MainMenu : PanelContainer
public event Action PingRequested;
public event Action CloseRequested;
public event Action Clicked;
public event Action<bool> SpectatorToggled;
public event Action<bool> SmoothTurnToggled;
public event Action<float> StrengthChanged;
public TabContainer Tabs { get; private set; }
public Button ReturnToSpawnButton { get; private set; }
@@ -27,6 +30,9 @@ public partial class MainMenu : PanelContainer
public Button CloseButton { get; private set; }
public Button HeaderCloseButton { get; private set; }
public HSlider ClickVolumeSlider { get; private set; }
public HSlider StrengthSlider { get; private set; }
public Button SpectatorButton { get; private set; }
public Button TurnModeButton { get; private set; }
public ScrollContainer LogScroll { get; private set; }
private Label _fpsLabel;
@@ -116,6 +122,39 @@ public partial class MainMenu : PanelContainer
_calibrationStatus.AddThemeColorOverride("font_color", new Color(0.6f, 0.6f, 0.65f));
calibRow.AddChild(_calibrationStatus);
player.AddChild(calibRow);
// Nimso's single strength value: every muscle and the hand guides scale with it.
HBoxContainer strengthRow = new();
strengthRow.AddThemeConstantOverride("separation", 20);
Label strengthLabel = new() { Text = "Strength 1.00×", VerticalAlignment = VerticalAlignment.Center, CustomMinimumSize = new Vector2(220, 0) };
strengthRow.AddChild(strengthLabel);
StrengthSlider = new HSlider
{
MinValue = 0.25,
MaxValue = 3,
Step = 0.05,
Value = 1,
SizeFlagsHorizontal = SizeFlags.ExpandFill,
SizeFlagsVertical = SizeFlags.ShrinkCenter,
CustomMinimumSize = new Vector2(0, 48),
FocusMode = FocusModeEnum.None,
};
StrengthSlider.ValueChanged += v =>
{
strengthLabel.Text = $"Strength {v:0.00}×";
StrengthChanged?.Invoke((float)v);
};
strengthRow.AddChild(StrengthSlider);
player.AddChild(strengthRow);
HBoxContainer modeRow = new();
modeRow.AddThemeConstantOverride("separation", 16);
TurnModeButton = MakeToggle("Turning: snap", "Turning: smooth", on => SmoothTurnToggled?.Invoke(on));
modeRow.AddChild(TurnModeButton);
// Spectator: no body; fly around by grabbing the air (the old drag movement).
SpectatorButton = MakeToggle("Spectator: off", "Spectator: on (grab-fly)", on => SpectatorToggled?.Invoke(on));
modeRow.AddChild(SpectatorButton);
player.AddChild(modeRow);
Tabs.AddChild(player);
// World
@@ -180,6 +219,25 @@ public partial class MainMenu : PanelContainer
return page;
}
private Button MakeToggle(string offText, string onText, Action<bool> changed)
{
Button button = new()
{
Text = offText,
ToggleMode = true,
CustomMinimumSize = new Vector2(0, ButtonHeight),
SizeFlagsHorizontal = SizeFlags.ExpandFill,
FocusMode = FocusModeEnum.None,
};
button.Toggled += on =>
{
button.Text = on ? onText : offText;
Clicked?.Invoke();
Callable.From(() => changed(on)).CallDeferred();
};
return button;
}
private Button MakeButton(string text, Action action)
{
Button button = new()
+14
View File
@@ -302,6 +302,20 @@ public partial class MenuSpike : Node3D
await TouchPress(1, Center(_menu.RecalibrateButton));
Check("touch: Recalibrate called once", stub.Calls == 1, $"(calls={stub.Calls})");
// 7b) Player-page toggles: spectator and smooth turn fire with the new state.
bool? spectator = null, smooth = null;
_menu.SpectatorToggled += on => spectator = on;
_menu.SmoothTurnToggled += on => smooth = on;
await TouchPress(1, TabCenter(0));
await TouchPress(1, Center(_menu.SpectatorButton));
await TouchPress(1, Center(_menu.TurnModeButton));
await Frame(3);
Check("touch: spectator toggled on", spectator == true, $"({spectator})");
Check("touch: smooth turn toggled on", smooth == true, $"({smooth})");
await TouchPress(1, Center(_menu.SpectatorButton));
await Frame(3);
Check("touch: spectator toggled back off", spectator == false, $"({spectator})");
// 8) Header Close via touch.
await TouchPress(1, Center(_menu.HeaderCloseButton));
Check("touch: Close fired once", _close == 1, $"(count={_close})");
+245 -21
View File
@@ -33,6 +33,14 @@ public partial class PlayerRagdoll : Node3D
// Hand guide (chest→hand drive): pulls the hand onto the controller; capped so the arm,
// not the guide, carries real loads.
public const float HandGuideKp = 40f, HandGuideForce = 40f, HandGuideMaxSpeed = 10f;
// STRUCTURAL strength: force along the arm (straight arm pressing on a ledge, hanging from a
// bar, carrying a bucket at your side) goes through bone and is strong; force across the arm
// needs muscle torque and stays at HandGuideForce. Cap = lerp(base, structural, alignment).
// Two arms ≈ 1.2 × body weight → two hands mantle/pull up, one can't (Nimso).
public const float HandStructuralForce = 400f;
public const float StructuralDeadband = 0.03f;
// Nimso's one global strength value: scales every muscle and the hand guides (menu slider).
public static float Strength = 1f;
public const float HandGuideAngularKp = 40f, HandGuideTorque = 25f;
// Ground control (world-space support on the pelvis, only while grounded).
public const float GroundFrequency = 4f; // Hz
@@ -40,6 +48,18 @@ public partial class PlayerRagdoll : Node3D
public const float LegStrength = 2f; // max support, × body weight
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 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 TorsoFriction = 0.3f; // clothing on floor: lets a crawl slide
// 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)
// 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;
@@ -48,6 +68,8 @@ public partial class PlayerRagdoll : Node3D
// 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 may lead an unobstructed (lagging/held) head by this much
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 GroundFollowRate = 8f;
public const float ResetDistance = 1.5f; // pelvis this far from its target → snap the whole body to the pose
// Geometry.
@@ -92,8 +114,9 @@ public partial class PlayerRagdoll : Node3D
public HingeJoint3D Elbow;
public Drive Shoulder, Guide;
public float UpperLen, ForeLen;
public RigidBody3D Held;
public float PrevFlexTarget = -1f;
public float GuideCap = HandGuideForce;
public PhysicsBody3D Held;
}
public RigidBody3D Pelvis { get; private set; }
@@ -101,7 +124,11 @@ public partial class PlayerRagdoll : Node3D
public RigidBody3D Head { get; private set; }
public bool Built { get; private set; }
public bool Grounded { get; private set; }
public float GroundY { get; private set; }
public float GroundY => _footY;
public bool Braced { get; private set; } // a hand touches or holds static world
// Really climbing: a hand holds static world, or the arms are lifting the body (mantle).
public bool Climbing => Built && (_arms[0].Held is StaticBody3D || _arms[1].Held is StaticBody3D || _lifted);
public RigidBody3D Legs { get; private set; }
public Vector3 Eye => Head.GlobalTransform * _eyeLocal;
public BodyProfile Profile { get; private set; }
public int Snaps { get; private set; } // safety resets (NaN / too far) — should stay 0
@@ -115,6 +142,14 @@ public partial class PlayerRagdoll : Node3D
private Vector3 _pelvisCenterLocal, _chestCenterLocal, _headCenterLocal; // from each body's proximal joint, in its basis
private readonly Dictionary<RigidBody3D, Transform3D> _targets = new();
private readonly Vector3[] _girdle = new Vector3[2]; // True Body shoulder offset, chest frame
private float _footY;
private float _supportGoalY;
private bool _lifted;
private float _legsHeight;
private Drive _legsDrive;
private bool _legsSolid = true;
private ProceduralLegs _legVisuals;
private float _appliedStrength = 1f;
private Vector3 _pelvisTargetPrev;
private bool _havePelvisPrev;
@@ -157,14 +192,21 @@ public partial class PlayerRagdoll : Node3D
_eyeLocal -= headCenterFromPivot; // eye relative to the head body's centre
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, BodyLayer, WorldMask, pants,
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));
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,
c * new Transform3D(Basis.Identity, hip - new Vector3(0f, _legsHeight * 0.5f, 0f)), visible: false);
_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));
Head = MakeBody("Head", new BoxShape3D { Size = HeadSize * s }, 0.081f * m, BodyLayer, WorldMask, skin,
c * new Transform3D(Basis.Identity, neckPivot + headCenterFromPivot));
// 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 });
@@ -175,6 +217,10 @@ public partial class PlayerRagdoll : Node3D
StaticBody3D anchor = new() { Name = "BalanceAnchor", CollisionLayer = 0, CollisionMask = 0 };
AddChild(anchor);
_balance = MakeDrive(anchor, Pelvis, Pelvis.GlobalTransform, 0f, BalanceTorque);
_legsDrive = MakeDrive(Pelvis, Legs, c * new Transform3D(Basis.Identity, hip), LegsForce, LegsTorque);
_legVisuals = new ProceduralLegs { Name = "LegVisuals" };
AddChild(_legVisuals);
_legVisuals.Setup(Pelvis, -_pelvisCenterLocal, p.Stature, pants, new Color(0.15f, 0.15f, 0.17f));
_balanceCap = BalanceTorque;
_spine = MakeBall(Pelvis, Chest, c * lumbar, c.Basis * TwistAlong(Vector3.Up), Mathf.DegToRad(60f), Mathf.DegToRad(35f), SpineKp, SpineTorque);
@@ -217,6 +263,9 @@ public partial class PlayerRagdoll : Node3D
Built = true;
_havePelvisPrev = false;
_appliedStrength = 1f;
_legsSolid = true;
ApplyStrength();
SnapToPose(pose, left, right);
GD.Print($"PlayerRagdoll: built for stature {p.Stature:F2} m (arm {p.UpperArm:F3}+{p.Forearm:F3} m, {_bodies.Count} bodies)");
}
@@ -249,11 +298,11 @@ 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)
private RigidBody3D MakeBody(string name, Shape3D shape, float mass, uint layer, uint mask, Color color, Transform3D at, bool visible = true)
{
RigidBody3D body = new() { Name = name, Mass = mass, CollisionLayer = layer, CollisionMask = mask, Transform = at };
RigidBody3D body = new() { Name = name, Mass = mass, CollisionLayer = layer, CollisionMask = mask, Transform = at, ContactMonitor = true, MaxContactsReported = 4 };
body.AddChild(new CollisionShape3D { Shape = shape });
if (shape is BoxShape3D box && layer != 0)
if (shape is BoxShape3D box && layer != 0 && visible)
{
MeshInstance3D mesh = new() { Mesh = new BoxMesh { Size = box.Size } };
mesh.SetSurfaceOverrideMaterial(0, new StandardMaterial3D { AlbedoColor = color, Roughness = 0.6f });
@@ -337,14 +386,17 @@ public partial class PlayerRagdoll : Node3D
// World transform every body should have in the True Body pose. The trunk is rebuilt top-down
// through the physical chain (upper chest → lumbar → hips) so a leaning chest doesn't drag
// the head off the camera; the hips take part of the crouch lean (hip hinge).
private void ComputeTargets(TrueBodySolver s, Transform3D left, Transform3D right)
private void ComputeTargets(TrueBodySolver s, Transform3D left, Transform3D right, bool physicalArms = false, float dt = 0f)
{
Basis chest = s.ChestBasis;
Basis pelvis = s.YawBasis * new Basis(Vector3.Right, -s.Lean * PelvisLeanShare);
// Hips take half of a crouch lean, all of it in a crawl (keeps them off the floor).
float share = Mathf.Lerp(PelvisLeanShare, 1f, Mathf.Clamp((s.Lean - TrueBodySolver.ChestLeanMax) / 0.5f, 0f, 1f));
Basis pelvis = s.YawBasis * new Basis(Vector3.Right, -s.Lean * share);
Vector3 lumbar = s.UpperChest - chest.Y * (_trunk - _lumbar);
Vector3 hip = lumbar - pelvis.Y * _lumbar;
_targets[Pelvis] = new Transform3D(pelvis, hip + pelvis * _pelvisCenterLocal);
_targets[Chest] = new Transform3D(chest, lumbar + chest * _chestCenterLocal);
_targets[Legs] = new Transform3D(s.YawBasis, hip - Vector3.Up * (_legsHeight * 0.5f)); // snap pose only; LegsDrive steps it
// Head + neck pivot at the neck base: orient like the camera, tilted by the small extra
// rotation that puts the body's eye on the camera (the True Body bends the neck at two points).
Vector3 eyeDir = s.HeadBasis * _neckBaseToEye;
@@ -368,9 +420,33 @@ public partial class PlayerRagdoll : Node3D
_targets[a.Upper] = new Transform3D(BoneBasis(up, hinge), sh + up * (a.UpperLen * 0.5f));
_targets[a.Fore] = new Transform3D(BoneBasis(fore, hinge), el + fore * (a.ForeLen * 0.5f));
_targets[a.Hand] = (side == 0 ? left : right).Orthonormalized();
if (physicalArms)
{
// Re-aim the arm from the PHYSICAL shoulder. Horizontally the body may be held away from
// the camera (pressed against a ledge, leaning over a table): the arm reaches for the
// real hand. Vertically a sagging body keeps its arm pose (the aim point moves with
// the shoulder), so straight real arms stay straight virtual arms — pillars that hold
// you up when crawling or press you up a ledge (the hand guide adds the rest).
Transform3D chestPhys = Chest.GlobalTransform.Orthonormalized();
Vector3 shPhys = chestPhys * (a.Shoulder.AnchorA + _girdle[side]);
Transform3D ctrl = _targets[a.Hand];
Vector3 aim = ctrl * new Vector3(0f, 0f, WristOffset) + Vector3.Up * (shPhys.Y - sh.Y);
ArmSolver solver = _armSolvers[side];
solver.Solve(shPhys, aim, chestPhys.Basis, chestPhys.Basis.X * (side == 0 ? -1f : 1f),
ctrl.Basis, a.UpperLen, a.ForeLen, dt);
Vector3 d = solver.Wrist - shPhys;
Vector3 h = d.LengthSquared() > 1e-8f ? solver.Pole.Cross(d.Normalized()) : chestPhys.Basis.X;
h = h.LengthSquared() > 1e-8f ? h.Normalized() : chestPhys.Basis.X;
Vector3 u = (solver.Elbow - shPhys).Normalized(), f = (solver.Wrist - solver.Elbow).Normalized();
_targets[a.Upper] = new Transform3D(BoneBasis(u, h), shPhys + u * (a.UpperLen * 0.5f));
_targets[a.Fore] = new Transform3D(BoneBasis(f, h), solver.Elbow + f * (a.ForeLen * 0.5f));
}
}
}
private readonly ArmSolver[] _armSolvers = { new(), new() };
public Transform3D Target(RigidBody3D body) => _targets.TryGetValue(body, out Transform3D t) ? t : body.GlobalTransform;
// ---------------------------------------------------------------- per tick
@@ -379,7 +455,7 @@ public partial class PlayerRagdoll : Node3D
{
if (!Built)
return;
ComputeTargets(s, left, right);
ComputeTargets(s, left, right, physicalArms: true, dt);
foreach (RigidBody3D b in _bodies)
{
if (BodyMath.ResetIfNonFinite(b, Target(b), "PlayerRagdoll"))
@@ -401,14 +477,20 @@ public partial class PlayerRagdoll : Node3D
Muscle(_spine, _targets[Pelvis].Basis, _targets[Chest].Basis, dt);
Muscle(_neck, _targets[Chest].Basis, _targets[Head].Basis, dt);
if (Strength != _appliedStrength)
ApplyStrength();
Braced = false;
for (int side = 0; side < 2; side++)
{
Arm a = _arms[side];
ShoulderDrive(a, side, dt);
ElbowMuscle(a, dt);
HandGuide(a, _targets[a.Hand], side == 0 ? leftVelocity : rightVelocity);
Braced |= IsBraced(a);
}
GroundControl(pelvisTarget, floorY, dt);
LegsDrive(s);
_legVisuals.Tick(_footY, Grounded, !_legsSolid, s.YawBasis, dt);
}
// Muscle = PD on the joint's relative rotation toward the relative rotation of the two TARGET
@@ -461,6 +543,72 @@ public partial class PlayerRagdoll : Node3D
a.Elbow.SetParam(HingeJoint3D.Param.MotorTargetVelocity, HingeSign * w);
}
private void ApplyStrength()
{
_appliedStrength = Strength;
float k = Mathf.Max(0.05f, Strength);
SetAngularCap(_spine.Joint, SpineTorque * k);
SetAngularCap(_neck.Joint, NeckTorque * k);
foreach (Arm a in _arms)
{
SetAngularCap(a.Shoulder.Joint, ShoulderTorque * k);
SetAngularCap(a.Guide.Joint, HandGuideTorque * k);
a.Elbow.SetParam(HingeJoint3D.Param.MotorMaxImpulse, ElbowTorque * k);
}
}
private static void SetAngularCap(Generic6DofJoint3D j, float cap)
{
j.SetParamX(Generic6DofJoint3D.Param.AngularMotorForceLimit, cap);
j.SetParamY(Generic6DofJoint3D.Param.AngularMotorForceLimit, cap);
j.SetParamZ(Generic6DofJoint3D.Param.AngularMotorForceLimit, cap);
}
private static void SetLinearCap(Generic6DofJoint3D j, float cap)
{
j.SetParamX(Generic6DofJoint3D.Param.LinearMotorForceLimit, cap);
j.SetParamY(Generic6DofJoint3D.Param.LinearMotorForceLimit, cap);
j.SetParamZ(Generic6DofJoint3D.Param.LinearMotorForceLimit, cap);
}
// A hand pressing on or holding static world (a ledge, a wall, a grabbed bar).
private static bool IsBraced(Arm a)
{
if (a.Held is StaticBody3D)
return true;
foreach (Node3D n in a.Hand.GetCollidingBodies())
if (n is StaticBody3D)
return true;
return false;
}
// The lower-body collider hangs from the actual hips, its bottom never below a step above the
// feet; once it would have to slide far up into the torso (deep crouch, crawl) it stops colliding.
private void LegsDrive(TrueBodySolver s)
{
Transform3D pelvis = Pelvis.GlobalTransform;
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;
if (solid != _legsSolid)
{
_legsSolid = solid;
// The shape itself is disabled: Box3D keeps existing contacts when only the layer changes.
Legs.GetChild<CollisionShape3D>(0).SetDeferred(CollisionShape3D.PropertyName.Disabled, !solid);
}
if (!solid)
center = hip.Y; // tucked into the pelvis, out of the way
Vector3 desired = new(hip.X, center, hip.Z);
Quaternion frameA = Rot(Pelvis) * _legsDrive.FrameA;
SetLinearVelocity(_legsDrive.Joint, frameA.Inverse() * ((desired - Legs.GlobalPosition) * LegsKp));
Quaternion target = (Rot(Pelvis) * _legsDrive.FrameA).Inverse() * (s.YawBasis.GetRotationQuaternion() * _legsDrive.FrameB);
Quaternion current = frameA.Inverse() * (Rot(Legs) * _legsDrive.FrameB);
SetAngularVelocity(_legsDrive.Joint, (BodyMath.RotationError(target, current) * LegsKp).LimitLength(MuscleMaxSpin));
}
// Shoulder = one drive: its linear motor keeps the humeral head on the chest's shoulder point
// shifted by the True Body girdle offset (shrug/protraction), its angular motor is the
// shoulder muscle (same PD + feed-forward as Muscle, parent = chest).
@@ -474,7 +622,8 @@ public partial class PlayerRagdoll : Node3D
SetLinearVelocity(d.Joint, frameA.Inverse() * (err * GirdleKp));
Quaternion current = frameA.Inverse() * (Rot(a.Upper) * d.FrameB);
Quaternion target = (_targets[Chest].Basis.Orthonormalized().GetRotationQuaternion() * d.FrameA).Inverse()
// Arm targets are solved from the physical chest, so the physical chest is the parent frame.
Quaternion target = (chest.Basis.GetRotationQuaternion() * d.FrameA).Inverse()
* (_targets[a.Upper].Basis.Orthonormalized().GetRotationQuaternion() * d.FrameB);
Vector3 ff = d.HasPrev && dt > 0f ? (BodyMath.RotationError(target, d.PrevTarget) / dt).LimitLength(MuscleMaxSpin) : Vector3.Zero;
d.PrevTarget = target;
@@ -488,12 +637,24 @@ public partial class PlayerRagdoll : Node3D
Transform3D chest = Chest.GlobalTransform;
Vector3 anchor = chest * a.Guide.AnchorA;
Vector3 anchorVelocity = Chest.LinearVelocity + Chest.AngularVelocity.Cross(anchor - Chest.GlobalPosition);
Vector3 desired = (targetVelocity + (target.Origin - a.Hand.GlobalPosition) * HandGuideKp).LimitLength(HandGuideMaxSpeed);
Vector3 err = target.Origin - a.Hand.GlobalPosition;
Vector3 desired = (targetVelocity + err * HandGuideKp).LimitLength(HandGuideMaxSpeed);
Quaternion frameA = Rot(Chest) * a.Guide.FrameA;
Vector3 shoulder = a.Upper.GlobalTransform * a.Shoulder.AnchorB;
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;
float cap = Mathf.Lerp(HandGuideForce, HandStructuralForce, align) * Mathf.Max(0.05f, Strength);
if (Mathf.Abs(cap - a.GuideCap) > 1f)
{
a.GuideCap = cap;
SetLinearCap(a.Guide.Joint, cap);
}
SetLinearVelocity(a.Guide.Joint, frameA.Inverse() * (desired - anchorVelocity));
// Angular: hand rotation onto the controller rotation, as a relative spin vs the chest.
Vector3 err = BodyMath.RotationError(target.Basis.GetRotationQuaternion(), Rot(a.Hand));
Vector3 wWorld = (err * HandGuideAngularKp).LimitLength(MuscleMaxSpin * 2f) - Chest.AngularVelocity;
Vector3 rotErr = BodyMath.RotationError(target.Basis.GetRotationQuaternion(), Rot(a.Hand));
Vector3 wWorld = (rotErr * HandGuideAngularKp).LimitLength(MuscleMaxSpin * 2f) - Chest.AngularVelocity;
SetAngularVelocity(a.Guide.Joint, frameA.Inverse() * wWorld);
}
@@ -506,20 +667,42 @@ public partial class PlayerRagdoll : Node3D
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;
if (hit.Count > 0)
{
Vector3 point = (Vector3)hit["position"];
Vector3 normal = (Vector3)hit["normal"];
if (pos.Y - point.Y < hipAboveFloor + GroundProbeExtra && normal.Y > 0.6f)
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)
{
// The foot of a too-steep slope: keep standing on the ground we're on (the legs
// collider stops us); dropping support here would collapse the body.
Grounded = true;
GroundY = point.Y;
}
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 || !_legsSolid || pos.Y - point.Y > 0.6f * hipAboveFloor) && clear);
Grounded = true;
if (reachable)
_footY = point.Y;
}
}
Vector3 targetVelocity = _havePelvisPrev && dt > 0f ? (target.Origin - _pelvisTargetPrev) / dt : Vector3.Zero;
_pelvisTargetPrev = target.Origin;
// Feed-forward from the GOAL (pose + ground offset): when the play-space floor follows the
// ground, the pose target and the ground offset move by the same amount in opposite
// directions — differentiating the pose alone would launch the body.
Vector3 goalNow = target.Origin + Vector3.Up * (_footY - floorY);
Vector3 targetVelocity = _havePelvisPrev && dt > 0f ? (goalNow - _pelvisTargetPrev) / dt : Vector3.Zero;
_pelvisTargetPrev = goalNow;
_havePelvisPrev = true;
if (targetVelocity.Length() > 10f)
targetVelocity = Vector3.Zero;
@@ -529,13 +712,25 @@ 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);
_supportGoalY = goal.Y;
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);
Vector3 horizontal = new Vector3(force.X, 0f, force.Z).LimitLength(GroundFriction * support);
float carry = Mathf.Max(GroundFriction * support, _legsSolid ? 0f : CrawlCarry);
Vector3 horizontal = new Vector3(force.X, 0f, force.Z).LimitLength(carry);
Pelvis.ApplyCentralForce(horizontal + Vector3.Up * support);
}
else if (Braced)
{
// Hanging/mantling on the hands: the hips still drift after the head a little, so you can
// shift your weight over a ledge you're pressing yourself up onto.
Vector3 lead = target.Origin - pos;
lead.Y = 0f;
Vector3 v = Pelvis.LinearVelocity;
v.Y = 0f;
Pelvis.ApplyCentralForce(((lead * 20f - v * 8f) * BodyMass).LimitLength(BracedAirControl));
}
Vector3 rotErr = BodyMath.RotationError(target.Basis.GetRotationQuaternion(), Rot(Pelvis));
Vector3 spin = (rotErr * BalanceKp).LimitLength(MuscleMaxSpin);
@@ -576,20 +771,44 @@ 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);
else if (lead.Length() > FreeSlack)
correction = -lead * (1f - FreeSlack / lead.Length());
// 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
{
float slack = Grounded ? FreeSlack : Climbing ? AirSlack : FallSlack;
if (lead.Length() > slack)
correction = -lead * (1f - slack / lead.Length());
}
}
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;
return correction;
}
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)
{
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;
}
public void TeleportBy(Transform3D delta)
{
if (!Built)
@@ -601,6 +820,9 @@ public partial class PlayerRagdoll : Node3D
b.AngularVelocity = delta.Basis * b.AngularVelocity;
}
_pelvisTargetPrev = delta * _pelvisTargetPrev;
_footY += delta.Origin.Y;
foreach (RigidBody3D b in _bodies)
b.ResetPhysicsInterpolation();
}
public void SnapToPose(TrueBodySolver s, Transform3D left, Transform3D right)
@@ -620,11 +842,13 @@ public partial class PlayerRagdoll : Node3D
a.Hand.GlobalTransform = new Transform3D(hb, s.Wrist[side] - hb * new Vector3(0f, 0f, WristOffset));
}
_havePelvisPrev = false;
foreach (RigidBody3D b in _bodies)
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.
public void SetHeld(int side, RigidBody3D prop)
public void SetHeld(int side, PhysicsBody3D prop)
{
if (!Built)
return;
+23 -3
View File
@@ -38,6 +38,21 @@ public partial class PlayerRig : Node
private bool _rebuild = true;
public PlayerRagdoll Ragdoll => _ragdoll;
public static bool Spectator { get; private set; }
// Spectator = no body (fly with the old grab-drag movement); leaving it rebuilds the body at
// the current pose.
public static void SetSpectator(bool on)
{
Spectator = on;
AnchorMoveMode.Enabled = on;
if (_instance == null)
return;
if (on)
_instance._ragdoll.Clear();
_instance._rebuild = true;
GD.Print($"PlayerRig: spectator {(on ? "on" : "off")}");
}
public static RigidBody3D GetHandBody(XRController3D controller)
{
@@ -46,7 +61,7 @@ public partial class PlayerRig : Node
return _instance._ragdoll.Hand(controller == _instance._left ? 0 : 1);
}
public static void SetHeld(XRController3D controller, RigidBody3D prop)
public static void SetHeld(XRController3D controller, PhysicsBody3D prop)
{
if (_instance != null)
_instance._ragdoll.SetHeld(controller == _instance._left ? 0 : 1, prop);
@@ -65,7 +80,9 @@ public partial class PlayerRig : Node
_trueBody.ExternallySolved = true;
TrueBody.ShowDebug = false;
}
_ragdoll = new PlayerRagdoll { Name = "Ragdoll" };
// PlayerRig itself is excluded from physics interpolation (ghosts follow controllers per
// frame); the body is simulated, so it interpolates.
_ragdoll = new PlayerRagdoll { Name = "Ragdoll", PhysicsInterpolationMode = PhysicsInterpolationModeEnum.On };
AddChild(_ragdoll);
BodyProfile.Changed += _ => _rebuild = true;
for (int i = 0; i < 2; i++)
@@ -93,8 +110,11 @@ public partial class PlayerRig : Node
{
float dt = (float)delta;
BodyProfile profile = BodyProfile.Current;
if (profile == null)
if (profile == null || Spectator)
{
_haveLastOrigin = false;
return;
}
// 1. Someone else moved the play space discretely: bring the body along.
Transform3D origin = _origin.GlobalTransform;
+243
View File
@@ -0,0 +1,243 @@
using Godot;
using System.Collections.Generic;
// The body-physics sandbox (Nimso-sandbox style), built in code around the spawn/table area:
// EAST — mantling ledges (0.3 … 1.5 m), a 1 m platform with stairs, a walkable 18° ramp and a
// too-steep 40° one, a climbing wall with holds onto a 2.4 m top, a pull-up bar, monkey bars.
// WEST — physics toys: a plank bridge on two blocks (walk on it / knock it off), loose planks,
// a seesaw, a hanging swing board (grab it and hang), a crate pyramid, a heavy box,
// barrels, a balance beam.
// SOUTH — a crawl tunnel (0.6 m clearance), a duck-under bar (1.2 m) and a doorway.
// Static pieces are world (layer 1) and grabbable for climbing (PropGrabber grabs any static body
// not in "no_grab"); dynamic pieces are "prop"s (grab/push) and come back with Reset().
public partial class Playground : Node3D
{
private readonly List<(RigidBody3D Body, Transform3D Start)> _dynamic = new();
private const uint WorldLayer = 1, WorldMask = 0b11101, PropMask = 0b11101;
private static readonly Color Concrete = new(0.62f, 0.62f, 0.6f), Wood = new(0.72f, 0.55f, 0.33f),
DarkWood = new(0.5f, 0.36f, 0.2f), Metal = new(0.45f, 0.47f, 0.52f), Hold = new(0.9f, 0.45f, 0.15f),
Blue = new(0.3f, 0.45f, 0.75f), Green = new(0.35f, 0.6f, 0.35f), Red = new(0.7f, 0.25f, 0.2f);
public override void _Ready()
{
BuildEast();
BuildWest();
BuildSouth();
}
public void Reset()
{
foreach (var (body, start) in _dynamic)
{
if (!IsInstanceValid(body))
continue;
body.GlobalTransform = start;
body.LinearVelocity = Vector3.Zero;
body.AngularVelocity = Vector3.Zero;
body.ResetPhysicsInterpolation();
}
}
// ---------------------------------------------------------------- builders
private StaticBody3D Block(string name, Vector3 size, Vector3 center, Color color, Basis? basis = null)
{
StaticBody3D body = new() { Name = name, CollisionLayer = WorldLayer, CollisionMask = WorldMask, Transform = new Transform3D(basis ?? Basis.Identity, center) };
body.AddChild(new CollisionShape3D { Shape = new BoxShape3D { Size = size } });
body.AddChild(Mesh(new BoxMesh { Size = size }, color));
AddChild(body);
return body;
}
private StaticBody3D Bar(string name, Vector3 from, Vector3 to, float radius, Color color)
{
Vector3 axis = to - from;
float len = axis.Length();
Vector3 y = axis / len;
Vector3 x = Mathf.Abs(y.Y) < 0.99f ? Vector3.Up.Cross(y).Normalized() : Vector3.Right;
StaticBody3D body = new() { Name = name, CollisionLayer = WorldLayer, CollisionMask = WorldMask, Transform = new Transform3D(new Basis(x, y, x.Cross(y)), (from + to) * 0.5f) };
body.AddChild(new CollisionShape3D { Shape = new CylinderShape3D { Radius = radius, Height = len } });
body.AddChild(Mesh(new CylinderMesh { TopRadius = radius, BottomRadius = radius, Height = len, RadialSegments = 12 }, color));
AddChild(body);
return body;
}
private RigidBody3D Prop(string name, Shape3D shape, Mesh mesh, float mass, Vector3 center, Color color, Basis? basis = null, float friction = 0.8f)
{
RigidBody3D body = new()
{
Name = name,
Mass = mass,
Transform = new Transform3D(basis ?? Basis.Identity, center),
CollisionMask = PropMask,
ContactMonitor = true,
MaxContactsReported = 4,
PhysicsMaterialOverride = new PhysicsMaterial { Friction = friction, Bounce = 0.05f },
};
body.AddToGroup("prop");
body.AddChild(new CollisionShape3D { Shape = shape });
body.AddChild(Mesh(mesh, color));
AddChild(body);
_dynamic.Add((body, body.GlobalTransform));
return body;
}
private RigidBody3D BoxProp(string name, Vector3 size, float mass, Vector3 center, Color color, Basis? basis = null) =>
Prop(name, new BoxShape3D { Size = size }, new BoxMesh { Size = size }, mass, center, color, basis);
private static MeshInstance3D Mesh(Mesh mesh, Color color)
{
MeshInstance3D m = new() { Mesh = mesh };
m.SetSurfaceOverrideMaterial(0, new StandardMaterial3D { AlbedoColor = color, Roughness = 0.75f });
return m;
}
private void Sign(string text, Vector3 at)
{
AddChild(new Label3D
{
Text = text,
FontSize = 96,
PixelSize = 0.002f,
OutlineSize = 12,
Billboard = BaseMaterial3D.BillboardModeEnum.FixedY,
Position = at,
});
}
// ---------------------------------------------------------------- zones
private void BuildEast()
{
// Mantling ledges: walk up to one, put both hands on it and press yourself up.
float[] heights = { 0.3f, 0.6f, 0.9f, 1.2f, 1.5f };
for (int i = 0; i < heights.Length; i++)
{
float h = heights[i];
Block($"Ledge{h:0.0}", new Vector3(1.2f, h, 1.2f), new Vector3(4.5f + i * 1.9f, h * 0.5f, -8f), Concrete);
Sign($"{h:0.0} m", new Vector3(4.5f + i * 1.9f, h + 0.35f, -7.3f));
}
Sign("Mantle: both hands on the ledge, push down", new Vector3(8.3f, 2.4f, -8f));
// Platform 1 m high with stairs (0.17 m risers) on the south side and ramps on the north.
Block("Platform", new Vector3(3f, 1f, 3f), new Vector3(8f, 0.5f, -1.5f), Concrete);
for (int i = 0; i < 5; i++)
{
float top = 0.17f * (i + 1);
Block($"Stair{i}", new Vector3(1.2f, top, 0.3f), new Vector3(8f, top * 0.5f, 0.15f + (4 - i) * 0.3f), Concrete.Lightened(0.1f));
}
float walkable = Mathf.DegToRad(18f);
float rampLen = 1f / Mathf.Sin(walkable);
// Negative X rotation lifts the slab's +Z end, which touches the platform's north edge.
Block("RampWalkable", new Vector3(1.2f, 0.1f, rampLen), new Vector3(7f, 0.5f, -3f - Mathf.Cos(walkable) * rampLen * 0.5f),
Green, new Basis(Vector3.Right, -walkable));
float steep = Mathf.DegToRad(40f);
float steepLen = 1f / Mathf.Sin(steep);
Block("RampSteep", new Vector3(1.2f, 0.1f, steepLen), new Vector3(9f, 0.5f, -3f - Mathf.Cos(steep) * steepLen * 0.5f),
Red, new Basis(Vector3.Right, -steep));
Sign("18° ramp", new Vector3(7f, 1.6f, -4.5f));
Sign("40° (too steep)", new Vector3(9f, 1.6f, -4.5f));
// Climbing wall: holds on the west face, top at 2.4 m to climb onto.
Block("ClimbWall", new Vector3(1.5f, 2.4f, 3f), new Vector3(12.25f, 1.2f, -1.5f), Concrete.Darkened(0.15f));
RandomNumberGenerator rng = new() { Seed = 5 };
for (float y = 0.4f; y < 2.35f; y += 0.35f)
{
for (int k = 0; k < 4; k++)
{
float z = -2.7f + k * 0.8f + rng.RandfRange(-0.15f, 0.15f);
Block("Hold", new Vector3(0.08f, 0.06f, 0.22f), new Vector3(11.46f, y + rng.RandfRange(-0.06f, 0.06f), z), Hold);
}
}
Sign("Climbing wall: grip the holds", new Vector3(11f, 3f, -1.5f));
// Pull-up bar and monkey bars.
Bar("PostA", new Vector3(5f, 0f, 2.5f), new Vector3(5f, 2.25f, 2.5f), 0.05f, Metal);
Bar("PostB", new Vector3(6.4f, 0f, 2.5f), new Vector3(6.4f, 2.25f, 2.5f), 0.05f, Metal);
Bar("PullUpBar", new Vector3(5f, 2.1f, 2.5f), new Vector3(6.4f, 2.1f, 2.5f), 0.025f, Metal);
Sign("Pull-up bar", new Vector3(5.7f, 2.6f, 2.5f));
Bar("MonkeyRailL", new Vector3(5f, 2.25f, 4.5f), new Vector3(10f, 2.25f, 4.5f), 0.04f, Metal);
Bar("MonkeyRailR", new Vector3(5f, 2.25f, 5.1f), new Vector3(10f, 2.25f, 5.1f), 0.04f, Metal);
foreach (float x in new[] { 5f, 10f })
foreach (float z in new[] { 4.5f, 5.1f })
Bar("MonkeyPost", new Vector3(x, 0f, z), new Vector3(x, 2.25f, z), 0.05f, Metal);
for (float x = 5.4f; x < 9.8f; x += 0.4f)
Bar("Rung", new Vector3(x, 2.2f, 4.5f), new Vector3(x, 2.2f, 5.1f), 0.022f, Metal);
Sign("Monkey bars", new Vector3(7.5f, 2.8f, 4.8f));
}
private void BuildWest()
{
// Plank bridge: two blocks with a loose 9 kg plank across — walk on it, or knock it off.
Block("BridgeBlockA", new Vector3(1f, 0.8f, 1f), new Vector3(-4.5f, 0.4f, -5f), Concrete);
Block("BridgeBlockB", new Vector3(1f, 0.8f, 1f), new Vector3(-8f, 0.4f, -5f), Concrete);
BoxProp("BridgePlank", new Vector3(3.4f, 0.06f, 0.4f), 9f, new Vector3(-6.25f, 0.83f, -5f), Wood);
Sign("Plank bridge", new Vector3(-6.25f, 1.6f, -5f));
// Loose planks to pick up, carry, lean, swing (light enough to wield, long enough to lever).
for (int i = 0; i < 3; i++)
BoxProp($"Plank{i}", new Vector3(2f, 0.04f, 0.2f), 4f, new Vector3(-5f, 0.02f + i * 0.045f, -2.6f + i * 0.05f), Wood.Lightened(0.05f * i),
new Basis(Vector3.Up, 0.1f * i));
// Seesaw: a plank hinged on a pivot block.
Block("SeesawPivot", new Vector3(0.3f, 0.45f, 0.5f), new Vector3(-8.5f, 0.225f, -1.5f), Metal);
RigidBody3D seesaw = BoxProp("Seesaw", new Vector3(3.2f, 0.06f, 0.45f), 12f, new Vector3(-8.5f, 0.48f, -1.5f), DarkWood);
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),
};
AddChild(pivot);
Sign("Seesaw", new Vector3(-8.5f, 1.3f, -1.5f));
// Swing board: a 10 kg board hinged at its top edge from a 2.4 m beam — grab it and hang.
Bar("SwingPostA", new Vector3(-5f, 0f, 1.5f), new Vector3(-5f, 2.45f, 1.5f), 0.05f, Metal);
Bar("SwingPostB", new Vector3(-7f, 0f, 1.5f), new Vector3(-7f, 2.45f, 1.5f), 0.05f, Metal);
StaticBody3D beam = Bar("SwingBeam", new Vector3(-5f, 2.4f, 1.5f), new Vector3(-7f, 2.4f, 1.5f), 0.05f, Metal);
RigidBody3D board = BoxProp("SwingBoard", new Vector3(1.2f, 1.2f, 0.05f), 10f, new Vector3(-6f, 1.75f, 1.5f), Wood);
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),
};
AddChild(swing);
Sign("Hanging board", new Vector3(-6f, 2.9f, 1.5f));
// Crate pyramid (4 kg each), a heavy 25 kg box, barrels.
Vector3 basePos = new(-9.5f, 0f, 2f);
int n = 0;
for (int row = 0; row < 3; row++)
for (int col = 0; col < 3 - row; col++)
BoxProp($"Crate{n++}", Vector3.One * 0.4f, 4f, basePos + new Vector3((col + row * 0.5f) * 0.42f, 0.2f + row * 0.401f, 0f), Wood.Darkened(0.1f));
BoxProp("HeavyBox", Vector3.One * 0.7f, 25f, new Vector3(-9f, 0.35f, 4f), Blue);
for (int i = 0; i < 2; i++)
Prop($"Barrel{i}", new CylinderShape3D { Radius = 0.28f, Height = 0.85f }, new CylinderMesh { TopRadius = 0.28f, BottomRadius = 0.28f, Height = 0.85f }, 12f,
new Vector3(-7.5f + i * 0.7f, 0.425f, 4.5f), Red);
// Balance beam.
Block("BalanceBeam", new Vector3(0.12f, 0.3f, 4f), new Vector3(-11f, 0.15f, -2f), DarkWood);
Sign("Balance beam", new Vector3(-11f, 1.1f, -2f));
}
private void BuildSouth()
{
// Crawl tunnel: 0.6 m clearance, 3 m long.
Block("TunnelRoof", new Vector3(1.4f, 0.1f, 3f), new Vector3(-2.5f, 0.65f, 7f), Concrete);
Block("TunnelWallL", new Vector3(0.1f, 0.6f, 3f), new Vector3(-3.15f, 0.3f, 7f), Concrete);
Block("TunnelWallR", new Vector3(0.1f, 0.6f, 3f), new Vector3(-1.85f, 0.3f, 7f), Concrete);
Sign("Crawl tunnel (0.6 m)", new Vector3(-2.5f, 1.2f, 5.3f));
// Duck-under bar at 1.2 m and a doorway (1.9 × 0.8 m).
Bar("DuckPostA", new Vector3(1f, 0f, 7f), new Vector3(1f, 1.3f, 7f), 0.05f, Metal);
Bar("DuckPostB", new Vector3(2.6f, 0f, 7f), new Vector3(2.6f, 1.3f, 7f), 0.05f, Metal);
Bar("DuckBar", new Vector3(1f, 1.25f, 7f), new Vector3(2.6f, 1.25f, 7f), 0.03f, Metal);
Sign("Duck under (1.2 m)", new Vector3(1.8f, 1.8f, 7f));
Block("DoorL", new Vector3(0.2f, 2.1f, 0.3f), new Vector3(4.1f, 1.05f, 7f), Wood);
Block("DoorR", new Vector3(0.2f, 2.1f, 0.3f), new Vector3(5.1f, 1.05f, 7f), Wood);
Block("DoorTop", new Vector3(1.2f, 0.2f, 0.3f), new Vector3(4.6f, 2.0f, 7f), Wood);
Sign("Doorway", new Vector3(4.6f, 2.5f, 7f));
}
}
+1
View File
@@ -0,0 +1 @@
uid://dg665wra8wjhg
+227
View File
@@ -0,0 +1,227 @@
using Godot;
// Visual procedural legs for the ragdoll player (Nimso: "a very basic procedural leg system", feet
// "don't actually hold the player up" — ground control does). Feet stay planted on the ground and
// step, alternating, when the hips move or turn away from them; the stride leads with the body's
// velocity. Airborne the feet hang under the hips; low (crawling) they trail behind. Knees bend
// forward via a 2-bone solve. Physics-tick state (PlayerRagdoll.Step calls Tick), drawn in
// _Process from the interpolated pelvis. No collision — the lower-body collider is separate.
public partial class ProceduralLegs : Node3D
{
public const float ThighRatio = 0.245f, ShinRatio = 0.246f, AnkleRatio = 0.039f; // Winter, × stature
public const float HipHalfWidth = 0.09f;
public const float StanceHalfWidth = 0.11f;
public const float StepTrigger = 0.2f; // m from the ideal spot
public const float StepTurnTrigger = 0.6f; // rad
public const float StepLead = 0.25f; // s of velocity the new foothold leads by
public const float StepTimeSlow = 0.32f, StepTimeFast = 0.2f;
public const float StepHeight = 0.09f;
public const float SettleTime = 0.4f; // standing still this long → the trailing foot squares up
private struct Foot
{
public Vector3 Planted, From, To;
public float Yaw, FromYaw, ToYaw;
public float T; // step progress 0..1 (1 = planted)
public Vector3 Current;
public float CurrentYaw;
}
private readonly Foot[] _feet = new Foot[2];
private readonly MeshInstance3D[] _thigh = new MeshInstance3D[2], _shin = new MeshInstance3D[2], _foot = new MeshInstance3D[2];
private RigidBody3D _pelvis;
private Vector3 _hipLocal; // pelvis-body-local hip centre
private float _thighLen, _shinLen, _ankle, _scale;
private bool _initialized;
private int _lastStepped;
private float _stillTime;
public void Setup(RigidBody3D pelvis, Vector3 hipLocal, float stature, Color pants, Color shoes)
{
_pelvis = pelvis;
_hipLocal = hipLocal;
_thighLen = ThighRatio * stature;
_shinLen = ShinRatio * stature;
_ankle = AnkleRatio * stature;
_scale = stature / 1.75f;
PhysicsInterpolationMode = PhysicsInterpolationModeEnum.Off; // meshes are placed every frame
TopLevel = true;
for (int i = 0; i < 2; i++)
{
_thigh[i] = MakeMesh(new Vector3(0.13f * _scale, 1f, 0.14f * _scale), pants);
_shin[i] = MakeMesh(new Vector3(0.1f * _scale, 1f, 0.11f * _scale), pants);
_foot[i] = MakeMesh(new Vector3(0.1f * _scale, 0.07f * _scale, 0.25f * _scale), shoes);
}
_initialized = false;
}
private MeshInstance3D MakeMesh(Vector3 size, Color color)
{
MeshInstance3D m = new() { Mesh = new BoxMesh { Size = size } };
m.SetSurfaceOverrideMaterial(0, new StandardMaterial3D { AlbedoColor = color, Roughness = 0.7f });
AddChild(m);
return m;
}
private Vector3 Hip(Transform3D pelvis, int side) =>
pelvis * (_hipLocal + new Vector3(side == 0 ? -HipHalfWidth * _scale : HipHalfWidth * _scale, 0f, 0f));
private static float YawOf(Basis b)
{
Vector3 f = -b.Z;
return Mathf.Atan2(-f.X, -f.Z);
}
// groundY = where the feet stand (ground control's foot level); crawl = legs collider off.
public void Tick(float groundY, bool grounded, bool crawl, Basis yawBasis, float dt)
{
if (_pelvis == null)
return;
Transform3D pelvis = _pelvis.GlobalTransform;
Vector3 velocity = _pelvis.LinearVelocity;
velocity.Y = 0f;
float yaw = YawOf(yawBasis);
Vector3 back = yawBasis.Z;
for (int i = 0; i < 2; i++)
{
Vector3 hip = Hip(pelvis, i);
Vector3 side = yawBasis.X * (i == 0 ? -1f : 1f);
Vector3 ideal = new Vector3(hip.X, groundY, hip.Z) + side * (StanceHalfWidth - HipHalfWidth) * _scale;
if (crawl)
ideal += back * (_thighLen + _shinLen) * 0.8f;
if (!_initialized)
{
_feet[i] = new Foot { Planted = ideal, Current = ideal, To = ideal, Yaw = yaw, CurrentYaw = yaw, ToYaw = yaw, T = 1f };
continue;
}
ref Foot f = ref _feet[i];
if (!grounded)
{
// Hanging: feet dangle under the hips, a little forward, and replant on landing.
Vector3 hang = hip + Vector3.Down * (_thighLen + _shinLen) * 0.92f - back * 0.08f;
f.Current = f.Current.Lerp(hang, 1f - Mathf.Exp(-dt * 10f));
f.CurrentYaw = Mathf.LerpAngle(f.CurrentYaw, yaw, 1f - Mathf.Exp(-dt * 10f));
f.Planted = f.Current;
f.T = 1f;
continue;
}
if (f.T < 1f)
{
float speed = velocity.Length();
float duration = Mathf.Lerp(StepTimeSlow, StepTimeFast, Mathf.Clamp(speed / 2.5f, 0f, 1f));
f.T = Mathf.Min(1f, f.T + dt / duration);
f.To = new Vector3(f.To.X, groundY, f.To.Z);
float k = Mathf.SmoothStep(0f, 1f, f.T);
f.Current = f.From.Lerp(f.To, k) + Vector3.Up * (Mathf.Sin(Mathf.Pi * f.T) * (StepHeight + 0.03f * speed));
f.CurrentYaw = Mathf.LerpAngle(f.FromYaw, f.ToYaw, k);
if (f.T >= 1f)
{
f.Planted = f.To;
f.Yaw = f.ToYaw;
}
}
else
{
f.Planted = new Vector3(f.Planted.X, Mathf.MoveToward(f.Planted.Y, groundY, dt * 2f), f.Planted.Z);
f.Current = f.Planted;
f.CurrentYaw = f.Yaw;
}
}
if (!_initialized)
{
_initialized = true;
return;
}
if (!grounded)
return;
// One foot at a time: step the foot that is farthest from where it should be.
if (_feet[0].T >= 1f && _feet[1].T >= 1f)
{
float speed = velocity.Length();
_stillTime = speed < 0.05f ? _stillTime + dt : 0f;
int pick = -1;
float worst = 0f;
for (int i = 0; i < 2; i++)
{
Vector3 hip = Hip(pelvis, i);
Vector3 side = yawBasis.X * (i == 0 ? -1f : 1f);
Vector3 ideal = new Vector3(hip.X, groundY, hip.Z) + side * (StanceHalfWidth - HipHalfWidth) * _scale;
if (crawl)
ideal += back * (_thighLen + _shinLen) * 0.8f;
float off = new Vector2(_feet[i].Planted.X - ideal.X, _feet[i].Planted.Z - ideal.Z).Length();
float turn = Mathf.Abs(Mathf.AngleDifference(_feet[i].Yaw, yaw));
bool need = off > StepTrigger * _scale || turn > StepTurnTrigger || (_stillTime > SettleTime && off > 0.06f);
float score = off + turn * 0.2f + (i == _lastStepped ? -0.01f : 0f);
if (need && score > worst)
{
worst = score;
pick = i;
}
}
if (pick >= 0)
{
Vector3 hip = Hip(pelvis, pick);
Vector3 side = yawBasis.X * (pick == 0 ? -1f : 1f);
Vector3 to = new Vector3(hip.X, groundY, hip.Z) + side * (StanceHalfWidth - HipHalfWidth) * _scale + velocity * StepLead;
if (crawl)
to += back * (_thighLen + _shinLen) * 0.8f;
ref Foot f = ref _feet[pick];
f.From = f.Planted;
f.FromYaw = f.Yaw;
f.To = to;
f.ToYaw = yaw;
f.T = 0f;
_lastStepped = pick;
_stillTime = 0f;
}
}
}
public override void _Process(double delta)
{
if (_pelvis == null || !_initialized || !IsInstanceValid(_pelvis))
return;
Transform3D pelvis = _pelvis.GetGlobalTransformInterpolated();
Vector3 forward = -pelvis.Basis.Z;
forward.Y = 0f;
forward = forward.LengthSquared() > 1e-6f ? forward.Normalized() : Vector3.Forward;
for (int i = 0; i < 2; i++)
{
Vector3 hip = Hip(pelvis, i);
Vector3 ankle = _feet[i].Current + Vector3.Up * _ankle;
// 2-bone solve, knee bending forward.
Vector3 d = ankle - hip;
float reach = _thighLen + _shinLen;
float len = Mathf.Clamp(d.Length(), 0.05f, reach * 0.999f);
Vector3 dir = d.LengthSquared() > 1e-8f ? d.Normalized() : Vector3.Down;
ankle = hip + dir * len;
float along = (_thighLen * _thighLen - _shinLen * _shinLen + len * len) / (2f * len);
float radius = Mathf.Sqrt(Mathf.Max(_thighLen * _thighLen - along * along, 0f));
Vector3 pole = forward - dir * forward.Dot(dir);
pole = pole.LengthSquared() > 1e-6f ? pole.Normalized() : forward;
Vector3 knee = hip + dir * along + pole * radius;
Place(_thigh[i], hip, knee);
Place(_shin[i], knee, ankle);
Basis footBasis = new Basis(Vector3.Up, _feet[i].CurrentYaw);
_foot[i].GlobalTransform = new Transform3D(footBasis, _feet[i].Current + Vector3.Up * (0.035f * _scale) + footBasis * new Vector3(0f, 0f, -0.06f * _scale));
}
}
private static void Place(MeshInstance3D mesh, Vector3 from, Vector3 to)
{
Vector3 seg = to - from;
float len = seg.Length();
if (len < 1e-4f)
return;
Vector3 y = seg / len;
Vector3 x = Mathf.Abs(y.Y) < 0.99f ? Vector3.Up.Cross(y).Normalized() : Vector3.Right;
Vector3 z = x.Cross(y);
// Box mesh is 1 m tall along Y: stretch it to the segment.
mesh.GlobalTransform = new Transform3D(new Basis(x, y * len, z), (from + to) * 0.5f);
}
}
+1
View File
@@ -0,0 +1 @@
uid://bwbp1b87b5qxx
+36 -13
View File
@@ -11,6 +11,8 @@ public partial class PropGrabber : Node
private const float BreakTime = 0.25f;
private const float BreakDistanceBoth = 0.4f;
private const float GripRipDistance = 0.8f;
private const float WorldGrabRadius = 0.05f; // palm sphere for grabbing static world (ledges, bars, holds)
public const string NoGrabGroup = "no_grab"; // static bodies that can't be grabbed (the floor)
private static PropGrabber _instance;
@@ -18,7 +20,7 @@ public partial class PropGrabber : Node
private XRController3D _right;
private PhysicsArms _arms;
private readonly RigidBody3D[] _held = new RigidBody3D[2];
private readonly PhysicsBody3D[] _held = new PhysicsBody3D[2]; // a prop, or static world (climbing)
private readonly ConeTwistJoint3D[] _joints = new ConeTwistJoint3D[2];
private readonly Vector3[] _localAnchor = new Vector3[2];
private readonly uint[] _grabSeq = new uint[2];
@@ -59,7 +61,7 @@ public partial class PropGrabber : Node
if (grip && !_gripPrev[hand] && _held[hand] == null)
{
RigidBody3D prop = FindProp(controller, hand);
PhysicsBody3D prop = FindProp(controller, hand);
RigidBody3D handBody = PhysicsHands.GetBody(controller);
if (prop != null && handBody != null)
{
@@ -68,26 +70,27 @@ public partial class PropGrabber : Node
Vector3 pivot = PalmPoint(handBody, hand);
_joints[hand] = MakeWeld(handBody, prop, pivot, RigidWeldBias);
_localAnchor[hand] = prop.GlobalTransform.AffineInverse() * pivot;
_arms?.SetHeld(hand, prop);
_arms?.SetHeld(hand, prop as RigidBody3D);
PlayerRig.SetHeld(controller, prop);
GD.Print($"PropGrabber: grabbed '{prop.Name}' ({prop.Mass:0.###} kg, {(hand == 0 ? "left" : "right")}{(_held[other] == prop ? ", two-handed" : "")})");
string what = prop is RigidBody3D rb ? $"{rb.Mass:0.###} kg" : "static world";
GD.Print($"PropGrabber: grabbed '{prop.Name}' ({what}, {(hand == 0 ? "left" : "right")}{(_held[other] == prop ? ", two-handed" : "")})");
}
}
if (!grip && _held[hand] != null)
{
RigidBody3D prop = _held[hand];
PhysicsBody3D prop = _held[hand];
_held[hand] = null;
DestroyJoint(hand);
if (IsInstanceValid(prop))
GD.Print($"PropGrabber: released '{prop.Name}' at {prop.LinearVelocity.Length():F2} m/s");
if (prop is RigidBody3D released && IsInstanceValid(released))
GD.Print($"PropGrabber: released '{released.Name}' at {released.LinearVelocity.Length():F2} m/s");
}
_gripPrev[hand] = grip;
}
private void HoldUpdate(XRController3D controller, int hand, float delta)
{
RigidBody3D body = _held[hand];
PhysicsBody3D body = _held[hand];
if (body == null)
return;
if (!IsInstanceValid(body))
@@ -148,7 +151,7 @@ public partial class PropGrabber : Node
}
}
private int PrimaryHand(RigidBody3D body)
private int PrimaryHand(PhysicsBody3D body)
{
int primary = -1;
for (int hand = 0; hand < 2; hand++)
@@ -159,7 +162,7 @@ public partial class PropGrabber : Node
return primary;
}
private ConeTwistJoint3D MakeWeld(RigidBody3D handBody, RigidBody3D prop, Vector3 pivot, float bias)
private ConeTwistJoint3D MakeWeld(RigidBody3D handBody, PhysicsBody3D prop, Vector3 pivot, float bias)
{
ConeTwistJoint3D weld = new()
{
@@ -175,7 +178,7 @@ public partial class PropGrabber : Node
return weld;
}
private void BreakGrip(int hand, RigidBody3D body, string reason)
private void BreakGrip(int hand, PhysicsBody3D body, string reason)
{
if (_held[hand] != body)
return;
@@ -199,7 +202,7 @@ public partial class PropGrabber : Node
private static float PalmOffset(int hand) => hand == 0 ? PalmGrabOffset : -PalmGrabOffset;
private RigidBody3D FindProp(XRController3D controller, int hand)
private PhysicsBody3D FindProp(XRController3D controller, int hand)
{
RigidBody3D handBody = PhysicsHands.GetBody(controller);
if (handBody == null)
@@ -208,7 +211,27 @@ public partial class PropGrabber : Node
RigidBody3D nearest = NearestProp(palm, GrabRadius);
if (nearest != null)
return nearest;
return NearestProp(handBody.GlobalPosition, CenterGrabRadius);
nearest = NearestProp(handBody.GlobalPosition, CenterGrabRadius);
if (nearest != null)
return nearest;
return WorldUnderPalm(palm, handBody.GetWorld3D());
}
// Climbing: any static body touching the palm (except the "no_grab" group) can be held.
private static StaticBody3D WorldUnderPalm(Vector3 palm, World3D world)
{
PhysicsShapeQueryParameters3D query = new()
{
Shape = new SphereShape3D { Radius = WorldGrabRadius },
Transform = new Transform3D(Basis.Identity, palm),
CollisionMask = 1,
};
foreach (Godot.Collections.Dictionary hit in world.DirectSpaceState.IntersectShape(query, 8))
{
if (hit["collider"].AsGodotObject() is StaticBody3D body && !body.IsInGroup(NoGrabGroup))
return body;
}
return null;
}
private RigidBody3D NearestProp(Vector3 point, float radius)
+203
View File
@@ -57,6 +57,17 @@ public partial class RagdollSpike : Node3D
_phases.Add(("12 kg held at arm's length sags more", () => SetupWeight(close: false), _ => { }, 3f, () => FinishWeight(close: false)));
_phases.Add(("teleport + snap turn carry the body", Rest, TickTeleport, 2f, FinishTeleport));
_phases.Add(("random motion soak", Rest, TickRandom, 8f, FinishSoak));
_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 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(("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)));
Next();
}
@@ -448,6 +459,198 @@ public partial class RagdollSpike : Node3D
return _finite && NewSnaps == 0 && _maxEyeErr < 0.05f && _maxHandErr < 0.06f;
}
// ---------------------------------------------------------------- obstacles, mantling, climbing, crawling
private const float ObstacleFront = -0.55f;
// A box from ObstacleFront 2 m deep (wide), `height` tall.
private void SetupObstacle(float height, float depth = 2f)
{
Rest();
_stage.AddChild(MakeStatic(new Vector3(3f, height, depth), new Vector3(0f, height * 0.5f, ObstacleFront - depth * 0.5f), 29));
}
private void SetupTableObstacle()
{
Rest();
_stage.AddChild(MakeStatic(new Vector3(1.4f, 0.05f, 0.8f), new Vector3(0f, 0.725f, ObstacleFront - 0.4f), 29));
foreach (float x in new[] { -0.65f, 0.65f })
foreach (float z in new[] { ObstacleFront - 0.05f, ObstacleFront - 0.75f })
_stage.AddChild(MakeStatic(new Vector3(0.06f, 0.7f, 0.06f), new Vector3(x, 0.35f, z), 29));
}
private bool FinishBlockedLow()
{
GD.Print($"RAGDOLL: obstacle front z {ObstacleFront}: pelvis z {_ragdoll.Pelvis.GlobalPosition.Z:F3}, feet y {_ragdoll.GroundY:F3}, origin y {_origin.Origin.Y:F3}, camera min z {_minCamZ:F3}, snaps {NewSnaps}");
return _finite && NewSnaps == 0 && _ragdoll.GroundY < 0.1f && _origin.Origin.Y < 0.1f && _ragdoll.Pelvis.GlobalPosition.Z > ObstacleFront - 0.3f;
}
private bool FinishStep()
{
GD.Print($"RAGDOLL: step: feet y {_ragdoll.GroundY:F3}, origin y {_origin.Origin.Y:F3}, pelvis z {_ragdoll.Pelvis.GlobalPosition.Z:F3}, snaps {NewSnaps}");
return _finite && NewSnaps == 0 && Mathf.Abs(_ragdoll.GroundY - 0.25f) < 0.03f && _origin.Origin.Y > 0.2f && _ragdoll.Pelvis.GlobalPosition.Z < ObstacleFront - 0.3f;
}
private float _maxPelvisRise, _lastRise;
// 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);
}
private StaticBody3D _bar;
private const float BarY = 1.95f, BarHangZ = -0.15f;
private float _barPelvisStart;
private bool _barGrabbed;
private void SetupBarHang()
{
Rest();
_bar = new StaticBody3D { Position = new Vector3(0f, BarY, BarHangZ), CollisionMask = 29 };
_bar.AddChild(new CollisionShape3D { Shape = new CylinderShape3D { Radius = 0.025f, Height = 2f }, RotationDegrees = new Vector3(0f, 0f, 90f) });
_stage.AddChild(_bar);
_barGrabbed = false;
_maxPelvisRise = 0f;
}
// Reach up to the bar, grip it (the PropGrabber weld), then pull the controllers down.
private void TickPullUp(bool both)
{
float t = _t;
float pull = Mathf.Clamp((t - 0.8f) / 1.5f, 0f, 1f);
float y = Mathf.Lerp(BarY - 0.03f, BarY - 0.75f, pull);
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)
{
_barGrabbed = true;
_barPelvisStart = _ragdoll.Pelvis.GlobalPosition.Y;
for (int side = both ? 0 : 1; side < 2; side++)
{
RigidBody3D hand = _ragdoll.Hand(side);
ConeTwistJoint3D weld = new() { Position = hand.GlobalPosition, NodeA = hand.GetPath(), NodeB = _bar.GetPath(), SwingSpan = 0f, TwistSpan = 0f, Bias = 0f };
_stage.AddChild(weld);
_ragdoll.SetHeld(side, _bar);
}
}
if (_barGrabbed)
_maxPelvisRise = Mathf.Max(_maxPelvisRise, _ragdoll.Pelvis.GlobalPosition.Y - _barPelvisStart);
}
private float _twoHandRise;
private bool FinishPullUp(bool both)
{
GD.Print($"RAGDOLL: pull-up ({(both ? "two hands" : "one hand")}): pelvis rose {_maxPelvisRise:F3} m, braced {_ragdoll.Braced}, snaps {NewSnaps}");
_ragdoll.SetHeld(0, null);
_ragdoll.SetHeld(1, null);
if (both)
{
_twoHandRise = _maxPelvisRise;
return _finite && NewSnaps == 0 && _maxPelvisRise > 0.25f;
}
return _finite && NewSnaps == 0 && _maxPelvisRise < _twoHandRise * 0.6f;
}
private const float TunnelStart = -1.0f, TunnelEnd = -3.5f;
private float _minCamY;
private void SetupTunnel()
{
Rest();
float len = TunnelStart - TunnelEnd;
float mid = (TunnelStart + TunnelEnd) * 0.5f;
_stage.AddChild(MakeStatic(new Vector3(1.4f, 0.1f, len), new Vector3(0f, 0.65f, mid), 29));
_stage.AddChild(MakeStatic(new Vector3(0.1f, 0.6f, len), new Vector3(-0.65f, 0.3f, mid), 29));
_stage.AddChild(MakeStatic(new Vector3(0.1f, 0.6f, len), new Vector3(0.65f, 0.3f, mid), 29));
_minCamY = float.MaxValue;
}
// Get down on hands and knees (eyes ~0.4 m, looking ahead), then stick-crawl forward.
private void TickCrawl(float dt)
{
float k = Mathf.SmoothStep(0f, 1f, Mathf.Min(_t / 2f, 1f));
_headLocal = new Transform3D(new Basis(Vector3.Right, -0.35f * k), new Vector3(0f, Mathf.Lerp(EyeHeight, 0.42f, k), Mathf.Lerp(0f, -0.25f, k)));
_leftLocal = new Transform3D(Aim(Vector3.Forward + Vector3.Down), new Vector3(-0.2f, Mathf.Lerp(1.0f, 0.12f, k), Mathf.Lerp(-0.3f, -0.45f, k)));
_rightLocal = new Transform3D(Aim(Vector3.Forward + Vector3.Down), new Vector3(0.2f, Mathf.Lerp(1.0f, 0.12f, k), Mathf.Lerp(-0.3f, -0.45f, k)));
_stickVelocity = _t > 2.3f ? new Vector3(0f, 0f, -0.8f) : Vector3.Zero;
_measureFrom = float.MaxValue;
if (_t > 2.3f)
_minCamY = Mathf.Min(_minCamY, Camera.Origin.Y);
}
private bool FinishCrawl()
{
GD.Print($"RAGDOLL: crawl: camera min z {_minCamZ:F3} (tunnel {TunnelStart}..{TunnelEnd}), eye y {_ragdoll.Eye.Y:F2}, pelvis y {_ragdoll.Pelvis.GlobalPosition.Y:F2}, origin y {_origin.Origin.Y:F3}, snaps {NewSnaps}");
return _finite && NewSnaps == 0 && _minCamZ < TunnelStart - 1.5f && Mathf.Abs(_origin.Origin.Y) < 0.1f;
}
// Start standing on a 1 m platform whose edge is 0.5 m ahead.
private void SetupLedgeTop()
{
Rest();
_origin = new Transform3D(Basis.Identity, new Vector3(0f, 1f, 0f));
_stage.AddChild(MakeStatic(new Vector3(3f, 1f, 3f), new Vector3(0f, 0.5f, 1f), 29));
}
private bool FinishDrop()
{
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;
}
// A slope rising away (−Z) from ObstacleFront up to 1 m, then a platform.
private void SetupRamp(float degrees)
{
Rest();
float a = Mathf.DegToRad(degrees);
float len = 1f / Mathf.Sin(a);
float run = Mathf.Cos(a) * len;
_stage.AddChild(new StaticBody3D { CollisionMask = 29, Transform = new Transform3D(new Basis(Vector3.Right, a), new Vector3(0f, 0.5f - 0.05f / Mathf.Cos(a), ObstacleFront - run * 0.5f)) });
_stage.GetChild<StaticBody3D>(_stage.GetChildCount() - 1).AddChild(new CollisionShape3D { Shape = new BoxShape3D { Size = new Vector3(3f, 0.1f, len) } });
_stage.AddChild(MakeStatic(new Vector3(3f, 1f, 3f), new Vector3(0f, 0.5f, ObstacleFront - run - 1.5f), 29));
}
private bool FinishRamp(bool walkable)
{
GD.Print($"RAGDOLL: ramp: feet y {_ragdoll.GroundY:F3}, origin y {_origin.Origin.Y:F3}, pelvis {_ragdoll.Pelvis.GlobalPosition}, snaps {NewSnaps}");
bool up = _ragdoll.GroundY > 0.6f;
// Stopped at the slope's foot: on the floor, view level, not flung away.
bool stopped = Mathf.Abs(_origin.Origin.Y) < 0.1f && _ragdoll.Pelvis.GlobalPosition.Length() < 2.5f;
return _finite && NewSnaps == 0 && (walkable ? up : !up && stopped);
}
private readonly RandomNumberGenerator _rng = new() { Seed = 11 };
private Vector3 _ra, _rb, _rc;
+24 -11
View File
@@ -14,6 +14,10 @@ public sealed class TrueBodySolver
public const float ChestLeanMax = 0.75f; // rad (~43°) at a deep crouch
public const float CrouchRange = 0.5f; // crouch factor 1 when eyes are this fraction of standing eye height lower
public const float HeadPitchToChest = 0.25f;
// Crawl: when the head gets so low that the hips would go through the floor, the trunk tips
// further (pivoting at the hips, hips kicking back) up to nearly horizontal (Nimso: lower = crawl).
public const float CrawlLeanMax = 1.45f; // rad (~83°)
public const float HipMinHeight = 0.15f; // hip joint above the floor when crawling
public const float PelvisFrequency = 2.5f; // Hz, critically damped follow → hip lag/sway
public const float GirdleReachStart = 0.98f; // fraction of arm length where the shoulder starts to help
public const float GirdleSmoothRate = 15f;
@@ -100,18 +104,27 @@ public sealed class TrueBodySolver
Crouch = Mathf.Clamp((standingPivot - pivotAboveFloor) / (CrouchRange * p.EyeHeight), 0f, 1f);
float headPitchDown = Mathf.Max(0f, -Mathf.Asin(Mathf.Clamp(headFwd.Y, -1f, 1f)));
float lean = Crouch * ChestLeanMax + headPitchDown * HeadPitchToChest;
Lean = lean;
ChestBasis = yawBasis * new Basis(Vector3.Right, -lean);
// Neck: from the head pivot down along the average of chest-up and head-up, sized so a
// standing, level head puts the shoulder joints at the measured drop below the eyes.
float trunk = TrunkRatio * p.Stature;
float neckLen = Mathf.Max(0.05f, p.ShoulderDrop + EyeToNeckPivot.Y - NeckBaseAboveShoulders);
Vector3 neckDir = (ChestBasis.Y + headBasis.Y).Normalized();
NeckBase = NeckPivot - neckDir * neckLen;
// Keep the shoulder line behind the eyes by the anthropometric offset (neck pivot already
// supplies part of it).
UpperChest = NeckBase - ChestBasis.Y * NeckBaseAboveShoulders
+ ChestBasis * new Vector3(0f, 0f, ShouldersBehindEyes - EyeToNeckPivot.Z);
Vector3 neckDir = Vector3.Up;
for (int iter = 0; iter < 3; iter++)
{
ChestBasis = yawBasis * new Basis(Vector3.Right, -lean);
// Neck: from the head pivot down along the average of chest-up and head-up, sized so a
// standing, level head puts the shoulder joints at the measured drop below the eyes.
neckDir = (ChestBasis.Y + headBasis.Y).Normalized();
NeckBase = NeckPivot - neckDir * neckLen;
// Keep the shoulder line behind the eyes by the anthropometric offset (neck pivot already
// supplies part of it).
UpperChest = NeckBase - ChestBasis.Y * NeckBaseAboveShoulders
+ ChestBasis * new Vector3(0f, 0f, ShouldersBehindEyes - EyeToNeckPivot.Z);
float room = (UpperChest.Y - floorY - HipMinHeight) / trunk;
float crawl = Mathf.Acos(Mathf.Clamp(room, Mathf.Cos(CrawlLeanMax), 1f));
if (crawl <= lean + 1e-3f)
break;
lean = crawl;
}
Lean = lean;
// Pelvis: hangs below the upper chest along the trunk; follows through a critically damped
// spring so it lags quick head motion (hip sway) instead of being rigidly attached.
+4
View File
@@ -61,6 +61,7 @@ public partial class WorldPanel : Node3D
_menu.ResetPropsRequested += () =>
{
_propShop.Respawn();
GetNodeOrNull<Playground>("../../Playground")?.Reset();
_menu.Log("Props reset");
};
_menu.PingRequested += () =>
@@ -75,6 +76,9 @@ public partial class WorldPanel : Node3D
_click = MakeTone(900f, 25, 0.35f, 1f);
_ping = MakeTone(1320f, 4, 0.3f, 3f);
_menu.ClickVolumeSlider.ValueChanged += v => _click.VolumeDb = Mathf.LinearToDb((float)v / 70f);
_menu.StrengthChanged += v => PlayerRagdoll.Strength = v;
_menu.SmoothTurnToggled += on => PlayerRig.SmoothTurn = on;
_menu.SpectatorToggled += PlayerRig.SetSpectator;
SetPanelActive(false);
}