9일차 요약(UI, Character, 무한맵, Sound)
UI
- ButtonUI::OnClick을 에디터에서 설정할 수 있다.
- 일반적인 EmptyObject와 UI용 EmptyObject의 차이
- 일반적인 EmptyObject는 Transform을 소유하고 UI용은 Rect Transform을 소유한다.
- UI도 다른 Object와 동일하게 Animation을 생성하고 적용할 수 있다.
2D Image Animation
- 원하는 Sprite를 한 번에 Hierarchy창으로 드래그하면 자동으로 Animtion이 생성된다.
무한맵
- 무한맵을 만드는 방법은 맵을 움직여 무한으로 늘어나는 것 같이 눈속임하는 방법이 있다.
- Material의 offset을 움직이면 된다.
2D 캐릭터
- 2D캐릭터는 3D와는 다른 Component를 사용한다.
- 대부분 비슷한데 뒤에 2D가 붙는다.
- 만약 이를 맞추지 않으면 올바르게 동작하지 않는다.
- 2D캐릭터에게 제일 최적화된 Collider는 CircleCollider2D이다.
Sound
- Audio Source Component: 스피커 역할
- Audio Listener Component: 헤드셋 역할
- Audio Clip: 재생할 음악
- 소리 재생은 다양한 Object에서 사용한다
- 코드 중복, 설정할 곳이 많아진다
- 이를 해결하기 위해 소리를 관리하는 Manager Object를 Singleton으로 관리하면 좋다.
- 소리를 재생하는데 다양한 함수가 있다.
- Play: 하나의 소리만 재생할 수 있다. BGM을 재생하는데 적합하다.
- PlayOneShot: 전달받은 Clip을 다른 소리와 중첩하여 실행할 수 있다. 효과음을 재생하는데 적합하다.
UI
Intro UI
버튼 UI는 클릭하면 이벤트를 실행시킬 수 있는 UI이다.
버튼에는 이미지, 클릭 처리, 텍스트 등 다양한 속성을 갖고 있다.
버튼에 이미지를 적용하는 방법은 Source Image에 리소스를 설정하면 된다.
Set Native Size라는 버튼을 누르게 되면 Source Image가 가지고 있는 가로, 세로 비율을 적용시킬 수 있다.
버튼의 OnClick이벤트를 처리하는 함수를 적용할 수 있다.
Script를 작성하여 적용할 수 있으며 에디터 상의 기능도 적용이 가능하다.
화면을 끄고 싶기 때문에 SetActive를 실행하도록 설정하면 된다.
UI 정리
UI를 Canvas에 추가하다 보면 너무 많은 Component가 추가되어 어느 화면에 포함되는 것인지 구분하기 힘들다.
따라서, 화면별로 정리해 주는 것이 좋다.
Canvas에서 Create Empty를 한다면 기존 Empty Object와는 다르게 UI용으로 만들어진다.
UI용 Empty Object는 Transform이 아니라 Rect Transform을 가진 채로 생성된다.
GameOver UI
새가 장애물에 닿아 게임이 종료되면 출력되는 UI를 제작해 보자.
우선 계층구조를 생성한다.
Background의 배경색을 검은색으로 설정한 뒤, Alpha값을 0으로 설정하여 처음에 보이지 않도록 해준다.
그리고 MainUI에 Animation을 생성하여 Background가 fade 되도록 설정한다.
GameOver 이미지에도 애니메이션을 추가하여 적용하면 다음과 같다.
UI흐름 정리
현재 게임에서 UI흐름은 다음과 같이 동작한다.
따라서, IntroUI에서 BtnStart가 눌린다면 IntroUI는 SetActive(false)를 Main은 SetActive(true)를 실행해 준다.
2D Image Animation
적용할 Sprite를 클릭한 후, Hierarchy창에 드래그 앤 드롭하면 자동으로 Animtion을 만들어 준다.
무한맵
무한하게 늘어나는 맵을 만들기 위해서는 배경으로 사용된 Image가 무수히 많이 필요하다.
하지만, 이런 방법은 효율적이지 못하기 때문에 이미 지나간 배경을 재사용하는 기법을 사용한다.
혹은 Material의 Offset을 수정하는 방식을 사용하기도 한다.
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class LoopMap : MonoBehaviour
{
private SpriteRenderer rd;
public float offsetSpeed = 0.5f;
void Start()
{
rd = GetComponent<SpriteRenderer>();
}
void Update()
{
var offsetVal = offsetSpeed * Time.deltaTime;
rd.material.SetTextureOffset("_MainTex", rd.material.mainTextureOffset + new Vector2(offsetVal, 0f));
}
}
해당 방법은 Material의 Offset을 수정하는 방법이다.
하지만 Image에는 Unity에서 제공하는 기본 Material이 적용되어 있어 Offset 같은 값들을 수정하지 못한다.
따라서, 새로운 Material을 생성해서 적용해 주어야 한다.
UI용 Material은 Unlit/Texture Type으로 만든다.
이후, 원하는 Texture를 적용하여 배경에 적용할 Material을 생성한다.
이제, Background로 설정한 Image에 해당 Material을 적용하고 Script를 추가하면 된다.
2D Character
Flappy Bird에서는 중력에 의해 새가 떨어지는 물리 작용이 필요하다.
따라서, 2D Ragidbody를 추가해야 한다.
키입력을 통해 새가 위로 날아오를 수 있게 Script를 작성해 보자.
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class BirdMovement : MonoBehaviour
{
private Rigidbody2D myRigid;
[SerializeField] private float flyPower = 10f;
private void Start()
{
myRigid = GetComponent<Rigidbody2D>();
}
void Update()
{
if (Input.GetKeyDown(KeyCode.Space))
{
if (myRigid.velocity.y >= 0f)
{
myRigid.velocity = Vector3.zero;
}
myRigid.AddForce(Vector3.up * flyPower, ForceMode2D.Impulse);
}
}
}
현재는 Space를 누르면 계속하여 위로 힘이 가해진다.
이는 무한하게 크게 작용이 될 수 있으므로 이를 제한하는 것이 좋아 보인다.
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class BirdMovement : MonoBehaviour
{
private Rigidbody2D myRigid;
[SerializeField] private float flyPower = 10f;
[SerializeField] private float limitPower = 5f;
private void Start()
{
myRigid = GetComponent<Rigidbody2D>();
}
// Update is called once per frame
void Update()
{
if (Input.GetKeyDown(KeyCode.Space))
{
myRigid.AddForce(Vector3.up * flyPower, ForceMode2D.Impulse);
if (myRigid.velocity.y >= limitPower)
{
myRigid.velocity = new Vector3(myRigid.velocity.x, limitPower);
}
}
}
}
새가 장애물에 닿으면 게임이 종료되어야 하기 때문에 Trigger Event를 발생시켜야 한다.
따라서, 장애물과 캐릭터 두 객체에 Collider가 있어야 한다.
2D캐릭터에게 제일 최적화된 Collider는 Circle Collider 2D이다.
Pipe(obstacle)
장애물을 설치하고 이동시켜 보자.
우선, 파이프 하나를 게임상에 배치하고 이동시켜 보자.
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class RendomPipeMap : MonoBehaviour
{
[SerializeField] private GameObject pipe;
[SerializeField] private float pipeSpeed = 10f;
void Update()
{
pipe.transform.position -= Vector3.right * pipeSpeed * Time.deltaTime;
}
}
파이프에 새가 닿으면 게임을 종료해야 한다.
따라서, 새가 닿았는지에 대한 이벤트가 처리되어야 한다.
Collider를 추가해 Trigger Event를 만들면 된다.
이제 여러 개의 파이프를 배치해 보자.
우선, 위아래 파이프가 두 개가 필요하다.
복사하여 하나를 뒤집으면 된다.
이때, flip을 사용하면 편리하다.
그리고 이를 하나의 그룹으로 묶고 앞서 작성한 RandomPipeMap Script를 부모에 넣어 두 파이프를 관리하도록 하자.
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class RendomPipeMap : MonoBehaviour
{
[SerializeField] private GameObject[] pipes;
[SerializeField] private float pipeSpeed = 5f;
void Update()
{
foreach (var pipe in pipes)
{
pipe.transform.position -= Vector3.right * pipeSpeed * Time.deltaTime;
}
}
}
추가로 두 파이프가 랜덤 하게 등장하도록 바꿔보자.
배열의 index와 랜덤 한 값을 동일하게 유지하고 가독성이 좋도록 enum을 사용해 보자.
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class RendomPipeMap : MonoBehaviour
{
public enum PIPE_TYPE { TOP, BOTTOM, ALL, SIZE }
public PIPE_TYPE pipeType;
[SerializeField] private GameObject[] pipes;
[SerializeField] private float pipeSpeed = 5f;
private void Start()
{
SetPipeType();
}
void Update()
{
foreach (var pipe in pipes)
{
pipe.transform.position -= Vector3.right * pipeSpeed * Time.deltaTime;
}
}
void SetPipeType()
{
pipeType = (PIPE_TYPE)Random.Range(0, (int)PIPE_TYPE.SIZE);
ActivatePipe();
}
void ActivatePipe()
{
if(pipeType == PIPE_TYPE.TOP)
{
pipes[(int)PIPE_TYPE.TOP].SetActive(true);
pipes[(int)PIPE_TYPE.BOTTOM].SetActive(false);
}
else if(pipeType == PIPE_TYPE.BOTTOM)
{
pipes[(int)PIPE_TYPE.TOP].SetActive(false);
pipes[(int)PIPE_TYPE.BOTTOM].SetActive(true);
}
else
{
pipes[(int)PIPE_TYPE.TOP].SetActive(true);
pipes[(int)PIPE_TYPE.BOTTOM].SetActive(true);
}
}
}
이미 지나간 파이프들을 다시 앞으로 끌어와 재활용하는 로직을 추가해 보자.
파이프가 계속 왼쪽으로 이동하다 특정 위치를 넘어가면 화면에 보이지 않게 된다.
지금의 경우 -10으로 설정할 수 있다.
따라서, position.x가 -10보다 작다면 다시 앞쪽으로 끌어오도록 해보자.
void Update()
{
foreach (var pipe in pipes)
{
pipe.transform.position -= Vector3.right * pipeSpeed * Time.deltaTime;
if (pipe.transform.position.x <= -10f)
{
SetPipeType();
pipe.transform.position = new Vector3(10f, pipe.transform.position.y, pipe.transform.position.z);
}
}
}
이때, 파이프 타입 역시 새로 설정해 주는 것을 유의하자.
마지막으로 파이프의 높이를 랜덤하게 조작해 보자.
변경할 높이를 난수로 생성하여 더하거나 빼면 된다.
void SetPipeType()
{
pipeType = (PIPE_TYPE)Random.Range(0, (int)PIPE_TYPE.SIZE);
float randomHeight = Random.Range(-3f, 2f);
transform.position = new Vector3(transform.position.x, randomHeight, transform.position.z);
ActivatePipe();
}
Sound
배경음악이나 SFX 등을 재생하는 매니저 오브젝트를 추가해 보자.
사운드의 필수 요소 3가지는 다음과 같다.
- Audio Source Component: 스피커 역할
- Audio Listener Component: 헤드셋 역할
- Audio Clip: 재생할 음악
이를 따로 관리하는 것은 쉽지 않으므로 통합 관리하는 매니저 오브젝트를 생성하자.
해당 오브젝트에서는 Script를 통해 재생할 Clip을 설정하거나 BGM을 재생하는 등의 역할을 한다.
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class SoundManager : MonoBehaviour
{
public enum SOUND_TYPE
{
INTRO = 0,
GAME,
JUMP,
COLLISION,
}
[SerializeField] private AudioSource audioSource;
[SerializeField] private AudioClip[] clips;
private void Awake()
{
audioSource = GetComponent<AudioSource>();
}
void Start()
{
OnIntroBGM();
}
void OnIntroBGM()
{
//Set IntroBGM
audioSource.clip = clips[(int)SOUND_TYPE.INTRO];
audioSource.Play();
}
public void OnMainBGM()
{
//Set MainBGM
audioSource.clip = clips[(int)SOUND_TYPE.GAME];
audioSource.Play();
}
public void OnEventSound(SOUND_TYPE soundType)
{
audioSource.PlayOneShot(clips[(int)soundType]);
}
}
현재 상태라면 시작하자마자 IntroBGM이 재생될 것이다.
이제, IntroUI에서 Start 버튼을 누르면 OnMainBGM실행하여 MainBGM을 실행하도록 하면 된다.
이는 IntroUI를 안 보이게 하는 OnClick이벤트와 동일하게 진행된다.
이렇게 되면 BGM에 대한 설정은 마쳤다.
이제 점프, 충돌에 대한 일회성 SFX를 추가해 보자.
우선, 점프는 BirdMovement에서 키입력을 감지하기 때문에 해당 부분에서 SoundManager.OnEventSound를 호출하면 된다.
BirdMovement에서 Clip의 순서를 확인하는 것이 쉽지 않으므로 enum으로 설정하는 게 좋아 보인다.
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class BirdMovement : MonoBehaviour
{
private Rigidbody2D myRigid;
[SerializeField] public SoundManager soundManager;
[SerializeField] private float flyPower = 10f;
[SerializeField] private float limitPower = 5f;
private void Start()
{
myRigid = GetComponent<Rigidbody2D>();
}
void Update()
{
if (Input.GetKeyDown(KeyCode.Space))
{
//Play SFX
soundManager.OnEventSound(SoundManager.SOUND_TYPE.JUMP);
myRigid.AddForce(Vector3.up * flyPower, ForceMode2D.Impulse);
if (myRigid.velocity.y >= limitPower)
{
myRigid.velocity = new Vector3(myRigid.velocity.x, limitPower);
}
}
}
}
Script를 수정했다면 에디터에서 SoundManager를 설정해 주어야 한다.
마지막으로 파이프의 충돌 시에 SFX를 출력해 보자.
충돌을 감지하는 Script는 파이프에 부착된 PipeEvent라는 Script에서 담당한다.
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class PipeEvent : MonoBehaviour
{
public GameObject endUI;
[SerializeField] public SoundManager soundManager;
public void OnTriggerEnter2D(Collider2D collision)
{
if (collision.CompareTag("Player"))
{
Debug.Log("Game Over");
endUI.SetActive(true);
soundManager.OnEventSound(SoundManager.SOUND_TYPE.COLLISION);
}
}
}
Player라는 Tag를 소유한 오브젝트와 충돌이 일어났는지 감지하는 로직에서 SoundManager의 OnEventSound를 호출하면 된다.
마찬가지로 해당 부분도 에디터에서 SoundManger를 설정해 주어야 한다.
파이프의 경우 총 8개의 파이프에 하나씩 설정해주어야 한다.
이를 해결하는 방법은 SoundManager를 Singleton으로 사용하는 것이다.
soundManager = GameObject.Find("SoundManager").GetComponent<SoundManager>();
파이프에 해당 코드를 추가하면 된다.
Tips
Audio 파일 형식 중 최적화가 가장 잘 된 것은 Ogg타입이다.