꺼 둔 동안의 보상은 프레임이 아니라 시각 차로 센다

브라우저 탭이 숨으면 requestAnimationFrame 이 멈추고 전투 연출도 선다. 그래도 방치 보상은 하나도 새지 않는다. 마지막으로 받은 시각과 지금 시각의 차로 세기 때문이다.

방치형 게임을 브라우저 탭에 띄워 두고 다른 탭으로 넘어갔다 돌아오면, 그동안 전투 화면은 멈춰 있다. 탭이 숨으면 브라우저가 requestAnimationFrame 을 멈추기 때문이다. 앱을 아예 꺼 버리면 프레임은 한 장도 돌지 않는다.

그런데 방치형은 꺼 둔 동안에도 무언가 쌓여야 하는 장르다. 밤새 꺼 뒀다가 아침에 켰는데 아무것도 없다면 이 장르를 고를 이유가 없다. 프레임이 안 도는 동안의 보상은 어디서 세야 할까?

답은 프레임이 아니라 시계다. 마지막으로 받은 시각과 지금 시각의 차이만 있으면 된다. 만들고 있는 방치형 RPG 「브루니 키우기: 바랜 섬」의 core/idle_calc.gd 와 경제 명세서가 그 계산을 어떻게 나눠 맡는지 보자.

프레임으로 세면 꺼 둔 시간은 0이다

게임 루프 안에서 매 프레임 「1초에 골드 0.17개」씩 더하는 식이면 화면이 떠 있는 동안은 잘 돈다. 문제는 화면이 안 떠 있을 때다. 탭이 숨으면 루프가 서고, 앱을 끄면 루프 자체가 없다. 프레임으로 센 보상은 그 시간을 통째로 놓친다.

그래서 이 게임은 두 가지를 갈라 놓았다. 눈에 보이는 전투와 방치 연출은 프레임을 따라가다 탭이 숨으면 같이 멈춘다. 보상은 시각 차로 계산하니 잃지 않는다. 연출이 멈추는 건 그냥 둔다. 숫자만 맞으면 된다.

경과 분은 두 시각의 뺄셈 하나

꺼 둔 시간을 재는 함수는 이게 전부다.

gdscript
## 마지막 수령 이후 분. 기기 시계가 거꾸로 갔으면 0.
static func offline_minutes(last_ms: int, now_ms: int, device_backward: bool = false) -> int:
	if device_backward or now_ms <= last_ms:
		return 0
	return int((now_ms - last_ms) / 60000)

마지막 수령 시각과 지금 시각을 밀리초로 받아 뺀다. 60,000 으로 나눠 분으로 바꾸고 남는 초는 버린다. 59초를 꺼 뒀다면 0분이다.

주목할 건 맨 위의 조건이다. 지금 시각이 마지막 시각보다 같거나 이르면 0을 돌려준다. 호출하는 쪽이 기기 시계가 거꾸로 갔다고 알려 줘도(device_backward) 0이다. 음수 경과 시간이 보상 계산에 흘러 들어갈 길을 여기서 막는다.

이 함수는 상태를 바꾸지 않는다. 시각 두 개를 받아 분 하나를 돌려줄 뿐이다. 그래서 시험하기 쉽다. 실제로 보상을 지갑에 넣고 수령 시각을 갱신하는 건 다른 모듈(IdleBox)이 한다.

24시간을 넘긴 시간은 사라진다

경과 시간을 그대로 다 쳐 주지는 않는다. 상한이 있다. 기본은 24시간(1,440분), 유료 정기권인 「물빛 정기권」이 켜져 있으면 32시간(1,920분)이다.

gdscript
static func cap_minutes() -> int:
	var c := cfg()
	return int(c.cap_h_pass if pass_on() else c.cap_h) * 60

보상 함수는 경과 분을 clampi(elapsed_min, 0, maxi(0, cap_min)) 으로 상한 안에 묶는다. 그리고 상한을 넘었는지를 capped 로 같이 돌려준다. 명세서의 예시를 보면 이 플래그가 왜 필요한지 보인다. 150번째 판에서 30시간 동안 접속하지 않았다면 1,800분 중 1,440분만 쳐서 골드 275,288 을 준다. 넘친 6시간, 골드로 치면 68,822 는 사라진다. 그때 화면에 「가득 찼어요」를 띄우려면 넘쳤다는 사실을 알아야 한다.

같은 30시간이라도 정기권이 있으면 이야기가 다르다. 1,800분이 상한 1,920분 안에 들어가고 보상 20% 가 붙어 골드 412,932 가 된다. 정기권 없는 경우보다 골드가 50% 많다.

명세서의 참고 자료에는 다른 게임의 방치 상한도 적혀 있다. 「버섯커 키우기」 8시간, 「AFK 아레나」 12시간이다. 이 게임의 24시간은 그보다 길다.

시간당 값에 경과 분을 곱하고 마지막에 한 번 내린다

받을 양은 명세서 4장의 식 하나로 정한다.

지급량 = ⌊ 시간당(s) × min(경과 분, 상한 분) ÷ 60 × (1 + 보정 합) ⌋ × (광고 2배 ? 2 : 1)

시간당 값은 지금까지 깬 최고 판 번호 s 에 따라 오른다. 판이 하나 오를 때마다 2%씩 커진다.

재화시간당
골드600 × 1.02^(s−1)
물감 방울100 × 1.02^(s−1)
브루니 경험치300 × 1.02^(s−1)
붓6 + 장 번호 (1장 7 ~ 8장 14)

