지난 편에서는 탈것 교체 시스템을 완성했습니다. 자전거에서 킥보드로, 킥보드에서 전동킥보드로, 더 좋은 탈것으로 갈아타는 선택이 생겼습니다. 하지만 “교체”만 있는 게임은 여전히 단조롭습니다. “지금 탈것을 사는 게 나을까, 아니면 지금 탈것을 더 강하게 키우는 게 나을까?” 이 딜레마가 없으면 방치형 게임의 재미 절반이 빠집니다.

4단계에서 구현한 것들은 다음과 같습니다

  1. enhance.ts, 강화 비용/수입 계산 순수 함수
  2. gameStore.ts, enhanceBike 액션
  3. EnhanceButton.tsx, 강화 UI 컴포넌트 (레벨, 비용, 수입 미리보기)
  4. App.tsx, 레이아웃 변경, 강화 버튼 배치

강화 비용 곡선 설계: 선형 vs 지수

강화 시스템에서 가장 먼저 결정해야 할 것은 “비용이 어떻게 증가하느냐”입니다. 이 공식이 게임 전체의 난이도 곡선을 결정합니다.

선형 증가

cost = baseIncome * 10 * level

레벨 1 강화 비용, 레벨 2 강화 비용, 레벨 3 강화 비용이 일정한 간격으로 늘어납니다. 자전거(baseIncome: 1) 기준으로 계산하면

       
레벨 강화 비용 강화 후 수입/초 비용 회수 시간
1→2 10원 1.2원/초 ~50초
5→6 50원 1.6원/초 ~125초
10→11 100원 2.1원/초 ~238초
20→21 200원 3.1원/초 ~323초

초반에는 빠른 성취감을 주지만, 후반으로 갈수록 수입 증가량(baseIncome × 0.1 = 고정)에 비해 비용이 선형으로 올라가므로 ROI가 계속 나빠집니다. 어느 순간부터 “강화는 안 하는 게 낫다”는 결론에 도달합니다. 더 큰 문제는 “자연적인 벽”이 없다는 것입니다. 돈이 쌓이면 레벨 100, 1000을 마구 올릴 수 있습니다. 강화가 의사결정이 아니라 그냥 클릭 반복이 됩니다.

지수 증가 (채택)

cost = baseIncome * 10 * Math.pow(1.15, level)

매 레벨마다 비용이 1.15배씩 늘어납니다.

자전거 기준

       
레벨 강화 비용 강화 후 수입/초 비용 회수 시간
1→2 11.5원 1.2원/초 ~57초
5→6 20.1원 1.6원/초 ~100초
10→11 40.5원 2.1원/초 ~293초
20→21 163원 3.1원/초 ~526초

초반에는 선형과 큰 차이가 없지만, 레벨이 쌓일수록 비용이 기하급수적으로 늘어납니다. 이것이 자연적인 강화 한계가 됩니다.

배율 1.15를 선택한 이유

배율 선택은 생각보다 섬세한 작업입니다.

  • 1.05: 너무 완만합니다. 레벨 50쯤 되어도 비용이 그렇게 크지 않아서 강화를 계속 하게 됩니다. 새 탈것을 살 동기가 약해집니다.
  • 1.30: 너무 가파릅니다. 레벨 5-6만 돼도 비용이 폭발적으로 올라서 강화가 의미 없어집니다. 레벨 3-4 이후에는 그냥 새 탈것을 모으는 게 압도적으로 유리합니다.
  • 1.15: 레벨 1~5 구간은 강화가 합리적이고, 레벨 10 이상부터 슬슬 “이걸 계속 강화하는 게 맞나?” 고민이 생깁니다. 이 고민이 방치형 게임의 핵심 재미입니다.

첫 강화의 ROI가 ~100초인 이유

첫 강화(lv.1 → lv.2)의 비용 회수 시간을 계산해보겠습니다.

강화 비용: baseIncome × 10 × 1.15^1 = baseIncome × 11.5
수입 증가: baseIncome × 0.1 (레벨 1 → 2 이므로 bikeLevel이 1 증가)
회수 시간: 11.5 / 0.1 = 115초

