← DEVLOG
게임 제작2025.11.077 min read

Fortress — 포탄 물리와 AI 상대방 구현기

각도와 파워로 쏘는 포탄의 포물선 궤적, AI가 거리와 장애물을 계산해 반격하는 2인용 포격 게임 제작기

canvasphysicsgame-aiweb-audioballistics

시작 — 포격의 기하학

Artillery 계열 게임은 단순하지만 수학적으로 만족스럽습니다. 각도와 파워를 정하면 포물선이 그려지고, 그 끝이 적의 포탑에 닿으면 됩니다. 처음에는 간단해 보였지만, 제대로 된 AI 상대를 만드는 과정이 예상보다 훨씬 흥미로웠습니다.


포물선 탄도 물리

모든 것은 초기 속도 분해에서 시작합니다. 플레이어가 입력한 각도와 파워를 수평/수직 속도 성분으로 분해하고, 매 프레임 중력을 수직 속도에 더합니다.

const vx = Math.cos(angle) * power * POWER_SCALE;
const vy = -Math.sin(angle) * power * POWER_SCALE; // 위쪽이 음수

function updateBullet(bullet: Bullet) {
  bullet.vy += GRAVITY; // 매 프레임 중력 적용
  bullet.x += bullet.vx;
  bullet.y += bullet.vy;
}

GRAVITY 값은 게임 플레이 감각에 맞게 조정했습니다. 실제 중력(9.8m/s²)을 그대로 쓰면 포탄이 너무 빠르게 낙하해서 짧은 사거리만 사용하게 됩니다. 게임의 스케일에 맞는 값을 찾는 것이 중요했습니다.


충돌 감지 — AABB와 지형 충돌

포탄과 적 포탑의 충돌은 AABB(Axis-Aligned Bounding Box)로 처리합니다. 포탑은 직사각형이므로 충분합니다.

function checkTurretHit(bullet: Bullet, turret: Turret): boolean {
  return (
    bullet.x >= turret.x && bullet.x <= turret.x + turret.w && bullet.y >= turret.y && bullet.y <= turret.y + turret.h
  );
}

지형과의 충돌은 다릅니다. 지형은 불규칙한 다각형이므로, 포탄의 Y좌표가 해당 X 위치의 지형 높이보다 높아지면 충돌로 판정합니다.

function checkTerrainHit(bullet: Bullet, terrain: TerrainPoint[]): boolean {
  const terrainY = getTerrainY(terrain, bullet.x);
  return bullet.y >= terrainY;
}

지형 데이터는 X좌표에 따른 Y높이 배열로 구성됩니다. 게임 시작 시 랜덤 시드로 Perlin Noise 기반 지형을 생성하고, 이후 폭발이 발생하면 해당 지점의 지형을 파괴해 움푹 파인 크레이터를 만듭니다.


AI 상대방 — 수렴형 조준

AI 상대는 단순히 플레이어를 향해 쏘는 것이 아닙니다. 수렴형 조준 알고리즘을 사용합니다. 이전 착탄점의 오차를 측정해 다음 발사 각도를 점진적으로 수정해 나갑니다.

interface AiState {
  angle: number;
  power: number;
  lastLandingX: number | null;
  targetX: number;
}

function computeAiShot(ai: AiState): { angle: number; power: number } {
  if (ai.lastLandingX === null) {
    // 첫 발: 거리 기반 초기 추측
    const dist = Math.abs(ai.targetX - AI_TURRET_X);
    ai.angle = Math.PI / 4; // 45도 초기값
    ai.power = estimatePower(dist);
  } else {
    // 이후 발: 착탄 오차에 따른 보정
    const error = ai.targetX - ai.lastLandingX;
    ai.angle += error * ANGLE_CORRECTION_FACTOR;
    ai.power += error * POWER_CORRECTION_FACTOR;
  }

  return { angle: clamp(ai.angle, MIN_ANGLE, MAX_ANGLE), power: clamp(ai.power, 1, 100) };
}

AI가 처음에는 빗나가다가 점점 정확해지는 과정이 실제 포격 훈련처럼 느껴집니다. 너무 빠르게 수렴하면 AI가 압도적으로 강해지고, 너무 느리면 AI답지 않게 계속 빗나갑니다. 보정 계수를 조절해 적당한 긴장감을 찾는 것이 튜닝의 핵심이었습니다.


폭발 파티클

착탄 시 파티클 폭발이 시각적 피드백을 담당합니다. 각 파티클은 착탄 위치에서 랜덤한 radial 방향으로 방출됩니다.

function createExplosion(x: number, y: number): Particle[] {
  return Array.from({ length: PARTICLE_COUNT }, () => {
    const angle = Math.random() * Math.PI * 2;
    const speed = 2 + Math.random() * 4;
    return {
      x,
      y,
      vx: Math.cos(angle) * speed,
      vy: Math.sin(angle) * speed,
      life: 1.0, // 1.0 → 0.0 페이드아웃
      size: 2 + Math.random() * 3,
    };
  });
}

function updateParticle(p: Particle) {
  p.x += p.vx;
  p.y += p.vy;
  p.vy += 0.15; // 파티클에도 중력 적용
  p.life -= 0.04;
}

파티클에도 중력을 적용했습니다. 위로 퍼진 파티클이 포물선을 그리며 떨어지는 것이 더 자연스러운 폭발 느낌을 줍니다.


멀티 라운드 구조

게임은 단일 포격으로 끝나지 않습니다. 각 포탑은 TANK_HP만큼의 체력을 가지며, 명중할 때마다 체력이 감소합니다. 체력이 0이 되면 그 라운드가 끝나고 다음 라운드가 시작됩니다.

라운드가 바뀌어도 지형은 이전 라운드의 크레이터가 유지됩니다. 시간이 지날수록 전장이 점점 황폐해지는 시각적 효과가 자연스럽게 생깁니다.


캔버스 성능 — 4K 해상도 캡

포격 게임의 Canvas는 매 프레임 지형, 포탑, 포탄 궤적, 폭발 파티클을 모두 렌더링합니다. 데스크톱에서는 문제가 없었지만, 4K 디스플레이에서 devicePixelRatio가 2~3으로 올라가면 캔버스 버퍼 크기가 기하급수적으로 커져 GPU 부하가 급증했습니다.

해결 방법은 캔버스 버퍼의 총 픽셀 수를 1920 × 1080 기준으로 제한하는 것이었습니다.

const MAX_AREA = 1920 * 1080;
const area = w * h;
const scale = area > MAX_AREA ? Math.sqrt(MAX_AREA / area) : 1;
canvas.width = Math.round(w * scale);
canvas.height = Math.round(h * scale);

이 캡을 적용한 후에도 시각적 차이는 거의 눈에 띄지 않았습니다. 포탄 궤적이나 파티클은 서브픽셀 수준의 해상도 차이가 체감되지 않기 때문입니다. 반면 4K 모니터에서의 프레임 안정성은 확실히 개선되었습니다.


마치며

AI가 오차를 수정하며 점점 정확하게 조준해오는 과정을 보면서, 무언가 묘한 쾌감을 느꼈습니다. 내가 만든 알고리즘이 스스로 학습하는 것처럼 보이는 순간이 있습니다. 물론 실제로는 단순한 피드백 루프이지만, 게임 플레이로 체험하면 꽤 인상적입니다.

그리고 AI가 정확히 명중시켰을 때 — 내가 만든 AI에게 진 그 순간 — 묘하게 뿌듯했습니다.

이 포스트와 연결된 콘텐츠

직접 체험하기