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

+11 -3
View File
@@ -123,7 +123,9 @@ device only; the editor has an XR simulator for dev. Trackers/action map: see `x
ruin immersion and are hard to aim at distance; they are the fallback, not the default. ruin immersion and are hard to aim at distance; they are the fallback, not the default.
- **Hand tracking**: cheap spike early, full support later. Interaction logic must not assume - **Hand tracking**: cheap spike early, full support later. Interaction logic must not assume
controllers — controller tip and fingertip feed the same cursor concept. controllers — controller tip and fingertip feed the same cursor concept.
- **Locomotion** (implemented, NEEDS FIXING per owner 2026-10-01 — two-hand turn not - **Locomotion** (SUPERSEDED 2026-10-02 by the ragdoll player's stick locomotion in `PlayerRig`; the grab-drag
mode below survives only as the menu's Spectator mode; teleport markers still work and carry the body.
Historical notes: implemented, NEEDS FIXING per owner 2026-10-01 — two-hand turn not
2-anchor based, two-hand simultaneous move weird, teleport + lasers broken/experimental; 2-anchor based, two-hand simultaneous move weird, teleport + lasers broken/experimental;
fix-or-replace rides on the body-physics track decision, owner candidate replacement = fix-or-replace rides on the body-physics track decision, owner candidate replacement =
physics thumbstick movement): physics thumbstick movement):
@@ -297,8 +299,14 @@ device only; the editor has an XR simulator for dev. Trackers/action map: see `x
discrete origin jumps carry the body). It REPLACED PhysicsHands/PhysicsTorso/PhysicsArms/BodyRig in discrete origin jumps carry the body). It REPLACED PhysicsHands/PhysicsTorso/PhysicsArms/BodyRig in
`main.tscn` (legacy files kept for their spikes). Record + measured rules: `main.tscn` (legacy files kept for their spikes). Record + measured rules:
`research/ragdoll-player-step2.md` — **never put a light (< ~1 kg) body between two strong motors** (Box3D `research/ragdoll-player-step2.md` — **never put a light (< ~1 kg) body between two strong motors** (Box3D
one-iteration substeps oscillate). Regression `scenes/ragdoll_spike.tscn`. Next: procedural legs, 144 Hz, one-iteration substeps oscillate). Regression `scenes/ragdoll_spike.tscn`. **Step 2.1 (owner playtest fixes):**
climbing. Owner scope: thumbstick locomotion only, no jump, avatar scaling and NPCs later. step rule + lower-body collider (no walking onto tables), structural arm strength (two-hand mantles/pull-ups,
one hand can't), crawl pose, slope limit 35°, climbing on any static body (group `no_grab` excluded = the floor),
`ProceduralLegs`, `Playground.cs` sandbox, menu strength slider / spectator (old drag movement, no body) / snap-
smooth turn, **physics 144 Hz with physics interpolation** (`Main.PhysicsRate`; XROrigin3D, TrueBody, PlayerRig
are interpolation-OFF because they move per frame — anything new moved in `_Process` must be excluded too).
Rule learned: play-space corrections must never feed ground control's feed-forward (runaway feedback).
Owner scope: thumbstick locomotion only, no jump, avatar scaling and NPCs later.
10. **Body physics Stage 2 — PHYSICAL ARMS, implemented 2026-10-02, awaiting owner playtest.** 10. **Body physics Stage 2 — PHYSICAL ARMS, implemented 2026-10-02, awaiting owner playtest.**
`PhysicsArms.cs`: per side an upper-arm + forearm RigidBody3D (2.0 / 1.2 kg, layer 5) that `PhysicsArms.cs`: per side an upper-arm + forearm RigidBody3D (2.0 / 1.2 kg, layer 5) that
PD-chase their analytic 2-bone IK pose, solved each physics tick from the PHYSICAL chest PD-chase their analytic 2-bone IK pose, solved each physics tick from the PHYSICAL chest
+24 -13
View File
@@ -92,7 +92,22 @@ TERMINOLOGY (owner-corrected — use precisely):
collision exceptions), so grabbing works unchanged. Hand ghosts at > 10 cm lag. Rebuilds on recalibration. Full collision exceptions), so grabbing works unchanged. Hand ghosts at > 10 cm lag. Rebuilds on recalibration. Full
design + measured findings: `research/ragdoll-player-step2.md` (incl. the light-link oscillation rule). design + measured findings: `research/ragdoll-player-step2.md` (incl. the light-link oscillation rule).
Regression `scenes/ragdoll_spike.tscn` "RAGDOLL: overall PASS" (stand, reach, swings, stick + room-scale wall, Regression `scenes/ragdoll_spike.tscn` "RAGDOLL: overall PASS" (stand, reach, swings, stick + room-scale wall,
bar blocked/crouch under, lean over table, crate push, 12 kg close vs far, teleport + snap turn, random soak). bar blocked/crouch under, lean over table, crate push, 12 kg close vs far, teleport + snap turn, random soak,
knee-high box + table block walking, 0.25 m step up, two-hand mantle onto 0.9 m vs one hand fails, pull-up on a
grabbed bar two vs one hand, crawl through a 0.6 m tunnel, walk off a 1 m ledge, 18° ramp up / 40° ramp stops).
**Step 2.1 (owner playtest fixes, same day)** — see `research/ragdoll-player-step2.md` "Step 2.1": step rule
(MaxStepHeight 0.35 + back-edge ray) and a lower-body `Legs` collider (blocks knee-high things, tucks when
crawling/mantling, shape DISABLED not layer-cleared), STRUCTURAL hand strength (40 → 400 N with the axial share of
the error), arm aim from the physical shoulder horizontally / True Body pose vertically, crawl pose in
`TrueBodySolver` (trunk tips to 83° keeping the hips ≥ 0.15 m), crawl carry, MaxSlope 35°, `Climbing` (hand on
static or arms lifting) = the only state that drags the view vertically, ground feed-forward that ignores play-space
corrections (a runaway at 7 m/s otherwise), `ProceduralLegs` visuals, static-world grabs (climbing), menu strength
slider / spectator / snap-smooth toggles, 144 Hz + physics interpolation (XR origin, TrueBody, PlayerRig excluded).
- **Playground.cs** — the body sandbox around the table (east climbing/mantling, west physics toys, south
crawl/duck/door); dynamic pieces are "prop"s reset by the menu's Reset props. `tools/playground_shots.tscn` renders
overview PNGs (`godot-mono --path . tools/playground_shots.tscn -- <dir>`).
- **AnchorMoveMode.cs** is now SPECTATOR-only (`AnchorMoveMode.Enabled`, menu → Player → Spectator), where the
ragdoll is cleared; leaving rebuilds it at the current pose.
- **LEGACY (not in main.tscn since step 2; kept for their spikes): PhysicsHands / PhysicsTorso / PhysicsArms / BodyRig.** - **LEGACY (not in main.tscn since step 2; kept for their spikes): PhysicsHands / PhysicsTorso / PhysicsArms / BodyRig.**
- **PhysicsHands.cs** — physical palm bodies chasing the controller poses: 0.4 kg boxes (flat slab, - **PhysicsHands.cs** — physical palm bodies chasing the controller poses: 0.4 kg boxes (flat slab,
thumb-side face), gravity off, CCD, **layer 4 (player hands)/mask 1|4** — NOT colliding with the thumb-side face), gravity off, CCD, **layer 4 (player hands)/mask 1|4** — NOT colliding with the
@@ -372,11 +387,10 @@ Floor StaticBody mask 1|3|4|5 (collision_mask 29).
## 5. Known issues / open questions ## 5. Known issues / open questions
- **Ragdoll player (step 2) — unvalidated in-headset.** Known gaps (details in - **Ragdoll player (step 2.1) — owner playtested step 2 ("feels amazing for the most part"); 2.1 fixes are
`research/ragdoll-player-step2.md`): no legs yet; shoulder has no cone limit; grabs are props only (no unvalidated in-headset.** Remaining gaps: shoulder has no cone limit (fork addition); strength values are
climbing on static geometry); strength values are human-scale guesses; physics still at the headset rate human-scale guesses (menu slider); stick-crawling is a comfort concession (Nimso pulls with hands only); legs are
(144 Hz not switched); legacy anchor-grab movement now fights gravity (the body falls, the view follows), so visual only. Legacy spikes (arm/grab/body/collapse) pin 90 Hz because the chase bodies were tuned there.
it only pulls you along the ground; teleport markers still work (the body is carried).
- **Body Stage 1 chest collapse — ROOT-CAUSED + FIXED (2026-10-01), awaiting owner re-playtest.** - **Body Stage 1 chest collapse — ROOT-CAUSED + FIXED (2026-10-01), awaiting owner re-playtest.**
The first in-headset session NaN'd the chest ("head detaching from the body"). Cause was The first in-headset session NaN'd the chest ("head detaching from the body"). Cause was
@@ -522,13 +536,10 @@ Floor StaticBody mask 1|3|4|5 (collision_mask 29).
## 7. Next steps (in order) ## 7. Next steps (in order)
0. **Owner playtest of body step 2 (the ragdoll player)** — stand, reach, push props, grab/hold (strength is now 0. **Owner playtest of body step 2.1 + the playground** — mantle the ledges (both hands on top, press down, lean
human-scale: heavy things only close to the body), walk with the left stick into walls/the table, snap turn in), climb the wall holds / pull-up bar / monkey bars (grip = grab static), crawl the tunnel, walk ramps/stairs,
with the right, crouch. Read the log after ("PlayerRagdoll: ... snapped" = a safety reset). Tuning knobs: planks/seesaw/swing board, strength slider, spectator mode, smooth turn. Read the log after. Next body work
`PlayerRagdoll` strength/gain constants, `PlayerRig.MoveSpeed/SmoothTurn`. Step 1 (calibration, True Body) after feedback: shoulder cone limit (fork), Nimso flight-phase gait, NPC on the same ragdoll.
was validated by the owner ("seems normal"); `TrueBody.ShowDebug` is off now that the ragdoll is the visual.
Next body steps: procedural legs, 144 Hz (needs physics interpolation + origin moves in physics ticks),
climbing (weld to static), one global strength scalar.
1. **Owner re-playtest of the body (chest fix + Stage 2 arms)** — the Stage-1 collapse is 1. **Owner re-playtest of the body (chest fix + Stage 2 arms)** — the Stage-1 collapse is
fixed (`collapse_spike` 49/49); feel is unvalidated in-headset. Read the log after. fixed (`collapse_spike` 49/49); feel is unvalidated in-headset. Read the log after.
2. **Locomotion decision** — owner report 2026-10-01: two-hand anchor turning broken (not 2. **Locomotion decision** — owner report 2026-10-01: two-hand anchor turning broken (not
+2 -1
View File
@@ -26,7 +26,8 @@ project/assembly_name="VR Messs"
[physics] [physics]
common/physics_ticks_per_second=90 common/physics_ticks_per_second=144
common/physics_interpolation=true
common/max_physics_steps_per_frame=14 common/max_physics_steps_per_frame=14
3d/physics_engine="Box3D Physics" 3d/physics_engine="Box3D Physics"
+29
View File
@@ -63,3 +63,32 @@ body), `scripts/PlayerRig.cs` (XR driver + stick locomotion), regression `scenes
`AnchorMoveMode`/`Locomotion` move the XR origin in `_Process`, which would jitter under interpolation. `AnchorMoveMode`/`Locomotion` move the XR origin in `_Process`, which would jitter under interpolation.
- **Anchor-grab / teleport legacy locomotion** still drive the origin. Teleports carry the body. Grab-flying upward - **Anchor-grab / teleport legacy locomotion** still drive the origin. Teleports carry the body. Grab-flying upward
now fights gravity (the body falls and the view follows it), so it effectively only pulls you along the ground. now fights gravity (the body falls and the view follows it), so it effectively only pulls you along the ground.
## Step 2.1 — owner playtest fixes + sandbox (2026-10-02)
Owner playtest of step 2: "feels amazing for the most part". It found three problems:
- You could walk onto the table: the body detached for a second and wobbled before settling.
- You couldn't climb onto the table by pushing yourself up.
- You couldn't crawl under the table.
Changes and the measured reasons:
| Change | Why (RagdollSpike evidence) |
|---|---|
| **Step rule**: ground more than `MaxStepHeight` 0.35 m above the feet isn't stood on unless the hips got up there: airborne/landing, legs tucked, or hips > 60% of hip height above it, and a second ray 12 cm behind the hips also finds it | The single pelvis ray treated a 0.725 m table under the hips as a "step" → walking onto the table |
| **Lower-body collider** (`Legs`, 6 kg box from the hips down to a step above the feet, DRIVE from the pelvis). It slides up when crouching, and its shape is **disabled** (not just layer-cleared) and tucked into the pelvis when crawling or when the arms lift the body (mantle) | Knee-high obstacles and tables now block; steps ≤ 0.35 m pass under it. Box3D kept the old contact when only the collision layer changed, which jammed a crawl against a tunnel roof |
| **Structural hand strength**: guide cap = lerp(40 N, 400 N, axial share of the hand error), with a 3 cm deadband | Force along the arm (pressing down on a ledge, hanging, carrying at your side) is bone-strong; across the arm it stays weak. Measured: two-hand mantle onto 0.9 m ✓, one hand ✗ (5 cm). Pull-up on a grabbed bar: two hands lift the pelvis 0.69 m, one hand 0.24 m. 12 kg at arm's length still sags (6.6 cm vs 0.9 cm close) |
| **Arm aim from the physical shoulder**, horizontal only: the arm reaches for the real hand when the body is held away horizontally (ledge, lean-over), but keeps its True Body pose vertically | Purely camera-relative arms left the hands 26 cm short when the body was held back at a ledge. Purely physical arms fold to wherever a sagging chest is and stop being pillars, so the crawl collapsed (eye 0.14 m vs 0.42). The split fixes both |
| **Ground feed-forward from the GOAL** (pose + ground offset) | When the floor follows up onto a ledge, pose and offset move opposite; differentiating the pose alone launched the body 1 m up |
| **Crawl pose** (`TrueBodySolver`): when the hips would go below 0.15 m, the trunk tips toward ~83°, hips kick back; ragdoll hips take the full lean in a crawl | Crouch lean capped at 43° put the hips through the floor, so the head could never get low |
| **Crawl carry** 300 N + torso friction 0.3 | With the torso resting on the floor, μ·weight exceeded the support-scaled carry, so stick crawling stalled (Nimso has no stick crawl at all; ours is a comfort concession) |
| **Braced air control** 150 N, **AirSlack** 0.05 m | While mantling/hanging, the hips drift after the head; airborne, the view rides the body tightly |
| **Climbing grabs**: `PropGrabber` welds the hand to any `StaticBody3D` under the palm (5 cm sphere) except group `no_grab` (the floor) | Pull-ups, ledges, climbing-wall holds, monkey bars |
| **Procedural legs** (`ProceduralLegs.cs`): visual only. Planted feet, alternate stepping on 20 cm / 34° error, stride leads velocity by 0.25 s, 0.2–0.32 s steps; airborne they dangle, crawling they trail | Nimso: feet don't hold the body up |
| **144 Hz physics + physics interpolation**. `XROrigin3D`, `TrueBody` and `PlayerRig` (ghosts) are excluded; the ragdoll is explicitly interpolated. Legacy chase-body spikes pin 90 Hz | Owner's earlier choice; the old origin-moving code runs per frame, so its subtree must not interpolate |
| **Strength slider** (0.25–3×, scales every muscle + hand guides), **Spectator** toggle (no body, the old grab-drag flight), **Turning** snap/smooth toggle | Owner: drag movement "doesn't fit this anymore", keep it as a spectator mode |
| **Playground** (`Playground.cs`): mantling ledges 0.3–1.5 m, a 1 m platform with stairs, an 18° ramp and a 40° ramp, a climbing wall, a pull-up bar, monkey bars, a plank bridge, loose planks, a seesaw, a hanging swing board, a crate pyramid, a 25 kg box, barrels, a balance beam, a 0.6 m crawl tunnel, a duck bar, a doorway. "Reset props" resets it | Owner: a Nimso-sandbox-like level |
Ground steeper than `MaxSlopeDegrees` 35° gives no support, so the 40° ramp can't be walked up; the 18° one can.
Still open: the shoulder cone limit (needs a fork addition). `tools/playground_shots.tscn` renders overview PNGs for
layout checks.
+11 -4
View File
@@ -1,4 +1,4 @@
[gd_scene load_steps=20 format=3] [gd_scene load_steps=21 format=3]
[ext_resource type="Script" path="res://scripts/Main.cs" id="1_main"] [ext_resource type="Script" path="res://scripts/Main.cs" id="1_main"]
[ext_resource type="PackedScene" path="res://scenes/teleport_marker.tscn" id="2_marker"] [ext_resource type="PackedScene" path="res://scenes/teleport_marker.tscn" id="2_marker"]
@@ -10,6 +10,7 @@
[ext_resource type="Script" path="res://scripts/Calibration.cs" id="12_calibration"] [ext_resource type="Script" path="res://scripts/Calibration.cs" id="12_calibration"]
[ext_resource type="Script" path="res://scripts/TrueBody.cs" id="13_truebody"] [ext_resource type="Script" path="res://scripts/TrueBody.cs" id="13_truebody"]
[ext_resource type="Script" path="res://scripts/PlayerRig.cs" id="14_playerrig"] [ext_resource type="Script" path="res://scripts/PlayerRig.cs" id="14_playerrig"]
[ext_resource type="Script" path="res://scripts/Playground.cs" id="15_playground"]
[sub_resource type="ProceduralSkyMaterial" id="ProceduralSkyMaterial_sky"] [sub_resource type="ProceduralSkyMaterial" id="ProceduralSkyMaterial_sky"]
sky_horizon_color = Color(0.66, 0.72, 0.81, 1) sky_horizon_color = Color(0.66, 0.72, 0.81, 1)
@@ -27,10 +28,10 @@ albedo_color = Color(0.35, 0.35, 0.38, 1)
[sub_resource type="PlaneMesh" id="PlaneMesh_floor"] [sub_resource type="PlaneMesh" id="PlaneMesh_floor"]
material = SubResource("Material_floor") material = SubResource("Material_floor")
size = Vector2(10, 10) size = Vector2(40, 40)
[sub_resource type="BoxShape3D" id="BoxShape_floor"] [sub_resource type="BoxShape3D" id="BoxShape_floor"]
size = Vector3(10, 0.1, 10) size = Vector3(40, 0.1, 40)
[sub_resource type="SphereMesh" id="SphereMesh_hand"] [sub_resource type="SphereMesh" id="SphereMesh_hand"]
radius = 0.04 radius = 0.04
@@ -55,7 +56,7 @@ directional_shadow_max_distance = 10.0
shadow_bias = 0.04 shadow_bias = 0.04
shadow_normal_bias = 0.5 shadow_normal_bias = 0.5
[node name="Floor" type="StaticBody3D" parent="."] [node name="Floor" type="StaticBody3D" parent="." groups=["no_grab"]]
collision_mask = 29 collision_mask = 29
[node name="FloorShape" type="CollisionShape3D" parent="Floor"] [node name="FloorShape" type="CollisionShape3D" parent="Floor"]
@@ -66,6 +67,7 @@ shape = SubResource("BoxShape_floor")
mesh = SubResource("PlaneMesh_floor") mesh = SubResource("PlaneMesh_floor")
[node name="XROrigin3D" type="XROrigin3D" parent="."] [node name="XROrigin3D" type="XROrigin3D" parent="."]
physics_interpolation_mode = 2
[node name="XRCamera3D" type="XRCamera3D" parent="XROrigin3D"] [node name="XRCamera3D" type="XRCamera3D" parent="XROrigin3D"]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1.7, 0) transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1.7, 0)
@@ -106,6 +108,9 @@ script = ExtResource("3_locomotion")
[node name="AnchorMoveMode" type="Node" parent="."] [node name="AnchorMoveMode" type="Node" parent="."]
script = ExtResource("5_anchormove") script = ExtResource("5_anchormove")
[node name="Playground" type="Node3D" parent="."]
script = ExtResource("15_playground")
[node name="PropShop" type="Node3D" parent="."] [node name="PropShop" type="Node3D" parent="."]
script = ExtResource("6_propshop") script = ExtResource("6_propshop")
@@ -116,9 +121,11 @@ script = ExtResource("7_propgrabber")
script = ExtResource("12_calibration") script = ExtResource("12_calibration")
[node name="TrueBody" type="Node3D" parent="."] [node name="TrueBody" type="Node3D" parent="."]
physics_interpolation_mode = 2
script = ExtResource("13_truebody") script = ExtResource("13_truebody")
[node name="PlayerRig" type="Node" parent="."] [node name="PlayerRig" type="Node" parent="."]
physics_interpolation_mode = 2
script = ExtResource("14_playerrig") script = ExtResource("14_playerrig")
[node name="FadeLayer" type="CanvasLayer" parent="."] [node name="FadeLayer" type="CanvasLayer" parent="."]
+12
View File
@@ -1,7 +1,12 @@
using Godot; 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 partial class AnchorMoveMode : Node
{ {
public static bool Enabled;
private const float GripThreshold = 0.6f; private const float GripThreshold = 0.6f;
private XROrigin3D _origin; private XROrigin3D _origin;
@@ -25,6 +30,13 @@ public partial class AnchorMoveMode : Node
public override void _Process(double delta) 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 leftGrip = _left.GetFloat("grip") > GripThreshold && !PropGrabber.IsGrabbing(_left);
bool rightGrip = _right.GetFloat("grip") > GripThreshold && !PropGrabber.IsGrabbing(_right); 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() 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 }; 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) }); floor.AddChild(new CollisionShape3D { Shape = new BoxShape3D { Size = new Vector3(20f, 0.1f, 20f) }, Position = new Vector3(0f, -0.05f, 0f) });
AddChild(floor); AddChild(floor);
+3
View File
@@ -53,6 +53,9 @@ public partial class BodySpike : Node3D
public override void _Ready() 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(); BuildWorld();
_head = new Node3D { Position = new Vector3(0f, 1.6f, 0f) }; _head = new Node3D { Position = new Vector3(0f, 1.6f, 0f) };
AddChild(_head); AddChild(_head);
+3
View File
@@ -58,6 +58,9 @@ public partial class CollapseSpike : Node3D
public override void _Ready() 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: // 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. // every scenario, seated or not, uses the same standing 1.65 m-eye profile.
BodyProfile.Current = BodyProfile.Estimate(1.65f); BodyProfile.Current = BodyProfile.Estimate(1.65f);
+3
View File
@@ -89,6 +89,9 @@ public partial class GrabSpike : Node3D
public override void _Ready() 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) }; StaticBody3D floor = new() { Position = new Vector3(0, -0.5f, 0) };
floor.AddChild(new CollisionShape3D { Shape = new BoxShape3D { Size = new Vector3(20, 1, 20) } }); floor.AddChild(new CollisionShape3D { Shape = new BoxShape3D { Size = new Vector3(20, 1, 20) } });
AddChild(floor); AddChild(floor);
+6 -1
View File
@@ -14,6 +14,8 @@ public partial class Main : Node3D
[Export] [Export]
public int MaximumRefreshRate { get; set; } = 90; public int MaximumRefreshRate { get; set; } = 90;
public const int PhysicsRate = 144;
private OpenXRInterface _xrInterface; private OpenXRInterface _xrInterface;
private bool _xrIsFocused; private bool _xrIsFocused;
@@ -70,7 +72,10 @@ public partial class Main : Node3D
currentRefreshRate = newRate; 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() private void OnSessionVisible()
+58
View File
@@ -18,6 +18,9 @@ public partial class MainMenu : PanelContainer
public event Action PingRequested; public event Action PingRequested;
public event Action CloseRequested; public event Action CloseRequested;
public event Action Clicked; 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 TabContainer Tabs { get; private set; }
public Button ReturnToSpawnButton { 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 CloseButton { get; private set; }
public Button HeaderCloseButton { get; private set; } public Button HeaderCloseButton { get; private set; }
public HSlider ClickVolumeSlider { 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; } public ScrollContainer LogScroll { get; private set; }
private Label _fpsLabel; private Label _fpsLabel;
@@ -116,6 +122,39 @@ public partial class MainMenu : PanelContainer
_calibrationStatus.AddThemeColorOverride("font_color", new Color(0.6f, 0.6f, 0.65f)); _calibrationStatus.AddThemeColorOverride("font_color", new Color(0.6f, 0.6f, 0.65f));
calibRow.AddChild(_calibrationStatus); calibRow.AddChild(_calibrationStatus);
player.AddChild(calibRow); 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); Tabs.AddChild(player);
// World // World
@@ -180,6 +219,25 @@ public partial class MainMenu : PanelContainer
return page; 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) private Button MakeButton(string text, Action action)
{ {
Button button = new() Button button = new()
+14
View File
@@ -302,6 +302,20 @@ public partial class MenuSpike : Node3D
await TouchPress(1, Center(_menu.RecalibrateButton)); await TouchPress(1, Center(_menu.RecalibrateButton));
Check("touch: Recalibrate called once", stub.Calls == 1, $"(calls={stub.Calls})"); 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. // 8) Header Close via touch.
await TouchPress(1, Center(_menu.HeaderCloseButton)); await TouchPress(1, Center(_menu.HeaderCloseButton));
Check("touch: Close fired once", _close == 1, $"(count={_close})"); 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, // Hand guide (chest→hand drive): pulls the hand onto the controller; capped so the arm,
// not the guide, carries real loads. // not the guide, carries real loads.
public const float HandGuideKp = 40f, HandGuideForce = 40f, HandGuideMaxSpeed = 10f; 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; public const float HandGuideAngularKp = 40f, HandGuideTorque = 25f;
// Ground control (world-space support on the pelvis, only while grounded). // Ground control (world-space support on the pelvis, only while grounded).
public const float GroundFrequency = 4f; // Hz 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 LegStrength = 2f; // max support, × body weight
public const float GroundFriction = 1f; // horizontal force ≤ this × support 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 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 // 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). // let the arms' reaction torques set the whole trunk swinging).
public const float BalanceKp = 10f; public const float BalanceKp = 10f;
@@ -48,6 +68,8 @@ public partial class PlayerRagdoll : Node3D
// Play-space reconciliation. // Play-space reconciliation.
public const float CameraRadius = 0.03f; // sphere swept from the head's eye to the camera 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 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 GroundFollowRate = 8f;
public const float ResetDistance = 1.5f; // pelvis this far from its target → snap the whole body to the pose public const float ResetDistance = 1.5f; // pelvis this far from its target → snap the whole body to the pose
// Geometry. // Geometry.
@@ -92,8 +114,9 @@ public partial class PlayerRagdoll : Node3D
public HingeJoint3D Elbow; public HingeJoint3D Elbow;
public Drive Shoulder, Guide; public Drive Shoulder, Guide;
public float UpperLen, ForeLen; public float UpperLen, ForeLen;
public RigidBody3D Held;
public float PrevFlexTarget = -1f; public float PrevFlexTarget = -1f;
public float GuideCap = HandGuideForce;
public PhysicsBody3D Held;
} }
public RigidBody3D Pelvis { get; private set; } public RigidBody3D Pelvis { get; private set; }
@@ -101,7 +124,11 @@ public partial class PlayerRagdoll : Node3D
public RigidBody3D Head { get; private set; } public RigidBody3D Head { get; private set; }
public bool Built { get; private set; } public bool Built { get; private set; }
public bool Grounded { 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 Vector3 Eye => Head.GlobalTransform * _eyeLocal;
public BodyProfile Profile { get; private set; } public BodyProfile Profile { get; private set; }
public int Snaps { get; private set; } // safety resets (NaN / too far) — should stay 0 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 Vector3 _pelvisCenterLocal, _chestCenterLocal, _headCenterLocal; // from each body's proximal joint, in its basis
private readonly Dictionary<RigidBody3D, Transform3D> _targets = new(); private readonly Dictionary<RigidBody3D, Transform3D> _targets = new();
private readonly Vector3[] _girdle = new Vector3[2]; // True Body shoulder offset, chest frame 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 Vector3 _pelvisTargetPrev;
private bool _havePelvisPrev; private bool _havePelvisPrev;
@@ -157,14 +192,21 @@ public partial class PlayerRagdoll : Node3D
_eyeLocal -= headCenterFromPivot; // eye relative to the head body's centre _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); 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)); 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, Chest = MakeBody("Chest", new BoxShape3D { Size = chestSize }, 0.216f * m, BodyLayer, WorldMask, shirt,
c * new Transform3D(Basis.Identity, lumbar + _chestCenterLocal)); c * new Transform3D(Basis.Identity, lumbar + _chestCenterLocal));
Head = MakeBody("Head", new BoxShape3D { Size = HeadSize * s }, 0.081f * m, BodyLayer, WorldMask, skin, Head = MakeBody("Head", new BoxShape3D { Size = HeadSize * s }, 0.081f * m, BodyLayer, WorldMask, skin,
c * new Transform3D(Basis.Identity, neckPivot + headCenterFromPivot)); c * new Transform3D(Basis.Identity, neckPivot + headCenterFromPivot));
// The neck: a box hanging under the head, same body. // The neck: a box hanging under the head, same body.
Vector3 neckCenter = (neckBase + neckPivot) * 0.5f - (neckPivot + headCenterFromPivot); 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 }); 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 }; 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 }); 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 }; StaticBody3D anchor = new() { Name = "BalanceAnchor", CollisionLayer = 0, CollisionMask = 0 };
AddChild(anchor); AddChild(anchor);
_balance = MakeDrive(anchor, Pelvis, Pelvis.GlobalTransform, 0f, BalanceTorque); _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; _balanceCap = BalanceTorque;
_spine = MakeBall(Pelvis, Chest, c * lumbar, c.Basis * TwistAlong(Vector3.Up), Mathf.DegToRad(60f), Mathf.DegToRad(35f), SpineKp, SpineTorque); _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; Built = true;
_havePelvisPrev = false; _havePelvisPrev = false;
_appliedStrength = 1f;
_legsSolid = true;
ApplyStrength();
SnapToPose(pose, left, right); 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)"); 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)); 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 }); 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 } }; MeshInstance3D mesh = new() { Mesh = new BoxMesh { Size = box.Size } };
mesh.SetSurfaceOverrideMaterial(0, new StandardMaterial3D { AlbedoColor = color, Roughness = 0.6f }); 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 // 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 // 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). // 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 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 lumbar = s.UpperChest - chest.Y * (_trunk - _lumbar);
Vector3 hip = lumbar - pelvis.Y * _lumbar; Vector3 hip = lumbar - pelvis.Y * _lumbar;
_targets[Pelvis] = new Transform3D(pelvis, hip + pelvis * _pelvisCenterLocal); _targets[Pelvis] = new Transform3D(pelvis, hip + pelvis * _pelvisCenterLocal);
_targets[Chest] = new Transform3D(chest, lumbar + chest * _chestCenterLocal); _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 // 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). // rotation that puts the body's eye on the camera (the True Body bends the neck at two points).
Vector3 eyeDir = s.HeadBasis * _neckBaseToEye; 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.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.Fore] = new Transform3D(BoneBasis(fore, hinge), el + fore * (a.ForeLen * 0.5f));
_targets[a.Hand] = (side == 0 ? left : right).Orthonormalized(); _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; public Transform3D Target(RigidBody3D body) => _targets.TryGetValue(body, out Transform3D t) ? t : body.GlobalTransform;
// ---------------------------------------------------------------- per tick // ---------------------------------------------------------------- per tick
@@ -379,7 +455,7 @@ public partial class PlayerRagdoll : Node3D
{ {
if (!Built) if (!Built)
return; return;
ComputeTargets(s, left, right); ComputeTargets(s, left, right, physicalArms: true, dt);
foreach (RigidBody3D b in _bodies) foreach (RigidBody3D b in _bodies)
{ {
if (BodyMath.ResetIfNonFinite(b, Target(b), "PlayerRagdoll")) 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(_spine, _targets[Pelvis].Basis, _targets[Chest].Basis, dt);
Muscle(_neck, _targets[Chest].Basis, _targets[Head].Basis, dt); Muscle(_neck, _targets[Chest].Basis, _targets[Head].Basis, dt);
if (Strength != _appliedStrength)
ApplyStrength();
Braced = false;
for (int side = 0; side < 2; side++) for (int side = 0; side < 2; side++)
{ {
Arm a = _arms[side]; Arm a = _arms[side];
ShoulderDrive(a, side, dt); ShoulderDrive(a, side, dt);
ElbowMuscle(a, dt); ElbowMuscle(a, dt);
HandGuide(a, _targets[a.Hand], side == 0 ? leftVelocity : rightVelocity); HandGuide(a, _targets[a.Hand], side == 0 ? leftVelocity : rightVelocity);
Braced |= IsBraced(a);
} }
GroundControl(pelvisTarget, floorY, dt); 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 // 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); 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 // 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 // shifted by the True Body girdle offset (shrug/protraction), its angular motor is the
// shoulder muscle (same PD + feed-forward as Muscle, parent = chest). // 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)); SetLinearVelocity(d.Joint, frameA.Inverse() * (err * GirdleKp));
Quaternion current = frameA.Inverse() * (Rot(a.Upper) * d.FrameB); 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); * (_targets[a.Upper].Basis.Orthonormalized().GetRotationQuaternion() * d.FrameB);
Vector3 ff = d.HasPrev && dt > 0f ? (BodyMath.RotationError(target, d.PrevTarget) / dt).LimitLength(MuscleMaxSpin) : Vector3.Zero; Vector3 ff = d.HasPrev && dt > 0f ? (BodyMath.RotationError(target, d.PrevTarget) / dt).LimitLength(MuscleMaxSpin) : Vector3.Zero;
d.PrevTarget = target; d.PrevTarget = target;
@@ -488,12 +637,24 @@ public partial class PlayerRagdoll : Node3D
Transform3D chest = Chest.GlobalTransform; Transform3D chest = Chest.GlobalTransform;
Vector3 anchor = chest * a.Guide.AnchorA; Vector3 anchor = chest * a.Guide.AnchorA;
Vector3 anchorVelocity = Chest.LinearVelocity + Chest.AngularVelocity.Cross(anchor - Chest.GlobalPosition); 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; 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)); SetLinearVelocity(a.Guide.Joint, frameA.Inverse() * (desired - anchorVelocity));
// Angular: hand rotation onto the controller rotation, as a relative spin vs the chest. // 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 rotErr = BodyMath.RotationError(target.Basis.GetRotationQuaternion(), Rot(a.Hand));
Vector3 wWorld = (err * HandGuideAngularKp).LimitLength(MuscleMaxSpin * 2f) - Chest.AngularVelocity; Vector3 wWorld = (rotErr * HandGuideAngularKp).LimitLength(MuscleMaxSpin * 2f) - Chest.AngularVelocity;
SetAngularVelocity(a.Guide.Joint, frameA.Inverse() * wWorld); SetAngularVelocity(a.Guide.Joint, frameA.Inverse() * wWorld);
} }
@@ -506,20 +667,42 @@ public partial class PlayerRagdoll : Node3D
float hipAboveFloor = target.Origin.Y - floorY; float hipAboveFloor = target.Origin.Y - floorY;
var query = PhysicsRayQueryParameters3D.Create(pos, pos + Vector3.Down * (hipAboveFloor + GroundProbeExtra + 0.5f), WorldMask); var query = PhysicsRayQueryParameters3D.Create(pos, pos + Vector3.Down * (hipAboveFloor + GroundProbeExtra + 0.5f), WorldMask);
Godot.Collections.Dictionary hit = GetWorld3D().DirectSpaceState.IntersectRay(query); Godot.Collections.Dictionary hit = GetWorld3D().DirectSpaceState.IntersectRay(query);
bool wasGrounded = Grounded;
Grounded = false; Grounded = false;
if (hit.Count > 0) if (hit.Count > 0)
{ {
Vector3 point = (Vector3)hit["position"]; Vector3 point = (Vector3)hit["position"];
Vector3 normal = (Vector3)hit["normal"]; 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; 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; // Feed-forward from the GOAL (pose + ground offset): when the play-space floor follows the
_pelvisTargetPrev = target.Origin; // 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; _havePelvisPrev = true;
if (targetVelocity.Length() > 10f) if (targetVelocity.Length() > 10f)
targetVelocity = Vector3.Zero; 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. // Pose heights are relative to the tracking floor; stand on the ground actually under us.
Vector3 goal = target.Origin + Vector3.Up * (GroundY - floorY); Vector3 goal = target.Origin + Vector3.Up * (GroundY - floorY);
_supportGoalY = goal.Y;
float w = Mathf.Tau * GroundFrequency; float w = Mathf.Tau * GroundFrequency;
Vector3 accel = (goal - pos) * (w * w) + (targetVelocity - Pelvis.LinearVelocity) * (2f * GroundDamping * w); Vector3 accel = (goal - pos) * (w * w) + (targetVelocity - Pelvis.LinearVelocity) * (2f * GroundDamping * w);
Vector3 force = accel * BodyMass; Vector3 force = accel * BodyMass;
float support = Mathf.Clamp(force.Y + weight, 0f, LegStrength * weight); 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); 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 rotErr = BodyMath.RotationError(target.Basis.GetRotationQuaternion(), Rot(Pelvis));
Vector3 spin = (rotErr * BalanceKp).LimitLength(MuscleMaxSpin); Vector3 spin = (rotErr * BalanceKp).LimitLength(MuscleMaxSpin);
@@ -576,20 +771,44 @@ public partial class PlayerRagdoll : Node3D
float[] fractions = GetWorld3D().DirectSpaceState.CastMotion(_cameraQuery); float[] fractions = GetWorld3D().DirectSpaceState.CastMotion(_cameraQuery);
float safe = fractions.Length > 0 ? fractions[0] : 1f; float safe = fractions.Length > 0 ? fractions[0] : 1f;
if (safe < 1f) if (safe < 1f)
{
correction = -lead * (1f - safe); correction = -lead * (1f - safe);
else if (lead.Length() > FreeSlack) // Only a climbing body drags the view vertically through geometry; otherwise a
correction = -lead * (1f - FreeSlack / lead.Length()); // 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) if (Grounded)
{ {
correction.Y = 0f; correction.Y = 0f;
correction.Y += (GroundY - floorY) * (1f - Mathf.Exp(-dt * GroundFollowRate)); 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; return correction;
} }
private PhysicsShapeQueryParameters3D _cameraQuery; 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) public void TeleportBy(Transform3D delta)
{ {
if (!Built) if (!Built)
@@ -601,6 +820,9 @@ public partial class PlayerRagdoll : Node3D
b.AngularVelocity = delta.Basis * b.AngularVelocity; b.AngularVelocity = delta.Basis * b.AngularVelocity;
} }
_pelvisTargetPrev = delta * _pelvisTargetPrev; _pelvisTargetPrev = delta * _pelvisTargetPrev;
_footY += delta.Origin.Y;
foreach (RigidBody3D b in _bodies)
b.ResetPhysicsInterpolation();
} }
public void SnapToPose(TrueBodySolver s, Transform3D left, Transform3D right) 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)); a.Hand.GlobalTransform = new Transform3D(hb, s.Wrist[side] - hb * new Vector3(0f, 0f, WristOffset));
} }
_havePelvisPrev = false; _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 // 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. // 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) if (!Built)
return; return;
+23 -3
View File
@@ -38,6 +38,21 @@ public partial class PlayerRig : Node
private bool _rebuild = true; private bool _rebuild = true;
public PlayerRagdoll Ragdoll => _ragdoll; 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) public static RigidBody3D GetHandBody(XRController3D controller)
{ {
@@ -46,7 +61,7 @@ public partial class PlayerRig : Node
return _instance._ragdoll.Hand(controller == _instance._left ? 0 : 1); 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) if (_instance != null)
_instance._ragdoll.SetHeld(controller == _instance._left ? 0 : 1, prop); _instance._ragdoll.SetHeld(controller == _instance._left ? 0 : 1, prop);
@@ -65,7 +80,9 @@ public partial class PlayerRig : Node
_trueBody.ExternallySolved = true; _trueBody.ExternallySolved = true;
TrueBody.ShowDebug = false; 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); AddChild(_ragdoll);
BodyProfile.Changed += _ => _rebuild = true; BodyProfile.Changed += _ => _rebuild = true;
for (int i = 0; i < 2; i++) for (int i = 0; i < 2; i++)
@@ -93,8 +110,11 @@ public partial class PlayerRig : Node
{ {
float dt = (float)delta; float dt = (float)delta;
BodyProfile profile = BodyProfile.Current; BodyProfile profile = BodyProfile.Current;
if (profile == null) if (profile == null || Spectator)
{
_haveLastOrigin = false;
return; return;
}
// 1. Someone else moved the play space discretely: bring the body along. // 1. Someone else moved the play space discretely: bring the body along.
Transform3D origin = _origin.GlobalTransform; 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 BreakTime = 0.25f;
private const float BreakDistanceBoth = 0.4f; private const float BreakDistanceBoth = 0.4f;
private const float GripRipDistance = 0.8f; 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; private static PropGrabber _instance;
@@ -18,7 +20,7 @@ public partial class PropGrabber : Node
private XRController3D _right; private XRController3D _right;
private PhysicsArms _arms; 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 ConeTwistJoint3D[] _joints = new ConeTwistJoint3D[2];
private readonly Vector3[] _localAnchor = new Vector3[2]; private readonly Vector3[] _localAnchor = new Vector3[2];
private readonly uint[] _grabSeq = new uint[2]; private readonly uint[] _grabSeq = new uint[2];
@@ -59,7 +61,7 @@ public partial class PropGrabber : Node
if (grip && !_gripPrev[hand] && _held[hand] == null) if (grip && !_gripPrev[hand] && _held[hand] == null)
{ {
RigidBody3D prop = FindProp(controller, hand); PhysicsBody3D prop = FindProp(controller, hand);
RigidBody3D handBody = PhysicsHands.GetBody(controller); RigidBody3D handBody = PhysicsHands.GetBody(controller);
if (prop != null && handBody != null) if (prop != null && handBody != null)
{ {
@@ -68,26 +70,27 @@ public partial class PropGrabber : Node
Vector3 pivot = PalmPoint(handBody, hand); Vector3 pivot = PalmPoint(handBody, hand);
_joints[hand] = MakeWeld(handBody, prop, pivot, RigidWeldBias); _joints[hand] = MakeWeld(handBody, prop, pivot, RigidWeldBias);
_localAnchor[hand] = prop.GlobalTransform.AffineInverse() * pivot; _localAnchor[hand] = prop.GlobalTransform.AffineInverse() * pivot;
_arms?.SetHeld(hand, prop); _arms?.SetHeld(hand, prop as RigidBody3D);
PlayerRig.SetHeld(controller, prop); 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) if (!grip && _held[hand] != null)
{ {
RigidBody3D prop = _held[hand]; PhysicsBody3D prop = _held[hand];
_held[hand] = null; _held[hand] = null;
DestroyJoint(hand); DestroyJoint(hand);
if (IsInstanceValid(prop)) if (prop is RigidBody3D released && IsInstanceValid(released))
GD.Print($"PropGrabber: released '{prop.Name}' at {prop.LinearVelocity.Length():F2} m/s"); GD.Print($"PropGrabber: released '{released.Name}' at {released.LinearVelocity.Length():F2} m/s");
} }
_gripPrev[hand] = grip; _gripPrev[hand] = grip;
} }
private void HoldUpdate(XRController3D controller, int hand, float delta) private void HoldUpdate(XRController3D controller, int hand, float delta)
{ {
RigidBody3D body = _held[hand]; PhysicsBody3D body = _held[hand];
if (body == null) if (body == null)
return; return;
if (!IsInstanceValid(body)) 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; int primary = -1;
for (int hand = 0; hand < 2; hand++) for (int hand = 0; hand < 2; hand++)
@@ -159,7 +162,7 @@ public partial class PropGrabber : Node
return primary; 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() ConeTwistJoint3D weld = new()
{ {
@@ -175,7 +178,7 @@ public partial class PropGrabber : Node
return weld; return weld;
} }
private void BreakGrip(int hand, RigidBody3D body, string reason) private void BreakGrip(int hand, PhysicsBody3D body, string reason)
{ {
if (_held[hand] != body) if (_held[hand] != body)
return; return;
@@ -199,7 +202,7 @@ public partial class PropGrabber : Node
private static float PalmOffset(int hand) => hand == 0 ? PalmGrabOffset : -PalmGrabOffset; 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); RigidBody3D handBody = PhysicsHands.GetBody(controller);
if (handBody == null) if (handBody == null)
@@ -208,7 +211,27 @@ public partial class PropGrabber : Node
RigidBody3D nearest = NearestProp(palm, GrabRadius); RigidBody3D nearest = NearestProp(palm, GrabRadius);
if (nearest != null) if (nearest != null)
return nearest; 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) 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(("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(("teleport + snap turn carry the body", Rest, TickTeleport, 2f, FinishTeleport));
_phases.Add(("random motion soak", Rest, TickRandom, 8f, FinishSoak)); _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(); Next();
} }
@@ -448,6 +459,198 @@ public partial class RagdollSpike : Node3D
return _finite && NewSnaps == 0 && _maxEyeErr < 0.05f && _maxHandErr < 0.06f; 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 readonly RandomNumberGenerator _rng = new() { Seed = 11 };
private Vector3 _ra, _rb, _rc; 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 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 CrouchRange = 0.5f; // crouch factor 1 when eyes are this fraction of standing eye height lower
public const float HeadPitchToChest = 0.25f; 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 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 GirdleReachStart = 0.98f; // fraction of arm length where the shoulder starts to help
public const float GirdleSmoothRate = 15f; public const float GirdleSmoothRate = 15f;
@@ -100,18 +104,27 @@ public sealed class TrueBodySolver
Crouch = Mathf.Clamp((standingPivot - pivotAboveFloor) / (CrouchRange * p.EyeHeight), 0f, 1f); Crouch = Mathf.Clamp((standingPivot - pivotAboveFloor) / (CrouchRange * p.EyeHeight), 0f, 1f);
float headPitchDown = Mathf.Max(0f, -Mathf.Asin(Mathf.Clamp(headFwd.Y, -1f, 1f))); float headPitchDown = Mathf.Max(0f, -Mathf.Asin(Mathf.Clamp(headFwd.Y, -1f, 1f)));
float lean = Crouch * ChestLeanMax + headPitchDown * HeadPitchToChest; float lean = Crouch * ChestLeanMax + headPitchDown * HeadPitchToChest;
Lean = lean; float trunk = TrunkRatio * p.Stature;
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 neckLen = Mathf.Max(0.05f, p.ShoulderDrop + EyeToNeckPivot.Y - NeckBaseAboveShoulders); float neckLen = Mathf.Max(0.05f, p.ShoulderDrop + EyeToNeckPivot.Y - NeckBaseAboveShoulders);
Vector3 neckDir = (ChestBasis.Y + headBasis.Y).Normalized(); Vector3 neckDir = Vector3.Up;
NeckBase = NeckPivot - neckDir * neckLen; for (int iter = 0; iter < 3; iter++)
// Keep the shoulder line behind the eyes by the anthropometric offset (neck pivot already {
// supplies part of it). ChestBasis = yawBasis * new Basis(Vector3.Right, -lean);
UpperChest = NeckBase - ChestBasis.Y * NeckBaseAboveShoulders // Neck: from the head pivot down along the average of chest-up and head-up, sized so a
+ ChestBasis * new Vector3(0f, 0f, ShouldersBehindEyes - EyeToNeckPivot.Z); // 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 // 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. // 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 += () => _menu.ResetPropsRequested += () =>
{ {
_propShop.Respawn(); _propShop.Respawn();
GetNodeOrNull<Playground>("../../Playground")?.Reset();
_menu.Log("Props reset"); _menu.Log("Props reset");
}; };
_menu.PingRequested += () => _menu.PingRequested += () =>
@@ -75,6 +76,9 @@ public partial class WorldPanel : Node3D
_click = MakeTone(900f, 25, 0.35f, 1f); _click = MakeTone(900f, 25, 0.35f, 1f);
_ping = MakeTone(1320f, 4, 0.3f, 3f); _ping = MakeTone(1320f, 4, 0.3f, 3f);
_menu.ClickVolumeSlider.ValueChanged += v => _click.VolumeDb = Mathf.LinearToDb((float)v / 70f); _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); SetPanelActive(false);
} }
+50
View File
@@ -0,0 +1,50 @@
using Godot;
// Dev tool (not shipped gameplay): renders a few overview screenshots of the playground to
// user-supplied paths. godot-mono --path . tools/playground_shots.tscn -- <outdir>
public partial class PlaygroundShots : Node3D
{
private int _frame;
private int _shot;
private Camera3D _cam;
private string _dir = "user://";
private readonly (Vector3 Pos, Vector3 Look)[] _views =
{
(new Vector3(0f, 9f, 14f), new Vector3(0f, 0f, -1f)),
(new Vector3(3f, 4f, 3f), new Vector3(9f, 1f, -4f)),
(new Vector3(-2f, 3.5f, 4f), new Vector3(-7f, 1f, -1f)),
(new Vector3(0f, 2.5f, 11f), new Vector3(1f, 0.6f, 7f)),
};
public override void _Ready()
{
foreach (string a in OS.GetCmdlineUserArgs())
_dir = a;
AddChild(new DirectionalLight3D { RotationDegrees = new Vector3(-50f, -30f, 0f), ShadowEnabled = true });
AddChild(new WorldEnvironment { Environment = new Environment { BackgroundMode = Environment.BGMode.Color, BackgroundColor = new Color(0.55f, 0.65f, 0.8f), AmbientLightSource = Environment.AmbientSource.Color, AmbientLightColor = new Color(0.5f, 0.5f, 0.55f) } });
StaticBody3D floor = new();
floor.AddChild(new CollisionShape3D { Shape = new BoxShape3D { Size = new Vector3(40, 0.1f, 40) }, Position = new Vector3(0, -0.05f, 0) });
floor.AddChild(new MeshInstance3D { Mesh = new PlaneMesh { Size = new Vector2(40, 40) } });
AddChild(floor);
AddChild(new Playground());
_cam = new Camera3D { Current = true };
AddChild(_cam);
SetView();
}
private void SetView() => _cam.LookAtFromPosition(_views[_shot].Pos, _views[_shot].Look);
public override void _Process(double delta)
{
if (++_frame < 30)
return;
GetViewport().GetTexture().GetImage().SavePng($"{_dir}/view{_shot}.png");
_frame = 0;
if (++_shot >= _views.Length)
{
GetTree().Quit();
return;
}
SetView();
}
}
+1
View File
@@ -0,0 +1 @@
uid://ceamhylc4wq66
+6
View File
@@ -0,0 +1,6 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://tools/PlaygroundShots.cs" id="1"]
[node name="PlaygroundShots" type="Node3D"]
script = ExtResource("1")