Compare commits

...
10 Commits
Author SHA1 Message Date
Foxyz 797b67c19c UID resave of main/crag scenes, shadow split tweaks, drop debug teleport markers, ignore .claude and research/personal 2026-10-03 03:07:24 +03:00
FoxyzandClaude Opus 5.5 b8365ee95f Grab buffer (squeeze early, catch on contact), elbow can't fold inward when the controller turns out
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 21:31:14 +03:00
FoxyzandClaude Opus 5.5 ca60df3d75 Render-clock tracking timestamps (menu toggle), physics step cap 6, docs for step 2.7
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 20:43:49 +03:00
FoxyzandClaude Opus 5.5 22159aa902 Soft grab (Nimso): ease the object into the hand (or the hand to the hold) before the weld
Light props (<= 6 kg) fly to the palm, static world / heavy things pull the hand; position only,
~0.1 s, never a snap. Welds no longer freeze a hover gap. Spike: 1 kg cube from 6 cm -> 1.5 mm,
bar from 5 cm -> 1.2 cm (contact slop on a curved hold).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 20:28:40 +03:00
FoxyzandClaude Opus 5.5 83186f9676 Clambering: capsule hips (Nimso), footprint takes the edge while pressing, knee-up memory
The box pelvis caught on ledge lips; a sideways capsule rides over. While clambering
(pressing, just pressed, legs tucked) the stance footprint may take higher ground the hips
are only partly over. Spike measures the free hand's error while climbing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 20:26:22 +03:00
FoxyzandClaude Opus 5.5 0bddcf4517 Jump: hold right A to charge (legs bend), release to jump
Tap hops ~0.16 m, full charge ~0.68 m, a real crouch charges too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 20:15:32 +03:00
Foxyz 537e72c207 Merge branch 'worktree-agent-a220f513d68e27a82' 2026-10-02 20:14:11 +03:00
FoxyzandClaude Opus 5.5 2d986d08ac Ground under the body: sphere footprint, clamber by pressure (Nimso), physics objects climbable, arms keep colliding
- Ground = centre ray + stance-wide sphere cast (face normal from a ray inside the contact):
  planks and lips keep support.
- Higher ground is stood on when the hips are over it and the body presses onto it (hands
  braced, a just-finished press, legs tucked over it, or airborne) - no hips-well-above rule.
- Bracing counts heavy/frozen physics objects (>= 15 kg), not what the hand carries.
- Knee up: braced + hips held back tucks the legs collider.
- Support goal capped at straight legs (tiptoe/over-tall head floated the body).
- Arms never get collision exceptions with held objects (they disabled whole crag rocks).
- Spike: heavy-box mantle, 15 cm plank, chest-high ledge two hands yes / one hand no.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 20:14:11 +03:00
FoxyzandClaude Opus 5.5 df7a2c5a89 Maps are editor-built scenes: [Tool] Playground/Crag, headless rebuild for agents
scenes/maps/playground.tscn and crag.tscn are generated by the map scripts' Rebuild (inspector
button, or godot-mono --headless --path . tools/rebuild_maps.tscn) and loaded as saved by the game,
so hand edits stick. The crag route is saved as Marker3D nodes and crag_spike checks the saved
scene. Layout verified identical to the code-built one (tools/map_dump.tscn).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 20:10:10 +03:00
FoxyzandClaude Opus 5.5 3e300c7f04 Shoulder range of motion: drive-mode angular limits in the Box3D fork
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 17:45:00 +03:00
34 changed files with 8333 additions and 264 deletions

No files matched your search

