미스틱 로또는 신비로움이었다. 별자리, 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중 저장 버그
이게 진짜 황당했다.
추첨 결과를 한 번 뽑으면 기록에 네 개가 쌓였다. 처음엔 코드를 잘못 본 줄 알았는데 진짜였다.
원인을 좁혀가다 보니:
ResultScreen이 마운트되면서useEffect로saveHistoryEntry호출 (1번)- React Strict Mode가 dev에서
useEffect를mount → cleanup → mount로 강제 두 번 실행 → 2번 - 사용자가 "기록 보기"로 갔다가 "돌아가기"로 ResultScreen에 다시 오면 → 또 mount → 3번
- 다시 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 액션. 둘 다 결국 번호에 무언가를 더하는 일이었다.