캐릭터 만들기

Asset에서 캐릭터 그림파일을 추가한 뒤, 빈 오브젝트에 이미지를 넣어준다.

 

이미지 파일의 크기를 조절하는 방법에는 여러가지 방법이 있다.

  • Main Camera - Camera Size 조절

카메라 사이즈가 커지면 캐릭터가 작아보이는 것을 이용한다.

 

  • Scale값 조절

 

  • PPU값 조절

 

 

캐릭터 이동

캐릭터 이동은 이전 글 중 <Unity> 입문 - 2D 게임 개발(2)의 입력과 캐릭터 이동 부분을 참고하자.

Legacy 형태는 짧지만 객체지향에 위배되는 코드이기 때문에 지양하는 것이 좋으며, Input System 방식의 객체지향 형식으로 코드를 쓰는 것이 좋다. 그러나 간단하게 테스트용으로 쓰이기도 하므로 알아두는 것이 좋다.

 

방 만들기

방 만들기 역시 <Unity> 입문 - 2D 게임 개발(3)의 타일맵 부분을 참고하면 된다.

캐릭터의 Rigidbody 2D와 타일의 Collider가 충돌이 일어나면 실제 물리 충돌이 일어나는데 그로 인해 캐릭터가 빙글빙글 도는 현상이 일어난다. 그것을 방지하기 위해 Rigidbody 2D - Constraints - Freeze Rotation의 Z을 체크해준다.

 

타일맵을 찍을 때 Edit - Preference - Tile Palette에서 기본툴 외의 다른 툴들을 추가해서 사용할 수 있다.

 

캐릭터 애니메이션 추가

애니메이터 창을 열어 기본 상태 애니메이션에 걷는 애니메이션을 추가해준 뒤, 서로 연결해준다.

 

Stand에서 Walk로 가는 Transition을 클릭하여 Has Exit Time 체크를 해제하고, Conditions를 추가해서 Walk를 true로 바꿔준다.

 

Walk에서 Stand로 가는 Transition 마찬가지로 Has Exit Time 체크를 해제하고, 이번엔 Walk를 false로 바꿔준다.

Has Exit Time는 종료 시점을 활성화하는 옵션으로 종료 시점의 비율만큼 애니메이션이 재생된 후에 다음 상태로 전이된다. 즉시 재생시키고 싶다면 비활성화 해야한다.

 

이제 위에서 만든 애니메이션을 관리하기 위해 스크립트를 만든다.

움직일 때 Walk로 전환 즉, 키를 입력받았을 때 전환시키려면 InputController가 필요하기 때문에 컨트롤러를 불러온다.

PlayerInputController controller;

[SerializeField] private Animator anim;

private void Awake()
{
    controller = GetComponent<PlayerInputController>();
}

void Start()
{
    controller.OnMoveEvent += Animation;
}

void Animation(Vector2 direction)
{
    anim.SetBool("walk", direction.magnitude > 0f);
}

 

 

알아두면 좋은 벡터값 계산

  • 덧셈

(Ax + Bx), (Ay + By)

 

  • 뺄셈

(Ax - Bx), (Ay - By)

A벡터에서 B벡터를 뺐더니 B에서 A로 향하는 벡터가 만들어졌다.

실제로 이 계산은 플레이어를 쫓아가는 몬스터의 방향과 거리를 알 수 있는 등 게임에서 많이 쓰인다.

 

  • 곱셈

(x * n), (y * n)

벡터에 숫자를 곱하면 방향은 바뀌지 않고 크기만 변한다.

벡터의 크기가 클 수록 속도가 빠르다고 볼 수 있다. 백터에 어떠한 숫자를 곱함으로 속도를 빠르거나 느리게 할 수 있다.

 

 

몬스터에서 플레이어를 향한 벡터값을 구하기 위해 플레이어의 좌표에서 몬스터 좌표를 빼면 방향과 크기를 알 수 있다. 그러나 이 좌표값으로 계산하면 방향과 크기가 한 번에 구해지기 때문에 몬스터가 느린지 빠른지 구분 할 수 없다.

그래서 방향을 유지하며 크기를 전부 1로 바꾼 뒤, 속도를 곱해주면 속도에 따라 벡터의 크기가 달라지게 된다. 이것을 정규화(normalized)라고 한다. 이때 방향만 남은 크기가 1인 벡터를 단위벡터라고 한다.

