I managed to solve the problem. I’ll post the code so that anyone working on the same thing in C# in the future can have a reference.
There have been some changes from the initial question, but now it’s working as expected. It’s important to note that the player (or character) must inherit from this Stair class, and it should have 2 raycasts. The first one, AHeadRay, should be positioned at (X: 0 | Y: 0.55 | Z: -0.55), and the second (belowRay) one at (X: 0 | Y: 0 | Z: 0) relative to its parent.
The code is not fully finalized yet—I haven’t added camera smoothing, for example. However, I had some issues with it, so this might help someone in the future.
using Godot;
using System;
/// <summary>
/// This modifier adds the ability to chimb stairs to CharacterBody3D. But is necessary to inherit the Stair Climbing Control.
/// </summary>
public partial class StairClimbingControl: CharacterBody3D { //Stair Climbing Control
protected float lastFrameWasFloor = -Mathf.Inf;
private bool snappedToStairLastFrame = false;
private float maxStepHeight = 0.5f;
[Export] protected RayCast3D belowRay;
[Export] protected RayCast3D aHeadRay;
public void SnapDownToStairCheck() {
bool didSnap = false;
belowRay.ForceRaycastUpdate();
bool floorBelow = belowRay.IsColliding() && !IsSurfaceTooSteep(belowRay.GetCollisionNormal());
var wasOnFloorLastFrame = Engine.GetPhysicsFrames() == lastFrameWasFloor;
if (this.IsOnFloor() && Velocity.Y <= 0 && (wasOnFloorLastFrame || snappedToStairLastFrame) && floorBelow) {
KinematicCollision3D bodyTestResult = new KinematicCollision3D();
if (TestMove(this.GlobalTransform, new Vector3(0, -maxStepHeight, 0), bodyTestResult)) {
// Necessary smoothing camera
float translateY = bodyTestResult.GetTravel().Y;
this.Position += new Vector3(0, translateY, 0);
this.ApplyFloorSnap();
didSnap = true;
}
snappedToStairLastFrame = didSnap;
}
}
public bool SnapUpStairCheck(float delta) {
if (this.IsOnFloor() && !snappedToStairLastFrame)
return false;
Vector3 vectorOneXZ = new Vector3(1, 0, 1);
if (this.Velocity.Y > 0 || (this.Velocity * vectorOneXZ).Length() == 0)
return false;
Vector3 expectedMoveMotion = this.Velocity * vectorOneXZ * delta;
Transform3D stepPosWithClearence = this.GlobalTransform.Translated(expectedMoveMotion + new Vector3(0, maxStepHeight * 2, 0));
KinematicCollision3D downCheckResult = new KinematicCollision3D();
if (this.TestMove(stepPosWithClearence, new Vector3(0, -maxStepHeight * 2, 0), downCheckResult)
&& (downCheckResult.GetCollider().IsClass("StaticBody3D") || downCheckResult.GetCollider().IsClass("CsgShape3D"))) {
float stepHeight = ((stepPosWithClearence.Origin + downCheckResult.GetTravel()) - this.GlobalPosition).Y;
if (stepHeight > maxStepHeight || stepHeight <= .001f || (downCheckResult.GetPosition() - this.GlobalPosition).Y > maxStepHeight)
return false;
aHeadRay.GlobalPosition = (downCheckResult.GetPosition() + new Vector3(0, maxStepHeight, 0) + expectedMoveMotion.Normalized() * .1f);
aHeadRay.ForceRaycastUpdate();
if (aHeadRay.IsColliding() && !IsSurfaceTooSteep(aHeadRay.GetCollisionNormal())) {
// Necessary smoothing camera
this.GlobalPosition = stepPosWithClearence.Origin + downCheckResult.GetTravel();
this.ApplyFloorSnap();
snappedToStairLastFrame = true;
GD.Print("Snap UP ");
return true;
}
}
return false;
}
private bool IsSurfaceTooSteep(Vector3 _normal) {
return _normal.AngleTo(Vector3.Up) > this.FloorMaxAngle;
}
}
In the implementation within _PhysicsProcess, the movement should look like this:
public override void _PhysicsProcess(double delta) {
if (IsOnFloor())
lastFrameWasFloor = Engine.GetPhysicsFrames();
if (!SnapUpStairCheck((float)delta)) {
MovementAndAnimation((float)delta);
SnapDownToStairCheck();
}
In this function, ‘MovementAndAnimation((float)delta);’, it applies my player’s movements based on input and other necessary actions. At the end of this function, there’s a standard ‘MoveAndSlide()’.