본문으로 건너뛰기

dotgimon 제작기 25. 출시 준비와 도트 화면 전환, 대기 화면 발열 개선

·23 min read·25 / 32

dotgimon은 Expo/React Native(이하 RN)로 만드는 가상 펫 게임이다. 화면 속 작은 LCD 창에 제한된 색의 도트로 펫을 그린다. 폰 앱이 있고, 같은 게임 엔진을 QuickJS(앱 안에 내장하는 작은 JavaScript 엔진)로 돌리는 애플워치·Wear OS(구글의 스마트워치 OS) 앱이 있다.

이날은 며칠 동안 쌓인 작업을 기능 단위로 나눠 한꺼번에 커밋했다. 스토어에 올릴 준비를 했고, 도감(몬스터 진화표)·히스토리(지금까지 키운 펫의 세대별 기록)·설정을 RN Modal에서 꺼내 도트 화면으로 바꿨다. 화면 속 게임기 본체에 달린 A/B/C 조작 버튼도 폰·애플워치·Wear OS 모두 도트로 다시 그렸다. 앱을 켤 때 알이 흔들리는 스플래시를 넣었고, 가만히 켜 둔 화면에서 기기가 뜨거워지는 문제도 줄였다.

앱 ID는 스토어에 올리기 전에 정해야 했다

앱 ID(iOS 번들 ID, 안드로이드 패키지 이름)를 com.dotgimon.app에서 com.kkwak.dotgimon으로 바꿨다. 애플워치 앱은 그 아래 .watch다. 앱 ID는 스토어에 한 번 올리면 바꿀 수 없어서 올리기 전에 정했다. Maestro(모바일 앱 UI 테스트 도구) 테스트 흐름과 문서의 앱 ID도 같이 고쳤다.

빌드는 EAS(Expo의 클라우드 빌드·배포 서비스)로 한다. eas.json에는 development(개발 중 코드를 바로 불러오는 개발용 앱), preview(내부 배포용, 바로 설치하는 안드로이드 APK 파일), production 세 프로필을 뒀다.

빌드 번호는 원격에서 자동으로 올라간다(appVersionSource: "remote", autoIncrement). 애플워치 앱은 iOS 앱에 딸린 확장 타깃(본 앱과 함께 묶여 배포되는 부속 앱)으로 같이 서명되도록 appExtensions에 등록했다. Wear OS는 안드로이드 빌드 도구인 Gradle 쪽에서 릴리스 버전 규칙을 따로 정했다.

심사에서 설명할 수 없는 권한은 지웠다

스토어 심사에서는 앱이 요청하는 권한마다 이유를 묻는다. 쓰지 않는 권한이 라이브러리 때문에 따라 들어오면 설명할 수 없다. 그래서 하나씩 지웠다.

  • expo-audio: 마이크 권한, 녹음, 백그라운드 재생을 모두 껐다. 이 앱은 효과음만 낸다.
  • 안드로이드 blockedPermissions(라이브러리가 넣은 권한을 빼는 Expo 설정): 녹음, 미디어 재생용 포그라운드 서비스(알림을 띄운 채 계속 도는 작업), 외부 저장소 읽기·쓰기, 다른 앱 위에 그리기를 막았다.
  • iOS: 앱 제출 때마다 나오는 수출 규정 질문(표준 외 암호화를 쓰는가)에 미리 "아니오"로 답하는 ITSAppUsesNonExemptEncryption: false를 넣었다.
  • 동작(모션) 권한 문구는 "걸음 수 측정이 필요하다"에서 "앱을 켜 두고 걸으면 펫 공격력이 조금 오른다, 걸음 수는 기기 밖으로 나가지 않는다"로 바꿨다. 무엇에 쓰는지를 말하는 문구다.

개발용 앱(개발 클라이언트)이 남기는 흔적도 지웠다. expo-dev-client는 exp+<slug> URL 스킴(링크로 앱을 여는 주소 형식, slug는 Expo 프로젝트 이름)을 넣는다. _expo._tcp Bonjour(애플 기기의 로컬 네트워크 서비스 탐색 기능) 서비스도 넣는데, 둘 다 릴리스에는 쓰이지 않는다.

