← DEVLOG
게임 제작2025.09.178 min read

갤러그를 Canvas로 재현하며

고전 아케이드 갤러그 — 적 편대 AI, 트랙터 빔, 스프라이트 캐싱까지 Canvas 2D로 구현한 기록

canvasweb-audiogame-aisprite-cache

시작 — 오락실의 기억

어린 시절 오락실에서 100원짜리 동전을 넣고 플레이하던 갤러그는 단순해 보이지만 놀랍도록 정교한 게임입니다. 편대 이동, 다이브 패턴, 트랙터 빔 보스 — 지금 다시 들여다보면 1981년에 이런 메커니즘을 구현했다는 사실이 경이롭습니다.

Canvas 2D API로 이것을 재현할 수 있는지 확인해보고 싶었습니다. 그리고 가장 먼저 마주친 문제는 렌더링 성능이었습니다.


shadowBlur 문제 — 스프라이트 캐싱 패턴

갤러그 특유의 발광 효과를 위해 처음에는 모든 적을 그릴 때마다 shadowBlur를 적용했습니다. 결과는 참혹했습니다. 적이 40개만 화면에 있어도 프레임 드롭이 심각했습니다.

shadowBlur의 비용은 픽셀 수에 비례합니다. 매 프레임 동일한 모양을 반복해서 그리면서 블러를 재계산하는 것은 낭비입니다. 해결책은 오프스크린 캔버스에 미리 렌더링해두고 재사용하는 스프라이트 캐싱 패턴이었습니다.

const spriteCache = new Map<string, HTMLCanvasElement>();

function getGlowSprite(type: EnemyType, color: string): HTMLCanvasElement {
  const key = `${type}-${color}`;
  if (spriteCache.has(key)) return spriteCache.get(key)!;

  const offscreen = document.createElement('canvas');
  offscreen.width = ENEMY_SIZE + GLOW_PADDING * 2;
  offscreen.height = ENEMY_SIZE + GLOW_PADDING * 2;
  const ctx = offscreen.getContext('2d')!;

  ctx.shadowBlur = 12;
  ctx.shadowColor = color;
  drawEnemyShape(ctx, type, GLOW_PADDING, GLOW_PADDING, ENEMY_SIZE);

  spriteCache.set(key, offscreen);
  return offscreen;
}

캐싱 이후 shadowBlur는 플레이어 함선(1개)과 플레이어 총알(최대 2개)에만 직접 적용합니다. Bee, Butterfly, Boss 적은 모두 캐시된 이미지를 drawImage로 복사합니다. 적 40개 기준 렌더링 시간이 약 8ms에서 1ms 이하로 줄었습니다.


편대 이동 — formation offsetX 시스템

갤러그 적들은 개별적으로 움직이지 않습니다. 편대 전체가 하나의 단위로 좌우로 이동하고, 벽에 닿으면 반전하면서 조금씩 하강합니다.

interface Formation {
  offsetX: number;
  dir: 1 | -1;
  speed: number;
}

function updateFormation(formation: Formation, enemies: Enemy[]) {
  formation.offsetX += formation.dir * formation.speed;

  // 편대 내 가장 좌측/우측 적 위치 계산
  const leftmost = Math.min(...enemies.map(e => e.homeX + formation.offsetX));
  const rightmost = Math.max(...enemies.map(e => e.homeX + formation.offsetX));

  if (leftmost <= WALL_MARGIN || rightmost >= GAME_W - WALL_MARGIN) {
    formation.dir *= -1;
    // 벽에 닿을 때마다 6px 하강
    enemies.forEach(e => {
      e.homeY += 6;
    });
  }
}

각 적의 실제 위치는 homeX + formation.offsetXhomeY의 합입니다. 다이브 중인 적은 이 편대 오프셋에서 분리되어 독립적인 궤적을 따릅니다.


다이브 AI — 상태 전환 패턴

다이브 패턴은 세 가지 상태로 관리됩니다.

type EnemyState = 'formation' | 'diving' | 'returning';

formation 상태에서는 편대와 함께 이동합니다. 일정 확률로 다이브를 시작하면 diving 상태가 되고, 플레이어 X좌표를 향해 베지어 곡선 경로로 하강합니다. 화면 하단을 벗어나면 화면 상단으로 이동해 returning 상태로 편대 위치로 복귀합니다.

보스 갤러그는 다이브 시 날개 한 쌍을 열어 트랙터 빔을 발사합니다. 빔에 맞은 플레이어 함선은 포획되어 편대에 합류하고, 이후 플레이어가 그 함선을 격추하면 함선을 되찾으면서 이중 발사 모드가 활성화됩니다. 이 순간의 점수는 2배입니다.

if (boss.state === 'diving' && boss.tractorActive) {
  // 빔 충돌 감지
  if (checkBeamCollision(boss, player)) {
    player.captured = true;
    boss.capturedShip = createCapturedShip(player);
    boss.tractorActive = false;
  }
}

오디오 — pendingSounds 큐 패턴

Web Audio API의 AudioContext는 게임 루프와 별도의 타이밍으로 동작합니다. 게임 로직 처리 중 즉시 사운드를 재생하면 동일 프레임에 여러 사운드가 겹치거나 렌더링을 지연시킬 수 있습니다.

해결책으로 게임 루프 내에서는 사운드 이벤트를 pendingSounds 배열에 큐잉하고, 루프 마지막에 일괄 처리하는 패턴을 사용했습니다.

const pendingSounds: SoundEvent[] = [];

// 충돌 감지 시
pendingSounds.push({ type: 'enemyExplode', x: enemy.x });

// 루프 마지막에 처리
pendingSounds.forEach(evt => audioModule.play(evt));
pendingSounds.length = 0;

적 격파음, 플레이어 발사음, 트랙터 빔 효과음 등 모든 사운드가 이 큐를 통해 처리됩니다. 같은 프레임에 동일한 사운드가 두 번 큐잉되면 중복을 제거하는 디바운스도 함께 적용했습니다.


스테이지 설계

1스테이지부터 보스 갤러그가 등장하면 긴장감이 없어집니다. 초반 스테이지는 Bee만으로 구성하고, 스테이지가 올라가면서 Butterfly, Boss 순으로 추가되도록 설계했습니다.

편대 배치 패턴은 실제 갤러그 아케이드 버전을 참고했습니다. Bee는 5열×4행, Butterfly는 위에 2열, Boss는 최상단 1열에 배치되는 구조입니다. 편대 진입 시 각 그룹이 화면 측면에서 곡선을 그리며 자기 위치로 날아가는 진입 애니메이션도 구현했습니다.


마치며

갤러그를 구현하면서 1981년 남코 개발팀이 한 선택들을 하나씩 이해하게 됐습니다. 왜 편대가 단위로 움직이는지, 왜 다이브 중인 적이 정확히 그 경로를 따르는지 — 하드웨어 제약 속에서 최선의 게임플레이를 만들어낸 설계들이었습니다.

오락실에서 100원어치 긴장감을 주던 그 게임을 코드로 다시 만들면서, 어린 시절의 감각을 조금이나마 되찾은 느낌이었습니다.

이 포스트와 연결된 콘텐츠

직접 체험하기