Apex Seoul 개발 기록 - 코너에 갇힌 차를 도로로 돌려보내기


기록을 남기기 전에, 끝까지 달릴 수 있어야 했다

지난 글에서는 차량별 개인 최고 기록과 최근 완주 이력을 저장했다. 다시 달렸을 때 기록이 남고, 브라우저를 껐다 켜도 마지막 선택을 복원할 수 있게 됐다.

그런데 플레이 중에 주행 자체가 막히는 문제가 나왔다. 코너에서 가드레일에 박힌 뒤, 가속을 해도 차가 빠져나오지 못하는 경우다. 플레이하는 입장에서는 앞뒤로 어떻게든 움직여보고 싶지만 차는 그 자리에 붙어 있다.

충돌해서 시간을 잃는 것은 타임어택에서 받아들일 수 있다. 진입 속도가 너무 빨랐다면 다음에는 조금 일찍 브레이크를 밟으면 된다. 하지만 입력해도 탈출할 수 없는 상태에서는 다음 행동을 고를 수가 없다. 결국 주행을 처음부터 다시 시작해야 한다.

그래서 일정 시간 동안 움직이지 못하면 차량을 도로 중앙으로 돌려보내는 고착 자동 복귀를 붙였다. 복귀한 차는 잠깐 깜박이고, 그 상태에서도 바로 운전을 이어갈 수 있다.

커버는 이 동작을 설명하기 위해 imagegen으로 만든 컨셉 일러스트다. 아래 본문 이미지는 실제 게임의 브라우저 검증 화면이다.

충돌 처리가 있어도 탈출 경로는 필요하다

Apex Seoul에는 이미 가드레일 충돌과 반동, 속도 손실이 있다. 가드레일과 저속 조향을 다뤘던 글에서도 정지 상태에서 차가 옆으로 미끄러지지 않도록 조향에 따른 횡이동을 제한했다.

이런 제약은 각각 필요하다. 차가 벽을 뚫으면 안 되고, 속도가 0인데 핸들만 돌렸다고 옆 차선으로 이동해도 이상하다. 다만 충돌 상태와 저속 조작이 만나는 경계에서는 플레이어가 빠져나오기 어려운 상황이 생길 수 있다.

이번 자동 복귀가 고착의 물리적 원인을 모두 고쳤다는 뜻은 아니다. 충돌 계산은 계속 점검해야 한다. 그와 함께, 문제가 생겼을 때 현재 주행을 이어갈 수 있는 경로를 마련했다.

처음부터 다시 시작하는 Retry도 있지만 코스 중간까지 달린 뒤라면 아깝다. 자동 복귀는 지금까지 달린 위치와 시간을 유지하면서 차를 다시 운전 가능한 상태로 만든다.

속도 0만 보고 복귀시키면 안 된다

가장 간단한 구현은 “속도가 낮은 상태가 몇 초 지속되면 리셋”이다. 하지만 그러면 출발을 기다리거나 브레이크를 밟고 멈춘 차까지 이동시킬 수 있다.

그래서 현재 구현은 세 조건을 함께 본다.

  1. 카운트다운이나 완주 이후가 아닌, 실제 주행 중이다.
  2. 브레이크를 누르지 않고 가속을 시도하고 있다.
  3. 가드레일에 접촉한 채 실제 위치 변화가 작다.

씬에서 판정 함수로 넘기는 입력은 이렇다.

const recovered = updateStuckRecovery(this.recovery, {
    active: this.runState.started && !this.runState.finished,
    attempting: drive.accelPressed && !drive.brakePressed,
    contact: this.playerVehicle.guardrailContactActive,
    z: this.cameraResource.z,
    x: this.playerVehicle.lateralOffset,
}, seconds);

현재 조작에는 실제 후진 기능이 없다. 이번에도 후진 기어를 추가한 것은 아니며, 가속해도 빠져나오지 못하는 상태에 복귀를 연결했다. 브레이크만 누르거나 가속과 브레이크를 동시에 누르는 경우는 의도적으로 멈추려는 입력으로 보고 고착 시간을 쌓지 않는다.

위치 판정에는 엔진 RPM이나 표시 속도 대신 코스상의 전후 위치 z와 도로 중심 기준 좌우 위치 x를 쓴다. 엔진이 돌고 있다는 사실만으로 차가 탈출하고 있다고 판단할 수는 없기 때문이다.

조금 움직였다는 이유로 판정이 계속 취소되지 않도록

차가 완전히 같은 좌표에 있어야만 고착으로 보면 충돌 경계에서 생기는 작은 흔들림 때문에 감지가 어려울 수 있다. 그래서 관찰을 시작한 위치에서 얼마나 벗어났는지를 계산한다.

