feat(camera): add dynamic camera system with multiple behavior modes

- Add TengriCameraConfig data asset for camera parameter configuration
  * Support FreeLook (3D) and SideScroller (2.5D) behavior modes
  * Configurable dead zone system for 2.5D gameplay
  * Smooth transition parameters between configs
  * Camera lag settings for cinematic feel
- Add TengriCameraComponent for runtime camera management
  * Post-physics tick to prevent visual jitter with interpolation
  * Dead zone logic: camera only moves when player exits bounds
  * Smooth config transitions using interpolation
  * Debug visualization for dead zone boundaries
- Use interpolated render position from movement component
- Support dynamic config switching via Blueprint (e.g. camera volumes)

Camera system designed for seamless transitions between 3D exploration
and 2.5D platforming sections with configurable dead zones.
main
Nikolay Petrov 2026-01-06 21:50:54 +05:00
parent 14d3696805
commit 74996e5e4b
12 changed files with 562 additions and 0 deletions

View File

@ -0,0 +1,23 @@
import {
ETengriCameraBehavior,
TengriCameraConfig,
} from '/Source/TengriPlatformer/Camera/Core/TengriCameraConfig.ts';
import { Vector } from '/Content/UE/Vector.ts';
import { Rotator } from '/Content/UE/Rotator.ts';
export class DA_CameraAiming extends TengriCameraConfig {
override BehaviorType = ETengriCameraBehavior.FreeLook;
override FixedRotation = new Rotator(0, -15, -90);
override TargetArmLength = 250;
override SocketOffset = new Vector(0, 100, 60);
override TransitionSpeed = 2;
override bEnableLag = false;
override DeadZoneExtent = new Vector(200, 0, 150);
override DeadZoneOffset = new Vector(0, 0, 50);
override bDrawDebugBox = false;
}

BIN
Content/Camera/DA_CameraAiming.uasset (Stored with Git LFS) Normal file

Binary file not shown.

View File

@ -0,0 +1,23 @@
import {
ETengriCameraBehavior,
TengriCameraConfig,
} from '/Source/TengriPlatformer/Camera/Core/TengriCameraConfig.ts';
import { Vector } from '/Content/UE/Vector.ts';
import { Rotator } from '/Content/UE/Rotator.ts';
export class DA_CameraDefault extends TengriCameraConfig {
override BehaviorType = ETengriCameraBehavior.FreeLook;
override FixedRotation = new Rotator(0, -15, -90);
override TargetArmLength = 400;
override SocketOffset = new Vector(0, 0, 0);
override TransitionSpeed = 2;
override bEnableLag = false;
override DeadZoneExtent = new Vector(200, 0, 150);
override DeadZoneOffset = new Vector(0, 0, 50);
override bDrawDebugBox = false;
}

BIN
Content/Camera/DA_CameraDefault.uasset (Stored with Git LFS) Normal file

Binary file not shown.

View File

@ -0,0 +1,24 @@
import {
ETengriCameraBehavior,
TengriCameraConfig,
} from '/Source/TengriPlatformer/Camera/Core/TengriCameraConfig.ts';
import { Vector } from '/Content/UE/Vector.ts';
import { Rotator } from '/Content/UE/Rotator.ts';
export class DA_CameraScroller extends TengriCameraConfig {
override BehaviorType = ETengriCameraBehavior.FreeLook;
override FixedRotation = new Rotator(0, 0, 0);
override TargetArmLength = 1200;
override SocketOffset = new Vector(0, 0, 100);
override TransitionSpeed = 1.5;
override bEnableLag = true;
override LagSpeed = 5;
override DeadZoneExtent = new Vector(100, 300, 100);
override DeadZoneOffset = new Vector(0, 0, 50);
override bDrawDebugBox = false;
}

BIN
Content/Camera/DA_CameraSideScroller.uasset (Stored with Git LFS) Normal file

Binary file not shown.

View File

