본문으로 건너뛰기

dotgimon 제작기 26. 고기·비타민 분리와 번호 코드 대전 중계 서버

·32 min read·26 / 32

DOTGIMON은 Expo/React Native로 만드는 가상 펫 게임이다. 휴대용 액정 장난감처럼 작은 LCD 화면 안에 도트 그림으로 펫을 키운다. 폰 앱이 있고, 같은 게임 엔진을 QuickJS(C로 작성된 작은 JavaScript 엔진)로 돌리는 애플워치·Wear OS(구글의 스마트워치 운영체제) 앱이 있다.

이날은 손댄 곳이 넓었다. 육성 쪽에서는 고기와 비타민의 역할을 나누고 방치 규칙을 다시 짰다. 테스터의 애플워치 SE 3에서만 엔진이 켜지지 않던 원인을 찾았고, 서로의 8자리 코드를 입력해 겨루는 번호 코드 대전에는 중계 서버를 붙였다. 소리와 버튼 입력을 정리하고, 걸으며 야생 몬스터를 만나는 WILD 모드의 설계서도 썼다.

훈련 한 번이 밥과 비타민을 같이 먹고 있었다

허기는 고기(MEAT)로, 근력은 비타민(VITA)으로 채우는 게이지다. 훈련(게이지를 멈춰 맞히는 3라운드 미니게임) 한 세트를 끝까지 하면 허기가 1 줄었고, 시작할 때는 성장 단계(유년기·성장기·성숙기·완전체 등)별로 정해진 만큼 근력을 먼저 냈다. 훈련 한 번이 두 자원을 같이 썼다.

그래서 고기는 무엇에 쓰고 비타민은 무엇에 쓰는지가 흐렸다. 훈련을 많이 하면 밥을 자주 줘야 했고, 그게 훈련 비용처럼 느껴졌다.

설계서(2026-10-13-train-food-resource-split-design.md)에서 자원마다 줄어드는 이유를 하나씩 정했다.

자원줄어드는 이유채우기
허기 (MEAT)시간만MEAT
근력 (VITA)시간 + 훈련 피로VITA
  • 훈련은 허기를 건드리지 않는다. 고기와 훈련은 체중으로만 이어진다. 고기는 체중을 올리고 훈련은 내린다.
  • 허기는 단계마다 줄어드는 주기가 다르다. 몸이 클수록 빨리 배고프다.
  • 큰 단계에는 화면에 보이지 않는 허기 여유분이 있다. 허기 칸은 4칸만 그리고, 여유분은 표시하지 않는다.
  • 근력이 시간에 따라 줄어드는 주기는 90분에서 3시간으로 늘렸다.
  • 훈련 시작 비용을 없애고 숨은 피로를 뒀다. 세트를 끝낼 때마다 피로가 쌓이고, 단계별 한도에 닿으면 근력이 1 줄고 피로는 0이 된다. 시작 조건은 근력 1 이상이다.

피로는 세트를 끝낼 때만 쌓이게 했다

피로는 세트 완주 때만 바뀐다. 그래서 기기의 세 버튼(A 이동·B 확정·C 종료) 중 C를 눌러 취소하면 피로도 근력도 그대로다. 취소할 때 되돌아갈 값으로 훈련 시작 시점에 찍어 두는 스냅샷(TrainSnapshot)에 피로를 넣을 필요가 없었다.

세트를 완주했을 때 체중을 내리고 피로를 쌓는 부분이다. 한도에 닿으면 근력을 1 깎고 피로를 비운다.

// src/game/engine/commit-train.ts (세트 완주)
s.lifeTrain++
s.weight = Math.max(MIN_WEIGHT, s.weight - TRAIN_WEIGHT_LOSS)
s.fatigue++
if (s.fatigue >= (TRAIN_FATIGUE_LIMIT[this.stageIdx()] ?? 1)) {
  s.strength = Math.max(0, s.strength - 1)
  s.fatigue = 0
}

