Unreal 개발 본 캠프 37일차

HappyCircle·2026년 1월 20일

Unreal 개발

목록 보기
54/163

📘 TIL

FallingObstacle (Timer + Physics)

RotatingLaunchObstacle

SpawnVolume 랜덤 스폰 / 중복 방지 / 디버깅


🎯 오늘의 목표

도전 과제

  • Tick 없이 Timer 기반 동작
    • 낙하 시작 지연
    • 제거 타이머
    • 주기 랜덤 변경
  • 새로운 장애물 유형
    • 밟으면 회전 방향의 접선 방향으로 발사
  • SpawnVolume 고도화
    • DataTable 확률 스폰
    • 시작 시 N개 스폰 + 반복 스폰
    • 이동 / 회전 랜덤 속성 부여
    • “최소 1개 동작 보장”
    • 스폰 위치 중복 방지
  • 디버깅
    • FallingObstacle이 낮은 위치에서 떨어지는 것처럼 보이던 문제 해결

🪨 1) FallingObstacle — Timer 기반 Drop + 착지 후 굴림

설계 목표

  • Tick 완전 제거
  • DropDelay 이후 낙하 시작
  • 착지 후 제자리 회전 ❌ → 바닥에 굴러가기 ⭕
  • Impulse 대신 SetPhysicsLinearVelocity 사용

핵심 설계 포인트

  • BaseObstacle의 이동 / 회전 기능 비활성화
  • BeginPlay에서 낙하 위치를 위로 올려둠
  • Hit 이벤트로 “착지 시점” 판별
  • 착지 후 수평 속도 직접 세팅 → 예측 가능

FallingObstacle.cpp (핵심)

#include "FallingObstacle.h"
#include "Components/StaticMeshComponent.h"
#include "TimerManager.h"
#include "UObject/ConstructorHelpers.h"

AFallingObstacle::AFallingObstacle()
{
	PrimaryActorTick.bCanEverTick = false;

	// Base 이동/회전 사용 안 함
	MoveSettings.MoveSpeed = 0.f;
	MoveSettings.MaxRange = 0.f;
	RotationSettings.bEnableRotation = false;

	static ConstructorHelpers::FObjectFinder<UStaticMesh> MeshAsset(
		TEXT("/Game/Resources/Megascans/3D/Old_Wooden_Log_wcjheff/Medium/wcjheff_tier_2.wcjheff_tier_2")
	);

	if (MeshAsset.Succeeded() && StaticMeshComp)
	{
		StaticMeshComp->SetStaticMesh(MeshAsset.Object);

		StaticMeshComp->SetSimulatePhysics(false);
		StaticMeshComp->SetEnableGravity(false);

		StaticMeshComp->SetNotifyRigidBodyCollision(true);
		StaticMeshComp->BodyInstance.bUseCCD = true;

		StaticMeshComp->SetCollisionEnabled(ECollisionEnabled::QueryAndPhysics);
		StaticMeshComp->SetCollisionResponseToAllChannels(ECR_Block);
	}
}

BeginPlay – 낙하 준비

void AFallingObstacle::BeginPlay()
{
	Super::BeginPlay();

	InitialLocation = GetActorLocation();

	// 낙하 시작 위치 (위로 올림)
	SetActorLocation(InitialLocation + FVector(0, 0, SpawnHeightOffset));

	if (StaticMeshComp)
	{
		StaticMeshComp->OnComponentHit.AddDynamic(this, &AFallingObstacle::HandleMeshHit);
	}

	if (bAutoDrop)
	{
		GetWorldTimerManager().SetTimer(
			DropTimerHandle,
			this,
			&AFallingObstacle::StartDrop,
			DropDelay,
			false
		);
	}
}

Drop 시작

void AFallingObstacle::StartDrop()
{
	if (bDropping) return;
	bDropping = true;

	if (!StaticMeshComp) return;

	StaticMeshComp->SetSimulatePhysics(true);
	StaticMeshComp->SetEnableGravity(true);

	StaticMeshComp->SetLinearDamping(0.05f);
	StaticMeshComp->SetAngularDamping(0.1f);
}

착지 후 굴림 처리

void AFallingObstacle::HandleMeshHit(
	UPrimitiveComponent* HitComp,
	AActor* OtherActor,
	UPrimitiveComponent* OtherComp,
	FVector NormalImpulse,
	const FHitResult& Hit)
{
	if (!bDropping || bLanded) return;
	bLanded = true;

	if (!StaticMeshComp) return;

	FVector Dir = GetActorForwardVector();
	Dir.Z = 0.f;
	Dir = Dir.GetSafeNormal();

	const float YawJitter = FMath::RandRange(-60.f, 60.f);
	Dir = Dir.RotateAngleAxis(YawJitter, FVector::UpVector).GetSafeNormal();

	const FVector NewVel = Dir * RollSpeed;
	StaticMeshComp->SetPhysicsLinearVelocity(
		FVector(NewVel.X, NewVel.Y, 0.f),
		true
	);

	if (RollTorqueInRadians > 0.f)
	{
		const FVector TorqueAxis =
			FVector::CrossProduct(FVector::UpVector, Dir).GetSafeNormal();

		StaticMeshComp->AddTorqueInRadians(
			TorqueAxis * RollTorqueInRadians,
			NAME_None,
			true
		);
	}

	GetWorldTimerManager().SetTimer(
		DestroyTimerHandle,
		this,
		&AFallingObstacle::DestroySelf,
		DestroyDelayAfterLanding,
		false
	);
}

