H
ryuhaon.dev
/Blog/번호 뽑기에 대포를 끼얹은 이유 — 대포 추첨 개발기
개발게임Next.js회고

번호 뽑기에 대포를 끼얹은 이유 — 대포 추첨 개발기

그냥 숫자 뽑기는 심심해서 포탄에 담아 발사했다. SVG 대포 디테일에 며칠을 쓴 이야기.

미스틱 로또는 신비로움이었다. 별자리, MBTI, 기분으로 "운명"을 소환하는 느낌.

대포 추첨은 정반대 결을 만들고 싶었다. 번호가 "뽑히는" 게 아니라 "발사되는" 추첨. 그냥 1부터 100 사이 숫자 몇 개를 뽑는 건데, 그걸 포탄에 담아서 슈우웅 날려보내면 어떨까 싶었다.

미스틱 로또가 의식이라면, 대포 추첨은 액션이다.


첫 번째 결정 — 무엇을 자유롭게 둘 것인가

로또는 1~45에서 6개. 주사위는 1~6. 그 사이 어딘가에 사용자가 직접 정하고 싶은 케이스가 너무 많다.

"1~30에서 3개 뽑고 싶은데"
"팀원 7명 중에 발표자 1명 뽑아줘"
"6면체 주사위 5번 굴려줘"

그래서 번호 범위 1~999, 개수 1~10, 중복 허용 토글, 오름차순 토글을 다 설정으로 뺐다. 프리셋으로 "주사위", "미니", "로또", "100"을 두고, 그 외엔 직접 입력.

여기서 한 가지 함정에 걸렸다. 직접 입력 칸에 1이나 1500처럼 범위 밖 값을 넣으면 max 상태가 안 바뀐다. 근데 사용자 입력창엔 그 숫자가 그대로 보인다. 사용자가 본 숫자와 실제 추첨 범위가 다르다. 시작 버튼 누르면 "어? 왜 100까지지?" 가 된다.

빨간 보더와 에러 문구로 막았다. "2 ~ 999 범위로 입력하세요". 단순한 검증인데 처음에 빼먹어서 한참 헤맸다.


대포를 그렸다 — SVG 280줄

처음엔 대포 이모지(💥)나 단순한 원기둥 도형으로 시작했다. 근데 발사 모션이 들어가니까 본체가 너무 빈약해 보였다.

그래서 SVG로 직접 그렸다. 돌고래 손잡이, 캐스카벨(뒤쪽 노브), 점화구, 황동 별 양각, 4단 보강 띠, 8-스포크 휠. 17~18세기 함대용 대포 사진을 한참 봤다.

<radialGradient id="cl-cascabel" cx="0.35" cy="0.35" r="0.65">
  <stop offset="0%" stopColor="#fef9c3" />
  <stop offset="35%" stopColor="#fbbf24" />
  <stop offset="75%" stopColor="#92400e" />
  <stop offset="100%" stopColor="#1c0a01" />
</radialGradient>

황동 광택, 나무 결, 무쇠 림 그라데이션이 다 따로 정의돼 있다. 합치면 SVG 한 컴포넌트가 280줄 가까이 된다.

처음엔 "굳이 이렇게까지?" 싶었는데, 한 번 만들고 나니 발사 애니메이션이 훨씬 무게감 있게 보였다. 카메라가 빠지지 않는 메인 무대니까 디테일이 살아야 했다.


발사 — 회전된 포구에서 시작해야 한다

대포가 각도를 틀어서 발사한다. 번호가 6개면 각각 다른 각도로. -52도부터 +52도까지 부채꼴로 펼친다.

그러면 포탄의 시작점이 문제다. 대포 정중앙 좌표에서 시작하면 안 된다. 대포가 30도 기울어졌으면 포구도 30도 기울어진 위치에 있다. 시작 좌표를 회전 행렬로 직접 계산해야 한다.

const angleRad = (cannonAngle * Math.PI) / 180;
const dx = CANNON_MUZZLE_LOCAL.x - CANNON_PIVOT.x;
const dy = CANNON_MUZZLE_LOCAL.y - CANNON_PIVOT.y;
const rotatedX = dx * Math.cos(angleRad) - dy * Math.sin(angleRad);
const rotatedY = dx * Math.sin(angleRad) + dy * Math.cos(angleRad);

피벗 좌표((130, 140))를 기준으로 포구의 로컬 좌표((130, 16))를 각도만큼 회전시킨다. 고등학교 수학 같지만, 이 한 줄을 놓치면 포탄이 대포 옆구리에서 튀어나간다.


사운드는 음원 파일 없이 — Web Audio API 합성

발사음을 mp3로 넣을까 했는데, 라이선스 걱정 + 로딩 시간이 거슬렸다. 그래서 Web Audio API로 직접 합성했다.

발사음은 세 겹이다.

  • 저주파 사인파: 160Hz → 38Hz 급강하 (둔탁한 폭발음)
  • 중주파 삼각파: 420Hz → 120Hz (탁한 충격)
  • 밴드패스 필터 노이즈: 900Hz → 220Hz 스윕 (연기와 잔향)
