dotgimon 제작기 03. 실시간 생애주기와 오프라인 정산 구현
dotgimon은 30분 안에 한 생애를 확인하던 데모에서, 앱을 닫아 둔 동안에도 시간이 흐르는 육성 게임으로 바뀌어야 했다. 부화와 배고픔 같은 시간 상수를 늘리는 것만으로 끝나지 않았다. 잠든 시간, 병든 시간, 저장하지 않은 시간까지 같은 엔진 규칙으로 연결했다.
게임은 64×64 LCD 위에서 돌아가고, Game 클래스의 update(now, dt)가 매 프레임 상태를 바꾼다. 이 구조에 자동 수면과 실제 시간의 진화 조건을 넣었다. 구현하면서 개발용 배속, 환생 초기화, 테스트 자체의 문제도 함께 드러났다.
감소 시계를 돌봄 행동과 맞췄다
부화는 3분, 배고픔 한 칸 감소는 40분, 힘 감소는 90분으로 바꿨다. 똥은 4시간마다 생기고, 배고픔이 0으로 6시간 이어지면 병드는 기준을 썼다. 수명만 차면 죽는 분기는 없앴다.
방치사는 병과 굶주림이 함께 이어진 시간을 재도록 했다. 두 조건 중 하나라도 풀리면 카운트다운을 비웠다.
if (s.sick && s.hunger === 0) {
if (s.deathSince === null) s.deathSince = now
else if (now - s.deathSince >= DEATH_NEGLECT_MS) {
this.die(now)
return
}
} else {
s.deathSince = null
}DEATH_NEGLECT_MS의 실제 시간 기준은 24시간이었다. 병만 있거나 배고픔만 0인 상태를 같은 사망 조건으로 취급하지 않았다. 치료로 조건을 풀면 다시 시간을 재도록 했다.
감소 타이머의 기준도 함께 고쳤다. 먹인 뒤에도 이전 감소 시계가 이어지면 금방 한 칸이 줄 수 있었다. 급식으로 배고픔이 오르는 순간 lastHungerAt을 현재 시각으로 바꾸고, 훈련이나 대전 등으로 힘이 오르는 경로에도 같은 처리를 넣었다.
청소를 마치면 lastPoopAt을 다시 잡았다. 기상할 때는 배고픔·힘·똥의 기준 시각을 모두 현재로 돌렸다. 수면 중에 멈춘 감소가 아침에 한꺼번에 밀려오는 것을 막으려는 변경이었다.
계속 깨어 있고 추가 급식이 없다면 네 칸의 배고픔이 줄어드는 기준 시간은 160분이다. 잠들면 감소가 멈추고 기상 시 기준도 바뀌므로, 급식한 벽시계 시각에서 무조건 160분 뒤에 0이 되는 규칙으로 두지는 않았다.
120배 배속을 걸자 걷는 모습까지 빨라졌다
실시간 진화를 눈으로 기다리기는 어려웠다. 개발 모드에서만 DEV_TIME_SCALE을 120으로 두고 게임 시계와 경과 시간에 곱했다. 저장 데이터에는 배율과 가속 시각도 넣어, 실행 배율이 달라지면 시간 기준을 다시 맞추게 했다.
그런데 몬스터의 걸음과 깜빡임, 먹는 연출까지 120배로 빨라졌다. 생애를 재는 시간과 화면 연출에 같은 now를 쓰고 있었기 때문이다. 부화만 빨리 보고 싶었는데 버튼을 눌렀을 때의 움직임도 달라졌다.
시계 자체를 빠르게 보내는 방식을 버리고, 실제 now와 dt는 유지한 채 육성 시간 상수만 배율로 나누었다. 당시 변경에서 부화와 방치 시간은 짧아지고 청소 연출은 그대로 남았다.
const TS = DEV_TIME_SCALE
const HATCH_MS = 180_000 / TS
const HUNGER_DECAY_MS = 2_400_000 / TS
const DEATH_NEGLECT_MS = 86_400_000 / TS
const CLEAN_MS = 1300가속 시각을 저장할 필요가 없어져 누적 시계와 저장 시각 재조정 코드도 걷어냈다. 수면 시각은 프로덕션에서 실제 현지 시각을 쓰고 개발에서는 가속한 시각을 계산했다. 육성 속도를 바꾸는 기능이 연출과 저장 형식까지 번졌던 부분을 줄였다.
수면 전이를 감소보다 앞에 두었다
종별 수면 시간은 도감의 sleepAt과 wakeAt을 우선 사용했다. 값이 없으면 종 ID의 해시로 취침과 기상 시각을 계산했다. 같은 종은 같은 시간표를 갖고, 진화하면 새 종의 시간표를 따르는 구조였다.
취침 판정은 배고픔과 힘을 줄이는 코드보다 앞에 배치했다. 같은 프레임에서 취침 시각과 감소 시각을 함께 넘었을 때 취침을 우선하기 위해서였다. 수면 상태가 먼저 켜지면 뒤의 감소 조건이 그 틱을 건너뛰었다.
부화 후 3시간은 시간표를 무시하는 유예를 두었다. 막 부화한 펫이 바로 잠들어 첫 조작을 못 하는 경우를 줄이려는 설계였다. 먹거나 진화하는 중에는 취침을 미루되, 조회·선택 화면에 머무는 것만으로 수면을 피하지 못하게 MAIN으로 돌렸다.
LIGHT는 ON/OFF 두 줄 메뉴로 만들었다. 잠든 뒤 30분 안에 불을 끄지 않으면 sleepDisturb를 올리고, sleepJudged로 같은 수면에서 중복 판정하지 않게 했다. 자는 동안 불을 다시 켜는 행동은 별도로 횟수를 올렸다.
HEAL은 바로 상태를 바꾸던 치료에서 연출이 있는 행동으로 바뀌었다. 전체 연출은 1,700ms이고 800ms에 치료를 확정했다. 아프지 않으면 고개를 젓고 OK를 보였다. 연출이 진행되는 동안 다른 행동이 끼어들지 않도록 busy()에 치료 상태를 포함했다.
진화 조건을 고치고 도달할 수 있는지 확인했다
데모에서는 훈련과 대전 수치를 DEMO_DIV로 나누어 조건을 낮춰 썼다. 실제 시간에서는 그 보정을 없애고 도감의 조건 자체를 바꿨다. 훈련 32회 이상 같은 조건을 12회 이상으로 바꾸는 표를 스크립트에 넣고 데이터를 변환했다.
변환표에 없는 조건은 예외로 멈췄다. 사람이 모든 종의 조건을 옮겨 적는 대신, 예상하지 못한 값을 조용히 통과시키지 않는 쪽을 택했다. 변환 뒤 같은 조건이 된 그룹은 합쳤다.
완전체 진화는 대전 10회 이상과 승리 8회 이상을 기본 관문으로 바꾸었다. 후보가 둘인 일부 종은 첫 후보에 승리 9회 조건을 두어 갈라지게 했다. 관문을 못 채우면 마지막 후보로 넘어가지 않고 성숙기에 머물렀다.
당시 선택 로직에서는 낮은 단계와 완전체 관문을 구별했다.
let chosen = def.evo.find(
e => e.groups.length === 0 || e.groups.some(groupOk),
)
if (!chosen) {
if (this.stageIdx() >= 3) return
chosen = def.evo[def.evo.length - 1]
}진화하면서 종이 바뀌면 케어 실수, 훈련, 과식, 수면 방해, 대전, 승리 횟수를 모두 비웠다. 각 단계의 행동으로 다음 분기를 고르기 위해서였다. 활동시간은 MAIN에서만 쌓던 제한을 없애고, 깨어 있고 아프지 않으면 조회 화면에서도 누적했다.
테스트는 성장기 12종에 케어·훈련·과식·수면 방해의 24가지 조합을 넣었다. 모든 진화 대상에 도달할 조합이 있는지, 데모 수치가 남지 않았는지, 관문 미달에서 후보를 고르지 않는지 확인하도록 했다. 시간을 요구치 근처로 맞춘 성장 테스트에서는 돌봄과 대전, 진화가 함께 이어지는지도 검사했다.
진화 조건은 화면에 공개하지 않는 대신 전조 동작을 넣었다. 초기 단계에서 필요한 활동시간의 75%를 넘으면 주기적으로 걸음을 멈추고 정면을 보았다. 병든 동안에는 이 동작도 껐다. 조건표를 보여 주지 않으면서 상태가 바뀔 기미만 남겼다.
방치 횟수와 생애 기록은 따로 쌓았다
기존 케어 실수는 호출이 시작되면 한 번만 올라갔다. 하루 종일 호출 중이어도 한 번이라 방치 시간을 구별하지 못했다. careCounted를 없애고 호출이 시작된 시각을 저장하는 careCallSince를 넣었다.
호출이 120분 이어질 때마다 가산하고 기준 시각을 다시 잡았다. 호출이 풀리면 기준을 비웠다. 같은 곳에서 생애 누계인 lifeCare도 함께 올렸다.
if (this.needsAttention()) {
if (s.careCallSince === null) s.careCallSince = now
else if (now - s.careCallSince >= CARE_MISTAKE_MS) {
s.careMistakes++
s.lifeCare++
s.careCallSince = now
}
} else {
s.careCallSince = null
}단계 지표는 진화할 때 초기화되지만 생애 누계는 유지했다. 훈련과 대전, 승리도 같은 방식으로 나누고, lifeChain에는 거쳐 온 종 ID를 쌓았다. 사망 시각인 deadAt을 저장해 앱을 닫은 동안 죽어도 함께한 시간을 계산할 수 있게 했다.
사망 화면은 A 버튼으로 묘비, 돌봄·훈련·승패 기록, 진화 경로를 넘기게 만들었다. STATUS의 나이도 초 대신 일 단위로 바꾸었다. 훈련은 체중을 올리던 부호를 바꿔 성공하면 2, 실패하면 1을 줄이되 하한을 5로 두었다. 대전 패배에는 15% 발병 확률을 넣었다.
꺼져 있던 시간도 update로 진행했다
앱을 닫아 둔 동안의 상태를 감소량 한 번으로 계산하면 중간 사건의 순서를 놓쳤다. 배고픔이 줄고 병이 들고, 잠을 자는 과정이 진화와 사망에도 영향을 주었다. 그래서 마지막으로 반영한 시각과 현재가 60초 넘게 벌어지면 catchUp에 맡겼다.
정산은 최대 1분씩 시간을 옮겨 같은 update를 호출했다. 현재에 도달하거나 사망하면 멈췄다.
let t = from
while (t < to && !s.dead) {
const step = Math.min(60_000, to - t)
t += step
this.update(t, step)
}
s.lastSeenAt = to안쪽 호출 간격은 60초 이하라 긴 시간 점프를 처리하는 분기로 다시 들어가지 않았다. 정산 전에 먹기·청소·훈련·치료 같은 연출 상태를 비우고, 훈련이나 대전 화면이었다면 MAIN으로 돌렸다. 연출 데이터 없이 그 화면에 남는 상태를 막기 위해서였다.
정산 중 종이 바뀌면 복귀한 시각에 최종 형태로 진화 연출을 한 번 걸었다. 앱을 닫아 둔 동안의 모든 연출을 다시 재생하지 않았다. 시간 진행과 화면에서 보여 줄 연출을 나누었다.
비교 테스트에서는 48시간 점프한 게임과 1분 간격으로 진행한 게임의 주요 상태가 같은지 확인했다. 비교 필드를 넓히고 두 게임의 마지막 시각도 맞췄다. 같은 함수 사용만으로 모든 프레임 간격의 결과가 같다고 보장할 수는 없어, 테스트한 조건을 분명하게 두었다.
구 세이브의 펫은 살리고 오래된 기준은 비웠다
v3 저장에는 마지막 반영 시각과 세대, 생애 누계, 사망·호출 시작 시각을 더했다. v1과 v2에서 읽은 펫은 유지하되, 데모 속도로 쌓인 진화 지표 여섯 개는 한 번 초기화했다. 종과 단계, 활동시간, 도감까지 버릴 이유는 없었다.
lastSeenAt이 없는 저장은 로드 시각을 기본값으로 썼다. 오래된 시각으로 시작하면 로드 직후 긴 정산이 생길 수 있었다. 배고픔이 0이 된 시각과 감소·생성 기준도 구 버전에서는 다시 잡았다. 이미 오래된 값 때문에 펫이 곧바로 병드는 경우를 막았다.
정산을 넣은 뒤에는 테스트의 기본 저장 버전도 바꿨다. 옛 버전 값을 기본으로 둔 테스트가 긴 시간 점프를 하면서 의도하지 않은 정산 경로에 들어갔다. 실제로 검사하려는 상황에 맞춰 저장 버전과 마지막 시각을 같이 설정했다.
리셋에서 빠진 필드가 시간을 되돌렸다
환생을 점검하다 생애 누계가 새 펫으로 이어지는 문제를 발견했다. resetPet()에서 사망 시각과 케어·훈련·대전·승리 누계, 진화 경로를 비우도록 고쳤다. 세대만 환생할 때 하나 올리고 유지했다.
lastSeenAt도 빠져 있었다. 죽은 뒤 며칠이 지나 새 알로 시작하면, 첫 프레임에서 죽어 있던 기간 전체를 다시 정산했다. 초기화할 때 이 값을 현재로 맞추고, 사망 후 긴 시간이 지나 환생해도 새 펫 상태가 손상되지 않는 테스트를 넣었다.
테스트 자체가 아무것도 검증하지 않는 경우도 있었다. 훈련 수와 생애 훈련 수를 직접 넣고 같은 값을 확인하던 테스트였다. 구현을 바꾸어도 실패하지 않았다. 실제 링크 대전을 시작해 승리를 확정하고, 진화한 뒤 단계 지표는 비워져도 생애 누계는 남는 흐름으로 바꿨다.
하루 세 번 돌보는 시나리오에는 다른 한계가 있었다. 검사에 사용한 종은 ID 해시상 낮에 오래 자는 시간표였다. 09시·13시·19시 방문으로 계산한 기준을 그대로 적용할 수 없어 테스트에서만 수면을 21시~08시로 고정했다. 이 테스트가 확인한 것은 그 생활 리듬의 케어 기준이며, 모든 종의 시간표는 아니었다.
화면 상태는 바뀔 때만 React에 넘겼다
메뉴 아이콘은 도트에서 SVG 패스로 바꾸고 Skia로 그렸다. 엔진은 아이콘을 직접 그리는 대신 메뉴 표시 여부와 커서, 깜빡임 상태를 넘겼다. React 쪽에서는 이 값을 문자열 키로 비교해 바뀔 때만 상태를 갱신했다.
커서가 없을 때도 깜빡임 값만 계속 바뀌면 불필요한 렌더가 생겼다. 그래서 선택된 아이콘이 있을 때만 깜빡임을 키에 넣었다. 액션을 끝낸 뒤에는 커서가 같은 아이콘에 남도록 여러 경로의 초기화도 걷어냈다.
SVG 파싱 위치도 옮겼다. 당시에는 모듈을 불러오는 중 Skia 네이티브 호출이 일어나는 것이 크래시 원인이라고 보고, MakeFromSVGString을 컴포넌트의 useMemo 안으로 옮겼다. 파싱 결과를 재사용하는 조치였으며, useMemo 자체를 네이티브 준비 완료나 영구적인 한 번 실행의 보장으로 삼지는 않았다.
실시간 육성으로 바뀌면서 시간 상수보다 초기화와 저장 경계에서 손볼 곳이 많이 나왔다. 사망 카운트다운을 저장하고, 환생에서는 마지막 반영 시각을 새로 잡고, 테스트는 실제 행동 경로를 지나가게 했다. 펫의 상태를 오래 유지하는 기능과 새 생애를 시작하는 기능이 같은 필드를 서로 다른 기준으로 다뤘다.