Sprite Flight (3) 폭발, 재시작, 모바일 입력, 빌드
이번 글에서는 아래 내용을 정리한다.
- 6편: Particle System, 프리팹을 코드에서
Instantiate, 재시작 버튼과clicked +=,SceneManager.LoadScene - 7편(보너스): 여러 기능 중 모바일 입력(
InputAction)만 - 8편: Build Profiles, 씬 목록, Windows 빌드
- 6편 완성 코드에서 리뷰 연습으로 찾은 세 곳
- Sprite Flight를 끝내며
2편에서 이어진다.
6편: 폭발과 재시작
Particle System 만들기
Hierarchy에서 Effects > Particle System을 만들어 “ExplosionEffect"로 이름 짓고 Position을 (0, 0, 0)으로 둔다. 만드는 순간 Scene 뷰에 파티클 오버레이가 떠서 재생, 일시 정지, 재시작을 미리 볼 수 있다.
Inspector에서 기본 설정을 바꾼다.
| 섹션 | 속성 | 값 | 뜻 |
|---|---|---|---|
| Main | Looping | 끔 | 한 번만 방출 |
| Main | Duration | 0.25 | 방출하는 시간 |
| Main | Start Size | Random Between Two Constants, 0 ~ 0.25 | 입자 크기를 무작위로 |
| Emission | Bursts | + 추가, Count 50 | 시간에 걸쳐 조금씩이 아니라 한꺼번에 50개 |
| Shape | Shape | Cone → Sphere | 사방으로 퍼지게 |
속성 이름에 마우스를 올리면 툴팁이 뜬다. 파티클 시스템은 설정 항목이 수십 개라 이 툴팁으로 익히는 것이 빠르다. 폴리싱 작업의 기본 도구라 여기서 손에 익혀 두는 것이 목적이다.
프리팹으로 만들고 씬에서 지우기
ExplosionEffect를 Prefabs 폴더로 드래그해 프리팹으로 만든 뒤, Hierarchy에서 삭제한다. 씬에 놓인 것은 지워도 프리팹 파일이 남아 있으니 코드에서 언제든 다시 만들 수 있다. 몬스터 템플릿을 테이블에 등록해 두고 존에는 스폰할 때만 나타나는 것과 같다.
코드에서 생성하기
public GameObject explosionEffect;
void OnCollisionEnter2D(Collision2D collision)
{
Instantiate(explosionEffect, transform.position, transform.rotation);
Destroy(gameObject);
}Instantiate(프리팹, 위치, 회전)이 프리팹의 인스턴스를 씬에 만든다. explosionEffect 칸은 Inspector에서 Player를 선택하고 Project 창의 ExplosionEffect 프리팹을 드래그해 채운다. 이것을 빼먹으면 Instantiate(null, ...)이 되어 예외가 난다. 5편의 uiDocument와 같은 종류의 실수다.
이 프리팹에는 Particle System만 있고 스크립트가 없는데도 나타나자마자 재생된다. Particle System의 Play On Awake가 기본으로 켜져 있기 때문이다. 다만 재생이 끝나도 오브젝트는 남는다. Main 섹션의 Stop Action을 Destroy로 두면 끝나고 스스로 사라진다. 튜토리얼은 이것을 안 다루는데, 죽을 때마다 하나씩 쌓이는 것이라 실전이라면 넣어야 한다.
재시작 버튼
UI Builder에서 UILayout을 열고 Library의 Button을 끌어 넣는다. 이름은 “RestartButton”, Text는 “Restart”. Inline Styles에서 Align Self를 Center, Position을 Absolute, Top을 50%로 둔다.
스크립트에서 찾아서 처음에는 숨긴다.
private Button restartButton;
void Start()
{
restartButton = uiDocument.rootVisualElement.Q<Button>("RestartButton");
restartButton.style.display = DisplayStyle.None; // 레이아웃에서 완전히 제거
restartButton.clicked += ReloadScene; // 클릭 이벤트에 함수 등록
}
void OnCollisionEnter2D(Collision2D collision)
{
Instantiate(explosionEffect, transform.position, transform.rotation);
restartButton.style.display = DisplayStyle.Flex; // 다시 표시
Destroy(gameObject);
}
void ReloadScene()
{
SceneManager.LoadScene(SceneManager.GetActiveScene().name);
}SceneManager가 빨간 줄이면 using UnityEngine.SceneManagement;를 추가한다.
restartButton.clicked += ReloadScene;이 C# 글에서 본 delegate +=의 실제 사용이다. clicked는 “클릭되면 부를 함수 목록"이고, 거기에 ReloadScene을 추가하는 것이다. 숫자를 더하는 것이 아니라 기능을 더하는 것이다.
DisplayStyle.None은 공간도 차지하지 않고 보이지도 않는다. Flex는 표준 레이아웃으로 표시한다. CSS의 flexbox에서 온 이름이다.
6편 최종 코드와 리뷰
튜토리얼이 제시한 완성 코드다.
void Start()
{
rb = GetComponent<Rigidbody2D>();
scoreText = uiDocument.rootVisualElement.Q<Label>("ScoreLabel");
restartButton = uiDocument.rootVisualElement.Q<Button>("RestartButton");
restartButton.style.display = DisplayStyle.None;
restartButton.clicked += ReloadScene;
}
void Update()
{
elapsedTime += Time.deltaTime;
score = Mathf.FloorToInt(elapsedTime * scoreMultiplier);
scoreText.text = "Score: " + score;
// ... 마우스 조작
}
void OnCollisionEnter2D(Collision2D collision)
{
Destroy(gameObject);
Instantiate(explosionEffect, transform.position, transform.rotation);
restartButton.style.display = DisplayStyle.Flex;
}리뷰 연습으로 세 곳을 찾았다. 튜토리얼이 틀렸다기보다 실전 기준과 다른 곳이다.
꼭 기억할 것 ④
Destroy는 그 프레임 끝에 실행된다
OnCollisionEnter2D에서Destroy(gameObject)가 첫 줄인데 그다음 줄에서transform.position을 읽는다. 동작은 한다.Destroy가 즉시가 아니라 프레임 끝에 실행되기 때문이다. 하지만 자기를 파괴한 뒤에 자기 위치를 읽는 순서는 읽는 사람이 놀란다. 튜토리얼 5단계 본문은 “Destroy 바로 앞에 넣으라"고 했는데 완성 코드는 반대다.Destroy는 마지막 줄로 둔다.
두 번째는 Update의 "Score: " + score다. 2편에서 본 매 프레임 문자열 할당이다.
세 번째는 restartButton.clicked += ReloadScene;에 짝이 되는 -=가 없다는 것이다. 여기서는 씬을 통째로 다시 로드해서 전부 파괴되니 실제 문제는 없지만, +=를 보면 -=가 어디 있는지 찾는 습관의 대상이다. 그리고 6편 전체에서 public GameObject explosionEffect;처럼 public 필드를 쓰는데, 1편에서 정한 규칙대로면 [SerializeField] private이다.
선택 과제
Expert 과제 중 “장애물이 벽에 부딪힐 때 작은 충격 이펙트"는 해 볼 만하다.
void OnCollisionEnter2D(Collision2D collision)
{
Vector2 contactPoint = collision.GetContact(0).point;
GameObject bounceEffect = Instantiate(bounceEffectPrefab, contactPoint, Quaternion.identity);
Destroy(bounceEffect, 1f); // 1초 뒤 파괴
}collision.GetContact(0).point가 실제 충돌 지점이다. Destroy(obj, 1f)는 두 번째 인자로 지연 시간을 받는다. 튜토리얼 팁대로 rb.linearVelocity.magnitude에 따라 이펙트 크기를 바꾸면 세게 부딪힐수록 크게 터진다. 게임 필의 기본이다.
7편: 보너스 기능 중 모바일 입력
7편은 컨셉 바꾸기, 스프라이트 교체, 색 구성, 난이도 상승, 사운드, 배경 파티클, 부스터 불꽃, 모바일 입력의 아홉 가지가 자유 과제로 있다. 이 중 모바일 입력만 한다. 2편에서 Mouse.current가 모바일에서 null이라고 했는데, 이것이 그 해결책이고 나중에 Android로 낼 때 어차피 필요하다.
InputAction
using UnityEngine.InputSystem;
public InputAction moveForward;
public InputAction lookPosition;
void Start()
{
moveForward.Enable();
lookPosition.Enable();
}Inspector에 두 액션 칸이 생긴다. 각각 바인딩을 잡는다.
| 액션 | Action Type | 바인딩 경로 |
|---|---|---|
| Move Forward | Button | Pointer > Press |
| Look Position | Value | Pointer > Position |
Pointer가 핵심이다. 마우스와 터치를 모두 포함하는 추상 장치라, 같은 코드가 PC에서는 마우스로, 폰에서는 손가락으로 동작한다. 장치별 분기가 없어진다.
Update의 코드를 바꾼다.
// 전
if (Mouse.current.leftButton.isPressed)
Vector3 mousePos = Camera.main.ScreenToWorldPoint(Mouse.current.position.value);
// 후
if (moveForward.IsPressed())
Vector3 mousePos = Camera.main.ScreenToWorldPoint(lookPosition.ReadValue<Vector2>());wasPressedThisFrame은 moveForward.WasPressedThisFrame()으로, wasReleasedThisFrame은 WasReleasedThisFrame()으로 바뀐다.
Device Simulator(Game 뷰 탭 옆의 Simulator)에서 폰 기종을 고르고 가로로 돌려 테스트할 수 있다. 폰 없이 화면 비율과 터치를 확인하는 도구라 모바일 게임을 만들 때 계속 쓴다.
한 가지 짚을 것. 이 방식은 InputAction을 스크립트마다 public 필드로 두고 Enable()을 직접 부른다. 스크립트가 서너 개만 되어도 바인딩이 흩어져 관리가 안 된다. 실전에서는 액션 에셋(.inputactions) 하나에 모아 두고 각 스크립트가 거기서 가져다 쓴다. Universal 2D 템플릿에 들어 있는 InputSystem_Actions.inputactions가 그것이다.
나머지 보너스 중 알아 둘 것만 적는다.
- 스프라이트 교체: 새 스프라이트를 자식으로 붙이고 기존
Sprite Renderer컴포넌트의 체크를 끈다. 1편의 체크박스 이야기다. - 난이도 상승: 물리 재질의 Bounciness를 1.05로 두면 튈 때마다 빨라진다. 1.0이 에너지 보존이고 그 이상이면 증폭이다.
- 사운드:
AudioSource컴포넌트를 붙이고 클립을 넣는다. 프리팹에 붙이면 생성될 때 자동 재생된다.
8편: 빌드
튜토리얼은 Web으로 빌드해 Unity Play에 올리는 것까지 하는데, 그 사이트는 학습자용 공유 페이지라 실제 출시 경로와 무관하다. 빌드 절차만 하고, Web 대신 Windows로 했다.
File > Build Profiles(Ctrl+Shift+B)를 연다.- Scene List에서 기존 항목을 지우고, Game 씬을 연 상태에서
Add Open Scenes를 누른다. 빌드에 어떤 씬이 들어가는지 정하는 곳이다. 빠뜨리면 빈 빌드가 나온다. - Platforms에서 Windows를 고른다. 프로젝트를 만들 때부터 Windows가 활성이라
Switch Platform은 필요 없다. Web으로 하려면 여기서 전환하고, 모듈이 없으면 Hub에서 설치한다. Build를 누르고 프로젝트 폴더 밖에 새 폴더를 지정한다. 프로젝트 안에 두면 다음 빌드 때 유니티가 그 폴더까지 에셋으로 읽는다.
첫 빌드는 셰이더 컴파일 때문에 몇 분 걸린다. 끝나면 SpriteFlight.exe, SpriteFlight_Data 폴더, UnityPlayer.dll이 생긴다. _Data 폴더가 없으면 실행이 안 되니 배포할 때는 폴더째 보낸다.
2022년에는 File > Build Settings였는데 Unity 6에서 Build Profiles로 바뀌었다. 플랫폼마다 프로필을 여러 개 두고 심볼과 설정을 따로 가질 수 있다. 나중에 Android로 낼 때 같은 화면에서 플랫폼만 바꾸면 된다.
Sprite Flight를 끝내며
8편을 하루 반 만에 끝냈다. 4시간 30분짜리 과정이니 시간은 예정대로였고, 튜토리얼 자체보다 따라 하다가 생긴 질문에 답을 찾는 데 더 오래 걸렸다. 그 질문들이 이 세 글의 내용이다.
기억이 돌아온 속도가 예상보다 빨랐다. 에디터를 만지는 순간 프리팹, Is Trigger, Inspector, [SerializeField] 같은 것이 이름보다 먼저 떠올랐다. 8개월 동안 손으로 만든 것들이라 개념은 남아 있었고, 이름과 메뉴 위치만 다시 이어지면 되는 상태였다.
그리고 4년이 그냥 지나간 것이 아니었다. 프리팹을 몬스터 템플릿으로, Update를 틱으로, 프리팹 사이드 이펙트를 속성 테이블 패치 사고로 대응시키면서 이해하니 처음 배울 때보다 빨랐다. “Init에서 고정값을 박는 코드가 이상하다”, “GC가 임계치에서 돌면 new를 못 쓰는 것 아닌가” 같은 질문은 학원 때는 못 하던 것이다.
아직 안 돌아온 것은 코루틴 같은 것들이다. 다음 글부터는 유니티 에디터를 열고, 한 화면짜리 작은 가게 타이쿤을 처음부터 만들면서 그것들을 실제 코드에서 만난다.
뭘 배웠지?
- Particle System은 Looping, Duration, Start Size, Emission Bursts, Shape 다섯 가지로 폭발을 만든다. Stop Action을 Destroy로 두면 끝나고 사라진다.
- 프리팹은 씬에서 지워도 코드에서
Instantiate(프리팹, 위치, 회전)으로 만들 수 있다. 프리팹 참조 칸은 Inspector에서 채운다. Destroy는 그 프레임 끝에 실행된다. 함수의 마지막 줄에 둔다.button.clicked += 함수가 delegate+=의 실제 사용이다.SceneManager.LoadScene(이름)으로 씬을 다시 로드하면 전부 초기 상태로 돌아간다.InputAction에Pointer바인딩을 쓰면 마우스와 터치를 같은 코드로 받는다. 실전에서는 액션 에셋 하나에 모은다.- 빌드는 Build Profiles에서 씬 목록을 잡고 플랫폼을 고른 뒤 프로젝트 밖 폴더에 만든다.
- 튜토리얼 완성 코드도 리뷰 대상이다.
Destroy순서, 매 프레임 문자열, 짝 없는+=,public필드.
생각 해보기
문제 1
폭발 프리팹이 재생이 끝나도 씬에 남아 있다. 스크립트 없이 Inspector 설정 하나로 스스로 사라지게 하려면 무엇을 바꿔야 할까? (난이도 : 下)
문제 2
OnCollisionEnter2D에서 Destroy(gameObject)를 첫 줄에 두고 그다음 줄에서 transform.position을 읽어도 동작하는 이유는 무엇일까? 그런데도 순서를 바꿔야 하는 이유는? (난이도 : 中)
문제 3
restartButton.clicked += ReloadScene;에 -=가 없는데도 이 게임에서 문제가 안 되는 이유는 무엇일까? 문제가 되는 상황을 하나 만들어 보자. (난이도 : 中)
문제 4
Mouse.current.leftButton.isPressed를 moveForward.IsPressed()로 바꾸면 모바일에서 무엇이 달라질까? Pointer 바인딩이 마우스와 터치를 모두 받는다는 것은 무슨 뜻일까? (난이도 : 中)
문제 5
빌드 폴더를 프로젝트 안의 Assets 옆에 만들면 다음 빌드 때 무슨 일이 생길까? (난이도 : 下)
문제 6
6편 완성 코드의 OnCollisionEnter2D를 실전 기준으로 다시 써 보자. 순서, 접근 제한자, null 검사를 고려한다. (난이도 : 上)
문제 7
장애물이 벽에 부딪힐 때 충격 이펙트를 만들면 장애물끼리 부딪힐 때는 이펙트가 두 개 생긴다. 하나만 생기게 하려면 어떻게 판정해야 할까? (난이도 : 上)