Cosmic Tetris — 우주 테마 레이어를 얹은 클래식 테트리스
표준 Tetris Guideline (SRS · 7-bag · 락 딜레이) 코어 위에 외곽 핫핑크 에너지 게이지·블랙홀 흡수·UFO 적·번개 연출까지 — 익숙한 룰을 망가뜨리지 않으면서 우주를 얹은 과정과 캔버스 최적화 회고
시작 — 왜 다시 테트리스였나
우주 테마 아케이드를 쌓아오면서 슈팅류가 늘어났습니다. Galaga·Asteroids·Cosmic Barrage·Cosmic Flick·Cosmic Pinball까지. 장르 공백을 찾아보니 퍼즐·쌓기(stacking) 쪽이 비어 있었어요.
테트리스는 검증된 고전입니다. 룰을 따로 설명하지 않아도 모두가 알고, 집중 몰입이 빠릅니다. 여기에 우리 페이지의 우주 톤을 얹으면 Pinball 때와 같은 전략이 됩니다 — 친숙한 코어 + 우주 레이어. 다만 이번엔 그 우주 레이어를 어떻게 "코어 룰을 망가뜨리지 않으면서" 끼워 넣을지가 핵심 고민이었습니다.
1. 코어 — Tetris Guideline 준수
바닥부터 룰을 재발명하지 않고 공식 가이드라인을 존중했습니다.
- 7-bag randomizer: 7개 피스가 한 번씩 나오는 가방을 매번 새로 섞어서 공급. 같은 피스가 두 번 등장하지 않아 체감 분배가 공정해집니다.
- SRS (Super Rotation System): 회전 시 5단계 wall kick 테이블로 벽·블록에 끼였을 때도 자연스럽게 돌아갑니다. I 피스만 별도 테이블.
- 락 딜레이 + 이동 리셋: 피스가 바닥에 닿은 후 0.5초 잠금 대기. 이 동안 이동/회전 시 리셋 (최대 15회) — 마지막 순간 조정이 가능합니다.
- 스코어링: Single 100 / Double 300 / Triple 500 / Tetris 800, T-spin 보너스, Back-to-back ×1.5, Combo 보너스.
이 "기본"만 튼튼하면 이미 재미있는 게임이 됩니다.
2. 우주 레이어 — ENERGY CORE + BLACKHOLE
여기서부터 페이지 고유의 감성이 들어갑니다.
ENERGY CORE — 보드 외곽이 곧 게이지
처음엔 별도 게이지 바를 보드 위에 두었습니다. 하지만 사용자 피드백 중 "보드 외곽 네온 라인을 게이지로 쓰면 어떻겠냐"는 제안이 결정적이었어요. 결과적으로 별도 UI 위젯 없이 외곽선이 점점 핫핑크로 차오르는 형태로 정리됐습니다.
// 우상단 꼭지에서 시계방향으로 stroke path trace
const tracePath = () => {
ctx.beginPath();
ctx.moveTo(BOARD_X + W, BOARD_Y);
let r = remain;
// Seg1: 우상단 → 우하단 (H)
const s1 = Math.min(r, H);
ctx.lineTo(BOARD_X + W, BOARD_Y + s1);
r -= s1;
if (r <= 0) return;
// Seg2: 우하단 → 좌하단 (W)
// ... 4세그먼트
};
핀볼에서 사용한 3-레이어 네온 stroke 패턴(외곽 아우라 + 바디 + 코어 하이라이트)을 그대로 빌려와 strokeNeonRect 헬퍼로 추출하고, 보드 기본 외곽(보라)과 에너지 게이지(핫핑크)에 동일 문법으로 적용했습니다.
라인 클리어량별 충전:
| 동작 | 충전 |
|---|---|
| Single | +8 |
| Double | +18 |
| Triple | +32 |
| Tetris (4줄) | +52 |
| 피스 락 (정체 방지) | +0.5 |
Tetris 한 번이면 절반 이상이 핑크로 물듭니다. 자연스럽게 Tetris setup 동기가 강해지는 곡선.
BLACKHOLE 흡수 — 역전의 한 번
게이지가 FULL이 되면 B 키(모바일은 전용 버튼)로 발동합니다.
- 하단 3줄 통째 흡수 + 2,000점 보너스
- 외곽 핫핑크 pulse → 발동 가능 시각 알림
- 발동 시 prelude 번개 3회가 빠른 연속으로 보드를 가로지르고
- 흡수 진행 1.5초 동안 0.2~0.66초 간격으로 추가 번개가 랜덤으로 떨어집니다
번개는 핀볼 ambient에서 사용한 지그재그 path + maxAge fade를 가져와 단순화했어요. 외곽 글로우(shadowBlur 14) + 흰빛 코어(shadowBlur 0) 두 레이어로 그립니다.
function spawnLightning(state, frameOffset = 0) {
const sx = BOARD_X + 30 + Math.random() * (W - 60);
const sy = BOARD_Y + 20 + Math.random() * H * 0.3;
const ex = BOARD_X + 30 + Math.random() * (W - 60);
const ey = BOARD_Y + Math.min(H - 30, sy + 200 + Math.random() * (H * 0.5));
// 5~9 세그먼트 지그재그 noise 추가
state.lightnings.push({ points, age: -frameOffset, maxAge: 18 });
}
age: -frameOffset 트릭으로 한 번에 3개를 push하되 0/6/12 frame 시점에 등장하게 했습니다. spawn 함수를 따로 큐잉하지 않아도 자연스럽게 시간차를 줄 수 있어요.
3. METEOR — 주기적 긴장 주입 (UFO 도입 후 약화)
50초마다 유성이 떨어집니다. 1.5초 경고 동안 두 → 한 컬럼이 하이라이트되고 그 자리 스택 블록 1개가 제거되어 구멍이 생겨요.
처음에는 30초 / 두 컬럼 / 두 셀 제거였는데, 뒤에 추가된 UFO와 역할이 너무 겹쳐 약화시켰습니다. 중첩 외란이 너무 자주 일어나면 누적 빌드업 자체가 불가능해진다는 점을 의식한 결정이었어요.
4. UFO 적 — Phase A: 격추 가능한 침입자
가장 큰 변동성을 만든 것은 UFO입니다. 핀볼에서 사용했던 UFO 자산(렌더/이동/HP/레이저)을 가져와 테트리스에 맞게 단순화했습니다.
등장 규칙
- 레벨 5 이상부터 25~40초 간격(랜덤) 등장
- 레벨대로 1~3개 동시 등장
- 좌/우/상단에서 슬라이드 진입 (easeOutCubic)
- 가로 위치는 slot stagger — 여러 UFO가 한 자리에 겹치지 않게 균등 분포
표적 — 가장 위 셀 우선
function findTopFilledCell(state) {
for (let r = BOARD_BUFFER_ROWS; r < state.board.length; r++) {
for (let c = 0; c < BOARD_COLS; c++) {
if (state.board[r][c] !== null) return [r, c];
}
}
return null;
}
여기서 한 가지 게임 디자인 결정을 했습니다. UFO 표적을 랜덤 셀이 아니라 가장 위(row index가 작은) 셀로 고정. 그러면 UFO 공격이 스택 정리에 가까운 도움 효과가 됩니다. 너무 무작위하게 setup을 깨뜨리면 운빨 게임이 되어버려요.
격추 — 피스로 박치기
가장 만족스러운 메커닉입니다. 떨어지는 피스가 UFO 영역과 겹치면 격추되어 level × 500점 + 폭발 파티클 + "UFO DOWN +N" 라벨이 뜹니다. 즉 수동적으로 당하기만 하지 않고 반격 수단이 있어요.
function checkPieceUfoCollision(state) {
if (!state.current || state.ufos.length === 0) return;
for (const u of state.ufos) {
if (u.despawnProgress > 0) continue;
if (u.spawnProgress < 1) continue; // 진입 중엔 무적
for (const [cx, cy] of cells) {
// ... 셀 중심 ↔ UFO 중심 거리 검사
if (dx * dx + dy * dy < (CELL_SIZE * 0.4 + UFO_R) ** 2) {
u.despawnProgress = 0.001;
state.score += SCORE_UFO_DESTROY * state.level;
spawnExplosion(state, u.x, u.y);
}
}
}
}
블랙홀 발동 시에는 모든 UFO를 즉시 despawn 시작시킵니다. 안 그러면 1.5초 동안 멈춰있는 UFO가 어색하게 보여요.
5. 캔버스 최적화 — OffscreenCanvas 캐시
테트리스의 가장 큰 핫스팟은 의외로 떨어진 블록 그리기였어요. 보드는 10×20 = 200 셀, 채워진 셀이 많아질수록 매 프레임 200 ops 가까이 발생하고 각 셀은 그라디언트 + 폴리곤 path 2개 + stroke까지. 60fps 기준 초당 7만+ 페인트 호출이 가능합니다.
해결: PieceType별 OffscreenCanvas 캐시.
const _cellCache: Map<PieceType, OffscreenCanvas | HTMLCanvasElement> = new Map();
function getCellCache(type: PieceType) {
let cache = _cellCache.get(type);
if (!cache) {
cache = buildCellCache(type); // 1회만 그림
_cellCache.set(type, cache);
}
return cache;
}
function drawBlockCell(ctx, col, row, type, alpha) {
const cache = getCellCache(type);
// alpha < 1 (고스트) 만 globalAlpha + save/restore, 그 외엔 단순 drawImage
ctx.drawImage(cache, x, y);
}
매 프레임 200 셀이 단순 drawImage로 단축. 핀볼에서 학습한 패턴을 그대로 차용했습니다.
비슷하게 보드 프레임 캐시도 적용 — 그리드 + 네온 외곽선이 한 번에 OffscreenCanvas에 렌더되어 매 프레임 drawImage 1회.
6. 캐시의 함정 — 텍스트는 캐시하지 마세요
처음엔 "ENERGY CORE" 라벨까지 같이 OffscreenCanvas에 캐시했어요. 그런데 사용자 피드백: "글자가 뿌옇게 보여요."
원인: 캐시 캔버스를 CSS 논리 크기(w × 24)로 만들면 그 해상도가 고정됩니다. 실제 화면에서는 DPR(2x~3x) + fit scale로 drawImage 시 bilinear 업스케일되어 텍스트 edge가 blur. 게이지 트랙(rect) 같은 단순 도형은 티가 덜 나지만 텍스트 edge는 두드러져요.
해결은 단순했습니다. 텍스트는 캐시하지 않고 매 프레임 ctx.fillText로 직접 렌더. ctx의 transform(DPR 포함)이 이미 적용된 상태라 해상도 그대로 drawing buffer에 쓰여 sharp.
function drawEnergyBar(ctx, ...) {
// 게이지 트랙은 캐시
ctx.drawImage(_energyPanelCache, x, y);
// 라벨은 매 프레임 직접 (sharp)
ctx.fillText('ENERGY CORE', x, y);
}
이후 외곽 게이지로 시각화가 통합되면서 라벨 자체는 사라졌지만, 이 학습은 다른 게임 HUD 텍스트에도 반영했습니다.
7. CSS vs JS 사이징의 줄다리기
처음 hook은 부모 컨테이너 크기를 읽어 canvas.style.width/height를 매 프레임 직접 설정했습니다. 그런데 사용자가 직접 CSS를 조정하려고 하니 인라인 style이 CSS를 덮어써서 모든 조정이 무력화되는 문제가 발생했어요.
해결: 사이징을 CSS에 위임, hook은 canvas.clientWidth/Height를 읽어 drawing buffer(canvas.width/height 속성)와 ctx transform만 세팅.
const recompute = () => {
const cssW = canvas.clientWidth;
const cssH = canvas.clientHeight;
dprRef.current = setupCanvasScale(canvas, cssW, cssH, { setStyle: false });
const sx = cssW / LOGICAL_W;
const sy = cssH / LOGICAL_H;
const fit = Math.min(sx, sy);
cssFitRef.current = {
cssW,
cssH,
fit,
offX: (cssW - LOGICAL_W * fit) / 2,
offY: (cssH - LOGICAL_H * fit) / 2,
};
};
렌더 시 transform:
ctx.setTransform(fit * dpr, 0, 0, fit * dpr, offX * dpr, offY * dpr);
drawCosmicTetris(ctx, state, LOGICAL_W, LOGICAL_H, isMobile);
이러면 CSS가 박스 크기 전담, hook은 그 안에 fit + 중앙 정렬만 담당. 사용자가 aspect-ratio, max-width, max-height, transform을 자유롭게 조정해도 모두 살아 있어요.
8. 오디오 — BGM은 뮤트, SFX는 항상
스피커 버튼은 BGM만 뮤트하고 SFX는 항상 재생되어야 합니다 (audio.md 규칙). 이게 처음엔 제대로 작동하다 dispatch 가드가 잘못 들어가면서 SFX까지 꺼지는 버그가 한 번 있었어요.
또 별도 발견: BGM 시작 시점에 isMuted 반영 안 됨. phase 진입 useEffect와 isMuted useEffect가 분리되어 있어서, 뮤트 상태로 게임을 시작하면 isMuted effect가 트리거되지 않아 BGM이 디폴트 볼륨으로 재생됐습니다.
// 수정 후 — phase + isMuted 둘 다 deps, BGM 시작 직후 즉시 setVolume
useEffect(() => {
if (startPhases.includes(s.phase)) {
if (ctx) {
startTetrisBGM(ctx);
setTetrisBGMVolume(isMuted ? 0 : BGM_VOLUME); // ← 즉시 반영
}
} else {
setTetrisBGMVolume(isMuted ? 0 : BGM_VOLUME);
}
}, [ui.phase, isMuted]);
같은 버그가 핀볼 hook에도 있어서 함께 수정했습니다.
9. 효과음 합성 — gain 하한선
작업 중반쯤 사용자가 "락 소리(블록 쌓일 때)가 안 들려요"라고 말씀하셔서 디버깅했더니 — 180→80Hz sine sweep 같은 저주파 thump만으로는 일반 노트북/모바일 스피커에서 거의 안 들립니다. 인간 청각 범위 안이긴 해도, 작은 스피커는 100Hz 이하 출력이 매우 약해요.
export function playTetrisLock(ctx) {
// ❌ 이전: 80Hz sine 만 — 안 들림
// ✅ 새로: mid-range click + thump + 노이즈 burst
sfxBlip(ctx, 1100, 0.02, 'square', 0.22); // transient click
sfxSweep(ctx, 380, 160, 0.09, 'square', 0.3); // mid thump
sfxNoise(ctx, 0.06, 0.22, 1400);
}
모든 게임 SFX는 mid-range(200~2000Hz)에 transient를 같이 넣자가 학습이었어요. 사용자는 80Hz보다 1100Hz의 짧은 click을 훨씬 잘 인지합니다.
10. 회고
- 익숙함 + 차별화의 균형: 코어 룰을 손대지 않고 외란(블랙홀·유성·UFO)만 더했습니다. 어느 하나라도 룰을 직접 무력화했다면 운빨 게임이 됐을 것.
- 외란 중첩 주의: UFO 도입 시 유성을 약화시킨 건 옳은 결정이었습니다. "한 화면에 너무 많은 일"이 일어나면 누적 빌드업이 사라집니다.
- 반격 메커니즘 = 만족도: UFO를 일방적으로 당하기만 하는 적이 아닌, 떨어지는 피스로 박치기 격추 + 큰 점수로 만든 것이 가장 좋은 결정.
- 외곽이 곧 UI: 별도 위젯을 추가하기보다 이미 있는 시각 요소(보드 외곽 네온 라인)를 게이지로 활용하는 통합이 훨씬 깔끔.
- OffscreenCanvas는 모든 곳에 적용하지 말 것: 텍스트는 직접 렌더가 sharp. 캐시는 정적 도형·gradient에만.
- CSS vs JS 사이징: 한쪽이 박스 크기 전담, 다른 쪽이 fit/transform 전담. 양쪽이 동시에 박스를 결정하면 디버깅 지옥.
다음 단계로는 Phase B (UFO 변환 광선 — 떨어지는 피스를 다른 피스로 변경) 또는 Radial Tetris (블랙홀 중심 원형 그리드) 실험을 열어두고 있어요. Phase A의 사용자 반응을 본 뒤에 결정할 예정입니다.
방명록
이 글에 대한 한 줄을 남겨주세요
불러오는 중...