Unity/Dev

[Unity][VR] 리듬 게임 모듈화

_GyC_ 2023. 6. 15. 15:03

학회 프로젝트에서 VR 리듬게임 모듈화를 하기로 해서 해보겠다. 바로 ㄱ


A. Sketch

모듈화란?

더보기

나도 이번에 처음 접해본 단어인데, 리듬게임을 만드려면 언제 어떤 노트를 보낼지 하나하나 다 정해야 한다. 그런데 모듈화를 하면 직접 노트를 내가 플레이하면서 찍는 것이다. 그 찍은 노트를 토대로 게임을 플레이할 수 있다. 글의 마지막 최종 결과 영상을 보면 이해가 더 잘 될것이다.

 

VR컨트롤러를 좌우로 움직이며 노트를 찍고 플레이하는 리듬게임 모듈을 만들어볼 것이다.

대충 스케치는 다음과 같다.

 

요약하자면, recording이 시작되면 특정 간격(n초)마다 left/right 포인트의 시간과 거리를 list에 저장한다.

그리고 playing이 시작되면 해당 list들을 읽으며 노트가 record된 시간에 record된 거리에 배치한다.

 

여기서 잠깐! 오른쪽/왼쪽 컨트롤러의 포인트를 특정 Axis에 투영한 것이 left/right 포인트이다. (시간되면 이 부분도 글에서 다루겠다.)


B. Recorder & Timer

먼저, recorder는 시간을 알려주는 텍스트와 record를 시작하는 버튼이 있다.

유니티에서 제공하는 3D 모형들로 직접 만들었다.

Start버튼을 누르면 record가 시작되고, Time 텍스트에서 지난 시간을 볼 수 있다.

    void Update()
    {
        startButton.GetComponent<Button>().onClick.AddListener(() => StartTimer());
        if((start || debugStart) && !isCounting)
        {
            start = false;
            debugStart = false;
            CountDown(countDown);
        }
        if(isCounting) CheckCount();
        if(isTicking) CheckTimer();

        if(playTimer.IsCounting() || isCounting || isTicking) startButton.SetActive(false);
        else startButton.SetActive(true);
    }
    
    private void StartTimer() { start = true; }

'startButton'을 눌러주면 StartTimer함수가 실행된다.

    private void CountDown(float t)
    {
        countStart = Time.time;
        countCurrent = 0f;
        countEnd = t;
        countCheck = t;
        isCounting = true;
        Debug.Log("TIMER: Countdown Start");
    }
    
    private void CheckCount()
    {
        countCurrent = Time.time - countStart;
        if((countCurrent >= 0) && (countCurrent < 1)) timeText.text = 3.ToString();
        else if((countCurrent >= 1) && (countCurrent < 2)) timeText.text = 2.ToString();
        else if((countCurrent >= 2) && (countCurrent < 3)) timeText.text = 1.ToString();
        else
        {
            isCounting = false;
            SetTimer(songLength);
        }
    }

Record가 시작되어 CountDown이 먼저 실행된다. (3초)

 

여기서 CountDown을 굳이 넣은 이유는 두가지이다.

1. 바로 시작해버리면 준비 시가이 없음.

2. 나중에 Play할 때 3초 전에 미리 노트가 나오게.

2번의 경우는 대부분의 리듬게임이 그럴 것이다. 플레이어도 미리 노트들을 봐야 플레이가 제대로 가능하다.

따라서 나중에 Play할 때 3초전에 노트가 나와서 떨어지게 할 것이다.

 

CheckCount를 통해 텍스트를 바꿔주고 만약 시간이 끝나면 Timer를 시작한다.

 

    private void SetTimer(float t)
    {
        timeStart = Time.time;
        timeCurrent = 0f;
        timeEnd = t;
        // timeText.text = $"{timeCurrent:N2}";
        isTicking = true;
        audioSource.Play();

        Debug.Log("TIMER: Start");
    }
    
    private void CheckTimer()
    {
        timeCurrent = Time.time-timeStart;
        if(timeCurrent >= timeEnd)
        {   
            startButton.SetActive(true);

            Debug.Log("TIMER: End");
            timeCurrent = timeEnd;
            isTicking = false;
        }
        timeText.text = $"{(timeCurrent):N3}";
    }

