(주말공부)XR 플밍 - (11) 심화 과제 수행 및 시행착오 정리 (4/19)

이형원·2025년 4월 18일
0

XR플밍

목록 보기
48/215

매일의 수업마다 심화 과제가 있는데, 왠만하면 그날 당일에 심화 과제까지 풀고 TIL에 작성한 코드를 남기는 방식으로 했었다. 하지만 당일에 못 풀었던 적 한 번과, 시행착오가 많았던 탓에 차라리 따로 내용을 빼는 편이 좋겠다는 것 이렇게 두 가지는 따로 TIL로 정리하기로 했다.

1. 4/14 당일 못 풀었던 심화과제

유니티를 딱 시작한 1일차에 나왔던 심화과제였다. 이 문제는 결국 하루 정도를 고민해서 제출하긴 했는데, 시행착오 등 기록할 만한 부분이 있어서 따로 정리하기로 했다.

1.1 과제 내용

과제 내용은 다음과 같다.

  1. (심화)시계 구현하기(선택사항)
  • 제출 조건 : 유니티패키지(.unitypackage)
  • 구현 내용 :
    - Cylinder 오브젝트로 시계의 원판을, Cube로 시침과 분침을 구분해 배치할 수 있도록 한다.
    - 스크립트를 사용해 인스펙터에서 시간, 분을 int 타입으로 설정 가능하도록 구현
    - 시간, 분 변경 시 시침과 분침이 알맞은 각도로 회전하도록 구현한다.
    (부드럽게 회전하는 애니메이션이 적용되는 것이 아닌, 설정된 시간으로 바로 바뀌는 조건)
    - 기능구현에 사용한 씬과 스크립트를 포함한 유니티패키지를 Export하여 제출한다.
    - 기본실습과 함께 제출하는 경우, 두개의 유니티패키지를 Zip파일로 압축해 제출한다.

1.2 해결 과정

우선은 시계를 만드는 조건에 대해서도 명시되어 있으니, 새로운 씬을 만들고 시계부터 만들었다.

또한 저 Cube를 그대로 회전시키면 중심부를 기준으로 회전할 것이므로, 시곗바늘이 돌아가는 것처럼 구현하기 위해 Empty Space를 부모 객체로 만들고 자식으로 넣어주었다.

이제 이 MinutesMover를 어떻게 회전시켜야 시계처럼 돌아갈지 사전에 테스트를 해 보자.


보다시피 Z축 Rotation을 돌리면 돌아가는 것으로 보이고, 양수로 설정하면 시계 반대 방향으로 돌아가는 것을 확인했다.
이러면 스크립트를 작성하는 단계에서 양수를 입력 받으면 음수로 바꿔서 출력해야 하겠다는 유의점을 감안해야 한다.

그러면 이제 시곗바늘을 움직이기 위한 스크립트를 만들어야 할 것 같은데, 이 과제를 받았을 당시에는 아직 Transform 관련 개념을 배우기 전이라서 결국 인터넷 서치를 했었다.

Rotation을 변경하기 위해서 Quaternion.Euler를 써 보기로 했다.

분과 관련된 코드와 시와 관련된 코드를 따로 만들어야 겠다고 처음엔 생각했다. 그래서 분과 관련된 코드를 먼저 만들어 보기로 했다.

(당시에 해당 심화과제를 풀면서 종이로 메모해봤던 내용이다.)


이와 같은 생각을 바탕으로 처음 MinutesMover 스크립트를 작성했다.
public class ClockMover : MonoBehaviour
{
    public GameObject minutesMover;

    public float minutes;

    void Update()
    {
        minutesMover.transform.rotation = Quaternion.Euler(0, 0, -minutes * 6f);
    }
}

이 코드를 MinutesMover에 붙이고, 정상적으로 작동하는 것을 1차적으로 확인했다.

여기까진 좋았지만, 문제는 HourMover를 만들 때부터였다.

HourMover는 분과 시의 입력을 둘 다 해야지 정확하게 출력할 수 있다.
하지만 분과 시를 중복해서 입력하게 할 수도 없고, 그러면 MinutesMover의 변수를 참조하도록 해야 할까?

