Collider는 충돌을 감지하는 기능을 한다. 모양에 따라 Box, Sphere, Mesh 등 여러가지 형태를 가지고 있다. Rigidbody는 물리적인 법칙을 적용하기 위해 달아주어야한다. Rigidbody는 최소한의 사용을 지향하는 것이 좋기 때문에 주로 변화가 많은 캐릭터에 주는 편이다.
Rigidbody는 Rigidbody가 달려있는 물체가 다른 Collider가 달려있는 물체와 충돌했을 때 충돌에 대해 소통할 수 있게 해준다.
충돌이 발생하면 유니티에서는 OnCollisionEnter, OnCollisionStay, OnCollisionExit 등의 이벤트를 발생시킨다.
Window - 2D - Tile Palette에서 Create New Pallete를 해준 뒤, 타일 이미지 Assets을 추가한다.
적당히 꾸며주고 Collision을 따로 만들어 Tilemap Collider 2D를 추가해준 후, 충돌범위를 지정해주고 투명도를 조절해서 보이지 않게 해준다.
플레이어에 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() 공격을 실행한다.
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 등 모두 사용할 수 있다.
유니티에서 이미지를 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만큼 이동하게 되어 너무 빠르다.
성능이 다른 컴퓨터마다 프레임이 다르므로 속도 또한 제각각일 것이다. 그렇기 때문에 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 한다.
추가한 컨트롤러를 열고 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()보다 호출이 느리기 때문에 물리처리가 끝난 이후에 호출이 되도록 한다.
Game 뷰 : 게임이 실제로 실행되는 뷰. 플레이어가 게임을 플레이하는 화면을 실시간으로 확인한다.
Hierarchy 뷰 : 현재 씬의 게임 오브젝트 계층 구조 표시 및 편집
Inspector 뷰 : 선택된 게임 오브젝트의 속성 및 구성 요소 편집
Project 뷰 : 프로젝트의 에셋 표시 및 관리
Console 뷰 : 게임 실행 중의 로그 및 메시지 표시(* 오류 확인을 위해 켜두는 것이 좋다.)
마우스 조작
마우스 왼쪽 클릭 : 게임오브젝트 선택
마우스 오른쪽 클릭 : w,s,a,d,q,e 키로 씬 뷰를 1인칭 이동가능, 드래그시 씬 뷰 회전
마우스 스크롤 : 줌 인 & 아웃
마우스 스크롤 클릭 : 씬 뷰 이동
alt + 마우스 왼쪽 드래그 : 씬 뷰 화면 중심 회전
alt + 마우스 오른쪽 드래그 : 줌 인 & 아웃
툴바
Hand Tool (손 도구, 단축키 : Q) : 씬 뷰를 이동시킴.
Move Tool (이동 도구, 단축키 : W) : 게임오브젝트 이동
Rotate Tool (회전 도구, 단축키 : E) : 게임오브젝트 회전
Scale Tool (스케일 도구, 단축키 : R) : 게임오브젝트 스케일 조절
Rect Tool (사각형 도구, 단축키 : T) : 게임오브젝트 스케일을 사각형 방향으로 조절함.
Transform Tool (좌표변형 도구, 단축키 : Y) : MoveTool, RotateTool, ScaleTool 을 동시에 사용함.
유니티 스크립트 작성 방법
오브젝트에 연결하여 사용하려면 기본적으로 MonoBehaviour를 상속 받은 상태로 클래스를 작성한다. 그 외의 용도로 사용하는 경우에는 굳이 상속 받지 않아도 된다. Start(), Update(), FixedUpdate() 등 유니티에서 제공하는 함수들은 오버라이딩 하여 사용한다.
스크립트 라이프 사이클
게임 오브젝트가 살아있는 생명 주기 동안 동작하는 스크립트들
Awake : 게임 오브젝트가 생성될 때 호출되는 메서드. 주로 초기화 작업을 수행한다.
Start : 게임 오브젝트가 활성화되어 게임 루프가 시작될 때 호출되는 메서드. 초기 설정 및 시작 작업을 수행한다.
Update : 매 프레임마다 호출되는 메서드. 게임 로직의 주요 업데이트가 이루어진다. 매 프레임 호출되는 만큼 불필요한 계산을 피하고 최적화하기 위해 사용하지 않으면 지워주는 것이 좋다.
FixedUpdate : 물리 엔진 업데이트 시 호출되는 메서드. 물리적인 시뮬레이션에 관련된 작업을 처리할 때 사용된다.
LateUpdate : Update 메서드 호출 이후에 호출되는 메서드. 다른 오브젝트의 업데이트가 완료된 후에 작업을 수행하는 데 유용하다.
OnEnable : 게임 오브젝트가 활성화될 때 호출되는 메서드.
OnDisable : 게임 오브젝트가 비활성화될 때 호출되는 메서드.
OnDestroy : 게임 오브젝트가 파괴될 때 호출되는 메서드. 자원 정리 및 해제 작업을 수행한다.