Compare commits
10
Commits
423b709cc9
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
797b67c19c | ||
|
|
b8365ee95f | ||
|
|
ca60df3d75 | ||
|
|
22159aa902 | ||
|
|
83186f9676 | ||
|
|
0bddcf4517 | ||
|
|
537e72c207 | ||
|
|
2d986d08ac | ||
|
|
df7a2c5a89 | ||
|
|
3e300c7f04 |
No files matched your search
+2
-1
@@ -4,4 +4,5 @@
|
||||
.export/
|
||||
*.translation
|
||||
prefs.json
|
||||
.claude/worktrees/
|
||||
.claude/
|
||||
research/personal/
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
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
|
||||
`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
|
||||
the one project-setting line, and all gameplay code must stay standard-Godot-physics-node
|
||||
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.**
|
||||
**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);
|
||||
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`
|
||||
(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
|
||||
@@ -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 →
|
||||
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).
|
||||
**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.
|
||||
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
|
||||
|
||||
+11
-1
@@ -40,7 +40,7 @@ TERMINOLOGY (owner-corrected — use precisely):
|
||||
`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),
|
||||
`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
|
||||
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:
|
||||
@@ -111,6 +111,16 @@ TERMINOLOGY (owner-corrected — use precisely):
|
||||
- **Playground.cs** — the body sandbox around the table (east climbing/mantling, west physics toys, south
|
||||
crawl/duck/door); dynamic pieces are "prop"s reset by the menu's Reset props. `tools/playground_shots.tscn` renders
|
||||
overview PNGs (`godot-mono --path . tools/playground_shots.tscn -- <dir>`).
|
||||
- **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
|
||||
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.**
|
||||
|
||||
Binary file not shown.
+2
-2
@@ -27,9 +27,9 @@ project/assembly_name="VR Messs"
|
||||
[physics]
|
||||
|
||||
common/physics_ticks_per_second=144
|
||||
common/physics_interpolation=true
|
||||
common/max_physics_steps_per_frame=14
|
||||
common/max_physics_steps_per_frame=6
|
||||
3d/physics_engine="Box3D Physics"
|
||||
common/physics_interpolation=true
|
||||
|
||||
[rendering]
|
||||
|
||||
|
||||
@@ -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** |
|
||||
| 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
|
||||
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
|
||||
(−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.
|
||||
|
||||
## 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).
|
||||
@@ -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 |
|
||||
| **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 |
|
||||
|
||||
**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
@@ -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="PackedScene" path="res://scenes/teleport_marker.tscn" id="2_marker"]
|
||||
[ext_resource type="Script" path="res://scripts/Locomotion.cs" id="3_locomotion"]
|
||||
[ext_resource type="Script" path="res://scripts/WorldPanel.cs" id="4_worldpanel"]
|
||||
[ext_resource type="Script" path="res://scripts/AnchorMoveMode.cs" id="5_anchormove"]
|
||||
[ext_resource type="Script" path="res://scripts/PropShop.cs" id="6_propshop"]
|
||||
[ext_resource type="Script" path="res://scripts/PropGrabber.cs" id="7_propgrabber"]
|
||||
[ext_resource type="Script" path="res://scripts/Calibration.cs" id="12_calibration"]
|
||||
[ext_resource type="Script" path="res://scripts/TrueBody.cs" id="13_truebody"]
|
||||
[ext_resource type="Script" path="res://scripts/PlayerRig.cs" id="14_playerrig"]
|
||||
[ext_resource type="Script" path="res://scripts/Playground.cs" id="15_playground"]
|
||||
[ext_resource type="Script" path="res://scripts/Crag.cs" id="16_crag"]
|
||||
[ext_resource type="Script" uid="uid://dsy2jm1gdcao0" path="res://scripts/Main.cs" id="1_main"]
|
||||
[ext_resource type="Script" uid="uid://be6cxrxsc202g" 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" uid="uid://00dofwddtngn" 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" uid="uid://70m2rsbntlm4" 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" uid="uid://cr1somauqye6" 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="PackedScene" path="res://scenes/maps/playground.tscn" id="15_playground"]
|
||||
[ext_resource type="PackedScene" uid="uid://dbotaq78lap70" path="res://scenes/maps/crag.tscn" id="16_crag"]
|
||||
|
||||
[sub_resource type="ProceduralSkyMaterial" id="ProceduralSkyMaterial_sky"]
|
||||
sky_horizon_color = Color(0.66, 0.72, 0.81, 1)
|
||||
@@ -24,6 +23,9 @@ sky_material = SubResource("ProceduralSkyMaterial_sky")
|
||||
background_mode = 2
|
||||
sky = SubResource("Sky_main")
|
||||
|
||||
[sub_resource type="BoxShape3D" id="BoxShape_floor"]
|
||||
size = Vector3(80, 0.1, 80)
|
||||
|
||||
[sub_resource type="StandardMaterial3D" id="Material_floor"]
|
||||
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")
|
||||
size = Vector2(80, 80)
|
||||
|
||||
[sub_resource type="BoxShape3D" id="BoxShape_floor"]
|
||||
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"]
|
||||
[node name="Main" type="Node3D" unique_id=1909672424]
|
||||
script = ExtResource("1_main")
|
||||
|
||||
[node name="WorldEnvironment" type="WorldEnvironment" parent="."]
|
||||
[node name="WorldEnvironment" type="WorldEnvironment" parent="." unique_id=2031010393]
|
||||
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)
|
||||
shadow_enabled = true
|
||||
directional_shadow_max_distance = 10.0
|
||||
shadow_bias = 0.04
|
||||
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
|
||||
|
||||
[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)
|
||||
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")
|
||||
|
||||
[node name="XROrigin3D" type="XROrigin3D" parent="."]
|
||||
[node name="XROrigin3D" type="XROrigin3D" parent="." unique_id=1331283556]
|
||||
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)
|
||||
|
||||
[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)
|
||||
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)
|
||||
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")
|
||||
|
||||
[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)
|
||||
|
||||
[node name="TeleportMarkers" type="Node3D" parent="."]
|
||||
|
||||
[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="."]
|
||||
[node name="Locomotion" type="Node" parent="." unique_id=1254803967]
|
||||
script = ExtResource("3_locomotion")
|
||||
|
||||
[node name="AnchorMoveMode" type="Node" parent="."]
|
||||
[node name="AnchorMoveMode" type="Node" parent="." unique_id=501015667]
|
||||
script = ExtResource("5_anchormove")
|
||||
|
||||
[node name="Playground" type="Node3D" parent="."]
|
||||
script = ExtResource("15_playground")
|
||||
[node name="Playground" parent="." unique_id=75577887 instance=ExtResource("15_playground")]
|
||||
|
||||
[node name="Crag" type="Node3D" parent="."]
|
||||
script = ExtResource("16_crag")
|
||||
[node name="Crag" parent="." unique_id=1556412935 instance=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")
|
||||
|
||||
[node name="PropGrabber" type="Node" parent="."]
|
||||
[node name="PropGrabber" type="Node" parent="." unique_id=36341590]
|
||||
script = ExtResource("7_propgrabber")
|
||||
|
||||
[node name="Calibration" type="Node" parent="."]
|
||||
[node name="Calibration" type="Node" parent="." unique_id=701360651]
|
||||
script = ExtResource("12_calibration")
|
||||
|
||||
[node name="TrueBody" type="Node3D" parent="."]
|
||||
[node name="TrueBody" type="Node3D" parent="." unique_id=851816669]
|
||||
physics_interpolation_mode = 2
|
||||
script = ExtResource("13_truebody")
|
||||
|
||||
[node name="PlayerRig" type="Node" parent="."]
|
||||
[node name="PlayerRig" type="Node" parent="." unique_id=506216051]
|
||||
physics_interpolation_mode = 2
|
||||
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
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
color = Color(0, 0, 0, 0)
|
||||
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
@@ -11,7 +11,8 @@ public sealed class ArmSolver
|
||||
public const float SoftIKFactor = 0.98f;
|
||||
public const float PoleHandWeight = 0.85f;
|
||||
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 ElbowRiseAllowance = 0.05f;
|
||||
|
||||
@@ -76,7 +77,10 @@ public sealed class ArmSolver
|
||||
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));
|
||||
_poleAngle += Mathf.Clamp(step, -PoleMaxSpeed * delta, PoleMaxSpeed * delta);
|
||||
Vector3 pole = refN.Rotated(dir, _poleAngle);
|
||||
|
||||
+89
-26
@@ -12,11 +12,20 @@ using System.Collections.Generic;
|
||||
// 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
|
||||
// base (or jump — no fall damage).
|
||||
// The cliff faces south (+Z) toward spawn. Every route point is recorded in Route so crag_spike
|
||||
// can check reach; route bodies in RoutePieces so it can check nothing intersects them.
|
||||
// The cliff faces south (+Z) toward spawn. EDITOR-BUILT: this is the root of scenes/maps/crag.tscn
|
||||
// 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
|
||||
{
|
||||
[ExportToolButton("Rebuild (replaces all children)")]
|
||||
public Callable RebuildButton => Callable.From(RebuildInEditor);
|
||||
|
||||
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<(CollisionObject3D Body, Aabb Box)> RoutePieces = new();
|
||||
public readonly List<(CollisionObject3D Body, Aabb Box)> MassPieces = new();
|
||||
@@ -37,25 +46,76 @@ public partial class Crag : Node3D
|
||||
|
||||
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();
|
||||
BuildRoute();
|
||||
BuildBase();
|
||||
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;
|
||||
body.GlobalTransform = start;
|
||||
body.LinearVelocity = Vector3.Zero;
|
||||
body.AngularVelocity = Vector3.Zero;
|
||||
body.ResetPhysicsInterpolation();
|
||||
if (body.IsInGroup(RouteGroup))
|
||||
RoutePieces.Add((body, Bounds(body)));
|
||||
else if (body.IsInGroup(MassGroup))
|
||||
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
|
||||
|
||||
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).
|
||||
Node3D marker = GD.Load<PackedScene>("res://scenes/teleport_marker.tscn").Instantiate<Node3D>();
|
||||
marker.Set("MarkerLabel", "Crag");
|
||||
marker.Name = "CragMarker";
|
||||
marker.Position = new Vector3(1.5f, 0f, -9.5f);
|
||||
AddChild(marker);
|
||||
AddChild(marker, true);
|
||||
}
|
||||
|
||||
private void BuildSummitProps()
|
||||
@@ -258,45 +319,47 @@ public partial class Crag : Node3D
|
||||
|
||||
// ---------------------------------------------------------------- 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)
|
||||
{
|
||||
StaticBody3D b = Block(name, size, center, color, true, basis);
|
||||
MassPieces.Add((b, BoxAabb(size, b.Transform)));
|
||||
b.AddToGroup(MassGroup, true);
|
||||
return b;
|
||||
}
|
||||
|
||||
private StaticBody3D RouteBlock(string name, Vector3 size, Vector3 center, Color color, bool castShadow = true)
|
||||
{
|
||||
StaticBody3D b = Block(name, size, center, color, castShadow);
|
||||
RoutePieces.Add((b, BoxAabb(size, b.Transform)));
|
||||
b.AddToGroup(RouteGroup, true);
|
||||
return b;
|
||||
}
|
||||
|
||||
private StaticBody3D RouteBar(string name, Vector3 from, Vector3 to, float radius, Color color)
|
||||
{
|
||||
StaticBody3D b = Bar(name, from, to, radius, color);
|
||||
// Bounds of the cylinder: radius across the axis only (the ends are flat).
|
||||
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)));
|
||||
b.AddToGroup(RouteGroup, true);
|
||||
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)
|
||||
{
|
||||
StaticBody3D body = new() { Name = name, CollisionLayer = WorldLayer, CollisionMask = WorldMask, Transform = new Transform3D(basis ?? Basis.Identity, center) };
|
||||
if (!grab)
|
||||
body.AddToGroup(PropGrabber.NoGrabGroup);
|
||||
body.AddToGroup(PropGrabber.NoGrabGroup, true);
|
||||
body.AddChild(new CollisionShape3D { Shape = new BoxShape3D { Size = size } });
|
||||
MeshInstance3D mesh = Mesh(new BoxMesh { Size = size }, color);
|
||||
if (!castShadow)
|
||||
mesh.CastShadow = GeometryInstance3D.ShadowCastingSetting.Off;
|
||||
body.AddChild(mesh);
|
||||
AddChild(body);
|
||||
AddChild(body, true);
|
||||
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) };
|
||||
body.AddChild(new CollisionShape3D { Shape = new CylinderShape3D { Radius = radius, Height = len } });
|
||||
body.AddChild(Mesh(new CylinderMesh { TopRadius = radius, BottomRadius = radius, Height = len, RadialSegments = 12 }, color));
|
||||
AddChild(body);
|
||||
AddChild(body, true);
|
||||
return body;
|
||||
}
|
||||
|
||||
@@ -325,11 +388,10 @@ public partial class Crag : Node3D
|
||||
MaxContactsReported = 4,
|
||||
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(Mesh(mesh, color));
|
||||
AddChild(body);
|
||||
_dynamic.Add((body, body.GlobalTransform));
|
||||
AddChild(body, true);
|
||||
return body;
|
||||
}
|
||||
|
||||
@@ -347,12 +409,13 @@ public partial class Crag : Node3D
|
||||
{
|
||||
AddChild(new Label3D
|
||||
{
|
||||
Name = "Sign",
|
||||
Text = text,
|
||||
FontSize = 96,
|
||||
PixelSize = 0.002f * scale,
|
||||
OutlineSize = 12,
|
||||
Billboard = BaseMaterial3D.BillboardModeEnum.FixedY,
|
||||
Position = at,
|
||||
});
|
||||
}, true);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
using Godot;
|
||||
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
|
||||
// rock — the summit is high enough, and it's in plain view from spawn. Prints "CRAG: overall PASS".
|
||||
public partial class CragSpike : Node3D
|
||||
@@ -23,8 +24,9 @@ public partial class CragSpike : Node3D
|
||||
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) });
|
||||
AddChild(floor);
|
||||
AddChild(new Playground());
|
||||
_crag = new Crag();
|
||||
// The SAVED scenes (as the game loads them, hand edits included), not a fresh rebuild.
|
||||
AddChild(GD.Load<PackedScene>("res://scenes/maps/playground.tscn").Instantiate());
|
||||
_crag = GD.Load<PackedScene>("res://scenes/maps/crag.tscn").Instantiate<Crag>();
|
||||
AddChild(_crag);
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ public partial class MainMenu : PanelContainer
|
||||
public event Action Clicked;
|
||||
public event Action<bool> SpectatorToggled;
|
||||
public event Action<bool> SmoothTurnToggled;
|
||||
public event Action<bool> LowLagTrackingToggled;
|
||||
public event Action<float> StrengthChanged;
|
||||
|
||||
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));
|
||||
modeRow.AddChild(SpectatorButton);
|
||||
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);
|
||||
|
||||
// World
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
uid://d0oo5qx6v57bs
|
||||
+183
-77
@@ -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.
|
||||
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)
|
||||
// Jump (right A): a short countermovement dip, then the legs push JumpForce over the stroke up to
|
||||
// full extension. Takeoff speed is physics — v² = 2·(F/m − g)·stroke — so a crouch before
|
||||
// the press (real or stick) jumps higher: ~0.45 m standing, ~0.7 m from a deep crouch.
|
||||
public const float JumpForce = 2.4f; // × body weight
|
||||
public const float JumpDip = 0.2f; // m the hips drop first when pressed from standing
|
||||
// Jump (right A, Boneworks-style hold to charge, release to jump): holding bends the legs — the
|
||||
// support goal sinks at ChargeSpeed down to JumpMaxStroke below full extension — and releasing
|
||||
// pushes JumpForce over that stroke up to extension. Takeoff speed is physics,
|
||||
// v² = 2·(F/m − g)·stroke: a tap hops, a full charge (or a real/stick crouch) jumps ~0.7 m.
|
||||
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 JumpMaxStroke = 0.5f;
|
||||
public const float JumpBuffer = 0.15f; // s a press is remembered before touchdown
|
||||
public const float CoyoteTime = 0.12f; // s after leaving the ground a jump still works
|
||||
public const float JumpMinStroke = 0.08f; // a tap still hops
|
||||
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 BracedAirControl = 150f; // N of horizontal hip carry while airborne but braced on the hands (mantling)
|
||||
public const float CrawlCarry = 300f;
|
||||
@@ -187,7 +188,7 @@ public partial class PlayerRagdoll : Node3D
|
||||
private float _supportGoalY;
|
||||
private RigidBody3D _groundBody;
|
||||
private Vector3 _groundPoint;
|
||||
private float _tuckFree, _dt;
|
||||
private float _tuckFree, _dt, _sinceBraced = float.MaxValue;
|
||||
private PhysicsShapeQueryParameters3D _legsQuery;
|
||||
|
||||
private bool LegsSpaceFree(Vector3 center, Basis basis)
|
||||
@@ -197,12 +198,13 @@ public partial class PlayerRagdoll : Node3D
|
||||
return GetWorld3D().DirectSpaceState.IntersectShape(_legsQuery, 1).Count == 0;
|
||||
}
|
||||
private Vector3 _goalVelocity;
|
||||
private enum JumpPhase { None, Dip, Push, Flight }
|
||||
private enum JumpPhase { None, Charge, Push, Flight }
|
||||
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.
|
||||
public void Jump() => _jumpRequest = JumpBuffer;
|
||||
// Right A, every tick. Holding while landing starts the charge on touchdown.
|
||||
public bool JumpHeld { set => _jumpHeld = value; }
|
||||
|
||||
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
|
||||
|
||||
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,
|
||||
c * new Transform3D(Basis.Identity, hip + _pelvisCenterLocal), friction: TorsoFriction);
|
||||
// Hips = a sideways capsule (Nimso: "hip collider: a capsule, ~27 cm diameter"): a box's bottom
|
||||
// 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);
|
||||
_standingHip = standingHip + _pelvisCenterLocal.Y; // of the pelvis BODY (what ground control holds)
|
||||
_legsHeight = Mathf.Max(0.2f, standingHip - MaxStepHeight);
|
||||
@@ -321,7 +326,8 @@ public partial class PlayerRagdoll : Node3D
|
||||
a.Hand.ContactMonitor = true;
|
||||
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.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);
|
||||
@@ -348,7 +354,7 @@ public partial class PlayerRagdoll : Node3D
|
||||
_arms[i] = new Arm();
|
||||
Built = false;
|
||||
_jump = JumpPhase.None;
|
||||
_jumpRequest = 0f;
|
||||
_jumpHeld = false;
|
||||
_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
|
||||
// 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,
|
||||
PhysicsMaterialOverride = new PhysicsMaterial { Friction = friction } };
|
||||
body.AddChild(new CollisionShape3D { Shape = shape });
|
||||
if (shape is BoxShape3D box && layer != 0 && visible)
|
||||
Transform3D shapeAt = new(shapeBasis ?? Basis.Identity, Vector3.Zero);
|
||||
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 });
|
||||
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 };
|
||||
}
|
||||
|
||||
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 };
|
||||
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.AngularMotorForceLimit, torque);
|
||||
}
|
||||
configure?.Invoke(j); // before AddChild: limits/flags rebuild the server joint
|
||||
AddChild(j);
|
||||
Quaternion jq = at.Basis.Orthonormalized().GetRotationQuaternion();
|
||||
return new Drive
|
||||
@@ -567,6 +612,7 @@ public partial class PlayerRagdoll : Node3D
|
||||
HandGuide(a, _targets[a.Hand], side == 0 ? leftVelocity : rightVelocity);
|
||||
Braced |= IsBraced(a);
|
||||
}
|
||||
_sinceBraced = Braced ? 0f : _sinceBraced + dt;
|
||||
GroundControl(pelvisTarget, floorY, dt);
|
||||
HeadGuide(s, dt);
|
||||
LegsDrive(s);
|
||||
@@ -688,15 +734,24 @@ public partial class PlayerRagdoll : Node3D
|
||||
j.SetParamZ(Generic6DofJoint3D.Param.LinearMotorForceLimit, cap);
|
||||
}
|
||||
|
||||
// A hand pressing on or holding static world (a ledge, a wall, a grabbed bar) — not the floor:
|
||||
// hands on the floor while crawling don't lift the body.
|
||||
// A hand pressing on or holding something solid enough to push yourself up on (a ledge, a wall,
|
||||
// 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)
|
||||
{
|
||||
if (a.Held is StaticBody3D)
|
||||
if (a.Held is StaticBody3D || a.Held is RigidBody3D { Freeze: true })
|
||||
return true;
|
||||
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 false;
|
||||
}
|
||||
|
||||
@@ -716,7 +771,10 @@ public partial class PlayerRagdoll : Node3D
|
||||
bool climbingHold = _arms[0].Held is StaticBody3D || _arms[1].Held is StaticBody3D;
|
||||
_lifted = Braced && (Pelvis.GlobalPosition.Y > _supportGoalY + MantleLift || climbingHold);
|
||||
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;
|
||||
Vector3 untucked = new(hip.X, center, hip.Z);
|
||||
bool solid = _legsSolid ? !wantTuck : _tuckFree > UntuckDelay && LegsSpaceFree(untucked, s.YawBasis);
|
||||
@@ -813,16 +871,13 @@ public partial class PlayerRagdoll : Node3D
|
||||
{
|
||||
Vector3 pos = Pelvis.GlobalPosition;
|
||||
float hipAboveFloor = target.Origin.Y - floorY;
|
||||
var query = PhysicsRayQueryParameters3D.Create(pos, pos + Vector3.Down * (hipAboveFloor + GroundProbeExtra + 0.5f), WorldMask);
|
||||
Godot.Collections.Dictionary hit = GetWorld3D().DirectSpaceState.IntersectRay(query);
|
||||
bool wasGrounded = Grounded;
|
||||
Grounded = false;
|
||||
_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"];
|
||||
Vector3 normal = (Vector3)hit["normal"];
|
||||
_groundBody = GodotObject.InstanceFromId((ulong)hit["collider_id"]) as RigidBody3D; // Box3D: "collider" is null
|
||||
_groundBody = groundObject as RigidBody3D;
|
||||
_groundPoint = point;
|
||||
// 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.
|
||||
@@ -841,14 +896,17 @@ public partial class PlayerRagdoll : Node3D
|
||||
}
|
||||
else if (inReach && !steep)
|
||||
{
|
||||
// Step rule: ground more than a step above the feet is only stood on once the
|
||||
// hips are already well above it (climbed/mantled/landed there) — otherwise it's an
|
||||
// obstacle the legs collider holds us back from, and the feet stay where they were.
|
||||
// Higher ground climbed onto must be under the hips' back edge too (not just a
|
||||
// pelvis centre hanging over the lip).
|
||||
bool clear = point.Y <= _footY + MaxStepHeight || GroundBehindAt(pos, point.Y);
|
||||
bool reachable = point.Y <= _footY + MaxStepHeight
|
||||
|| ((!wasGrounded || _lifted || pos.Y - point.Y > 0.6f * hipAboveFloor) && clear);
|
||||
// Step rule (Nimso: higher ground "doesn't move my body up … until I push on it with enough
|
||||
// pressure, at which point I actually clamber over"): ground more than a step above the
|
||||
// feet is stood on once the hips are over it AND the body is pressing onto it — hands
|
||||
// braced on anything solid (ledge, crate), or already airborne
|
||||
// (landing, hanging). Otherwise it's an obstacle the legs collider holds us back from.
|
||||
// No "hips well above it" requirement: that made a ledge need a full press-up.
|
||||
bool step = point.Y <= _footY + MaxStepHeight;
|
||||
// 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;
|
||||
if (reachable)
|
||||
_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.
|
||||
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;
|
||||
if (_jump == JumpPhase.Dip)
|
||||
if (_jump == JumpPhase.Charge)
|
||||
goal.Y = Mathf.Min(goal.Y, _jumpFromY);
|
||||
float w = Mathf.Tau * GroundFrequency;
|
||||
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
|
||||
// extension) → Flight (no ground until falling again) → None on touchdown.
|
||||
// Jump state: held → Charge (the support goal sinks: the legs bend) → released → Push (full leg
|
||||
// force up to extension) → Flight (no ground until falling again) → None on touchdown.
|
||||
private void UpdateJump(float dt, bool wasGrounded)
|
||||
{
|
||||
_airT = Grounded ? 0f : _airT + dt;
|
||||
_jumpRequest -= dt;
|
||||
_jumpT += dt;
|
||||
float pos = Pelvis.GlobalPosition.Y;
|
||||
float top = _footY + _standingHip + JumpExtension;
|
||||
switch (_jump)
|
||||
{
|
||||
case JumpPhase.None:
|
||||
if (_jumpRequest > 0f && (Grounded || _airT < CoyoteTime) && !Climbing)
|
||||
if (_jumpHeld && Grounded && !Climbing)
|
||||
{
|
||||
_jumpRequest = 0f;
|
||||
_jump = JumpPhase.Charge;
|
||||
_jumpT = 0f;
|
||||
// Stroke at least JumpDip + JumpExtension: from standing, dip first.
|
||||
_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);
|
||||
_jumpFromY = Mathf.Min(pos, top);
|
||||
}
|
||||
break;
|
||||
case JumpPhase.Dip:
|
||||
if (pos <= _jumpFromY + 0.02f || _jumpT > 0.2f || !Grounded)
|
||||
case JumpPhase.Charge:
|
||||
if (!Grounded && _airT > CoyoteTime || Climbing)
|
||||
{
|
||||
_jump = JumpPhase.Push;
|
||||
_jumpFromY = pos;
|
||||
_jumpT = 0f;
|
||||
_jump = JumpPhase.None; // walked off or grabbed something while charging
|
||||
break;
|
||||
}
|
||||
if (_jumpHeld)
|
||||
{
|
||||
_jumpFromY = Mathf.Max(top - JumpMaxStroke, _jumpFromY - ChargeSpeed * dt);
|
||||
break;
|
||||
}
|
||||
_jump = JumpPhase.Push;
|
||||
_jumpFromY = Mathf.Min(pos, top - JumpMinStroke);
|
||||
_jumpT = 0f;
|
||||
break;
|
||||
case JumpPhase.Push:
|
||||
if (pos >= top || pos - _jumpFromY >= JumpMaxStroke || _jumpT > 0.35f)
|
||||
@@ -1038,16 +1102,66 @@ public partial class PlayerRagdoll : Node3D
|
||||
|
||||
private PhysicsShapeQueryParameters3D _cameraQuery;
|
||||
|
||||
// Second ground ray 12 cm behind the hips (toward the body's back): ground at least `groundY`.
|
||||
private bool GroundBehindAt(Vector3 pelvisPos, float groundY)
|
||||
// Ground under the body (Nimso: "ground detection uses the objects underneath the body"), not
|
||||
// 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;
|
||||
back.Y = 0f;
|
||||
back = back.LengthSquared() > 1e-6f ? back.Normalized() * 0.12f : Vector3.Zero;
|
||||
var query = PhysicsRayQueryParameters3D.Create(pelvisPos + back, pelvisPos + back + Vector3.Down * 3f, WorldMask);
|
||||
Godot.Collections.Dictionary hit = GetWorld3D().DirectSpaceState.IntersectRay(query);
|
||||
return hit.Count > 0 && ((Vector3)hit["position"]).Y > groundY - 0.05f;
|
||||
PhysicsDirectSpaceState3D space = GetWorld3D().DirectSpaceState;
|
||||
point = normal = Vector3.Zero;
|
||||
collider = null;
|
||||
underHips = false;
|
||||
Godot.Collections.Dictionary ray = space.IntersectRay(PhysicsRayQueryParameters3D.Create(pos, pos + Vector3.Down * length, WorldMask));
|
||||
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)
|
||||
{
|
||||
@@ -1086,23 +1200,15 @@ public partial class PlayerRagdoll : Node3D
|
||||
b.ResetPhysicsInterpolation();
|
||||
}
|
||||
|
||||
// Collision exceptions between a held prop and that arm's segments (its hand is excluded by
|
||||
// the grab weld itself) — a forearm wrapped around a held object must not fight it.
|
||||
// What each hand holds (bracing, climbing). Only the hand itself is excluded from the held object
|
||||
// (by the grab weld).
|
||||
public void SetHeld(int side, PhysicsBody3D prop)
|
||||
{
|
||||
if (!Built)
|
||||
return;
|
||||
Arm a = _arms[side];
|
||||
if (a.Held != null && IsInstanceValid(a.Held) && a.Held != _arms[1 - side].Held)
|
||||
{
|
||||
a.Upper.RemoveCollisionExceptionWith(a.Held);
|
||||
a.Fore.RemoveCollisionExceptionWith(a.Held);
|
||||
}
|
||||
a.Held = prop;
|
||||
if (prop != null)
|
||||
{
|
||||
a.Upper.AddCollisionExceptionWith(prop);
|
||||
a.Fore.AddCollisionExceptionWith(prop);
|
||||
}
|
||||
// The arm keeps colliding with what the hand holds (owner: real arms don't pass through things).
|
||||
// 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.
|
||||
_arms[side].Held = prop;
|
||||
}
|
||||
}
|
||||
+28
-8
@@ -5,7 +5,7 @@ using Godot;
|
||||
// physics tick, in this order:
|
||||
// 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),
|
||||
// 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);
|
||||
// 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.
|
||||
@@ -15,6 +15,9 @@ public partial class PlayerRig : Node
|
||||
public const float MoveAcceleration = 8f; // m/s²
|
||||
public const float StickDeadzone = 0.2f;
|
||||
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 SmoothTurnSpeed = 2.5f; // rad/s
|
||||
public const float SnapTurnThreshold = 0.7f;
|
||||
@@ -36,7 +39,6 @@ public partial class PlayerRig : Node
|
||||
private bool _haveLastOrigin;
|
||||
private Vector3 _moveVelocity;
|
||||
private bool _snapArmed = true;
|
||||
private bool _jumpPrev;
|
||||
private readonly MeshInstance3D[] _ghosts = new MeshInstance3D[2];
|
||||
private bool _rebuild = true;
|
||||
private RagdollDriver _driver;
|
||||
@@ -145,7 +147,8 @@ public partial class PlayerRig : Node
|
||||
_driver.RebuildRequested |= _rebuild;
|
||||
_rebuild = false;
|
||||
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;
|
||||
_lastOrigin = _origin.GlobalTransform;
|
||||
_haveLastOrigin = true;
|
||||
@@ -154,6 +157,24 @@ public partial class PlayerRig : Node
|
||||
_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 since (teleport marker, menu) is respected as the new real one.
|
||||
private void RestorePhysicsOrigin()
|
||||
@@ -169,6 +190,8 @@ public partial class PlayerRig : Node
|
||||
|
||||
public override void _Process(double delta)
|
||||
{
|
||||
_renderTicks = Engine.GetPhysicsFrames() + Engine.GetPhysicsInterpolationFraction();
|
||||
_renderDelta = delta;
|
||||
if (_interpolateOrigin && !Spectator)
|
||||
{
|
||||
Transform3D current = _origin.GlobalTransform;
|
||||
@@ -231,11 +254,8 @@ public partial class PlayerRig : Node
|
||||
if (_moveVelocity != Vector3.Zero)
|
||||
_origin.GlobalPosition += _moveVelocity * dt;
|
||||
|
||||
// Jump: right A, on the press.
|
||||
bool jump = _right.IsButtonPressed("ax_button");
|
||||
if (jump && !_jumpPrev)
|
||||
_ragdoll.Jump();
|
||||
_jumpPrev = jump;
|
||||
// Jump: hold right A to charge (the legs bend), release to jump.
|
||||
_ragdoll.JumpHeld = _right.IsButtonPressed("ax_button");
|
||||
|
||||
// Crouch: right stick held down lowers the body, up raises it back (the stick's dominant
|
||||
// axis decides between crouch and turn).
|
||||
|
||||
+42
-26
@@ -1,7 +1,9 @@
|
||||
using Godot;
|
||||
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
|
||||
// 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,
|
||||
@@ -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.
|
||||
// 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().
|
||||
[Tool]
|
||||
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 const uint WorldLayer = 1, WorldMask = 0b11101, PropMask = 0b11101;
|
||||
|
||||
@@ -21,22 +27,28 @@ public partial class Playground : Node3D
|
||||
|
||||
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();
|
||||
BuildWest();
|
||||
BuildSouth();
|
||||
}
|
||||
|
||||
public void Reset()
|
||||
{
|
||||
foreach (var (body, start) in _dynamic)
|
||||
{
|
||||
if (!IsInstanceValid(body))
|
||||
continue;
|
||||
body.GlobalTransform = start;
|
||||
body.LinearVelocity = Vector3.Zero;
|
||||
body.AngularVelocity = Vector3.Zero;
|
||||
body.ResetPhysicsInterpolation();
|
||||
}
|
||||
MapScene.Adopt(this);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 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) };
|
||||
body.AddChild(new CollisionShape3D { Shape = new BoxShape3D { Size = size } });
|
||||
body.AddChild(Mesh(new BoxMesh { Size = size }, color));
|
||||
AddChild(body);
|
||||
AddChild(body, true);
|
||||
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) };
|
||||
body.AddChild(new CollisionShape3D { Shape = new CylinderShape3D { Radius = radius, Height = len } });
|
||||
body.AddChild(Mesh(new CylinderMesh { TopRadius = radius, BottomRadius = radius, Height = len, RadialSegments = 12 }, color));
|
||||
AddChild(body);
|
||||
AddChild(body, true);
|
||||
return body;
|
||||
}
|
||||
|
||||
@@ -75,11 +87,10 @@ public partial class Playground : Node3D
|
||||
MaxContactsReported = 4,
|
||||
PhysicsMaterialOverride = new PhysicsMaterial { Friction = friction, Bounce = 0.05f },
|
||||
};
|
||||
body.AddToGroup("prop");
|
||||
body.AddToGroup(MapScene.PropGroup, true);
|
||||
body.AddChild(new CollisionShape3D { Shape = shape });
|
||||
body.AddChild(Mesh(mesh, color));
|
||||
AddChild(body);
|
||||
_dynamic.Add((body, body.GlobalTransform));
|
||||
AddChild(body, true);
|
||||
return body;
|
||||
}
|
||||
|
||||
@@ -97,13 +108,14 @@ public partial class Playground : Node3D
|
||||
{
|
||||
AddChild(new Label3D
|
||||
{
|
||||
Name = "Sign",
|
||||
Text = text,
|
||||
FontSize = 96,
|
||||
PixelSize = 0.002f,
|
||||
OutlineSize = 12,
|
||||
Billboard = BaseMaterial3D.BillboardModeEnum.FixedY,
|
||||
Position = at,
|
||||
});
|
||||
}, true);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- zones
|
||||
@@ -183,13 +195,16 @@ public partial class Playground : Node3D
|
||||
// Seesaw: a plank hinged on a pivot block.
|
||||
Block("SeesawPivot", new Vector3(0.3f, 0.45f, 0.5f), new Vector3(-8.5f, 0.225f, -1.5f), Metal);
|
||||
RigidBody3D seesaw = BoxProp("Seesaw", new Vector3(3.2f, 0.06f, 0.45f), 12f, new Vector3(-8.5f, 0.48f, -1.5f), DarkWood);
|
||||
// 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()
|
||||
{
|
||||
Name = "SeesawHinge",
|
||||
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
|
||||
NodeB = seesaw.GetPath(),
|
||||
NodeA = $"../{GetNode<Node>("SeesawPivot").Name}",
|
||||
NodeB = $"../{seesaw.Name}",
|
||||
};
|
||||
AddChild(pivot);
|
||||
AddChild(pivot, true);
|
||||
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.
|
||||
@@ -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);
|
||||
HingeJoint3D swing = new()
|
||||
{
|
||||
Name = "SwingHinge",
|
||||
Transform = new Transform3D(new Basis(Vector3.Back, Vector3.Up, Vector3.Left), new Vector3(-6f, 2.35f, 1.5f)),
|
||||
NodeA = beam.GetPath(),
|
||||
NodeB = board.GetPath(),
|
||||
NodeA = $"../{beam.Name}",
|
||||
NodeB = $"../{board.Name}",
|
||||
};
|
||||
AddChild(swing);
|
||||
AddChild(swing, true);
|
||||
Sign("Hanging board", new Vector3(-6f, 2.9f, 1.5f));
|
||||
|
||||
// Crate pyramid (4 kg each), a heavy 25 kg box, barrels.
|
||||
|
||||
+41
-11
@@ -11,6 +11,10 @@ public partial class PropGrabber : Node
|
||||
private const float BreakTime = 0.25f;
|
||||
private const float BreakDistanceBoth = 0.4f;
|
||||
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)
|
||||
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 float[] _breakTimer = new float[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;
|
||||
|
||||
public override void _Ready()
|
||||
@@ -50,20 +56,24 @@ public partial class PropGrabber : Node
|
||||
public override void _PhysicsProcess(double 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(_right, 1, dt);
|
||||
}
|
||||
|
||||
private void UpdateHand(XRController3D controller, int hand)
|
||||
{
|
||||
bool grip = controller.GetFloat("grip") > GripThreshold;
|
||||
int other = 1 - hand;
|
||||
|
||||
if (grip && !_gripPrev[hand] && _held[hand] == null)
|
||||
{
|
||||
PhysicsBody3D prop = FindProp(controller, hand);
|
||||
RigidBody3D handBody = PhysicsHands.GetBody(controller);
|
||||
if (prop != null && handBody != null)
|
||||
private void Weld(XRController3D controller, int hand, RigidBody3D handBody, PhysicsBody3D prop)
|
||||
{
|
||||
_held[hand] = prop;
|
||||
_grabSeq[hand] = _grabCount++;
|
||||
@@ -73,9 +83,29 @@ public partial class PropGrabber : Node
|
||||
_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" : "")})");
|
||||
GD.Print($"PropGrabber: grabbed '{prop.Name}' ({what}, {(hand == 0 ? "left" : "right")}{(_held[1 - hand] == prop ? ", two-handed" : "")})");
|
||||
}
|
||||
|
||||
private void UpdateHand(XRController3D controller, int hand)
|
||||
{
|
||||
bool grip = controller.GetFloat("grip") > GripThreshold;
|
||||
int other = 1 - hand;
|
||||
|
||||
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);
|
||||
RigidBody3D handBody = PhysicsHands.GetBody(controller);
|
||||
if (prop != null && handBody != null)
|
||||
{
|
||||
_easing[hand] = SoftGrab.Begin(handBody, hand, prop); // welded once it's in the hand (_PhysicsProcess)
|
||||
_bufferUntil[hand] = 0.0;
|
||||
}
|
||||
}
|
||||
if (!grip)
|
||||
_easing[hand] = null;
|
||||
|
||||
if (!grip && _held[hand] != null)
|
||||
{
|
||||
|
||||
@@ -21,19 +21,25 @@ public sealed class PoseFilter
|
||||
|
||||
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)
|
||||
{
|
||||
_has = true;
|
||||
_last = raw;
|
||||
_tLast = now;
|
||||
_tLast = stamp;
|
||||
Velocity = AngularVelocity = Vector3.Zero;
|
||||
return raw;
|
||||
}
|
||||
if (raw != _last)
|
||||
{
|
||||
float interval = (float)(now - _tLast);
|
||||
float interval = (float)(stamp - _tLast);
|
||||
if (interval > 1e-4f)
|
||||
{
|
||||
if (raw.Origin.DistanceTo(_last.Origin) > JumpDistance)
|
||||
@@ -49,9 +55,9 @@ public sealed class PoseFilter
|
||||
}
|
||||
}
|
||||
_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
|
||||
? new Basis(AngularVelocity.Normalized(), AngularVelocity.Length() * age) : Basis.Identity;
|
||||
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
|
||||
// reported (repeat between tracking updates); moveVelocity = intended origin velocity (stick),
|
||||
// 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,
|
||||
Vector3 moveVelocity, float dt)
|
||||
Vector3 moveVelocity, float dt, double? trackingAge = null)
|
||||
{
|
||||
_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)
|
||||
origin.Origin += Ragdoll.OriginCorrection((origin * head).Origin, origin.Origin.Y + StickCrouch, dt);
|
||||
float floorY = origin.Origin.Y + StickCrouch;
|
||||
|
||||
+243
-26
@@ -83,6 +83,8 @@ public partial class RagdollSpike : Node3D
|
||||
AddChild(_ragdoll);
|
||||
|
||||
_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(("reaches forward, out and overhead", Rest, TickReach, 4.5f, FinishReach));
|
||||
_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(("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(("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(("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));
|
||||
@@ -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(("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(("standing jump", Rest, t => TickJump(0f), 4f, () => FinishJump(crouched: false)));
|
||||
_phases.Add(("jump from a crouch goes higher", Rest, t => TickJump(0.45f), 4.5f, () => FinishJump(crouched: true)));
|
||||
_phases.Add(("jump: a tap of A hops", Rest, t => TickJump(0f, 0.05f), 4f, () => FinishJump(0)));
|
||||
_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(("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(("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)));
|
||||
@@ -227,6 +235,58 @@ public partial class RagdollSpike : Node3D
|
||||
|
||||
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)
|
||||
{
|
||||
StaticBody3D body = new() { Position = pos, CollisionMask = mask };
|
||||
@@ -531,9 +591,59 @@ public partial class RagdollSpike : Node3D
|
||||
private void SetupObstacle(float height, float depth = 2f)
|
||||
{
|
||||
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));
|
||||
}
|
||||
|
||||
// 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()
|
||||
{
|
||||
Rest();
|
||||
@@ -562,7 +672,7 @@ public partial class RagdollSpike : Node3D
|
||||
private bool FinishMantle(bool both)
|
||||
{
|
||||
GD.Print($"RAGDOLL: mantle ({(both ? "two hands" : "one hand")}): pelvis rose up to {_maxPelvisRise:F3} m, feet y {_ragdoll.GroundY:F3}, origin y {_origin.Origin.Y:F3}, snaps {NewSnaps}");
|
||||
bool onTop = _ragdoll.GroundY > 0.85f && _origin.Origin.Y > 0.8f;
|
||||
bool onTop = _ragdoll.GroundY > _ledgeTop - 0.05f && _origin.Origin.Y > _ledgeTop - 0.1f;
|
||||
_lastRise = _maxPelvisRise;
|
||||
_maxPelvisRise = 0f;
|
||||
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;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 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
|
||||
|
||||
private float _jumpStand, _jumpApex, _jumpEyeErr, _standJumpApex;
|
||||
private float _jumpStand, _jumpApex, _jumpEyeErr, _tapApex;
|
||||
private bool _jumpPressed;
|
||||
|
||||
// Optionally squat (eyes `crouch` lower) first, then press jump at 2 s; the player keeps the pose.
|
||||
private void TickJump(float crouch)
|
||||
// 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, float hold)
|
||||
{
|
||||
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));
|
||||
@@ -751,11 +938,9 @@ public partial class RagdollSpike : Node3D
|
||||
_jumpEyeErr = 0f;
|
||||
_jumpPressed = false;
|
||||
}
|
||||
if (_t >= 2f && !_jumpPressed)
|
||||
{
|
||||
_ragdoll.JumpHeld = _t >= 2f && _t < 2f + hold;
|
||||
if (_t >= 2f)
|
||||
_jumpPressed = true;
|
||||
_ragdoll.Jump();
|
||||
}
|
||||
if (_jumpPressed)
|
||||
{
|
||||
_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;
|
||||
if (!crouched)
|
||||
if (kind == 0)
|
||||
{
|
||||
_standJumpApex = _jumpApex;
|
||||
return ok && _jumpApex > 0.3f && _jumpApex < 0.65f;
|
||||
_tapApex = _jumpApex;
|
||||
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
|
||||
@@ -949,20 +1137,33 @@ public partial class RagdollSpike : Node3D
|
||||
private void TickHumanMantle(bool both)
|
||||
{
|
||||
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
|
||||
float crouch = Mathf.SmoothStep(0f, 1f, Mathf.Clamp((t - 1f) / 0.8f, 0f, 1f));
|
||||
// Walk up to a waist-high ledge (stop before its far end); a chest-high one starts within reach.
|
||||
// (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 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));
|
||||
float handZ = ObstacleFront - 0.08f - _origin.Origin.Z;
|
||||
float handY = t < 1f ? 1.0f : Mathf.Lerp(0.95f, 0.55f, rise) - 0.05f * crouch;
|
||||
// Hands on the top just past the near edge (of the box itself when it's a movable one).
|
||||
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);
|
||||
_leftLocal = both ? new Transform3D(down, new Vector3(-0.2f, handY, handZ)) : new Transform3D(Basis.Identity, new Vector3(-0.2f, 1.0f, -0.3f));
|
||||
_rightLocal = new Transform3D(down, new Vector3(0.2f, handY, handZ));
|
||||
// Walking up, the hands are down at the sides; then they go up over the edge onto the top.
|
||||
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)
|
||||
_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));
|
||||
_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)
|
||||
_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)
|
||||
{
|
||||
@@ -1091,9 +1301,15 @@ public partial class RagdollSpike : Node3D
|
||||
}
|
||||
}
|
||||
|
||||
private float _climbFreeErr;
|
||||
private int _climbFreeN;
|
||||
|
||||
private bool FinishClimbWall()
|
||||
{
|
||||
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}");
|
||||
Grip(0, false);
|
||||
Grip(1, false);
|
||||
@@ -1128,7 +1344,8 @@ public partial class RagdollSpike : Node3D
|
||||
private void SetupPlayground()
|
||||
{
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
+183
-1
@@ -4,7 +4,8 @@ using Godot;
|
||||
|
||||
// Headless regression for the forked addon's Generic6DOFJoint3D → Box3D mapping
|
||||
// (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
|
||||
public partial class SixDofSpike : Node3D
|
||||
{
|
||||
@@ -18,6 +19,11 @@ public partial class SixDofSpike : Node3D
|
||||
private const float WeakMuscle = 1.5f;
|
||||
private const float MuscleKp = 20f;
|
||||
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 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(("all-locked config is rigid", WeldTick, 1.5f, WeldFinish));
|
||||
_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();
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
_joint = MakeJoint(_anchor, _box, new Vector3(0, 3, 0), j => ForAxes((f, p) => f(Generic6DofJoint3D.Flag.EnableAngularLimit, false), j));
|
||||
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;
|
||||
}
|
||||
|
||||
// ---- 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 void ToggleTick(float dt)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
uid://c4rg1kcdtsxni
|
||||
@@ -79,6 +79,7 @@ public partial class WorldPanel : Node3D
|
||||
_menu.ClickVolumeSlider.ValueChanged += v => _click.VolumeDb = Mathf.LinearToDb((float)v / 70f);
|
||||
_menu.StrengthChanged += v => PlayerRagdoll.Strength = v;
|
||||
_menu.SmoothTurnToggled += on => PlayerRig.SmoothTurn = on;
|
||||
_menu.LowLagTrackingToggled += on => PlayerRig.SteadyTracking = !on;
|
||||
_menu.SpectatorToggled += PlayerRig.SetSpectator;
|
||||
|
||||
SetPanelActive(false);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
uid://bt87gcewn6ihe
|
||||
@@ -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 MeshInstance3D { Mesh = new PlaneMesh { Size = new Vector2(80, 80) } });
|
||||
AddChild(floor);
|
||||
AddChild(new Playground());
|
||||
AddChild(new Crag());
|
||||
AddChild(GD.Load<PackedScene>("res://scenes/maps/playground.tscn").Instantiate()); // the saved maps
|
||||
AddChild(GD.Load<PackedScene>("res://scenes/maps/crag.tscn").Instantiate());
|
||||
_cam = new Camera3D { Current = true };
|
||||
AddChild(_cam);
|
||||
SetView();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
uid://cd3m3hyr27lpy
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
Reference in new issue
Block a user