From 0b8f2131c17ef7f84ee71c6bf040218f4479285f Mon Sep 17 00:00:00 2001 From: Foxyz Date: Fri, 2 Oct 2026 10:10:38 +0300 Subject: [PATCH] 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 --- research/ragdoll-player-step2.md | 23 +++++ scripts/PlayerRagdoll.cs | 19 +++- scripts/Playground.cs | 8 +- scripts/PropGrabber.cs | 25 ++++- scripts/PropShop.cs | 7 +- scripts/RagdollSpike.cs | 168 +++++++++++++++++++++++++------ 6 files changed, 208 insertions(+), 42 deletions(-) diff --git a/research/ragdoll-player-step2.md b/research/ragdoll-player-step2.md index 250af82..b172494 100644 --- a/research/ragdoll-player-step2.md +++ b/research/ragdoll-player-step2.md @@ -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. diff --git a/scripts/PlayerRagdoll.cs b/scripts/PlayerRagdoll.cs index fecb846..330a115 100644 --- a/scripts/PlayerRagdoll.cs +++ b/scripts/PlayerRagdoll.cs @@ -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) { diff --git a/scripts/Playground.cs b/scripts/Playground.cs index edadfa8..b689705 100644 --- a/scripts/Playground.cs +++ b/scripts/Playground.cs @@ -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("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)); diff --git a/scripts/PropGrabber.cs b/scripts/PropGrabber.cs index e7b9cb1..9d767c8 100644 --- a/scripts/PropGrabber.cs +++ b/scripts/PropGrabber.cs @@ -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. diff --git a/scripts/PropShop.cs b/scripts/PropShop.cs index 135d8dc..e30a64e 100644 --- a/scripts/PropShop.cs +++ b/scripts/PropShop.cs @@ -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"); diff --git a/scripts/RagdollSpike.cs b/scripts/RagdollSpike.cs index 459881c..a985cb7 100644 --- a/scripts/RagdollSpike.cs +++ b/scripts/RagdollSpike.cs @@ -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("SwingBoard"), seesaw = _playground.GetNode("Seesaw"), plank = _playground.GetNode("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.