BRICK CRUSH — 페어프로그래밍으로 만든 모바일 벽돌깨기
무엇을 만들었나
네비게이터인 팀원이 제안하고 드라이버인 내가 만든 세로 화면 벽돌깨기. 만들기 전에 “반드시 막아야 할 함정 3가지”를 먼저 적었고, 정작 버그는 네 번째 자리에서 나왔다.
4회차 수업의 페어프로그래밍 실습으로 만든 게임이다.
index.html 한 개, 938줄. 라이브러리도, 이미지도, 사운드 파일도 없다. 그래픽은 전부 캔버스에 그렸다.
직접 해보기 → https://songyee-ai.github.io/game_crush/
코드 보기 → github.com/songyee-ai/game_crush
| 조작 | 방법 |
|---|---|
| 모바일 | 화면 아무 곳이나 잡고 좌우로 드래그 · 탭하면 발사 |
| 데스크톱 | 마우스 드래그 또는 좌우 방향키 · 스페이스·엔터로 발사 |
목숨 3개, 5스테이지. 강철을 뺀 모든 벽돌을 깨면 클리어하고, 다음 스테이지는 공이 8% 빨라진다.
1. 만들기 전 스케치
설계안에서 제일 앞에 둔 건 그래픽이 아니라 조작이었다.
모바일에서 편한 조작이 이 게임의 성패를 가릅니다. (…) 절대 위치가 아닌 상대 이동. 손가락이 20px 움직이면 패들도 그만큼 이동. 화면 왼쪽 끝을 터치했다고 패들이 순간이동하면 안 됨
그리고 하나 더. “반드시 막아야 할 함정 3가지”를 만들기 전에 이름 붙여 적어 뒀다. 터널링, 수평 갇힘, 프레임 의존. 셋 다 돌려보면 멀쩡해 보이는데 특정 조건에서만 튀어나오는 종류라, 나중에 찾으려면 못 찾는다.
2. 만드는 과정 — 프롬프트 기록
페어프로그래밍은 이렇게 굴러갔다
마이크와 화면을 공유한 채, 정해진 시간마다 드라이버와 네비게이터를 바꿔 가며 진행하는 방식이었다. 이 벽돌깨기는 네비게이터인 팀원이 게임과 아이템 구성을 제안하고, 드라이버인 내가 만든 구간이다.
그래서 아래 기록에는 한계가 하나 있다. 말로 오간 제안과 지적은 남아 있지 않다. 키보드 앞에서 일어난 일만 기록으로 남는데, 페어프로그래밍에서 절반은 말로 지나간다.
역할을 바꿨을 때 — 내가 네비게이터였던 판
같은 실습의 다른 구간에서는 역할이 반대였다. 팀원이 드라이버, 내가 네비게이터.
내가 제안한 건 카드 짝맞추기였다. 뒤집어서 같은 그림 두 장을 찾는 그 게임. 여기에 조건을 하나 붙였다 — 귀여운 동물 그림이 카드에 그려졌으면 좋겠다.
그런데 만들기 시작하면서 바로 걸렸다. 일러스트를 그리거나 구해 오는 시간까지 계산하면 실습 시간 안에 게임이 돌아가지 않는다. 그래서 동물 이모지로 대체하자고 조정했다. 그림 파일이 필요 없으니 카드 뒤집는 로직에만 시간을 쓸 수 있었다.
네비게이터를 해 보니 신경 쓰는 곳이 달랐다. 드라이버일 때는 “어떻게 만들지”를 붙잡고 있었는데, 네비게이터일 때는 무엇을 포기할지를 먼저 정해야 했다. 귀여운 동물 일러스트를 끝까지 고집했으면 카드가 한 장도 안 뒤집힌 채로 시간이 끝났을 것이다. 만들고 싶은 것과 시간 안에 되는 것 사이에서 한 칸 물러서는 판단 — 그게 네비게이터의 몫이었다.
시도 1 — 설계안 넣기
팀원의 제안을 설계안 한 장으로 정리해 통째로 넣었다. 요약하면 이렇다.
- 기술 조건 — 단일
.html하나. 외부 라이브러리·빌드 도구·이미지·사운드 파일 없음. 플레이 영역은 캔버스, HUD와 오버레이는 DOM. 최고 점수만 저장 - 조작(가장 중요) — 화면 어디를 잡아도 반응 · 절대 위치가 아닌 상대 이동(왼쪽 끝을 터치했다고 패들이 순간이동하면 안 된다) · 감도 1.5배 ·
touch-action: none - 화면 — 세로로 HUD → 플레이 영역(3:4) → 조작 여백. 벽돌은 위 60%에만. 아래 40%는 반응할 시간
- 상태 — 공은 처음부터 배열(나중에 멀티볼을 넣으려고 바꾸면 전부 고쳐야 한다), 효과는 불리언이 아니라 남은 시간(ms)
- 물리 — 패들 반사각은 맞은 지점으로 결정(최대 60도), 벽돌 충돌은 겹침이 작은 축만 반전, 프레임당 벽돌 하나만 처리
- 함정 3가지 — 터널링 / 수평 갇힘 / 프레임 의존을 막을 것
- 벽돌·아이템 — 내구도 1~3 · 강철 · 폭탄, 아이템 5종(
wideslowpiercemultishrink) 12% 확률 낙하, 최소 5스테이지
그리고 한 줄을 덧붙였다.
아래 사양을 그대로 따라주시고, 명시되지 않은 부분은 판단에 맡깁니다.
결과: 폴더 연결 → index.html 한 개 생성. 여기서도 셸로 긴 HTML을 밀어 넣다가 파일이 깨져서, 파일 쓰기 도구로 다시 작성했다. 2048 때와 똑같은 지점에서 똑같이 걸렸다.
고친 점: 이번에도 내가 다시 요청한 건 없다. 대신 검증 과정에서 진짜 버그 하나가 나왔다.
그 사이 — 검증에서 걸린 것
내가 설계안에 적은 규칙은 이랬다.
무조건
vy = -vy로 하면 옆구리를 스칠 때 공이 벽돌 속으로 파고듭니다. 겹침이 작은 축을 뒤집으세요.
맞는 규칙이었다. 그런데 공이 벽돌 사이 틈으로 똑바로 위를 향해 들어가는 경우가 있었다. 좌우 겹침이 더 작으니 규칙대로 vx를 뒤집는데, 수직으로 올라가는 공은 vx가 0이다. 0을 뒤집어도 0이라 반사가 아예 일어나지 않고, 공은 벽돌 두 개를 그대로 통과했다. 고친 방법은 한 줄 추가 — 겹침의 크기만 보지 말고 그 축으로 실제 다가가고 있는지를 함께 본다.
나머지 수정 셋: 프레임당 벽돌 하나만 처리하도록 플래그 보강 · 새 게임을 시작해도 패들이 죽은 자리에 남아 있던 것 수정 · 드래그 안내 문구 위치 이동.
함정 3가지는 실제로 재현을 시도해서 확인했다.
| 확인한 것 | 결과 |
|---|---|
| 터널링 — 속도 430·600·1200으로 각 1000회 이상 벽돌에 쏘기 | 통과 0건 |
| 수평 갇힘 — 세로 속도를 0.4로 강제 | 43으로 자동 교정 |
| 프레임 의존 — 120Hz 2프레임 vs 60Hz 1프레임 | 차이 0.0000 |
| 패들 반사각 — 맞은 지점 -1 ~ 1 | -60° ~ 60°, 속도 유지 |
| 5스테이지 완주 · 조작 감도 1.5배 | 정상 |
시도 2 — 배포
“깃허브 페이지 배포해줘.” 빌드 34초.
로컬에서는 미리보기가 data: 주소로 열려서 최고 점수 저장만 검증이 비어 있었는데, 실제 https:// 주소에서 저장·재읽기를 확인했다. 폭 320 / 375 / 430 / 가로 모드에서 가로 스크롤이 없는 것도 함께.
3. 완성 화면
4. 코드에서 배운 부분
4-1. 반사각을 물리가 아니라 위치로 정하면 생기는 일
function bounceOffPaddle(ball){
const p = state.paddle;
const hit = clamp((ball.x - p.x) / (p.width / 2), -1, 1); // -1 ~ 1
const angle = hit * (Math.PI / 3); // 최대 60도
ball.vx = ball.speed * Math.sin(angle);
ball.vy = -ball.speed * Math.cos(angle);
ball.y = PADDLE_Y - ball.radius - 0.5;
}
현실의 반사를 흉내 내지 않았더니 오히려 게임이 됐다. 패들 끝으로 받으면 크게 꺾이고 가운데로 받으면 곧게 올라가니까, 어디로 받을지 고르는 것이 실력이 된다. 그리고 방향만 각도로 정하고 빠르기는 speed 하나로 따로 들고 있어서, 몇 번을 튕겨도 속도가 흔들리지 않는다.
4-2. 조건이 맞아도 실행할 게 없을 수 있다
수정 후의 충돌 처리 코드다.
// 겹침이 작은 축을 고르되, 그 축으로 실제 접근 중일 때만.
// (벽돌 사이 틈으로 수직 진입하면 overlapX가 더 작아도 vx는 뒤집을 게 없다)
const approachX = (ball.x < bcx && ball.vx > 0) || (ball.x > bcx && ball.vx < 0);
const approachY = (ball.y < bcy && ball.vy > 0) || (ball.y > bcy && ball.vy < 0);
if ((overlapX < overlapY && approachX) || !approachY){
ball.vx = -ball.vx;
...
overlapX < overlapY는 틀린 조건이 아니었다. 조건은 맞았는데 그 조건이 고른 동작(vx 반전)이 그 상황에서는 아무것도 하지 않았을 뿐이다. 조건문을 볼 때 “이 조건이 참인가”만 보면 안 되고 “참일 때 하는 일이 실제로 의미가 있는가”까지 봐야 한다는 걸 여기서 배웠다.
4-3. 함정 3가지를 막은 방법
셋 다 “돌려보면 멀쩡해 보이는” 종류다. 느린 기기에서, 특정 속도에서, 특정 각도에서만 나타난다. 그래서 만든 다음에 찾는 게 아니라 만들기 전에 이름을 붙여 두는 쪽이 맞았다. 4-2의 버그도 결국 같은 방식으로 잡혔다 — 재현 조건을 코드로 만들어 수백 번 반복하니 드러났다.
5. 막혔던 점과 해결
| 막힌 지점 | 원인 | 해결 방법 |
|---|---|---|
| 벽돌 사이 틈으로 들어간 공이 벽돌 2개를 통과 | 겹침이 작은 축(vx)을 뒤집었는데 수직 진입이라 vx가 0. 0을 뒤집어도 0 |
그 축으로 접근 중인지 확인하는 조건 추가 (그림 4) |
| 긴 HTML을 셸로 넣다가 파일이 깨짐 | 2048 때와 같은 문제 — 긴 텍스트를 셸 명령으로 밀어 넣으면 셸이 먼저 해석한다 | 파일 쓰기 도구로 통째로 작성 |
| 새 게임을 시작해도 패들이 죽은 자리에 남아 있음 | restart()가 점수·목숨·스테이지만 되돌리고 패들 위치를 빼먹음 |
패들을 화면 가운데로 되돌리는 한 줄 추가 |
| 최고 점수 저장을 로컬에서 확인 못 함 | 미리보기가 data: 주소로 열려 브라우저 저장소에 접근할 수 없었다 |
배포 후 실제 https:// 주소에서 저장·재읽기 확인 |
6. 다음에 개선할 것
- 페어의 나머지 절반을 남기기 — 말로 오간 제안과 지적이 기록에 하나도 없다. 다음엔 네비게이터가 말한 걸 그때그때 메모로 남기기
- 스테이지를 더 만들기 —
STAGES배열에 문자열 몇 줄이면 끝나게 해 뒀으니 실제로 써먹어 보기 - 아이템 남은 시간을 화면에 표시 — 지금은 8초가 언제 끝나는지 알 수 없다
- 소리 — 지금은 파티클이 타격감을 내는 유일한 수단이다
- 이번에도 구현은 한 줄도 직접 쓰지 않았다. 다음엔 고치는 것부터라도 내 손으로