6 minute read

npc 공격 범위 추가

테스트 플레이 도중 발견한 문제. 공격모션과 함께 이동이 됨.

기존 전투범위에 적이 들어오기만 하면 MoveToTarget() 그리고 바로 Attack()이 실행되기 때문..

따라서 기존 전투범위는 어그로범위로 쓰고 새로 AttackShpere라는 공격 범위를 만들었다.


YaroCharacter.h
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Combat")
class USphereComponent* AttackSphere;
YaroCharacter.cpp

in 생성자

AttackSphere = CreateDefaultSubobject<USphereComponent>(TEXT("AttackSphere"));
AttackSphere->SetupAttachment(GetRootComponent());
AttackSphere->InitSphereRadius(460.f);
AttackSphere->SetRelativeLocation(FVector(250.f, 0.f, 0.f));
AttackSphere->SetCollisionResponseToAllChannels(ECollisionResponse::ECR_Overlap);
AttackSphere->SetCollisionResponseToChannel(ECollisionChannel::ECC_WorldStatic, ECollisionResponse::ECR_Ignore);
AttackSphere->SetCollisionObjectType(ECollisionChannel::ECC_WorldStatic);

방식은 전투 범위랑 거의 똑같음.

in BeginPlay()

AttackSphere->OnComponentBeginOverlap.AddDynamic(this,&AYaroCharacter::AttackSphereOnOverlapBegin);
AttackSphere->OnComponentEndOverlap.AddDynamic(this, &AYaroCharacter::AttackSphereOnOverlapEnd);
void AYaroCharacter::AttackSphereOnOverlapBegin(UPrimitiveComponent* OverlappedComponent, AActor* OtherActor, UPrimitiveComponent* OtherComp, int32 OtherBodyIndex, bool bFromSweep, const FHitResult& SweepResult)
{
    if (OtherActor)
    {
        AEnemy* Enemy = Cast<AEnemy>(OtherActor);
        if (Enemy && !bAttacking)
        {
            AIController->StopMovement();
            Attack();          
        }
    }
}

이 범위에 오버랩되면 이동 멈추고 공격.

void AYaroCharacter::AttackSphereOnOverlapEnd(UPrimitiveComponent* OverlappedComponent, AActor* OtherActor, UPrimitiveComponent* OtherComp, int32 OtherBodyIndex)
{
    if (OtherActor)
    {
        AEnemy* Enemy = Cast<AEnemy>(OtherActor);
        if (Enemy)
        {
            if (bAttacking)
            {
                bAttacking = false;
                SetInterpToEnemy(false);
            }

            if (Targets.Num() != 0)
            {
                MoveToTarget(Targets[0]);
            }
        }
    }
}

공격 범위 나가면 쫓아가기.


공격 범위가 생긴 루코 블루프린트.

이미지


그리고 회오리(스톰)마법을 너무 자주 쓸 때도 있어서(랜덤이니까) 보니까 회오리의 경우 크고 엄청 밝아서 정신 사나움;; 그래서 쿨타임을 주기로 결정.

YaroCharacter.h
bool bCanCastStrom = true;

void CanCastStormMagic();

불 변수가 참이면 스톰 마법 시전 가능. 거짓이면 시전 불가능.

밑의 함수에서 다시 참으로 바꿔줌.


YaroCharacter.cpp

in Attack()

if (bCanCastStrom) //npc can cast strom magic
{
    SkillNum = FMath::RandRange(1, 3);
    if (SkillNum == 1) // 스톰 마법
    {
        bCanCastStrom = false;
        FTimerHandle StormTimer;
        GetWorldTimerManager().SetTimer(StormTimer, this, &AYaroCharacter::CanCastStormMagic, 6.f, false); // 6초 뒤 다시 스톰 사용 가능

    }
}
else //npc can't cast strom magic
{
    SkillNum = FMath::RandRange(2, 3);
}
void AYaroCharacter::CanCastStormMagic()
{
	bCanCastStrom = true;
}


그리고 대화 중 플레이어를 자연스럽게 쳐다보기 위해 Player한테 하는 보간 추가

YaroCharacter.h
UPROPERTY(EditAnywhere)
bool bInterpToPlayer = false;
YaroCharacter.cpp

in Tick()

