본문으로 건너뛰기

dotgimon 제작기 13. 최종형을 위한 무한 챌린지

·16 min read·13 / 32

dotgimon에서 최종진화체를 얻고 나면 밥을 주고 똥을 치우는 돌봄은 계속되지만, 전투로 향할 다음 목표는 없었다. 일반 SOLO 대전은 진화에 필요한 대전 수와 승률을 쌓는 기능이었다. 더 진화할 곳이 없는 몬스터에게는 기록을 쌓을 별도 도전을 만들었다.

날짜는 작업 순서에 맞춰 배치했다. 실제 변경은 9월 30일부터 10월 1일 전후에 저장소에 반영했다. CLG라는 별도 챌린지가 있던 시점의 구조와 그 안에서 궁극체를 연결하며 조정한 수치를 기록했다.

강해지는 보상보다 기록을 남기는 쪽을 골랐다

서버 랭킹을 붙이는 방법도 있었지만 이 게임에는 계정이나 서버가 없었다. 로컬 프로필 하나로 키우는 게임에 인증과 랭킹 운영을 더하고 싶지는 않았다.

도전 보상으로 능력치를 올리면 통신 대전이 달라졌다. 챌린지를 하지 않은 몬스터가 불리해져, 친구와 싸우기 전에 챌린지를 돌아야 할 수 있었다. 100스테이지처럼 마지막 단계를 정하면 끝에 도달한 뒤 다시 같은 문제가 생겼다.

최종 클리어 지점을 따로 두지 않는 사다리로 만들고, 보상은 프레임과 칭호에 한정했다. 기록은 로컬에 저장했다. 최고 기록을 조작해도 그 값 자체가 전투 능력치를 올리지는 않도록 분리했다.

최종형은 단계 이름만으로 정하지 않았다

최종형 판정은 stage 4 이상이면서 진화 대상이 없는 종으로 정했다. 완전체라도 궁극체로 이어지는 종이면 대상에서 빠졌다.

// src/game/walking-growth.ts
export function isFinalForm(id: string) {
  const mon = BY_ID[id]
  return !!mon && mon.stage >= 4 && mon.evo.length === 0
}

궁극체 연결 뒤의 테스트에서는 Steelrex와 Cindrake는 최종형이 아니고 Vulcarion과 Astravern은 최종형이었다. 같은 완전체라는 이름만으로 참가 자격을 정할 수 없었다.

최종형의 대전 선택 화면에는 SOLO 대신 CLG를 표시했다. 윗줄도 진화용 승률 대신 최고 기록을 보여줬다.

// src/game/engine/render-status.ts (발췌)
drawTextCentered(ctx, isFinalForm(s.id) ? `BEST ${challengeStageLabel(s.challenge.best)}` : `WIN ${rate}`, 32, 17)
drawOption(ctx, isFinalForm(s.id) ? 'CLG' : 'SOLO', 18, 34, s.vselSel === 0)
drawOption(ctx, 'LINK', 46, 34, s.vselSel === 1)

이전 단계는 기존 SOLO를 사용했다. CLG에서는 항상 최고 클리어 기록보다 한 단계 높은 곳에 도전했다. 이기면 B로 다음 단계, 지면 B로 같은 단계에 다시 도전하고 C로 나왔다. 이미 깬 단계를 골라 반복하는 구조는 넣지 않았다.

상대는 여섯 종으로 고정하고 배율만 올렸다

상대를 무작위로 뽑으면 같은 스테이지에서도 난이도가 바뀐다. 종을 추가할 때 기존 도전의 상대까지 바뀌지 않도록 여섯 종의 순서를 고정했다. 이 규칙에는 CHALLENGE_RULESET = 1이라는 버전 번호를 붙여 기록과 함께 저장했다.

// src/game/challenge.ts (발췌)
const OPPONENTS = ['steelrex', 'pebbit', 'hugbear', 'implet', 'viperon', 'eggnaut'] as const
export function challengeEncounter(stage: number) {
  if (!Number.isSafeInteger(stage) || stage < 1) throw new Error('잘못된 챌린지 스테이지예요.')
  const boss = stage % 10 === 0
  const enemy = OPPONENTS[(stage - 1) % OPPONENTS.length]
  const base = combatStats({ i: enemy, t: 24, g: 4 })
  const scale = (.85 + .004 * (stage - 1)) * (boss ? 1.06 : 1)
  return { enemy, boss, stats: { ...base, maxHP: Math.max(1, Math.round(base.maxHP * scale)), attack: base.attack * scale } }
}

