dotgimon 제작기 31. 가려진 워치의 WILD와 훈련 유실 수정
DOTGIMON은 Expo/React Native로 만드는 LCD 방식 가상 펫 게임이다. 폰 앱이 있고, 같은 게임 엔진을 QuickJS(임베디드용으로 만든 작은 JavaScript 엔진)로 돌리는 애플워치·Wear OS 앱이 있다. 폰과 워치는 WILD(걸으며 야생 몬스터를 만나는 탐색 모드)를 하나의 상태로 같이 쓴다.
이날은 그 WILD를 앱이 가려진 상황까지 끌고 갔다. 같은 날 훈련 도중 동기화가 끼면 훈련 1회가 사라지던 버그도 고쳤다.
앱이 가려진 동안에도 WILD가 돌게 기기마다 손봤다
Apple Watch는 컴플리케이션이 있어야 새로고침 시간을 받는다
watchOS 앱은 가려지면 곧 정지된다. 새 권한(HealthKit·운동 세션) 없이 워치 앱이 가려진 동안 스스로 깨어나도록 예약하는 방법은 백그라운드 새로고침(시스템이 정해 둔 시각에 앱을 잠깐 깨워 주는 기능)뿐이다. Apple 문서에 따르면 새로고침 시간은 시계 화면에 그 앱의 컴플리케이션(시계 화면에 붙는 작은 앱 정보 칸)이 있어야 받는다. 그래서 컴플리케이션과 새로고침을 한 묶음으로 만들었다.
새로고침 예약은 15분 뒤로 잡는 작은 enum 하나로 모았다.
// targets/watch/WildRefresh.swift
enum WildRefresh {
static let id = "dotgimon.wild"
static let interval: TimeInterval = 15 * 60
static func schedule() {
WKApplication.shared().scheduleBackgroundRefresh(
withPreferredDate: Date(timeIntervalSinceNow: interval),
userInfo: id as NSSecureCoding & NSObjectProtocol
) { error in
if let error { NSLog("DOTGIMON-WILD refresh schedule failed %@", error.localizedDescription) }
}
}
}새로고침은 한 번에 하나만 예약할 수 있다. 그래서 WILD가 켜져 있으면 가려질 때와 새로고침이 끝날 때마다 다시 잡는다. 깨어나면 아래 순서를 지킨다.
- 폰에서 받은 것부터 반영한다.
- 만보기 기록을 조회해 가려진 동안의 걸음을 한꺼번에 더한다. 엔진이 걸음을 어느 시각까지 더했는지(
appliedTo) 기억하고 그 뒤 구간만 가져가므로, 손목을 들었을 때 센 걸음을 다시 세지 않는다. - 저장한다.
- 로컬 알림은 보내도 되는지 검사하는 함수(
wildAlertOk, 알림 가드)를 통과했을 때만 보낸다.
컴플리케이션과 스마트 스택(위젯을 쌓아 두고 넘겨 보는 watchOS 화면)은 워치 앱과 따로 빌드되는 위젯 확장(watch-widget 타깃)으로 넣었다. 꺼짐이면 앱 아이콘, 탐색 중이면 WILD, 몬스터가 기다리면 WILD !를 앱 LCD 글꼴 도트로 그린다.
걸음 수는 보이지 않는다. 처음 계획에는 "걸음/목표" 표시가 있었지만, 진행을 숨기는 것이 의도라 넣지 않았다. 위젯은 걸음을 직접 세지 않고 App Group(같은 개발자의 앱과 확장이 함께 쓰는 저장 공간)으로 상태만 읽는다. App Group은 워치 앱과 그 위젯 사이에만 둔다.
Apple Watch는 시스템이 앱을 지웠다가 다시 띄워도 이어 세야 한다. 그래서 워치 저장 파일에 공유 WILD 상태와 함께, 언제 가려졌고 걸음을 어디까지 반영했는지를 담았다.
Wear OS는 게임 엔진을 프로세스에 하나만 뒀다
Wear는 Activity(Android에서 화면 하나를 맡는 구성 요소)가 다시 만들어지는 일이 흔하다. 그때마다 게임 엔진(코어)을 새로 만들면, 앱을 완전히 껐다 켠 것(콜드 스타트)으로 보고 WILD를 끄는 규칙에 걸린다. 그래서 코어와 걸음 처리를 프로세스에 하나만 두는 WearCore로 옮겼다.
이제 Activity를 다시 만들어도 콜드 스타트가 아니다. 진짜 콜드 스타트라면 첫 틱(게임 루프가 상태를 처음 갱신하는 시점) 전에 WILD를 끈다.
몬스터가 기다리는 동안에도 걸음을 세는 서비스(WildStepService)를 끄지 않는다. 가려진 채 몬스터가 떠나 다시 탐색이 되면 다음 걸음 이벤트부터 다시 세야 한다. Android 12(API 31) 이상을 타깃으로 하는 앱은 몇 가지 예외를 빼면 가려진 상태에서 포그라운드 서비스(알림을 띄운 채 계속 실행되는 서비스)를 새로 시작할 수 없어서, 한 번 끄면 다시 켤 수 없다.
최근 앱 목록에서 밀어 지우면 WILD 끔을 저장하고 폰에도 알린 뒤 멈춘다. 서비스를 stopWithTask=false로 두어, 지워질 때 바로 죽지 않고 onTaskRemoved 콜백을 받게 했다.
소리를 꺼도 울리던 폰 알림을 한 모듈로 모았다
iPhone의 백그라운드 WILD 알림은 소리 설정을 보지 않고 울렸다. 이것을 고치면서 알림 보내는 코드를 한 모듈로 모았다. 아래는 그 모듈에서 알림을 보내는 함수로, 가드를 지나지 못하면 알림 없이 끝난다.
// src/game/use-game/wild-notice.ts
async post(now: number): Promise<boolean> {
const id = foundId()
if (!wake || !id || !session.game.wildAlertNow(now)) return false
notified = id
await wake.notify({ silent: !(session.haptic?.on ?? true) }).catch(() => {})
return true
},보내기 직전에 알림 가드를 지나야 한다. WILD가 켜져 있고, 몬스터가 기다리는 중이고, 어느 기기에서도 대전이 진행 중이 아닐 때(대전 잠금 없음)만 알린다. 대전 잠금은 한 기기가 대전을 시작하면 잡아 두는 표시로, 다른 기기는 그동안 관전만 한다. 소리 끔이면 소리·진동 없는 알림으로 남긴다(Android는 조용한 채널 wild-found-quiet).
알림을 보낸 조우가 대기 상태를 벗어나면(대전, 도망, 30분이 지나 몬스터가 떠남, 다른 기기에서 처리됨) 다음 깨우기나 앱 복귀 때 알림을 지운다. 이미 다른 기기에서 싸운 몬스터 알림이 남아 있으면 혼란스럽기 때문이다.
워치가 몬스터를 찾은 뒤 폰에서 도망(RUN)을 고르면, 워치는 동기화 메시지 반영을 미루는 화면에 있어도 바로 대기 상태를 벗어나고 알림도 막힌다. 이 시나리오를 테스트로 고정했다.
공유 WILD는 저장 파일에 넣고, 깨우기 전송은 따로 작게 묶었다
메모리에만 있던 공유 상태를 저장 파일로 옮겼다
공유 WILD 상태와 대전 잠금이 메모리에만 있으면 앱을 다시 켤 때 사라진다. 그래서 폰·워치 저장 파일(SaveBlob)이 wildShared·battleLocks·wildBg(가려진 동안의 기록)를 갖게 했다. 불러올 때는 깨진 필드만 버리고 나머지는 살린다. 펫 저장을 막지 않는다.
엔진은 첫 틱 전에 저장된 공유 상태부터 불러온다. 그래서 폰을 완전히 껐다 켜서 WILD가 꺼질 때도 그 탐색에서 센 걸음 수가 저장되고, 다시 켜면 그 수부터 이어 센다. 가려진 동안의 기록(wildBg)은 워치만 저장한다. 폰은 콜드 스타트에 WILD가 꺼지므로 지난 프로세스의 기록을 쓸 일이 없다.
미루는 화면에서도 공유 WILD는 바로 반영한다
동기화 메시지는 홈·사망·뽑기(가챠) 화면이 아닌 곳에서는 통째로 미뤄진다. 탐색 화면도 미루는 화면이다. 그대로면 "먼저 찾은 기기가 바로 알린다"가 불가능하다.
그래서 공유 WILD와 대전 잠금은 진동 설정(haptic)처럼, 메시지 본문과 따로 실어 미루지 않고 바로 반영하는 필드로 보냈다. 동기화 형식 버전은 그대로다.
깨우기 전송에는 표시 상태만 실었다
가려진 워치를 깨우려면 iPhone은 applicationContext·컴플리케이션 전송을, Android·Wear는 Data Layer에 바로 보내라고 표시한(setUrgent) 데이터 항목을 쓴다. applicationContext는 WatchConnectivity에서 가장 최근 상태 하나만 상대 기기에 넘기는 방식이고, Data Layer는 Wear OS 기기와 폰이 데이터를 주고받는 API다.
이 경로에 전체 동기화 메시지(현재 상태 스냅샷, 폰이 확정한 기준 상태인 체크포인트, 그 뒤 조작 기록인 액션)를 실으면 크기가 커진다. 깨우기에는 표시 상태만 필요하므로 따로 묶었다.
// src/sync/wild-sync.ts
const WAKE_LOCKS = 8
export function wildWakeJson(g: Game, now: number): string {
const w = clone(g.wildState())
if (w.session?.battle) delete w.session.battle.replay
if (w.lastBattle) delete w.lastBattle.replay
const locks = [...g.battleLocks].sort((a, b) => b.startedAt - a.startedAt).slice(0, WAKE_LOCKS)
return JSON.stringify({ wake: 1, sentAt: now, wild: w, battleLocks: locks })
}연출 데이터(replay)를 빼고 최근 잠금 8개와 보낸 시각만 싣는다. 크기는 테스트로 2KB 이하를 고정했다. 전체 동기화는 원래대로 보통 경로로 간다.
앱 종료 신호도 이 길로 보낸다. iPhone은 applicationWillTerminate가 불리면 끔을 보낸다. 이 메서드는 백그라운드에서 실행 중인 앱을 시스템이 종료할 때 불리고, 사용자가 앱을 종료할 때는 보통 불리지 않는다.
Android·Wear는 최근 앱에서 지울 때 보낸다. 받는 쪽에는 앱이 꺼져 있어도 시스템이 깨워 주는 리스너 서비스(WearableListenerService)를 두어 알림을 지운다.
리스너 서비스가 받은 끔을 그대로 따르면 걸음을 놓친다
처음에는 리스너 서비스가 끔을 받으면 바로 걸음 서비스를 멈추게 했다. 그런데 받은 상태는 보낸 쪽이 본 것이다. 연결이 끊긴 사이 이쪽이 나중에 다시 켰을 수 있다. 체크포인트 이후 두 기기의 액션을 같은 순서로 합쳐 다시 계산하면(이 과정을 접기라고 부른다) 결과는 켬인데, 서비스만 먼저 멈춘다.
Android 12 이상 타깃 앱은 가려진 채로 다시 켤 수 없으니 그대로 걸음을 놓친다. 그래서 리스너 서비스는 게임 엔진(JS)이 떠 있지 않을 때만 네이티브 코드로 걸음 서비스를 멈추고, 엔진이 떠 있으면 접은 결과를 따른다. 알림 지우기는 그대로 즉시 한다.
같은 대전이면 이력 줄도 두 기기에서 같아야 했다
두 기기가 같은 WILD 조우를 접으면 수치는 같게 나온다. 이력은 그렇지 않았다. 이력은 폰 SQLite의 life_events 테이블에, 대전 같은 펫의 사건이 생기는 순간 한 줄씩 더하기만 했다. 나중에 접기 결과가 바뀌어도 먼저 쓴 줄은 그대로라, 수치는 같은데 이력은 다를 수 있었다.
대전 이력 줄에 대전 잠금 id를 붙였다(친구 대전은 빼고). 폰이 체크포인트를 확정할 때, 그 접기에서 남은 대전은 같은 잠금 id의 줄을 지우고 새로 쓰고, 빠진 대전의 줄은 지운다.
이력은 폰에만 있고 워치에는 저장소가 없다. 그래서 폰 한 곳에서만 맞췄다. 맞추는 대상은 한 조우를 두 기기가 합의한 결과뿐이고, 다른 기록은 건드리지 않는다.
겹친 대전 판정도 한 번에 정해지게 정리했다. 두 기기에서 시간이 겹친 대전은 하나만 남긴다. 다른 사람 기기와 한 대전(근처 LINK·번호 코드)은 상대 기기에도 결과가 이미 남았으므로 언제나 남긴다.
나머지는 먼저 시작한 쪽, 시각이 같으면 폰, 그래도 같으면 잠금 id 순으로 하나씩 정한다. 이때 어차피 받아들여지지 않을 WILD 대전(나중에 찾은 몬스터와 싸운 대전 등)은 앞서 정한 결과로 미리 걸러 내고 비교한다.
꺼졌다 다시 켠 WILD는 끄기 전 걸음 수부터 이어 센다
WILD 걸음을 세는 규칙을 이날 새로 정했다. 탐색 중 어떤 이유로든 WILD가 꺼지면(직접 끔, 앱을 완전히 껐다 켬, 앱 종료) 그때까지 센 걸음을 남기고, 다시 켜면 그 수에서 이어 센다. 꺼진 동안 걸은 걸음은 세지 않는다. 몬스터를 만나 조우가 끝나거나 펫이 바뀌면 0이다.
구현은 접기에 맞췄다. 끈 기기가 끔 액션에 센 걸음을 싣고, 접기가 꺼진 상태에 그 값을 이월 값(carry)으로 담아 둔다. 다시 켜면 탐색이 carry에서 시작한다. 끔도 액션이라 두 기기가 같은 값에서 이어 간다.
탐색이 아닐 때(몬스터 대기·대전 중) 끄면 그 조우가 끝난 것이므로 0이다. 걸음 수는 화면이나 문구에 나오지 않는다.
훈련 도중 동기화가 끼면 훈련 1회가 사라졌다
시작만 체크포인트에 들어가고 발사·완주는 버려졌다
폰과 워치를 같이 쓰다 보면 훈련을 한 번 했는데 기록에 없는 경우가 있었다. 원인은 체크포인트였다. 폰은 워치 메시지를 받을 때마다 그 시각까지의 액션을 체크포인트에 확정한다.
훈련은 TRAIN_START → 라운드마다 발사(TRAIN_SHOT) → TRAIN_END(또는 TRAIN_CANCEL)까지 여러 액션으로 이루어진다. 액션을 다시 적용해 상태를 계산하는 재계산은, 훈련 시작 전 수치(스태미나·체중·훈련 횟수 등, trainSessions)를 메모리에 들고 있다가 끝날 때 쓴다. 그런데 이 수치는 체크포인트에 남지 않는다. 훈련 도중에 동기화가 끼어 TRAIN_START만 확정되면, 뒤의 발사·완주가 "세션 없음"으로 버려졌다.
열린 훈련의 시작 직전까지만 확정하게 했다
체크포인트를 열린 훈련의 시작 직전까지만 접게 했다. 시작·발사·완주를 한 번에 확정하기 위해서다. 아래 함수는 범위 안에서 아직 닫히지 않은 훈련 시작 중 가장 이른 시각을 돌려준다.
// src/sync/train-fold.ts
export function openTrainStart(actions: readonly Action[], from: number, to: number): number | null {
const closed = new Set<string>()
const starts: Action[] = []
for (const a of actions) {
if (a.at > to) continue
const d = a.data
if (d.type === 'TRAIN_END' || d.type === 'TRAIN_CANCEL') closed.add(keyOf(a, d.session))
else if (d.type === 'TRAIN_START') starts.push(a)
}
let open: number | null = null
for (const a of starts) {
if (a.at < from || a.data.type !== 'TRAIN_START' || closed.has(keyOf(a, a.data.session))) continue
if (starts.some(b => b.dev === a.dev && b.pet === a.pet && b.at > a.at)) continue
open = open === null ? a.at : Math.min(open, a.at)
}
return open
}같은 기기·같은 펫의 다음 훈련 시작은 앞 훈련을 닫는다. 한 기기는 한 번에 하나만 훈련하기 때문이다. 이 규칙이 없으면 끝 액션이 없는 옛 훈련 하나 때문에 체크포인트가 영영 그 앞에 멈춘다.
앱이 종료돼 끝이 오지 않는 훈련은 다시 켤 때 취소한다
반대 경우도 있다. 훈련 도중 앱이 종료되면 끝 액션이 영원히 오지 않는다. 훈련 화면은 저장되지 않으므로, 훈련 중에는 세션 id와 시작 전 수치를 저장 파일(trainOpen)에 남긴다. 다시 켤 때 C 버튼으로 훈련을 그만둔 것과 같은 취소(TRAIN_CANCEL)로 기록해 시작 전 수치로 되돌린다.
최고 판정(MEGA HIT)으로 오른 횟수까지 시작 전 값으로 돌아간다. 꺼져 있던 동안의 변화를 먼저 한꺼번에 계산(정산)하고 되돌려서 재계산과 같은 순서를 지킨다.
펫 상태와 동기화 형식은 바뀌지 않아서 동기화 버전을 올리지 않았다. 훈련 도중 동기화가 끼는 경우와 앱 종료로 끊긴 경우를 테스트로 고정했다.
원화는 띠부씰로 꾸미고, 문구에서는 동작 원리를 뺐다
워치 원화 페이지를 띠부씰처럼 바꿨다
지금 키우는 몬스터의 일러스트(원화)를 보여 주는 워치 페이지를 띠부씰(빵 봉지에 들어 있는 캐릭터 스티커) 디자인으로 바꿨다. 화면 맨 위 시각 표시 줄 아래를 흰 스티커가 채우고, 첫 줄 왼쪽에 속성 엠블럼 일러스트, 오른쪽에 속성색 연한 배지 안에 이름을 넣는다. 그 아래에는 원화가 크게 들어간다.
엠블럼은 6종을 새로 만들었고, 없으면 벡터 모양으로 대신한다. 이름 글꼴은 앱과 같은 Galmuri11(한글 도트 글꼴)을 필요한 글자만 서브셋으로 넣었다. 워치 엔진이 넘기는 원화 정보에 몬스터 속성(불·얼음 등 6종, affinity)을 더했고, Apple Watch와 Wear OS가 같은 모양이다.
설정 페이지도 한 화면 리스트로 다시 짰다. 줄마다 도트 아이콘, 이름과 상태, 도트 스위치가 있다.
걸음 기준과 늦은 알림 설명은 문구에서 뺐다
사용자에게 보이는 WILD 문구에서 걸음 기준, 알림이 늦을 수 있다는 설명, 내부 동작 설명을 뺐다. 가이드·권한 안내·알림·iOS 권한 문구·개인정보처리방침이 대상이고 한국어·영어를 같이 고쳤다.
알림은 "WILD 탐색 중에 야생 몬스터가 나타났어요" 대신 "야생 몬스터가 나타났어요!"가 됐고, 가이드의 긴 설명은 "WILD를 켜고 걸으면 야생 몬스터를 만나요"로 줄었다.
일부러 숨겼다. 몇 걸음에 나오는지 모르는 편이 걷는 재미가 있다. 개인정보처리방침의 필수 고지(어떤 데이터를 어디에 쓰는지)는 남겼다. 숫자나 지연 문구가 다시 들어오지 않도록 문구 검사 테스트를 추가했다.
훈련 헤더도 항상 TRAIN으로 보이게 했다. 진 뒤 첫 훈련에 붙던 RETRY 표기와, 앱 안 가이드에서 RETRY를 설명하던 용어 항목을 뺐다. 진 뒤 재도전은 헤더 표기 없이 몸풀기·기쁨 연출로만 구분하기로 했다.
설계서에는 구현하면서 달라진 점을 적어 뒀다. 이날 들어간 Swift·Kotlin 코드는 네이티브 빌드도, 실기기 실행도 하지 않았다. 확인은 JS 쪽 자동 테스트, 그리고 네이티브 소스를 텍스트로 읽어 이름과 호출 순서가 설계와 맞는지 보는 테스트까지다.
상대가 보낸 상태는 판단 재료일 뿐 결론이 아니었다.
훈련처럼 시작과 끝 사이에 메모리 상태가 있는 일은 체크포인트가 가르면 안 된다. 확정 경계를 그 일이 열리기 전으로 당겼다. 끝이 오지 않는 경우(앱 종료)도 따로 닫아 줘서 경계가 멈추지 않게 했다.
깨우는 경로는 작고 빨라야 하고, 동기화는 크고 확실해야 한다. 깨우기에는 표시 상태만 싣고 크기를 테스트로 묶어 두니 두 요구가 섞이지 않았다.
동작 원리를 문구에 적으면 친절해 보이지만 게임의 발견을 빼앗는다.