594 lines
22 KiB
C++
594 lines
22 KiB
C++
// Request Games © All rights reserved
|
|
|
|
// Source/TengriPlatformer/Movement/TengriMovementComponent.cpp
|
|
|
|
#include "TengriMovementComponent.h"
|
|
#include "Components/CapsuleComponent.h"
|
|
#include "TengriPlatformer/Domains/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
|
|
} |