Unity 입문 TopDown Shooting - 입력과 캐릭터 이동

Amberjack·2024년 1월 18일
0

Unity

목록 보기
8/44

📖 스크립트 작성하기!

▪️ 스크립트 작성 방법

  • 스크립트 생성 시, MonoBehavior를 상속 받는 클래스를 작성해야 한다. → 유니티에서 스크립트 생성 시 자동으로 추가되어 있음.
    해당 클래스는 유니티의 게임 오브젝트와 연결시키기 위해서 반드시 필요하다. → 해당 클래스가 유니티의 게임 오브젝트와 연결된 스크립트로 동작하도록 해준다.

  • 필요한 변수, 함수, 이벤트 등을 정의하고 구현한다. 게임의 동작을 위한 로직을 작성한다.

  • 필요한 Unity 함수들을 오버라이딩하여 원하는 동작을 구현할 수 있다. ex) Start(), Update(), FixedUpdate() ...

  • 필요에 따라 다른 스크립트나 Unity 컴포넌트와 상호작용하는 코드를 작성할 수 있다.

▪️ 스크립트 라이프 사이클

  • 스크립트 라이프 사이클 : 게임 오브젝트의 생명 주기 동안 호출되는 특정한 메서드들의 순서와 타이밍을 의미한다.
  • 게임 오브젝트의 생성, 초기화, 업데이트, 파괴 등과 관련된 작업을 수행한다.
  1. Awake : 게임 오브젝트가 생성될 때 호출되는 메서드. 주로 초기화 작업에 수행된다.
  2. Start : 게임 오브젝트가 활성화되어 게임 루프가 시작될 때 호출되는 메서드. 초기 설정 및 시작 작업을 수행한다.
  3. Update : 매 프레임마다 호출되는 메서드, 게임 로직의 주요 업데이트가 이루어진다.
  4. FixedUpdate : 물리 엔진 업데이트 시 호출되는 메서드. 물리적인 시뮬레이션에 관련된 작업을 처리할 때 사용한다.
  5. LateUpdate : Update 메서드 호출 이후에 호출되는 메서드. 다른 오브젝트의 업데이트가 완료된 후에 작업을 수행하는 데 유용.
  6. OnEnable : 게임 오브젝트가 활성화될 때 호출되는 메서드.
  7. OnDisable : 게임 오브젝트가 비활성화될 때 호출되는 메서드.
  8. OnDestroy : 게임 오브젝트가 파괴될 때 호출되는 메서드. 자원 정리 및 해제 작업이 수행된다.

이벤트 함수들의 실행 순서 : https://docs.unity3d.com/kr/2021.3/Manual/ExecutionOrder.html

📌 컴포넌트 이해

▪️ 유니티의 컴포넌트 방식

  • 게임 오브젝트에 부착되는 독립적인 기능 모듈! → 레고에서 블럭을 더하는 것과 비슷하다.
  • 각 컴포넌트는 특정한 작업을 수행하거나 기능을 제공한다.
  • 컴포넌트들을 게임 오브젝트에 추가하고 구성함으로써 게임의 동작과 특징을 결정.

▪️ 기본 컴포넌트 예시

  1. Transform : 게임 오브젝트의 위치, 회전, 크기 등을 조정할 때 사용.
  2. Rigidbody : 물리적인 효과를 게임 오브젝트에 적용할 수 있게 해준다.
  3. Collider : 충돌 감지를 처리하기 위해 사용된다.
  4. SpriteRenderer: 2D 그래픽을 표시하기 위해 사용된다.
  5. AudioSource : 사운드를 재생하기 위해 사용되는 컴포넌트.

이 외에도 사용자가 필요에 따라 컴포넌트를 직접 작성하고 추가할 수 있다.

🫅 플레이어 만들기

▪️ 캐릭터 스프라이트 PPU 설정하기!

PPU?

Pixels Per Unit(PPU) : 스프라이트의 픽셀 수와 해당 스프라이트가 게임 세계에서 차지하는 공간의 관계를 설명.
ex) PPU가 100이라면, 스프라이트의 100 픽셀은 게임 세계에서 1유니티의 단위를 나타낸다.

현재 캐릭터의 크기가 16 * 28의 크기인 것을 확인할 수 있다. 이 때, 캐릭터의 가로 크기를 1 유니티로 설정해보자.


캐릭터의 Inspector 창에서 Sprite Mode 밑에 PPU가 있는 것을 확인할 수 있다. 이 크기를 16으로 변경하자.

