클로드와 함께 방치형 게임 만들기: 3. 탈것 교체 시스템
'배달왕 키우기' 개발 세 번째 편. 수집형 대신 교체형 탈것 시스템을 택한 이유, 단방향 업그레이드와 강화 레벨 초기화 결정, buyBike의 반환 패턴까지 설계 의사결정을 정리한다.
지난 편에서는 방치형 게임의 심장인 게임 루프와 Zustand 상태 관리를 완성했습니다. requestAnimationFrame으로 초당 60회 tick을 돌리고, incomePerSecond()라는 computed 함수로 단일 진실 공급원을 확보했습니다. 이제 돈이 자동으로 쌓입니다. 하지만 지금 상태는 아직 게임이 아닙니다. 자전거만 타면서 돈이 쌓이는 것을 구경만 할 수 있습니다. 이번 편에서는 게임의 핵심 선택 루프(choice loop)를 만듭니다. “지금 탈것을 강화할까, 아니면 돈을 모아서 더 좋은 탈것으로 바꿀까?”라는 결정이 방치형 게임의 재미의 원천입니다.
3단계에서 구현한 것들
bikes.ts에 헬퍼 함수 추가gameStore.ts에buyBike액션utils.ts로 공통 유틸 분리BikeShop.tsx컴포넌트 구현App.tsx에 BikeShop 통합
탈것 시스템 설계: 교체형 vs 수집형
구현 전에 게임 디자인 레벨에서 먼저 결정해야 할 것이 있었습니다. 탈것을 어떻게 “소유”할 것인가입니다.
수집형 (Collection)
RPG, 포켓몬, 가챠 게임에서 자주 쓰는 방식입니다. 구매한 탈것은 모두 인벤토리에 남습니다.
인벤토리: [자전거, 킥보드, 전동킥보드, 스쿠터]
현재 사용 중: 스쿠터
장점
- 다수 탈것을 동시에 운용할 수 있습니다. “자전거 5대, 킥보드 3대”처럼 파견 시스템을 붙일 수 있습니다.
- 수집 욕구를 자극합니다. “다 모아야지” 심리입니다.
- 이전에 얻었던 탈것으로 돌아가는 유연성이 있습니다.
단점
- 상태가 복잡해집니다. 배열 관리, 파견 로직, UI 재고 관리가 필요합니다.
- 방치형의 핵심인 “단순함”이 희석됩니다.
- 이 게임의 컨셉(“배달왕 키우기”)과 맞지 않습니다. 배달왕은 탈것 하나를 타고 달리는 이미지입니다.
교체형 (Replacement)
현재 탈것 하나만 보유하고, 더 좋은 것을 사면 교체됩니다.
현재 탈것: 스쿠터
이전 탈것: 사라짐 (또는 "보유했음" 표시)
장점
- 상태가 단순합니다.
currentBikeId문자열 하나로 현재 탈것이 결정됩니다. - 결정의 무게감이 있습니다. “이걸 사면 자전거는 돌아오지 않는다.”
- 방치형에 맞는 선형적 진행감입니다. 항상 “다음 단계”를 향해 전진합니다.
- 구현이 직관적입니다.
단점
- 구매를 취소하거나 “아 자전거로 돌아가고 싶었는데” 하는 경우를 허용하지 않습니다.
- 수집 욕구를 자극하기 어렵습니다.
교체형을 선택한 이유: 이 게임의 목표는 “더 빠른 탈것을 타고 더 많은 배달을 하는 배달왕이 되는 것”입니다. 탈것은 수집 대상이 아니라 도구입니다. 또한 1인 개발로 빠르게 MVP를 만들어야 하는 상황에서, 상태 복잡도를 낮추는 것이 최우선이었습니다.
단방향 업그레이드: 되돌릴 수 없는 선택
교체형을 선택하면서 또 다른 결정이 필요했습니다. 더 비싼 탈것만 살 수 있게 할 것인가, 아니면 양방향으로 전환할 것인가입니다.
양방향 전환
자전거 → 킥보드 → 전동킥보드 (앞으로만)
자전거 ← 킥보드 ← 전동킥보드 (뒤로도 가능)
이 방식이면 플레이어가 “자전거로 돌아가서 초반 플레이를 다시 해보고 싶다”는 욕구를 충족할 수 있습니다. 하지만 실제로 그걸 원하는 플레이어가 얼마나 있을까요? 거의 없습니다. 오히려 실수로 더 나쁜 탈것으로 다운그레이드하는 사고를 방지하기 위해 단방향이 낫습니다.
단방향 업그레이드
buyBike: (bikeId: string) => {
const state = get();
const targetBike = BIKES.find(b => b.id === bikeId);
if (!targetBike) return false;
const currentBike = getBike(state.currentBikeId);
if (targetBike.baseIncome <= currentBike.baseIncome) return false; // ← 이 한 줄
if (state.money < targetBike.price) return false;
set({
money: state.money - targetBike.price,
currentBikeId: bikeId,
bikeLevel: 1,
});
return true;
},
targetBike.baseIncome <= currentBike.baseIncome이면 구매를 거부합니다. 현재 탈것보다 수입이 낮거나 같은 탈것은 살 수 없습니다. 비교 기준을 price가 아닌 baseIncome으로 한 것도 의도된 설계입니다. 가격이 아닌 실질 성능(초당 수입)으로 비교함으로써, 나중에 “같은 가격이지만 성능이 다른 탈것” 같은 변형 아이템을 추가할 여지를 열어둡니다. 되돌릴 수 없는 결정이 주는 긴장감: “킥보드를 사도 될까? 아직 자전거를 강화하는 게 나을까?” 이 고민이 방치형 게임의 핵심 재미입니다. 양방향이면 이 긴장감이 사라집니다. “어차피 다시 돌아올 수 있잖아”가 되면 결정의 무게가 없어집니다.
bikeLevel 초기화: 밸런스와 재미의 교차점
buyBike 액션에서 주목해야 할 한 줄이 있습니다
set({
money: state.money - targetBike.price,
currentBikeId: bikeId,
bikeLevel: 1, // ← 항상 1로 초기화
});
새 탈것을 사면 강화 레벨이 1로 리셋됩니다. 이 결정을 놓고 두 가지 선택지를 비교했습니다.
강화 레벨 유지
// 킥보드 lv.5 → 전동킥보드 lv.5
set({
currentBikeId: bikeId,
// bikeLevel은 건드리지 않음
});
장점
- 플레이어가 강화에 투자한 노력을 “가져갈 수 있다”는 느낌입니다.
- 강화 레벨이 탈것 교체와 독립적인 “성장 트리”가 됩니다.
단점
- 강화를 열심히 한 플레이어가 새 탈것을 사도 레벨 1처럼 느끼지 않습니다. “나 이미 lv.20이야”가 되면 강화 시스템이 무의미해집니다.
- 균형 잡기가 훨씬 복잡해집니다. 강화 레벨을 얼마나 높이면 새 탈것을 사는 게 이득인지 계산이 복잡해집니다.
강화 레벨 초기화 (채택)
수입 공식
baseIncome × (1 + 0.1 × bikeLevel)
- 자전거(baseIncome: 1)를 lv.10까지 강화하면: 1 × (1 + 0.1 × 10) = 2.0원/초
- 킥보드(baseIncome: 8)를 lv.1로 시작하면: 8 × (1 + 0.1 × 1) = 8.8원/초
킥보드가 훨씬 강합니다. 레벨을 유지해도 탈것 교체가 항상 이득입니다. 그렇다면 레벨을 유지하는 것이 플레이어에게 의미 있는 선택이 될 수 없습니다.
초기화하면
- “이 탈것을 사면 강화를 다시 해야 한다”는 부담감이 생깁니다.
- “지금 강화에 투자할까, 아니면 새 탈것을 살 돈을 모을까”라는 딜레마가 유지됩니다.
- 탈것을 살 때마다 “새로운 시작”의 느낌을 줍니다.
방치형 게임에서 “다시 강화해야 하나?” 하는 고민은 재미입니다. 그것이 플레이어를 계속 게임에 붙들어두는 요인입니다.
bikes.ts 헬퍼 함수: 단순함이 최선일 때
3단계에서 bikes.ts에 두 개의 헬퍼를 추가했습니다.
export function getBikeIndex(id: string): number {
return BIKES.findIndex(b => b.id === id);
}
export function getNextBikes(currentId: string): Bike[] {
const currentBike = getBike(currentId);
return BIKES.filter(b => b.baseIncome > currentBike.baseIncome);
}
처음에는 이 함수들이 필요 없을 것 같았습니다. buyBike 안에서 인라인으로 처리하면 되지 않나? 실제로 처음 구현은 그렇게 했습니다. 하지만 BikeShop.tsx를 만들면서 “구매 가능한 탈것만 보여주는” 필터링이 필요했고, 나중에 강화 시스템(4단계)에서도 현재 탈것의 인덱스가 필요할 것이 명확했습니다. 미리 분리해두는 것이 낫습니다. getNextBikes의 구현을 보면 baseIncome > currentBike.baseIncome으로 필터합니다. 즉, 현재 탈것보다 수입이 높은 탈것들을 반환합니다. BikeShop에서는 이 함수를 직접 쓰지 않고 BIKES 전체를 순회하면서 각 탈것의 상태를 판단하는 방식을 택했는데, 이렇게 하면 “보유했음” 상태도 함께 표시할 수 있기 때문입니다.
buyBike의 boolean 반환 패턴
buyBike: (bikeId: string) => boolean
이 함수가 void가 아니라 boolean을 반환합니다. 성공하면 true, 실패하면 false입니다.
세 가지 대안을 비교했습니다.
1. void 반환 (throw on error)
buyBike: (bikeId: string) => {
if (!canBuy) throw new Error('Cannot buy this bike');
// ...
}
React 컴포넌트에서 이 함수를 호출할 때
try {
buyBike(id);
} catch (e) {
showErrorMessage();
}
이 방식은 try-catch가 UI 로직에 침투합니다. 게임의 “구매 실패”는 예외적 상황이 아니라 정상적인 흐름입니다. 돈이 부족한 것은 에러가 아닙니다.
2. Result 타입
type BuyResult =
| { success: true }
| { success: false; reason: 'not_enough_money' | 'invalid_bike' | 'same_or_lower' };
TypeScript스럽고 명확합니다. 하지만 이 규모에서는 오버엔지니어링입니다. 실패 이유를 UI에 표시할 계획이 지금 당장은 없습니다.
3. boolean 반환 (채택)
onClick={() => buyBike(bike.id)}
UI에서는 buyBike가 false를 반환해도 별도 처리를 하지 않습니다. 버튼이 이미 비활성화되어 있기 때문에, false가 반환되는 경우는 정말 예외적인 상황(버그 수준)뿐입니다. 버튼의 disabled 상태가 이미 첫 번째 방어선입니다. 나중에 피드백이 필요해지면(구매 성공 애니메이션, 실패 사운드) 반환값을 확인하는 로직을 추가하면 됩니다. 지금은 단순하게 유지합니다.
유틸 분리: BIKE_EMOJI와 formatMoney
2단계까지는 BIKE_EMOJI와 formatMoney가 App.tsx에 있었습니다. 3단계에서 BikeShop.tsx를 만들면서 이 둘을 어디에 둘지 결정해야 했습니다.
어디에 둘 것인가
Option A: App.tsx에 유지, props로 전달
// App.tsx
export const BIKE_EMOJI = { ... };
export function formatMoney() { ... }
// BikeShop.tsx
import { BIKE_EMOJI, formatMoney } from '../App';
App.tsx에서 임포트하는 것은 명백히 좋지 않습니다. App.tsx는 루트 컴포넌트이지 유틸리티 모듈이 아닙니다. 순환 임포트 위험이 있고, 의존성 방향이 잘못되었습니다.
Option B: constants.ts
src/game/constants.ts // BIKES 같은 상수들
데이터 상수는 맞는 위치입니다. 하지만 formatMoney는 순수 함수지 상수가 아닙니다. 두 가지를 같은 파일에 섞는 것은 어색합니다.
Option C: utils.ts (채택)
src/game/utils.ts
export const BIKE_EMOJI: Record<string, string> = { ... };
export function formatMoney(amount: number): string { ... }
utils.ts는 “게임 로직의 유틸리티”를 담는 위치입니다. BIKE_EMOJI는 게임 데이터의 일부이고, formatMoney는 게임 숫자를 포매팅하는 함수입니다. 둘 다 game/ 폴더에 속하는 것이 자연스럽습니다. components/에 두지 않은 이유는, formatMoney는 UI와 무관한 순수 함수이기 때문입니다. 나중에 게임 로직에서도(e.g., 로그, 알림 텍스트) 쓰일 수 있습니다.
formatMoney 구현 결정
2단계 포스트에서 소수점 처리를 toFixed(1)로 소개했는데, 실제 구현을 보면 toFixed(2)로 되어 있습니다.
export function formatMoney(amount: number): string {
if (amount >= 1_000_000_000_000) {
return `${(amount / 1_000_000_000_000).toFixed(2)}조`;
}
if (amount >= 100_000_000) {
return `${(amount / 100_000_000).toFixed(2)}억`;
}
if (amount >= 10_000) {
return `${(amount / 10_000).toFixed(2)}만`;
}
return `${Math.floor(amount).toLocaleString()}`;
}
toFixed(1) vs toFixed(2)의 선택은 게임 초반 숫자를 실제로 보면서 결정했습니다. 초반에 “1.2만”은 다소 뭉개지는 느낌입니다. 30원, 700원처럼 구체적인 숫자가 1.3만, 1.27만처럼 표시될 때 toFixed(2)가 더 직관적이었습니다. 후반 거대한 수에서는 어차피 세부 자릿수가 중요하지 않습니다. 만원 이하는 Math.floor().toLocaleString()으로 그대로 표시합니다. “700”이 “0.07만”보다 낫기 때문입니다.
BikeShop.tsx: 상태 머신으로 UI 설계하기
BikeShop의 각 탈것 카드는 4가지 상태를 가집니다. 이것을 명확히 정의하고 시작했습니다.
상태 1: current — 현재 사용 중인 탈것
상태 2: lower — 이미 지나온 탈것 (baseIncome <= 현재)
상태 3: affordable — 살 수 있는 다음 탈것 (money >= price)
상태 4: not_affordable — 아직 돈이 부족한 탈것
const isCurrent = bike.id === currentBikeId;
const isLower = bike.baseIncome <= currentBike.baseIncome && !isCurrent;
const canAfford = money >= bike.price;
주의할 점: isLower의 조건에 && !isCurrent가 있습니다. 현재 탈것도 baseIncome <= currentBike.baseIncome을 만족하기 때문에, 이 체크가 없으면 현재 탈것이 “lower”로 잘못 분류됩니다.
각 상태의 시각적 표현
<div className={`flex items-center justify-between rounded-lg px-3 py-2 ${
isCurrent
? 'bg-green-900 border border-green-500' // 녹색 테두리
: isLower
? 'bg-gray-800 opacity-40' // 흐릿하게
: 'bg-gray-800' // 기본 배경
}`}>
- 현재 탈것: 초록색 배경, 초록색 테두리. 눈에 띄게 표시합니다.
- 지나온 탈것:
opacity-40으로 흐릿하게 처리합니다. “이건 과거”라는 느낌입니다. - 구매 가능/불가: 버튼으로 표시하고, 돈 부족이면 빨간 가격으로 나타냅니다.
버튼 상태 처리
<button
onClick={() => buyBike(bike.id)}
disabled={!canAfford}
className={`text-xs px-2 py-1 rounded font-bold transition-colors ${
canAfford
? 'bg-yellow-500 hover:bg-yellow-400 text-black'
: 'bg-gray-700 text-gray-500 cursor-not-allowed'
}`}
>
<span className={canAfford ? '' : 'text-red-400'}>
{formatMoney(bike.price)}원
</span>
</button>
disabled와 Tailwind 클래스를 분리했습니다. disabled HTML 속성은 클릭 자체를 막고, cursor-not-allowed는 마우스 커서로 “클릭 안 됨”을 시각적으로 알립니다. 이 두 가지를 함께 쓰는 것이 UX의 기본입니다. 돈이 부족할 때 가격을 빨간색으로 표시(text-red-400)하는 것도 중요한 신호입니다. “이걸 사려면 저 빨간 숫자만큼 더 모아야 한다”는 것을 직관적으로 전달합니다.
BikeShop의 스크롤 처리
<div className="flex flex-col gap-2 max-h-48 overflow-y-auto">
{BIKES.map(bike => (...))}
</div>
탈것이 8개인데, 모바일 화면에서 전부 표시하면 다른 UI가 밀려납니다. max-h-48(192px)로 최대 높이를 제한하고 overflow-y-auto로 스크롤을 추가했습니다. Tailwind의 max-h-48은 12rem = 192px입니다. 탈것 카드 하나가 약 52px이므로, 화면에 약 3-4개가 보입니다. 현재 탈것이 맨 위에서 멀어지면 스크롤이 필요하다는 단점이 있는데, 이것은 나중에 scrollIntoView로 해결할 수 있습니다.
Selector 기반 구독: 왜 세 번 나눠서 useGameStore를 쓰나
const money = useGameStore(state => state.money);
const currentBikeId = useGameStore(state => state.currentBikeId);
const buyBike = useGameStore(state => state.buyBike);
한 번에 다 가져오지 않고 세 번에 나눠서 쓰는 이유가 있습니다.
Zustand에서 selector 없이 전체 state를 구독하면
const state = useGameStore(); // 전체 구독
state의 어떤 값이 바뀌어도 이 컴포넌트가 리렌더링됩니다. tick()이 매 프레임 money를 업데이트하므로, BikeShop은 초당 60회 리렌더링됩니다.
반면 selector 방식이면
const money = useGameStore(state => state.money); // money만 구독
money가 변할 때만 리렌더됩니다. currentBikeId나 buyBike가 변해도 영향이 없습니다.
물론 현재는 BikeShop이 money를 구독하므로 어차피 매 프레임 리렌더됩니다. 하지만 이렇게 분리해두면, 나중에 money 표시를 상위 컴포넌트로 올리고 BikeShop은 currentBikeId만 구독하도록 리팩토링할 때 편합니다. buyBike 함수는 Zustand에서 한 번만 생성되고 참조가 변하지 않습니다. 매 프레임 리렌더가 일어나도 buyBike가 재생성되지 않으므로, 버튼 컴포넌트는 React.memo로 보호할 수 있습니다. 함수를 분리해서 가져오는 것은 이런 최적화의 전제 조건입니다.
Claude와의 협업: 설계 결정의 실제 과정
이번 단계는 구현보다 설계 결정이 많았습니다. Claude와의 대화에서 흥미로웠던 부분들을 공유합니다.
처음 스펙을 정리하는 과정
처음에 “탈것 교체 시스템을 만들어줘”라고 말했을 때, Claude가 먼저 질문들을 던졌습니다.
- “교체형인가요, 수집형인가요?”
- “다운그레이드도 허용할 건가요?”
- “탈것을 바꿀 때 강화 레벨은 어떻게 되나요?”
이 질문들이 실제로 설계의 핵심을 찌르고 있었습니다. 혼자였다면 코드부터 짜다가 나중에 이 결정들을 다시 수정했을 것입니다. AI와 대화하는 것 자체가 설계 리뷰 역할을 했습니다.
Claude가 제안했지만 다르게 선택한 것
BIKE_EMOJI를 bikes.ts에 두는 것: Claude는 탈것 데이터와 이모지를 같은 파일에 두는 것을 제안했습니다.
// Claude의 제안
export const BIKES: Bike[] = [
{ id: 'bicycle', name: '자전거', price: 0, baseIncome: 1, emoji: '🚲' },
...
];
일관성 있어 보이지만, 거절했습니다. Bike 인터페이스에 emoji 필드가 추가되면 게임 데이터와 UI 데이터가 섞입니다. bikes.ts는 게임 로직을 담당하는 파일이고, 이모지는 순수하게 UI 관심사입니다. 게임 로직 레이어에서 이모지를 알 필요가 없습니다.
구매 실패 시 toast 알림: Claude는 buyBike가 false를 반환할 때 화면에 “돈이 부족합니다” 같은 메시지를 표시하는 것을 제안했습니다. 기능적으로는 좋은 UX입니다. 하지만 지금 당장은 버튼 disabled로 충분합니다. toast 시스템은 별도 컴포넌트와 상태가 필요하고, 방치형 게임에서 빈번한 실패 알림은 오히려 방해가 됩니다. MVP 단계에서는 제외했습니다.
getNextBikes 함수를 BikeShop에서 활용: Claude는 BikeShop에서 getNextBikes()로 구매 가능 목록만 필터링해서 표시하는 방식을 제안했습니다. 하지만 “보유했음” 상태의 탈것도 목록에 보여주는 것이 게임 진행감을 줍니다. “아 나 킥보드도 지났구나”라는 성취감입니다. 전체 목록을 표시하되 상태별로 시각적으로 구분하는 방향을 선택했습니다.
Claude가 제안해서 그대로 채택한 것
boolean 반환 패턴: Result 타입이나 throw 대신 단순한 boolean 반환. “게임의 실패 흐름은 예외가 아니라 정상 흐름”이라는 설명이 설득력 있었습니다.
!isCurrent 체크: 처음 isLower 조건을 bike.baseIncome <= currentBike.baseIncome으로만 짰더니, 현재 탈것도 흐릿하게 표시되었습니다. Claude가 바로 && !isCurrent 조건을 추가해야 한다고 지적했습니다.
마주쳤던 버그들
1. 현재 탈것이 “보유했음”으로 표시되는 버그
위에서 언급한 isLower 조건 누락입니다. 자전거(baseIncome: 1)를 타고 있을 때, isLower = 1 <= 1 이 true가 되어버렸습니다.
// 버그
const isLower = bike.baseIncome <= currentBike.baseIncome;
// 수정
const isLower = bike.baseIncome <= currentBike.baseIncome && !isCurrent;
2. 구매 직후 이전 탈것의 UI 상태가 갱신 안 됨
처음에 BikeShop에서 currentBikeId를 props로 받지 않고, 직접 import한 BIKES 배열에서 계산하려 했습니다. Zustand를 구독하지 않으면 상태가 변해도 컴포넌트가 리렌더링되지 않습니다.
해결: useGameStore(state => state.currentBikeId)로 직접 구독했습니다.
3. 큰 숫자에서의 formatMoney 표시 이슈
초기 formatMoney에서 경계값 처리를 잘못해서, 99,999원이 “9.99만”으로 표시되었습니다. (10_000 이상이면 만원 단위로 표시하므로.) 실제로는 의도된 동작이지만, “99,999원”이 “9.99만”으로 보이는 것이 어색하다는 피드백을 스스로 느꼈습니다. 결국 표시하면서 익숙해지기로 했습니다. 숫자가 커지면 정확한 자릿수보다 단위가 중요합니다.
탈것 밸런스 테이블
구현하면서 실제로 게임이 얼마나 진행되는지 시뮬레이션해봤습니다:
| # | 탈것 | 가격 | 수입/초 | 구매까지 시간 |
| 1 | 자전거 | 무료 | 1원 | 즉시 |
| 2 | 킥보드 | 30원 | 8원 | ~30초 |
| 3 | 전동킥보드 | 700원 | 50원 | ~1.5분 |
| 4 | 스쿠터 | 15,000원 | 300원 | ~5분 |
| 5 | 오토바이 | 350,000원 | 2,000원 | ~19분 |
| 6 | 전기바이크 | 10,000,000원 | 15,000원 | ~1.4시간 |
| 7 | 고급 오토바이 | 500,000,000원 | 120,000원 | ~9시간 |
| 8 | 슈퍼바이크 | 20,000,000,000원 | 1,000,000원 | ~46시간 |
초반(자전거 → 스쿠터)은 빠른 성취감을 줍니다. 5분마다 새 탈것이 생기는 느낌입니다. 중반(오토바이 → 전기바이크)부터 간격이 벌어지고, 후반(슈퍼바이크)은 며칠 단위입니다. 이 구간에서 강화 시스템과 프레스티지가 변수가 됩니다. 강화를 열심히 하면 더 빨리 다음 탈것을 살 수 있고, 프레스티지를 하면 수입 배율이 올라갑니다. 탈것 교체 시스템만으로는 단조롭지만, 강화 시스템을 추가하면 이 간격을 얼마나 단축할지가 플레이어의 선택이 됩니다.
수입/초 대비 가격 비율을 보면 대략 탈것 가격 ÷ 수입 = 30초~30초로 일정하게 유지됩니다. 이것이 방치형 게임 밸런스의 핵심 원칙 중 하나입니다. “구매 비용 = 수입 × N초”가 일정하면 진행 속도감이 일정하게 느껴집니다.
다음 편 예고
탈것 교체 시스템이 완성되었습니다. 이제 “더 좋은 탈것으로 가느냐”는 선택이 생겼습니다. 하지만 아직 “지금 탈것을 더 강하게 키우냐”는 선택이 없습니다.
4단계에서는 탈것 강화 시스템을 구현합니다
- upgradeBike 액션: bikeLevel을 올리는 로직과 비용 공식
- 강화 비용 곡선: 선형 vs 지수 비교, 적정 강화 한계
- UpgradeButton 컴포넌트: 현재 레벨, 비용, 수입 변화 표시
- 탈것 교체 vs 강화의 딜레마: 밸런스 수치로 검증
지금 자전거(lv.1)를 킥보드 살 돈이 될 때까지 강화할 것인가, 아니면 그냥 킥보드를 살 것인가. 이 결정이 방치형 게임의 핵심이고, 강화 시스템이 완성되면 진짜 딜레마가 시작됩니다.
마치며
탈것 교체 시스템은 구현 자체보다 설계 결정이 더 많았던 단계입니다.
- 교체형 vs 수집형: 방치형 게임의 단순함을 지키기 위해 교체형을 선택했습니다.
- 단방향 업그레이드: 되돌릴 수 없는 선택이 긴장감을 만듭니다.
- bikeLevel 초기화: 강화 딜레마를 유지하기 위한 설계입니다.
- boolean 반환: 게임의 실패 흐름은 예외가 아니라 정상 흐름입니다.
- utils.ts 분리: UI 관심사와 게임 로직의 경계를 명확히 했습니다.
코드 줄 수로 보면 이번 단계는 많지 않습니다. 하지만 이 결정들이 이후 시스템(강화, 프레스티지, 광고)의 기반이 됩니다. 설계 결정의 연쇄가 게임 전체 구조를 만든다는 것을 다시 한번 확인했습니다. Claude와의 협업은 특히 “이 결정의 이유는 뭔가?”를 짚어주는 역할이 유효했습니다. 구현 속도보다 설계의 명확성을 먼저 가져가는 것이 장기적으로 훨씬 낫습니다.