normalized가 방향만 알아낼 수 있다면 반대로 magnitued는 방향을 무시하고 크기만 알아낼 수 있다.

과제를 하는 중에 WorldtoScreenPoint를 ScreenToWorldPoint로 오타내서 Flip이 잘 되지 않는 현상이 나타났다.

차이점이 궁금하여 찾아본 결과

ScreenToWorldPoint( ) : 화면 좌표(스크린 좌표)를 절대 좌표(월드 좌표)로 변환

WorldtoScreenPoint( ) : 월드 좌표를 화면 좌표로 변환

 

여기서 생각해야 할 점은 화면 좌표는 상대 좌표가 아니라는 점이다. (일단 저는 처음에 그렇게 이해해서 헷갈렸습니다...)

화면 좌표는 0, 0은 게임 화면의 좌측 하단이다. 아래 코드는 Player에 넣은 스크립트로, Player GameObject 자체가 월드 좌표이다. 즉, 마우스 위치를 Player라는 월드 좌표로 바꿔주어야 캐릭터를 기준으로 마우스의 벡터값을 계산할 수 있다.

private void OnLook(InputValue value)
{
    // Debug.Log("OnLook" + value.ToString());
    Vector2 newAim = value.Get<Vector2>(); // 마우스 포지션을 받아온다
    Vector2 worldPos = _camera.ScreenToWorldPoint(newAim); // 마우스 위치를 절대 위치로 바꾼다
    newAim = (worldPos - (Vector2)transform.position).normalized; // 캐릭터와 마우스 커서까지의 거리와 방향
    CallLookEvent(newAim);
}

이미 화면 좌표인 마우스 좌표를 또다시 스크린 포지션으로 변환시키려 해서 이상한 값이 나왔던 것이다.

 

어려운 개념이라는데 이해해서 뿌듯하당

유니티에서 이미지를 import하면 기본적으로 PPU(Pixels Per Unit)값이 100이다.

PPU값이 100이라는 의미는 Unity의 단위인 1유닛 안에 100 픽셀이 들어간다는 뜻이다. 즉, PPU값이 작을수록 1유닛 안에 차지하는 픽셀수가 줄어들기 때문에 이미지가 픽셀화 되며 커진다. PPU값은 이미지들끼리 통일해주는 것이 좋으며 높아질수록 고해상도가 되어 성능에도 영향을 미치기 때문에 값을 고려해야한다.

 

GameObject는 앞서 말한 것처럼 부모-자식 관계를 가지며, Transform 또한 마찬가지다.

부모인 Transform의 Position값을 바꾸면 자식인 MainSprite의 값도 따라온다. 부모의 절대 위치(World Position)가 기준이 되어 자식의 상대 위치(Local Position)가 절대 위치와 달라질 수 있는 것이다.

예를 들어, 자동차를 만든다면 핸들, 바퀴 등의 전부 다른 오브젝트들을 똑같이 이동해야하는 경로를 계산하여 만들어주기 번거롭기 때문에 자동차라는 큰 틀 아래에 핸들은 핸들 위치에, 바퀴는 바퀴 위치에 부품들을 자식으로 넣어주는 것이다.

포지션을 얻을 때 .position을 해주면 절대 위치를 가져오고, .localpostion을 해야 상대 위치를 가져온다.

 

유니티에서 오브젝트에 연결해준 스크립트를 열면 MonoBehaviour를 상속 받은 것을 볼 수 있다.

Start는 가장 첫 프레임에 동작하며 Update는 매 프레임마다 동작한다.

 

 

입력과 캐릭터 이동

캐릭터를 이동시키는 방법에는 여러가지가 있다.

Transform을 직접 좌표로 움직이는 방법이 있고 Rigidbody를 사용해서 물리적인 이동 처리를 하는 방식이 있다.

 

먼저 Edit - Project Settings - Player - Other Settings에서 Configuration의 Active Input Handling을 Both로 바꿔주자.

 

Transform을 직접 좌표로 움직이는 방법(Legacy - 절차지향)

Update()에 해당 코드를 입력한다.

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

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

GetAxis가 가로 "Horizontal"와 세로 "Verticla"을 지칭하며 가져오고, 절대 위치인 transform.position에 새로운 Vecter3()를 만들며 x, y 좌표를 더해준다.

 