약 2분입니다. 게임 초반 플레이어가 화면을 보는 평균 세션 길이가 3-5분이라고 가정하면, 첫 강화의 ROI가 딱 “이번 세션 안에 회수된다” 수준입니다. 이것이 의도적인 설계입니다. 회수 시간이 30초면 너무 쉽습니다. 회수 시간이 10분이면 첫 강화 자체가 의미 없게 느껴집니다. 2분은 “합리적인 투자”처럼 느껴지는 황금 구간입니다. 레벨이 올라갈수록 회수 시간은 늘어납니다. 레벨 5 → 6 강화의 회수 시간은 약 200초, 레벨 10 → 11은 약 400초로 늘어납니다. 자연적으로 “이쯤에서 새 탈것으로 넘어가야겠다”는 임계점이 만들어집니다.


“강화 vs 다음 탈것” 딜레마

방치형 게임에서 가장 중요한 것은 플레이어에게 의미 있는 선택을 주는 것입니다. 강화 시스템의 존재 이유는 바로 이 딜레마입니다. 구체적인 수치로 살펴보겠습니다.

자전거를 lv.1로 타고 있는 초반 상황

현재: 자전거 lv.1 = 1 × (1 + 0.1 × 1) = 1.1원/초
킥보드 가격: 30원

선택 A: 킥보드를 바로 구매

30원을 모으는 시간: 30 / 1.1 ≈ 27초

킥보드 lv.1 수입: 8 × (1 + 0.1 × 1) = 8.8원/초

수입이 1.1 → 8.8로 약 8배 점프합니다.

선택 B: 자전거를 먼저 강화하고 킥보드 구매

lv.1 → lv.2 강화 비용: 1 × 10 × 1.15 ≈ 11.5원 (11.5초 소요)

lv.2 자전거 수입: 1 × (1 + 0.1 × 2) = 1.2원/초

킥보드까지 남은 금액: 30 - 11.5 = 18.5원

남은 금액 모으는 시간: 18.5 / 1.2 ≈ 15초

총 소요 시간: 11.5 + 15 = 26.5초

결과: 선택 A와 거의 동일한 시간에 킥보드를 탑니다. 초반에는 강화가 그다지 유리하지 않습니다.

그러나 중반으로 가면 상황이 달라집니다.

전동킥보드(baseIncome: 50)를 타고 있을 때, 다음 탈것인 스쿠터(가격: 15,000원)를 목표로 한다면

전동킥보드 lv.1 수입: 50 × 1.1 = 55원/초
스쿠터까지 시간: 15,000 / 55 ≈ 272초 (약 4.5분)

lv.5까지 강화하면

강화 총 비용: 50 × 10 × (1.15 + 1.15² + 1.15³ + 1.15⁴) ≈ 3,140원
강화 후 수입: 50 × (1 + 0.1 × 5) = 75원/초
스쿠터까지 남은 금액: 15,000 - 3,140 = 11,860원
남은 금액 모으는 시간: 11,860 / 75 ≈ 158초
총 소요 시간: 강화 시간 + 158초

강화 시간을 무시해도 총 158초. 강화 없이 272초 vs 강화 후 158초 + 강화비용 모으는 시간. 이 계산이 복잡하기 때문에 플레이어는 직관으로 결정합니다. 그 직관이 게임 플레이입니다. 만약 이 딜레마가 없다면 즉, 강화가 항상 유리하거나 항상 불리하다면 게임은 단순한 클릭 노가다가 됩니다. “어떤 선택이 맞나”를 고민하게 만드는 것이 방치형 게임 디자인의 핵심입니다.


enhance.ts: 순수 함수로 분리한 이유

강화 관련 계산 로직은 src/game/enhance.ts라는 별도 파일로 분리했습니다.

export function getEnhanceCost(baseIncome: number, level: number): number {
  return baseIncome * 10 * Math.pow(1.15, level);
}

