레트로의 유니티 게임 프로그래밍 에센스 : C#으로 배우는 입문부터 4가지 게임
Input.GetAxis() 메서드로 Input.GetKey() 메서드를 대체했습니다. 여기서 다음과 같은 의문이 들 수 있습니다.
1. GetAxis() 메서드에 입력한 Horizontal 축과 Vertical 축은 무엇이며 왜 사용하는가?
2. GetAxis() 메서드의 출력값은 어째서 true, false가 아닌 숫자인가?:
정답
1. 입력키 커스터마이제이션을 구현하기 위해
2. 조이스틱 같은 다양한 입력 장치에 대응하기 위해
조작 키가 바뀔 때마다 매번 코드를 변경하고 다시 빌드할 순 없기에 '입력 이름'을 거쳐 가는 방식을 사용합니다.
누구나 한 번쯤 게임의 조작 키를 변경해본 경험이 있습니다. 대부분의 조작 키 설정 창은 '기능의 이름'과 '매핑된 키'쌍으로 구성됩니다.
예시
- 발사 <-> [마우스 왼쪽 버튼]
- 점프 <-> [스페이스바]
물론 설정 창에서 다음과 같이 매핑된 키를 마음대로 변경할 수 있습니다.
- 발사 <-> [마우스 오른쪽 버튼]
- 점프 <-> [엔터]
입력 매니저에서 미리 설정된 축들을 확인합니다.
기존 Horizontal과 Vertical 축의 설정을 변경하거나 Axes에 새로운 축을 추가하여 자신만의 입력 설정을 만들 수 있음
잘 살펴보면 Horizontal 축과 Vertical 축이 각각 하나씩 더 있다
두 번째 Horizontal 축과 Vertical 축을 펼쳐보면 버튼 필드가 비어있음, 대신 Type이 Joystick Axis로 설정되어 있다.
두 번째 Horizontal 축과 Vertical 축은 엑스박스와 같은 콘솔 게임기 게임 패드의 조이스틱(아날로그 스틱)에 대응된다.
즉, 별다른 처리를 추가하지 않아도 Input.GetAxis("Horizontal")을 사용했을 때 키보드와 게임 패드 모두에 대응할 수 있다. 키보드를 사용할 경우 첫 번째 Horizontal 축, 게임 패드를 사용할 경우 두 번째 Horizontal 축이 자동으로 사용된다.
이 장에서는 앞 장에서 만든 플레이어를 노리는 탄알을 만든다. 탄알 게임 오브젝트를 완성하고, 탄알 생성기로 탄알을 주기적으로 생성한다. 또한 탄알과 플레이어 사이의 충돌을 감지하는 방법을 배운다.
탄알이 속도를 가지도록 Bullet 게임 오브젝트에 리지드바디 컴포넌트를 추가한다
리지드바디 컴포넌트가 추가된 게임 오브젝트는 물리적인 상호작용이 가능합니다. 따라서 Bullet 게임 오브젝트가 중력의 영향을 받아 아래로 떨어지기에 리지드바디 컴포넌트의 중력 사용(Use Gravity) 필드를 체크 해제하여 중력을 무시합니다.
Bullet 게임 오브젝트에는 구 콜라이더(Sphere Collider)가 추가되어 있어 물리적인 표면이 존재한다. 따라서 Bullt 게임 오브젝트는 콜라이더를 가진 다른 게임 오브젝트와 충돌하면 튕겨나갈 수 있습니다.
탄알이 다른 탄알에 충돌했을 때 튕겨나가지 않고 그냥 통과하도록 트리거(Trigger)로 설정된 콜라이더를 사용
트리거 콜라이더는 충돌한 물체를 밀어내는 물리적인 표면이 없다. 그래서 트리거 콜라이더는 다른 일반 콜라이더와 겹치거나 서로 뚤고 지나갈 수 있지만 충돌 자체는 감지합니다. 따라서 트리거 콜라이더는 충돌이 감지되었을 떄 어떤 기능을 실행하는 방아쇠로 많이 사용한다.
예를 들어 플레이어가 특정 영역으로 진입했는지 검사할 때 트리거 콜라이더를 많이 사용합니다. 보이지 않는 트리거 콜라이더를 어떤 위치에 배치하고, 플레이어가 해당 트리거 콜라이더를 통과할 때 새로운 적을 출현시키거나 컷씬을 재상하는 방식으로 사용한다.
프리팹은 언제든지 재사용할 수 있는 미리 만들어진 게임 오브젝트 에셋(파일) 입니다. 비슷한 게임 오브젝트를 여러 개 만들 때 매번 다시 설정하는 번거로움을 줄이기 위해 프리팹을 사용한다.
게임 오브젝트를 프리팹으로 만들면 나중에 해당 게임 오브젝트와 똑같은 게임 오브젝트를 프리팹에서 복제 생성할 수 있습니다. 프리팹은 파일로 저장되기 때문에 현재 씬뿐만 아니라 다른 씬에서도 사용할 수 있습니다.
Bullet 스크립트가 활성화될 때 실행될 Start() 메서드에서는 Bullet 게임 오브젝트의 리지드바디 컴포넌트를 BulletRigidbody에 할당해야 합니다. 그리고 bulletRigidbody.velocity로 탄알의 속도를 변경한다.
void Start()
{
bulletRigidbody = GetComponent<Rigidbody>();
bulletRigidbody.velocity = transform.forward * speed;
}
Transform 타입의 변수 transform은 자신의 게임 오브젝트의 트랜스폼 컴포넌트로 바로 접근하는 변수
트랜스폼 컴포넌트는 게임 오브젝트의 위치, 크기, 회전을 담당하는 컴포넌트입니다. 따라서 모든 게임 오브젝트가 하나씩 가지고 있도록 강제되어 있으며 가장 자주 사용되는 컴포넌트입니다. 트랜스폼 컴포넌트가 없으면 3D 공간에 위치를 가질 수 없기 때문입니다.
편의상 유니티의 C# 스크립트들은 자신의 게임 오브젝트의 트랜스폼 컴포넌트를 코드 상에서 transform 변수로 즉시 접근할 수 있도록 구성되어 있습니다. 따라서 트랜스폼 컴포넌트는 GetComponent<Transform>()
등을 사용하여 직접 찾아오는 과정을 거칠 필요가 없습니다.
우리가 다음과 같은 transform으로 자신의 트랜스폼 컴포넌트에 바로 접근한 다음 트랜스폼 컴포넌트가 제공하는 forward 변수로 게임 오브젝트의 앞쪽 방향을 알 수 있습니다.
transform.forward;
트랜스폼 컴포넌트는 위치, 크기, 회전에 대한 여러 가지 편의 기능을 제공합니다. forward도 그중 하나로, 해당 트랜스폼을 가진 게임 오브젝트의 앞쪽 방향을 Vector3 값으로 제공합니다.
게임 도중에는 탄알이 굉장히 많이 생성된다. 플레이어가 피한 탄알은 게임 화면 바깥으로 영원히 움직이면서 게임 화면에서 보이지 않지만 씬에 계속 남기게 된다.
사라지지 않고 화면 밖으로 날아간 탄알 수가 계속 증가하면 컴퓨터 메모리를 엄청나게 낭비하게 됩니다. 따라서 탄알이 생성된 후 일정 시간이 흐르면 탄알이 스스로 자동 파괴되게 해야한다.
void Start()
{
bulletRigidbody = GetComponent<Rigidbody>();
bulletRigidbody.velocity = transform.forward * speed;
Destroy(gameObject, 3f);
}
3초뒤에 자신을 파괴
Destroy() 메서드는 입력한 오브젝트를 파괴한다.
void Destroy(Object obj);
void Destroy(Object obj, float t);
t초 이후 해당 오브젝트를 파괴
유니티에서 콜라이더를 가진 게임 오브젝트 A와 B가 서로 충돌한 상황을 가정해봅시다. 이때 게임 오브젝트 A와 B모두 자신이 충돌했다는 사실을 모릅니다.
게임 오브젝트는 자신이 충돌한 사실을 스스로 알 수 없습니다. 그 대신 충돌했음을 알려주는 메시지가 A와 B에 보내집니다. 충돌 메시지를 통해 게임 오브젝트와 해당 게임 오브젝트에 추가된 컴포넌트들은 충돌 사실을 알게 되고 충돌에 대응하는 메서드를 실행합니다.
게임 오브젝트와 컴포넌트는 충돌 종류에 따라 OnTriggerEnter
혹은 OnCollisionEnter
메시지를 받습니다.
Start() 메서드나 Update() 메서드가 자동으로 실행되는 이유는 Start, Update 메시지와 같은 이름으로 메서드를 작성했기 때문입니다. 마찬가지로 충돌 메시지에 대응할려면 충돌 메시지와 같은 이름으로 메서드를 작성하면 된다.
또한 충돌 메시지에는 충돌한 상대방 게임 오브젝트에 대한 정보도 함께 첨부된다. A에는 충돌한 상대방 B 게임 오브젝트의 정보가 전달된다. B에는 충돌한 상대방 A 게임 오브젝트의 정보가 전달된다. 따라서 충돌한 상대방 게임 오브젝트가 어떠한 오브젝트인지 충돌 메시지를 통해 알 수 있으며, 어떤 대응을 해야 하는지도 결정할 수 있습니다.
결론적으로 메시지 기반 방식 덕분에 우리는 충돌을 어떻게 감지할까 고민하지 않아도 된다. 단지 충돌했을 때 무엇을 실행할지만 결정하면 된다.
리지드바디 컴포넌트와 충돌 메시지
위에서 언급한 충돌 메시지를 발생시키는 것은 리지드바디 컴포넌트입니다. 따라서 충돌 이벤트 메서드를 사용하려면 서로 충돌 중인 게임 오브젝트 중에서 최소 하나의 게임 오브젝트는 리지드 바디 컴포넌트를 가지고 있어야한다!!!
충돌 메시지에 대응하는 메서드를 충돌 이벤트 메서드라고 부른다. 충돌 종류에 따라 충돌 이벤트 메서드 중 알맞은 것을 선택하여 구현하면 된다.
일반적인 콜라이더를 가진 두 게임 오브젝트가 충돌할 때 자동으로 실행된다. 충돌한 두 콜라이더는 서로 통과하지 않고 밀어낸다.
OnCollision 계열 메서드가 실행될 때는 메서드 입력으로 충돌 관련 정보가 Collision타입으로 들어온다.
Collision 타입은 충돌 관련 정보를 담아두는 단순한 정보 컨테이너이다. 따라서 입력으로 돌아온 collision을 통해 충돌한 상대방 게임 오브젝트, 충돌 지넘, 충돌 표면의 방향 등을 알 수 있습니다.
충돌한 두 게임 오브젝트의 콜라이더 중 최소 하나가 트리거 콜라이더라면 자동으로 실행됩니다. 이 경우 두 게임 오브젝트가 충돌했을 때 서로 그대로 통과합니다.
여기서 Collision이 아닌 Conllider 타입을 입력을 받는 이유는 트리거 충돌에는 상세한 충돌 정보가 필요 없기 때문이다.
트리거 충돌은 일반적인 충돌과 달리 서로를 밀어내지 않고 그대로 통과한다. 따라서 물리적인 반발력이나 정확한 충돌 지점, 충격량 등이 존재하지 않으므로 충돌한 상대방 게임 오브젝트(의 콜라이더 컴포넌트)를 곧장 받는다.
OnTrigger 계열의 메서드는 자신이 트리거 콜라이더가 아니어도 실행된다.
충돌한 두 콜라이더 중 하나 이상이 트리거 콜라이더일 때 양쪽 모두에서 OnTrigger 계열의 메서드가 실행된다.
Bullet 게임 오브젝트의 콜라이더 컴포넌트는 Is Trigger가 체크된 트리거 콜라이더이다.
따라서 Bullet 스크립트에 충돌 이벤트 메서드로 OnTriggerEnter()를 작성해야 한다.
작성할 OnTriggerEnter() 메서드에서는 아래 처리를 구현한다.
// 트리거 충돌 시 자동으로 실행되는 메서드
private void OnTriggerEnter(Collider other)
{
// 충돌한 상대방 게임 오브젝트가 Player 태그를 가진 경우
if (other.tag == "Player")
{
// 상대방 게임 오브젝트에서 PlayerController 컴포넌트 가져오기
PlayerController playerController = other.GetComponent<PlayerController>();
// 상대방으로부터 PlayerController 컴포넌트를 가져오는 데 성공했다면
if (playerController != null )
{
// 상대방 PlayerController 컴포넌트의 Die() 메서드 실행
playerController.Die();
}
}
}
Trigger랑 충돌하면 맨 먼저 other를 통해 충돌한 상대방 게임 오브젝트의 태그가 Player인지 검사합니다.
if (other.tag == "Player")
PlayerController playerController = other.GetComponent<PlayerController>();
if (playerController != null )
{
playerController.Die();
}
other.GetComponent<PlayerController>();
가 실행되면 other의 게임 오브젝트에 추가된 PlayerController 컴포넌트를 찾아 가져옵니다. 그리고 가져온 PlayerController 타입의 컴포넌트를 playerController 변수에 할당했다.
playerController를 통해 PlayerController 컴포넌트의 Die() 메서드를 실행하면 플레이어가 죽게 된다.
public class BulletSpawner : MonoBehaviour
{
public GameObject bulletPrefab; // 생성할 탄알의 원본 프리팹
public float spawnRateMin = 0.5f; // 최소 생성 주기
public float spawnRateMax = 3f; // 최대 생성 주기
private Transform target; // 발사할 대상
private float spawnRate; // 생성 주기
private float timeAfterSpawn; // 최근 생성 시점에서 지난 시간
void Start()
{
}
void Update()
{
}
}
추가된 변수들의 역활
void Start()
{
//최근 생성 이후의 누적 시간을 0으로 초기화
timeAfterSpawn = 0f;
//탄알 생성 간격을 spawnRateMin과 spawnRateMax 사이에서 랜덤 지정
spawnRate = Random.Range(spawnRateMin, spawnRateMax);
// PlayerController 컴포넌트를 가진 게임 오브젝트를 찾아 조준 대상으로 설정
target = FindObjectOfType<PlayerController>().transform;
}
입력 타입에 따른 Random.Range()의 동작 차이
Random.Range() 메서드는 float을 입력받을 때와 int를 입력받을 때의 동작이 다릅니다.
- Random.Range(0, 3) : 0, 1, 2 중에 하나가 int 값으로 출력됨
- Random.Range(0f, 3f) : 0f부터 3f 사이의 float 값이 출력됨
target을 public으로 선언하고 인스펙터 창에서 target 필드에 Player 게임 오브젝트를 직접 드래그&드롭하는 방법이 있다. 이 경우 탄알 생성기가 여러 개 존재하면 일일이 Player 게임 오브젝트를 여러 탄알 생성기의 target 변수로 드래그&드롭해야 한다.
FindObjectOfType() 메서드의 처리 비용
FindObjectOfType() 메서드는 씬이 존재하는 모든 오브젝트를 검색하여 원하는 타입의 오브젝트를 찾아 냅니다. FindObjectOfType() 메서드는 처리 비용이 크기 때문에 Start() 메서드처럼 초기에 한두 번 실행되는 메서드에서만 사용해야 합니다.
만약 Update() 메서드에서 FindObjectOfType()을 사용하면 프로그램이 심각하게 느려질 수 있습니다.
FindObjectOfType()과 FindObjectsOfType()
FindObjectOfType()과 비슷한 이름을 가진 FindObjectsOfType()도 있습니다. 전자는 해당 타입의 오브젝트를 하나만 찾습니다. 후자는 해당 타입의 오브젝트를 모두 찾아 배열로 반환합니다.
Update()는 화면이 한 번 갱신될 때마다 한 번 실행됩니다. 따라서 마지막 Update()가 실행된 시점과 현재 Update()가 실행된 시점 사이의 시간 간격이 프레임이 새로 그려지는 데 걸리는 시간이다.
초당 프레임은 컴퓨터 성능에 따라 다르다. 따라서 직전 Update() 실행과 현재 Update() 실행 사이의 시간 간격은 고정되어 있지 않다.
Update() 실행 사이의 시간 간격을 알기 위해 내장 변수 Time.deltaTime을 사용한다. Time.deltaTime에는 이전 프레임과 현재 프레임 사이의 시간 간격이 자동으로 할당된다.
따라서 Update() 메서드에서 어떤 변수에 Time.deltaTime 값을 계속 누적하면 특정 시점으로 부터 시간이 얼마나 흘렀는지 표현할 수 있다.
Instantiate(원본);
Instantiate는 '인스턴스화'로 번역합니다. 인스턴스화는 원본에서 복사본을 생성하는 행위를 표현하는 단어이다. 원본으로부터 복제 생성된 오브젝트를 인스턴스라고 부른다.
Instantiate(원본, 위치, 회전);
Instantiate(bulletPrefab, transform.position, transform.rotation);