진화와 조그레스(두 펫이 합체해 진화하는 것), 새 알에서는 피로를 0으로 되돌렸다. 한도가 단계마다 다르기 때문이다. 기록을 다시 돌려 상태를 계산하는 재계산(replay) 쪽은 훈련 시작 기록(TRAIN_START 액션)을 근력이 1 미만일 때(strength < 1)만 거부하게 바꿨다. 이 기록의 cost(시작 비용) 필드는 옛 기록과 맞추려고 남겨 뒀고, 값은 항상 0으로 채웠다.

다른 기기와 겨루는 통신 대전으로 보내는 허기 값은 4를 넘지 않게(min(hunger, 4)) 잘랐다. 받는 쪽 검사가 4 이하만 받아서, 여유분이 찬 펫을 그대로 보내면 거부된다. 두 기기가 주고받는 데이터 형식은 바꾸지 않았다.

상한을 바꾼 곳과 검사하는 곳이 따로 있었다

저녁에 문제가 나왔다. 여유분까지 먹인 펫(허기 5·6)을 저장하고 다시 켜면 오류 화면만 떴다. 저장 파일을 불러올 때 hunger > 4를 손상으로 보고 거부했기 때문이다. 폰·워치 동기화 때 기준으로 삼는 저장 상태(체크포인트)도 같은 검사를 거쳐서 버려졌다.

엔진의 상한은 바꿨지만, 저장 파일을 읽는 저장소 모델(src/storage/model.ts)의 검사는 옛 상한 4를 그대로 쓰고 있었다. 같은 값을 검사하는 곳이 두 군데였다. 여유분 최대치를 넘을 때만 손상으로 보고, 종의 상한을 넘은 값은 상한으로 맞추게 고쳤다. 저장소 모델 테스트에는 허기 5·6 저장값을 추가했다.

자는 동안의 똥 하나가 케어 실수를 여러 개 만들었다

케어 실수(돌봄 실수)는 똥·배고픔·병을 오래 두면 쌓이고, 다음에 어떤 종으로 진화할지(진화 갈래)를 가른다. 그런데 세는 방식이 펫이 자는 시간까지 포함했고, 같은 원인이 이어지면 여러 번 쌓였다. 밤새 자는 동안 똥 하나 때문에 케어 실수가 몇 개씩 생길 수 있었다.

  • 방치 시간은 깨어 있는 시간으로 센다. 자는 동안 멈췄다가 일어나면 이어서 흐른다.
  • 원인은 똥 여러 개, 허기 0, 병 세 가지다. 원인마다 정해진 시간을 넘기면 한 번만 센다. 원인이 풀렸다가 다시 생겨야 다시 센다. 똥 하나는 돌봐 달라는 호출(CALL) 알림만 띄운다.
  • 똥이 많이 쌓여도 바로 병에 걸리지 않고, 일정 시간 이어져야 병이 난다. 치료하면 이 시간도 처음부터다.
  • 저장 필드는 원인이 시작된 시각을 담던 Since 필드 대신, 깨어 있는 동안 쌓은 시간을 담는 hungerZeroMs 같은 Ms 필드로 둔다. 옛 저장은 마지막 정산(꺼져 있던 시간을 한꺼번에 계산한 시점)까지 흐른 시간으로 옮긴다.

이 규칙은 쌓기와 판정 두 함수로 나눴다. 쌓는 쪽은 직전 틱(게임이 상태를 한 번 갱신하는 단위)의 상태만 보고, 자는 동안에는 아무것도 쌓지 않고 바로 빠져나온다.

// src/game/engine/update-neglect.ts
/** 직전 틱의 상태로 지난 구간의 방치 시간을 쌓는다. 자는 동안은 흐르지 않는다 (기상 후 이어서) */
export function accrueNeglect(this: Game, elapsed: number) {
  const s = this.s
  if (s.sleeping || elapsed <= 0) return
  if (s.hunger === 0) s.hungerZeroMs += elapsed
  if (s.poop >= CARE_MISS_POOP) s.dirtyMs += elapsed
  if (s.poop >= 3) s.filthyMs += elapsed
  if (s.sick) s.sickMs += elapsed
  if (s.sick && s.hunger === 0) s.deathNeglectMs += elapsed
}