캐릭터의 가로 길이가 유니티 씬의 그리드 1 * 1에 맞춰지는 것을 확인할 수 있다.

▪️ 캐릭터 Player 만들기

  1. 캐릭터의 transform을 생성하자.

    캐릭터 밑에 여러 자식 오브젝트들의 transform을 한 번에 관리하기 위해서는 부모 transform을 만들어 한 번에 관리하는 것이 좋다!
    Transform 계층 구조 : 게임 오브젝트들 사이의 계층적인 관계. 부모의 Transform이 변경되면 자식도 동일하게 적용된다.

    ※ 항상 새로운 게임 오브젝트를 생성하면 transform을 Reset 시켜주기!!! → 게임 오브젝트는 현재 카메라의 위치에 생성되기 때문에 좌표값이 이상하게 생성된다.

    이상한 값에 생성되는 모습 ▲

📌 로컬 좌표계(Local Coordinate System), 월드 좌표계(World Corrdinate System)

Transform 구조를 이루고 있는 스프라이트가 있을 때, 월드 좌표계는 스프라이트의 절대적인 위치를 의미하고 로컬 좌표계는 부모 Transform을 기준으로 적용되는 상대적인 위치를 의미한다.

  1. 월드 좌표계 : 게임 세계의 전체적인 참조 프레임을 제공하며, 이는 모든 게임 오브젝트가 공유한다. 월드 좌표계에서의 위치는 게임 환경 내에서 오브젝트의 절대적인 위치를 나타낸다.

  2. 로컬 좌표계 : 개별 게임 오브젝트에 대한 참조 프레임을 제공. 오브젝트의 로컬 좌표계는 해당 오브젝트의 위치, 회전, 크기에 따라 변화한다. 자식 오브젝트의 로컬 좌표는 부모 오브젝트에 대한 상대적인 위치를 나타내며, 부모 오브젝트가 움직이면 그에 따라 자식 오브젝트의 월드 좌표도 변경된다.

🕹️ 플레이어 움직이기

▪️ TopDownCharacterController.cs 만들기

Assets 파일 밑에 Scripts 파일을 만든다. 그 밑에 Controllers라는 파일을 만든다. 그 후, C# Script를 생성한다.

해당 스크립트를 Player에게 넣어준다. (Add Component 혹은 드래그 앤 드랍.)

▪️ 캐릭터 움직이기 - Transform 값 변경하기

// TopDownCharacterController.cs

void Update()
{
    float x = Input.GetAxis("Horizontal");
    float y = Input.GetAxis("Vertical");

    transform.position += new Vector3(x, y, 0);
}

TopDownCharacterController.cs의 Update()에 해당 코드를 작성한다. 해당 코드는 Input.GetAxis()를 통해 키보드 값을 입력받는다. 입력받은 키에 따라 X와 Y 값을 변경해준다.

📌 Input.GetAxis()

Input.GetAxis()는 미리 유니티에서 정해둔 입력값들을 읽어들인다. Edit → Project Settings → Input Manager에서 확인할 수 있다.

위의 코드에서 Input.GetAxis("Horizontal");은 Input Manager에 선언되어 있는 것처럼 좌 우 화살표, 혹은 A, D키 값이 입력될 경우, Horizontal로 인식한다.

📌 Time.deltaTime

하지만 위의 코드를 실행해보면 캐릭터의 움직임이 매우 빠른 것을 확인할 수 있다.

그 이유는, Update() 메서드가 매 프레임마다 호출 되기 때문이다.

그러나 현재 프레임이 400 ~ 500 프레임을 왔다갔다 하기 때문에, 위의 코드는 1초에 500의 거리를 움직일 수 있게 되는 것이다!!!

그리고 무엇보다 컴퓨터 사양에 따라 프레임이 제각기 다르게 때문에, 위 처럼 코드를 작성하게 되면 사양에 따라 형평성에 어긋나는 게임이 되어 버리게 된다. 100 프레임은 1초에 100을, 500프레임은 1초에 500을 움직이기 때문이다.

이러한 문제는 Time.deltaTime을 좌표값에 곱해주어 해결할 수 있다.

Time.deltaTime?

이전 프레임부터 현재 프레임까지 경과 시간을 나타낸다. 예를 들어, 500 프레임일 경우, deltaTime은 1/500초가 된다.
이를 활용하면 게임의 프레임 속도에 상관없이 일정한 시간 간격으로 동작하도록 만들 수 있다!

