Files
joke-app/docs/games/one-card.md
EJClaw f4d63e1544 docs: 온라인 보드게임 사이트 계획서 (공통 14편 + 게임 26종)
- 개요, 조사(BGA·한국 인기 보드게임·UI/UX), 아키텍처, 실시간 프로토콜,
  안정성·보안, 계정, 로비·방, UI/UX, 데이터 모델, 게임 엔진, 테스트,
  배포·운영, 로드맵(M0~M9), 사용자 결정 항목
- 게임별 규칙·엔진 설계·UI·테스트 체크리스트 26종

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 04:18:29 +09:00

353 lines
30 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 원카드 (`one-card`)
> 마일스톤: M4 · 인원: 최소 2 ~ 최대 9명 (`uno-style` 모드는 최대 10명) · 예상 시간: 약 10~15분 · 난이도: 쉬움
## 1. 개요
- 손에 든 카드를 가장 먼저 모두 내면 이기는 버리기(shedding) 카드 게임이다. 바닥 카드와 무늬나 숫자가 같은 카드를 내고, 공격 카드(2, A, 조커)로 다음 사람에게 카드를 먹인다. 한국에서는 학교와 MT에서 트럼프 카드로 많이 하는 국민 카드 게임이고, 지역과 모임마다 하우스 룰이 많다.
- 모드 두 가지
- `classic`(기본): 트럼프 52장 + 조커 2장으로 하는 한국식 원카드.
- `uno-style`: 4색 숫자/기능 카드 108장으로 하는 "색깔 카드" 규칙. 해외 유명 상품의 공식 규칙을 따르지만, 화면에는 상품명을 쓰지 않는다(12장 참고).
- 인원 근거
- `classic`: 나무위키 기준 2~9명. 덱이 54장뿐이라 6명 이상이면 기본으로 덱 2벌을 쓴다(옵션 `deckCount`).
- `uno-style`: 원작 공식 규칙 기준 2~10명.
- `GameDefinition.minPlayers = 2`, `maxPlayers = 10`. 방을 만들 때와 시작할 때 모드별 최대 인원(`classic` 9명)을 한 번 더 검사한다.
## 2. 모드와 옵션
| 옵션 키 | 설명 | 선택지 | 기본값 |
|---|---|---|---|
| `mode` | 게임 모드 | `classic` / `uno-style` | `classic` |
| `handSize` | 처음 나눠 주는 장수 | 5 / 6 / 7 | 7 |
| `deckCount` | (`classic`) 사용할 덱 수 | `auto`(2~5명은 1벌, 6~9명은 2벌) / 1 / 2 | `auto` |
| `attack2` | (`classic`) 2의 공격량 | 1~3 | 2 |
| `attackA` | (`classic`) A(♠ 제외)의 공격량 | 2~5 | 3 |
| `attackSpadeA` | (`classic`) ♠A의 공격량 | 3~10 | 5 |
| `attackBlackJoker` | (`classic`) 흑백 조커의 공격량 | 5~10 | 5 |
| `attackColorJoker` | (`classic`) 컬러 조커의 공격량 | 5~13 | 7 |
| `threeDefense` | (`classic`) 같은 무늬 3으로 2/A 공격 막기 | `off` / `same-suit` | `same-suit` |
| `kEffect` | (`classic`) K 효과 | `extra-turn`(한 번 더) / `skip-two`(두 명 건너뛰기) / `none` | `extra-turn` |
| `qInTwoPlayer` | (`classic`) 2인일 때 Q 처리 | `no-effect` / `as-skip`(J처럼) | `no-effect` |
| `lastCardRule` | (`classic`) 마지막 1장으로 낼 수 없는 카드 | `none` / `joker`(조커 금지) / `attack`(2·A·조커 금지) / `special`(모든 특수 카드 금지) | `joker` |
| `afterJoker` | (`classic`) 조커 다음에 낼 수 있는 카드 | `previous`(조커 밑 카드 기준) / `any`(아무 카드) | `previous` |
| `multiPlay` | (`classic`) 같은 숫자 여러 장 동시 내기 | on / off | off |
| `oneCardPenalty` | 원카드 선언을 못 해서 잡혔을 때 먹는 장수 | 1~3 (`uno-style` 원작 2) | `classic` 1, `uno-style` 2 |
| `autoDeclare` | 1장이 되면 자동으로 선언(초보·어린이용) | on / off | off |
| `bankruptLimit` | 파산 장수(이 장수 이상이면 탈락) | 15 / 18 / 20 / 25 / 없음 | `classic` 20, `uno-style` 없음(원작에 없음) |
| `voluntaryDraw` | 낼 카드가 있어도 일부러 1장 먹고 넘기기 허용 | on / off | on |
| `playAfterDraw` | 먹은 카드가 낼 수 있는 카드면 바로 내기 허용 | on / off | `classic` off, `uno-style` on |
| `endCondition` | 종료 시점 | `first-out`(1등이 나오면 종료) / `last-standing`(꼴찌가 정해질 때까지) | `first-out` |
| `turnSeconds` | 턴 제한 시간 | 10 / 15 / 20 / 30 / 60 | 20 |
| `unoStacking` | (`uno-style`) +2/+4 받아치기(하우스 룰) | on / off | off |
| `unoWild4Rule` | (`uno-style`) 색 바꾸기+4 사용 조건 | `strict`(현재 색이 손에 없을 때만, 서버가 막음) / `challenge`(원작: 아무 때나 내되 의심 신청 가능) / `free`(제한 없음) | `strict` |
| `unoTargetScore` | (`uno-style`) 여러 판 점수제 목표 점수 | 없음(한 판) / 200 / 500 | 없음 |
## 3. 구성물
`classic`
- 트럼프 52장(♠♥♦♣ × A,2~10,J,Q,K)과 조커 2장. 흑백 조커(Black Joker)와 컬러 조커(Color Joker)가 한 장씩이다. 덱 2벌이면 108장.
- 특수 카드 요약
| 카드 | 분류 | 효과(기본값) |
|---|---|---|
| 2 | 공격 | 다음 사람에게 2장 |
| A (♥♦♣) | 공격 | 3장 |
| ♠A | 공격 | 5장 |
| 흑백 조커 | 공격 | 5장 |
| 컬러 조커 | 공격 | 7장 |
| 3 | 방어 | 같은 무늬의 2/A 공격을 무효로 함(`threeDefense`) |
| 7 | 무늬 변경 | 낸 사람이 다음 무늬를 지정 |
| J | 점프 | 다음 사람을 건너뜀 |
| Q | 방향 전환 | 진행 방향이 바뀜 |
| K | 한 번 더 | 낸 사람이 한 턴 더 함 |
`uno-style` (108장, 원작과 같은 구성)
- 4색(빨강·노랑·초록·파랑) 각 25장: 0 1장, 1~9 각 2장, 건너뛰기 2장, 방향 바꾸기 2장, +2 2장.
- 무색 8장: 색 바꾸기 4장, 색 바꾸기+4 4장.
- 화면 이름: 건너뛰기, 방향 바꾸기, +2, 색 바꾸기, 색 바꾸기+4.
## 4. 준비(셋업)
1. 덱을 만들고 시드 RNG로 섞는다.
2. 선 플레이어를 RNG로 고른다. 진행 방향은 시계 방향(좌석 인덱스가 커지는 쪽)이다.
3. 각자 `handSize`장씩 받는다.
4. 시작 카드를 뒤집는다.
- `classic`: 일반 숫자 카드(3~6, 8~10)가 나올 때까지 뒤집는다. 특수 카드(2, A, 3, 7, J, Q, K, 조커)는 덱 맨 아래로 돌려보낸다. 시작부터 공격이나 효과가 걸리는 혼란을 막으려는 기본값이다.
- `uno-style`: 원작 규칙을 따른다.
- 색 바꾸기+4: 덱에 다시 넣어 섞고 새로 뒤집는다.
- 색 바꾸기: 선 플레이어가 색을 정하고 첫 카드를 낸다.
- +2: 선 플레이어가 2장을 먹고 턴을 넘긴다.
- 건너뛰기: 선 플레이어를 건너뛴다.
- 방향 바꾸기: 방향을 반대로 바꾸고, 선 플레이어의 오른쪽(새 방향 기준 다음) 사람부터 시작한다. 2인이면 선 플레이어를 건너뛴 것과 같다.
- 숫자: 그대로 시작한다.
5. 남은 카드가 뽑을 더미(드로우 더미)가 된다.
## 5. 진행 규칙
### 5.1 공통 개념
- **현재 기준(top)**: 버린 더미 맨 위 카드의 무늬(색)와 숫자(기호). 7이나 색 바꾸기로 무늬/색이 지정되면 `declaredSuit`(`declaredColor`)가 무늬 기준을 대신한다.
- **공격 누적(pendingAttack)**: 공격 카드가 나오면 누적량이 쌓이고 다음 사람이 받는다. 받는 사람은 방어(받아치기)하거나 누적량만큼 먹는다.
- **턴**: 내 차례에는 아래 행동 중 하나만 한다(K 효과로 생긴 추가 턴은 따로 센다).
### 5.2 `classic` 턴 행동
**A. 공격을 받고 있을 때(`pendingAttack > 0`)**
1. **받아치기**: 아래 표에서 허용되는 공격 카드를 낸다. 그 카드의 공격량이 누적량에 더해지고, 다음 사람에게 넘어간다.
2. **3으로 막기**(`threeDefense = same-suit`): 맨 위 공격 카드가 2나 A이고, 그 무늬와 같은 무늬의 3을 내면 누적량이 0이 된다. 공격은 끝나고 다음 사람은 그 3을 기준으로 일반 턴을 한다. 조커 공격은 3으로 막을 수 없다.
3. **먹기**: 누적량만큼 뽑고 누적량을 0으로 만든다. 턴은 끝나고 아무것도 내지 않는다. 낼 수 있는 카드가 있어도 먹기를 고를 수 있다.
받아치기 허용표(맨 위 공격 카드 기준)
| 맨 위 공격 카드 | 받아칠 수 있는 카드 |
|---|---|
| 2 | 아무 무늬의 2, 맨 위 2와 같은 무늬의 A(♠2 위의 ♠A 포함), 조커 2종 |
| A(♥♦♣) | 아무 무늬의 A(♠A 포함), 조커 2종 |
| ♠A | 조커 2종만. 다른 A로는 받아칠 수 없다(나무위키 "다른 문양의 A로 지원 공격 불가") |
| 흑백 조커 | 컬러 조커 |
| 컬러 조커 | 없음(덱 2벌일 때는 다른 컬러 조커 가능) |
- 덱 2벌이면 같은 종류 카드도 받아칠 수 있다(♠A 위에 다른 ♠A, 흑백 조커 위에 흑백 조커).
- 공격 카드를 받아치기로 내도 `lastCardRule` 검사를 그대로 한다.
**B. 일반 턴(`pendingAttack = 0`)**
1. **카드 내기**: 아래 조건 중 하나를 만족하는 카드 1장(또는 `multiPlay`일 때 같은 숫자 여러 장)을 낸다.
- 무늬 기준(`declaredSuit`가 있으면 그 무늬, 없으면 맨 위 카드의 무늬)과 무늬가 같다.
- 맨 위 카드와 숫자(랭크)가 같다. `declaredSuit`가 있으면 숫자 일치는 쓸 수 없고, 7만 예외로 숫자 일치가 된다.
- 조커: 언제든 낼 수 있다(일반 턴의 공격 시작). 단 `lastCardRule`에 걸리면 안 된다.
- 맨 위 카드가 조커일 때
- `afterJoker = previous`: 조커 바로 밑 카드(조커가 여러 장 쌓였으면 조커가 아닌 첫 카드)를 기준으로 판정한다.
- `afterJoker = any`: 아무 카드나 낼 수 있다.
- 시작 카드는 조커가 될 수 없으므로 기준 카드는 항상 있다.
2. **먹기**: 1장을 뽑고 턴을 끝낸다. `voluntaryDraw = off`이면 낼 카드가 없을 때만 할 수 있다. `playAfterDraw = on`이면 뽑은 카드가 낼 수 있는 카드일 때 그 카드만 바로 낼 수 있다(내지 않아도 된다).
**C. 카드 효과(낸 직후 처리)**
- 2 / A / 조커: `pendingAttack += 공격량`. 다음 사람의 턴이 "공격 받음" 상태로 시작한다.
- 3: 공격 중이 아니면 일반 카드와 같다.
- 7: 낸 사람이 무늬 4개 중 하나를 고른다(같은 액션의 `chooseSuit` 필드). 7과 다른 무늬를 골라도 되고 같은 무늬를 골라도 된다. 다음 카드가 나오면 `declaredSuit`는 지워진다.
- J: 다음 사람을 건너뛴다. 2인이면 낸 사람이 다시 한다.
- Q: 진행 방향을 반대로 바꾼다. 2인이면 `qInTwoPlayer`를 따른다(기본은 효과 없음, 상대 차례).
- K
- `extra-turn`: 낸 사람이 바로 추가 턴을 한 번 한다. 추가 턴은 일반 턴과 같아서 낼 수 없으면 1장을 먹는다. K를 마지막 카드로 냈으면 추가 턴 없이 바로 나간다(승리).
- `skip-two`: 다음 두 사람을 건너뛴다(3인 이하에서는 낸 사람에게 돌아옴).
- `none`: 일반 카드.
- `multiPlay = on`: 같은 숫자 여러 장을 낼 때 첫 장은 위 조건을 만족해야 하고, 나머지는 숫자만 같으면 된다. 효과는 장수만큼 누적한다(2를 두 장 내면 공격 4장, J 두 장이면 두 명 건너뛰기, Q 두 장이면 방향 그대로, K 두 장이면 추가 턴 1번). 마지막 카드가 다음 기준이 된다. 7을 여러 장 내면 무늬 지정은 한 번만 한다.
**D. 마지막 카드 제한(`lastCardRule`)**
- 카드를 낸 뒤 손에 0장이 되는 수에만 적용한다. 금지된 카드로는 마지막 카드를 낼 수 없다.
- 기본 `joker`: 손에 조커 1장만 남으면 그 조커는 낼 수 없으므로 먹어야 한다(나무위키: "카드가 1장인 상태일 때는 조커를 내는 게 불가능하다").
### 5.3 원카드 선언
- 카드를 내서 손패가 정확히 1장이 된 순간 그 플레이어에게 "선언 창"이 열린다. 이때 모든 참가자(관전자 제외)에게 "원카드!" 버튼이 활성화된다.
- 서버 도착 순서로 판정한다.
- 1장 남은 본인이 먼저 누르면 안전하다(`declared`).
- 다른 플레이어가 먼저 누르면 본인이 `oneCardPenalty`장을 먹는다(잡힘). 먹은 뒤에는 손패가 2장 이상이라 창이 닫힌다.
- 카드를 내는 액션에 `declareOneCard: true`를 같이 보낼 수 있다(UI에서 "내면서 원카드 외치기"). 이 경우 창이 열리는 순간 바로 선언된 것으로 처리한다.
- 창이 닫히는 때: 본인 선언, 잡힘, 본인 손패 수가 1이 아니게 됨(공격 등으로 카드를 먹음), 또는 그 사람의 다음 턴이 시작됨. 다음 턴까지 아무도 잡지 못했으면 그대로 마지막 카드를 내고 나갈 수 있다.
- 창이 열려 있는 동안에도 게임은 멈추지 않는다. 다음 사람은 바로 자기 턴을 진행할 수 있다.
- `autoDeclare = on`이면 1장이 되는 순간 자동으로 선언된다.
- `uno-style` 원작 규칙: 끝에서 두 번째 카드를 낼 때 외쳐야 한다. 다음 사람이 턴을 시작(서버에 그 사람의 첫 액션이 적용)하기 전에 잡혀야 벌칙(2장)이 있다. 다음 사람이 행동한 뒤에는 잡을 수 없고 창이 닫힌다.
### 5.4 파산(`classic`, `uno-style` 공통 옵션)
- 카드를 먹은 뒤 손패가 `bankruptLimit`장 이상이 되면 그 플레이어는 즉시 파산(탈락)한다.
- 파산한 사람의 손패는 드로우 더미 맨 아래에 넣고 섞지 않는다(결정적 처리).
- 탈락자는 턴 순서에서 빠진다. 파산을 일으킨 공격의 누적은 이미 0이 되었으므로 다음 사람은 일반 턴을 한다.
- 남은 사람이 1명이면 그 사람이 1등이고 게임이 끝난다.
### 5.5 드로우 더미가 부족할 때
- 뽑아야 할 때 드로우 더미가 비면 버린 더미 맨 위 카드를 뺀 나머지를 시드 RNG로 섞어 새 드로우 더미를 만든다.
- 그래도 모자라면 있는 만큼만 뽑고 부족분은 없던 일로 한다. 공격 누적도 0으로 끝난다.
### 5.6 `uno-style` 턴 행동
- **카드 내기**: 현재 색(`declaredColor` 또는 맨 위 카드의 색)이 같거나, 숫자/기호가 같거나, 무색(색 바꾸기, 색 바꾸기+4) 카드면 낼 수 있다.
- **색 바꾸기+4 조건**
- `strict`: 현재 색과 같은 색 카드가 손에 없을 때만 낼 수 있다. 숫자/기호만 맞는 카드나 다른 무색 카드는 있어도 된다. 서버가 검증해서 막는다.
- `challenge`(원작): 언제든 낼 수 있다. 다음 사람이 "의심하기"를 하면 낸 사람의 손패를 의심한 사람에게만 공개한다.
- 위반(현재 색 카드가 있었음)이면 낸 사람이 4장을 먹는다. 의심한 사람은 먹지 않고 정상적으로 자기 턴을 한다.
- 위반이 아니면 의심한 사람이 6장(4+2)을 먹고 턴을 잃는다.
- "받아들이기"를 고르면 4장을 먹고 턴을 잃는다.
- `free`: 아무 제한 없음.
- **먹기**: 1장을 뽑는다. `playAfterDraw = on`(원작)이면 뽑은 카드가 낼 수 있을 때 그 카드만 바로 낼 수 있다. 낼 카드가 있어도 먹을 수 있다(원작 허용).
- **효과**
- 건너뛰기: 다음 사람 건너뛰기.
- 방향 바꾸기: 방향 반대. 2인이면 건너뛰기와 같다.
- +2: 다음 사람이 2장을 먹고 턴을 잃는다.
- 색 바꾸기: 색 지정.
- 색 바꾸기+4: 색 지정 후, 다음 사람이 4장을 먹고 턴을 잃는다.
- `unoStacking = on`(하우스 룰): +2에는 +2나 +4로, +4에는 +4로 받아칠 수 있고 누적된다. 받아칠 수 없거나 받아치지 않으면 누적량을 먹고 턴을 잃는다. `challenge`와 함께 켜면 의심하기는 누적 전체에 대해 직전 +4 한 장만 판정한다.
- 마지막 카드가 기능 카드여도 된다. +2/+4로 끝나면 다음 사람이 그 장수를 먹는다(점수 계산에 반영).
## 6. 승패와 점수 계산
- `endCondition = first-out`(기본): 처음으로 손패를 모두 낸 사람이 1등이고 게임이 끝난다. 나머지 순위는 아래 순서로 정한다.
1. 파산하지 않은 사람이 파산한 사람보다 앞선다.
2. 파산하지 않은 사람끼리는 남은 장수가 적은 순이다.
3. 장수가 같으면 남은 카드 점수 합(아래 표)이 낮은 순이다.
4. 그래도 같으면 공동 순위다.
5. 파산한 사람끼리는 늦게 파산한 사람이 앞선다.
- `endCondition = last-standing`: 나간 순서대로 1등, 2등…이 정해지고 마지막 1명이 남으면 끝난다. 그 사람이 꼴찌(파산자보다는 위)다.
- 카드 점수(`classic` 동점 처리 및 결과 요약용): 숫자 카드 = 숫자, J/Q/K = 10, A = 1, 조커 = 20.
- `uno-style` 원작 점수: 이긴 사람이 다른 사람들 손패 점수 합을 얻는다.
- 숫자 카드 = 숫자(0~9)
- 건너뛰기, 방향 바꾸기, +2 = 각 20점
- 색 바꾸기, 색 바꾸기+4 = 각 50점
- 예: 남은 카드가 B=[7, +2], C=[색 바꾸기], D=[0, 3]이면 승자는 (7+20) + 50 + (0+3) = 80점.
- `unoTargetScore`가 있으면 누적 점수가 목표에 먼저 닿은 사람이 최종 승리한다. 같은 판에서 둘 이상이 넘을 수는 없다(그 판 승자만 점수를 얻음).
- 이 게임은 칩이나 포인트를 걸지 않는다. 점수는 방 안에서만 보이는 결과 표시용이다.
## 7. 지역 룰 / 하우스 룰 (옵션으로 켜고 끄기)
- 공격량(`attack2`, `attackA`, `attackSpadeA`, `attackBlackJoker`, `attackColorJoker`): 지역마다 2→1, A→2, ♠A→5~10, 흑백 조커 5~10, 컬러 조커 7~13처럼 다르다(나무위키).
- 3 방어(`threeDefense`): 같은 무늬 3으로 막기. 끌 수 있다.
- K 효과(`kEffect`): 한 번 더 내기, 두 명 건너뛰기 등.
- 마지막 카드 제한(`lastCardRule`): 공격 카드, 특수 카드, 조커로 끝내기 금지.
- 같은 숫자 동시 내기(`multiPlay`).
- 파산 장수(`bankruptLimit`): 15~25장, 또는 파산 없음.
- 조커 다음 카드(`afterJoker`).
- 지원하지 않는 하우스 룰(문서화만): 스트레이트(같은 무늬 연속 숫자 한꺼번에 내기), 끼어들기(같은 카드가 있으면 차례가 아니어도 내기), 7-0 바꾸기. 끼어들기는 실시간 경쟁이라 서버 도착 순서로 처리할 수 있으므로 나중에 옵션으로 추가할 수 있다.
## 8. 엔진 설계
### 8.1 상태(State)
```ts
type Suit = 'S' | 'H' | 'D' | 'C';
type Color = 'R' | 'Y' | 'G' | 'B';
type ClassicCard =
| { id: string; kind: 'std'; suit: Suit; rank: 1|2|3|4|5|6|7|8|9|10|11|12|13 } // 1=A, 11=J, 12=Q, 13=K
| { id: string; kind: 'joker'; joker: 'black' | 'color' };
type UnoCard =
| { id: string; kind: 'num'; color: Color; value: 0|1|2|3|4|5|6|7|8|9 }
| { id: string; kind: 'skip' | 'reverse' | 'draw2'; color: Color }
| { id: string; kind: 'wild' | 'wild4' };
type Card = ClassicCard | UnoCard;
interface OneCardState {
mode: 'classic' | 'uno-style';
options: OneCardOptions;
rng: RngState;
seats: PlayerId[]; // 좌석 순서(시작 시 고정)
hands: Record<PlayerId, Card[]>; // 비공개
drawPile: Card[]; // 비공개, [0]이 맨 위
discard: Card[]; // 공개, 마지막이 맨 위
declaredSuit: Suit | null; // 7로 지정된 무늬
declaredColor: Color | null; // 색 바꾸기로 지정된 색
direction: 1 | -1;
current: PlayerId; // 현재 턴
phase:
| { kind: 'turn' }
| { kind: 'afterDraw'; drawnCardId: string } // playAfterDraw 선택 대기
| { kind: 'wild4Response'; by: PlayerId; target: PlayerId; prevColor: Color } // challenge 모드
| { kind: 'finished' };
pendingAttack: number; // classic 공격 누적 / uno 스태킹 누적
extraTurn: boolean; // K 추가 턴 진행 중
oneCardWindow: { player: PlayerId; declared: boolean; openedSeq: number } | null;
status: Record<PlayerId, 'playing' | 'out' | 'bankrupt'>;
finishOrder: PlayerId[]; // 나간 순서
bankruptOrder: PlayerId[]; // 파산 순서
turnStartedAt: number;
seq: number; // 적용된 액션 수(타이머·창 판정용)
scores?: Record<PlayerId, number>; // uno-style 누적 점수(여러 판)
log: GameEvent[]; // 최근 공개 이벤트(선택)
}
```
### 8.2 액션
| type | payload | 누가 / 언제 | 검증 조건 |
|---|---|---|---|
| `play` | `{ cardIds: string[]; chooseSuit?: Suit; chooseColor?: Color; declareOneCard?: boolean }` | `current`, phase=`turn` 또는 `afterDraw` | 카드가 모두 자기 손에 있음. `multiPlay` off면 1장, on이면 숫자가 모두 같음. 공격 중이면 받아치기표나 3 방어 조건, 아니면 5.2 B 조건. 7이면 `chooseSuit` 필수, 색 바꾸기류면 `chooseColor` 필수. `lastCardRule` 위반 아님. `afterDraw`면 방금 뽑은 카드 1장만. `uno-style`의 `strict`이면 +4 조건 |
| `draw` | `{}` | `current`, phase=`turn` | 공격 중이면 항상 가능(누적량만큼 먹음). 아니면 `voluntaryDraw` on이거나 낼 카드가 없을 때 |
| `pass` | `{}` | `current`, phase=`afterDraw` | 뽑은 카드를 내지 않고 턴 종료 |
| `declareOneCard` | `{}` | 참가자 누구나(관전자·탈락자 제외), `oneCardWindow` 열림 | 창이 열려 있고 아직 판정 전. 본인이면 선언, 타인이면 잡기 |
| `challengeWild4` | `{}` | phase=`wild4Response`의 `target` | `unoWild4Rule = challenge` |
| `acceptWild4` | `{}` | phase=`wild4Response`의 `target` | 같음 |
- `reason` 예: "같은 무늬나 같은 숫자 카드만 낼 수 있어요", "공격을 받고 있어요. 막을 카드를 내거나 카드를 먹으세요", "마지막 카드로 조커는 낼 수 없어요", "무늬를 골라 주세요".
- 이벤트 예: `cardPlayed`, `cardsDrawn{player,count}`, `attackStacked{total}`, `attackBlocked`, `suitDeclared`, `skipped`, `directionChanged`, `oneCardDeclared`, `oneCardCaught{catcher,target,penalty}`, `bankrupt`, `playerOut`, `reshuffled`, `wild4Challenged{result}`.
### 8.3 공개/비공개 정보 (view)
- 모두(관전자 포함)에게 공개되는 것
- 버린 더미 맨 위 카드와 최근 몇 장(애니메이션용)
- `declaredSuit`/`declaredColor`, 방향, 현재 턴, `pendingAttack`
- 각 플레이어의 손패 장수, 상태(진행/나감/파산), 드로우 더미 장수
- 원카드 창이 누구에게 열려 있는지, 타이머 마감 시각
- 본인에게만 공개되는 것
- 자기 손패 전체
- 자기 손패 중 지금 낼 수 있는 카드 ID 목록(`legalCardIds`). 하이라이트용으로 서버가 계산한다.
- `wild4Response`에서 의심하기를 한 경우: `challengeReveal` 이벤트로 낸 사람의 손패를 의심한 사람의 view에만 1회 넣는다. 나머지 사람과 관전자는 결과(위반/정상)만 받는다.
- 절대 보내지 않는 것: 드로우 더미 순서, 다른 사람 손패, RNG 상태. 다른 사람이 먹은 카드는 장수만 보낸다(`cardsDrawn` 이벤트도 viewer별로 카드 내용을 걸러서 보낸다).
### 8.4 랜덤 요소 (시드 RNG 사용 지점)
- `setup`: 덱 섞기, 선 플레이어 선택.
- 시작 카드 재뒤집기는 RNG를 쓰지 않는다(맨 아래로 보내기만 함).
- 드로우 더미 재구성 때 버린 더미 섞기.
- `uno-style` 시작 카드가 색 바꾸기+4일 때 다시 섞기.
- `onTimeout` 자동 행동에서 여러 후보 중 고를 때(아래).
### 8.5 타이머·시간 초과·연결 끊김 시 자동 행동
- `deadline`: `turnStartedAt + turnSeconds*1000`. `wild4Response`는 10초. 원카드 창에는 마감이 없다.
- `onTimeout(state, player)`
- 공격을 받는 중이면 `draw`(누적량 먹기). 자동 받아치기는 하지 않는다.
- 일반 턴이면 `draw`. 자동으로 카드를 내지 않는 쪽이 공정성 시비가 적다.
- `afterDraw`이면 `pass`.
- `wild4Response`이면 `acceptWild4`.
- 연결이 끊긴 플레이어는 매 턴 즉시 타임아웃 처리하지 않고 정상 타이머를 그대로 쓴다. 3턴 연속 타임아웃이면 "자리 비움"으로 표시하고, 이후에는 타이머를 5초로 줄인다(서버 정책, 엔진 밖).
- 원카드 창에서 본인이 끊겨 있으면 선언하지 못하므로 잡힐 수 있다. 실제 게임과 같게 둔다.
### 8.6 종료 조건과 결과(GameResult)
- 종료 조건
- `first-out`이면 첫 번째로 나간 사람이 생길 때.
- `last-standing`이면 진행 중인 사람이 1명 이하일 때.
- 파산으로 1명만 남았을 때.
- `uno-style` 점수제는 각 판을 한 게임으로 보고, 방 레벨에서 "다음 판"으로 이어간다. 엔진은 `scores`를 다음 `setup`에 넘길 수 있게 `options.carryScores`로 받는다.
```ts
interface GameResult {
ranking: { player: PlayerId; rank: number; status: 'out' | 'playing' | 'bankrupt'; cardsLeft: number; points: number }[];
winner: PlayerId[];
summary: string; // 예: "민수 님이 가장 먼저 카드를 모두 냈어요!"
}
```
## 9. UI/UX
- 모바일 세로 화면
- 위쪽: 상대 아바타가 원형/가로 스크롤로 배치되고, 각자 손패 장수 배지, 현재 턴 강조, 파산 표시가 붙는다.
- 가운데: 버린 더미(큰 카드), 드로우 더미, 진행 방향 화살표, 지정 무늬/색 아이콘, 공격 누적 배지("+7장!")가 크게 보인다.
- 아래: 내 손패(부채꼴, 2줄 자동 줄바꿈). 카드 하나는 최소 48×68px.
- 손패 정렬 버튼: 무늬별, 숫자별.
- PC: 같은 배치를 가로로 넓힌다. 손패는 한 줄이다.
- 조작
- 카드를 탭하면 위로 올라오고, 한 번 더 탭하거나 "내기" 버튼을 누르면 낸다. 드래그해서 버린 더미에 놓아도 된다.
- 낼 수 없는 카드는 회색 처리하고, 낼 수 있는 카드에 테두리를 표시한다.
- 7이나 색 바꾸기를 내면 큰 무늬/색 버튼 4개가 뜬다.
- 공격을 받을 때는 "N장 먹기" 버튼과 받아칠 수 있는 카드 하이라이트를 같이 보여 준다.
- 원카드 버튼: 화면 오른쪽 아래에 큰 원형 버튼이 있고, 누군가 1장이 되면 흔들리며 활성화된다. 내가 1장을 남기는 카드를 고르면 "원카드 외치며 내기"가 기본 선택이다.
- 애니메이션: 카드 날아가기, 공격 누적 숫자 커지기, 방향 전환 화살표 회전, 파산 시 카드 흩어짐. 모두 300ms 이하이고, "애니메이션 줄이기" 설정을 따른다.
- 초보자 도움말
- "규칙 보기"에 특수 카드 표를 그림으로 넣는다.
- 처음 몇 판은 "힌트" 토글로 낼 수 있는 카드 이유를 보여 준다(예: "♥ 무늬가 같아요").
- 공격받을 때 "막을 수 있는 카드: ♣2, 조커" 같은 안내 문구를 띄운다.
- 접근성: 무늬는 색과 모양을 같이 쓴다(♥♦ 빨강, ♠♣ 검정). `uno-style` 색 카드에는 색맹 대비용 무늬 패턴(점·줄·격자·물결)과 색 이름 글자를 함께 넣는다.
## 10. 테스트 체크리스트
- [ ] 셋업: 같은 시드면 같은 손패와 시작 카드가 나온다. 시작 카드가 2/A/7/J/Q/K/3/조커면 맨 아래로 가고 일반 숫자 카드가 시작 카드가 된다.
- [ ] ♥2 공격(2장) 다음 사람이 ♥A로 받아치면 누적 5장이고, 그다음 사람이 먹으면 정확히 5장을 받고 턴이 끝난다.
- [ ] ♥2 위에 ♣A는 받아치기가 거부된다(다른 무늬 A). ♣2는 허용된다.
- [ ] ♠A 위에 ♥A로 받아치기는 거부되고 흑백 조커는 허용된다. 흑백 조커 위 컬러 조커는 허용되고, 컬러 조커 위 흑백 조커는 거부된다.
- [ ] `threeDefense`: ♦A 공격에 ♦3을 내면 누적이 0이 되고 다음 사람은 ♦3 기준 일반 턴을 한다. ♣3은 거부된다. 조커 공격에는 3이 거부된다.
- [ ] 7을 내고 ♠을 지정하면 다음 사람은 ♠ 카드나 7만 낼 수 있고, 같은 숫자(바닥 7의 원래 무늬) 일치는 쓸 수 없다.
- [ ] J를 2인에서 내면 낸 사람이 다시 턴을 한다. Q를 2인에서 내면 기본값에서 상대 턴이다.
- [ ] K `extra-turn`: 추가 턴에 낼 카드가 없으면 1장을 먹는다. K가 마지막 카드면 바로 나간다.
- [ ] `lastCardRule = joker`: 손패가 [조커, ♥5]인 상태에서 ♥5를 내면 조커 1장이 남는다. 다음 턴에 조커를 내려 하면 "마지막 카드로 조커는 낼 수 없어요"로 거부된다.
- [ ] 원카드: A가 ♥5를 내서 1장이 되었을 때 B의 `declareOneCard`가 먼저 도착하면 A가 1장을 먹는다. A가 먼저 도착하면 B의 요청은 무시된다. `play`에 `declareOneCard: true`를 같이 보내면 잡히지 않는다.
- [ ] 파산: 손패 15장에서 공격 5장을 먹어 20장이 되면 즉시 파산한다. 그 카드는 드로우 더미 아래로 가고 턴 순서에서 빠진다. 2인 게임이면 상대가 바로 1등이다.
- [ ] 드로우 더미 0장에서 7장을 먹을 때 버린 더미(맨 위 제외)가 섞여 보충된다. 그래도 부족하면 있는 만큼만 먹고 오류 없이 진행된다.
- [ ] `uno-style` `strict`: 현재 색이 빨강이고 손에 빨강 3이 있으면 색 바꾸기+4가 거부된다. 빨강이 없고 파랑 +2만 있으면 허용된다.
- [ ] `uno-style` `challenge`: 위반이 확인되면 낸 사람이 4장을 먹는다. 정상이면 의심한 사람이 6장을 먹는다. 낸 사람 손패는 의심한 사람의 view에만 나오고 다른 사람과 관전자의 view에는 없다.
- [ ] `uno-style` 방향 바꾸기가 2인에서 건너뛰기로 동작한다. 시작 카드가 +2이면 선 플레이어가 2장을 먹고 턴을 잃는다.
- [ ] `uno-style` 점수: 위 6장 예시처럼 80점이 계산된다.
- [ ] 타임아웃: 공격받는 중 시간이 지나면 누적량을 먹는다. `afterDraw`에서 시간이 지나면 `pass`한다.
- [ ] 연결 끊김: 끊긴 플레이어 턴은 타이머대로 자동 먹기로 진행되고 게임이 멈추지 않는다.
- [ ] 정보 유출: 관전자 view와 상대 view를 JSON으로 직렬화했을 때 다른 사람의 카드 ID/무늬/숫자, 드로우 더미 내용이 들어 있지 않다. `cardsDrawn` 이벤트도 본인 외에는 장수만 들어 있다.
- [ ] 차례가 아닌 사람의 `play`, 손에 없는 카드 ID, 이미 낸 카드 ID 재전송(중복 패킷)이 모두 거부되고 상태가 바뀌지 않는다.
## 11. 참고 자료
- 나무위키 "원카드": https://namu.wiki/w/원카드 (공격 카드 장수와 방어, 7/J/Q/K, 원카드 선언, 파산 15~20장, 마지막 카드 제한, 2~9명)
- 위키백과 "원카드": https://ko.wikipedia.org/wiki/원카드 (♠A 5장, 흑백 조커 5장, 컬러 조커 7장 등)
- Wikipedia "Uno (card game)": https://en.wikipedia.org/wiki/Uno_(card_game) (108장 구성, 7장 배분, +4 의심 규칙, 미선언 벌칙 2장, 점수 20/50점, 500점)
- 원작 공식 규칙서(제조사 배포 PDF, 2~10명, 시작 카드 처리, 2인 방향 바꾸기 규칙)
## 12. 메모 (상표·법적 주의 등)
- "원카드"는 특정 회사 상품이 아닌 민속 게임 이름이라 그대로 써도 된다. 카드 그림은 트럼프의 일반적인 디자인을 직접 그려서 쓴다(특정 회사의 카드 뒷면 디자인을 베끼지 않는다).
- `uno-style` 모드: "UNO"는 Mattel의 등록 상표다. 화면, 도메인, 홍보에 "UNO", "우노", 원작 로고, 고유 카드 디자인(빨간 타원 로고 등)을 쓰지 않는다. 표시 이름 후보: "색깔 카드", "컬러 원카드", "무지개 카드"(사용자 선택). 게임 규칙 자체는 저작권 보호 대상이 아니지만, 규칙서 문구를 그대로 옮기지 않고 직접 쓴 설명을 쓴다.
- 돈, 칩, 포인트를 걸지 않는 게임이다. `uno-style` 점수는 방 안 결과 표시용이고 저장, 교환, 이월(방 밖)이 없다.