dotgimon 제작기 12. 보이지 않는 만보기와 성장 보상
dotgimon에 폰의 걸음 수를 연결했다. 걸음 수를 읽을 수 있는 기기에서 몬스터에게 작은 성장 보상을 주고 싶었다. 다만 숫자를 채우려고 걷는 게임이 되지 않도록 걸음 수 표시와 목표, 팝업, 별도 메뉴는 만들지 않았다.
날짜는 작업 순서에 맞춰 배치했다. 실제 변경은 9월 30일부터 10월 1일 전후에 저장소에 반영했다. 초기 규칙과 궁극체 연결 뒤의 보정 조정을 함께 기록했다.
보상이 안 보여도 중복 집계는 막아야 했다
작은 LCD에 걸음 수와 목표를 넣으면 화면에 요소가 늘고, 사용자가 몬스터보다 남은 걸음을 계산하게 될 수 있었다. 걸으면 조용히 조금 강해지는 숨은 요소로 두었다. 보이지 않는 만큼 중복 집계나 저장 오류도 눈치채기 어려워 센서와 게임 규칙을 분리했다.
Expo Pedometer의 watchStepCount는 걸음 갱신을 구독하는 API다. 사용한 구현은 관찰을 시작한 뒤의 누적값을 보냈다. 매번 받은 수를 그대로 더하면 이미 센 걸음을 다시 더하게 된다.
WalkingCounter는 증가분만 계산했다. 센서, 저장, 권한 처리는 WalkingPorts로 외부에서 넘겨 센서 없이도 검사할 수 있게 했다.
// src/walking/counter.ts (발췌)
const revision = this.revision
...
let previous = 0
this.subscription = this.ports.watch(steps => {
if (revision !== this.revision || !this.state.enabled || !this.foreground) return
// Native events are cumulative within each subscription; never sum them directly.
if (!Number.isSafeInteger(steps) || steps <= previous) return
const delta = steps - previous
const total = this.state.total + delta
if (!Number.isSafeInteger(total)) return
try {
this.ports.save(total)
previous = steps
this.update({ total, session: this.state.session + delta })
this.ports.onSteps?.(delta)
} catch {
this.stop()
this.update({ error: '걸음 기록을 저장하지 못해 측정을 멈췄어요. ...' })
}
})구독마다 previous를 0으로 두고 steps - previous만 더했다. 안전한 정수가 아니거나 이전 값보다 줄거나 같으면 무시했다. 합산한 값도 안전 정수인지 확인한 뒤 저장하고, 저장에 성공해야 previous와 화면 밖의 집계 상태를 갱신했다.
앱이 백그라운드로 가면 구독을 끊고, 돌아왔을 때 새로 시작했다. 권한 응답을 기다리는 중에 앱 상태가 바뀔 수도 있었다. 구독을 끊을 때마다 revision을 올리고, 오래된 비동기 요청은 번호가 달라졌으면 구독을 붙이지 않고 끝냈다.
useForegroundPedometer에서는 진행 중인 권한 요청 Promise를 공유했다. 앱이 inactive와 active 사이를 오가도 같은 요청을 두 번 보내지 않으려는 처리였다. 한 번 거절한 권한은 다시 묻지 않았다. 숨은 기능 때문에 권한 팝업이 반복되는 것은 원하지 않았다.
저장에 실패하면 측정을 멈추고 앱이 다시 포그라운드로 올 때 재시도했다. 실제 걷기와 폰 흔들기를 구분하는 판정은 넣지 않았다. 서버 검증이 없는 로컬 게임에서 센서가 보고한 걸음을 받아들이는 규칙으로 정했다.
하루 1만 보와 단계별 상한을 같이 뒀다
걸음 보상 규칙은 walking-growth.ts에 모았다. 500보마다 성장 1점을 주고, 하루에는 최대 10,000보를 인정했다.
// src/game/walking-growth.ts (발췌)
export const WALK_STEPS_PER_POINT = 500
export const WALK_DAILY_STEPS = 10000
export const WALK_ATTACK_PER_POINT = 0.0005
export const WALK_STAGE_CAP = [0, 0, 12, 24, 36, 48] as const성장기 상한은 12점, 성숙기는 24점, 완전체는 36점, 궁극체는 48점이었다. 유년기 두 단계에는 점수를 주지 않았다. 성장기 12점은 6,000보, 궁극체 48점은 24,000보에 해당했다.
기본 공격 보정은 1점당 0.05%로 잡았다. 성장기는 최대 0.6%, 성숙기는 1.2%, 완전체는 1.8%였다. 궁극체도 처음에는 이 비율을 썼지만 같은 종끼리의 승률 검사를 거쳐 별도로 낮췄다.
earnWalking은 현재 상태와 걸음 수를 받아 새 상태를 돌려주는 순수 함수로 만들었다. 센서를 직접 읽거나 저장하지 않아 날짜와 걸음 수의 경계를 따로 테스트할 수 있었다.
// src/game/walking-growth.ts (발췌)
export function earnWalking(current: WalkingGrowth, steps: number, stage: number, now: number, unlimited = false): WalkingGrowth {
if (!Number.isSafeInteger(steps) || steps <= 0) return current
// A backward calendar change must not grant a second daily allowance.
if (current.walkDay && day < current.walkDay) return current
...
if (day > next.walkDay) { next.walkDay = day; next.walkDaySteps = 0 }
const cap = unlimited ? Number.MAX_SAFE_INTEGER : WALK_STAGE_CAP[stage] ?? 0
if (next.walkPoints >= cap) { next.walkRemainder = 0; return next }
const accepted = Math.min(steps, WALK_DAILY_STEPS - next.walkDaySteps,
(cap - next.walkPoints) * WALK_STEPS_PER_POINT - next.walkRemainder)
next.walkDaySteps += accepted
const progress = next.walkRemainder + accepted
next.walkPoints += Math.floor(progress / WALK_STEPS_PER_POINT)
next.walkRemainder = progress % WALK_STEPS_PER_POINT
return next
}상태에는 성장점 walkPoints, 500보 미만 잔여인 walkRemainder, 기준 날짜 walkDay, 당일 인정량 walkDaySteps를 저장했다. 499보에서 끝나도 다음 걸음과 합칠 수 있었다.
한꺼번에 큰 값이 들어와도 하루 20점을 넘지 않게 했다. 999,999보를 넣은 테스트에서는 성장 20점과 당일 10,000보만 인정했다. 날짜를 뒤로 바꾸면 기존 기준 날짜보다 이르므로 보상을 주지 않았다.
단계 상한을 넘는 걸음은 쌓아두지 않았다. 상한에 도달하면 잔여 걸음도 0으로 지웠다. 남겨 두면 진화한 순간 새로 걷지 않아도 보상이 몰려 들어올 수 있었다.
최종진화체는 예외였다. stage 4 이상이고 더 진화할 곳이 없는 종은 상한 없이 점수를 쌓도록 unlimited를 넘겼다. 일반 대전에서는 단계 상한까지만 계산하고, 초과분은 챌린지 전용 보너스에 썼다.
리셋해도 오늘 받은 몫은 되돌리지 않았다
진화와 합체에서는 성장점을 유지했다. 새 알로 시작하는 새 생애에서는 성장점과 잔여 걸음을 0으로 돌리되, 기기 단위의 당일 인정량은 남겼다. 리셋으로 하루 보상을 다시 받는 것을 막았다.
2,000보 뒤 리셋한 테스트에서 [walkPoints, walkRemainder, walkDaySteps]는 [0, 0, 2000]이었다. 같은 날 99,999보를 더 넣어도 남은 8,000보만 인정해 16점이 올랐다.
센서가 걸음을 보고해도 모든 게임 상태에서 받아들이지는 않았다. 알, 사망, 질병, 수면, 진화나 리셋 연출, 대전과 통신 중에는 보상을 주지 않았다.
// src/game/engine/link-battle.ts (발췌)
addWalkingSteps(this: Game, steps: number, now: number) {
const s = this.s
if (!s.hatched || s.dead || s.sick || s.sleeping || s.evolveAnim || s.resetAnim ||
['BATTLE', 'DUEL', 'CONNECT', 'GACHA'].includes(s.screen)) return
Object.assign(s, earnWalking(s, steps, this.stageIdx(), now, isFinalForm(s.id)))
}대전과 통신 중에는 이미 능력치 스냅샷을 주고받고 있었다. 그 사이 보상을 더하면 합의한 입력과 현재 상태가 달라질 수 있어 제외했다. 같은 입력으로 같은 전투 결과를 계산하는 규칙을 유지하려는 처리였다.
성장점과 하루 인정량을 함께 저장했다
성장점만 저장되고 하루 인정량은 빠지면 같은 걸음에 보상을 다시 줄 수 있었다. 걸음 보상을 적용할 때 두 값을 펫 상태와 함께 같은 SQLite 트랜잭션으로 저장했다.
// src/game/use-game/care-actions.ts (발췌)
const before = { walkPoints: g.s.walkPoints, walkRemainder: g.s.walkRemainder,
walkDay: g.s.walkDay, walkDaySteps: g.s.walkDaySteps }
g.addWalkingSteps(steps, Date.now())
if (Object.entries(before).every(([key, value]) => g.s[key as keyof typeof before] === value)) return
try {
// Daily allowance and pet growth commit in the same SQLite transaction.
repo.save({ state: g.serialize(), discovered: [...discoveredRef.current] }, eventsRef.current)
eventsRef.current = []
} catch (error) {
Object.assign(g.s, before)
storageFailedRef.current = true
...
}값이 바뀌지 않았으면 저장하지 않았다. 저장에 실패하면 메모리의 걷기 상태를 이전 값으로 돌리고 기존 저장 오류 처리로 넘겼다.
저장을 읽는 parseSave에서도 잔여는 499 이하, 당일 인정량은 10,000 이하로 제한했다. 테스트에는 10,001을 넣었을 때 파싱을 거절하는 경우를 넣었다. 화면에 표시하지 않는 값도 저장을 읽는 경계에서 확인했다.
보너스는 훈련 횟수, MEGA HIT 기록, 치명타, 회피에는 넣지 않았다. 이 값들은 진화 조건과 이어져 있어 걸음으로 육성을 대신하지 않게 했다. 성장기 Rexling에 48점을 넣어도 단계 상한 12점만 반영해 공격력이 1.006배가 되고 다른 스탯은 같은지 검사했다.
2.4% 보너스에서 승률이 62.3%까지 올랐다
작은 수치만 보고 밸런스를 판단하지 않았다. 기존 simulateCombat으로 훈련 12·근력 4 조건을 고정하고 같은 종끼리 비교했다. 한쪽에만 단계별 최대 성장점을 준 뒤 종마다 시드 0~999의 1,000전을 돌렸다.
통과 기준은 최대 성장 개체의 승률 60% 미만, 무보상 대비 상승폭 8%p 미만이었다. 다음 진화형과의 100개 조합은 각 500전씩 돌려 하위 단계의 승률이 50% 미만인지도 확인했다.
궁극체를 실제 게임에 연결하고 같은 검사를 돌렸을 때 냉기 계통 한 종의 보정 승률이 62.3%였다. 48점에 기존 0.05%를 곱하면 최대 공격 보너스가 2.4%로 완전체보다 컸다. 궁극체의 점수 상한은 유지하고 1점당 공격 보정만 0.0375%로 낮췄다.
// src/game/walking-growth.ts (발췌)
export function walkingAttackBonus(points: number, stage: number) {
// Ultimate keeps 48 growth points, with the same 1.8% PvP ceiling as complete forms.
const perPoint = stage === 5 ? WALK_ATTACK_PER_POINT * 36 / 48 : WALK_ATTACK_PER_POINT
return Math.min(Math.max(0, Number.isFinite(points) ? Math.floor(points) : 0), WALK_STAGE_CAP[stage] ?? 0) * perPoint
}궁극체도 최대 보너스가 1.8%가 됐다. 조정 뒤 문서에 남긴 실측은 대전 가능한 81종에서 승률 49.0~58.3%, 무보상 대비 최대 상승폭 6.9%p였다. 하위 단계가 다음 진화형을 이긴 비율은 최대 0.8%였다.
이 수치는 훈련 12·근력 4라는 한 조건의 시뮬레이션 결과였다. 모든 컨디션이나 종 조합을 보장하지는 않았다. 같은 조건의 같은 종끼리는 작은 이득이 있지만 이 검사에서는 진화 단계의 격차를 뒤집지 못했다.
성장점은 통신 스냅샷의 p 필드에도 실었다. 계산 입력이 달라졌으므로 대전 프로토콜 버전을 올려 구버전 제안을 거절했다. 화면에는 숫자 하나 추가하지 않았지만 저장과 대전 규칙은 함께 바뀌었다.
일반 대전의 이득은 공격력 최대 1.8%로 제한했다. 최종형의 초과 성장점은 챌린지 계산에만 넘겼다. 걸음 수나 달성 팝업 없이, 앱이 포그라운드일 때 센서가 보고한 걸음을 하루 한도 안에서 보상하는 기능이 됐다.