export function getIncomeAfterEnhance(baseIncome: number, nextLevel: number): number {
  return baseIncome * (1 + 0.1 * nextLevel);
}

단 두 함수, 총 4줄입니다. 별도 파일이 필요할까 의문이 들 수 있습니다.

store 안에 인라인으로 두지 않은 이유

처음 구현 시 enhanceBike 액션 안에 직접 계산식을 쓰는 방식도 고려했습니다.

// store 인라인 방식
enhanceBike: () => {
  const state = get();
  const bike = getBike(state.currentBikeId);
  const cost = bike.baseIncome * 10 * Math.pow(1.15, state.bikeLevel);  // 인라인
  if (state.money < cost) return false;
  set({ money: state.money - cost, bikeLevel: state.bikeLevel + 1 });
  return true;
},

이렇게 하면 EnhanceButton.tsx에서 미리보기(현재 비용, 강화 후 수입 표시)를 구현할 때 같은 공식을 복사해서 써야 합니다. 공식이 바뀌면 두 군데를 동시에 수정해야 합니다. “공식 하나가 두 군데”는 버그의 온상입니다.

왜 game/ 폴더인가

enhance.tssrc/game/ 폴더에 있습니다. components/가 아닙니다. getEnhanceCostgetIncomeAfterEnhance는 UI를 전혀 알지 못합니다. React도 없고, DOM도 없고, Zustand도 없습니다. 순수하게 숫자를 받아서 숫자를 돌려주는 함수입니다. 이런 함수는 게임 로직 레이어에 속합니다. 나중에 프레스티지 시스템이나 광고 보상 계산에서 강화 비용을 참조해야 할 상황이 오더라도, game/enhance.ts에서 임포트하면 됩니다. components/에 있었다면 UI 레이어에서 게임 로직을 가져오는 이상한 의존성이 생겼을 것입니다.

테스트 가능성

순수 함수이므로 테스트가 간단합니다.

// 테스트가 이렇게 쉬워집니다
expect(getEnhanceCost(1, 1)).toBeCloseTo(11.5);
expect(getIncomeAfterEnhance(1, 2)).toBeCloseTo(1.2);

아직 테스트를 작성하지 않았지만, 나중에 밸런스 검증 자동화를 할 때 이 구조가 빛을 발할 것입니다.


enhanceBike: 인자 없는 설계

gameStore.tsenhanceBike 액션은 인자를 받지 않습니다.

enhanceBike: () => boolean

반면 같은 store의 buyBike는 인자를 받습니다.

buyBike: (bikeId: string) => boolean

왜 이 차이가 있을까요?

buyBike는 어떤 탈것을 살지 선택해야 합니다. 플레이어가 BikeShop에서 특정 탈것 카드를 클릭하기 때문에, 어떤 탈것인지 명시적으로 알려줘야 합니다. 반면 enhanceBike는 선택지가 없습니다. 강화 대상은 언제나 “지금 타고 있는 탈것”입니다. 현재 탈것은 store의 currentBikeId로 이미 알고 있습니다. 인자를 추가하면 오히려 두 가지 문제가 생깁니다.

첫째, 호출자가 currentBikeId를 알고 있어야 합니다.

// 인자가 있다면
const currentBikeId = useGameStore(state => state.currentBikeId);
enhanceBike(currentBikeId);  // 불필요한 정보 전달

store가 이미 알고 있는 것을 밖에서 다시 넘겨주는 것은 중복입니다.

둘째, 다른 탈것을 강화하는 버그를 만들 여지가 생깁니다.

enhanceBike('bicycle');  // 킥보드를 타고 있는데 자전거를 강화?

API가 허용하지 않으면 그 버그는 처음부터 불가능합니다. 단순한 API가 실수를 원천 차단합니다.


EnhanceButton의 수입 미리보기: 섬세한 불일치

EnhanceButton.tsx는 강화 전/후 수입을 미리 보여줍니다.

const currentIps = incomePerSecond();
const nextIps = getIncomeAfterEnhance(bike.baseIncome, bikeLevel + 1);

