Fullstack 116

heo4·약 21시간 전

Fullstack

목록 보기
71/71

풀스택

[심심오락실 개발일지] 초성 퀴즈 정식 승격부터 구슬 레이스 재설계까지 — 2026.10.02

예능식 퀴즈와 추억의 플래시 게임을 웹에서 즐기는 심심오락실(simsim-arcade) 개발기.
오늘은 PR 5개를 머지했다. 그중 하나는 "저품질"이라는 피드백을 받고 처음부터 다시 만든 PR이다.

  • 스택: React + TypeScript + Vite (Cloudflare Pages) / Hono + D1 (Cloudflare Workers) / pnpm 모노레포
  • 오늘 작업: Claude Code와 함께 작업

오늘 한 일 한눈에 보기

PR내용
#59초성 퀴즈를 샘플에서 공통 정식 게임으로 업그레이드
#60게임 카드 번호 자동 지정 (owner 만 적으면 끝)
#62게임과 분리된 📺 방송 도구 섹션 신설
#63방송 도구 1호 「구슬 레이스」 v1 (…핀볼 짝퉁이라는 평가)
#66구슬 레이스를 위에서 내려다본 서킷 레이스로 전면 재설계

같은 날 팀원 신영 님도 「캣 블레이드」를 추가·개편해서(#61, #64, #65) main 이 꽤 바빴던 하루.


1. 초성 퀴즈: 샘플 → 정식 게임 (#59)

초성 퀴즈는 원래 "새 게임은 이렇게 만들어요"를 보여 주는 구조 참고용 샘플이었다. 문제 20개, 정답 1개당 10점이라 게임이라기보다 예제에 가까웠다. 그런데 아이템 자체가 좋아서 정식 게임으로 올리기로 했다. 담당자 카드 번호 없이 공통 게임 자리(메인 맨 뒤)는 그대로 둔다.

문제 20개 → 257개, 그리고 생성 스크립트

10개 분야(음식·동물·과일·채소·물건·탈것·장소·직업·스포츠·사자성어·속담)로 늘렸다.
초성을 손으로 쓰면 반드시 오타가 나므로 정답에서 초성을 자동으로 뽑아 시드 SQL을 만드는 스크립트를 따로 만들었다.

const INITIALS = 'ㄱㄲㄴㄷㄸㄹㅁㅂㅃㅅㅆㅇㅈㅉㅊㅋㅌㅍㅎ';
function toChosung(text) {
  return [...text].map((ch) => {
    const code = ch.charCodeAt(0);
    if (code < 0xac00 || code > 0xd7a3) return ch; // 한글이 아니면(띄어쓰기 등) 그대로
    return INITIALS[Math.floor((code - 0xac00) / 588)];
  }).join('');
}
// '등잔 밑이 어둡다' → 'ㄷㅈ ㅁㅇ ㅇㄷㄷ'

스크립트는 힌트에 정답이 그대로 들어 있거나 정답이 중복되면 에러를 내도록 했다. 문제를 수백 개 쓰다 보면 실수가 생기니까.

"헬기"는 정답으로 인정하면 안 된다

처음엔 동의어를 정답으로 넉넉하게 넣었다(헬리콥터/헬기, 치킨/통닭…). 그런데 생각해 보니 초성 퀴즈에서 초성이 다른 동의어를 받아 주면 게임의 의미가 없어진다. 그래서 인정 답은 표기만 다른 말(짜장면/자장면, 리모컨/리모콘)로 제한했다.

점수 체계

  • 정답 100점, 힌트를 보고 맞히면 50점
  • 연속 정답 보너스 +10씩 (최대 +50), 오답·패스하면 끊김
  • 20문제를 시간 안에 전부 맞히면 남은 시간 1초당 +10점

서버는 비정상 점수를 막기 위해 게임별 상한(MAX_SCORE_BY_GAME)을 둔다. 상한값을 감으로 정하지 않고 maxScore() 함수로 계산해서 3440점으로 맞췄다.


2. 게임 카드 번호 자동 지정 (#60)

우리 팀은 메인 화면 카드 번호를 4명이 순서대로 돌아가며 받는다. 규칙은 이렇다.

내 n번째 게임의 카드 번호 = (n − 1) × 4 + 내 순번

지금까지는 이 번호를 각자 계산해서 card: 6 처럼 적었다. 빼먹거나 계산을 틀려도 아무도 몰랐다는 게 문제였다. 팀에서도 이 얘기가 나와서 이름만 적으면 번호가 자동으로 붙게 바꿨다.

export const CARD_OWNERS = ['혁', '경수', '신영', '동한'] as const;

function assignCards(entries: readonly GameEntry[]): GameMeta[] {
  const countByOwner = new Map<CardOwner, number>();
  return entries.map((entry) => {
    if (!entry.owner) return entry; // owner 없으면 공통 게임 (맨 뒤)
    const nth = (countByOwner.get(entry.owner) ?? 0) + 1;
    countByOwner.set(entry.owner, nth);
    const ownerOrder = CARD_OWNERS.indexOf(entry.owner) + 1;
    return { ...entry, card: (nth - 1) * CARD_OWNERS.length + ownerOrder };
  });
}
  • owner 가 '혁' | '경수' | '신영' | '동한' 타입이라 이름 오타는 typecheck 에서 걸린다
  • 사람마다 따로 세기 때문에 동시에 여러 명이 게임을 추가해도 번호가 겹치지 않는다
  • 기존 게임에 적용해서 번호가 바뀌지 않는지 확인했다 (1·2·3·5·6·7·9 그대로)

대신 규칙이 하나 생겼다. 등록 순서로 번호를 세기 때문에 새 게임은 항상 목록 맨 뒤에 추가해야 한다. 이 PR이 머지되자마자 신영 님의 캣 블레이드 PR(#61)이 새 방식(owner: '신영', 맨 뒤 추가)으로 들어왔고, 자동으로 카드 11번이 붙었다. 바로 써먹은 셈.


3. 방송 도구 섹션 신설 (#62)

다음 아이템은 게임이 아니었다. 인터넷 방송 스트리머들이 핀볼 룰렛이나 사다리타기로 시청자를 뽑는데, 이걸 대체할 추첨 도구를 만들고 싶었다.

문제는 사이트 구조였다. 지금은 모든 게임이 onFinish(score) → 점수 등록 → 랭킹 흐름을 따르는데, 추첨 도구에는 점수도 랭킹도 없다. 억지로 끼워 넣지 않고 게임과 나란히 서는 별도 구조를 만들었다.

  • apps/web/src/tools/<도구id>/ + tools/registry.ts (props 없는 컴포넌트)
  • /tools/:toolId 라우트 하나로 자동 생성 (도구별 라우트를 따로 만들지 않는 기존 원칙 유지)
  • ToolPage: 게임 페이지와 같은 테마 틀에 랭킹 대신 전체 화면 버튼 (OBS 창 캡처용)
  • 메인 화면·시작 메뉴에 "📺 방송 도구" 섹션 (등록된 도구가 없으면 숨김)
  • DB·API·점수 상한은 필요 없음

4. 구슬 레이스 v1… 그리고 "핀볼 짝퉁" (#63)

방송 도구 1호로 구슬 레이스를 골랐다. 시청자 이름이 적힌 구슬 수십~수백 개가 코스를 굴러가고, 도착 순서로 당첨자를 뽑는다.

신경 쓴 것: 공정성

추첨 도구라 결과를 조작할 수 없다는 게 가장 중요했다.

  • 매 판 crypto.getRandomValues 로 시드를 새로 뽑고, 코스와 출발 자리를 그 시드로 정한다
  • 결과 화면에 "추첨 번호 #시드"를 보여 준다
  • 물리 계산은 화면 프레임과 무관하게 1/60초 고정 스텝으로 돌린다. 그래서 배속을 바꾸거나 컴퓨터가 느려도 같은 시드면 같은 결과가 나온다

화면 없이 물리를 튜닝하는 법: 헤드리스 시뮬레이션

레이스 로직을 DOM 과 분리해 두니 Node 에서 레이스 수백 판을 그냥 돌려 볼 수 있었다. 구슬 2~300개로 레이스 시간, 시간 초과, 끼임 횟수를 쟀다. 이 과정에서 버그를 꽤 잡았다.

  • 지그재그 경사로 사이 틈(약 20)이 구슬 지름(22)보다 좁아서 끼던 문제
  • 벽에 붙어 장애물 없이 내려가는 지름길 때문에 6초 만에 1등이 들어오던 문제 → 벽 방지턱 추가

숫자상으로는 완벽했다. 그런데 배포 후 반응은…

"핀볼 짝퉁 같고 너무 저품질인데?"

맞는 말이었다. 원인은 두 가지였다.

  1. 구조가 핀볼 그대로: 위에서 떨어지고 핀에 튕기는 게 전부였다.
  2. 화면을 한 번도 안 보고 만들었다: 작업 환경에서 브라우저가 안 떠서 숫자만 맞췄다. 나중에 찍어 보니 카메라가 선두만 쫓아가서 구슬 45개 중 1개만 빈 판을 혼자 떨어지는 화면이었다. 레이스가 아니라 공 하나 떨어지는 영상.

5. 구슬 레이스 v2: 진짜 레이스로 (#66)

판을 바꿨다

세로 핀볼 판을 버리고 위에서 내려다본 서킷을 왼쪽 → 오른쪽으로 달리는 구조로 바꿨다.

  • 중력이 없으니 모든 구슬을 트랙 방향으로 똑같은 힘으로 민다 (공정성 유지)
  • 트랙은 사인파 몇 개를 겹쳐 매 판 무작위로 만든다. x 가 계속 오른쪽으로만 가게 해서 트랙이 자기 자신과 겹치지 않는다
  • 커브가 너무 급하면(안쪽 벽이 꼬이면) 진폭을 줄여서 다시 만든다
  • 구슬 위치는 "트랙을 따라 간 거리(s) + 가운데선에서 벗어난 거리(lat)"로 계산해서 순위를 매긴다

역전이 계속 나오게

  • 가속 패드는 트랙 한쪽 절반에만 깐다 → 그쪽으로 간 구슬만 빨라져서 추월이 생긴다
  • 진흙(감속), 구멍(빠지면 뒤로 되돌아감), 범퍼, 회전 바, 좁아지는 시케인 2곳

방송용 연출

  • F1 식 출발 신호등 (빨간불 3개 → 출발)
  • "🔥 ○○ 선두 탈환!", "🕳️ ○○ 구멍에 빠졌다! (2위)" 중계 자막 + 효과음
  • 선두 무리 전체가 들어오게 확대·축소하며 따라가는 카메라, 미니맵, 남은 거리
  • 결승 직전 1·2위가 붙어 있을 때만 슬로모션 (위아래 검은 띠 + "SLOW MOTION — 접전!")
  • 시상대 🥇🥈🥉 + 종이가루
  • 트랙 테마 4종(그린 서킷·사막 랠리·네온 나이트·스노우 컵)을 매 판 무작위로

튜닝 결과 (Node 시뮬레이션, 구슬 2~300개)

항목결과
첫 도착40~51초
선두 교체한 판에 3~11번
벽 이탈0
슬로모션대략 4판에 1번 (접전일 때만)

이번엔 화면을 보면서 만들었다

v1 의 교훈대로 헤드리스 브라우저로 신호등 → 중반 → 후반 → 결과 → 휴대폰을 매번 찍어 보면서 고쳤다. 스크린샷에서 바로 보인 버그들은 다음과 같다.

① 트랙 안이 잔디, 밖이 아스팔트로 칠해짐. 트랙 면을 "왼쪽 가장자리 → 오른쪽 가장자리(역순)"로 한 바퀴 그려야 하는데, 오른쪽을 그릴 때 moveTo 로 새로 시작해 버렸다. 그래서 다각형이 둘로 쪼개져 엉뚱한 곳이 칠해졌다.

// 수정 후: 두 번째 가장자리는 앞 경로에 이어 그린다
private surface(points: TrackPoint[], extra: number): Path2D {
  const path = new Path2D();
  this.edge(points, (p) => p.w + extra, path);
  this.edge([...points].reverse(), (p) => -(p.w + extra), path, /* connect */ true);
  path.closePath();
  return path;
}

② 진흙이 커브에서 트랙 밖 잔디로 삐져나옴. 바닥 장치는 트랙 면(Path2D)으로 clip 해서 그리도록 했다.

③ 무리 속 이름표가 겹쳐 안 읽힘. 앞선 구슬부터 자리를 잡고, 겹치면 위로 한두 칸 올린 뒤 구슬과 선으로 이어 줬다.

④ 휴대폰에서 순위표가 한 줄로 찌그러짐. 고정 높이 그리드를 풀었다.


삽질 기록 (다음의 나를 위해)

Squash merge + 이어진(stacked) PR = 충돌. #62 위에 #63 브랜치를 쌓아 올렸다. #62 가 squash 로 머지되면서 커밋이 새로 만들어졌고, #63 에는 원래 커밋이 남아 있어 GitHub 이 충돌로 표시했다. 실제 코드가 부딪힌 게 아니라서 아래 명령으로 정리했다.

git rebase --onto main <원래 #62 커밋> feature/...
git push --force-with-lease

WSL 에서 /mnt/c 프로젝트는 vite 가 파일 변경을 못 본다. 고쳤는데 화면이 그대로라서 한참 헤맸다. dev 서버가 옛날 파일을 계속 주고 있었다. 수정 후엔 dev 서버를 재시작해야 한다. 게다가 pkill -f "vite --port" 로는 프로세스가 안 잡혔다(실제 이름이 vite.js --port). 결국 PID 로 직접 죽였다.

sudo 없이 헤드리스 크롬 띄우기. 필요한 라이브러리가 없었는데 sudo 비밀번호를 넣을 수 없는 상황이었다. apt-get download 는 root 없이 되므로 패키지 파일만 받아 dpkg -x 로 풀고, LD_LIBRARY_PATH 로 경로를 지정해 해결했다. 이모지 폰트도 같은 방법으로 넣었다.


회고

  • 숫자가 맞아도 화면이 구리면 끝이다. v1 은 시뮬레이션상 완벽했지만 "핀볼 짝퉁" 한 마디에 무너졌다. 그림이 중요한 기능은 직접 눈으로 보고 나서 완료라고 하기로.
  • "왜 굳이 이걸 쓰지?"에 답이 있어야 한다. 핀볼 대체품이 핀볼처럼 생기면 의미가 없다. 구조부터 레이스로 바꾸니 그제야 다른 물건이 됐다.
  • 반복되는 규칙은 코드로. 카드 번호 계산, 초성 만들기처럼 사람이 매번 손으로 하던 일을 코드로 옮기니 실수할 여지가 사라졌다.

다음에 할 일

  • 배포된 사이트에서 구슬 레이스 소리·전체 화면 버튼 확인
  • 초성 퀴즈 점수 체계가 바뀌어서(최대 100 → 3440) 기존 랭킹 기록을 어떻게 할지 팀에서 결정
  • 방송 도구 2호 아이디어 (이름 서바이벌? 폭탄 돌리기?)

0개의 댓글