캐릭터 만들기

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);
}

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

 

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

충돌을 처리하기 위해서는 Collider와 Rigidbody가 필요하다.

Collider는 충돌을 감지하는 기능을 한다. 모양에 따라 Box, Sphere, Mesh 등 여러가지 형태를 가지고 있다. Rigidbody는 물리적인 법칙을 적용하기 위해 달아주어야한다. Rigidbody는 최소한의 사용을 지향하는 것이 좋기 때문에 주로 변화가 많은 캐릭터에 주는 편이다.

Rigidbody는 Rigidbody가 달려있는 물체가 다른 Collider가 달려있는 물체와 충돌했을 때 충돌에 대해 소통할 수 있게 해준다.

충돌이 발생하면 유니티에서는 OnCollisionEnter, OnCollisionStay, OnCollisionExit 등의 이벤트를 발생시킨다.

 

타일맵(Tilemap)

이미지들을 바둑판 배열 방식으로 맵을 구성하는 것.

 

Hierarchy에 우클릭해서 2D Object - Tilemap에서 Rectangular로 만들어준다.

 

Window - 2D - Tile Palette에서 Create New Pallete를 해준 뒤, 타일 이미지 Assets을 추가한다.

 

적당히 꾸며주고 Collision을 따로 만들어 Tilemap Collider 2D를 추가해준 후, 충돌범위를 지정해주고 투명도를 조절해서 보이지 않게 해준다.

Color에서 투명도 조절

 

플레이어에 Box Collider 2D를 Component 하여 방금 만들어둔 벽면 밖으로 나가지 못하도록 한다.

 

 

조준(Aim) 시스템

무기 Asset을 유니티에 끌어온 뒤, 스크립트를 작성해준다. flipY는 Y축을 기준으로 뒤집는 코드로, 캐릭터를 기준으로 무기가 90도를 넘어가면 뒤집도록 한다. 즉, 마우스 방향으로 캐릭터를 뒤집어준다.

[SerializeField] private SpriteRenderer armRenderer;
[SerializeField] private Transform armPivot;

[SerializeField] private SpriteRenderer characterRenderer;

private TopDownCharacterController _controller; // 캐릭터가 바라보도록 한다

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

void Start()
{
    _controller.OnLookEvent += OnAim;
}

public void OnAim(Vector2 newAimDirection)
{
    RotateArm(newAimDirection);
}

private void RotateArm(Vector2 direction)
{
	float rotZ = Mathf.Atan2(direction.y, direction.x) * Mathf.Rad2Deg;
    
    armRenderer.flipY = Mathf.Abs(rotZ) > 90f; // y축을 기준으로 뒤집는 코드
    characterRenderer.flipX = armRenderer.flipY; // 캐릭터를 기준으로 무기가 90도를 넘어가게 되면 뒤집는다
    armPivot.rotation = Quaternion.Euler(0, 0, rotZ); // 무기 회전
}

 

rotation이라고 하는 회전 값은 Quaternion이라고 하는 4원소 값을 쓰고 있기 때문에 우리가 일반적으로 사용하기 쉽지 않다. 아크 탄젠트로 세타 값이 나오면 Pi값, 즉 라디안 값이 나온다. 그러므로 라디안 값을 오일러(Euler) 값인 degree(도)로 바꾸는 값을 곱해준다.

 

Atan2(x, y)는 아크 탄젠트(역탄젠트)를 구하는 것이다. x와 y 값을 구해서 아크 탄젠트를 구하면 세타( θ)이 나온다. 즉, 벡터의 각도를 구하는 것이다.

 

캐릭터에 스크립트를 Component 하여 각각 지정해준다.

 

 

공격 시스템

공격을 구현하기 위한 스크립트를 생성한다.

보통 Awake에서 Component들을 준비하고 Start나 이후에 그 코드를 활용하는 식으로 구성한다.

 

일단 CharacterController 스크립트에서 Attack을 처리를 추가해준다.

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

    private float _timeSinceLastAttack = float.MaxValue; // 마지막으로 공격한 시간
    protected bool IsAttacking { get; set; } // Attack에 대한 프로퍼티

    protected virtual void Update() // virtual: 하위에서 상속받아 오버라이드에서 쓸 수 있도록 함
    {
        HandleAttackDelay();
    }

    private void HandleAttackDelay() // 공격에 대한 시스템 구현만
    {
        if (_timeSinceLastAttack <= 0.2f) // 나중에 수정
        {
            _timeSinceLastAttack += Time.deltaTime;
        }
        
        if (IsAttacking && _timeSinceLastAttack > 0.2f)
        {
            _timeSinceLastAttack = 0;
            CallAttackEvent();
        }
    }

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

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

    public void CallAttackEvent()
    {
        OnAttackEvent?.Invoke();
    }
}

 

 

CharacterInputController에 OnFire 키 입력값을 받으면 CharacterController의 HandleAttackDelay() 공격을 실행한다.

 private void OnFire(InputValue value)
 {
     IsAttacking = value.isPressed;
 }

 

이제 아까 만들어둔 실제 공격하는 스크립트를 작성한다.

public class TopDown : MonoBehaviour
{
    private TopDownCharacterController _controller;

    [SerializeField] private Transform projectileSpawnPosition;
    private Vector2 _aimDirection = Vector2.right;

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

    // Start is called before the first frame update
    void Start()
    {
        _controller.OnAttackEvent += OnShoot;
        _controller.OnLookEvent += OnAim;
    }

    private void OnAim(Vector2 newAimDirection) // 에임 위치를 잡아준다
    {
        _aimDirection = newAimDirection;
    }

    private void OnShoot() // 실제 공격
    {
        CreateProjectile();
    }

    private void CreateProjectile()
    {
        Debug.Log("Fire");
    }
}


유니티에서 Prefabs 파일을 만든 뒤, 발사체 GameObject를 만들어서 파일에 넣어준다.

Prefab은 유니티에서 오브젝트들을 한 번 만들어놓고 재사용하여 관리하기 쉽게 만들어진 것으로 미리 설정된 값을 그대로 쓸 수 있다. 현재 복제가 되어 나왔기 때문에 원본과는 별개로 동작할 수 있다. 모든 인스턴스를 일괄적으로 업데이트 할 수 있고 다양한 바리에이션을 만들 수 있기 때문에 Prefab으로 대부분의 오브젝트를 만든다.

 

Player에서 임시로 발사체를 받은 뒤, 복사체가 잘 생성되나 테스트하기 위해 TopDownShooting 스크립트에 아래 코드를 추가한다.

public GameObject testPrefab; // 임시 코드

 private void CreateProjectile()
 {
     Instantiate(testPrefab, projectileSpawnPosition.position, Quaternion.identity);
 }

Instantiate()는 동적 생성으로 원본을 받은 것을 복제본으로 만든다. 그런데 현재 원본의 형태가 유니티 오브젝트 형식이다. 오브젝트 형식은 유니티에서 사용하는 대부분의 오브젝트를 최상위로 가지고 있기 때문에 Asset, Material 등 모두 사용할 수 있다.

 

 

+ Recent posts