tengri/Source/TengriPlatformer/Movement/TengriMovementComponent.cpp

594 lines
22 KiB
C++

// Request Games © All rights reserved
// Source/TengriPlatformer/Movement/TengriMovementComponent.cpp
#include "TengriMovementComponent.h"
#include "Components/CapsuleComponent.h"
#include "TengriPlatformer/Movement/Collision/TengriCollisionResolver.h"
DEFINE_LOG_CATEGORY_STATIC(LogTengriMovement, Log, All);
// ============================================================================
// CONSTANTS
// ============================================================================
namespace TengriMovement
{
// Maximum physics iterations per frame (prevents infinite loop)
constexpr int32 MaxPhysicsIterationsPerFrame = 5;
// Ground snapping thresholds
constexpr float FastFallThreshold = -200.0f; // cm/s, skip snap when falling fast
constexpr float JumpingThreshold = 10.0f; // cm/s, skip snap when moving up
}
// ============================================================================
// PENDING EVENTS (NEW)
// ============================================================================
/**
* Structure to accumulate events during physics loop.
* These are broadcasted AFTER all physics iterations complete.
*/
struct FPendingLandingEvent
{
bool bIsHeavy;
float LandingVelocityZ;
};
// ============================================================================
// CONSTRUCTOR
// ============================================================================
UTengriMovementComponent::UTengriMovementComponent()
{
PrimaryComponentTick.bCanEverTick = true;
Velocity = FVector::ZeroVector;
bIsGrounded = false;
}
// ============================================================================
// INITIALIZATION
// ============================================================================
void UTengriMovementComponent::BeginPlay()
{
Super::BeginPlay();
InitializeSystem();
}
void UTengriMovementComponent::InitializeSystem()
{
const AActor* Owner = GetOwner();
if (!Owner)
{
UE_LOG(LogTengriMovement, Error, TEXT("InitializeSystem failed: No owner"));
SetComponentTickEnabled(false);
return;
}
OwnerCapsule = Cast<UCapsuleComponent>(Owner->GetRootComponent());
if (!OwnerCapsule)
{
UE_LOG(LogTengriMovement, Error,
TEXT("InitializeSystem failed: Owner root component is not a CapsuleComponent"));
SetComponentTickEnabled(false);
return;
}
if (!MovementConfig)
{
UE_LOG(LogTengriMovement, Warning,
TEXT("InitializeSystem: No MovementConfig assigned"));
return;
}
// Cache thresholds
CachedThresholds = MovementConfig->GetThresholds();
// Initialize fixed timestep parameters from config
FixedTimeStep = 1.0f / MovementConfig->PhysicsTickRate;
MaxAccumulatorTime = MovementConfig->MaxAccumulatedTime;
TimeAccumulator = 0.0f;
// Initialize physics state from current actor transform
PhysicsLocation = Owner->GetActorLocation();
PhysicsRotation = Owner->GetActorRotation();
PhysicsVelocity = FVector::ZeroVector;
// Initialize render state to match physics (no interpolation on first frame)
RenderLocation = PhysicsLocation;
RenderRotation = PhysicsRotation;
PreviousPhysicsLocation = PhysicsLocation;
PreviousPhysicsRotation = PhysicsRotation;
UE_LOG(LogTengriMovement, Log,
TEXT("System initialized. WalkableZ: %.3f, PhysicsRate: %.0f Hz, FixedStep: %.4f s"),
CachedThresholds.WalkableZ,
MovementConfig->PhysicsTickRate,
FixedTimeStep);
}
// ============================================================================
// BLUEPRINT API
// ============================================================================
void UTengriMovementComponent::SetInputVector(const FVector NewInput)
{
InputVector = NewInput.GetClampedToMaxSize(1.0f);
InputVector.Z = 0.0f;
}
void UTengriMovementComponent::SetJumpInput(const bool bPressed)
{
if (bPressed && !bIsJumpHeld)
{
if (MovementConfig)
{
JumpBufferTimer = MovementConfig->JumpBufferTime;
}
}
bIsJumpHeld = bPressed;
}
// ============================================================================
// TICK
// ============================================================================
void UTengriMovementComponent::TickComponent(
const float DeltaTime,
const ELevelTick TickType,
FActorComponentTickFunction* ThisTickFunction)
{
Super::TickComponent(DeltaTime, TickType, ThisTickFunction);
if (!MovementConfig || !OwnerCapsule)
{
return;
}
// ════════════════════════════════════════════════════════════════════
// FIXED TIMESTEP ACCUMULATION
// ════════════════════════════════════════════════════════════════════
TimeAccumulator += DeltaTime;
// Clamp accumulator to prevent "spiral of death"
if (TimeAccumulator > MaxAccumulatorTime)
{
UE_LOG(LogTengriMovement, Warning,
TEXT("TimeAccumulator clamped: %.3f -> %.3f (frame took too long)"),
TimeAccumulator, MaxAccumulatorTime);
TimeAccumulator = MaxAccumulatorTime;
}
// ════════════════════════════════════════════════════════════════════
// PENDING EVENTS (Accumulated during physics)
// ════════════════════════════════════════════════════════════════════
TArray<FPendingLandingEvent> PendingLandings;
// ════════════════════════════════════════════════════════════════════
// DETERMINISTIC PHYSICS LOOP
// ════════════════════════════════════════════════════════════════════
int32 PhysicsIterations = 0;
while (TimeAccumulator >= FixedTimeStep)
{
// Save state for interpolation BEFORE physics step
SavePreviousPhysicsState();
// Run deterministic physics at fixed rate
// Pass reference to PendingLandings to accumulate events
TickPhysics(FixedTimeStep, PendingLandings);
// Consume fixed time from accumulator
TimeAccumulator -= FixedTimeStep;
PhysicsIterations++;
// Safety: prevent runaway loop
if (PhysicsIterations >= TengriMovement::MaxPhysicsIterationsPerFrame)
{
UE_LOG(LogTengriMovement, Warning,
TEXT("Max physics iterations reached (%d), discarding remaining time"),
TengriMovement::MaxPhysicsIterationsPerFrame);
TimeAccumulator = 0.0f;
break;
}
}
// ════════════════════════════════════════════════════════════════════
// BROADCAST ACCUMULATED EVENTS
// ════════════════════════════════════════════════════════════════════
// Only broadcast the LAST landing event if multiple occurred
// (Prevents spam if character lands multiple times in one frame)
if (PendingLandings.Num() > 0)
{
const FPendingLandingEvent& LastLanding = PendingLandings.Last();
if (OnLanded.IsBound())
{
OnLanded.Broadcast(LastLanding.bIsHeavy);
}
if (LastLanding.bIsHeavy)
{
UE_LOG(LogTengriMovement, Verbose,
TEXT("Heavy landing detected! Velocity: %.1f cm/s"),
LastLanding.LandingVelocityZ);
}
}
// ════════════════════════════════════════════════════════════════════
// INTERPOLATION & RENDERING
// ════════════════════════════════════════════════════════════════════
if (MovementConfig->bEnableInterpolation && PhysicsIterations > 0)
{
// Calculate interpolation factor [0..1]
const float Alpha = TimeAccumulator / FixedTimeStep;
InterpolateRenderState(Alpha);
}
else
{
// No interpolation: use physics state directly
RenderLocation = PhysicsLocation;
RenderRotation = PhysicsRotation;
}
// Apply visual transform to actor
ApplyRenderState();
// Sync public Velocity for Blueprint access
Velocity = PhysicsVelocity;
}
// ============================================================================
// PHYSICS TICK
// ============================================================================
void UTengriMovementComponent::TickPhysics(
const float FixedDeltaTime,
TArray<FPendingLandingEvent>& OutPendingLandings)
{
// ════════════════════════════════════════════════════════════════════
// Phase 0: State & Timer Updates
// ════════════════════════════════════════════════════════════════════
// 1. Manage Coyote Time (Can we jump while falling?)
if (bIsGrounded)
{
CoyoteTimer = MovementConfig->CoyoteTime;
bHasJumpedThisFrame = false;
}
else
{
CoyoteTimer -= FixedDeltaTime;
}
// 2. Manage Jump Buffer (Did we press jump recently?)
if (JumpBufferTimer > 0.0f)
{
JumpBufferTimer -= FixedDeltaTime;
}
// ════════════════════════════════════════════════════════════════════
// Phase 1: Jump Execution
// ════════════════════════════════════════════════════════════════════
// Check if we can jump:
// 1. Button was pressed recently (Buffer > 0)
// 2. We are on ground OR recently left ground (Coyote > 0)
// 3. We haven't already jumped this frame (double jump prevention)
if (JumpBufferTimer > 0.0f && CoyoteTimer > 0.0f && !bHasJumpedThisFrame)
{
// Apply Jump Velocity
PhysicsVelocity.Z = MovementConfig->JumpVelocity;
// Update State
bIsGrounded = false;
bHasJumpedThisFrame = true;
// Consume Timers
JumpBufferTimer = 0.0f;
CoyoteTimer = 0.0f;
}
// ════════════════════════════════════════════════════════════════════
// Phase 2: Variable Jump Height
// ════════════════════════════════════════════════════════════════════
// If moving up AND button released -> Cut velocity
if (PhysicsVelocity.Z > MovementConfig->MinJumpVelocity && !bIsJumpHeld)
{
PhysicsVelocity.Z = MovementConfig->MinJumpVelocity;
}
// ════════════════════════════════════════════════════════════════════
// Phase 3: Horizontal Movement (Air Control)
// ════════════════════════════════════════════════════════════════════
const float CurrentZ = PhysicsVelocity.Z;
FVector HorizontalVelocity(PhysicsVelocity.X, PhysicsVelocity.Y, 0.f);
const float CurrentFriction = bIsGrounded
? MovementConfig->Friction
: MovementConfig->AirFriction;
if (!InputVector.IsNearlyZero())
{
// Calculate directional air control modifier
// Reduce control when trying to reverse direction mid-air
const FVector VelocityDir = HorizontalVelocity.GetSafeNormal();
const float Dot = FVector::DotProduct(VelocityDir, InputVector);
float FinalAirControl = MovementConfig->AirControl;
// Reduce braking effectiveness in air (prevents instant direction changes)
if (Dot < 0.0f)
{
FinalAirControl *= 0.5f;
}
const float CurrentAccel = MovementConfig->Acceleration * FinalAirControl;
const FVector TargetVelocity = InputVector * MovementConfig->MaxSpeed;
if (CurrentAccel > KINDA_SMALL_NUMBER)
{
HorizontalVelocity = FMath::VInterpTo(
HorizontalVelocity,
TargetVelocity,
FixedDeltaTime,
CurrentAccel
);
}
}
else
{
if (CurrentFriction > KINDA_SMALL_NUMBER)
{
HorizontalVelocity = FMath::VInterpTo(
HorizontalVelocity,
FVector::ZeroVector,
FixedDeltaTime,
CurrentFriction
);
}
}
PhysicsVelocity = HorizontalVelocity;
PhysicsVelocity.Z = CurrentZ;
// ════════════════════════════════════════════════════════════════════
// Phase 4: Rotation
// ════════════════════════════════════════════════════════════════════
FRotator TargetRot = PhysicsRotation; // Default: maintain current rotation
bool bShouldUpdateRotation = false;
float CurrentRotSpeed = MovementConfig->RotationSpeed;
// SCENARIO A: STRAFE MODE (Aiming)
// Character rotates to match camera even when stationary.
// Used for combat/precise aiming scenarios.
if (bStrafing)
{
if (const APawn* PawnOwner = Cast<APawn>(GetOwner()))
{
if (const AController* C = PawnOwner->GetController())
{
TargetRot = C->GetControlRotation();
bShouldUpdateRotation = true;
// Optional: Faster rotation in combat for responsive feel
CurrentRotSpeed *= 2.0f;
}
}
}
// SCENARIO B: STANDARD MOVEMENT (Classic platformer)
// Character rotates toward velocity direction only when moving.
// Maintains forward orientation based on movement.
else
{
if (const float MinSpeedSq = FMath::Square(MovementConfig->MinSpeedForRotation); PhysicsVelocity.SizeSquared2D() > MinSpeedSq)
{
TargetRot = PhysicsVelocity.ToOrientationRotator();
bShouldUpdateRotation = true;
}
}
// APPLY ROTATION
if (bShouldUpdateRotation)
{
// Always prevent pitch/roll to keep character upright
TargetRot.Pitch = 0.0f;
TargetRot.Roll = 0.0f;
PhysicsRotation = FMath::RInterpConstantTo(
PhysicsRotation,
TargetRot,
FixedDeltaTime,
CurrentRotSpeed
);
}
// ════════════════════════════════════════════════════════════════════
// Phase 5: Gravity
// ════════════════════════════════════════════════════════════════════
if (!bIsGrounded)
{
float CurrentGravity = MovementConfig->Gravity;
// Apply extra gravity if falling (makes jump snappy)
if (PhysicsVelocity.Z < 0.0f)
{
CurrentGravity *= MovementConfig->FallingGravityScale;
}
PhysicsVelocity.Z -= CurrentGravity * FixedDeltaTime;
// Clamp to terminal velocity
if (PhysicsVelocity.Z < -MovementConfig->TerminalVelocity)
{
PhysicsVelocity.Z = -MovementConfig->TerminalVelocity;
}
}
// ════════════════════════════════════════════════════════════════════
// Phase 6: Collision Resolution
// ════════════════════════════════════════════════════════════════════
const FVector DesiredDelta = PhysicsVelocity * FixedDeltaTime;
const FTengriSweepResult MoveResult = UTengriCollisionResolver::ResolveMovement(
this,
PhysicsLocation,
DesiredDelta,
OwnerCapsule,
CachedThresholds,
MovementConfig->MaxStepHeight,
MovementConfig->MaxSlideIterations
);
PhysicsLocation = MoveResult.Location;
// ════════════════════════════════════════════════════════════════════
// Phase 7: Ground Snapping
// ════════════════════════════════════════════════════════════════════
bool bJustSnapped = false;
// Only snap if we are NOT jumping upwards
if (PhysicsVelocity.Z <= 0.0f)
{
FHitResult SnapHit;
bJustSnapped = PerformGroundSnapping(PhysicsLocation, SnapHit);
// Always project velocity onto slope when snapped (preserves momentum)
if (bJustSnapped)
{
PhysicsVelocity = UTengriCollisionResolver::ProjectVelocity(
PhysicsVelocity,
SnapHit.ImpactNormal
);
}
}
// ════════════════════════════════════════════════════════════════════
// Phase 8: State Update
// ════════════════════════════════════════════════════════════════════
const bool bWasGrounded = bIsGrounded;
// We are grounded if:
// 1. We snapped to ground, OR
// 2. We hit a walkable surface during movement
const bool bHitWalkable = MoveResult.bBlocked &&
CachedThresholds.IsWalkable(MoveResult.Hit.ImpactNormal.Z);
const bool bNowGrounded = bJustSnapped || bHitWalkable;
// ════════════════════════════════════════════════════════════════════
// Phase 9: Landing Detection
// ════════════════════════════════════════════════════════════════════
if (!bWasGrounded && bNowGrounded)
{
// Store landing velocity BEFORE we zero it
const float LandingVelocityZ = PhysicsVelocity.Z;
const bool bIsHeavy = LandingVelocityZ < MovementConfig->HeavyLandVelocityThreshold;
// Accumulate event instead of broadcasting immediately
FPendingLandingEvent LandingEvent;
LandingEvent.bIsHeavy = bIsHeavy;
LandingEvent.LandingVelocityZ = LandingVelocityZ;
OutPendingLandings.Add(LandingEvent);
}
// Update grounded state
bIsGrounded = bNowGrounded;
// Reset Z velocity if we landed or are on ground
if (bIsGrounded && PhysicsVelocity.Z < 0.f)
{
PhysicsVelocity.Z = 0.f;
}
}
// ============================================================================
// INTERPOLATION
// ============================================================================
void UTengriMovementComponent::SavePreviousPhysicsState()
{
PreviousPhysicsLocation = PhysicsLocation;
PreviousPhysicsRotation = PhysicsRotation;
}
void UTengriMovementComponent::InterpolateRenderState(float Alpha)
{
Alpha = FMath::Clamp(Alpha, 0.0f, 1.0f);
// Linear interpolation for location
RenderLocation = FMath::Lerp(PreviousPhysicsLocation, PhysicsLocation, Alpha);
// Lerp for rotation (FMath::Lerp handles FRotator correctly)
RenderRotation = FMath::Lerp(PreviousPhysicsRotation, PhysicsRotation, Alpha);
}
void UTengriMovementComponent::ApplyRenderState() const
{
if (AActor* Owner = GetOwner())
{
Owner->SetActorLocation(RenderLocation, false, nullptr, ETeleportType::None);
Owner->SetActorRotation(RenderRotation, ETeleportType::None);
}
}
// ============================================================================
// GROUND SNAPPING
// ============================================================================
bool UTengriMovementComponent::PerformGroundSnapping(
FVector& InOutLocation,
FHitResult& OutSnapHit) const
{
// Skip snap when clearly airborne intentionally
const bool bIsFallingFast = PhysicsVelocity.Z < TengriMovement::FastFallThreshold;
if (const bool bIsJumping = PhysicsVelocity.Z > TengriMovement::JumpingThreshold; !bIsGrounded && (bIsFallingFast || bIsJumping))
{
return false;
}
// Use physics location as start point (NOT render position!)
const FVector Start = InOutLocation;
const FVector End = Start - FVector(0.f, 0.f, MovementConfig->GroundSnapDistance);
const FTengriSweepResult Sweep = UTengriCollisionResolver::PerformSweep(
this,
Start,
End,
OwnerCapsule
);
if (Sweep.bBlocked && CachedThresholds.IsWalkable(Sweep.Hit.ImpactNormal.Z))
{
// Apply micro-offset to prevent floor penetration
InOutLocation = Sweep.Location + FVector(0.f, 0.f, MovementConfig->GroundSnapOffset);
OutSnapHit = Sweep.Hit;
return true;
}
return false;
}
void UTengriMovementComponent::ForceRotation(const FRotator& NewRotation)
{
// Update all internal states so physics adopts new rotation immediately
PhysicsRotation = NewRotation;
RenderRotation = NewRotation;
PreviousPhysicsRotation = NewRotation; // Reset interpolation
}