상대는 Steelrex, Pebbit, Hugbear, Implet, Viperon, Eggnaut 순서로 반복했다. 훈련 24·근력 4 상태의 기본 능력치에 0.85 + 0.004 × (스테이지 - 1)을 곱했다. 보스 보정을 빼면 1스테이지 0.85배, 50스테이지 1.046배, 100스테이지 1.246배, 250스테이지 1.846배였다.

10스테이지마다 보스 보정 1.06을 추가로 곱했다. 10스테이지는 약 0.939배로 일반 11스테이지의 0.89배보다 높았다. 종 상성과 보스 때문에 바로 다음 단계가 언제나 더 어렵게 느껴지는 것은 아니었다.

테스트에서는 1부터 10억까지 일부 스테이지를 골라 같은 입력의 재현성, 유한한 능력치와 안전 정수 HP, 30스테이지 뒤의 증가를 확인했다. 종착점을 정하지 않았다는 것과 모든 숫자 범위를 검사했다는 것은 달랐다. 화면에는 10만 이상을 1.0E5 같은 지수 표기로 줄이고 저장에는 정확한 정수를 남겼다.

상한을 넘긴 성장점은 챌린지에서만 썼다

플레이어에게도 더 성장할 여지를 줬다. 숨은 만보기의 하루 10,000보 제한은 유지하되 최종형은 성장점을 계속 쌓게 했다. 일반 대전의 단계별 상한을 넘긴 점수는 챌린지에서만 로그 보너스로 계산했다. 같은 양을 더 걸어도 추가 보너스는 점점 줄어드는 곡선이었다.

// src/game/walking-growth.ts, src/game/challenge.ts (발췌)
export function soloWalkingBonus(points: number, id: string) {
  const stage = BY_ID[id]?.stage ?? 0
  const cap = WALK_STAGE_CAP[stage]
  const safe = Number.isSafeInteger(points) && points > 0 ? points : 0
  return walkingAttackBonus(safe, stage) + (isFinalForm(id) ? .2 * Math.log1p(Math.max(0, safe - cap) / 400) : 0)
}
 
export function challengePlayerStats(mon: CombatInput): CombatStats {
  const base = combatStats(mon)
  if (!isFinalForm(mon.i)) return base
  const factor = 1 + soloWalkingBonus(mon.p ?? 0, mon.i)
  const pvp = 1 + walkingAttackBonus(mon.p ?? 0, BY_ID[mon.i].stage)
  return { ...base, maxHP: Math.max(1, Math.round(base.maxHP * factor)), attack: base.attack / pvp * factor }
}

보너스는 일반 대전용 성장 보정에 0.2 × ln(1 + 상한 초과 점수 / 400)을 더한 값이다. 궁극체의 일반 대전 상한은 48점이었다. 아래는 궁극체 기준 계산이고, 초기 완전체 실측에서는 상한 36점을 썼다.

성장점궁극체의 챌린지 보너스
48+1.8%
448약 +15.7%
2,048약 +37.6%

0점에서 2,048점까지는 하루 20점을 받아도 최소 103일의 할당량이 필요했다. 이 보너스는 HP와 공격력에만 곱했다. 치명타와 회피 확률은 성장점에 따라 계속 오르지 않았다.

combatStats의 공격력에는 일반 대전용 걷기 보정이 이미 들어 있었다. 여기에 챌린지 배율을 그대로 곱하면 기본 보정이 겹친다. attack / pvp * factor로 기존 보정을 나눈 뒤 챌린지 배율을 적용했다.

통신 스냅샷의 성장점은 단계별 상한으로 잘라 보냈다. 챌린지에 들어갈 때는 playerStats를 계산해 고정했다. 전투 중 근력을 0으로 바꾸고 걸음 10,000보를 넣어도 진입 때의 능력치와 성장점이 그대로인지 테스트했다.

패배 때문에 펫이 아프지는 않게 했다

일반 대전은 승리 시 근력이 오르고 패배 시 체중이 줄며 15% 확률로 병에 걸렸다. 챌린지도 같은 결과 처리를 쓰면 계속 도전할수록 돌봄 부담이 늘어난다. 챌린지 결과는 따로 기록한 뒤 일반 처리에 들어가기 전에 반환했다.

// src/game/engine/duel-result.ts (발췌)
if (d.challengeStage) {
  s.challenge = finishChallenge(s.challenge, d.challengeStage, result === 'win', s.id, d.growthAtEntry ?? s.walkPoints, now)
  if (result === 'win') this.sound.happy(); else this.sound.error()
  this.record(result === 'win' ? 'challenge_win' : 'challenge_lose', now)
  return
}
// 이하 일반 대전: battleCount++, winCount++, 근력 +1 / 체중 -1, 15% 확률 질병 ...

