본문으로 건너뛰기

dotgimon 제작기 22. 폰·워치 연결과 LCD 안의 통신 화면

·20 min read·22 / 23

dotgimon에서 폰과 워치가 같은 펫을 키우려면 오프라인에서 한 행동도 다시 합쳐져야 했다. 행동 로그를 같은 엔진으로 재계산하는 코어는 있었지만, 두 기기가 어디까지 같은 상태인지 정하고 실제 메시지를 주고받는 부분이 필요했다.

통신 화면도 폰의 모달에 붙어 있었다. 워치에서도 같은 흐름을 쓰려고 대전과 리셋 질문을 LCD 안으로 옮겼다. 그 과정에서 SOLO와 LINK의 연출을 합치고, 진화해도 별로 커 보이지 않던 몬스터 그림까지 손봤다.

기준 상태는 폰 한 대만 만들었다

폰과 워치가 따로 진행하다 만나면 행동 로그를 합쳐 재계산한다. 문제는 어느 시점까지 계산한 결과를 새 기준으로 삼느냐였다. 양쪽이 각자 체크포인트를 만들면 기준 상태부터 달라질 수 있었다.

체크포인트는 폰만 만들게 했다. 대신 폰이 가진 현재 시각까지 무조건 계산하지 않았다. 워치 메시지의 sentAt, 곧 워치가 메시지를 보낸 시각을 기준으로 삼았다. 워치가 확인받지 못한 행동을 모두 보내므로 그 시각까지의 행동을 합칠 수 있다는 전제였다.

너무 미래의 행동은 받지 않았다. 폰 시계보다 5분 넘게 앞선 워치 기록은 버리고, 버렸다는 사실도 워치에 알려 같은 로그를 계속 보내지 않게 했다.

체크포인트에는 번호와 시각, 게임 상태 외에 이미 합친 행동 id도 넣었다.

export interface Checkpoint {
  seq: number
  at: number
  state: SavedState
  includes: string[]
}

includes는 워치가 확인을 받기 전에 같은 행동을 다시 보냈을 때 필요했다. 이미 합친 id를 기억해 두면 중복해서 먹이를 주거나 대전 결과를 더하지 않는다. 번호도 상대가 아는 값보다 뒤에서 시작하게 했다.

폰을 다시 설치해 기준이 사라졌는데 워치에는 동기화된 펫이 남은 경우도 다뤘다. 이때는 워치 펫을 이어받았다. 반대로 폰에만 기준이 있으면 폰 상태를 사용했다.

동기화 정보도 펫과 함께 저장했다

처음 계획은 SQLite에 행동과 체크포인트용 테이블을 따로 만드는 것이었다. 구현에서는 펫 저장 단위인 SaveBlob 안에 sync를 함께 넣었다. 게임 상태와 동기화 상태를 한 번에 저장하고 싶었기 때문이다.

동기화 부분이 손상돼도 펫 전체를 못 읽게 하지는 않았다. 읽을 수 없는 동기화 정보만 버리고 펫은 유지했다.

메뉴나 훈련, 대전을 보는 중에 상대 상태를 적용하면 화면이 갑자기 바뀔 수 있었다. 메시지는 보관했다가 MAIN으로 돌아올 때 적용하도록 미뤘다.

생애 이력의 중복 키도 나눴다. 부화·진화·사망처럼 시간이 지나 생기는 사건은 프레임 진행과 60초 재계산에서 시각이 조금 달라질 수 있어 키에서 시각을 뺐다. 승패나 리셋처럼 행동으로 생기는 사건에는 시각을 넣어 서로 다른 두 사건이 하나로 합쳐지지 않게 했다.

도감과 사용한 링크 세션 기록은 양쪽 합집합으로 합쳤다. 가상 폰·워치 테스트에서는 오프라인 진행, 중복과 늦은 메시지, 리셋 충돌, 폰 재설치, 손상 저장을 다뤘다. 순수 함수의 결과와 실제 네이티브 전달은 별도 확인 범위로 나눴다.