판정(judgeNeglect)은 풀린 원인의 누적을 0으로 돌리고, 넘긴 문턱을 발병·케어 실수·사망으로 확정한다. 이미 센 원인은 careMissed 목록에 들어 있어서 다시 세지 않는다.

실수가 덜 쌓이니 진화 문턱도 내려야 했다

케어 실수가 원인별 한 번이 되니, 완전히 방치해도 한 단계에 쌓이는 수가 적어졌다. 기존 진화 문턱 그대로면 방치한 펫이 좋은 갈래로 간다. 그래서 "양호 / 방치"를 가르는 문턱을 낮췄다.

같은 날 육성 규칙 몇 가지도 손봤다.

  • 체형 고정: 체형(체중에 따라 정해지는 몸집)은 진화 직전 체중으로 정하고 다음 진화까지 고정한다. 이렇게 하면 대전 직전에 과식이나 훈련으로 체형을 바꾸는 요령이 막힌다. 지금 체중으로 진화하면 체형이 달라지는 경우, 상태 화면에 >다음 표시가 1초마다 번갈아 뜬다. 이 표시 때문에 LCD 폰트에 > 글자를 새로 넣었다.
  • 체형 보너스: 체형이 무거우면 공격력이 오르는 공격형, 가벼우면 회피율이 오르는 회피형, 그 사이면 밸런스형이다. 이 체형 보너스를 줄였다.
  • 훈련 체중: 라운드 결과에 따라 빠지던 체중을 세트 완주 단위로 바꿨다. 취소하거나 끝까지 하지 않으면 빠지지 않는다.
  • 훈련 공격 보너스: 세트 수와 MEGA HIT(훈련 게이지를 맨 위 15% 구간에서 멈춘 최고 판정) 횟수가 반씩 나눠 맡는다. 능력치로 쌓이는 세트는 하루 정해진 수까지다. 기기 날짜 기준이라 시계를 되돌려도 한도가 새로 생기지 않는다.
  • 걷기 보너스: 단계마다 달랐던 걷기 공격 보너스를 모든 단계 같은 값으로 맞췄다.

SE 3 한 대에서만 LCD가 뜨지 않았다

테스터의 애플워치 SE 3에서만 앱을 켜면 흰 벽돌 무늬 배경만 나오고 게임 화면(LCD)이 뜨지 않았다. 시뮬레이터와 다른 실기기는 멀쩡했다. 테스터 기기라 로그를 받을 수 없었다.

로그 대신 사진 한 장을 받았다

실패를 화면에 보이게 하는 것부터 했다. 엔진 시작이 실패하면 LCD 자리에 실패한 단계, 오류 문구, watchOS 버전·기종·빌드 번호를 띄운다(StartError.swift). 단계는 게임 코드 번들 파일 찾기·읽기, QuickJS를 띄우고 코드를 읽어 들이는 px_create, 게임 초기화 init으로 나뉜다. 테스터는 사진 한 장만 보내면 된다.

받은 화면에는 px_create 단계에서 SyntaxError: invalid UTF-8 sequence가 찍혀 있었다. 같은 core.js인데 기기에 따라 문법 오류가 났다.

문자열 끝의 0 바이트 하나가 없었다

QuickJS의 JS_Eval(JS 소스 문자열을 받아 실행하는 함수)은 길이를 인자로 받지만, 길이 바로 다음 칸(input[len])이 '\0'이어야 한다. C 문자열은 끝을 0 바이트로 표시하는데, QuickJS의 파서(소스 문법을 읽는 부분)도 끝에 이 0 바이트가 있다고 보고 읽는다.