진화용 대전 수와 승수도 바꾸지 않았다. 패배 전후의 질병, 체중, 근력, 대전 수, 승수가 같고 같은 스테이지에 재도전할 수 있는지 검사했다.

기록에는 최고 단계와 시도·승리 횟수, 최고 기록의 날짜·종·성장점, 규칙 버전을 남겼다. 현재 최고 기록의 다음 단계 결과만 받았다.

// src/game/challenge.ts (발췌)
export function finishChallenge(record: ChallengeRecord, stage: number, win: boolean, species: string, growth: number, now: number): ChallengeRecord {
  if (stage !== record.best + 1) return record
  return { ...record, attempts: record.attempts + 1, wins: record.wins + Number(win),
    ...(win ? { best: stage, bestAt: now, speciesAtBest: species, growthAtBest: growth, ruleset: CHALLENGE_RULESET } : {}) }
}

매번 최고+1에서만 이기므로 best === wins가 성립했다. 저장을 읽을 때도 이 조건을 확인해 best: 99, wins: 0 같은 값은 거절했다. 전투 도중 C로 나간 도전은 기록하지 않았다.

성장점은 진입할 때 growthAtEntry에 따로 보관했다. 최고 기록에 남는 값도 전투를 시작했을 때의 점수였다. 결과를 저장하는 시점의 상태와 섞이지 않게 했다.

프레임과 칭호는 전투 계산에서 빠졌다

기록 구간마다 프레임 색과 칭호를 붙였다. 최고 10·25·50·100스테이지에서 브론즈·실버·골드·플래티넘으로 바뀌고, 250부터는 왕관 숫자가 250스테이지마다 하나씩 늘었다.

// src/game/challenge.ts (발췌)
export function challengeHonor(best: number) {
  if (best >= 250) return { title: `왕관의 수호자 ${Math.floor(best / 250)}`, label: 'CROWN', ... }
  if (best >= 100) return { title: '백전의 수호자', label: 'PLATINUM', ... }
  if (best >= 50)  return { title: '황금의 수호자', label: 'GOLD', ... }
  if (best >= 25)  return { title: '은빛 도전자', label: 'SILVER', ... }
  if (best >= 10)  return { title: '첫 번째 돌파', label: 'BRONZE', ... }
  return { title: '도전자', label: 'CHALLENGER', ... }
}

본체 LCD 베젤은 최고 기록 10부터 칭호 색을 사용했다. 통신 상대 카드에도 최고 단계와 칭호를 보여줬다. 입력에 c: 1000이라는 챌린지 기록을 넣어도 넣지 않은 경우와 combatStats가 같은지 테스트했다.

명예의 전당에는 세대별 최고 기록과 칭호, 최고 기록을 세운 종과 날짜, 듀얼 승수·대전 수·최고 연승을 남겼다. 새 생애에서는 챌린지 기록을 0으로 시작하고 이전 생애 기록은 보존했다.

듀얼 전적에는 LINK 결과만 넣었다. 기존에 SOLO와 통신 대전이 섞여 있던 기록은 PvP 전적으로 옮기지 않았다. 구분되지 않은 결과를 통신 승패로 보여줄 수는 없었다.

단일 스테이지 승률로 곡선을 확인했다

밸런스 테스트는 게이지 정지, 라운드별 공격, 치명타와 회피, 결과 전환까지 실제 전투 경로를 사용했다. 성장점 48/448/2,048과 스테이지 1/150/250/400/750 조합에 시드 500개씩을 적용했다.

같은 성장점에서 1스테이지가 750스테이지보다 승률이 높은지, 같은 150·250·400스테이지에서 성장점을 늘리면 승률이 올라가는지 확인했다. 치명타와 회피도 실제로 발생하는지 검사했다.

초기 Steelrex 실측은 완전체 상한 36점을 기준으로 남겼다. 훈련 24·근력 4, GOOD 게이지, 매 라운드 공격 조건의 각 500시드 결과였다.

스테이지성장점 36성장점 436성장점 2,036
5037.4%65.4%96.2%
1005.2%34.6%75.0%
2500%0%3.8%

연속으로 여러 단계를 돌파할 확률이 아니라 한 스테이지의 승률이었다. 모든 종이나 상태의 밸런스를 확인한 결과도 아니었다. 성장점을 더하면 같은 단계에서 유리해졌지만 큰 능력치 격차를 늘 뒤집지는 못했다.

진화용 대전 수와 통신 능력치를 건드리지 않고도 최종형에게 도전 기록을 남길 수 있었다. 패배하면 같은 단계에 다시 도전하고, 새 생애에서는 0부터 시작했다. 이전 세대가 어디까지 갔는지는 명예의 전당에 남았다.