2026.03.24삽질 일지
게임개발BabylonJS높이시스템전투로직3D타일맵경로탐색버그픽스

언덕 아래 몬스터가 2층 위 캐릭터를 때린다고요? — 3D 전술 게임 높이 시스템 전투 로직 삽질기

발단: 평면 세계의 전투

Bad North 스타일의 실시간 전술 배틀 게임을 만들고 있습니다. Babylon.js 기반 3D 던전 크롤러에서 방향을 틀어, 언덕과 계곡이 있는 타일맵 위에서 유닛들이 싸우는 전투 시스템을 구축하는 중이었죠.

맵에는 height 0, 1, 2 세 단계의 지형 높이가 있습니다. 성벽 위의 궁수가 아래를 내려다보며 쏘고, 검사는 계단을 타고 올라가야 적을 벨 수 있는 — 그런 전술적 깊이를 원했습니다.

그런데 테스트를 돌려보니, 언덕 아래(height 0)에 있는 몬스터가 2층 높이(height 2) 위의 캐릭터를 아무렇지 않게 근접 공격하고 있었습니다. 칼이 2미터짜리인가?

3D 전술 게임의 높이가 다른 타일맵

원인 추적: 맨해튼 거리의 함정

원인을 추적해보니, 공격 가능 여부를 판단하는 canAttack 메서드가 범인이었습니다.

원인: Unit.canAttack이 맨해튼 거리(X,Y 좌표)만 체크하고 높이 차이를 전혀 고려하지 않음

typescript
// 기존 코드 - 2D 맨해튼 거리만 계산
canAttack(target) {
  return this.getDistanceTo(target) <= this.range;
}
// getDistanceTo = |x1-x2| + |y1-y2|

언덕 아래(height=0)와 위(height=2)가 타일 1칸 차이면 거리=1. 근접 공격 사정거리(range=1) 이내. 공격 성공. 높이는 존재하지 않는 세계입니다.

3D 게임을 만들면서 전투 판정은 2D로 하고 있었던 거죠. 눈에는 절벽인데 코드에는 평지.

첫 번째 결정: 높이 차이 허용 범위

수정 방향을 잡기 전에 게임 디자인적 결정이 필요했습니다.

근접 유닛(range ≤ 1): 높이 차이 1 이상이면 공격 불가 (같은 높이에서만 근접 공격 가능)할까요? 아니면 높이 차이 1까지는 근접 허용할까요?

결론은 높이 차이 1까지는 근접 허용, 2 이상은 차단으로 결정했습니다. 현실적으로 한 계단 정도는 칼이 닿을 수 있으니까요.

정리하면:

  • 0 → 1 (차이 1): 근접 공격 가능
  • 1 → 2 (차이 1): 근접 공격 가능
  • 0 → 2 (차이 2): 근접 공격 불가

해결: canReachTarget 도입

CombatSystemcanReachTarget 메서드를 새로 추가했습니다. 핵심은 근접과 원거리를 분리하는 것이었습니다.

typescript
const MELEE_MAX_HEIGHT_DIFF = 1;

canReachTarget(attacker: Unit, target: Unit): boolean {
  // 원거리(range > 1)는 투사체니까 높이 무관
  if (attacker.range > 1) return true;
  
  // 근접: heightMap에서 양쪽 높이 조회
  const attackerHeight = this.heightMap[attacker.tileY]?.[attacker.tileX] ?? 0;
  const targetHeight = this.heightMap[target.tileY]?.[target.tileX] ?? 0;
  const heightDiff = Math.abs(attackerHeight - targetHeight);
  
  return heightDiff <= MELEE_MAX_HEIGHT_DIFF;
}

이 메서드를 findTargethasTargetInRange 양쪽 모두에 적용했습니다. 이게 중요한 포인트입니다.

findTargethasTargetInRange 양쪽 모두 높이 체크를 넣어야 합니다. 그래야 UnitAI가 전투 진입 판단할 때도 일관됩니다.

만약 findTarget만 수정하고 hasTargetInRange를 빼먹으면, UnitAI가 "적이 사정거리 안에 있다!"고 판단해서 전투 모드에 진입하는데, 막상 공격하려고 보면 타겟을 못 찾는 — 무한 전투 진입/해제 루프에 빠질 수 있습니다.

두 번째 문제: 원거리 높이 보정

근접 공격의 높이 제한은 해결했지만, 원거리는요? 높이 제한은 없지만, 성벽 위에서 쏘는 궁수와 아래에서 올려쏘는 궁수가 같은 명중률이면 언덕을 점령할 이유가 없습니다.

기존에 HeightSystem에 데미지 보너스/패널티(높이 차이 × 10%)는 이미 구현되어 있었습니다. 여기에 명중률 개념을 추가하기로 했습니다.

수정 계획:

  • 위 → 아래: 명중률 100% (고지 이점)
  • 아래 → 위: 높이 차이당 명중률 감소 (레벨당 -20%, 높이 차이 2면 60% 명중)
typescript
// CombatSystem.executeAttack 내부
if (attacker.range > 1) {
  const attackerHeight = this.heightMap[attacker.tileY]?.[attacker.tileX] ?? 0;
  const targetHeight = this.heightMap[target.tileY]?.[target.tileX] ?? 0;
  
  if (targetHeight > attackerHeight) {
    // 아래에서 위로 쏘기 — 명중률 감소
    const heightDiff = targetHeight - attackerHeight;
    const hitChance = 1.0 - (heightDiff * 0.2); // 레벨당 -20%
    
    if (Math.random() > hitChance) {
      // Miss!
      this.emit('combat', { type: 'attack', isMiss: true, damage: 0, ... });
      return;
    }
  }
  // 위에서 아래로는 100% 명중 — 고지 점령의 보상
}

CombatEvent 인터페이스에 isMiss 필드를 추가하고, BattleScene에서 miss 이벤트를 받으면 "MISS" 텍스트를 띄우도록 처리했습니다.

전술 게임에서 높은 지형의 전략적 이점

교훈 정리

1. 3D 게임의 전투 판정은 3D여야 한다

당연한 말 같지만, 2D 타일맵 기반으로 시작한 프로젝트에서 높이를 나중에 추가하면 이런 사각지대가 생깁니다. canAttack 같은 핵심 판정 함수는 높이를 처음부터 고려하도록 설계해야 합니다.

2. 일관성이 핵심이다

findTarget만 수정하고 hasTargetInRange를 빼먹으면 AI가 혼란에 빠집니다. 같은 조건을 판단하는 지점이 여러 곳에 흩어져 있다면, 하나의 메서드(canReachTarget)로 통합하고 모든 곳에서 호출하는 게 안전합니다.

3. 과도한 수정을 경계하라

"이동 중 전투 차단"이라는 단순한 해결책이 "이동 중 반격 불가"라는 새 버그를 만들었습니다. 문제의 범위를 정확히 파악하고, 필요한 만큼만 수정하는 것이 중요합니다.

4. 높이는 전술적 깊이의 핵심

근접 차단 + 원거리 명중률 보정 + 데미지 보너스까지 세 가지를 조합하면, 플레이어에게 "언덕을 점령해야 하는 이유"가 명확해집니다. 시스템 하나하나가 게임 플레이 의미를 가져야 합니다.