Files
playuniare.sori.studio/docs/spec.md

286 lines
15 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.
# 프로젝트 기술 명세
## 프로젝트 개요
- 프로젝트명: Play Uniare
- 목적: Union Arena 덱을 불러와 카드 조작 중심의 로컬·온라인 플레이 제공
- 현재 단계: 로컬 카드 조작과 온라인 대기실 연결
- 프런트엔드: Vue 3, Vite, Tailwind CSS
- 배포 대상: UGREEN NAS의 Docker 환경
## 제품 범위
### 포함
- 기존 덱 빌더 쿼리에서 덱 불러오기
- 카드 수량만큼 독립 인스턴스 생성
- 덱 셔플과 드로우
- 카드 드래그 이동
- 카드 앞면·뒷면 전환
- 카드 탭·언탭
- 손패, 필드, 덱, 무덤, 공개 영역
- 덱 위·아래 또는 무덤으로 카드 이동
- 덱 위에서 여러 장을 공개한 후 목적지 지정
- 혼자 연습하는 로컬 모드
- 방 코드 기반 2인 세션
- 각 플레이어가 자신의 카드만 조작하는 권한 모델
- 비공개 방 코드 기반 대기실
- 덱 URL 등록과 기본 덱 구성 검증
- 양쪽 플레이어 준비 완료 후 게임 시작
### 제외
- 카드 효과와 게임 규칙 자동 판정
- 음성·텍스트 채팅
- 랭킹, 매칭, 대회 운영
- 초기 단계의 회원가입
## 덱 입력
초기에는 기존 덱 빌더 형식을 호환한다.
```text
?series=ua03&seriesName=HUNTER%C3%97HUNTER&cards=<encoded-json>
```
수신 후 다음 절차를 적용한다.
1. 쿼리 길이와 필수 필드를 검사한다.
2. JSON 디코딩 오류를 처리한다.
3. `series`, `imgSrc`, `Number`, `count`를 검증한다.
4. 카드 수량만큼 고유한 게임 카드 인스턴스를 만든다.
5. 이미지 주소는 환경변수 기반 카드 자산 원본과 조합한다.
6. 각 카드의 `count`만큼 고유한 `instanceId`를 가진 게임 카드로 확장한다.
7. 불러온 카드는 내 덱 영역에 뒷면으로 쌓는다.
장기적으로는 축약된 덱 코드 또는 서버의 `deckId` 방식으로 전환한다.
- 카드 종류별 수량은 1~100 범위만 허용한다.
- 한 번에 불러오는 전체 카드 수는 최대 100장으로 제한한다.
- 잘못된 카드 항목은 제외하고 화면에 오류 내용을 표시한다.
- `series`와 이미지 파일명은 허용된 문자와 확장자만 사용한다.
- 덱 쿼리가 없을 때에는 조작 검증용 연습 카드 3장을 표시한다.
## 덱 등록 검증
- 대기실에서 덱 빌더 결과 페이지 전체 URL을 등록한다.
- `series` 값 하나를 작품 식별자로 사용해 단일 작품 덱임을 확인한다.
- 메인 덱은 정확히 50장이어야 한다.
- 동일 카드 제한은 현재 데이터의 `Number`를 기준으로 최대 4장까지 검사한다.
- FINAL, COLOR, SPECIAL 트리거는 각각 합계 4장까지 허용한다.
- AP 색상 카드는 메인 덱에 포함하지 않고 별도 3장을 사용하는 것으로
검사한다.
- 현재 카드 데이터에는 카드 이름 전용 필드가 없으므로 동일 이름 제한을 카드
번호 기준으로 대체한다. 향후 이름 필드가 추가되면 이름 기준으로 전환한다.
## 대기실과 방
- `/`는 비공개 게임 대기실이다.
- 방장은 6자리 초대 코드를 생성한다.
- 참가자는 초대 코드를 입력해 같은 방에 참가한다.
- 각 플레이어는 자신의 덱 URL을 등록하고 준비 완료를 누른다.
- 두 플레이어의 연결·덱 등록·준비 완료가 모두 확인되어야 온라인 게임을
시작한다.
- 방 상태와 덱 등록 결과는 Socket.IO 서버가 최종 검증한다.
- 두 플레이어가 모두 준비하면 서버가 각 플레이어에게 자신의 덱 쿼리와
좌석을 전달하고 `/game`으로 자동 이동시킨다.
- 서버가 게임 시작 시 두 플레이어 중 한 명을 무작위 선공으로 지정한다.
- 온라인 게임에서는 사용자가 선공·후공을 직접 선택할 수 없다.
- 양쪽 플레이어가 첫 손패와 라이프를 확정한 뒤 선공 플레이어의 턴부터
시작한다.
- 현재 차례의 플레이어만 턴 종료를 요청할 수 있으며 서버가 상대에게 차례를
넘긴다.
- 방은 서버 메모리에 저장하므로 서버 재시작 시 종료된다.
- 브라우저 새로고침이나 짧은 네트워크 끊김에는 재접속 식별자로 같은 좌석을
복구한다.
- 내 덱 순서, 손패, 카드 위치와 준비 상태는 플레이어별 브라우저 저장소에
보관해 새로고침 후 복구한다.
- 개인 저장 상태는 상대 플레이어에게 전송하지 않는다.
- 연결이 끊긴 플레이어의 자리는 60초 동안 보존하고, 그 안에 복구되지 않으면
진행 중인 게임을 종료한다.
- 진행 중인 게임이 있는 사용자가 대기실이나 사이트 첫 화면으로 돌아오면
서버가 자신의 덱 쿼리와 좌석을 다시 전달해 게임 화면으로 복귀시킨다.
- 명시적인 `방 나가기`는 방 전체를 종료하고 양쪽 플레이어의 재접속 정보와
개인 게임 스냅샷을 삭제한다.
- 대기실에서 `현재 방 종료 후 새 방 만들기`로 먹통이 된 방을 정리하고 즉시
새 초대 코드를 만들 수 있다.
- 기능 개발을 위해 유효한 덱으로 `/game`에 진입하는 로컬 게임 테스트를
제공한다.
## 권한 모델
- 플레이어는 자기 카드만 이동·뒤집기·탭·정리할 수 있다.
- 상대 효과로 내 카드가 제거될 때에도 내가 직접 내 카드를 이동한다.
- 서버는 모든 카드 조작에서 요청 사용자와 카드 소유자를 비교한다.
- 상대 손패에는 장수만 공개하고 카드 식별 정보는 공개하지 않는다.
- 각 플레이어는 자신의 공개 카드 상태만 서버에 제출할 수 있다.
- 서버는 필드, 공개, 무덤, 라이프 카드만 상대에게 전달한다.
- 덱과 손패는 카드 목록을 보내지 않고 장수만 전달한다.
## 화면 좌표
- 배경 이미지는 카드 배치 가이드로 사용한다.
- 표준 플레이 보드 원본은 3840×2160의 16:9 이미지다.
- 표준 카드 비율은 너비÷높이 약 `0.716`으로 사용한다.
- 표준 카드 너비는 플레이 보드 전체 너비의 `5.75%`를 기준으로 한다.
- 실제 영역은 배경 위의 투명한 드롭 영역으로 정의한다.
- 자유 배치 좌표는 보드 너비·높이 기준 0부터 1 사이 값으로 저장한다.
- 드래그 중인 카드는 보드 경계를 벗어나지 않도록 카드 반쪽 크기만큼 좌표를
제한한다.
- 창 크기가 변해도 같은 상대 위치를 유지해야 한다.
- 보드 좌우 여백은 플레이어별 라이프, 덱·손패 장수, 연결 상태와 메모를
표시하는 정보 패널 영역으로 활용한다.
## 상태 저장
- 로컬 모드: 브라우저 메모리, 추후 복구가 필요하면 localStorage 검토
- 온라인 모드: 서버가 방 상태의 최종 권위를 가진다.
- 온라인 개인 비공개 상태: 현재 단계에서는 플레이어 브라우저 저장소에
암호화 없이 저장한다. 공용 PC에서는 게임 후 브라우저 데이터를 정리해야
한다.
- 초기 서버: Node.js와 Socket.IO, 메모리 기반 방 상태
- 운영 안정화 단계: Redis 또는 데이터베이스 저장 검토
- 서버의 공개 방 상태를 Pinia가 수신해 화면에 표시한다.
- 공개 방 상태에는 상대 덱의 작품명과 장수만 포함하며 카드 목록은 포함하지
않는다.
## 카드 직접 조작
- 포인터 입력으로 카드를 선택하고 자유롭게 이동한다.
- 카드 더블클릭으로 탭·언탭을 전환한다.
- 카드 우클릭 메뉴에서 앞면·뒷면, 탭·언탭, 맨 위로 이동을 조작한다.
- 탭 상태는 카드를 시계 방향으로 90도 회전해 표현한다.
- 카드를 드래그하거나 `맨 위로`를 누르면 `stackOrder`를 증가시킨다.
- 여러 카드가 겹친 경우 가장 큰 `stackOrder`를 가진 카드를 위에 표시한다.
- 탭 카드의 보드 경계는 회전된 실제 너비와 높이를 기준으로 제한한다.
- 뒷면 전용 이미지가 확정되기 전에는 공통 Union Arena 카드 뒷면 스타일을
CSS로 표시한다.
- 키보드 포커스로도 카드를 선택할 수 있어야 한다.
- 개발 기준 화면에서는 같은 카드의 독립 인스턴스 3장으로 겹침과 선택
동작을 검증한다.
- 온라인 게임에서 내 공개 카드는 상대 화면의 반대편 좌표로 변환해 표시한다.
- 상대 카드는 선택, 드래그, 더블클릭, 우클릭 조작을 할 수 없다.
- 공개 상태 전송에는 증가하는 리비전 번호를 붙이고, 서버와 상대 브라우저는
이전 리비전이 늦게 도착하면 무시한다.
## 덱·손패·무덤 동작
- 덱 카드는 보드의 내 덱 위치에 뒷면으로 겹쳐 표시한다.
- 온라인 게임에서 서버는 카드 내용이 아닌 인스턴스 식별자 순서만 비공개로
관리한다.
- 온라인 셔플은 서버의 암호학적 난수로 순서를 결정한다.
- 온라인 드로우, 덱 위 여러 장 공개, 라이프 배치는 서버가 반환한 맨 위 카드
식별자를 사용한다.
- 덱 위·아래 이동은 서버의 비공개 덱 순서에도 같은 위치로 반영한다.
- `1장 드로우`는 가장 높은 `stackOrder`의 덱 카드를 손패로 이동한다.
- 손패 카드는 앞면으로 표시하고 보드 하단 중앙에 자동 정렬한다.
- 손패는 플레이매트 위가 아니라 화면 하단의 별도 한 줄 트레이에 표시한다.
- 손패 카드는 필드 카드보다 작게 표시하고 장수가 많아지면 가로로 스크롤한다.
- 플레이매트의 `ACTION POINT AREA`는 손패 카드로 가리지 않는다.
- 손패·덱·무덤 카드를 드래그하면 필드 카드로 전환한다.
- 셔플은 덱 카드의 레이어 순서를 무작위로 다시 배정하고 모두 뒷면·언탭으로
정리한다.
- 우클릭 메뉴에서 선택 카드를 손패, 덱 위, 덱 아래, 무덤으로 이동한다.
- 덱 위 카드는 가장 큰 `stackOrder`, 덱 아래 카드는 가장 작은
`stackOrder`를 사용한다.
- 무덤 카드는 앞면으로 무덤 기준 위치에 겹쳐 표시한다.
- 플레이어 정보 패널의 덱·손패 장수는 현재 영역 상태에서 계산한다.
- 덱 패널에서 1~10장 사이의 공개 수량을 입력한다.
- 공개 명령은 덱 위에서 지정한 장수를 가져와 보드 중앙 공개 영역에 앞면으로
정렬한다.
- 공개 영역의 카드는 우클릭 메뉴로 각각 덱 위·덱 아래·손패·무덤에
정리한다.
- 공개 영역 카드가 이동하면 남은 공개 카드를 다시 중앙 정렬한다.
## 게임 준비와 턴 시작
- 유효한 덱으로 게임에 진입하면 덱을 섞고 첫 손패 7장을 드로우한다.
- 멀리건은 한 번만 가능하다.
- 멀리건은 기존 손패를 제외한 덱에서 새 7장을 먼저 뽑고, 기존 손패를 덱에
되돌린 뒤 섞는다.
- 손패 확정 시 덱 위 7장을 보지 않고 라이프 영역에 뒷면으로 배치한다.
- AP 카드는 별도 3장을 사용하며 현재 화면에서는 사용 가능 AP를 숫자로
표시한다.
- 선공 1턴 AP 1, 2턴 AP 2, 3턴 이후 AP 3으로 갱신한다.
- 후공 1턴 AP 2, 2턴 이후 AP 3으로 갱신한다.
- 선공 첫 턴에는 자동 드로우하지 않고 그 외 턴 시작에는 1장 드로우한다.
- 턴 시작 시 내 필드의 탭 카드를 모두 언탭한다.
- `isUntapLocked`가 설정된 카드는 턴 시작 자동 언탭에서 제외한다.
- 언탭 제외 잠금은 카드 우클릭 메뉴에서 설정·해제한다.
- 온라인 게임에서는 서버의 현재 차례가 내 플레이어로 바뀔 때 턴 시작
언탭, AP 갱신과 드로우를 한 번 적용한다.
## 게임 화면
- 실제 게임 화면에는 고정 헤더를 표시하지 않는다.
- 16:9 플레이 보드를 뷰포트 안에서 가능한 가장 크게 표시한다.
- 하단 손패 트레이 높이를 제외한 영역에서 16:9 플레이 보드를 가능한 가장
크게 표시한다.
- 남는 공간은 검은색 레터박스로 처리한다.
- `F` 키 또는 우측 상단의 반투명 버튼으로 브라우저 전체화면을 전환한다.
- `Esc` 키로 브라우저 전체화면과 카드 우클릭 메뉴를 닫을 수 있다.
- 카드 크기 기준 가이드는 기본적으로 숨긴다.
## 포트 정책
- Vue 개발 서버는 Vite가 제시하는 포트를 사용하며 기본값은 보통 `5173`이다.
- 로컬 Socket.IO 서버 기본 포트는 `31873`이다.
- Docker 컨테이너 내부 포트는 다른 컨테이너와 같아도 충돌하지 않는다.
- NAS 호스트에 공개하는 포트는 다른 서비스와 겹치면 안 된다.
- NAS 운영 HTTP 호스트 포트 기본값은 `41809`다.
- 운영 HTTP 호스트 포트는 `.env`의
`PLAYUNIARE_PRODUCTION_HTTP_PORT`로 변경한다.
- 기능 테스트는 로컬에서 수행하고 NAS에는 운영 컨테이너만 배포한다.
- 운영 브라우저는 Socket.IO 포트를 직접 호출하지 않고
`http://playuniare.sori.studio/socket.io`처럼 같은 도메인의 리버스 프록시
경로를 사용한다.
- 포트 번호는 코드에 하드코딩하지 않고 환경변수와 Compose 설정으로 관리한다.
## 환경변수
```text
VITE_CARD_ASSET_BASE_URL=/card-assets
```
브라우저 Socket.IO 클라이언트는 별도 서버 URL 없이 현재 접속한 출처의
`/socket.io`를 사용한다. 로컬 서버 포트를 바꿀 때만 실행 명령에
`SOCKET_SERVER_PORT`를 지정한다. 실제 운영 값은 `.env`에 두고 Git에
커밋하지 않는다.
## NAS 카드 이미지 공유
운영 NAS에서는 카드 이미지를 새 프로젝트에 복사하지 않는다. 기존 Uniare
컨테이너가 사용하는 웹 루트의 이미지 폴더를 읽기 전용으로 공유한다.
```text
기존 Uniare 웹 루트:
/volume1/docker/projects/apps/uniare/config/www
카드 이미지 원본:
/volume1/docker/projects/apps/uniare/config/www/images
Play Uniare 컨테이너 내부:
/srv/uniare-card-images
브라우저 공개 경로:
/card-assets
```
- `config/www`는 기존 `index.html`이 존재하는 실제 웹 루트다.
- Docker bind mount는 `images` 폴더만 읽기 전용으로 연결한다.
- Play Uniare는 기존 카드 이미지를 읽을 수 있지만 수정하거나 삭제할 수 없다.
- Nginx 작업 프로세스는 NAS 카드 파일 소유자와 같은 UID `1000`의
`cardreader` 사용자로 실행한다.
- 카드 이미지 URL은 `/card-assets/<series>/<fileName>` 형식을 사용한다.
- 프런트엔드는 `VITE_CARD_ASSET_BASE_URL`과 시리즈·파일명을 조합해 카드
이미지 URL을 생성한다.
- 같은 Play Uniare 도메인으로 제공되므로 별도의 CORS 설정이 필요하지 않다.
## 배포 환경
- 로컬 개발: Vite 개발 서버
- 로컬 배포 검증: Docker Compose
- NAS 운영: `compose.yaml`과 `compose.nas.yaml`
- 운영 주소: `http://playuniare.sori.studio`
- NAS 테스트 컨테이너는 별도로 유지하지 않는다.