보정은 더하고 곱하지 않는다. 정기권 0.20 에 유물이나 장비의 % 를 더한 합이 (1 + 보정 합) 이 된다. 광고 2배만 맨 마지막에 곱한다. reward() 는 보정까지만 계산하고 2배는 부르는 쪽이 곱한다.

gdscript
	for k in KINDS:
		var b := pb + float(extra.get(k, 0.0))
		out[k] = int(floor(float(r[k]) * m / 60.0 * (1.0 + b) + 1e-9))

시간당 값은 내림하지 않고 소수 그대로 둔다. 내림은 재화마다 맨 끝에서 한 번만 한다. 경제를 미리 돌려 본 파이썬 시뮬레이터가 그렇게 계산하니, 게임도 같은 자리에서 내려야 숫자가 맞는다. 끝의 + 1e-9 는 부동소수 오차 때문이다. 딱 떨어져야 할 값이 아주 조금 모자라게 계산되면 내림 한 번에 1이 날아간다.

명세서에 단위 테스트 기대값으로 적힌 예가 있다. 150번째 판, 600분 경과, 골드 보정 합 +33.4% 면 ⌊11,470.35 × 600/60 × 1.334⌋ = 153,014 다. 광고 2배로 받으면 306,028 이고, 보정이 하나도 없으면 114,703 이다.

시계를 뒤로 돌리면 0을 준다

시각 차로 보상을 주면 바로 떠오르는 꼼수가 있다. 기기 시계를 앞으로 돌려 경과 시간을 부풀리는 것. 반대로 뒤로 돌려 버리면 어떻게 될까?

명세서의 예시 ④가 그 경우다. 210번째 판에서 기기 시계가 마지막 저장 시각보다 3시간 이전으로 가 있으면 경과 시간이 음수가 된다. 보상은 0. 막거나 경고를 띄우지는 않는다. 다음에 서버 시각을 받으면 그때 정상으로 계산한다. offline_minutes 의 첫 조건이 이 규칙을 그대로 옮긴 것이다.

앞으로 돌리는 쪽은 서버 시각이 맡는다. 명세서는 방치 보상을 서버 시각 기준으로 계산한다고 못 박아 뒀다. 예시 ⑤를 보자. 120번째 판에서 서버 응답이 없는데 기기 시계로는 30시간이 지났다. 일단 기기 기준으로 상한 1,440분을 쳐서 골드 151,978 을 먼저 주고 pending_verify 표시를 남긴다. 다음에 온라인이 됐을 때 서버 기준으로 실제 6시간이었다면, 차액 골드 113,984 를 다음 방치 보상에서 깎는다.

꺼 둔 동안 자동 돌파로 넘은 판은 되돌리지 않는다. 그 전투는 실제로 이긴 것이라서다. 깎는 건 재화 쪽뿐이다.

다만 지금 만든 1차판에는 서버가 없다. 그래서 offline_minutes 는 시계가 거꾸로 갔는지를 스스로 판단하지 않고 device_backward 로 넘겨받게 열어 뒀다. 서버 시각이 붙으면 그 판단을 부르는 쪽이 채운다.

꺼 둔 동안의 진행은 30분 칸으로 다시 돌린다

보상만 주고 끝내면 반쪽이다. 방치형은 꺼 둔 동안 캐릭터도 자라 있어야 한다. 그래서 앱을 켤 때 경과 시간을 30분 칸으로 나눠 꺼 둔 동안의 일을 순서대로 다시 돌린다. 칸마다 사냥 보상, 브루니 레벨업, 자동 장착, 다음 판 자동 도전이다. 자동 도전은 최대 10판까지이고 보스 판이나 장 끝, 전투력 80% 미만, 3패에서 멈춘다.

밤 10시에 끄고 아침 7시 20분에 켰다면(예시 ①) 560분이다. 30분 칸 18개를 돌리고 20분은 다음으로 넘긴다. 45번째 판 기준 보정 없이 골드 13,384, 방울 2,230, 경험치 6,692, 붓 74 가 쌓인다. 이건 사냥 보상만 센 값이다. 자동 돌파로 판이 오르면 그 뒤 칸은 시간당 값이 커지니 더 많아진다.

프레임은 멈춰도 된다. 시각 두 개만 정확하면 꺼 둔 시간은 하나도 새지 않는다.

핵심 한 줄꺼 둔 시간은 프레임이 아니라 두 시각의 차로 센다. 상한으로 자르고, 거꾸로 간 시계에는 0을 준다.

자주 묻는 것

방치형 게임 오프라인 보상 계산 방법

마지막 수령 시각과 지금 시각을 밀리초로 빼서 분으로 바꾼다. 상한(24시간, 정기권 32시간)으로 자른 뒤 시간당 값 × 분 ÷ 60 × (1 + 보정 합)을 재화마다 따로 내림한다. 광고 2배는 마지막에 곱한다.

기기 시계를 바꾸면 방치 보상이 늘어나나

뒤로 돌리면 경과 시간이 음수가 돼 보상이 0이다. 앞으로 돌리는 쪽은 서버 시각이 맡는다. 서버 응답이 없을 땐 기기 기준으로 먼저 주고, 다음 온라인 때 서버 기준으로 차액을 다음 방치 보상에서 깎는다.

브루니가 적는다

색을 바꾸며 숨는 카멜레온이다. 혼자 게임을 만들며 막힌 곳을 한 편씩 적는다. 같은 데서 두 번 막히지 않으려고 쓰는 기록이다.

게임 개발 글 10편 모두 보기 →