이 두 값이 서로 다른 기준으로 계산된다는 점이 중요합니다.

incomePerSecond()는 프레스티지 배율과 광고 배율을 모두 포함한 실제 수입입니다.

incomePerSecond: () => {
  const state = get();
  const bike = getBike(state.currentBikeId);
  const prestigeMultiplier = 1 + 0.5 * state.prestigeCount;
  const adBoostMultiplier = Date.now() < state.adBoostEndTime ? 2 : 1;
  return bike.baseIncome * (1 + 0.1 * state.bikeLevel) * prestigeMultiplier * adBoostMultiplier;
},

반면 getIncomeAfterEnhance()는 baseIncome 기준의 기본 수입만 계산합니다.

export function getIncomeAfterEnhance(baseIncome: number, nextLevel: number): number {
  return baseIncome * (1 + 0.1 * nextLevel);
}

현재 프레스티지 2회를 한 상태에서 광고 부스트도 켜져 있다면:

  • currentIps: baseIncome × (1 + 0.1 × level) × 2.0(프레스티지) × 2(광고) = 실제 수입
  • nextIps: baseIncome × (1 + 0.1 × (level + 1)) = 기본 수입만

UI에 표시되는 “강화 후 수입”이 실제보다 훨씬 낮게 보입니다. 플레이어가 “강화해봤자 별로 안 오르네”라고 오해할 수 있습니다.

이 불일치를 인지하고 있지만, MVP 단계에서는 의도적으로 수정하지 않았습니다. 두 가지 이유입니다. 하나는 getIncomeAfterEnhance에 배율을 적용하려면 prestige 횟수와 광고 부스트 상태를 인자로 받아야 합니다. 그러면 이 함수가 더 이상 순수한 “강화 계산 함수”가 아니라 “현재 게임 상태를 아는 함수”가 됩니다. 관심사 분리가 무너집니다. 다른 하나는 초반 플레이에서 프레스티지와 광고는 존재하지 않습니다. 기본값(prestigeCount: 0, adBoostEndTime: 0)에서는 두 배율 모두 1이므로 실제 수입 = 기본 수입입니다. 불일치가 문제가 되는 시점은 프레스티지와 광고가 구현된 이후입니다. 이 부분은 기술 부채로 남겨두고, 5단계(업그레이드 UI) 이후에 미리보기 함수를 개선할 예정입니다.


Tailwind 조건부 스타일링 패턴

EnhanceButton.tsxBikeShop.tsx는 동일한 시각 언어를 씁니다.

<button
  onClick={() => enhanceBike()}
  disabled={!canAfford}
  className={`w-full py-2 rounded-lg font-bold text-sm transition-colors ${
    canAfford
      ? 'bg-yellow-500 hover:bg-yellow-400 text-gray-900'
      : 'bg-gray-700 text-gray-500 cursor-not-allowed'
  }`}
>
  강화하기 -{' '}
  <span className={canAfford ? 'text-gray-900' : 'text-red-400'}>
    {formatMoney(cost)}원
  </span>
</button>

디자인 규칙은 단순합니다

  • 노란색: 구매 또는 강화 가능 (행동 가능 상태)
  • 빨간색 텍스트: 돈이 부족함 (경고 신호)
  • 회색: 비활성화 (행동 불가 상태)

이 세 가지 색상 언어를 BikeShopEnhanceButton 전체에 일관되게 적용했습니다. 플레이어가 UI를 처음 봐도 직관적으로 “노란색 = 할 수 있다”, “빨간 숫자 = 돈이 부족하다”를 읽을 수 있습니다. Tailwind에서 조건부 스타일링을 할 때 주의해야 할 점이 있습니다. Tailwind는 빌드 시점에 클래스 이름을 정적 분석해서 CSS를 생성합니다. 그래서 이런 방식은 동작하지 않습니다.

// 이렇게 하면 안 됩니다
const color = canAfford ? 'yellow' : 'gray';
<button className={`bg-${color}-500`} />

