본문으로 건너뛰기

dotgimon 제작기 23. 도트 셸과 치료 연출, 호출 진동 예약

·19 min read·23 / 23

dotgimon의 LCD는 도트인데 바깥 본체에는 매끈한 그라데이션이 남아 있었다. 폰과 워치의 모양을 같은 도트 기준으로 맞추면서 아이콘과 셸 색을 손봤다. 게임 안에서는 아픔과 치료가 더 잘 보이도록 연출을 바꿨다.

앱을 열어 보지 않으면 돌봄 요청을 모르는 문제도 있었다. 같은 엔진으로 다음 사건을 예측해, 앱이 화면에서 내려갈 때 알림이나 진동을 예약하는 코드를 넣었다.

치료가 끝나기 전에 무엇을 하는지 보이고 싶었다

기존 치료는 작은 주사기가 몸 옆에서 흔들리다가 800ms에 낫고, 전체 연출은 1,700ms에 끝났다. 아픈 몬스터는 같은 자세를 반복하고 머리 위 !가 깜빡였다. 아프다는 상태와 치료하는 동작이 짧게 지나갔다.

아플 때는 떨림과 십자가 보이게, 치료는 주사기가 들어와 찌르고 약을 넣는 과정이 보이게 바꿨다. 연출 시간은 상수로 나눴다.

export const HEAL_JAB_AT = 600
export const HEAL_INJECT_AT = 1100
export const HEAL_CURE_AT = 2100
export const HEAL_MS = 2700
export const HEAL_MONSTER_X = 22
export const SICK_SHIVER_PERIOD_MS = 2000
export const SICK_SHIVER_MS = 540

주사기는 600ms에 몸에 닿고, 1,100ms부터 약을 넣으며, 2,100ms에 치료를 확정했다. 전체는 2,700ms로 늘렸다. 몬스터를 왼쪽으로 옮겨 오른쪽에 주사기가 들어올 공간도 만들었다.

아플 때는 2초마다 처음 540ms 동안 떠는 자세를 보여 줬다. 그 안에서 90ms마다 좌우로 한 칸씩 움직였다. 시각만으로 자세와 위치를 정하는 함수로 나눠 테스트할 수 있게 했다.

주사기는 6×3칸의 채운 모양에서 14×7칸의 선 그림으로 바꿨다. 눈금을 넣고, 찌르는 순간에는 움찔하는 자세를 보여 줬다. 피스톤은 500ms마다 한 칸씩 최대 두 칸 움직였다.

치료 중 십자는 깜빡이지 않게 했다. 완치 때 사라지는 장면을 읽으려면 그전까지는 계속 보여야 했다. 완치 후에는 몸 둘레를 반짝이게 하고 행복한 표정으로 넘어갔다.

십자와 주사기 위치는 몬스터 그림 경계인 PORTRAIT_BOUNDS로 구했다. 종마다 폭이 달라 고정 좌표만으로는 몸 옆에 맞추기 어려웠다. 시간표와 배치 계산은 그리기 코드와 나눴다.

확인은 자동 테스트와 엔진 출력으로 만든 정지 프레임까지였다. 정상 속도의 시뮬레이터나 기기 재생은 보지 못했다. 시간표가 맞는 것과 동작이 자연스러운 것은 같은 검사가 아니었다.

아이콘도 작은 게임기로 만들었다

아이콘은 크림색 벽돌 바탕에 게임기 프레임을 놓고, LCD 안에 반점 알을 그리는 모양으로 바꿨다. Python 스크립트에서 28칸 격자의 베젤을 그려 플랫폼별 이미지를 만들었다.

이번 iOS 기본 아이콘은 불투명 RGB PNG로 만들었다. Android 적응형 아이콘은 벽돌 배경과 프레임 전경을 나눴다. Android 아이콘의 안전 영역에 맞춰 프레임 크기를 줄여 원형으로 잘려도 남도록 했다.

