본문으로 건너뛰기

dotgimon 제작기 20. 워치 성능 확인과 폰·워치 동기화 코어

·18 min read·20 / 32

폰과 워치에서 같은 펫을 키우려면 두 기기에서 게임 엔진이 같은 규칙으로 돌아가야 했다. dotgimon의 TS 엔진을 JS로 묶어 QuickJS에 넣은 시험 앱이 있어서, 실행 결과와 프레임 시간을 확인했다. 그다음에는 서로 다른 기기에서 한 돌봄을 어떻게 합칠지 다뤘다.

시뮬레이터에서는 정한 기준 안에 들었다

고정 시드로 실행한 시나리오는 Node의 기준 JSON과 같았다. 데스크톱, watchOS 시뮬레이터, Wear 에뮬레이터에서 PARITY OK를 확인했다. 이 비교는 최종 상태와 프레임 해시를 함께 보는 검사였다.

JS 실행 시간 기준은 프레임당 평균 5ms 미만, 측정값의 95%가 들어오는 p95는 8ms 미만이었다. 화면 없이 코어만 돌릴 때와 앱이 화면을 그리는 동안을 나눠 쟀다.

실행 환경코어만 실행한 평균 / p95화면 실행 중 평균 / p95
데스크톱0.11 / 0.12ms측정하지 않음
watchOS 시뮬레이터0.15 / 0.17ms0.49 / 0.75ms
Wear 에뮬레이터0.14 / 0.20ms0.340.38 / 0.570.73ms

측정한 값은 기준 안에 들었다. Wear에서 버튼을 조작하는 동안 평균이 1.19ms까지 올라갔고, JS 힙은 620~665KB였다. 이 결과로 Kotlin Multiplatform으로 엔진을 다시 쓰지 않고 TS 코어를 유지하는 쪽을 택했다.

watchOS는 arm64와 arm64_32 빌드를, Wear는 두 ABI의 네이티브 라이브러리가 APK에 들어가는 것을 확인했다. hourAt이 반환한 시각도 네이티브 현지 시각과 같았다.

Expo의 @bacons/apple-targets와 Wear Gradle 모듈 통합은 임시 작업 트리에서 시험했다. watch 타깃 빌드와 :wear:assembleDebug는 통과했지만, 그 실험 설정을 그대로 현재 브랜치에 남긴 것은 아니었다.

시뮬레이터와 에뮬레이터의 결과를 실제 워치 성능이나 배터리로 환산하지는 않았다. arm64_32도 컴파일만 확인했다. 최소 화면에서는 작은 워치의 버튼이 잘렸고 Wear 원형 화면에서도 버튼 배치가 맞지 않았다. 엔진 실행 판정과 화면 완성도는 나눴다.

실시간 프레임까지 같게 만들 필요는 없었다

동기화 초안은 두 기기의 실시간 엔진이 프레임 속도와 무관하게 같은 결과를 내도록 만드는 방향이었다. 그런데 엔진은 틱이 실행된 시각에 배고픔 감소 등의 기준점을 갱신했다. 프레임이 다르면 기준 시각도 조금씩 달라졌다.

깨어 있던 시간을 더하는 stageAwakeMs += dt부터 시간 계산 전체를 바꾸는 안도 있었다. 그 방식은 동기화를 위해 기존 게임을 넓게 수정하게 됐다.

결정성의 대상을 바꿨다. 실시간 화면은 각 기기에서 진행하되, 합칠 때 사용하는 계산 일정을 같게 만들었다. 기준 상태인 체크포인트와 그 이후 행동 로그를 같은 순서로 재계산하는 방식이었다.

평소 오프라인 정산과 같은 60초 간격으로 시간을 진행하고, 행동이 발생한 시각에는 정확히 한 틱을 더했다. 같은 규칙과 시간대를 전제로 동일한 체크포인트와 행동을 넣으면 같은 재계산 경로를 지나게 했다.

한쪽에만 행동이 있으면 상대 스냅샷을 채택하고 현재 시각까지 정산했다. 양쪽 모두 행동한 경우에만 로그를 합쳐 재계산했다. 모든 연결마다 같은 비용을 낼 필요는 없었다.

행동과 난수 결과를 따로 남겼다

로그에는 사용자가 확정한 행동과 다시 뽑으면 안 되는 난수 결과를 담았다. 먹이는 고기인지 비타민인지, 조명은 켰는지 껐는지 기록했다. 부화나 배고픔 감소처럼 시간만 지나 생기는 변화는 엔진이 다시 계산하게 했다.