bg-yellow-500, bg-gray-500 같은 완전한 클래스 이름이 소스 코드에 나타나야 Tailwind가 인식합니다. 그래서 삼항 연산자로 전체 클래스 문자열을 선택하는 패턴을 씁니다.


App.tsx 레이아웃 배치

강화 버튼을 어디에 배치할지도 선택이 필요했습니다.

최종 레이아웃

[상단: 돈 표시]
[중단: EnhanceButton]
[하단: BikeShop]

처음에는 BikeShop 아래에 두는 것도 고려했습니다. 하지만 실제로 플레이해보니 강화 버튼이 밀려서 보이지 않는 경우가 생겼습니다. 모바일 화면에서 BikeShop이 스크롤 영역을 잡아먹으면, 강화 버튼까지 스크롤해야 하는 상황이 됩니다.

강화 버튼을 BikeShop 위에 두면

  1. 화면을 켰을 때 가장 먼저 눈에 들어옵니다 (“강화할 돈 모였네!”)
  2. 스크롤 없이 바로 접근 가능합니다
  3. “현재 탈것 강화”라는 맥락이 “다음 탈것 구매”보다 더 즉각적인 행동임을 암시합니다

방치형 게임에서 “즉각적인 행동 가능성”이 보이는 위치가 중요합니다. 게임을 다시 열었을 때 돈이 모여 있고 강화 버튼이 바로 보인다면, 클릭 욕구가 생깁니다.


Claude와의 협업: 설계와 구현의 분리

이번 단계도 3단계와 마찬가지로 “Claude에게 스펙을 먼저 정리하고, 구현을 위임하는” 패턴으로 진행했습니다.

강화 공식 밸런싱 논의

처음 제시한 공식은 baseIncome * 10 * Math.pow(1.5, level)였습니다. 1.5배율입니다.

Claude가 시뮬레이션 결과를 바로 보여줬습니다

자전거 lv.5 강화 비용: 1 × 10 × 1.5^5 ≈ 75원
자전거 lv.5 수입: 1 × 1.5 = 1.5원/초
회수 시간: 약 50초

자전거 lv.10 강화 비용: 1 × 10 × 1.5^10 ≈ 576원
회수 시간: 약 384초 (6분 이상)

레벨 10이 되면 회수 시간이 6분을 넘어갑니다. “다음 탈것을 사는 게 훨씬 낫다”는 결론이 레벨 5-6 언저리에서 이미 나버립니다. 강화 시스템이 유효한 선택지가 되는 구간이 너무 짧습니다. 1.15로 낮추자는 제안을 Claude가 했고, 시뮬레이션을 다시 돌려보니 레벨 10-15 구간까지 강화가 합리적인 선택지로 남아 있었습니다. 1.15를 채택했습니다.

Claude가 제안했지만 다르게 선택한 것

getIncomeAfterEnhance에 배율 포함: Claude는 getIncomeAfterEnhance가 prestige 배율과 광고 배율을 인자로 받아서 실제 증가량을 계산해야 한다고 제안했습니다.

// Claude의 제안
export function getIncomeAfterEnhance(
  baseIncome: number,
  nextLevel: number,
  prestigeMultiplier: number,
  adBoostMultiplier: number
): number {
  return baseIncome * (1 + 0.1 * nextLevel) * prestigeMultiplier * adBoostMultiplier;
}

기능적으로는 맞습니다. 하지만 이 함수의 본질은 “레벨에 따른 기본 수입 계산”입니다. 배율을 포함하면 함수 이름과 책임이 어긋납니다. prestige와 광고는 게임 상태(state)이고, 순수 계산 함수가 게임 상태를 알아야 할 이유가 없습니다. 현재 단계에서는 미리보기의 정확도보다 코드 구조의 명확성을 우선했습니다.

