Unreal 개발 본 캠프 59일차

HappyCircle·2026년 2월 25일

Unreal 개발

목록 보기
76/163

📘 TIL – 투사체 버그 수정, World Space Damage Text 시스템 안정화, 몬스터 특수 기믹 시스템 로직 반영 작업


🎯 오늘의 이슈

  • 물리 기반 Projectile 불안정 제거
  • HUD 기반 데미지 텍스트 → 월드 오브젝트 전환
  • Special Skill 트리거를 전투 흐름에 완전 연결
  • Proc 정책 확정
  • 크래시 원인 범위 압축

🚀 1. Projectile 시스템 – Hit 기반 제거 & Trace 기반 재설계


🔴 기존 구조 문제

기존 흐름:

UProjectileMovementComponent
OnComponentHit
ApplyDamage
Destroy

문제점:

  • OnComponentHit 미호출
  • 스폰 직후 Owner 겹침
  • QueryAndPhysics + NotifyRigidBodyCollision 충돌 루프
  • BlockAllDynamic 환경에서 예측 불가
  • SpawnCollisionHandlingOverride 의존

결론:

물리 이벤트 의존도가 너무 높았다.


💡 해결 전략: 직접 Sweep 판정

핵심 전환:

  • ProjectileMovement 제거
  • Tick에서 직접 이동
  • 이전 위치 → 다음 위치 SphereSweep
  • 유효 타겟 필터 후 ApplyDamage

📦 APotatoMonsterProjectile 구조

UCLASS()
class POTATOPROJECT_API APotatoMonsterProjectile : public AActor
{
    GENERATED_BODY()

public:
    APotatoMonsterProjectile();
    void InitVelocity(const FVector& InVelocity);

protected:
    virtual void Tick(float DeltaSeconds) override;

private:
    FVector Velocity;
    float Damage;
};

🔁 Tick 내부 Sweep 로직

void APotatoMonsterProjectile::Tick(float DeltaSeconds)
{
    Super::Tick(DeltaSeconds);

    FVector Start = GetActorLocation();
    FVector End   = Start + Velocity * DeltaSeconds;

    FHitResult Hit;
    FCollisionQueryParams Params;
    Params.AddIgnoredActor(GetOwner());

    bool bHit = GetWorld()->SweepSingleByChannel(
        Hit,
        Start,
        End,
        FQuat::Identity,
        ECC_Pawn,
        FCollisionShape::MakeSphere(15.f),
        Params
    );

    if (bHit && Hit.GetActor())
    {
        UGameplayStatics::ApplyDamage(
            Hit.GetActor(),
            Damage,
            nullptr,
            GetOwner(),
            nullptr
        );

        Destroy();
        return;
    }

    SetActorLocation(End);
}

🔧 CombatComponent 수정

기존:

ProjectileMovement->Velocity = AimDir * Speed;

변경:

Projectile->InitVelocity(AimDir * Speed);

⚠ 스폰 위치 보정 (중요)

FVector SpawnLoc = MuzzleLoc + AimDir * 40.f;

보정이 없으면:

  • 몬스터 캡슐 내부 생성
  • 즉시 겹침
  • 관통처럼 보이는 현상 발생

✅ 결과

  • 스폰 직후 크래시 제거
  • 통과 문제 해결
  • Collision 세팅 단순화
  • BlockAllDynamic 유지 가능
  • 예측 가능성 크게 증가

💥 2. World Space Damage Text 시스템 안정화


1️⃣ World Space 전환

WidgetComp->SetWidgetSpace(EWidgetSpace::World);

World Space는 자동 빌보드가 아니다.
직접 회전 처리가 필요하다.


2️⃣ Yaw-only 빌보드 구현

APlayerController* PC = GetWorld()->GetFirstPlayerController();
if (PC)
{
    FVector CamLoc;
    FRotator CamRot;
    PC->GetPlayerViewPoint(CamLoc, CamRot);

    FVector MyLoc = GetActorLocation();
    FRotator LookAt = (CamLoc - MyLoc).Rotation();

    FRotator FinalRot(0.f, LookAt.Yaw, 0.f);
    WidgetComp->SetWorldRotation(FinalRot);
}

Pitch 제거가 핵심.
3인칭 숄더뷰는 Yaw-only가 가장 자연스럽다.


3️⃣ 거리 기반 스케일 보정

float Dist = FVector::Distance(CamLoc, MyLoc);
Dist = FMath::Max(Dist, MinEffectiveDistance);