Edit - Project Settings - Input Manager에서 보면 "Horizontal"은 Left와 Right를, "Verticla"은 Down와 Up 방향을 자동으로 처리해준다. 즉, Axis(축) 값을 가지고 왼쪽, 오른쪽, 위, 아래 라는 값을 반환 받고 있는 것이다.

필요하다면 이곳에서 엑세스를 더 추가하거나 Input 클래스에 있는 GetKeyDown으로 각각의 개별적인 키 입력을 받을 수 있다.

 

그런데 이대로 실행하면 캐릭터를 이동했을 때, Update에 입력해주었기 때문에 매 프레임마다 호출하여 1초에 200~300만큼 이동하게 되어 너무 빠르다.

280프레임

성능이 다른 컴퓨터마다 프레임이 다르므로 속도 또한 제각각일 것이다. 그렇기 때문에 deltaTime을 곱해준다.

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

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

deltaTime은 이전 프레임과 현재 프레임 사이의 시간이다. 게임의 프레임 속도에 상관없이 일정한 시간 간격으로 동작하게 해준다.

10프레임과 60프레임이 있다고 가정했을 때, 프레임과 프레임 사이가 10프레임은 10/1, 60프레임은 60/1일 것이다. 그런데 1초가 지나면 10/1 * 10, 60/1 * 60이 되어 결과적으로 1이 되기 때문에 같아진다. 10프레임은 1초 동안 10번 쪼개서 움직이고 60프레임은 1초 동안 60번 쪼개서 움직인 것이다. 그렇기 때문에 deltaTime을 곱하는 것이다.

deltaTime은 실제 누적 시간 또한 처리할 수 있다.

GetAxis는 0부터 1까지 도달하는 가속도가 붙는 느낌을 주며, GetAxisRaw는 0, 1, -1 딱 떨어지는 값으로 주기 때문에 스무싱 값이 없다.

float x = Input.GetAxisRaw("Horizontal");
float y = Input.GetAxisRaw("Vertical");

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

 

speed를 public값으로 주면 동기화 되어 유니티 Inspector에서 마음대로 조절할 수 있다.

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

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

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

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

 

 

