김선영
한세대학교 22학번 컴퓨터공학과
한세대학교 22학번 컴퓨터공학과
안녕하세요. 오늘은 유니티로 리듬 게임을 만들 때 가장 중요한 **‘구조 설계’**부터 다루어보겠습니다.
인터넷에 흔한 예제들처럼 충돌체(Collider)를 쓰거나 GameObject.Find로 노트를 찾는 방식은 쉽지만, 노트가 많아지면 렉이 걸리고 정확한 판정을 구현하기 어렵습니다. 우리는 **자료구조(Queue)**를 활용해 ‘라인(Line)’ 별로 노트를 관리하는 정석적인 방법으로 구현해 보겠습니다.
우리는 게임을 4개의 라인(D, F, J, K 키 등)으로 구성한다고 가정합니다. 핵심은 각 라인이 자신의 노트 목록을 직접 관리하게 하는 것입니다.
Queue(대기열)에 담아 관리하고, 키 입력이 들어오면 가장 먼저 생성된 노트와의 거리만 계산합니다.Note.cs)노트는 단순해야 합니다. 움직이고, 필요 없으면 사라지는 기능만 담당합니다.
using UnityEngine;
public class Note : MonoBehaviour
{
// 노트의 속도는 외부(Manager)에서 통제하는 것이 확장성에 좋습니다.
private float noteSpeed;
public void Initialize(float speed)
{
this.noteSpeed = speed;
}
void Update()
{
// 단순 이동: 위 -> 아래
transform.Translate(Vector2.down * noteSpeed * Time.deltaTime);
}
// 화면 밖으로 나갔을 때 호출될 함수 (여기서는 간단히 처리)
public void HideNote()
{
// 나중에 오브젝트 풀링(Object Pooling)을 적용하기 위해 Destroy 대신 비활성화 추천
gameObject.SetActive(false);
}
}
LaneManager.cs) - 핵심!이 스크립트는 하나의 ‘라인’을 담당합니다. 4키 게임이라면 이 스크립트가 붙은 오브젝트가 4개 있어야 합니다.
Queue를 사용하면 매번 모든 노트를 검사할 필요 없이 **‘판정 대상이 되는 맨 앞의 노트’**만 쏙 꺼내서 검사할 수 있습니다.
using System.Collections.Generic;
using UnityEngine;
public class LaneManager : MonoBehaviour
{
[Header("Settings")]
public KeyCode inputKey; // 이 라인의 입력 키 (예: D)
public GameObject notePrefab; // 생성할 노트 프리팹
public Transform spawnPoint; // 노트 생성 위치
public Transform hitPoint; // 판정선 위치 (Target)
[Header("Difficulty")]
public float noteSpeed = 5f;
// 현재 화면에 나와있는 이 라인의 노트들을 순서대로 관리하는 큐
private Queue<Note> noteQueue = new Queue<Note>();
void Update()
{
// 1. 입력 처리
if (Input.GetKeyDown(inputKey))
{
HandleInput();
}
// (테스트용) 일정 시간마다 노트 생성 로직은 별도 GameManager에서 하는 게 좋지만,
// 여기서는 편의상 테스트 코드로 넣습니다.
if(Random.Range(0, 100) < 2) SpawnNote();
}
public void SpawnNote()
{
// 실제 생성 (나중엔 Object Pool에서 꺼내오는 방식으로 변경 권장)
GameObject g = Instantiate(notePrefab, spawnPoint.position, Quaternion.identity);
Note newNote = g.GetComponent<Note>();
newNote.Initialize(noteSpeed); // 속도 주입
noteQueue.Enqueue(newNote); // 대기열(큐)에 등록! 이게 핵심입니다.
}
private void HandleInput()
{
// 큐가 비어있다면 판정할 노트가 없다는 뜻
if (noteQueue.Count == 0) return;
// 가장 오래된(맨 아래에 있는) 노트 확인 (Peek은 꺼내지 않고 보기만 함)
Note targetNote = noteQueue.Peek();
// 판정선과 노트 사이의 거리 절댓값 계산 (오차)
float distance = Mathf.Abs(targetNote.transform.position.y - hitPoint.position.y);
// 판정 범위 설정 (단위: 유니티 좌표)
// 0.1f는 아주 정확함(Perfect), 0.5f는 보통(Good), 그 이상은 Miss
if (distance < 0.5f)
{
Debug.Log($"Hit! ({inputKey}) - 오차: {distance:F4}");
// 판정 성공 시 큐에서 완전히 제거하고 노트 오브젝트 처리
Note hitNote = noteQueue.Dequeue();
hitNote.HideNote(); // 혹은 이펙트 재생 후 비활성화
}
else
{
// 거리가 너무 멀면 헛누른 것으로 간주 (Miss 처리 등)
Debug.Log("Bad Timing...");
}
}
// 화면 밖으로 나간 노트 처리 (Update 등에서 체크 필요)
// 실제 구현에선 Miss 처리를 위해 거리가 일정 이상 멀어지면 Dequeue 해줘야 함.
}
왜 굳이 Queue를 썼을까요?
GameObject.Find나 Tag 검색은 게임 내의 모든 오브젝트를 뒤지기 때문에 느립니다. Queue를 쓰면 우리는 언제나 맨 앞(Target) 노트 하나만 신경 쓰면 됩니다. (연산량 최소화)LaneManager를 붙입니다. 이것을 복사해 4개를 만듭니다.D, SpawnPoint (-1.5, 6, 0), HitPoint (-1.5, -3, 0)F, SpawnPoint (-0.5, 6, 0), HitPoint (-0.5, -3, 0)Instantiate와 Destroy는 메모리를 많이 먹습니다. 다음 시간에는 생성된 노트를 재활용하는 오브젝트 풀링(Object Pooling) 기법과, 음악 박자에 딱 맞춰 노트를 떨어뜨리는 BPM 동기화에 대해 알아보겠습니다.
도움이 되셨나요? 질문은 댓글로 남겨주세요!