ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 내일배움캠프 18일차 TIL - 2D게임 기초 개발 입문 1
    TIL/Unity 2024. 5. 9. 20:48

     

    [학습목표]

    Top-Down 게임을 제작하면서 유니티 2D 게임 개발 기초를 학습한다. 강의를 시청하고 입력과 캐릭터 이동, Input System, 충돌 처리와 타일맵, 쿼터니언과 조준 시스템, 공격시스템, 스텟 시스템 등에 대해 학습하고 구현한다.

    [학습내용]

    유니티 공부가 본격적으로 시작된 거 같다. 이번주에 강의를 완강하는 것을 목표로 열심히 들었다. 오늘부터는 Top-Down 뷰로 구성된 슈팅게임을 구현한다.

     

    개요

    클래스 구조 (메인)

     

    클래스 구조 (기타)

     

    갈수록 프로젝트가 복잡해지기 때문에 이런 도식화가 필요해지는 걸 느끼는 거 같다. 대충 둘러보면 입력과 관련된 클래스, 공격과 관련된 클래스, 애니메이션과 관련된 클래스, 에너미 시스템과 관련된 클래스 등이 보인다. 오브젝트 풀도 배울 수 있을 것으로 보인다.

     

     

    유니티 스크립트

    • 유니티 스크립트는 MonoBehaviour를 상속받은 클래스를 통해 작동한다. 필요한 변수, 함수 등을 정의하고 구현한다.
      필요한 경우 Start(), Update()등을 오버라이딩 할 수 있다.
    • 실행 순서가 정해진 메소드들이 있다. 주로 게임 오브젝트의 생성, 초기화, 업데이트, 파괴 등을 수행한다.
      유니티 스크립트 라이프 사이클

    중요한 기능

    • PPU(Pixel Per Unit): 스프라이트의 픽셀 수와 그것이 차지하는 공간의 관계를 설명한다. PPU 값이 클수록 스프라이트는 작아진다. 성능에 관계있으므로 적당한 값이 중요하다.
    • SerializeField: private로 선언된 변수를 인스펙터에서 직접 수정 가능한 필드로 표시해준다.

     

    캐릭터 이동 기본 코드

    using UnityEngine;
    
    public class InputManagerMovement : MonoBehaviour
    {
        Rigidbody2D rigidbody;
        
        [SerializeField] private float speed;
        
        void Start()
        {
            rigidbody = GetComponent<Rigidbody2D>();
        }
    
        void Update()
        {
            float vertical = Input.GetAxis("Vertical");
            float horizontal = Input.GetAxis("Horizontal");
    
            Vector2 direction = new Vector2(horizontal, vertical);
            direction = direction.normalized; // normalized는 벡터의 길이를 1로 만들어주는 정규화
    
            rigidbody.velocity = direction * speed;
        }
    }

     

     

    Input System

    위의 기초적인 InputManager로는 한계가 있다. 다양한 플랫폼에 대응하거나, 키를 리바인딩 하는 등의 기능이 모자란 부분이 있고, 한 클래스에 모든 게 구현되어 있어, 단일 책임 원칙(SRP)과 거리가 먼 구조이다.

     

    새로운 Input System의 핵심 개념

    • Input Action: 입력 행동을 정의한다. 예) 점프, 공격
    • Input Action Asset: 여러 입력 행동을 그룹화하고, 이를 통해 재사용이 가능해진다.
    • Player Input Component: 자동으로 입력을 처리하고 해당 게임 오브젝트에 메시지를 보내는 컴포넌트

    Input System을 사용하려면 Package를 추가해야 한다. 프로젝트의 경량화를 위해 빼놓은 것 같다.

    Package Manager에서 추가

     

    Input System의 다양한 옵션

    • Action Type: 입력을 어떻게 받는가
      • Value: 일반적인 상태에 사용. 다양한 컨트롤에 대응
      • Button: 눌렀을 때 발생하는 액션에 사용. Control Type이 Button으로 고정
      • Pass-Through: 명확화(Disambiguation)를 거치지 않은 value.
    • Control Type: 입력 데이터가 어떤 식으로 들어오는가
      • Axis(float)
      • Button(0 or 1)
      • Vector2

     

    Input Action 생성, 수정하기

    Create - Input Action

     

    Input Action 설정창

     

    우측 상단에서 Add Control Scheme을 통해 Control Scheme에 키보드와 마우스를 추가해줘야 한다.

    이후 Action Map을 추가해주고, Action Type과 Control Type을 설정해 줬다.

    Move, Look: Value/Vector2

    Fire: Value/Any

    그리고 Move에 +버튼을 눌러 바인딩으로 Add Up/Down/Left/Right Composite를 설정해줬다.

     

    플레이어 이동 관련 스크립트

    using System;
    using UnityEngine;
    
    
    public class TopDownController : MonoBehaviour
    {
        public event Action<Vector2> OnMoveEvent;
        public event Action<Vector2> OnLookEvent;
    
    
        public void CallMoveEvent(Vector2 direction)
        {
            OnMoveEvent?.Invoke(direction);
        }
    
        public void CallLookEvent(Vector2 direction)
        {
            OnLookEvent?.Invoke(direction);
        }
    
    }

    TopDownController.cs

     

    using UnityEngine;
    using UnityEngine.InputSystem;
    
    public class PlayerInputController : TopDownCharacterController
    {
        private Camera _camera;
        private void Awake()
        {
            _camera = Camera.main;
        }
    
        public void OnMove(InputValue value)
        {
            // Debug.Log("OnMove" + value.ToString());
            Vector2 moveInput = value.Get<Vector2>().normalized;
            CallMoveEvent(moveInput);
        }
    
        public 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)
    				// Vector 값을 실수로 변환
            {
                CallLookEvent(newAim);
            }
        }
    
        public void OnFire(InputValue value)
        {
            Debug.Log("OnFire" + value.ToString());
        }
    }

    PlayerInputController.cs

     

    using UnityEngine;
    
    public class TopDownMovement : MonoBehaviour
    {
        private TopDownController movementController;
        private Rigidbody2D movementRigidbody;
    
        private Vector2 movementDirection = Vector2.zero;
    
        private void Awake()
        {
            movementController = GetComponent<TopDownController>();
            movementRigidbody = GetComponent<Rigidbody2D>();
        }
    
        private void Start()
        {
            movementController.OnMoveEvent += Move;
        }
    
        private void FixedUpdate()
        {
            ApplyMovement(movementDirection);
        }
    
        private void Move(Vector2 direction)
        {
            movementDirection = direction;
        }
    
        private void ApplyMovement(Vector2 direction)
        {
            direction = direction * 5;
    
            movementRigidbody.velocity = direction;
        }
    }

    TopDownMovement.cs

     

    이 세 스크립트는 다음과 같은 순서로 진행된다.

    1. Player 게임 오브젝트에 추가한 PlayerInput 컴포넌트가 PlayerInputController의 OnMove() 호출
      이 때 OnMove()라는 정해진 메소드 이름을 사용
    2. OnMove()가 TopDownController의 CallMoveEvent()를 호출
    3. CallMoveEvent()가 OnMoveEvent에 등록된 이벤트를 탐색
    4. TopDownMovement로부터 등록되어 있는 Move() 실행
    5. 움직일 방향 설정 완료
    6. FixedUpdate() 때 ApplyMovement()를 호출해 실제 이동 실행

     

    타일맵

    2D 게임에서 자주 사용되는 타일 형태 맵을 제작할 수 있도록 Tilemap 시스템이 있다.

     

    구성 요소

    • Tilemap GameObject: 특정 타일의 배치를 관리한다. Tilemap Grid의 자식으로 위치한다.
    • Grid GameObject: 타일맵이 위치할 기본 격자
    • Tilemap Renderer: 모양 그리는 렌더러
    • Tilemap Collider 2D: 물리적인 경계를 통해 상호작용 가능하게 한다.
    • Tile Assets: 개별타일의 모양과 동작을 정의한다. 여러 타일을 묶어 Tileset이라고 한다.

    타일맵 그리기

    Inspector 창에서 Create - 2D Object - Tilemap - Rectangular

    기본적으로 Grid 밑에 생성

    Window - 2D - Tile Palette로 타일 파레트 창을 띄울 수 있다.

    Create New Palette를 누르고 스프라이트를 드래그하여 파레트 안으로 넣는다.

     

     

    조준 시스템

    조준 시스템을 구현하기 위해서는 먼저 쿼터니언과 삼각함수(주로 아크탄젠트)를 알아야 한다.

     

    쿼터니언

    • 4차원 복소수를 이용한 회전 표현 방법. (x, y, z, w)의 형태로 표현
    • 기존 오일러각(Euler Angle) 방식은 짐벌락(Gimber Lock) 문제 발생 가능성 존재
    • 짐벌락이란 과한 회전 등에 의해 여러 축이 서로 겹쳐졌을 때 컴퓨터가 처리하지 못하는 문제
    • 유니티는 Vector3를 통한 오일러각으로 표현되지만 시중의 많은 소프트웨어는 쿼터니언 사용
    • 값은 직접 건드리지 않는 것이 좋음
    • 주요 메소드
      • Quaternion.Euler(0f, 0f, 90f): 오일러각을 쿼터니언으로 변경
      • Quaternion.LookLotation: 앞과 위를 특정한 방향으로 하는 회전 쿼터니언 생성
      • Quaternion.Slerp: 쿼터니언과 다른 쿼터니언 사이의 내분점, 예) Q1과 Q2 사이 30% 지점은?

     

    삼각함수와 아크탄젠트

    삼각함수는 알려진 한 각도를 끼고 있는 두 변의 비율을 알기 위해 사용하는 함수다. 탄젠트는 그 중에서도 밑변과 높이를 이용하기 때문에 이를 좌표로 사용할 수 있다.

    반대로 역삼각함수는 비율을 통해 각도를 구하는 함수이다. 아크탄젠트는 그렇다면 비율이 좌표이기 때문에 좌표를 통해 각도를 구할 수 있다.

    또한 좌표의 x, y값의 부호를 통해 360도를 다 구분해 낼 수 있다.

    Mathf.Atan2(y, x)와 같은 방식으로 사용하며, y와 x의 위치에 유의해야 한다.

    라디안의 형태로 값을 반환하며, Mathf.Rad2Deg를 곱해주어 디그리 형태로 바꿔줘서 사용한다.

     

    using UnityEngine;
    
    public class TopDownAimRotation : MonoBehaviour
    {
        [SerializeField] private SpriteRenderer armRenderer;
        [SerializeField] private Transform armPivot;
    
        [SerializeField] private SpriteRenderer characterRenderer;
    
        private TopDownController _controller;
    
        private void Awake()
        {
            _controller = GetComponent<TopDownController>();
        }
    
        void Start()
        {
            _controller.OnLookEvent += OnAim;
        }
    
        public void OnAim(Vector2 newAimDirection)
        {
            // OnLook
            RotateArm(newAimDirection);
        }
    
        private void RotateArm(Vector2 direction)
        {
            float rotZ = Mathf.Atan2(direction.y, direction.x) * Mathf.Rad2Deg; // Rad2Deg는 Radian을 Degree로 바꿔줌
    
            characterRenderer.flipX = Mathf.Abs(rotZ) > 90f;
            
            armPivot.rotation = Quaternion.Euler(0, 0, rotZ);
        }
    }

    TopDownAimLotation.cs

     

     

    공격 시스템

    마우스 클릭을 통해 실제 탄알을 발사하는 기능을 만든다. 아직 날아가진 않고 생성까지만 구현한다.

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

    PlayerInputController.cs 수정

     

    public event Action OnAttackEvent;
    
    private float timeSinceLastAttack = float.MaxValue;
    protected bool IsAttacking { get; set; }
    
    protected virtual void Update()
    {
        HandleAttackDelay();
    }
    
    private void HandleAttackDelay()
    {
        if(timeSinceLastAttack <= 0.2f)
        {
            timeSinceLastAttack += Time.deltaTime;
        }
        
        if(IsAttacking && _timeSinceLastAttack > 0.2f)
        {
            timeSinceLastAttack = 0;
            CallAttackEvent();
        }
    }

    TopDownController.cs 코드 추가

     

    using UnityEngine;
    
    public class TopDownShooting : MonoBehaviour
    {
        private TopDownController controller;
    
        [SerializeField] private Transform projectileSpawnPosition;
        private Vector2 aimDirection = Vector2.right;
    
        public GameObject testPrefab;
    
        private void Awake()
        {
            controller = GetComponent<TopDownController>();
        }
    
        void Start()
        {
            controller.OnAttackEvent += OnShoot;
            controller.OnLookEvent += OnAim;
        }
    
        private void OnAim(Vector2 newAimDirection)
        {
            aimDirection = newAimDirection;
        }
    
        private void OnShoot()
        {
            CreateProjectile();
        }
    
        private void CreateProjectile()
        {
            Instantiate(testPrefab, projectileSpawnPosition.position, Quaternion.identity);
        }
    }

    TopDownShooting.cs

     

    탄알(Arrow)은 프리펩으로 만들고 Instantiate 하는 방식을 사용한다.

    보통 프리펩을 만들 땐 하위 게임 오브젝트에 그 기능을 구현하는 방식이 선호된다고 한다.

    Arrow 프리펩

     

     

    스크립터블 오브젝트(Scriptable Object, SO)

    • 유니티에서 데이터를 저장하고 관리하는 유연한 데이터 컨테이너
    • 재사용 가능한 데이터/설정을 저장
    • 에디터와 통합되어 인스펙터 창에서 수정, 관리 가능

     

    먼저 AttackSO를 만들고, 그걸 상속받아 원거리 공격용 RangedAttackSO를 만든다.

    using UnityEngine;
    
    [CreateAssetMenu(fileName = "DefaultAttackSO", menuName = "TopDownController/Attacks/Default", order = 0)]
    public class AttackSO : ScriptableObject
    {
        [Header("Attack Info")]
        public float size;
        public float delay;
        public float power;
        public float speed;
        public LayerMask target;
    
        [Header("Knock Back Info")]
        public bool isOnKnockback;
        public float knockbackPower;
        public float knockbackTime;
    }

    AttackSo.cs

     

    using UnityEngine;
    
    [CreateAssetMenu(fileName = "RangedAttackSO", menuName = "TopDownController/Attacks/Ranged", order = 1)]
    public class RangedAttackSO : AttackSO
    {
        [Header("Ranged Attack Data")]
        public string bulletNameTag;
        public float duration;
        public float spread;
        public int numberofProjectilesPerShot;
        public float multipleProjectilesAngel;
        public Color projectileColor;
    }

    RangedAttackSO.cs

     

    다음으로, 캐릭터 스탯을 저장할 CharacterStat을 만든다.

    using System;
    using UnityEngine;
    
    public enum StatsChangeType
    {
    	Add, // 0
    	Multiple, // 1
    	Override, // 2
    }
    
    [Serializable]
    public class CharacterStat
    {
    	public StatsChangeType statsChangeType;
    	[Range(1, 100)] public int maxHealth;
    	[Range(1f, 20f)] public float speed;
    	public AttackSO attackSO;
    }

    CharacterStat.cs

     

    using UnityEngine;
    using System.Collections.Generic;
    
    public class CharacterStatHandler : MonoBehaviour
    {
        [SerializeField] private CharacterStat baseStats;
        public CharacterStat CurrentStat { get; private set; }
        public List<CharacterStat> statsModifiers = new List<CharacterStat>();
    
        private void Awake()
        {
            UpdateCharacterStat();
        }
    
        private void UpdateCharacterStat()
        {
            AttackSO attackSO = null;
            if (baseStats.attackSO != null)
            {
                attackSO = Instantiate(baseStats.attackSO);
            }
    
            CurrentStat = new CharacterStat { attackSO = attackSO };
            CurrentStat.statsChangeType = baseStats.statsChangeType;
            CurrentStat.maxHealth = baseStats.maxHealth;
            CurrentStat.speed = baseStats.speed;
    
        }
    }

    CharacterStatHandler.cs

     

    지금은 핸들러에서 스탯 보정 작업을 구현하지 않아, 기본 스탯값으로 초기화하는 기능만 존재한다.

    나중에 구현하게 되면 역할이 생길 것이다.

     

    SO 데이터 생성

    Create - TopDownController - Attacks - Ranged로 생성

    인스펙터 창에서 SO 세부 설정

     

    [결과물]

    인게임 화면

    [회고]

    어제는 쉬어가는 느낌이 강했는데, 오늘은 많이 빡빡했다. 내일도 오늘과 비슷한 양을 학습해야 이번 주 안에 강의를 끝낼 수 있을 거 같다. 그래도 배운 게 많아서 뿌듯하긴 하다. 머릿속에 다 들어온지는 모르겠지만.