dotgimon 제작기 30. 폰·워치 공유 WILD 세션과 만보기 전환
DOTGIMON은 Expo/React Native로 만드는, 도트 LCD 화면을 흉내 낸 가상 펫 게임이다. 폰 앱이 있고, 같은 게임 엔진을 QuickJS(임베디드용으로 만든 작은 JavaScript 엔진)로 돌리는 애플워치·Wear OS 앱이 있다.
WILD는 대전 메뉴에서 켜는 탐색 모드다. 켜 두고 걸으면 일정 걸음마다 야생 몬스터가 나타나고, 만나면 싸우거나(BATTLE), 합체하거나(JOGRESS), 도망칠(RUN) 수 있다. 앱이 가려졌다는 건 홈 화면 뒤로 가거나 화면이 꺼진 상태를 말한다.
이날은 설계가 두 번 크게 바뀌었고, 그때마다 코드가 따라갔다. 아침에는 저장 도장(저장 파일 변조를 막는 장치)을 마무리하고 WILD에 백그라운드 알림을 붙였다. 점심 무렵 모든 기기를 만보기 방식으로 바꿨고, 오후에는 폰과 워치가 WILD를 하나의 상태로 같이 쓰는 설계를 새로 썼다.
도장 확인을 복원보다 먼저 둬야 세탁을 막는다
저장 도장은 펫 저장 파일에 HMAC 도장을 찍어서, 누군가 파일을 고쳐 강한 펫을 만들어 친구 대전에 나오는 것을 막는 장치다. HMAC은 비밀 열쇠와 데이터로 계산하는 인증 코드라, 열쇠 없이 파일만 고치면 값이 맞지 않는다.
도장 계산과 확인은 이미 들어 있었고, 이날 아침에는 남은 연결을 마쳤다. 세 가지가 걸려 있었다.
- 부팅할 때 도장 확인과 서버 백업 복원 중 무엇을 먼저 하느냐.
- 보안 저장소(열쇠가 들어 있는 곳)를 읽지 못한 첫 부팅을 어떻게 처리하느냐.
- iCloud나 기기 이전으로 저장 파일이 다른 기기에 옮겨 가는 길.
부팅 순서는 도장 확인 → 복원으로 정했다. 복원을 먼저 하면 복원된 파일에 새 도장이 찍혀서, 고친 파일을 복원으로 "세탁"할 수 있기 때문이다.
확인이 끝나기 전의 저장은 저장 관문(SealGate)이 붙잡아 미뤘다. 도장이 깨진 펫은 홈에 !를 표시하고 DUAL(친구 대전)에 나가지 못하게 했다. 저장을 서버 백업으로 보내는 업로더에도 올리기 직전의 도장 확인을 한 번 더 넣었다.
열쇠를 못 읽은 첫 부팅에서는 도장 없이 시작하지 않았다. 대신 LCD에 KEY? 재시도 안내를 보이고, 횟수 제한 없이 다시 시도하게 했다.
저장을 미루는 동안의 재시도는 이와 별개다. 열쇠를 못 읽어 저장을 미룬 동안에는 30초마다 열쇠를 다시 읽고, 10번까지 못 읽으면 포기하고 도장 없이 저장한다.
이 주기 재시도는 앱이 앞에 있을 때로 한정했다. 기기를 켜고 처음 잠금을 풀기 전처럼 백그라운드에서 돌면, 열쇠를 읽을 수 없는 동안 10번을 다 써 버린다. 그러면 도장 없이 저장되고, 다음 실행에서 변조로 잘못 판정된다.
펫 저장을 기기 백업에서 뺐다
저장 파일이 백업을 타고 다른 기기로 넘어가지 않게 했다. 플랫폼마다 따로 만들었다.
Android는 설정 플러그인(config plugin, 빌드 때 네이티브 설정 파일을 고쳐 주는 Expo 확장)으로 dataExtractionRules를 넣었다. 클라우드 백업과 기기 간 이전에 넣을 파일을 정하는 매니페스트 설정이고, 이 설정으로 펫 저장을 두 경로 모두에서 뺐다. Wear OS는 앱 데이터 백업 자체를 끄는 allowBackup=false로 뒀다. iOS는 파일마다 "백업 제외" 표시를 다는 Expo 네이티브 모듈을 프로젝트 안에 직접 만들었다.
iOS 모듈은 받은 경로마다 파일이 있는지 보고 isExcludedFromBackup을 켠 뒤, 표시한 파일 수를 돌려준다.
// modules/dotgimon-backup-exclude/ios/DotgimonBackupExcludeModule.swift
Function("exclude") { (paths: [String]) -> Int in
var marked = 0
for path in paths {
guard FileManager.default.fileExists(atPath: path) else { continue }
var url = URL(fileURLWithPath: path)
var values = URLResourceValues()
values.isExcludedFromBackup = true
if (try? url.setResourceValues(values)) != nil { marked += 1 }
}
return marked
}파일이 새로 생기면 표시가 없다. 그래서 JS 쪽이 앱을 켤 때마다 DB 파일과 -wal·-shm(SQLite가 DB 옆에 만드는 로그 파일과 공유 메모리 파일), 그 폴더에 표시를 다시 달았다.
도장이 지키는 범위는 부팅·복원·업로드·친구 대전 입구까지 이어졌다. 근처 친구와 하는 대전(LINK)에서 상대가 보낸 값도 간단히 검사하게 했다. 이긴 수는 대전 수를 넘을 수 없다. 진화 때 0이 되는 훈련 수도, 새 알에서만 0이 되는 숨은 훈련 성과를 넘을 수 없다.
예전 AsyncStorage에 있던 저장은 SQLite로 옮기는 트랜잭션이 커밋된 뒤에만 지우도록 순서를 바로잡았다.
WILD 알림은 위치로 깨우다가 만보기로 돌아섰다
아침에는 위치 업데이트로 앱을 깨웠다
WILD는 그때까지 앱이 보일 때만 걸음을 셌다. 주머니에 폰을 넣고 걸으면 몬스터가 나타나도 모른다. 그래서 "WILD를 켠 채 가려져 있어도 몬스터가 나오면 바로 알림"을 목표로 잡았다.
iOS에는 가려진 앱을 실시간으로 깨워 줄 정식 수단이 거의 없다. HealthKit(애플의 건강 데이터 프레임워크)의 백그라운드 전달(새 건강 데이터가 생기면 앱을 깨워 알려 주는 기능)은 최대 1시간 간격이라 늦다. 그래서 백그라운드 위치 업데이트로 앱을 깨우고, 깨어날 때마다 만보기 기록을 조회하는 방식을 택했다. 위치는 앱을 깨우는 데만 쓰고, 좌표는 저장·전송·판정에 쓰지 않았다.
엔진에는 가려진 구간을 두 번 세지 않도록, 마지막으로 걸음을 넣은 시각(appliedTo)을 경계로 뒀다. 깨어날 때와 앱으로 돌아왔을 때 각각 걸음을 넣는 정산이 서로 다른 구간만 가져간다.
iOS는 백그라운드로 간 앱을 곧 정지시키고, 정지된 앱은 위치 업데이트를 받지 못한다. 정지되기 전에 위치 업데이트를 시작해야 했다. 그 사이에는 권한 두 개만 읽고 바로 시작하도록 경로를 줄였고, 언어·문구 읽기는 뺐다.
Android도 붙였다. Expo 센서 라이브러리 expo-sensors의 Android 만보기는 앱이 가려지면 센서 구독을 끊는다. 그래서 TYPE_STEP_COUNTER(부팅 후 누적 걸음)를 읽는 네이티브 모듈을 프로젝트 안에 만들고, 위치 포그라운드 서비스(상시 알림을 단 채 계속 실행되는 Android 서비스)로 프로세스를 살려 뒀다.
점심 무렵, 걸음 기능은 만보기로 하자고 방향을 틀었다
WILD는 걸음으로 하는 기능이다. 그러니 네 기기 모두 만보기로 구현하고, 위치는 대체할 수단이 없는 곳에만 쓴다. 쓰지 않는 권한은 앱에서 뺀다.
| 기기 | 가려진 동안 | 위치 |
|---|---|---|
| iPhone | 위치 업데이트로 깨워 만보기 기록 조회 | 필요(깨우기용) |
| Android | 건강(걸음) 유형 포그라운드 서비스가 걸음 센서 값을 계속 받는다 | 뺀다 |
| Apple Watch | 손목을 다시 들 때 만보기 기록으로 정산 | 불필요 |
| Wear OS | Android와 같은 걸음 서비스 | 불필요 |
Android는 위치 서비스를 걸음 서비스(WildStepService, foregroundServiceType="health")로 바꿨다. 게임 규칙(엔진), 엔진을 기기 기능에 잇는 코드(호스트), 누적 걸음 처리 코드는 그대로 두고 앱을 깨우는 기기별 구현만 갈아 끼웠다.
걸음 센서는 걸음마다 이벤트를 보낸다. 깨울 때마다 정산과 저장을 하므로, 깨우기를 최소 10초 간격으로 걸렀다.
// src/walking/step-wake-gate.ts
export const STEP_WAKE_MIN_MS = 10_000
export function stepWakeGate(now: () => number, ms = STEP_WAKE_MIN_MS): () => boolean {
let last = -Infinity
return () => {
const t = now()
if (t - last < ms) return false
last = t
return true
}
}타이머를 쓰지 않은 이유는 Android에서 앱이 가려지면 JS 타이머가 멈추기 때문이다. 거른 이벤트의 걸음도 버려지지 않는다. 센서 값이 누적값이라 다음 깨우기가 함께 센다.
권한을 정리하는 설정 플러그인 withPermissionAudit도 만들었다. Android 위치 권한은 LINK가 블루투스로 근처 기기를 찾을 때 Android 11 이하에서 요구하는 것 하나만 남겼다. 그것도 maxSdkVersion 30을 붙여 Android 11까지만 요청하게 했다. 위치 포그라운드 서비스, 백그라운드 위치, 기기 부팅 알림을 받는 권한, iOS가 가끔 앱을 깨워 데이터를 받게 하는 fetch 백그라운드 모드, 원격 푸시 권한, Face ID 사용 목적 문구도 뺐다. 권한마다 어느 기능이 왜 쓰는지는 docs/permissions.md 표로 남겼다.
위치로 깨우는 방식도 동작은 했다. 하지만 걸음 기능에 위치 권한을 요구하는 것은 사용자에게도 스토어 심사에도 설명하기 어렵다. 만보기로 바꾸고 권한 표를 만들자, 각 권한을 왜 쓰는지 한 줄로 말할 수 있게 됐다.
워치에서도 WILD를 켤 수 있게 됐다
워치 앱 안에서 QuickJS로 도는 게임 코드(spike/core, 워치 코어)에 WILD 기능 옵션(wildCapable)을 켰다. 걸음을 받아 엔진에 넣는 작은 연결 코드(wild-host.ts)와, Swift·Kotlin이 부르는 JS 함수 묶음 __px.wild*도 붙였다. 계산은 전부 JS에 두고, Swift·Kotlin은 누적 걸음 값과 화면 상태만 넘기게 했다.
누적값을 늘어난 걸음으로 바꾸는 함수도 폰과 같은 규칙을 쓴다. 재부팅으로 값이 줄면 새 부팅의 0부터 세고, 펫이 낮잠에 들어 탐색이 끝난 구간은 시간 비율로 걸음을 잘라 낸다.
- Apple Watch: 앱이 보이고 탐색 중일 때 iOS·watchOS의 걸음 수 API인
CMPedometer의startUpdates로 걸음을 받고, 손목을 다시 들면 가려진 구간을queryPedometerData로 조회한다. HealthKit이나 운동 세션 같은 새 권한은 쓰지 않는다. 걸음 권한을 물을 때 보이는 설명 문구(NSMotionUsageDescription)는 한국어·영어로 넣었다. - Wear OS: 폰과 같은 건강 유형 걸음 서비스에 Ongoing Activity(진행 중인 작업을 시계 화면에 표시하는 Wear OS API)를 붙여, 시계 화면과 최근 항목에 'WILD 탐색 중'이 보이게 했다.
버튼으로 WILD를 켜고 걸음을 넣어 몬스터를 만나기까지 가는 워치 골든 시나리오도 추가했다. 골든 시나리오는 정해 둔 입력을 넣고 결과를 저장해 둔 기준과 비교하는 테스트다. 기존 워치 골든 시나리오는 그대로 통과했다.
폰과 워치가 WILD 하나를 같이 쓴다
오후가 되자 질문이 하나 남았다. 폰과 워치를 둘 다 들고 WILD를 켜면 어떻게 되나.
그때까지 WILD 진행은 기기마다 따로였다. 둘 다 켜면 각자 몬스터를 만나고, 각자 싸우고, 결과 두 개가 다 들어간다. 잠긴 아이폰의 알림은 워치로 넘어오니 손목에는 알림이 두 번 온다.
처음에는 Apple Watch의 백그라운드 새로고침(watchOS가 가려진 앱을 약 15분마다 잠깐 깨워 주는 기능) 계획을 질문지로 썼다. 그런데 질문을 정리해 놓고 보니 그 앞단의 구조부터 바꾸게 됐다. 한 펫의 WILD는 폰과 워치가 하나를 같이 쓴다. 이 결정으로 워치 백그라운드 계획은 통째로 새 설계로 바뀌었다.
상태는 정해진 순서로만 바뀐다
OFF는 꺼짐, EXPLORING은 탐색 중, FOUND는 몬스터 대기, BATTLE은 대전 중, DONE은 끝남이다. 공유 WILD는 아래 순서로만 바뀌고, 어느 상태에서든 끄면 OFF로 돌아간다.
n은 회차다. 몬스터는 먼저 찾은 기기가 정한다. 종·등급·능력치·찾은 시각을 그 기기가 계산해 기록하고, 다른 기기는 받은 그대로 쓴다.
능력치까지 넣은 이유는 두 기기의 앱 버전이나 밸런스 표가 달라도 같은 몬스터여야 하기 때문이다. 받은 종을 모르는 구버전 기기는 알 그림으로 대신 그리고, 받은 능력치로 싸운다.
화면·알림·컴플리케이션(시계 화면에 올리는 작은 정보 칸)은 모두 이 상태만 보고 그린다. 일정 시간 동안 아무도 상대하지 않아 몬스터가 떠나는 전이는 기기가 남기는 조작 기록(액션) 없이 시각에서 계산한다. 감지한 기기의 시계를 쓰면 두 기기가 다른 시각을 갖게 되기 때문이다.
스냅샷을 고르는 병합은 받은 순서를 탔다
처음 구현은 두 기기가 보낸 그 순간의 상태 전체(스냅샷)를 받아 규칙으로 하나를 고르는 병합이었다. 리뷰에서 문제가 나왔다. 받은 순서에 따라 결과가 달라진다. A를 받고 B를 받은 기기와, B를 받고 A를 받은 기기가 다른 상태가 될 수 있다.
그래서 같은 날 규칙을 다시 썼다. 공유 WILD 상태는 두 기기 WILD 액션의 합집합을 시각 순서로 접은 값이다. 접기(fold)는 목록을 앞에서부터 하나씩 훑으며 값을 누적하는 것으로, 배열의 reduce와 같은 개념이다.
합집합은 받은 순서·중복·재전송과 무관하고, 접기는 같은 입력이면 늘 같은 결과를 내는 순수 함수다. 같은 액션을 아는 두 기기는 언제나 같은 상태가 된다.
아래 함수가 시각 순으로 정렬된 액션을 하나씩 적용하면서, 받아들인 대전과 잠금을 따로 모은다.
// src/game/wild/fold.ts
function foldSorted(start: WildShared, sorted: readonly Action[], now: number) {
let s = start
const accepted = new Set<string>(), acceptedLocks = new Set<string>()
for (const a of sorted) {
s = wildAdvance(s, a.at)
const k = wildResultKey(a.data)
if (a.pet !== s.pet && (isWildEvent(a.data) || k)) s = offWild(a.pet)
if (staminaOffOf(a.data) && !acceptedLocks.has(staminaOffOf(a.data)!)) continue
if (isWildEvent(a.data)) s = wildEvent(s, a) ?? s
else if (k && wildBattleOk(s, k)) { s = wildBattle(s, a); accepted.add(a.id); if (lockOf(a.data)) acceptedLocks.add(lockOf(a.data)!) }
}
return { state: wildAdvance(s, now), accepted }
}규칙은 "먼저 일어난 일이 이긴다" 하나다. 켬/끔은 값이 바뀔 때만, FOUND는 EXPLORING일 때만, RUN은 FOUND일 때만 받는다. 액션 기록을 다시 적용해 펫 상태를 만드는 재계산(replay)이 이 접기를 펫 수치와 같은 반복에서 돌리므로 화면에 보이는 승자와 기록에 남는 승자가 같다.
어떤 액션을 받아들일지는 속성 테스트(무작위 입력을 많이 만들어 항상 지켜져야 할 성질을 확인하는 테스트)로 고정했다. 두 기기가 같은 상태에 이른 뒤 예전 메시지를 다시 받아도 상태가 그대로인지 본다. 밀린 메시지를 다 받은 것과 최신 하나만 받은 것, 섞인 순서로 받은 것이 같은지도 본다. 친구 대전이 빠지지 않는지도 확인한다.
먼저 찾은 FOUND가 그 회차의 세션이 됐다
남은 경우가 있었다. 연결이 끊긴 채 두 기기가 같은 회차에서 각자 몬스터를 찾고 각자 싸웠다면 무엇이 남나. 처음 안은 "먼저 시작한 대전"이었다.
결론은 달랐다. 세션은 한 회차에 만난 몬스터 하나를 가리킨다. 그 회차에서 먼저 찾은 FOUND가 세션이 되고, 그 FOUND와 싸운 대전만 받는다.
한 기기가 찾는 순간 다른 기기는 그 세션으로 바뀌어야 한다. 그러니 나중 FOUND와 싸운 대전은 먼저 시작했어도 빠진다. 연결돼 있으면 바로, 끊겼으면 다시 만났을 때 접기가 같은 결과를 낸다.
빠진 대전은 수치·전적·스태미나도, 생애 기록의 대전 한 줄도 남기지 않는다. 세션 경쟁에서 밀린 기기의 결과는 받아들여진 쪽 결과로 바뀐다. 이 프로젝트에는 대전 기록을 자동으로 초기화하지 않는 규칙이 있다. 그래서 이것이 초기화가 아니라 한 세션을 합의한 결과로 맞추는 것이라는 설명을 AGENTS.md(AI 코딩 에이전트에게 주는 프로젝트 지침 파일)에 한 줄 더했다.
대전 잠금 하나를 모든 대전이 같이 본다
WILD만 정리해서는 부족했다. 워치가 WILD 대전 중인데 폰에서 STAGE를 시작하면 대전 두 개가 겹친다. 그래서 대전을 시작하는 다섯 곳이 모두 같은 대전 잠금 하나를 보게 했다.
- STAGE: 혼자 층을 올라가는 도전 대전
- LINK: 근처 친구와 하는 대전
- 번호 코드: 번호를 맞춰 중계 서버로 하는 친구 대전
- WILD: 걷다가 만난 야생 몬스터와의 대전
- JOGRESS: 합체
전투 상태(s.battle)를 채우는 곳도 한 함수로 모았다. 이미 대전 중이면 LCD에 BATTLING을 표시하고 거절한다. 모달은 없다.
잠금에는 그 대전을 실제로 진행하는 주인 기기가 있다. 주인 기기에서 싸우는 동안 다른 기기는 관전이다. 같은 대전을 시작 시각에 맞춰 같은 지점부터 재생하고, 위쪽에 어디서 싸우는지(PHONE/WATCH)를 작게 붙인다.
관전 중에는 메뉴·돌봄·훈련까지 모든 입력을 거절한다. 친구 대전 상대를 기다리는 짝 찾기(번호 코드 대기, 근처 LINK 대기)도 관전처럼 다른 기기를 막고, 시작부터 실제 시각 기준으로 1분 상한을 뒀다. 앱이 가려져도 이 1분은 멈추지 않는다.
거의 동시에 두 기기가 대전을 시작하면 바깥 상대(근처 LINK·번호 코드로 만난 친구 기기)가 있는 대전이 잠금을 가져간다. 그다음은 먼저 시작한 쪽이고, 그마저 같으면 폰이다. 이 순서를 비교 함수 하나에 담았다.
// src/game/battle-lock.ts
export function lockOwner<T extends LockSpan>(a: T, b: T): T {
const c = Number(!!b.external) - Number(!!a.external) || a.startedAt - b.startedAt ||
Number(a.dev !== 'phone') - Number(b.dev !== 'phone') || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)
return c <= 0 ? a : b
}바깥 상대가 있는 대전이 언제나 이기는 이유는, 친구 기기에 이미 결과가 남아서 뺄 수 없기 때문이다.
잠금도 처음에는 두 개씩 합쳤다. 그런데 셋 이상이면 순서에 따라 결과가 달라졌다. 아는 잠금 전부를 한 번에 접는 foldLocks로 바꾸자 그 문제가 없어졌다.
낮잠·스태미나·앱 종료 규칙도 함께 정했다
설계 중 남은 질문에 하나씩 답을 냈고, 답이 바로 규칙이 됐다.
- 한가하지 않으면 잠들지 않는다. WILD 켬, 대전 잠금, 관전, 짝 찾기 중에는 낮잠이 시작되지 않는다. 한가해졌을 때 아직 낮잠 시간대면 그때 잔다. 지나간 낮잠은 건너뛰고, 미룬 잠을 따로 저장하지 않는다.
- 대전 뒤 스태미나가 0이면 WILD를 끈다. 끔도 액션이라, 그 대전이 겹친 대전으로 빠지면 끔도 같이 빠진다.
- 백그라운드는 계속, 완전 종료는 끔. 앱 프로세스가 살아 있으면 계속 센다. 앱 전환 화면에서 밀어 지우거나 재부팅해서 다음 실행이 콜드 스타트(앱을 처음부터 새로 시작하는 실행)가 되면 WILD가 꺼진다.
- Apple Watch만 예외다. watchOS는 정지된 앱을 수시로 지웠다가 백그라운드 새로고침 때 다시 실행하는데, 사용자 종료와 시스템 재실행을 구분하는 API를 찾지 못했다. 콜드 스타트마다 끄면 워치 WILD는 첫 새로고침에 꺼지므로 워치만 유지한다.
이날 저녁까지 공유 WILD 상태 전이, 접기, 대전 잠금, 관전과 그 대전 재생, 잠 미루기가 들어갔다. 공유 WILD 상태와 대전 잠금을 폰·워치 저장 파일에 남기는 부분도 넣었다. 실기기 두 대로 겹치는 상황을 재현해 보지는 않았다. 확인은 두 기기의 메시지 교환을 코드로 흉내 낸 테스트와 속성 테스트까지다.
소리는 네 기기 모두 같은 규칙으로 울린다
소리 관련 규칙이 기기마다 조금씩 달랐다. 폰은 '무음' 설정이 있었고, 호출(펫이 배고픔·똥·아픔처럼 돌봄이 필요할 때 기기를 울려 부르는 것)은 "최근 24시간 안에 워치를 봤으면 워치만 울린다"는 규칙이었다. 워치에는 설정 화면이 없었다.
네 기기가 같은 규칙을 따르게 했다. '무음'을 '소리' 설정으로 바꾸고, 소리 켬이면 호출이 그 기기의 모드대로 울린다. 벨소리 모드면 소리, 진동 모드면 진동, 무음·방해 금지면 없음이다. 소리를 낼지 진동할지는 OS 설정이 정한다.
호출은 폰과 워치 둘 다 울린다. 예전 "워치만" 규칙은 지웠다.
호출을 울릴지 판단하는 함수는 설정 값 하나만 본다.
// src/sync/haptic.ts
export function ringsHere(pref: HapticPref): boolean {
return pref.on
}기기 종류와 마지막으로 워치를 본 시각까지 받던 함수가 이렇게 줄었다. 저장·통신 키 이름(haptic)은 형식을 지키려고 그대로 뒀다.
워치는 좌우로 넘기는 3페이지가 됐다
앱을 켜면 가운데 펫 페이지가 나오고, 왼쪽에 원화, 오른쪽에 설정 페이지가 있다.
원화 페이지는 지금 키우는 몬스터의 원화를 띠부씰 카드처럼 보여 준다. 원화는 빌드할 때 320px JPEG로 줄여 워치 앱에 다 넣었다. 워치 혼자 진화해도 원화가 보여야 하기 때문이다.
워치 설정에는 '소리'와 워치 전용 '알림' 항목이 있다. 알림 끔은 그 워치 안에만 저장한다.
워치 코어는 원래 엔진이 버튼음을 포함해 소리를 낼 때마다, 워치 앱이 넘겨준 진동 함수를 그 자리에서 불렀다. 이것을 호출 신호만 큐(차례로 쌓아 두는 목록)에 쌓는 방식으로 바꿨다. 워치의 Swift·Kotlin 앱이 매 갱신 주기마다 __px.cues()로 꺼내 울리고, 소리 끔이면 아예 쌓지 않는다.
약관을 넣고 iOS 27 즉시 종료를 고쳤다
- 이용약관을 추가했다. 리버스 엔지니어링·복제 금지 조항을 담았고, 문서와 앱 화면에 한국어·영어로 실었다.
- iOS 27 SDK로 빌드한 앱이 실행 즉시 종료되던 문제를 UIScene 수명주기(화면 단위로 앱 상태를 관리하는 iOS 방식)를 적용해 고쳤다.
- 버튼을 누르는 동안에는 2초마다 하는 자동 저장을 미뤄, 연타 중 화면이 밀리지 않게 했다.
- 워치가 폰 펫을 처음 받기 전에는 임시 알 대신
SYNC대기 화면을 보이게 했다. - 아주 오래된 저장을 복원할 때도 훈련 성과가 훈련 수보다 작아지지 않게 맞췄다.
- 관전 표시가 전투 화면의 라운드·제목·HP 바와 겹치던 것을 빈 자리로 옮기고, 픽셀 상자 겹침 테스트를 넣었다.
- 번호 코드 중계 서버가 기기 쪽 짝 찾기 마감을 받아, 두 기기를 잇는 대기방의 마감을 그만큼 당기게 했다. 마감 안에 서버가 대전 성사(
go)로 정했으면 기기가 늦게 물어도 그대로 성사다. - 개인정보처리방침·가이드·README·스토어 심사 노트를 기기별 내용으로 고쳤다.
상태를 고르지 않고 액션을 접었다
폰·워치 공유 WILD도 상태 전체 대신 조작 기록(액션)을 주고받는다는, 이 프로젝트가 처음부터 써 온 원칙을 그대로 따랐다. 워치 백그라운드의 세부 질문을 구체적으로 적어 두자, 답은 세부가 아니라 구조 수준에서 나왔다.