dotgimon 제작기 02. 실시간 육성 규칙과 구현 계획
dotgimon은 64×64 도트 화면에서 몬스터를 키우는 게임인데, 당시 시간은 데모 속도로 흘렀다. 알은 몇 초 만에 부화하고 배고픔도 금방 줄었다. 잠깐 동작을 확인하기에는 편했지만, 하루에 몇 번씩 열어 키우는 게임과는 거리가 있었다.
코드를 늘리기 전에 실제 시간으로 돌아가는 규칙을 정리했다. 수면과 조명, 치료, 진화, 앱을 닫아 둔 동안의 정산을 문서로 맞췄다. 같은 날 문서를 여러 번 고친 이유는 새 기능을 더하려고만은 아니었다. 정한 숫자끼리 맞지 않는 곳이 계속 나왔다.
빠른 데모를 느리게 만드는 것만으로는 부족했다
목표는 하루에 세 번에서 다섯 번, 한 번에 1~3분 정도 확인하는 육성 게임이었다. 잘 돌보지 못한 결과도 다른 진화로 이어지게 두고, 죽음은 드물게 만들고 싶었다. 진화 조건은 화면에 공개하지 않고 자기 행동 기록을 보며 분기를 알아내는 방향으로 정했다.
초안에서는 부화를 3분, 배고픔 한 칸 감소를 40분, 힘 한 칸 감소를 90분으로 바꾸었다. 병과 굶주림이 함께 24시간 이어지면 방치사하는 기준도 잡았다. 먹거나 청소하는 연출은 초 단위로 유지했다. 육성 속도를 늦춘다고 버튼을 누른 뒤 기다리는 시간까지 길어질 이유는 없었다.
진화는 단순한 벽시계 시간이 아니라 깨어 있고 아프지 않았던 시간을 쌓도록 설계했다. 단계별 활동시간은 1시간, 12시간, 30시간, 45시간이었다. 잠든 시간과 아픈 시간을 성장에 넣지 않으니, 같은 날 태어나도 돌봄에 따라 진화 시점이 달라졌다.
초안에는 12일 수명이 있었다. 시간이 차면 졸업하는 방식이었지만 검토 도중 없앴다. 잘 돌보는 펫도 일정 시간이 지나면 떠나게 하는 대신, 병과 굶주림이 이어질 때만 죽는 방향을 선택했다. 이 결정은 사망 화면의 사유, 생애 요약, 새 알로 시작하는 조건까지 함께 바꾸었다.
하루에 세 번 방문하는 상황을 직접 계산했다
careMistakes는 돌봄이 필요한 상태를 오래 방치했을 때 올라가는 값이다. 배고픔이나 똥 때문에 호출 중이어도 한 번만 가산하면, 잠깐 늦게 온 경우와 하루 종일 방치한 경우가 같아졌다. 호출이 이어지는 동안 일정 간격으로 다시 세도록 바꾸었다.
가산 간격은 처음에 45분이었다. 검토하면서 90분을 거쳐 120분으로 늘렸고, 똥 생성 간격도 3시간에서 4시간으로 바꾸었다. 하루에 세 번 방문하는 사람까지 계속 방치 판정을 받는다면 처음 정한 생활 리듬과 맞지 않았다.
숫자만 늘려서는 문제가 풀리지 않았다. 배고픔 감소 시계가 급식과 무관하게 돌면 방금 배불리 먹인 뒤에도 곧바로 한 칸이 줄 수 있었다. 수면 중에 밀린 감소를 기상 순간에 몰아 적용하면 아침마다 상태가 나빠졌다.
그래서 타이머의 기준 시각을 돌봄 행동과 맞췄다. 급식으로 배고픔 값이 오르면 감소 기준을 급식 시각으로, 훈련이나 비타민으로 힘이 오르면 힘 감소 기준을 그 시각으로 바꾸었다. 청소가 끝나면 똥 생성 기준도 다시 잡았다. 기상할 때는 감소와 생성 기준을 모두 현재 시각으로 돌렸다.
09시, 13시, 19시에 방문하는 대표 시나리오를 계산했다. 09시에 네 칸을 채우고 계속 깨어 있으면 배고픔이 0이 되는 기준 시각은 11시 40분이었다. 13시 방문까지는 호출이 120분 이어지지 않아 실수가 쌓이지 않았다. 13시 급식 뒤에는 15시 40분부터 배고파지고, 17시 40분에 한 번 가산된 뒤 19시 방문으로 호출을 끝냈다.
방문을 09시와 19시 두 번으로 줄이면 같은 조건에서 호출이 더 길게 이어졌다. 13시 40분, 15시 40분, 17시 40분에 가산되는 차이가 생겼다. 체크인 횟수와 케어 판정을 연결하려면 감소 간격뿐 아니라 급식·청소·기상 시각까지 같은 가정으로 계산해야 했다.
똥이 없을 때 CLEAN을 눌러도 생성 시계가 다시 시작되는지는 따로 정했다. 이때는 NO POOP만 보이고 청소 연출이 없으므로 기준 시각도 바꾸지 않았다. 같은 프레임에 취침과 배고픔 감소가 겹치면 취침을 우선했다. 기준을 적지 않으면 구현 순서에 따라 다른 게임이 되는 부분이었다.
수면은 자동으로, 조명은 직접 조작하게 나눴다
처음 수면 시간표는 단계별로 정했다. 어린 종은 일찍 자고 일부 그림자·유령 종만 늦게 자는 방식이었다. 이후 종마다 다른 리듬을 주기 위해 ID에서 취침과 기상 시각을 계산하는 쪽으로 바꾸었다.
같은 종이면 같은 시간표가 나오게 문자열 해시를 썼다. 몬스터 데이터에 직접 적은 시간이 있으면 그 값을 우선하고, 없으면 ID로 계산했다.
function sleepWindow(m: Monster): { sleepAt: number; wakeAt: number } {
if (
m.sleepAt !== null && m.sleepAt !== undefined &&
m.wakeAt !== null && m.wakeAt !== undefined
) {
return { sleepAt: m.sleepAt, wakeAt: m.wakeAt }
}
const h = hashId(m.id)
const sleepAt = h % 24
const len = 3 + (Math.floor(h / 24) % 12)
return { sleepAt, wakeAt: (sleepAt + len) % 24 }
}설계상 취침은 023시, 수면 길이는 314시간이었다. 낮에 자는 종도 생기는 규칙이다. 실제 시각은 기기의 현지 시간으로 읽되, 모든 종이 밤에 잔다는 뜻으로 두지는 않았다.
LIGHT 버튼은 잠을 켜고 끄는 기능에서 조명을 조작하는 메뉴로 바꾸었다. 잠은 시간표가 결정하고 사용자는 불만 켰다 껐다 했다. 취침할 때 이미 꺼져 있거나 잠든 뒤 30분 안에 끄면 넘어가고, 켜 둔 채 유예 시간을 넘기면 수면 방해 횟수를 올렸다.
소등을 놓친 판정은 수면 한 번에 한 번만 했다. 자는 동안 불을 다시 켜는 행동은 별도로 수면 방해를 올렸다. 이 값을 일반 케어 실수에도 중복해서 더하지 않도록 호출 조건에서 소등 방치를 분리했다. 같은 행동을 두 지표에서 벌주지 않기 위해서였다.
먹기나 진화 연출 도중에는 취침을 미루고, 연출이 끝나 MAIN으로 돌아온 뒤 재우는 규칙도 넣었다. 자는 펫을 훈련하거나 대전시키는 입력은 막았다. 어떤 화면에 머물러 있느냐가 수면을 무한히 피하는 방법이 되지 않도록 조회 화면과 행동 화면을 구별했다.
치료는 즉시 상태만 바꾸던 동작에 주사기 연출을 붙이는 것으로 정했다. 아플 때는 주사기와 움찔하는 동작을 보여 주고 연출 중간에 병과 굶주림·사망 카운트다운을 비웠다. 아프지 않을 때는 고개를 젓고 OK를 보이게 했다. 치료 효과와 화면에서 효과를 확인하는 시점을 함께 정한 것이다.
펫의 위치도 배경 산의 바닥선에 발이 닿도록 아래로 옮기는 기준을 잡았다. 좌표 값으로 끝내지 않고 화면을 보고 미세 조정하는 항목으로 나눴다. 타이머 수용 기준과 외형 검수는 같은 테스트로 처리하지 않았다.
수명을 없애자 새 알로 돌아가는 방식도 달라졌다
수명으로 끝나는 졸업을 없애면서 사망은 방치 경로 하나가 되었다. 병이 들고 배고픔이 0인 상태가 함께 24시간 이어질 때만 사망했다. 잘 돌보면 성숙기나 완전체인 채 계속 살 수 있었다.
새 몬스터를 키우기 위한 자발적인 이별도 이 설계에서는 넣지 않았다. 수집을 위해 현재 펫을 떠나보내는 선택에 비용을 남기려는 결정이었다. 건강한 펫을 바로 지우는 버튼보다, 돌봄을 끊어야 새 생애가 시작되는 쪽을 골랐다.
진화에서도 시간만 차면 넘어가는 방식을 일부 바꿨다. 성숙기에서 완전체로 가려면 대전 10회 이상과 승리 8회 이상을 채우게 했다. 조건을 못 채우면 성숙기에 머물고 다시 판정했다. 낮은 단계처럼 마지막 진화 대상으로 넘어가는 예외 처리는 완전체 관문에 두지 않았다.
케어 실수, 훈련, 과식, 수면 방해, 대전, 승리 횟수는 진화할 때 초기화했다. 각 단계에서 한 행동으로 다음 분기를 결정하려고 했다. 생애 전체 기록은 별도 누계로 남겨 단계별 조건과 섞이지 않게 했다.
개발용 배속이 저장 시각을 망가뜨릴 수 있었다
실제 시간으로 진화가 흐르면 개발 중에 결과를 기다리기 어려웠다. 그래서 개발 모드에서만 시간을 120배로 빠르게 보내는 설계를 넣었다. 실제 1분이 게임 2시간이 되고, 하루는 12분으로 줄었다.
문제는 가속한 시각을 그대로 저장하는 방식이었다. bornAt 같은 절대 시각이 실제 시간보다 미래가 되면 배율을 바꾸거나 일반 실행으로 돌아왔을 때 나이가 음수가 될 수 있었다. 화면 확인을 위한 기능이 저장 형식까지 건드렸다.
프로덕션에서는 Date.now()를 그대로 쓰고, 개발에서는 누적 시계와 배율 정보를 따로 저장하도록 설계를 바꾸었다. 로드할 때 저장된 배율과 현재 배율이 다르면 기준 시각을 다시 잡았다. 개발용 저장은 그 과정에서 일부 시간 기준을 초기화하는 것으로 범위를 제한했다.
구 저장을 버리지 않는 방법도 함께 정했다. 세이브 버전은 수면 필드를 넣은 v2, 생애 필드와 실시간 진화를 넣은 v3로 나누었다. v3 이전 저장에서 펫과 단계, 활동시간, 도감은 유지하되 데모 속도로 쌓인 진화 지표 여섯 개는 한 번 비웠다. 같은 숫자라도 데모와 실제 시간에서 의미가 달랐기 때문이다.
앱을 닫아 둔 시간에도 같은 규칙을 썼다
오프라인 정산은 경과 시간에 감소량만 곱하는 방식으로 만들지 않았다. 중간에 잠들고, 병들고, 진화하는 순서가 있기 때문이다. 마지막으로 반영한 시각부터 현재까지 update를 1분 간격으로 반복 호출하는 설계를 택했다.
실시간과 정산이 같은 함수를 쓰게 했지만, 모든 프레임 간격에서 결과가 같다는 보장으로 삼지는 않았다. 검증은 긴 시간 점프와 1분 간격 진행을 비교하는 방식으로 정했다. 사망하면 반복을 끝내고, 실제 반복 횟수는 종과 시작 상태에 따라 달라지는 것으로 두었다.
정산을 붙이면서 저장 필드도 더 필요해졌다. 사망까지 남은 시간을 재는 deathSince와 호출 시작 시각인 careCallSince를 저장하지 않으면 앱을 다시 여는 것만으로 시간이 초기화될 수 있었다. 옛 저장에 오래된 배고픔 기준이 남아 로드 직후 병드는 경우도 막도록 초기값을 정했다.
연출 상태는 복귀 전에 비웠다. 먹기와 치료 연출을 해제한 뒤에도 훈련·대전 화면만 남으면 뒷받침하는 상태가 없는 화면이 되었다. 정산 시작 때 해당 화면도 MAIN으로 돌리는 규칙을 넣었다.
구현 계획에는 시작하지 말아야 할 조건도 적었다
문서마다 같은 규칙을 다시 적다 보니 충돌하는 곳이 생겼다. 수면·조명·치료는 수면 설계를 기준으로 삼고, 진화·사망·저장·정산은 실시간 생애 설계에서 맡도록 나누었다. 행복도처럼 결정이 확정되지 않은 기능은 이번 구현 범위에서 뺐다.
구현 계획에는 사전 조건을 확인하는 Task 0을 두었다. 수면 상수와 저장 버전이 준비되지 않았으면 실시간 진화 작업을 시작하지 않도록 했다. 이어지는 작업 14개는 진화, 케어, 생애 기록, 저장과 정산을 묶어 진행하도록 나눴다.
편집 위치도 줄 번호에서 함수 이름과 검색 패턴으로 바꾸었다. 엔진에 코드가 조금만 늘어도 줄 번호는 다른 곳을 가리켰다. s.poop = 0 같은 대입은 청소뿐 아니라 리셋에도 있으므로 검색 결과를 찾는 것과 바꿀 지점을 고르는 일도 구별했다.
테스트에는 실패해야 하는 조건을 구체적으로 적었다. 호출을 240분 방치했을 때 실수 두 번이 쌓이는지, 완전체 관문이 부족하면 멈추는지, 구 세이브의 펫이 유지되는지를 값으로 확인하게 했다. 화면 위치와 아이콘처럼 눈으로 판단하는 항목은 자동 테스트 통과와 따로 남겼다.
QR 대전 설계에서도 계산 순서를 문서로 고정했다. 양쪽 기기가 같은 두 페이로드를 받아 결과를 계산하도록 하고, 해시와 난수 결과를 비교할 예시 값을 넣었다. 새 합체 종이 도감에 들어오므로 화면 분모도 고정 숫자 대신 실제 도감 길이를 쓰게 했다.
코드를 쓰지 않았어도 구현 전에 없앨 수 있는 모호함은 많았다. 먹인 순간 감소 시계를 다시 시작하는지, 취침과 감소가 겹치면 무엇을 먼저 하는지, 앱을 다시 켜면 방치 시간이 유지되는지까지 문장과 숫자로 맞췄다. 기능 이름보다 경계 시각과 저장 필드를 정리하는 데 시간이 더 들어갔다.