플레이어들은 기술적인 문제 때문에 플레이를 망치는 것을 정말 싫어합니다. 플랫폼은 명확했고, 타이밍도 적절했는데, 플레이어가 실수를 해서가 아니라 브라우저 탭의 포커스를 잃었다는 이유만으로 게임이 캐릭터를 죽여버리는 상황 말입니다.

저는 제가 만든 Three.js 아케이드 게임인 Solstice Leap에서 이 문제를 직접 경험했습니다. 이 게임은 '버튼을 눌러 점프를 충전한 다음, 버튼을 떼서 틈 사이를 뛰어넘는' 하나의 만족스러운 메커니즘을 중심으로 설계되었습니다. 플레이 테스트를 진행하던 중, 저는 아주 짜증 나는 패턴을 발견했습니다. 만약 누군가 메시지에 답장하기 위해 Alt-Tab을 누르거나 충전 중에 다른 탭을 클릭하면, 창이 다시 활성화되는 순간 캐릭터가 허공으로 몸을 던져버리거나, 때로는 포커스를 잃자마자 즉시 뛰어내려 버리는 것이었습니다. 게임이 일상적인 운영체제의 중단을 의도적인 버튼 해제로 해석한 것입니다. 플레이는 억울하게 끝났고, 조작에 대한 신뢰는 무너졌습니다.

근본 원인: 하나의 이벤트가 두 가지 역할을 수행함

버그는 미묘했지만 직접적이었습니다. 기존의 입력 레이어에서 코드는 창의 blur 이벤트를 점프 해제 로직에 직접 연결해 두었습니다.

window.addEventListener("blur", releaseCharge);

얼핏 보면 합리적으로 보일 수도 있습니다. 플레이어가 키나 포인터를 누르고 있다가 무언가에 의해 중단된 상황이니까요. 하지만 blur 이벤트는 입력 이벤트가 아닙니다. 그것은 창 관리 신호입니다. 브라우저 탭이 운영체제의 포커스를 잃을 때 발생하며, 이는 플레이어가 탭을 전환하거나, 창을 최소화하거나, 외부 모니터를 클릭하거나, 심지어 시스템 알림이 포커스를 가져갈 때도 발생할 수 있습니다. 이러한 동작 중 그 어떤 것도 "캐릭터를 점프시키고 싶다"는 의미가 아닙니다. 그저 "게임 외부의 무언가와 상호작용하고 있다"는 의미일 뿐입니다.

blur 이벤트를 releaseCharge로 연결함으로써, 게임은 두 가지 완전히 다른 개념을 하나로 뭉뚱그려 처리했습니다. 하나는 의도적인 중단(플레이어가 버튼을 놓음)이고, 다른 하나는 외부의 방해(브라우저가 더 이상 활성 창이 아님)입니다. releaseCharge는 현재 충전 상태를 기반으로 점프 힘을 계산하고 즉시 속도를 적용했기 때문에, 충전 도중 포커스를 잃으면 쌓여 있던 힘만큼 그대로 발사되어 버렸습니다. 플레이어가 다시 돌아왔을 때는 캐릭터가 죽어 있거나, 본인이 허락하지 않은 동작 때문에 진행 상황이 망가진 상태였습니다.

Three.js 개발자를 위한 브라우저의 현실

Three.js는 강력한 3D 캔버스를 제공하지만, 입력은 여전히 DOM을 통해 흐릅니다. 이 차이가 중요합니다. 브라우저는 스페이스바를 누르고 있는 것이 점프를 충전한다는 사실을 본질적으로 알지 못합니다. 브라우저는 단지 키가 눌려 있다는 것만 압니다. 포커스가 문서(document)를 벗어날 때, 브라우저는 눌려 있는 모든 키에 대해 자동으로 keyup 이벤트를 생성해주지 않습니다. 대신, 창이 사라졌다는 사실만 알려줍니다. 만약 게임 로직이 '포커스의 부재가 곧 입력의 부재'라고 가정한다면, 유령 동작(phantom actions)이 발생하게 됩니다.

이러한 구분은 활을 당기거나, 차량의 엔진을 가속하거나, 충전된 마법을 캐스팅하거나, 스테미나를 소모하며 질주하는 등 도처에 존재하는 '차지업(charge-up)' 메커니즘에서 특히 중요합니다. 시간이 지남에 따라 상태를 축적하는 모든 지속적인 동작은 동일한 오해에 취약합니다. 네이티브 애플리케이션은 종종 포커스를 잃으면 전체 시뮬레이션을 일시 중지합니다. 브라우저 게임도 그렇게 할 수 있지만, 게임을 계속 실행하더라도 시스템의 방해와 플레이어의 명령은 반드시 분리해야 합니다.

의도와 방해의 분리

해결책은 충전 상태에서의 탈출 경로를 두 개의 별도 경로로 나누는 것이었습니다. 한 경로는 의도적인 입력을 처리하고, 다른 경로는 현실 세계가 개입했을 때를 대비한 생존 로직을 처리합니다.

의도적인 해제pointerupkeyup—는 여전히 점프를 실행합니다. 이것들은 플레이어가 직접 보내는 "가라"는 신호입니다.

포커스 상실 이벤트blur, pointercancel, 그리고 문서가 숨겨질 때 발생하는 visibilitychange—는 이제 cancelCharge라는 별도의 함수를 호출합니다.