iOS는 앱이 처음 로컬 네트워크에 접근할 때 권한을 묻는다. 릴리스 앱이 쓰지 않는 Bonjour 항목이 남아 있으면 심사관은 왜 필요한지 묻는다. 그래서 production 빌드에서만 이 항목들을 지우는 설정 플러그인을 만들었다. 설정 플러그인은 Expo가 네이티브 프로젝트 파일을 만드는 단계(prebuild)에서 설정 파일을 고치는 함수다.

// plugins/withReleaseCleanup.js
module.exports = function withReleaseCleanup(config) {
  if (process.env.EAS_BUILD_PROFILE !== 'production') return config
  config = withInfoPlist(config, (config) => {
    const plist = config.modResults
    if (plist.CFBundleURLTypes) {
      plist.CFBundleURLTypes = plist.CFBundleURLTypes
        .map((type) => ({ ...type, CFBundleURLSchemes: (type.CFBundleURLSchemes ?? []).filter((s) => !isDevScheme(s)) }))
        .filter((type) => type.CFBundleURLSchemes.length > 0)
    }
    if (plist.NSBonjourServices) plist.NSBonjourServices = plist.NSBonjourServices.filter((s) => s !== '_expo._tcp')
    return config
  })
  // 안드로이드 매니페스트의 intent-filter 에서도 exp+ 스킴을 뺀다
  return withAndroidManifest(config, (config) => { /* ... */ return config })
}

iOS 앱 설정 파일(Info.plist)에서는 개발용 URL 스킴과 _expo._tcp를 걸러내고, 안드로이드 앱 설정 파일(매니페스트)에서도 exp+ 스킴을 뺀다. 개발·preview 빌드에서는 그대로 둔다. 개발 클라이언트가 이 스킴으로 Metro(React Native 번들러)에 붙기 때문이다. 홈 화면 표시 이름을 Dotgimon으로 고정하는 플러그인(withDisplayName)도 같이 넣었다.

서버가 없으니 개인정보 처리방침의 요약은 한 줄이었다

docs/privacy-policy.md를 새로 썼다. 한국어와 영어 두 판이다. 항목은 기기에 저장하는 정보, 근처 친구와 대전·합체(두 펫을 짝지어 진화시키기), 워치 연동, 걸음 수, 호출 진동(돌봄이 필요할 때 펫이 진동으로 부르는 기능), 아동, 변경과 문의다.

이 시점에 앱은 서버를 쓰지 않아서 "모든 정보는 기기 안에 있다"가 요약이다. 설정 화면에서도 같은 내용을 볼 수 있게 했다(settings/privacy-policy.tsx, 문구는 언어별 번역 파일).

알림은 처음 한 번만, 위치는 정밀·대략 권한을 같이 물었다

호출 알림 권한은 앱이 다시 화면 앞으로 올 때마다 요청하고 있었다. iOS는 이미 정한 권한이면 창을 띄우지 않는다. 그래도 아직 묻지 않은 상태(notDetermined)일 때만 요청하도록 바꿨다. 알림 문구도 DOTGIMON / CALL!에서 기기 언어에 맞춘 "펫이 부르고 있어요!" / "Your pet is calling!"로 바꿨다.

안드로이드에서 근처 기기와 블루투스로 대전·합체를 주고받으려면 위치 권한이 필요하다. Android 12 이상에서는 정밀 위치 권한(FINE)만 요청하면 무시될 수 있다. 그래서 매니페스트에서 대략 위치 권한(COARSE)을 선언하는 안드로이드 버전 상한(maxSdkVersion)을 API 28에서 32로 올렸다. 요청할 때도 정밀·대략 권한을 같이 묻게 했다.

도트 게임기 위에 iOS 기본 시트가 뜨고 있었다

도감·히스토리·설정은 RN Modal로 열렸다. 도감·설정은 아래에서 밀려 올라오고 히스토리는 서서히 나타났다. 기기 화면은 도트 게임기인데 그 위에 iOS 기본 시트가 뜨는 모양이었다. 이 프로젝트는 게임기 화면에서 여는 기능에 모달을 쓰지 않기로 했고, 이번에 남은 세 화면도 같은 방향으로 옮겼다.

