Apex Seoul 개발 기록 - 아날로그 HUD와 차량별 터보 응답
아날로그 RPM, 디지털 속도계, 차량별 부스트 계기를 구현했다. 트윈터보의 과급 상태를 두 단계로 분리하고, 계기판을 오른쪽 하단에 배치했다.
지난 글에서는 아날로그 RPM과 차량별 부스트 계기를 연결했다. Raven, Mirae, Seorin의 엔진 상태를 화면에서 구분할 수 있게 됐다.
계기판을 붙이고 나니 다음으로 손댈 부분은 기록이었다. 같은 코스를 다시 달렸는데 이전보다 빨랐는지 알 수 없으면 타임어택의 재미가 반쯤 빠진다. 브라우저를 다시 열 때마다 차량을 처음부터 고르는 것도 번거롭고.
그래서 이번에는 개인 최고 기록(PB)과 최근 완주 이력을 저장하고, 마지막으로 고른 차량과 색상도 복원하도록 붙였다.
코스는 아직 북악 하나지만 저장 구조까지 한 코스에 묶고 싶지는 않았다. 코스와 차량을 기준으로 데이터를 나누고, 자리만 잡아뒀던 RECORDS 메뉴와 옵션의 RESET LOCAL RECORDS도 연결했다.

위 화면의 Mirae 1:30.00은 화면과 저장 연결을 확인하기 위해 넣은 테스트 기록이다. 실제 주행으로 정한 목표 시간이나 내장 기본 기록은 아니다.
기존 저장 방식은 꽤 단순했다. 코스 ID를 key에 붙이고 완주 시간 숫자 하나를 저장했다.
apex-seoul:best-run:bugak-ridge-downhill → 완주 시간 숫자
차량이 하나일 때는 이 정도로도 충분하다. 그런데 지금은 세 대다. 이 구조에서는 Mirae로 세운 기록을 Raven으로 달리면서 비교하게 된다. 숫자는 남아 있는데, 정작 어느 차로 세웠는지 모르는 셈이다.
차량마다 가속과 과급 응답을 다르게 만들어놓고 기록을 한데 섞을 수는 없었다. 그래서 기록을 아래 세 값의 조합으로 묶었다.
trackId × vehicleId × rulesetVersion
| 기준 | 역할 |
|---|---|
trackId | 어느 코스에서 달렸는지 구분 |
vehicleId | 어느 차량으로 달렸는지 구분 |
rulesetVersion | 같은 조건으로 비교할 수 있는 기록인지 구분 |
색상은 주행 이력에 남기지만 최고 기록을 나누지는 않는다. 파란 Raven과 빨간 Raven은 같은 개인 최고 기록을 사용한다.
rulesetVersion은 앞으로의 나를 위해 넣었다. 개발하다 보면 코스 길이도 바꾸고, 차량 성능도 조정하게 된다. 그때 예전 PB가 너무 빨라서 도저히 깰 수 없는 기록으로 남으면 곤란하다. 비교 조건이 바뀌면 규칙 버전을 올리고, 예전 기록은 따로 보존할 수 있게 했다. 현재 값은 time-attack-v1이다.
schemaVersion과는 역할이 다르다. JSON 필드 구성이 바뀌면 저장 형식의 문제이고, 차가 더 빨라지거나 코스가 길어지면 기록 비교의 문제다. 둘이 항상 같이 바뀌는 것은 아니어서 분리했다.
지금 남길 데이터는 마지막 선택과 완주 요약 정도다. 이미 쓰고 있던 localStorage에 버전이 있는 JSON을 저장하는 방식으로 확장했다.
고스트처럼 매 프레임 위치를 저장해야 한다면 IndexedDB도 검토하겠지만, 이번에는 최근 이력 수를 제한하는 쪽으로 범위를 잡았다. 저장도 매 프레임이 아니라 선택 확정과 완주, 초기화 시점에만 한다.
apex-seoul:profile:v1
└─ 마지막 코스·차량·색상
apex-seoul:records:v1
├─ 코스·차량·규칙별 기록
└─ 기존 형식에서 가져온 legacy 기록
코스·차량·규칙 하나에 대응하는 묶음을 RecordBucket으로 만들었다. 이름은 조금 거창하지만 안에 들어가는 것은 PB, 최근 이력, 누적 통계다.
export type RecordBucket = RecordIdentity & {
bestRun: RunSummary | null;
recentRuns: RunSummary[];
completedRunCount: number;
totalFinishTimeSec: number;
};
bestRun에는 완주 시간뿐 아니라 checkpoint 누적 통과 시간도 같이 넣었다. 나중에 “지난 PB보다 이 구간에서 얼마나 늦었지?”를 보여 주려면 필요하다.
여기서는 PB를 세운 한 번의 주행을 통째로 남겼다. 서로 다른 주행에서 잘 나온 구간만 모으면 실제로 달린 적 없는 기록이 되기 때문이다.
최근 완주는 코스·차량·규칙별로 20개까지 저장한다. 화면에서는 우선 최근 5개를 보여 준다. PB는 최근 목록과 따로 보관하므로, 오래된 주행이 목록에서 빠져도 최고 기록은 유지된다.
완주 횟수와 완주 시간의 합도 남긴다. 이 시간은 정상 완주에 걸린 시간의 합이며, 메뉴에 머문 시간이나 중간에 포기한 주행까지 포함하는 총 플레이 시간은 아니다.
저장을 붙이면 보통 읽고 쓰는 경로부터 생각하게 된다. 그런데 첫 실행에는 읽을 데이터가 없다. 리셋 직후도 마찬가지다.
이때마다 화면에서 빈 값을 따로 처리하기보다는, 저장소가 없어도 사용할 수 있는 기본 구조를 먼저 만들기로 했다.
saveDefaults.ts에 기본 코스와 차량 배열을 두고, 그 조합으로 빈 기록을 만들었다.
export const SAVE_COURSES = [
{
id: 'bugak-ridge-downhill',
name: 'Bugak Ridge Downhill',
checkpointCount: 3,
},
] as const;
export const SAVE_VEHICLES = ['raven-coupe', 'seorin-gt', 'mirae-gt'] as const;
기본 기록을 만드는 함수는 코스 배열과 차량 배열을 순회하며 새 객체를 반환한다. 각 항목은 다음 상태로 시작한다.
{
bestRun: null,
recentRuns: [],
completedRunCount: 0,
totalFinishTimeSec: 0,
}
읽는 순서도 기본 구조 생성 → 저장 데이터 검증 → 유효한 항목 반영으로 잡았다. 저장 파일에 없는 항목은 기본값으로 남는다.
나중에 코스를 배열에 추가해도 이전 저장 파일 때문에 새 기록 행이 빠지는 일을 피할 수 있다. 물론 배열에 ID를 넣는다고 도로나 코스 선택 UI까지 생기는 것은 아니다. 그쪽은 별도 작업이다.
처음 실행했을 때의 개인 PB는 null, 화면 표시는 NO RECORD다. 아직 달리지 않은 차량에 완주 기록을 만들어 넣지는 않는다.
개인 기록과 별도로, 내부에서 관리할 기준 기록 배열도 준비했다.
export const DEFAULT_REFERENCE_RECORDS: readonly RunSummary[] = [];
지금은 비어 있다. 나중에 코스와 차량별 기준 시간을 정하면 이 배열에 일괄 작성할 예정이다. Records 화면에서는 현재 코스·차량·규칙에 맞는 항목을 REFERENCE TARGET으로 표시할 수 있게 했다.
이 배열은 게임이 제공하는 기준이고, 개인 PB는 플레이어가 만든 데이터다. 옵션에서 개인 기록을 지워도 기준 기록은 그대로 남게 했다. 나중에 기본 시간을 채워 넣을 때도 사용자 저장 데이터를 직접 고칠 필요가 없다.
둘 다 “다시 시작”처럼 보이지만 Retry와 기록 초기화는 건드리는 데이터가 다르다. Retry를 눌렀다고 힘들게 세운 PB까지 지워지면 안 된다.
| 동작 | 기록 | 마지막 선택 |
|---|---|---|
| 결과 화면의 Retry | 유지 | 같은 코스·차량·색상으로 재시작 |
| 옵션의 기록 초기화 | 개인 PB·최근 이력·누적 통계·legacy 초기화 | 유지 |
| 저장소가 없는 상태로 시작 | 내장 배열에서 빈 기록 생성 | 북악·파란 Raven 기본 선택 |
RESET LOCAL RECORDS를 한 번 누르면 삭제 확인 문구를 보여 준다. 같은 항목을 다시 누르면 초기화한다. 키를 계속 누르고 있을 때 반복 입력만으로 확정되지 않도록 처리했다.