테마 아이콘에는 흰 실루엣을 쓰고 LCD 창은 투명하게 뒀다. 파비콘은 큰 아이콘을 그대로 줄이지 않고 48px 전용으로 그렸다. 알은 원래 크기로 넣어 작은 화면에서 도트가 흐려지지 않게 했다.

적응형 아이콘의 배경색도 LCD 초록에서 벽돌색인 #e2dccb로 바꿨다. 실제 홈 화면에서 표시되는 모양은 확인하지 못했다.

셸을 고르는 순서를 정했다

셸은 게임기의 본체 색이다. 본체 색은 짙은 여섯 색과 아이보리를 합쳐 일곱 가지로 줄였다. 기존 펄 색의 저장값 7은 새 배열 길이로 나눈 나머지가 0이라 아이보리로 읽혔다. 별도 저장 데이터 변환은 하지 않았다.

직접 고른 색도 지원했다. 사용자 색이 있으면 그것을 쓰고, 없으면 설정한 기본 색, 그마저 없으면 펫에 배정된 색을 썼다.

export function resolveShell(
  settings: { shell: number | null; shellColor: string | null },
  petShell: number,
): Shell {
  if (settings.shellColor) {
    return {
      key: 'custom',
      body: settings.shellColor,
      dark: isDarkShell(settings.shellColor),
    }
  }
  return SHELLS[(settings.shell ?? petShell) % SHELLS.length] ?? SHELLS[0]
}

색을 고르는 패널은 별도 모달 대신 설정 화면 안에 펼쳤다. 드래그 중에는 미리보기만 바꾸고 손을 떼면 저장했다. 같은 칸을 다시 눌러도 닫히지 않게 해 중복 탭으로 곧바로 접히는 일을 피했다.

사용자 색은 폰에서 워치로만 보냈다. 워치는 #rrggbb 형식만 받아 저장했고, 자기 색을 폰에 돌려보내지는 않았다. 저장 후 다시 읽어도 같은 색인지, 잘못된 문자열을 무시하는지 테스트했다.

베젤은 한 곳에서 만들고 기기마다 칠했다

베젤은 LCD 둘레의 테두리다. 폰과 워치는 화면을 그리는 도구가 달랐다. 플랫폼별로 베젤 계산을 따로 쓰면 모서리와 띠의 간격을 계속 맞춰야 했다. 순수 TS에서 색별 사각형 목록을 만들고, 각 기기는 그것을 칠하게 했다.

LCD 도트 한 칸의 절반을 베젤 단위로 삼아 LCD 64칸을 128칸으로 보았다. 바깥 그림자와 외곽선, 어두운 경사와 밝은 경사, 몸통을 순서대로 덮었다. 아래쪽은 체크 무늬로 한 톤 어둡게 했다.

폰과 워치는 같은 생성 함수를 쓰되 프레임과 탭의 치수는 다르게 줬다. 위 띠에는 DOTGIMON을 새긴 모양을 넣고 아래 각인은 두지 않았다.

같은 색으로 이어진 칸을 가로줄 하나로 합쳐 사각형 수를 줄였다. 플랫폼에는 각 줄의 위치와 폭만 넘겼다.

본체의 곡면 명암과 펄 광택은 없앴다. 단색 셸에 벽돌 줄눈과 위·왼쪽의 빛, 아래·오른쪽의 그늘을 넣었다. 3배 배율 화면에서는 1.5pt가 4.5px이 되므로 선 두께를 정수 pt로 맞췄다.

워치는 벽돌 타일을 폰의 절반 크기로 그렸다. 버튼은 손을 뗄 때가 아니라 대는 순간 동작하게 바꿨다. 훈련 게이지가 지나가는 순간을 잡는 입력이라 손을 뗀 시각을 쓰면 늦었다.

