이 글에서 만드는 것
Unity를 한 번도 써 본 적이 없는 상태에서 시작해, 하루 동안 다음을 만들었습니다.
- 바닥 위에 상자 네 개가 놓여 있습니다.
- 마우스를 움직이면 배트가 따라오고, 왼쪽 버튼을 누르면 배트를 휘두릅니다.
- 배트에 맞은 상자는 날아가서 바닥 밖으로 떨어집니다.
- 이것을 Windows에서 실행되는
.exe파일로 만들어 봅니다.
게임이라고 하기에는 작지만, 이 안에 Unity의 기본이 거의 다 들어 있습니다. 화면에 물체를 놓고(GameObject), 물체에 기능을 붙이고(Component), 코드로 조작하고(Script), 실행 파일로 내보내는(Build) 흐름입니다.
저도 Unity는 처음이라, 헷갈렸던 곳과 실제로 막혔던 곳을 그대로 적었습니다. 처음 시작하는 분이 위에서부터 차례로 따라 할 수 있게 쓰는 것이 목표입니다.
완성된 모습. 하얀 바닥 위에 상자들이 있고, 그 옆에 떠 있는 가느다란 막대가 배트입니다. (누르면 크게 보입니다)
실습 환경은 다음과 같습니다.
| 항목 | 내용 |
|---|---|
| 엔진 | Unity 6 (에디터 6000.6.3f1) |
| 운영체제 | Windows |
| 언어 | C# |
| 실습 프로젝트 | Unity 튜토리얼 샘플 (3D, 씬 이름 GetStarted_Scene) |
저는 Unity가 제공하는 튜토리얼 샘플 프로젝트 위에서 실습했습니다. 바닥과 상자가 이미 놓여 있어서 편했지만, 빈 3D 프로젝트에 바닥(Plane) 하나와 상자(Cube) 몇 개를 놓고 시작해도 똑같이 따라 할 수 있습니다.
먼저 알아 둘 말 네 가지
Unity 자료를 읽다 보면 같은 단어가 계속 나옵니다. 이 네 가지만 먼저 잡고 가면 나머지가 훨씬 쉽게 읽힙니다.
씬(Scene)은 게임의 한 장면입니다. 무대 하나라고 생각하면 됩니다. 지금은 씬 하나만 씁니다.
GameObject는 씬에 놓이는 모든 물체입니다. 상자, 바닥, 카메라, 조명이 전부 GameObject입니다. 저는 처음에 이것을 entity라고 불러야 하나 헷갈렸는데, Unity에서 entity는 DOTS(ECS)라는 전혀 다른 개발 방식의 용어입니다. "Unity entity"로 검색하면 엉뚱한 자료가 나오니, 씬에 놓인 물체는 GameObject라고 기억해 두면 됩니다.
컴포넌트(Component)는 GameObject에 붙이는 기능 조각입니다. GameObject 자체는 빈 껍데기이고, 무엇을 붙이느냐에 따라 성격이 정해집니다. 모양을 그리는 컴포넌트를 붙이면 눈에 보이고, 물리 컴포넌트를 붙이면 떨어지고 부딪힙니다. Unity는 "상자 클래스를 상속해서 무거운 상자를 만든다"가 아니라 "빈 물체에 필요한 기능을 조립한다"는 방식으로 생각합니다.
스크립트(Script)는 직접 C#으로 써서 붙이는 컴포넌트입니다. 그 가운데 BatController처럼 물체를 조작하는 스크립트를 흔히 Controller라고 부릅니다. 공식 용어는 아니고 관습적인 이름입니다.
그래서 이 글의 제목인 "GameObject와 Controller 사용법"은 풀어 쓰면 이렇습니다. 씬에 물체를 놓고, 컴포넌트를 붙이고, 스크립트로 움직여 본다.
Unity 설치하고 프로젝트 만들기
Unity는 프로그램이 두 개입니다. Unity Hub는 에디터 버전과 프로젝트 목록을 관리하는 런처이고, 실제 작업은 Hub에서 연 Unity 에디터 안에서 합니다.
- Unity Hub를 설치하고 Unity 계정으로 로그인합니다. 개인 사용자는 무료인 Personal 라이선스가 자동으로 활성화됩니다.
- Installs → Install Editor에서 Unity 6 버전을 고릅니다. 여러 편을 이어서 만들 계획이라면 장기 지원 버전(LTS)이 안정적입니다. 이번 실습에 쓴 에디터는 6000.6.3f1입니다.
- 설치할 모듈을 고릅니다. 나중에 휴대폰용으로 내보낼 계획이면 Android Build Support(OpenJDK, Android SDK & NDK Tools 포함)와 iOS Build Support를 함께 넣습니다. 모듈은 나중에 추가할 수도 있으니 지금은 넘어가도 됩니다. iOS 빌드에는 Mac과 Xcode가 필요합니다.
- Projects → New project에서 템플릿을 고릅니다. 이번 실습은 3D 튜토리얼 샘플로 했습니다.
- 프로젝트 이름과 저장 위치를 정하고 Create project를 누릅니다. 처음 열 때는 시간이 꽤 걸립니다.
- 에디터가 열리면 Edit → Preferences → External Tools에서 코드 편집기(Rider 또는 VS Code)를 지정합니다. 스크립트를 더블클릭했을 때 열리는 프로그램입니다. Android Studio에 익숙하다면 같은 회사에서 만든 Rider가 손에 잘 맞습니다.
에디터 화면 읽기
에디터를 처음 열면 창이 많아서 어디를 봐야 할지 모르겠습니다. 위의 전체 화면 캡처를 옆에 두고 하나씩 보면 됩니다. 창은 여섯 개이고, 처음에는 앞의 네 개만 알아도 충분합니다.
| 창 | 어디에 있나 | 무엇을 하나 |
|---|---|---|
| Hierarchy | 왼쪽 | 지금 씬에 있는 GameObject 목록. 여기서 물체를 고른다 |
| Scene | 가운데 | 물체를 배치하는 작업대. 자유롭게 돌려 보며 편집한다 |
| Inspector | 오른쪽 | Hierarchy에서 고른 물체에 붙은 컴포넌트와 그 값 |
| Project | 아래 | 프로젝트 폴더의 모든 파일. 스크립트도 여기에 만든다 |
| Game | 가운데 (Scene 옆 탭) | 실제 플레이 화면. 게임 속 카메라가 보는 모습 |
| Console | 아래 (Project 옆 탭) | 로그와 에러 메시지 |
기본 동작은 하나입니다. Hierarchy에서 물체를 클릭하면, 그 물체의 정보가 Inspector에 나타납니다. Unity 작업의 대부분은 이 "왼쪽에서 고르고 오른쪽에서 고친다"의 반복입니다.
Hierarchy 창. 줄 하나하나가 GameObject입니다. 이 가운데 MainCamera와 BatPivot(그 안의 Bat)이 이번에 직접 추가한 것이고, 나머지는 튜토리얼 샘플에 원래 있던 것입니다.
앱을 만들어 본 분이라면 이렇게 대응시켜 보면 빨리 익숙해집니다. Hierarchy는 뷰 트리, Inspector는 속성 패널, Scene은 레이아웃 편집 화면, Game은 에뮬레이터, Console은 Logcat입니다.
자주 쓰는 단축키
수정 → 저장 → 플레이를 계속 반복하게 되므로 이 몇 개는 손에 익혀 두는 것이 좋습니다.
| 단축키 | 기능 |
|---|---|
| Ctrl + S | 씬 저장 |
| Ctrl + P | 플레이 시작 / 중지 |
| Ctrl + Shift + P | 일시정지 |
| Ctrl + Alt + P | 한 프레임씩 진행 |
| Ctrl + B | Build And Run (빌드하고 바로 실행) |
| Ctrl + Shift + B | Build Profiles 창 열기 |
| Ctrl + R | 에셋 새로고침 |
| Ctrl + Shift + F | 선택한 물체를 지금 Scene 창에서 보는 시점으로 옮김 (Align With View) |
처음에 꼭 당하는 함정: 플레이 중에 고친 값은 사라진다
위쪽 가운데의 ▶ 버튼(Ctrl + P)을 누르면 에디터 안에서 게임이 실행됩니다. 이 상태를 플레이 모드라고 합니다. 플레이 모드에서도 Inspector의 값을 바꿀 수 있어서 이것저것 조정하게 되는데, 플레이를 멈추면 그동안 바꾼 값이 전부 원래대로 돌아갑니다.
한참 맞춘 값을 날리지 않으려면 Edit → Preferences → Colors에서 Playmode tint를 눈에 띄는 색으로 바꿔 두세요. 플레이 중에는 에디터 전체가 그 색으로 물들어서 "지금 고치는 값은 저장되지 않는다"는 것을 바로 알 수 있습니다.
상자에 물리 붙이기
이제 상자가 맞으면 날아가도록 만듭니다. 지금 상자는 모양만 있을 뿐 무게도 없고 중력도 받지 않습니다. 물리 법칙을 따르게 하려면 Rigidbody라는 컴포넌트를 붙여야 합니다. Rigidbody가 붙은 물체는 중력을 받아 떨어지고, 다른 물체와 부딪히면 밀려납니다.
- Hierarchy에서 상자(01_Box_Yellow)를 클릭합니다.
- Inspector 맨 아래의 Add Component 버튼을 누릅니다.
- 검색창에 Rigidbody를 입력하거나, 목록에서 Physics → Rigidbody를 고릅니다.
- Inspector 오른쪽 위의 Static 체크를 해제합니다. Static은 "이 물체는 절대 움직이지 않는다"는 표시라, 켜져 있으면 날아가지 않습니다.
- Rigidbody의 Mass(질량)를 기본값 1에서 0.7로 낮춥니다. 가벼울수록 잘 날아갑니다.
- 나머지 상자 세 개에도 똑같이 합니다.
Add Component 메뉴. Physics와 Physics 2D가 나란히 있습니다. 3D 물체에는 Physics 안의 Rigidbody를 써야 합니다. Physics 2D의 Rigidbody 2D는 이름만 비슷한 다른 컴포넌트입니다.
Rigidbody를 붙인 뒤 상자의 Inspector. 위에서부터 컴포넌트가 차례로 쌓여 있습니다.
이 화면이 "GameObject는 컴포넌트의 조립"이라는 말을 그대로 보여 줍니다. 상자 하나에 붙어 있는 것을 위에서부터 읽으면 이렇습니다.
| 컴포넌트 | 하는 일 |
|---|---|
| Transform | 위치, 회전, 크기. 모든 GameObject에 반드시 있다 |
| Cube (Mesh Filter) | 어떤 모양인가 (정육면체) |
| Mesh Renderer | 그 모양을 화면에 그린다 |
| Box Collider | 충돌을 판정하는 보이지 않는 상자. 이것이 있어야 부딪힌다 |
| Rigidbody | 중력과 힘을 받아 움직인다 (방금 붙인 것) |
Collider와 Rigidbody의 차이가 처음에는 헷갈립니다. Collider는 "부딪히는 모양"이고 Rigidbody는 "물리로 움직이는 몸"입니다. Collider만 있으면 벽처럼 제자리에서 막기만 하고, Rigidbody까지 있어야 밀리고 날아갑니다.
여기까지 하고 Ctrl + P로 플레이해 보면, 공중에 떠 있던 상자가 있었다면 바닥으로 떨어지는 것을 볼 수 있습니다. 물리가 붙었다는 뜻입니다.
배트 만들기
상자를 칠 배트를 만듭니다. 배트는 물체 두 개를 겹쳐서 만듭니다. 하나로 만들지 않는 이유는 조금 뒤에 나옵니다.
- Hierarchy의 빈 곳에서 우클릭 → Create Empty를 고릅니다. 모양이 없는 빈 GameObject가 생깁니다. 이름을 BatPivot으로 바꿉니다.
- BatPivot을 우클릭 → 3D Object → Cube를 고릅니다. BatPivot 안쪽에 Cube가 생깁니다. 이름을 Bat으로 바꿉니다.
- Bat을 선택하고 Inspector의 Transform을 Position 0, 0, 1.5 / Scale 0.3, 0.3, 3으로 바꿉니다. 정육면체가 길쭉한 막대가 됩니다.
- BatPivot을 선택하고 Position의 Y를 상자와 같은 높이로 맞춥니다(제 씬에서는 8.86입니다).
- BatPivot에 Add Component → Physics → Rigidbody를 붙이고 Is Kinematic을 체크합니다. Interpolate 항목도 Interpolate로 바꿔 두면 움직임이 더 부드럽게 보입니다.
이렇게 한 물체 안에 다른 물체를 넣은 것을 부모-자식 관계라고 합니다. BatPivot이 부모, Bat이 자식입니다. 부모를 움직이거나 돌리면 자식이 그대로 따라갑니다.
| 오브젝트 | 역할 | 붙어 있는 것 | 설정 |
|---|---|---|---|
| BatPivot (부모, 빈 오브젝트) | 배트를 쥔 손. 이동과 회전을 맡는다 | Rigidbody, BatController 스크립트 | Is Kinematic 체크, Interpolate |
| Bat (자식, Cube) | 배트 자체. 모양과 충돌을 맡는다 | Mesh Renderer, Box Collider | Position 0, 0, 1.5 / Scale 0.3, 0.3, 3 |
자식인 Bat의 Inspector. 자식의 Position은 부모를 기준으로 한 값입니다. Z가 1.5라는 것은 "부모보다 1.5만큼 앞에 있다"는 뜻입니다.
왜 둘로 나누나요 — 회전축
물체는 자기 중심점을 축으로 회전합니다. 막대 하나만 만들어서 돌리면 가운데를 축으로 프로펠러처럼 돕니다. 배트는 손잡이 끝을 쥐고 휘둘러야 하는데 말입니다.
그래서 길이 3짜리 막대(Bat)를 앞으로 1.5만큼 밀어 두었습니다. 그러면 막대의 한쪽 끝이 정확히 부모(BatPivot)의 위치에 옵니다. 이제 부모를 돌리면 막대는 그 끝을 축으로 돕니다. 빈 물체 BatPivot은 눈에 보이지 않지만 "쥐는 손" 역할을 하는 셈입니다. 회전축을 옮기고 싶을 때 빈 부모를 만드는 것은 Unity에서 아주 흔히 쓰는 방법입니다.
Is Kinematic이 뭔가요
상자에 붙인 Rigidbody는 중력을 받아 떨어지고 부딪히면 밀려납니다. 그런데 배트가 그러면 곤란합니다. 배트는 손에 쥔 물건이라 떨어져도 안 되고, 상자에 부딪혔다고 튕겨 나가도 안 됩니다.
Is Kinematic을 켜면 그 Rigidbody는 중력에도, 충돌에도 밀리지 않고 스크립트가 시키는 대로만 움직입니다. 그러면서도 다른 물체는 제대로 밀어냅니다. "남은 밀지만 나는 밀리지 않는" 물체가 필요할 때 씁니다.
제가 겪은 실수: Rigidbody를 자식에 붙였더니
처음에는 Rigidbody를 눈에 보이는 Bat 쪽에 붙였습니다. 플레이를 누르자 Bat만 따로 중력을 받아 아래로 떨어져 버렸고, 손잡이(BatPivot)만 허공에 남았습니다.
Rigidbody는 함께 움직이는 묶음의 가장 바깥 부모에 하나만 붙입니다. 자식의 Collider는 따로 손대지 않아도 부모의 Rigidbody에 자동으로 속합니다. 자식에 Rigidbody가 따로 있으면 그 자식은 물리적으로 독립된 물체가 되어 버리고, 충돌했다는 알림도 부모에 붙인 스크립트까지 올라오지 않습니다.
스크립트로 배트 움직이기
배트를 만들었지만 아직 가만히 있습니다. 마우스를 따라오고 클릭하면 휘두르게 하려면 스크립트를 써서 붙여야 합니다.
스크립트 만들고 붙이기
- Project 창에서 Assets 폴더를 우클릭 → Create 메뉴에서 MonoBehaviour Script를 고릅니다(Unity 6에서는 Scripting 아래에 있습니다).
- 이름을 BatController로 짓습니다. 파일 이름과 코드 안의 클래스 이름이 같아야 합니다. 다르면 물체에 붙일 수 없습니다.
- 만들어진 파일을 더블클릭하면 코드 편집기가 열립니다. 아래 코드를 붙여 넣고 저장합니다.
- 에디터로 돌아와 잠시 기다리면 Unity가 코드를 다시 읽습니다(컴파일).
- Project 창의 BatController를 Hierarchy의 BatPivot 위로 끌어다 놓습니다. BatPivot을 선택하고 Add Component에서 이름을 검색해 붙여도 같습니다.
Project 창의 Assets 폴더. 맨 아래에 직접 만든 BatController와 FreeCamera 스크립트가 있습니다.
스크립트를 물체에 붙이는 것은 Rigidbody를 붙이는 것과 똑같은 일입니다. 스크립트도 컴포넌트이기 때문입니다.
BatController 전체 코드
using UnityEngine;
using UnityEngine.InputSystem;
[RequireComponent(typeof(Rigidbody))]
public class BatController : MonoBehaviour
{
[Header("마우스 따라가기")]
public Camera cam; // 비워 두면 Main Camera 사용
public float followSpeed = 20f; // 포인터를 따라가는 최대 속도
public float heightSpeed = 4f; // Q/E 높이 조절 속도
[Header("휘두르기")]
public float swingSpeed = 720f; // 휘두르는 속도 (초당 각도)
public float swingAngle = 160f; // 휘두르는 범위
public float hitForce = 8f; // 맞았을 때 추가로 날리는 힘
Rigidbody rb;
float startAngle;
float currentAngle;
bool swinging;
bool returning;
float height; // 배트 높이
Vector3 targetPos; // 따라갈 목표 위치
void Awake()
{
rb = GetComponent<Rigidbody>();
rb.isKinematic = true;
rb.useGravity = false;
startAngle = transform.eulerAngles.y;
height = transform.position.y;
targetPos = transform.position;
if (cam == null) cam = Camera.main;
}
void Update()
{
var mouse = Mouse.current;
var kb = Keyboard.current;
if (mouse == null) return;
// 좌클릭: 휘두르기
if (mouse.leftButton.wasPressedThisFrame && !swinging && !returning)
swinging = true;
// Q / E: 높이 조절
if (kb != null)
{
if (kb.eKey.isPressed) height += heightSpeed * Time.deltaTime;
if (kb.qKey.isPressed) height -= heightSpeed * Time.deltaTime;
}
// 마우스 위치 → 배트 높이의 평면 위 지점 찾기
if (cam != null)
{
Ray ray = cam.ScreenPointToRay(mouse.position.ReadValue());
Plane plane = new Plane(Vector3.up, new Vector3(0, height, 0));
if (plane.Raycast(ray, out float distance))
targetPos = ray.GetPoint(distance);
}
targetPos.y = height;
}
void FixedUpdate()
{
// 포인터 위치로 부드럽게 따라가기
Vector3 next = Vector3.MoveTowards(rb.position, targetPos, followSpeed * Time.fixedDeltaTime);
rb.MovePosition(next);
// 휘두르기 → 원위치
if (swinging)
{
currentAngle += swingSpeed * Time.fixedDeltaTime;
if (currentAngle >= swingAngle)
{
currentAngle = swingAngle;
swinging = false;
returning = true;
}
}
else if (returning)
{
currentAngle -= swingSpeed * 0.5f * Time.fixedDeltaTime;
if (currentAngle <= 0)
{
currentAngle = 0;
returning = false;
}
}
rb.MoveRotation(Quaternion.Euler(0, startAngle + currentAngle, 0));
}
void OnCollisionEnter(Collision collision)
{
if (!swinging) return;
Rigidbody target = collision.rigidbody;
if (target == null || target.isKinematic) return;
// 배트 바깥 방향으로 살짝 띄워서 날리기
Vector3 dir = target.position - transform.position;
dir.y = 0;
dir.Normalize();
target.AddForce((dir + Vector3.up * 0.3f) * hitForce, ForceMode.Impulse);
}
}
스크립트를 붙이고 BatPivot을 선택하면 Inspector가 이렇게 됩니다.
BatPivot의 Inspector. 맨 아래 Bat Controller가 방금 붙인 스크립트입니다. 코드에 public으로 적은 변수들이 값을 넣는 칸으로 나타납니다.
코드 읽는 법
코드가 길어 보이지만 Unity가 정해 둔 틀을 알면 금방 읽힙니다.
Unity가 알아서 불러 주는 함수들. Awake, Update, FixedUpdate, OnCollisionEnter는 제가 지은 이름이 아닙니다. 이 이름으로 함수를 만들어 두면 Unity가 정해진 때에 알아서 불러 줍니다.
| 함수 | 언제 불리나 | 이 스크립트에서 하는 일 |
|---|---|---|
| Awake | 시작할 때 한 번 | Rigidbody를 찾아 두고, 처음 위치와 각도를 기억한다 |
| Update | 화면을 그릴 때마다 (매 프레임) | 마우스와 키보드 입력을 읽는다 |
| FixedUpdate | 물리 계산 주기마다 (일정한 간격) | 배트를 실제로 옮기고 돌린다 |
| OnCollisionEnter | 무언가와 부딪힌 순간 | 맞은 상자에 힘을 더해 날린다 |
앱은 버튼을 누르는 것 같은 이벤트가 있을 때만 코드가 돌지만, 게임은 아무 일이 없어도 Update가 1초에 수십 번씩 계속 돕니다. 이것을 게임 루프라고 하고, 앱 개발과 가장 다른 점이었습니다.
public 변수는 Inspector의 칸이 됩니다. public float followSpeed = 20f;라고 쓰면 Inspector에 Follow Speed 칸이 생기고 20이 들어갑니다. 코드를 고치지 않고 에디터에서 숫자만 바꿔 가며 손맛을 맞출 수 있습니다. [Header("…")]는 칸들 위에 소제목을 달아 줍니다.
Cam 칸은 비워 둬도 됩니다. public Camera cam; 한 줄이 Inspector에 Cam 칸을 만듭니다. 위 캡처에서 None (Camera)라고 비어 있는 그 칸입니다. 비어 있으면 Camera.main이 알아서 카메라를 찾아 씁니다. 다만 Camera.main은 물체의 이름이 아니라 Tag가 MainCamera인 카메라를 찾습니다. 이 이야기는 아래 카메라 부분에서 다시 나옵니다.
Time.deltaTime을 곱하는 이유. 컴퓨터마다 1초에 그리는 프레임 수가 다릅니다. "매 프레임 0.1씩 이동"이라고 쓰면 빠른 컴퓨터에서 더 빨리 움직입니다. Time.deltaTime은 직전 프레임에 걸린 시간이라, 이것을 곱하면 "1초에 얼마"라는 뜻이 되어 어느 컴퓨터에서나 속도가 같아집니다. FixedUpdate 안에서는 Time.fixedDeltaTime을 씁니다.
마우스 위치를 3D 위치로 바꾸기 (Ray와 Plane). 마우스는 화면 위의 평면 좌표이고 배트는 3D 공간에 있습니다. 그래서 카메라에서 마우스가 가리키는 방향으로 광선(Ray)을 쏘고, 배트 높이에 가상의 평면(Plane)을 깔아 둔 다음, 광선이 평면과 만나는 지점을 구합니다. 그 지점이 배트가 따라갈 목표 위치입니다.
순간이동시키지 않고 따라가게 하기. 배트를 목표 위치로 곧바로 옮기면, 마우스를 빨리 움직였을 때 배트가 순식간에 먼 거리를 건너뛰면서 스치기만 한 상자도 강하게 밀어냅니다. 그래서 Vector3.MoveTowards로 한 번에 followSpeed만큼만 다가가게 했습니다.
MovePosition, MoveRotation. Kinematic Rigidbody는 transform.position을 직접 바꾸지 않고 이 두 함수로 움직입니다. 그래야 물리 엔진이 배트가 지나간 길을 알고 상자를 제대로 밀어냅니다.
[RequireComponent(typeof(Rigidbody))]. 이 스크립트를 붙이면 Rigidbody가 없을 때 자동으로 같이 붙여 줍니다. 그런데 자동으로 붙는 Rigidbody는 기본 설정이라 Kinematic이 아니고 중력도 받습니다. 그래서 Awake에서 isKinematic = true, useGravity = false를 코드로 한 번 더 지정했습니다. 위 캡처에서 Use Gravity가 체크된 채로 남아 있는데도 실행하면 배트가 떨어지지 않는 이유입니다.
카메라 달기
여기서 한 번 크게 막혔습니다. 빌드해서 실행했더니 화면이 그냥 검은색이었습니다.
원인은 씬에 카메라가 없어서였습니다. 에디터의 Scene 창은 에디터가 가진 자체 카메라로 보여 주기 때문에, 씬에 카메라가 없어도 편집할 때는 멀쩡히 보입니다. 하지만 실제 게임 화면(Game 창과 빌드한 게임)은 씬 안에 놓인 카메라 GameObject가 찍은 것을 보여 줍니다. 카메라가 없으면 찍는 사람이 없으니 검은 화면이 됩니다.
- 메뉴에서 GameObject → Camera를 골라 카메라를 추가합니다.
- 카메라를 선택하고 Inspector 맨 위의 Tag를 MainCamera로 바꿉니다.
- Scene 창에서 마음에 드는 시점을 잡은 다음, 카메라를 선택한 채로 Ctrl + Shift + F(Align With View)를 누릅니다. 카메라가 지금 보고 있는 시점으로 옮겨집니다.
- Game 탭을 눌러 방금 잡은 화면이 보이는지 확인합니다.
카메라의 Inspector. 이름 바로 아래 Tag가 MainCamera로 되어 있습니다. 맨 아래 Free Camera는 바로 다음에 붙일 스크립트입니다.
이름과 Tag는 다릅니다
Hierarchy에 보이는 글자는 이름이고, 아무렇게나 지어도 됩니다. Tag는 Inspector 맨 위에서 고르는 꼬리표입니다. 코드의 Camera.main은 이름을 보지 않고 Tag가 MainCamera인 카메라를 찾습니다. 카메라 이름을 Main Camera라고 지었더라도 Tag가 Untagged면 Camera.main은 아무것도 찾지 못하고, 배트는 마우스를 따라오지 않습니다.
Tag를 믿고 싶지 않으면 Hierarchy의 카메라를 BatController의 Cam 칸으로 직접 끌어다 놓아도 됩니다. 그러면 Tag와 상관없이 그 카메라를 씁니다.
FreeCamera — 실행 중에 시점 움직이기
카메라를 고정해 두면 상자가 날아간 쪽을 볼 수 없어서, 에디터의 Scene 창처럼 마우스와 키보드로 시점을 움직이는 스크립트를 만들어 카메라에 붙였습니다. 만드는 방법은 BatController와 같고, 붙이는 곳만 카메라입니다.
using UnityEngine;
using UnityEngine.InputSystem;
public class FreeCamera : MonoBehaviour
{
[Header("키보드 이동")]
public float moveSpeed = 10f; // 화살표 이동 속도
public float fastMultiplier = 3f; // Shift 누르면 빨라지는 배수
[Header("마우스")]
public float lookSensitivity = 0.2f; // 우클릭 회전 감도
public float panSpeed = 0.02f; // 휠 클릭 드래그 이동 감도
public float zoomStep = 1.5f; // 휠 한 칸당 앞뒤 이동 거리
float yaw;
float pitch;
void Start()
{
// 지금 카메라 각도에서 시작
Vector3 e = transform.eulerAngles;
yaw = e.y;
pitch = e.x > 180f ? e.x - 360f : e.x;
}
void Update()
{
var kb = Keyboard.current;
var mouse = Mouse.current;
if (kb == null || mouse == null) return;
float speed = moveSpeed * (kb.leftShiftKey.isPressed ? fastMultiplier : 1f);
// 화살표: 바라보는 방향 기준으로 앞뒤 양옆 (높이는 유지)
Vector3 forward = transform.forward; forward.y = 0; forward.Normalize();
Vector3 right = transform.right; right.y = 0; right.Normalize();
Vector3 move = Vector3.zero;
if (kb.upArrowKey.isPressed) move += forward;
if (kb.downArrowKey.isPressed) move -= forward;
if (kb.rightArrowKey.isPressed) move += right;
if (kb.leftArrowKey.isPressed) move -= right;
// Page Up / Page Down: 위아래
if (kb.pageUpKey.isPressed) move += Vector3.up;
if (kb.pageDownKey.isPressed) move -= Vector3.up;
transform.position += move.normalized * speed * Time.deltaTime;
// 마우스 휠: 바라보는 방향으로 확대/축소
float scroll = mouse.scroll.ReadValue().y;
if (Mathf.Abs(scroll) > 0.01f)
transform.position += transform.forward * Mathf.Sign(scroll) * zoomStep;
Vector2 delta = mouse.delta.ReadValue();
// 우클릭 드래그: 시점 회전
if (mouse.rightButton.isPressed)
{
yaw += delta.x * lookSensitivity;
pitch -= delta.y * lookSensitivity;
pitch = Mathf.Clamp(pitch, -89f, 89f); // 뒤집히지 않게 제한
transform.rotation = Quaternion.Euler(pitch, yaw, 0f);
}
// 휠 클릭 드래그: 화면 이동
if (mouse.middleButton.isPressed)
{
transform.position -= (transform.right * delta.x + transform.up * delta.y) * panSpeed;
}
}
}
이 코드에서 하나만 짚고 넘어갑니다. 화살표 키 이동에는 Time.deltaTime을 곱했는데 마우스 회전에는 곱하지 않았습니다. 키를 누르고 있는 동안의 이동은 "1초에 얼마"라서 프레임 시간을 곱해야 합니다. 반면 mouse.delta는 이미 "이번 프레임에 마우스가 움직인 양"입니다. 여기에 또 곱하면 프레임이 높은 컴퓨터일수록 회전이 느려집니다.
두 스크립트 모두 맨 위에 using UnityEngine.InputSystem;이 있습니다. 키보드와 마우스를 Keyboard.current, Mouse.current로 읽는 새 입력 방식(Input System)을 쓴다는 뜻입니다. 인터넷의 예전 예제에 많이 나오는 Input.GetKeyDown(...)은 옛 방식이라, 새 방식으로 설정된 프로젝트에서는 에러가 날 수 있습니다. 튜토리얼 샘플에는 Input System이 이미 들어 있었습니다. InputSystem을 찾을 수 없다는 에러가 나면 Package Manager에서 Input System 패키지를 설치합니다.
실행해 보기
Ctrl + S로 저장하고 Ctrl + P로 플레이합니다. Game 창을 한 번 클릭해야 키보드와 마우스 입력이 게임으로 들어갑니다.
배트는 왼쪽 마우스 버튼과 Q/E, 카메라는 오른쪽·가운데 마우스 버튼과 화살표 키로 나눠 서로 겹치지 않게 했습니다.
| 대상 | 조작 | 기능 |
|---|---|---|
| 배트 | 마우스 이동 | 배트 위치 (포인터가 있는 곳이 손잡이) |
| 배트 | 좌클릭 | 휘두르기 |
| 배트 | Q / E | 아래로 / 위로 |
| 카메라 | 화살표 키 | 바라보는 방향 기준으로 이동 |
| 카메라 | Page Up / Page Down | 위로 / 아래로 |
| 카메라 | Shift + 이동 | 빠르게 |
| 카메라 | 마우스 휠 | 확대 / 축소 |
| 카메라 | 우클릭 드래그 | 시점 회전 |
| 카메라 | 휠 클릭 드래그 | 화면을 밀어서 이동 |
배트가 상자보다 높거나 낮아서 헛스윙을 한다면 Q/E로 높이를 맞춥니다. 휘두르는 느낌이 마음에 들지 않으면 플레이를 멈추고 BatPivot의 Inspector에서 Swing Speed, Swing Angle, Hit Force를 바꿔 봅니다. 플레이 중에 바꾼 값은 사라진다는 것, 잊지 마세요.
Windows에서 실행되는 파일로 만들기
에디터 안에서 잘 돌아가면 이제 에디터 없이도 실행되는 프로그램으로 내보냅니다. 이것을 빌드라고 합니다.
- Ctrl + S로 씬을 저장합니다. 저장하지 않은 변경은 빌드에 들어가지 않습니다.
- File → Build Profiles를 엽니다. 예전 자료에 Build Settings라고 나오는 창이 Unity 6에서 이름을 바꾼 것입니다.
- 왼쪽 Platforms에서 Windows를 고릅니다. 다른 플랫폼이 Active로 되어 있으면 Switch Platform을 누릅니다.
- Scene List에 지금 씬이 있는지 확인합니다. 없으면 Add Open Scenes로 넣습니다. 목록 맨 위의 씬이 게임을 켰을 때 처음 나옵니다.
- Build를 누르고 저장할 폴더를 정합니다. 프로젝트 폴더 안에
Builds/Windows같은 폴더를 새로 만들어 지정하면 됩니다. Assets 폴더 안에는 두면 안 됩니다. - 빌드가 끝나면 그 폴더의
.exe를 실행합니다.
File 메뉴 아래쪽에 Build Profiles와 Build And Run이 있습니다.
Build Profiles 창. 왼쪽에서 Windows가 Active이고, 오른쪽 Scene List에 GetStarted_Scene이 올라가 있습니다. 오른쪽 아래가 Build 버튼입니다.
빌드 폴더에는 .exe 하나만 생기지 않습니다. <게임 이름>_Data 폴더 등이 함께 생기는데, .exe와 이 폴더들은 항상 같이 있어야 실행됩니다. 다른 컴퓨터로 옮길 때는 폴더째 옮기세요. 함께 생기는 ..._BackUpThisFolder_ButDontShipItWithYourGame 폴더는 이름 그대로 남에게 줄 때는 빼는 폴더입니다.
빌드하면서 막혔던 곳
제가 실제로 겪은 문제는 위쪽 두 가지이고, 아래는 빌드하면서 궁금해서 찾아본 것들입니다.
| 증상 / 하고 싶은 것 | 원인 | 해결 |
|---|---|---|
| 검은 화면이 지지직거리며 깜빡인다 | DirectX 12와 그래픽 드라이버의 충돌 | Player → Other Settings에서 Auto Graphics API for Windows를 해제하고 Direct3D11을 맨 위로 올린다 |
| 깜빡임 없이 그냥 검은 화면이다 | 씬에 카메라가 없다 | 위의 "카메라 달기"대로 카메라를 추가하고 Tag를 MainCamera로 |
| 같은 폴더에 다시 빌드해도 되나 | 덮어쓴다. 다만 예전 빌드의 파일이 남을 수 있다 | 실행 중인 exe를 먼저 끈다. 깔끔하게 하려면 폴더를 비우고 빌드 |
| 시작할 때 Made with Unity 화면을 빼고 싶다 | Unity 6부터는 Personal 라이선스도 끌 수 있다 | Player → Splash Image → Show Splash Screen 해제 |
| 창에 X 닫기 버튼이 없다 | 전체 화면으로 뜨고 있다 | Player → Resolution and Presentation에서 Fullscreen Mode를 Windowed로, Resizable Window 체크 |
| 게임 안에 종료 버튼을 두고 싶다 | 직접 만들어야 한다 | 아래 QuitGame 스크립트를 버튼의 On Click()에 연결 |
표에 나오는 Player는 Edit → Project Settings → Player 화면입니다. 게임 이름, 아이콘, 창 모드, 그래픽 설정처럼 "내보낸 게임"에 관한 설정이 여기 모여 있습니다.
Player → Other Settings. Auto Graphics API for Windows의 체크를 끄면 아래에 목록이 나타납니다. Direct3D11을 끌어서 맨 위에 두었습니다.
검은 화면 깜빡임은 원인을 찾는 데 가장 오래 걸렸습니다. 설정을 바꾸기 전에 원인이 이것이 맞는지 빠르게 확인하는 방법이 있습니다. 빌드한 exe를 -force-d3d11 옵션을 붙여 실행해 보는 것입니다. 이렇게 했을 때 멀쩡히 나오면 DirectX 12 문제가 맞습니다.
Player → Resolution and Presentation. Fullscreen Mode를 Windowed로 바꾸면 보통 프로그램처럼 창으로 뜨고 닫기 버튼이 생깁니다.
빌드한 게임이 이상하게 동작하는데 화면에 아무 단서가 없으면 로그 파일을 봅니다. %USERPROFILE%\AppData\LocalLow\<회사 이름>\<게임 이름>\Player.log에 있습니다. 회사 이름과 게임 이름은 Player 화면 맨 위의 Company Name, Product Name입니다.
있으면 편한 작은 스크립트 두 개
필수는 아니지만, 스크립트가 얼마나 짧아도 되는지 보여 주는 예라서 함께 적습니다.
QuitGame — 종료 버튼
화면에 버튼(UI → Button - TextMeshPro)을 만들고, 이 스크립트의 Quit()을 버튼의 On Click()에 연결합니다. 에디터에서는 플레이를 멈추고, 빌드한 게임에서는 프로그램을 끝냅니다. #if UNITY_EDITOR는 "에디터에서 돌 때만 이 줄을 쓴다"는 뜻입니다.
using UnityEngine;
public class QuitGame : MonoBehaviour
{
public void Quit()
{
#if UNITY_EDITOR
UnityEditor.EditorApplication.isPlaying = false;
#else
Application.Quit();
#endif
}
}
Esc 키로도 끝내고 싶으면 Update에서 Keyboard.current.escapeKey.wasPressedThisFrame을 확인해 Quit()을 부르면 됩니다. 참고로 iOS는 앱 안에 종료 버튼을 두지 않는 것이 Apple의 가이드라인입니다.
FallRemover — 떨어진 상자 지우기
바닥 밖으로 날아간 상자는 끝없이 떨어지면서 계속 계산됩니다. 일정 높이 아래로 내려가면 지워 줍니다. 각 상자에 붙입니다.
using UnityEngine;
public class FallRemover : MonoBehaviour
{
public float killY = -20f;
void Update()
{
if (transform.position.y < killY)
Destroy(gameObject);
}
}
오늘 배운 것 정리
| 개념 | 한 줄 요약 |
|---|---|
| GameObject | 씬에 놓이는 모든 물체. 그 자체는 빈 껍데기 |
| 컴포넌트 | GameObject에 붙이는 기능 조각. 스크립트도 컴포넌트 |
| Hierarchy와 Inspector | 왼쪽에서 고르고 오른쪽에서 고친다 |
| Collider | 부딪히는 모양 |
| Rigidbody | 물리로 움직이는 몸. 묶음의 가장 바깥 부모에 하나만 |
| Is Kinematic | 중력과 충돌에 밀리지 않고 스크립트로만 움직인다. MovePosition, MoveRotation으로 옮긴다 |
| 부모-자식 | 부모를 움직이면 자식이 따라간다. 빈 부모로 회전축을 옮길 수 있다 |
| 생명주기 함수 | Awake(시작할 때) → Update(매 프레임) / FixedUpdate(물리 주기) |
| deltaTime | 곱해야 어느 컴퓨터에서나 같은 속도. 마우스 이동량처럼 이미 "이번 프레임의 양"인 값에는 곱하지 않는다 |
| public 변수 | Inspector에 칸으로 나타나 에디터에서 값을 조절한다 |
| 이름과 Tag | Camera.main은 이름이 아니라 Tag(MainCamera)로 찾는다 |
| 카메라 | 씬에 카메라가 없으면 게임 화면은 검다. Scene 창에 보이는 것에 속지 말 것 |
| Ray와 Plane | 화면의 마우스 위치를 3D 공간의 위치로 바꾸는 방법 |
| 플레이 모드 | 플레이 중에 바꾼 값은 멈추면 사라진다 |
다음에 해 볼 것
이번 실습은 Survival Trail이라는 2D 생존 게임 시리즈(초원, 산, 섬, 사막, 정글)를 만들기 전의 몸풀기였습니다. 다음 실습은 아래에서 하나를 골라 이어 갑니다.
- 2D로 전환: Universal 2D 프로젝트를 새로 만들어 2D 조명, 노멀맵, 그림자 테스트
- 채집 기본기: 캐릭터 이동 → 열매 줍기 → 인벤토리에 담기 → 배고픔 회복
- 점수와 UI: 떨어뜨린 상자 수를 화면에 표시하고 상자가 다시 생성되게 하기
- 안드로이드 빌드: 같은 흐름으로 .apk를 만들어 폰에서 실행
- 프리팹: 상자를 프리팹으로 만들어 여러 개 배치
- Claude Code 연결: Unity 프로젝트 폴더에서 Claude Code 실행, Unity MCP 연결 확인
- Git 설정: Unity용 .gitignore, Git LFS, Force Text, Visible Meta Files