← 프로젝트 회고 · Frontend
RETROSPECTIVE · 회고

싱크 맞추기 화면을 없애기까지: 리듬게임 자동 싱크

Frontend

웹 리듬게임은 기기마다 소리가 귀에 닿는 시각이 다르다. 스피커와 유선 이어폰은 수십 ms, 블루투스는 150ms가 넘기도 하고, 아이폰 사파리는 AudioContext.outputLatency를 알려주지 않아서 코드로 빼 줄 수도 없다. 그래서 대부분의 리듬게임은 플레이 전에 "싱크 맞추기"를 시킨다. 이 사이트도 그랬는데, 결국 그 화면을 통째로 지웠다.

1차: 3단계 싱크 마법사

처음 만든 건 노트 속도 → 화면만 보고 치기(타격 싱크) → 소리만 듣고 치기(음악 싱크) 3단계였다. 2단계는 결과가 일정했는데 3단계가 이상했다. 같은 사람이 같은 이어폰으로 해도 매번 20~40ms씩 '빠르게' 나왔다.

원인은 사람 쪽에 있었다. 메트로놈 소리에 맞춰 치면 사람은 소리보다 살짝 먼저 친다(negative mean asynchrony). 소리를 기다렸다 반응하는 게 아니라 다음 박을 예측하기 때문이다. 그러니 '들리는 소리에 맞춰 친 시각'으로 소리 지연을 재면 지연이 실제보다 작게 나온다.

그래서 3단계를 '치지 말고 보고 듣기만 해서, 노트가 선에 닿는 순간과 딸깍 소리가 맞을 때까지 −/+를 누르는' 방식으로 바꿨다. 측정은 정확해졌지만, 써 본 반응은 한 줄이었다. 사용자가 하기 너무 불편하다.

2차: 치는 동안 알아서 맞추기

생각을 바꿨다. 싱크가 틀렸다는 건 결국 플레이 중 입력이 늘 한쪽으로 쏠린다는 뜻이다. 소리가 늦든, 손이 늦든, 채보가 살짝 앞에 찍혔든 증상은 같다. 그렇다면 따로 재지 말고, 실제로 치는 입력을 보고 그만큼 판정을 옮기면 된다.

  1. 입력마다 같은 레인에서 가장 가까운 노트와의 차이를 기록한다(±250ms 안). 판정 창(±130ms)보다 넓게 잡아서, 싱크가 크게 틀려 MISS가 나는 입력도 보정 재료가 되게 했다. 블루투스로 150ms 늦으면 처음엔 거의 다 MISS라, 판정 결과만 보면 영영 못 따라잡는다.
  2. 입력 12개마다 최근 24개의 중앙값을 본다. 평균은 실수로 한 번 크게 늦게 친 입력에 끌려간다.
  3. 중앙값 주변의 흔들림(MAD)이 30ms를 넘으면 아무것도 안 한다. 들쭉날쭉한 건 싱크 문제가 아니라 실력이나 실수다.
  4. 쏠림이 6ms 이상이면 그 70%만, 한 번에 최대 40ms까지만 옮긴다. 한 번에 다 옮기면 잡음까지 따라가서 출렁인다.
  5. 옮긴 만큼 지금까지 모은 기록에서도 빼 둔다. 그래야 다음 판단이 '남은 쏠림'만 본다.
const autoSyncTick = () => {
if (syncTaps.length - syncSeen < AUTO_SYNC.every) return;
syncSeen = syncTaps.length;
const real = autoSyncStep(syncTaps, live.judge); // 중앙값·MAD로 옮길 양(ms)
if (real === 0) return;
live.judge += real;
for (let i = 0; i < syncTaps.length; i++) syncTaps[i] -= real / 1000;
onSettings({ judge: live.judge });
};

판정만 옮기면 안 되는 경우: 블루투스

타격 싱크는 판정만 옮기고 노트가 보이는 위치는 그대로 둔다. 손 버릇 수십 ms라면 그게 맞다. 그런데 소리가 150ms 늦는 경우에 판정만 옮기면, 점수는 맞지만 눈에 보이는 노트는 소리보다 150ms 먼저 선에 닿는다. 치는 사람은 계속 어색하다.

그래서 손 지연으로 볼 수 있는 한계를 ±60ms로 두고, 판이 끝났을 때 타격 싱크가 그걸 넘었으면 넘는 몫을 음악 싱크로 옮긴다. 음악 싱크는 노트 화면과 판정을 같이 옮기는 값이라, 둘의 합은 그대로(판정은 그대로)이고 다음 판부터 노트가 소리 나는 순간에 선에 닿는다. 플레이 도중이 아니라 판 끝에 옮기는 건, 치는 중에 노트가 갑자기 툭 움직이면 그게 더 거슬리기 때문이다.

export function splitSync(judge: number, offset: number) {
const ex = judge - clamp(judge, -AUTO_SYNC.handCap, AUTO_SYNC.handCap);
if (ex === 0) return { judge, offset };
return { judge: judge - ex, offset: clamp(offset + ex, -400, 400) };
}

확인

실제 지연·손 흔들림·실수 5%를 넣은 시뮬레이션으로 확인했다. 25ms 늦는 사람은 입력 12개 만에, 140ms 늦는 블루투스는 입력 60개쯤(한두 마디)에 ±8ms 안으로 들어왔다. 실제 지연이 0이면 ±6ms 안에서만 살짝 움직이고, 흔들림이 큰 사람은 덜 움직였다.

UI도 같이 덜어냈다. 처음엔 보정할 때마다 화면에 '싱크 자동 보정 +12ms'를 띄웠는데, 노트를 가린다고 해서 가장자리로 옮겼다가, 그래도 거슬린다고 해서 지웠다. 일시정지·결과 화면에서만 보여 준다. 첫 방문 때 띄우던 '싱크부터 맞춰주세요' 안내창과 3단계 마법사도 지웠고, 수동 조절은 자동 싱크를 끄면 슬라이더가 열리는 것만 남겼다.

배운 점

  1. 정확하게 '재는' 기능보다, 사용자가 아무것도 안 해도 되는 기능이 낫다. 측정이 사용자의 일이면 그 정확도는 사용자 인내심을 넘지 못한다.
  2. 사람의 입력을 데이터로 쓸 땐 사람의 버릇(박을 예측해서 먼저 치기)부터 알아야 한다.
  3. 자동 보정은 평균 대신 중앙값, '흔들릴 땐 가만히', '조금씩 여러 번'. 이 세 가지면 잡음에 휘둘리지 않는다.
  4. 보정이 잘 될수록 알릴 필요가 없다. 조용히 맞는 게 최고의 피드백이었다.
Gunmo Lee
싱크 맞추기 화면을 없애기까지: 리듬게임 자동 싱크 | Gunmo's Dev Life