SetTimer와 CheckTimer를 통해 타이머를 작동시키고 이때는 노래까지 play해준다.


C. Record

실제로 left/right 포인트의 time과 dist값을 list에 기록하는 부분이다.

    void Update()
    {
        SetBools();

        if(recTimer.IsTicking() && !isRecording)
        {
            ResetList();
            StartCoroutine("RecordNoteCoroutine");
            isRecording = true;
        }
        else if(!recTimer.IsTicking() && isRecording)
        {
            StopCoroutine("RecordNoteCoroutine");
            isRecording = false;
        }
    }
    
    void SetBools()
    {
        isPlayingL = setting.IsPlayingL();
        isPlayingR = setting.IsPlayingR();

        if(point2axis.GetLeftPointOutOfBound()) isOutOfBoundL = true;
        else isOutOfBoundL = false;

        if(point2axis.GetRightPointOutOfBound()) isOutOfBoundR = true;
        else isOutOfBoundR = false;
    }
    
    void ResetList()
    {
        Debug.Log("RECORD: Reset List");
        notesL = new List <string> ();
        notesR = new List <string> ();
    }

SetBools를 통해 코드에 필요한 variable들을 설정해준다.

ResetList를 통해 다시 record를 하려고 할 때 left/right list를 초기화해준다.

 

    IEnumerator RecordNoteCoroutine()
    {
        if(L && isPlayingL && !isOutOfBoundL)
        {
            // Record Time and Distance
            float time = Time.time - recTimer.GetTimeStart();
            float dist = Mathf.Abs(LeftBottomCorner.transform.position.x - leftAxisPoint.transform.localPosition.x);
            string note = time.ToString() + "/" + dist.ToString();
            notesL.Add(note);
            // Instantiate Note for visual
            Instantiate(leftRecNote, leftAxisPoint.transform.position, panel.transform.rotation);
        }
        if(R && isPlayingR && !isOutOfBoundR)
        {
            // Record Time and Distance
            float time = Time.time - recTimer.GetTimeStart();
            float dist = Mathf.Abs(LeftBottomCorner.transform.position.x - rightAxisPoint.transform.localPosition.x);
            string note = time.ToString() + "/" + dist.ToString();
            notesR.Add(note);
            // Instantiate Note for visual
            Instantiate(rightRecNote, rightAxisPoint.transform.position, panel.transform.rotation);
        }
        // Recording Frequency
        yield return new WaitForSeconds(recFreq);
        StartCoroutine("RecordNoteCoroutine");
    }

기록은 매 n초마다 하기 때문에 코루틴을 사용해준다.

각 노트의 time과 dist를 계산해서 이를 note로 변환.

note가 string인 이유는 "time/dist"의 형식으로 저장해서 나중에 Split 함수를 사용하려고 한다.

기록을 함과 동시에 시각적으로 기록이된다는 것을 보이기 위해 Left/RightRecNote라는 prefab을 instantiate해준다.

 

    void Update()
    {
        transform.Translate(panel.transform.up * speed * Time.deltaTime);
    }

Left/RightRecNote는 Instantiate되면 리듬게임 panel을 따라서 위로 쭉 올라가다가 3초(카운트다운) 후에 사라진다.

리듬게임 panel


D. Record 결과

아주 이쁘기 노트들이 찍히는 것을 볼수있다!

여기서 기록되는 주기를 짧게 설정하면 더 자주 노트가 찍힐 것이다.


 E. Play

Play Timer도 있는데 이건 Record Timer랑 거의 똑같아서 넘어가겠다.

 

Play는 아까 record에서 기록한 list들을 받아와서 차례대로 노트를 찍으면된다.

이번에는 record와는 반대 방향으로 내려올것이다. 

 

    void Update()
    {   
        SetBools();

        if(playTimer.IsCounting() || playTimer.IsTicking())
        {
            // Start Playing
            isPlaying = true;
            GetNotes();
            if(L && indexL < notesL.Count) PlayNoteL();
            if(R && indexR < notesR.Count) PlayNoteR();
            
        }
        else if(!playTimer.IsTicking() && isPlaying)
        {
            // Stop Playing
            isPlaying = false;
            indexL = 0;
            indexR = 0;
        }
    }
    
    void SetBools()
    {
        isRecording = record.IsRecording();
    }

    void GetNotes()
    {
        notesL = record.GetNotesL();
        notesR = record.GetNotesR();
    }