if (bInterpToPlayer)
{
    FRotator LookAtYaw = GetLookAtRotationYaw(Player->GetActorLocation());
    FRotator InterpRotation = FMath::RInterpTo(GetActorRotation(), LookAtYaw, DeltaTime, InterpSpeed); //smooth transition

    SetActorRotation(InterpRotation);
}

나중에 다른 npc 쳐다보는 것도 보간 넣어야할 듯.


플레이어도 마찬가지로 npc 보간 있음.

Main.h
bool bInterpToNpc = false;

class AYaroCharacter* TargetNpc; // Who the player look at
Main.cpp

in Tick()

if (bInterpToNpc && TargetNpc)
{
    FRotator LookAtYaw = GetLookAtRotationYaw(TargetNpc->GetActorLocation());
    FRotator InterpRotation = FMath::RInterpTo(GetActorRotation(), LookAtYaw, DeltaTime, InterpSpeed); //smooth transition

    SetActorRotation(InterpRotation);
}

아마 npc들도 이런 식으로 변경할 듯.


보통 Dialogue.cpp에서 이렇게 쓰임.

Main->bInterpToNpc = true;
Main->TargetNpc = Main->Luko;

npc는 불 변수만 참으로 해주면 됨.


Enemy도 살짝 개선

플레이어가 죽어도 때리고 있길래..

간단하게 공격 함수에 조건문만 추가해줌.

Enemy.cpp

in Attack()

if (CombatTarget == Main && Main->MovementStatus == EMovementStatus::EMS_Dead) return;



새 몽타주와 애니메이션 추가

원래 플레이어가 처음에 누워있다 일어나는 것도 전부 전투몽타주에 있었는데, 지팡이 줍는 거랑 후반부에 돌 줍는 애니메이션도 추가해야하다보니 그냥 몽타주를 하나 새로 만듬. 전투몽타주는 전투 관련한 것만 하고, 전투 관련이 아닌 것들은 새 몽타주에서 관리.

노말 몽타주 블루프린트

이미지

기존 전투몽타주에 있던 Laying이랑 GetUp도 여기로 옮김.

지팡이 줍는 애니메이션에서 새 노티파이 PickWand 추가.

이미지

이 노티파이 이벤트가 실행될 때 지팡이 장착시킴.

지팡이 주우라는 시스템 메세지 없애고 ActiveOverlappingItem도 null로 만든 뒤 플레이어를 움직일 수 있게 하고 새 시스템 메세지, 포탈로 이동하라는 메세지를 띄워줌.


Main 헤더에서 노말 몽타주 선언. (귀찮아서 말로 대체)

Main.cpp

in LMBDown()

if (ActiveOverlappingItem && !EquippedWeapon)
{
    UAnimInstance* AnimInstance = GetMesh()->GetAnimInstance();
    if (AnimInstance && NormalMontage)
    {
        // 지팡이 줍는 도중에 또 몽타주가 실행되는 걸 막음
        if (AnimInstance->Montage_IsPlaying(NormalMontage) == true) return;
        bCanMove = false; // 못 움직임
        FRotator LookAtYaw = GetLookAtRotationYaw(ActiveOverlappingItem->GetActorLocation());
        SetActorRotation(LookAtYaw);

        AnimInstance->Montage_Play(NormalMontage);
        AnimInstance->Montage_JumpToSection(FName("PickWand"), NormalMontage);
    }
}

원래 여기서 바로 장착시키는 거였는데 이렇게 변경함.



레벨과 경험치 시스템…!

파판14 디자인에서 영감을 얻었다.

일단 경험치 바 위젯. 다른 상태능력치바와 거의 동일하다.

이미지

ProgressBar에 바인딩 생성.

이미지


레벨과 경험치 모두 HUDOverlay에 같이 넣기로 함.

이미지

(오른쪽 위 조작 매뉴얼은 버튼 아니고 그냥 이미지다.)

레벨 (숫자)텍스트에 바인딩 생성.

이미지

경험치 텍스트가 좀 어려웠는데

이미지

현재 경험치가 30이라 하고 최대 경험치가 90이라 했을 때, Exp / MaxExp = 0.3333333…이 되는데

여기에 100을 곱해서 33.33333.. 으로 만든 뒤 이것을 문자열로 바꾸고 .(소수점)을 기준으로 문자열을 나눈다.

(앞부분 33, 뒷부분 33333333…)