게임이 매 프레임마다 일정한 속도로 실행되어야 할 때 사용하면 유용하다.

따라서 위의 코드를 수정하면 다음과 같다.

// TopDownCharacterController.cs

void Update()
{
    float x = Input.GetAxis("Horizontal");
    float y = Input.GetAxis("Vertical");

    transform.position += new Vector3(x, y, 0) * Time.deltaTime;
}

📌 SerializeField 속성

현재 캐릭터의 움직임을 변경하기 위해 speed라는 변수를 선언한다고 가정해보자!

public class TopDownCharacterController : MonoBehaviour
{
    private float speed = 5f;

    // Start is called before the first frame update
    void Start()
    {
        
    }

    // Update is called once per frame
    void Update()
    {
        float x = Input.GetAxis("Horizontal");
        float y = Input.GetAxis("Vertical");

        transform.position += new Vector3(x, y, 0) * speed * Time.deltaTime;
    }
}

speed를 private으로 선언했기 때문에 Inspector에 speed는 표시되지 않는다.

하지만 Inspector에서 speed 값을 변경하고 싶을 경우가 있을 수 있다. 이 경우 public 으로 선언을 하면 Inspector 에서 확인할 수 있다.

하지만!!! 변수를 public으로 두면 안될 경우가 있을 수 있다. 게임에 굉장히 중요한 변수일 경우 private으로 설정해둬야 할 필요가 있기 때문이다. 이 경우, SerializeField 속성을 사용할 수 있다.

public class TopDownCharacterController : MonoBehaviour
{
    [SerializeField]private float speed = 5f;

    // Start is called before the first frame update
    void Start()
    {
        
    }

    // Update is called once per frame
    void Update()
    {
        float x = Input.GetAxis("Horizontal");
        float y = Input.GetAxis("Vertical");

        transform.position += new Vector3(x, y, 0) * speed * Time.deltaTime;
    }
}

이 경우, private임에도 불구하고 Inspector 창에서 값을 변경할 수 있게 된다.

값이 변경되는 모습 ▼

🏊 플레이어 이동 구현하기

위의 Input.GetAxis() 말고 다른 플레이어 이동을 구현하는 방식이 있다.

public event Action<Vector2> OnMoveEvent;
public event Action<Vector2> OnLookEvent;

public void CallMoveEvent(Vector2 direction)
{
	// ? : 해당 값이 널인지 확인한다. OnMoveEvent?.Invoke()의 경우, OnMoveEvent가 널이 아니라면 Invoke를 실행한다.
    OnMoveEvent?.Invoke(direction);		
}

public void CallLookEvent(Vector2 direction)
{
    OnLookEvent?.Invoke(direction);
}

위의 코드를 지우고 해당 코드를 작성한다. Event를 통해 해당 메서드들이 이 코드 내에서만 호출되도록 만들어 줄 수 있다.

이후, 유니티의 Windows → Package Manager에서 Input System을 찾아 설치한다.

설치가 완료되면 Assets 밑에 Input이라는 폴더를 생성한다.

그 후, Input 폴더 밑에 Input Action을 추가한다.

추가된 Input Action을 더블 클릭하여 Input Controller를 연다.

Add Scheme을 해준다.


이 기능을 통해 키보드, 마우스 외에도 조이스틱이나 컨트롤러의 입력을 추가하여 받을 수 있다.

이후, Action Map을 생성하여 Player 로 이름을 변경해준다.

그 다음엔 Action을 추가해준다. Action Type은 Value로, Control Type은 Vector 2(반환 받을 값)로 설정한다.
이 후, New action 우측의 +버튼을 눌러 "Add Up\Down\Left\Right Composite"을 추가한다.

이후, Up, Down, Left, Right에 키들을 할당한다.

이후, Move로 Action의 이름을 변경한다.

🏂 액션 추가

액션을 더 추가해보자. Look과 Fire를 추가하자.
Look :

Fire :

완료된 모습!

이후, Scripts → Controllers 밑에 PlayerInputController.cs를 생성한다.

using UnityEngine;
using UnityEngine.InputSystem;

public class PlayerInputController : TopDownCharacterController		// TopDownCharacterController를 상속받는다.
{
    private Camera _camera;

    private void Awake()
    {
        // 태그가 main인 카메라 찾아오기 -> 메인 카메라를 가져온다.
        _camera = Camera.main;
    }

    public void OnMove(InputValue value)
    {
        Debug.Log("OnMove" + value.ToString());
    }