워치가 먼저 보내야 연결이 시작됐다

폰은 워치 메시지를 받아야 새 체크포인트를 만들 수 있었다. 아직 페어링되지 않은 폰이 먼저 상태를 보내는 것만으로는 흐름이 시작되지 않았다. 워치가 연결되면 먼저 보내고 폰이 답하게 했다.

주기도 달리 뒀다. 워치는 30초마다, 폰은 페어링됐을 때 15초마다 보냈다. 메시지가 확인받지 않은 행동을 다시 포함하므로 재시도 큐에는 최신 메시지 하나만 남겼다.

Apple 쪽은 즉시 메시지와 백그라운드 전송을 나눴다

watchOS 앱에는 QuickJS 호스트와 LCD를 옮기고, 폰에는 Expo 모듈 dotgimon-watch-sync를 만들었다. 연결돼 있을 때는 sendMessage, 그렇지 않을 때는 transferUserInfo로 전송을 요청했다.

빌드에서는 플러그인 순서에 걸렸다. 워치 브리징 헤더 같은 설정을 넣는 플러그인을 apple-targets 앞에 두어야 원하는 설정이 만들어졌다. Nearby용 스크립트가 같은 application 타입의 watch 타깃에도 SPM 설정을 넣는 문제는 watchOS를 제외해 고쳤다.

개발용 테스트 펫도 동기화와 충돌했다. 이 기능은 행동 로그 없이 상태를 바꾸므로 재계산을 하면 로그에 있던 새 알로 돌아갔다. 테스트 펫을 넣은 직후 체크포인트도 현재 상태로 다시 잡도록 rebaseToCurrent()를 연결했다.

아이폰과 애플워치 시뮬레이터 페어에서 저장 파일과 SQLite를 비교했다. 워치가 폰 펫을 채택하고, 양쪽에서 준 먹이가 상대에 적용되는 것을 확인했다. 워치 쪽 먹이 입력은 버튼 자동 조작 대신 디버그 실행 인자로 일으켰다.

실기기와 앱을 완전히 종료한 상태의 전달은 확인하지 못했다. Apple 문서는 transferUserInfo를 시뮬레이터에서 지원하지 않는다고 명시한다. 시뮬레이터에서 본 왕복을 백그라운드 큐 전체의 검증으로 보지는 않았다.

Wear는 빌드와 실행까지 확인했다

Wear 앱은 Gradle 모듈로 추가했다. Data Layer는 폰과 워치의 패키지명과 서명이 같아야 통신한다. 워치도 com.dotgimon.app을 applicationId로 쓰고, 소스 namespace만 별도로 뒀다.

메시지는 MessageClient, 마지막 데이터 동기화는 DataClient에 맡겼다. 각 기기가 자기 경로에 최근 메시지를 남기고 앱이 켜질 때 상대 경로를 읽게 했다. 같은 내용도 변경으로 전달되도록 시각 값을 함께 넣었다.

JS에는 iOS와 같은 onSync, send API를 노출했다. 게임 세션에서 플랫폼별 전송 방법을 알 필요는 없었다.

폰과 Wear 앱 빌드는 성공했고 APK 안의 코어와 스프라이트, 네이티브 라이브러리를 확인했다. Wear 에뮬레이터는 실행됐지만 페어링된 노드가 없었다. 폰과 Wear 사이의 실제 메시지 전달은 이 작업에서 확인하지 못했다.

통신 선택부터 결과까지 LCD에서 끝냈다

대전·합체·번호 코드·리셋 질문이 시스템 창이나 폰 모달로 뜨면 LCD 게임기 모양에서 벗어났다. 워치에도 같은 화면 흐름을 쓰려면 엔진이 상태를 알고 그리는 편이 맞았다.