그리고 나눠진 앞부분 문자열에 .을 붙이고 뒷부분 문자열은 두번째 자리까지만 가져와서 ‘앞부분 문자열 + .’ 에 붙인다. 마지막으로 %을 붙인다. 이것을 텍스트로 변환.

결론적으로 33 + . + 33 + %이렇게 해서 하나의 텍스트 33.33% 가 됨.

그리고 마지막 레벨 5일 때는 퍼센트가 아니라 Max라는 텍스트가 나타나도록 함.


자 이제 코드를 좀 보자면 일단 경험치를 얻는 방식!

몬스터를 한 번 이상 공격했으면 그 몬스터가 쓰러질 때 경험치를 얻음.

당연히 몬스터마다 경험치는 다르고.

몬스터를 한 번 이상 공격했으면 - 이 부분이 조금 까다로웠는데


Enemey.h
UPROPERTY(VisibleAnywhere)
bool bAttackFromPlayer = false; // 플레이어에게 공격 당한 몬스터는 참

UPROPERTY(EditAnywhere)
float EnemyExp; //  몬스터가 가지는 경험치 양

처음엔 Enemy.cpp의 HitEnd()에다가 index == 0이면(즉, 플레이어의 공격이면) 불변수를 참으로 하려고 했는데..

이게 인식이 엄청나게 안 먹힌다. 이상함…… npc랑 여럿이 공격해서 그런지.. 아무튼.. 그래서 여긴 결국 포기했고.

두 번째 방법. MagicSkill에서 처리하기.

MagicSkill.cpp

in OnComponentBeginOverlap()

if (index == 0)
{
    Enemy->bAttackFromPlayer = true;
    if (Enemy->Main->CombatTarget == nullptr)
    {
        Enemy->Main->bAutoTargeting = true;
        Enemy->Main->CombatTarget = Enemy; // 자동으로 타겟 지정
        Enemy->Main->Targeting();
        Enemy->Main->bAutoTargeting = false;
    }
}

적에게 오버랩되었을 때 이 공격이 플레이어 것이면 오버랩된 적의 bAttackFromPlayer를 참으로 만듬.

그 아래 코드는 자동타겟팅인데 MagicSkill에 따로 플레이어를 받는 변수가 없어서 그냥 Enemy꺼 Main 씀ㅋ

플레이어의 전투타겟이 없으면(즉 타겟팅하지 않은 상태) 이 공격에 오버랩된 적을 전투 타겟으로 만들고 타겟팅시킴.

bAutoTargeting이라는 불 변수를 둔 이유는 플레이어의 Targeting()에서 전투타겟을 설정하기 때문. 그래서 방금 공격 맞은 애가 아닌 플레이어의 범위에 먼저 들어온 애가 타겟팅 되는 일이 생김. 그래서 저 변수가 true면 자동타겟팅한다는 뜻이므로 Targeting()에서 전투타겟 설정 안 함.

Main.cpp
void AMain::Targeting() //Targeting using Tap key
{
    if (bOverlappingCombatSphere) //There is a enemy in combatsphere
    {
        if (targetIndex >= Targets.Num()) //타겟인덱스가 총 타겟 가능 몹 수 이상이면 다시 0으로 초기화
        {
            targetIndex = 0;
        }
        //There is already exist targeted enemy, then targetArrow remove
        if (MainPlayerController->bTargetArrowVisible)
        {
            MainPlayerController->RemoveTargetArrow();
            MainPlayerController->RemoveEnemyHPBar();
        }
        bHasCombatTarget = true;
        if (Targets.Num() != 0 && !bAutoTargeting) // ***********이 부분!!****************
        {
            CombatTarget = Targets[targetIndex];
            targetIndex++;
        }

        MainPlayerController->DisplayTargetArrow(); 
        MainPlayerController->DisplayEnemyHPBar();
    }
}

참고로 전투범위 내에 있어야 타겟팅 화살표와 체력바가 뜹니다.


그렇게 해서 Enemy의 불 변수가 참이 되면!

Enemy.cpp

in Die()

if (bAttackFromPlayer) Main->GetExp(EnemyExp);

쓰러질 때 Main이 경험치를 얻는 함수가 실행됨. 매개변수로 몬스터마다 가지고 있는 고유의 경험치를 받음.