이와 같은 생각으로 스크립트 상으로 MinutesMover의 변수를 가져오려는 시도를 했었다.
하지만 문득 생각해보니, 차라리 이렇게 처리하는 편이 나아 보였다.

그냥 HourMover나 MinutesMover 스크립트를 따로 만들지 말고, ClockMover 스크립트 하나로 시계의 분과 시를 조작해보자.

이와 같은 발상으로 아래와 같이 코드를 작성하였다.

public class ClockMover : MonoBehaviour
{
    public GameObject hourMover;
    public GameObject minutesMover;

    public float hours;
    public float minutes;

    void Update()
    {
        hourMover.transform.rotation = Quaternion.Euler(0, 0, -hours * 30f - minutes * 0.5f);
        minutesMover.transform.rotation = Quaternion.Euler(0, 0, -minutes * 6f);
    }
}

1.3 결과

결과는 아래와 같이 나온다.

1.4 느낀 점

문제를 처음 접했을 당시에 사실 당황스러운 문제기도 했다. 당장 게임을 만들고 게임과 관련된 기능을 구현해보는 걸 알고 싶은데, 시계를 구현할 필요가 있을까 생각이 들었던 부분이다.

하지만 심화과제의 의도는 아무래도, 어떤 문제를 받았을 때 이를 해결할 수 있는 능력이 있는가를 시험해 보고자 한 것 같다. 실제로 게임 프로그래머는 만들었던 걸 계속 만드는 것이 아니라, 늘 새로운 기능을 구현해야 하는 직업이다. 즉 문제 해결 능력은 게임 프로그래머에게 있어서 필수적으로 가져야 할 능력이란 것이다.

이와 같은 과제를 해결하는 사고 과정이 상당히 뜻깊게 다가왔고, 고민하고 해결책을 낼 수 있는 능력을 기르는 것이 중요하다는 걸 다시 깨닫게 되었다.

2. 4/18 Coroutine과 이벤트 - TIL 분량상 자른 내용

이 심화과제의 경우, 과제 자체는 당일에 해결했으나 아무래도 과정 중에 겪은 시행착오와 오류가 많았던 탓에 여러모로 배운 점이 많았던 과제였다. 다만 이걸 당일 TIL로 정리하면 내용이 너무 번잡해질 것 같은 우려가 있어서, 따로 내용을 빼서 주말 TIL로 정리하고자 하였다.

2.1 과제 내용

  1. (심화)코루틴과 이벤트 심화 구현하기(선택사항)
  • 제출 조건 : 유니티패키지(.unitypackage)
  • 구현 내용 :
    이벤트 활용하기
    - 트리거 충돌체로 탱크가 진입할 수 있는 구역을 구현한다
    - 구역에는 여러 몬스터를 배치하여 대기 시킨다
    - 구역 진입시 구역은 이벤트를 발생시킨다
    - 이벤트에 반응하여 구역에 있는 몬스터들이 탱크를 향해 이동할 수 있도록 구현한다

2.2 해결 과정

우선은 트리거를 발생시킬 충돌체를 만들어보기로 했다.

아주 거대한 박스를 만들어서 지나갈 때는 무조건 닿을 영역으로 만들어 놓았다.
여기서 이 박스가 그대로 보여서는 안되니 박스의 렌더링 부분에서 손을 보면 된다.

Mesh Renderer를 끄면 이렇게 투명한 상태로 만들 수 있다. 다만 충돌체가 사라진 것은 아닌 투명한 물체를 구현할 수 있다.
이제 해당 물체에 달 스크립트를 짜 주자.

using UnityEngine;
using UnityEngine.Events;

public class EventArea : MonoBehaviour
{
    public UnityEvent monsterRaid;

    private void OnTriggerEnter(Collider other)
    {
        if(other.gameObject.tag == "Player")
        {
            monsterRaid.Invoke();
        }
    }
}

해당 구간으로 들어가면 몬스터 레이드가 발생하도록 이벤트를 추가하였다.
이제 해당 구역에 들어가면 몬스터가 플레이어를 추격하도록 만들어준 다음, 참조하여 Invoke 시켜주면 된다.

몬스터는 이미 이전 공부 과정에서 만든 몬스터가 있었지만, 아무래도 시야 안의 플레이어를 인식하는 적을 그대로 쓸 수는 없을 거라 생각해 몬스터를 새로 만들기로 했다.

따라서 프리팹만 똑같은 NewMonster를 만들기로 결정하였고, 몬스터를 어떻게 이동시켜야 하는지 고민했다.
이전에 제작한 몬스터에서 활용할 수 있는 부분은 없을까 고민해보았다.

우선 Hp와 그 처리에 대한 부분은 이전 코드 내용을 재활용하고, 레이캐스트로 추적하는 부분을 아예 없애버렸다.

using UnityEngine;

public class MonsterController : MonoBehaviour
{
    [Header("Components")]
    [SerializeField] float moveSpeed;
    [SerializeField] GameObject target;

    private int monsterMaxHp = 3;
    private int monsterHp;

    private void Awake()
    {
        monsterHp = monsterMaxHp;
    }
    private void Update()
    {
        MonsterIsDead();
       	Trace();
    }

    private void OnCollisionEnter(Collision other)
    {
        if (other.gameObject.tag == "PlayerBullet")
        {
            TakeDamage();
        }
    }

    private void TakeDamage()
    {
        monsterHp--;
    }

    private void MonsterIsDead()
    {
        if (monsterHp == 0)
        {
            Destroy(gameObject);
        }
    }

    private void Trace()
    {
        transform.position = Vector3.MoveTowards(transform.position, target.transform.position, moveSpeed * Time.deltaTime);
        transform.LookAt(target.transform.position);
    }
}

이와 같이 작성하고, target에 player를 넣은 뒤에 아래와 같이 세팅했다.

NewMonster들을 비활성화 해놓고, 영역에 들어오면 활성화되어 쫓아오는 방식으로 구현하였다.

몬스터가 정상적으로 쫓아는 모습을 확인할 수 있다. 또한 플레이어는 몬스터를 공격할 수 있고, 몬스터에게 피격될 시 죽는 모습도 확인할 수 있었다.

다만 마지막에 죽는 장면에서 오류가 뜨는 것을 확인할 수 있었다.

이 문구는 무엇일까? 이러한 오류가 뜨면서 게임이 강제로 멈추는 것을 확인할 수 있었다.

  • MissingReferenceException

말 그대로 해석하자면 참조할 것이 사라져서 발생한 오류라는 걸 알 수 있었다. 정확히 세 개가 같이 뜬 것을 보니 몬스터 쪽에서 문제가 생긴 것이었다.

using UnityEngine;

public class MonsterController : MonoBehaviour
{
...
    private void Update()
    {
        MonsterIsDead();
       	Trace();
    }
...
    private void Trace()
    {
    	// target.Position을 참조하는 함수이니, target이 사라지면 오류가 발생한다.
        transform.position = Vector3.MoveTowards(transform.position, target.transform.position, moveSpeed * Time.deltaTime);
        transform.LookAt(target.transform.position);
    }
}

코드를 다시 살펴보면, target 에다가 Player를 직접 참조시켰었다. 하지만 플레이어가 죽게 되면 그대로 파괴되면서 참조할 수 있는 target이 없어지게 된다. 이로 인해 오류가 발생한다는 사실을 알게 되었으니, Trace 자체가 target이 있을 때에만 진행되어야 한다는 사실을 알게 되었다.
Update 코드를 아래와 같이 변경하면 문제는 해결된다.

private void Update()
{
    MonsterIsDead();
    if (target != null)
    {
        Trace();
    }
}

2.3 스스로 선정한 도전 과제 구현하기

과제 자체의 구현 내용은 완성했다. 하지만 이대로 끝내기에는 뭔가 밋밋했다.

기왕 하는 거 제대로 해 보자는 셈으로, 추가로 구현할 내용을 추가했다.

추가 구현 내용
1. 플레이어가 영역 밖으로 나가면 추격 중지 (어그로가 풀리는 상황을 감안)
2. 플레이어 추격을 중단했을 때 몬스터가 제자리로 돌아가기

이걸 위해서 우선 EventArea 스크립트에 이벤트를 추가했다.

using UnityEngine;
using UnityEngine.Events;

public class EventArea : MonoBehaviour
{
...
    public UnityEvent outOfArea;
...
    private void OnTriggerExit(Collider other)
    {
        if (other.gameObject.tag == "Player")
        {
            outOfArea.Invoke();
        }
    }
}

플레이어가 해당 영역을 벗어났을 때를 구현할 이벤트를 추가하고, 몬스터에 해당 내용을 구현해야 한다.

우선은 몬스터가 추격 중지하는 부분을 만들려고 했다.

using UnityEngine;

public class MonsterController : MonoBehaviour
{
    [Header("Components")]
    [SerializeField] float moveSpeed;
    [SerializeField] GameObject target;

    private bool playerDetected = false;
    private int monsterMaxHp = 3;
    private int monsterHp;

    private void Awake()
    {
        monsterHp = monsterMaxHp;
        // 시작 단계에서 비활성화 해놓고 영역 접근 시 소환
        gameObject.SetActive(false);
    }
    private void Update()
    {
        MonsterIsDead();
        if (target != null)
        {
            if (playerDetected)
            {
                Trace();
            }            
        }
    }

...

    // 플레이어가 영역 안에 있을 때 추격
    public void PlayerDetected()
    {
        playerDetected = true;
    }
    // 플레이어가 영역 밖으로 벗어났을 경우 추격 중지
    public void PlayerGone()
    {
        playerDetected = false;
    }
}

플레이어가 있는지 여부를 판단할 bool 변수를 추가하고, public으로 해당 함수를 이벤트에서 사용할 수 있도록 하였다.

이벤트를 전부 추가하고 테스트를 해 보았다.

여기까지 의도하는 대로 작동하는 것을 확인할 수 있었다.
이제 여기에서 몬스터가 제자리로 돌아가는 부분을 구현할 차례인데, 이 부분에서 상당한 시행착오가 있었다.

  1. 구문 오류

이제 원래 있었던 자리로 돌아가야 하니까, Transform firstPosition으로 설정을 하고 변수를 넣으니, 오류가 발생하는 것을 발견했다. 오류 내용을 보니 저 자리에 Transform이 아니라 Vector3가 들어가야 한다고 되어 있는데, 처음엔 이게 바로 이해가 되지 않았다.

target.Transform.position을 썼을 때는 잘 작동했는데, 이것도 설마 벡터였단 소린가?


transform 쪽에 커서를 갖다대면 이렇게 뜨지만,

position 쪽에 커서를 갖다대면 이렇게 뜬다.

사소한 변수 착각 문제지만, 아무래도 당장 사용에 익숙하지 않아서 발생한 문제인 것 같다.
MoveTowards에 들어가는 Position은 Vector3 변수이다. 기억해 두자.

  1. 몬스터 동작 오류

transform 쪽에 정신을 팔려서 들어가야 할 인자가 Vector란 걸 놓친 것이다. 그래서, 우선 인자를 Vector로 변경했다.
그리고 인스펙터 창을 확인했다.

의도대로 좌표가 표기되는 것을 확인했다. 여기에다가 몬스터의 처음 위치의 좌표를 입력해주면 정상작동 할 거라고 생각했다.
하지만, 예상치 못한 반응이 나왔다.

이 녀석들이 돌아가기는 하는데, 왜 누워버리는 걸까. 아무래도 계속 MoveTowards가 작동하는 탓인 것 같았다.
그러면 이걸 중단시켜줄 방법을 찾아야 한단 소린데, 그 방법이 금방 떠오르지 않았다.

보간이면 어떻게든 되지 않을까? 라는 생각이 먼저 들어 해당 함수를 Lerp로 바꿔보는 시도도 해 보았다.
하지만 보간 수치를 조정해 봐도 몬스터가 45도 정도 기운 상태가 되거나 하는 등 비정상적인 작동을 보였다.

  1. 최종적인 해결 방안