@ -0,0 +1,5 @@
// Request Games © All rights reserved
// Source/TengriPlatformer/Camera/Core/TengriCameraConfig.cpp
#include "TengriCameraConfig.h"

View File

@ -0,0 +1,91 @@
// Request Games © All rights reserved
// Source/TengriPlatformer/Camera/Core/TengriCameraConfig.h
#pragma once
#include "CoreMinimal.h"
#include "Engine/DataAsset.h"
#include "TengriCameraConfig.generated.h"
UENUM(BlueprintType)
enum class ETengriCameraBehavior : uint8
{
FreeLook UMETA(DisplayName = "Free Look (3D)"),
SideScroller UMETA(DisplayName = "Side Scroller (2.5D with DeadZone)")
};
/**
* Camera configuration data asset.
* Defines camera behavior, positioning, smoothing, and dead zone parameters.
*/
UCLASS(BlueprintType)
class TENGRIPLATFORMER_API UTengriCameraConfig : public UDataAsset
{
GENERATED_BODY()
public:
// ════════════════════════════════════════════════════════════════════
// BEHAVIOR
// ════════════════════════════════════════════════════════════════════
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Behavior")
ETengriCameraBehavior BehaviorType = ETengriCameraBehavior::FreeLook;
// ════════════════════════════════════════════════════════════════════
// TRANSFORM
// ════════════════════════════════════════════════════════════════════
/** Fixed camera rotation (SideScroller mode only) */
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Transform")
FRotator FixedRotation = FRotator(-15.0f, -90.0f, 0.0f);
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Transform")
float TargetArmLength = 1000.0f;
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Transform")
FVector SocketOffset = FVector(0.f, 0.f, 100.f);
// ════════════════════════════════════════════════════════════════════
// SMOOTHING
// ════════════════════════════════════════════════════════════════════
/** Interpolation speed for config transitions (arm length, rotation) */
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Smoothing")
float TransitionSpeed = 2.0f;
// ════════════════════════════════════════════════════════════════════
// LAG
// ════════════════════════════════════════════════════════════════════
/** Enable camera lag for cinematic feel */
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Lag")
bool bEnableLag = true;
/** Lag interpolation speed (lower = slower/cinematic, higher = tighter) */
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Lag", meta = (EditCondition = "bEnableLag", ClampMin = "0.1", ClampMax = "100.0"))
float LagSpeed = 5.0f;
// ════════════════════════════════════════════════════════════════════
// DEAD ZONE
// ════════════════════════════════════════════════════════════════════
/**
* Dead zone extents (cm) - camera only moves when player exits this box.
* X = Forward/Backward, Y = Depth (usually 0 in 2.5D), Z = Up/Down
*/
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Dead Zone")
FVector DeadZoneExtent = FVector(200.0f, 0.0f, 150.0f);
/** Offset dead zone center relative to player (e.g. see more ahead than behind) */
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Dead Zone")
FVector DeadZoneOffset = FVector(0.0f, 0.0f, 50.0f);
// ════════════════════════════════════════════════════════════════════
// DEBUG
// ════════════════════════════════════════════════════════════════════
/** Draw debug box showing dead zone boundaries */
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Debug")
bool bDrawDebugBox = false;
};

View File