const lowOsc = c.createOscillator();
lowOsc.type = 'sine';
lowOsc.frequency.setValueAtTime(160, now);
lowOsc.frequency.exponentialRampToValueAtTime(38, now + 0.32);

세 개를 동시에 울리고 0.4초 만에 페이드 아웃. 진짜 대포 같진 않지만 충분히 폭발 같다.

착지음은 주파수 스윕 + 클릭, 마무리는 C-E-G 화음 0.08초 간격. 음원 파일 0KB로 게임 사운드가 다 들어갔다.


4중 저장 버그

이게 진짜 황당했다.

추첨 결과를 한 번 뽑으면 기록에 네 개가 쌓였다. 처음엔 코드를 잘못 본 줄 알았는데 진짜였다.

원인을 좁혀가다 보니:

  1. ResultScreen이 마운트되면서 useEffectsaveHistoryEntry 호출 (1번)
  2. React Strict Mode가 dev에서 useEffectmount → cleanup → mount로 강제 두 번 실행 → 2번
  3. 사용자가 "기록 보기"로 갔다가 "돌아가기"로 ResultScreen에 다시 오면 → 또 mount → 3번
  4. 다시 strict mode 이중 실행 → 4번

savedRef로 useRef 가드를 둔다고 막을 수도 없었다. ref는 컴포넌트 unmount 시 리셋되니까 기록 화면 갔다 오면 새 ref가 된다.

해결은 단순했다. 저장 로직을 화면 컴포넌트에서 떼서 트랜지션 콜백으로 옮겼다.

// Game.tsx
const handleCannonDone = useCallback(() => {
  saveHistoryEntry(numbers, settings);
  if (soundEnabled) playFinale();
  setScreen('result');
}, [numbers, settings, soundEnabled]);

useEffect는 렌더링 동기화용이지, "한 번만 일어나야 하는 작업" 용이 아니다. 트랜지션은 user gesture 콜백이라 정확히 한 번. 이 패턴은 다른 화면에서도 적용할 수 있을 것 같아서 별도로 기억해뒀다.


더블탭으로 발사 두 번 — firingRef로 동기 차단

fireAll 버튼에 if (autoFiring) return 가드를 뒀는데, setAutoFiring(true)가 비동기라 React 18의 setState 배칭 안에서 두 번째 클릭이 가드를 통과할 수 있다. 발사 루프가 두 개 동시에 돌면 onDone도 두 번 호출되고, 또 기록 중복 저장.

setState 기반 가드는 batch 때문에 race가 난다. useRef로 동기 가드를 깔아야 한다.

const firingRef = useRef(false);
const fireAll = async () => {
  if (firingRef.current) return;
  firingRef.current = true;
  // ...
};

이건 setState와 useRef의 차이를 체감한 사례였다. setState는 "다음 렌더에 반영되는 값", useRef는 "지금 당장 바뀌는 값".


자잘한 것들 (이 게임도 결국 자잘함)

  • AudioContext 정리: 게임 떠나면 closeAudio()로 닫음. 안 그러면 SPA에서 들락날락 시 쌓임
  • ResizeObserver: 비행 중 화면 회전(모바일 가로↔세로)되면 포탄 좌표 어긋남. 좌표 다시 계산
  • clipboard 폴백: HTTP/구형 브라우저에서 navigator.clipboard 안 됨. execCommand + textarea로 폴백, 실패 시 "길게 눌러 선택해주세요" 안내
  • count >= 8일 때 reticle 축소: 좁은 모바일(320px)에서 슬롯 10개가 안 들어감. 동적으로 sm 사이즈로 전환
  • 페이지네이션: 기록 최대 50개, 페이지당 5개. 페이지가 7개 넘어가면 "현재 / 전체" 컴팩트 모드 (모바일 overflow 회피)

결국 만들고 싶었던 한 장면

이 모든 게 결국 포탄이 호를 그리며 날아가서 슬롯에 박히는 1.5초를 위한 거다.

  • 대포가 각도를 틀고 (420ms)
  • 발사음과 함께 화면이 흔들리고 (300ms)
  • 포탄이 코멧 트레일을 끌며 포물선으로 날아가 (900ms)
  • 슬롯에 박히면서 폭발 링이 퍼지고 (450ms)
  • 번호가 등장한다 (240ms)

이 1.5초가 마음에 들지 않으면 게임 자체가 무의미했다. 다른 모든 디테일(돌고래 손잡이, 황동 별 양각, 합성 사운드)은 이 1.5초를 더 묵직하게 만들기 위한 거였다.


미스틱 로또와 비교하면

미스틱 로또는 의미를 부여하려 했다. "왜 이 번호야?" 에 별자리/MBTI/기분으로 답한다.

대포 추첨은 감각을 부여하려 했다. "왜 이 번호야?" 에 굳이 답하지 않는다. 그냥 발사가 시원했고, 숫자가 박혔다. 그게 다다.

두 게임이 같은 "번호 뽑기" 문제를 정반대 방향에서 푼다는 게 만들고 나서 깨달았다. 의미 vs 감각, 의식 vs 액션. 둘 다 결국 번호에 무언가를 더하는 일이었다.

지금 발사하러 가기 →

🛒 이 글과 관련된 추천 상품

쿠팡 파트너스

ⓘ 이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.