// 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(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 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& 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(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 }