기존 흐름:
UProjectileMovementComponent
OnComponentHit
ApplyDamage
Destroy
문제점:
결론:
물리 이벤트 의존도가 너무 높았다.
핵심 전환:
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;
};
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);
}
기존:
ProjectileMovement->Velocity = AimDir * Speed;
변경:
Projectile->InitVelocity(AimDir * Speed);
FVector SpawnLoc = MuzzleLoc + AimDir * 40.f;
보정이 없으면:
WidgetComp->SetWidgetSpace(EWidgetSpace::World);
World Space는 자동 빌보드가 아니다.
직접 회전 처리가 필요하다.
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가 가장 자연스럽다.
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));
수학적으로 맞는 값보다
게임적으로 자연스러운 값이 중요하다.
float T = CurrentLife / TotalLife;
float Smooth = 1.f - (T * T * (3.f - 2.f * T));
WidgetComp->SetRenderOpacity(Smooth);
등장보다 사라짐의 완성도가 중요하다.
SetActorTickEnabled(true); // ShowDamage
SetActorTickEnabled(false); // Finish
항상 Tick 금지.
상태 기반 활성화가 정석.
풀링은 성능보다 상태 안정성이 핵심.
void UBTService_TrySpecialSkillCooldown::TickNode(...)
{
AActor* Target = Cast<AActor>(
OwnerComp.GetBlackboardComponent()->GetValueAsObject(CurrentTarget.SelectedKeyName)
);
if (Target)
{
Monster->SpecialSkillComp->TryStartSkill(OnCooldownSkillId, Target);
}
}
BT는 “계속 시도”
SkillComponent가 최종 필터
float APotatoMonster::TakeDamage(...)
{
CurrentHP -= DamageAmount;
if (SpecialSkillComp && !SpecialSkill_OnHit.IsNone())
{
SpecialSkillComp->TryStartSkill(SpecialSkill_OnHit, DamageCauser);
}
if (CurrentHP <= 0.f)
{
Die();
}
return DamageAmount;
}
if (AnimInst->Montage_Play(AttackMontage) > 0.f)
{
if (bEnableOnAttackSpecialProc)
{
float Roll = FMath::FRand();
if (Roll <= OnAttackSpecialChance)
{
SpecialSkillComp->TryStartSkill(OnAttackSkillId, Target);
}
}
}
삽입 위치가 체감을 결정한다.
void APotatoMonster::Die()
{
bIsDead = true;
if (SpecialSkillComp && !SpecialSkill_OnDeath.IsNone())
{
SpecialSkillComp->TryStartSkill(SpecialSkill_OnDeath, nullptr);
}
Destroy();
}
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;
}
1️⃣ 물리 Hit 의존 제거 → 직접 판정
2️⃣ HUD 피드백 → 월드 오브젝트 피드백
3️⃣ 트리거 위치 = 체감 설계
4️⃣ NextTick 파이프라인 = 확장 안정성
5️⃣ SpecialSkill = Trigger + Execution 오케스트레이터