@ -0,0 +1,53 @@
// Source/TengriPlatformer/Camera/Core/TengriCameraConfig.ts
import { Rotator } from '/Content/UE/Rotator.ts';
import type { Float } from '/Content/UE/Float.ts';
import { Vector } from '/Content/UE/Vector.ts';
export enum ETengriCameraBehavior {
FreeLook = 'Free Look (3D)',
SideScroller = 'Side Scroller (2.5D with DeadZone)',
}
export class TengriCameraConfig {
// ════════════════════════════════════════════════════════════════════
// BEHAVIOR
// ════════════════════════════════════════════════════════════════════
public readonly BehaviorType: ETengriCameraBehavior =
ETengriCameraBehavior.FreeLook;
// ════════════════════════════════════════════════════════════════════
// TRANSFORM
// ════════════════════════════════════════════════════════════════════
public readonly FixedRotation: Rotator = new Rotator(-15.0, -90.0, 0.0);
public readonly TargetArmLength: Float = 1000.0;
public readonly SocketOffset: Vector = new Vector(0.0, 0.0, 100.0);
// ════════════════════════════════════════════════════════════════════
// SMOOTHING
// ════════════════════════════════════════════════════════════════════
public readonly TransitionSpeed: Float = 2.0;
// ════════════════════════════════════════════════════════════════════
// LAG
// ════════════════════════════════════════════════════════════════════
public readonly bEnableLag: boolean = true;
public readonly LagSpeed: Float = 5.0;
// ════════════════════════════════════════════════════════════════════
// DEAD ZONE
// ════════════════════════════════════════════════════════════════════
public readonly DeadZoneExtent: Vector = new Vector(200.0, 0.0, 150.0);
public readonly DeadZoneOffset: Vector = new Vector(0.0, 0.0, 50.0);
// ════════════════════════════════════════════════════════════════════
// DEBUG
// ════════════════════════════════════════════════════════════════════
public readonly bDrawDebugBox: boolean = false;
}

View File

