지난 글에서 "게임을 하나씩 쉽게 추가할 수 있는 뼈대"를 만들었다면,
이번에는 그 뼈대 위에 사이트 분위기(테마) 를 입히고, 제 담당인 카드 1번 게임 — 힌트 퀴즈 를 만들었습니다.
하루 동안 PR 11개를 머지하면서 겪은 시행착오까지 정리해 봤습니다.
🌐 https://simsim-arcade.pages.dev
| PR | 내용 |
|---|---|
| #10 · #13 | README 정리 — 배포 배지·링크, 저장소 설정, 프로젝트 목표 |
| #12 | 🪟 윈도우 XP 테마 + 클래식 테마 전환 |
| #15 | 메인 화면 카드 4칸 + "준비 중" 카드 |
| #16 | XP 시작 메뉴 + Q&A 페이지 |
| #19 | 🎯 힌트 퀴즈 게임 추가 (카드 1) |
| #20 | 문제 조회 API 에 장르(category) 필터 |
| #22 | 장르 선택 + 축구선수 1000명 (위키데이터) |
| #24 | 난이도 완화 + 부분·유사 정답 인정 |
| #25 | 문항 수 선택 + 문제당 제한시간 + 시간 보너스 점수 |
| #26 | 🖥️ 윈도우 98 테마 + 테마 3종 선택 |
같은 날 팀원(카드 3번)의 과일 슬라이서도 머지되면서, 메인 화면에 드디어 게임이 여러 개 생겼습니다. 🍉
README 에 프로젝트 목표를 한 줄로 못 박아 두었습니다.
팀원 각자가 게임 하나를 맡아 처음부터 끝까지 직접 만들면서, 풀스택 개발자로서의 역량을 확인하는 프로젝트
main 머지 = 자동 배포팀원 초대는 GitHub 저장소 Write 권한이면 충분했습니다.
브랜치 push, PR, 리뷰 승인, 머지, 태그까지 다 되고, 설정·Secrets 만 관리자 몫입니다.
대신 main 보호 규칙(Ruleset) 이 있어야 규칙이 "말"이 아니라 "강제"가 됩니다.
| 규칙 | 설정 |
|---|---|
| PR 필수 | main 직접 push 불가 |
| 리뷰 승인 | 1명 이상 |
| 필수 체크 | CI(typecheck · lint · format · build) 통과 |
| 머지 방식 | Squash and merge 만 |
| 강제 push / 삭제 | 금지 |
💡 점검해 보니 관리자 계정이 Ruleset 우회 목록에 "Always"로 들어가 있었습니다.
main 에 올라가는 순간 운영 배포 + 운영 DB 시드가 도는 구조라, 우회 권한은 꼭 필요한 만큼만 두는 게 안전합니다.
"추억의 오락실" 콘셉트에 맞춰 사이트 전체를 윈도우 XP 느낌으로 바꿔 봤습니다.
로고나 공식 배경 사진 같은 저작권 있는 이미지는 쓰지 않고, 분위기만 CSS 로 재현했습니다.
XP 가 마음에 들었지만 처음 디자인도 버리기 아까워서 두 테마를 버튼 하나로 전환하게 만들었습니다.
<html data-theme="xp"> <!-- 또는 classic -->
global.css 에서 :root / :root[data-theme='xp'] 로 나눔Xxx.classic.module.css / Xxx.xp.module.css 로 스타일 분리localStorage 에 저장, index.html 에서 렌더링 전에 먼저 적용해 깜빡임 방지가장 좋았던 점은 게임 담당자가 따로 할 일이 없다는 것입니다.
게임 CSS 에서 var(--accent), var(--surface) 같은 토큰만 쓰면 두 테마를 자동으로 따라갑니다.
XP 모드의 [시작] 버튼을 누르면 진짜 시작 메뉴처럼 열립니다.
┌──────────────────────────────┐
│ 🕹️ 심심오락실 │
├───────────────┬──────────────┤
│ 게임 목록 │ 🏠 게임 목록 │
│ (registry 자동) │ ❓ Q&A · 도움말 │
│ │ 📁 프로젝트 소개 │
├───────────────┴──────────────┤
│ 🎨 클래식 테마로 바꾸기 │
└──────────────────────────────┘
게임 목록은 registry.ts 에서 자동으로 만들어져서, 게임이 추가되면 메뉴에도 알아서 들어갑니다.
XP 가 있으니 98 도 빠질 수 없죠. 하루의 마지막 작업으로 세 번째 테마를 추가했습니다.
98 특유의 입체감은 box-shadow 를 네 겹 겹쳐서 만들었습니다. 바깥은 흰색/검은색, 안쪽은 밝은 회색/어두운 회색.
:root[data-theme='win98'] {
/* 튀어나온 버튼 */
--bevel-raised:
inset -1px -1px #0a0a0a, inset 1px 1px #ffffff,
inset -2px -2px #808080, inset 2px 2px #dfdfdf;
/* 눌린 버튼 — 빛 방향만 뒤집으면 끝 */
--bevel-pressed:
inset -1px -1px #ffffff, inset 1px 1px #0a0a0a,
inset -2px -2px #dfdfdf, inset 2px 2px #808080;
}
[data-theme='win98'] .btn { box-shadow: var(--bevel-raised); }
[data-theme='win98'] .btn:active { box-shadow: var(--bevel-pressed); }
XP 와 98 은 바탕화면 · 창 · 작업 표시줄 · 시작 메뉴 라는 화면 구조가 똑같습니다.
그래서 컴포넌트는 그대로 두고 CSS 만 *.win98.module.css 로 추가했습니다.
export type Theme = 'classic' | 'xp' | 'win98';
// 바탕화면 구조를 쓰는 테마인지 (XP·98) — 클래식만 헤더·푸터 구조
export const isDesktopTheme = (theme: Theme) => theme !== 'classic';
// Record<Theme, T> 라서 테마를 추가하면 스타일이 빠진 곳을 타입 검사가 알려 준다
const styles = useThemeStyles({ classic: classicStyles, xp: xpStyles, win98: win98Styles });
Theme 타입에 'win98' 한 단어를 추가하자마자 TypeScript 가 98 스타일이 빠진 컴포넌트를 전부 에러로 알려 줘서,
빠뜨린 곳 없이 채울 수 있었습니다.
테마가 셋이 되면서 "다음 테마로 넘기는 토글 버튼"은 불편해져서, 고르는 방식으로 바꿨습니다.
| 테마 | 바꾸는 곳 |
|---|---|
| XP · 98 | [시작] 메뉴 맨 아래 — 🪟 XP / 🖥️ 98 / 📄 클래식 버튼 (지금 테마는 눌린 모양) |
| 클래식 | 헤더 오른쪽 테마 선택 목록 |
역시 게임 담당자는 할 일이 없습니다. 토큰만 쓴 게임 화면은 98 에서도 자동으로 회색·남색 톤이 됩니다.
게임이 아직 다 없어도 카드 1~4번 자리를 미리 보여주고 싶었습니다.
// registry.ts — 카드 칸은 공통 코드
export const GAME_CARD_SLOTS = [
{ card: 1, owner: '혁' },
{ card: 2, owner: '경수' },
{ card: 3, owner: '신영' },
{ card: 4, owner: '동한' },
];
// 게임 담당자는 자기 항목에 card 번호만 적으면 끝
{ id: 'hint-quiz', ..., card: 1 }
게임 담당자는 여전히 "항목 1개 추가" 규칙만 지키면 되고, 비어 있는 칸은 🚧 "준비 중" 카드로 표시됩니다.
🐛 과일 슬라이서는 이 기능보다 먼저 머지돼서
card: 3이 빠져 있었고, 3번 칸이 "준비 중" + 게임은 맨 뒤에 따로 뜨는 문제가 있었습니다.
다음 업데이트 PR 에서 한 줄이 추가되며 해결 — 공통 규칙이 바뀌면 이미 머지된 코드도 같이 봐야 한다는 교훈.
제시어가 아니라 힌트가 하나씩 열리고, 적은 힌트로 먼저 맞힐수록 이기는 퀴즈입니다.
[축구선수] 지금 맞히면 +85점
이 축구선수는 누구일까요?
① 국적 대한민국
② 출생 연도 1989년
③ 🔒 힌트 3
...
⑦ 🔒 힌트 7 (마지막은 이름 초성!)
"먼저 맞히는 사람이 이긴다"를 실시간 대결로 만들려면 Durable Objects 가 필요한데, 팀 규칙상 파티 모드는 나중이라
지금은 1인용 + 랭킹 경쟁으로 만들었습니다.
직접 플레이해 보면서 규칙을 계속 고쳤습니다.
| 버전 | 규칙 | 문제점 → 개선 |
|---|---|---|
| v1 | 힌트 5개, 10초마다 자동 공개 | 시간이 너무 짧고 힌트가 어려움 |
| v2 | 20초, 힌트 순서 변경, 이름 초성 힌트 추가, 못 맞히면 정답 공개 후 클릭해서 다음 | 풀네임만 정답이라 억울함 |
| v3 | 부분·유사 정답 인정 | 문항 수가 고정 |
| v4 | 문항 수 선택(5·10·20), 문제당 60초, 시간 초과 = 0점, 빨리 맞히면 보너스 | 👍 |
5문제와 20문제 판이 하나의 랭킹을 쓰기 때문에, 점수를 그냥 더하면 20문제 판이 항상 이깁니다.
그래서 문제당 평균으로 환산했습니다.
문제 점수 = 힌트 점수(100 → 85 → 70 → … → 10) × 시간 보너스(10초 이내 ×1.0 → 60초 ×0.5)
최종 점수 = 문제당 평균 × 10 (최고 1000점)
| 판 | 결과 | 최종 점수 |
|---|---|---|
| 5문제 | 3개 만점 | 600점 |
| 20문제 | 12개 만점 | 600점 |
같은 실력이면 같은 점수가 나옵니다. 서버의 점수 상한(MAX_SCORE_BY_GAME)도 1000 으로 맞췄습니다.
장르(축구선수·동물·나라·음식)를 고르면 그 장르 문제만 나와야 하는데,
기존 문제 API 는 게임 전체에서 랜덤으로만 뽑았습니다.
GET /api/games/hint-quiz/questions?limit=10&category=동물
quiz_item.meta.category 로 거르는 범용 필터를 추가했습니다. (다른 게임도 사용 가능)
.where(
<and(
eq(quizItem.gameId, gameId),
// 값은 바인딩 파라미터로 전달된다 (SQL 인젝션 안전)
category ? sql`json_extract(${quizItem.meta}, '$.category') = ${category}` : undefined,
),
)
API 는 공통 코드(백엔드 담당 영역) 라 게임 PR 에 섞지 않고 feature/be-* PR 로 분리해서 백엔드 팀원 리뷰를 받았습니다.
빈 값, 너무 긴 값, ' OR 1=1 -- 같은 인젝션 시도까지 로컬에서 확인했습니다.
처음엔 구글이나 구단 공식 사이트를 생각했지만, 이용약관·저작권 문제가 있고 1000명을 하나씩 검증하기도 어렵습니다.
위키데이터(Wikidata) 는 위키백과의 구조화 데이터로, 라이선스가 CC0(자유 이용) 이고 SPARQL 로 조회할 수 있습니다.
SELECT ?p ?links WHERE {
?p wdt:P106 wd:Q937857; # 직업: 축구선수
wikibase:sitelinks ?links. # 위키백과 문서 수 = 유명도
?p wdt:P413 []; wdt:P2048 []; wdt:P569 []. # 포지션·신장·생년 있는 사람만
FILTER EXISTS { ?p rdfs:label ?ko FILTER(LANG(?ko) = "ko") } # 한국어 이름
}
ORDER BY DESC(?links)
수집 스크립트(seeds/scripts/hint-quiz-football.mjs)를 레포에 남겨 두어서, 다시 돌리면 최신 데이터로 갱신됩니다.
결과는 약 330KB 짜리 시드 SQL — D1 은 SQL 문 하나가 100KB 를 넘으면 안 돼서 100행씩 나눠 INSERT 합니다.
① 알베르 카뮈가 축구선수?
작가 카뮈도 젊은 시절 골키퍼여서 "축구선수"로 잡혔습니다. → 포지션·신장·소속팀 이력이 모두 있는 사람만 남기도록 필터 강화.
② 수아레스의 소속팀이 인터 마이애미 하나뿐?
위키데이터는 "현재 소속팀"에 우선 순위(preferred rank) 가 붙어 있으면, 간단한 조회(wdt:)로는 그 팀만 돌려줍니다.
소속팀 기록은 12개나 있었는데 말이죠. → 모든 기록을 읽는 p:P54/ps:P54 방식으로 변경.
?p p:P54 ?st. ?st ps:P54 ?team.
FILTER NOT EXISTS { ?st wikibase:rank wikibase:DeprecatedRank }
③ 한국 선수가 1명뿐
전 세계 "유명한 순"으로 뽑으니, 기성용·이강인·황희찬(문서 수 30~40개)은 밀려나고
한국어 문서가 많은 일본 J리그 선수가 180명이나 들어왔습니다.
→ 한국 선수 몫 200명 + 나머지 800명은 나라당 최대 70명 으로 선발 방식 변경.
④ 그 외
이명주 (축구 선수) → 이명주최종 힌트 순서는 국적 → 출생 연도 → 포지션 → 소속팀 → 신장 → 다른 소속팀 → 이름 초성 입니다.
외래어 표기는 사람마다 다르게 씁니다. 풀네임만 정답이면 억울한 경우가 너무 많았습니다.
| 종류 | 예 |
|---|---|
| 부분 정답 | 메시, 리오넬, 수아레스 / 한국 선수는 성 뺀 이름 흥민 |
| 유사 정답 | 음밥페, 킬리앙 음바페, 데이빗 베컴, 엘링 홀란드, 주제 무리뉴 |
베컴 → ㅂ ㅔ ㅋ ㅓ ㅁ)function allowedDistance(length: number): number {
if (length < 5) return 0; // 짧은 이름은 정확히
if (length < 9) return 1;
if (length < 14) return 2;
return 3;
}
김민재 와 김민수 는 한 글자 차이지만 다른 사람호나우두 가 정답 처리되던 문제 수정호날두 ≠ 호나우두, 해리 케인 ≠ 해리 매과이어 는 오답테스트로 검증했습니다.
| 증상 | 원인 | 해결 |
|---|---|---|
| 코드를 고쳐도 화면이 안 바뀜 | 저장소가 WSL 의 /mnt/c(Windows 디스크)에 있어 파일 변경 감지 불가 → Vite 가 예전 코드를 캐시 | 개발 서버 재시작 (근본 해결: 저장소를 WSL 홈으로 이동 or usePolling) |
| 이어서 올린 PR 을 만들 수 없음 | 기반 PR 이 먼저 머지되며 브랜치가 삭제됨 | git rebase --onto origin/main <기반 커밋> 으로 옮긴 뒤 main 대상 PR |
| PR 에 두 번째 커밋이 빠짐 | push 하기 전에 PR 이 이미 머지됨 | 빠진 커밋만 cherry-pick 해서 새 PR |
| Squash 머지 후 로컬 브랜치가 안 지워짐 | git branch --merged 는 Squash 머지를 인식 못함 | PR 머지 여부를 확인하고 git branch -D |
| 시드 두 개가 서로 문제를 지움 | 둘 다 DELETE WHERE game_id = ... | 파일마다 자기 문제만 지우도록 meta.source 로 구분 |
하루 동안 공통 기반(XP·98 테마·시작 메뉴·카드 칸·API) 과 내 게임(힌트 퀴즈) 을 오가며 작업했습니다.
"게임 PR 엔 내 게임만, 공통 변경은 따로" 규칙 덕분에 PR 하나하나가 작고 리뷰하기 쉬웠고,
머지 = 자동 배포라서 PR 이 머지될 때마다 바로 운영 사이트에서 확인할 수 있었습니다.
다음 단계는
다음 글에서 또 만나요. 🎮