Loading
2013. 8. 29. 21:18 - lazykuna

리듬게임 파일(BMS)의 처리 방법에 대하여

BMS 에뮬레이터를 제작했던 경험에 기반하여 글을 작성한다.


(1) 파싱

헤더 부분은 적절하게 파싱하면 될 것이므로 생략... 다만, BPM 및 TOTAL 값들이 integer이 아닌 float 형태로 들어올 수 있으므로 유의하자.

데이터 부분의 경우 노트 하나를 다음과 같은 구조체로 만들어 저장한다

[(int)노트 키 값, (int)노트 채널값, (float)노트 beat, (int)기타 사항, (int)노트 time]

실질적으로 파싱떄는 노트 기 값과 노트 채널 값, 노트 beat값을 채우게 된다. beat값은 #aaabb에서 aaa가 base beat이므로 이를 이용하여 값을 쉽게 만들 수 있다.

이렇게 얻은 데이터들은 반드시, 적어도 변속BPM/STOP/노트 이 셋 데이터는 단 하나의 List로 묶어서 저장해야 한다. 이 셋은 순차적으로 읽어져야만 하기 때문에 sorting 되어 있어야 한다.

노트 키 값의 경우, 확장 채널을 지원하게 되면서 일반적인 Hex decoding이 아닌 0~9,A~Z의 36진수의 값을 가지게 된다. decoding 함수를 직접 만들어서 xx꼴의 키 값을 integer로 만들어 저장할 수 있도록 한다.

기타 사항은 여러 용도로 쓰이는데, 롱노트임을 표시하거나 눌린 노트/눌리지 않은 노트임을 확인하거나 등의 다양한 용도로 쓰일 수 있다.

노트 time의 경우 파싱이 모두 끝난 후에 별개로 계산하는 과정을 거치는데 이에 대해서는 (2)에서 쓰도록 한다.