@ -0,0 +1,215 @@
// Request Games © All rights reserved
// Source/TengriPlatformer/Camera/TengriCameraComponent.cpp
#include "TengriCameraComponent.h"
#include "GameFramework/SpringArmComponent.h"
#include "DrawDebugHelpers.h"
#include "TengriPlatformer/Movement/TengriMovementComponent.h"
UTengriCameraComponent::UTengriCameraComponent()
{
PrimaryComponentTick.bCanEverTick = true;
// Update after physics to prevent jitter with interpolated character movement
PrimaryComponentTick.TickGroup = TG_PostPhysics;
}
void UTengriCameraComponent::BeginPlay()
{
Super::BeginPlay();
// Initialize focus to owner location to prevent initial snap
if (const AActor* Owner = GetOwner())
{
CurrentFocusLocation = Owner->GetActorLocation();
}
// Apply default config if assigned
if (DefaultConfig)
{
SetCameraConfig(DefaultConfig.Get());
}
}
void UTengriCameraComponent::InitializeCamera(USpringArmComponent* InSpringArm, UCameraComponent* InCamera)
{
SpringArm = InSpringArm;
Camera = InCamera;
if (SpringArm)
{
// Take full control of SpringArm transform (no automatic parenting)
SpringArm->SetUsingAbsoluteLocation(true);
SpringArm->SetUsingAbsoluteRotation(true);
// Disable built-in lag systems (we implement custom lag)
SpringArm->bUsePawnControlRotation = false;
SpringArm->bEnableCameraLag = false;
SpringArm->bEnableCameraRotationLag = false;
}
}
void UTengriCameraComponent::SetCameraConfig(UTengriCameraConfig* NewConfig)
{
CurrentConfig = NewConfig ? NewConfig : DefaultConfig.Get();
}
void UTengriCameraComponent::TickComponent(float DeltaTime, ELevelTick TickType, FActorComponentTickFunction* ThisTickFunction)
{
Super::TickComponent(DeltaTime, TickType, ThisTickFunction);
if (!SpringArm || !CurrentConfig || !GetOwner()) return;
// ════════════════════════════════════════════════════════════════════
// Phase 1: Determine Target Position
// ════════════════════════════════════════════════════════════════════
FVector TargetPos;
// Use interpolated render position to avoid jitter
if (auto* MoveComp = GetOwner()->FindComponentByClass<UTengriMovementComponent>())
{
TargetPos = MoveComp->GetRenderLocation();
}
else
{
TargetPos = GetOwner()->GetActorLocation(); // Fallback (will jitter)
}
// Apply dead zone logic in SideScroller mode
if (CurrentConfig->BehaviorType == ETengriCameraBehavior::SideScroller)
{
TargetPos = CalculateDeadZoneTarget(TargetPos);
}
// ════════════════════════════════════════════════════════════════════
// Phase 2: Smooth Camera Movement (Lag)
// ════════════════════════════════════════════════════════════════════
if (CurrentConfig->bEnableLag)
{
CurrentFocusLocation = FMath::VInterpTo(
CurrentFocusLocation,
TargetPos,
DeltaTime,
CurrentConfig->LagSpeed
);
}
else
{
CurrentFocusLocation = TargetPos;
}
// ════════════════════════════════════════════════════════════════════
// Phase 3: Apply Position to SpringArm
// ════════════════════════════════════════════════════════════════════
SpringArm->SetWorldLocation(CurrentFocusLocation);
// ════════════════════════════════════════════════════════════════════
// Phase 4: Interpolate SpringArm Parameters
// ════════════════════════════════════════════════════════════════════
const float TransSpeed = CurrentConfig->TransitionSpeed;
SpringArm->TargetArmLength = FMath::FInterpTo(
SpringArm->TargetArmLength,
CurrentConfig->TargetArmLength,
DeltaTime,
TransSpeed
);
SpringArm->SocketOffset = FMath::VInterpTo(
SpringArm->SocketOffset,
CurrentConfig->SocketOffset,
DeltaTime,
TransSpeed
);
// ════════════════════════════════════════════════════════════════════
// Phase 5: Handle Rotation
// ════════════════════════════════════════════════════════════════════
if (CurrentConfig->BehaviorType == ETengriCameraBehavior::FreeLook)
{
// Free look: use controller rotation (mouse/gamepad)
if (const APawn* PawnOwner = Cast<APawn>(GetOwner()))
{
SpringArm->SetWorldRotation(PawnOwner->GetControlRotation());
}
}
else
{
// Side scroller: interpolate to fixed rotation
const FRotator CurrentRot = SpringArm->GetComponentRotation();
const FRotator NewRot = FMath::RInterpTo(
CurrentRot,
CurrentConfig->FixedRotation,
DeltaTime,
TransSpeed
);
SpringArm->SetWorldRotation(NewRot);
// Update control rotation to match camera (for correct input interpretation)
if (APawn* PawnOwner = Cast<APawn>(GetOwner()))
{
if (AController* C = PawnOwner->GetController())
{
C->SetControlRotation(NewRot);
}
}
}
// ════════════════════════════════════════════════════════════════════
// Phase 6: Debug Visualization
// ════════════════════════════════════════════════════════════════════
#if WITH_EDITOR
if (CurrentConfig->bDrawDebugBox)
{
DrawDebugDeadZone(CurrentFocusLocation, CurrentConfig->DeadZoneExtent);
}
#endif
}
FVector UTengriCameraComponent::CalculateDeadZoneTarget(const FVector& PlayerLocation) const
{
// Camera doesn't move while player stays inside dead zone box.
// When player exits bounds, camera shifts just enough to keep player at box edge.
FVector DesiredFocus = CurrentFocusLocation;
const FVector Diff = PlayerLocation - DesiredFocus;
const FVector Extent = CurrentConfig->DeadZoneExtent;
// X-axis (Forward/Backward or Left/Right)
if (FMath::Abs(Diff.X) > Extent.X)
{
DesiredFocus.X = PlayerLocation.X - (FMath::Sign(Diff.X) * Extent.X);
}
// Z-axis (Up/Down)
if (FMath::Abs(Diff.Z) > Extent.Z)
{
DesiredFocus.Z = PlayerLocation.Z - (FMath::Sign(Diff.Z) * Extent.Z);
}
// Y-axis (Depth) - usually locked to player in 2.5D to prevent going off-screen
DesiredFocus.Y = PlayerLocation.Y;
return DesiredFocus;
}
void UTengriCameraComponent::DrawDebugDeadZone(const FVector& Center, const FVector& Extent) const
{
DrawDebugBox(
GetWorld(),
Center,
Extent,
FColor::Green,
false, // bPersistentLines
-1.0f, // LifeTime
0, // DepthPriority
2.0f // Thickness
);
}

View File

