Orbital Defense — 궤도 에너지 예산으로 디자인된 타워 배치
타워 아무 데나 많이 배치하는 게 아니라 궤도별 에너지 예산이 제한. 제약 기반 디자인으로 게임성을 살리고, SfxEvent 큐 패턴과 이중 tick 시스템까지 정리.
시작 — "돈만 되면 전부 배치" 의 함정
처음 Orbital Defense 프로토타입을 만들었을 때, 타워 배치 제한은 단순했습니다 — "충분한 에너지가 있으면 어디든 설치 가능". 몇 판 해보니 금방 깨달았습니다.
- 자원이 쌓이면 가장 강한 타워로 도배 하는 단일 전략이 최적해가 됨
- 궤도마다 다른 특성을 살릴 이유가 사라짐
- 재도전 동기 약해짐
"제약이 곧 게임성" 이라는 고전 격언을 다시 한 번 배웠습니다. 이 글은 그래서 도입한 궤도별 에너지 예산 시스템 과 개발 중 만난 기술 과제들을 정리합니다.
디자인 — 궤도별 예산 할당
궤도 5개 각각에 배치 비용 상한을 두었습니다.
const ORBIT_ENERGY_BUDGET = [60, 80, 100, 120, 140];
// 내측(저궤도)은 예산 작고, 외측으로 갈수록 큼
- 외측 궤도일수록 예산이 크지만 적 도달까지 시간 여유 있음 — 전진 기지 역할
- 내측 궤도는 예산 적고 최후의 방어선 — 반드시 고효율 타워만
- 각 궤도에서 "어떤 조합" 을 만들지가 전략의 핵심
배치 비용만 집계 (업그레이드는 별도 비용 풀). 이 단순 규칙 하나가 매 판마다 다른 조합을 강제했습니다.
배치 제한 — 4가지 reject 사유
배치 불가 상황을 4가지로 분류해 UX 피드백을 명확히 했습니다:
| 사유 ID | 의미 |
|---|---|
noCost | 전체 에너지 부족 |
maxTotal | 15개 타워 상한 초과 |
orbitBudget | 해당 궤도 예산 초과 |
tooClose | 기존 타워와 거리 40px 미만 |
이 reject 사유를 캔버스 위에 말풍선 으로 즉시 띄워 "왜 안 되는지" 를 설명. "돈은 충분한데 왜 배치 안 되지?" 라는 좌절을 제거.
3곳 동기화 필수
배치 로직은 3곳에서 체크합니다:
placeTower()— 실제 배치 시점getPlaceReject()— UI 에 사유 표시handleCanvasMouseMove— 마우스 호버 시 실시간 가능 여부
이 3곳이 같은 규칙을 따라야 일관된 UX. 규칙 추가/수정 시 모두 업데이트 체크리스트.
말풍선 UX
"배치 불가" 때 단순히 마우스 커서를 붉게 하는 것보다, 가능하면 무엇을 고치면 될지 알려주는 게 낫습니다.
const REJECT_LABELS: Record<PlaceRejectReason, string> = {
noCost: '에너지 부족',
maxTotal: '최대 15개까지',
orbitBudget: '이 궤도 예산 초과',
tooClose: '다른 타워와 너무 가까움',
};
마우스 호버 시 해당 위치의 reject 사유를 계산해서 말풍선 표시. 사용자는 즉시 "아, 옆 궤도로 가야 하는구나" 를 알 수 있습니다.
기술 과제 1 — SfxEvent 큐 패턴
개발 초기에 발사 효과음 재생 로직이 이랬습니다:
// ❌ 비효율 — 매 프레임 O(n²)
const newProjectiles = next.projectiles.filter(p => !prev.projectiles.some(pp => pp.id === p.id));
newProjectiles.forEach(p => playTowerFireSFX());
60fps 에 30개 타워가 쏘면 매 프레임 n² 비교 + 배열 생성 + GC 스파이크. 프레임 드롭의 원인이었습니다.
해결: 로직에서 이벤트가 발생하는 순간 큐에 push, 렌더/사운드 루프에서 O(n) 소비.
type SfxEvent =
| { type: 'towerFire'; towerIdx: number }
| { type: 'enemyAttack'; enemyId: string }
| { type: 'stealthReveal' };
// 로직 내부
function updateGameState(state) {
// ... 타워 발사 판정
state.sfxQueue.push({ type: 'towerFire', towerIdx });
}
// 컨테이너
useEffect(() => {
for (const ev of state.sfxQueue) {
switch (ev.type) {
case 'towerFire':
playTowerFireSFX();
break;
// ...
}
}
state.sfxQueue = [];
});
매 프레임 O(n) 소비 + 끝난 뒤 배열 비우기. 그 뒤로는 FPS 안정.
기술 과제 2 — 이중 tick 시스템
처음엔 state.tick 하나만으로 모든 시간을 관리했는데, 이게 wave phase 에서만 증가 하도록 설계되어 있었습니다.
문제: prep/waveEnd/menu 같은 non-wave phase 에서 UI 페이드/애니메이션이 멈춘 것처럼 보였습니다. state.tick 이 증가 안 하니까요.
해결: 두 개의 tick 분리.
// 로직용 — wave phase 안에서만 증가 (스폰·AI 타이밍)
state.tick;
// 렌더용 — RAF 루프에서 매 프레임 증가 (모든 phase)
tickRef.current;
state.tick: 게임 로직 (적 스폰·타워 발사·공격 쿨다운)tickRef.current: 시각 효과 (페이드·애니메이션)
이렇게 분리하니 prep phase 에서도 메뉴 애니메이션이 자연스럽게 흐르고, 게임 로직은 wave phase 에서만 진행되는 상태가 정확히 성립했습니다.
⚠️ 헷갈리는 순간: prep/waveEnd 에서
state.tick기반으로 시간 측정하는 코드를 실수로 작성하면 "영원히 0" 이라 타이머가 작동 안 함.state.tick은 wave 전용 이라는 점을 주석으로 명시.
UI 안전 영역
전체 화면 Canvas 에 HUD 가 상단·하단에 걸려 있는 구조. 궤도 렌더링이 HUD 뒤로 들어가지 않도록 안전 영역 을 계산:
const UI_PAD_TOP = 100;
const UI_PAD_BOTTOM = 60;
const availH = ch - UI_PAD_TOP - UI_PAD_BOTTOM;
const sc = Math.min(cw / REF_W, availH / REF_H);
const cy = UI_PAD_TOP + availH / 2; // 수직 중심
이 계산을 4곳에서 동일하게 참조:
drawOrbitalDefense(렌더)- RAF loop (이동 계산)
- click handler (배치 판정)
- mousemove (호버 판정)
한 값이라도 달라지면 "클릭한 곳과 실제 배치된 곳이 다른" 미묘한 버그. 공통 상수로 뽑아서 한 곳에서만 관리.
회고
"제약이 곧 게임성" 을 다시 확인한 프로젝트였습니다. 단순한 예산 상한 하나가 타워 조합의 다양성을 전부 만들어냈습니다.
기술적으로는 이중 tick 분리 와 SfxEvent 큐 패턴 이 기억에 남습니다. 전자는 "phase 별로 다른 속도로 흐르는 시간" 이라는 추상화, 후자는 "프레임 간 diff 검출 대신 이벤트 방출" 이라는 전환. 둘 다 다른 타워 디펜스·웨이브 기반 게임에도 재사용 가치 높습니다.
전략 게임 디자인에서 "선택지가 많다" 보다 "의미 있는 선택지가 몇 개 있다" 가 훨씬 재미있다는 걸 이 프로젝트로 배웠습니다.
방명록
이 글에 대한 한 줄을 남겨주세요
불러오는 중...