const moved = Math.hypot(
    input.z - state.anchorZ,
    input.x - state.anchorX,
) > RECOVERY.movementUnits;

기준보다 많이 움직였으면 기준 위치와 대기 시간을 새로 잡는다. 그만큼 이동했다면 일단 탈출하거나 주행 중인 것으로 보는 것이다. 입력을 놓거나 접촉이 충분히 오래 끊겨도 누적을 해제한다.

현재 설정은 다음과 같다.

항목현재 값의미
이동 기준12 world units관찰 시작 위치에서 이보다 멀리 이동하면 대기 시간을 다시 시작
고착 대기3초입력과 접촉 조건을 유지하며 기다리는 시간
복귀 안내0.5초RETURNING TO ROAD…를 보여 주는 시간
접촉 유예0.2초접촉이 잠깐 끊겨도 관찰을 이어가는 범위
복귀 후 연출1.5초차량이 깜박이며 BACK ON ROAD를 표시하는 시간

여기서 12는 미터나 화면 픽셀이 아니라 게임 내부 좌표 단위다. 모든 차량과 코너에 대해 확정한 최적값도 아니다. 실제 주행 피드백을 보면서 조정할 초기 기준이다.

3초가 지나면 안내를 띄우고, 추가 0.5초 동안에도 탈출하지 못했을 때 복귀한다. 안내 중 스스로 움직이기 시작하면 복귀를 취소할 수 있다. 판정에는 프레임 수 대신 초 단위 시간을 누적해서 30fps와 120fps에서도 같은 대기 시간을 사용한다.

이 계산은 stuckRecovery.ts에 분리했다. 판정 함수는 차량 이미지나 Phaser 씬을 모르고, 입력과 위치를 받아 이번 프레임에 복귀해야 하는지만 반환한다. 덕분에 화면을 띄우지 않고도 대기 시간과 오발동 조건을 확인할 수 있다.

위치만 중앙으로 옮기면 끝이 아니었다

복귀 위치는 현재 코스상의 전후 위치를 그대로 둔 도로 중앙이다. cameraResource.z는 바꾸지 않고 차량의 lateralOffset을 0으로 돌린다.

현재 코스는 충돌 경계가 도로 양옆에 있으므로 이 위치를 사용할 수 있다. 앞으로 중앙에 장애물이나 다른 차량을 넣는다면, 중앙이라는 이유만으로 배치하지 말고 안전한 공간인지 확인하는 과정부터 추가해야 한다.

위치보다 더 신경 쓴 부분은 차량 상태다. 좌표만 옮기고 가드레일 접촉이나 반동 속도를 남겨두면, 화면에서는 중앙인데 내부에서는 계속 충돌 중인 차가 될 수 있다. 이전 drift나 차체 방향 상태가 남는 것도 곤란하다.

그래서 기존의 정지 차량 생성 함수를 사용해 차량 상태를 다시 만들었다.

const impacts = this.playerVehicle.guardrailImpactCount;

this.playerVehicle = createDefaultPlayerVehicleState(
    0,
    ACTIVE_RUNTIME_VEHICLE.engineProfile,
    PLAYER_DEFAULTS.PLAYER_ACCEL_SPEED,
);

this.playerVehicle.guardrailImpactCount = impacts;

이렇게 하면 차량별 엔진 프로파일을 유지하면서 속도, 기어와 RPM, 조향·미끄러짐·가드레일 상태를 정지 기본값에 맞출 수 있다. 이미 일어난 충돌 횟수는 따로 보존한다.

차량 바깥의 상태도 정리했다. 출발 제어, 차량 렌더링 캐시, 언더스티어 표시 상태, 카메라의 좌우 이동, 충돌 표시용 타이머를 초기화한다. 도로 물체의 움직임을 추적하는 상태도 리셋해서 복귀 순간의 위치 변화를 평소 주행처럼 이어서 해석하지 않게 했다.

복귀 직후 가속하면 다시 움직일 수 있는지까지 확인해야 이 처리가 끝난다. 화면에서만 중앙으로 이동한 채 여전히 조작이 막혀 있다면 복귀라고 부를 수 없다.

자동 복귀 뒤 BACK ON ROAD 안내와 함께 다시 가속하는 Apex Seoul 차량

이 화면은 고착 조건을 의도적으로 넣은 브라우저 테스트에서 캡처했다. 중앙 복귀 후 실제 차량 컨트롤러에 가속 입력을 주어 다시 움직이는 경로를 확인한 장면이다. 실제 코너 고착을 재현한 영상은 아니다.

깜박임은 이동을 알려주는 표시다

차가 아무 안내 없이 순간 이동하면 플레이어 입장에서는 또 다른 오류처럼 보일 수 있다. 그래서 복귀 직전에 안내하고, 배치된 뒤에는 1.5초 동안 차의 투명도를 완만하게 바꿨다.