번호 코드는 호스트와 게스트를 고르는 대신 상호 입력으로 바꿨다. 양쪽이 MY CODE를 띄우고 INPUT에 상대 코드를 넣었다. 작은 코드 쪽을 호스트로 정하면 두 기기에서 같은 순서를 만들 수 있었다.

if (mine === theirs) return { ok: false, error: 'SELF' }
const amHost = mine < theirs
const [hostCode, guestCode] = amHost
  ? [mine, theirs]
  : [theirs, mine]
const s = fnv1a(`${hostCode}${guestCode}`).toString(36)

호스트 코드에 묶인 별도 응답은 없앴다. 양쪽이 같은 코드 쌍으로 세션을 계산했고, 폰은 버튼 자리에 키패드를, 워치는 화면을 덮는 키패드를 보여 줬다.

자기 폰과 자기 워치가 대전하는 경우도 막았다. 코드를 보여 주거나 입력하는 동안 내 코드를 동기화 메시지에 실었다. 상대가 기억한 코드와 같으면 SAME PET으로 거부했다. 사용한 세션은 폰의 저장소와 워치의 SaveBlob.linkLog로 확인했다.

근처 연결은 상태 머신으로 나눴다

Nearby 연결은 검색, 연결 요청, 제안 교환, 결과 확인이 비동기로 이어졌다. 두 기기가 서로를 찾으니 동시에 연결을 거는 경우도 있었다. 상태와 전이를 명시하려고 XState를 넣었다.

이름 토큰이 작은 쪽만 연결을 걸게 했다. 완료·실패·닫기 상태에서는 네이티브 검색과 연결을 중지했다. 오류와 닫기 입력도 어느 상태에서든 받을 수 있게 뒀다.

CONNECT 대기 시간은 20초로 늘렸다. 두 몬스터를 보여 주는 LINK! 화면 뒤에 대전이나 합체를 재생했다. 별도 동의 버튼은 없애고 이 연결 흐름으로 들어오는 것을 동의로 취급했다.

iOS에서는 검색을 중지할 때 앱이 종료되는 문제도 있었다. 당시에는 BoringSSL의 sdallocx 심볼 연결을 의심했고 앱 타깃에 같은 이름의 정의를 넣어 우회했다. 시뮬레이터에서는 CONNECT에서 실패로 끝나는 경로까지만 확인했다. 기기 두 대의 실제 근처 연결은 확인 범위 밖이었다.

사망과 리셋도 게임 안의 질문이 됐다

사망 후 기록을 저장할지 묻는 화면을 LCD의 SAVE LOG로 바꿨다. YES를 고르면 SAVED, NO면 BYE 연출을 보여 준 뒤 새 알로 넘어갔다.

리셋은 설정 창을 닫고 LCD에서 기록 저장을 물은 뒤 한 번 더 RESET?을 보여 줬다. 실수로 지우지 않도록 확인 단계의 기본 선택은 NO였다.

기록을 남기지 않기로 한 펫은 삭제 목록에도 넣었다. 늦게 도착한 생애 이벤트 때문에 지운 이력이 다시 나타나지 않게 하려는 처리였다. 저장 오류는 재시도하고, 설정 저장 오류는 시스템 Alert 대신 설정 안의 SAVE ERR로 표시했다.

SOLO를 LINK와 같은 대전 화면으로 맞췄다

기존 SOLO는 게이지와 공격·회피를 직접 누르는 대전이었다. LINK와 규칙과 연출을 따로 유지해야 했다. SOLO를 자동으로 진행하는 도전 사다리로 바꾸고 LINK와 같은 화면에서 재생하게 했다.

모든 성장 단계는 S1에서 시작했다. 결과는 도전 기록만 바꾸고 진화 조건이나 패배 페널티에는 넣지 않았다. 상대 목록도 도감 순서에서 그때그때 뽑지 않고 고정했다. 종이 추가돼도 기존 단계가 밀리지 않게 하기 위해서였다.