Swift의 Data는 JS의 Uint8Array처럼 바이트만 담는 버퍼라 끝에 0이 없다. 파서는 파일 끝을 지나 그 뒤 메모리까지 읽었다. 그 바이트가 우연히 0이면 멀쩡하고, 아니면 UTF-8 오류가 났다. 그래서 기기마다 결과가 달랐다.

호스트(워치 앱 안에서 QuickJS를 띄우고 게임 코드를 호출하는 C 코드 pxhost.c) 쪽에서 길이보다 한 칸 큰 버퍼를 잡고 끝에 0을 붙인 뒤 넘기게 고쳤다.

// spike/host/pxhost.c
// JS_Eval 은 input[len] == '\0' 을 요구한다. 호스트가 넘기는 버퍼(Swift Data 등)는 끝에 0 이 없어
// 파서가 파일 뒤 메모리까지 읽는다 — 그 바이트에 따라 기기마다 SyntaxError(invalid UTF-8)가 난다 (SE 3 실기기)
char *code = malloc(len + 1);
// (메모리 부족 처리 생략)
memcpy(code, src, len);
code[len] = '\0';
JSValue r = JS_Eval(h->ctx, code, len, "core.js", JS_EVAL_TYPE_GLOBAL);
free(code);

Wear OS도 같은 pxhost.c를 쓰므로 함께 고쳐졌다.

번호 코드 대전은 상대를 기다리지 않고 시작하고 있었다

번호 코드(CODE) 대전은 서로의 8자리 코드를 입력하면 각 기기가 정해진 규칙으로 따로 계산해 같은 결과를 얻는 방식이다. 두 기기가 서로 신호를 주고받을 길이 없었다. 그래서 상대 코드를 다 치는 순간 내 기기만 대전을 시작했다. 상대는 아직 내 코드를 치는 중일 수도 있었다.

근처 통신(LINK)은 연결이 되면 LINK!를 1.5초 보여 주고 시작했다. 두 방식의 흐름이 달랐다.

LINK와 CODE를 같은 흐름으로 맞췄다

설계서(2026-10-13-code-link-ready-relay-design.md)에서는 두 방식 모두 상대를 기다리는 READY 화면을 거쳐 연결 화면인 CONNECT!를 보여 주고, 대전이나 조그레스로 넘어가게 했다.

DUAL은 대전 메뉴에서 통신 대전을 고르는 항목이다. 근처 기기를 찾는 쪽이 LINK, 내 코드를 보여 주는 MY CODE와 상대 코드를 치는 INPUT을 거치는 쪽이 CODE다.

  • 대기 화면 글자는 READY, 연결 화면은 CONNECT!다. 두 몬스터가 마주 보는 연출은 그대로 뒀다.
  • CODE는 양쪽이 모두 입력했다는 것이 확인돼야 CONNECT!가 뜬다. 이를 위해 데이터를 저장하지 않는 중계 서버를 뒀다.
  • 상대를 기다리는 모든 상태를 하나의 "대기"로 묶었다. 대기가 시간 초과·상대 없음·네트워크 오류로 끝나면 실패 문구 없이 홈으로 간다. NO PARTNER·NO NET 같은 화면은 없앴다.
  • 코드 형식이나 자격 검사(BAD CODE, SAME PET 등)는 서버에 가기 전에 끝낸다. 서버에 갈 필요가 없는 실패에 서버를 부르지 않는다.

워치에서도 되는 요청은 long-poll뿐이었다

서버는 Cloudflare Workers와 Durable Objects로 만들었다. Workers는 Cloudflare 서버에서 요청마다 함수를 실행하는 서비스고, Durable Objects는 이름 하나당 인스턴스가 하나만 떠서 상태를 들고 있을 수 있는 Workers 기능이다. 두 기기가 만나는 방 하나가 Durable Object 하나다.