일부 특정한 역할을 담당하는 채널(#02 beat 길이와 같이...)이 있는데, beat 길이 채널은 별도로 파싱하여 배열에 저장하도록 한다. (해당 배열은 물론 1.0 값으로 초기화 되어 있어야 한다)



(2) 파싱한 데이터 처리

각각의 키값에 시간을 매긴다. 방법은 다음과 같다.

1. 먼저 첫 비트부터 마지막 비트까지 도는 for 구문을 작성하고

2. 다음 key beat에 도달하기 전에 정수 비트 값을 체크하는 반복 루틴을 만든다. 무슨 의미냐면, 지금 비트가 2.5고 다음 비트가 4.2이면 3비트 ,4비트를 거쳐야 한다는 의미이다.

3. 해당 비트를 거칠 때 비트 구간에 대한 시간을 합산한다. 즉 time += (beat difference) * (1.0/bpm*60*4) * (beat 길이[정수값 beat]) (초)

이렇게 하는 이유는 beat 길이 채널이 존재하기 때문이다.

4. 해당 비트의 시간을 매긴다 (data.time = time). 이때 해당 비트가 STOP이면 time에 합산 해주고, BPM변속이면 bpm 값을 갱신한다.


이렇게 각 비트마다 시간을 얻어내는 이유는 판정을 위해서이다.


파싱한 데이터는 이제 beat에 따라 sorting 한다. 이제 배열은 시간(beat) 순서대로 정렬되어 있기 때문에 순차적으로 접근이 가능하다.



(3) 데이터 그리기

먼저 현재 시간으로부터 비트를 얻어낸다. 방법은 다음과 같다.


1. 첫 비트부터 마지막 비트까지 도는 for 구문에서

2. 각 비트마다 (2)의 방법으로 시간을 도출해낸다. 

3. 시간을 도출해낼때마다 해당 시간이 현재 시간보다 더 값이 큰지를 확인하고, 값이 더 클 경우

4. 비트를 도출해낸다. [현재까지의 beat + (시간차)*(bpm/60/4)/beat 길이[정수값 beat] ]


이제 이 beat로부터 pos값을 얻어낸다. 값은 누적해서 합산한다.


1. 첫 비트부터 마지막 비트까지 도는 for 구문에서

2. 다음 key beat에 도달하기 전에 매 정수값 beat를 체크하는 반복 루틴을 거친다. position은 계속 합산한다. 방법은 3을 참조.

3. position은 다음과 같이 합산한다 - (beat 차이) * speed * bpm * beat길이

4. 이제 position에 해당 노트를 그려주거나, bpm을 변경한다. STOP의 경우는 그냥 무시.

5. 2나 4 과정에서 beat가 정수가 될 때가 있는데 그 때 마디를 표시하는 수평선을 그려줄 수 있다.

6. position이 화면을 벗어나면 반복 구문에서 break;


이 때 현재 beat보다 작은 노트 값들은 miss 처리를 해 줄 수 있다.

miss된 노트임을  확인하기 위해 노트 구조체의 [기타 사항]란에 적당한 데이터를 삽입할 수 있다.



(4) 판정

다음과 같은 조건으로 첫 비트부터 검색을 시도한다 - 누른 키 값과 일치한 유효한 최소 beat를 가진 노트

sorting을 했으면 최소 beat임은 쉽게 파악할 수 있고, 유효한 것은 [기타 사항]란을 참조할 수 있고, 누른 키 값과의 일치성은 channel(key) 값을 참조할 수 있다.

그리고 앞에서 얻은 해당 노트의 시간과 현재 시간과의 차이를 얻어낸 후, 최고 판정부터 유효 시간 이내인지 확인을 한다.

이 이외의 부분은 본인이 자율적으로 처리를 하기 때문에 별도의 설명은 없음.



(5) 롱노트

롱노트는 처리 방법이 조금 까다롭기 때문에 별도로 다룬다.

사실 이렇게 처리하는게 좋은 방법인지는 나도 모른다... 코드가 너무 지저분하기 때문에 -_-.


먼저 파싱 부분부터 쓴다.


<1> 파싱 및 처리

일단 #LNTYPE 1 을 기준으로 데이터를  처리하게 되는데, #LNTYPE 2는 다음과 같이 처리하도록 한다.

1. LNTYPE 2에 해당하는 키 값 발견! 이제 for 구문을 돌리자.

2. 다음부터 검색하는 키 값이 현재 키 값과 일치하지 않으면, 구문에서 빠져나간다.

일치하면, 기존의 키 값을 list에서 빼 버린다.

3. (2)를 반복한다.


그러면 가장 처음과 끝 부분의 키 값만 남아 #LNTYPE 1의 형식의 데이터가 된다.


#LNOBJ의 경우, 노트를 집어넣을 때 추가적인 확인 과정이 필요하다.

1. #LNOBJ로 설정한 노트가 확인되었다 -> list에서 거꾸로 탐색을 시작한다.

2. 현재 channel과 동일한 가장 최근의(반대 순서) note를 찾았다 -> 이 노트와 1의 노트를 50~60대 롱노트 channel로 변경하여 저장한다.


이제 해당 롱노트의 처음과 끝을 마킹한다 (꼭 필요한 과정은 아닌 듯 하지만 난 그렇게 했다)

방법은 쉬우니 언급하지 않겠다. 처음인지 끝인지의 여부는 [기타 사항]란에 저장이 된다.


<2> 그리기

앞에서 완성한 그리는 루틴에 롱노트 루틴을 추가하면 된다.


먼저 첫 비트부터 마지막 비트까지 도는 for 구문에서

1. 롱노트 시작 - 시작할 때의 pos와 시작하는 롱노트 개체(인덱스)를 저장한다. 해당 pos가 아직 화면 표시 단계가 아니라면, lain의 시작 값으로 저장한다.

2. 롱노트 끝(화면 바깥으로 나간 것 포함) - 시작~끝 구간 크기만큼의 노트를 그리도록 한다. 노트를 miss로 그릴지, press로 그릴지, unpress로 그릴지는 '롱노트 시작'이 가지고 있는 데이터에 따른다. 끝 pos가 화면 표시 단계가 아니라면, 무시한다. 롱노트 state를 다시 초기화시킨다.


for 구문이 끝나면, 롱노트 상태를 확인한다. 끝이 나지 않은 롱노트가 있다면 ( 1의 상태), lain의 끝까지 그려주면 된다.


롱노트의 시작 부분이 화면 바깥을 지나가서 BAD 이상의 시간이 지나갔으면 miss 상태로 만든다. 롱노트의 끝 부분이 화면 바깥을 지나가면 화면 바깥을 지난 롱노트 상태로 만들어 준다. (이는 판정 부분의 수월함을 위함이다)


<3> 판정

유효한 롱노트를 눌렀을 때, BAD가 아닌 이상 곧바로 판정을 내지 않는다. 해당 판정은 저장한다.

롱노트를 땠을 때, 너무 일찍 떼지(BAD 판정) 않은 이상 저장된 판정을 표시하도록 한다.

참조로, 롱노트를 누른 상태에서 롱노트의 끝이 화면 바깥으로 나가면(시간이 지나면) 자동으로 떼는 것으로 처리한다.



(6) etc

1. bpm은 말 그대로 1초당 들어있는 비트 수이다. 이를 60으로 나누어 주면 bps가 될 것인데, 4로 또 나누는 이유는 한 비트는 4개의 박자로 이루어져 있기 때문이다(4/4박자). 즉 한 박자의 길이(1/4beat)는 bpm/60/4이며, 이 값으로 1을 나누어 주면 박자 당 초(spb)가 나오게 될 것이다.

2. BMS 파일 포멧에 관한 문서는 여기저기를 읽어보라.

3. 내가 썼지만 설명이 참 개판같다. Rhythmus 코드를 참조하라.

4. autoplay의 경우 화면 바깥으로 나간 노트들 중 눌리지 않은 노트가 있을 때 press/release를 시키면 간단하게 구현이 된다.





오래된 글이지만, 몇가지 질문사항이 들어온 김에 글을 업데이트합니다. 위에 잡소리보다 이게 더 도움이 될 것 같네요.


  • 프로그래밍 기술은 눈 깜짝할 새에 바뀌고 있고, 똑같은 특정 기능을 구현하는 방법은 사람마다 천지차이입니다. 다른 사람이 만든 방법은 단순 참조용으로만 쓰시고, 그걸 이해하여 본인의 방법을 구현하는 것이 좋다고 생각합니다. bmx2wav 파일을 임의로 약간 개조한 제 bms 파일 로드 코드나, stepmania repo에 들어가서 코드를 읽어보는 것을 추천합니다.
  • bms 파일 로드 및 처리는 결코 간단하고 쉬운 구현이 아니라고 생각합니다. bms는 수많은 복잡한 객체들로 이루어져 있고, 이에 대한 일대일 혹은 일대다 대응 관계를 잘 파악해야 비로소 파서(게임)를 짤 수 있다고 생각합니다. 그리고, 이를 구현하는 데 도움될만한 몇가지 모델들이 존재하는데, tinyxml2 같은 경우에는 objpool을 만들어서 메모리 누수나 객체의 재생성으로 인한 낭비 없이 굉장히 효율적으로 객체를 관리합니다. 줄여서, 이 글을 읽고 bms에 대한 이해를 충분히 하시고 난 이후에 작업을 진행하세요. 자료구조 공부는 덤.
  • bms의 마디 길이라는 개념은, bms 편집기를 써 보시면 알겠지만 노트의 위치를 변화하지 않고 단순히 measure(마디) 위치만을 변화시킵니다. 따라서 bms object(노트, stop/bpm 명령 등 ...)는 마디가 아닌 다른 절대값을 기준으로 위치시켜야 합니다. 이제 이 값을 바(bar)라고 하겠습니다.
  • 마디 길이 이야기를 마저 하자면, 바의 일반적인 길이를 10240이라고 하면, 각 마디에서 구분할 수 있는 최대한의 노트 밀도, 즉 해상력은 10240개가 되겠죠. 만약 마디 길이가 0.5라면 그 마디의 해상도는 5120이 될 것입니다. 너무 작으면 노트를 제대로 표현할 수 없고, 너무 크면 마디 끝까지 갈 수 없게 될 것입니다(999마디의 bar index가 INT_MAX값을 넘어가게 되겠죠?). 제가 위에서 언급한 아이디어에 대한 이해를 돕기 위해 씁니다.[각주:1]
  • 일전의 자료구조 이야기에 이어서, bms object는 항상 정렬되어 있어야 하며, 순차적으로 접근하게 될 것입니다. 따라서 sort를 쓸 수도 있지만, std::map을 이용하여 iteration을 해도 좋을 것입니다.


누차 이야기하지만 다른 사람이 만든 좋은 코드를 읽어보는 것이 큰 도움이 됩니다. stepmaina가 꽤 좋은 코드를 가지고 있지만 꽤 크기가 커서 읽기에 어려운 점이 있습니다. 제가 만든 코드는 다른 사람(CHILD님의 코드를 차용, 게다가 본인의 발코딩 실력!)의 코드를 빌려온 거라 꽤 더러워서 추천하기는 어렵네요. 현재 bmsbelplus 프로젝트를 리팩토링하여 ojm/bmx/vos/sm 등의 파일들을 읽게 만드는 프로젝트를 진행중인데, 그때가 되면 좀 읽을만한 코드가 나오지 않을까 싶습니다.


아니, 그냥 stepmania 코드 읽으세요. 그게 제일 좋겠네요.










  1. 이 방식은 스텝매니아와 bmx2wav(bmsbel)에서 사용되는 것으로 추정됩니다. [본문으로]