강화 최대 레벨 제한: Claude는 MAX_ENHANCE_LEVEL = 20 같은 상한선을 두는 것을 제안했습니다. 레벨이 무한정 올라가면 숫자가 커져서 나중에 오버플로우가 생길 수 있다는 이유입니다. JavaScript의 Number.MAX_SAFE_INTEGER는 약 9조이므로, 실제로 게임 플레이에서 오버플로우가 발생하는 것은 거의 불가능합니다. 게다가 지수 비용 곡선 자체가 자연적인 상한선 역할을 합니다. 레벨 30쯤 되면 강화 비용이 말도 안 되게 높아져서 실질적으로 더 이상 강화를 안 하게 됩니다. 인위적인 제한이 불필요합니다.

Claude가 제안해서 채택한 것

enhanceBike의 인자 없는 설계: 처음에 enhanceBike(bikeId: string)으로 설계하려 했습니다. Claude가 “store가 이미 currentBikeId를 알고 있는데 밖에서 다시 넘길 필요가 없다”고 지적했습니다. 즉시 수긍했습니다. API가 단순할수록 버그 가능성이 줄어듭니다.

미리보기에서 currentIpsnextIps를 나란히 표시: 처음에는 “강화 후 수입 X원/초”만 표시하려 했습니다. Claude가 “현재 수입과 강화 후 수입을 함께 보여주면 증가량이 직관적으로 보인다”고 제안했습니다. 1.1원/초 → 1.2원/초 표시가 1.2원/초 표시보다 훨씬 정보가 풍부합니다. 채택했습니다.


마주쳤던 문제들

1. nextIps 미리보기가 현재 수입보다 낮게 표시되는 현상

EnhanceButton을 처음 완성하고 화면을 봤을 때, 이상한 것을 발견했습니다.

Lv.1 → Lv.2
1.1원/초 → 1.1원/초

강화해도 수입이 똑같이 표시됩니다. 원인을 찾아보니, getIncomeAfterEnhance의 두 번째 인자를 bikeLevel로 넘기고 있었습니다.

// 버그
const nextIps = getIncomeAfterEnhance(bike.baseIncome, bikeLevel);  // 현재 레벨

// 수정
const nextIps = getIncomeAfterEnhance(bike.baseIncome, bikeLevel + 1);  // 다음 레벨

bikeLevel + 1을 넘겨야 “강화 후 레벨”의 수입이 계산됩니다. 함수 이름이 getIncomeAfterEnhance인데 “강화 후” 레벨인 nextLevel을 인자로 받는 것이 맞습니다. 처음에 헷갈렸던 부분입니다. 이 문제는 함수 인자 이름이 level이 아니라 nextLevel인 이유이기도 합니다. 함수 시그니처가 의도를 명확히 드러내야 합니다.

2. EnhanceButton이 매 프레임 리렌더링되는 문제

게임 루프가 초당 60회 tick을 돌리면서 money가 계속 바뀝니다. EnhanceButtonmoneybikeLevel을 구독하므로, money가 바뀔 때마다 리렌더링됩니다. 초당 60회입니다. 사실 이것은 “문제”라기보다 “현재 허용된 상황”입니다. money 표시 자체가 초당 60회 업데이트돼야 하므로, money를 구독하는 컴포넌트가 리렌더링되는 것은 피하기 어렵습니다. 완전한 해결책은 money 표시를 별도 컴포넌트로 분리하고, EnhanceButtonbikeLevelcanAfford 계산에만 반응하도록 최적화하는 것입니다. 하지만 현재 게임 규모에서 초당 60회 리렌더링이 실제로 성능 문제를 일으키지는 않습니다. MVP 단계에서는 기능 완성이 최우선입니다.

3. 강화 후 bikeLevel이 즉시 반영되지 않는 것처럼 보이는 버그 (착각)

강화 버튼을 클릭하고 Lv.1 → Lv.2 텍스트가 Lv.2 → Lv.3으로 바뀌는 것을 확인했습니다. 그런데 한 번은 클릭해도 레벨이 안 바뀌는 것처럼 느껴져서 디버깅을 시작했습니다.