DragGesture(minimumDistance: 0)를 사용하고 눌림 표시는 최소 0.12초 유지했다. 눌릴 때마다 그림자를 바꾸면 버튼이 흰색으로 그려지는 문제가 있어서 버튼 몸통은 별도 뷰로 두고 크기와 밝기만 바꿨다. 시안을 보며 형태를 맞췄지만 실기기 확인은 하지 못했다.

번호 코드 키패드의 C도 바꿨다. 화면 C는 입력을 전부 지우는 CLR로 구분하고, 닫기는 X 버튼으로 뺐다. 하드웨어 C는 닫기로 유지했다. 숫자를 넣고 지운 뒤 다시 입력되는지 테스트했다.

앱이 내려간 동안의 호출을 예약했다

돌봄이 필요해도 화면을 열기 전에는 알 수 없었다. 호출 대상은 배고픔 0, 새로 생긴 똥, 아픔, 불을 켠 채 잠드는 경우로 정했다. 소리 없이 진동만 전달하는 것을 목표로 삼았고, 최근 동기화한 워치가 있으면 워치 쪽에 맡기는 방향을 골랐다.

게임 규칙은 유지했다. 불을 켠 채 잠드는 경우에 알림을 추가하되 기존 CALL 표시나 돌봄 실수, 진화 조건을 바꾸지 않았다. 진동 설정은 폰과 워치가 함께 쓰게 했다.

앱이 화면 밖에서 계속 JS를 실행한다고 전제할 수는 없었다. 그래서 앞으로 호출이 생길 시각을 계산해 OS에 예약했다. 이미 있는 predict는 현재 상태의 복제본을 1분씩 진행하며 다음 사건을 찾는 함수였다.

똥이 이미 있는 상태에서 하나 더 늘어나는 것은 새 호출로 보지 않았다. 0개에서 처음 생기는 dirty와 불을 켠 채 잠드는 lights를 예측 결과에 추가했다.

if (prev.poop === 0 && cur.poop > 0) mark('dirty', t)
if (!prev.sleeping && cur.sleeping && cur.lightOn) mark('lights', t)

상태 관찰값에 lightOn도 넣었다. 게임 규칙을 새 공식으로 복사하지 않고 같은 엔진이 만든 변화를 골랐다.

예약 목록은 예측 결과에서 호출 종류만 추렸다. 1분 미만 간격으로 겹치는 호출은 하나만 남겼다. 이미 호출 중인 상태를 계속 다시 알리는 방식은 아니었다.

export const CALL_MERGE_MS = 60_000
 
for (const p of predict(state, now)) {
  if (!CALL_KINDS.includes(p.kind)) continue
  const last = alarms[alarms.length - 1]
  if (last && p.at - last.at < CALL_MERGE_MS) continue
  alarms.push({ kind: p.kind as CallKind, at: p.at })
}

이미 똥이 있으면 새 dirty가 없고, 불을 끈 채 잠들면 lights가 없으며, 사망 뒤에는 예약이 없는지 테스트했다. 최대 24시간을 1분씩 계산하는 함수여서 매 프레임이나 2초 저장 주기에는 붙이지 않았다.

화면에서 내려갈 때 한 번 계산했다

앱이 active에서 벗어날 때 예약하고 돌아오면 지웠다. 화면을 보고 있는 동안에는 게임 안에서 상태를 볼 수 있기 때문이다.

iOS의 inactive와 background 전환에서 같은 예측을 두 번 하지 않도록 scheduled 표시를 뒀다. 내려간 동안 워치 메시지로 진동 설정이나 담당 기기가 바뀌면 메시지 적용 뒤 다시 예약했다.

권한 요청은 사용자가 화면을 보는 active 상태에서 하도록 정했다. 스케줄러는 권한 요청 시점과 알림 계산을 나눴다.

폰은 최근 워치 메시지를 받은 시각을 저장했다. 24시간 안에 메시지를 받았으면 워치가 맡았다고 보고 폰은 예약하지 않았다.