두 기기는 서로의 코드를 알게 되면 같은 방 이름을 계산할 수 있다. 정렬한 두 코드와 모드로 16자리 16진수를 만든다. 워치의 QuickJS에는 암호화 API(crypto)가 없어서 이미 있던 fnv1a 해시(빠르고 단순한 비암호용 해시)를 다시 썼다. 프로필 ID나 몬스터 정보는 보내지 않는다.

애플워치는 일반 앱에 WebSocket 같은 저수준 네트워킹을 허용하지 않는다. 그래서 폰·워치 모두 일반 HTTPS 요청 하나로 기다리는 long-poll(서버가 응답을 바로 주지 않고 상대가 올 때까지 붙잡아 두는 요청)을 썼다. 서버는 최대 18초 기다린다. 기기 제한시간(20초)보다 짧게 둬서 서버가 먼저 timeout을 알려 준다.

방 하나의 상태는 메모리에만 둔다. 방 로직은 Workers 실행 환경에 기대지 않는 순수 TypeScript 모듈로 떼어 내고, vitest(Vite 기반 테스트 러너)로 검사했다. 아래는 한 기기가 기다리기 시작할 때의 처리다. 상대가 이미 기다리고 있으면 둘 다 matched로 끝내고, 없으면 제한시간까지 대기를 걸어 둔다.

// server/relay/src/room-logic.ts
/** ticket: 요청마다 기기가 만든 임의 문자열 — 늦게 온 cancel 이 새 대기를 지우지 않게 한다 */
wait(role: Role, ticket: string): Promise<WaitStatus> {
  this.take(role)?.resolve('conflict') // 같은 역할의 새 요청이 앞의 대기를 대체한다
  const partner = this.take(other(role))
  if (partner) { partner.resolve('matched'); return Promise.resolve('matched') }
  return new Promise(resolve => {
    const timer = this.timers.set(() => { if (this.waiters[role]?.resolve === resolve) delete this.waiters[role]; resolve('timeout') }, SERVER_WAIT_MS)
    this.waiters[role] = { resolve, timer, ticket }
  })
}

역할은 두 코드를 비교해 H와 G 중 하나로 정한다. 같은 역할이 다시 기다리면 새 요청이 앞의 대기를 대신하고, 앞의 요청은 conflict로 끝난다. 취소(cancel)는 요청마다 기기가 만드는 임의 문자열(ticket)이 같을 때만 대기를 지운다. C를 눌러 취소한 요청이 늦게 도착해, 다시 입력한 새 대기를 지우는 일을 막는다.

상태 머신에는 타이머를 넣지 않았다

앱 쪽 상태는 두 층으로 나눴다. 엔진의 통신 화면 상태(LinkFlow.step)는 LCD에 무엇을 그릴지만 안다(READY·CONNECT!·FAIL). XState(상태와 전이를 선언해 두는 상태 머신 라이브러리)로 만든 codeRelayMachine은 서버 요청이 어디까지 왔는지만 안다. 둘은 콜백과 이벤트로만 이어진다. 근처 통신의 linkMachine과 같은 구조다.

중계 머신 안에는 after 지연 전이(정해진 시간이 지나면 다음 상태로 넘어가는 XState 문법)도, fromPromise(Promise를 머신 안의 작업으로 감싸는 XState 함수)도 쓰지 않았다. 워치의 QuickJS 호스트에는 setTimeout이 없고, Promise 작업 큐를 언제 돌릴지도 호스트에 달려 있기 때문이다. 모든 전이가 바깥 이벤트로만 일어나니 폰과 워치에서 똑같이 돈다. 20초 제한은 기존처럼 엔진이 시계(now)로 판단한다.

XState에서 final 상태(머신이 끝났음을 뜻하는 상태)에 들어간 뒤에 오는 이벤트는 무시된다. 그래서 C로 취소한 뒤 늦게 도착한 matched가 대전을 시작하는 일이 구조로 막힌다. 만남을 엔진에 반영하는 codeMatched 함수도 READY 상태일 때만 동작해서 한 번 더 막는다.