시안 몇 가지를 보고 "기기 화면 위를 덮는 화면"을 골랐다. 모달을 없애니 모달이 대신 해 주던 일이 보였다. 안드로이드 뒤로 가기, 스크린리더가 뒤 화면을 읽지 않게 가두기, 노치·홈 표시줄을 피하는 안전 영역 여백이다. 이것들을 PixelScreen 컴포넌트 하나에 모았고, 세 화면이 같은 규칙을 따르게 됐다.

src/components/pixel/에는 도트 UI 부품을 만들었다. 도트 테두리 상자(PixelBox), 아이콘, 누르는 도트 버튼(PixelKey), 계단 모서리 계산(geometry.ts), 색·크기 기준(theme.ts)이다. 도감·히스토리·설정이 모두 이 부품만 쓴다.

화면은 미끄러지지 않고 12계단 셔터로 내려온다

여닫는 전환은 셔터로 했다. 부드럽게 미끄러지는 대신 위에서 아래로 12계단으로 끊어 채운다.

아래 함수는 애니메이션 진행도를 받아 보이는 높이를 계산한다. 'worklet' 표시는 애니메이션 라이브러리 Reanimated의 지시어다. 이 함수를 JS 스레드가 아니라 화면을 그리는 UI 스레드에서도 돌릴 수 있게 한다.

// src/components/pixel/shutter.ts
export const SHUTTER_MS = 320
export const SHUTTER_STEPS = 12
 
/** 진행도(0~1) → 보이는 높이. 12계단으로 끊고 도트(2pt) 격자에 맞춘다 */
export function shutterHeight(progress: number, height: number): number {
  'worklet'
  const p = Math.min(1, Math.max(0, progress))
  const stepped = Math.max(0, Math.ceil(p * SHUTTER_STEPS - 1e-6)) / SHUTTER_STEPS
  return Math.min(height, Math.ceil((stepped * height) / DOT) * DOT)
}

진행도를 12계단 단위로 올림한 뒤, 높이를 다시 도트 크기의 배수로 올려서 셔터 끝이 격자에서 벗어나지 않게 했다.

항목이전이후
열기Modal 기본 전환(도감·설정은 아래에서 올라옴, 히스토리는 서서히 나타남)위에서 아래로 12계단, 320ms
닫기Modal 기본 전환아래에서 위로 12계단, 320ms. 다 걷힌 뒤 화면을 내린다
셔터 끝 줄없음움직이는 동안만 2pt 밝은 줄

전환 시간은 320ms로 정하고 docs/animation-timing.md에 기준으로 남겼다. 도감은 진화 경로 계산을 dex-routes.ts로 빼고 탭 줄을 dex-tabs.tsx로 나눴다. 설정에서 게임기 본체 외관(셸) 8종 중 하나를 고르는 곳에는 도트로 그린 미니 기기 견본(shell-sample.tsx)을 따로 두었다.

자동 테스트는 계단 수와 격자 정렬만 본다. 실제 기기에서 정상 속도로 돌려 보지는 않았다.

매끄러운 원이던 A/B/C 버튼도 도트로 맞췄다

본체(셸)의 LCD 둘레 테두리(베젤)와 벽돌 무늬 바탕은 도트인데 A/B/C 버튼만 매끄러운 원이었다. 시안 몇 개 중 "2톤 테두리 음영"을 골랐다.

셸에 파인 원형 홈이 있고, 그 안에 계단 원 버튼이 들어간다. 홈은 위·왼쪽이 그늘, 아래·오른쪽이 턱이다. 버튼은 테두리 바로 안쪽 한 줄만 위는 밝게, 아래는 어둡게 칠한다. 버튼 오른쪽 아래에는 1칸(도트 한 개) 그림자가 있고, 누르면 버튼이 그 자리로 1칸 내려앉으면서 음영이 뒤집힌다.

