[Unity/C#] 주먹구구식은 그만! 제대로 된 구조로 DDR 리듬게임 만들기 (1편: 라인 시스템과 판정)

안녕하세요. 오늘은 유니티로 리듬 게임을 만들 때 가장 중요한 **‘구조 설계’**부터 다루어보겠습니다.

인터넷에 흔한 예제들처럼 충돌체(Collider)를 쓰거나 GameObject.Find로 노트를 찾는 방식은 쉽지만, 노트가 많아지면 렉이 걸리고 정확한 판정을 구현하기 어렵습니다. 우리는 **자료구조(Queue)**를 활용해 ‘라인(Line)’ 별로 노트를 관리하는 정석적인 방법으로 구현해 보겠습니다.


🏗️ 핵심 아키텍처: Lane System

우리는 게임을 4개의 라인(D, F, J, K 키 등)으로 구성한다고 가정합니다. 핵심은 각 라인이 자신의 노트 목록을 직접 관리하게 하는 것입니다.

  • Note: 스스로 이동하며, 자신의 위치 정보를 가집니다.
  • LaneManager: 특정 라인(예: 첫 번째 줄)의 노트들을 Queue(대기열)에 담아 관리하고, 키 입력이 들어오면 가장 먼저 생성된 노트와의 거리만 계산합니다.

1. 노트 스크립트 (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); 
    }
}

2. 라인 매니저 (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 해줘야 함.
}

3. 구조의 장점 설명 (블로그 본문용)

왜 굳이 Queue를 썼을까요?

  1. 성능 최적화: GameObject.FindTag 검색은 게임 내의 모든 오브젝트를 뒤지기 때문에 느립니다. Queue를 쓰면 우리는 언제나 맨 앞(Target) 노트 하나만 신경 쓰면 됩니다. (연산량 최소화)
  2. 버그 방지: 충돌(Collider) 방식은 가끔 노트를 뚫고 지나가거나, 동시에 두 개가 눌리는 버그가 생깁니다. 큐 방식은 데이터 순서가 보장되므로 이런 버그가 없습니다.
  3. 확장성: 나중에 ‘롱 노트’나 ‘더블 노트’를 만들 때도 이 구조 위에 로직만 얹으면 됩니다.

4. 에디터 세팅 (따라하기)

  1. Lane 오브젝트 만들기: 빈 오브젝트를 만들고 LaneManager를 붙입니다. 이것을 복사해 4개를 만듭니다.
  2. 설정:
    • Lane 1: Key D, SpawnPoint (-1.5, 6, 0), HitPoint (-1.5, -3, 0)
    • Lane 2: Key F, SpawnPoint (-0.5, 6, 0), HitPoint (-0.5, -3, 0)
    • … (나머지 라인도 좌표만 다르게 설정)
  3. 실행: 플레이하면 각 라인에서 노트가 독립적으로 관리되며, 정확한 키를 눌렀을 때만 판정이 일어납니다.

🚀 다음 편 예고: “오브젝트 풀링과 리듬 동기화”

InstantiateDestroy는 메모리를 많이 먹습니다. 다음 시간에는 생성된 노트를 재활용하는 오브젝트 풀링(Object Pooling) 기법과, 음악 박자에 딱 맞춰 노트를 떨어뜨리는 BPM 동기화에 대해 알아보겠습니다.


도움이 되셨나요? 질문은 댓글로 남겨주세요!