Asteroids — 토러스 공간과 소행성 분열을 구현하며
화면 끝에서 반대편으로 이어지는 토러스 공간, 소행성 3단계 분열 — 고전 물리 게임의 핵심 메커니즘 구현기
시작 — 관성의 게임
Asteroids(1979)는 그 단순함이 핵심입니다. 추력, 회전, 총알 — 세 가지 입력만으로 우주 공간의 물리를 체험하게 만드는 게임입니다. 특히 추력을 껐을 때 관성으로 계속 미끄러지는 느낌이 그 시대 다른 게임에는 없던 것이었습니다.
이 물리적 감각을 Canvas로 재현하는 데 집중했습니다.
토러스 공간 — wrapX / wrapY
Asteroids의 가장 유명한 특성은 화면 경계입니다. 왼쪽으로 나가면 오른쪽에서 나오고, 위로 나가면 아래에서 나옵니다. 수학적으로는 도넛 모양의 위상 공간인 토러스(Torus) 구조입니다.
구현 자체는 단순합니다.
function wrapX(x: number): number {
if (x < 0) return x + GAME_W;
if (x > GAME_W) return x - GAME_W;
return x;
}
function wrapY(y: number): number {
if (y < 0) return y + GAME_H;
if (y > GAME_H) return y - GAME_H;
return y;
}
그런데 렌더링에서 문제가 생깁니다. 소행성이 경계에 걸쳐 있을 때 — 즉 반쪽은 왼쪽 끝, 반쪽은 오른쪽 끝에 있을 때 — 올바르게 그려야 합니다. 이를 위해 경계에 걸친 객체는 두 위치에 각각 그리는 방식으로 처리했습니다.
function drawWithWrap(ctx: CanvasRenderingContext2D, obj: PhysicsObject) {
const positions = getWrappedPositions(obj.x, obj.y, obj.radius);
positions.forEach(([x, y]) => drawAt(ctx, obj, x, y));
}
소행성 분열 — 3단계 분열 물리
소행성은 맞으면 두 개로 분열됩니다. 크기는 large → medium → small 세 단계이며, small은 분열 없이 소멸합니다.
function splitAsteroid(a: Asteroid, bullets: Bullet[]): Asteroid[] {
if (a.size === 'small') return []; // 소멸
const nextSize = a.size === 'large' ? 'medium' : 'small';
const speed = Math.hypot(a.vx, a.vy) * 1.4; // 분열 시 1.4배 가속
const baseAngle = Math.atan2(a.vy, a.vx);
// 두 조각이 ±30도 방향으로 분기
return [+30, -30].map(offset => {
const angle = baseAngle + (offset * Math.PI) / 180;
return createAsteroid({
x: a.x,
y: a.y,
vx: Math.cos(angle) * speed,
vy: Math.sin(angle) * speed,
size: nextSize,
});
});
}
분열 각도와 속도 배율은 원본 아케이드 버전을 참고해 조정했습니다. ±30도, 1.4배 조합이 가장 자연스러운 폭발감을 줬습니다.
관성 비행 — 마찰 없는 우주
플레이어 함선에는 마찰이 없습니다. 추력을 가하면 그 방향으로 속도가 축적되고, 추력을 끊어도 감속하지 않습니다.
function updateShip(ship: Ship, input: Input) {
if (input.thrust) {
const ax = Math.cos(ship.angle) * THRUST_FORCE;
const ay = Math.sin(ship.angle) * THRUST_FORCE;
ship.vx += ax;
ship.vy += ay;
// 최대 속도 클램프
const speed = Math.hypot(ship.vx, ship.vy);
if (speed > MAX_SPEED) {
ship.vx = (ship.vx / speed) * MAX_SPEED;
ship.vy = (ship.vy / speed) * MAX_SPEED;
}
}
// 속도 감쇠 없음 — 우주 공간
ship.x = wrapX(ship.x + ship.vx);
ship.y = wrapY(ship.y + ship.vy);
}
이 무감쇠 물리가 Asteroids만의 긴장감을 만듭니다. 소행성 사이를 피해 이동하면서 관성을 제어하는 것이 핵심 기술입니다.
하이퍼스페이스
원본 아케이드의 하이퍼스페이스 기능도 구현했습니다. 버튼을 누르면 화면의 랜덤 위치로 순간이동합니다. 단, 소행성이 밀집한 위치에 등장할 수도 있습니다.
function hyperspace(ship: Ship, asteroids: Asteroid[]) {
ship.x = Math.random() * GAME_W;
ship.y = Math.random() * GAME_H;
ship.vx = 0;
ship.vy = 0;
// 소행성과 겹치면 즉사 판정
const overlap = asteroids.some(a => Math.hypot(a.x - ship.x, a.y - ship.y) < a.radius + ship.radius);
if (overlap) explodeShip(ship);
}
도박 요소가 게임에 절묘한 긴장감을 더합니다. 극한 상황에서 하이퍼스페이스를 쓸 것인지 말 것인지 — 이 판단 자체가 재미있습니다.
스테이지 클리어 오디오
스테이지 클리어 시 짧은 팡파르를 WebAudio 스케줄링으로 구현했습니다. setTimeout 기반이 아닌 AudioContext.currentTime 기반으로 정확히 타이밍을 맞춥니다.
function playStageClear(ctx: AudioContext, gainNode: GainNode) {
const freqs = [523, 659, 784, 1047]; // C5, E5, G5, C6
const t = ctx.currentTime;
freqs.forEach((freq, i) => {
const startTime = t + i * 0.12;
const osc = ctx.createOscillator();
osc.frequency.value = freq;
osc.connect(gainNode);
osc.start(startTime);
osc.stop(startTime + 0.1);
});
}
마치며
Asteroids를 구현하면서 가장 놀라웠던 점은 관성 하나만으로 이 정도의 긴장감이 만들어진다는 사실이었습니다. 마찰이 없다는 것 — 그 단순한 물리적 사실이 플레이어에게 끊임없는 속도 관리를 요구하고, 거기서 독특한 재미가 생깁니다.
1979년 아타리 개발팀이 이것을 알고 있었는지, 우연히 발견한 것인지는 알 수 없습니다. 하지만 그 결과물은 40년이 지난 지금도 여전히 재미있습니다.