+2 -1
View File
@@ -4,4 +4,5 @@
.export/ .export/
*.translation *.translation
prefs.json prefs.json
.claude/worktrees/ .claude/
research/personal/
+561
View File
@@ -0,0 +1,561 @@
From 7303770df3f6c09741b5e463b83debc2b809b139 Mon Sep 17 00:00:00 2001
From: Foxyz <timka1302@gmail.com>
Date: Fri, 2 Oct 2026 17:34:54 +0300
Subject: [PATCH] Angular limits for drive-mode Generic6DOFJoint3D (shoulder
range of motion)
Box3D's b3MotorJoint has no angular limits, so a drive used as a shoulder could be
bent into the chest or behind the back. patches/box3d/0001 adds them to the motor
joint (applied to the box3d checkout at configure time, submodule stays upstream):
- swing limit: per-quadrant ellipse on the swing rotation vector (asymmetric
lower/upper bounds about the limit frame's x and y), with a Jacobian that
includes the radius change along the boundary;
- twist limit about frame B's z, exact twist Jacobian;
- own limit frames, so moving the drive target (frame A) never moves the range;
- warm starting, joint torque/impulse reporting, recording writer/reader.
The wrapper enables them when any angular axis of a DRIVE has a real range
(limit flag on, lower < upper): X = twist, Y/Z = swing bounds, measured between
the joint frames as created. Limit constraints run at 120 Hz softness.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---
CMakeLists.txt | 15 +
.../0001-motor-joint-angular-limits.patch | 408 ++++++++++++++++++
.../box3d_generic_6dof_joint_impl_3d.cpp | 46 ++
.../box3d_generic_6dof_joint_impl_3d.hpp | 7 +
4 files changed, 476 insertions(+)
create mode 100644 patches/box3d/0001-motor-joint-angular-limits.patch
diff --git a/CMakeLists.txt b/CMakeLists.txt
index 06ef797..d0b87a0 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -17,6 +17,21 @@ set(BOX3D_BENCHMARKS OFF CACHE BOOL "" FORCE)
set(BOX3D_DOCS OFF CACHE BOOL "" FORCE)
set(BOX3D_UNIT_TESTS OFF CACHE BOOL "" FORCE)
+# Local additions to Box3D itself live as patches (the submodule stays pinned to upstream).
+# Applied to the checkout at configure time; a marker string per patch makes this idempotent.
+# 0001 — angular (swing ellipse + twist) limits on b3MotorJoint (marker b3MotorJointSwingLimit)
+find_package(Git REQUIRED)
+file(READ "${CMAKE_CURRENT_SOURCE_DIR}/box3d/src/motor_joint.c" GODOT_BOX3D_MOTOR_JOINT_SOURCE)
+if(NOT GODOT_BOX3D_MOTOR_JOINT_SOURCE MATCHES "b3MotorJointSwingLimit")
+ execute_process(
+ COMMAND "${GIT_EXECUTABLE}" -C "${CMAKE_CURRENT_SOURCE_DIR}/box3d" apply
+ "${CMAKE_CURRENT_SOURCE_DIR}/patches/box3d/0001-motor-joint-angular-limits.patch"
+ RESULT_VARIABLE GODOT_BOX3D_PATCH_RESULT)
+ if(NOT GODOT_BOX3D_PATCH_RESULT EQUAL 0)
+ message(FATAL_ERROR "Failed to apply patches/box3d/0001-motor-joint-angular-limits.patch to box3d/")
+ endif()
+endif()
+
add_subdirectory(box3d)
# --- godot-cpp (fetched via ExternalProject_Add) ---
diff --git a/patches/box3d/0001-motor-joint-angular-limits.patch b/patches/box3d/0001-motor-joint-angular-limits.patch
new file mode 100644
index 0000000..3208bae
--- /dev/null
+++ b/patches/box3d/0001-motor-joint-angular-limits.patch
@@ -0,0 +1,408 @@
+diff -ruN a/include/box3d/types.h b/include/box3d/types.h
+--- a/include/box3d/types.h 2026-10-01 17:24:25.919048141 +0300
++++ b/include/box3d/types.h 2026-10-02 17:18:19.916301593 +0300
+@@ -740,6 +740,28 @@
+
+ /// Maximum spring torque in newton-meters
+ float maxSpringTorque;
++
++ /// Angular limit frames (rotation only, relative to each body's origin). The limits measure the
++ /// rotation of limit frame B relative to limit frame A: the swing of the z-axis away from frame A's
++ /// z-axis and the twist about it. Independent of localFrameA/B, so moving the drive target (frame A)
++ /// does not move the limits. Default identity.
++ b3Quat limitRotationA;
++ b3Quat limitRotationB;
++
++ /// Swing limit. The swing is the rotation vector taking limit frame A's z-axis onto limit frame B's
++ /// z-axis; its components about frame A's x and y axes must stay inside the ellipse spanned by these
++ /// per-quadrant bounds (radians, lower <= 0 <= upper, magnitudes up to 0.99 pi). Equal bounds give a
++ /// circular cone; unequal ones an asymmetric (e.g. anatomical) range.
++ bool enableSwingLimit;
++ float lowerSwingX;
++ float upperSwingX;
++ float lowerSwingY;
++ float upperSwingY;
++
++ /// Twist limit about limit frame B's z-axis, radians in [-0.99 pi, 0.99 pi].
++ bool enableTwistLimit;
++ float lowerTwistAngle;
++ float upperTwistAngle;
+ } b3MotorJointDef;
+
+ /// Use this to initialize your joint definition
+diff -ruN a/src/joint.c b/src/joint.c
+--- a/src/joint.c 2026-10-01 17:24:25.937994385 +0300
++++ b/src/joint.c 2026-10-02 17:18:19.921160668 +0300
+@@ -59,6 +59,8 @@
+ {
+ b3MotorJointDef def = { 0 };
+ def.base = b3DefaultJointDef();
++ def.limitRotationA = b3Quat_identity;
++ def.limitRotationB = b3Quat_identity;
+ return def;
+ }
+
+@@ -495,6 +497,18 @@
+ joint->motorJoint.angularHertz = def->angularHertz;
+ joint->motorJoint.angularDampingRatio = def->angularDampingRatio;
+ joint->motorJoint.maxSpringTorque = def->maxSpringTorque;
++ joint->motorJoint.limitRotationA = b3NormalizeQuat( def->limitRotationA );
++ joint->motorJoint.limitRotationB = b3NormalizeQuat( def->limitRotationB );
++ joint->motorJoint.enableSwingLimit = def->enableSwingLimit;
++ joint->motorJoint.lowerSwingX = b3ClampFloat( def->lowerSwingX, -0.99f * B3_PI, 0.0f );
++ joint->motorJoint.upperSwingX = b3ClampFloat( def->upperSwingX, 0.0f, 0.99f * B3_PI );
++ joint->motorJoint.lowerSwingY = b3ClampFloat( def->lowerSwingY, -0.99f * B3_PI, 0.0f );
++ joint->motorJoint.upperSwingY = b3ClampFloat( def->upperSwingY, 0.0f, 0.99f * B3_PI );
++ joint->motorJoint.enableTwistLimit = def->enableTwistLimit;
++ joint->motorJoint.lowerTwistAngle =
++ b3ClampFloat( b3MinFloat( def->lowerTwistAngle, def->upperTwistAngle ), -0.99f * B3_PI, 0.99f * B3_PI );
++ joint->motorJoint.upperTwistAngle =
++ b3ClampFloat( b3MaxFloat( def->lowerTwistAngle, def->upperTwistAngle ), -0.99f * B3_PI, 0.99f * B3_PI );
+
+ b3JointId jointId = { joint->jointId + 1, world->worldId, pair.joint->generation };
+ B3_REC_CREATE( world, CreateMotorJoint, jointId, worldId, *def );
+@@ -1028,7 +1042,10 @@
+ {
+ b3MotorJoint* joint = &sim->motorJoint;
+ linearImpulse = b3Length( b3Add( joint->linearVelocityImpulse, joint->linearSpringImpulse ) );
+- angularImpulse = b3Length( b3Add( joint->angularVelocityImpulse, joint->angularSpringImpulse ) );
++ b3Vec3 limitImpulse = b3MulSV( -joint->swingImpulse, joint->swingAxis );
++ limitImpulse = b3MulAdd( limitImpulse, joint->lowerTwistImpulse - joint->upperTwistImpulse, joint->twistJacobian );
++ angularImpulse =
++ b3Length( b3Add( b3Add( joint->angularVelocityImpulse, joint->angularSpringImpulse ), limitImpulse ) );
+ }
+ break;
+
+@@ -1570,7 +1587,7 @@
+ break;
+
+ case b3_motorJoint:
+- b3SolveMotorJoint( joint, context );
++ b3SolveMotorJoint( joint, context, useBias );
+ break;
+
+ case b3_prismaticJoint:
+diff -ruN a/src/joint.h b/src/joint.h
+--- a/src/joint.h 2026-10-01 17:24:25.937994385 +0300
++++ b/src/joint.h 2026-10-02 17:25:15.094307396 +0300
+@@ -119,6 +119,22 @@
+ b3Transform frameB;
+ b3Vec3 deltaCenter;
+ b3Matrix3 angularMass;
++
++ // angular limits (see b3MotorJointDef)
++ b3Quat limitRotationA;
++ b3Quat limitRotationB;
++ bool enableSwingLimit;
++ bool enableTwistLimit;
++ float lowerSwingX, upperSwingX, lowerSwingY, upperSwingY;
++ float lowerTwistAngle, upperTwistAngle;
++
++ b3Quat limitFrameA;
++ b3Quat limitFrameB;
++ b3Vec3 swingAxis; // last solved swing Jacobian (negated), for warm starting
++ b3Vec3 twistJacobian; // last solved twist Jacobian, for warm starting
++ float swingImpulse;
++ float lowerTwistImpulse;
++ float upperTwistImpulse;
+ } b3MotorJoint;
+
+ typedef struct b3ParallelJoint
+@@ -398,7 +414,7 @@
+ void b3WarmStartWheelJoint( b3JointSim* base, b3StepContext* context );
+
+ void b3SolveDistanceJoint( b3JointSim* base, b3StepContext* context, bool useBias );
+-void b3SolveMotorJoint( b3JointSim* base, b3StepContext* context );
++void b3SolveMotorJoint( b3JointSim* base, b3StepContext* context, bool useBias );
+ void b3SolveParallelJoint( b3JointSim* base, b3StepContext* context );
+ void b3SolvePrismaticJoint( b3JointSim* base, b3StepContext* context, bool useBias );
+ void b3SolveRevoluteJoint( b3JointSim* base, b3StepContext* context, bool useBias );
+diff -ruN a/src/motor_joint.c b/src/motor_joint.c
+--- a/src/motor_joint.c 2026-10-01 17:24:25.937994385 +0300
++++ b/src/motor_joint.c 2026-10-02 17:26:50.138061833 +0300
+@@ -161,7 +161,25 @@
+
+ b3Vec3 b3GetMotorJointTorque( b3World* world, b3JointSim* base )
+ {
+- return b3MulSV( world->inv_h, b3Add( base->motorJoint.angularVelocityImpulse, base->motorJoint.angularSpringImpulse ) );
++ b3MotorJoint* joint = &base->motorJoint;
++ b3Vec3 impulse = b3Add( joint->angularVelocityImpulse, joint->angularSpringImpulse );
++ impulse = b3MulSub( impulse, joint->swingImpulse, joint->swingAxis );
++ impulse = b3MulAdd( impulse, joint->lowerTwistImpulse - joint->upperTwistImpulse, joint->twistJacobian );
++ return b3MulSV( world->inv_h, impulse );
++}
++
++// Radius of the swing limit ellipse in the direction of the swing rotation axis (x, y), limit frame A.
++// Per-quadrant semi-axes, so lower and upper bounds may differ (anatomical ranges).
++static float b3MotorJointSwingLimit( const b3MotorJoint* joint, float axisX, float axisY )
++{
++ float a = axisX >= 0.0f ? joint->upperSwingX : -joint->lowerSwingX;
++ float b = axisY >= 0.0f ? joint->upperSwingY : -joint->lowerSwingY;
++ a = b3MaxFloat( a, 1.0e-3f );
++ b = b3MaxFloat( b, 1.0e-3f );
++ float u = axisX / a;
++ float v = axisY / b;
++ float d = u * u + v * v;
++ return d > 0.0f ? b3MinFloat( 1.0f / sqrtf( d ), 0.99f * B3_PI ) : b3MinFloat( b3MinFloat( a, b ), 0.99f * B3_PI );
+ }
+
+ // Point-to-point constraint
+@@ -228,6 +246,29 @@
+
+ joint->angularMass = b3InvertMatrix( invInertiaSum );
+
++ // Angular limits: the spherical joint's cone/twist formulation, measured in their own frames so the
++ // drive target (frame A) can move freely. The Jacobians are built in the solve from the current
++ // sub-step rotation; swingAxis/twistJacobian keep the last ones for warm starting.
++ joint->limitFrameA = b3MulQuat( bodySimA->transform.q, joint->limitRotationA );
++ joint->limitFrameB = b3MulQuat( bodySimB->transform.q, joint->limitRotationB );
++ if ( base->fixedRotation )
++ {
++ joint->swingImpulse = 0.0f;
++ joint->lowerTwistImpulse = 0.0f;
++ joint->upperTwistImpulse = 0.0f;
++ }
++
++ if ( context->enableWarmStarting == false || joint->enableSwingLimit == false )
++ {
++ joint->swingImpulse = 0.0f;
++ }
++
++ if ( context->enableWarmStarting == false || joint->enableTwistLimit == false )
++ {
++ joint->lowerTwistImpulse = 0.0f;
++ joint->upperTwistImpulse = 0.0f;
++ }
++
+ if ( context->enableWarmStarting == false )
+ {
+ joint->linearVelocityImpulse = b3Vec3_zero;
+@@ -259,6 +300,8 @@
+
+ b3Vec3 linearImpulse = b3Add( joint->linearVelocityImpulse, joint->linearSpringImpulse );
+ b3Vec3 angularImpulse = b3Add( joint->angularVelocityImpulse, joint->angularSpringImpulse );
++ angularImpulse = b3MulSub( angularImpulse, joint->swingImpulse, joint->swingAxis );
++ angularImpulse = b3MulAdd( angularImpulse, joint->lowerTwistImpulse - joint->upperTwistImpulse, joint->twistJacobian );
+
+ stateA->linearVelocity = b3MulSub( stateA->linearVelocity, mA, linearImpulse );
+ stateA->angularVelocity = b3Sub( stateA->angularVelocity, b3MulMV( iA, b3Add( b3Cross( rA, linearImpulse ), angularImpulse ) ) );
+@@ -266,7 +309,7 @@
+ stateB->angularVelocity = b3Add( stateB->angularVelocity, b3MulMV( iB, b3Add( b3Cross( rB, linearImpulse ), angularImpulse ) ) );
+ }
+
+-void b3SolveMotorJoint( b3JointSim* base, b3StepContext* context )
++void b3SolveMotorJoint( b3JointSim* base, b3StepContext* context, bool useBias )
+ {
+ B3_ASSERT( base->type == b3_motorJoint );
+
+@@ -344,6 +387,161 @@
+ wB = b3Add( wB, b3MulMV( iB, impulse ) );
+ }
+
++ if ( ( joint->enableSwingLimit || joint->enableTwistLimit ) && base->fixedRotation == false )
++ {
++ // Jacobians are rebuilt from the current sub-step rotation (not the prepare-time axes): with an
++ // asymmetric range the limit radius changes with the swing direction, and a fast arm sliding
++ // along the boundary would otherwise sink deep into the corners.
++ b3Quat limitA = b3MulQuat( stateA->deltaRotation, joint->limitFrameA );
++ b3Quat limitB = b3MulQuat( stateB->deltaRotation, joint->limitFrameB );
++ b3Quat limitQ = b3InvMulQuat( limitA, limitB );
++ b3Matrix3 invInertiaSum = b3AddMM( iA, iB );
++
++ // Swing geometry in limit frame A: z = cone axis, zB = frame B's z, n = swing axis (z x zB),
++ // m = in-plane direction of zB (n = z x m).
++ b3Vec3 zB = b3RotateVector( limitQ, b3Vec3_axisZ );
++ float planar = sqrtf( zB.x * zB.x + zB.y * zB.y );
++ float swingAngle = b3GetSwingAngle( limitQ );
++ b3Vec3 n = b3Vec3_zero;
++ b3Vec3 m = b3Vec3_zero;
++ if ( planar > 1.0e-6f )
++ {
++ m = (b3Vec3){ zB.x / planar, zB.y / planar, 0.0f };
++ n = (b3Vec3){ -m.y, m.x, 0.0f };
++ }
++
++ if ( joint->enableSwingLimit && planar > 1.0e-6f )
++ {
++ // C = r(phi) - theta >= 0, theta = swing angle, phi = direction of the swing axis n, r = the
++ // per-quadrant ellipse radius. With the relative angular velocity w (frame A):
++ // dtheta/dt = w.n dphi/dt = w.z - cot(theta) w.m
++ // so dC/dt = J.w with J = -n + r'(phi) (z - cot(theta) m).
++ float limit = b3MotorJointSwingLimit( joint, n.x, n.y );
++ float a = n.x >= 0.0f ? joint->upperSwingX : -joint->lowerSwingX;
++ float b = n.y >= 0.0f ? joint->upperSwingY : -joint->lowerSwingY;
++ a = b3MaxFloat( a, 1.0e-3f );
++ b = b3MaxFloat( b, 1.0e-3f );
++ float slope = limit * limit * limit * n.x * n.y * ( 1.0f / ( a * a ) - 1.0f / ( b * b ) );
++ if ( limit >= 0.99f * B3_PI )
++ {
++ slope = 0.0f;
++ }
++ float cotTheta = b3ClampFloat( cosf( swingAngle ) / b3MaxFloat( sinf( swingAngle ), 1.0e-3f ), -10.0f, 10.0f );
++ b3Vec3 jacobianLocal = {
++ -n.x - slope * cotTheta * m.x,
++ -n.y - slope * cotTheta * m.y,
++ slope,
++ };
++ b3Vec3 jacobian = b3RotateVector( limitA, jacobianLocal );
++ float k = b3Dot( jacobian, b3MulMV( invInertiaSum, jacobian ) );
++ float swingMass = k > 0.0f ? 1.0f / k : 0.0f;
++ joint->swingAxis = b3Neg( jacobian );
++
++ float c = limit - swingAngle;
++ float bias = 0.0f;
++ float massScale = 1.0f;
++ float impulseScale = 0.0f;
++ if ( c > 0.0f )
++ {
++ // speculation
++ bias = c * context->inv_h;
++ }
++ else if ( useBias )
++ {
++ bias = base->constraintSoftness.biasRate * c;
++ massScale = base->constraintSoftness.massScale;
++ impulseScale = base->constraintSoftness.impulseScale;
++ }
++
++ float cdot = b3Dot( b3Sub( wB, wA ), jacobian );
++ float oldImpulse = joint->swingImpulse;
++ float deltaImpulse = -massScale * swingMass * ( cdot + bias ) - impulseScale * oldImpulse;
++ joint->swingImpulse = b3MaxFloat( oldImpulse + deltaImpulse, 0.0f );
++ deltaImpulse = joint->swingImpulse - oldImpulse;
++
++ wA = b3MulSub( wA, deltaImpulse, b3MulMV( iA, jacobian ) );
++ wB = b3MulAdd( wB, deltaImpulse, b3MulMV( iB, jacobian ) );
++ }
++ else
++ {
++ // at the centre the swing direction is undefined (and the limit far away)
++ joint->swingImpulse = 0.0f;
++ }
++
++ if ( joint->enableTwistLimit )
++ {
++ float twistAngle = b3GetTwistAngle( limitQ );
++
++ // Exact Jacobian of twist = 2 atan2(q.z, q.s) for dq/dt = 0.5 (0, w) * q, w in frame A
++ // (the spherical joint's tan(theta/2) form lags by ~5 degrees at 100 degrees of swing).
++ float qs = limitQ.s, qx = limitQ.v.x, qy = limitQ.v.y, qz = limitQ.v.z;
++ float invD = 1.0f / b3MaxFloat( qs * qs + qz * qz, 1.0e-6f );
++ b3Vec3 twistLocal = { ( qs * qy + qz * qx ) * invD, ( qz * qy - qs * qx ) * invD, 1.0f };
++ b3Vec3 twistJacobian = b3RotateVector( limitA, twistLocal );
++ float k = b3Dot( twistJacobian, b3MulMV( invInertiaSum, twistJacobian ) );
++ float twistMass = k > 0.0f ? 1.0f / k : 0.0f;
++ joint->twistJacobian = twistJacobian;
++
++ // Lower limit
++ {
++ float c = twistAngle - joint->lowerTwistAngle;
++ float bias = 0.0f;
++ float massScale = 1.0f;
++ float impulseScale = 0.0f;
++ if ( c > 0.0f )
++ {
++ // speculation
++ bias = c * context->inv_h;
++ }
++ else if ( useBias )
++ {
++ bias = base->constraintSoftness.biasRate * c;
++ massScale = base->constraintSoftness.massScale;
++ impulseScale = base->constraintSoftness.impulseScale;
++ }
++
++ float cdot = b3Dot( b3Sub( wB, wA ), twistJacobian );
++ float oldImpulse = joint->lowerTwistImpulse;
++ float deltaImpulse = -massScale * twistMass * ( cdot + bias ) - impulseScale * oldImpulse;
++ joint->lowerTwistImpulse = b3MaxFloat( oldImpulse + deltaImpulse, 0.0f );
++ deltaImpulse = joint->lowerTwistImpulse - oldImpulse;
++
++ wA = b3MulSub( wA, deltaImpulse, b3MulMV( iA, twistJacobian ) );
++ wB = b3MulAdd( wB, deltaImpulse, b3MulMV( iB, twistJacobian ) );
++ }
++
++ // Upper limit
++ {
++ float c = joint->upperTwistAngle - twistAngle;
++ float bias = 0.0f;
++ float massScale = 1.0f;
++ float impulseScale = 0.0f;
++ if ( c > 0.0f )
++ {
++ // speculation
++ bias = c * context->inv_h;
++ }
++ else if ( useBias )
++ {
++ bias = base->constraintSoftness.biasRate * c;
++ massScale = base->constraintSoftness.massScale;
++ impulseScale = base->constraintSoftness.impulseScale;
++ }
++
++ // sign flipped on Cdot
++ float cdot = b3Dot( b3Sub( wA, wB ), twistJacobian );
++ float oldImpulse = joint->upperTwistImpulse;
++ float deltaImpulse = -massScale * twistMass * ( cdot + bias ) - impulseScale * oldImpulse;
++ joint->upperTwistImpulse = b3MaxFloat( oldImpulse + deltaImpulse, 0.0f );
++ deltaImpulse = joint->upperTwistImpulse - oldImpulse;
++
++ // sign flipped on applied impulse
++ wA = b3MulAdd( wA, deltaImpulse, b3MulMV( iA, twistJacobian ) );
++ wB = b3MulSub( wB, deltaImpulse, b3MulMV( iB, twistJacobian ) );
++ }
++ }
++ }
++
+ b3Vec3 rA = b3RotateVector( stateA->deltaRotation, joint->frameA.p );
+ b3Vec3 rB = b3RotateVector( stateB->deltaRotation, joint->frameB.p );
+
+diff -ruN a/src/recording.c b/src/recording.c
+--- a/src/recording.c 2026-10-01 17:24:25.938994483 +0300
++++ b/src/recording.c 2026-10-02 17:18:33.365120096 +0300
+@@ -327,7 +327,7 @@
+ "b3DistanceJointDef changed: update b3RecW_DISTANCEJOINTDEF and its reader together" );
+ _Static_assert( sizeof( void* ) != 8 || sizeof( b3FilterJointDef ) == 112,
+ "b3FilterJointDef changed: update b3RecW_FILTERJOINTDEF and its reader together" );
+-_Static_assert( sizeof( void* ) != 8 || sizeof( b3MotorJointDef ) == 168,
++_Static_assert( sizeof( void* ) != 8 || sizeof( b3MotorJointDef ) == 232,
+ "b3MotorJointDef changed: update b3RecW_MOTORJOINTDEF and its reader together" );
+ _Static_assert( sizeof( void* ) != 8 || sizeof( b3PrismaticJointDef ) == 152,
+ "b3PrismaticJointDef changed: update b3RecW_PRISMATICJOINTDEF and its reader together" );
+@@ -464,6 +464,16 @@
+ b3RecW_F32( buf, v.angularHertz );
+ b3RecW_F32( buf, v.angularDampingRatio );
+ b3RecW_F32( buf, v.maxSpringTorque );
++ b3RecW_QUAT( buf, v.limitRotationA );
++ b3RecW_QUAT( buf, v.limitRotationB );
++ b3RecW_BOOL( buf, v.enableSwingLimit );
++ b3RecW_F32( buf, v.lowerSwingX );
++ b3RecW_F32( buf, v.upperSwingX );
++ b3RecW_F32( buf, v.lowerSwingY );
++ b3RecW_F32( buf, v.upperSwingY );
++ b3RecW_BOOL( buf, v.enableTwistLimit );
++ b3RecW_F32( buf, v.lowerTwistAngle );
++ b3RecW_F32( buf, v.upperTwistAngle );
+ }
+
+ void b3RecW_PRISMATICJOINTDEF( b3RecBuffer* buf, b3PrismaticJointDef v )
+diff -ruN a/src/recording_replay.c b/src/recording_replay.c
+--- a/src/recording_replay.c 2026-10-01 17:24:25.939994580 +0300
++++ b/src/recording_replay.c 2026-10-02 17:18:19.922206121 +0300
+@@ -555,6 +555,16 @@
+ def.angularHertz = b3RecR_F32( rdr );
+ def.angularDampingRatio = b3RecR_F32( rdr );
+ def.maxSpringTorque = b3RecR_F32( rdr );
++ def.limitRotationA = b3RecR_QUAT( rdr );
++ def.limitRotationB = b3RecR_QUAT( rdr );
++ def.enableSwingLimit = b3RecR_BOOL( rdr );
++ def.lowerSwingX = b3RecR_F32( rdr );
++ def.upperSwingX = b3RecR_F32( rdr );
++ def.lowerSwingY = b3RecR_F32( rdr );
++ def.upperSwingY = b3RecR_F32( rdr );
++ def.enableTwistLimit = b3RecR_BOOL( rdr );
++ def.lowerTwistAngle = b3RecR_F32( rdr );
++ def.upperTwistAngle = b3RecR_F32( rdr );
+ return def;
+ }
+
diff --git a/src/joints/box3d_generic_6dof_joint_impl_3d.cpp b/src/joints/box3d_generic_6dof_joint_impl_3d.cpp
index 8de5f8f..6687737 100644
--- a/src/joints/box3d_generic_6dof_joint_impl_3d.cpp
+++ b/src/joints/box3d_generic_6dof_joint_impl_3d.cpp
@@ -110,6 +110,34 @@ Vector3 Box3DGeneric6DOFJointImpl3D::_to_world_from_frame_a(const Vector3& p_loc
return frame.xform(p_local);
}
+bool Box3DGeneric6DOFJointImpl3D::_drive_has_angular_limits() const {
+ for (const AxisState& a : axes) {
+ if (a.flag[PhysicsServer3D::G6DOF_JOINT_FLAG_ENABLE_ANGULAR_LIMIT] &&
+ a.param[PhysicsServer3D::G6DOF_JOINT_ANGULAR_LOWER_LIMIT] < a.param[PhysicsServer3D::G6DOF_JOINT_ANGULAR_UPPER_LIMIT]) {
+ return true;
+ }
+ }
+ return false;
+}
+
+// Limit flag off = free; lower >= upper = locked (Godot's convention).
+void Box3DGeneric6DOFJointImpl3D::_angular_range(int p_axis, float& r_lower, float& r_upper) const {
+ const AxisState& a = axes[p_axis];
+ if (!a.flag[PhysicsServer3D::G6DOF_JOINT_FLAG_ENABLE_ANGULAR_LIMIT]) {
+ r_lower = (float)(-0.99 * Math_PI);
+ r_upper = (float)(0.99 * Math_PI);
+ return;
+ }
+ const double lower = a.param[PhysicsServer3D::G6DOF_JOINT_ANGULAR_LOWER_LIMIT];
+ const double upper = a.param[PhysicsServer3D::G6DOF_JOINT_ANGULAR_UPPER_LIMIT];
+ if (lower >= upper) {
+ r_lower = r_upper = 0.0f;
+ return;
+ }
+ r_lower = (float)CLAMP(lower, -0.99 * Math_PI, 0.0);
+ r_upper = (float)CLAMP(upper, 0.0, 0.99 * Math_PI);
+}
+
b3JointId Box3DGeneric6DOFJointImpl3D::_create_joint_id(b3WorldId p_world_id, b3BodyId p_body_a, b3BodyId p_body_b, b3Transform p_local_frame_a, b3Transform p_local_frame_b) {
mode = _classify();
b3JointId id = b3_nullJointId;
@@ -170,6 +198,24 @@ b3JointId Box3DGeneric6DOFJointImpl3D::_create_joint_id(b3WorldId p_world_id, b3
def.base.bodyIdB = p_body_b;
def.base.localFrameA = godot_to_b3_transform(local_frame_a * Transform3D(Basis(_equilibrium_rotation()), _equilibrium_position()));
def.base.localFrameB = p_local_frame_b;
+ if (_drive_has_angular_limits()) {
+ // Measured between the creation frames (not the equilibrium-shifted target frame), with
+ // the same twist remap as BALL: Box3D limit frame z = joint X, x = joint -Z, y = joint Y.
+ def.limitRotationA = remap_twist(p_local_frame_a).q;
+ def.limitRotationB = remap_twist(p_local_frame_b).q;
+ def.enableTwistLimit = axes[Vector3::AXIS_X].flag[PhysicsServer3D::G6DOF_JOINT_FLAG_ENABLE_ANGULAR_LIMIT];
+ _angular_range(Vector3::AXIS_X, def.lowerTwistAngle, def.upperTwistAngle);
+ def.enableSwingLimit = axes[Vector3::AXIS_Y].flag[PhysicsServer3D::G6DOF_JOINT_FLAG_ENABLE_ANGULAR_LIMIT] ||
+ axes[Vector3::AXIS_Z].flag[PhysicsServer3D::G6DOF_JOINT_FLAG_ENABLE_ANGULAR_LIMIT];
+ float lower_z = 0.0f, upper_z = 0.0f;
+ _angular_range(Vector3::AXIS_Y, def.lowerSwingY, def.upperSwingY);
+ _angular_range(Vector3::AXIS_Z, lower_z, upper_z);
+ def.lowerSwingX = -upper_z;
+ def.upperSwingX = -lower_z;
+ // Stiffer than the 60 Hz joint default: an arm sliding along an asymmetric range at speed
+ // otherwise sinks ~5 degrees past the twist bound (the drive itself has its own springs).
+ def.base.constraintHertz = 120.0f;
+ }
id = b3CreateMotorJoint(p_world_id, &def);
if (_any_flag(PhysicsServer3D::G6DOF_JOINT_FLAG_ENABLE_LINEAR_LIMIT)) {
for (int i = 0; i < 3; i++) {
diff --git a/src/joints/box3d_generic_6dof_joint_impl_3d.hpp b/src/joints/box3d_generic_6dof_joint_impl_3d.hpp
index af6d75d..528747b 100644
--- a/src/joints/box3d_generic_6dof_joint_impl_3d.hpp
+++ b/src/joints/box3d_generic_6dof_joint_impl_3d.hpp
@@ -17,6 +17,11 @@
// DRIVE — any linear axis not locked → b3MotorJoint: springs pull frame B onto frame A shifted
// by the equilibrium points, force/torque capped by the motor force limits (0 = no
// cap); motor target velocities add a velocity drive. Linear limits are not enforced.
+// Angular limits ARE enforced (patched Box3D motor joint) once any angular axis has a
+// real range (limit flag on, lower < upper): X = twist range, Y/Z = swing bounds of an
+// asymmetric ellipse (swing rotation-vector components about joint Y and Z, lower and
+// upper may differ), all measured between the joint frames AS CREATED — the equilibrium
+// shift of frame A (the drive target) does not move them. A flag-off axis is free.
//
// Box3D units: spring STIFFNESS is the spring frequency in Hz and DAMPING the damping ratio
// (not N/m and N·s/m) — Box3D's soft constraints are frequency-based and mass-independent.
@@ -65,6 +70,8 @@ private:
Vector3 _equilibrium_position() const;
Vector3 _motor_velocity(Param p_param, Flag p_gate) const;
Vector3 _to_world_from_frame_a(const Vector3& p_local) const;
+ bool _drive_has_angular_limits() const;
+ void _angular_range(int p_axis, float& r_lower, float& r_upper) const;
void _apply_live();
AxisState axes[3];
--
2.55.0
+10 -2
View File
@@ -27,7 +27,8 @@ embodiment/view system in particular is theory until proven.
(rebuild recipe + source in `.vendor/`, only the Linux binary is patched locally). (rebuild recipe + source in `.vendor/`, only the Linux binary is patched locally).
Patches so far: ConeTwistJoint3D → rigid weld (`research/godot-box3d-weld.md`), Generic6DOFJoint3D → Patches so far: ConeTwistJoint3D → rigid weld (`research/godot-box3d-weld.md`), Generic6DOFJoint3D →
ball/weld/drive with torque-capped muscle motors (`research/godot-box3d-6dof.md`, regression ball/weld/drive with torque-capped muscle motors (`research/godot-box3d-6dof.md`, regression
`scenes/sixdof_spike.tscn`). `scenes/sixdof_spike.tscn`), drive-mode angular limits = anatomical shoulder range (fork branch
`shoulder-cone-limit`, `.vendor/box3d-drive-angular-limits.patch`; a Box3D source patch applied by CMake).
Jolt (built-in) is the fallback: reverting is Jolt (built-in) is the fallback: reverting is
the one project-setting line, and all gameplay code must stay standard-Godot-physics-node the one project-setting line, and all gameplay code must stay standard-Godot-physics-node
based so that flip is lossless. based so that flip is lossless.
@@ -320,7 +321,7 @@ device only; the editor has an XR simulator for dev. Trackers/action map: see `x
`PhysicsMaterialOverride` before AddChild.** `PhysicsMaterialOverride` before AddChild.**
**Step 2.6:** landing DIP is physics (legs touch down only within reach → leg strength stops the body below **Step 2.6:** landing DIP is physics (legs touch down only within reach → leg strength stops the body below
its stance; big falls fold the legs, Boneworks-style; rise eased so gravity alone can stop it at the stance); its stance; big falls fold the legs, Boneworks-style; rise eased so gravity alone can stop it at the stance);
JUMP on right A (countermovement dip + leg push over the stroke — crouching first jumps higher); STICK CROUCH on JUMP on right A (2.7: hold to charge, release to jump; crouching charges too); STICK CROUCH on
right stick Y (`RagdollDriver.StickCrouch` = virtual floor offset); body facing from `BodyMath.HeadFacing` right stick Y (`RagdollDriver.StickCrouch` = virtual floor offset); body facing from `BodyMath.HeadFacing`
(no flip looking down past vertical); the XR origin is DRAWN interpolated like the bodies (`PlayerRig` (no flip looking down past vertical); the XR origin is DRAWN interpolated like the bodies (`PlayerRig`
`_physOrigin`, restored before physics) — the camera/body "two places at once" desync. Rule learned: play-space `_physOrigin`, restored before physics) — the camera/body "two places at once" desync. Rule learned: play-space
@@ -329,6 +330,13 @@ device only; the editor has an XR simulator for dev. Trackers/action map: see `x
**The crag** (`Crag.cs`): a 26 m box-rock cliff north of the playground with one graded route (boulders → **The crag** (`Crag.cs`): a 26 m box-rock cliff north of the playground with one graded route (boulders →
ladder → 8 m ledge → jug wall → traverse → overhang → 16 m ledge → chimney → summit with props); the way down ladder → 8 m ledge → jug wall → traverse → overhang → 16 m ledge → chimney → summit with props); the way down
is the "Crag" teleport marker at the base. Regression `scenes/crag_spike.tscn` "CRAG: overall PASS" (layout only). is the "Crag" teleport marker at the base. Regression `scenes/crag_spike.tscn` "CRAG: overall PASS" (layout only).
**Step 2.7 (owner 2026-10-02: "Nimso's project is the main source of inspiration/mechanics — don't reinvent
the wheel"; check `research/nimso-project-tx.md` + `research/sources/nimso-transcripts/` FIRST for any body
mechanic):** ground under the BODY (centre ray + stance-wide sphere footprint), clambering by PRESSURE (higher
ground is stood on once the hips are over it and the body presses onto it — no special mantle mechanic),
capsule hips (Nimso ~27 cm), heavy physics objects brace like world, arms always collide with held things,
SOFT GRAB (`SoftGrab.cs`: eased into the hand before the weld), hold-A-to-charge jump, render-clock-timed
tracking (`PoseFilter` sampleTime; menu toggle "Tracking: steady/low lag"), physics step cap 6.
Owner scope: avatar scaling and NPCs later. Owner scope: 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
+11 -1
View File
@@ -40,7 +40,7 @@ TERMINOLOGY (owner-corrected — use precisely):
`body_spike` "BODY: …", `collapse_spike` "COLLAPSE: …", `arm_spike` "ARM: overall PASS", `body_spike` "BODY: …", `collapse_spike` "COLLAPSE: …", `arm_spike` "ARM: overall PASS",
`sixdof_spike` "6DOF: overall PASS" (run FIRST with `weld_spike` whenever the addon binary changes), `sixdof_spike` "6DOF: overall PASS" (run FIRST with `weld_spike` whenever the addon binary changes),
`truebody_spike` "TRUEBODY: overall PASS" (no physics; plain `--headless`), `menu_spike` "MENU: overall PASS" `truebody_spike` "TRUEBODY: overall PASS" (no physics; plain `--headless`), `menu_spike` "MENU: overall PASS"
(in-VR menu input injection, no XR). `ragdoll_spike` "RAGDOLL: overall PASS" (`-- only=a,b` runs the phases whose names contain a or b), `crag_spike` "CRAG: overall PASS" (layout geometry only). Only run the spikes your change touches — the full set takes a while. All nine PASS as of this handoff. (in-VR menu input injection, no XR). `ragdoll_spike` "RAGDOLL: overall PASS" (`-- only=a,b` runs the phases whose names contain a or b), `crag_spike` "CRAG: overall PASS" (layout geometry of the SAVED crag scene; see Maps below). Only run the spikes your change touches — the full set takes a while. All nine PASS as of this handoff.
**Fresh checkout / new worktree: run `godot-mono --headless --import` FIRST** (it may segfault at **Fresh checkout / new worktree: run `godot-mono --headless --import` FIRST** (it may segfault at
exit — harmless) — without `.godot/extension_list.cfg` the Box3D extension silently doesn't load, exit — harmless) — without `.godot/extension_list.cfg` the Box3D extension silently doesn't load,
Godot falls back to its default physics and weld/grab/arm/6dof spikes report CHECK. Headless asset import: Godot falls back to its default physics and weld/grab/arm/6dof spikes report CHECK. Headless asset import:
@@ -111,6 +111,16 @@ TERMINOLOGY (owner-corrected — use precisely):
- **Playground.cs** — the body sandbox around the table (east climbing/mantling, west physics toys, south - **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 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>`). overview PNGs (`godot-mono --path . tools/playground_shots.tscn -- <dir>`).
- **Maps are EDITOR-BUILT scenes** (`MapScene.cs`): `scenes/maps/playground.tscn` and `scenes/maps/crag.tscn` (roots =
the `[Tool]` scripts Playground / Crag, instanced by main.tscn). The game NEVER regenerates them — it loads what is
saved, so hand edits in the editor stick. Regenerate from code with the map's inspector button "Rebuild (replaces
all children)" (open the map's own scene, then save), or headless for agents:
`godot-mono --headless --path . tools/rebuild_maps.tscn [-- only=playground|crag]` → "MAPS: rebuilt …" / "MAPS: done"
(rebuild REPLACES all children, discarding hand edits). After changing Playground.cs/Crag.cs builders, rebuild,
then run `crag_spike` (it checks the SAVED crag: route = the Marker3D children of its "Route" node, metadata
kind/label; route bodies in group `crag_route`, rock in `crag_mass`). Joint node paths are saved relative
(`../Name`); groups must be added persistent (`AddToGroup(g, true)`) or they aren't saved. `tools/map_dump.tscn`
dumps a layout (type/transform/size/groups) for before/after comparisons.
- **AnchorMoveMode.cs** is now SPECTATOR-only (`AnchorMoveMode.Enabled`, menu → Player → Spectator), where the - **AnchorMoveMode.cs** is now SPECTATOR-only (`AnchorMoveMode.Enabled`, menu → Player → Spectator), where the
ragdoll is cleared; leaving rebuilds it at the current pose. 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.**
Binary file not shown.
+2 -2
View File
@@ -27,9 +27,9 @@ project/assembly_name="VR Messs"
[physics] [physics]
common/physics_ticks_per_second=144 common/physics_ticks_per_second=144
common/physics_interpolation=true common/max_physics_steps_per_frame=6
common/max_physics_steps_per_frame=14
3d/physics_engine="Box3D Physics" 3d/physics_engine="Box3D Physics"
common/physics_interpolation=true
[rendering] [rendering]
+44 -1
View File
@@ -17,7 +17,7 @@ the joint primitives for the Project-TX-style muscle ragdoll (`nimso-project-tx.
|---|---|---| |---|---|---|
| all 3 linear axes locked (limit on, lower ≥ upper, no linear spring/motor), angular not fully locked or angular motor/spring on | `b3SphericalJoint` = **BALL** | joint **X = twist axis** (angular X limits = twist range); swing cone symmetric, half-angle = largest \|limit\| of angular Y/Z (warned if Y/Z differ); angular spring → rotation spring to the angular equilibrium (Euler, Godot default YXZ); angular **motor = torque-capped velocity motor** | | all 3 linear axes locked (limit on, lower ≥ upper, no linear spring/motor), angular not fully locked or angular motor/spring on | `b3SphericalJoint` = **BALL** | joint **X = twist axis** (angular X limits = twist range); swing cone symmetric, half-angle = largest \|limit\| of angular Y/Z (warned if Y/Z differ); angular spring → rotation spring to the angular equilibrium (Euler, Godot default YXZ); angular **motor = torque-capped velocity motor** |
| everything locked, no angular motor/spring (the Godot default) | `b3WeldJoint` = **WELD** | rigid at the creation pose | | everything locked, no angular motor/spring (the Godot default) | `b3WeldJoint` = **WELD** | rigid at the creation pose |
| any linear axis free/springy/motored | `b3MotorJoint` = **DRIVE** | springs pull frame B onto frame A shifted by the equilibrium points (linear + Euler); force/torque caps = Linear/Angular **MotorForceLimit** (0 = uncapped); motor target velocities add a velocity drive; linear limits NOT enforced (warned) | | any linear axis free/springy/motored | `b3MotorJoint` = **DRIVE** | springs pull frame B onto frame A shifted by the equilibrium points (linear + Euler); force/torque caps = Linear/Angular **MotorForceLimit** (0 = uncapped); motor target velocities add a velocity drive; linear limits NOT enforced (warned); **angular limits enforced** (patch 3, below) once any angular axis has a real range |
- **Units differ from Godot/Jolt**: spring STIFFNESS = frequency in **Hz**, DAMPING = **damping ratio** (Box3D - **Units differ from Godot/Jolt**: spring STIFFNESS = frequency in **Hz**, DAMPING = **damping ratio** (Box3D
soft constraints are mass-independent). Choose ~5–15 Hz, ratio ~1. soft constraints are mass-independent). Choose ~5–15 Hz, ratio ~1.
@@ -46,3 +46,46 @@ horizontal with a 10 N·m cap (0.0°) and FAILS with 1.5 N·m (drops 107°); tra
moving target to 0.000 m; a hand driven into a wall never penetrates it and pushes the 10 kg anchor body AWAY moving target to 0.000 m; a hand driven into a wall never penetrates it and pushes the 10 kg anchor body AWAY
(−0.82 m — push-off reaction; pulling toward a fixed point is the climbing case); all-locked = rigid (0.13°); 90 (−0.82 m — push-off reaction; pulling toward a fixed point is the climbing case); all-locked = rigid (0.13°); 90
live flag toggles stay finite. All existing spikes (weld, physics, grab, body, collapse, arm) PASS 3× on the new binary. live flag toggles stay finite. All existing spikes (weld, physics, grab, body, collapse, arm) PASS 3× on the new binary.
## Patch 3 (2026-10-02): angular limits on the DRIVE — the shoulder range of motion
Problem: the shoulder is a DRIVE (linear motor = moving girdle socket, angular motor = muscle), and Box3D's
`b3MotorJoint` had no angular limits — an external force could bend the arm into the chest or behind the back.
A second joint can't add them (`b3SphericalJoint` always pins its anchor and would fight the moving socket), so the
limits went into Box3D's motor joint itself.
- Fork branch `shoulder-cone-limit` (on `generic-6dof-joint`), commit "Angular limits for drive-mode
Generic6DOFJoint3D". The Box3D change is `patches/box3d/0001-motor-joint-angular-limits.patch` in the fork, applied
to the `box3d/` checkout by CMake at configure time (marker `b3MotorJointSwingLimit`, idempotent) — the submodule
stays pinned to upstream. Rebuild recipe unchanged (`cmake -B build …`, `cmake --build build`). Linux binary only.
- Box3D side (`b3MotorJointDef`): `limitRotationA/B` (own limit frames, so moving the drive target never moves the
range), `enableSwingLimit` + `lower/upperSwingX/Y` (per-quadrant ELLIPSE on the swing rotation vector — asymmetric
bounds), `enableTwistLimit` + `lower/upperTwistAngle`. Solved after the angular motor/spring, Jacobians rebuilt
every sub-step: swing `J = −n + r′(φ)(z − cot θ · m)` (the radius change along an eccentric boundary — without
it a fast arm sank 36° into the corners), exact twist Jacobian (Box3D's spherical `tan(θ/2)` form lags ~5° at 100°
swing). Warm-started, included in joint torque reports and recordings (def size tripwire 168 → 232).
- Wrapper mapping: a DRIVE gets limits when any angular axis has the limit flag on with lower < upper. Joint
**X = twist** (X limits), **Y/Z = swing bounds**: the swing rotation-vector components about joint Y and Z must stay
inside the ellipse whose semi-axes are the per-sign bounds (lower ≤ 0 ≤ upper, up to ±0.99π). Flag off = that axis
free; lower ≥ upper = locked. Measured between the joint frames AS CREATED (relative rotation identity = centre),
not the equilibrium-shifted target. Limit softness 120 Hz (joint default 60 Hz let the twist sink ~5° while the arm
slid along the swing boundary under 25 N·m). Limits rebuild the joint — configure before `AddChild`.
- Plain drives (angular limit flags off, or Godot's locked default with no real range) are byte-for-byte unchanged:
`ragdoll_spike` prints identical numbers on the old and new binary.
### Shoulder values (for `PlayerRagdoll`)
Joint frame at build: X = the humerus as built (`dir`), Y = world up made square to it, Z = X × Y. Fitted against
target arm directions (chest frame): bounds Y ∈ [−90°, +100°] (right arm; left mirrors to [−100°, +90°]),
Z ∈ [−85°, +155°], twist ±80°. Inside with ≥ 2.6° margin: hanging, 45° extension, forward, overhead, overhead 10°
behind, lateral, lateral 30° behind, 45°-across hug, hand on the opposite shoulder, hand behind the back, up-and-across.
Out by ≥ 8°: medial horizontal, 80° extension, straight back, behind-up, behind the head across, medial behind-down.
(Arm hanging medially in front of the torso stays allowed — real, and own-body collision is off anyway.)
### Proof (added to `sixdof_spike`, 3× identical; weld_spike 3× PASS on the same binary)
Shoulder-style drive (2 kg × 0.3 m arm, socket = linear velocity motor 2000 N, bounds Y [−20°, 60°], Z [−40°, 100°],
twist ±45°): a 25 N·m torque sweeping every swing direction + 6 N·m alternating twist → swing overshoot 1.1° max,
every bound reached (61/20/101/41°), twist max 46.3°, socket 1.8 mm. A 30 N·m muscle PD reaches targets inside the
range (0.0 / 0.0 / 1.3° error) and stops at 60.2° when commanded to 85° (beyond the 60° bound). Moving the socket
5.8 cm (shrug) tracks to 0.0 mm with the arm still (0.0° swing).
+28
View File
@@ -219,3 +219,31 @@ fall); held poses drift 1 cm with the eye within 7 mm; crawl, mantle, climb, pul
| **Stick crouch (right stick Y)** = `RagdollDriver.StickCrouch`, a virtual floor this far above the real one, fed to the solver, ground control and the view re-centering | The pose crouches, the play space settles that far below the ground, the camera rides down. Spike: 0.6 m → eye 1.045, origin −0.601, eye vs camera 5 mm; back up to 1.650 | | **Stick crouch (right stick Y)** = `RagdollDriver.StickCrouch`, a virtual floor this far above the real one, fed to the solver, ground control and the view re-centering | The pose crouches, the play space settles that far below the ground, the camera rides down. Spike: 0.6 m → eye 1.045, origin −0.601, eye vs camera 5 mm; back up to 1.650 |
| **XR origin drawn interpolated** (`PlayerRig`: physics owns `_physOrigin`; `_Process` (priority −100) shows `prev.InterpolateWith(cur, Engine.GetPhysicsInterpolationFraction())`, `_PhysicsProcess` (priority −100) restores it; anyone else moving the origin is adopted; jumps/snap turns don't interpolate) | The bodies are drawn interpolated a tick behind while the origin stepped raw at 144 Hz under a 90 Hz display — the body looked like it was in two places when moving (owner). Not verifiable headless; needs the headset | | **XR origin drawn interpolated** (`PlayerRig`: physics owns `_physOrigin`; `_Process` (priority −100) shows `prev.InterpolateWith(cur, Engine.GetPhysicsInterpolationFraction())`, `_PhysicsProcess` (priority −100) restores it; anyone else moving the origin is adopted; jumps/snap turns don't interpolate) | The bodies are drawn interpolated a tick behind while the origin stepped raw at 144 Hz under a 90 Hz display — the body looked like it was in two places when moving (owner). Not verifiable headless; needs the headset |
| Menu summon on left X only | Right A is jump | | Menu summon on left X only | Right A is jump |
**Shoulder range (2026-10-02):** the fork's DRIVE now honours angular limits (Box3D motor joint + swing ellipse/twist, `research/godot-box3d-6dof.md`); `PlayerRagdoll.ShoulderRange` (Y −90..+100° mirrored, Z −85..+155°, twist ±80°, frame X = humerus at build). Ragdoll spike unchanged with it on — the limits only engage when something forces the arm.
## Step 2.7 — climbing family, soft grab, charge jump, render-timed tracking (2026-10-02)
Owner: Nimso's Project TX is the main source of mechanics. His clambering has "no actual mechanics — the physics
engine does all the work"; higher ground "doesn't move my body up … until I push on it with enough pressure".
| Change | Why / measured |
|---|---|
| **Ground = centre ray + sphere footprint** (r 0.12, face normal from a ray 2 cm inside the contact). Same-level ground from either; higher ground from the centre ray, or from the footprint while clambering | One ray dropped support as the hips swayed off a plank. Spike: 15 cm plank, ±9 cm hip sway → never loses the plank |
| **Pressure rule** replaces "hips well above + whole hips over": stand on higher ground when the hips are over it and pressing = airborne, hands braced, lifted, a press within 0.6 s, or legs tucked | The old rule needed a full press-up. Two hands mantle 0.9 m and chest-high 1.2 m; one hand can't do 1.2 m |
| **Bracing on physics objects** (≥ 15 kg or frozen; not what the hand carries) | Mantling the 25 kg playground box never counted as bracing (owner screenshot). Now mantles |
| **Knee up**: braced (or just pressed) + hips held back tucks the legs collider | Thigh-high box: straight arms can't press while standing |
| **Capsule hips** (Nimso) | The box pelvis's bottom edge caught ledge lips 1 cm below it |
| **Support goal ≤ straight legs** | A head above the calibrated height (tiptoes) floated the body 35 cm |
| **Arms never get collision exceptions** with held objects | Gripping one hold of a crag rock switched the arm off against the whole rock — arms "randomly" phased through the world |
| **Soft grab** (`SoftGrab.cs`): grip point = palm-normal cast (else toward the centre); light props (≤ 6 kg) are moved to the palm, otherwise the hand goes to the hold; ~0.1 s, position only, weld when touching or stalled | Welds froze hover gaps. 1 kg cube from 6 cm → 1.5 mm in 0.10 s; bar from 5 cm → 1.2 cm (contact slop) |
| **Jump: hold A to charge, release** (support goal sinks at 1.2 m/s, max stroke 0.5 m; JumpForce 2.2 g) | Owner: Boneworks-style. Tap 0.16 m, full charge 0.68 m, crouch + tap 0.66 m |
| **Render-clock tracking timestamps** (`PoseFilter.Sample(..., sampleTime)`, `PlayerRig.TrackingAge`, bible v2 recipe) | At 1 m/s the per-tick speed spread is 1.25 m/s by arrival vs 0.013 m/s render-timed — every feed-forward beat at 18 Hz. Constant ~2-tick lag instead. Toggle in the menu to compare |
| **max_physics_steps_per_frame 14 → 6** | One hitch could queue 14 ragdoll ticks in a frame (bible v2) |
| Tried and reverted: arm aim from the physical shoulder vertically while climbing/tilted | Free-hand error 6.4 → 6.2 cm only, and it weakened pulls. The "tracking off when tilted" screenshots need a recording to diagnose |
**2.7 follow-ups (owner playtest):** grab buffer — a squeeze that finds nothing keeps trying for 0.4 s while held
(`PropGrabber.GrabBuffer`; launching up a ladder left too short a window to catch a rung; Nimso's videos only
mention arm collision saving missed grabs). Elbow pole clamp asymmetric: 110° outward, 35° inward
(`ArmSolver.PoleMaxInward`; controller turned out folded the elbow 5.6 cm inward → 2.6 cm; 17° broke the crawl's
pillar arms). Known, minor: the procedural legs lag and jitter during very fast falls.
+42 -69
View File
@@ -1,17 +1,16 @@
[gd_scene load_steps=22 format=3] [gd_scene format=3 uid="uid://dy3bqxairm8lu"]
[ext_resource type="Script" path="res://scripts/Main.cs" id="1_main"] [ext_resource type="Script" uid="uid://dsy2jm1gdcao0" path="res://scripts/Main.cs" id="1_main"]
[ext_resource type="PackedScene" path="res://scenes/teleport_marker.tscn" id="2_marker"] [ext_resource type="Script" uid="uid://be6cxrxsc202g" path="res://scripts/Locomotion.cs" id="3_locomotion"]
[ext_resource type="Script" path="res://scripts/Locomotion.cs" id="3_locomotion"] [ext_resource type="Script" uid="uid://babrdgj2luqwv" path="res://scripts/WorldPanel.cs" id="4_worldpanel"]
[ext_resource type="Script" path="res://scripts/WorldPanel.cs" id="4_worldpanel"] [ext_resource type="Script" uid="uid://00dofwddtngn" path="res://scripts/AnchorMoveMode.cs" id="5_anchormove"]
[ext_resource type="Script" path="res://scripts/AnchorMoveMode.cs" id="5_anchormove"] [ext_resource type="Script" uid="uid://dathgkdgedhrj" path="res://scripts/PropShop.cs" id="6_propshop"]
[ext_resource type="Script" path="res://scripts/PropShop.cs" id="6_propshop"] [ext_resource type="Script" uid="uid://70m2rsbntlm4" path="res://scripts/PropGrabber.cs" id="7_propgrabber"]
[ext_resource type="Script" path="res://scripts/PropGrabber.cs" id="7_propgrabber"] [ext_resource type="Script" uid="uid://uniaovn6oy51" path="res://scripts/Calibration.cs" id="12_calibration"]
[ext_resource type="Script" path="res://scripts/Calibration.cs" id="12_calibration"] [ext_resource type="Script" uid="uid://cr1somauqye6" path="res://scripts/TrueBody.cs" id="13_truebody"]
[ext_resource type="Script" path="res://scripts/TrueBody.cs" id="13_truebody"] [ext_resource type="Script" uid="uid://n62b8ehiuaeb" path="res://scripts/PlayerRig.cs" id="14_playerrig"]
[ext_resource type="Script" path="res://scripts/PlayerRig.cs" id="14_playerrig"] [ext_resource type="PackedScene" path="res://scenes/maps/playground.tscn" id="15_playground"]
[ext_resource type="Script" path="res://scripts/Playground.cs" id="15_playground"] [ext_resource type="PackedScene" uid="uid://dbotaq78lap70" path="res://scenes/maps/crag.tscn" id="16_crag"]
[ext_resource type="Script" path="res://scripts/Crag.cs" id="16_crag"]
[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)
@@ -24,6 +23,9 @@ sky_material = SubResource("ProceduralSkyMaterial_sky")
background_mode = 2 background_mode = 2
sky = SubResource("Sky_main") sky = SubResource("Sky_main")
[sub_resource type="BoxShape3D" id="BoxShape_floor"]
size = Vector3(80, 0.1, 80)
[sub_resource type="StandardMaterial3D" id="Material_floor"] [sub_resource type="StandardMaterial3D" id="Material_floor"]
albedo_color = Color(0.35, 0.35, 0.38, 1) albedo_color = Color(0.35, 0.35, 0.38, 1)
@@ -31,112 +33,83 @@ albedo_color = Color(0.35, 0.35, 0.38, 1)
material = SubResource("Material_floor") material = SubResource("Material_floor")
size = Vector2(80, 80) size = Vector2(80, 80)
[sub_resource type="BoxShape3D" id="BoxShape_floor"] [node name="Main" type="Node3D" unique_id=1909672424]
size = Vector3(80, 0.1, 80)
[sub_resource type="SphereMesh" id="SphereMesh_hand"]
radius = 0.04
height = 0.08
[sub_resource type="StandardMaterial3D" id="Material_hand_left"]
albedo_color = Color(0.9, 0.3, 0.35, 1)
[sub_resource type="StandardMaterial3D" id="Material_hand_right"]
albedo_color = Color(0.3, 0.5, 0.9, 1)
[node name="Main" type="Node3D"]
script = ExtResource("1_main") script = ExtResource("1_main")
[node name="WorldEnvironment" type="WorldEnvironment" parent="."] [node name="WorldEnvironment" type="WorldEnvironment" parent="." unique_id=2031010393]
environment = SubResource("Environment_main") environment = SubResource("Environment_main")
[node name="Sun" type="DirectionalLight3D" parent="."] [node name="Sun" type="DirectionalLight3D" parent="." unique_id=432080936]
transform = Transform3D(1, 0, 0, 0, 0.642788, 0.766044, 0, -0.766044, 0.642788, 0, 8, 0) transform = Transform3D(1, 0, 0, 0, 0.642788, 0.766044, 0, -0.766044, 0.642788, 0, 8, 0)
shadow_enabled = true shadow_enabled = true
directional_shadow_max_distance = 10.0
shadow_bias = 0.04 shadow_bias = 0.04
shadow_normal_bias = 0.5 shadow_normal_bias = 0.5
directional_shadow_split_1 = 0.021
directional_shadow_split_2 = 0.141
[node name="Floor" type="StaticBody3D" parent="." groups=["no_grab"]] [node name="Floor" type="StaticBody3D" parent="." unique_id=777163049 groups=["no_grab"]]
collision_mask = 29 collision_mask = 29
[node name="FloorShape" type="CollisionShape3D" parent="Floor"] [node name="FloorShape" type="CollisionShape3D" parent="Floor" unique_id=809623835]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -0.05, 0) transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -0.05, 0)
shape = SubResource("BoxShape_floor") shape = SubResource("BoxShape_floor")
[node name="FloorMesh" type="MeshInstance3D" parent="Floor"] [node name="FloorMesh" type="MeshInstance3D" parent="Floor" unique_id=256794069]
mesh = SubResource("PlaneMesh_floor") mesh = SubResource("PlaneMesh_floor")
[node name="XROrigin3D" type="XROrigin3D" parent="."] [node name="XROrigin3D" type="XROrigin3D" parent="." unique_id=1331283556]
physics_interpolation_mode = 2 physics_interpolation_mode = 2
[node name="XRCamera3D" type="XRCamera3D" parent="XROrigin3D"] [node name="XRCamera3D" type="XRCamera3D" parent="XROrigin3D" unique_id=1255391558]
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)
[node name="LeftHand" type="XRController3D" parent="XROrigin3D"] [node name="LeftHand" type="XRController3D" parent="XROrigin3D" unique_id=2026728916]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -0.5, 1, -0.5) transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -0.5, 1, -0.5)
tracker = &"left_hand" tracker = &"left_hand"
pose = &"default"
[node name="RightHand" type="XRController3D" parent="XROrigin3D"] [node name="RightHand" type="XRController3D" parent="XROrigin3D" unique_id=1389872386]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0.5, 1, -0.5) transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0.5, 1, -0.5)
tracker = &"right_hand" tracker = &"right_hand"
pose = &"default"
[node name="WorldPanel" type="Node3D" parent="XROrigin3D"] [node name="WorldPanel" type="Node3D" parent="XROrigin3D" unique_id=1603972303]
script = ExtResource("4_worldpanel") script = ExtResource("4_worldpanel")
[node name="SpawnPoint" type="Marker3D" parent="."] [node name="SpawnPoint" type="Marker3D" parent="." unique_id=423410557]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 2.5) transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 2.5)
[node name="TeleportMarkers" type="Node3D" parent="."] [node name="Locomotion" type="Node" parent="." unique_id=1254803967]
[node name="Marker1" parent="TeleportMarkers" instance=ExtResource("2_marker")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -3, 0, -3)
MarkerLabel = "1"
[node name="Marker2" parent="TeleportMarkers" instance=ExtResource("2_marker")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 3, 0, -3)
MarkerLabel = "2"
[node name="Marker3" parent="TeleportMarkers" instance=ExtResource("2_marker")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, -4)
MarkerLabel = "3"
[node name="Locomotion" type="Node" parent="."]
script = ExtResource("3_locomotion") script = ExtResource("3_locomotion")
[node name="AnchorMoveMode" type="Node" parent="."] [node name="AnchorMoveMode" type="Node" parent="." unique_id=501015667]
script = ExtResource("5_anchormove") script = ExtResource("5_anchormove")
[node name="Playground" type="Node3D" parent="."] [node name="Playground" parent="." unique_id=75577887 instance=ExtResource("15_playground")]
script = ExtResource("15_playground")
[node name="Crag" type="Node3D" parent="."] [node name="Crag" parent="." unique_id=1556412935 instance=ExtResource("16_crag")]
script = ExtResource("16_crag") transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0.016632557, 0, -0.0011329651)
[node name="PropShop" type="Node3D" parent="."] [node name="PropShop" type="Node3D" parent="." unique_id=2025490553]
script = ExtResource("6_propshop") script = ExtResource("6_propshop")
[node name="PropGrabber" type="Node" parent="."] [node name="PropGrabber" type="Node" parent="." unique_id=36341590]
script = ExtResource("7_propgrabber") script = ExtResource("7_propgrabber")
[node name="Calibration" type="Node" parent="."] [node name="Calibration" type="Node" parent="." unique_id=701360651]
script = ExtResource("12_calibration") script = ExtResource("12_calibration")
[node name="TrueBody" type="Node3D" parent="."] [node name="TrueBody" type="Node3D" parent="." unique_id=851816669]
physics_interpolation_mode = 2 physics_interpolation_mode = 2
script = ExtResource("13_truebody") script = ExtResource("13_truebody")
[node name="PlayerRig" type="Node" parent="."] [node name="PlayerRig" type="Node" parent="." unique_id=506216051]
physics_interpolation_mode = 2 physics_interpolation_mode = 2
script = ExtResource("14_playerrig") script = ExtResource("14_playerrig")
[node name="FadeLayer" type="CanvasLayer" parent="."] [node name="FadeLayer" type="CanvasLayer" parent="." unique_id=950278420]
[node name="Fade" type="ColorRect" parent="FadeLayer"] [node name="Fade" type="ColorRect" parent="FadeLayer" unique_id=774436280]
anchors_preset = 15 anchors_preset = 15
anchor_right = 1.0 anchor_right = 1.0
anchor_bottom = 1.0 anchor_bottom = 1.0
color = Color(0, 0, 0, 0)
mouse_filter = 2 mouse_filter = 2
color = Color(0, 0, 0, 0)
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+6 -2
View File
@@ -11,7 +11,8 @@ public sealed class ArmSolver
public const float SoftIKFactor = 0.98f; public const float SoftIKFactor = 0.98f;
public const float PoleHandWeight = 0.85f; public const float PoleHandWeight = 0.85f;
public const float PoleSmoothRate = 12f; public const float PoleSmoothRate = 12f;
public const float PoleMaxAngle = 1.92f; // 110 deg either way from down/back/out public const float PoleMaxAngle = 1.92f; // 110 deg outward from down/back/out
public const float PoleMaxInward = 0.6f; // 35 deg inward: a controller turned outward must not fold the elbows toward each other (owner)
public const float PoleMaxSpeed = 6f; // rad/s public const float PoleMaxSpeed = 6f; // rad/s
public const float ElbowRiseAllowance = 0.05f; public const float ElbowRiseAllowance = 0.05f;
@@ -76,7 +77,10 @@ public sealed class ArmSolver
targetAngle = raw * PoleHandWeight * confidence; targetAngle = raw * PoleHandWeight * confidence;
} }
} }
targetAngle = Mathf.Clamp(targetAngle, -PoleMaxAngle, PoleMaxAngle); // Asymmetric: the elbow may swing far out, but only a little in toward the body's midline.
float outward = refB.Dot(side); // > 0: a positive angle swings the elbow outward
float inwardLimit = Mathf.Lerp(PoleMaxAngle, PoleMaxInward, Mathf.Clamp(Mathf.Abs(outward) / 0.5f, 0f, 1f));
targetAngle = outward >= 0f ? Mathf.Clamp(targetAngle, -inwardLimit, PoleMaxAngle) : Mathf.Clamp(targetAngle, -PoleMaxAngle, inwardLimit);
float step = (targetAngle - _poleAngle) * (1f - Mathf.Exp(-delta * PoleSmoothRate)); float step = (targetAngle - _poleAngle) * (1f - Mathf.Exp(-delta * PoleSmoothRate));
_poleAngle += Mathf.Clamp(step, -PoleMaxSpeed * delta, PoleMaxSpeed * delta); _poleAngle += Mathf.Clamp(step, -PoleMaxSpeed * delta, PoleMaxSpeed * delta);
Vector3 pole = refN.Rotated(dir, _poleAngle); Vector3 pole = refN.Rotated(dir, _poleAngle);
+89 -26
View File
@@ -12,11 +12,20 @@ using System.Collections.Generic;
// 6. a 0.9 m chimney with holds on both walls up the third tier; // 6. a 0.9 m chimney with holds on both walls up the third tier;
// 7. the summit (26 m) with throwable props. Way down: aim at the "Crag" teleport marker at the // 7. the summit (26 m) with throwable props. Way down: aim at the "Crag" teleport marker at the
// base (or jump — no fall damage). // base (or jump — no fall damage).
// The cliff faces south (+Z) toward spawn. Every route point is recorded in Route so crag_spike // The cliff faces south (+Z) toward spawn. EDITOR-BUILT: this is the root of scenes/maps/crag.tscn
// can check reach; route bodies in RoutePieces so it can check nothing intersects them. // and Rebuild() generates everything into that scene (MapScene: inspector button, or
// tools/rebuild_maps.tscn headless); the game loads the saved scene. The route is saved too: every
// route point is a Marker3D under "Route" (metadata kind/label, in climbing order), route bodies are in
// group "crag_route" and rock in "crag_mass" — Route/RoutePieces/MassPieces are read back from the
// saved nodes on _Ready, so crag_spike checks the scene as saved (and as hand-edited).
[Tool]
public partial class Crag : Node3D public partial class Crag : Node3D
{ {
[ExportToolButton("Rebuild (replaces all children)")]
public Callable RebuildButton => Callable.From(RebuildInEditor);
public enum Step { Stand, Hold } public enum Step { Stand, Hold }
public const string RouteNode = "Route", RouteGroup = "crag_route", MassGroup = "crag_mass";
public readonly List<(Step Kind, Vector3 Point, string Name)> Route = new(); public readonly List<(Step Kind, Vector3 Point, string Name)> Route = new();
public readonly List<(CollisionObject3D Body, Aabb Box)> RoutePieces = new(); public readonly List<(CollisionObject3D Body, Aabb Box)> RoutePieces = new();
public readonly List<(CollisionObject3D Body, Aabb Box)> MassPieces = new(); public readonly List<(CollisionObject3D Body, Aabb Box)> MassPieces = new();
@@ -37,25 +46,76 @@ public partial class Crag : Node3D
public override void _Ready() public override void _Ready()
{ {
if (Engine.IsEditorHint())
return;
MapScene.CollectProps(this, _dynamic);
ReadLayout();
}
public void Reset() => MapScene.Reset(_dynamic);
private void RebuildInEditor()
{
if (!MapScene.CanRebuildInEditor(this))
return;
Rebuild();
MapScene.MarkUnsaved();
}
// Replaces every child with the generated layout, owned by this map (saved with its scene).
public void Rebuild()
{
MapScene.Clear(this);
_rng.Seed = 26; // same rubble/talus every rebuild
_route = new Node3D { Name = RouteNode };
AddChild(_route);
BuildMass(); BuildMass();
BuildRoute(); BuildRoute();
BuildBase(); BuildBase();
BuildSummitProps(); BuildSummitProps();
MapScene.Adopt(this);
ReadLayout();
} }
public void Reset() // Route points and piece bounds from the (saved) nodes.
public void ReadLayout()
{ {
foreach (var (body, start) in _dynamic) Route.Clear();
RoutePieces.Clear();
MassPieces.Clear();
if (GetNodeOrNull<Node>(RouteNode) is Node route)
foreach (Node child in route.GetChildren())
if (child is Marker3D m)
Route.Add(((Step)(int)m.GetMeta("kind"), m.GlobalPosition, (string)m.GetMeta("label")));
foreach (Node child in GetChildren())
{ {
if (!IsInstanceValid(body)) if (child is not CollisionObject3D body)
continue; continue;
body.GlobalTransform = start; if (body.IsInGroup(RouteGroup))
body.LinearVelocity = Vector3.Zero; RoutePieces.Add((body, Bounds(body)));
body.AngularVelocity = Vector3.Zero; else if (body.IsInGroup(MassGroup))
body.ResetPhysicsInterpolation(); MassPieces.Add((body, Bounds(body)));
} }
} }
// World bounds of a box or a cylinder piece (a cylinder's ends are flat: radius across its axis only).
private static Aabb Bounds(CollisionObject3D body)
{
CollisionShape3D cs = body.GetChildOrNull<CollisionShape3D>(0);
Transform3D t = body.GlobalTransform * cs.Transform;
if (cs.Shape is CylinderShape3D cyl)
{
Vector3 from = t * new Vector3(0f, -cyl.Height * 0.5f, 0f), to = t * new Vector3(0f, cyl.Height * 0.5f, 0f);
Vector3 d = (to - from).Normalized(), r = new(
cyl.Radius * Mathf.Sqrt(Mathf.Max(0f, 1f - d.X * d.X)), cyl.Radius * Mathf.Sqrt(Mathf.Max(0f, 1f - d.Y * d.Y)), cyl.Radius * Mathf.Sqrt(Mathf.Max(0f, 1f - d.Z * d.Z)));
return new Aabb(from - r, Vector3.Zero).Expand(from + r).Expand(to - r).Expand(to + r);
}
Vector3 size = ((BoxShape3D)cs.Shape).Size;
return t * new Aabb(-size * 0.5f, size);
}
private Node3D _route;
// ---------------------------------------------------------------- the rock // ---------------------------------------------------------------- the rock
private void BuildMass() private void BuildMass()
@@ -241,8 +301,9 @@ public partial class Crag : Node3D
// The way down: a teleport marker at the base (aim at it from the summit). // The way down: a teleport marker at the base (aim at it from the summit).
Node3D marker = GD.Load<PackedScene>("res://scenes/teleport_marker.tscn").Instantiate<Node3D>(); Node3D marker = GD.Load<PackedScene>("res://scenes/teleport_marker.tscn").Instantiate<Node3D>();
marker.Set("MarkerLabel", "Crag"); marker.Set("MarkerLabel", "Crag");
marker.Name = "CragMarker";
marker.Position = new Vector3(1.5f, 0f, -9.5f); marker.Position = new Vector3(1.5f, 0f, -9.5f);
AddChild(marker); AddChild(marker, true);
} }
private void BuildSummitProps() private void BuildSummitProps()
@@ -258,45 +319,47 @@ public partial class Crag : Node3D
// ---------------------------------------------------------------- builders // ---------------------------------------------------------------- builders
private void AddStep(Step kind, Vector3 point, string name) => Route.Add((kind, point, name)); // A route point: an editor-visible Marker3D under "Route", in climbing order.
private void AddStep(Step kind, Vector3 point, string name)
{
Marker3D m = new() { Name = kind == Step.Hold ? "Hold" : "Stand", Position = point };
m.SetMeta("kind", (int)kind);
m.SetMeta("label", name);
_route.AddChild(m, true);
}
private StaticBody3D Mass(string name, Vector3 size, Vector3 center, Color color, Basis? basis = null) private StaticBody3D Mass(string name, Vector3 size, Vector3 center, Color color, Basis? basis = null)
{ {
StaticBody3D b = Block(name, size, center, color, true, basis); StaticBody3D b = Block(name, size, center, color, true, basis);
MassPieces.Add((b, BoxAabb(size, b.Transform))); b.AddToGroup(MassGroup, true);
return b; return b;
} }
private StaticBody3D RouteBlock(string name, Vector3 size, Vector3 center, Color color, bool castShadow = true) private StaticBody3D RouteBlock(string name, Vector3 size, Vector3 center, Color color, bool castShadow = true)
{ {
StaticBody3D b = Block(name, size, center, color, castShadow); StaticBody3D b = Block(name, size, center, color, castShadow);
RoutePieces.Add((b, BoxAabb(size, b.Transform))); b.AddToGroup(RouteGroup, true);
return b; return b;
} }
private StaticBody3D RouteBar(string name, Vector3 from, Vector3 to, float radius, Color color) private StaticBody3D RouteBar(string name, Vector3 from, Vector3 to, float radius, Color color)
{ {
StaticBody3D b = Bar(name, from, to, radius, color); StaticBody3D b = Bar(name, from, to, radius, color);
// Bounds of the cylinder: radius across the axis only (the ends are flat). b.AddToGroup(RouteGroup, true);
Vector3 d = (to - from).Normalized(), r = new Vector3(
radius * Mathf.Sqrt(Mathf.Max(0f, 1f - d.X * d.X)), radius * Mathf.Sqrt(Mathf.Max(0f, 1f - d.Y * d.Y)), radius * Mathf.Sqrt(Mathf.Max(0f, 1f - d.Z * d.Z)));
RoutePieces.Add((b, new Aabb(from - r, Vector3.Zero).Expand(from + r).Expand(to - r).Expand(to + r)));
return b; return b;
} }
private static Aabb BoxAabb(Vector3 size, Transform3D t) => t * new Aabb(-size * 0.5f, size);
private StaticBody3D Block(string name, Vector3 size, Vector3 center, Color color, bool castShadow, Basis? basis = null, bool grab = true) private StaticBody3D Block(string name, Vector3 size, Vector3 center, Color color, bool castShadow, Basis? basis = null, bool grab = true)
{ {
StaticBody3D body = new() { Name = name, CollisionLayer = WorldLayer, CollisionMask = WorldMask, Transform = new Transform3D(basis ?? Basis.Identity, center) }; StaticBody3D body = new() { Name = name, CollisionLayer = WorldLayer, CollisionMask = WorldMask, Transform = new Transform3D(basis ?? Basis.Identity, center) };
if (!grab) if (!grab)
body.AddToGroup(PropGrabber.NoGrabGroup); body.AddToGroup(PropGrabber.NoGrabGroup, true);
body.AddChild(new CollisionShape3D { Shape = new BoxShape3D { Size = size } }); body.AddChild(new CollisionShape3D { Shape = new BoxShape3D { Size = size } });
MeshInstance3D mesh = Mesh(new BoxMesh { Size = size }, color); MeshInstance3D mesh = Mesh(new BoxMesh { Size = size }, color);
if (!castShadow) if (!castShadow)
mesh.CastShadow = GeometryInstance3D.ShadowCastingSetting.Off; mesh.CastShadow = GeometryInstance3D.ShadowCastingSetting.Off;
body.AddChild(mesh); body.AddChild(mesh);
AddChild(body); AddChild(body, true);
return body; return body;
} }
@@ -309,7 +372,7 @@ public partial class Crag : Node3D
StaticBody3D body = new() { Name = name, CollisionLayer = WorldLayer, CollisionMask = WorldMask, Transform = new Transform3D(new Basis(x, y, x.Cross(y)), (from + to) * 0.5f) }; 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(new CollisionShape3D { Shape = new CylinderShape3D { Radius = radius, Height = len } });
body.AddChild(Mesh(new CylinderMesh { TopRadius = radius, BottomRadius = radius, Height = len, RadialSegments = 12 }, color)); body.AddChild(Mesh(new CylinderMesh { TopRadius = radius, BottomRadius = radius, Height = len, RadialSegments = 12 }, color));
AddChild(body); AddChild(body, true);
return body; return body;
} }
@@ -325,11 +388,10 @@ public partial class Crag : Node3D
MaxContactsReported = 4, MaxContactsReported = 4,
PhysicsMaterialOverride = new PhysicsMaterial { Friction = 0.8f, Bounce = 0.05f }, PhysicsMaterialOverride = new PhysicsMaterial { Friction = 0.8f, Bounce = 0.05f },
}; };
body.AddToGroup("prop"); body.AddToGroup(MapScene.PropGroup, true);
body.AddChild(new CollisionShape3D { Shape = shape }); body.AddChild(new CollisionShape3D { Shape = shape });
body.AddChild(Mesh(mesh, color)); body.AddChild(Mesh(mesh, color));
AddChild(body); AddChild(body, true);
_dynamic.Add((body, body.GlobalTransform));
return body; return body;
} }
@@ -347,12 +409,13 @@ public partial class Crag : Node3D
{ {
AddChild(new Label3D AddChild(new Label3D
{ {
Name = "Sign",
Text = text, Text = text,
FontSize = 96, FontSize = 96,
PixelSize = 0.002f * scale, PixelSize = 0.002f * scale,
OutlineSize = 12, OutlineSize = 12,
Billboard = BaseMaterial3D.BillboardModeEnum.FixedY, Billboard = BaseMaterial3D.BillboardModeEnum.FixedY,
Position = at, Position = at,
}); }, true);
} }
} }
+5 -3
View File
@@ -1,7 +1,8 @@
using Godot; using Godot;
using System.Collections.Generic; using System.Collections.Generic;
// Regression for the crag (no player): the route is climbable on paper — consecutive route points // Regression for the crag (no player), checked on the SAVED scenes/maps/crag.tscn (route = its
// Route markers; rebuild with tools/rebuild_maps.tscn): the route is climbable on paper — consecutive route points
// within human reach, every point in free space, route pieces not buried in each other or the // within human reach, every point in free space, route pieces not buried in each other or the
// rock — the summit is high enough, and it's in plain view from spawn. Prints "CRAG: overall PASS". // rock — the summit is high enough, and it's in plain view from spawn. Prints "CRAG: overall PASS".
public partial class CragSpike : Node3D public partial class CragSpike : Node3D
@@ -23,8 +24,9 @@ public partial class CragSpike : Node3D
floor.AddToGroup(PropGrabber.NoGrabGroup); floor.AddToGroup(PropGrabber.NoGrabGroup);
floor.AddChild(new CollisionShape3D { Shape = new BoxShape3D { Size = new Vector3(80, 0.1f, 80) }, Position = new Vector3(0, -0.05f, 0) }); floor.AddChild(new CollisionShape3D { Shape = new BoxShape3D { Size = new Vector3(80, 0.1f, 80) }, Position = new Vector3(0, -0.05f, 0) });
AddChild(floor); AddChild(floor);
AddChild(new Playground()); // The SAVED scenes (as the game loads them, hand edits included), not a fresh rebuild.
_crag = new Crag(); AddChild(GD.Load<PackedScene>("res://scenes/maps/playground.tscn").Instantiate());
_crag = GD.Load<PackedScene>("res://scenes/maps/crag.tscn").Instantiate<Crag>();
AddChild(_crag); AddChild(_crag);
} }
+5
View File
@@ -20,6 +20,7 @@ public partial class MainMenu : PanelContainer
public event Action Clicked; public event Action Clicked;
public event Action<bool> SpectatorToggled; public event Action<bool> SpectatorToggled;
public event Action<bool> SmoothTurnToggled; public event Action<bool> SmoothTurnToggled;
public event Action<bool> LowLagTrackingToggled;
public event Action<float> StrengthChanged; public event Action<float> StrengthChanged;
public TabContainer Tabs { get; private set; } public TabContainer Tabs { get; private set; }
@@ -155,6 +156,10 @@ public partial class MainMenu : PanelContainer
SpectatorButton = MakeToggle("Spectator: off", "Spectator: on (grab-fly)", on => SpectatorToggled?.Invoke(on)); SpectatorButton = MakeToggle("Spectator: off", "Spectator: on (grab-fly)", on => SpectatorToggled?.Invoke(on));
modeRow.AddChild(SpectatorButton); modeRow.AddChild(SpectatorButton);
player.AddChild(modeRow); player.AddChild(modeRow);
// Compare in the headset: tracking timed by the render clock (even motion) or by arrival (less lag).
HBoxContainer trackingRow = new();
trackingRow.AddChild(MakeToggle("Tracking: steady", "Tracking: low lag", on => LowLagTrackingToggled?.Invoke(on)));
player.AddChild(trackingRow);
Tabs.AddChild(player); Tabs.AddChild(player);
// World // World
+80
View File
@@ -0,0 +1,80 @@
using Godot;
using System.Collections.Generic;
// Shared plumbing for the editor-built maps (Playground, Crag). Each map is the ROOT of its own scene
// (scenes/maps/*.tscn) with a [Tool] script whose Rebuild() regenerates every child from code and
// gives it to the scene (Owner = the map root) so it is saved into the .tscn like hand-made nodes.
// The game never regenerates: it loads what was saved, so edits made in the editor stick (until the
// next Rebuild, which replaces ALL children). Rebuild from the inspector button with the map scene
// open, or headless for agents: godot-mono --headless --path . tools/rebuild_maps.tscn
public static class MapScene
{
public const string PropGroup = "prop";
// The inspector button only works with the map scene itself open (nodes owned by an instanced
// sub-scene's root aren't saved into the outer scene).
public static bool CanRebuildInEditor(Node map)
{
if (map.GetTree()?.EditedSceneRoot == map)
return true;
GD.PushWarning($"{map.Name}: open its own scene (scenes/maps/) to rebuild it — it's instanced here.");
return false;
}
public static void Clear(Node map)
{
foreach (Node child in map.GetChildren())
{
map.RemoveChild(child);
child.Free();
}
}
// Every generated node belongs to the map scene; children of instanced sub-scenes (e.g. a
// teleport marker) keep their own owner.
public static void Adopt(Node map)
{
foreach (Node child in map.GetChildren())
Adopt(map, child);
}
private static void Adopt(Node root, Node node)
{
if (node.Owner == null)
node.Owner = root;
if (node.SceneFilePath != "")
return;
foreach (Node child in node.GetChildren())
Adopt(root, child);
}
public static void MarkUnsaved()
{
#if TOOLS
if (Engine.IsEditorHint())
EditorInterface.Singleton.MarkSceneAsUnsaved();
#endif
}
// Runtime: the dynamic props (group "prop") and where they start, for Reset().
public static void CollectProps(Node map, List<(RigidBody3D Body, Transform3D Start)> props)
{
props.Clear();
foreach (Node child in map.GetChildren())
if (child is RigidBody3D body && body.IsInGroup(PropGroup))
props.Add((body, body.GlobalTransform));
}
public static void Reset(List<(RigidBody3D Body, Transform3D Start)> props)
{
foreach (var (body, start) in props)
{
if (!GodotObject.IsInstanceValid(body))
continue;
body.GlobalTransform = start;
body.LinearVelocity = Vector3.Zero;
body.AngularVelocity = Vector3.Zero;
body.ResetPhysicsInterpolation();
}
}
}
+1
View File
@@ -0,0 +1 @@
uid://d0oo5qx6v57bs
+183 -77
View File
@@ -64,15 +64,16 @@ public partial class PlayerRagdoll : Node3D
// ~0.3 m from 1 m, legs fully folded from a big fall (Boneworks) — then the normal rise. // ~0.3 m from 1 m, legs fully folded from a big fall (Boneworks) — then the normal rise.
public const float LandingReach = 0.05f; // m past the pose's hip height public const float LandingReach = 0.05f; // m past the pose's hip height
public const float AbsorbTime = 0.4f; // s after a hard touchdown the head guide stays off (it would hold the dip up) public const float AbsorbTime = 0.4f; // s after a hard touchdown the head guide stays off (it would hold the dip up)
// Jump (right A): a short countermovement dip, then the legs push JumpForce over the stroke up to // Jump (right A, Boneworks-style hold to charge, release to jump): holding bends the legs — the
// full extension. Takeoff speed is physics — v² = 2·(F/m − g)·stroke — so a crouch before // support goal sinks at ChargeSpeed down to JumpMaxStroke below full extension — and releasing
// the press (real or stick) jumps higher: ~0.45 m standing, ~0.7 m from a deep crouch. // pushes JumpForce over that stroke up to extension. Takeoff speed is physics,
public const float JumpForce = 2.4f; // × body weight // v² = 2·(F/m − g)·stroke: a tap hops, a full charge (or a real/stick crouch) jumps ~0.7 m.
public const float JumpDip = 0.2f; // m the hips drop first when pressed from standing public const float JumpForce = 2.2f; // × body weight
public const float ChargeSpeed = 1.2f; // m/s the hips sink while A is held
public const float JumpExtension = 0.06f; // m above the standing hips at toe-off public const float JumpExtension = 0.06f; // m above the standing hips at toe-off
public const float JumpMaxStroke = 0.5f; public const float JumpMaxStroke = 0.5f;
public const float JumpBuffer = 0.15f; // s a press is remembered before touchdown public const float JumpMinStroke = 0.08f; // a tap still hops
public const float CoyoteTime = 0.12f; // s after leaving the ground a jump still works public const float CoyoteTime = 0.12f; // s after leaving the ground a release still jumps
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 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 BracedAirControl = 150f; // N of horizontal hip carry while airborne but braced on the hands (mantling)
public const float CrawlCarry = 300f; public const float CrawlCarry = 300f;
@@ -187,7 +188,7 @@ public partial class PlayerRagdoll : Node3D
private float _supportGoalY; private float _supportGoalY;
private RigidBody3D _groundBody; private RigidBody3D _groundBody;
private Vector3 _groundPoint; private Vector3 _groundPoint;
private float _tuckFree, _dt; private float _tuckFree, _dt, _sinceBraced = float.MaxValue;
private PhysicsShapeQueryParameters3D _legsQuery; private PhysicsShapeQueryParameters3D _legsQuery;
private bool LegsSpaceFree(Vector3 center, Basis basis) private bool LegsSpaceFree(Vector3 center, Basis basis)
@@ -197,12 +198,13 @@ public partial class PlayerRagdoll : Node3D
return GetWorld3D().DirectSpaceState.IntersectShape(_legsQuery, 1).Count == 0; return GetWorld3D().DirectSpaceState.IntersectShape(_legsQuery, 1).Count == 0;
} }
private Vector3 _goalVelocity; private Vector3 _goalVelocity;
private enum JumpPhase { None, Dip, Push, Flight } private enum JumpPhase { None, Charge, Push, Flight }
private JumpPhase _jump; private JumpPhase _jump;
private float _jumpT, _jumpRequest, _airT, _jumpFromY, _sinceLanding = float.MaxValue; private float _jumpT, _airT, _jumpFromY, _sinceLanding = float.MaxValue;
private bool _jumpHeld;
// Right A. Buffered, so a press just before touchdown still jumps. // Right A, every tick. Holding while landing starts the charge on touchdown.
public void Jump() => _jumpRequest = JumpBuffer; public bool JumpHeld { set => _jumpHeld = value; }
private float _standingHip; // hips of the standing pose above the feet = the legs' full extension private float _standingHip; // hips of the standing pose above the feet = the legs' full extension
@@ -255,8 +257,11 @@ 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 - LegsMass, BodyLayer, WorldMask, pants, // Hips = a sideways capsule (Nimso: "hip collider: a capsule, ~27 cm diameter"): a box's bottom
c * new Transform3D(Basis.Identity, hip + _pelvisCenterLocal), friction: TorsoFriction); // edge caught on ledge lips a centimetre below it, so the hips couldn't slide over onto them.
float hipRadius = Mathf.Min(0.5f * Mathf.Max(pelvisSize.Y, pelvisSize.Z), 0.5f * pelvisSize.X);
Pelvis = MakeBody("Pelvis", new CapsuleShape3D { Radius = hipRadius, Height = pelvisSize.X }, (0.142f + 0.139f + 0.322f) * m - LegsMass, BodyLayer, WorldMask, pants,
c * new Transform3D(Basis.Identity, hip + _pelvisCenterLocal), friction: TorsoFriction, shapeBasis: new Basis(Vector3.Back, Mathf.Pi / 2f));
float standingHip = pose.PelvisTarget.Y - (pose.Eye.Y - p.EyeHeight); float standingHip = pose.PelvisTarget.Y - (pose.Eye.Y - p.EyeHeight);
_standingHip = standingHip + _pelvisCenterLocal.Y; // of the pelvis BODY (what ground control holds) _standingHip = standingHip + _pelvisCenterLocal.Y; // of the pelvis BODY (what ground control holds)
_legsHeight = Mathf.Max(0.2f, standingHip - MaxStepHeight); _legsHeight = Mathf.Max(0.2f, standingHip - MaxStepHeight);
@@ -321,7 +326,8 @@ public partial class PlayerRagdoll : Node3D
a.Hand.ContactMonitor = true; a.Hand.ContactMonitor = true;
a.Hand.MaxContactsReported = 8; a.Hand.MaxContactsReported = 8;
a.Shoulder = MakeDrive(Chest, a.Upper, c * new Transform3D(Basis.Identity, shoulder), GirdleForce, ShoulderTorque); a.Shoulder = MakeDrive(Chest, a.Upper, c * new Transform3D(ShoulderFrame(dir), shoulder), GirdleForce, ShoulderTorque,
j => ShoulderRange(j, sign));
a.Elbow = MakeHinge(a.Upper, a.Fore, c * elbow, c.Basis * new Basis(dir, hinge.Cross(dir), hinge)); a.Elbow = MakeHinge(a.Upper, a.Fore, c * elbow, c.Basis * new Basis(dir, hinge.Cross(dir), hinge));
a.Wrist = MakeBall(a.Fore, a.Hand, c * wrist, c.Basis * TwistAlong(dir), Mathf.DegToRad(80f), Mathf.DegToRad(90f), 0f, 0f); a.Wrist = MakeBall(a.Fore, a.Hand, c * wrist, c.Basis * TwistAlong(dir), Mathf.DegToRad(80f), Mathf.DegToRad(90f), 0f, 0f);
a.Guide = MakeDrive(Chest, a.Hand, c * new Transform3D(handBasis, handOrigin), HandGuideForce, HandGuideTorque); a.Guide = MakeDrive(Chest, a.Hand, c * new Transform3D(handBasis, handOrigin), HandGuideForce, HandGuideTorque);
@@ -348,7 +354,7 @@ public partial class PlayerRagdoll : Node3D
_arms[i] = new Arm(); _arms[i] = new Arm();
Built = false; Built = false;
_jump = JumpPhase.None; _jump = JumpPhase.None;
_jumpRequest = 0f; _jumpHeld = false;
_sinceLanding = float.MaxValue; _sinceLanding = float.MaxValue;
} }
@@ -369,14 +375,22 @@ public partial class PlayerRagdoll : Node3D
// NOTE (Box3D wrapper): friction is read only when the body's shapes are created — the material // NOTE (Box3D wrapper): friction is read only when the body's shapes are created — the material
// must be set before AddChild (setting it later silently does nothing). // must be set before AddChild (setting it later silently does nothing).
private RigidBody3D MakeBody(string name, Shape3D shape, float mass, uint layer, uint mask, Color color, Transform3D at, bool visible = true, float friction = 1f) private RigidBody3D MakeBody(string name, Shape3D shape, float mass, uint layer, uint mask, Color color, Transform3D at, bool visible = true, float friction = 1f,
Basis? shapeBasis = null)
{ {
RigidBody3D body = new() { Name = name, Mass = mass, CollisionLayer = layer, CollisionMask = mask, Transform = at, ContactMonitor = true, MaxContactsReported = 4, RigidBody3D body = new() { Name = name, Mass = mass, CollisionLayer = layer, CollisionMask = mask, Transform = at, ContactMonitor = true, MaxContactsReported = 4,
PhysicsMaterialOverride = new PhysicsMaterial { Friction = friction } }; PhysicsMaterialOverride = new PhysicsMaterial { Friction = friction } };
body.AddChild(new CollisionShape3D { Shape = shape }); Transform3D shapeAt = new(shapeBasis ?? Basis.Identity, Vector3.Zero);
if (shape is BoxShape3D box && layer != 0 && visible) body.AddChild(new CollisionShape3D { Shape = shape, Transform = shapeAt });
Mesh visual = shape switch
{ {
MeshInstance3D mesh = new() { Mesh = new BoxMesh { Size = box.Size } }; BoxShape3D box => new BoxMesh { Size = box.Size },
CapsuleShape3D cap => new CapsuleMesh { Radius = cap.Radius, Height = cap.Height },
_ => null,
};
if (visual != null && layer != 0 && visible)
{
MeshInstance3D mesh = new() { Mesh = visual, Transform = shapeAt };
mesh.SetSurfaceOverrideMaterial(0, new StandardMaterial3D { AlbedoColor = color, Roughness = 0.6f }); mesh.SetSurfaceOverrideMaterial(0, new StandardMaterial3D { AlbedoColor = color, Roughness = 0.6f });
body.AddChild(mesh); body.AddChild(mesh);
} }
@@ -412,7 +426,37 @@ public partial class PlayerRagdoll : Node3D
return new Ball { Joint = j, Parent = parent, Child = child, FrameA = Rot(parent).Inverse() * jq, FrameB = Rot(child).Inverse() * jq, Kp = kp }; return new Ball { Joint = j, Parent = parent, Child = child, FrameA = Rot(parent).Inverse() * jq, FrameB = Rot(child).Inverse() * jq, Kp = kp };
} }
private Drive MakeDrive(PhysicsBody3D parent, RigidBody3D child, Transform3D at, float force, float torque) // Shoulder joint frame: X = the humerus as built (the twist axis), Y = "up" square to it, Z = X × Y.
// The range below is measured from this build pose (relative rotation identity).
private static Basis ShoulderFrame(Vector3 bone)
{
Vector3 y = (Vector3.Up - bone * bone.Y).Normalized();
return new Basis(bone, y, bone.Cross(y));
}
// Anatomical range (fork: drive-mode angular limits): swing bounds about joint Y/Z, an asymmetric
// ellipse fitted so hanging, 45° extension, overhead (+10° behind), lateral 30° behind, a 45°-across
// hug and a hand behind the back fit with ≥ 2.5° to spare, while straight back, 80° extension,
// behind-the-head and medial-horizontal stay ≥ 8° out. The left arm mirrors Y (sign = −1).
public static readonly float ShoulderSwingBack = Mathf.DegToRad(90f), ShoulderSwingAcross = Mathf.DegToRad(100f);
public static readonly float ShoulderSwingDown = Mathf.DegToRad(85f), ShoulderSwingUp = Mathf.DegToRad(155f);
public static readonly float ShoulderTwist = Mathf.DegToRad(80f);
private static void ShoulderRange(Generic6DofJoint3D j, float sign)
{
j.SetFlagX(Generic6DofJoint3D.Flag.EnableAngularLimit, true);
j.SetFlagY(Generic6DofJoint3D.Flag.EnableAngularLimit, true);
j.SetFlagZ(Generic6DofJoint3D.Flag.EnableAngularLimit, true);
j.SetParamX(Generic6DofJoint3D.Param.AngularLowerLimit, -ShoulderTwist);
j.SetParamX(Generic6DofJoint3D.Param.AngularUpperLimit, ShoulderTwist);
j.SetParamY(Generic6DofJoint3D.Param.AngularLowerLimit, sign > 0f ? -ShoulderSwingBack : -ShoulderSwingAcross);
j.SetParamY(Generic6DofJoint3D.Param.AngularUpperLimit, sign > 0f ? ShoulderSwingAcross : ShoulderSwingBack);
j.SetParamZ(Generic6DofJoint3D.Param.AngularLowerLimit, -ShoulderSwingDown);
j.SetParamZ(Generic6DofJoint3D.Param.AngularUpperLimit, ShoulderSwingUp);
}
private Drive MakeDrive(PhysicsBody3D parent, RigidBody3D child, Transform3D at, float force, float torque,
System.Action<Generic6DofJoint3D> configure = null)
{ {
Generic6DofJoint3D j = new() { Transform = at, NodeA = parent.GetPath(), NodeB = child.GetPath(), ExcludeNodesFromCollision = true }; Generic6DofJoint3D j = new() { Transform = at, NodeA = parent.GetPath(), NodeB = child.GetPath(), ExcludeNodesFromCollision = true };
foreach (var flag in new System.Action<Generic6DofJoint3D.Flag, bool>[] { j.SetFlagX, j.SetFlagY, j.SetFlagZ }) foreach (var flag in new System.Action<Generic6DofJoint3D.Flag, bool>[] { j.SetFlagX, j.SetFlagY, j.SetFlagZ })
@@ -427,6 +471,7 @@ public partial class PlayerRagdoll : Node3D
param(Generic6DofJoint3D.Param.LinearMotorForceLimit, force); param(Generic6DofJoint3D.Param.LinearMotorForceLimit, force);
param(Generic6DofJoint3D.Param.AngularMotorForceLimit, torque); param(Generic6DofJoint3D.Param.AngularMotorForceLimit, torque);
} }
configure?.Invoke(j); // before AddChild: limits/flags rebuild the server joint
AddChild(j); AddChild(j);
Quaternion jq = at.Basis.Orthonormalized().GetRotationQuaternion(); Quaternion jq = at.Basis.Orthonormalized().GetRotationQuaternion();
return new Drive return new Drive
@@ -567,6 +612,7 @@ public partial class PlayerRagdoll : Node3D
HandGuide(a, _targets[a.Hand], side == 0 ? leftVelocity : rightVelocity); HandGuide(a, _targets[a.Hand], side == 0 ? leftVelocity : rightVelocity);
Braced |= IsBraced(a); Braced |= IsBraced(a);
} }
_sinceBraced = Braced ? 0f : _sinceBraced + dt;
GroundControl(pelvisTarget, floorY, dt); GroundControl(pelvisTarget, floorY, dt);
HeadGuide(s, dt); HeadGuide(s, dt);
LegsDrive(s); LegsDrive(s);
@@ -688,15 +734,24 @@ public partial class PlayerRagdoll : Node3D
j.SetParamZ(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) — not the floor: // A hand pressing on or holding something solid enough to push yourself up on (a ledge, a wall,
// hands on the floor while crawling don't lift the body. // a grabbed bar, a heavy crate) — not the floor: hands on the floor while crawling don't lift the
// body. Nimso: physics objects are climbed "the same" as the world. A light prop just gets pushed,
// and something you're carrying isn't support.
public const float BraceMinMass = 15f;
public const float ClamberMemory = 0.6f; // s a hand press still counts after the hands leave
private static bool IsBraced(Arm a) private static bool IsBraced(Arm a)
{ {
if (a.Held is StaticBody3D) if (a.Held is StaticBody3D || a.Held is RigidBody3D { Freeze: true })
return true; return true;
foreach (Node3D n in a.Hand.GetCollidingBodies()) foreach (Node3D n in a.Hand.GetCollidingBodies())
if (n is StaticBody3D && !n.IsInGroup(PropGrabber.NoGrabGroup)) {
if (n == a.Held || n.IsInGroup(PropGrabber.NoGrabGroup))
continue;
if (n is StaticBody3D || n is RigidBody3D { Freeze: true } || n is RigidBody3D { Mass: >= BraceMinMass })
return true; return true;
}
return false; return false;
} }
@@ -716,7 +771,10 @@ public partial class PlayerRagdoll : Node3D
bool climbingHold = _arms[0].Held is StaticBody3D || _arms[1].Held is StaticBody3D; bool climbingHold = _arms[0].Held is StaticBody3D || _arms[1].Held is StaticBody3D;
_lifted = Braced && (Pelvis.GlobalPosition.Y > _supportGoalY + MantleLift || climbingHold); _lifted = Braced && (Pelvis.GlobalPosition.Y > _supportGoalY + MantleLift || climbingHold);
bool deep = center + _legsHeight * 0.5f >= hip.Y + LegsOffAbove; bool deep = center + _legsHeight * 0.5f >= hip.Y + LegsOffAbove;
bool wantTuck = deep || _lifted || (!_legsSolid && Braced); // Hands braced while the hips are held back by an obstacle = getting a knee up onto it (a
// thigh-high crate or ledge the straight arms can't press down on while standing).
bool kneeUp = (Braced || _sinceBraced < ClamberMemory) && HeldBack != Vector3.Zero;
bool wantTuck = deep || _lifted || kneeUp || (!_legsSolid && Braced);
_tuckFree = wantTuck ? 0f : _tuckFree + _dt; _tuckFree = wantTuck ? 0f : _tuckFree + _dt;
Vector3 untucked = new(hip.X, center, hip.Z); Vector3 untucked = new(hip.X, center, hip.Z);
bool solid = _legsSolid ? !wantTuck : _tuckFree > UntuckDelay && LegsSpaceFree(untucked, s.YawBasis); bool solid = _legsSolid ? !wantTuck : _tuckFree > UntuckDelay && LegsSpaceFree(untucked, s.YawBasis);
@@ -813,16 +871,13 @@ public partial class PlayerRagdoll : Node3D
{ {
Vector3 pos = Pelvis.GlobalPosition; Vector3 pos = Pelvis.GlobalPosition;
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);
Godot.Collections.Dictionary hit = GetWorld3D().DirectSpaceState.IntersectRay(query);
bool wasGrounded = Grounded; bool wasGrounded = Grounded;
Grounded = false; Grounded = false;
_groundBody = null; _groundBody = null;
if (hit.Count > 0) bool clambering = Braced || _lifted || _sinceBraced < ClamberMemory || !_legsSolid;
if (ProbeGround(pos, hipAboveFloor + GroundProbeExtra + 0.5f, wasGrounded || clambering ? (clambering ? 2 : 1) : 0, out Vector3 point, out Vector3 normal, out GodotObject groundObject, out bool underHips))
{ {
Vector3 point = (Vector3)hit["position"]; _groundBody = groundObject as RigidBody3D;
Vector3 normal = (Vector3)hit["normal"];
_groundBody = GodotObject.InstanceFromId((ulong)hit["collider_id"]) as RigidBody3D; // Box3D: "collider" is null
_groundPoint = point; _groundPoint = point;
// Standing (or walking down a step) the legs keep contact over GroundProbeExtra; falling, // Standing (or walking down a step) the legs keep contact over GroundProbeExtra; falling,
// they touch down only within their reach — the rest of the fall is stopped by the legs. // they touch down only within their reach — the rest of the fall is stopped by the legs.
@@ -841,14 +896,17 @@ public partial class PlayerRagdoll : Node3D
} }
else if (inReach && !steep) else if (inReach && !steep)
{ {
// Step rule: ground more than a step above the feet is only stood on once the // Step rule (Nimso: higher ground "doesn't move my body up … until I push on it with enough
// hips are already well above it (climbed/mantled/landed there) — otherwise it's an // pressure, at which point I actually clamber over"): ground more than a step above the
// obstacle the legs collider holds us back from, and the feet stay where they were. // feet is stood on once the hips are over it AND the body is pressing onto it — hands
// Higher ground climbed onto must be under the hips' back edge too (not just a // braced on anything solid (ledge, crate), or already airborne
// pelvis centre hanging over the lip). // (landing, hanging). Otherwise it's an obstacle the legs collider holds us back from.
bool clear = point.Y <= _footY + MaxStepHeight || GroundBehindAt(pos, point.Y); // No "hips well above it" requirement: that made a ledge need a full press-up.
bool reachable = point.Y <= _footY + MaxStepHeight bool step = point.Y <= _footY + MaxStepHeight;
|| ((!wasGrounded || _lifted || pos.Y - point.Y > 0.6f * hipAboveFloor) && clear); // The press lasts a moment after the hands leave (you push up, then move over it), and legs
// tucked up with the hips over it are already on top of it.
bool pressing = !wasGrounded || Braced || _lifted || _sinceBraced < ClamberMemory || !_legsSolid;
bool reachable = step || (underHips && pressing);
Grounded = true; Grounded = true;
if (reachable) if (reachable)
_footY = point.Y; _footY = point.Y;
@@ -883,8 +941,11 @@ 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);
// Legs can't hold the hips higher than straight legs (head above the calibrated height —
// tiptoes, a reach up — would otherwise float the body off the ground).
goal.Y = Mathf.Min(goal.Y, _footY + _standingHip + JumpExtension);
_supportGoalY = goal.Y; _supportGoalY = goal.Y;
if (_jump == JumpPhase.Dip) if (_jump == JumpPhase.Charge)
goal.Y = Mathf.Min(goal.Y, _jumpFromY); goal.Y = Mathf.Min(goal.Y, _jumpFromY);
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);
@@ -937,35 +998,38 @@ public partial class PlayerRagdoll : Node3D
} }
} }
// Jump state: request → Dip (countermovement, the support goal drops) → Push (full leg force up to // Jump state: held → Charge (the support goal sinks: the legs bend) → released → Push (full leg
// extension) → Flight (no ground until falling again) → None on touchdown. // force up to extension) → Flight (no ground until falling again) → None on touchdown.
private void UpdateJump(float dt, bool wasGrounded) private void UpdateJump(float dt, bool wasGrounded)
{ {
_airT = Grounded ? 0f : _airT + dt; _airT = Grounded ? 0f : _airT + dt;
_jumpRequest -= dt;
_jumpT += dt; _jumpT += dt;
float pos = Pelvis.GlobalPosition.Y; float pos = Pelvis.GlobalPosition.Y;
float top = _footY + _standingHip + JumpExtension; float top = _footY + _standingHip + JumpExtension;
switch (_jump) switch (_jump)
{ {
case JumpPhase.None: case JumpPhase.None:
if (_jumpRequest > 0f && (Grounded || _airT < CoyoteTime) && !Climbing) if (_jumpHeld && Grounded && !Climbing)
{ {
_jumpRequest = 0f; _jump = JumpPhase.Charge;
_jumpT = 0f; _jumpT = 0f;
// Stroke at least JumpDip + JumpExtension: from standing, dip first. _jumpFromY = Mathf.Min(pos, top);
_jumpFromY = Mathf.Max(top - JumpMaxStroke, Mathf.Min(pos, top - JumpDip - JumpExtension));
_jump = pos > _jumpFromY + 0.02f && Grounded ? JumpPhase.Dip : JumpPhase.Push;
_jumpFromY = Mathf.Min(_jumpFromY, pos);
} }
break; break;
case JumpPhase.Dip: case JumpPhase.Charge:
if (pos <= _jumpFromY + 0.02f || _jumpT > 0.2f || !Grounded) if (!Grounded && _airT > CoyoteTime || Climbing)
{ {
_jump = JumpPhase.Push; _jump = JumpPhase.None; // walked off or grabbed something while charging
_jumpFromY = pos; break;
_jumpT = 0f;
} }
if (_jumpHeld)
{
_jumpFromY = Mathf.Max(top - JumpMaxStroke, _jumpFromY - ChargeSpeed * dt);
break;
}
_jump = JumpPhase.Push;
_jumpFromY = Mathf.Min(pos, top - JumpMinStroke);
_jumpT = 0f;
break; break;
case JumpPhase.Push: case JumpPhase.Push:
if (pos >= top || pos - _jumpFromY >= JumpMaxStroke || _jumpT > 0.35f) if (pos >= top || pos - _jumpFromY >= JumpMaxStroke || _jumpT > 0.35f)
@@ -1038,17 +1102,67 @@ public partial class PlayerRagdoll : Node3D
private PhysicsShapeQueryParameters3D _cameraQuery; private PhysicsShapeQueryParameters3D _cameraQuery;
// Second ground ray 12 cm behind the hips (toward the body's back): ground at least `groundY`. // Ground under the body (Nimso: "ground detection uses the objects underneath the body"), not
private bool GroundBehindAt(Vector3 pelvisPos, float groundY) // under one point: a sphere the width of the stance is cast down from the hips, plus the
// centre ray. One ray lost the ground as soon as the hips swayed off a plank or past a ledge lip.
// The sphere's contact can be a corner, whose normal is useless — the surface normal comes from a
// short ray just inside the contact. Same-level ground (within a step) from either probe is
// stood on; higher ground only from the centre ray (the hips must really be over it), and it's
// reported via underHips for the pressure rule.
public const float FootprintRadius = 0.12f;
// mode: 0 airborne, 1 standing, 2 clambering (pressing onto something: the footprint may take
// higher ground the hips are only partly over — the capsule hips then ride up onto its edge).
private bool ProbeGround(Vector3 pos, float length, int mode, out Vector3 point, out Vector3 normal, out GodotObject collider, out bool underHips)
{ {
Vector3 back = Pelvis.GlobalTransform.Basis.Z; PhysicsDirectSpaceState3D space = GetWorld3D().DirectSpaceState;
back.Y = 0f; point = normal = Vector3.Zero;
back = back.LengthSquared() > 1e-6f ? back.Normalized() * 0.12f : Vector3.Zero; collider = null;
var query = PhysicsRayQueryParameters3D.Create(pelvisPos + back, pelvisPos + back + Vector3.Down * 3f, WorldMask); underHips = false;
Godot.Collections.Dictionary hit = GetWorld3D().DirectSpaceState.IntersectRay(query); Godot.Collections.Dictionary ray = space.IntersectRay(PhysicsRayQueryParameters3D.Create(pos, pos + Vector3.Down * length, WorldMask));
return hit.Count > 0 && ((Vector3)hit["position"]).Y > groundY - 0.05f; bool haveRay = ray.Count > 0;
if (haveRay)
{
point = (Vector3)ray["position"];
normal = (Vector3)ray["normal"];
collider = GodotObject.InstanceFromId((ulong)ray["collider_id"]); // Box3D: "collider" is null
underHips = true;
}
_footprintQuery ??= new PhysicsShapeQueryParameters3D { Shape = new SphereShape3D { Radius = FootprintRadius }, CollisionMask = WorldMask };
_footprintQuery.Transform = new Transform3D(Basis.Identity, pos);
_footprintQuery.Motion = Vector3.Down * length;
Godot.Collections.Dictionary rest = space.GetRestInfo(_footprintQuery);
if (rest.Count == 0)
return haveRay;
Vector3 contact = (Vector3)rest["point"];
if (contact.Y >= pos.Y - 0.05f)
return haveRay; // touching a wall beside the hips, not ground
Vector3 inward = new(contact.X - pos.X, 0f, contact.Z - pos.Z);
inward = inward.LengthSquared() > 1e-6f ? inward.Normalized() * 0.02f : Vector3.Zero;
Vector3 probe = contact + inward;
Godot.Collections.Dictionary face = space.IntersectRay(PhysicsRayQueryParameters3D.Create(
new Vector3(probe.X, contact.Y + 0.05f, probe.Z), new Vector3(probe.X, contact.Y - 0.05f, probe.Z), WorldMask));
if (face.Count == 0)
return haveRay;
Vector3 sPoint = (Vector3)face["position"], sNormal = (Vector3)face["normal"];
if (sNormal.Y <= Mathf.Cos(Mathf.DegToRad(MaxSlopeDegrees)))
return haveRay;
// The sphere wins where the centre ray found nothing, nothing walkable, or only lower ground
// within the stance (a plank's edge under the hips) — but never higher ground than a step.
bool sameLevel = sPoint.Y <= _footY + MaxStepHeight || mode != 1;
bool better = !haveRay || normal.Y <= Mathf.Cos(Mathf.DegToRad(MaxSlopeDegrees)) || sPoint.Y > point.Y + 0.01f;
if (sameLevel && better)
{
point = sPoint;
normal = sNormal;
collider = GodotObject.InstanceFromId((ulong)face["collider_id"]);
underHips = true; // under the footprint
}
return true;
} }
private PhysicsShapeQueryParameters3D _footprintQuery;
public void TeleportBy(Transform3D delta) public void TeleportBy(Transform3D delta)
{ {
if (!Built) if (!Built)
@@ -1086,23 +1200,15 @@ public partial class PlayerRagdoll : Node3D
b.ResetPhysicsInterpolation(); b.ResetPhysicsInterpolation();
} }
// Collision exceptions between a held prop and that arm's segments (its hand is excluded by // What each hand holds (bracing, climbing). Only the hand itself is excluded from the held object
// the grab weld itself) — a forearm wrapped around a held object must not fight it. // (by the grab weld).
public void SetHeld(int side, PhysicsBody3D prop) public void SetHeld(int side, PhysicsBody3D prop)
{ {
if (!Built) if (!Built)
return; return;
Arm a = _arms[side]; // The arm keeps colliding with what the hand holds (owner: real arms don't pass through things).
if (a.Held != null && IsInstanceValid(a.Held) && a.Held != _arms[1 - side].Held) // The old per-object exception switched the arm off against a whole crag rock while
{ // gripping one of its holds — arms "randomly" phased through the world mid-climb.
a.Upper.RemoveCollisionExceptionWith(a.Held); _arms[side].Held = prop;
a.Fore.RemoveCollisionExceptionWith(a.Held);
}
a.Held = prop;
if (prop != null)
{
a.Upper.AddCollisionExceptionWith(prop);
a.Fore.AddCollisionExceptionWith(prop);
}
} }
} }
+28 -8
View File
@@ -5,7 +5,7 @@ using Godot;
// physics tick, in this order: // physics tick, in this order:
// 1. discrete origin jumps made by other code (spawn, teleport markers) carry the body along; // 1. discrete origin jumps made by other code (spawn, teleport markers) carry the body along;
// 2. stick: left = move (head-relative), right X = turn (snap by default, SmoothTurn for smooth), // 2. stick: left = move (head-relative), right X = turn (snap by default, SmoothTurn for smooth),
// right Y = crouch (down lowers, up raises — RagdollDriver.StickCrouch); right A = jump; // right Y = crouch (down lowers, up raises — RagdollDriver.StickCrouch); right A = hold to charge, release to jump;
// 3. the ragdoll's OriginCorrection (push-back at walls, floor follows the ground, falls); // 3. the ragdoll's OriginCorrection (push-back at walls, floor follows the ground, falls);
// 4. True Body solve from the corrected camera/controllers, then the ragdoll's muscles. // 4. True Body solve from the corrected camera/controllers, then the ragdoll's muscles.
// Waits for the calibrated BodyProfile like everything else; rebuilds on recalibration. // Waits for the calibrated BodyProfile like everything else; rebuilds on recalibration.
@@ -15,6 +15,9 @@ public partial class PlayerRig : Node
public const float MoveAcceleration = 8f; // m/s² public const float MoveAcceleration = 8f; // m/s²
public const float StickDeadzone = 0.2f; public const float StickDeadzone = 0.2f;
public static bool SmoothTurn = false; public static bool SmoothTurn = false;
// Tracking timed by the render clock (bible v2: uniform motion, constant ~2-tick lag) vs by
// arrival (less lag, 18 Hz speed beat). Menu toggle so both can be compared in the headset.
public static bool SteadyTracking = true;
public const float SnapTurnDegrees = 30f; public const float SnapTurnDegrees = 30f;
public const float SmoothTurnSpeed = 2.5f; // rad/s public const float SmoothTurnSpeed = 2.5f; // rad/s
public const float SnapTurnThreshold = 0.7f; public const float SnapTurnThreshold = 0.7f;
@@ -36,7 +39,6 @@ public partial class PlayerRig : Node
private bool _haveLastOrigin; private bool _haveLastOrigin;
private Vector3 _moveVelocity; private Vector3 _moveVelocity;
private bool _snapArmed = true; private bool _snapArmed = true;
private bool _jumpPrev;
private readonly MeshInstance3D[] _ghosts = new MeshInstance3D[2]; private readonly MeshInstance3D[] _ghosts = new MeshInstance3D[2];
private bool _rebuild = true; private bool _rebuild = true;
private RagdollDriver _driver; private RagdollDriver _driver;
@@ -145,7 +147,8 @@ public partial class PlayerRig : Node
_driver.RebuildRequested |= _rebuild; _driver.RebuildRequested |= _rebuild;
_rebuild = false; _rebuild = false;
Transform3D o = _origin.GlobalTransform; Transform3D o = _origin.GlobalTransform;
_driver.Tick(profile, ref o, _camera.Transform, _left.Transform, _right.Transform, _moveVelocity, dt); _driver.Tick(profile, ref o, _camera.Transform, _left.Transform, _right.Transform, _moveVelocity, dt,
SteadyTracking ? TrackingAge() : null);
_origin.GlobalTransform = o; _origin.GlobalTransform = o;
_lastOrigin = _origin.GlobalTransform; _lastOrigin = _origin.GlobalTransform;
_haveLastOrigin = true; _haveLastOrigin = true;
@@ -154,6 +157,24 @@ public partial class PlayerRig : Node
_interpolateOrigin = true; _interpolateOrigin = true;
} }
// The tracking poses read in this tick were delivered with the last frame, predicted for its render
// time. In physics ticks: render time of that frame ≈ (physics frames + fraction) at its _process,
// plus one frame delta for the frame these ticks belong to (bible v2 recipe).
private double TrackingAge()
{
float rate = Engine.PhysicsTicksPerSecond;
ulong frame = Engine.GetProcessFrames();
if (frame != _sampleFrame)
{
_sampleFrame = frame;
_sampleTicks = _renderTicks + _renderDelta * rate;
}
return (Engine.GetPhysicsFrames() - _sampleTicks) / rate;
}
private double _renderTicks, _renderDelta, _sampleTicks;
private ulong _sampleFrame = ulong.MaxValue;
// The origin on screen is interpolated; physics must run on the real one. Anyone else who moved // The origin on screen is interpolated; physics must run on the real one. Anyone else who moved
// the origin since (teleport marker, menu) is respected as the new real one. // the origin since (teleport marker, menu) is respected as the new real one.
private void RestorePhysicsOrigin() private void RestorePhysicsOrigin()
@@ -169,6 +190,8 @@ public partial class PlayerRig : Node
public override void _Process(double delta) public override void _Process(double delta)
{ {
_renderTicks = Engine.GetPhysicsFrames() + Engine.GetPhysicsInterpolationFraction();
_renderDelta = delta;
if (_interpolateOrigin && !Spectator) if (_interpolateOrigin && !Spectator)
{ {
Transform3D current = _origin.GlobalTransform; Transform3D current = _origin.GlobalTransform;
@@ -231,11 +254,8 @@ public partial class PlayerRig : Node
if (_moveVelocity != Vector3.Zero) if (_moveVelocity != Vector3.Zero)
_origin.GlobalPosition += _moveVelocity * dt; _origin.GlobalPosition += _moveVelocity * dt;
// Jump: right A, on the press. // Jump: hold right A to charge (the legs bend), release to jump.
bool jump = _right.IsButtonPressed("ax_button"); _ragdoll.JumpHeld = _right.IsButtonPressed("ax_button");
if (jump && !_jumpPrev)
_ragdoll.Jump();
_jumpPrev = jump;
// Crouch: right stick held down lowers the body, up raises it back (the stick's dominant // Crouch: right stick held down lowers the body, up raises it back (the stick's dominant
// axis decides between crouch and turn). // axis decides between crouch and turn).
+42 -26
View File
@@ -1,7 +1,9 @@
using Godot; using Godot;
using System.Collections.Generic; using System.Collections.Generic;
// The body-physics sandbox (Nimso-sandbox style), built in code around the spawn/table area: // The body-physics sandbox (Nimso-sandbox style) around the spawn/table area. EDITOR-BUILT: this is the
// root of scenes/maps/playground.tscn and Rebuild() generates the pieces below into that scene
// (MapScene: inspector button, or tools/rebuild_maps.tscn headless); the game loads the saved scene.
// EAST — mantling ledges (0.3 … 1.5 m), a 1 m platform with stairs, a walkable 18° ramp and a // 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. // 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, // WEST — physics toys: a plank bridge on two blocks (walk on it / knock it off), loose planks,
@@ -10,8 +12,12 @@ using System.Collections.Generic;
// SOUTH — a crawl tunnel (0.6 m clearance), a duck-under bar (1.2 m) and a doorway. // 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 // 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(). // not in "no_grab"); dynamic pieces are "prop"s (grab/push) and come back with Reset().
[Tool]
public partial class Playground : Node3D public partial class Playground : Node3D
{ {
[ExportToolButton("Rebuild (replaces all children)")]
public Callable RebuildButton => Callable.From(RebuildInEditor);
private readonly List<(RigidBody3D Body, Transform3D Start)> _dynamic = new(); private readonly List<(RigidBody3D Body, Transform3D Start)> _dynamic = new();
private const uint WorldLayer = 1, WorldMask = 0b11101, PropMask = 0b11101; private const uint WorldLayer = 1, WorldMask = 0b11101, PropMask = 0b11101;
@@ -21,22 +27,28 @@ public partial class Playground : Node3D
public override void _Ready() public override void _Ready()
{ {
if (!Engine.IsEditorHint())
MapScene.CollectProps(this, _dynamic);
}
public void Reset() => MapScene.Reset(_dynamic);
private void RebuildInEditor()
{
if (!MapScene.CanRebuildInEditor(this))
return;
Rebuild();
MapScene.MarkUnsaved();
}
// Replaces every child with the generated layout, owned by this map (saved with its scene).
public void Rebuild()
{
MapScene.Clear(this);
BuildEast(); BuildEast();
BuildWest(); BuildWest();
BuildSouth(); BuildSouth();
} MapScene.Adopt(this);
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 // ---------------------------------------------------------------- builders
@@ -46,7 +58,7 @@ public partial class Playground : Node3D
StaticBody3D body = new() { Name = name, CollisionLayer = WorldLayer, CollisionMask = WorldMask, Transform = new Transform3D(basis ?? Basis.Identity, center) }; 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(new CollisionShape3D { Shape = new BoxShape3D { Size = size } });
body.AddChild(Mesh(new BoxMesh { Size = size }, color)); body.AddChild(Mesh(new BoxMesh { Size = size }, color));
AddChild(body); AddChild(body, true);
return body; return body;
} }
@@ -59,7 +71,7 @@ public partial class Playground : Node3D
StaticBody3D body = new() { Name = name, CollisionLayer = WorldLayer, CollisionMask = WorldMask, Transform = new Transform3D(new Basis(x, y, x.Cross(y)), (from + to) * 0.5f) }; 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(new CollisionShape3D { Shape = new CylinderShape3D { Radius = radius, Height = len } });
body.AddChild(Mesh(new CylinderMesh { TopRadius = radius, BottomRadius = radius, Height = len, RadialSegments = 12 }, color)); body.AddChild(Mesh(new CylinderMesh { TopRadius = radius, BottomRadius = radius, Height = len, RadialSegments = 12 }, color));
AddChild(body); AddChild(body, true);
return body; return body;
} }
@@ -75,11 +87,10 @@ public partial class Playground : Node3D
MaxContactsReported = 4, MaxContactsReported = 4,
PhysicsMaterialOverride = new PhysicsMaterial { Friction = friction, Bounce = 0.05f }, PhysicsMaterialOverride = new PhysicsMaterial { Friction = friction, Bounce = 0.05f },
}; };
body.AddToGroup("prop"); body.AddToGroup(MapScene.PropGroup, true);
body.AddChild(new CollisionShape3D { Shape = shape }); body.AddChild(new CollisionShape3D { Shape = shape });
body.AddChild(Mesh(mesh, color)); body.AddChild(Mesh(mesh, color));
AddChild(body); AddChild(body, true);
_dynamic.Add((body, body.GlobalTransform));
return body; return body;
} }
@@ -97,13 +108,14 @@ public partial class Playground : Node3D
{ {
AddChild(new Label3D AddChild(new Label3D
{ {
Name = "Sign",
Text = text, Text = text,
FontSize = 96, FontSize = 96,
PixelSize = 0.002f, PixelSize = 0.002f,
OutlineSize = 12, OutlineSize = 12,
Billboard = BaseMaterial3D.BillboardModeEnum.FixedY, Billboard = BaseMaterial3D.BillboardModeEnum.FixedY,
Position = at, Position = at,
}); }, true);
} }
// ---------------------------------------------------------------- zones // ---------------------------------------------------------------- zones
@@ -183,13 +195,16 @@ public partial class Playground : Node3D
// Seesaw: a plank hinged on a pivot block. // 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); 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); RigidBody3D seesaw = BoxProp("Seesaw", new Vector3(3.2f, 0.06f, 0.45f), 12f, new Vector3(-8.5f, 0.48f, -1.5f), DarkWood);
// Joint paths resolve relative to the joint (a sibling here) and are saved that way; set
// before AddChild so the joint never exists without its bodies.
HingeJoint3D pivot = new() HingeJoint3D pivot = new()
{ {
Name = "SeesawHinge",
Transform = new Transform3D(new Basis(Vector3.Right, Vector3.Up, Vector3.Back), new Vector3(-8.5f, 0.48f, -1.5f)), Transform = new Transform3D(new Basis(Vector3.Right, Vector3.Up, Vector3.Back), new Vector3(-8.5f, 0.48f, -1.5f)),
NodeA = GetNode<Node>("SeesawPivot").GetPath(), // absolute: joint paths resolve relative to the joint NodeA = $"../{GetNode<Node>("SeesawPivot").Name}",
NodeB = seesaw.GetPath(), NodeB = $"../{seesaw.Name}",
}; };
AddChild(pivot); AddChild(pivot, true);
Sign("Seesaw", new Vector3(-8.5f, 1.3f, -1.5f)); 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. // Swing board: a 10 kg board hinged at its top edge from a 2.4 m beam — grab it and hang.
@@ -199,11 +214,12 @@ public partial class Playground : Node3D
RigidBody3D board = BoxProp("SwingBoard", new Vector3(1.2f, 1.2f, 0.05f), 10f, new Vector3(-6f, 1.75f, 1.5f), Wood); RigidBody3D board = BoxProp("SwingBoard", new Vector3(1.2f, 1.2f, 0.05f), 10f, new Vector3(-6f, 1.75f, 1.5f), Wood);
HingeJoint3D swing = new() HingeJoint3D swing = new()
{ {
Name = "SwingHinge",
Transform = new Transform3D(new Basis(Vector3.Back, Vector3.Up, Vector3.Left), new Vector3(-6f, 2.35f, 1.5f)), Transform = new Transform3D(new Basis(Vector3.Back, Vector3.Up, Vector3.Left), new Vector3(-6f, 2.35f, 1.5f)),
NodeA = beam.GetPath(), NodeA = $"../{beam.Name}",
NodeB = board.GetPath(), NodeB = $"../{board.Name}",
}; };
AddChild(swing); AddChild(swing, true);
Sign("Hanging board", new Vector3(-6f, 2.9f, 1.5f)); Sign("Hanging board", new Vector3(-6f, 2.9f, 1.5f));
// Crate pyramid (4 kg each), a heavy 25 kg box, barrels. // Crate pyramid (4 kg each), a heavy 25 kg box, barrels.
+40 -10
View File
@@ -11,6 +11,10 @@ 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;
// Grab buffer (owner: launching up a ladder leaves a short window to catch the next rung): a
// squeeze that finds nothing keeps trying while held, so you can squeeze early and catch the
// hold the moment the palm reaches it.
private const float GrabBuffer = 0.4f; // s
private const float WorldGrabRadius = 0.07f; // palm sphere for grabbing static world (ledges, bars, holds) private const float WorldGrabRadius = 0.07f; // palm sphere for grabbing static world (ledges, bars, holds)
public const string NoGrabGroup = "no_grab"; // static bodies that can't be grabbed (the floor) public const string NoGrabGroup = "no_grab"; // static bodies that can't be grabbed (the floor)
@@ -26,6 +30,8 @@ public partial class PropGrabber : Node
private readonly uint[] _grabSeq = new uint[2]; private readonly uint[] _grabSeq = new uint[2];
private readonly float[] _breakTimer = new float[2]; private readonly float[] _breakTimer = new float[2];
private readonly bool[] _gripPrev = new bool[2]; private readonly bool[] _gripPrev = new bool[2];
private readonly SoftGrab[] _easing = new SoftGrab[2];
private readonly double[] _bufferUntil = new double[2]; // grabbed, being eased into the hand (no weld yet)
private uint _grabCount; private uint _grabCount;
public override void _Ready() public override void _Ready()
@@ -50,32 +56,56 @@ public partial class PropGrabber : Node
public override void _PhysicsProcess(double delta) public override void _PhysicsProcess(double delta)
{ {
float dt = (float)delta; float dt = (float)delta;
for (int hand = 0; hand < 2; hand++)
{
SoftGrab ease = _easing[hand];
if (ease == null)
continue;
if (!IsInstanceValid(ease.Target) || !IsInstanceValid(ease.Hand))
_easing[hand] = null;
else if (ease.Step(dt))
{
_easing[hand] = null;
Weld(hand == 0 ? _left : _right, hand, ease.Hand, ease.Target);
}
}
HoldUpdate(_left, 0, dt); HoldUpdate(_left, 0, dt);
HoldUpdate(_right, 1, dt); HoldUpdate(_right, 1, dt);
} }
private void Weld(XRController3D controller, int hand, RigidBody3D handBody, PhysicsBody3D prop)
{
_held[hand] = prop;
_grabSeq[hand] = _grabCount++;
Vector3 pivot = PalmPoint(handBody, hand);
_joints[hand] = MakeWeld(handBody, prop, pivot, RigidWeldBias);
_localAnchor[hand] = prop.GlobalTransform.AffineInverse() * pivot;
_arms?.SetHeld(hand, prop as RigidBody3D);
PlayerRig.SetHeld(controller, prop);
string what = prop is RigidBody3D rb ? $"{rb.Mass:0.###} kg" : "static world";
GD.Print($"PropGrabber: grabbed '{prop.Name}' ({what}, {(hand == 0 ? "left" : "right")}{(_held[1 - hand] == prop ? ", two-handed" : "")})");
}
private void UpdateHand(XRController3D controller, int hand) private void UpdateHand(XRController3D controller, int hand)
{ {
bool grip = controller.GetFloat("grip") > GripThreshold; bool grip = controller.GetFloat("grip") > GripThreshold;
int other = 1 - hand; int other = 1 - hand;
if (grip && !_gripPrev[hand] && _held[hand] == null) double now = Time.GetTicksMsec() / 1000.0;
if (grip && !_gripPrev[hand])
_bufferUntil[hand] = now + GrabBuffer;
if (grip && now <= _bufferUntil[hand] && _held[hand] == null && _easing[hand] == null)
{ {
PhysicsBody3D 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)
{ {
_held[hand] = prop; _easing[hand] = SoftGrab.Begin(handBody, hand, prop); // welded once it's in the hand (_PhysicsProcess)
_grabSeq[hand] = _grabCount++; _bufferUntil[hand] = 0.0;
Vector3 pivot = PalmPoint(handBody, hand);
_joints[hand] = MakeWeld(handBody, prop, pivot, RigidWeldBias);
_localAnchor[hand] = prop.GlobalTransform.AffineInverse() * pivot;
_arms?.SetHeld(hand, prop as RigidBody3D);
PlayerRig.SetHeld(controller, prop);
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)
_easing[hand] = null;
if (!grip && _held[hand] != null) if (!grip && _held[hand] != null)
{ {
+16 -7
View File
@@ -21,19 +21,25 @@ public sealed class PoseFilter
public void Reset() => _has = false; public void Reset() => _has = false;
public Transform3D Sample(Transform3D raw, double now) // sampleTime: when the pose was really measured (the render time of the frame that delivered it),
// in the same clock as `now`. Without it a sample is timestamped by the first tick that saw it,
// which at 144 Hz under 90 Hz frames alternates 1 and 2 ticks late: the extrapolated motion
// then beats at 18 Hz (±30% speed per frame — bible v2, measured). With it the age is exact
// and motion uniform, at a constant ~2-tick lag. May be negative (a tick before the sample).
public Transform3D Sample(Transform3D raw, double now, double? sampleTime = null)
{ {
double stamp = sampleTime ?? now;
if (!_has) if (!_has)
{ {
_has = true; _has = true;
_last = raw; _last = raw;
_tLast = now; _tLast = stamp;
Velocity = AngularVelocity = Vector3.Zero; Velocity = AngularVelocity = Vector3.Zero;
return raw; return raw;
} }
if (raw != _last) if (raw != _last)
{ {
float interval = (float)(now - _tLast); float interval = (float)(stamp - _tLast);
if (interval > 1e-4f) if (interval > 1e-4f)
{ {
if (raw.Origin.DistanceTo(_last.Origin) > JumpDistance) if (raw.Origin.DistanceTo(_last.Origin) > JumpDistance)
@@ -49,9 +55,9 @@ public sealed class PoseFilter
} }
} }
_last = raw; _last = raw;
_tLast = now; _tLast = stamp;
} }
float age = Mathf.Min((float)(now - _tLast), MaxExtrapolation); float age = Mathf.Clamp((float)(now - _tLast), -MaxExtrapolation, MaxExtrapolation);
Basis spin = AngularVelocity.LengthSquared() > 1e-10f Basis spin = AngularVelocity.LengthSquared() > 1e-10f
? new Basis(AngularVelocity.Normalized(), AngularVelocity.Length() * age) : Basis.Identity; ? new Basis(AngularVelocity.Normalized(), AngularVelocity.Length() * age) : Basis.Identity;
return new Transform3D(spin * _last.Basis.Orthonormalized(), _last.Origin + Velocity * age); return new Transform3D(spin * _last.Basis.Orthonormalized(), _last.Origin + Velocity * age);
@@ -78,11 +84,14 @@ public sealed class RagdollDriver
// origin = play space (modified by the correction); raw* = origin-local tracked poses as last // origin = play space (modified by the correction); raw* = origin-local tracked poses as last
// reported (repeat between tracking updates); moveVelocity = intended origin velocity (stick), // reported (repeat between tracking updates); moveVelocity = intended origin velocity (stick),
// the only origin motion that is real hand motion for the guides' feed-forward. // the only origin motion that is real hand motion for the guides' feed-forward.
// trackingAge: how long ago (s) the current tracking poses were measured, if known (PlayerRig
// derives it from the render clock); null = timestamp by arrival (the spike's simulated input).
public void Tick(BodyProfile profile, ref Transform3D origin, Transform3D rawHead, Transform3D rawLeft, Transform3D rawRight, public void Tick(BodyProfile profile, ref Transform3D origin, Transform3D rawHead, Transform3D rawLeft, Transform3D rawRight,
Vector3 moveVelocity, float dt) Vector3 moveVelocity, float dt, double? trackingAge = null)
{ {
_now += dt; _now += dt;
Transform3D head = Head.Sample(rawHead, _now), left = Left.Sample(rawLeft, _now), right = Right.Sample(rawRight, _now); double? stamp = trackingAge.HasValue ? _now - trackingAge.Value : null;
Transform3D head = Head.Sample(rawHead, _now, stamp), left = Left.Sample(rawLeft, _now, stamp), right = Right.Sample(rawRight, _now, stamp);
if (Ragdoll.Built && !RebuildRequested) if (Ragdoll.Built && !RebuildRequested)
origin.Origin += Ragdoll.OriginCorrection((origin * head).Origin, origin.Origin.Y + StickCrouch, dt); origin.Origin += Ragdoll.OriginCorrection((origin * head).Origin, origin.Origin.Y + StickCrouch, dt);
float floorY = origin.Origin.Y + StickCrouch; float floorY = origin.Origin.Y + StickCrouch;
+243 -26
View File
@@ -83,6 +83,8 @@ public partial class RagdollSpike : Node3D
AddChild(_ragdoll); AddChild(_ragdoll);
_phases.Add(("hinge sign", SetupHinge, TickHinge, 0.3f, FinishHinge)); _phases.Add(("hinge sign", SetupHinge, TickHinge, 0.3f, FinishHinge));
_phases.Add(("pose filter: render-timed tracking moves evenly (no 18 Hz beat)", Rest, _ => { }, 0.05f, FinishPoseFilter));
_phases.Add(("elbow: a controller turned outward doesn't fold the elbow inward", Rest, _ => { }, 0.05f, FinishElbowOut));
_phases.Add(("stands still, head on the camera, hands on the controllers", Rest, _ => { }, 4f, FinishStand)); _phases.Add(("stands still, head on the camera, hands on the controllers", Rest, _ => { }, 4f, FinishStand));
_phases.Add(("reaches forward, out and overhead", Rest, TickReach, 4.5f, FinishReach)); _phases.Add(("reaches forward, out and overhead", Rest, TickReach, 4.5f, FinishReach));
_phases.Add(("fast arm swings stay attached", Rest, TickSwing, 3f, FinishSwing)); _phases.Add(("fast arm swings stay attached", Rest, TickSwing, 3f, FinishSwing));
@@ -100,7 +102,10 @@ public partial class RagdollSpike : Node3D
_phases.Add(("a table can't be walked over", SetupTableObstacle, _ => _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(("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 (crouch, hands on the edge, stand and press)", () => SetupObstacle(0.9f), _ => TickHumanMantle(both: true), 7f, () => FinishMantle(true))); _phases.Add(("two-handed mantle onto a 0.9 m ledge (crouch, hands on the edge, stand and press)", () => SetupObstacle(0.9f), _ => TickHumanMantle(both: true), 7f, () => FinishMantle(true)));
_phases.Add(("one hand can't mantle", () => SetupObstacle(0.9f), _ => TickHumanMantle(both: false), 7f, () => FinishMantle(false))); _phases.Add(("two-handed mantle onto a chest-high 1.2 m ledge", () => SetupObstacle(1.2f), _ => TickHumanMantle(both: true), 7f, () => FinishMantle(true)));
_phases.Add(("one hand can't mantle the 1.2 m ledge (the hips must be lifted)", () => SetupObstacle(1.2f), _ => TickHumanMantle(both: false), 7f, () => FinishMantle(false)));
_phases.Add(("mantles onto the playground's heavy box (25 kg physics object, 0.7 m)", SetupCrateLedge, _ => TickHumanMantle(both: true), 8.5f, FinishCrateMantle));
_phases.Add(("walks a 15 cm plank swaying the hips without falling off", SetupPlank, TickPlank, 6f, FinishPlank));
_phases.Add(("two-handed pull-up on a grabbed bar", () => SetupBarHang(), t => TickPullUp(both: true), 3.5f, () => FinishPullUp(true))); _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(("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(("crawls through a 0.6 m tunnel", SetupTunnel, TickCrawl, 7f, FinishCrawl));
@@ -111,10 +116,13 @@ public partial class RagdollSpike : Node3D
_phases.Add(("climbs a wall hand over hand (grab, pull, regrab higher)", SetupClimbWall, TickClimbWall, 8f, FinishClimbWall)); _phases.Add(("climbs a wall hand over hand (grab, pull, regrab higher)", SetupClimbWall, TickClimbWall, 8f, FinishClimbWall));
_phases.Add(("walks off a 1 m ledge and lands: a dip the view follows, no bounce", () => SetupLedgeTop(1f), TickDrop, 4f, () => FinishDrop(1f))); _phases.Add(("walks off a 1 m ledge and lands: a dip the view follows, no bounce", () => SetupLedgeTop(1f), TickDrop, 4f, () => FinishDrop(1f)));
_phases.Add(("big fall (5 m): legs fold fully, then it recovers", () => SetupLedgeTop(5f), TickDrop, 5f, () => FinishDrop(5f))); _phases.Add(("big fall (5 m): legs fold fully, then it recovers", () => SetupLedgeTop(5f), TickDrop, 5f, () => FinishDrop(5f)));
_phases.Add(("standing jump", Rest, t => TickJump(0f), 4f, () => FinishJump(crouched: false))); _phases.Add(("jump: a tap of A hops", Rest, t => TickJump(0f, 0.05f), 4f, () => FinishJump(0)));
_phases.Add(("jump from a crouch goes higher", Rest, t => TickJump(0.45f), 4.5f, () => FinishJump(crouched: true))); _phases.Add(("jump: holding A to charge jumps much higher", Rest, t => TickJump(0f, 0.6f), 4.5f, () => FinishJump(1)));
_phases.Add(("jump: a tap from a real crouch beats a standing tap", Rest, t => TickJump(0.45f, 0.05f), 4.5f, () => FinishJump(2)));
_phases.Add(("looks down past vertical while crouched (body doesn't flip around)", Rest, TickLookDown, 5f, FinishLookDown)); _phases.Add(("looks down past vertical while crouched (body doesn't flip around)", Rest, TickLookDown, 5f, FinishLookDown));
_phases.Add(("stick crouch lowers the body and the view, and stands back up", Rest, TickStickCrouch, 6f, FinishStickCrouch)); _phases.Add(("stick crouch lowers the body and the view, and stands back up", Rest, TickStickCrouch, 6f, FinishStickCrouch));
_phases.Add(("soft grab: a 1 kg cube 6 cm off the palm is eased into the hand, then held", () => SetupSoftGrab(prop: true), TickSoftGrab, 3f, FinishSoftGrab));
_phases.Add(("soft grab: a bar 5 cm off the palm — the hand goes to it", () => SetupSoftGrab(prop: false), TickSoftGrab, 3f, FinishSoftGrab));
_phases.Add(("standing on a seesaw tips it (body weight reaches movable ground)", SetupSeesaw, _ => { }, 3f, FinishSeesaw)); _phases.Add(("standing on a seesaw tips it (body weight reaches movable ground)", SetupSeesaw, _ => { }, 3f, FinishSeesaw));
_phases.Add(("playground: swing board hangs, seesaw on its pivot, big plank grabbable", SetupPlayground, _ => { }, 2f, FinishPlayground)); _phases.Add(("playground: swing board hangs, seesaw on its pivot, big plank grabbable", SetupPlayground, _ => { }, 2f, FinishPlayground));
_phases.Add(("walks up an 18° ramp", () => SetupRamp(18f), _ => _stickVelocity = new Vector3(0f, 0f, -1.2f), 4f, () => FinishRamp(true))); _phases.Add(("walks up an 18° ramp", () => SetupRamp(18f), _ => _stickVelocity = new Vector3(0f, 0f, -1.2f), 4f, () => FinishRamp(true)));
@@ -227,6 +235,58 @@ public partial class RagdollSpike : Node3D
private int NewSnaps => _ragdoll.Snaps - _snapsAtStart; private int NewSnaps => _ragdoll.Snaps - _snapsAtStart;
// Right arm reaching forward-down, controller yawed 0..90° outward: the elbow must stay on the
// outer side of the shoulder→wrist line (toward +X), never toward the other arm.
private static bool FinishElbowOut()
{
float worst = float.MaxValue;
for (int deg = 0; deg <= 90; deg += 15)
{
ArmSolver arm = new();
Vector3 shoulder = new(0.2f, 1.45f, 0f), target = new(0.25f, 1.05f, -0.4f);
Basis hand = new Basis(Vector3.Up, -Mathf.DegToRad(deg)) * new Basis(Vector3.Right, -0.6f);
for (int i = 0; i < 200; i++)
arm.Solve(shoulder, target, Basis.Identity, Vector3.Right, hand, 0.3f, 0.27f, 1f / 144f);
Vector3 dir = (arm.Wrist - shoulder).Normalized();
Vector3 mid = shoulder + dir * (arm.Elbow - shoulder).Dot(dir);
worst = Mathf.Min(worst, arm.Elbow.X - mid.X); // < 0 = elbow toward the midline
}
GD.Print($"RAGDOLL: elbow vs the arm line with the controller turned 0..90° outward: most inward {worst:F3} m (limit -0.04)");
return worst > -0.04f;
}
// A controller moving at 1 m/s, sampled once per 90 Hz frame (predicted for that frame's render
// time), read by 144 Hz ticks. Per-tick output steps should all be 1/144 m: timestamped by
// arrival they alternate (1 or 2 ticks late), timestamped by render time they don't.
private static bool FinishPoseFilter()
{
float Spread(bool stamped)
{
PoseFilter f = new();
float min = float.MaxValue, max = 0f;
Vector3 prev = Vector3.Zero;
for (int tick = 0; tick < 300; tick++)
{
double now = tick / 144.0;
long frame = (long)Math.Floor(now * 90.0); // the last frame delivered before this tick
double render = (frame + 1) / 90.0; // its predicted display time
Transform3D raw = new(Basis.Identity, new Vector3((float)render, 0f, 0f));
Vector3 p = f.Sample(raw, now, stamped ? render : null).Origin;
if (tick > 20)
{
float step = p.X - prev.X;
min = Mathf.Min(min, step);
max = Mathf.Max(max, step);
}
prev = p;
}
return (max - min) * 144f; // spread of the per-tick speed, m/s
}
float arrival = Spread(false), stamped = Spread(true);
GD.Print($"RAGDOLL: pose filter at 1 m/s: per-tick speed spread {arrival:F3} m/s by arrival, {stamped:F3} m/s render-timed");
return stamped < 0.05f && stamped < arrival;
}
private static StaticBody3D MakeStatic(Vector3 size, Vector3 pos, uint mask = 0) private static StaticBody3D MakeStatic(Vector3 size, Vector3 pos, uint mask = 0)
{ {
StaticBody3D body = new() { Position = pos, CollisionMask = mask }; StaticBody3D body = new() { Position = pos, CollisionMask = mask };
@@ -531,9 +591,59 @@ public partial class RagdollSpike : Node3D
private void SetupObstacle(float height, float depth = 2f) private void SetupObstacle(float height, float depth = 2f)
{ {
Rest(); Rest();
_ledgeBox = null;
_ledgeTop = height;
if (height >= 1f)
_origin = new Transform3D(Basis.Identity, new Vector3(0f, 0f, ObstacleFront + 0.4f));
_stage.AddChild(MakeStatic(new Vector3(3f, height, depth), new Vector3(0f, height * 0.5f, ObstacleFront - depth * 0.5f), 29)); _stage.AddChild(MakeStatic(new Vector3(3f, height, depth), new Vector3(0f, height * 0.5f, ObstacleFront - depth * 0.5f), 29));
} }
// Playground.HeavyBox (25 kg, 0.7 m cube) as the ledge, same human mantle as onto the static ledge.
private void SetupCrateLedge()
{
Rest();
_ledgeBox = MakeBox(Vector3.One * 0.7f, 25f, new Vector3(0f, 0.35f, ObstacleFront - 0.35f));
_ledgeTop = 0.7f;
}
private RigidBody3D _ledgeBox;
private float _ledgeTop = 0.9f;
private bool FinishCrateMantle()
{
GD.Print($"RAGDOLL: crate mantle: pelvis rose up to {_maxPelvisRise:F3} m, feet y {_ragdoll.GroundY:F3}, origin y {_origin.Origin.Y:F3}, snaps {NewSnaps}");
_maxPelvisRise = 0f;
return _finite && NewSnaps == 0 && _ragdoll.GroundY > 0.6f && _origin.Origin.Y > 0.55f;
}
private const float PlankY = 0.3f;
private float _plankMinFoot;
private void SetupPlank()
{
Rest();
_origin = new Transform3D(Basis.Identity, new Vector3(0f, PlankY, 0f));
_stage.AddChild(MakeStatic(new Vector3(0.15f, PlankY, 8f), new Vector3(0f, PlankY * 0.5f, -3f), 29));
_plankMinFoot = float.MaxValue;
}
// Walk along it with the head (and so the hips) swaying ±9 cm side to side.
private void TickPlank(float dt)
{
float sway = _t > 1f ? 0.09f * Mathf.Sin(Mathf.Tau * 0.7f * (_t - 1f)) : 0f;
_headLocal = new Transform3D(Basis.Identity, new Vector3(sway, EyeHeight, 0f));
_stickVelocity = _t > 1f && _t < 5f ? new Vector3(0f, 0f, -0.6f) : Vector3.Zero;
_measureFrom = float.MaxValue;
if (_ragdoll.Built && !_needBuild && _t > 0.5f)
_plankMinFoot = Mathf.Min(_plankMinFoot, _ragdoll.Grounded ? _ragdoll.GroundY : -1f);
}
private bool FinishPlank()
{
GD.Print($"RAGDOLL: plank: lowest supported foot level {_plankMinFoot:F3} (plank top {PlankY}), pelvis {_ragdoll.Pelvis.GlobalPosition}, eye error {_ragdoll.Eye.DistanceTo(Camera.Origin):F3}, snaps {NewSnaps}");
return _finite && NewSnaps == 0 && _plankMinFoot > PlankY - 0.03f && _ragdoll.Pelvis.GlobalPosition.Z < -1.8f;
}
private void SetupTableObstacle() private void SetupTableObstacle()
{ {
Rest(); Rest();
@@ -562,7 +672,7 @@ public partial class RagdollSpike : Node3D
private bool FinishMantle(bool both) 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}"); 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; bool onTop = _ragdoll.GroundY > _ledgeTop - 0.05f && _origin.Origin.Y > _ledgeTop - 0.1f;
_lastRise = _maxPelvisRise; _lastRise = _maxPelvisRise;
_maxPelvisRise = 0f; _maxPelvisRise = 0f;
return _finite && NewSnaps == 0 && (both ? onTop : !onTop); // standing tall also lifts the hips; one hand never gets you up return _finite && NewSnaps == 0 && (both ? onTop : !onTop); // standing tall also lifts the hips; one hand never gets you up
@@ -728,13 +838,90 @@ public partial class RagdollSpike : Node3D
&& Mathf.Abs(_ragdoll.Pelvis.GlobalPosition.Y - _ragdoll.GroundY - _dropStand) < 0.05f; && Mathf.Abs(_ragdoll.Pelvis.GlobalPosition.Y - _ragdoll.GroundY - _dropStand) < 0.05f;
} }
// ---------------------------------------------------------------- soft grab
private PhysicsBody3D _sgTarget;
private SoftGrab _sg;
private ConeTwistJoint3D _sgWeld;
private float _sgGapAtWeld, _sgMaxSpeed, _sgHeldGap, _sgWeldTime;
private bool _sgProp;
private void SetupSoftGrab(bool prop)
{
Rest();
_sgProp = prop;
_sg = null;
_sgWeld = null;
_sgGapAtWeld = _sgHeldGap = -1f;
_sgMaxSpeed = 0f;
_sgWeldTime = -1f;
_sgTarget = null;
}
// Right controller held still in front; at 1.5 s the target appears off the palm and grip is pressed.
private void TickSoftGrab(float dt)
{
_measureFrom = float.MaxValue;
if (!_ragdoll.Built || _needBuild)
return;
RigidBody3D hand = _ragdoll.Hand(1);
if (_sgTarget == null && _t >= 1.5f)
{
Vector3 n = SoftGrab.PalmNormal(hand, 1);
Vector3 surface = SoftGrab.PalmSurface(hand, 1);
if (_sgProp)
{
RigidBody3D cube = MakeBox(Vector3.One * 0.08f, 1f, surface + n * (0.06f + 0.04f));
cube.GravityScale = 0f; // "floating" just for the setup instant; gravity back on at the weld
_sgTarget = cube;
}
else
{
StaticBody3D bar = new() { CollisionMask = 29, Position = surface + n * (0.05f + 0.025f) };
bar.AddChild(new CollisionShape3D { Shape = new CylinderShape3D { Radius = 0.025f, Height = 1f } });
_stage.AddChild(bar);
_sgTarget = bar;
}
return; // let the new body enter the space before querying it
}
if (_sgTarget == null)
return;
if (_sg == null && _sgWeld == null)
_sg = SoftGrab.Begin(hand, 1, _sgTarget);
if (_sg != null)
{
if (_sgTarget is RigidBody3D rb)
_sgMaxSpeed = Mathf.Max(_sgMaxSpeed, rb.LinearVelocity.Length());
_sgMaxSpeed = Mathf.Max(_sgMaxSpeed, hand.LinearVelocity.Length());
if (_sg.Step(dt))
{
_sgGapAtWeld = _sg.Gap;
_sgWeldTime = _t;
_sg = null;
_sgWeld = new ConeTwistJoint3D { Position = PropGrabber.PalmPoint(hand, 1), NodeA = hand.GetPath(), NodeB = _sgTarget.GetPath(), SwingSpan = 0f, TwistSpan = 0f, Bias = 0f, ExcludeNodesFromCollision = true };
_stage.AddChild(_sgWeld);
if (_sgTarget is RigidBody3D cube)
cube.GravityScale = 1f;
_ragdoll.SetHeld(1, _sgTarget);
}
}
}
private bool FinishSoftGrab()
{
float held = _sgTarget == null ? -1f : (SoftGrab.PalmSurface(_ragdoll.Hand(1), 1) - _sgTarget.GlobalPosition).Length() - (_sgProp ? 0.04f : 0.025f);
GD.Print($"RAGDOLL: soft grab ({(_sgProp ? "1 kg cube" : "static bar")}): welded after {(_sgWeldTime - 1.5f):F3} s with gap {_sgGapAtWeld:F4} m, fastest motion {_sgMaxSpeed:F2} m/s, held gap at the end {held:F3} m, snaps {NewSnaps}");
_ragdoll.SetHeld(1, null);
return _finite && NewSnaps == 0 && _sgWeldTime > 0f && _sgWeldTime - 1.5f < 0.25f && _sgGapAtWeld < (_sgProp ? 0.01f : 0.015f) && _sgMaxSpeed < 3.5f && held < 0.02f;
}
// ---------------------------------------------------------------- jump // ---------------------------------------------------------------- jump
private float _jumpStand, _jumpApex, _jumpEyeErr, _standJumpApex; private float _jumpStand, _jumpApex, _jumpEyeErr, _tapApex;
private bool _jumpPressed; private bool _jumpPressed;
// Optionally squat (eyes `crouch` lower) first, then press jump at 2 s; the player keeps the pose. // Optionally squat (eyes `crouch` lower) first, then hold A from 2 s for `hold` s; the player keeps the pose.
private void TickJump(float crouch) private void TickJump(float crouch, float hold)
{ {
float k = Mathf.SmoothStep(0f, 1f, Mathf.Clamp((_t - 0.8f) / 0.8f, 0f, 1f)) * (crouch > 0f ? 1f : 0f); float k = Mathf.SmoothStep(0f, 1f, Mathf.Clamp((_t - 0.8f) / 0.8f, 0f, 1f)) * (crouch > 0f ? 1f : 0f);
_headLocal = new Transform3D(new Basis(Vector3.Right, -0.2f * k), new Vector3(0f, EyeHeight - crouch * k, -0.05f * k)); _headLocal = new Transform3D(new Basis(Vector3.Right, -0.2f * k), new Vector3(0f, EyeHeight - crouch * k, -0.05f * k));
@@ -751,11 +938,9 @@ public partial class RagdollSpike : Node3D
_jumpEyeErr = 0f; _jumpEyeErr = 0f;
_jumpPressed = false; _jumpPressed = false;
} }
if (_t >= 2f && !_jumpPressed) _ragdoll.JumpHeld = _t >= 2f && _t < 2f + hold;
{ if (_t >= 2f)
_jumpPressed = true; _jumpPressed = true;
_ragdoll.Jump();
}
if (_jumpPressed) if (_jumpPressed)
{ {
_jumpApex = Mathf.Max(_jumpApex, pelvis - _jumpStand); _jumpApex = Mathf.Max(_jumpApex, pelvis - _jumpStand);
@@ -763,16 +948,19 @@ public partial class RagdollSpike : Node3D
} }
} }
private bool FinishJump(bool crouched) private bool FinishJump(int kind)
{ {
GD.Print($"RAGDOLL: {(crouched ? "crouched" : "standing")} jump: hips rose {_jumpApex:F3} m above the standing stance, eye vs camera max {_jumpEyeErr:F3}, grounded {_ragdoll.Grounded}, jumping {_ragdoll.Jumping}, snaps {NewSnaps}"); string what = kind == 0 ? "standing tap" : kind == 1 ? "full charge" : "tap from a crouch";
GD.Print($"RAGDOLL: {what}: hips rose {_jumpApex:F3} m above the standing stance, eye vs camera max {_jumpEyeErr:F3}, grounded {_ragdoll.Grounded}, jumping {_ragdoll.Jumping}, snaps {NewSnaps}");
bool ok = _finite && NewSnaps == 0 && _ragdoll.Grounded && !_ragdoll.Jumping && _jumpEyeErr < 0.12f && _origin.Origin.Length() < 0.15f; bool ok = _finite && NewSnaps == 0 && _ragdoll.Grounded && !_ragdoll.Jumping && _jumpEyeErr < 0.12f && _origin.Origin.Length() < 0.15f;
if (!crouched) if (kind == 0)
{ {
_standJumpApex = _jumpApex; _tapApex = _jumpApex;
return ok && _jumpApex > 0.3f && _jumpApex < 0.65f; return ok && _jumpApex > 0.03f && _jumpApex < 0.35f;
} }
return ok && _jumpApex > _standJumpApex + 0.1f; if (kind == 1)
return ok && _jumpApex > _tapApex + 0.25f && _jumpApex < 0.9f;
return ok && _jumpApex > _tapApex + 0.1f;
} }
// ---------------------------------------------------------------- looking down past vertical // ---------------------------------------------------------------- looking down past vertical
@@ -949,20 +1137,33 @@ public partial class RagdollSpike : Node3D
private void TickHumanMantle(bool both) private void TickHumanMantle(bool both)
{ {
float t = _t; float t = _t;
_stickVelocity = t < 1f || (t > 4.5f && t < 5.5f) ? new Vector3(0f, 0f, -0.8f) : Vector3.Zero; // stop before the ledge's far end // Walk up to a waist-high ledge (stop before its far end); a chest-high one starts within reach.
float crouch = Mathf.SmoothStep(0f, 1f, Mathf.Clamp((t - 1f) / 0.8f, 0f, 1f)); // (A 70 cm box is too small to walk on after climbing it.)
_stickVelocity = (t < 1f && _ledgeTop < 1f) || (t > 4.5f && t < 5.5f && _ledgeBox == null) ? new Vector3(0f, 0f, -0.8f) : Vector3.Zero;
// Crouch to load the legs for a waist-high ledge; at a chest-high one you reach up instead.
float crouch = Mathf.SmoothStep(0f, 1f, Mathf.Clamp((t - 1f) / 0.8f, 0f, 1f)) * (_ledgeTop < 1f ? 1f : 0f);
float rise = Mathf.SmoothStep(0f, 1f, Mathf.Clamp((t - 2.2f) / 1.6f, 0f, 1f)); float rise = Mathf.SmoothStep(0f, 1f, Mathf.Clamp((t - 2.2f) / 1.6f, 0f, 1f));
float eye = EyeHeight - 0.25f * crouch + 0.35f * rise; float eye = EyeHeight - 0.25f * crouch + 0.35f * rise;
float lean = 0.25f * rise; float lean = (_ledgeTop < 1f ? 0.25f : 0.55f) * rise; // over a chest-high edge you put your head well over it
_headLocal = new Transform3D(new Basis(Vector3.Right, -0.3f * crouch), new Vector3(0f, eye, -lean)); _headLocal = new Transform3D(new Basis(Vector3.Right, -0.3f * crouch), new Vector3(0f, eye, -lean));
float handZ = ObstacleFront - 0.08f - _origin.Origin.Z; // Hands on the top just past the near edge (of the box itself when it's a movable one).
float handY = t < 1f ? 1.0f : Mathf.Lerp(0.95f, 0.55f, rise) - 0.05f * crouch; float front = _ledgeBox != null ? _ledgeBox.GlobalPosition.Z + 0.35f : ObstacleFront;
float handZ = front - 0.08f - _origin.Origin.Z;
float handY = t < 1f ? 1.0f : Mathf.Lerp(_ledgeTop + 0.05f, _ledgeTop - 0.35f, rise) - 0.05f * crouch - _origin.Origin.Y;
Basis down = Aim(Vector3.Down + Vector3.Forward * 0.3f); Basis down = Aim(Vector3.Down + Vector3.Forward * 0.3f);
_leftLocal = both ? new Transform3D(down, new Vector3(-0.2f, handY, handZ)) : new Transform3D(Basis.Identity, new Vector3(-0.2f, 1.0f, -0.3f)); // Walking up, the hands are down at the sides; then they go up over the edge onto the top.
_rightLocal = new Transform3D(down, new Vector3(0.2f, handY, handZ)); float reach = Mathf.Clamp((t - 0.8f) / 0.4f, 0f, 1f);
Vector3 rest = new(0.2f, 1.0f, -0.3f), onTop = new(0.2f, Mathf.Max(handY, _ledgeTop + 0.08f - _origin.Origin.Y), handZ);
Vector3 r = reach < 1f ? rest.Lerp(new Vector3(0.2f, _ledgeTop + 0.12f - _origin.Origin.Y, -0.3f), reach) : new Vector3(0.2f, handY, handZ);
if (reach >= 1f && t < 1.4f)
r = onTop;
_leftLocal = both ? new Transform3D(down, new Vector3(-r.X, r.Y, r.Z)) : new Transform3D(Basis.Identity, new Vector3(-0.2f, 1.0f, -0.3f));
_rightLocal = new Transform3D(down, r);
if (_ragdoll.Built && !_needBuild) if (_ragdoll.Built && !_needBuild)
_maxPelvisRise = Mathf.Max(_maxPelvisRise, _ragdoll.Pelvis.GlobalPosition.Y - 0.99f); _maxPelvisRise = Mathf.Max(_maxPelvisRise, _ragdoll.Pelvis.GlobalPosition.Y - 0.99f);
if (t > 4.5f) if (OS.HasEnvironment("MANTLETRACE") && _ragdoll.Built && ((int)(t * 144f)) % 14 == 0)
GD.Print($"MT t {t:F2} pelvis {_ragdoll.Pelvis.GlobalPosition} v {_ragdoll.Pelvis.LinearVelocity.Y:F2} o {_origin.Origin.Y:F2} j {_ragdoll.Jumping} braced {_ragdoll.Braced} lifted {_ragdoll.Lifted} legs {_ragdoll.LegsSolid} g {_ragdoll.Grounded} foot {_ragdoll.GroundY:F2} hands {_ragdoll.Hand(0).GlobalPosition.Y:F2}/{_ragdoll.Hand(1).GlobalPosition.Y:F2} {(_stage.GetChildCount() > 0 && _stage.GetChild(0) is RigidBody3D rb ? rb.GlobalPosition.ToString() : "")}");
if (t > (_ledgeTop < 1f ? 4.5f : 5.5f))
{ {
_leftLocal = new Transform3D(Basis.Identity, new Vector3(-0.2f, 1.0f, -0.3f)); _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)); _rightLocal = new Transform3D(Basis.Identity, new Vector3(0.2f, 1.0f, -0.3f));
@@ -1075,6 +1276,15 @@ public partial class RagdollSpike : Node3D
} }
if (t > 1f) if (t > 1f)
_climbEyeErr = Mathf.Max(_climbEyeErr, _ragdoll.Eye.DistanceTo(Camera.Origin)); _climbEyeErr = Mathf.Max(_climbEyeErr, _ragdoll.Eye.DistanceTo(Camera.Origin));
// The free hand reaching for the next hold while hanging on the other (tipped body): it
// should stay on its controller (owner: "tracking off when the body is tilted").
for (int side = 0; side < 2; side++)
if (_climbWeld[side] == null && _climbStep is 2 or 3 && _ragdoll.Hand(side).GetContactCount() == 0)
{
Vector3 ctrl = (side == 0 ? Left : Right).Origin;
_climbFreeErr += _ragdoll.Hand(side).GlobalPosition.DistanceTo(ctrl);
_climbFreeN++;
}
void Next(int step) void Next(int step)
{ {
@@ -1091,9 +1301,15 @@ public partial class RagdollSpike : Node3D
} }
} }
private float _climbFreeErr;
private int _climbFreeN;
private bool FinishClimbWall() private bool FinishClimbWall()
{ {
float rise = _ragdoll.Pelvis.GlobalPosition.Y - _climbStartPelvis; float rise = _ragdoll.Pelvis.GlobalPosition.Y - _climbStartPelvis;
GD.Print($"RAGDOLL: wall climb: free hand vs its controller while reaching {(_climbFreeN > 0 ? _climbFreeErr / _climbFreeN : 0f):F3} m mean");
_climbFreeErr = 0f;
_climbFreeN = 0;
GD.Print($"RAGDOLL: wall climb: reached step {_climbStep}/5, pelvis rose {rise:F3} m (> 0.35), holding {_climbWeld[0] != null}/{_climbWeld[1] != null}, eye vs camera max {_climbEyeErr:F3} (< {PlayerRagdoll.MaxViewLead + 0.03f:F2}), snaps {NewSnaps}"); GD.Print($"RAGDOLL: wall climb: reached step {_climbStep}/5, pelvis rose {rise:F3} m (> 0.35), holding {_climbWeld[0] != null}/{_climbWeld[1] != null}, eye vs camera max {_climbEyeErr:F3} (< {PlayerRagdoll.MaxViewLead + 0.03f:F2}), snaps {NewSnaps}");
Grip(0, false); Grip(0, false);
Grip(1, false); Grip(1, false);
@@ -1128,7 +1344,8 @@ public partial class RagdollSpike : Node3D
private void SetupPlayground() private void SetupPlayground()
{ {
Rest(); Rest();
_playground = new Playground { Position = new Vector3(0f, 0f, 40f) }; // far from the player _playground = GD.Load<PackedScene>("res://scenes/maps/playground.tscn").Instantiate<Playground>(); // the saved scene
_playground.Position = new Vector3(0f, 0f, 40f); // far from the player
_stage.AddChild(_playground); _stage.AddChild(_playground);
} }
+183 -1
View File
@@ -4,7 +4,8 @@ using Godot;
// Headless regression for the forked addon's Generic6DOFJoint3D → Box3D mapping // Headless regression for the forked addon's Generic6DOFJoint3D → Box3D mapping
// (ball = b3SphericalJoint with cone/twist limits + torque-capped motor; drive = b3MotorJoint; // (ball = b3SphericalJoint with cone/twist limits + torque-capped motor; drive = b3MotorJoint;
// all-locked = weld). Run first whenever the addon binary changes. // all-locked = weld; drive + angular limits = b3MotorJoint with the fork's swing-ellipse/twist limits,
// the shoulder). Run first whenever the addon binary changes.
// godot-mono --headless --fixed-fps 90 --path . scenes/sixdof_spike.tscn // godot-mono --headless --fixed-fps 90 --path . scenes/sixdof_spike.tscn
public partial class SixDofSpike : Node3D public partial class SixDofSpike : Node3D
{ {
@@ -18,6 +19,11 @@ public partial class SixDofSpike : Node3D
private const float WeakMuscle = 1.5f; private const float WeakMuscle = 1.5f;
private const float MuscleKp = 20f; private const float MuscleKp = 20f;
private const float MuscleMaxSpin = 8f; private const float MuscleMaxSpin = 8f;
// Limited drive (the shoulder): asymmetric swing bounds about joint Y / Z, twist about X.
private static readonly float LimLowerY = Mathf.DegToRad(-20f), LimUpperY = Mathf.DegToRad(60f);
private static readonly float LimLowerZ = Mathf.DegToRad(-40f), LimUpperZ = Mathf.DegToRad(100f);
private static readonly float LimTwist = Mathf.DegToRad(45f);
private const float SocketKp = 30f, SocketForce = 2000f;
private readonly List<(string Name, Action<float> Tick, float Duration, Func<bool> Finish)> _phases = new(); private readonly List<(string Name, Action<float> Tick, float Duration, Func<bool> Finish)> _phases = new();
private int _phase = -1; private int _phase = -1;
@@ -37,6 +43,9 @@ public partial class SixDofSpike : Node3D
_phases.Add(("hand pushing into a wall pushes the anchor body away (reaction)", DriveBlockedTick, 1.5f, DriveBlockedFinish)); _phases.Add(("hand pushing into a wall pushes the anchor body away (reaction)", DriveBlockedTick, 1.5f, DriveBlockedFinish));
_phases.Add(("all-locked config is rigid", WeldTick, 1.5f, WeldFinish)); _phases.Add(("all-locked config is rigid", WeldTick, 1.5f, WeldFinish));
_phases.Add(("live flag toggling stays finite", ToggleTick, 1f, ToggleFinish)); _phases.Add(("live flag toggling stays finite", ToggleTick, 1f, ToggleFinish));
_phases.Add(("limited drive holds the asymmetric swing ellipse + twist under hard torque", LimitedDriveTorqueTick, 4f, LimitedDriveTorqueFinish));
_phases.Add(("limited drive: muscle reaches targets inside the range, stops at the bound outside it", LimitedDriveMuscleTick, 6f, LimitedDriveMuscleFinish));
_phases.Add(("limited drive: the linear socket drive still moves the joint (shrug)", LimitedDriveSocketTick, 1.5f, LimitedDriveSocketFinish));
Next(); Next();
} }
@@ -210,6 +219,39 @@ public partial class SixDofSpike : Node3D
_box = MakeBox(new Vector3(0.1f, 0.4f, 0.1f), 1f, new Vector3(0, 2.8f, 0)); _box = MakeBox(new Vector3(0.1f, 0.4f, 0.1f), 1f, new Vector3(0, 2.8f, 0));
_joint = MakeJoint(_anchor, _box, new Vector3(0, 3, 0), j => ForAxes((f, p) => f(Generic6DofJoint3D.Flag.EnableAngularLimit, false), j)); _joint = MakeJoint(_anchor, _box, new Vector3(0, 3, 0), j => ForAxes((f, p) => f(Generic6DofJoint3D.Flag.EnableAngularLimit, false), j));
break; break;
case 10:
case 11:
case 12:
// An upper arm (2 kg, 0.3 m) hanging from a static chest through a shoulder-style DRIVE:
// linear velocity motor = socket, angular velocity motor = muscle, angular limits on.
// Joint X points down the arm (twist axis), joint Y = world +X, joint Z = world +Z.
_anchor = MakeStatic(Vector3.One * 0.1f, new Vector3(0, 3, 0));
_box = MakeBox(new Vector3(0.08f, 0.3f, 0.08f), 2f, new Vector3(0, 2.85f, 0));
_joint = MakeJoint(_anchor, _box, new Vector3(0, 3, 0), j =>
{
j.Basis = new Basis(Vector3.Forward, Mathf.Pi / 2f);
ForAxes((f, p) =>
{
f(Generic6DofJoint3D.Flag.EnableLinearLimit, false);
f(Generic6DofJoint3D.Flag.EnableAngularLimit, true);
f(Generic6DofJoint3D.Flag.EnableLinearMotor, true);
f(Generic6DofJoint3D.Flag.EnableMotor, true);
p(Generic6DofJoint3D.Param.LinearMotorForceLimit, SocketForce);
p(Generic6DofJoint3D.Param.AngularMotorForceLimit, phase == 11 ? 30f : 0.5f);
}, j);
j.SetParamX(Generic6DofJoint3D.Param.AngularLowerLimit, -LimTwist);
j.SetParamX(Generic6DofJoint3D.Param.AngularUpperLimit, LimTwist);
j.SetParamY(Generic6DofJoint3D.Param.AngularLowerLimit, LimLowerY);
j.SetParamY(Generic6DofJoint3D.Param.AngularUpperLimit, LimUpperY);
j.SetParamZ(Generic6DofJoint3D.Param.AngularLowerLimit, LimLowerZ);
j.SetParamZ(Generic6DofJoint3D.Param.AngularUpperLimit, LimUpperZ);
});
_overshootDeg = -999f;
_maxTwist = 0f;
_reach = Vector4.Zero;
_muscleStage = -1;
_stageErr.Clear();
break;
} }
} }
@@ -358,6 +400,146 @@ public partial class SixDofSpike : Node3D
return _finite && _maxSwing < 2f && _maxPivotSep < PivotLimit; return _finite && _maxSwing < 2f && _maxPivotSep < PivotLimit;
} }
// ---- limited drive (shoulder) ----
private float _overshootDeg;
private Vector4 _reach; // max +Y, max -Y, max +Z, max -Z swing components reached (deg)
private int _muscleStage;
private readonly List<string> _stageErr = new();
private bool _stagesOk = true;
// Swing rotation vector (joint Y/Z components, rad) taking frame A's X onto frame B's X, and the
// remaining twist about X (rad).
private void SwingTwist(out Vector2 swing, out float twist)
{
Quaternion rel = Rel();
Vector3 b = rel * Vector3.Right;
float angle = Vector3.Right.AngleTo(b);
Vector3 axis = Vector3.Right.Cross(b);
swing = axis.LengthSquared() > 1e-12f ? new Vector2(axis.Y, axis.Z).Normalized() * angle : Vector2.Zero;
Quaternion swingQ = axis.LengthSquared() > 1e-12f ? new Quaternion(axis.Normalized(), angle) : Quaternion.Identity;
Quaternion t = swingQ.Inverse() * rel;
twist = 2f * Mathf.Atan2(t.X, t.W);
if (twist > Mathf.Pi) twist -= Mathf.Tau;
if (twist < -Mathf.Pi) twist += Mathf.Tau;
}
// Radius of the quadrant ellipse in the swing direction (same formula as the fork).
private static float SwingLimit(Vector2 dir)
{
float a = dir.X >= 0f ? LimUpperY : -LimLowerY, b = dir.Y >= 0f ? LimUpperZ : -LimLowerZ;
float u = dir.X / a, v = dir.Y / b;
return 1f / Mathf.Sqrt(u * u + v * v);
}
// Socket: linear velocity motor toward the pivot (+ a moving offset), like PlayerRagdoll.ShoulderDrive.
private void SocketDrive(Vector3 offset)
{
Vector3 err = _pivotWorld + offset - _box.GlobalTransform * _pivotLocalB;
Vector3 v = FrameA().Inverse() * (err * SocketKp);
_joint.SetParamX(Generic6DofJoint3D.Param.LinearMotorTargetVelocity, v.X);
_joint.SetParamY(Generic6DofJoint3D.Param.LinearMotorTargetVelocity, v.Y);
_joint.SetParamZ(Generic6DofJoint3D.Param.LinearMotorTargetVelocity, v.Z);
}
private void Muscle(Quaternion target)
{
Vector3 err = BodyMath.RotationError(target, Rel());
Vector3 w = (err * MuscleKp).LimitLength(MuscleMaxSpin);
_joint.SetParamX(Generic6DofJoint3D.Param.AngularMotorTargetVelocity, w.X);
_joint.SetParamY(Generic6DofJoint3D.Param.AngularMotorTargetVelocity, w.Y);
_joint.SetParamZ(Generic6DofJoint3D.Param.AngularMotorTargetVelocity, w.Z);
}
private void TrackLimits()
{
TrackFinite();
SwingTwist(out Vector2 s, out float tw);
if (s.Length() > Mathf.DegToRad(2f))
_overshootDeg = Mathf.Max(_overshootDeg, Mathf.RadToDeg(s.Length() - SwingLimit(s.Normalized())));
_maxTwist = Mathf.Max(_maxTwist, Mathf.RadToDeg(Mathf.Abs(tw)));
_reach = new Vector4(Mathf.Max(_reach.X, Mathf.RadToDeg(s.X)), Mathf.Max(_reach.Y, -Mathf.RadToDeg(s.X)),
Mathf.Max(_reach.Z, Mathf.RadToDeg(s.Y)), Mathf.Max(_reach.W, -Mathf.RadToDeg(s.Y)));
_maxPivotSep = Mathf.Max(_maxPivotSep, PivotSep());
}
private void LimitedDriveTorqueTick(float dt)
{
SocketDrive(Vector3.Zero);
Muscle(Rel()); // weak (0.5 N·m) muscle holding nothing in particular
// A hard torque sweeping all swing directions (joint Y/Z) plus an alternating twist torque.
float phi = _t * 3f;
Vector3 local = new Vector3(6f * Mathf.Sign(Mathf.Sin(_t * 5f)), 25f * Mathf.Cos(phi), 25f * Mathf.Sin(phi));
_box.ApplyTorque(FrameA() * local);
TrackLimits();
}
private bool LimitedDriveTorqueFinish()
{
GD.Print($"6DOF: 25 N·m sweep: swing overshoot max {_overshootDeg:F1} deg (limit {LimitToleranceDeg}); reached +Y {_reach.X:F0}/60, -Y {_reach.Y:F0}/20, +Z {_reach.Z:F0}/100, -Z {_reach.W:F0}/40 deg; twist max {_maxTwist:F1} deg (limit 45); socket {_maxPivotSep:F4} m");
bool reached = _reach.X > 50f && _reach.Y > 14f && _reach.Z > 85f && _reach.W > 32f;
return _finite && _overshootDeg < LimitToleranceDeg && reached && _maxTwist < Mathf.RadToDeg(LimTwist) + LimitToleranceDeg && _maxPivotSep < PivotLimit;
}
// Targets (swing Y, swing Z, twist, deg): inside, inside, inside, then one beyond the +Y bound.
private static readonly Vector3[] MuscleTargets = { new(40f, 0f, 0f), new(0f, 80f, 0f), new(-15f, -30f, 30f), new(85f, 0f, 0f) };
private static Quaternion SwingTwistQuat(Vector3 deg)
{
Vector3 rv = new(0f, Mathf.DegToRad(deg.X), Mathf.DegToRad(deg.Y));
Quaternion swing = rv.LengthSquared() > 1e-12f ? new Quaternion(rv.Normalized(), rv.Length()) : Quaternion.Identity;
return swing * new Quaternion(Vector3.Right, Mathf.DegToRad(deg.Z));
}
private void LimitedDriveMuscleTick(float dt)
{
SocketDrive(Vector3.Zero);
int stage = Mathf.Min((int)(_t / 1.5f), MuscleTargets.Length - 1);
Quaternion target = SwingTwistQuat(MuscleTargets[stage]);
Muscle(target);
TrackLimits();
if (_t >= (stage + 1) * 1.5f - dt * 1.5f && _muscleStage < stage)
{
_muscleStage = stage;
SwingTwist(out Vector2 s, out float tw);
Vector3 got = new(Mathf.RadToDeg(s.X), Mathf.RadToDeg(s.Y), Mathf.RadToDeg(tw));
if (stage < 3)
{
float err = Mathf.RadToDeg(BodyMath.RotationError(target, Rel()).Length());
_stageErr.Add($"target {MuscleTargets[stage]} → {got.X:F0},{got.Y:F0},{got.Z:F0} err {err:F1}");
_stagesOk &= err < 3f;
}
else
{
_stageErr.Add($"target {MuscleTargets[stage]} (beyond +Y 60) → swing Y {got.X:F1}");
_stagesOk &= got.X < 60f + LimitToleranceDeg && got.X > 55f;
}
}
}
private bool LimitedDriveMuscleFinish()
{
GD.Print($"6DOF: 30 N·m muscle: {string.Join("; ", _stageErr)}; overshoot max {_overshootDeg:F1} deg; socket {_maxPivotSep:F4} m");
return _finite && _stagesOk && _stageErr.Count == MuscleTargets.Length && _overshootDeg < LimitToleranceDeg && _maxPivotSep < PivotLimit;
}
private Vector3 ShrugOffset => new Vector3(0f, 0.05f, -0.03f) * Mathf.Min(_t / 0.5f, 1f);
private void LimitedDriveSocketTick(float dt)
{
SocketDrive(ShrugOffset);
Muscle(Quaternion.Identity);
TrackFinite();
SwingTwist(out Vector2 s, out _);
_maxSwing = Mathf.Max(_maxSwing, Mathf.RadToDeg(s.Length()));
}
private bool LimitedDriveSocketFinish()
{
float err = (_box.GlobalTransform * _pivotLocalB).DistanceTo(_pivotWorld + ShrugOffset);
GD.Print($"6DOF: socket moved {ShrugOffset.Length():F3} m: tracking error {err:F4} m (limit 0.005), arm swing max {_maxSwing:F1} deg");
return _finite && err < 0.005f && _maxSwing < 5f;
}
private int _toggles; private int _toggles;
private void ToggleTick(float dt) private void ToggleTick(float dt)
+88
View File
@@ -0,0 +1,88 @@
using Godot;
// Nimso's "soft grab" (NEXT GEN — No Jank): a grab never snaps and never welds across a gap — the
// grabbed thing is EASED into the hand first, then the weld locks the pose. Without it the weld
// froze whatever gap there was at the moment of the press: hands hovering next to bars and holds,
// props held a few centimetres off the palm (owner).
// The grip point is where the palm actually faces the object's surface (a short sphere cast along
// the palm normal, else toward the object's centre). Over EaseTime that surface point and the palm
// surface are brought together: a light prop flies into the hand, the hand goes to static world or
// anything too heavy to pull. Position only — the relative orientation is kept (no rotation snap).
public sealed class SoftGrab
{
public const float EaseTime = 0.1f; // s
public const float SettleGap = 0.004f; // m: close enough to weld
public const float MaxEaseSpeed = 3f; // m/s: a far grab takes a little longer instead of jerking
public const float LightMass = 6f; // kg: lighter props come to the hand, heavier things pull the hand
public readonly RigidBody3D Hand;
public readonly PhysicsBody3D Target;
public readonly int Side;
private readonly Vector3 _onTarget; // grip point, target-local
private float _t, _lastGap = float.MaxValue;
public float Gap => (PalmSurface(Hand, Side) - Target.GlobalTransform * _onTarget).Length();
private SoftGrab(RigidBody3D hand, int side, PhysicsBody3D target, Vector3 gripWorld)
{
Hand = hand;
Side = side;
Target = target;
_onTarget = target.GlobalTransform.AffineInverse() * gripWorld;
}
// The palm faces +X on the left hand, −X on the right (PropGrabber.PalmPoint).
public static Vector3 PalmNormal(RigidBody3D hand, int side) =>
hand.GlobalTransform.Basis.Orthonormalized().X * (side == 0 ? 1f : -1f);
public static Vector3 PalmSurface(RigidBody3D hand, int side) =>
hand.GlobalPosition + PalmNormal(hand, side) * (PlayerRagdoll.HandSize.X * 0.5f);
public static SoftGrab Begin(RigidBody3D hand, int side, PhysicsBody3D target)
{
Vector3 surface = PalmSurface(hand, side);
Vector3 grip = CastTo(hand, target, hand.GlobalPosition, PalmNormal(hand, side) * 0.14f)
?? CastTo(hand, target, surface, target.GlobalPosition - surface)
?? surface; // already touching/inside: weld where it is
return new SoftGrab(hand, side, target, grip);
}
private static Vector3? CastTo(RigidBody3D hand, PhysicsBody3D target, Vector3 from, Vector3 motion)
{
if (motion.LengthSquared() < 1e-8f)
return null;
PhysicsShapeQueryParameters3D q = new()
{
Shape = new SphereShape3D { Radius = 0.01f },
Transform = new Transform3D(Basis.Identity, from),
Motion = motion,
CollisionMask = 1,
};
q.Exclude = new Godot.Collections.Array<Rid> { hand.GetRid() };
Godot.Collections.Dictionary hit = hand.GetWorld3D().DirectSpaceState.GetRestInfo(q);
if (hit.Count == 0 || (ulong)hit["collider_id"] != target.GetInstanceId())
return null;
return (Vector3)hit["point"];
}
// Every physics tick until it returns true (then weld). Velocities are set, not forces: the ease
// is a short, exact move, and the solver still resolves contacts along the way.
public bool Step(float dt)
{
_t += dt;
Vector3 gap = PalmSurface(Hand, Side) - Target.GlobalTransform * _onTarget;
// Done when touching, or when it stopped closing (the palm is against the surface — contact
// slop leaves ~1 cm on curved holds — or the arm can't get there).
bool stalled = _t > EaseTime && _lastGap - gap.Length() < 0.0005f;
_lastGap = gap.Length();
if (gap.Length() < SettleGap || stalled || _t > EaseTime * 3f)
return true;
float remaining = Mathf.Max(EaseTime - _t, dt);
Vector3 close = (gap / remaining).LimitLength(MaxEaseSpeed);
if (Target is RigidBody3D body && !body.Freeze && body.Mass <= LightMass)
body.LinearVelocity = Hand.LinearVelocity + close;
else
Hand.LinearVelocity = (Target is RigidBody3D moving ? moving.LinearVelocity : Vector3.Zero) - close;
return false;
}
}
+1
View File
@@ -0,0 +1 @@
uid://c4rg1kcdtsxni
+1
View File
@@ -79,6 +79,7 @@ public partial class WorldPanel : Node3D
_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.StrengthChanged += v => PlayerRagdoll.Strength = v;
_menu.SmoothTurnToggled += on => PlayerRig.SmoothTurn = on; _menu.SmoothTurnToggled += on => PlayerRig.SmoothTurn = on;
_menu.LowLagTrackingToggled += on => PlayerRig.SteadyTracking = !on;
_menu.SpectatorToggled += PlayerRig.SetSpectator; _menu.SpectatorToggled += PlayerRig.SetSpectator;
SetPanelActive(false); SetPanelActive(false);
+62
View File
@@ -0,0 +1,62 @@
using Godot;
using System.Collections.Generic;
using System.Linq;
// Dumps the map layout (type, transform, shape/mesh size, groups) sorted, for before/after comparison.
// godot-mono --headless --path . tools/map_dump.tscn -- out=<file>
public partial class MapDump : Node3D
{
private int _frame;
public override void _Ready()
{
foreach (string path in RebuildMaps.Maps)
AddChild(GD.Load<PackedScene>(path).Instantiate());
}
public override void _Process(double delta)
{
if (++_frame < 3)
return;
string outPath = "/tmp/claude-1000/maps/dump.txt";
foreach (string a in OS.GetCmdlineUserArgs())
if (a.StartsWith("out="))
outPath = a[4..];
var lines = new List<string>();
foreach (Node map in GetChildren())
Walk(map, map.GetType().Name, lines);
lines.Sort(System.StringComparer.Ordinal);
System.IO.File.WriteAllLines(outPath, lines);
GD.Print($"MAPDUMP: {lines.Count} nodes → {outPath}");
GetTree().Quit();
}
private static string R(Vector3 v) => $"({v.X:F3},{v.Y:F3},{v.Z:F3})";
private void Walk(Node n, string map, List<string> lines)
{
foreach (Node ch in n.GetChildren())
{
string info = ch.GetClass();
if (ch is Node3D n3)
{
Transform3D t = n3.GlobalTransform;
info += $" o{R(t.Origin)} x{R(t.Basis.X)} y{R(t.Basis.Y)}";
}
if (ch is CollisionShape3D cs && cs.Shape is BoxShape3D bs) info += $" box{R(bs.Size)}";
if (ch is CollisionShape3D cc && cc.Shape is CylinderShape3D cy) info += $" cyl{cy.Radius:F3}/{cy.Height:F3}";
if (ch is CollisionShape3D cp && cp.Shape is SphereShape3D sp) info += $" sph{sp.Radius:F3}";
if (ch is MeshInstance3D mi && mi.GetSurfaceOverrideMaterial(0) is StandardMaterial3D m) info += $" col{m.AlbedoColor.ToHtml()} sh{(int)mi.CastShadow}";
if (ch is Label3D l) info += $" text[{l.Text}] px{l.PixelSize:F4}";
if (ch is CollisionObject3D co) info += $" L{co.CollisionLayer}/{co.CollisionMask}";
if (ch is RigidBody3D rb) info += $" m{rb.Mass:F2} f{rb.PhysicsMaterialOverride?.Friction:F2}";
var groups = ch.GetGroups().Select(g => g.ToString()).Where(g => !g.StartsWith("_")).OrderBy(g => g);
info += " g[" + string.Join(",", groups) + "]";
if (ch is Joint3D j) info += $" joint {j.GetNodeOrNull(j.NodeA)?.GetClass()}@{R((j.GetNodeOrNull(j.NodeA) as Node3D)?.GlobalPosition ?? Vector3.Zero)}-{j.GetNodeOrNull(j.NodeB)?.GetClass()}@{R((j.GetNodeOrNull(j.NodeB) as Node3D)?.GlobalPosition ?? Vector3.Zero)}";
if (ch.GetType().Name == "TeleportMarker" || ch.SceneFilePath != "") info += $" scene[{ch.SceneFilePath}] label[{ch.Get("MarkerLabel")}]";
lines.Add($"{map}: {info}");
if (ch.SceneFilePath == "")
Walk(ch, map, lines);
}
}
}
+1
View File
@@ -0,0 +1 @@
uid://bt87gcewn6ihe
+2 -2
View File
@@ -31,8 +31,8 @@ public partial class PlaygroundShots : Node3D
floor.AddChild(new CollisionShape3D { Shape = new BoxShape3D { Size = new Vector3(80, 0.1f, 80) }, Position = new Vector3(0, -0.05f, 0) }); floor.AddChild(new CollisionShape3D { Shape = new BoxShape3D { Size = new Vector3(80, 0.1f, 80) }, Position = new Vector3(0, -0.05f, 0) });
floor.AddChild(new MeshInstance3D { Mesh = new PlaneMesh { Size = new Vector2(80, 80) } }); floor.AddChild(new MeshInstance3D { Mesh = new PlaneMesh { Size = new Vector2(80, 80) } });
AddChild(floor); AddChild(floor);
AddChild(new Playground()); AddChild(GD.Load<PackedScene>("res://scenes/maps/playground.tscn").Instantiate()); // the saved maps
AddChild(new Crag()); AddChild(GD.Load<PackedScene>("res://scenes/maps/crag.tscn").Instantiate());
_cam = new Camera3D { Current = true }; _cam = new Camera3D { Current = true };
AddChild(_cam); AddChild(_cam);
SetView(); SetView();
+68
View File
@@ -0,0 +1,68 @@
using Godot;
// Headless map rebuild for agents (same as pressing each map's "Rebuild" button in the editor and
// saving): loads each map scene, regenerates its children from code and saves it back.
// godot-mono --headless --path . tools/rebuild_maps.tscn [-- only=playground|crag]
// Prints "MAPS: rebuilt <path> (<n> nodes)" per map and "MAPS: done". Overwrites hand edits in those
// scenes — that's what Rebuild means.
public partial class RebuildMaps : Node
{
public static readonly string[] Maps = { "res://scenes/maps/playground.tscn", "res://scenes/maps/crag.tscn" };
public override void _Ready()
{
string only = null;
foreach (string a in OS.GetCmdlineUserArgs())
if (a.StartsWith("only="))
only = a[5..];
bool ok = true;
foreach (string path in Maps)
{
if (only != null && !path.Contains(only))
continue;
ok &= RebuildOne(path);
}
GD.Print(ok ? "MAPS: done" : "MAPS: FAILED");
GetTree().Quit(ok ? 0 : 1);
}
private bool RebuildOne(string path)
{
Node map = ResourceLoader.Load<PackedScene>(path, null, ResourceLoader.CacheMode.Ignore).Instantiate();
AddChild(map); // in the tree, so joint paths and globals resolve like in the editor
switch (map)
{
case Playground p:
p.Rebuild();
break;
case Crag c:
c.Rebuild();
break;
default:
GD.PushError($"MAPS: {path} root is not a map ({map.GetType().Name})");
return false;
}
RemoveChild(map);
PackedScene packed = new();
Error err = packed.Pack(map);
if (err == Error.Ok)
err = ResourceSaver.Save(packed, path);
int count = CountOwned(map, map);
map.Free();
if (err != Error.Ok)
{
GD.PushError($"MAPS: saving {path} failed: {err}");
return false;
}
GD.Print($"MAPS: rebuilt {path} ({count} nodes)");
return true;
}
private static int CountOwned(Node root, Node n)
{
int c = 0;
foreach (Node child in n.GetChildren())
c += (child.Owner == root ? 1 : 0) + CountOwned(root, child);
return c;
}
}
+1
View File
@@ -0,0 +1 @@
uid://cd3m3hyr27lpy
+6
View File
@@ -0,0 +1,6 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://tools/MapDump.cs" id="1"]
[node name="MapDump" type="Node3D"]
script = ExtResource("1")
+6
View File
@@ -0,0 +1,6 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://tools/RebuildMaps.cs" id="1"]
[node name="RebuildMaps" type="Node"]
script = ExtResource("1")