판정 창 140ms와 입력 오프셋, 히트 에러 바를 만든 이유
브라우저 리듬 게임에서 "판정이 이상하다"는 느낌은 대부분 게임 로직보다 기기의 소리 지연에서 온다. ORBIT RUSH는 판정의 기준 시계를 오디오 쪽에 두고, 남는 오차는 플레이어가 직접 보고 고칠 수 있게 만드는 방향을 택했다. 3.2.0에서 추가한 히트 에러 바와 결과 히스토그램은 그 마지막 단계다.
기준 시계는 AudioContext 하나
브라우저에서 시간을 재는 방법은 여러 가지지만 리듬 게임에 쓸 만한 것은 Web Audio의 AudioContext.currentTime이다. 음악이 실제로 스케줄되는 시계와 같은 시계이기 때문이다. 화면 프레임 시각(requestAnimationFrame의 타임스탬프)을 기준으로 삼으면 탭 전환이나 프레임 드랍 때 음악과 판정이 미세하게 벌어진다.
게임을 시작하면 현재 오디오 시각에 0.25초와 카운트인 4박을 더한 지점을 0박(t0)으로 정한다. 이후 모든 판정은 "이 히트의 박을 초로 바꾼 값 + t0"과 입력 순간의 오디오 시각을 비교한다. 엔드리스처럼 템포가 중간에 바뀌는 모드를 위해 박→초 변환은 템포 변화 지점 목록을 따라 구간별로 계산한다. 일시정지는 오디오 컨텍스트 자체를 suspend()하므로 시계가 함께 멈추고, 다시 풀 때 시간이 튀지 않는다. 재개할 때는 3·2·1 카운트다운(0.6초 간격)을 먼저 보여 준 뒤에야 컨텍스트를 깨운다.
판정 창의 숫자
판정 창은 game.js에 이렇게 정의되어 있다.
const JUDGE={perfect:{win:.050,score:100,...},
great:{win:.095,score:70,...},
good:{win:.140,score:40,...}};
const EARLY_LIMIT=.30;
PERFECT ±50ms, GREAT ±95ms, GOOD ±140ms다. GOOD 바깥은 미스인데, 일찍 누른 쪽은 처리가 둘로 나뉜다. 140ms보다 일찍이지만 300ms 이내라면 EARLY 미스로 처리하고, 300ms보다 더 이른 입력은 아예 무시한다. 이 구분이 없으면 ½박 연타 구간에서 곤란해진다. 164 BPM에서 ½박은 약 183ms라서, 앞 히트를 처리한 직후 다음 히트를 미리 누르려는 입력이 너무 이른 경우가 생긴다. 이를 전부 미스로 잡으면 억울한 판정이 쌓이고, 전부 무시하면 마구 두드리는 플레이가 통한다. 300ms라는 경계는 그 사이의 타협이다.
또 하나, 30ms 안에 연달아 들어온 입력은 버린다. 키보드와 포인터 이벤트가 겹치거나 터치 화면에서 손가락이 두 번 인식될 때 두 번째 입력이 다음 히트를 먹어 버리는 것을 막기 위해서다. 늦은 쪽은 별도 입력이 없어도 매 프레임 검사한다. 현재 히트 시각에서 140ms가 지나도록 입력이 없으면 자동으로 MISS(블랙홀 뗌이면 HOLD!)를 내고 다음 히트로 넘어간다.
입력 오프셋과 8박 자동 보정
오디오 시계를 쓴다고 지연이 사라지지는 않는다. 스피커나 블루투스 이어폰까지 소리가 나가는 데 걸리는 시간은 기기마다 다르고, 게임은 그 값을 정확히 알 방법이 없다. 그래서 설정에 −150~+150ms 범위의 입력 오프셋을 두고, 판정할 때 입력 시각에서 이 값을 뺀다. 소리보다 늦게 누르는 경향이면 +쪽으로 옮기면 된다.
슬라이더만 주면 대부분의 사람은 어디로 옮겨야 할지 모른다. 그래서 자동 보정을 붙였다. 0.6초 간격(100 BPM)으로 틱 소리 8번을 들려주고, 각 탭이 가장 가까운 틱에서 얼마나 벗어났는지를 모은다. 탭이 4번 미만이면 다시 하라고 안내한다. 모은 값을 정렬해 가장 빠른 것과 가장 늦은 것 하나씩을 버리고 평균을 낸 뒤 5ms 단위로 반올림해 오프셋에 넣는다. 양 끝을 버리는 이유는 첫 탭이 늦거나 한 번 박을 놓친 경우 평균 전체가 끌려가는 것을 막기 위해서다. 중앙값을 쓰는 방법도 있지만 표본이 8개뿐이라 양 끝을 자른 평균이 조금 더 부드럽게 움직인다.