hw-button-pixels.ts는 RN에 의존하지 않는 순수 TS다. 홈 지름과 버튼 지름, 눌림 여부를 받아 층(홈 명암 세 단계, 그림자, 외곽선, 밝은 줄, 몸통, 어두운 줄)마다 가로 줄 목록을 돌려준다. 베젤과 같은 방식이라 그리는 쪽은 줄마다 사각형만 칠하면 된다.

워치도 같은 모양이어야 해서 워치 코어(워치 앱이 QuickJS로 돌리는 게임 엔진 묶음)가 셸 색과 버튼 도트를 넘겨주게 했다. 애플워치는 HwButtonPixels.swift, Wear OS는 PixelButton.kt로 코어가 준 줄을 그린다. Wear OS에는 셸 색 벽돌 바탕(WearFace.kt)과 앱 아이콘도 같이 넣었다.

정지 이미지 스플래시를 움직이게 하려면 같은 그림을 한 번 더 그려야 했다

앱을 켜면 OS 스플래시(정지 이미지)가 떴다가 게임 화면으로 바로 바뀌었다. 이 스플래시를 움직이게 하고 싶었다.

OS 스플래시는 정지 이미지라 움직일 수 없다. 그래서 같은 그림을 앱 안에서 다시 그리고(앱 스플래시), 그 그림이 깔린 뒤에 OS 스플래시를 걷는다. 두 그림의 칸 위치가 1px이라도 다르면 이어받는 순간 튄다.

앱 스플래시 배치(boot-splash/layout.ts)는 OS 스플래시 이미지를 만드는 스크립트와 같은 칸 격자(1024px 이미지, 56칸, 칸 18px)로 계산한다. app.json의 스플래시 이미지 폭 228pt도 같은 상수로 묶었다. 같은 격자 상수를 쓰니 넘어가는 순간이 튀지 않았다.

앱 스플래시가 준비되기 전에 OS 스플래시가 저절로 걷히면 안 된다. 앱 최상위 레이아웃 파일(app/_layout.tsx)에서 자동으로 숨기는 동작을 막아 뒀다.

// app/_layout.tsx
// OS 스플래시는 앱 스플래시(BootSplash)가 같은 그림을 깐 뒤 직접 걷는다
SplashScreen.preventAutoHideAsync()

움직임은 시안 비교 끝에 알 전체를 칸 단위로 옮기는 안으로 정했다. 윗부분만 미는 안은 윤곽이 끊겨 보였고, 기울인 프레임은 도트가 거칠었다.

흔들기 길이는 같은 날 네 번 바뀌었다

처음 1,700ms였던 흔들기를 5,900ms까지 늘렸다.

단계고친 이유합계
처음없음1,700ms
1차"너무 빨리 사라지는 감이 있다"2,700ms
2차"좌우 흔들기+톡을 2번 정도"4,500ms
3차"사이 정지가 너무 짧다" (300 → 700ms)4,900ms
4차"주기에 리듬감이 없다"5,900ms

4차에서는 같은 간격(280ms)으로 흔들던 것을 버렸다. 양 끝에서 머물고 가운데는 빨리 지나는 추 리듬으로 바꿨다(왼쪽 320 → 가운데 160 → 오른쪽 320 → 가운데 480). 톡(알이 위로 한 번 뛰었다 내려앉는 동작) 앞에 뜸을 두고 착지 뒤 가라앉게 했다.

끝나면 페이드 없이 게임 화면으로 바뀐다. 흔들기 간격은 펫이 아플 때 90ms마다 좌우로 떠는 연출보다 느리게, 사람이 한 번씩 세어 볼 수 있는 속도로 맞췄다.

도감을 열 때마다 초상 자리가 한 프레임 비었다

도감·히스토리에 띄우는 몬스터 도트 그림(초상)은 Skia(RN용 2D 그래픽 라이브러리)의 useImage로 읽었다. 공용 캐시가 없어서 화면을 열 때마다 PNG를 다시 읽고 디코딩했다. 그 사이 빈 칸이 한 프레임 보였다.

