docs: 온라인 보드게임 사이트 계획서 (공통 14편 + 게임 26종)
- 개요, 조사(BGA·한국 인기 보드게임·UI/UX), 아키텍처, 실시간 프로토콜, 안정성·보안, 계정, 로비·방, UI/UX, 데이터 모델, 게임 엔진, 테스트, 배포·운영, 로드맵(M0~M9), 사용자 결정 항목 - 게임별 규칙·엔진 설계·UI·테스트 체크리스트 26종 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
206
docs/games/dobble.md
Normal file
206
docs/games/dobble.md
Normal file
@@ -0,0 +1,206 @@
|
||||
# 도블 (`dobble`)
|
||||
|
||||
> 마일스톤: M8 · 인원: 최소 2 ~ 최대 8명 · 예상 시간: 약 3~10분 · 난이도: 쉬움
|
||||
|
||||
## 1. 개요
|
||||
- 그림 8개가 그려진 원형 카드 두 장에는 **항상 정확히 하나**의 같은 그림이 있다. 그 그림을 누구보다 빨리 찾아 누르는 실시간 순발력 게임. 여러 개의 짧은 미니게임으로 즐긴다.
|
||||
- 한국에서는 "도블"(영문판 Spot it!)이라는 이름으로 가족·어린이 보드게임으로 매우 유명하다. 글을 몰라도 할 수 있어 어린이·어르신에게 적합.
|
||||
- 인원 근거: 원작(Play Factory / Asmodee, 2009) 박스 표기 2~8인.
|
||||
|
||||
## 2. 모드와 옵션
|
||||
| 옵션 키 | 설명 | 선택지 | 기본값 |
|
||||
|---|---|---|---|
|
||||
| `miniGame` | 미니게임 | `tower`(탑 쌓기) / `well`(우물) / `hotPotato`(뜨거운 감자) / `poisonedGift`(독이 든 선물) | `tower` |
|
||||
| `deckSize` | 덱 장수 | 57(수학적 완전 덱) / 55(원작과 같은 장수) | 57 |
|
||||
| `hotPotatoRounds` | 뜨거운 감자 라운드 수 | 3 / 5 / 7 | 5 |
|
||||
| `wrongLockoutMs` | 틀린 그림을 눌렀을 때 잠금 시간 | 0 / 1000 / 2000 | 1000 |
|
||||
| `latencyComp` | 지연 보정 | `off` / `light` | `off` |
|
||||
| `symbolStyle` | 그림 크기·회전 | `easy`(크기 균일, 회전 없음) / `classic`(크기·회전 무작위) | `classic` |
|
||||
| `showRtt` | 각자 핑(ms) 표시 | on / off | on |
|
||||
| `wellEndAtFirst` | 우물: 첫 완주자가 나오면 종료 | on / off | off |
|
||||
|
||||
## 3. 구성물
|
||||
### 3.1 덱 생성: 7차 사영평면
|
||||
- 그림(기호) 57종, 카드 57장, 카드당 그림 8개. 어떤 두 카드도 정확히 1개의 그림을 공유하고, 각 그림은 정확히 8장에 나온다.
|
||||
- n = 7(소수)일 때 다음 알고리즘으로 생성(서버 코드에서 실제 실행해 57장·57종·모든 쌍 공유 1개·그림당 8회 등장을 확인함):
|
||||
```ts
|
||||
function buildDeck(n = 7): number[][] {
|
||||
const cards: number[][] = [];
|
||||
// 1) 무한원 직선: 그림 0..n
|
||||
cards.push(Array.from({ length: n + 1 }, (_, i) => i));
|
||||
// 2) 그림 0을 지나는 n장
|
||||
for (let j = 0; j < n; j++) {
|
||||
const c = [0];
|
||||
for (let k = 0; k < n; k++) c.push(n + 1 + n * j + k);
|
||||
cards.push(c);
|
||||
}
|
||||
// 3) 나머지 n*n장: 기울기 i, 절편 j
|
||||
for (let i = 0; i < n; i++) {
|
||||
for (let j = 0; j < n; j++) {
|
||||
const c = [i + 1];
|
||||
for (let k = 0; k < n; k++) c.push(n + 1 + n * k + ((i * k + j) % n));
|
||||
cards.push(c);
|
||||
}
|
||||
}
|
||||
return cards; // 1 + n + n*n = 57장, 그림 번호 0..56
|
||||
}
|
||||
```
|
||||
- `deckSize=55`이면 생성 후 RNG로 2장을 제외한다(원작도 55장). 두 카드 공유 1개 성질은 유지된다.
|
||||
- 이 알고리즘은 n이 소수일 때만 성립(n=7 고정).
|
||||
- 그림 번호는 고정이 아니라, 게임마다 RNG로 57개 그림 이미지에 섞어 배정한다(같은 카드 조합이 매번 같은 모양으로 반복되지 않게).
|
||||
|
||||
### 3.2 그림 57종 (자체 제작 아이콘, 한글 이름)
|
||||
사과, 해, 달, 별, 하트, 물방울, 불꽃, 눈송이, 번개, 구름, 나무, 꽃, 나뭇잎, 선인장, 버섯, 당근, 레몬, 체리, 고양이, 강아지, 물고기, 새, 거북이, 무당벌레, 거미, 공룡, 토끼, 펭귄, 열쇠, 자물쇠, 시계, 전구, 연필, 가위, 안경, 우산, 모자, 왕관, 종, 닻, 자전거, 자동차, 로켓, 배, 공, 주사위, 음표, 카메라, 집, 성, 선물상자, 촛불, 망치, 느낌표, 물음표, 눈사람, 무지개 (57개).
|
||||
- 색약 배려: 모양만으로 구분 가능해야 하며 색은 보조. 비슷한 실루엣(해/별, 달/바나나 등) 피하기.
|
||||
|
||||
### 3.3 카드 배치(레이아웃)
|
||||
- 카드는 원형. 8개 그림의 위치는 미리 만든 원 패킹 템플릿 6종 중 하나를 RNG로 고르고, `classic`이면 그림마다 크기(0.6~1.2배)와 회전(0~359도)을 RNG로 정한다. `easy`는 크기 1.0, 회전 0.
|
||||
- 레이아웃은 셋업 때 카드별로 확정해 상태에 저장(모든 클라이언트가 같은 모양을 봄).
|
||||
|
||||
## 4. 준비(셋업)
|
||||
공통: 덱 생성 → RNG 셔플 → 레이아웃 확정. 좌석은 RNG.
|
||||
- **탑 쌓기(tower)**: 각자 1장(자기 탑의 첫 장, 공개). 나머지는 가운데 뽑기 더미, 맨 위 1장 공개.
|
||||
- **우물(well)**: 가운데에 1장 공개. 남은 카드를 같은 장수로 나눔 `floor((덱-1)/N)`장씩, 나머지는 사용하지 않음(비공개 보관). 각자 자기 더미의 맨 위가 공개.
|
||||
- **뜨거운 감자(hotPotato)**: 라운드마다 각자 1장. 이미 쓴 카드는 다시 쓰지 않음(8인 × 7라운드 = 56장 ≤ 57).
|
||||
- **독이 든 선물(poisonedGift)**: 각자 1장(공개), 나머지는 가운데 뽑기 더미, 맨 위 공개.
|
||||
- 모든 미니게임은 3초 카운트다운 후 동시에 카드가 공개되며 시작.
|
||||
|
||||
## 5. 진행 규칙
|
||||
### 5.1 공통: "찾기(claim)"
|
||||
- 플레이어는 두 카드(아래 미니게임별 조합)의 공통 그림을 눌러 "찾기"를 보낸다. 서버가 도착 순서로 판정.
|
||||
- 찾기에는 기준 카드 버전(가운데 카드 id 또는 대상 플레이어의 맨 위 카드 id)이 들어간다. 그 사이 카드가 바뀌었으면 "늦었어요"로 거부하고 벌칙 없음.
|
||||
- 공통 그림이 아니면 "틀렸어요" + `wrongLockoutMs` 동안 그 플레이어의 찾기 잠금(서버가 판정).
|
||||
- 원작의 "동시에 외치면 카드를 먼저 놓은 사람"은 온라인에서 서버 도착 순서로 대체한다.
|
||||
|
||||
### 5.2 미니게임별 규칙
|
||||
| 미니게임 | 비교하는 두 카드 | 성공 시 | 종료 | 승리 |
|
||||
|---|---|---|---|---|
|
||||
| 탑 쌓기 | 내 탑 맨 위 ↔ 가운데 맨 위 | 가운데 카드를 내 탑 위로 가져옴(내 새 맨 위) | 뽑기 더미 소진 | 카드 가장 많은 사람 |
|
||||
| 우물 | 내 더미 맨 위 ↔ 가운데 맨 위 | 내 카드를 가운데에 올림(새 가운데) | 1명 빼고 모두 비움(`wellEndAtFirst`면 첫 완주자) | 먼저 비운 순서대로 순위, 마지막 사람 꼴찌 |
|
||||
| 뜨거운 감자 | 내 카드(맨 위) ↔ 다른 사람 카드 맨 위 | 내 카드 묶음 전체를 그 사람 위에 올림. 나는 이번 라운드 탈출 | 1명이 모든 카드를 가짐 → 그 사람이 그 카드를 벌점으로 보관 | 모든 라운드 후 벌점 카드 가장 적은 사람 |
|
||||
| 독이 든 선물 | 다른 사람 카드 맨 위 ↔ 가운데 맨 위 | 가운데 카드를 그 사람 위에 올림 | 뽑기 더미 소진 | 카드 가장 적은 사람 |
|
||||
|
||||
- 뜨거운 감자의 "묶음 전체를 올림": 원작은 받은 사람이 새 맨 위 카드로 계속 경기한다. 탈출한 사람(카드 0장)은 그 라운드 동안 대상이 될 수 없다.
|
||||
- 독이 든 선물에서 자기 카드에는 올릴 수 없다(대상은 다른 사람만).
|
||||
- 탑 쌓기 예(3인): 가운데 카드 {사과, 해, 고양이, ...}, 내 맨 위 {고양이, 열쇠, ...} → "고양이" 누름 → 서버 도착 1등이면 가운데 카드가 내 탑으로, 다음 가운데 카드 공개. 다른 사람이 같은 가운데 카드 기준으로 0.05초 늦게 보낸 찾기는 "늦었어요".
|
||||
|
||||
### 5.3 실시간 판정과 공정성
|
||||
- 서버(방 단위 단일 스레드 큐)가 메시지 수신 순서대로 `serverTs`(서버 수신 시각, ms)를 찍어 액션에 붙이고 그 순서대로 validate/apply한다. 같은 ms라도 큐 순서가 곧 판정 순서이며 로그에 남아 재생 가능.
|
||||
- 클라이언트가 보내는 시각은 신뢰하지 않는다.
|
||||
- **RTT 측정**: 2초마다 ping/pong, 지수이동평균(α=0.2)으로 플레이어별 RTT 저장. `showRtt`면 이름 옆에 표시(초록 < 80ms, 노랑 < 200ms, 빨강 ≥ 200ms).
|
||||
- **지연 보정 `light`**(선택):
|
||||
- 첫 유효 찾기가 도착하면 즉시 확정하지 않고 60ms 창을 연다.
|
||||
- 창 안에 도착한 같은 기준 카드의 유효 찾기들의 보정 시각 = `serverTs - min(RTT/2, 80)`. 가장 작은 사람이 승리.
|
||||
- 확정은 창이 닫힐 때 서버 내부 `resolve` 액션으로(재생 가능). 화면 반응이 60ms 늦어지는 대신 지연이 큰 사람의 불리함을 최대 80ms까지 줄인다. RTT를 일부러 부풀리는 악용을 막기 위해 보정 상한 80ms 고정.
|
||||
- **잠금**: 틀린 찾기 후 `wrongLockoutMs` 동안 거부(무작위 연타 방지). 잠금 중 거부 사유 "잠깐! 1초 뒤에 다시 눌러요".
|
||||
- **봇 의심**: 공개된 카드에서 공통 그림은 프로그램으로 즉시 계산 가능하므로 막을 수 없다. 카드 공개 후 200ms 미만 정답이 연속 5회면 방장에게 "의심" 표시만 한다(자동 처벌 없음).
|
||||
|
||||
## 6. 승패와 점수 계산
|
||||
- 미니게임별 순위는 5.2 표. 동점은 공동 순위.
|
||||
- 우물: 비운 순서가 순위. 우물에서 남은 2명 중 1명이 비우면 나머지가 꼴찌.
|
||||
- 뜨거운 감자: 라운드마다 패자가 받은 카드 수를 벌점 누적. 최종 벌점 적은 순.
|
||||
- 방 기록용 점수(여러 판 연속 플레이 시 누적, 원작 토너먼트 방식 단순화): 1등 3점, 2등 2점, 3등 1점, 그 외 0점. 공동 순위는 같은 점수.
|
||||
|
||||
## 7. 지역 룰 / 하우스 룰 (옵션으로 켜고 끄기)
|
||||
| 옵션 | 내용 | 기본값 |
|
||||
|---|---|---|
|
||||
| `wrongLockoutMs=0` | 틀려도 벌칙 없음(원작은 벌칙 규정 없음) | 1000 |
|
||||
| `wrongGivesCard` | 탑 쌓기에서 틀리면 내 탑 맨 위 1장을 가운데 더미 맨 아래로 | off |
|
||||
| `symbolStyle=easy` | 어린이 모드: 크기 균일·회전 없음 | `classic` |
|
||||
| `triplet` | 트리플렛(9장 중 같은 그림을 가진 3장 찾기) 미니게임 | 향후 추가 |
|
||||
|
||||
## 8. 엔진 설계
|
||||
### 8.1 상태(State)
|
||||
```ts
|
||||
interface CardLayout { template: number; items: { symbol: number; x: number; y: number; scale: number; rot: number }[] }
|
||||
|
||||
interface DobbleState {
|
||||
options: DobbleOptions;
|
||||
rng: RngState;
|
||||
cards: number[][]; // 카드 id → 그림 번호 8개(셋업에서 생성)
|
||||
layouts: CardLayout[]; // 카드 id → 레이아웃
|
||||
symbolArt: number[]; // 그림 번호 → 아이콘 id(셔플)
|
||||
order: PlayerId[];
|
||||
piles: Record<PlayerId, number[]>; // 플레이어 더미, 맨 위 = 끝. well은 뒷면 더미 포함
|
||||
center: number[]; // 가운데 공개 더미(맨 위 = 끝)
|
||||
drawPile: number[]; // 비공개 뽑기 더미
|
||||
unused: number[]; // 우물 나머지·55장 제외분
|
||||
lockedUntil: Record<PlayerId, number>;
|
||||
pendingWindow: null | { base: number; openedAt: number; claims: { player: PlayerId; target: PlayerId | 'center'; symbol: number; adjTs: number }[] };
|
||||
round: number; // 뜨거운 감자
|
||||
penalty: Record<PlayerId, number>; // 뜨거운 감자 벌점
|
||||
outThisRound: PlayerId[]; // 뜨거운 감자 탈출자
|
||||
finishedOrder: PlayerId[]; // 우물 완주 순서
|
||||
phase: 'countdown' | 'playing' | 'roundEnd' | 'ended';
|
||||
phaseEndsAt: number;
|
||||
stats: Record<PlayerId, { correct: number; wrong: number; fastHits: number }>;
|
||||
}
|
||||
```
|
||||
|
||||
### 8.2 액션
|
||||
액션에는 서버가 `serverTs`를 붙인다(클라이언트 값 무시).
|
||||
|
||||
| type | payload | 누가 / 언제 | 검증 조건 |
|
||||
|---|---|---|---|
|
||||
| `claim` | `{ base: number; target?: PlayerId; symbol: number }` | 참가자, playing | 잠금 해제됨(`serverTs >= lockedUntil`), `base`가 현재 기준 카드 id와 일치(아니면 "늦었어요"), 미니게임별 대상 규칙(우물·탑은 target 없음, 감자·선물은 다른 생존 대상), 그림이 두 카드에 모두 있음(아니면 잠금 부여) |
|
||||
| `resolve` | `{}` | 서버 내부, `latencyComp=light` 창 마감 | 창이 열려 있음 |
|
||||
| `tick` | `{}` | 서버 타이머 | 카운트다운·라운드 전환 |
|
||||
|
||||
"틀렸어요"는 `validate` 실패가 아니라 `apply`가 잠금만 기록하는 정상 액션으로 처리한다(잠금 상태를 남겨야 하므로). 늦은 찾기는 `validate` 실패.
|
||||
|
||||
### 8.3 공개/비공개 정보 (view)
|
||||
- 모두(관전자 포함): 모든 공개 카드(가운데 맨 위, 각자 더미 맨 위, 뜨거운 감자의 각자 맨 위)의 그림과 레이아웃, 더미 장수, 순위, 잠금 상태, RTT.
|
||||
- 비공개: 뽑기 더미 순서, 우물의 각자 뒷면 카드, 사용하지 않는 카드, 다음에 나올 카드, RNG 상태.
|
||||
- 레이아웃과 그림 번호는 공개 카드에 대해서만 보낸다(나중에 나올 카드의 레이아웃 선전송 금지).
|
||||
- 정답(공통 그림)은 보내지 않는다(클라이언트가 계산 가능하다는 점은 5.3 참고).
|
||||
|
||||
### 8.4 랜덤 요소 (시드 RNG 사용 지점)
|
||||
그림-아이콘 배정, 덱 셔플, 55장 제외 카드, 레이아웃 템플릿·크기·회전, 좌석.
|
||||
|
||||
### 8.5 타이머·시간 초과·연결 끊김 시 자동 행동
|
||||
- 차례가 없는 동시 게임이므로 `activePlayers` = 남아 있는 참가자 전원, 턴 타이머 없음. `deadline` = 카운트다운/라운드 전환/`pendingWindow` 마감 시각.
|
||||
- `onTimeout`: 개인 시간 초과 개념이 없으므로 아무 행동도 반환하지 않는 `noop`.
|
||||
- 정체 방지: 45초 동안 아무도 맞히지 못하면(거의 없음) 서버가 공통 그림을 2초간 깜빡여 보여 주고 계속(점수 없음).
|
||||
- 연결 끊김: 탑 쌓기·독이 든 선물은 그대로 진행(끊긴 사람은 못 맞힐 뿐). 우물·뜨거운 감자는 30초 이상 끊기면 "기권" 처리 — 우물은 꼴찌 그룹, 뜨거운 감자는 대상에서 제외되고 현재 카드는 가운데로 반환.
|
||||
|
||||
### 8.6 종료 조건과 결과(GameResult)
|
||||
```ts
|
||||
interface DobbleResult {
|
||||
miniGame: 'tower' | 'well' | 'hotPotato' | 'poisonedGift';
|
||||
ranking: { rank: number; id: PlayerId; cards?: number; penalty?: number; points: number }[];
|
||||
stats: Record<PlayerId, { correct: number; wrong: number }>;
|
||||
summary: string; // 예: "탑 쌓기 1등 하린님 21장!"
|
||||
}
|
||||
```
|
||||
|
||||
## 9. UI/UX
|
||||
- 모바일 세로: 위쪽에 가운데 카드(화면 폭 80% 원), 아래쪽에 내 카드(같은 크기). 다른 사람은 상단 작은 아바타 + 장수(뜨거운 감자·독이 든 선물에서는 대상 카드를 탭해 크게 보기).
|
||||
- 조작: 내 카드 또는 가운데 카드의 그림을 탭하면 찾기. 그림 터치 영역은 최소 48px(카드 지름 300px 기준으로 템플릿 설계). 뜨거운 감자/독이 든 선물은 먼저 대상 카드를 선택하고 그림 탭(두 번 탭), 대상이 2명이면 자동 선택.
|
||||
- 피드백: 정답 시 그림 확대+초록 테두리, 늦음 시 회색 "늦었어요", 틀림 시 화면 흔들림 + 잠금 원형 타이머.
|
||||
- 접근성: 그림 이름 음성 읽기(선택), `easy` 모드, 큰 카드 모드. 색만으로 정보 전달 금지.
|
||||
- 연습 모드: 혼자서 공통 그림 찾기(엔진 재사용, 기록 없음).
|
||||
- PC: 카드 좌우 배치, 마우스 클릭.
|
||||
|
||||
## 10. 테스트 체크리스트
|
||||
- [ ] 덱 생성: 57장, 그림 57종, 카드당 서로 다른 그림 8개, 모든 카드 쌍(1596쌍) 공유 그림 정확히 1개, 각 그림 8회 등장. 55장 모드에서도 쌍 성질 유지.
|
||||
- [ ] 탑 쌓기: 두 사람이 같은 가운데 카드에 정답 → 먼저 도착한 사람만 획득, 나중 사람은 "늦었어요"(잠금 없음).
|
||||
- [ ] 틀린 그림 → `wrongLockoutMs` 동안 정답도 거부, 잠금 해제 후 허용. `serverTs` 기준.
|
||||
- [ ] 우물: 장수 균등(`floor((덱-1)/N)`), 완주 순서 순위, 마지막 1명 꼴찌로 종료. `wellEndAtFirst` on이면 첫 완주 즉시 종료.
|
||||
- [ ] 뜨거운 감자: 탈출자는 대상 불가, 한 명이 모두 가지면 라운드 종료·벌점 누적, 설정 라운드 후 벌점 최소 승리.
|
||||
- [ ] 독이 든 선물: 자기 자신 대상 거부, 더미 소진 시 카드 최소 승리, 동점 공동 순위.
|
||||
- [ ] 지연 보정 light: 상대 RTT 20ms(보정 10ms), 내 RTT 200ms(보정 80ms 상한)일 때 내가 40ms 늦게 도착해도 보정 시각이 30ms 빨라 승리, 100ms 늦게 도착하면 60ms 창 밖이라 패배.
|
||||
- [ ] 연결 끊김: 우물에서 30초 끊김 → 기권 처리 후 남은 사람으로 계속.
|
||||
- [ ] 비공개 누출 검사: 모든 view/이벤트에 뽑기 더미·우물 뒷면·미사용 카드의 그림·레이아웃·id가 없는지 자동 검사.
|
||||
- [ ] 같은 시드 + 액션 로그(serverTs 포함) 재생 시 동일 결과.
|
||||
- [ ] 봇 의심 표시: 200ms 미만 정답 5연속 시 방장 view에만 표시.
|
||||
|
||||
## 11. 참고 자료
|
||||
- Wikipedia "Dobble" (55장, 카드당 8개, 7차 사영평면) — https://en.wikipedia.org/wiki/Dobble
|
||||
- UltraBoardGames "Spot it! 규칙"(동시 외침 처리, 미니게임 점수) — https://www.ultraboardgames.com/spot-it/game-rules.php
|
||||
- BoardGameGeek "Spot it!" — https://boardgamegeek.com/boardgame/63268/spot-it
|
||||
- Wikipedia "Projective plane"(유한 사영평면 구성) — https://en.wikipedia.org/wiki/Projective_plane
|
||||
- 나무위키 "도블" — https://namu.wiki/w/도블
|
||||
|
||||
## 12. 메모 (상표·법적 주의 등)
|
||||
- "Dobble"과 "Spot it!"은 Asmodee 그룹의 상표이고 원작 그림 일러스트는 저작물이다. 사영평면 구조(수학)는 자유롭게 쓸 수 있지만 이름·그림은 자체 제작해야 한다. 표시 이름 후보: "같은 그림 찾기", "짝꿍 그림", "번쩍 찾기".
|
||||
- 도박 요소 없음.
|
||||
Reference in New Issue
Block a user