402 lines
13 KiB
C++
402 lines
13 KiB
C++
// Source/TengriPlatformer/Movement/Collision/TengriCollisionResolver.cpp
|
|
|
|
#include "TengriCollisionResolver.h"
|
|
#include "Components/CapsuleComponent.h"
|
|
#include "Engine/World.h"
|
|
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|
// CONSTANTS
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|
|
|
namespace TengriPhysics
|
|
{
|
|
// Contact offset to prevent surface penetration
|
|
constexpr float ContactOffset = 0.005f;
|
|
|
|
// Minimum movement distance (below this, stop iterating)
|
|
constexpr float MinMoveDist = 0.001f;
|
|
|
|
// Step-up: reject surfaces sloping toward us more than this
|
|
constexpr float StepUpMaxSlopeZ = 0.2f;
|
|
|
|
// Step-up: reject overhangs/ceilings
|
|
constexpr float StepUpMinNormalZ = -0.01f;
|
|
|
|
// Minimum upward velocity to count as "jumping"
|
|
constexpr float MinUpwardVelocity = 0.01f;
|
|
|
|
// Minimum Normal.Z to be considered ground in step-down
|
|
constexpr float MinGroundNormalZ = 0.1f;
|
|
|
|
// Step height tolerance for final validation
|
|
constexpr float StepHeightTolerance = 1.0f;
|
|
|
|
// Overlap check uses slightly smaller capsule
|
|
constexpr float OverlapCheckScale = 0.95f;
|
|
|
|
// Down trace extends slightly beyond step height
|
|
constexpr float DownTraceMultiplier = 1.2f;
|
|
}
|
|
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|
// PRIMITIVES
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|
|
|
FTengriSweepResult UTengriCollisionResolver::PerformSweep(
|
|
const UObject* WorldContext,
|
|
const FVector& Start,
|
|
const FVector& End,
|
|
const UCapsuleComponent* Capsule,
|
|
bool bShowDebug)
|
|
{
|
|
FTengriSweepResult Result;
|
|
Result.Location = End;
|
|
Result.bBlocked = false;
|
|
|
|
if (!WorldContext || !Capsule)
|
|
{
|
|
return Result;
|
|
}
|
|
|
|
UWorld* World = WorldContext->GetWorld();
|
|
if (!World)
|
|
{
|
|
return Result;
|
|
}
|
|
|
|
FCollisionQueryParams Params;
|
|
Params.AddIgnoredActor(Capsule->GetOwner());
|
|
Params.bTraceComplex = false;
|
|
|
|
const bool bHit = World->SweepSingleByChannel(
|
|
Result.Hit,
|
|
Start,
|
|
End,
|
|
Capsule->GetComponentQuat(),
|
|
ECC_Visibility,
|
|
FCollisionShape::MakeCapsule(
|
|
Capsule->GetScaledCapsuleRadius(),
|
|
Capsule->GetScaledCapsuleHalfHeight()
|
|
),
|
|
Params
|
|
);
|
|
|
|
if (bHit)
|
|
{
|
|
Result.bBlocked = true;
|
|
Result.Location = Result.Hit.Location;
|
|
}
|
|
|
|
return Result;
|
|
}
|
|
|
|
FVector UTengriCollisionResolver::ClipVelocity(const FVector& Velocity, const FVector& Normal)
|
|
{
|
|
const float Backoff = FVector::DotProduct(Velocity, Normal);
|
|
if (Backoff > 0.f)
|
|
{
|
|
return Velocity;
|
|
}
|
|
return Velocity - (Normal * Backoff);
|
|
}
|
|
|
|
FVector UTengriCollisionResolver::ProjectVelocity(const FVector& Velocity, const FVector& Normal)
|
|
{
|
|
const FVector Dir = ClipVelocity(Velocity, Normal).GetSafeNormal();
|
|
return Dir * Velocity.Size();
|
|
}
|
|
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|
// STEP UP
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|
|
|
bool UTengriCollisionResolver::StepUp(
|
|
const UObject* WorldContext,
|
|
const FVector& StartLocation,
|
|
const FVector& DesiredDelta,
|
|
const FHitResult& ImpactHit,
|
|
const UCapsuleComponent* Capsule,
|
|
float MaxStepHeight,
|
|
FVector& OutLocation)
|
|
{
|
|
// Reject sloped surfaces and overhangs
|
|
if (ImpactHit.ImpactNormal.Z > TengriPhysics::StepUpMaxSlopeZ ||
|
|
ImpactHit.ImpactNormal.Z < TengriPhysics::StepUpMinNormalZ)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
// Reject if falling
|
|
if (DesiredDelta.Z < TengriPhysics::StepUpMinNormalZ)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
// === Phase A: Trace Up ===
|
|
const FVector StepUpEnd = StartLocation + FVector(0.f, 0.f, MaxStepHeight);
|
|
const FTengriSweepResult UpSweep = PerformSweep(WorldContext, StartLocation, StepUpEnd, Capsule, false);
|
|
|
|
// Reject if not enough headroom (hit ceiling before half step height)
|
|
if (UpSweep.bBlocked && UpSweep.Location.Z < (StartLocation.Z + MaxStepHeight * 0.5f))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
// === Phase B: Trace Forward ===
|
|
// When pressing against a wall, DesiredDelta approaches zero.
|
|
// We must check forward at least CapsuleRadius to avoid phasing through geometry.
|
|
FVector ForwardDir = DesiredDelta.GetSafeNormal2D();
|
|
if (ForwardDir.IsNearlyZero())
|
|
{
|
|
ForwardDir = -ImpactHit.ImpactNormal;
|
|
}
|
|
|
|
const float MinCheckDist = Capsule ? Capsule->GetScaledCapsuleRadius() : 30.0f;
|
|
const float CheckDist = FMath::Max(DesiredDelta.Size2D(), MinCheckDist);
|
|
const FVector ForwardEnd = UpSweep.Location + (ForwardDir * CheckDist);
|
|
|
|
const FTengriSweepResult ForwardSweep = PerformSweep(WorldContext, UpSweep.Location, ForwardEnd, Capsule, false);
|
|
|
|
// Reject if obstacle continues upward (wall, next stair step)
|
|
if (ForwardSweep.bBlocked)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
// === Phase C: Trace Down ===
|
|
const FVector DownStart = ForwardSweep.Location;
|
|
const FVector DownEnd = DownStart - FVector(0.f, 0.f, MaxStepHeight * TengriPhysics::DownTraceMultiplier);
|
|
const FTengriSweepResult DownSweep = PerformSweep(WorldContext, DownStart, DownEnd, Capsule, false);
|
|
|
|
if (!DownSweep.bBlocked || DownSweep.Hit.ImpactNormal.Z < TengriPhysics::MinGroundNormalZ)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
// Validate actual height difference
|
|
const float RealHeightDiff = DownSweep.Location.Z - StartLocation.Z;
|
|
if (RealHeightDiff > (MaxStepHeight + TengriPhysics::StepHeightTolerance))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
// === Phase D: Overlap Check ===
|
|
// Verify we can stand at the new location without intersecting geometry
|
|
FCollisionQueryParams OverlapParams;
|
|
OverlapParams.AddIgnoredActor(Capsule->GetOwner());
|
|
|
|
const bool bOverlap = WorldContext->GetWorld()->OverlapBlockingTestByChannel(
|
|
DownSweep.Location,
|
|
Capsule->GetComponentQuat(),
|
|
ECC_Visibility,
|
|
FCollisionShape::MakeCapsule(
|
|
Capsule->GetScaledCapsuleRadius() * TengriPhysics::OverlapCheckScale,
|
|
Capsule->GetScaledCapsuleHalfHeight() * TengriPhysics::OverlapCheckScale
|
|
),
|
|
OverlapParams
|
|
);
|
|
|
|
if (bOverlap)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
OutLocation = DownSweep.Location;
|
|
return true;
|
|
}
|
|
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|
// GROUND SNAPPING
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|
|
|
bool UTengriCollisionResolver::SnapToGround(
|
|
const UObject* WorldContext,
|
|
const UCapsuleComponent* Capsule,
|
|
const float SnapDistance,
|
|
const FSurfaceThresholds& Thresholds,
|
|
FVector& OutLocation,
|
|
FHitResult& OutHit)
|
|
{
|
|
if (!WorldContext || !Capsule)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
const FVector Start = Capsule->GetComponentLocation();
|
|
const FVector End = Start - FVector(0.f, 0.f, SnapDistance);
|
|
|
|
if (const FTengriSweepResult Sweep = PerformSweep(WorldContext, Start, End, Capsule, false); Sweep.bBlocked && Thresholds.IsWalkable(Sweep.Hit.ImpactNormal.Z))
|
|
{
|
|
OutLocation = Sweep.Location;
|
|
OutHit = Sweep.Hit;
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|
// SLIDE HELPERS
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|
|
|
namespace
|
|
{
|
|
/** Calculate horizontal slide along overhang/ceiling when walking (not jumping) */
|
|
FVector CalculateOverhangSlide(const FVector& RemainingDelta, const FVector& ImpactNormal)
|
|
{
|
|
// If jumping into ceiling, use normal clip
|
|
if (RemainingDelta.Z > TengriPhysics::MinUpwardVelocity)
|
|
{
|
|
return UTengriCollisionResolver::ClipVelocity(RemainingDelta, ImpactNormal);
|
|
}
|
|
|
|
// Walking under sloped ceiling - slide horizontally only
|
|
const FVector UpVector(0.f, 0.f, 1.f);
|
|
FVector HorizontalTangent = FVector::CrossProduct(ImpactNormal, UpVector).GetSafeNormal();
|
|
|
|
if (HorizontalTangent.IsNearlyZero())
|
|
{
|
|
return FVector::ZeroVector;
|
|
}
|
|
|
|
// Align tangent with desired movement direction
|
|
if (FVector::DotProduct(HorizontalTangent, RemainingDelta) < 0.f)
|
|
{
|
|
HorizontalTangent *= -1.f;
|
|
}
|
|
|
|
return HorizontalTangent * FVector::DotProduct(RemainingDelta, HorizontalTangent);
|
|
}
|
|
|
|
/** Calculate slide along wall, preventing upward velocity from steep surfaces */
|
|
FVector CalculateWallSlide(const FVector& RemainingDelta, const FVector& ImpactNormal)
|
|
{
|
|
FVector ClipDelta = UTengriCollisionResolver::ClipVelocity(RemainingDelta, ImpactNormal);
|
|
|
|
// If wall would push us up, force horizontal movement only
|
|
if (ClipDelta.Z > 0.f)
|
|
{
|
|
FVector HorizontalTangent = FVector::CrossProduct(ImpactNormal, FVector(0.f, 0.f, 1.f)).GetSafeNormal();
|
|
|
|
if (!HorizontalTangent.IsNearlyZero())
|
|
{
|
|
if (FVector::DotProduct(HorizontalTangent, RemainingDelta) < 0.f)
|
|
{
|
|
HorizontalTangent *= -1.f;
|
|
}
|
|
return HorizontalTangent * FVector::DotProduct(RemainingDelta, HorizontalTangent);
|
|
}
|
|
|
|
ClipDelta.Z = 0.f;
|
|
}
|
|
|
|
return ClipDelta;
|
|
}
|
|
|
|
/** Apply crease logic when stuck between two surfaces */
|
|
FVector ApplyCreaseLogic(
|
|
const FVector& NewDelta,
|
|
const FVector& RemainingDelta,
|
|
const FVector& PrevNormal,
|
|
const FVector& CurrentNormal)
|
|
{
|
|
if (const FVector Crease = FVector::CrossProduct(PrevNormal, CurrentNormal); Crease.SizeSquared() > TengriPhysics::ContactOffset)
|
|
{
|
|
const float Dot = FVector::DotProduct(RemainingDelta, Crease);
|
|
return Crease * Dot;
|
|
}
|
|
|
|
return NewDelta;
|
|
}
|
|
}
|
|
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|
// MAIN RESOLUTION
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|
|
|
FTengriSweepResult UTengriCollisionResolver::ResolveMovement(
|
|
const UObject* WorldContext,
|
|
const FVector& StartLocation,
|
|
const FVector& DesiredDelta,
|
|
const UCapsuleComponent* Capsule,
|
|
const FSurfaceThresholds& Thresholds,
|
|
const float MaxStepHeight,
|
|
const int32 MaxIterations,
|
|
const bool bShowDebug)
|
|
{
|
|
FTengriSweepResult FinalResult;
|
|
FinalResult.Location = StartLocation;
|
|
|
|
FVector CurrentLocation = StartLocation;
|
|
FVector RemainingDelta = DesiredDelta;
|
|
FVector PrevNormal = FVector::ZeroVector;
|
|
|
|
for (int32 Iteration = 0; Iteration < MaxIterations; ++Iteration)
|
|
{
|
|
const FVector Target = CurrentLocation + RemainingDelta;
|
|
const FTengriSweepResult Sweep = PerformSweep(WorldContext, CurrentLocation, Target, Capsule, bShowDebug);
|
|
|
|
FinalResult.CollisionCount++;
|
|
|
|
// No collision - movement complete
|
|
if (!Sweep.bBlocked)
|
|
{
|
|
CurrentLocation = Sweep.Location;
|
|
break;
|
|
}
|
|
|
|
FinalResult.bBlocked = true;
|
|
FinalResult.Hit = Sweep.Hit;
|
|
|
|
// Classify surface
|
|
const float NormalZ = Sweep.Hit.ImpactNormal.Z;
|
|
const bool bIsFloor = Thresholds.IsWalkable(NormalZ);
|
|
const bool bIsOverhang = Thresholds.IsOverhang(NormalZ);
|
|
|
|
// Try step-up for walls
|
|
if (!bIsFloor && !bIsOverhang)
|
|
{
|
|
if (FVector StepDest; StepUp(WorldContext, CurrentLocation, RemainingDelta, Sweep.Hit, Capsule, MaxStepHeight, StepDest))
|
|
{
|
|
CurrentLocation = StepDest;
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Apply contact offset to prevent penetration
|
|
CurrentLocation = Sweep.Location + (Sweep.Hit.ImpactNormal * TengriPhysics::ContactOffset);
|
|
|
|
// Calculate slide direction based on surface type
|
|
FVector NewDelta;
|
|
if (bIsFloor)
|
|
{
|
|
NewDelta = ProjectVelocity(RemainingDelta, Sweep.Hit.ImpactNormal);
|
|
}
|
|
else if (bIsOverhang)
|
|
{
|
|
NewDelta = CalculateOverhangSlide(RemainingDelta, Sweep.Hit.ImpactNormal);
|
|
}
|
|
else
|
|
{
|
|
NewDelta = CalculateWallSlide(RemainingDelta, Sweep.Hit.ImpactNormal);
|
|
}
|
|
|
|
// Handle corner/crease situations
|
|
if (Iteration > 0)
|
|
{
|
|
NewDelta = ApplyCreaseLogic(NewDelta, RemainingDelta, PrevNormal, Sweep.Hit.ImpactNormal);
|
|
}
|
|
|
|
PrevNormal = Sweep.Hit.ImpactNormal;
|
|
RemainingDelta = NewDelta;
|
|
|
|
// Stop if remaining movement is negligible
|
|
if (RemainingDelta.SizeSquared() < FMath::Square(TengriPhysics::MinMoveDist))
|
|
{
|
|
break;
|
|
}
|
|
}
|
|
|
|
FinalResult.Location = CurrentLocation;
|
|
return FinalResult;
|
|
} |