히트 에러 바: 지금 내 손이 어느 쪽으로 쏠리는가
보정을 한 번 해도 플레이 중의 경향은 따로 있다. 긴장하면 빨라지고, 빠른 곡에서는 늦어지는 식이다. 3.2.0에서 리듬 게임 장르에서 널리 쓰이는 표시 방식을 조사해 HUD 하단에 히트 에러 바를 넣었다.
바의 가로 폭은 ±160ms를 나타내고, 그 위에 GOOD·GREAT·PERFECT 구간을 초록·청록·노랑으로 겹쳐 칠한다. 최근 30타의 오차가 짧은 세로 눈금으로 찍히는데, 최근 것일수록 진하고 오래된 것일수록 투명하다. 맨 위의 작은 삼각형은 최근 30타의 평균 위치다. 눈금 하나하나보다 삼각형이 어느 쪽에 머무는지를 보면 오프셋을 어느 방향으로 옮겨야 할지 바로 알 수 있다. 판정 팝업에도 같은 정보를 넣었다. PERFECT가 아닌 판정에는 빠르면 ◀, 늦으면 ▶가 붙는다.
바 아래에는 UR(Unstable Rate)을 실시간으로 보여 준다. 계산은 단순하다. 지금까지의 오차를 밀리초로 바꾼 표준편차에 10을 곱한 값이다. 평균 오차가 "치우침"이라면 UR은 "흔들림"이다. 오프셋이 완벽해도 손이 들쭉날쭉하면 UR이 높게 나온다. 두 지표를 나눠 보여 주면 보정으로 고칠 문제와 연습으로 고칠 문제가 구분된다.
결과 화면의 타이밍 히스토그램
트랙이 끝나면 모든 판정 오차를 ±140ms 범위의 28칸 히스토그램으로 그린다. 한 칸이 10ms다. 막대 색은 그 칸이 속한 판정 구간을 따르므로, 분포가 노란 가운데에 모였는지 초록 가장자리로 퍼졌는지 한눈에 보인다. 아래에는 타이밍 평균, 편차, UR을 함께 적는다. 평균의 절댓값이 8ms 미만이면 "정확"으로 표시한다.

AUTO 모드는 이 기능을 검증하는 데도 쓰였다. AUTO는 매 히트의 정확한 시각에 오프셋을 더해 입력을 넣으므로, 판정 단계에서 오프셋을 빼면 오차가 정확히 0이 된다. 히스토그램이 한 칸에 몰리고 UR이 0으로 나오면 시계 계산과 오프셋 적용이 맞물려 있다는 뜻이다.
3.2.2에서 결과 화면 안내의 부호를 바로잡았다. 판정은 tp = t − offset/1000이라, 평균이 +30ms(늦음)일 때 오프셋을 +30으로 올리면 오차가 0이 되고 −30으로 내리면 +60으로 오히려 벌어진다. 자동 보정과 설정 화면 문구는 처음부터 "늦으면 +"였는데, 결과 화면만 "늦는 편 → 오프셋 −"로 반대로 적혀 있었다. 지금은 늦으면 "오프셋 +쪽으로", 빠르면 "오프셋 −쪽으로"라고 안내하고, 현재 오프셋에 평균을 더해 5ms 단위로 반올림한 권장값(±150ms 안)도 함께 보여 준다.
정리
판정 품질은 창의 너비보다 시계의 일관성에서 먼저 결정된다. 오디오 시계 하나로 음악·그림·판정을 묶고, 기기 지연은 오프셋과 자동 보정으로 흡수하고, 남은 개인차는 에러 바와 히스토그램으로 보여 주는 3단 구조가 지금의 ORBIT RUSH다. 결과 화면은 이제 권장 오프셋 값까지 알려 주니, 다음으로 손볼 부분은 그 값을 한 번에 넣는 "이 값으로 오프셋 적용" 버튼이다.