예능식 퀴즈와 추억의 플래시 게임을 웹에서 즐기는 심심오락실(simsim-arcade) 개발기.
오늘은 PR 5개를 머지했다. 그중 하나는 "저품질"이라는 피드백을 받고 처음부터 다시 만든 PR이다.
| PR | 내용 |
|---|---|
| #59 | 초성 퀴즈를 샘플에서 공통 정식 게임으로 업그레이드 |
| #60 | 게임 카드 번호 자동 지정 (owner 만 적으면 끝) |
| #62 | 게임과 분리된 📺 방송 도구 섹션 신설 |
| #63 | 방송 도구 1호 「구슬 레이스」 v1 (…핀볼 짝퉁이라는 평가) |
| #66 | 구슬 레이스를 위에서 내려다본 서킷 레이스로 전면 재설계 |
같은 날 팀원 신영 님도 「캣 블레이드」를 추가·개편해서(#61, #64, #65) main 이 꽤 바빴던 하루.
초성 퀴즈는 원래 "새 게임은 이렇게 만들어요"를 보여 주는 구조 참고용 샘플이었다. 문제 20개, 정답 1개당 10점이라 게임이라기보다 예제에 가까웠다. 그런데 아이템 자체가 좋아서 정식 게임으로 올리기로 했다. 담당자 카드 번호 없이 공통 게임 자리(메인 맨 뒤)는 그대로 둔다.
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('');
}
// '등잔 밑이 어둡다' → 'ㄷㅈ ㅁㅇ ㅇㄷㄷ'
스크립트는 힌트에 정답이 그대로 들어 있거나 정답이 중복되면 에러를 내도록 했다. 문제를 수백 개 쓰다 보면 실수가 생기니까.
처음엔 동의어를 정답으로 넉넉하게 넣었다(헬리콥터/헬기, 치킨/통닭…). 그런데 생각해 보니 초성 퀴즈에서 초성이 다른 동의어를 받아 주면 게임의 의미가 없어진다. 그래서 인정 답은 표기만 다른 말(짜장면/자장면, 리모컨/리모콘)로 제한했다.
서버는 비정상 점수를 막기 위해 게임별 상한(MAX_SCORE_BY_GAME)을 둔다. 상한값을 감으로 정하지 않고 maxScore() 함수로 계산해서 3440점으로 맞췄다.
우리 팀은 메인 화면 카드 번호를 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 에서 걸린다대신 규칙이 하나 생겼다. 등록 순서로 번호를 세기 때문에 새 게임은 항상 목록 맨 뒤에 추가해야 한다. 이 PR이 머지되자마자 신영 님의 캣 블레이드 PR(#61)이 새 방식(owner: '신영', 맨 뒤 추가)으로 들어왔고, 자동으로 카드 11번이 붙었다. 바로 써먹은 셈.
다음 아이템은 게임이 아니었다. 인터넷 방송 스트리머들이 핀볼 룰렛이나 사다리타기로 시청자를 뽑는데, 이걸 대체할 추첨 도구를 만들고 싶었다.
문제는 사이트 구조였다. 지금은 모든 게임이 onFinish(score) → 점수 등록 → 랭킹 흐름을 따르는데, 추첨 도구에는 점수도 랭킹도 없다. 억지로 끼워 넣지 않고 게임과 나란히 서는 별도 구조를 만들었다.
apps/web/src/tools/<도구id>/ + tools/registry.ts (props 없는 컴포넌트)/tools/:toolId 라우트 하나로 자동 생성 (도구별 라우트를 따로 만들지 않는 기존 원칙 유지)ToolPage: 게임 페이지와 같은 테마 틀에 랭킹 대신 전체 화면 버튼 (OBS 창 캡처용)방송 도구 1호로 구슬 레이스를 골랐다. 시청자 이름이 적힌 구슬 수십~수백 개가 코스를 굴러가고, 도착 순서로 당첨자를 뽑는다.
추첨 도구라 결과를 조작할 수 없다는 게 가장 중요했다.
crypto.getRandomValues 로 시드를 새로 뽑고, 코스와 출발 자리를 그 시드로 정한다레이스 로직을 DOM 과 분리해 두니 Node 에서 레이스 수백 판을 그냥 돌려 볼 수 있었다. 구슬 2~300개로 레이스 시간, 시간 초과, 끼임 횟수를 쟀다. 이 과정에서 버그를 꽤 잡았다.
숫자상으로는 완벽했다. 그런데 배포 후 반응은…
"핀볼 짝퉁 같고 너무 저품질인데?"
맞는 말이었다. 원인은 두 가지였다.
세로 핀볼 판을 버리고 위에서 내려다본 서킷을 왼쪽 → 오른쪽으로 달리는 구조로 바꿨다.
튜닝 결과 (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 로 경로를 지정해 해결했다. 이모지 폰트도 같은 방법으로 넣었다.