먹이 하나를 줬더라도 과식인지 아닌지는 합쳐진 상태에 따라 다르다. 두 기기에서 동시에 먹이를 줬다면 첫 행동 이후 이미 배가 찼을 수 있었다. 그래서 FEED에는 결과 포만도 대신 먹이 종류를 남겼다.

반대로 스타터, 셸 색, 대전 승패, 패배 후 발병처럼 무작위로 정한 결과는 그대로 저장했다. 병합할 때 난수를 다시 뽑으면 같은 행동이 다른 결과가 되기 때문이다.

행동 정렬은 시각, 기기, id 순서로 정했다. 같은 시각에 입력이 들어와도 순서를 고정하는 코드다.

export function compareActions(a: Action, b: Action): number {
  return a.at - b.at
    || (a.dev < b.dev ? -1 : a.dev > b.dev ? 1 : 0)
    || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)
}

id는 기기와 시각, 순번으로 만들었다. 기록 때문에 Math.random을 부르면 게임의 난수 흐름까지 달라질 수 있어서 난수를 쓰지 않았다.

엔진에서는 연출과 상태 확정을 나눴다. 실시간 먹이 주기와 재계산에서 먹이를 적용하는 코드가 같은 commitFeed를 부르게 했다. 상태 변경 코드를 두 벌로 복사하지 않으려는 선택이었다.

훈련은 시작할 때 근력이 차감되므로 TRAIN_START를 남겼다. 발사 기록은 같은 세션 id로 묶었다. 중도 취소해도 시작 기록은 남고, 재계산에서 시작이 거부되면 그 세션의 발사도 적용하지 않았다.

청소는 똥이 있을 때만, 치료는 아플 때만 기록했다. 빈 청소까지 상태 변경으로 기록하면 다음 똥이 생기는 기준 시각을 뒤로 미룰 수 있었다.

합친 뒤에는 행동의 조건도 다시 확인했다

재계산은 중복 id를 제거하고 행동을 정렬한 뒤 진행했다. 행동 시각까지 엔진을 돌리고, 그때의 펫 상태에 맞는지 검사했다.

죽었거나 부화하지 않은 펫에게 하는 행동, 이미 바뀐 개체에 대한 행동은 버렸다. 근력이 부족한 훈련과 대전 자격이 없는 입력도 적용하지 않았다. 기기 시계가 5분 넘게 미래인 기록, 체크포인트보다 오래된 기록도 제외했다.

재계산 중에는 행동 기록 콜백을 비웠다. 같은 확정 함수를 다시 부르면서 로그가 또 쌓이는 것을 막고, 끝나면 finally에서 복원했다.

입력 순서를 바꿔도 결과가 같은지, 중복 행동이 한 번만 적용되는지, 재계산이 난수를 호출하지 않는지 테스트했다. 실시간 확정 함수를 표준 일정으로 실행한 결과와 재계산 결과도 바이트 단위로 비교했다. 작성한 시나리오에서 두 경로가 일치하는 기준이 생겼다.

다음 사건도 같은 엔진으로 계산했다

돌봄이 없을 때 배고픔이나 수면이 언제 바뀌는지 알기 위해 predict를 만들었다. 별도 공식을 쓰는 대신 저장 상태의 복제본을 60초씩 진행하며 조건이 처음 바뀐 시각을 기록했다.

배고픔이 0이 되는 순간, 똥이 늘어나는 순간, 아프거나 잠드는 순간을 잡았다. 기본 범위는 24시간이어서 최대 1,440번 엔진을 돌렸다. 프레임마다 부르는 함수로 두지는 않았다.

시간대 검사에는 취침 시각을 지나는 시나리오를 추가했다. 같은 순간을 KST로 보면 잠들고 뉴욕 시간대로 보면 깨어 있는 결과를 확인했다. 데스크톱 QuickJS에서도 KST의 수면 골든 비교는 통과하고 뉴욕은 실패했다.

이 실패는 숨길 오류가 아니었다. 현지 시각을 쓰는 규칙에서 두 기기의 시간대가 다르면 상태가 달라질 수 있다는 한계가 재현됐다.

저장 규칙을 React 훅 밖으로 꺼냈다

폰의 useGame에는 게임 루프뿐 아니라 저장 주기, 실패 시 중단, 리셋과 링크 결과 적용이 섞여 있었다. 워치에서도 같은 규칙을 쓰려고 GameSession으로 옮겼다.