@ -0,0 +1,101 @@
// Request Games © All rights reserved
// Source/TengriPlatformer/Camera/TengriCameraComponent.h
#pragma once
#include "CoreMinimal.h"
#include "Components/ActorComponent.h"
#include "Core/TengriCameraConfig.h"
#include "TengriCameraComponent.generated.h"
class USpringArmComponent;
class UCameraComponent;
/**
* Dynamic camera system with multiple behavior modes and smooth transitions.
* Supports free look (3D) and side-scroller (2.5D) with configurable dead zones.
* Updates after physics to prevent visual jitter during interpolation.
*/
UCLASS(ClassGroup=(Custom), meta=(BlueprintSpawnableComponent))
class TENGRIPLATFORMER_API UTengriCameraComponent : public UActorComponent
{
GENERATED_BODY()
public:
UTengriCameraComponent();
virtual void TickComponent(float DeltaTime, ELevelTick TickType,
FActorComponentTickFunction* ThisTickFunction) override;
/**
* Initialize camera system with spring arm and camera references.
* Must be called before TickComponent runs.
* @param InSpringArm - Spring arm component for camera positioning
* @param InCamera - Camera component for rendering
*/
void InitializeCamera(USpringArmComponent* InSpringArm, UCameraComponent* InCamera);
/**
* Switch to a new camera configuration (e.g. from camera volume trigger).
* Smoothly transitions between configs using TransitionSpeed.
* @param NewConfig - New camera configuration to apply
*/
UFUNCTION(BlueprintCallable, Category = "Tengri Camera")
void SetCameraConfig(UTengriCameraConfig* NewConfig);
protected:
virtual void BeginPlay() override;
public:
// ════════════════════════════════════════════════════════════════════
// CONFIGURATION
// ════════════════════════════════════════════════════════════════════
/** Default camera configuration applied at BeginPlay */
UPROPERTY(EditAnywhere, Category = "Config")
TObjectPtr<UTengriCameraConfig> DefaultConfig;
/** Currently active camera configuration */
UPROPERTY(Transient, BlueprintReadOnly, Category = "Config")
TObjectPtr<UTengriCameraConfig> CurrentConfig;
private:
// ════════════════════════════════════════════════════════════════════
// COMPONENT REFERENCES
// ════════════════════════════════════════════════════════════════════
UPROPERTY()
TObjectPtr<USpringArmComponent> SpringArm;
UPROPERTY()
TObjectPtr<UCameraComponent> Camera;
// ════════════════════════════════════════════════════════════════════
// STATE
// ════════════════════════════════════════════════════════════════════
/** Current smoothed camera focus point in world space */
FVector CurrentFocusLocation = FVector::ZeroVector;
// ════════════════════════════════════════════════════════════════════
// INTERNAL HELPERS
// ════════════════════════════════════════════════════════════════════
/**
* Calculate target focus position with dead zone clamping.
* Returns input position if player is inside dead zone,
* otherwise returns edge of dead zone closest to player.
* @param PlayerLocation - Current player position
* @return Target camera focus position
*/
FVector CalculateDeadZoneTarget(const FVector& PlayerLocation) const;
/**
* Draw debug visualization of dead zone box.
* Only enabled in editor builds when bDrawDebugBox is true.
* @param Center - Dead zone center point
* @param Extent - Dead zone half-extents
*/
void DrawDebugDeadZone(const FVector& Center, const FVector& Extent) const;
};

View File

@ -0,0 +1,18 @@
// Source/TengriPlatformer/Camera/TengriCameraComponent.ts
import { ActorComponent } from '/Content/UE/ActorComponent.ts';
import { TengriCameraConfig } from '/Source/TengriPlatformer/Camera/Core/TengriCameraConfig.ts';
export class TengriCameraComponent extends ActorComponent {
constructor() {
super();
this.CurrentConfig = new TengriCameraConfig();
}
public SetCameraConfig(NewConfig: TengriCameraConfig): void {
console.log(NewConfig);
}
public CurrentConfig: TengriCameraConfig;
}