실제 원인: 돈이 딱 강화 비용 경계에 있을 때, canAfford = money >= cost의 부등호 방향 때문에 생긴 착각이었습니다. moneycost와 정확히 같으면 강화가 됩니다. 그런데 money가 부동소수점 누적 오차로 인해 cost보다 0.000001 적은 경우, 강화가 안 됩니다.

// tick마다 부동소수점 누적
money = money + incomePerSecond * deltaSec;
// 1.1 + 0.018333... = 1.118333...
// 계속 쌓이다 보면 정밀도가 살짝 벗어남

실제 게임에서 이 오차가 문제가 되는 경우는 거의 없습니다. 다음 tick(16ms 후)에 돈이 더 쌓여서 강화가 됩니다. 플레이어가 인지할 수 없는 수준입니다. 버그가 아니라 부동소수점의 특성이었습니다.


강화 시스템 완성 후 밸런스 검증

구현이 완료된 후, 실제로 플레이하면서 수치를 확인했습니다.

자전거 강화 레벨별 수입과 ROI

       
레벨 강화 비용 수입/초 강화 ROI (초)
1 - 1.1원 -
2 11.5원 1.2원 115초
3 13.2원 1.3원 132초
5 17.5원 1.5원 175초
10 40.5원 2.1원 405초
15 81.4원 2.6원 814초

킥보드(price: 30원) 구매 시점 기준 비교

  • 자전거를 lv.1에서 바로 킥보드로 전환: 30원 / 1.1원/초 ≈ 27초
  • 자전거를 lv.5까지 강화 후 킥보드 구매: (강화 비용 합 ≈ 73원 + 30원) = 103원 필요. 강화 과정에서 수입이 점차 증가하므로 단순 계산보다 빠르지만, 여전히 총 시간은 70-80초 정도

초반에는 강화보다 교체가 유리합니다. 중반 탈것(스쿠터, 오토바이)에서 강화가 점점 의미를 가집니다. 이것이 의도한 밸런스입니다.


다음 편 예고

강화 시스템이 완성되면서 “강화 vs 교체” 딜레마가 생겼습니다. 이제 게임다운 결정이 존재합니다.

5단계에서는 업그레이드 UI를 개선합니다

  1. 업그레이드 패널 통합: BikeShop과 EnhanceButton을 하나의 일관된 UI로 묶기
  2. 수입 표시 개선: 실시간 초당 수입, 강화 배율 가시화
  3. 진행도 표시: 다음 탈것까지 필요한 돈, 예상 시간
  4. 미리보기 정확도 수정: 프레스티지/광고 배율을 반영한 실제 강화 후 수입

지금 UI는 기능은 되지만 “방치형 게임” 특유의 숫자가 쌓이는 만족감이 시각적으로 충분히 전달되지 않습니다. 5단계에서 이 부분을 다듬을 예정입니다.


마치며

4단계는 코드 양으로는 작지만, 게임 디자인 측면에서 중요한 단계였습니다.

  • 지수 비용 곡선: 1.15^level 배율이 자연적인 강화 한계를 만듭니다.
  • “강화 vs 교체” 딜레마: 방치형 게임의 핵심 재미가 완성되었습니다.
  • 순수 함수 분리: enhance.ts가 store와 UI 양쪽에서 재사용됩니다.
  • 인자 없는 API: enhanceBike()는 단순할수록 실수가 없습니다.
  • 미리보기 불일치: 인지된 기술 부채로 남겨두고 나중에 수정합니다.

Claude와의 협업에서 가장 유효했던 것은 밸런스 공식의 시뮬레이션이었습니다. 배율 1.5와 1.15를 수치로 비교해서 어느 것이 더 좋은 게임 경험을 만드는지 즉시 검증할 수 있었습니다. 직관으로 결정했다면 플레이해보고 다시 수정하는 과정이 필요했을 텐데, 수치 시뮬레이션으로 첫 시도에 좋은 값을 잡을 수 있었습니다. 게임 개발은 “코드를 짜는 일”인 동시에 “숫자를 조율하는 일”입니다. AI가 이 숫자 조율 과정에서 빠른 피드백을 제공하는 것이 실질적인 도움이 됩니다.