Apex Seoul 개발 기록 - 세 대의 프로파일, RPM 컷, Retry시 리소스 오류
세 차량의 코너링 설정을 프로파일로 분리했다. 터보 차량의 변속과 RPM 컷을 수정하고, Retry 뒤 나무가 사라지던 Scene 수명주기 버그를 정리했다.
Cover image: OpenAI imagegen으로 생성한 모바일 플레이 콘셉트 이미지
차량별 로컬 기록과 기본값 복원을 정리한 다음에는 모바일에서 게임을 어떻게 플레이하게 할지 살펴봤다.
처음에는 스마트폰을 가로로 돌리면 화면 문제는 어느 정도 해결되지 않을까 싶었다. 레이싱 게임이니 가로 화면을 기준으로 잡고, 기기를 기울여 핸들을 돌리면 조작도 자연스러울 것 같았다.
그런데 실제로 메뉴와 입력 흐름을 따라가 보니 생각할 것이 꽤 많았다. 터치로 메뉴에 들어갈 수는 있는데 키보드 없이 돌아올 수 있는지, 가속하면서 동시에 조향할 수 있는지, 핸들을 돌리려다 화면 자체가 세로로 바뀌면 어떻게 할지까지 연결돼 있었다.
이번 글은 모바일 구현을 완료했다는 기록이 아니다. 현재 마련한 기반 위에 어떤 순서와 원칙으로 모바일 조작과 PWA를 붙일지 정리한 설계 기록이다. 커버 역시 실제 게임 스크린샷이 아니라 앞으로의 플레이 방식을 표현한 AI 생성 콘셉트 이미지다.
게임 화면을 작은 디스플레이 안에 맞추는 것과, 그 화면에서 편하게 조작하는 것은 별개다.
예를 들어 1200 × 760 논리 해상도의 캔버스를 844 × 390 화면에 비율을 유지하며 넣으면, 캔버스는 대략 616 × 390 크기로 표시된다. 게임 안에서 높이가 50인 버튼도 실제 화면에서는 약 26 CSS px 정도가 된다. 화면 안에 들어오기는 하지만 손가락으로 누르기에는 작다.
그래서 앞으로는 게임의 논리 좌표뿐 아니라 실제 표시 크기를 기준으로 버튼과 글자를 점검하려고 한다. 터치 영역은 우선 44 CSS px 이상을 프로젝트 목표로 잡고, 노치와 홈 인디케이터에 가리지 않도록 여백도 확보할 계획이다. 모든 모바일 기기에서 이 수치 하나면 충분하다는 의미는 아니다.
메뉴도 같은 기준으로 봐야 한다. Records에서 키보드 입력으로 돌아가게 해 두면 모바일에서는 빠져나올 수 없다. Records와 차량 선택 화면에 명시적인 돌아가기 버튼을 마련한 이유다. 이후에도 Start, Records, Options, Credits를 각각 따로 보는 것이 아니라, 들어가고 조작하고 다시 메인 메뉴로 돌아오는 흐름으로 확인하려고 한다.
그리고 모바일 대응을 하더라도 PC 플레이는 그대로 유지해야 한다. 창이 좁다는 이유만으로 데스크톱 사용자를 회전 안내 화면에 가두거나 키보드 입력을 막으면 안 된다.
모바일의 기본값은 센서 권한이 필요 없는 가상 버튼 조작으로 정했다.
| 입력 | 기본 모드 | Motion Steering 모드 |
|---|---|---|
| 좌우 조향 | 화면 왼쪽 아래의 좌우 버튼 | 스마트폰 기울기 |
| 가속·브레이크 | 화면 오른쪽 아래의 페달 버튼 | 동일한 페달 버튼 |
| 센서 사용 | 없음 | 권한과 센서 상태 확인 후 사용 |
페달과 방향 버튼은 한 번 누르면 상태가 바뀌는 토글이 아니라, 누르고 있는 동안 입력되는 버튼으로 만들 예정이다. 방향 버튼과 가속 버튼을 동시에 누를 수 있어야 하므로 멀티터치가 기본 전제다.
각 손가락의 포인터 ID를 추적하고, 손가락을 떼거나 터치가 취소되면 해당 입력을 해제해야 한다. 앱 전환, 창의 포커스 상실, 장면 종료 때에는 남은 입력을 모두 지운다. 가속과 브레이크를 동시에 누르는 경우에는 브레이크를 우선하는 정책으로 정리하려고 한다.
이런 처리가 빠지면 버튼 자체는 잘 보여도 손을 뗀 뒤 차가 계속 가속하는 문제가 생길 수 있다. 모바일 입력에서는 버튼을 누르는 처리만큼 해제하는 처리도 중요하다.
진동 피드백은 이번 범위에서 제외했다. 먼저 입력과 회전 대응을 안정적으로 만드는 데 집중하려고 한다.
Controls 항목은 다음처럼 단순하게 가져간다.
Motion Steering Off
Steering Sensitivity 70%
Motion Steering이 Off이면 가상 버튼을 사용한다. 이때 Steering Sensitivity는 회색으로 표시하고 변경할 수 없게 한다. On일 때만 민감도를 활성화한다.
여기서 민감도는 기기를 얼마나 기울였을 때 어느 정도 조향할지를 조절하는 값으로 정의한다. 키보드나 가상 버튼의 조향 성능을 바꾸는 공통 차량 설정으로 사용하지 않는다. 디지털 입력에 민감도 개념을 적용할 수 없어서가 아니라, 이번 게임에서는 역할을 이렇게 나누기로 한 것이다.
현재는 모드 선택값을 저장하고 민감도 UI의 활성 상태를 바꾸는 기반이 들어가 있다. On 표시가 된다고 센서 권한 요청과 실제 기울기 조향까지 완성된 것은 아니다. 다음 구현에서는 설정값뿐 아니라 실제로 사용할 수 있는 상태인지 확인하는 과정이 필요하다.
앞으로의 활성화 흐름은 다음과 같이 잡았다.
사용자가 On 선택
→ 지원 여부 확인 및 필요한 권한 요청
→ 유효한 센서 값 수신 확인
→ 현재 잡고 있는 자세를 중립으로 보정
→ 성공한 뒤 Motion Steering 활성화
중간에 실패
→ Off 유지 또는 복귀 + 이유 안내 + 가상 버튼 사용
브라우저가 별도 센서 권한 요청을 요구하는 경우에는 사용자가 옵션을 누른 동작에서 요청해야 한다. 센서 API는 보안 컨텍스트 등의 제약도 받는다. 구현 시에는 API 존재 여부와 권한 결과를 확인하고, 권한이 허용됐더라도 실제 데이터가 들어오는지 별도로 검증할 예정이다. 관련 동작은 W3C Device Orientation and Motion 명세를 기준으로 확인한다.
지원하지 않는 기기, 권한 거부, 데이터 수신 시간 초과, 중립 보정 실패는 모두 정상적으로 처리할 실패 경로다. 이런 경우 게임 전체를 막는 대신 가상 버튼으로 계속 플레이할 수 있게 한다.
저장된 설정이 On이라는 사실도 현재 세션의 센서 사용 가능 여부를 보장하지 않는다. 재실행 후에는 권한과 데이터 상태를 다시 확인하고, 필요하면 사용자 동작을 통해 재활성화하도록 할 생각이다.
흔히 가속도 센서 조작이라고 부르지만, 이번 조향은 원시 가속도를 바로 핸들에 연결하기보다는 DeviceOrientationEvent가 제공하는 기기 자세 값을 활용하는 방향으로 검토하고 있다.
다만 센서 좌표와 사용자가 보는 화면의 좌우는 항상 같지 않다. 스마트폰을 어느 쪽으로 돌려 가로로 잡았는지에 따라 화면 기준의 조향 축과 부호를 맞춰야 한다. 특정 축 하나를 그대로 핸들에 연결하고 끝내지 않고, 양쪽 가로 방향에서 모두 확인할 예정이다.
중립도 스마트폰을 바닥에 평평하게 놓은 상태로 강제하지 않는다. 플레이어가 편하게 잡고 있는 자세를 기준으로 보정하는 편이 맞다.
초기 튜닝은 다음 정도에서 시작해 보려고 한다.
보정 시간 250~500ms, 데드존 약 3도, 최대 조향에 필요한 기울기 약 20도 정도를 출발점으로 생각하고 있다. 아직 실기기에서 검증한 확정값은 아니다. 센서 갱신 속도와 손의 떨림이 다르므로 직접 플레이하며 조정해야 한다.
입력 구조도 손볼 부분이 있다. 현재 DriveCommand의 조향 값은 -1 | 0 | 1이고, 여러 입력을 합치는 과정도 부호만 남기는 방식이다. 여기에 센서 값만 넣으면 작은 기울기와 큰 기울기의 차이가 사라진다.
모션 조향에서는 -1부터 1 사이의 연속값이 차량까지 전달되도록 바꿔야 한다. 키보드 입력은 기존처럼 유지하고, 모션과 다른 조향 입력이 함께 들어올 때의 우선순위도 명시할 계획이다.
가장 신경 쓰였던 부분은 화면 회전이었다. 기울기를 조향으로 읽고 있더라도 운영체제는 같은 기기의 자세 변화를 보고 화면을 세로로 바꿀 수 있다.
조향에 필요한 각도를 작게 잡으면 그런 상황을 줄일 수는 있겠지만, 그것만으로 화면 회전을 방지했다고 볼 수는 없다.
지원되는 환경에서는 screen.orientation.lock('landscape')로 가로 고정을 시도할 수 있다. 다만 브라우저 지원과 실행 조건에 제약이 있고, 일반적으로 모바일의 전체 화면 같은 조건이 요구된다. 잠금 성공을 게임의 필수 전제로 삼지는 않기로 했다. MDN의 ScreenOrientation.lock() 설명에서도 이러한 제약을 확인할 수 있다.
정책은 간단하다. 가로 잠금은 가능한 경우 시도하고, 잠금이 실패한 상태에서 실제로 세로가 되면 게임을 멈춘다. 잠금 실패 자체만으로 가로 화면에서의 플레이를 막지는 않는다.
세로 화면 안에서 캔버스를 CSS로 90도 돌려 계속 주행시키는 방식은 이번에는 선택하지 않았다. 표시 방향과 터치 좌표, 센서 기준까지 별도로 맞추는 대신 안전한 일시정지 흐름을 먼저 만들려고 한다.
세로 전환 시에는 주행뿐 아니라 카운트다운과 기록 시간도 함께 정지하고, 가속·브레이크·조향 입력을 해제한다. 화면에는 가로로 돌려 달라는 안내를 띄운다.
다시 가로가 되어도 즉시 차를 출발시키지는 않을 예정이다. 화면이 안정된 뒤 사용자가 재개하고, 모션 모드라면 중립을 다시 확인한 다음 새로운 터치부터 받아들인다. 회전 전에 누르고 있던 페달 입력이 되살아나서는 안 된다.
세로를 거치지 않고 반대쪽 가로 방향으로 뒤집은 경우도 센서 기준이 달라질 수 있으므로 재보정 대상으로 본다.
모바일에서 자주 실행할 게임이라면 홈 화면에 설치해 독립된 창처럼 여는 PWA도 잘 맞을 것 같다.
방향 설정은 웹 앱 manifest의 orientation에 landscape를 지정하는 방식으로 준비할 수 있다. 이 값은 앱의 선호 방향을 나타내며, 실제 적용은 브라우저와 운영체제 지원에 영향을 받는다. PWA로 설치했다고 모든 환경에서 강제 가로 고정이 보장되는 것은 아니다. MDN의 manifest orientation 설명을 참고했다.
설정 방향을 보여 주는 예시는 다음과 같다. 설치 대상은 게임 허브의 iframe이나 /play/ 래퍼가 아니라 독립 게임 경로로 고정한다. 아직 적용된 manifest나 설치 요건을 모두 갖춘 완성본은 아니다.
{
"id": "/game-assets/apex-seoul/",
"name": "Apex Seoul",
"short_name": "Apex Seoul",
"start_url": "/game-assets/apex-seoul/",
"scope": "/game-assets/apex-seoul/",
"display": "standalone",
"orientation": "landscape"
}
둘 수 있다. 다만 버튼이 운영체제 설치를 직접 수행하는 것은 아니다. Chromium 계열 브라우저가 설치 가능한 앱이라고 판단했을 때 beforeinstallprompt event를 보내면, 게임은 그것을 잠시 보관한다. 이후 메인 메뉴나 Options의 INSTALL APP을 사용자가 눌렀을 때 한 번만 browser-native prompt를 열 수 있다. 설치를 수락하거나 취소한 뒤, 또는 appinstalled event가 오면 보관한 event와 버튼을 함께 제거한다. MDN의 custom install prompt 가이드를 따른다.
이 event가 없다는 것은 오류가 아니다. 이미 설치된 앱, 설치 조건을 충족하지 못한 상태, 지원하지 않는 브라우저에서는 설치 action을 숨긴다. 특히 게임 허브의 iframe에서는 설치를 시도하지 않고 OPEN STANDALONE GAME으로 독립 게임 경로를 열게 한다. 설치 프롬프트와 manifest 범위가 한 화면 안에서 일관되게 유지되기 때문이다.
iPhone과 iPad에서는 같은 event로 설치 창을 열 수 없다. browser mode일 때만 Safari 등의 공유 메뉴에서 홈 화면에 추가하는 짧은 안내를 보여 준다. standalone으로 실행 중이면 안내도 숨긴다. 즉 하나의 INSTALL APP UI는 지원 환경에서는 설치 요청, iOS 같은 환경에서는 설치 방법 안내라는 점진적 기능이 된다.
실제 도입할 때는 manifest 연결과 192/512px·maskable 앱 아이콘, HTTPS, 대상 브라우저의 설치 조건을 함께 점검해야 한다. 게임 빌드가 매번 public 게임 산출물을 새로 만들므로 PWA 파일은 배포 결과 경로에 직접 두지 않고 games/apex-seoul/public/에서 함께 빌드되게 한다. Android의 설치 실행과 일반 브라우저 실행, iPhone의 홈 화면 실행을 각각 확인하고, 어느 쪽이든 세로 전환 시 일시정지하는 fallback은 유지할 계획이다.
설치와 오프라인 지원도 한 덩어리로 생각하지 않으려고 한다. 서비스 워커를 도입한다면 어떤 파일을 저장하고 언제 새 버전으로 바꿀지 별도로 설계해야 한다.
특히 이 게임은 블로그와 같은 사이트에서 제공된다. manifest의 앱 범위와 서비스 워커의 제어 범위는 별도로 확인하고, 둘 다 /game-assets/apex-seoul/로 한정해 블로그 페이지와 다른 게임, /play/ 래퍼에 영향을 주지 않도록 할 생각이다.
캐시 대상도 저장소 전체가 아니라 실행에 필요한 게임 자산으로 제한한다. 새 버전이 나왔다고 레이스 중간에 강제로 새로고침하는 대신, 메뉴에서 업데이트를 안내하는 방향이 좋겠다. 로컬 기록 초기화와 캐시 갱신도 서로 다른 동작으로 유지하려고 한다.
앞으로의 순서는 다음처럼 잡고 있다.
844 × 390, 667 × 375, 640 × 360 같은 작은 가로 화면에서 버튼과 글자를 먼저 확인할 예정이다. 하지만 개발자 도구의 화면 크기 변경만으로 센서 권한이나 운영체제의 자동 회전까지 검증했다고 볼 수는 없다. 그 부분은 실제 Android와 iPhone에서 확인해야 한다.
결국 목표는 기울기 조향이라는 기능 하나를 추가하는 것이 아니다. 센서가 없어도 기본 조작으로 달릴 수 있고, 권한을 거부해도 게임이 막히지 않고, 화면이 돌아가도 기록과 입력이 꼬이지 않는 모바일 플레이를 만드는 것이다.
가상 버튼을 기본으로, 모션 조향은 선택으로, PWA는 실행 경험을 개선하는 다음 단계로. 이 순서로 하나씩 붙여 나가려고 한다.
세 차량의 코너링 설정을 프로파일로 분리했다. 터보 차량의 변속과 RPM 컷을 수정하고, Retry 뒤 나무가 사라지던 Scene 수명주기 버그를 정리했다.
코너 충돌 뒤 가속해도 움직이지 못하는 상황에 자동 복귀를 붙였다. 정상 정차와 고착을 구분하는 조건, 도로 중앙 배치와 차량 상태 초기화, 깜박임 연출, 타임어택 기록을 지키는 처리까지 정리했다.
최고 시간 하나만 저장하던 레이싱 게임에 차량별 PB와 최근 완주 이력을 붙였다. localStorage 저장부터 내부 배열 기반 기본값, 리셋 후 기록이 되살아나지 않게 하는 처리까지 정리했다.
아날로그 RPM, 디지털 속도계, 차량별 부스트 계기를 구현했다. 트윈터보의 과급 상태를 두 단계로 분리하고, 계기판을 오른쪽 하단에 배치했다.
Apex Seoul이 loading scene을 어떻게 구성하는지 설계합니다.
디더링된 5way 프로토타입이 전달하지 못하던 조향 정보를 7way로 확장하고, 3D 수정과 결정적 스크립트 가공만으로 Raven Coupe·Seorin GT·Mirae GT 후보 atlas를 만드는 과정을 정리합니다.