    public void OnLook(InputValue value)
    {
        Debug.Log("OnLook" + value.ToString());
    }

    public void OnFire(InputValue value)
    {
        Debug.Log("OnFire" + value.ToString());
    }
}

이후 코드를 작성한다.

코드 작성을 완료하면, 플레이어한테 Add 되어 있던 TopDownCharacterController.cs를 PlayerInputController로 변경해준다.
그 다음, Player에게 Add Component에서 Player Input을 추가하고 Actions에 TopDown Controller 2D를 넣어준다.

Player Input에서 Behavior 부분을 살펴보면, Send Messages 방식으로 적용이 되어 있으며, 밑에 설명에 OnMove, OnLook, OnFire 메서드가 있으면 실행을 시켜준다는 것을 알 수 있다.


유니티를 실행해보면 Console 창에서 코드에서 작성한 debug log가 출력되는 것을 확인할 수 있다.

이제 PlayerInputController.cs를 마저 작성해보자.

public void OnMove(InputValue value)
{
    Vector2 moveInput = value.Get<Vector2>().normalized;        // normalized : 해당 벡터 값을 단위 벡터로 변경한다.
    CallMoveEvent(moveInput);
}

Vector2(x, y) 값을 Input System을 통해 InputValue로 받아 CallMoveEvent를 통해 값을 넘겨주는 코드이다.
이 때, 벡터 뒤에 .normalized가 붙은 것을 확인할 수 있다.

📌 normalized?

뒤에 .normalized가 없다고 가정해보자. 그럴 경우, 벡터 값 X, Y를 각각 받을 때는 문제가 발생하지 않는다. 그러나, 플레이어가 키를 동시에 눌러서 X와 Y값을 동시에 받을 경우를 가정해보자. 이 때, X 와 Y 값을 동시에 받기 때문에 대각선으로 캐릭터는 움직이게 된다. 그러나 X와 Y의 대각선 값은 X, Y보다 크기 때문에(피타고라스 정리에 의해서) 캐릭터가 대각선으로 움직일 경우 이동 속도가 1.5배 빨라지게 된다!!! 때문에 이 문제를 해결하기 위해 입력값을 일정 단위로 잘라주는 .normalized를 사용하게 되는 것이다.


위의 코드의 경우, .normalized를 통해 Vector2값을 단위 벡터 값으로 받아 사용하게 된다. X, Y값과 대각선 값을 같도록 설정하는 것이다.

🤔 Event와 Subscribe, Observer Pattern

코드를 살펴보면 CallMoveEvent(MoveInput);이라는 코드를 통해 Event를 발생시키는 것을 확인할 수 있다.

TopDownCharacterController.cs에 이벤트들을 구독해놓고, PlayerInputController.cs에서 구독해놓은 이벤트가 발생하면, 이벤트 메서드를 실행하게 되는 것이다.

이러한 Event와 구독을 통해 호출해주는 방식을 옵저버 패턴(Observer Pattern)이라고 한다.

이제 PlayerInputController.cs 를 마저 작성하자.

using UnityEngine;
using UnityEngine.InputSystem;

public class PlayerInputController : TopDownCharacterController
{
    private Camera _camera;

    private void Awake()
    {
        // 태그가 main인 카메라 찾아오기 -> 메인 카메라를 가져온다.
        _camera = Camera.main;
    }

    public void OnMove(InputValue value)
    {
        Vector2 moveInput = value.Get<Vector2>().normalized;        // normalized : 해당 벡터 값을 단위 벡터로 변경한다.
        CallMoveEvent(moveInput);
    }

    public void OnLook(InputValue value)
    {
        Vector2 newAim = value.Get<Vector2>();
        Vector2 worldPos = _camera.ScreenToWorldPoint(newAim);      // 마우스 위치 값(UI 상의 위치 값)을 월드 좌표계로 변경해준다.
        newAim = (worldPos - (Vector2)transform.position).normalized;       // 현재 위치에서 마우스 포인터를 바라보는 방향

        if(newAim.magnitude >= .9f)     // magnitue : 크기. 즉, newAim의 Vector2의 크기를 말한다. 그리고 newAim은 normalized를 했기 때문에 1이다.
        {
            CallLookEvent(newAim);
        }
    }

    public void OnFire(InputValue value)
    {
        Debug.Log("OnFire" + value.ToString());
    }
}

🖱️ 마우스 방향 받아오기!

