Cosmic Pinball — MS 핀볼에 대한 향수로 만든 우주 아케이드
윈도우 번들 Space Cadet 의 향수와 웹 페이지의 우주 컨셉을 결합한 세로 핀볼. 회전 collider 터널링, shadowBlur 비용, 전투하는 UFO 적까지 — 구현 과정의 기술 회고
시작 — 왜 다시 핀볼이었나
2000년대 초반 윈도우에 기본으로 들어있던 3D Pinball Space Cadet 을 기억하시나요. Full Tilt! Pinball 에서 파생된 그 핀볼이 점심시간마다 사무실을 정지시키던 시절이 있었습니다. 웜홀 3컬러, 중앙 블랙홀(Gravity Well), 랭크 업 시스템, Attack Bumpers — 아주 밀도 높게 짜인 시간 투자 대비 재미의 최대치 가 그 작은 창에 담겨 있었죠.
이 웹 페이지는 줄곧 우주 컨셉을 이어왔습니다. Galaga, Asteroids, Cosmic Barrage 같은 아케이드가 쌓였지만, 무언가 인터랙티브한 "테이블" 하나가 빠져 있다는 느낌이 계속 들었습니다. 우주 정거장 데크 위에 놓인, 계속 튕기고 계속 점수가 오르는 그런 기계. 그렇게 Cosmic Pinball 이 시작됐습니다.
목표:
- Space Cadet 의 핵심 요소(웜홀 / Gravity Well / 랭크) 오마주
- 이 웹 페이지의 우주 배경과 자연스럽게 연결
- 모바일 세로 레이아웃에서도 플레이 가능
- 캔버스 Web Audio 순수 합성 — 외부 샘플 없이
1부 — 물리
회전 collider 와 터널링
핀볼에서 가장 어려운 문제는 플리퍼 였습니다. 공은 빠르고, 플리퍼는 더 빨리 회전합니다. 처음 구현에선 플리퍼 각도를 프레임당 1회 갱신하고 공 물리를 6 substep 으로 돌렸는데, 이게 바로 치명적인 버그를 일으켰습니다.
// ❌ 처음 구현 — 프레임당 1회 각도 갱신
updateFlipperAngle(flipper, state.particles); // 1 frame = 0.85 rad 전체 이동
for (let i = 0; i < SUBSTEPS; i++) {
resolveCollisions(state); // 공 substep — 플리퍼는 이미 이동 완료된 상태
}
플리퍼 팁이 한 프레임에 64px 이동 하는데, 공 substep 입장에선 순간이동 한 것으로 보였습니다. 공을 쳐올려야 할 타이밍에 플리퍼가 먼저 최종 위치로 점프해버리니, 공은 허공에 남겨졌죠. 플레이어 입장에선 "분명 공이 플리퍼 위에 있었는데 그냥 통과했다" 는 답답한 경험이었습니다.
해결은 substep 내에서 각도를 점진적으로 갱신 하는 것이었습니다.
// ✅ 수정 — 각도를 substep 안에서 증분
for (let i = 0; i < SUBSTEPS; i++) {
stepFlipperAngle(state.flippers.left); // substep 당 0.142 rad
stepFlipperAngle(state.flippers.right);
// ... 공 물리 step ...
resolveCollisions(state);
}
이 때 중요한 건 angularVelocity 는 per-frame 환산값 으로 유지한다는 점입니다. tangent impulse(ω × r) 수식이 그대로 맞아야 하기 때문이죠.
"보이지 않는 공간에서 튕김" — Tapered Collider
두 번째 문제도 플리퍼였습니다. 플리퍼는 사다리꼴 모양(pivot 쪽 넓고 tip 쪽 좁음)으로 그렸는데, 충돌은 균일 capsule 반지름 19px 로 처리했습니다. 그 결과:
| 위치 | 시각적 half-width | 충돌 반지름 | 차이 |
|---|---|---|---|
| pivot 끝 | 9px | 19px | 10px |
| tip 끝 | 6.4px | 19px | 12.6px |
팁 끝부분에 공이 닿지도 않은 것처럼 보이는데 튕겨 나오는, 묘하게 이상한 감촉. 해결책은 충돌 반지름을 시각에 맞춰 테이퍼 하는 것이었습니다.
// t = 0(pivot) ~ 1(tip) 보간
const flipperHalfW = halfBig * (1 - t) + halfSmall * t;
const r = BALL_R + flipperHalfW;
// 추가: 세그먼트 길이도 시각 팁 직전까지로
const SEG_SCALE = 0.96;
SUBSTEPS × MAX_SPEED < BALL_R 불변식
고속 물리에서 공이 벽을 뚫고 사라지는 경우가 있었습니다. 원인은 명확했습니다.
MAX_BALL_SPEED = 64,SUBSTEPS = 6→ substep 당 10.67px 이동BALL_R = 10— 벽 충돌은 중심이 BALL_R 이내로 접근할 때만 감지
공이 한 substep 에 자기 반지름보다 더 멀리 이동하니, 벽 앞에서 -9px 떨어져 있던 공이 다음 substep 에 +1px 지나 있어도 충돌이 트리거되지 않는 겁니다. SUBSTEPS 를 8 로 올려 64/8 = 8 < 10 이 되도록 맞춰서 해결했습니다.
그래도 혹시 모를 NaN·경계 이탈에 대비해 clampBallSafety() 백업 안전장치를 뒀습니다. 공이 캔버스를 벗어나면 생명 차감 없이 런치 위치로 복원 — 실종은 버그지 플레이어의 잘못이 아니니까요.
런치 레인 재진입 — One-Way Gate
발사된 공이 플레이필드에서 돌다가 다시 런치 레인으로 들어가는 버그가 있었습니다. 물리적으로는 자연스러운 현상이지만, 게임 흐름상 매우 어색합니다. 실제 핀볼대엔 일방 통행 플랩 게이트가 달려 있죠.
이걸 Canvas 2D 에서 구현하려면 Wall 타입에 속도 방향 필터를 넣으면 됩니다.
export interface Wall {
x1: number;
y1: number;
x2: number;
y2: number;
oneWay?: 'blockDown' | 'blockUp';
}
// 충돌 루프:
for (const w of walls) {
if (w.oneWay === 'blockDown' && ball.vy <= 0) continue; // 위로 이동 중엔 통과
collideCircleSegment(ball, w.x1, w.y1, w.x2, w.y2, BALL_R, WALL_RESTITUTION);
}
발사 공(vy<0)은 게이트 통과, 플레이필드에서 떨어지는 공(vy>0)은 반사. 레인 상단 아치 끝 에 게이트를 배치해 공이 아치 끝 간극으로 재진입하던 경로를 막았습니다.
2부 — UFO 를 "적" 으로
단순한 범퍼로는 성에 차지 않았습니다. 우주 테마인 만큼 UFO 들이 능동적으로 공격하는 시스템을 넣었죠. 3단계로 구현했습니다.
1단계 — HP 와 파괴
각 UFO 에 체력 3. 공이 맞을 때마다 감소, 0 이 되면 파괴 + 폭발 파티클 + 보너스 1,000점, 3초 후 재생성.
2단계 — 레이저 AI
if (공 반경 220px 이내 && 쿨다운 완료) {
차징 30프레임 (0.5초) → 발사
발사체: 5.5px/frame, 수명 1.5초
}
피격 시 공 속도 ×0.55 + 방향 교란. 생명 차감은 없습니다. "레이저는 위협이지만 즉사는 아니다" 가 밸런스의 핵심이었습니다.
3단계 — 시계방향 슬롯 로테이션
UFO 가 고정 위치에 있으면 예측 가능해집니다. 그래서 5개 UFO 가 5개 슬롯을 시계방향으로 순환, 5초마다 다음 슬롯으로 smooth lerp 이동하게 만들었습니다.
const UFO_SLOTS = [
{ x: 200, y: 160 },
{ x: 340, y: 160 },
{ x: 380, y: 250 },
{ x: 270, y: 350 },
{ x: 160, y: 250 },
];
// 각 UFO 는 frame / 300 만큼 슬롯 인덱스 증가, 마지막 1초는 ease-in-out lerp
정지된 4초 동안엔 레이저를 쏘고, 1초 동안엔 부드럽게 다음 자리로 옮깁니다. 고정된 적이 아니라 끊임없이 자리를 바꾸는 적대 무리 가 됐습니다.
3부 — 성능
네온이 예쁘면 느려진다
초기 벽은 3 레이어 네온 합성이었습니다. 50+ 벽 세그먼트 × 3 레이어 = 150 stroke / frame, 각각 shadowBlur 20/12 적용. 결과는 눈에 띄게 버벅이는 게임.
먼저 배치 + 캐싱 두 단계로 최적화했습니다.
1단계 Stroke 배칭:
// ❌ 각 세그먼트마다 stroke
for (const w of walls) {
ctx.beginPath(); ctx.moveTo(...); ctx.lineTo(...); ctx.stroke(); // shadowBlur N회
}
// ✅ 전체 세그먼트 1 stroke (shadowBlur 1회만)
ctx.beginPath();
for (const w of walls) {
ctx.moveTo(w.x1, w.y1); ctx.lineTo(w.x2, w.y2);
}
ctx.stroke();
2단계 OffscreenCanvas 캐시 — 벽은 정적이니 최초 1회만 그리고 매 프레임은 drawImage.
let _wallsCache: OffscreenCanvas | null = null;
// 최초: buildWallsCache() → 벽 렌더링
// 매 프레임: ctx.drawImage(_wallsCache, 0, 0)
파티클의 숨은 비용
공통 유틸 drawParticle 이 내부에서 shadowBlur = 4 를 기본 적용하고 있었습니다. MAX_PARTICLES=80 이면 최악의 경우 80 × GPU blur 연산 이 매 프레임 발생하던 겁니다. shadowBlur 제거 + save/restore 최소화로 프레임 타임이 확 줄었습니다.
캐싱 카탈로그
결국 이런 것들을 전부 OffscreenCanvas 에 캐시했습니다.
- 벽 (1 캐시) — 정적
- 범퍼 body (kind × r 조합별, 4 캐시) — 창문 회전·히트 펄스만 동적
- 드롭 타겟 (id 별, 3 캐시) — breathe 펄스만
ctx.scale()로 적용 - 웜홀 base (color × r, 3 캐시) — 회전 링은 동적
- 슬링샷 (id 별, 3 캐시) — 히트 플래시는
globalCompositeOperation: 'lighter'오버레이 - 링 범퍼 (r 별, 1 캐시) — 회전 리벳 4개만 동적
결과: 프레임당 shadowBlur 호출이 ~110 → ~18-25 (약 80% 절감).
4부 — 오디오
핀볼 소리는 두 가지로 나뉩니다. 효과음(SFX) 과 배경음(BGM).
SFX — Web Audio 순수 합성
범퍼 "뿅", 슬링샷 튕김, 웜홀 whoosh, 블랙홀 서브베이스 드론, UFO 레이저 zap, 파괴 폭발 — 모두 OscillatorNode + BiquadFilter 로 만들었습니다. 외부 mp3 없이.
블랙홀이 열려 있는 10초 동안엔 지속형 앰비언트 (40Hz 서브베이스 + 80Hz 옥타브 + LFO 필터 스윕) 가 계속 재생되며 "뭔가 열려 있다" 는 긴장을 유지합니다.
플리퍼 "텅텅" 소리는 2톤으로 나눴습니다. press 때는 110→55Hz 펀치, release 때는 92→48Hz 부드러운 회귀음. 좌우 빠르게 교대하면 자연스럽게 "텅텅텅텅" 리듬이 생기죠.
BGM — A minor 8마디 루프
MS Space Cadet 의 음악이 C minor 계열의 어둡고 긴장감 있는 진행이었던 기억이 나서, A minor 8마디 로 비슷한 무드를 재현했습니다.
Am | Am | F | G | C | Am | F | E7 →
마지막 E 도미넌트에서 긴장이 최고조에 올랐다가 다시 Am 으로 해결되는 클래식한 마이너 진행. 템포 112 BPM, 17초 루프. square 파 아르페지오 멜로디 + triangle 파 베이스 펄스로 합성했습니다.
스피커 버튼은 BGM 만 뮤트합니다. 게임 피드백인 SFX 는 항상 재생 — UFO 레이저 warning 이나 블랙홀 드론이 안 들리면 오히려 플레이가 어색해지니까요.
5부 — 배경에 살아있는 우주
빈 캔버스 뒷면에 뭔가 살아 움직이는 것 이 있었으면 싶어서 앰비언트 시스템을 추가했습니다.
- 3대의 소형 우주선 이 화면을 떠다니며 가끔씩 레이저를 교환, 폭발
- 간헐적으로 번쩍이는 우주 번개 (빠른 지그재그 flash)
- 느리게 드리프트하는 네온 orb
- 양쪽 링 범퍼에서 방사되는 노란 번개 (매 프레임 1.8% 확률로 발사)
이들 전부 게임 로직·물리·점수에 0 영향. 완벽하게 순수 시각 장식이지만, 있는 것과 없는 것의 차이가 엄청납니다. 플레이필드 뒤에서 "뭔가 계속 벌어지는" 느낌이 생기니 몰입도가 다른 차원이죠.
마무리 — 핀볼은 결국 감각의 게임
수학적으로 계산되지 않는 부분이 많았습니다. 플리퍼 팁의 튕김 강도, 슬링샷의 탄성, UFO 레이저 피격 시 공 감속 비율 — 전부 밸런싱과 체감 이 답을 결정했습니다. "이 순간 공이 이렇게 움직여야 재밌다" 라는 감각을 코드로 옮기는 과정.
MS 시절 Space Cadet 을 켜고 Rank Promoted 사인을 기다리던 그 느낌을, 웹 캔버스 위에 다시 불러낼 수 있어서 만족스러운 작업이었습니다. 스페이스바를 꾹 눌러 플런저를 최대로 당긴 다음, 웜홀 세 번째 히트로 BLACK HOLE OPENED! 사인이 뜨는 순간 — 핀볼이 왜 아직도 재미있는지를 다시 확인하는 짧은 시간이었습니다.
직접 플레이해 보세요. 🛸
방명록
이 글에 대한 한 줄을 남겨주세요
불러오는 중...