Main.cpp
void AMain::GetExp(float exp)
{
	Exp += exp; // 매개변수로 받아온 값을 현재 경험치에 더함.

	if (Exp >= MaxExp) // 최대 경험치 이상이라면
	{
        Level += 1; // 레벨을 올리고
        if (LevelUpSound != nullptr) // 레벨 사운드가 잘 들어가있으면 레벨사운드 재생
            UAudioComponent* AudioComponent = UGameplayStatics::SpawnSound2D(this, LevelUpSound);

        if (Exp == MaxExp) // 최대 경험치와 동일했으면 현재 경험치는 0으로 초기화
        {
            Exp = 0.f;
        }
        else // 최대 경험치 초과했으면 초과한 만큼의 양으로 현재 경험치 설정
        {
            float tmp = Exp - MaxExp;
            Exp = tmp;
        }
        
        if(Level == 5) Exp = 100.f; // 마지막 레벨일 땐 경험치 Max

        switch (Level) 
        {
            case 2: // 레벨 2가 되었을 때
                MaxExp = 130.f; // 최대 경험치 변경
                MainPlayerController->SystemMessageNum = 6; // 시스템 메세지 등장
                MainPlayerController->SetSystemMessage();
                break;
            case 3:
                MaxExp = 210.f;
                MainPlayerController->SystemMessageNum = 7;
                MainPlayerController->SetSystemMessage();
                break;
            case 4:
                MaxExp = 400.f;
                MainPlayerController->SystemMessageNum = 8;
                MainPlayerController->SetSystemMessage();
                break;
            case 5: // 마지막 레벨
                MainPlayerController->SystemMessageNum = 9;
                MainPlayerController->SetSystemMessage();
                break;
        }

        FTimerHandle Timer; 
        GetWorld()->GetTimerManager().SetTimer(Timer, FTimerDelegate::CreateLambda([&]()
        {
            MainPlayerController->RemoveSystemMessage();
        }), 3.f, false); // 3초 뒤 시스템 메세지 제거
    }
}



저장 정보 추가

YaroSaveGame.h
USTRUCT(BlueprintType)
    struct FCharacterStats
    {
        GENERATED_BODY()

        UPROPERTY(VisibleAnywhere, Category = "SaveGameData")
            float HP;
        UPROPERTY(VisibleAnywhere, Category = "SaveGameData")
            float MaxHP;

        UPROPERTY(VisibleAnywhere, Category = "SaveGameData")
            float MP;
        UPROPERTY(VisibleAnywhere, Category = "SaveGameData")
            float MaxMP;

        UPROPERTY(VisibleAnywhere, Category = "SaveGameData")
            float SP;
        UPROPERTY(VisibleAnywhere, Category = "SaveGameData")
            float MaxSP;

        // ********* 추가된 부분
        UPROPERTY(VisibleAnywhere, Category = "SaveGameData")
            int Level;

        UPROPERTY(VisibleAnywhere, Category = "SaveGameData")
            float Exp;

        UPROPERTY(VisibleAnywhere, Category = "SaveGameData")
            float MaxExp;
        // ************

        UPROPERTY(VisibleAnywhere, Category = "SaveGameData")
            FVector Location;

        UPROPERTY(VisibleAnywhere, Category = "SaveGameData")
            FRotator Rotation;

        UPROPERTY(VisibleAnywhere, Category = "SaveGameData")
            FString WeaponName;
    };

레벨, 현재 경험치 및 최대 경험치 정보를 추가했다.

UPROPERTY(VisibleAnywhere, BlueprintReadWrite, Category = Basic)
int32 DialogueNum = 0;

대화 넘버도 저장하게 함.


Main.cpp

in SaveGame()

SaveGameInstance->CharacterStats.Level = Level;
SaveGameInstance->CharacterStats.Exp = Exp;
SaveGameInstance->CharacterStats.MaxExp = MaxExp;

SaveGameInstance->DialogueNum = MainPlayerController->DialogueNum;

저장 함수에서 추가된 저장 정보 저장해줌.

GetWorld()->GetTimerManager().SetTimer(SaveTimer, this, &AMain::SaveGame, 5.f, false);

5초마다 저장 함수 실행하도록 함.

if (MainPlayerController->bDialogueUIVisible)
{
    GetWorld()->GetTimerManager().SetTimer(SaveTimer, this, &AMain::SaveGame, 5.f, false);
    return;
}

대화 중인 경우 5초 뒤에 다시 실행하고 리턴함. 대화 중엔 저장 불가함.



양이 많은 것 같아서 다음 포스트에 이어 쓰겠음!

Updated: