Deff_Dev

[Unity C#] Trigger와 Action을 이용한 NPC 대화 구현 본문

Unity(유니티)/유니티 공부

[Unity C#] Trigger와 Action을 이용한 NPC 대화 구현

Deff_a 2024. 5. 12. 17:23

 
NPC에 가까이 갔을 때 상호 작용 키가 활성화되고 해당 키를 누르면 대화가 시작되는 기능을 만들어볼려고 한다.
 
이 글에서는 편의를 위해 외부 컴포넌트를 가져올 때 캐싱해서 가져온다는 것을 가정하에 모든 과정을 진행하기 때문에,
본인의 프로젝트에 맞게 적절하게 수정하면서 진행하면 된다.
그리고 카메라 줌, 텍스트 타이핑 등 디테일적인 요소 구현은 이 글에서 다루지 않았으니 참고하길 바란다.


NPC 대화 저장 및 반환하는 클래스

NPC 대화 내용을 저장하고 현재 대화 단계에 맞는 대화를 반환하는 DialogueData 클래스를 생성한다.

[System.Serializable]
public class DialogueData 
{
    private int dialogueStep = 0; // 대화 단계 
    public string[] dialogues; // 단계별 대화를 저장하는 문자열 배열

    public string GetDialogue() // 대화 반환
    {
        if(dialogues.Length <= dialogueStep)
        {
            return ".....";
        }

        return dialogues[dialogueStep++];
    }

    public void ResetDialogue() // 댜화 단계 초기화
    {
        dialogueStep = 0;
    }

    public bool DialogueComplete() // 대화가 끝났는 지 반환
    {
        return dialogues.Length <= dialogueStep;
    }
}

 
그리고 NPC 대화 이벤트를 담당하는 NpcDialogueHandler 스크립트를 생성한 뒤 DialogueData 객체를 선언한다.

 
NPC 게임 오브젝트에 해당 스크립트를 추가하고 Dialogues에 대화를 추가한다.
('@'는 텍스트가 출력될 때, 플레이어의 이름으로 치환할 예정이다.)


UI 생성

대화창, 상호작용 아이콘 생성한다.

UI는 하고 싶은 대로 디자인하면 된다.

 
이 글에서는 상호작용 아이콘을 UI로 만들었지만 UI가 아닌 일반 게임 오브젝트로 만들어도 상관없다. 


Trigger를 이용한 상호작용 활성화/비활성화

이제 플레이어가 NPC 근처로 왔을 때 상호작용이 활성화되고 멀어졌을 때 비활성화되는 기능을 만들어보겠다.
 

NPC 게임 오브젝트안에 플레이어 탐지 구역을 나타내는 Square 스프라이트를 생성 및 크기를 조절하고 플레이어가 볼 수 없도록 투명도를 0으로 수정한다.
 

Box Collider 2D를 추가하고 is Trigger를 체크한다.
 

Collider 범위가 플레이어 탐지 범위가 되고 범위 안으로 플레이어 오브젝트가 들어온다면 상호작용이 활성화된다.
 
이러면 에디터 상에서 준비는 다 마쳤으니 상호작용을 활성화하는 PlayerDetectionTrigger 스크립트를 작성해보겠다.
 
플레이어가 범위 안으로 들어올 때는 OnTriggerEnter2D를 이용하여 상호 작용 가능 UI를 NPC 옆에 활성화시켜주고, 플레이어가 범위 밖으로 나갈 때는 OnTriggerExit2D를 이용하여 활성화된 부분들을 비활성화 시켜주는 방법으로 구현했다.

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

// NPC 상호작용 관련 플레이어 탐지 클래스
public class PlayerDetectionTrigger : MonoBehaviour
{
    private TopDownController controler;
    [SerializeField] private RectTransform interactionIcon; // 상호 작용 가능 UI
    [SerializeField] private Vector3 interactionIconPosition; // 상호 작용 가능 UI가 표시될 위치 

    private bool isPlayerInside = false; // 플레이어가 범위 안으로 들어왔다면 true
    private void Start()
    {
        controler = EntityDataManager.Instance.PlayerData.GetComponent<TopDownController>();
    }

    private void OnTriggerEnter2D(Collider2D collision) // NPC 상호작용 활성화
    {
        if (!collision.CompareTag("Player")) // 플레이어가 아니라면 return
        {
            return;
        }

        isPlayerInside = true;
        StartCoroutine(InteractionIconPositionUpdater());
        interactionIcon.gameObject.SetActive(true);

    }
    private void OnTriggerExit2D(Collider2D collision) // NPC 상호작용 비활성화
    {
        if (!collision.CompareTag("Player")) // 플레이어가 아니라면 return
        {
            return;
        }

        isPlayerInside = false;
        interactionIcon.gameObject.SetActive(false);
    }

    private IEnumerator InteractionIconPositionUpdater() // 상호작용 UI 위치를 NPC 옆에 표시
    {
        while (isPlayerInside)
        {
            Vector3 screenPosition = Camera.main.WorldToScreenPoint(transform.position);
            interactionIcon.position = screenPosition + interactionIconPosition;

            yield return null;
        }
    }
}

 
여기서 InteractionIconPositionUpdater 코루틴은 상호 작용 아이콘을 NPC 옆으로 고정시키는 기능을 한다.
 
상호 작용 아이콘을 UI로 만들었기 때문에 위치를 계속 업데이트해서 상호 작용 아이콘을 고정시킨다.
(UI가 아닌 일반 오브젝트로 만들었다면 이렇게 고정 시킬 필요는 없을 거 같다.)
 

InteractionIconPositionUpdater을 이용하지 않는다면 이런식으로 플레이어의 움직임에 따라 아이콘도 같이 움직인다.
 

 
상호작용 활성화/비활성화까지 구현을 완료했다.
 
이제 Action을 이용하여 NPC 대화 Event를 실행하는 방법을 설명하겠다.


Action을 이용한 대화 이벤트 호출 

이 프로젝트에서는 각 클래스가 특정 기능을 담당하고, TopDownController 클래스에서는 이러한 기능을 수행하는 함수들을 Action을 통해 저장한다.
이 때 PlayerInputController에서 키 입력을 받는다면 입력한 키에 맞는 이벤트를 실행하는 식으로 설계되어있다.
 
위 설계 방식을 지키기 위해 상호작용 이벤트도 Action을 이용하여 구현했다.
 
Action을 이용하지 않을거라면 이 글에서 필요한 부분들만 가지고 가면 된다.

using System;
using UnityEngine;

public class TopDownController : MonoBehaviour
{
    public event Action OnInteractEvent; 

    public void CallInteractEvent()
    {
        OnInteractEvent?.Invoke();
    }
}

TopDownController 스크립트를 생성하고 상호작용 이벤트를 저장할 Action을 선언한 뒤 해당 이벤트를 실행 시킬 함수를 작성한다.
 

using UnityEngine;
using UnityEngine.InputSystem;

public class PlayerInputController : TopDownController
{
    public void OnInteract(InputValue value)
    {
        if (value.isPressed) // 상호작용 키를 눌렀다면
        {
            CallInteractEvent(); // 상호작용 이벤트 실행 함수 호출
        }
    }  
}

TopDownController를 상속 받는 PlayerInputController 스크립트를 생성하고 InputSystem을 이용해 키 입력을 받는다.
(InputSystem을 잘 모른다면 InputSystem을 따로 공부한 뒤 구현하거나  InputManager를 이용하여 구현해도 된다.)
 
이동은 각자 편한대로 구현하면 된다.
 

이렇게 작성한 PlayerInputControllerPlayer 오브젝트에 작성한 스크립트를 추가한다.
 
상호작용 키 입력 및 이벤트 호출하는 기능을 만들었으니 이제 대화 이벤트를 추가할 차례이다.


대화 이벤트 구현

대화 이벤트는 DialogueData 객체를 저장한 NpcDialogueHandler 클래스에 추가하겠다.

using UnityEngine;
using TMPro;

public class NpcDialogueHandler : MonoBehaviour
{
    [SerializeField] private DialogueData dialogueData; // NPC 대화 정보
    [SerializeField] private GameObject dialougeUI; // 대화 창 UI
    [SerializeField] private TextMeshProUGUI dialougeText; // 대화 창 UI

    public void DialogueEvent() // 대화 이벤트
    {
        if (dialogueData.DialogueComplete()) // 대화가 끝났는지 확인
        {
            // 끝났다면 대화 종료 및 초기화
            ControlDialogueInterface(false); 
            dialogueData.ResetDialogue();
            return;
        }
        
        if (!dialougeUI.gameObject.activeSelf) // 대화 창이 켜져있지 않다면 대화 시작
        {
            ControlDialogueInterface(true);
        }

        // 대화 창 텍스트에 대화 저장
        string playerName = EntityDataManager.Instance.PlayerData.Name;
        text.Replace("\'@\'", $"\'{playerName}\'"); // Player 이름 치환
        dialougeText.text = dialogueData.GetDialogue();
    }

    private void ControlDialogueInterface(bool isTrue) // 대화 창 활성화/비활성화
    {
        dialougeUI.gameObject.SetActive(isTrue);
    }
}

 
위처럼 대화 Event를 작성했다.
 
게임이 시작될 때 입력된 대화에 '@'가 있다면 해당 부분을 Player 이름으로 치환하고 Text에 저장한다.


대화 이벤트 추가 및 삭제

대화 이벤트 추가/삭제하는 기능은 PlayerDetectionTrigger 클래스에서 상호작용이 활성화/비활성화될 때, TopDownController에 있는 OnInteractEventNpcDialogueHandler에 저장된 대화 이벤트 함수를 추가/삭제 한다.

using System.Collections;
using UnityEngine;

// NPC 상호작용 관련 플레이어 탐지 클래스
public class PlayerDetectionTrigger : MonoBehaviour
{
    [SerializeField] private TopDownController controller;
    [SerializeField] private NpcDialogueHandler npcDialogueHandler; // 대화 이벤트 클래스
    [SerializeField] private RectTransform interactionIcon; // 상호 작용 가능 UI
    [SerializeField] private Vector3 interactionIconPosition; // 상호 작용 가능 UI가 표시될 위치 

    private bool isPlayerInside = false; // 플레이어가 범위 안으로 들어왔다면 true

    private void OnTriggerEnter2D(Collider2D collision) // NPC 상호작용 활성화
    {
        if (!collision.CompareTag("Player")) // 플레이어가 아니라면 return
        {
            return;
        }

        isPlayerInside = true;
        StartCoroutine(InteractionIconPositionUpdater());
        interactionIcon.gameObject.SetActive(true);
        controller.OnInteractEvent += npcDialogueHandler.DialogueEvent; // 대화 이벤트 추가
    }
    private void OnTriggerExit2D(Collider2D collision) // NPC 상호작용 비활성화
    {
        if (!collision.CompareTag("Player")) // 플레이어가 아니라면 return
        {
            return;
        }

        isPlayerInside = false;
        interactionIcon.gameObject.SetActive(false);
        controller.OnInteractEvent -= npcDialogueHandler.DialogueEvent; // 대화 이벤트 해제
    }

    private IEnumerator InteractionIconPositionUpdater() // 상호작용 UI 위치를 NPC 옆에 표시
    {
        while (isPlayerInside)
        {
            // Canvas 상에서 NPC의 위치를 구함
            Vector3 screenPosition = Camera.main.WorldToScreenPoint(transform.position);
            // 상호 작용 아이콘을 NPC 옆으로 이동
            interactionIcon.position = screenPosition + interactionIconPosition;

            yield return null;
        }
    }
}

최종 구현

 

현재 진행중인 프로젝트를 기준으로 설명하는 것이기 때문에 Hierarchy창에 오브젝트가 많다.

 
여기까지 진행이 됐다면 작성한 스크립트를 게임 오브젝트에 맞게 추가하고 캐싱한 뒤 게임을 실행하면 잘 작동하는 것을 확인할 수 있다.

대화 이벤트 중일 때 이동 제한, 카메라 포커스, 텍스트 타이핑 등 디테일적인 부분은 각자 추가해보길 바란다.
(텍스트 타이핑은 에셋 스토어에 KoreanTyping 에셋이 있으니 그걸 사용해도 된다.)


글을 마치며

 
코딩에 완벽한 방법은 존재하지 않다고 생각하기 때문에 여기서 소개한 구현 방법이 완벽한 방법이라고 생각하지 않습니다. 
 
더 좋은 방법이나 개선해야할 점이 있다면 댓글로 알려주시면 감사드리겠습니다.