본문으로 건너뛰기

dotgimon 제작기 19. 워치용 공통 엔진 설계와 QuickJS 시험 구현

·14 min read·19 / 32

dotgimon을 손목에서도 키우고 싶었다. 폰 화면만 워치에 비추는 방법도 있었지만, 폰이 없으면 돌볼 수 없는 형태는 원하지 않았다. watchOS와 Wear OS에서 따로 실행하면서 폰과 같은 펫을 키우는 쪽으로 설계를 잡았다.

폰 앱은 Expo와 React Native로 만들었지만 성장과 전투 규칙은 TypeScript 엔진에 모여 있었다. 워치 작업을 시작하기 전에 이 엔진을 그대로 가져갈 수 있는지 확인했다.

규칙을 두 번 구현하고 싶지 않았다

Kotlin Multiplatform으로 코어를 다시 쓰면 두 워치 플랫폼에서 공통 코드를 실행할 수 있다. 대신 성장·수면·진화·대전·합체 규칙을 다시 옮기는 작업이 필요했다. 두 구현이 조금씩 달라지면 같은 펫인데 기기마다 다른 결과를 낼 수 있었다.

다른 선택은 작은 JavaScript 엔진인 QuickJS를 앱에 넣는 것이었다. 기존 TS를 JS 한 파일로 번들하면 게임 규칙을 한 벌로 유지할 수 있다. 워치의 성능과 메모리 범위 안에서 실행되는지는 별도 문제였다.

QuickJS로 최소 앱을 만들어 확인하고, 기준을 넘지 못하면 KMP를 검토하는 조건을 걸었다. 처음부터 워치 UI 전체를 만들기보다 실행 가능성을 가르는 시험 구현을 먼저 만들었다.

항목정한 방향
펫 조작폰과 워치 어느 쪽에서도 돌보기
연결이 끊겼을 때각자 진행한 행동을 재연결 때 합치기
워치 엔진기존 TS를 JS로 번들해 QuickJS에서 실행
워치 통신사람이 입력할 수 있는 번호 코드
워치 조작LCD와 A·B·C 버튼, 크라운 회전은 A

iOS와 Wear OS, Android와 Apple Watch의 교차 동기화는 범위에서 뺐다. 워치끼리 직접 BLE로 연결하는 기능과 코드 위·변조 방지도 포함하지 않았다. 독립 실행은 목표였고, 이 시험 구현이 모든 기능을 갖춘 워치 앱은 아니었다.

돌아간다는 말 대신 기준을 적었다

시험 구현의 이름은 P0로 정했다. 작은 기능 하나를 만드는 단계보다, 본 개발의 전제가 맞는지 가르는 단계에 가까웠다.

같은 시드와 입력을 넣었을 때 Node와 QuickJS의 결과가 같아야 했다. 기준 결과를 저장해 두고 비교하는 골든 파일을 사용했다. 상태뿐 아니라 그려진 프레임도 비교 범위에 넣었다.

성능 기준은 프레임당 JS 실행 시간 평균 5ms 미만, p95 8ms 미만으로 정했다. p95는 측정한 프레임의 95%가 그 시간 안에 끝난다는 뜻이다. watchOS 시뮬레이터와 Wear 에뮬레이터에서 측정하도록 했다.

빌드도 나눠 확인하도록 적었다. watchOS는 arm64와 arm64_32, Wear는 arm64-v8a와 armeabi-v7a를 포함했다. 엔진의 Date#getHours()가 호스트의 현지 시각과 맞는지, Expo의 워치 타깃과 Android Gradle 프로젝트에 들어가는지도 별도 기준이었다.

엔진 자체 기준과 빌드 통합 기준은 구분했다. Expo 타깃 플러그인 통합이 막힌다고 곧바로 게임 엔진을 다시 쓸 이유는 없었다. 통합 방식의 실패와 QuickJS 실행의 실패를 다른 판단으로 남겼다.

작은 그리기 인터페이스로 워치 버퍼를 만들었다

게임 엔진은 Canvas나 Skia를 직접 부르지 않았다. 색을 정하고 사각형을 칠하거나 지우는 PixelCtx 인터페이스를 사용했다.

export interface PixelCtx {
  fillStyle: string
  fillRect(x: number, y: number, w: number, h: number): void
  clearRect(x: number, y: number, w: number, h: number): void
}

웹 Canvas와 폰 Skia 쪽에서 이 모양에 맞춰 그려 주고 있었다. 워치에는 64×64 Uint8Array에 색 번호를 쓰는 BufferCtx를 추가했다. 엔진이 어떤 플랫폼에서 그리는지는 알 필요가 없었다.

색 번호는 투명한 LCD 바탕 0, 잉크 1, 화면을 가리는 밝은 색 2로 나눴다. 등록하지 않은 색은 3으로 남기고 횟수도 셌다. 새로운 색이 들어왔는데 조용히 다른 색으로 보이는 일을 피하고 싶었다.

테스트에서는 일부러 팔레트에 없는 색을 넣었다. 경고용 번호가 기록되고 unknown 횟수가 늘어나는지 확인했다. 좌표는 정수로 반올림하고 화면 경계에서 잘랐다.

폰의 실행 순서를 작은 코어로 옮겼다

createCore는 React Native와 Skia를 가져오지 않는 함수로 만들었다. 게임을 갱신하고, LCD를 그리고, 연출 중에는 최상층을 따로 그린 뒤 스프라이트와 메뉴 정보를 반환했다.

프레임 버퍼는 두 레이어였다. 평소 LCD 위에 몬스터가 올라가고 그 위에 진화 커튼이 덮이는 순서를 유지하려면 한 장에 모두 칠할 수는 없었다. 몬스터는 버퍼 안에 그리지 않고 위치와 그림 이름을 JSON으로 넘겼다.