만약 중요한 값이기 때문에 public으로 하고 싶지 않다면 [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.GetAxisRaw("Horizontal");
        float y = Input.GetAxisRaw("Vertical");

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

 

 

Rigidbody를 사용한 물리적인 이동 처리 방법(Input System - 객체지향)

이벤트 Action을 이용해보자.

public class TopDownCharacterController : MonoBehaviour
{
    // event : 외부에서 호출하지 못하게 막아주는 기능
    public event Action<Vector2> OnMoveEvent;
    public event Action<Vector2> OnLookEvent;

    public void CallMoveEvent(Vector2 direction)
    {
        OnMoveEvent?.Invoke(direction); //?. : OnLookEvent이 Null이 아닐 때만 동작
    }

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

?.은 Move 이벤트나 Look 이벤트에 액션이 잘 걸려있을 때만 동작하도록 한다. event는 이벤트들을 외부에서 호출(Invoke)하지 못하게 막아주는 기능이다. 위의 코드에서 보다시피 본인만 호출하고 있다.

 

이제 플레이어의 Input을 처리할 수 있도록 만들어주기 위해 유니티에 Input System을 추가적으로 설치 해준다. Input System은 조이스틱이나 다른 컨트롤러 등까지 호환되기 때문에 자주 사용된다.

Window - Package Manager에서 Uniy Registry에 보이는 Input System을 Install 한다.

유니티를 재시작한 뒤, Asset에 Input 폴더를 생성하여 Create - Input Action을 추가한다.

 

추가한 컨트롤러를 열고 Add Control Scheme에서 +를 눌러 키보드와 마우스를 생성한다.

혹시나 클릭이 안된다면 해상도 문제일 수 있다.

Action Type을 Value로, Control Type을 Vector2로 바꿔준다. 이 액션을 취했을 때, 돌려받는 값이 Vector2인 것이다.

 

New action에서 아까 추가한 Up&Down&Right&Left를 누르고 키를 설정해준다.

여기까지 Action을 연결할 준비가 되었다.

 

입력 처리를 하기 위해 Player와 연결 시켜줄 스크립트를 생성하여 Input Action 스크립트를 상속시킨 뒤, 유니티 자체 엔진 클래스인 Camera를 객체로 불러온다.

public class PlayerInputController : TopDownCharacterController
{
    private Camera _camera;

    private void Awake()
    {
        _camera = Camera.main; // Camera 씬에서 태그가 main인 코드를 찾아오겠다 = 마우스와 캐릭터 포지션 위치 확인
    }
    
    private void OnMove(InputValue value)
    {
        Debug.Log("OnMove" + value.ToString());
    }

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

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

 

Send 메세지 방식은 만들어진 액션에 On을 붙이면 실행됐을 때, 돌려받는 형식이다. 확인하기 위해 Debug 해준다.

Player 오브젝트에 방금 작성한 스크립트와 Player Input을 컴포넌트 한 뒤, Actions에 설정해뒀던 방향 컨트롤러를 끌어다 놓고 플레이하여 방금 설정한 값들(wasd, 방향키, 마우스 등)을 입력하면 Console창에 뜬다.

 

값이 입력되는 것을 확인하고 다시 돌아와서 코드를 완성시킨다.

normalized를 하는 이유는 방향키를 두 개 동시에 입력했을 때, x와 y축 값이 합쳐져 대각선 방향의 속도가 빨라지기 때문에 그것을 방지하기 위해 단위 벡터로 만들어 주는 것이다.

private void OnMove(InputValue value)
{
    // Debug.Log("OnMove" + value.ToString());
    Vector2 moveinput = value.Get<Vector2>().normalized;
    CallMoveEvent(moveinput); // 움직일 때 벡터 값을 불러온다
}

private void OnLook(InputValue value)
{
    // Debug.Log("OnLook" + value.ToString());
    Vector2 newAim = value.Get<Vector2>(); // 마우스 포지션을 받아온다
    Vector2 worldPos = _camera.ScreenToWorldPoint(newAim); // 마우스 위치를 절대 위치로 바꾼다
    newAim = (worldPos - (Vector2)transform.position).normalized; // 캐릭터와 마우스 커서까지의 거리와 방향

    //if (newAim.magnitude >= .9f) // magnitude : 크기(normalized 했기 때문에 1) 혹시 모를 오류(0, 0처럼 0으로 나누는 경우) 방지를 위해 제한
    //{
    //    CallLookEvent(newAim);
    //} // 이미 normalized 해주었기 때문에 안해도 됨. 강의에서 실수한 듯!
}

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

Move, Look, Fire 이벤트들을 어떤 행동할 때 알려주는 것을 걸어놓는 것을 '구독한다(Subscribe)'라고 표현한다. CallMoveEvent에서 이벤트 액션들을 Invoke하면 구독한 것들에 신호를 준다. 이것을 Observer 패턴이라고 한다.

 

이제 이동을 처리하는 스크립트를 만들어준다.

GetComponent는 Inspector 안에서 Component끼리 서로 인지할 수 있는 방법으로 상위의 Compenent인 TopDownCharacterController를 가져온다. 같은 방식으로 Rigidbody를 가져온다. 키보드 값이 입력되면 ApplyMevment에서 direction을 받아오고 속도를 곱해서 가속도를 저장해준다. 그리고 rigidbody가 그 가속도만큼 움직인다.

FixedUpdate()는 Update()보다 호출이 느리기 때문에 물리처리가 끝난 이후에 호출이 되도록 한다.

 private TopDownCharacterController _controller;

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

 private void Awake()
 {
     _controller = GetComponent<TopDownCharacterController>(); // GetComponent : Inspector 안의 Compenent를 가져온다
     _rigidbody = GetComponent<Rigidbody>();
 }

 private void Start()
 {
     _controller.OnMoveEvent += Move; // Move 구독
 }

 private void FixedUpdate() // 물리처리 이후에 이동처리 메서드 호출
 {
     ApplyMovment(_movementDirection);
 }

 private void Move(Vector2 direction)
 {
     _movementDirection = direction; // 키보드 입력값 설정
 }

 private void ApplyMovment(Vector2 direction) // 이동처리
 {
     direction = direction * 5; // 5라는 속도로 이동

     _rigidbody.velocity = direction;
 }

 

_controller.OnMoveEvent에 Move를 구독했기 때문에 키보드를 눌렀을 때, PlayerInputController의 상위인 TopDownCharacterController에 전달하고 거기서 구독하고 있는 TopDownMovement가 실행된다.

Player Input에서 키보드 입력을 받으면 CharacterController를 부르고 구독하고 있는 Move로 movement가 들어오게 된다.

 

 

+ Recent posts