정의
chord(코드) 는 건반에서 여러 음을 동시에 치는 화음처럼, 여러 키를 같이 눌러 하나의 동작을 만드는 입력이에요. 이 문서의 chord 수집창은 첫 키 이벤트가 왔을 때 결과를 바로 정하지 않고, 짧은 시간 동안 같이 들어오는 키를 모은 뒤 한 번에 확정하는 방법이에요.
왜 필요한가
브라우저에는 "동시에 눌렀다"라는 개념이 없어요. W와 D를 같이 눌러도 keydown 이벤트는 키마다 따로, 순서를 가지고 도착해요. 게임처럼 화면을 그리는 루프(requestAnimationFrame)와 규칙을 한 칸 진행하는 고정 간격 계산(시뮬레이션 스텝)이 따로 도는 구조에서는 그 이벤트 사이에 프레임이나 스텝이 끼어들 수 있어요.
그래서 이런 버그가 생겨요. 같은 방향키를 두 번 빠르게 누르면 그 방향으로 대시하는 조작에서, 첫 키 이벤트에서 방향을 바로 확정해 다음 스텝에 쓰면 곧이어 온 두 번째 키(대각선을 만드는 키)는 합쳐지지 못해요. 두 키를 번갈아 누르면 한 키가 이미 눌린 상태로 남아 있어서 우연히 대각선이 되기 때문에, 눈으로는 같은 조작인데 결과가 달라지는 것처럼 보여요.
동작 원리
시간 → t0: keydown W (더블탭 성립) t0+Δ: keydown D
├──────── 수집창 (예: 수십 ms, 연장하지 않음) ────────┤
└→ 시뮬레이션 스텝에서 W+D 대각선으로 한 번 소비
- 첫 이벤트에서 확정하지 않아요. 더블탭이 성립하는 첫 키에서 수집창을 열어요.
- 창 안의 다른 축 키를 보충해요. 수직(W/S) 키와 수평(A/D) 키가 한 창 안에 들어오면 방향을 합쳐요.
- 창은 연장하지 않아요. 키가 올 때마다 창을 늘리면 지연에 상한이 없어져요. 첫 키에서만 시작해서 고정 길이로 끝나요.
- 소비는 시뮬레이션 스텝에서 한 번만 해요. 렌더 프레임에서 소비하면 렌더와 시뮬레이션이 어긋날 때 두 번 실행되거나 빠져요. 소비하면 버퍼를 비워요.
- 상태가 바뀌면 비워요. 창이 포커스를 잃거나(blur), 일시정지, 재시작에서 버퍼와 "첫 탭을 눌렀던 기록"을 지워요.
실무 적용
const CHORD_WINDOW_MS = 50; // 체감 지연이 없는 짧은 상한. 고정 길이.
let pending: { firstKey: string; startedAt: number; axes: Set<"x" | "z"> } | null = null;
window.addEventListener("keydown", (event) => {
if (!isDoubleTap(event.code)) return;
pending ??= { firstKey: event.code, startedAt: performance.now(), axes: new Set() };
pending.axes.add(axisOf(event.code)); // 창 안에서 들어온 키의 축을 모은다
});
function onSimulationStep(now: number) {
if (pending && now - pending.startedAt >= CHORD_WINDOW_MS) {
dash(directionFrom(pending.axes)); // 한 번만 소비
pending = null;
}
}
function resetInput() {
pending = null; // blur, pause, restart에서 호출
}
테스트는 같은 조합을 두 키 순서로 모두, 그리고 두 번째 keydown 사이에 프레임을 끼워서 작성해요. "번갈아 누르기"만 검증하면 이 회귀를 못 잡아요.
트레이드오프
| 선택 | 장점 | 비용 |
|---|---|---|
| 첫 이벤트 즉시 확정 | 지연 0 | 같이 누른 키를 놓칠 수 있어요 |
| 짧은 수집창 | 동시 입력을 안정적으로 합침 | 단일 키 입력도 창만큼 늦게 실행돼요 |
| 긴 수집창 | 느린 동시 입력도 잡음 | 조작이 둔하게 느껴져요 |
사용하면 안 되는 경우
- 키 하나만 필요한 동작에는 수집창이 필요 없어요. 불필요한 지연만 늘어요.
- 정밀한 입력이 필요한 격투 게임류는 프레임 단위의 입력 버퍼 정책을 따로 설계해야 해요. 이 문서의 방식은 그대로 쓰기 어려워요.
- 이 방식을 다른 입력 정책(게임패드, 터치)에 그대로 일반화할 근거는 없어요.
흔한 실수
- "동시에 눌렀다"를 한 이벤트라고 가정하기. 키마다 이벤트가 따로 와요.
- 창을 키가 올 때마다 연장하기. 지연에 상한이 없어져요.
- 리셋 경로를 빼먹기. blur나 pause 뒤에 남은 오래된 입력이 다음 동작을 오염시켜요.
- 번갈아 누르기만 테스트하기. 같은 조작이라도 이벤트 순서가 다르면 다른 코드 경로예요.
관련 개념
- ime-composition-commit-boundary — 여러 입력이 모여 한 의미가 될 때 확정 시점을 미루는 같은 계열의 문제
- pointer-events-touch-gesture-handling — 취소·리셋 경로에서 상태를 비우는 원칙
- test-oracle — 기대값을 구현이 아니라 정상 동작에서 가져오는 관점