export function ringsHere(
  dev: Dev,
  pref: HapticPref,
  watchSeenAt: number | null,
  now: number,
): boolean {
  if (!pref.on) return false
  if (dev === 'watch') return true
  return watchSeenAt === null || now - watchSeenAt >= WATCH_HANDLES_MS
}

이 기준은 현재 연결돼 있다는 뜻이 아니었다. 워치가 예약하는 범위와 같은 24시간을 담당 기간으로 사용한 정책이었다. 짧은 기준을 쓰면 워치 앱이 내려간 뒤 폰도 다시 예약해 양쪽이 울릴 수 있었다.

진동 설정은 값과 변경 시각을 세션에 저장했다. 나중 시각의 설정을 쓰고 같은 시각이면 자기 값을 유지했다. 펫 행동 로그에는 넣지 않았다. 진동 설정을 바꾼 일이 펫 상태의 재계산에 섞이지 않게 한 것이다.

폰 설정에는 VIBE를 넣었다. 워치에는 설정 화면이 없어 값을 받기만 했지만 설정 데이터의 전송은 양방향으로 만들었다.

예약이 뒤늦게 되살아나는 순서를 막았다

Apple 쪽은 UNUserNotificationCenter의 시간 트리거로 DOTGIMON과 CALL! 알림을 예약했다. 워치는 사운드를 지정하지 않았고 폰에는 무음 파일 call-silent.caf를 지정했다. 이 설정으로 원하는 진동이 오는지는 실기기에서 확인하지 못했다.

예약에는 지우기, 권한 확인, 추가가 비동기로 이어졌다. 화면에 돌아와 예약을 지웠는데 직전 요청의 추가가 늦게 실행되면 알림이 다시 생길 수 있었다. 요청마다 세대 번호를 올리고 최신 요청만 추가하게 했다.

@MainActor private static var generation = 0
 
@MainActor static func schedule(_ json: String?, now: Double) {
  generation += 1
  let mine = generation
  // 기존 예약 제거와 권한 확인 뒤 실행
  Task { @MainActor in
    if mine == generation { add(alarms, now: now) }
  }
}

중간 처리를 생략한 구조다. 화면 복귀의 clear도 세대를 올려 진행 중인 옛 예약 요청이 추가되지 않게 했다.

Android와 Wear는 AlarmManager.setAndAllowWhileIdle이 호출한 수신기에서 알림 용도의 진동을 요청했다. 정확한 시각을 보장하는 알람은 아니었다. AlarmManager와 Vibrator의 정책, 절전과 기기 설정에 따라 늦거나 울리지 않을 수 있다.

폰에는 Expo 네이티브 모듈을 두고 JS에 schedule, clear, requestPermission만 노출했다. 모듈이 없는 빌드에서는 아무 일도 하지 않게 했다.

워치가 조용한 동안에는 상태가 오래될 수 있었다

24시간 담당 정책에는 빈틈이 있었다. 워치 앱이 꺼져 폰의 돌봄을 받지 못하면 이미 해결한 배고픔을 기준으로 호출할 수 있었다. 반대로 폰은 워치가 맡았다고 생각해 새 호출을 예약하지 않을 수 있었다.

폰에서 진동을 꺼도 꺼진 워치에 전달되지 않으면 기존 예약은 남았다. 워치 알림 권한이 거부돼 예약하지 못해도 폰은 최근 워치를 봤다는 이유로 조용할 수 있었다. 이 한계를 설계에 적었고, 꺼진 워치의 예약을 갱신하는 처리는 구현하지 않았다.

자동 테스트는 예측 결과에서 예약을 만드는 계산, 앱 상태 전환, 가상 두 기기의 설정 동기화를 확인했다. 실제 진동은 시뮬레이터에서 확인할 수 없었고 실기기에서도 확인하지 못했다.

치료 연출과 아이콘도 정지 프레임과 생성 결과까지 본 상태였다. 이날 추가한 것은 화면과 예약 코드였다. 손목의 진동과 기기에서 재생되는 동작까지 확인한 결과로 묶지는 않았다.