능력치는 기준 단계 사이에서 배율을 이어 주는 로그 보간으로 계산하고, 120단계 이후에는 제곱근 식으로 천천히 높였다. 단계 차이로 주는 회피 보정은 SOLO에만 넣었다. 규칙이 달라져 도전 규칙 버전도 올렸다.

몬스터 배치는 그림의 실제 경계를 기준으로 맞췄다. 왼쪽과 오른쪽 가장자리에 같은 간격을 두고, 대전 투사체가 빠르다는 요청에 맞춰 이동 시간을 늘렸다.

항목이전변경
장면 전환1,200ms2,040ms
공격 도착·피해 반영2,200ms3,400ms
공격 턴 간격5,200ms6,400ms
결과 표시3,000ms3,000ms

훈련 시간표는 유지했다. 투사체는 13칸으로 크기를 맞추고 닮은 그림을 같은 계열로 정리했다. 테스트는 시간과 위치의 연속성을 확인했지만, 실기기에서 보이는 속도까지 확인한 것은 아니었다.

진화 단계보다 자세 폭이 크기를 정하고 있었다

진화해도 몬스터가 별로 커 보이지 않았다. 행동 스프라이트를 가져오는 스크립트에 단계별 크기 규칙이 없어서 유년기와 완전체의 그림 크기가 비슷했다. 자세별 이미지를 고정 폭으로 자르다 보니 꼬리가 잘리거나 옆 자세가 섞인 경우도 있었다.

긴 변만 기준으로 줄이면 납작한 종과 키 큰 종의 덩치가 달라 보였다. 대기 자세의 가로와 세로를 곱한 값의 제곱근을 기준으로 삼았다. 단계별 목표는 16·19·22·25·28·30칸으로 뒀다.

export const STAGE_SIZE = [16, 19, 22, 25, 28, 30]
export const stageArea = stage => STAGE_SIZE[stage] * .93

0.93은 기존 그림에서 면적 기준 길이와 긴 변의 비율을 맞추기 위해 정한 값이었다. 자세 하나가 넓다고 종 전체를 지나치게 줄이지 않도록 손으로 다시 그린 자세는 크기 계산에서 제외했다.

흰 배와 무늬도 몸 테두리 안에 남게 검은 외곽선을 닫았다. 자세는 고정 칸만으로 자르지 않고 가까운 획을 한 몸으로 묶어 순서대로 배정했다. 맞닿은 자세는 좁은 세로줄에서 나눴다.

87종을 다시 가져오고, 한 줄에 자세가 일곱 개뿐인 아틀라스는 여덟 칸에 맞춰 다시 만들었다. 넓은 자세 때문에 작아지던 종과 완전체보다 작던 궁극체도 따로 손봤다. 기기 화면에서의 최종 시각 검수까지 마친 작업은 아니었다.

같은 작업 트리에서 다른 변경이 섞였다

LCD 밖의 설정·도감·기록 화면 글꼴은 Galmuri11로 바꿨다. 히스토리 카드 펼침에는 아래 카드 이동과 상세 페이드, 화살표 회전을 붙였다. DEX와 HISTORY의 제목·날짜도 번역 키로 옮겼다.

여러 작업이 같은 트리에서 진행되다 보니 다른 세션이 스테이징해 둔 폰트 삭제가 내 커밋에 섞였다. 치료와 청소의 미완성 변경도 함께 들어가 테스트가 깨졌다. 해당 변경을 되돌리고 의도했던 돌봄 감정 표시만 남겼다.

골든 파일도 미커밋 변경이 섞인 코드로 만들어져 기준과 맞지 않았다. 저장소에 기록한 코드 상태에서 다시 생성했다. 그 뒤에는 내가 바꾼 경로를 지정해 커밋했다.

가상 기기 사이의 계산은 확인됐지만 실제 전송은 플랫폼마다 확인한 범위가 달랐다. Apple 쪽 시뮬레이터 왕복, Wear 빌드와 실행, Nearby 실패 화면을 한꺼번에 “연결 완료”로 묶지는 않았다.