cancelCharge는 수정된 형태의 해제가 아닙니다. 이것은 '하드 리셋'입니다. 축적된 충전 힘을 0으로 되돌리고, 플레이어의 시각적 크기를 기본 대기 상태로 복구하며, 화면상의 충전 게이지를 초기화하고, 게임을 조준 모드로 되돌립니다. 가장 중요한 점은, 이 함수가 발사 궤적 코드에는 전혀 손을 대지 않는다는 것입니다. 속도 계산도, 물리적 충격도, 도약도 없습니다. 충전은 안전하게 증발합니다.

업데이트된 연결 구조는 개념적으로 다음과 같습니다.

window.addEventListener("blur", cancelCharge);

하지만 진정한 아키텍처의 변화는 '충전'이 이제 두 가지 가능한 탈출 경로를 가진 하나의 '상태(state)'라는 점을 인식한 데 있습니다. 정상적인 해제 시에는 상태 머신이 충전율을 평가하고, 점프 속도를 계산한 뒤, 도약 애니메이션으로 전환합니다. 방해 이벤트가 발생하면 상태 머신은 이를 중단하고 대기 상태로 되돌립니다. 이 경로들을 분리함으로써 부수 효과(side effects)를 방지할 수 있습니다.

pointercancel 이벤트도 함께 감지해야 합니다. 브라우저는 터치스크린의 팜 리젝션(palm rejection) 제스처, 시스템 메뉴 호출, 또는 특이한 상황에서 펜이 접촉을 잃는 것과 같이 포인팅 장치에서 시스템 수준의 중단이 감지될 때 이 이벤트를 발생시킵니다. blurpointercancel을 함께 사용하면 데스크톱의 멀티태스킹과 모바일의 중단 상황을 모두 커버할 수 있습니다. 여기에 visibilitychange를 추가하면, 일부 브라우저와 OS 조합에서 발생할 수 있는, 윈도우 객체 자체에 반드시 blur가 발생하지 않으면서 사용자가 탭을 전환하는 시나리오까지 포착할 수 있습니다.

경계 조건 테스트

입력 버그를 수정하려면 해피 패스(happy path)를 벗어난 테스트가 필요합니다. 단일 탭에서 차분하게 게임을 플레이하는 것만으로는 이러한 문제를 발견할 수 없습니다. 새로운 동작을 검증하기 위해 저는 두 가지 구체적인 시나리오를 실행했습니다.

첫째, 점프를 차지(charge)하기 시작한 상태에서 키보드를 사용해 브라우저 탭을 전환함으로써 강제로 blur 이벤트를 발생시켰습니다. 게임은 즉시 차지 모드에서 벗어나 조준 상태로 돌아갔습니다. 점프는 실행되지 않았고, 속도도 적용되지 않았습니다. 차지 미터도 초기화되었습니다. 둘째, 정상적으로 차지를 수행한 후 의도적으로 버튼을 떼었습니다. 점프는 이전과 동일한 궤적과 힘의 스케일링을 유지하며 정확하게 실행되었습니다. 게임의 조작감은 그대로 유지되었고, 오직 에지 케이스(edge case)만 패치되었습니다.

두 경로는 반드시 독립적으로 유지되어야 합니다. 실수로 발생하는 점프는 막으면서 정당한 점프의 느낌을 둔하게 만드는 수정은 해결책이 아니라 또 다른 버그일 뿐입니다. 브라우저의 혼란으로부터 시스템을 견고하게 만들면서도 기존 메커니즘의 날카로움을 보존하는 것이 목표였습니다.

지속적인 입력을 위한 패턴

이 문제는 플랫폼 게임을 훨씬 넘어 확장됩니다. 지속적인 누름(press)에 의존하는 모든 Three.js 게임이 이 위험에 노출되어 있습니다. 마우스를 누르고 있는 동안 텐션이 쌓이는 1인칭 그래플링 훅이나, 키를 누르고 있으면 부스트가 충전되는 레이싱 게임을 생각해 보십시오. 만약 정리 로직(teardown logic)이 버튼을 뗄 때의 핸들러에만 존재하고 탭 전환, OS 알림, 또는 화면 잠금을 고려하지 않는다면, 여러분은 운영 체제가 대신 게임을 플레이하도록 허용하는 셈입니다.

더 넓은 관점에서의 패턴은 입력 레이어를 세 가지 명시적인 상태, 즉 활성 입력(active input), 해제된 입력(released input), 취소된 입력(cancelled input)으로 구축하는 것입니다. 활성 입력은 차지를 쌓거나 동작을 시작합니다. 해제된 입력은 이를 확정합니다. 취소된 입력은 이를 깔끔하게 종료합니다. 윈도우 blur가 해제(release)인 것처럼 위장하게 두지 마십시오. 브라우저는 호스트이지 플레이어가 아닙니다.

사용자의 행동을 고려하라

사람들은 탭을 전환합니다. 직접 메시지에 답장을 합니다. 보조 모니터로 가이드를 찾아봅니다. 업무용 Slack 알림을 확인합니다. 이것들은 에지 케이스가 아니라 브라우저 내에서의 표준적인 행동입니다. 일반적인 인간의 멀티태스킹을 처벌하는 브라우저 게임은 취약하게 느껴집니다. 포커스 상실을 명령이 아닌 취소로 처리함으로써, Solstice Leap은 이제 플레이어가 정성껏 설정한 점프를 망치지 않고도 잠시 자리를 비울 수 있게 해줍니다.

blur 이벤트는 해제(release) 이벤트가 아닙니다. 그것은 단지 브라우저가 방에서 잠시 나갔다고 말하는 것일 뿐입니다. 그에 맞춰 코드를 작성하십시오. 그러면 플레이어들은 실제로 점프를 하려고 할 때 컨트롤을 믿고 뛰어오를 수 있을 것입니다.