🌀 2) RotatingLaunchObstacle — 회전 접선 방향 발사

핵심 아이디어
회전 방향(+, -)에 따라 접선 방향이 달라짐

CrossProduct(Up, Radial)로 접선 벡터 계산

StepTrigger(Box)로 “밟기” 판별

Timer로 회전 속도 주기 랜덤 변경

회전 랜덤화

void ARotatingLaunchObstacle::RandomizeRotation()
{
	const float NewSpeed = FMath::RandRange(MinYawSpeed, MaxYawSpeed);
	const float Dir = bRandomizeDirection
		? (FMath::RandBool() ? 1.f : -1.f)
		: 1.f;

	RotationSettings.RotationSpeedPerSecond =
		FRotator(0.f, NewSpeed * Dir, 0.f);
}

밟았을 때 발사 로직

void ARotatingLaunchObstacle::OnObstacleBeginOverlap(AActor* OtherActor)
{
	ACharacter* Character = Cast<ACharacter>(OtherActor);
	if (!Character) return;

	const float ObZ = GetActorLocation().Z;
	const float ChZ = Character->GetActorLocation().Z;
	if (ChZ < ObZ + MinStepZOffset) return;

	const double Now = GetWorld()->GetTimeSeconds();
	if (Now - LastLaunchTimeSec < LaunchCooldown) return;
	LastLaunchTimeSec = Now;

	FVector Radial = Character->GetActorLocation() - GetActorLocation();
	Radial.Z = 0.f;
	Radial = Radial.GetSafeNormal();

	const float YawSign =
		(RotationSettings.RotationSpeedPerSecond.Yaw >= 0.f) ? 1.f : -1.f;

	const FVector Tangent =
		FVector::CrossProduct(GetActorUpVector(), Radial)
		.GetSafeNormal() * YawSign;

	const FVector LaunchVel =
		Tangent * LaunchStrength + FVector(0.f, 0.f, LaunchUp);

	Character->LaunchCharacter(LaunchVel, true, true);
}

📦 3) SpawnVolume — 확률 스폰 + 중복 방지 + 최소 1개 동작 보장

추가한 기능
BeginPlay 시 N개 즉시 스폰

Timer 기반 반복 스폰

DataTable 확률 기반 장애물 선택

Patrol 계열만 이동 / 회전 랜덤

Falling / Launch는 컨셉 고정

거리 기반 중복 방지

완전 정지 상태 방지 (최소 1개 동작 보장)

스폰 위치 중복 방지

#include "EngineUtils.h"

static bool IsFarFromExisting(
	UWorld* World,
	const FVector& Candidate,
	float MinDist)
{
	for (TActorIterator<ABaseObstacle> It(World); It; ++It)
	{
		const ABaseObstacle* Existing = *It;
		if (!IsValid(Existing)) continue;

		if (FVector::DistSquared(
			Existing->GetActorLocation(),
			Candidate) < MinDist * MinDist)
		{
			return false;
		}
	}
	return true;
}

🐞 4) 디버깅 — FallingObstacle 낙하 높이 문제

증상
FallingObstacle이 높은 곳에서 떨어지는 것처럼 보이지 않음

마치 바닥 근처에서 바로 낙하하는 느낌

원인
SpawnVolume에서 스폰 직후 아래 코드 실행:

Ob->ClampToBounds();

FallingObstacle은 BeginPlay에서
SpawnHeightOffset만큼 위로 올리는데,
Clamp가 Z까지 포함하면서 다시 박스 안으로 눌림.

해결 방법

if (!Ob->IsA<AFallingObstacle>())
{
	Ob->ClampToBounds();
}

🧠 Day 3 회고

설계가 깔끔해도

스폰

경계

타이머
가 섞이면 실행 순서 이슈가 반드시 발생함

FallingObstacle은 컨셉상 경계 Clamp 예외 대상

Physics Impulse는 결과가 튀기 쉬움
→ 속도 직접 세팅이 훨씬 안정적

SpawnVolume의 “랜덤 속성 부여”는 강력하지만
👉 최종 검증(유효 동작 보장) 없으면
멈춘 장애물이 생길 수 있음

profile
개발합시다!

0개의 댓글