계속 고민해보다가 결국 최종적인 해결방안이 떠올랐다. 그날 같이 배웠던 Coroutine을 사용하는 방법이다.
Coroutine으로 원래 위치에 도달하면 일시정지시키고 코루틴을 할당해제시키면, 원하는 대로 몬스터 행동을 만들 수 있을 것 같았다.
이와 같은 생각으로 Coroutine을 이용한 돌아가기 행동을 만들었고 최종적으로 완성된 코드 전문은 아래와 같다.

using System.Collections;
using UnityEditor.SceneTemplate;
using UnityEngine;

public class MonsterController : MonoBehaviour
{
    [Header("Components")]
    [SerializeField] float moveSpeed;
    [SerializeField] GameObject target;
    [SerializeField] Vector3 firstPosition;
    [SerializeField] float stopDistance;

    private bool playerDetected = false;
    private int monsterMaxHp = 3;
    private int monsterHp;
    private Coroutine goBackCoroutine;

    private void Awake()
    {
        monsterHp = monsterMaxHp;
        // 처음엔 몬스터 안 보이게 처리
        gameObject.SetActive(false);
    }
    private void Update()
    {
        MonsterIsDead();
        if (target != null)
        {
            if (playerDetected)
            {
                Trace();
            }
            else
            {
                if (goBackCoroutine == null)
                {
                    goBackCoroutine = StartCoroutine(Goback());
                }
            }
        }
    }

    private void OnCollisionEnter(Collision other)
    {
        if (other.gameObject.tag == "PlayerBullet")
        {
            TakeDamage();
        }
    }

    private void TakeDamage()
    {
        monsterHp--;
    }

    private void MonsterIsDead()
    {
        if (monsterHp == 0)
        {
            Destroy(gameObject);
        }
    }

    private void Trace()
    {
        transform.position = Vector3.MoveTowards(transform.position, target.transform.position, moveSpeed * Time.deltaTime);
        transform.LookAt(target.transform.position);
    }

    // 플레이어가 영역 안에 있을 때 추격
    public void PlayerDetected()
    {
        playerDetected = true;
    }
    // 플레이어가 영역 밖으로 벗어났을 경우 추격 중지
    public void PlayerGone()
    {
        playerDetected = false;
    }

    private IEnumerator Goback()
    {
        while (true)
        {
            float distance = Vector3.Distance(firstPosition, transform.position);
            if (distance <= stopDistance)
            {
                goBackCoroutine = null;
                yield break;
            }
            transform.position = Vector3.MoveTowards(transform.position, firstPosition, moveSpeed * Time.deltaTime);
            transform.LookAt(firstPosition);

            yield return null;
        }
    }
}

이와 같이 작업하면서 느낀 것은, 확실히 Coroutine 자체가 코드를 짜기 쉬운 편이 아니라는 것이었다. 이와 같이 코드를 작성 후 테스트를 진행해 보았다.

드디어 원하는대로 작동하는 몬스터를 구현할 수 있었다.

2.4 느낀 점

게임을 하면서 몬스터와 조우하고, 몬스터에게서 도망치면 몬스터가 다시 제자리로 돌아가는 모습을 꽤 보았다.
특히 재밌게 했던 젤다의 전설 야생의 숨결이나, 왕국의 눈물에서도 그런 시스템이 구현되어 있다.
하지만 이런 사소한 시스템 하나도 막상 만들어 보니 많은 생각이 필요하고 쉬운 작업이 아님을 확실히 느꼈다.

분명 내가 구현한 방법보다 훨씬 더 좋은 방법이 있을지도 모르고 더 복잡하고 섬세한 매커니즘이 있을지도 모른다. 하지만 우선 어설프게라도 해냈다는 성취감을 느꼈고, 더 좋은 시스템을 만들기 위한 생각과 노력이 필요하다는 것을 느꼈다.

profile
게임 만들러 코딩 공부중

0개의 댓글