전송은 기기마다 다르게 붙였다.

  • 폰: fetch + AbortController(진행 중인 fetch를 취소하는 브라우저 API).
  • 워치: 엔진이 큐에 wait·cancel 요청을 쌓고, 호스트가 매 틱 게임 코드의 함수 묶음 __px에서 relayOut()으로 꺼내 HTTPS 요청을 보낸 뒤 결과를 relayIn()으로 돌려준다. 애플워치는 URLSession, Wear OS는 백그라운드 스레드의 HttpURLConnection이다. 둘 다 각 플랫폼의 기본 HTTP 클라이언트다. 응답은 ticket이 맞을 때만 엔진에 넘긴다.

워치 번들에 xstate가 들어가야 해서, node_modules를 번들에 넣지 못하게 막던 검사에 xstate만 예외로 두었다. 호스트가 넣어 주는 진동 함수(__pxHaptic) 하나만 있는 빈 전역 환경을 Node의 vm 모듈로 만들었다. 실제 QuickJS 호스트와 같은 조건이다. 그 안에서 번들을 워치 두 대처럼 돌리는 테스트도 붙였다.

연출을 보고 끄면 패배를 피할 수 있었다

이 작업을 하면서 하나 더 고쳤다. 통신 대전의 일반 전적(승·패 수)은 대전 애니메이션이 끝날 때 반영됐다. 질 것 같은 연출을 보고 C를 누르거나 앱을 끄면 패배를 피할 수 있었다.

이제 CONNECT!에서 결과를 저장하는 순간 듀얼 전적(통신 대전만 따로 모은 기록), 일반 전적, 근력, 체중, 패배 시 발병까지 한 번에 확정한다. 통신 대전·조그레스 연출 중에는 C를 받지 않는다. 워치는 저장 주기를 기다리지 않고 확정 직후 바로 저장한다. 연출 뒤에 반영하면 연출을 보고 끄는 사람이 생긴다.

서버가 결과를 모르는 빈틈은 한계로 적어 뒀다

서버가 두 기기에 matched를 보낸 직후 한쪽 네트워크가 끊기면, 받은 쪽만 결과를 저장한다. 서버는 결과를 모르므로 맞출 방법이 없다. 대전이면 승패가 한쪽에만 남고, 조그레스면 한쪽만 합체할 수 있다. 이 내용은 설계서에 한계로 적어 두었다.

서버 주소는 개인정보 처리방침과 구조 문서에 적었다. 실기기 두 대로 READY에서 CONNECT!가 거의 동시에 뜨는지는 확인하지 않았다.

소리가 겹치고, 연출 중 버튼이 애매했다

  • 효과음 겹침: 한 번에 한 소리만 재생하는 큐(sfx-queue)를 두고, 버튼 조작음만 앞 소리를 끊고 바로 낸다. 버튼음은 seek(재생 위치를 처음으로 옮기는 동작)가 끝난 뒤 재생해서 누를 때마다 들리게 했다. 띠리링 소리(happy)는 실제 용도에 맞춰 heal(치료음)로 이름을 바꿨다.
  • 감정 표현: 펫의 기쁨·슬픔을 깜빡이는 감정 표시(이모트)로 보여 주는 연출이다. 이 연출 길이를 1,800ms에서 2,400ms로 늘리고, 시작할 때 기쁨·슬픔 효과음을 낸다.
  • 앱 안 호출: 호출이 생기면 4초 동안 1초마다 울리고, 아무 버튼이나 누르면 멈춘다. 소리가 켜져 있으면 호출음, 꺼져 있으면 진동이다. 앱에 돌아와 꺼져 있던 시간을 한꺼번에 계산할 때는 울리지 않는다.
  • 백그라운드 호출: 기기 소리 상태를 따른다. iOS는 호출음 알림을 보내고, 무음 모드면 소리는 나지 않고 진동 여부는 iOS 햅틱 설정을 따른다. 안드로이드는 벨소리 모드면 호출음, 진동 모드면 진동이다.
  • 연출 중 버튼: 먹기·주사(병 치료)·청소 연출 중 B를 누르면 효과를 확정하고 직전 선택 화면으로 돌아간다. 감정 표현 중 C는 직전 아이콘에 커서를 두고, 한 번 더 누르면 해제한다. 진화 연출은 어떤 버튼으로도 끊지 않는다.