위 코드의 OnLook()을 살펴보자. 현재 마우스 좌표값을 받아오기 위해 newAim이라는 Vector2 변수를 생성했다.
그리고 worldPos라는 변수를 생성한 것을 확인할 수 있는데, 그 이유는 마우스의 좌표값이 UI의 기준으로 받아지기 때문이다.

마우스의 좌표값을 UI 기준이 아닌 World의 기준으로 변환하기 위해 worldPos를 선언했으며, ScreenToWorldPoint(newAim)을 통해 현재 newAim이 월드 좌표 기준으로 얼마나 떨어져 있는지를 반환받는다.

이후, newAim = (worldPos - (Vector2)transform.position).normalized; 실행하는 데, 이 코드 실행 후 newAim에는 마우스 위치에 대한 방향과 거리가 나오게 된다. 그리고 normalized를 통해 Vector2의 값이 1이 되면서, newAim에는 카메라의 방향만 저장되게 된다! 고 한다...

이후 if문에서 newAim.magnitude 값을 비교하게 되는데, magnitude는 인자로 들어온 벡터의 길이를 반환한다. 즉, newAim의 길이가 0.9보다 클 경우 if문이 실행이 된다는 의미이다. newAim은 normalized를 통해 1의 값이 저장되므로, 해당 if문이 실행되게 된다.

😎 움직임 구현하기!

지금까지 움직임에 대한 Event 처리를 했다. 이제는 해당 Event를 받아 움직임을 처리할 코드를 작성해야 한다.
유니티에서 Scripts 밑에 Entities 폴더를 생성, 그 밑에 TopDownMovement.cs를 만든다.

이 TopDownMovement를 Player에게 연결한 후, 코드를 작성해보자.

TopDownMovemet.cs

TopDownMovement는 TopDownCharacterController에서 controller를 받아올 것이기 때문에 controller를 선언해준다.
그리고 이동할 위치값을 받아올 변수와 물리적인 처리를 위해 Rigidbody 2D를 선언한다.

private TopDownCharacterController _controller;		// _가 붙는 변수는 private을 의미한다.

private Vector2 _movementDirection = Vector2.zero;
private Rigidbody2D _rigidbody;

이후, _controller와 _rigidbody를 받아오기 위해 GetComponent를 사용한다.

private void Awake()
{
	_controller = GetComponent<TopDownCharacterController>();
    _rigidbody = GetComponent<Rigidbody2D>();
}

📌 GetComponent<>()?

유니티에서 오브젝트에 추가된 Component를 가져오기 위해 사용한다. 이를 통해 해당 오브젝트의 Inspector에 있는 Component들을 불러 사용할 수 있다.

이 후, Start()에 OnMoveEvent에 대한 처리를 하자.

private TopDownCharacterController _controller;

private Vector2 _movementDirection = Vector2.zero;
private Rigidbody2D _rigidbody;

private void Awake()
{
    _controller = GetComponent<TopDownCharacterController>(); // GetComponent : GetComponent를 통해 Inspector의 컴포넌트를 가져올 수 있다.
    _rigidbody = GetComponent<Rigidbody2D>();
}

private void Start()
{
	// PlayerInputController에 있는 OnMove를 구독. OnMove를 들을 리스너로 등록.
	_controller.OnMoveEvent += Move;
}

private void FixedUpdate()
{
	// rigidbody를 사용하기 때문에 물리적인 처리가 종료된 후 호출되는 FixedUpdate()를 사용한다.
	ApplyMovement(_movementDirection);
}

private void Move(Vector2 direction)
{
	_movementDirection = direction;		// OnMove() 이벤트가 발생하면 구독되어 있던 Move()가 동작한다.
}
private void ApplyMovement(Vector2 direction)
{
	directioin = direction * 5;
    
    _rigidbody.velocity = direction;		// velocity : 가속도
}

Start()를 통해 해당 컨트롤러가 생성된 후 PlayerInputController에 있는 OnMove를 Subscribe하게 된다.
이후, OnMove 이벤트가 호출되면 구독되어 있던 Move가 동작하고, Move를 통해 _movementDirection이 할당되고 이후 FixedUpdate()가 호출되면 ApplyMovement를 통해 rigidbody에서 움직임을 처리하게 된다.

🎮 캐릭터 움직여 보기!

이제 다시 유니티로 돌아오자. 유니티로 와서 Player에게 Rigidbody 2D(반드시 Gravity Scale = 0으로 변경!)와 TopDownMovement 스크립트를 할당하자.

캐릭터가 움직이는 모습!! ▼

0개의 댓글