리셋에서 특히 챙긴 부분은 새로고침해도 지운 기록이 돌아오지 않는 것이다. 화면과 메모리만 비우면 당장은 성공한 것처럼 보이지만, 다음 실행에서 이전 JSON을 다시 읽게 된다.
그래서 빈 기본 기록 문서를 저장하고, 예전 형식의 key도 함께 정리했다. 삭제한 기록이 legacy 이전 경로를 타고 다시 들어오는 경우도 막아야 했다.
또한 localStorage.clear()로 저장소 전체를 비우지 않는다. 이 게임이 소유한 기록만 처리하고, 마지막 선택 정보와 같은 주소의 다른 데이터는 유지한다. 이번 옵션 연결 범위는 기록 리셋이며, 다른 설정 항목의 저장과 실제 주행 반영은 후속이다.
저장소 접근은 앱에서 공유하는 RunRecordStore로 모았다. 주행 씬이나 결과 화면이 직접 localStorage를 읽고 쓰지 않도록 했다.
완주 기록은 finish 연출과 결과 화면 전환에 앞서 확정한다.
주행 시작: runId 생성·기존 PB 확인
→ 완주 확정
→ 최근 이력과 누적 통계 갱신
→ 더 빠르면 PB 교체
→ 저장 결과와 함께 ResultScene으로 이동
완주 처리에 같은 runId가 다시 들어와도 횟수가 늘지 않도록 했다. 결과 화면이나 연출을 수정하다가 저장 호출이 겹치더라도 한 번 달린 것으로 남아야 한다.
동률일 때도 기준을 정했다. 기존 PB와 checkpoint는 유지하고, 이번 주행은 최근 이력에만 추가한다.
첫 기록 표시는 의외로 따로 챙겨야 했다. 저장을 마친 뒤에는 이미 PB가 생겼으니, 현재 값만 보고는 첫 완주인지 알 수 없다.
결과 화면에 previousBestTimeSec를 따로 넘겨서 처음에는 FIRST RECORD, 기존 기록을 줄였을 때는 NEW BEST를 표시했다. 작지만 데이터를 언제 읽느냐가 화면 문구에도 영향을 주는 부분이었다.
Retry에는 runSetup을 명시적으로 전달한다. 같은 차량과 색상으로 다시 시작하면서 이전 finish와 결과 전환 상태도 초기화한다. 차량 선택 화면에서는 저장된 마지막 차량과 색상을 복원한다.
기록을 저장하지 못했다고 결과 화면까지 막을 수는 없다. 저장이 거부되면 이번 실행의 기록은 메모리에 유지하고, 결과 화면에 브라우저에는 저장되지 않았다고 알려준다.
이때도 메뉴를 갔다 오면 기록이 사라지지 않도록 store는 앱에서 공유한다. 씬마다 새로 만들면 메모리 fallback을 둔 의미가 줄어든다.
리셋 저장에 실패했을 때도 완료라고 표시하지 않는다. 이번 실행에서만 초기화되었으며, 새로고침하면 이전 기록이 남을 수 있다는 문구를 보여 준다.
손상된 JSON은 기본 구조로 시작한다. 일부 기록 항목이 잘못되었으면 유효한 항목은 남기고, 지원하지 않는 미래 버전의 저장 문서는 자동으로 덮어쓰지 않는다.
기존 코스별 숫자 기록에는 차량 정보가 없다. 이를 Raven 기록으로 추정하거나 세 차량에 복제하지 않고, 차량을 알 수 없는 legacy 기록으로 따로 옮겼다.
브라우저 데이터를 삭제하면 개인 기록도 사라진다. 내장 기본값이 복원하는 것은 코스·차량 목록과 빈 기록 구조다. 삭제된 개인 최고 시간을 복구하는 백업 기능은 아직 없다.
저장 모듈 테스트에서는 차량별 분리, 첫 기록·갱신·동률, 중복 완주, 최근 20개 제한, 재실행 복원, 초기화, 손상 데이터와 저장 거부를 확인했다. 기존 형식의 기록 이전과 다른 탭에서 기록이 바뀌었을 때 오래된 상태로 덮어쓰지 않는 처리도 확인했다.
별도의 브라우저 테스트에서는 Records 메뉴 진입, 옵션 리셋의 두 단계 확인, 새로고침 뒤 빈 기록 복원, 마지막 선택 유지, 완주 결과와 Retry 연결을 확인했다. 위 스크린샷도 이 검증 과정에서 캡처했다. 완주는 정해진 상태를 넣어 저장 경로를 확인했으므로, 실제 운전이나 프레임별 시간 정확성 검증을 대신하지는 않는다.
저장·브라우저 테스트와 기존 차량·HUD 검증, 게임 빌드는 통과했다. 전체 TypeScript 검사에는 기존 렌더링 코드 등의 오류가 남아 있어 전체 검사까지 통과한 상태는 아니다.
다음은 저장한 checkpoint 시간을 주행 HUD의 PB 비교에 연결하는 작업이다. 현재는 PB 주행의 데이터를 보관하는 단계이며, 구간별 차이를 HUD에 표시하지는 않는다. 시간 경계를 넘는 순간의 보간과 pause·focus 처리도 함께 정리해야 한다.
처음에는 최고 시간 숫자 하나를 저장하면 끝날 줄 알았는데, 차량 구분과 기본값, 첫 기록 표시, 리셋 이후 동작까지 꽤 여러 군데가 연결됐다.
이제 기록을 남기는 기반은 생겼다. 다음에는 “이번에 2초 빨라졌다”에서 한 걸음 더 가서, 어느 코너에서 그 2초를 줄였는지 보여 주려고 한다. 다시 달릴 때 바꿔볼 지점 하나를 알려주는 것이 다음 목표다.
아날로그 RPM, 디지털 속도계, 차량별 부스트 계기를 구현했다. 트윈터보의 과급 상태를 두 단계로 분리하고, 계기판을 오른쪽 하단에 배치했다.
Apex Seoul에 checkpoint split, best record, 가로등과 Π형 gate를 더하고, 고정 카메라에서도 차량이 도로 위를 계속 달려 사라지는 finish 연출을 만든 과정을 정리합니다.
Apex Seoul의 무입력 차량이 코너를 따라가던 원인을 road-relative 좌표와 passive yaw에서 찾고, world heading 기반 횡이동, 조향 sprite 분리, 가드레일 contact lifecycle과 코너 출구 회전 제한으로 조향이 필요한 코너를 다시 만들었습니다.
최고속과 가드레일을 맞춘 뒤에도 코너는 느리고 차체는 과하게 꺾여 보였습니다. grip과 drift의 sprite 역할을 분리하고, 코너 리듬과 near-field 표식을 고친 뒤, 다른 pseudo 3D 레이싱 게임과 화면 흐름을 비교해 physical speed와 world progression 사이에 longitudinal scale을 도입한 과정을 정리합니다.
최고속을 고치자 가드레일 충돌이 드러나고, 충돌을 고치자 차량이 화면 밖으로 밀렸습니다. Apex Seoul의 자동 주행 QA에서 이어진 실패와 수정 과정을 정리합니다.
Apex Seoul의 야간 헤드라이트가 조향과 고저차에서 어색해진 이유를 좌표계·차량 sprite 방향·빛 합성으로 나눠 추적했습니다. camera 기준 cone, 단일 회전 사다리꼴, 과도한 중심 수렴, 잘못된 lift를 버리고 실제 램프 위치에서 시작하는 두 광원 구조와 terrain별 위치 검증으로 정리한 기록입니다.