float Raw = Dist / ReferenceDistance;
float Scale = FMath::Lerp(1.f, Raw, DistanceScaleStrength);
Scale = FMath::Clamp(Scale, MinScale, MaxScale);

WidgetComp->SetWorldScale3D(FVector(Scale));

수학적으로 맞는 값보다
게임적으로 자연스러운 값이 중요하다.


4️⃣ Fade Out 처리

float T = CurrentLife / TotalLife;
float Smooth = 1.f - (T * T * (3.f - 2.f * T));
WidgetComp->SetRenderOpacity(Smooth);

등장보다 사라짐의 완성도가 중요하다.


5️⃣ Tick 최적화

SetActorTickEnabled(true);   // ShowDamage
SetActorTickEnabled(false);  // Finish

항상 Tick 금지.
상태 기반 활성화가 정석.


6️⃣ Pool 안정화

  • Active → TSet 변경
  • 중복 Release 방지
  • AlwaysSpawn 적용
  • EndPlay 정리

풀링은 성능보다 상태 안정성이 핵심.


⚔ 3. Special Skill Trigger + Proc 시스템 연결


🎯 정책 확정

  • 평타는 항상 실행
  • 스페셜은 추가 Proc
  • 대체가 아니라 “추가타” 체감

1️⃣ OnCooldown (BTService)

void UBTService_TrySpecialSkillCooldown::TickNode(...)
{
    AActor* Target = Cast<AActor>(
        OwnerComp.GetBlackboardComponent()->GetValueAsObject(CurrentTarget.SelectedKeyName)
    );

    if (Target)
    {
        Monster->SpecialSkillComp->TryStartSkill(OnCooldownSkillId, Target);
    }
}

BT는 “계속 시도”
SkillComponent가 최종 필터


2️⃣ OnHit 연결 (TakeDamage 내부)

float APotatoMonster::TakeDamage(...)
{
    CurrentHP -= DamageAmount;

    if (SpecialSkillComp && !SpecialSkill_OnHit.IsNone())
    {
        SpecialSkillComp->TryStartSkill(SpecialSkill_OnHit, DamageCauser);
    }

    if (CurrentHP <= 0.f)
    {
        Die();
    }

    return DamageAmount;
}

3️⃣ OnAttack Proc (몽타주 성공 직후)

if (AnimInst->Montage_Play(AttackMontage) > 0.f)
{
    if (bEnableOnAttackSpecialProc)
    {
        float Roll = FMath::FRand();

        if (Roll <= OnAttackSpecialChance)
        {
            SpecialSkillComp->TryStartSkill(OnAttackSkillId, Target);
        }
    }
}

삽입 위치가 체감을 결정한다.


4️⃣ OnDeath

void APotatoMonster::Die()
{
    bIsDead = true;

    if (SpecialSkillComp && !SpecialSkill_OnDeath.IsNone())
    {
        SpecialSkillComp->TryStartSkill(SpecialSkill_OnDeath, nullptr);
    }

    Destroy();
}

🧠 SpecialSkill 실행 파이프라인

TryStartSkill
  → BeginTelegraph
  → BeginCast
  → QueueExecuteNextTick
  → ExecuteSkill_Internal
  → ArmCooldown
  → QueueEndNextTick
  → EndSkill_Internal

NextTick 기반 실행:

GetWorld()->GetTimerManager().SetTimerForNextTick(
    FTimerDelegate::CreateUObject(this, &USpecialSkillComponent::ExecuteSkill_Internal)
);

Busy 판정:

bool USpecialSkillComponent::IsBusy() const
{
    return State != ESpecialSkillState::Idle;
}

🔍 스폰 직후 크래시 후보 정리

  • DataTable RowStruct 변경 후 재직렬화 누락
  • UPROPERTY 타입 변경 후 BP 직렬화 깨짐
  • 스폰 직후 BTService 즉시 Skill 호출
  • Blackboard Key 타입 불일치

🎯 오늘의 구조적 전환 요약

1️⃣ 물리 Hit 의존 제거 → 직접 판정
2️⃣ HUD 피드백 → 월드 오브젝트 피드백
3️⃣ 트리거 위치 = 체감 설계
4️⃣ NextTick 파이프라인 = 확장 안정성
5️⃣ SpecialSkill = Trigger + Execution 오케스트레이터


profile
개발합시다!

0개의 댓글