SetBools도 아까와 마찬가지로 필요 변수들을 세팅해준다.

GetNotes를 통해 Record한 list들을 가져온다.

 

    void PlayNoteL()
    {
        string[] words = notesL[indexL].Split("/");
        float time = float.Parse(words[0]);
        float dist = float.Parse(words[1]);
        
        if((Time.time - playTimer.GetCountStart() >= time))
        {
            indexL += 1;
            Vector3 pos = leftTopCorner.transform.position + new Vector3(dist, 0f, 0f);
            Instantiate(leftPlayNote, pos, panel.transform.rotation);
        }
    }

    void PlayNoteR()
    {
        string[] words = notesR[indexR].Split("/");
        float time = float.Parse(words[0]);
        float dist = float.Parse(words[1]);
        
        if((Time.time - playTimer.GetCountStart() >= time))
        {
            indexR += 1;
            Vector3 pos = leftTopCorner.transform.position + new Vector3(dist, 0f, 0f);
            Instantiate(rightPlayNote, pos, panel.transform.rotation);
        }
    }

PlayNoteL/R은 list의 index를 옮겨가며 자기 시간이 오면 Left/RightPlayNote를 instantiate해준다.


F. 점수 매기기

점수를 체크하는 부분은 Left/RightPlayNote prefab에 붙였다.

Start함수에서

StartCoroutine("DestroyNoteCoroutine");

를 해주면 아래 코드가 실행된다.

    IEnumerator DestroyNoteCoroutine()
    {
        yield return new WaitForSeconds(countDown);
        CalScore();
    }

    private void CalScore()
    {
        float pointA = 0f;
        float pointB = 0f;
        bool isPlaying = false;
        if(isLeft)
        {
            pointA = this.gameObject.transform.position.x;
            pointB = leftAxisPoint.transform.position.x;
            isPlaying = isPlayingL;
        }
        else
        {
            pointA = this.gameObject.transform.position.x;
            pointB = rightAxisPoint.transform.position.x;
            isPlaying = isPlayingR;
        }
        float dist = Mathf.Abs(pointA - pointB);
        float range = correctRange/scores.Length;
        float score = 0f;
        if(isPlaying)
        {
            for(int i = 0; i < scores.Length; i++)
            {
                if(dist >= range*i && dist < range*i+range)
                {
                    score = float.Parse(scores[i].ToString());
                    break;
                }
            }
        }
        setting.AddTotalScore(score);

        if(score == 0f) Destroy(this.gameObject, 1f);
        else Destroy(this.gameObject);
    }

먼저 여기서

correctRange는 노트 판정 범위이다.

scores는 얼마나 노트와 가깝게 플레이하냐에 따라 얼마의 점수를 줄지 적혀있는 array이다.

예를 들어 점수가 perfect, great, good이 있고 각각은 10, 7, 3점을 준다면 

scores = [10, 7, 3]일 것이다.

 

위 코드는 자신(PlayNote)와 left/right 포인트의 거리를 통해 점수를 판정하는 것이다.

 


G. 최종 결과

이렇게 record를 하고 play를 하면 내가 기록한대로 노트들이 내려온다!

노트들은 내가 trigger버튼을 눌러야 플레이가 되고 점수도 오른다.

 

일단은 틀리면 노트가 panel 밖으로 나가서 destroy되게 했고,

잘 플레이하면 노트가 그 즉시 destory되도록 했다. 

이건 어디까지나 시각적으로 틀렸는지 맞았는지를 알려주기 위함이라, 나중에 바뀔 것이다.

 

이제 남은 큰 과제는 아마 이렇게 기록한 것들을 database에 저장해서 날라가지 않도록 하는 것이다!

이건 다음에...

 

그럼 즐개~ (즐거울 개발..이라는...뜻)