혼자 하는 사용자도 진화할 길이 필요했다

STAGE 챌린지(혼자 컴퓨터 상대와 스테이지를 올라가는 대전)는 기록 전용이라 진화 실적(대전 수·승수)에 들어가지 않는다. 혼자 하는 사용자는 성숙기 이후 진화에 필요한 대전 조건을 채울 수 없었다. 주변에 통신 상대가 없어도 걸어 다니며 만나는 야생 몬스터와 대전·조그레스해서 진화할 수 있게 하려고 WILD 모드를 구상했다.

이날은 설계서(2026-10-13-wild-encounter-design.md)만 썼고, 리뷰를 두 번 받아 고쳤다.

  • 대전 메뉴는 STAGE / LINK / WILD로 넓힌다. WILD를 고르면 펫이 걷는 탐색 화면이 나온다.
  • 일정 걸음을 걸으면 위치를 한 번 읽어 작은 칸과 시간대를 정한다. 같은 칸·시간대·단계의 펫은 같은 몬스터를 본다. 서버 없이 기기 안에서 정해진 규칙으로 계산해 정한다.
  • 야생 대전은 LINK 대전과 같은 취급이다. 듀얼 전적, 진화 실적, 패배 페널티에 모두 들어간다.
  • 결과는 B로 대전을 시작하는 순간 확정한다. 통신 대전의 결과 확정 시점을 앞당긴 것과 같은 이유다.
  • 성숙기에서 조그레스할 수 있는 종이면 낮은 확률로 짝이 나온다. "대전을 채워 일반 완전체로 갈지, 짝을 만나 조그레스할지"가 갈림길이 된다.
  • 확률 상수는 한 파일에 모은다. 확률형 아이템을 넣게 되면 확률 공개 의무 대상이 될 수 있기 때문이다.
  • 시간대 경계는 기기 시간대 설정과 관계없이 UTC 기준으로 모두 같게 둔다.

컬러피커를 빼고 자잘한 화면을 손봤다

  • 셸 색: 셸(LCD를 감싼 기기 몸체) 색을 '직접 고르기'로 정하던 컬러피커를 뺐다. reanimated-color-picker 의존성도 지웠다. 대신 연분홍 셸을 팔레트에 넣었다. 폰·애플워치·Wear OS 모두 같다.
  • 아트 교체 경로: 도트 작업물을 외부에 넘기고 받아 오는 art:handoff·art:import 스크립트를 만들었다. 몬스터 pelagorn의 도트를 이 경로로 다시 넣었다.
  • 앱 스플래시: 두 번 반복하니 답답해서 한 번으로 줄였다. 사이 정지 800ms와 두 번째 흔들기와 톡(부화 직전 알이 톡 하고 한 번 움직이는 동작) 2,000ms를 뺐다. 한 주기 안의 간격은 그대로다. 합계는 5,900ms에서 3,100ms가 됐다.
  • 도감: 처음 열면 지금 키우는 몬스터가 속한 버전(V1~V6 계통) 탭을 고르고, 탭 줄도 그 탭이 가운데 오게 한 번 스크롤한다.
  • 코드 입력 키패드: 세로 화면에서 키패드가 A/B/C보다 키가 커서 LCD가 위로 밀렸다. 키패드에 위아래 음수 여백을 줘서, 흐름에서 차지하는 높이를 A/B/C 줄과 같게 맞췄다.
  • art/ 아래 다른 작업의 백업 사본이 type-check를 깨뜨려 검사 대상에서 뺐다.