- 개요, 조사(BGA·한국 인기 보드게임·UI/UX), 아키텍처, 실시간 프로토콜, 안정성·보안, 계정, 로비·방, UI/UX, 데이터 모델, 게임 엔진, 테스트, 배포·운영, 로드맵(M0~M9), 사용자 결정 항목 - 게임별 규칙·엔진 설계·UI·테스트 체크리스트 26종 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
212 lines
16 KiB
Markdown
212 lines
16 KiB
Markdown
# 벽 쌓기 길찾기 (쿼리도 방식) (`quoridor`)
|
||
|
||
> 마일스톤: M7 · 인원: 최소 2 ~ 최대 4명 (2인 또는 4인만, 3인 불가) · 예상 시간: 약 10~20분 · 난이도: 쉬움~보통
|
||
|
||
## 1. 개요
|
||
- 9×9 판에서 자기 말을 반대편 끝줄까지 먼저 보내면 이기는 게임. 매 차례 말을 움직이거나 벽을 세워 상대의 길을 돌아가게 만든다. 단, 누구의 길도 완전히 막아서는 안 된다. 규칙이 직관적이라 초등학생 보드게임 수업과 가족 게임으로 국내에서도 인기가 있다.
|
||
- 인원 근거: 원작(Gigamic, 1997, Mirko Marchesi 디자인)은 2인 또는 4인용. `minPlayers = 2`, `maxPlayers = 4`이며, setup에서 플레이어 수가 3이면 거부한다("2명 또는 4명이 필요해요").
|
||
- 완전 정보 게임. view에서 제외할 것은 RNG 상태뿐.
|
||
- 원작명은 상표이므로 표시 이름은 일반 명칭을 권장(12장).
|
||
|
||
## 2. 모드와 옵션
|
||
| 옵션 키 | 설명 | 선택지 | 기본값 |
|
||
|---|---|---|---|
|
||
| `wallsPerPlayer` | 1인당 벽 수 | 2인: 10, 4인: 5 고정 (원작). 하우스 룰로 2인 6~10 선택 가능 | 2인 10 / 4인 5 |
|
||
| `diagonalJumpTrigger` | 대각선 점프가 허용되는 조건 | `wallEdgePawn`(뒤가 벽·판 끝·다른 말이면 대각 허용) / `wallOnly`(벽일 때만, Gigamic 2인 규칙서 문구 그대로) | `wallEdgePawn` |
|
||
| `seatOrder` | 시작 위치·순서 | `random` / `joinOrder` | `random` |
|
||
| `continueForRanking` | 4인에서 1등이 나온 뒤에도 계속해서 2·3등을 가릴지 | `true` / `false` | `false` |
|
||
| `timeControl.kind` | 시간 방식 | `none` / `perMove` / `fischer` | `perMove` |
|
||
| `timeControl.perMoveSec` | 한 수당 제한 | 15 / 30 / 60 | 30 |
|
||
| `timeControl.baseSec` / `incSec` | 전체 / 수당 추가 | 3+2, 5+3 | 5+3 |
|
||
| `timeoutPolicy` | `perMove` 시간 초과 처리 | `autoMove`(최단 경로로 말 1칸 이동) / `lose` | `autoMove` |
|
||
| `idleLimitSec` | `none` 무응답 한도 | 120 / 300 | 300 |
|
||
| `undo` | 무르기 허용 횟수(다른 모든 플레이어 동의) | `off` / `1` / `3` / `unlimited` | `3` |
|
||
| `drawOffer` | 무승부 제안(남은 모든 플레이어 동의) | `true` / `false` | `true` |
|
||
| `showPath` | 내 최단 경로 표시(힌트) | `true` / `false` | `true` |
|
||
| `disconnectGraceSec` | 연결 끊김 유예 | 30 / 60 / 120 | 60 |
|
||
|
||
## 3. 구성물
|
||
- 9×9 칸(81칸) 판. 칸과 칸 사이에 홈(groove)이 있어 벽을 끼운다.
|
||
- 말 2개 또는 4개(색 구분).
|
||
- 벽 20개. 각 벽은 칸 2개 길이.
|
||
- 좌표: 열 a~i(왼→오), 행 1~9(아래→위). 내부 칸 `(c, r)` 0~8, `r=0`이 1행.
|
||
- 벽 앵커: 벽의 가운데는 항상 홈의 교차점(8×8 = 64곳)에 온다. 앵커 `(c, r)` (0~7)은 칸 `(c,r) (c+1,r) (c,r+1) (c+1,r+1)` 네 칸의 가운데 교차점.
|
||
- 가로벽 `H(c,r)`: `(c,r)↔(c,r+1)`, `(c+1,r)↔(c+1,r+1)` 이동을 막는다.
|
||
- 세로벽 `V(c,r)`: `(c,r)↔(c+1,r)`, `(c,r+1)↔(c+1,r+1)` 이동을 막는다.
|
||
|
||
## 4. 준비(셋업)
|
||
1. 플레이어 수 확인(2 또는 4). `seatOrder=random`이면 RNG로 자리 배정.
|
||
2. 시작 위치와 목표:
|
||
|
||
| 자리 | 시작 칸 | 목표 | 차례 순서 |
|
||
|---|---|---|---|
|
||
| 남(S) | e1 `(4,0)` | 9행 전체 (`r=8`) | 1 |
|
||
| 서(W, 4인) | a5 `(0,4)` | i열 전체 (`c=8`) | 2 |
|
||
| 북(N) | e9 `(4,8)` | 1행 전체 (`r=0`) | 2인: 2 / 4인: 3 |
|
||
| 동(E, 4인) | i5 `(8,4)` | a열 전체 (`c=0`) | 4 |
|
||
|
||
(4인 순서는 판을 위에서 볼 때 시계 방향: 남 → 서 → 북 → 동.)
|
||
3. 벽 배분: 2인 10개씩, 4인 5개씩.
|
||
4. 남이 먼저 둔다.
|
||
|
||
## 5. 진행 규칙
|
||
### 5.1 턴
|
||
- 차례인 플레이어는 다음 중 하나만 한다: (A) 말 이동, (B) 남은 벽이 있으면 벽 1개 설치. 패스 없음(단 5.4 예외).
|
||
|
||
### 5.2 말 이동
|
||
- 기본: 상하좌우 인접 칸으로 1칸. 사이에 벽이 없고, 판 안이며, 다른 말이 없어야 한다. 대각선 1칸 이동은 기본 이동이 아니다.
|
||
- 직선 점프: 인접 칸(방향 d)에 다른 말 Q가 있고 나와 Q 사이에 벽이 없을 때, Q 너머 칸(같은 방향 d)이 판 안이고 Q와 그 칸 사이에 벽이 없고 비어 있으면 그 칸으로 뛰어넘는다.
|
||
- 대각 점프: 위 직선 점프가 불가능한 경우(`wallEdgePawn`: Q 뒤가 벽·판 끝·다른 말 / `wallOnly`: Q 뒤가 벽일 때만), Q의 좌우(방향 d에 수직인 두 방향) 인접 칸 중 Q와 그 칸 사이에 벽이 없고 판 안이며 비어 있는 칸으로 이동할 수 있다.
|
||
- 한 번에 말 1개만 뛰어넘는다(4인에서 말 2개가 일렬이어도 2개를 넘는 점프 불가).
|
||
- 직선 점프가 가능하면 대각 점프는 불가.
|
||
|
||
### 5.3 벽 설치
|
||
- 합법 조건(모두 만족):
|
||
1. 남은 벽이 1개 이상.
|
||
2. 앵커 `(c, r)`가 0~7 범위(벽은 판 밖으로 나갈 수 없음, 항상 정확히 2칸을 막음).
|
||
3. 기존 벽과 겹치지 않음: `H(c,r)`는 `H(c-1,r)`, `H(c,r)`, `H(c+1,r)`, `V(c,r)`와 충돌. `V(c,r)`는 `V(c,r-1)`, `V(c,r)`, `V(c,r+1)`, `H(c,r)`와 충돌(같은 교차점 십자 교차 금지).
|
||
4. 설치 후에도 게임에 남아 있는 모든 플레이어(설치한 본인 포함)가 각자 목표 줄까지 갈 수 있는 경로가 하나 이상 존재. 경로 탐색 시 말은 장애물로 보지 않고 벽만 본다.
|
||
- 경로 검사(BFS):
|
||
```ts
|
||
function hasPath(walls: WallSet, start: Cell, goal: (c: Cell) => boolean): boolean {
|
||
const seen = new Set<number>([idx(start)]);
|
||
const queue: Cell[] = [start];
|
||
while (queue.length) {
|
||
const cur = queue.shift()!;
|
||
if (goal(cur)) return true;
|
||
for (const d of [N, S, E, W]) {
|
||
const nxt = step(cur, d);
|
||
if (!onBoard(nxt) || blocked(walls, cur, d) || seen.has(idx(nxt))) continue;
|
||
seen.add(idx(nxt)); queue.push(nxt);
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
// 벽 후보 합법성 = 조건 1~3 && 모든 활성 플레이어 p에 대해 hasPath(walls ∪ {후보}, pos(p), goal(p))
|
||
```
|
||
- 설치한 벽은 이동·제거할 수 없다.
|
||
|
||
### 5.4 움직일 수 없는 경우
|
||
- 말 이동이 하나도 불가능하고(다른 말과 벽에 둘러싸임) 벽도 없으면 자동 패스(이벤트 `forcedPass`). 원작 규칙서에 명시되지 않은 극히 드문 상황에 대한 서비스 규칙.
|
||
|
||
## 6. 승패와 점수 계산
|
||
- 자기 목표 줄의 아무 칸에 말이 도착하는 즉시 승리(1등). 기본(`continueForRanking=false`)은 그 즉시 게임 종료.
|
||
- 4인 결과 순위: 1등 = 도착자. 나머지는 종료 시점의 최단 경로 길이(BFS 거리, 말 무시)가 짧은 순으로 2~4등, 같으면 공동 순위. 기권·실격자는 최하위(기권 순서가 늦을수록 높은 순위).
|
||
- `continueForRanking=true`(4인): 도착한 말은 판에서 제거하고(그 칸은 비게 됨, 벽은 남음) 남은 플레이어로 계속, 마지막 1명이 남으면 종료. 도착 순서대로 1~3등, 남은 1명 4등.
|
||
- 2인: 도착자 승리, 상대 패배.
|
||
- 기권: 2인은 상대 승. 4인은 기권자의 말을 판에서 제거, 남은 벽은 소멸, 설치한 벽은 유지. 남은 인원이 1명이면 그 사람 1등.
|
||
- 무승부: 남은 모든 플레이어 동의 시. 무승부면 남은 플레이어 모두 공동 1등.
|
||
- 점수 개념 없음(결과에 각자 남은 거리·남은 벽 수 기록).
|
||
|
||
## 7. 지역 룰 / 하우스 룰 (옵션으로 켜고 끄기)
|
||
- 대각 점프 조건: 원작 2인 규칙서는 "점프할 말 뒤에 벽이 있으면 좌우로" 라고만 적혀 있어 판 끝·다른 말의 경우 해석이 갈린다. 대부분의 온라인 구현과 영문 위키는 판 끝·다른 말도 포함하므로 기본값은 `wallEdgePawn`.
|
||
- 2인 벽 개수 줄이기(빠른 게임): 하우스 룰.
|
||
- 3인 플레이는 원작에 없으므로 미지원.
|
||
|
||
## 8. 엔진 설계
|
||
### 8.1 상태(State)
|
||
```ts
|
||
type Seat = 'S' | 'W' | 'N' | 'E';
|
||
type Cell = { c: number; r: number };
|
||
type Wall = { c: number; r: number; o: 'h' | 'v' };
|
||
|
||
interface QuoridorState {
|
||
options: QuoridorOptions;
|
||
seats: { seat: Seat; playerId: PlayerId; pos: Cell | null; // null = 제거(기권/도착 후 제외)
|
||
wallsLeft: number; status: 'playing' | 'finished' | 'resigned' | 'timedOut';
|
||
finishOrder: number | null }[];
|
||
turnIndex: number; // seats 배열 인덱스
|
||
walls: Wall[];
|
||
wallIndex: { h: boolean[]; v: boolean[] }; // 8x8 앵커별 점유 (빠른 충돌 검사)
|
||
moves: ({ kind: 'move'; seat: Seat; from: Cell; to: Cell; auto: boolean }
|
||
| { kind: 'wall'; seat: Seat; wall: Wall }
|
||
| { kind: 'pass'; seat: Seat })[];
|
||
phase: 'playing' | 'finished';
|
||
clock: { remainingMs: Record<Seat, number>; turnStartedAt: number; autoMoveStreak: Record<Seat, number> };
|
||
undo: { usedBy: Record<Seat, number>; pending: { by: Seat; accepted: Seat[] } | null };
|
||
draw: { pendingBy: Seat | null; accepted: Seat[]; lastOfferMove: Record<Seat, number> };
|
||
result: QuoridorResult | null;
|
||
rng: RngState;
|
||
}
|
||
```
|
||
|
||
### 8.2 액션
|
||
| type | payload | 누가 / 언제 | 검증 조건 |
|
||
|---|---|---|---|
|
||
| `move` | `{ to: Cell }` | 차례인 플레이어 | `to`가 5.2 규칙으로 계산한 이동 가능 칸 목록에 포함. 사유 예: "벽이 가로막고 있어요", "대각선으로는 상대 말을 넘을 때만 갈 수 있어요" |
|
||
| `placeWall` | `{ c, r, o }` | 차례인 플레이어 | 5.3 조건 1~4. 사유 예: "남은 벽이 없어요", "다른 벽과 겹쳐요", "누군가의 길을 완전히 막을 수 없어요" |
|
||
| `requestUndo` | `{}` | 직전 행동을 한 플레이어 | 횟수 남음, 대기 없음. 2인: 본인 직전 행동 1개(상대가 이미 행동했으면 2개). 4인: 본인이 방금 행동했고 다음 사람이 아직 행동하지 않았을 때만, 1개 |
|
||
| `respondUndo` | `{ accept }` | 요청자 외 활성 플레이어 전원 | 1명이라도 거절 → 취소. 전원 수락 → `moves` 재생으로 복원(벽 개수 포함), 시계 유지 |
|
||
| `offerDraw` / `respondDraw` / `cancelOffer` | | 활성 플레이어 | 전원 수락 시 무승부. 다음 사람이 행동하면 자동 취소 |
|
||
| `resign` | `{}` | 활성 플레이어 | 6장 처리 |
|
||
| `timeout` | `{}` | 시스템 | 8.5 |
|
||
|
||
- 이동 가능 칸 계산 `legalPawnMoves(state, seat)`: 4방향에 대해 5.2 규칙을 적용해 중복 없는 칸 목록 반환.
|
||
- 행동 후: 도착 판정 → 종료 또는 순위 기록 → 다음 활성 플레이어로 차례 이동(제거된 자리는 건너뜀) → 그 플레이어가 이동·벽 모두 불가하면 자동 패스.
|
||
- 이벤트: `pawnMoved`, `wallPlaced`, `forcedPass`, `playerFinished`, `playerResigned`, `gameEnded`.
|
||
|
||
### 8.3 공개/비공개 정보 (view)
|
||
- 공통 공개: 말 위치, 모든 벽, 각자 남은 벽 수, 차례, 기보, 시계, `deadline`, 대기 요청(누가 수락했는지 포함), 각 플레이어 최단 거리(관전 재미 요소, 공개 정보에서 계산 가능하므로 숨길 이유 없음), 결과.
|
||
- 차례인 플레이어에게만: `legalPawnMoves`, `legalWalls`(최대 128개 후보 중 합법 앵커 목록 — 클라이언트가 BFS를 구현하지 않아도 미리보기 가능), `showPath`면 `myShortestPath`.
|
||
- 제외: `rng`.
|
||
|
||
### 8.4 랜덤 요소 (시드 RNG 사용 지점)
|
||
1. `seatOrder=random`의 자리 배정.
|
||
2. `autoMove`에서 최단 경로의 첫 칸 후보가 여럿일 때 선택.
|
||
|
||
### 8.5 타이머·시간 초과·연결 끊김 시 자동 행동
|
||
- `deadline(state)`: `perMove` → `turnStartedAt + perMoveSec*1000`, `fischer` → `turnStartedAt + remainingMs[seat]`, `none` → `turnStartedAt + idleLimitSec*1000`, 종료 → `null`.
|
||
- `onTimeout(state, player)` → `{ type: 'timeout' }`. `apply`:
|
||
- `perMove + autoMove`: 이동 가능 칸 중 "목표까지 BFS 거리(말 무시)가 가장 짧아지는 칸"으로 이동(점프 포함). 동률은 RNG. 이동 불가면 자동 패스. streak 3회면 시간패.
|
||
- 그 외: 시간패. 2인은 상대 승. 4인은 기권과 같은 처리(말 제거, `timedOut`).
|
||
- 연결 끊김: 끊긴 플레이어 차례에 `min(deadline, 끊김 + disconnectGraceSec)`에 `onTimeout`. 4인에서 끊긴 플레이어가 무르기·무승부 응답자일 때는 "응답 없음 = 거절"로 30초 후 자동 취소(서버 타이머가 `respondUndo{accept:false}` 대신 요청 자체를 만료시키는 이벤트 처리).
|
||
|
||
### 8.6 종료 조건과 결과(GameResult)
|
||
```ts
|
||
interface QuoridorResult {
|
||
ranking: { playerId: PlayerId; rank: number }[]; // 공동 순위 허용
|
||
reason: 'reachedGoal' | 'lastPlayerStanding' | 'resign' | 'timeLoss' | 'drawAgreed';
|
||
perPlayer: { playerId: PlayerId; seat: Seat; distanceLeft: number; wallsLeft: number;
|
||
status: 'finished' | 'playing' | 'resigned' | 'timedOut' }[];
|
||
moveCount: number;
|
||
summaryKo: string; // "남(철수) 승리 — 23수 만에 도착"
|
||
}
|
||
```
|
||
|
||
## 9. UI/UX
|
||
- 모바일 세로: 정사각 판이 화면 폭 100%(폰 360px에서 칸 약 36px + 홈). 상단에 상대(들) 정보와 남은 벽(막대 아이콘), 하단에 내 정보와 모드 전환 큰 버튼 2개 "말 움직이기" / "벽 세우기"(각 48px 이상).
|
||
- 말 이동 모드: 이동 가능 칸이 밝게 표시, 탭으로 이동.
|
||
- 벽 세우기 모드: 홈의 교차점 근처를 탭하면 벽 미리보기(반투명), 가로/세로 전환 버튼(또는 같은 곳 다시 탭으로 회전), 놓을 수 없으면 빨간 미리보기와 이유 표시, "확인" 버튼으로 설치. 작은 화면에서 홈을 직접 터치하기 어렵기 때문에 "교차점 가장 가까운 곳 스냅 + 확인" 2단계 필수.
|
||
- 힌트: 내 최단 경로를 점선으로, 벽 미리보기 중에는 "이 벽을 놓으면 상대 길이 +4칸" 같은 변화량 표시(초보에게 벽의 효과를 알려줌, 옵션).
|
||
- 4인: 각 말 색과 목표 줄을 판 가장자리 색 띠로 표시.
|
||
- 규칙 보기: 점프·대각 점프 그림, "길을 완전히 막으면 안 돼요" 그림.
|
||
|
||
## 10. 테스트 체크리스트
|
||
- [ ] 2인/4인 시작 위치·목표·차례 순서, 3인 시작 거부.
|
||
- [ ] 기본 이동: 벽·판 끝·다른 말 때문에 막힘.
|
||
- [ ] 직선 점프 성공, 점프 뒤 칸이 벽/판 끝/말일 때 대각 점프 2곳, 그중 한쪽이 벽으로 막히면 1곳만.
|
||
- [ ] `wallOnly`에서 판 끝 뒤 대각 점프 불가.
|
||
- [ ] 직선 점프 가능할 때 대각 점프 거부, 말 2개 연속 점프 거부.
|
||
- [ ] 벽 겹침: 같은 방향 반 칸 겹침, 같은 앵커 십자 교차 거부. 서로 다른 앵커의 T자 접촉은 허용.
|
||
- [ ] 판 밖 앵커(0~7 범위 밖) 거부, 남은 벽 0개일 때 거부.
|
||
- [ ] 경로 차단: 상대 길을 완전히 막는 벽 거부, 자기 길을 막는 벽 거부, 4인에서 제3자의 길을 막는 벽 거부, 말 때문에만 막히는 경우는 허용.
|
||
- [ ] 목표 줄 도착 즉시 승리(점프로 도착 포함).
|
||
- [ ] 4인 기권: 말 제거, 벽 유지, 이후 경로 검사에서 제외, 1명 남으면 종료.
|
||
- [ ] `continueForRanking`: 도착자 제거 후 계속, 순위 기록.
|
||
- [ ] 4인 결과 순위가 남은 거리로 정렬되고 동률 공동 순위.
|
||
- [ ] `autoMove`: 최단 거리 감소 칸으로 이동, 같은 시드 동일 결과, 3연속 시간패.
|
||
- [ ] 4인 무르기: 다른 3명 전원 수락 시에만, 다음 사람이 행동한 뒤에는 요청 불가.
|
||
- [ ] 자동 패스: 말·벽 모두 불가한 인위적 국면.
|
||
- [ ] 정보 누출: 관전자·비차례 view에 `legalWalls`/`myShortestPath` 없음, `rng` 없음.
|
||
- [ ] 성능: 벽 후보 128개 × BFS 4회 계산이 1ms 수준인지(합법 벽 목록 생성).
|
||
|
||
## 11. 참고 자료
|
||
- Wikipedia "Quoridor": https://en.wikipedia.org/wiki/Quoridor (9×9, 2인 10개·4인 5개 벽, 점프와 대각 점프 조건: 판 끝·세 번째 말·벽, 길 차단 금지, 4인 시작 위치 e1·a5·e9·i5)
|
||
- Gigamic 공식 규칙서(Quoridor rules PDF, gigamic.com 제품 페이지): 2인 규칙의 대각 점프 문구("벽이 있으면 좌우로"), 벽은 2칸 길이·홈에 설치
|
||
- BoardGameGeek "Quoridor": https://boardgamegeek.com/boardgame/624/quoridor (출판사·인원·플레이 시간)
|
||
|
||
## 12. 메모 (상표·법적 주의 등)
|
||
- "Quoridor"(쿼리도/쿼리더)는 Gigamic의 등록 상표다. 게임 규칙 자체는 저작권 보호 대상이 아니지만 이름·로고·판 디자인은 사용하지 말 것. 서비스 표시 이름 후보: "벽 쌓기 길찾기", "길막기", "미로 탈출 보드". 최종 이름은 사용자 결정 사항. 규칙 보기에서 "쿼리도와 비슷한 규칙" 같은 비교 언급 여부도 사용자와 확인.
|
||
- 게임 id `quoridor`는 내부용이며 UI에 노출하지 않는다(필요하면 `wall-maze` 등으로 변경).
|
||
- 금전·베팅 요소 없음.
|