세션은 RN·Skia·Expo를 가져오지 않게 했다. 시각은 호출자가 now로 넘기고 저장소는 SessionStore 인터페이스로 받았다. 폰의 SQLite 저장소는 이미 이 인터페이스에 맞아 저장소 자체를 바꾸지 않았다.

생애 이벤트가 생겼거나 2초가 지났을 때 저장했다. 저장 실패 시 진행과 입력을 멈춰 기록을 보호했고, 리셋과 통신 결과 저장이 실패하면 상태와 이벤트, 행동 로그를 되돌렸다.

워치 코어도 같은 세션 위에서 돌도록 바꿨다. 시뮬레이터에서는 알 배정과 주기 저장, 부화 직후 저장, 먹이 뒤 포만도 변화를 SQLite에서 확인했다.

워치 글자는 배경만 옅게 해서는 안 됐다

폰과 워치를 나란히 놓으니 워치 글자가 뭉개졌다. 같은 격자와 유리 반사광도 작은 화면에서는 더 강하게 보였다. 격자 불투명도를 0.10에서 0.035로, 반사광을 0.32에서 0.12로 낮췄다.

STATUS처럼 글자가 많은 화면은 배경 풍경을 빼고 단색 LCD를 썼다. 글자 도트도 넓혀 봤지만 일부 획은 여전히 사라졌다.

원인은 색 버퍼의 정수 반올림이었다. 엔진은 작은 글자를 2/3도트 간격의 소수 좌표에 그렸다. Math.round로 정수에 맞추면 가까운 두 획이 같은 칸으로 합쳐질 수 있었다.

소수 좌표 그대로 사각형을 기록하는 RectCtx를 추가했다. 호스트는 사각형 목록을 받아 그리게 하고, 골든 해시는 기존 색 번호 버퍼로 유지했다. 실제 표시를 고치면서 기존 비교 기준까지 바꾸지는 않았다.

폰과 워치가 같은 순서로 합성하도록 lcdScene도 만들었다. 배경, 엔진 도트, 몬스터, 최상층 연출, 메뉴, 격자, 반사광의 정보를 플랫폼 중립 데이터로 전달했다. 스프라이트는 PNG 스트립을 잘라 확대하고, 메뉴 SVG는 사각형 목록으로 바꿨다.

셸과 버튼도 폰을 기준으로 조정했다. 작은 워치에서는 버튼과 라벨 높이를 줄여 LCD 도트 2pt를 유지했다. 버튼 클릭에는 워치 햅틱을 연결했다.

흩어진 작업을 합치고 폰 규칙도 맞췄다

워치와 폰, 화면 미리보기 작업이 서로 다른 작업 트리에 있었다. 합치기 전에 미커밋 변경을 보존한 뒤 병합했다. 중복된 camelCase 파일은 kebab-case 이름으로 정리하고, 엔진 확정 함수와 세션을 분리된 모듈에 옮겼다.

프로젝트 이름은 dotgimon으로 맞췄지만 기존 DB 파일명 pixelpet.db는 유지했다. 파일명 정리 때문에 저장 데이터를 잃을 이유는 없었다.

LCD 밖의 앱 화면에는 한국어와 영어를 넣었다. 언어 선택을 읽기 전에는 렌더링하지 않아 저장한 언어 대신 기기 언어가 잠깐 보이지 않게 했다. LCD 안의 FULL, NEED VITA 같은 영문 문구는 게임기 화면의 일부로 남겼다.

훈련 횟수는 명중한 라운드가 아니라 세 라운드를 끝낸 세트로 세게 바꿨다. MISS가 있어도 완주하면 한 번, 중도 취소하면 0번이었다. 크리티컬 확률은 훈련 횟수 대신 MEGA HIT 횟수를 사용했고, 진화에 필요한 훈련 수치는 줄였다.

NPC 능력치는 기존과 같게 맞추고 대전 규칙과 링크 프로토콜 버전도 함께 바꿨다. 서로 다른 규칙으로 계산하는 기기가 그대로 대전하지 않게 한 것이다.

도감에는 최대 2.5배 핀치 확대와 이동을 넣었다. 두 손가락 중심 아래의 콘텐츠가 움직이지 않도록 이동량도 보정했다. 모달 안에서도 제스처를 받도록 별도 루트를 두었다.

워치 글자의 소수 좌표와 동기화의 계산 일정은 다른 문제였지만, 둘 다 기존 엔진을 무조건 고치는 쪽으로 끝나지는 않았다. 표시용 좌표와 비교용 버퍼를 나눴고, 실시간 진행과 병합용 재계산도 목적에 맞게 나눴다.