설계에서는 네이티브 앱이 LCD 색 번호를 확대해 그리고, 스프라이트 정보에 맞춰 이미지를 얹게 했다. 게임 규칙은 코어에 남겼다.

효과음 호출은 Proxy로 이름을 받아 호스트의 햅틱 콜백에 전달했다. 프레임 사이 시간인 dt는 폰과 같이 0~100ms로 잘랐다. 오래 멈췄다가 돌아왔을 때 한 번의 화면 갱신 시간이 지나치게 커지는 것을 막는 처리였다.

같은 난수와 같은 입력을 고정했다

QuickJS에서 실행된다는 것만으로 폰과 결과가 같다고 볼 수는 없었다. 80초 동안 알 배정과 부화, 메뉴 입력을 진행하는 시나리오를 만들었다. 시작 시각과 버튼을 누르는 시각을 고정하고 난수도 같은 시드를 사용했다.

시나리오를 실행하는 동안만 Math.random을 바꾸고, 끝나면 원래 함수로 되돌렸다.

export function withSeed<T>(seed: number, fn: () => T): T {
  const original = Math.random
  const rnd = mulberry32(seed)
  Math.random = () => rnd() / 4294967296
  try {
    return fn()
  } finally {
    Math.random = original
  }
}

예외가 나더라도 복원되도록 finally에 넣었다. 테스트에서도 원래 난수 함수가 돌아오는지 확인했다.

결과에는 최종 게임 상태와 프레임 해시를 함께 넣었다. 상태만 맞고 그림이 다른 경우도 비교하려고 두 레이어의 바이트를 일정 간격으로 해시에 누적했다. Node에서 만든 JSON 문자열을 골든 파일로 저장하고 호스트 결과와 바이트 단위로 대조하게 했다.

엔진은 수면 시간을 현지 시각으로 판단한다. 테스트 시간대는 Asia/Seoul로 맞췄고, 호스트에 hourAt 함수도 노출했다. 다만 이 짧은 시나리오는 자동 취침과 기상을 지나지 않았다. 시각 조회 비교와 수면 경로 전체의 일치를 같은 검증으로 보지는 않았다.

번들에 폰 의존성이 섞이면 빌드를 멈췄다

esbuild는 코어 진입점을 es2020을 대상으로, 바로 실행되는 함수 형식인 IIFE 한 파일로 묶었다. 번들 정보를 검사해 node_modules가 들어가면 실패하도록 했다. 엔진에 React Native 의존성이 섞이는 순간 워치 번들에서 알 수 있게 만든 것이다.

오디오와 저장 모델에서 가져온 항목은 타입뿐이었다. 실행 시 필요한 import가 아니어서 그 모듈들을 새로 분리하지 않고 번들할 수 있었다.

QuickJS-ng는 v0.17.0으로 고정했다. 같은 C 파일을 데스크톱, Xcode, Gradle/NDK에서 컴파일해야 해서 라이브러리 소스와 헤더를 저장소에 함께 넣었다. 실제 컴파일 대상은 quickjs.c, libregexp.c, libunicode.c, dtoa.c였다.

Swift와 Kotlin 사이에는 C 함수만 뒀다

QuickJS API를 두 언어에서 따로 다루지 않도록 pxhost라는 C 계층을 만들었다. 초기화, 시간 진행, 버튼 입력, 프레임 읽기, 저장 문자열 반환을 같은 함수로 호출하게 했다.

반환값의 소유권도 정했다. 문자열은 호출자가 px_free로 해제하고, 프레임 버퍼는 다음 호출 전까지만 사용하게 했다. int를 반환하는 함수는 성공 시 0, JS 예외 시 -1을 반환했다.

메모리는 32MiB, JS 스택은 256KiB로 제한했다. 스택 제한은 처음 설정한 512KiB보다 줄였다. 호스트의 스택 여유를 보수적으로 잡은 설정이며, 실제 워치에서 한도를 넘기는 동작까지 확인한 것은 아니었다.

호스트가 다른 깊이에서 JS를 호출할 수 있어서 JS_UpdateStackTop으로 기준 위치를 갱신했다. 실패한 프레임 호출에 예외 객체가 남는 경로도 정리했다. 한 호스트는 한 스레드에서만 쓰도록 호출 계약을 맞췄다.

데스크톱에는 골든 비교, 현지 시각 비교, 프레임 실행 시간 측정을 하는 실행 파일을 만들었다. watchOS는 Swift 브리징 헤더로 C 함수를 부르고, Wear는 Kotlin에서 C 함수를 부르는 연결부인 JNI를 한 겹 뒀다.

watchOS 최소 앱은 두 LCD 레이어를 합친 이미지와 버튼만 보여 줬다. 몬스터 스프라이트와 메뉴 아이콘까지 합성한 화면은 아니었다. Wear도 같은 호스트 API를 호출하는 최소 앱으로 구성했다.

낮에는 몬스터 발 위치와 배변 전 동작, 훈련 비타민 비용도 정리했다. Nearby용 Swift 패키지를 붙이는 플러그인이 기존 빌드 설정을 덮던 문제는 기존 값에 이어 붙이는 방식으로 고쳤다. 워치 시험 구현은 그와 분리해 같은 엔진을 두 플랫폼에 넣는 데 집중했다.

이날 확인한 범위는 최소 앱 코드와 빌드였다. 설계에 적은 성능·동작 기준을 모두 통과했다고 판정하지는 않았다. 게임 규칙을 다시 쓰지 않고도 LCD 버퍼와 입력을 C 함수 뒤에 연결할 수 있는 구조가 만들어졌다.