portrait-cache.ts에 몬스터별로 한 번만 읽어 두는 캐시를 만들고, 스플래시가 끝난 뒤 8개씩 나눠 미리 채운다. 32×32 도트라 전부 들고 있어도 1MB 안팎이다.

원화를 도트로 바꾸는 절차도 프롬프트로 정리했다

몬스터 원본 그림(원화)을 32×32 흑백 도트로 바꿀 때 쓰는 공통 변환 프롬프트(pixel-conversion-prompt.txt)를 만들었다. pnpm art:pixel-prompt <id>는 이 공통 프롬프트에 몬스터 종별 디자인 설명을 붙여 출력한다. 후보는 확대해서 실루엣·얼굴·팔다리 분리를 검토하고, 통과한 도트만 게임이 읽는 도트 데이터(pixels.txt)로 옮긴다. 몬스터 pelagorn을 이 절차로 다시 만들었다.

그 밖에 Wear OS 아이콘 생성, EAS 빌드에서 의존성 설치 뒤 워치 코어를 만드는 스크립트, Gradle 캐시 무시 설정, 걸음 보너스·셸·똥 청소 쪽 주석 정리도 같이 들어갔다.

펫이 가만히 서 있어도 매번 그림을 새로 녹화하고 있었다

앱을 켜 두기만 해도 폰이 따뜻해졌다. 게임 루프는 매 vsync(화면 주사율에 맞춘 프레임 신호, 여기서는 requestAnimationFrame)마다 돌았다. 그리고 50ms마다 LCD 그림을 Skia Picture(그리기 명령을 녹화해 두었다가 다시 재생하는 객체)로 새로 녹화해 UI 스레드에 넘겼다. 펫이 가만히 서 있어 그림이 그대로여도 매번 녹화했다.

두 가지를 바꿨다. 하나는 그리기 명령 비교다.

엔진이 그리는 사각형 채우기 명령(fillRect)을 Skia에 바로 그리지 않고, 명령만 받아 쌓는 대상(FrameOpsCtx)을 거쳐 숫자 배열에 모은다. 한 칸이 [색 id, x, y, w, h]다. 직전 프레임과 배열이 같으면 녹화와 UI 재그리기를 건너뛴다.

// src/game/use-game/lcd-frame-ops.ts
/** draw의 명령을 layer에 모으고, 직전 프레임과 다르면 true */
collect(layer: FrameLayer, draw: (ctx: PixelCtx) => void): boolean {
  layer.begin()
  this.layer = layer
  draw(this)
  this.layer = null
  return layer.changed()
}

엔진의 draw에 자기 자신을 그리기 대상으로 넘겨 명령만 모으고, changed()로 직전 프레임과 다른지만 돌려준다. 배열 두 개를 번갈아 쓰기 때문에 프레임마다 새 배열을 만들지 않는다.

엔진 쪽은 고치지 않았다. 원래 캔버스 2D API와 이름이 같은 fillStyle·fillRect만 쓰는 좁은 인터페이스(PixelCtx)로 그리기 때문이다. 받는 쪽만 바꿔 명령을 숫자 배열로 모을 수 있었고, 비교 한 번으로 녹화를 건너뛰는 것도 그래서 가능했다.

다른 하나는 루프를 깨우는 방식이다. 평소에는 vsync 대신 50ms 타이머로 루프를 깨운다. 대전·훈련처럼 빠른 화면에서만 vsync를 쓴다. 타이머를 기다리는 중에 버튼이 눌리면 타이머를 끊고 다음 vsync에 바로 돈다.

앱이 백그라운드로 가면 루프를 멈춘다. iOS 타이머는 앱이 정지되기 전까지 계속 돌기 때문이다. 메뉴 커서 깜빡임을 300ms마다 React state로 바꾸던 것도 뺐다. 그 값이 바뀔 때마다 LCD 캔버스 전체가 다시 그려졌다.

항목이전이후
평소 루프매 vsync50ms 타이머
그림이 같을 때매번 Skia 녹화건너뜀
백그라운드루프 계속멈춤

녹화를 건너뛰는 판정은 lcd-frame-ops.test.ts로 확인했다. 발열이 얼마나 줄었는지는 수치로 재지 않았다.