dotgimon 제작기 32. 카리몬 부화 아이콘과 휴대폰 우선 호출 알림
DOTGIMON은 Expo/React Native로 만드는 LCD 방식 가상 펫 게임이다. 폰 앱이 있고, 같은 게임 엔진을 QuickJS(임베디드용으로 만든 작은 JavaScript 엔진)로 돌리는 애플워치·Wear OS 앱이 있다. 이날 새벽에는 아트, 낮에는 규칙을 다뤘고 밤에는 설계서를 썼다.
카리몬은 몬스터 cindle의 한국어 이름이고, 새 아이콘의 주인공이다. 몬스터는 유년기Ⅰ → 유년기Ⅱ → 성장기 → 성숙기 → 완전체 → 궁극체 순서로 진화하고, 카리몬은 두 번째 단계인 유년기Ⅱ다. 호출은 돌봄이 필요해 화면에 CALL이 깜빡이는 상태다. 코드 대전은 8자리 번호를 서로 입력해 중계 서버를 거쳐 싸우는 대전이다.
알을 깨고 나오는 카리몬을 아이콘과 스플래시에 같이 넣었다
기존 앱 아이콘은 크림 벽돌 위 게임기 프레임 안에 알이 있는 그림이었다. 새로 고른 방향은 "알을 깨고 나오는 카리몬, 그 위에 작은 불꽃"이다. 아이콘이 바뀌면 앱을 켤 때 처음 보는 장면도 같은 이야기여야 한다. 그래서 부팅 스플래시(앱을 켤 때 게임 화면보다 먼저 보이는 시작 화면)의 알 흔들기 뒤에 부화 장면을 붙이기로 했다.
원본 한 장에서 모든 플랫폼 아이콘을 뽑았다
승인된 v8 시안 원본은 assets/branding/cindle-hatch-fire-source.png에 바이트 그대로 보관한다. 아이콘 생성 스크립트 pnpm art:icon이 그 한 장에서 모든 플랫폼 아이콘을 뽑는다.
- 원본 그림의 잔 질감은 정해 둔 밝기 기준값 하나로 걷어내고, 색은 게임 화면의 연녹색 LCD 배경(
#aebd88)과 진한 잉크 도트(#39421c) 두 색으로 맞춘다. - 불꽃까지 포함한 로고 그림 전체가 아이콘에서 실제로 보이는 영역의 61.6% 안에 들어가게 맞춘다. 홈 화면이 아이콘을 원 모양으로 잘라 보여 줘도 불꽃이 잘리지 않게 하려는 것이다.
- iOS·watchOS 아이콘과 적응형 이전 방식의 Android 아이콘(레거시)은 1024px 불투명 이미지로 만든다. Android 적응형 아이콘(홈 화면 앱이 기기마다 다른 모양으로 잘라 쓰도록 전경·배경 레이어를 따로 둔 아이콘)은 투명 전경 + 불투명 배경 + 단색 레이어로 나눈다. 단색 레이어는 시스템 테마 색으로 칠하는 테마 아이콘용 그림이다. Android 전경은 전체 108 크기 중 가운데 72만 항상 보인다는 표준 비율(72/108)에 맞춰 배치한다.
- Android 배경색은
#aebd88(LCD 초록)이다. - 워치 앱·위젯 아이콘은 원형 로고로 바꿨고, Wear OS 앱 목록에 보이는 아이콘(런처 아이콘)도 같이 바꿨다.
같은 스크립트가 스플래시에 쓸 몬스터·불꽃 윤곽(assets/branding/splash-logo-paths.json)도 함께 내보낸다. 아이콘과 스플래시가 같은 원본에서 나오므로 둘이 어긋날 일이 없다.
prebuild는 Expo가 설정을 읽어 iOS·Android 네이티브 프로젝트를 만드는 단계다. 이미 만들어 둔 iOS 프로젝트를 지우지 않고 그 위에 다시 만드는 --no-clean 옵션으로 돌리면, 워치 앱 빌드 설정(워치 타깃)을 갱신하는 단계에서 실패했다. 폴더를 지우고 새로 만드는 경로는 통과했다. 그래서 프로젝트의 평소 방식대로 prebuild를 새로 하기로 했다.
스플래시는 3,100에서 4,820으로 늘었다가 3,540 ms로 줄었다
부팅 스플래시는 OS 스플래시(정지 알 이미지)를 앱이 같은 그림으로 이어받은 뒤, 알을 흔들고 톡 튀기는 연출이다. 기존 길이는 3,100 ms였다.
새벽에 부화 장면을 붙였다. 기존 구간은 그대로 두고, 알이 멈춰 있던 마지막 700 ms를 "껍질이 갈라지는 구간"으로 썼다. 그 뒤에 몬스터 등장을 더했다.
| 구간 | 이전 → 변경 |
|---|---|
| 앞 정지·흔들기·톡 | 400 + 2,000 ms → 그대로 |
| 알 마무리 정지 | 700 ms → 갈라진 껍질 700 ms |
| 카리몬 등장 | 없음 → 240 ms × 3단계 (720 ms) |
| 작은 불트림(작은 불꽃이 나타나는 장면) | 없음 → 300 ms |
| 마지막 정지 | 없음 → 700 ms |
| 합계 | 3,100 → 4,820 ms |
등장 간격 240 ms는 게임의 걷기 이동 간격(ACTION_TIMING.walk.ms), 불트림 300 ms는 기쁨 인사 간격(ACTION_TIMING.happy.ms)을 그대로 가져왔다. 스플래시만 따로 빠르거나 느리게 움직이지 않게 하려는 것이다.
처음에는 게임 안에서 쓰는 카리몬 도트(픽셀 그림)를 썼는데 "로고와 달라 보인다"는 피드백이 왔다. 그래서 아이콘에서 뽑은 윤곽으로 바꿨다. 그랬더니 이번에는 "부화할 때 처음 알이 다른 알로 바뀐다"는 피드백이 왔다. 갈라진 뒤 껍질까지 아이콘에서 뽑은 껍질 윤곽(SVG 경로)으로 갈아 끼우던 것이 원인이었다.
껍질은 OS 스플래시와 같은 원래 알(EGG_DOTS)의 아래쪽 윤곽·무늬를 끝까지 같은 좌표로 유지하게 고쳤다. 몬스터만 아이콘에서 뽑은 윤곽으로 그려서, 원래 알의 계단 모양 경계 뒤에서 드러나게 했다. 아이콘 원본에서 가져올 것은 몬스터까지였고, 껍질은 원래 알 그대로 둬야 했다.
화면 아래 가운데에는 앱 버전(Constants.expoConfig.version)을 표시한다.
짧게 하려고 간격 대신 좌우 흔들기를 뺐다
저녁에는 "좌우로 흔드는 부분은 빼자"는 요청이 왔다. 흔들기 2,000 ms 중 좌우 왕복(320 + 160 + 320 + 480 ms)을 빼고 톡 한 번(위로 240, 착지 480)만 남겼다. SPLASH_STEPS는 phase가 알 → 갈라짐 → 등장 → 인사 → 마무리 순서로 넘어가는 시간표다.
// src/components/boot-splash/timeline.ts
const HOP = [
{ ms: 240, dx: 0, dy: -1 },
{ ms: 480, dx: 0, dy: 0 },
]
/** Brand intro only: never advances the saved pet's actual hatch/evolution state. */
export const SPLASH_STEPS: readonly SplashStep[] = [
{ ms: 400, dx: 0, dy: 0, phase: 'egg' },
...HOP.map(step => ({ ...step, phase: 'egg' as const })),
{ ms: 700, dx: 0, dy: 0, phase: 'crack' },
...[6, 3, 0].map(rise => ({ ms: ACTION_TIMING.walk.ms, dx: 0, dy: 0, phase: 'rise' as const, rise })),
{ ms: ACTION_TIMING.happy.ms, dx: 0, dy: 0, phase: 'hello' },
{ ms: 700, dx: 0, dy: 0, phase: 'finish' },
]합계는 400 + 240 + 480 + 700 + 240×3 + 300 + 700 = 3,540 ms다. 남긴 구간의 간격은 하나도 줄이지 않았다. 빨리 끝내려고 간격을 줄이는 대신, 눈에 잘 들어오지 않는 동작 하나를 통째로 뺐다.
게임 준비가 늦으면 마지막 자세에서 기다린다. 준비 상태가 바뀌어도 진행 중인 단계 타이머를 다시 시작하지 않는다. 주석에 적은 대로 이 연출은 브랜드 연출일 뿐이고, 저장된 펫의 부화·진화 상태는 건드리지 않는다.
워치도 폰과 같은 좌표로 부화한다
워치는 그동안 정지 알을 띄워 놓고, 폰에서 동기화 답을 받거나 4초가 지나면 걷어냈다. 이제 애플워치와 Wear OS 워치도 폰과 같은 부화 연출을 SPLASH_STEPS의 9단계 그대로 재생한다.
워치용 JS 코어를 만드는 스크립트 pnpm watch:core가 폰의 알 도트·갈라짐, 아이콘의 몬스터·불꽃을 작은 사각형으로 쪼갠 좌표, 제목 글자 도트, 시간표를 splash.json으로 내보낸다. watchOS와 Wear OS는 각자의 2D 그리기 API(Canvas)로 그 좌표를 칠하기만 한다. 폰과 워치에서 그리는 코드를 따로 짜지 않는다는 원칙을 여기서도 따랐다.
끝나는 조건은 두 가지가 모두 채워져야 한다. 3,540 ms 연출이 끝나고, 폰 답을 받았거나 4초 대기 한도가 지나야 한다. 답이 빠르면 연출 끝까지 기다리고, 늦으면 마지막 자세로 기다린다. 4초는 연출 뒤에 더하는 시간이 아니라 시작 시점부터 재는 한도다.
종료 페이드는 기존 250 ms 그대로다.
Apple Watch 시뮬레이터와 Wear OS 에뮬레이터에서 정상 속도로 녹화해 알 → 갈라짐 → 카리몬 → 게임 전환 프레임을 확인했다. 실기기 정상 배속 재생은 확인하지 않았다.
신규 9종을 싣고 쌓인 시안 파일 수천 개를 지웠다
6번 계통의 성숙기 3종과 조그레스 완전체 6종(3쌍)은 도감에 등록만 되어 있었다. 몬스터는 시작 알 무늬에 따라 VER 1~6 여섯 계통으로 나뉜다. 조그레스는 두 마리를 연결해 합체 진화시키는 기능이고, 도감은 진화표로 몬스터를 모아 보는 화면이다.
그림이 없어서 도감에 물음표(?)로만 나왔다. 이날 원화(도트로 옮기기 전의 원본 일러스트)와 도트 승인본이 들어왔다. 이 그림을 스프라이트(게임 화면에 쓰는 도트 이미지)·실루엣, 몬스터 id와 그림 파일을 잇는 매핑 코드, 메타데이터에 연결했다.
이 프로젝트에서는 개발을 돕는 AI(Claude)가 몬스터 도트를 직접 찍지 않는다. 사람이 만든 원화·도트를 승인하고, 도트 반입 스크립트 art:import로만 게임에 싣는다. 승인 전(hold) 원화는 앱에 싣지 않는다는 규칙도 이미 테스트로 묶여 있어서, 승인 표시만 바뀌면 그대로 들어간다.
같은 날 알 몬스터 에그넛 도트도 원본으로 바꿨다. 껍질 곡선을 다듬고 입을 한 칸으로 줄였고, 모션은 새 도트로 다시 만들었다.
승인된 공격 도트도 원래 크기로 적용했다. 공격 도트 108종, 432프레임이다. 승인본은 art/projectiles/final/ 한 곳으로 모았고, 선택 기록(selections.json)과 적용 기록(integration.json)도 같은 폴더에 둔다. 승인 테스트도 이 선택 기록을 기준으로 바꿨다.
지우기 전에 남길 파일부터 고정했다
몇 주 동안 시안을 돌리면서 후보 폴더, 미선택 시안, 옛 수정본, 비교 이미지, 검토용 HTML, 전용 검토 도구가 쌓였다. 남길 파일을 먼저 정해 두고 한 번에 정리했다.
- 옛 경로에서 지운 파일 6,490개, 추가 점검에서 지운 초안·중복 프레임·아이콘 시안·기기 목업 572개.
- 승인 원본은 현재 경로로 옮긴 뒤 옛 작업 폴더를 지웠다.
- 기본 디자인 120종 모음집을 현재 카탈로그로 다시 만들었다.
지우는 작업이라 무엇을 남겨야 하는지부터 정했다. 게임 에셋·현재 기본 도트·애니메이션·공격 도트 등 보호 파일 18,799개의 SHA-256 해시(파일 내용으로 계산한 지문 값)가 정리 전후로 같은지 확인했다. 승인 공격 도트와 게임 데이터가 같은지, 모음집의 로컬 링크가 깨지지 않았는지도 봤다. 남길 파일의 해시를 먼저 고정해 둔 덕에, 수천 개를 지워도 게임 에셋이 그대로인지 바로 확인할 수 있었다.
어떤 파일을 지웠는지는 경로별로 art/final/cleanup-report.json에 남겼다. 이 정리로 게임에 보이는 도트 모양이나 애니메이션 속도는 바뀌지 않았다.
덩치 큰 몬스터가 훈련만으로 아주 가벼워지고 있었다
그동안 몸무게 하한은 모든 몬스터가 같은 MIN_WEIGHT = 5였고, 상한은 없었다. 작은 유령 같은 몬스터와 커다란 궁극체가 같은 숫자 범위를 쓰니 몸무게가 생김새와 맞지 않았다. 훈련을 많이 하면 덩치 큰 몬스터도 아주 가벼워졌고, 고기를 계속 주면 끝없이 무거워졌다.
몬스터마다 기준 몸무게를 하나 두기로 했다. 일러스트의 덩치와 생김새를 보고 초안을 만들었고, 그 초안대로 정했다.
- 최저와 최고는 기준에서 자동으로 계산한다. 몬스터별로 따로 정하지 않는다.
- 모든 몸무게 변화(먹이·훈련·전투·응가·진화·조그레스)는 범위를 넘으면 최저·최고값으로 맞춘다. 진화로 종이 바뀌면 새 종의 범위로 다시 맞추고, 옛 저장을 불러올 때도 범위 안으로 맞춘다.
- 최고 몸무게에서도 고기는 먹을 수 있다. 배는 차지만 몸무게는 오르지 않는다.
- 대전은 이기든 지든 몸무게가 조금 빠지고, 응가를 쌀 때도 빠진다. 응가가 이미 가득 차서 새로 싸지 않으면 몸무게도 그대로다.
규칙은 src/game/weight-profile.ts 한 파일에 모았다. 엔진의 각 경로는 몸무게를 바꾼 뒤 아래 함수로 범위 안에 맞추기만 한다.
// src/game/weight-profile.ts
/** 종의 범위 안으로 자른 몸무게 */
export function clampWeight(id: string, weight: number) {
const { min, max } = weightRange(id)
return Math.min(max, Math.max(min, weight))
}범위 계산이 한 곳에 있으니, 먹이·훈련·진화 경로가 저마다 상한·하한을 따로 들고 있지 않다. 표에 없는 종(알 등)은 진화 단계별 기본값을 쓴다. 더티 12종(돌봄 실수가 많을 때 나오는 응가몬 계열)은 처음에 원화가 없어서 단계 기본값을 썼는데, 이날 원화가 승인된 뒤 덩치를 보고 다시 정했다.
WILD 대전(걸으면서 만나는 야생 몬스터와 싸우는 대전)의 몸무게 변화는 대전 결과에 함께 담긴다. 그래서 폰과 워치가 결과를 동기화할 때 같은 값으로 맞춰지고, 두 번 적용되지 않는다. 가이드북의 몸무게 설명도 새 규칙에 맞게 고쳤다.
폰에서 치운 똥을 워치가 뒤늦게 알렸다
실기기에서 이런 일이 있었다. 폰에서 똥 호출이 와서 폰에서 치웠다. 그 뒤에 잠긴 애플워치가 호출 알림을 울렸다. 알림을 눌러 워치 앱을 열자 동기화가 되면서 똥이 없었다.
워치 알림은 워치 앱이 화면에서 내려가 백그라운드로 갈 때 미리 예약해 둔 호출 알림이었다. 워치가 혼자 늦게 울린 것은 폰에서 한 돌봄이 반영되지 않은 예전 상태로 시각을 계산했기 때문이다. 워치 예약이 예전 상태에 머물 수 있다는 건 설계 때 이미 한계로 적어 둔 경우였다.
그 밑에는 폰 소식이 워치에 늦게 닿는 문제가 있다. watchOS는 화면에 떠 있지 않은 앱을 깨워 소식을 받게 하는 일(백그라운드 깨우기)을 네 플랫폼 중 가장 엄하게 막는다. 이 방향에 기대는 구조로는 같은 일이 다시 생긴다.
정시는 휴대폰이 맡고 워치는 늦춘 예비로 둔다
- 휴대폰이 정시에 울린다. 아이폰은 잠겨 있으면 OS가 그 알림을 애플워치로 넘겨주고, 안드로이드 폰은 페어링된 Wear 워치에 알림을 기본으로 전달한다.
- 워치는 같은 호출을 늦춘 시각에 예비로 예약한다. 그 사이 돌보면 취소된다. 워치만 차고 달리기를 나가도 호출을 놓치지 않는다. 대신 몇 분 늦게 온다.
- 늦출 시간에는 임시로 5분을 넣었다. 실기기 전달 지연을 잰 값이 없어서다.
- 게임 규칙(언제 똥을 싸는지, 호출 조건, 돌봄 실수)은 바꾸지 않는다. 알릴 시각만 다룬다.
워치 우선(정시는 워치, 폰이 예비)도 검토했다. 하지만 정시 알림이 덜 믿을 만한 폰 → 워치 전달에 기대게 되어 이번 문제를 풀지 못한다. 서버 푸시(서버가 Apple의 APNs, Google의 FCM 같은 푸시 알림 서비스로 알림을 보내는 방식)는 가장 완전하지만, 그 연동과 계정·개인정보 부담이 커서 고르지 않았다.
믿을 만한 쪽이 정시를 맡고 다른 쪽을 늦춘 예비로 두면, 늦은 소식이 와도 대부분 취소가 제때 닿는다.
늦추는 계산은 JS 코어 한 곳에만 뒀다
네이티브(Swift·Kotlin으로 짠 각 플랫폼 코드)는 계산하지 않고, 폰과 워치가 같이 쓰는 JS 게임 엔진(JS 코어)이 돌려준 목록을 예약만 한다.
// src/game/call-schedule.ts
export const WATCH_BACKUP_DELAY_MS = 5 * 60_000
export const delayAlarms = (alarms: readonly CallAlarm[], ms: number): CallAlarm[] =>
alarms.map(a => ({ ...a, at: a.at + ms }))정시 목록의 각 알람 시각에 같은 시간을 더하기만 한다. 페어링된 워치만 늦춘다. 혼자 쓰는 워치는 정시에 울린다.
이 구조가 돌아가려면 상대 기기에 호출 목록을 넘겨줘야 한다. 돌봄으로 호출 이유가 바뀌거나 앱이 백그라운드로 갈 때, 그 기기는 지금 상태로 목록을 한 번 계산해서 상대 기기를 깨우며 보내는 메시지(깨우기 내용)에 싣는다. 받는 쪽은 JS 엔진이 실행 중이 아니어도 네이티브 코드가 예약을 통째로 바꾼다.
- 애플워치: 받은 정시 목록에 늦출 시간을 더해 다시 예약하고, 돌봄 뒤와 화면에서 내려갈 때 폰에 알린다. 꺼져 있던 앱이 WatchConnectivity(아이폰과 애플워치가 데이터를 주고받는 Apple 프레임워크) 때문에 새로 실행된 경우(콜드 런치)를 백그라운드로 보지 않아서, 예약을 다시 잡지 않던 빈틈도 고쳤다.
- Wear OS: 앱이 꺼져 있어도 폰 메시지가 오면 시스템이 실행해 주는 리스너 서비스가 넘겨받은 목록으로 예비 알림을 잡고, 돌봄 뒤와 앱이 화면에서 내려갈 때(
onPause) 폰에 넘긴다. - 안드로이드 폰: JS 엔진이 떠 있지 않을 때는 리스너 서비스가 받은 목록을 호출 알람 모듈로 넘겨 예약을 바꾼다. 두 네이티브 모듈이 빌드 설정(gradle)에서 서로를 의존하지 않도록, 받을 곳을 지정해 보내는 Android 앱 내부 메시지(명시적 브로드캐스트)로 넘겼다. 지난 시각은 빼서, 늦게 받은 목록의 지난 호출이 바로 울리지 않게 했다.
늦출 시간도 같은 상수 값으로 깨우기 내용에 실어 보내므로, 네이티브는 상수를 따로 갖지 않는다.
iOS의 updateApplicationContext(WatchConnectivity에서 최신 상태 하나를 상대 기기에 전달하는 API)는 마지막 하나로 덮어쓴다. WILD 대전 상태를 알리는 깨우기와 호출 깨우기를 따로 보내면 서로 지운다. 그래서 기존 WILD 깨우기 내용에 호출 목록을 함께 싣고, 마지막으로 계산한 목록은 이후 모든 깨우기(WILD 상태가 바뀔 때 보내는 것 포함)에 계속 실리게 했다.
지금 울리던 호출 이유가 돌봄으로 없어졌을 때(치움·밥·치료)는 평소보다 빨리 전달되는 급한 깨우기로 보낸다. iOS는 하루 횟수가 정해진 워치 페이스 위젯(컴플리케이션) 전송을, Android는 setUrgent를 쓴다.
받은 동기화로 바뀐 상태는 이쪽의 액션이 아니므로 다시 넘기지 않는다. 그렇게 하지 않으면 두 기기가 같은 소식을 계속 주고받는다.
늦출 시간을 재려고, 목록을 받은 네이티브 코드는 call wake delay ms=<지연> how=<경로> 로그를 한 줄 남긴다.
단위 테스트로는 늦춤 계산, 워치·페어링일 때만 늦춘다는 규칙, 돌봄으로 호출 이유가 사라지면 급한 깨우기가 된다는 규칙을 확인했다. 두 기기를 흉내 낸 테스트에서 한쪽이 치우면 다른 쪽이 받은 목록에 똥 호출이 없는지도 확인했다. 네이티브 받기와 OS 알림 전달은 시뮬레이터로 확인할 수 없고, 실기기에서도 확인하지 않았다.
성숙기 진화를 짝 하나에 두 마리, 반반으로 바꿨다
성장기 → 성숙기 진화는 조건에 따라 갈리는 갈래 여섯 개(A·B·C·D·E와 더티로 가는 X)에, 어디에도 맞지 않을 때 가는 방치 성숙기 하나가 붙은 구조였다. 돌봄 실수, 훈련 수, 과식, 대전 수가 섞인 조건이라, 키우는 사람이 "무엇을 해야 저 몬스터가 나오는지" 따라가기 어려웠다.
조건을 4줄로 줄였다. 위에서부터 차례로 보고, 처음 맞는 줄을 따른다.
- 짝 줄은 두 마리 중 하나가 반반으로 정해진다. 몇 번 안에 반드시 나온다는 보장(천장)도, 숨은 조건도 없다.
- 과식·대전 수는 성장기 갈림에서 빠진다.
- 성숙기 이후 진화 규칙과 조그레스 몬스터는 그대로다.
반반인데도 폰과 워치가 같은 면을 얻어야 했다
진화는 시간이 흐를 때마다 게임 상태를 갱신하는 엔진 함수 update() 안에서 일어난다. 앱이 꺼져 있던 시간은 1분씩 다시 돌려 따라잡는다(정산).
폰과 워치도 같은 코어로 같은 결과를 내야 한다. 그래서 Math.random을 쓸 수 없다. 펫마다 정해진 개체 키(petKey)와 성장기 id로 해시를 만들고, 그 값의 비트 하나를 0·1 동전으로 쓴다.
// src/game/evolution-rules/pairs.ts
/** 개체(petKey)·성장기마다 고정된 동전 — Math.random 을 쓰지 않아 정산 재생·휴대폰↔워치가 같은 면을 얻는다 (§5) */
export function growthCoin(petKey: string, growthId: string): 0 | 1 {
return (fnv1a(`evo-pair|${petKey}|${growthId}`) >>> 31) as 0 | 1 // FNV 맨 아래 비트는 글자 비트의 단순 XOR 이라 섞임이 약하다 — 맨 위 비트를 쓴다
}petKey는 낮잠 일정을 정하는 난수 시드처럼 개체 단위 결정론(같은 입력이면 언제 어디서 계산해도 같은 결과가 나오는 성질)이 필요한 곳에 이미 쓰고 있는 키다. 한 마리를 키우는 동안에는 앱을 껐다 켜도, 정산을 다시 돌려도 같은 면이 나온다.
처음에는 해시의 맨 아래 비트를 쓰려 했다. 그런데 FNV-1a(빠르고 단순한 비암호 해시 함수)의 맨 아래 비트는 입력 글자들의 맨 아래 비트를 XOR(두 비트가 다르면 1)로 겹친 값일 뿐이라 고르게 섞이지 않는다. 그래서 맨 위 비트를 쓴다. 12개 성장기 × 여러 개체에서 두 면이 고르게 나오는지를 테스트로 확인했다.
계열은 종이 아니라 "어느 짝에서 나왔는가"로 정했다
성숙기에는 성장기 때 돌봄 수준으로 갈리는 네 계열(정통·보통·방치·더티)이 있고, 계열에 따라 완전체로 가는 길이 다르다. 그동안 계열은 종 기준이었다. 이제는 이 개체가 어느 짝에서 나왔는지로 정한다. 지금까지 거친 종을 순서대로 남긴 생애 기록(lifeChain)에 직전 성장기가 있어서 새 저장 필드는 필요 없다.
실제로 계열이 바뀌는 종은 하나뿐이다. 같은 성숙기가 한 성장기에서는 잘 돌봄 짝으로, 다른 성장기에서는 조금 소홀 짝으로 나오는 경우다. 잘 돌봐서 나왔다면 최고 완전체로 가는 길이 열려야 하므로 의도한 변화다.
도감 트리는 어느 경로로 왔든 갈 수 있는 종을 모두 그린다. 같은 종으로 가는 갈래를 합치던 merge()는 지웠다. 이제 한 성장기 안에 같은 종이 두 번 나오지 않기 때문이다.
도감에 진화 조건(레시피)을 보여 주는 화면은 이번에 넣지 않았다. 진화 판정은 "이번 단계 값"을 보는데 상태창에는 평생 값만 있다. 숫자를 보여 주면 어느 값과 비교하는지 헷갈리는 문제가 있어서 그대로 뒀다.
시뮬레이터도 코드 대전에 들어오고 있었다
시뮬레이터와 휴대폰 사이에 코드 대전이 됐다. 이유는 두 가지다. 중계 서버는 두 기기가 보낸 몬스터 정보를 크기만 보고 그대로 전달한다. 그리고 정식 앱인지 확인하는 단계가 없다.
시뮬레이터에서는 사용자가 앱 코드와 저장 데이터에 서명하는 비밀 키(저장 열쇠)를 모두 쥐고 있어서, 앱 안의 검사는 지울 수 있다. 저장 데이터에 찍어 변조 여부를 확인하는 도장(저장 도장)도 마찬가지다. 막으려면 서버가 Apple·Google의 보증을 확인해야 한다.
목표는 운영 서버의 코드 대전에 스토어로 받은 정식 앱이 도는 실기기만 들어오게 하는 것이다. 시뮬레이터, 에뮬레이터, 개발 빌드, APK 직접 설치(스토어를 거치지 않고 설치 파일을 바로 깐 앱), 고친 앱, 대부분의 루팅 기기(관리자 권한을 열어 OS 제한을 푼 기기)는 막는 대상으로 잡았다. 이날은 설계까지만 했고, 아래는 모두 설계서에 적은 내용이다.
서버가 플랫폼 증명을 확인하는 구조로 설계했다
- 서버를 둘로 나눈다. 개발 서버는 확인을 끄고, 운영 서버는 켠다. 개발 빌드·시뮬레이터는 개발 서버, TestFlight·Play 테스트 트랙(각각 Apple·Google의 출시 전 테스트 배포 경로)·출시 빌드는 운영 서버를 쓴다. 서로 다른 서버끼리는 대전이 안 된다.
- 워치도 스스로 확인을 받는다. 애플워치는 App Attest(정품 앱·실기기에서 온 요청임을 Apple이 보증해 주는 서비스), Wear OS는 Play Integrity(Google Play가 앱과 기기의 무결성을 판정해 주는 API)로, 지금처럼 서버에 직접 보낸다.
- 본문은 JS 엔진이 만든 문자열을 그대로 보낸다. 지금 워치는 몬스터 정보를 Swift·Kotlin에서 다시 직렬화(객체를 JSON 문자열로 바꾸는 일)하는데, 키 순서와
/를\/로 바꿔 쓰는지(이스케이프)가 플랫폼마다 달라진다. 증명은 본문 원문의 해시를 덮으므로, 바이트가 하나라도 다르면 검증이 깨진다. - 증명은 HTTP 헤더(
X-Dotgimon-Att)로 보낸다. 본문 상한(2KB)을 건드리지 않으려는 것이다. 증명이 덮는 값은 고정 접두어(dotgimon-wait-v1) + 코드 대전 방 번호 + 본문 원문의 SHA-256이고, 서버는JSON.parse전에 받은 원문 그대로 해시를 계산한다. - Apple App Attest: 서버가 서명한 일회용 값(재사용을 막는 challenge)을 받아 기기가 열쇠(기기 안에서 만든 서명용 키)를 등록하고, 신청마다 서명을 검증한다. Durable Object는 Cloudflare Workers에서 ID마다 하나만 존재하며 상태를 저장하는 객체다. 열쇠마다 Durable Object를 하나 두고, 공개키와 카운터(서명할 때마다 하나씩 늘어나는 수)를 저장한다. 카운터가 늘었는지 비교하고 저장하는 일은 그 안에서 차례로 처리한다. 일회용 값은 서버에 저장하지 않고 서명과 만료만 본다.
- Google Play Integrity: 서버가 기기가 보낸 무결성 토큰을 풀어 앱 인식(Play에서 받은 정식 앱인지)·기기 무결성·요청 해시·시각을 본다. Google 호출이 실패하면 확인 없이 통과시키지 않고 거절한다.
- 몬스터 값 검사: 서버도 기기가 상대 몬스터 정보를 읽고 값이 맞는지 검사할 때 쓰는
parsePeerMon을 같은 코드로 돌린다. - 대기 시간: 확인에 쓴 시간(특히 Google 호출)을 대기 시간(서버가 상대를 기다리며 요청을 붙잡아 두는 시간)에서 빼서, 기기 제한시간보다 먼저 답하게 한다. 받은 시각은 서버가 정하고, 기기가 보낸 같은 이름의 값은 덮어쓴다. 기기가 자기 대기를 늘리지 못하게 하려는 것이다.
설계서를 쓰고 두 번 검토를 거쳤다. 첫 검토에서 "본문 그대로 보내기 + 증명 헤더" 구조, 오류 전달 방식, 열쇠 미리 등록, 열쇠 저장 구조를 정리했다. 두 번째 검토에서는 대기 시간을 기기의 남은 시간에 맞추는 부분과 남용을 막는 요청 제한 방식을 고쳤다.
대시보드의 Rate Limiting(일정 시간 안의 요청 수 제한) 규칙은 존(Cloudflare에 연결한 도메인) 단위 설정이다. 그래서 *.workers.dev 주소로 도는 지금 구조에 맞춰, Worker(Cloudflare에서 돌리는 서버 코드) 안에서 바로 부르는 Rate Limiting 바인딩을 쓰도록 적었다.
도감, 효과음, 저장 도장까지 손본 자잘한 것들
- STAGE 소장 보상 설계서: STAGE는 층을 하나씩 올라가며 싸우는 혼자 하는 도전 대전이다. STAGE를 오를수록 기기 꾸미기(본체·프레임·버튼 색)와 유물 소장품(누적 500층마다 하나씩 열리는 수집품)이 열리는 보상을 기획했다. 보상은 소장·자랑용이고 전투력은 주지 않는다. 모은 것은 새 펫을 키워도, 기록을 초기화해도 남는다. 유물 도트를 맡길 아트 작업 지시서도 같이 썼다.
- 도감 기술명: 도감 상세 정보판에 몬스터별 기술명을 한 줄로 보여 준다(
기술: 불꽃박치기/Move: BLAZE RAM). 132종 모두 한·영 이름이 있는지 테스트로 고정했고, 더티 12종은 승인된 원화를 보고 새로 지었다. 기술 기획서도 버전별 표로 다시 정리했다. - 도감 트리: 더티 계열을 각 열(유년기Ⅰ부터 이어지는 진화 단계)마다 맨 아래로 옮겼다. 지금 키우는 몬스터에서 닿을 수 있는 진화선만 진하게 그린다. 같은 탭(버전별 V1~V6 탭과 조그레스 JOG 탭)의 나머지 선은 옅게 그린다.
- 효과음 밀림: 버튼을 연타하면 LCD와 효과음이 밀렸다.
expo-audio(Expo의 소리 재생 라이브러리) 기본값은 소리가 끝날 때마다 iOS 오디오 세션(앱이 소리를 내려고 OS에서 잡아 두는 재생 상태)을 끄고, 다음play()가 JS 스레드에서 세션을 다시 켜느라 막힌다. 세션을 켜 두게 바꿨다. 다른 앱 소리 위에 섞어 재생하는 설정(ambient + mixWithOthers)이라 세션을 켜 두어도 다른 앱 소리를 끊지 않는다. - 거절 문구: 대전·조그레스 조건이 안 맞을 때 뜨는 문구다. 진화 단계 표기를
LV.로 통일한 데 맞춰NEED ROOKIE+는NEED LV. III+(성장기 이상 필요)로 바꿨다. 조그레스 대상이 아니면NO JOGRESS하나로, 아픈 펫의 조그레스는NEED HEAL로 정리했다. - 워치 시작 화면: 워치를 새로 켤 때 폰 답을 받기 전까지는 스플래시를 유지한다(최대 4초). 이전 상태가 잠깐 비치는 것을 막으려는 것이다.
저장 도장 우회 경로도 막았다. 실행 중에 DB의 변조 목록을 지우거나, 옛 저장을 불러와 새 도장을 받는 경로다. 이제 저장이 새 도장을 찍기 전에 DB에 든 저장 원문과 변조 펫 목록이 도장과 맞는지 먼저 확인한다. 안 맞으면 지금 펫을 변조로 표시하고, 그 펫이 대전에 나가지 못하게 대전 잠금을 바로 건다.
그래도 앱 안의 검사는 앱을 쥔 사람이 지울 수 있다. 그래서 저장 도장으로 막을 수 있는 범위와, 서버가 플랫폼 보증을 확인해야 막을 수 있는 범위를 나눠서 설계서에 적었다.