현재 alpha 범위는 0.4에서 1이다. 차를 완전히 지우지 않으면서 정상 주행과 다른 상태임을 보여 준다. 깜박임이 끝나기를 기다릴 필요 없이 바로 가속과 조향을 할 수 있다.

이때 무적 시간을 주지는 않았다. 깜박이는 동안에도 충돌 판정은 유지한다. 복귀를 이용해 다음 가드레일을 관통할 수 있다면 타임어택의 주행 조건이 달라지기 때문이다.

시스템에서 동작 줄이기 설정을 사용하는 경우에는 깜박임 대신 alpha 0.6의 고정 반투명 표시와 안내 문구를 사용한다. 복귀 후 연출 중에는 고착 판정을 다시 쌓지 않고, 연출이 끝난 뒤 새로운 관찰을 시작한다.

타임어택이므로 시간과 진행은 그대로 둔다

복귀 기능을 붙이면서 정한 기준은 완주를 이어가게 하되, 앞으로 보내주지는 않는 것이다.

고착을 감지하는 3초와 안내 0.5초, 복귀 후 연출 중에도 주행 시간은 계속 흐른다. 별도의 시간 벌점은 넣지 않았다. 이미 멈춰 있는 동안 시간이 지나고, 복귀 후에는 정지 상태에서 다시 가속해야 한다.

코스의 전후 위치를 바꾸지 않으므로 복귀 자체가 체크포인트나 결승선을 넘지 않는다. 지금까지 통과한 체크포인트와 경과 시간도 초기화하지 않는다. 일반 주행 시간과 진행을 갱신한 뒤, 완주하지 않은 경우에만 복귀를 처리한다.

브라우저 탭을 숨기거나 포커스를 잃었을 때는 주행과 고착 관찰을 멈춘다. 입력도 해제하고, 돌아온 첫 프레임의 큰 시간 간격은 제외한다. 다른 창을 보고 돌아왔다는 이유로 고착 시간이 한꺼번에 쌓여 복귀하면 안 되기 때문이다.

복귀를 사용한 정상 완주는 기록에 포함하고, 완주 요약에는 recoveryCount를 남긴다. 예전 저장 데이터에 이 값이 없으면 0으로 읽는다. 이번에는 복귀와 시간 계산 등 주행 조건이 달라졌으므로 기록 규칙을 time-attack-v2로 올렸다. 이전 PB는 보존하되 현재 규칙의 기록 비교에서는 분리한다.

Retry 때는 새 주행이므로 복귀 횟수와 대기 상태, 연출 상태를 다시 초기화한다. 도로 중앙으로 돌아오는 복귀와 코스 처음부터 다시 달리는 Retry는 이 부분에서도 다르게 처리했다.

확인한 것과 더 달려봐야 할 것

순수 함수 검증에서는 30/60/120fps에서 복귀 대기 시간이 일치하는지 확인했다. 입력이 없거나 접촉하지 않았을 때, 실제 위치가 계속 바뀔 때는 복귀하지 않는지도 확인했다.

브라우저에서는 조건을 정해 넣는 테스트로 다음 연결을 확인했다.

관련 자동 검증과 기존 가드레일 충돌 검증, 게임 빌드는 통과했다. 전체 TypeScript 검사에는 기존 렌더링 코드 등의 오류가 남아 있다.

남은 확인은 실제 코너에서의 체감이다. 세 차량을 모두 운전하며 고착 상황을 재현한 검증까지 끝난 것은 아니다. 느리게 빠져나오는 차를 너무 빨리 복귀시키지는 않는지, 정말 막혔을 때 기다리는 시간이 답답하지 않은지를 더 봐야 한다.

기록을 저장하고 비교하는 기능을 붙이다 보니, 그 기록을 남길 수 있도록 끝까지 달리게 하는 처리도 필요해졌다. 이제 코너에서 한 번 막혔다고 주행 전체를 포기할 필요는 없다. 시간을 잃더라도 도로로 돌아와 다음 코너를 계속 달릴 수 있게 됐다.


관련 태그 글

225km/h인데도 코너가 느려 보였다 - grip sprite와 도로 진행 배율을 다시 나누기

225km/h인데도 코너가 느려 보였다 - grip sprite와 도로 진행 배율을 다시 나누기

최고속과 가드레일을 맞춘 뒤에도 코너는 느리고 차체는 과하게 꺾여 보였습니다. grip과 drift의 sprite 역할을 분리하고, 코너 리듬과 near-field 표식을 고친 뒤, 다른 pseudo 3D 레이싱 게임과 화면 흐름을 비교해 physical speed와 world progression 사이에 longitudinal scale을 도입한 과정을 정리합니다.

각주