오늘의 할일
1. 팀프로젝트 정리
2. 다음 학습 목표
스크럼 하면서(통합하면서) 수정사항 발생했는데
0. Combat 파트 수정 요청
HandGun / Magnum Inventory 연동
현재 무기 → Ammo ID 결정 → Inventory에서 탄약 확인 → Reload → 탄약 소비
사용 ID:
HandGun
HandGunAmmo
Magnum
MagnumAmmo
HandGun → HandGunAmmo만 사용
Magnum → MagnumAmmo만 사용
재장전 시 InventoryComponent의 탄약 차감
탄약 부족 시 가능한 만큼만 장전
Inventory 탄약 음수 방지
기존 코드에 Inventory 탄약 연동이 일부 있으므로 새로 만들기보다 현재 코드 점검
플레이어 행동 제한
공격/재장전 → Player 상태 확인 → 가능할 때만 실행
사망
Inventory/UI Open
숨기
숨기 전환
등에서 공격/재장전이 실행되지 않도록 Player의 공통 행동 가능 상태와 연결.
오늘은 이렇게 작업할 거 같음
변경 사항: FinishReload() 함수에 PlayerCharacter->CanPerformAction()을 검사하는 코드 추가
변경 사항: 탄약이 0일 때 강제로 장전을 시도하는 블록 안에서, 기존에 있던 if (PlayerCharacter->IsHiding()) return; 코드 삭제
이미 함수 맨 위쪽에서 CanPerformAction()로 은신 상태를 걸러내고 있기 때문에, 아래쪽까지 내려올 일이 없음
void UCombatComponent::PrimaryAttack()
{
// 플레이어 캐릭터로 캐스팅
APlayerCharacter* PlayerCharacter = Cast<APlayerCharacter>(GetOwner());
// 플레이어가 없거나, 현재 행동 불가 상태(사망, 은신 등)라면 즉시 차단
if (PlayerCharacter == nullptr || !PlayerCharacter->CanPerformAction())
{
return;
}
// 전투 컴포넌트 내부 상태 검사 (쿨타임, 장전 중)
if (bCanPrimaryAttack == false || bIsReloading == true)
{
return;
}
// 인벤토리에서 장착 상태 실시간 확인
UInventoryComponent* InventoryComponent = GetOwner()->FindComponentByClass<UInventoryComponent>();
if (InventoryComponent == nullptr || !InventoryComponent->HasEquippedWeapon())
{
return; // 장착된 무기가 없으면 사격 불가
}
// 인벤토리에서 현재 무기 ID를 가져옴
FName CurrentWeaponID = InventoryComponent->GetEquippedWeaponID();
// 유효한 무기인지 검사
if (CurrentWeaponID != FName("HandGun") && CurrentWeaponID != FName("Magnum"))
{
return;
}
// 가져온 무기 ID로 맵에서 데이터를 찾음
FWeaponData* CurrentWeaponData = WeaponDataMap.Find(CurrentWeaponID);
if (CurrentWeaponData == nullptr) return;
if (CurrentWeaponData->CurrentAmmo <= 0)
{
UE_LOG(LogTemp, Warning, TEXT("(탄약이 없습니다)"));
if (EmptySound) UGameplayStatics::PlaySoundAtLocation(this, EmptySound, GetOwner()->GetActorLocation());
OnAmmoEmptyWarning();
ReloadWeapon();
return;
}
// 조건 충족 시: 상태 변경 및 탄약 차감
bCanPrimaryAttack = false;
CurrentWeaponData->CurrentAmmo -= 1; // 탄약 차감
UpdateAmmoUI();
// 쿨타임 타이머 시작 (공격 속도 시간만큼 대기 후 ResetPrimaryAttack 실행)
GetWorld()->GetTimerManager().SetTimer(
TimerHandle_PrimaryCooldown,
this,
&UCombatComponent::ResetPrimaryAttack,
CurrentWeaponData->AttackSpeed,
false
);
AActor* Owner = GetOwner();
if (Owner == nullptr) return;
// 플레이어의 카메라 위치와 회전값 가져오기
FVector EyeLocation;
FRotator EyeRotation;
Owner->GetActorEyesViewPoint(EyeLocation, EyeRotation);
// 총기 사운드 및 애니메이션 재생
if (FireSound)
{
UGameplayStatics::PlaySoundAtLocation(this, FireSound, EyeLocation);
}
ACharacter* OwnerCharacter = Cast<ACharacter>(Owner);
if (OwnerCharacter && FireAnimation)
{
OwnerCharacter->PlayAnimMontage(FireAnimation);
}
// 무한한 사거리 (시선 방향 벡터 * 999999.0f)
FVector ShotDirection = EyeRotation.Vector();
FVector TraceEnd = EyeLocation + (ShotDirection * 999999.0f);
FHitResult HitResult;
FCollisionQueryParams QueryParams;
QueryParams.AddIgnoredActor(Owner); // 내가 쏜 총에 내가 맞지 않도록 예외 처리
// 레이캐스트(보이지 않는 선) 발사
bool bSuccess = GetWorld()->LineTraceSingleByChannel(
HitResult,
EyeLocation,
TraceEnd,
ECC_Visibility, // 눈에 보이는 물체들과 충돌
QueryParams
);
// 테스트용 빨간색 궤적 그리기 (에디터에서 눈으로 확인하기 위함, 2초간 유지)
DrawDebugLine(GetWorld(), EyeLocation, TraceEnd, FColor::Red, false, 2.0f, 0, 2.0f);
if (bSuccess)
{
// 맞은 지점에 초록색 점 표시
DrawDebugPoint(GetWorld(), HitResult.ImpactPoint, 20.0f, FColor::Green, false, 2.0f);
// 어떤 액터를 맞췄는지, 데미지는 얼마를 줘야 하는지 계산해서 ProcessHit로 넘김
AActor* HitActor = HitResult.GetActor();
float DamageToApply = GetWeaponDamage();
FName HitBoneName = HitResult.BoneName;
// 뼈 이름이 "head", "Head", "neck" 등일 경우 데미지 2배 (권총/매그넘 공통)
if (HitBoneName == FName("head") || HitBoneName == FName("Head") || HitBoneName == FName("neck"))
{
DamageToApply *= HeadshotMultiplier; // 2.0f 대신 헤더에서 선언한 변수 사용
UE_LOG(LogTemp, Warning, TEXT("헤드샷. 배수(%f) 적용됨 (적중 부위: %s)"), HeadshotMultiplier, *HitBoneName.ToString());
}
// 계산된 최종 데미지를 전달
ProcessHit(HitActor, DamageToApply);
// 타격 위치에 피 튀김/스파크 이펙트 생성
if (HitEffect)
{
UGameplayStatics::SpawnEmitterAtLocation(GetWorld(), HitEffect, HitResult.ImpactPoint);
}
}
}
void UCombatComponent::ProcessHit(AActor* HitTarget, float AppliedDamage, bool bIsHeadshot)
{
// 타격 대상이 유효한지 검사
if (HitTarget == nullptr) return;
// 시체 타격 방지 (중복 킬 방지)
// 맞은 대상이 좀비 캐릭터인지 확인
AZombieCharacter* HitZombie = Cast<AZombieCharacter>(HitTarget);
if (HitZombie)
{
// 체력이 0 이하라면(죽었다면) 타격 무시
if (HitZombie->GetHealth() <= 0.0f)
{
UE_LOG(LogTemp, Warning, TEXT("이미 죽은 적입니다. 타격 무시."));
return;
}
// 데미지 전달 (벽에는 적용되지 않음)
float ActualDamage = UGameplayStatics::ApplyDamage(
HitTarget,
AppliedDamage,
GetOwner()->GetInstigatorController(),
GetOwner(),
UDamageType::StaticClass()
);
// 명중 신호 및 실제 적용된 데미지 UI로 발송 (벽을 맞추면 신호 안 감)
OnEnemyHit.Broadcast(ActualDamage, bIsHeadshot);
UE_LOG(LogTemp, Warning, TEXT("타격 성공. 맞은 대상: %s, 최종 데미지: %f"), *HitTarget->GetName(), ActualDamage);
}
}
void UCombatComponent::ReloadWeapon()
{
// 상태 및 장전 중복 검사
// 플레이어 캐릭터로 캐스팅
APlayerCharacter* PlayerCharacter = Cast<APlayerCharacter>(GetOwner());
// 플레이어가 없거나, 현재 행동 불가 상태(사망, 은신 등)라면 즉시 차단
if (PlayerCharacter == nullptr || !PlayerCharacter->CanPerformAction())
{
return;
}
// 전투 컴포넌트 내부 상태 검사 (쿨타임, 장전 중)
if (bCanPrimaryAttack == false || bIsReloading == true)
{
return;
}
// 인벤토리에서 장착 상태 실시간 확인
UInventoryComponent* InventoryComponent = GetOwner()->FindComponentByClass<UInventoryComponent>();
if (InventoryComponent == nullptr || !InventoryComponent->HasEquippedWeapon())
{
return;
}
// 인벤토리에서 현재 무기 ID를 가져옴
FName CurrentWeaponID = InventoryComponent->GetEquippedWeaponID();
if (CurrentWeaponID != FName("HandGun") && CurrentWeaponID != FName("Magnum")) return;
// 가져온 무기 ID로 맵에서 데이터를 찾음
FWeaponData* CurrentWeaponData = WeaponDataMap.Find(CurrentWeaponID);
if (CurrentWeaponData == nullptr) return;
// 이미 탄창이 꽉 찼으면 무시
if (CurrentWeaponData->CurrentAmmo >= CurrentWeaponData->MagazineCapacity) return;
// CurrentWeaponID 사용
FName AmmoItemName = (CurrentWeaponID == FName("HandGun")) ? FName("HandGunAmmo") : FName("MagnumAmmo");
// 가방에 예비 총알이 있는지 검사
int32 AmmoCount = InventoryComponent->GetItemQuantity(AmmoItemName);
if (AmmoCount <= 0)
{
UE_LOG(LogTemp, Warning, TEXT("장전 실패: 가방에 %s가 없습니다"), *AmmoItemName.ToString());
// 장전 실패 시에도 찰칵 소리 재생
if (EmptySound) UGameplayStatics::PlaySoundAtLocation(this, EmptySound, GetOwner()->GetActorLocation());
return;
}
// 장전 상태 진입 (사격 불가)
bIsReloading = true;
UE_LOG(LogTemp, Warning, TEXT("재장전 중..."));
// 재장전 사운드 및 애니메이션 재생
if (ReloadSound)
{
UGameplayStatics::PlaySoundAtLocation(this, ReloadSound, GetOwner()->GetActorLocation());
}
ACharacter* OwnerCharacter = Cast<ACharacter>(GetOwner());
if (OwnerCharacter && ReloadAnimation)
{
OwnerCharacter->PlayAnimMontage(ReloadAnimation);
}
// 장전 대기 시간(2초) 타이머 시작
GetWorld()->GetTimerManager().SetTimer(
TimerHandle_Reload,
this,
&UCombatComponent::FinishReload,
2.0f,
false
);
}
void UCombatComponent::FinishReload()
{
// 장전 도중에 인벤토리를 열었거나, 캐비닛에 숨었거나, 죽었다면 장전 취소
APlayerCharacter* PlayerCharacter = Cast<APlayerCharacter>(GetOwner());
if (PlayerCharacter == nullptr || !PlayerCharacter->CanPerformAction())
{
bIsReloading = false;
UE_LOG(LogTemp, Warning, TEXT("장전 도중 행동 불가 상태가 되어 장전이 취소되었습니다."));
return;
}
// 인벤토리 컴포넌트와 무기 장착 여부 확인
UInventoryComponent* InventoryComponent = GetOwner()->FindComponentByClass<UInventoryComponent>();
if (InventoryComponent == nullptr || !InventoryComponent->HasEquippedWeapon())
{
bIsReloading = false;
return;
}
// 인벤토리에서 무기 ID를 가져와서 데이터 조회
FName CurrentWeaponID = InventoryComponent->GetEquippedWeaponID();
FWeaponData* CurrentWeaponData = WeaponDataMap.Find(CurrentWeaponID);
if (CurrentWeaponData == nullptr) return;
// 소모할 인벤토리 탄약 이름 결정
FName AmmoItemName = (CurrentWeaponID == FName("HandGun")) ? FName("HandGunAmmo") : FName("MagnumAmmo");
// 탄창에 채워야 할 빈 공간 계산
int32 NeededAmmo = CurrentWeaponData->MagazineCapacity - CurrentWeaponData->CurrentAmmo;
// 가방에 남아있는 총알 갯수 확인
int32 AmmoCount = InventoryComponent->GetItemQuantity(AmmoItemName);
// 실제 장전할 수량(ReloadAmount) 결정
// 빈 공간(NeededAmmo)과 가방 속 총알(AmmoCount) 중 더 작은 값을 선택
int32 ReloadAmount = FMath::Min(NeededAmmo, AmmoCount);
if (InventoryComponent->RemoveItem(AmmoItemName, ReloadAmount))
{
CurrentWeaponData->CurrentAmmo += ReloadAmount;
}
// 장전 상태 해제
bIsReloading = false;
UE_LOG(LogTemp, Warning, TEXT("장전 완료. 현재 탄창: %d발"), CurrentWeaponData->CurrentAmmo);
UpdateAmmoUI();
}
1) 우는천사 데미지 즉사 처리
2) 우는천사 에셋 확정
3) 우는천사 파일들 폴더 AI로 옮기기
#pragma once
#include "CoreMinimal.h"
#include "ZombieCharacter.h"
#include "WeepingAngelZombie.generated.h"
UCLASS()
class DEAD_HOSPITAL_API AWeepingAngelZombie : public AZombieCharacter
{
GENERATED_BODY()
public:
AWeepingAngelZombie();
// 매 프레임 시야를 검사하기 위해 Tick 함수 오버라이드
virtual void Tick(float DeltaTime) override;
// 공격 불가 처리 -> 데미지 파이프라인 오버라이드
virtual float TakeDamage(float DamageAmount, struct FDamageEvent const& DamageEvent, class AController* EventInstigator, AActor* DamageCauser) override;
// 우는 천사에게 닿았을 때 즉사 처리를 위한 오버랩 함수
virtual void NotifyActorBeginOverlap(AActor* OtherActor) override;
// 즉사 데미지가 들어가기 직전에 효과음(뼈 부러지는 소리 등)
UPROPERTY(EditAnywhere)
USoundBase* CatchSound;
private:
// 플레이어가 나를 보고 있는지 계산하는 함수
bool CheckIfSeenByPlayer();
// 상태 변화를 감지하기 위한 이전 프레임 상태 저장용 변수
bool bWasSeenLastFrame = false;
protected:
// 이동 시 재생할 반복 사운드 컴포넌트
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "JumpScare|Audio")
class UAudioComponent* MovementAudioComp;
};
#include "WeepingAngelZombie.h"
#include "GameFramework/CharacterMovementComponent.h"
#include "Kismet/GameplayStatics.h"
#include "PlayerCharacter.h"
#include "Components/AudioComponent.h"
AWeepingAngelZombie::AWeepingAngelZombie()
{
PrimaryActorTick.bCanEverTick = true;
ChaseSpeed = 800.0f;
// 오디오 컴포넌트 생성 및 부착
MovementAudioComp = CreateDefaultSubobject<UAudioComponent>(TEXT("MovementAudioComp"));
MovementAudioComp->SetupAttachment(RootComponent);
// 게임 시작 시에는 기본적으로 소리가 나지 않게 설정
MovementAudioComp->bAutoActivate = false;
}
// 공격 불가
float AWeepingAngelZombie::TakeDamage(float DamageAmount, struct FDamageEvent const& DamageEvent, class AController* EventInstigator, AActor* DamageCauser)
{
// 아무리 데미지가 들어와도 0.0f를 반환하여 체력이 깎이지 않게 방어
UE_LOG(LogTemp, Warning, TEXT("우는 천사는 공격할 수 없습니다"));
return 0.0f;
}
// 얼음/땡 처리
void AWeepingAngelZombie::Tick(float DeltaTime)
{
Super::Tick(DeltaTime);
bool bIsSeenNow = CheckIfSeenByPlayer();
// 상태가 변했을 때만 속도와 애니메이션 업데이트
if (bIsSeenNow != bWasSeenLastFrame)
{
if (bIsSeenNow)
{
// [얼음] 플레이어가 쳐다볼 때: 발을 묶고 애니메이션을 멈춤
GetCharacterMovement()->MaxWalkSpeed = 0.0f;
GetMesh()->bPauseAnims = true;
// 움직임이 멈추면 돌 갈리는 소리 정지
if (MovementAudioComp->IsPlaying())
{
MovementAudioComp->Stop();
}
}
else
{
// [땡] 시야에서 벗어났을 때: 돌진하며 애니메이션 재생
GetCharacterMovement()->MaxWalkSpeed = ChaseSpeed;
GetMesh()->bPauseAnims = false;
// 시야에서 벗어나 움직이기 시작하면 돌 갈리는 소리 재생
if (!MovementAudioComp->IsPlaying())
{
MovementAudioComp->Play();
}
}
bWasSeenLastFrame = bIsSeenNow;
}
}
// 시야 판정
bool AWeepingAngelZombie::CheckIfSeenByPlayer()
{
APlayerCharacter* Player = Cast<APlayerCharacter>(UGameplayStatics::GetPlayerCharacter(GetWorld(), 0));
if (Player == nullptr) return false;
FVector PlayerLocation;
FRotator PlayerRotation;
Player->GetActorEyesViewPoint(PlayerLocation, PlayerRotation);
FVector DirToAngel = (GetActorLocation() - PlayerLocation).GetSafeNormal();
FVector PlayerForward = PlayerRotation.Vector();
// 시야각(FOV) 검사
// 내적 값이 0.5 이상이면 정면을 기준으로 약 60도 원뿔 시야 내에 있다는 뜻
float DotProduct = FVector::DotProduct(PlayerForward, DirToAngel);
if (DotProduct > 0.5f)
{
// 장애물(벽) 가림 검사
FHitResult HitResult;
FCollisionQueryParams QueryParams;
QueryParams.AddIgnoredActor(Player); // 플레이어 자신은 통과
bool bHit = GetWorld()->LineTraceSingleByChannel(
HitResult,
PlayerLocation,
GetActorLocation(),
ECC_Visibility,
QueryParams
);
// 시야에 있고 + 광선을 쐈을 때 벽에 막히지 않고 우는 천사를 정확히 맞췄다면
if (bHit && HitResult.GetActor() == this)
{
return true; // 플레이어가 나를 쳐다보고 있다는 뜻
}
}
return false;
}
void AWeepingAngelZombie::NotifyActorBeginOverlap(AActor* OtherActor)
{
Super::NotifyActorBeginOverlap(OtherActor);
// 닿은 대상이 플레이어인지 확인
APlayerCharacter* Player = Cast<APlayerCharacter>(OtherActor);
// 플레이어가 유효하고, 아직 죽지 않은 상태라면
if (Player != nullptr && !Player->IsDead())
{
UE_LOG(LogTemp, Warning, TEXT("우는 천사에게 붙잡혔습니다! 즉사 발동."));
// 데미지를 주기 직전에 사운드 재생
if (CatchSound)
{
UGameplayStatics::PlaySoundAtLocation(this, CatchSound, GetActorLocation());
}
// 플레이어에게 99999의 압도적인 데미지를 가해 기존 사망 로직(Die)을 강제로 실행시킴
UGameplayStatics::ApplyDamage(
Player,
99999.0f, // 즉사 데미지
GetController(),
this,
UDamageType::StaticClass()
);
}
}
정전 -> 등장형 전투담당+몬스터(ai)담당
복도-> 불깜빡 -> 멀리서 귀신 등장 -> 불꺼짐 -> 불켜짐 -> 중간거리 귀신 -> 불꺼짐 -> 불켜짐 -> 바로 앞 귀신 -> 소멸
소리를 이용한 가짜 점프 스케어 전투담당+맵/레벨 담당
뒤에서 쾅하고 문닫히는 소리 -> 플레이어가 급하게 뒤돌아봄 -> 아무것도 없음 아무것도 없는 폐병원에서 소녀의 콧노래 소리가 들림 -> 노래가 들리는 곳으로 가봤지만 아무것도 없음
부모 클래스를 Actor - BlackoutScareTrigger 클래스 생성
#pragma once
#include "CoreMinimal.h"
#include "GameFramework/Actor.h"
#include "BlackoutScareTrigger.generated.h"
class UBoxComponent;
class AZombieCharacter;
UCLASS()
class DEAD_HOSPITAL_API ABlackoutScareTrigger : public AActor
{
GENERATED_BODY()
public:
ABlackoutScareTrigger();
protected:
// 플레이어가 밟을 트리거
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "JumpScare|Component")
UBoxComponent* TriggerBox;
// 귀신이 나타날 3가지 위치 (에디터에서 화살표로 직관적 배치 가능)
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "JumpScare|Component")
USceneComponent* FarPoint;
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "JumpScare|Component")
USceneComponent* MidPoint;
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "JumpScare|Component")
USceneComponent* ClosePoint;
// 소환할 귀신(좀비) 클래스
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "JumpScare|Setting")
TSubclassOf<AZombieCharacter> GhostClass;
// 불이 켜져 있는 시간 (기본 0.4초)
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "JumpScare|Setting")
float LightOnDuration = 0.4f;
// 불이 꺼져 있는 시간 (기본 0.2초)
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "JumpScare|Setting")
float LightOffDuration = 0.2f;
// 블루프린트에서 조명 끄고 켜기를 구현할 이벤트
UFUNCTION(BlueprintImplementableEvent, Category = "JumpScare|Event")
void OnToggleLights(bool bTurnOn);
private:
UFUNCTION()
void OnOverlapBegin(UPrimitiveComponent* OverlappedComp, AActor* OtherActor, UPrimitiveComponent* OtherComp, int32 OtherBodyIndex, bool bFromSweep, const FHitResult& SweepResult);
// 연출 단계를 진행하는 핵심 함수
void AdvanceScareSequence();
FTimerHandle SequenceTimerHandle;
int32 CurrentStage = 0;
AZombieCharacter* SpawnedGhost = nullptr;
};
#include "BlackoutScareTrigger.h"
#include "Components/BoxComponent.h"
#include "TimerManager.h"
#include "../AI/ZombieCharacter.h"
#include "PlayerCharacter.h"
ABlackoutScareTrigger::ABlackoutScareTrigger()
{
PrimaryActorTick.bCanEverTick = false;
TriggerBox = CreateDefaultSubobject<UBoxComponent>(TEXT("TriggerBox"));
RootComponent = TriggerBox;
TriggerBox->SetCollisionProfileName(TEXT("Trigger"));
TriggerBox->OnComponentBeginOverlap.AddDynamic(this, &ABlackoutScareTrigger::OnOverlapBegin);
FarPoint = CreateDefaultSubobject<USceneComponent>(TEXT("FarPoint"));
FarPoint->SetupAttachment(RootComponent);
MidPoint = CreateDefaultSubobject<USceneComponent>(TEXT("MidPoint"));
MidPoint->SetupAttachment(RootComponent);
ClosePoint = CreateDefaultSubobject<USceneComponent>(TEXT("ClosePoint"));
ClosePoint->SetupAttachment(RootComponent);
}
void ABlackoutScareTrigger::OnOverlapBegin(UPrimitiveComponent* OverlappedComp, AActor* OtherActor, UPrimitiveComponent* OtherComp, int32 OtherBodyIndex, bool bFromSweep, const FHitResult& SweepResult)
{
APlayerCharacter* Player = Cast<APlayerCharacter>(OtherActor);
if (Player != nullptr && GhostClass != nullptr)
{
// 중복 실행 방지를 위해 콜리전 끄기
TriggerBox->SetCollisionEnabled(ECollisionEnabled::NoCollision);
// 귀신을 미리 한 번만 소환해 두고 투명하게 숨김
FActorSpawnParameters SpawnParams;
SpawnParams.SpawnCollisionHandlingOverride = ESpawnActorCollisionHandlingMethod::AlwaysSpawn;
SpawnedGhost = GetWorld()->SpawnActor<AZombieCharacter>(GhostClass, FarPoint->GetComponentLocation(), FarPoint->GetComponentRotation(), SpawnParams);
if (SpawnedGhost)
{
SpawnedGhost->SetActorHiddenInGame(true); // 안 보이게 숨김
SpawnedGhost->SetActorEnableCollision(false); // 충돌체 끄기
}
// 연출 상태 머신 시작
CurrentStage = 0;
AdvanceScareSequence();
}
}
void ABlackoutScareTrigger::AdvanceScareSequence()
{
float NextDelay = 0.1f;
switch (CurrentStage)
{
case 0:
// [불 꺼짐] 첫 정전
OnToggleLights(false);
NextDelay = LightOffDuration;
break;
case 1:
// [불 켜짐] 멀리서 귀신 등장
OnToggleLights(true);
if (SpawnedGhost)
{
SpawnedGhost->SetActorLocationAndRotation(FarPoint->GetComponentLocation(), FarPoint->GetComponentRotation());
SpawnedGhost->SetActorHiddenInGame(false);
}
NextDelay = LightOnDuration;
break;
case 2:
// [불 꺼짐]
OnToggleLights(false);
if (SpawnedGhost) SpawnedGhost->SetActorHiddenInGame(true);
NextDelay = LightOffDuration;
break;
case 3:
// [불 켜짐] 중간 거리 귀신 등장
OnToggleLights(true);
if (SpawnedGhost)
{
SpawnedGhost->SetActorLocationAndRotation(MidPoint->GetComponentLocation(), MidPoint->GetComponentRotation());
SpawnedGhost->SetActorHiddenInGame(false);
}
NextDelay = LightOnDuration;
break;
case 4:
// [불 꺼짐]
OnToggleLights(false);
if (SpawnedGhost) SpawnedGhost->SetActorHiddenInGame(true);
NextDelay = LightOffDuration;
break;
case 5:
// [불 켜짐] 코앞 귀신 등장
OnToggleLights(true);
if (SpawnedGhost)
{
SpawnedGhost->SetActorLocationAndRotation(ClosePoint->GetComponentLocation(), ClosePoint->GetComponentRotation());
SpawnedGhost->SetActorHiddenInGame(false);
// 사운드 부착 가능 (마지막 귀신 나타날 때)
}
NextDelay = LightOnDuration;
break;
case 6:
// [불 꺼짐] 귀신 소멸
OnToggleLights(false);
if (SpawnedGhost) SpawnedGhost->Destroy(); // 연출이 끝났으므로 완전 삭제
NextDelay = LightOffDuration;
break;
case 7:
// [불 켜짐] 상황 종료, 트리거 자체 파괴
OnToggleLights(true);
Destroy();
return; // 타이머 종료
}
CurrentStage++;
// 계산된 지연 시간 후에 다음 단계를 자동으로 실행
GetWorld()->GetTimerManager().SetTimer(SequenceTimerHandle, this, &ABlackoutScareTrigger::AdvanceScareSequence, NextDelay, false);
}
맵 / 레벨
BP_BlackoutScareTrigger를 복도에 배치하고 액터를 클릭하시면 FarPoint(멀리), MidPoint(중간), ClosePoint(코앞)라는 컴포넌트 3개가 있는데 화살표를 드래그해서 귀신이 튀어나올 위치를 잡을 수 있습니다(조명 및 위치 세팅)
블루프린트 - Event Graph에 Event On Toggle Lights 노드를 추가할 수 있는데 bTurnOn이 true일 때는 복도의 조명들을 켜고, false일 때는 끄도록 노드를 연결해 주시면 타이밍은 알아서 켜지게 됩니다
AI / 몬스터
여기에 사용될 귀신은 플레이어를 공격하지 않고 서 있기만 하는 거라 디테일 패널 - GhostClass에 넣을 블루프린트는 BT가 아예 돌지 않는 가만히 서 있는 전용 좀비 블루프린트로 하나 만들어서 할당해주시면 됩니다!
전달 완료
Actor를 부모로 하는 FakeAudioScareTrigger 클래스 생성
다목적 오디오 스케어 트리거로 만들기
#pragma once
#include "CoreMinimal.h"
#include "GameFramework/Actor.h"
#include "FakeAudioScareTrigger.generated.h"
class UBoxComponent;
class UAudioComponent;
class USoundBase;
UCLASS()
class DEAD_HOSPITAL_API AFakeAudioScareTrigger : public AActor
{
GENERATED_BODY()
public:
AFakeAudioScareTrigger();
protected:
// 소리가 실제로 들려올 위치 (문짝, 혹은 빈 방의 구석)
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "JumpScare|Components")
USceneComponent* SoundOrigin;
// 밟으면 소리가 시작됨
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "JumpScare|Components")
UBoxComponent* PlayTrigger;
// 밟으면 소리가 멈춤 (빈 방에 도착했을 때)
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "JumpScare|Components")
UBoxComponent* StopTrigger;
// 콧노래처럼 계속 재생할 때 사용할 오디오 컴포넌트
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "JumpScare|Components")
UAudioComponent* AudioComp;
// 재생할 공포 사운드
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "JumpScare|Settings")
USoundBase* ScareSound;
// true면 쾅 소리 한 번만 재생(문 닫힘), false면 루프 재생(콧노래)
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "JumpScare|Settings")
bool bIsOneShot = true;
UFUNCTION()
void OnPlayTriggerOverlap(UPrimitiveComponent* OverlappedComp, AActor* OtherActor, UPrimitiveComponent* OtherComp, int32 OtherBodyIndex, bool bFromSweep, const FHitResult& SweepResult);
UFUNCTION()
void OnStopTriggerOverlap(UPrimitiveComponent* OverlappedComp, AActor* OtherActor, UPrimitiveComponent* OtherComp, int32 OtherBodyIndex, bool bFromSweep, const FHitResult& SweepResult);
};
#include "FakeAudioScareTrigger.h"
#include "Components/BoxComponent.h"
#include "Components/AudioComponent.h"
#include "Kismet/GameplayStatics.h"
#include "PlayerCharacter.h"
AFakeAudioScareTrigger::AFakeAudioScareTrigger()
{
PrimaryActorTick.bCanEverTick = false;
// 소리 발생지 세팅
SoundOrigin = CreateDefaultSubobject<USceneComponent>(TEXT("SoundOrigin"));
RootComponent = SoundOrigin;
// 소리 시작 트리거 세팅
PlayTrigger = CreateDefaultSubobject<UBoxComponent>(TEXT("PlayTrigger"));
PlayTrigger->SetupAttachment(RootComponent);
PlayTrigger->SetCollisionProfileName(TEXT("Trigger"));
PlayTrigger->OnComponentBeginOverlap.AddDynamic(this, &AFakeAudioScareTrigger::OnPlayTriggerOverlap);
// 소리 종료 트리거 세팅
StopTrigger = CreateDefaultSubobject<UBoxComponent>(TEXT("StopTrigger"));
StopTrigger->SetupAttachment(RootComponent);
StopTrigger->SetCollisionProfileName(TEXT("Trigger"));
StopTrigger->OnComponentBeginOverlap.AddDynamic(this, &AFakeAudioScareTrigger::OnStopTriggerOverlap);
// 오디오 컴포넌트 세팅
AudioComp = CreateDefaultSubobject<UAudioComponent>(TEXT("AudioComp"));
AudioComp->SetupAttachment(RootComponent);
AudioComp->bAutoActivate = false;
}
void AFakeAudioScareTrigger::OnPlayTriggerOverlap(UPrimitiveComponent* OverlappedComp, AActor* OtherActor, UPrimitiveComponent* OtherComp, int32 OtherBodyIndex, bool bFromSweep, const FHitResult& SweepResult)
{
APlayerCharacter* Player = Cast<APlayerCharacter>(OtherActor);
if (Player != nullptr && ScareSound != nullptr)
{
if (bIsOneShot)
{
// 쾅 하는 단발성 소리 (문 닫힘)
UGameplayStatics::PlaySoundAtLocation(this, ScareSound, SoundOrigin->GetComponentLocation());
Destroy(); // 한 번 놀래키고 액터 삭제
}
else
{
// 콧노래처럼 계속 들리는 소리
if (!AudioComp->IsPlaying())
{
AudioComp->SetSound(ScareSound);
AudioComp->Play();
}
// 소리를 켰으니 시작 트리거만 끄고(중복 실행 방지), 끄는 트리거는 남겨둠
PlayTrigger->SetCollisionEnabled(ECollisionEnabled::NoCollision);
}
}
}
void AFakeAudioScareTrigger::OnStopTriggerOverlap(UPrimitiveComponent* OverlappedComp, AActor* OtherActor, UPrimitiveComponent* OtherComp, int32 OtherBodyIndex, bool bFromSweep, const FHitResult& SweepResult)
{
APlayerCharacter* Player = Cast<APlayerCharacter>(OtherActor);
if (Player != nullptr && !bIsOneShot)
{
// 빈 방에 도착했을 때: 콧노래 소리를 1초에 걸쳐 점점 줄여서 꺼버림
if (AudioComp->IsPlaying())
{
AudioComp->FadeOut(1.0f, 0.0f);
}
StopTrigger->SetCollisionEnabled(ECollisionEnabled::NoCollision);
// 페이드아웃 끝날 시간(1.5초) 여유를 주고 액터 파괴
SetLifeSpan(1.5f);
}
}
1. 뒤에서 쾅 하고 닫히는 문 세팅법
1) BP_FakeAudioScareTrigger를 맵에 꺼내고 SoundOrigin(루트)을 플레이어가 이미 지나쳐 온 등 뒤의 문짝 위치에 배치.
2) PlayTrigger 박스를 플레이어가 밟게 될 진행 방향 앞쪽으로 배치
3) 디테일 패널 - ScareSound - 문 쾅하는 소리를 넣고, bIsOneShot을 체크(True)
(이 상태에서는 StopTrigger는 쓰지 않으므로 어디에 둬도 무시)
2. 콧노래 부르는 텅 빈 방 세팅법
1) 맵에 액터를 꺼내고 SoundOrigin을 빈 방 한가운데에 두고 PlayTrigger 박스는 플레이어가 방에 접근하기 전에 복도 멀리(소리가 들리기 시작할 시점)에 배치
2) StopTrigger 박스는 플레이어가 문을 열고 빈 방 안으로 들어오는 입구에 두기
3) 디테일 패널 - ScareSound에 콧노래 소리(루프)를 넣고 bIsOneShot을 체크 해제(False)
병원에 나오자마자 좀비가 우글거림-> 탁 트인 주차장으로 달려가서 차에 탑승-> 시동이 안걸림 -> 좀비들 차로 다가와서 유리창 두드림 -> 유리창-> 금가기 시작함-> 다행히 시동걸림 -> 운전해서 탈출
폐병동이 깊은산속에 있는 컨셉
비도 오고 우중충한 날씨
컨셉에 맞게 캐릭터 구축




일단 작업 시작하긴 했지만 크래딧 부족으로 영상을 만들지는 못함
영상 제작 AI를 구독하거나 하루하루 모이는 크래딧 모아서 만들어야할 거 같음
지금은 각 씬에 대표 이미지만 작업한 상황임
엔딩 영상 작업 더 하거나 에셋들 찾아보고 에셋 확정리스트 작업. 그리고 2차 에셋 병합 작업 후 야근..