feat: expand experimental online multiplayer

This commit is contained in:
2026-06-22 10:48:42 +09:00
parent 1af3c71f8a
commit 314c98fe66
15 changed files with 2071 additions and 109 deletions

View File

@@ -32,7 +32,16 @@ npm install
## 개발 서버 실행 ## 개발 서버 실행
실시간 대기실을 사용하므로 터미널 두 개를 연다. 가장 간단한 방법은 프로젝트 폴더에서 다음 명령 하나를 실행하는 것이다.
```bash
npm run dev:all
```
이 명령은 Vue 개발 서버와 Socket.IO 서버를 동시에 실행한다. 화면에 표시되는
Vite의 `Local` 주소를 브라우저에서 연다.
각 서버 로그를 별도 터미널에서 보고 싶다면 터미널 두 개를 연다.
첫 번째 터미널에서 Socket.IO 서버를 실행한다. 첫 번째 터미널에서 Socket.IO 서버를 실행한다.
@@ -57,6 +66,12 @@ Local: http://localhost:5173/
Vite는 브라우저의 `/socket.io` 요청을 로컬 `31873` 포트로 전달한다. 두 Vite는 브라우저의 `/socket.io` 요청을 로컬 `31873` 포트로 전달한다. 두
개발 서버를 종료하려면 각각의 터미널에서 `Control + C`를 누른다. 개발 서버를 종료하려면 각각의 터미널에서 `Control + C`를 누른다.
`npm run dev:all`을 사용했다면 해당 터미널에서 `Control + C`를 한 번 누르면
두 서버가 함께 종료된다.
- Vue 파일 변경은 자동으로 화면에 반영된다.
- `server/` 파일을 변경한 경우에는 `Control + C`로 종료한 뒤
`npm run dev:all`을 다시 실행한다.
## 같은 네트워크의 다른 기기에서 테스트 ## 같은 네트워크의 다른 기기에서 테스트
@@ -105,7 +120,7 @@ VITE_CARD_ASSET_BASE_URL=http://playuniare.sori.studio/card-assets
```bash ```bash
git pull git pull
npm install npm install
npm run dev npm run dev:all
``` ```
작업이 끝난 뒤: 작업이 끝난 뒤:
@@ -142,11 +157,24 @@ SOCKET_SERVER_PORT=31874 npm run dev
### 실시간 서버에 연결되지 않는 경우 ### 실시간 서버에 연결되지 않는 경우
- `npm run dev:server` 터미널이 실행 중인지 확인한다. - `npm run dev:server` 터미널이 실행 중인지 확인한다.
- 명령 하나로 실행하려면 기존 개발 서버를 모두 종료한 뒤
`npm run dev:all`을 사용한다.
- 서버 터미널에 `listening on port 31873` 메시지가 있는지 확인한다. - 서버 터미널에 `listening on port 31873` 메시지가 있는지 확인한다.
- 포트를 바꿨다면 Vue와 Socket.IO 서버를 같은 `SOCKET_SERVER_PORT`로 - 포트를 바꿨다면 Vue와 Socket.IO 서버를 같은 `SOCKET_SERVER_PORT`로
실행했는지 확인한다. 실행했는지 확인한다.
- 브라우저 개발자 도구 Network 탭에서 `/socket.io` 요청을 확인한다. - 브라우저 개발자 도구 Network 탭에서 `/socket.io` 요청을 확인한다.
### `EMFILE: too many open files`가 나오는 경우
이 프로젝트의 기본 `dev:server`는 파일 감시를 사용하지 않으므로 최신
`package.json`에서는 발생하지 않아야 한다. 기존 터미널을 모두 종료하고
다음 명령을 다시 실행한다.
```bash
npm install
npm run dev:all
```
### 패키지 오류가 발생하는 경우 ### 패키지 오류가 발생하는 경우
먼저 다음을 다시 실행한다. 먼저 다음을 다시 실행한다.

View File

@@ -85,3 +85,58 @@
- Socket.IO 포트는 NAS 호스트에 직접 공개하지 않는다. - Socket.IO 포트는 NAS 호스트에 직접 공개하지 않는다.
- 현재 서버 재시작 시 방이 사라지는 단순한 구조를 유지하고, 게임 상태 - 현재 서버 재시작 시 방이 사라지는 단순한 구조를 유지하고, 게임 상태
동기화와 재접속 복구는 후속 단계에서 구현한다. 동기화와 재접속 복구는 후속 단계에서 구현한다.
## 2026-06-19: 온라인 선후공·턴·공개 카드 동기화
- 선후공은 클라이언트 선택이 아니라 서버가 무작위로 한 명만 지정한다.
- 양쪽 초기 손패 확정 후 선공부터 현재 차례를 시작한다.
- 현재 차례인 플레이어만 서버에 턴 종료를 요청할 수 있다.
- 플레이어는 자신의 공개 카드 상태만 제출하며 서버는 상대 상태를 직접
수정하는 요청을 제공하지 않는다.
- 손패와 덱은 장수만 공유하고 카드 식별 정보는 서버 공개 상태에서 제외한다.
- 구현 복잡도를 낮추기 위해 현재는 카드별 이벤트 대신 플레이어별 공개 상태
스냅샷을 전송한다. 재접속과 순서 보장은 후속 단계에서 보강한다.
## 2026-06-19: 손패 트레이와 단기 재접속
- 상하 3행 플레이매트 구조를 유지하고 좌우 회전 배치는 채택하지 않았다.
- 하단 Action Point 영역을 손패가 덮지 않도록 플레이매트 밖에 전용 손패
트레이를 추가했다.
- 손패 카드는 축소해 한 줄로 표시하고 넘치는 카드는 가로 스크롤로 확인한다.
- Socket.IO 소켓 ID와 별개인 플레이어 식별자와 복구 토큰을 도입했다.
- 연결 종료 즉시 방에서 제거하지 않고 60초 동안 같은 좌석의 재접속을
허용한다.
- 비공개 덱·손패와 카드 위치는 플레이어별 브라우저 저장소에 보관해
새로고침 후 복구한다.
- 서버는 상대에게 공개할 카드만 보관하며 개인 손패 원본은 받지 않는 구조를
유지한다.
## 2026-06-19: 로컬 통합 개발 명령
- Vue만 실행했을 때 발생하는 Socket.IO 연결 오류를 줄이기 위해
`concurrently`를 개발 의존성으로 추가했다.
- `npm run dev:all` 명령 하나로 Vite와 Socket.IO 개발 서버를 함께 실행한다.
## 2026-06-19: 방 종료와 진행 중 게임 복귀
- 방 상태가 꼬여도 사용자가 직접 초기화할 수 있도록 대기실과 게임 화면에
`방 나가기`를 추가했다.
- 한 플레이어가 명시적으로 방을 나가면 방 전체를 종료하고 상대에게 종료
사유를 전달한다.
- 대기실에서 기존 방을 종료하고 즉시 새 방을 만드는 동선을 추가했다.
- 진행 중 게임 세션으로 재접속하면 서버가 덱 쿼리와 좌석을 다시 전달해
게임 화면으로 자동 복귀시킨다.
- 명시적 종료와 서버의 방 종료 알림에서는 브라우저 재접속 정보와 개인 게임
스냅샷을 함께 삭제한다.
## 2026-06-22: 서버 권위 비공개 덱 순서
- 서버는 카드 내용 대신 플레이어별 카드 인스턴스 식별자 순서만 보관한다.
- 온라인 셔플은 Node.js 암호학적 난수를 사용하고 결과 순서를 해당
플레이어에게만 반환한다.
- 드로우, 여러 장 공개와 라이프 배치는 서버 비공개 덱의 맨 위 식별자를
제거한 결과로 처리한다.
- 덱 위·아래 이동은 서버 순서에도 반영해 화면 장수와 실제 드로우 결과가
어긋나지 않도록 했다.
- 공개 카드 상태에는 단조 증가 리비전을 추가해 지연된 이전 상태가 최신
상태를 덮지 못하게 했다.

View File

@@ -15,6 +15,9 @@
- 서버 기반 방 생성·방 코드 참가 - 서버 기반 방 생성·방 코드 참가
- 양쪽 플레이어의 좌석·덱 등록·준비 상태 - 양쪽 플레이어의 좌석·덱 등록·준비 상태
- 양쪽 준비 완료 시 게임 화면 자동 이동 - 양쪽 준비 완료 시 게임 화면 자동 이동
- 방 나가기
- 현재 방 종료 후 새 방 만들기
- 진행 중 게임 세션 발견 시 게임 화면 자동 복귀
- 유효한 덱으로 로컬 게임 테스트 - 유효한 덱으로 로컬 게임 테스트
### `/game` ### `/game`
@@ -33,14 +36,23 @@
- 불러온 덱을 내 덱 영역에 뒷면으로 쌓기 - 불러온 덱을 내 덱 영역에 뒷면으로 쌓기
- 잘못된 덱 데이터 오류 안내 - 잘못된 덱 데이터 오류 안내
- 내 덱 1장 드로우와 셔플 - 내 덱 1장 드로우와 셔플
- 온라인 서버 권위 셔플·드로우·여러 장 공개
- 덱 위 1~10장 공개 - 덱 위 1~10장 공개
- 중앙 공개 영역 자동 정렬 - 중앙 공개 영역 자동 정렬
- 하단 손패 자동 정렬 - 플레이매트와 분리된 화면 하단 한 줄 손패 트레이
- 축소 손패 카드와 가로 스크롤
- 우클릭 메뉴의 손패·덱 위·덱 아래·무덤 이동 - 우클릭 메뉴의 손패·덱 위·덱 아래·무덤 이동
- 첫 손패 7장 - 첫 손패 7장
- 선후공 선택과 1회 멀리건 - 로컬 선후공 선택과 1회 멀리건
- 온라인 서버 무작위 선후공 지정
- 라이프 7장 세팅 - 라이프 7장 세팅
- 턴 시작 AP·드로우·일괄 언탭 - 턴 시작 AP·드로우·일괄 언탭
- 현재 차례 강조와 서버 기반 턴 종료·교대
- 상대 공개 카드·덱·손패·라이프 장수 실시간 표시
- 상대 카드 반대편 좌표 표시와 조작 차단
- 새로고침·단기 연결 끊김의 같은 좌석 재접속
- 게임 화면의 방 나가기
- 상대가 방을 나가면 대기실 자동 복귀
- 카드별 자동 언탭 제외 잠금 - 카드별 자동 언탭 제외 잠금
- 마우스·터치 포인터 기반 자유 드래그 - 마우스·터치 포인터 기반 자유 드래그
- 카드 더블클릭 탭·언탭 - 카드 더블클릭 탭·언탭
@@ -50,7 +62,7 @@
- 선택 카드 좌표·레이어·앞뒤·탭 상태 표시 - 선택 카드 좌표·레이어·앞뒤·탭 상태 표시
- 좌우 플레이어 정보 패널 - 좌우 플레이어 정보 패널
- 라이프, 덱·손패 장수, 연결 상태와 메모 자리 - 라이프, 덱·손패 장수, 연결 상태와 메모 자리
- 다음 변경: 게임 화면의 카드 상태를 Socket.IO 서버와 동기화 - 다음 변경: 카드 조작 이벤트별 세부 검증과 진행 중 표시
- 문서 제목과 기본 언어: `index.html`의 `Play Uniare`, 한국어 - 문서 제목과 기본 언어: `index.html`의 `Play Uniare`, 한국어
## 예정 화면 ## 예정 화면

View File

@@ -84,7 +84,26 @@
- 방 상태와 덱 등록 결과는 Socket.IO 서버가 최종 검증한다. - 방 상태와 덱 등록 결과는 Socket.IO 서버가 최종 검증한다.
- 두 플레이어가 모두 준비하면 서버가 각 플레이어에게 자신의 덱 쿼리와 - 두 플레이어가 모두 준비하면 서버가 각 플레이어에게 자신의 덱 쿼리와
좌석을 전달하고 `/game`으로 자동 이동시킨다. 좌석을 전달하고 `/game`으로 자동 이동시킨다.
- 서버가 게임 시작 시 두 플레이어 중 한 명을 무작위 선공으로 지정한다.
- 온라인 게임에서는 사용자가 선공·후공을 직접 선택할 수 없다.
- 양쪽 플레이어가 첫 손패와 라이프를 확정한 뒤 선공 플레이어의 턴부터
시작한다.
- 현재 차례의 플레이어만 턴 종료를 요청할 수 있으며 서버가 상대에게 차례를
넘긴다.
- 방은 서버 메모리에 저장하므로 서버 재시작 시 종료된다. - 방은 서버 메모리에 저장하므로 서버 재시작 시 종료된다.
- 브라우저 새로고침이나 짧은 네트워크 끊김에는 재접속 식별자로 같은 좌석을
복구한다.
- 내 덱 순서, 손패, 카드 위치와 준비 상태는 플레이어별 브라우저 저장소에
보관해 새로고침 후 복구한다.
- 개인 저장 상태는 상대 플레이어에게 전송하지 않는다.
- 연결이 끊긴 플레이어의 자리는 60초 동안 보존하고, 그 안에 복구되지 않으면
진행 중인 게임을 종료한다.
- 진행 중인 게임이 있는 사용자가 대기실이나 사이트 첫 화면으로 돌아오면
서버가 자신의 덱 쿼리와 좌석을 다시 전달해 게임 화면으로 복귀시킨다.
- 명시적인 `방 나가기`는 방 전체를 종료하고 양쪽 플레이어의 재접속 정보와
개인 게임 스냅샷을 삭제한다.
- 대기실에서 `현재 방 종료 후 새 방 만들기`로 먹통이 된 방을 정리하고 즉시
새 초대 코드를 만들 수 있다.
- 기능 개발을 위해 유효한 덱으로 `/game`에 진입하는 로컬 게임 테스트를 - 기능 개발을 위해 유효한 덱으로 `/game`에 진입하는 로컬 게임 테스트를
제공한다. 제공한다.
@@ -94,6 +113,9 @@
- 상대 효과로 내 카드가 제거될 때에도 내가 직접 내 카드를 이동한다. - 상대 효과로 내 카드가 제거될 때에도 내가 직접 내 카드를 이동한다.
- 서버는 모든 카드 조작에서 요청 사용자와 카드 소유자를 비교한다. - 서버는 모든 카드 조작에서 요청 사용자와 카드 소유자를 비교한다.
- 상대 손패에는 장수만 공개하고 카드 식별 정보는 공개하지 않는다. - 상대 손패에는 장수만 공개하고 카드 식별 정보는 공개하지 않는다.
- 각 플레이어는 자신의 공개 카드 상태만 서버에 제출할 수 있다.
- 서버는 필드, 공개, 무덤, 라이프 카드만 상대에게 전달한다.
- 덱과 손패는 카드 목록을 보내지 않고 장수만 전달한다.
## 화면 좌표 ## 화면 좌표
@@ -113,6 +135,9 @@
- 로컬 모드: 브라우저 메모리, 추후 복구가 필요하면 localStorage 검토 - 로컬 모드: 브라우저 메모리, 추후 복구가 필요하면 localStorage 검토
- 온라인 모드: 서버가 방 상태의 최종 권위를 가진다. - 온라인 모드: 서버가 방 상태의 최종 권위를 가진다.
- 온라인 개인 비공개 상태: 현재 단계에서는 플레이어 브라우저 저장소에
암호화 없이 저장한다. 공용 PC에서는 게임 후 브라우저 데이터를 정리해야
한다.
- 초기 서버: Node.js와 Socket.IO, 메모리 기반 방 상태 - 초기 서버: Node.js와 Socket.IO, 메모리 기반 방 상태
- 운영 안정화 단계: Redis 또는 데이터베이스 저장 검토 - 운영 안정화 단계: Redis 또는 데이터베이스 저장 검토
- 서버의 공개 방 상태를 Pinia가 수신해 화면에 표시한다. - 서버의 공개 방 상태를 Pinia가 수신해 화면에 표시한다.
@@ -133,13 +158,25 @@
- 키보드 포커스로도 카드를 선택할 수 있어야 한다. - 키보드 포커스로도 카드를 선택할 수 있어야 한다.
- 개발 기준 화면에서는 같은 카드의 독립 인스턴스 3장으로 겹침과 선택 - 개발 기준 화면에서는 같은 카드의 독립 인스턴스 3장으로 겹침과 선택
동작을 검증한다. 동작을 검증한다.
- 온라인 게임에서 내 공개 카드는 상대 화면의 반대편 좌표로 변환해 표시한다.
- 상대 카드는 선택, 드래그, 더블클릭, 우클릭 조작을 할 수 없다.
- 공개 상태 전송에는 증가하는 리비전 번호를 붙이고, 서버와 상대 브라우저는
이전 리비전이 늦게 도착하면 무시한다.
## 덱·손패·무덤 동작 ## 덱·손패·무덤 동작
- 덱 카드는 보드의 내 덱 위치에 뒷면으로 겹쳐 표시한다. - 덱 카드는 보드의 내 덱 위치에 뒷면으로 겹쳐 표시한다.
- 온라인 게임에서 서버는 카드 내용이 아닌 인스턴스 식별자 순서만 비공개로
관리한다.
- 온라인 셔플은 서버의 암호학적 난수로 순서를 결정한다.
- 온라인 드로우, 덱 위 여러 장 공개, 라이프 배치는 서버가 반환한 맨 위 카드
식별자를 사용한다.
- 덱 위·아래 이동은 서버의 비공개 덱 순서에도 같은 위치로 반영한다.
- `1장 드로우`는 가장 높은 `stackOrder`의 덱 카드를 손패로 이동한다. - `1장 드로우`는 가장 높은 `stackOrder`의 덱 카드를 손패로 이동한다.
- 손패 카드는 앞면으로 표시하고 보드 하단 중앙에 자동 정렬한다. - 손패 카드는 앞면으로 표시하고 보드 하단 중앙에 자동 정렬한다.
- 손패 카드가 많아지면 제한된 폭 안에서 카드 간격을 줄인다. - 손패는 플레이매트 위가 아니라 화면 하단의 별도 한 줄 트레이에 표시한다.
- 손패 카드는 필드 카드보다 작게 표시하고 장수가 많아지면 가로로 스크롤한다.
- 플레이매트의 `ACTION POINT AREA`는 손패 카드로 가리지 않는다.
- 손패·덱·무덤 카드를 드래그하면 필드 카드로 전환한다. - 손패·덱·무덤 카드를 드래그하면 필드 카드로 전환한다.
- 셔플은 덱 카드의 레이어 순서를 무작위로 다시 배정하고 모두 뒷면·언탭으로 - 셔플은 덱 카드의 레이어 순서를 무작위로 다시 배정하고 모두 뒷면·언탭으로
정리한다. 정리한다.
@@ -170,11 +207,15 @@
- 턴 시작 시 내 필드의 탭 카드를 모두 언탭한다. - 턴 시작 시 내 필드의 탭 카드를 모두 언탭한다.
- `isUntapLocked`가 설정된 카드는 턴 시작 자동 언탭에서 제외한다. - `isUntapLocked`가 설정된 카드는 턴 시작 자동 언탭에서 제외한다.
- 언탭 제외 잠금은 카드 우클릭 메뉴에서 설정·해제한다. - 언탭 제외 잠금은 카드 우클릭 메뉴에서 설정·해제한다.
- 온라인 게임에서는 서버의 현재 차례가 내 플레이어로 바뀔 때 턴 시작
언탭, AP 갱신과 드로우를 한 번 적용한다.
## 게임 화면 ## 게임 화면
- 실제 게임 화면에는 고정 헤더를 표시하지 않는다. - 실제 게임 화면에는 고정 헤더를 표시하지 않는다.
- 16:9 플레이 보드를 뷰포트 안에서 가능한 가장 크게 표시한다. - 16:9 플레이 보드를 뷰포트 안에서 가능한 가장 크게 표시한다.
- 하단 손패 트레이 높이를 제외한 영역에서 16:9 플레이 보드를 가능한 가장
크게 표시한다.
- 남는 공간은 검은색 레터박스로 처리한다. - 남는 공간은 검은색 레터박스로 처리한다.
- `F` 키 또는 우측 상단의 반투명 버튼으로 브라우저 전체화면을 전환한다. - `F` 키 또는 우측 상단의 반투명 버튼으로 브라우저 전체화면을 전환한다.
- `Esc` 키로 브라우저 전체화면과 카드 우클릭 메뉴를 닫을 수 있다. - `Esc` 키로 브라우저 전체화면과 카드 우클릭 메뉴를 닫을 수 있다.

View File

@@ -47,10 +47,20 @@
- [x] 방 만들기·참가 대기실 UI - [x] 방 만들기·참가 대기실 UI
- [x] 방 만들기·참가 서버 동기화 - [x] 방 만들기·참가 서버 동기화
- [x] 플레이어 좌석 할당 - [x] 플레이어 좌석 할당
- [ ] 게임 카드 플레이어 소유권 - [x] 게임 카드 플레이어 소유권 기반 상태 제출
- [ ] 카드 조작 권한 서버 검증 - [x] 상대 카드 직접 조작 차단
- [ ] 상대 손패 비공개 전송 - [x] 상대 손패·덱 카드 식별 정보 비공개
- [ ] 상태 스냅샷과 재접속 - [x] 공개 카드 이동·탭·뒤집기·영역 상태 동기화
- [x] 서버 무작위 선후공과 현재 차례 동기화
- [ ] 카드 조작 이벤트별 세부 서버 검증
- [x] 덱 인스턴스 순서와 셔플의 서버 권위 처리
- [x] 서버 권위 드로우·여러 장 공개·라이프 배치
- [x] 60초 유예 기반 같은 좌석 재접속
- [x] 브라우저 저장소 기반 내 비공개 덱·손패 상태 재접속 복구
- [x] 진행 중 게임의 대기실·첫 화면 접근 시 자동 복귀
- [x] 방 나가기와 새 방 만들기 복구 동선
- [ ] 전체 게임 상태 스냅샷 복구
- [x] 명시적인 방 종료 시 플레이어별 로컬 스냅샷 자동 삭제
- [x] 연결 종료 시 방 참가자 정리 - [x] 연결 종료 시 방 참가자 정리
- [x] NAS WebSocket 프록시 배포 - [x] NAS WebSocket 프록시 배포
- [ ] 비어 있지 않은 방의 유휴 만료 처리 - [ ] 비어 있지 않은 방의 유휴 만료 처리
@@ -64,6 +74,8 @@
- [ ] 이미지 로딩 실패 폴백 - [ ] 이미지 로딩 실패 폴백
- [ ] 접근성 키보드 조작 범위 검토 - [ ] 접근성 키보드 조작 범위 검토
- [ ] 서버 재시작 시 방 종료 안내 - [ ] 서버 재시작 시 방 종료 안내
- [x] 공개 카드 상태 리비전으로 오래된 제출 무시
- [ ] 비공개 덱 조작 중 중복 클릭 잠금
- [ ] Redis 또는 상태 저장 필요성 검토 - [ ] Redis 또는 상태 저장 필요성 검토
## 확인이 필요한 정책 ## 확인이 필요한 정책

View File

@@ -64,3 +64,42 @@
- 방을 이동할 때 이전 Socket.IO 채널에서 확실히 나가도록 정리했다. - 방을 이동할 때 이전 Socket.IO 채널에서 확실히 나가도록 정리했다.
- Nginx의 `/socket.io` 프록시와 Docker 내부 실시간 서버 서비스를 추가했다. - Nginx의 `/socket.io` 프록시와 Docker 내부 실시간 서버 서비스를 추가했다.
- 방 생성부터 양쪽 게임 시작까지 검증하는 2인 통합 테스트를 추가했다. - 방 생성부터 양쪽 게임 시작까지 검증하는 2인 통합 테스트를 추가했다.
- 온라인 선후공을 서버 무작위 지정으로 변경하고 양쪽의 중복 선공 선택을
제거했다.
- 양쪽 손패 확정 후 선공부터 시작하는 현재 차례와 턴 종료 교대를 추가했다.
- 필드·공개·무덤·라이프 카드 상태와 비공개 영역 장수를 상대 화면에
동기화했다.
- 상대 카드는 반대편 좌표로 표시하고 직접 조작할 수 없도록 했다.
- 손패와 덱 카드 목록이 상대에게 전달되지 않도록 서버 공개 상태를 제한했다.
- 통합 테스트 범위를 선후공, 초기 준비, 공개 카드 비공개 필터, 턴 교대까지
확장했다.
- 플레이매트의 Action Point 영역을 침범하던 손패를 화면 하단 전용 트레이로
분리했다.
- 손패 카드를 축소된 한 줄 목록으로 표시하고 가로 스크롤을 지원한다.
- 새로고침과 짧은 네트워크 끊김 후 같은 방·좌석·턴을 복구하는 재접속
식별자를 추가했다.
- 연결이 끊긴 플레이어의 자리를 60초간 보존하고 재접속 통합 테스트를
추가했다.
- 내 덱 순서, 손패, 카드 위치, 멀리건과 준비 상태를 플레이어별 브라우저
저장소에 보관해 새로고침 후 복구하도록 했다.
- 로컬에서 Vue만 실행해 웹소켓 오류가 발생하는 문제를 예방하도록
`npm run dev:all` 통합 개발 명령을 추가했다.
- macOS 파일 감시 한도에서 Socket.IO 개발 서버가 `EMFILE`로 종료되는
문제를 피하도록 서버 개발 명령의 `--watch`를 제거했다.
- 대기실과 게임 화면에 `방 나가기`를 추가하고 한쪽이 나가면 양쪽 모두
대기실 상태로 복귀하도록 했다.
- 먹통이 된 방을 정리하고 즉시 새 초대 코드를 만드는 버튼을 추가했다.
- 진행 중인 게임에서 대기실로 이동하거나 브라우저를 다시 열어도 게임
화면으로 자동 복귀하도록 재접속 응답을 보강했다.
- 방 종료 시 재접속 세션과 플레이어별 게임 스냅샷을 삭제하도록 했다.
## 2026-06-22
- 온라인 덱 셔플을 클라이언트 난수 대신 서버의 암호학적 난수로 변경했다.
- 서버가 플레이어별 비공개 덱 인스턴스 순서를 관리하도록 추가했다.
- 드로우, 여러 장 공개, 라이프 배치와 덱 위·아래 이동을 서버 순서에
연결했다.
- 공개 카드 상태에 리비전 번호를 추가해 늦게 도착한 이전 상태를 무시하도록
했다.
- 서버 통합 테스트에 비공개 덱 셔플·드로우·되돌리기와 오래된 리비전 거부를
추가했다.

260
package-lock.json generated
View File

@@ -17,6 +17,7 @@
"devDependencies": { "devDependencies": {
"@tailwindcss/vite": "^4.3.1", "@tailwindcss/vite": "^4.3.1",
"@vitejs/plugin-vue": "^6.0.6", "@vitejs/plugin-vue": "^6.0.6",
"concurrently": "^10.0.3",
"tailwindcss": "^4.3.1", "tailwindcss": "^4.3.1",
"vite": "^8.0.12" "vite": "^8.0.12"
} }
@@ -1006,6 +1007,32 @@
"node": ">=0.4.0" "node": ">=0.4.0"
} }
}, },
"node_modules/ansi-regex": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
"integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/ansi-regex?sponsor=1"
}
},
"node_modules/ansi-styles": {
"version": "6.2.3",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz",
"integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/ast-kit": { "node_modules/ast-kit": {
"version": "2.2.0", "version": "2.2.0",
"resolved": "https://registry.npmjs.org/ast-kit/-/ast-kit-2.2.0.tgz", "resolved": "https://registry.npmjs.org/ast-kit/-/ast-kit-2.2.0.tgz",
@@ -1057,6 +1084,19 @@
"url": "https://github.com/sponsors/antfu" "url": "https://github.com/sponsors/antfu"
} }
}, },
"node_modules/chalk": {
"version": "5.6.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz",
"integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^12.17.0 || ^14.13 || >=16.0.0"
},
"funding": {
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
"node_modules/chokidar": { "node_modules/chokidar": {
"version": "5.0.0", "version": "5.0.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz",
@@ -1072,6 +1112,46 @@
"url": "https://paulmillr.com/funding/" "url": "https://paulmillr.com/funding/"
} }
}, },
"node_modules/cliui": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz",
"integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==",
"dev": true,
"license": "ISC",
"dependencies": {
"string-width": "^7.2.0",
"strip-ansi": "^7.1.0",
"wrap-ansi": "^9.0.0"
},
"engines": {
"node": ">=20"
}
},
"node_modules/concurrently": {
"version": "10.0.3",
"resolved": "https://registry.npmjs.org/concurrently/-/concurrently-10.0.3.tgz",
"integrity": "sha512-hc3LH4UaKWd/bbyDK/IGVa4RB6PtQ3CUYwtrkzqHn+wIG3Hr5fhpRlk0L/gCa8ZE1L/Ufj50Zho69cI5w8SQBA==",
"dev": true,
"license": "MIT",
"dependencies": {
"chalk": "5.6.2",
"rxjs": "7.8.2",
"shell-quote": "1.8.4",
"supports-color": "10.2.2",
"tree-kill": "1.2.2",
"yargs": "18.0.0"
},
"bin": {
"conc": "dist/bin/index.js",
"concurrently": "dist/bin/index.js"
},
"engines": {
"node": ">=22"
},
"funding": {
"url": "https://github.com/open-cli-tools/concurrently?sponsor=1"
}
},
"node_modules/confbox": { "node_modules/confbox": {
"version": "0.2.4", "version": "0.2.4",
"resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz", "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz",
@@ -1152,6 +1232,13 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/emoji-regex": {
"version": "10.6.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz",
"integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==",
"dev": true,
"license": "MIT"
},
"node_modules/engine.io": { "node_modules/engine.io": {
"version": "6.6.9", "version": "6.6.9",
"resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.9.tgz", "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.9.tgz",
@@ -1221,6 +1308,16 @@
"url": "https://github.com/fb55/entities?sponsor=1" "url": "https://github.com/fb55/entities?sponsor=1"
} }
}, },
"node_modules/escalade": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/estree-walker": { "node_modules/estree-walker": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
@@ -1264,6 +1361,29 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0" "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
} }
}, },
"node_modules/get-caller-file": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
"dev": true,
"license": "ISC",
"engines": {
"node": "6.* || 8.* || >= 10.*"
}
},
"node_modules/get-east-asian-width": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz",
"integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/graceful-fs": { "node_modules/graceful-fs": {
"version": "4.2.11", "version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
@@ -1877,12 +1997,35 @@
"@rolldown/binding-win32-x64-msvc": "1.0.3" "@rolldown/binding-win32-x64-msvc": "1.0.3"
} }
}, },
"node_modules/rxjs": {
"version": "7.8.2",
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
"integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.1.0"
}
},
"node_modules/scule": { "node_modules/scule": {
"version": "1.3.0", "version": "1.3.0",
"resolved": "https://registry.npmjs.org/scule/-/scule-1.3.0.tgz", "resolved": "https://registry.npmjs.org/scule/-/scule-1.3.0.tgz",
"integrity": "sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==", "integrity": "sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/shell-quote": {
"version": "1.8.4",
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.4.tgz",
"integrity": "sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/socket.io": { "node_modules/socket.io": {
"version": "4.8.3", "version": "4.8.3",
"resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.8.3.tgz", "resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.8.3.tgz",
@@ -1957,6 +2100,40 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/string-width": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz",
"integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"emoji-regex": "^10.3.0",
"get-east-asian-width": "^1.0.0",
"strip-ansi": "^7.1.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/strip-ansi": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz",
"integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^6.2.2"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/strip-ansi?sponsor=1"
}
},
"node_modules/superjson": { "node_modules/superjson": {
"version": "2.2.6", "version": "2.2.6",
"resolved": "https://registry.npmjs.org/superjson/-/superjson-2.2.6.tgz", "resolved": "https://registry.npmjs.org/superjson/-/superjson-2.2.6.tgz",
@@ -1969,6 +2146,19 @@
"node": ">=16" "node": ">=16"
} }
}, },
"node_modules/supports-color": {
"version": "10.2.2",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz",
"integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/chalk/supports-color?sponsor=1"
}
},
"node_modules/tailwindcss": { "node_modules/tailwindcss": {
"version": "4.3.1", "version": "4.3.1",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.1.tgz", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.1.tgz",
@@ -2006,12 +2196,22 @@
"url": "https://github.com/sponsors/SuperchupuDev" "url": "https://github.com/sponsors/SuperchupuDev"
} }
}, },
"node_modules/tree-kill": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz",
"integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==",
"dev": true,
"license": "MIT",
"bin": {
"tree-kill": "cli.js"
}
},
"node_modules/tslib": { "node_modules/tslib": {
"version": "2.8.1", "version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD", "devOptional": true,
"optional": true "license": "0BSD"
}, },
"node_modules/ufo": { "node_modules/ufo": {
"version": "1.6.4", "version": "1.6.4",
@@ -2251,6 +2451,24 @@
"integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==", "integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/wrap-ansi": {
"version": "9.0.2",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz",
"integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^6.2.1",
"string-width": "^7.0.0",
"strip-ansi": "^7.1.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
"node_modules/ws": { "node_modules/ws": {
"version": "8.21.0", "version": "8.21.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
@@ -2280,6 +2498,16 @@
"node": ">=0.4.0" "node": ">=0.4.0"
} }
}, },
"node_modules/y18n": {
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
"integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=10"
}
},
"node_modules/yaml": { "node_modules/yaml": {
"version": "2.9.0", "version": "2.9.0",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz",
@@ -2294,6 +2522,34 @@
"funding": { "funding": {
"url": "https://github.com/sponsors/eemeli" "url": "https://github.com/sponsors/eemeli"
} }
},
"node_modules/yargs": {
"version": "18.0.0",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz",
"integrity": "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==",
"dev": true,
"license": "MIT",
"dependencies": {
"cliui": "^9.0.1",
"escalade": "^3.1.1",
"get-caller-file": "^2.0.5",
"string-width": "^7.2.0",
"y18n": "^5.0.5",
"yargs-parser": "^22.0.0"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=23"
}
},
"node_modules/yargs-parser": {
"version": "22.0.0",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz",
"integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==",
"dev": true,
"license": "ISC",
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=23"
}
} }
} }
} }

View File

@@ -5,7 +5,8 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"dev:server": "node --watch server/index.js", "dev:server": "node server/index.js",
"dev:all": "concurrently --kill-others --names SOCKET,VITE --prefix-colors magenta,cyan \"npm run dev:server\" \"npm run dev\"",
"start:server": "node server/index.js", "start:server": "node server/index.js",
"test:server": "node server/integration-test.js", "test:server": "node server/integration-test.js",
"build": "vite build", "build": "vite build",
@@ -21,6 +22,7 @@
"devDependencies": { "devDependencies": {
"@tailwindcss/vite": "^4.3.1", "@tailwindcss/vite": "^4.3.1",
"@vitejs/plugin-vue": "^6.0.6", "@vitejs/plugin-vue": "^6.0.6",
"concurrently": "^10.0.3",
"tailwindcss": "^4.3.1", "tailwindcss": "^4.3.1",
"vite": "^8.0.12" "vite": "^8.0.12"
} }

View File

@@ -1,5 +1,5 @@
import { createServer } from 'node:http' import { createServer } from 'node:http'
import { randomInt } from 'node:crypto' import { randomInt, randomUUID } from 'node:crypto'
import { Server } from 'socket.io' import { Server } from 'socket.io'
import { validateDeckRegistrationUrl } from '../src/services/deckRegistration.js' import { validateDeckRegistrationUrl } from '../src/services/deckRegistration.js'
@@ -7,9 +7,13 @@ const SERVER_PORT = Number(process.env.SOCKET_SERVER_PORT || 31873)
const ROOM_CODE_CHARACTERS = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789' const ROOM_CODE_CHARACTERS = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'
const ROOM_CODE_LENGTH = 6 const ROOM_CODE_LENGTH = 6
const MAXIMUM_ROOM_PLAYERS = 2 const MAXIMUM_ROOM_PLAYERS = 2
const MAXIMUM_GAME_CARDS = 100
const PUBLIC_CARD_ZONES = new Set(['field', 'graveyard', 'life', 'reveal'])
const PLAYER_RECONNECT_GRACE_PERIOD_MILLISECONDS = 60_000
const rooms = new Map() const rooms = new Map()
const socketRoomCodes = new Map() const socketRoomCodes = new Map()
const socketPlayerIds = new Map()
/** /**
* 사람이 읽기 쉬운 비공개 방 코드를 생성한다. * 사람이 읽기 쉬운 비공개 방 코드를 생성한다.
@@ -42,7 +46,7 @@ function createPublicRoomState(room) {
players: Array.from(room.players.values()).map((player) => ({ players: Array.from(room.players.values()).map((player) => ({
playerId: player.playerId, playerId: player.playerId,
seat: player.seat, seat: player.seat,
isConnected: true, isConnected: player.isConnected,
isReady: player.isReady, isReady: player.isReady,
hasRegisteredDeck: Boolean(player.deckRegistration?.isValid), hasRegisteredDeck: Boolean(player.deckRegistration?.isValid),
deckSeriesName: player.deckRegistration?.seriesName || '', deckSeriesName: player.deckRegistration?.seriesName || '',
@@ -64,6 +68,142 @@ function broadcastRoomState(socketServer, room) {
.emit('room:state', createPublicRoomState(room)) .emit('room:state', createPublicRoomState(room))
} }
/**
* 현재 게임 턴과 준비 상태를 참가자에게 전송한다.
*
* @param {Server} socketServer Socket.IO 서버
* @param {object} room 방 상태
* @returns {void}
*/
function broadcastGameState(socketServer, room) {
if (!room.gameState) return
socketServer.to(room.roomCode).emit('game:state', {
firstPlayerId: room.gameState.firstPlayerId,
currentTurnPlayerId: room.gameState.currentTurnPlayerId,
turnSequence: room.gameState.turnSequence,
setupConfirmedPlayerIds: Array.from(
room.gameState.setupConfirmedPlayerIds,
),
})
}
/**
* 클라이언트가 제출한 카드 상태를 공개 가능한 값으로 제한한다.
*
* @param {unknown} requestedCards 클라이언트 카드 상태
* @returns {object[]} 검증된 공개 카드 목록
*/
function sanitizePublicCardState(requestedCards) {
if (!Array.isArray(requestedCards)) return []
return requestedCards
.slice(0, MAXIMUM_GAME_CARDS)
.filter(
(card) =>
card &&
typeof card.instanceId === 'string' &&
typeof card.cardNumber === 'string' &&
typeof card.imageUrl === 'string' &&
PUBLIC_CARD_ZONES.has(card.zone),
)
.map((card) => ({
instanceId: card.instanceId.slice(0, 120),
cardNumber: card.cardNumber.slice(0, 120),
imageUrl: card.imageUrl.slice(0, 500),
zone: card.zone,
position: {
x: Math.min(Math.max(Number(card.position?.x) || 0.5, 0), 1),
y: Math.min(Math.max(Number(card.position?.y) || 0.5, 0), 1),
},
isFaceUp: card.zone === 'life' ? false : Boolean(card.isFaceUp),
isTapped: Boolean(card.isTapped),
isUntapLocked: Boolean(card.isUntapLocked),
stackOrder: Number.isFinite(Number(card.stackOrder))
? Number(card.stackOrder)
: 0,
}))
}
/**
* 카드 인스턴스 식별자 목록을 중복 없이 검증한다.
*
* @param {unknown} requestedInstanceIds 클라이언트가 제출한 식별자 목록
* @returns {string[] | null} 검증된 목록 또는 실패 시 null
*/
function sanitizeDeckInstanceIds(requestedInstanceIds) {
if (
!Array.isArray(requestedInstanceIds) ||
requestedInstanceIds.length > MAXIMUM_GAME_CARDS
) {
return null
}
const instanceIds = requestedInstanceIds.filter(
(instanceId) =>
typeof instanceId === 'string' &&
instanceId.length > 0 &&
instanceId.length <= 120,
)
if (
instanceIds.length !== requestedInstanceIds.length ||
new Set(instanceIds).size !== instanceIds.length
) {
return null
}
return instanceIds
}
/**
* 암호학적 난수로 카드 인스턴스 순서를 섞는다.
*
* @param {string[]} instanceIds 섞을 카드 식별자 목록
* @returns {string[]} 새로 섞인 목록
*/
function shuffleInstanceIds(instanceIds) {
const shuffledInstanceIds = [...instanceIds]
for (
let instanceIndex = shuffledInstanceIds.length - 1;
instanceIndex > 0;
instanceIndex -= 1
) {
const randomInstanceIndex = randomInt(instanceIndex + 1)
const heldInstanceId = shuffledInstanceIds[instanceIndex]
shuffledInstanceIds[instanceIndex] =
shuffledInstanceIds[randomInstanceIndex]
shuffledInstanceIds[randomInstanceIndex] = heldInstanceId
}
return shuffledInstanceIds
}
/**
* 상대 플레이어에게 제출자의 공개 카드와 영역별 장수를 전달한다.
*
* @param {Server} socketServer Socket.IO 서버
* @param {object} room 방 상태
* @param {object} player 상태를 제출한 플레이어
* @returns {void}
*/
function broadcastPlayerGameState(socketServer, room, player) {
socketServer.to(room.roomCode).except(player.socketId).emit(
'game:opponent-state',
{
playerId: player.playerId,
revision: player.publicStateRevision || 0,
cards: player.publicCards || [],
counts: player.gameCounts || {
deck: 0,
hand: 0,
life: 0,
},
},
)
}
/** /**
* 소켓이 현재 참가 중인 방과 플레이어 상태를 찾는다. * 소켓이 현재 참가 중인 방과 플레이어 상태를 찾는다.
* *
@@ -73,7 +213,8 @@ function broadcastRoomState(socketServer, room) {
function findSocketRoomContext(socketId) { function findSocketRoomContext(socketId) {
const roomCode = socketRoomCodes.get(socketId) const roomCode = socketRoomCodes.get(socketId)
const room = roomCode ? rooms.get(roomCode) : null const room = roomCode ? rooms.get(roomCode) : null
const player = room?.players.get(socketId) const playerId = socketPlayerIds.get(socketId)
const player = playerId ? room?.players.get(playerId) : null
return room && player ? { room, player } : null return room && player ? { room, player } : null
} }
@@ -89,14 +230,20 @@ function leaveCurrentRoom(socketServer, socket) {
const socketId = socket.id const socketId = socket.id
const roomCode = socketRoomCodes.get(socketId) const roomCode = socketRoomCodes.get(socketId)
const room = roomCode ? rooms.get(roomCode) : null const room = roomCode ? rooms.get(roomCode) : null
const playerId = socketPlayerIds.get(socketId)
socketRoomCodes.delete(socketId) socketRoomCodes.delete(socketId)
socketPlayerIds.delete(socketId)
if (roomCode) { if (roomCode) {
socket.leave(roomCode) socket.leave(roomCode)
} }
if (!room) return if (!room || !playerId) return
room.players.delete(socketId) const player = room.players.get(playerId)
if (player?.disconnectTimer) {
clearTimeout(player.disconnectTimer)
}
room.players.delete(playerId)
if (room.players.size === 0) { if (room.players.size === 0) {
rooms.delete(roomCode) rooms.delete(roomCode)
@@ -104,9 +251,71 @@ function leaveCurrentRoom(socketServer, socket) {
} }
room.hasStarted = false room.hasStarted = false
room.gameState = null
Array.from(room.players.values()).forEach((remainingPlayer) => {
remainingPlayer.isReady = false
})
broadcastRoomState(socketServer, room) broadcastRoomState(socketServer, room)
} }
/**
* 명시적인 방 나가기 요청 시 방 전체를 종료하고 참가자를 초기화한다.
*
* @param {Server} socketServer Socket.IO 서버
* @param {object} room 종료할 방
* @param {string} message 참가자에게 표시할 종료 사유
* @returns {void}
*/
function closeRoomForAllPlayers(socketServer, room, message) {
Array.from(room.players.values()).forEach((player) => {
if (player.disconnectTimer) {
clearTimeout(player.disconnectTimer)
}
if (player.socketId) {
const playerSocket = socketServer.sockets.sockets.get(player.socketId)
socketRoomCodes.delete(player.socketId)
socketPlayerIds.delete(player.socketId)
playerSocket?.leave(room.roomCode)
playerSocket?.emit('room:closed', { message })
}
})
rooms.delete(room.roomCode)
}
/**
* 연결이 끊긴 플레이어를 잠시 방에 보존해 재접속을 허용한다.
*
* @param {Server} socketServer Socket.IO 서버
* @param {import('socket.io').Socket} socket 연결이 끊긴 소켓
* @returns {void}
*/
function preserveDisconnectedPlayer(socketServer, socket) {
const roomContext = findSocketRoomContext(socket.id)
if (!roomContext) return
const { room, player } = roomContext
socketRoomCodes.delete(socket.id)
socketPlayerIds.delete(socket.id)
player.isConnected = false
player.socketId = null
broadcastRoomState(socketServer, room)
player.disconnectTimer = setTimeout(() => {
room.players.delete(player.playerId)
if (room.players.size === 0) {
rooms.delete(room.roomCode)
return
}
room.hasStarted = false
room.gameState = null
broadcastRoomState(socketServer, room)
}, PLAYER_RECONNECT_GRACE_PERIOD_MILLISECONDS)
}
/** /**
* 양쪽 플레이어가 준비되면 각자 자신의 덱 정보로 게임 시작을 알린다. * 양쪽 플레이어가 준비되면 각자 자신의 덱 정보로 게임 시작을 알린다.
* *
@@ -125,15 +334,30 @@ function startRoomGameWhenReady(socketServer, room) {
if (!canStartGame || room.hasStarted) return if (!canStartGame || room.hasStarted) return
room.hasStarted = true room.hasStarted = true
const firstPlayer = players[randomInt(players.length)]
room.gameState = {
firstPlayerId: firstPlayer.playerId,
currentTurnPlayerId: null,
turnSequence: 0,
setupConfirmedPlayerIds: new Set(),
}
broadcastRoomState(socketServer, room) broadcastRoomState(socketServer, room)
players.forEach((player) => { players.forEach((player) => {
socketServer.to(player.playerId).emit('game:started', { socketServer.to(player.socketId).emit('game:started', {
roomCode: room.roomCode, roomCode: room.roomCode,
seat: player.seat, seat: player.seat,
gameQuery: player.deckRegistration.gameQuery, gameQuery: player.deckRegistration.gameQuery,
gameState: {
firstPlayerId: room.gameState.firstPlayerId,
currentTurnPlayerId: null,
turnSequence: 0,
setupConfirmedPlayerIds: [],
},
}) })
}) })
broadcastGameState(socketServer, room)
} }
const httpServer = createServer((request, response) => { const httpServer = createServer((request, response) => {
@@ -156,26 +380,37 @@ socketServer.on('connection', (socket) => {
leaveCurrentRoom(socketServer, socket) leaveCurrentRoom(socketServer, socket)
const roomCode = createUniqueRoomCode() const roomCode = createUniqueRoomCode()
const playerId = randomUUID()
const resumeToken = randomUUID()
const room = { const room = {
roomCode, roomCode,
hasStarted: false, hasStarted: false,
players: new Map(), players: new Map(),
} }
const player = { const player = {
playerId: socket.id, playerId,
resumeToken,
socketId: socket.id,
seat: 'player-one', seat: 'player-one',
isConnected: true,
isReady: false, isReady: false,
deckRegistration: null, deckRegistration: null,
publicCards: [],
gameCounts: null,
publicStateRevision: 0,
privateDeckOrder: [],
} }
room.players.set(socket.id, player) room.players.set(playerId, player)
rooms.set(roomCode, room) rooms.set(roomCode, room)
socketRoomCodes.set(socket.id, roomCode) socketRoomCodes.set(socket.id, roomCode)
socketPlayerIds.set(socket.id, playerId)
socket.join(roomCode) socket.join(roomCode)
acknowledge?.({ acknowledge?.({
ok: true, ok: true,
playerId: socket.id, playerId,
resumeToken,
room: createPublicRoomState(room), room: createPublicRoomState(room),
}) })
broadcastRoomState(socketServer, room) broadcastRoomState(socketServer, room)
@@ -205,25 +440,102 @@ socketServer.on('connection', (socket) => {
leaveCurrentRoom(socketServer, socket) leaveCurrentRoom(socketServer, socket)
const playerId = randomUUID()
const resumeToken = randomUUID()
const player = { const player = {
playerId: socket.id, playerId,
resumeToken,
socketId: socket.id,
seat: 'player-two', seat: 'player-two',
isConnected: true,
isReady: false, isReady: false,
deckRegistration: null, deckRegistration: null,
publicCards: [],
gameCounts: null,
publicStateRevision: 0,
privateDeckOrder: [],
} }
room.players.set(socket.id, player) room.players.set(playerId, player)
socketRoomCodes.set(socket.id, roomCode) socketRoomCodes.set(socket.id, roomCode)
socketPlayerIds.set(socket.id, playerId)
socket.join(roomCode) socket.join(roomCode)
acknowledge?.({ acknowledge?.({
ok: true, ok: true,
playerId: socket.id, playerId,
resumeToken,
room: createPublicRoomState(room), room: createPublicRoomState(room),
}) })
broadcastRoomState(socketServer, room) broadcastRoomState(socketServer, room)
}) })
socket.on('room:resume', (resumeRequest, acknowledge) => {
const roomCode =
typeof resumeRequest?.roomCode === 'string'
? resumeRequest.roomCode.trim().toUpperCase()
: ''
const room = rooms.get(roomCode)
const player = room?.players.get(resumeRequest?.playerId)
if (
!room ||
!player ||
player.resumeToken !== resumeRequest?.resumeToken
) {
acknowledge?.({ ok: false, error: '복구할 게임 세션이 없습니다.' })
return
}
if (player.disconnectTimer) {
clearTimeout(player.disconnectTimer)
player.disconnectTimer = null
}
player.socketId = socket.id
player.isConnected = true
socketRoomCodes.set(socket.id, roomCode)
socketPlayerIds.set(socket.id, player.playerId)
socket.join(roomCode)
acknowledge?.({
ok: true,
playerId: player.playerId,
room: createPublicRoomState(room),
gameState: room.gameState
? {
firstPlayerId: room.gameState.firstPlayerId,
currentTurnPlayerId: room.gameState.currentTurnPlayerId,
turnSequence: room.gameState.turnSequence,
setupConfirmedPlayerIds: Array.from(
room.gameState.setupConfirmedPlayerIds,
),
}
: null,
gameStartPayload:
room.hasStarted && player.deckRegistration?.gameQuery
? {
roomCode: room.roomCode,
seat: player.seat,
gameQuery: player.deckRegistration.gameQuery,
gameState: room.gameState
? {
firstPlayerId: room.gameState.firstPlayerId,
currentTurnPlayerId:
room.gameState.currentTurnPlayerId,
turnSequence: room.gameState.turnSequence,
setupConfirmedPlayerIds: Array.from(
room.gameState.setupConfirmedPlayerIds,
),
}
: null,
}
: null,
})
broadcastRoomState(socketServer, room)
broadcastGameState(socketServer, room)
})
socket.on('player:register-deck', (deckUrlText, acknowledge) => { socket.on('player:register-deck', (deckUrlText, acknowledge) => {
const roomContext = findSocketRoomContext(socket.id) const roomContext = findSocketRoomContext(socket.id)
@@ -267,12 +579,217 @@ socketServer.on('connection', (socket) => {
startRoomGameWhenReady(socketServer, roomContext.room) startRoomGameWhenReady(socketServer, roomContext.room)
}) })
socket.on('room:leave', () => { socket.on('game:confirm-setup', (acknowledge) => {
leaveCurrentRoom(socketServer, socket) const roomContext = findSocketRoomContext(socket.id)
const gameState = roomContext?.room.gameState
if (!roomContext || !roomContext.room.hasStarted || !gameState) {
acknowledge?.({ ok: false, error: '시작된 게임을 찾지 못했습니다.' })
return
}
gameState.setupConfirmedPlayerIds.add(roomContext.player.playerId)
if (
gameState.setupConfirmedPlayerIds.size === MAXIMUM_ROOM_PLAYERS &&
!gameState.currentTurnPlayerId
) {
gameState.currentTurnPlayerId = gameState.firstPlayerId
gameState.turnSequence = 1
}
broadcastGameState(socketServer, roomContext.room)
acknowledge?.({ ok: true })
})
socket.on('game:publish-player-state', (requestedState, acknowledge) => {
const roomContext = findSocketRoomContext(socket.id)
if (!roomContext || !roomContext.room.hasStarted) {
acknowledge?.({ ok: false, error: '시작된 게임을 찾지 못했습니다.' })
return
}
const requestedRevision = Number(requestedState?.revision) || 0
if (requestedRevision <= roomContext.player.publicStateRevision) {
acknowledge?.({
ok: true,
ignored: true,
revision: roomContext.player.publicStateRevision,
})
return
}
roomContext.player.publicStateRevision = requestedRevision
roomContext.player.publicCards = sanitizePublicCardState(
requestedState?.cards,
)
roomContext.player.gameCounts = {
deck: Math.max(0, Number(requestedState?.counts?.deck) || 0),
hand: Math.max(0, Number(requestedState?.counts?.hand) || 0),
life: Math.max(0, Number(requestedState?.counts?.life) || 0),
}
acknowledge?.({ ok: true, revision: requestedRevision })
broadcastPlayerGameState(
socketServer,
roomContext.room,
roomContext.player,
)
})
socket.on('game:request-opponent-state', () => {
const roomContext = findSocketRoomContext(socket.id)
if (!roomContext || !roomContext.room.hasStarted) return
Array.from(roomContext.room.players.values())
.filter((player) => player.playerId !== roomContext.player.playerId)
.forEach((player) => {
socket.emit('game:opponent-state', {
playerId: player.playerId,
revision: player.publicStateRevision || 0,
cards: player.publicCards || [],
counts: player.gameCounts || {
deck: 0,
hand: 0,
life: 0,
},
})
})
})
socket.on('game:shuffle-private-deck', (requestedInstanceIds, acknowledge) => {
const roomContext = findSocketRoomContext(socket.id)
const instanceIds = sanitizeDeckInstanceIds(requestedInstanceIds)
if (!roomContext || !roomContext.room.hasStarted || !instanceIds) {
acknowledge?.({ ok: false, error: '덱 순서를 갱신하지 못했습니다.' })
return
}
roomContext.player.privateDeckOrder = shuffleInstanceIds(instanceIds)
acknowledge?.({
ok: true,
instanceIds: roomContext.player.privateDeckOrder,
})
})
socket.on('game:draw-private-deck', (requestedCount, acknowledge) => {
const roomContext = findSocketRoomContext(socket.id)
const drawCount = Math.min(
Math.max(Number(requestedCount) || 1, 1),
10,
)
if (!roomContext || !roomContext.room.hasStarted) {
acknowledge?.({ ok: false, error: '드로우할 게임을 찾지 못했습니다.' })
return
}
const drawnInstanceIds = []
while (
drawnInstanceIds.length < drawCount &&
roomContext.player.privateDeckOrder.length > 0
) {
drawnInstanceIds.push(roomContext.player.privateDeckOrder.pop())
}
acknowledge?.({ ok: true, instanceIds: drawnInstanceIds })
})
socket.on('game:place-private-deck-card', (placementRequest, acknowledge) => {
const roomContext = findSocketRoomContext(socket.id)
const instanceId =
typeof placementRequest?.instanceId === 'string'
? placementRequest.instanceId
: ''
const placement =
placementRequest?.placement === 'bottom' ? 'bottom' : 'top'
if (!roomContext || !roomContext.room.hasStarted || !instanceId) {
acknowledge?.({ ok: false, error: '덱 카드를 배치하지 못했습니다.' })
return
}
roomContext.player.privateDeckOrder =
roomContext.player.privateDeckOrder.filter(
(deckInstanceId) => deckInstanceId !== instanceId,
)
if (placement === 'bottom') {
roomContext.player.privateDeckOrder.unshift(instanceId)
} else {
roomContext.player.privateDeckOrder.push(instanceId)
}
acknowledge?.({ ok: true })
})
socket.on('game:remove-private-deck-card', (instanceId, acknowledge) => {
const roomContext = findSocketRoomContext(socket.id)
if (
!roomContext ||
!roomContext.room.hasStarted ||
typeof instanceId !== 'string'
) {
acknowledge?.({ ok: false, error: '덱 카드를 제거하지 못했습니다.' })
return
}
roomContext.player.privateDeckOrder =
roomContext.player.privateDeckOrder.filter(
(deckInstanceId) => deckInstanceId !== instanceId,
)
acknowledge?.({ ok: true })
})
socket.on('game:end-turn', (acknowledge) => {
const roomContext = findSocketRoomContext(socket.id)
const gameState = roomContext?.room.gameState
if (!roomContext || !roomContext.room.hasStarted || !gameState) {
acknowledge?.({ ok: false, error: '시작된 게임을 찾지 못했습니다.' })
return
}
if (gameState.currentTurnPlayerId !== roomContext.player.playerId) {
acknowledge?.({ ok: false, error: '현재 자신의 턴이 아닙니다.' })
return
}
const nextPlayer = Array.from(roomContext.room.players.values()).find(
(player) => player.playerId !== roomContext.player.playerId,
)
if (!nextPlayer) {
acknowledge?.({ ok: false, error: '상대 플레이어가 연결되어 있지 않습니다.' })
return
}
gameState.currentTurnPlayerId = nextPlayer.playerId
gameState.turnSequence += 1
acknowledge?.({ ok: true })
broadcastGameState(socketServer, roomContext.room)
})
socket.on('room:leave', (acknowledge) => {
const roomContext = findSocketRoomContext(socket.id)
if (!roomContext) {
acknowledge?.({ ok: true })
return
}
closeRoomForAllPlayers(
socketServer,
roomContext.room,
'한 플레이어가 방을 나가 방이 종료되었습니다.',
)
acknowledge?.({ ok: true })
}) })
socket.on('disconnect', () => { socket.on('disconnect', () => {
leaveCurrentRoom(socketServer, socket) preserveDisconnectedPlayer(socketServer, socket)
}) })
}) })

View File

@@ -76,6 +76,19 @@ function waitForGameStart(socket) {
}) })
} }
/**
* 지정한 Socket.IO 이벤트를 한 번 기다린다.
*
* @param {import('socket.io-client').Socket} socket 이벤트를 기다릴 소켓
* @param {string} eventName 기다릴 이벤트 이름
* @returns {Promise<object>} 수신한 이벤트 데이터
*/
function waitForSocketEvent(socket, eventName) {
return new Promise((resolve) => {
socket.once(eventName, resolve)
})
}
/** /**
* 두 클라이언트의 방 생성부터 게임 시작까지 검증한다. * 두 클라이언트의 방 생성부터 게임 시작까지 검증한다.
* *
@@ -90,6 +103,7 @@ async function runIntegrationTest() {
path: '/socket.io', path: '/socket.io',
transports: ['websocket', 'polling'], transports: ['websocket', 'polling'],
}) })
let resumedSecondPlayerSocket = null
try { try {
await Promise.all([ await Promise.all([
@@ -149,11 +163,191 @@ async function runIntegrationTest() {
assert.equal(secondStartPayload.seat, 'player-two') assert.equal(secondStartPayload.seat, 'player-two')
assert.equal(firstStartPayload.roomCode, secondStartPayload.roomCode) assert.equal(firstStartPayload.roomCode, secondStartPayload.roomCode)
assert.match(firstStartPayload.gameQuery, /series=ua01/) assert.match(firstStartPayload.gameQuery, /series=ua01/)
assert.ok(
[createRoomResponse.playerId, joinRoomResponse.playerId].includes(
firstStartPayload.gameState.firstPlayerId,
),
)
console.log('Socket.IO 2인 방 통합 테스트를 통과했습니다.') const privateDeckInstanceIds = Array.from(
{ length: 50 },
(_, cardIndex) => `private-card-${cardIndex + 1}`,
)
const shuffledDeckResponse = await emitWithAcknowledgement(
firstPlayerSocket,
'game:shuffle-private-deck',
privateDeckInstanceIds,
)
assert.equal(shuffledDeckResponse.ok, true)
assert.equal(shuffledDeckResponse.instanceIds.length, 50)
assert.equal(new Set(shuffledDeckResponse.instanceIds).size, 50)
const drawnDeckResponse = await emitWithAcknowledgement(
firstPlayerSocket,
'game:draw-private-deck',
2,
)
assert.deepEqual(drawnDeckResponse.instanceIds, [
shuffledDeckResponse.instanceIds[49],
shuffledDeckResponse.instanceIds[48],
])
const returnedCardInstanceId = drawnDeckResponse.instanceIds[0]
await emitWithAcknowledgement(
firstPlayerSocket,
'game:place-private-deck-card',
{
instanceId: returnedCardInstanceId,
placement: 'top',
},
)
const redrawnDeckResponse = await emitWithAcknowledgement(
firstPlayerSocket,
'game:draw-private-deck',
1,
)
assert.equal(redrawnDeckResponse.instanceIds[0], returnedCardInstanceId)
await emitWithAcknowledgement(
firstPlayerSocket,
'game:confirm-setup',
)
const startedTurnStatePromise = waitForSocketEvent(
firstPlayerSocket,
'game:state',
)
await emitWithAcknowledgement(
secondPlayerSocket,
'game:confirm-setup',
)
const startedTurnState = await startedTurnStatePromise
assert.equal(
startedTurnState.currentTurnPlayerId,
startedTurnState.firstPlayerId,
)
const opponentStatePromise = waitForSocketEvent(
secondPlayerSocket,
'game:opponent-state',
)
await emitWithAcknowledgement(
firstPlayerSocket,
'game:publish-player-state',
{
revision: 2,
cards: [
{
instanceId: 'published-card-1',
cardNumber: 'TEST-001',
imageUrl: '/card-assets/ua01/TEST-001.png',
zone: 'field',
position: { x: 0.4, y: 0.7 },
isFaceUp: true,
isTapped: false,
isUntapLocked: false,
stackOrder: 10,
},
{
instanceId: 'hidden-hand-card',
cardNumber: 'SECRET-001',
imageUrl: '/card-assets/ua01/SECRET-001.png',
zone: 'hand',
position: { x: 0.5, y: 0.9 },
isFaceUp: true,
isTapped: false,
isUntapLocked: false,
stackOrder: 11,
},
],
counts: {
deck: 35,
hand: 7,
life: 7,
},
},
)
const opponentState = await opponentStatePromise
assert.equal(opponentState.cards.length, 1)
assert.equal(opponentState.cards[0].instanceId, 'published-card-1')
assert.equal(opponentState.counts.hand, 7)
assert.equal(opponentState.revision, 2)
const staleStateResponse = await emitWithAcknowledgement(
firstPlayerSocket,
'game:publish-player-state',
{
revision: 1,
cards: [],
counts: {
deck: 0,
hand: 0,
life: 0,
},
},
)
assert.equal(staleStateResponse.ignored, true)
assert.equal(staleStateResponse.revision, 2)
const currentTurnSocket =
startedTurnState.currentTurnPlayerId === createRoomResponse.playerId
? firstPlayerSocket
: secondPlayerSocket
const nextTurnStatePromise = waitForSocketEvent(
firstPlayerSocket,
'game:state',
)
const endTurnResponse = await emitWithAcknowledgement(
currentTurnSocket,
'game:end-turn',
)
const nextTurnState = await nextTurnStatePromise
assert.equal(endTurnResponse.ok, true)
assert.notEqual(
nextTurnState.currentTurnPlayerId,
startedTurnState.currentTurnPlayerId,
)
secondPlayerSocket.disconnect()
resumedSecondPlayerSocket = io(SOCKET_SERVER_URL, {
path: '/socket.io',
transports: ['websocket', 'polling'],
})
await waitForConnection(resumedSecondPlayerSocket)
const resumeResponse = await emitWithAcknowledgement(
resumedSecondPlayerSocket,
'room:resume',
{
roomCode: joinRoomResponse.room.roomCode,
playerId: joinRoomResponse.playerId,
resumeToken: joinRoomResponse.resumeToken,
},
)
assert.equal(resumeResponse.ok, true)
assert.equal(resumeResponse.playerId, joinRoomResponse.playerId)
assert.equal(resumeResponse.room.hasStarted, true)
assert.equal(
resumeResponse.gameStartPayload.roomCode,
joinRoomResponse.room.roomCode,
)
assert.match(resumeResponse.gameStartPayload.gameQuery, /series=ua01/)
const roomClosedPromise = waitForSocketEvent(
firstPlayerSocket,
'room:closed',
)
const leaveRoomResponse = await emitWithAcknowledgement(
resumedSecondPlayerSocket,
'room:leave',
)
const roomClosedInformation = await roomClosedPromise
assert.equal(leaveRoomResponse.ok, true)
assert.match(roomClosedInformation.message, /방이 종료/)
console.log('Socket.IO 2인 게임 통합 테스트를 통과했습니다.')
} finally { } finally {
firstPlayerSocket.disconnect() firstPlayerSocket.disconnect()
secondPlayerSocket.disconnect() secondPlayerSocket.disconnect()
resumedSecondPlayerSocket?.disconnect()
} }
} }

View File

@@ -14,6 +14,15 @@ const props = defineProps({
type: Boolean, type: Boolean,
default: false, default: false,
}, },
isInteractive: {
type: Boolean,
default: true,
},
displayMode: {
type: String,
default: 'board',
validator: (displayMode) => ['board', 'hand'].includes(displayMode),
},
}) })
const emit = defineEmits([ const emit = defineEmits([
@@ -24,12 +33,19 @@ const emit = defineEmits([
]) ])
const hasImageError = ref(false) const hasImageError = ref(false)
const positionStyle = computed(() => ({ const positionStyle = computed(() =>
left: `${props.card.position.x * 100}%`, props.displayMode === 'hand'
top: `${props.card.position.y * 100}%`, ? {
zIndex: props.card.stackOrder, zIndex: props.card.stackOrder,
transform: `translate(-50%, -50%) rotate(${props.card.isTapped ? 90 : 0}deg)`, transform: `rotate(${props.card.isTapped ? 90 : 0}deg)`,
})) }
: {
left: `${props.card.position.x * 100}%`,
top: `${props.card.position.y * 100}%`,
zIndex: props.card.stackOrder,
transform: `translate(-50%, -50%) rotate(${props.card.isTapped ? 90 : 0}deg)`,
},
)
/** /**
* 카드 포인터 입력을 플레이 보드 드래그 처리기로 전달한다. * 카드 포인터 입력을 플레이 보드 드래그 처리기로 전달한다.
@@ -38,7 +54,7 @@ const positionStyle = computed(() => ({
* @returns {void} * @returns {void}
*/ */
function startCardDrag(pointerEvent) { function startCardDrag(pointerEvent) {
if (pointerEvent.button !== 0) return if (!props.isInteractive || pointerEvent.button !== 0) return
emit('selected', props.card.instanceId) emit('selected', props.card.instanceId)
emit('drag-started', { emit('drag-started', {
@@ -63,6 +79,7 @@ function handleImageError() {
* @returns {void} * @returns {void}
*/ */
function handleDoubleClick(mouseEvent) { function handleDoubleClick(mouseEvent) {
if (!props.isInteractive) return
mouseEvent.preventDefault() mouseEvent.preventDefault()
emit('double-clicked', props.card.instanceId) emit('double-clicked', props.card.instanceId)
} }
@@ -74,6 +91,7 @@ function handleDoubleClick(mouseEvent) {
* @returns {void} * @returns {void}
*/ */
function requestContextMenu(mouseEvent) { function requestContextMenu(mouseEvent) {
if (!props.isInteractive) return
mouseEvent.preventDefault() mouseEvent.preventDefault()
emit('selected', props.card.instanceId) emit('selected', props.card.instanceId)
emit('context-menu-requested', { emit('context-menu-requested', {
@@ -87,14 +105,19 @@ function requestContextMenu(mouseEvent) {
<template> <template>
<button <button
type="button" type="button"
class="absolute aspect-[0.716] w-[5.75%] touch-none select-none rounded-[5%] text-left outline-none transition-[transform,box-shadow] duration-150 focus-visible:ring-2 focus-visible:ring-cyan-300" class="aspect-[0.716] touch-none select-none rounded-[5%] text-left outline-none transition-[transform,box-shadow] duration-150 focus-visible:ring-2 focus-visible:ring-cyan-300"
:class=" :class="[
isDragging displayMode === 'hand'
? 'relative h-[11vh] min-h-16 max-h-28 shrink-0'
: 'absolute w-[5.75%]',
!isInteractive
? 'cursor-default shadow-lg shadow-black/50'
: isDragging
? 'cursor-grabbing shadow-[0_0_0_2px_rgba(103,232,249,0.95),0_10px_28px_rgba(0,0,0,0.7)]' ? 'cursor-grabbing shadow-[0_0_0_2px_rgba(103,232,249,0.95),0_10px_28px_rgba(0,0,0,0.7)]'
: isSelected : isSelected
? 'cursor-grab shadow-[0_0_0_2px_rgba(253,224,71,0.95),0_10px_28px_rgba(0,0,0,0.65)]' ? 'cursor-grab shadow-[0_0_0_2px_rgba(253,224,71,0.95),0_10px_28px_rgba(0,0,0,0.65)]'
: 'cursor-grab shadow-lg shadow-black/50 hover:shadow-xl' : 'cursor-grab shadow-lg shadow-black/50 hover:shadow-xl',
" ]"
:style="positionStyle" :style="positionStyle"
:aria-label="`${card.cardNumber} 카드 이동`" :aria-label="`${card.cardNumber} 카드 이동`"
@pointerdown="startCardDrag" @pointerdown="startCardDrag"

View File

@@ -5,10 +5,12 @@ import {
onMounted, onMounted,
reactive, reactive,
ref, ref,
watch,
} from 'vue' } from 'vue'
import GameCard from '../cards/GameCard.vue' import GameCard from '../cards/GameCard.vue'
import standardPlayboardImage from '../../assets/playboards/union-arena-standard.png' import standardPlayboardImage from '../../assets/playboards/union-arena-standard.png'
import { parseDeckQuery } from '../../services/deckQuery.js' import { parseDeckQuery } from '../../services/deckQuery.js'
import { useRoomStore } from '../../stores/room.js'
const PLAYBOARD_ASPECT_RATIO = 16 / 9 const PLAYBOARD_ASPECT_RATIO = 16 / 9
const STANDARD_CARD_ASPECT_RATIO = 0.716 const STANDARD_CARD_ASPECT_RATIO = 0.716
@@ -20,6 +22,17 @@ const LOCAL_HAND_MAXIMUM_WIDTH = 0.34
const LOCAL_REVEAL_CENTER_POSITION = Object.freeze({ x: 0.5, y: 0.53 }) const LOCAL_REVEAL_CENTER_POSITION = Object.freeze({ x: 0.5, y: 0.53 })
const LOCAL_REVEAL_MAXIMUM_WIDTH = 0.42 const LOCAL_REVEAL_MAXIMUM_WIDTH = 0.42
const LOCAL_LIFE_POSITION = Object.freeze({ x: 0.315, y: 0.69 }) const LOCAL_LIFE_POSITION = Object.freeze({ x: 0.315, y: 0.69 })
const PUBLIC_CARD_ZONES = new Set(['field', 'graveyard', 'life', 'reveal'])
const roomStore = useRoomStore()
const gameSearchParameters = new URLSearchParams(window.location.search)
const requestedRoomCode = gameSearchParameters.get('room') || ''
const isOnlineGame = computed(
() =>
Boolean(requestedRoomCode) &&
roomStore.roomCode === requestedRoomCode &&
Boolean(roomStore.playerId),
)
const isCardGuideVisible = ref(false) const isCardGuideVisible = ref(false)
const gameScreenElement = ref(null) const gameScreenElement = ref(null)
@@ -98,9 +111,13 @@ const isInitialHandConfirmed = ref(
!deckQueryResult.hasDeckQuery || gameCards.length < 14, !deckQueryResult.hasDeckQuery || gameCards.length < 14,
) )
const hasUsedMulligan = ref(false) const hasUsedMulligan = ref(false)
const isFirstPlayer = ref(true) const localFirstPlayerChoice = ref(true)
const turnNumber = ref(0) const turnNumber = ref(0)
const availableActionPoints = ref(0) const availableActionPoints = ref(0)
const lastAppliedTurnSequence = ref(0)
const hasRestoredLocalSnapshot = ref(false)
const didRestoreLocalSnapshot = ref(false)
const publicStateRevision = ref(0)
const deckLoadErrors = deckQueryResult.errors const deckLoadErrors = deckQueryResult.errors
const deckLoadStatusLabel = deckQueryResult.hasDeckQuery const deckLoadStatusLabel = deckQueryResult.hasDeckQuery
? `덱 ${gameCards.length}장 불러옴` ? `덱 ${gameCards.length}장 불러옴`
@@ -124,6 +141,34 @@ const activeDragState = {
const selectedGameCard = computed(() => const selectedGameCard = computed(() =>
findGameCard(selectedCardInstanceId.value), findGameCard(selectedCardInstanceId.value),
) )
const isFirstPlayer = computed(() =>
isOnlineGame.value
? roomStore.gameState?.firstPlayerId === roomStore.playerId
: localFirstPlayerChoice.value,
)
const isMyTurn = computed(
() =>
!isOnlineGame.value ||
roomStore.gameState?.currentTurnPlayerId === roomStore.playerId,
)
const turnStatusLabel = computed(() => {
if (!isOnlineGame.value) return '로컬 게임'
if (!roomStore.gameState?.currentTurnPlayerId) {
return '양쪽 손패 확정 대기'
}
return isMyTurn.value ? '내 차례' : '상대 차례'
})
const opponentGameCards = computed(() =>
(roomStore.opponentGameState?.cards || []).map((gameCard) => ({
...gameCard,
instanceId: `opponent-${gameCard.instanceId}`,
ownerId: 'opponent-player',
position: {
x: 1 - gameCard.position.x,
y: 1 - gameCard.position.y,
},
})),
)
const selectedCardPositionLabel = computed(() => { const selectedCardPositionLabel = computed(() => {
if (!selectedGameCard.value) return '카드를 선택하세요' if (!selectedGameCard.value) return '카드를 선택하세요'
@@ -140,6 +185,14 @@ const localDeckCardCount = computed(
const localHandCardCount = computed( const localHandCardCount = computed(
() => gameCards.filter((gameCard) => gameCard.zone === 'hand').length, () => gameCards.filter((gameCard) => gameCard.zone === 'hand').length,
) )
const localHandCards = computed(() =>
gameCards
.filter((gameCard) => gameCard.zone === 'hand')
.sort((firstCard, secondCard) => firstCard.stackOrder - secondCard.stackOrder),
)
const boardGameCards = computed(() =>
gameCards.filter((gameCard) => gameCard.zone !== 'hand'),
)
const localRevealedCardCount = computed( const localRevealedCardCount = computed(
() => gameCards.filter((gameCard) => gameCard.zone === 'reveal').length, () => gameCards.filter((gameCard) => gameCard.zone === 'reveal').length,
) )
@@ -159,17 +212,27 @@ const playerInformationPanels = computed(() => [
positionClass: 'left-[1.4%] top-[8%]', positionClass: 'left-[1.4%] top-[8%]',
label: 'OPPONENT', label: 'OPPONENT',
displayName: '상대 플레이어', displayName: '상대 플레이어',
connectionLabel: '연결 대기', connectionLabel: isOnlineGame.value
lifeCount: 7, ? isMyTurn.value
deckCountLabel: '—', ? '대기 중'
handCountLabel: '—', : '진행 중'
: '연결 대기',
lifeCount: isOnlineGame.value
? roomStore.opponentGameState.counts.life
: 7,
deckCountLabel: isOnlineGame.value
? String(roomStore.opponentGameState.counts.deck)
: '—',
handCountLabel: isOnlineGame.value
? String(roomStore.opponentGameState.counts.hand)
: '—',
}, },
{ {
panelId: 'local-player', panelId: 'local-player',
positionClass: 'right-[1.4%] bottom-[8%]', positionClass: 'right-[1.4%] bottom-[8%]',
label: 'YOU', label: 'YOU',
displayName: '내 플레이어', displayName: '내 플레이어',
connectionLabel: '로컬 모드', connectionLabel: isOnlineGame.value ? turnStatusLabel.value : '로컬 모드',
lifeCount: deckQueryResult.hasDeckQuery ? localLifeCardCount.value : 7, lifeCount: deckQueryResult.hasDeckQuery ? localLifeCardCount.value : 7,
deckCountLabel: String(localDeckCardCount.value), deckCountLabel: String(localDeckCardCount.value),
handCountLabel: String(localHandCardCount.value), handCountLabel: String(localHandCardCount.value),
@@ -199,6 +262,113 @@ function findGameCard(cardInstanceId) {
return gameCards.find((card) => card.instanceId === cardInstanceId) return gameCards.find((card) => card.instanceId === cardInstanceId)
} }
/**
* 현재 온라인 게임의 개인 카드 상태 저장 키를 만든다.
*
* @returns {string | null} 플레이어별 로컬 저장 키
*/
function createLocalGameSnapshotKey() {
if (!requestedRoomCode || !roomStore.playerId) return null
return `playuniare-game-${requestedRoomCode}-${roomStore.playerId}`
}
/**
* 상대에게 공개하지 않는 내 덱·손패를 포함한 전체 상태를 브라우저에 저장한다.
*
* @returns {Promise<void>}
*/
function saveLocalGameSnapshot() {
const snapshotKey = createLocalGameSnapshotKey()
if (!snapshotKey) return
localStorage.setItem(
snapshotKey,
JSON.stringify({
cards: gameCards,
isInitialHandConfirmed: isInitialHandConfirmed.value,
hasUsedMulligan: hasUsedMulligan.value,
turnNumber: turnNumber.value,
availableActionPoints: availableActionPoints.value,
highestStackOrder: highestStackOrder.value,
lowestStackOrder: lowestStackOrder.value,
publicStateRevision: publicStateRevision.value,
}),
)
}
/**
* 새로고침 전에 저장한 내 전체 카드 상태를 복구한다.
*
* @returns {boolean} 복구 성공 여부
*/
function restoreLocalGameSnapshot() {
const snapshotKey = createLocalGameSnapshotKey()
if (!snapshotKey) return false
try {
const serializedSnapshot = localStorage.getItem(snapshotKey)
if (!serializedSnapshot) return false
const snapshot = JSON.parse(serializedSnapshot)
if (!Array.isArray(snapshot.cards) || snapshot.cards.length === 0) {
return false
}
gameCards.splice(0, gameCards.length, ...snapshot.cards)
isInitialHandConfirmed.value = Boolean(snapshot.isInitialHandConfirmed)
hasUsedMulligan.value = Boolean(snapshot.hasUsedMulligan)
turnNumber.value = Number(snapshot.turnNumber) || 0
availableActionPoints.value =
Number(snapshot.availableActionPoints) || 0
highestStackOrder.value =
Number(snapshot.highestStackOrder) ||
Math.max(20, ...gameCards.map((gameCard) => gameCard.stackOrder))
lowestStackOrder.value =
Number(snapshot.lowestStackOrder) ||
Math.min(0, ...gameCards.map((gameCard) => gameCard.stackOrder))
publicStateRevision.value = Number(snapshot.publicStateRevision) || 0
return true
} catch {
return false
}
}
/**
* 상대에게 공개해도 되는 내 카드와 비공개 영역 장수를 서버에 전달한다.
*
* @returns {void}
*/
function publishLocalGameState() {
if (!isOnlineGame.value) return
saveLocalGameSnapshot()
const publicCards = gameCards
.filter((gameCard) => PUBLIC_CARD_ZONES.has(gameCard.zone))
.map((gameCard) => ({
instanceId: gameCard.instanceId,
cardNumber: gameCard.cardNumber,
imageUrl: gameCard.imageUrl,
zone: gameCard.zone,
position: { ...gameCard.position },
isFaceUp: gameCard.zone === 'life' ? false : gameCard.isFaceUp,
isTapped: gameCard.isTapped,
isUntapLocked: gameCard.isUntapLocked,
stackOrder: gameCard.stackOrder,
}))
publicStateRevision.value += 1
void roomStore.publishPlayerGameState({
revision: publicStateRevision.value,
cards: publicCards,
counts: {
deck: localDeckCardCount.value,
hand: localHandCardCount.value,
life: localLifeCardCount.value,
},
})
}
/** /**
* 조작 대상으로 사용할 카드를 선택한다. * 조작 대상으로 사용할 카드를 선택한다.
* *
@@ -220,6 +390,7 @@ function flipGameCard(cardInstanceId) {
if (!gameCard) return if (!gameCard) return
gameCard.isFaceUp = !gameCard.isFaceUp gameCard.isFaceUp = !gameCard.isFaceUp
publishLocalGameState()
} }
/** /**
@@ -243,6 +414,7 @@ function toggleGameCardTap(cardInstanceId) {
if (!gameCard) return if (!gameCard) return
gameCard.isTapped = !gameCard.isTapped gameCard.isTapped = !gameCard.isTapped
publishLocalGameState()
} }
/** /**
@@ -266,6 +438,7 @@ function bringSelectedCardToFront() {
highestStackOrder.value += 1 highestStackOrder.value += 1
selectedCard.stackOrder = highestStackOrder.value selectedCard.stackOrder = highestStackOrder.value
publishLocalGameState()
closeCardContextMenu() closeCardContextMenu()
} }
@@ -415,6 +588,19 @@ function moveGameCardToZone(
if (previousZone === 'life' || destinationZone === 'life') { if (previousZone === 'life' || destinationZone === 'life') {
arrangeLifeCards() arrangeLifeCards()
} }
if (isOnlineGame.value) {
if (destinationZone === 'deck') {
void roomStore.placePrivateDeckCard(
gameCard.instanceId,
deckPlacement,
)
} else if (previousZone === 'deck') {
void roomStore.removePrivateDeckCard(gameCard.instanceId)
}
}
publishLocalGameState()
} }
/** /**
@@ -436,12 +622,22 @@ function moveSelectedCardToZone(destinationZone, deckPlacement = 'top') {
/** /**
* 내 덱 맨 위 카드를 손패로 한 장 드로우한다. * 내 덱 맨 위 카드를 손패로 한 장 드로우한다.
* *
* @returns {void} * @returns {Promise<void>}
*/ */
function drawTopDeckCard() { async function drawTopDeckCard() {
const topDeckCard = gameCards let topDeckCard
.filter((gameCard) => gameCard.zone === 'deck')
.sort((firstCard, secondCard) => secondCard.stackOrder - firstCard.stackOrder)[0] if (isOnlineGame.value) {
const drawnInstanceIds = await roomStore.drawPrivateDeckCards(1)
topDeckCard = findGameCard(drawnInstanceIds?.[0])
} else {
topDeckCard = gameCards
.filter((gameCard) => gameCard.zone === 'deck')
.sort(
(firstCard, secondCard) =>
secondCard.stackOrder - firstCard.stackOrder,
)[0]
}
if (!topDeckCard) return if (!topDeckCard) return
@@ -450,49 +646,92 @@ function drawTopDeckCard() {
} }
/** /**
* 내 덱 카드 순서를 무작위로 섞고 모두 뒷면으로 정리한다. * 서버 또는 로컬에서 정한 덱 순서를 카드 레이어에 반영한다.
* *
* @param {string[]} orderedInstanceIds 아래에서 위 순서의 카드 식별자
* @returns {void} * @returns {void}
*/ */
function shuffleLocalDeck() { function applyPrivateDeckOrder(orderedInstanceIds) {
const deckCards = gameCards.filter((gameCard) => gameCard.zone === 'deck') orderedInstanceIds.forEach((instanceId) => {
const deckCard = findGameCard(instanceId)
if (!deckCard || deckCard.zone !== 'deck') return
for (let cardIndex = deckCards.length - 1; cardIndex > 0; cardIndex -= 1) {
const randomCardIndex = Math.floor(Math.random() * (cardIndex + 1))
const heldCard = deckCards[cardIndex]
deckCards[cardIndex] = deckCards[randomCardIndex]
deckCards[randomCardIndex] = heldCard
}
deckCards.forEach((deckCard) => {
highestStackOrder.value += 1 highestStackOrder.value += 1
deckCard.stackOrder = highestStackOrder.value deckCard.stackOrder = highestStackOrder.value
deckCard.position = { ...LOCAL_DECK_POSITION } deckCard.position = { ...LOCAL_DECK_POSITION }
deckCard.isFaceUp = false deckCard.isFaceUp = false
deckCard.isTapped = false deckCard.isTapped = false
}) })
}
/**
* 내 덱 카드 순서를 무작위로 섞고 모두 뒷면으로 정리한다.
*
* @returns {Promise<void>}
*/
async function shuffleLocalDeck() {
const deckCards = gameCards.filter((gameCard) => gameCard.zone === 'deck')
let orderedInstanceIds
if (isOnlineGame.value) {
orderedInstanceIds = await roomStore.shufflePrivateDeck(
deckCards.map((deckCard) => deckCard.instanceId),
)
} else {
const shuffledDeckCards = [...deckCards]
for (
let cardIndex = shuffledDeckCards.length - 1;
cardIndex > 0;
cardIndex -= 1
) {
const randomCardIndex = Math.floor(Math.random() * (cardIndex + 1))
const heldCard = shuffledDeckCards[cardIndex]
shuffledDeckCards[cardIndex] = shuffledDeckCards[randomCardIndex]
shuffledDeckCards[randomCardIndex] = heldCard
}
orderedInstanceIds = shuffledDeckCards.map(
(deckCard) => deckCard.instanceId,
)
}
if (!orderedInstanceIds) return
applyPrivateDeckOrder(orderedInstanceIds)
selectedCardInstanceId.value = null selectedCardInstanceId.value = null
closeCardContextMenu() closeCardContextMenu()
publishLocalGameState()
} }
/** /**
* 덱 위에서 지정된 수만큼 공개 영역으로 이동한다. * 덱 위에서 지정된 수만큼 공개 영역으로 이동한다.
* *
* @returns {void} * @returns {Promise<void>}
*/ */
function revealTopDeckCards() { async function revealTopDeckCards() {
const requestedCardCount = clampNumber( const requestedCardCount = clampNumber(
Number(revealRequestCount.value) || 1, Number(revealRequestCount.value) || 1,
1, 1,
10, 10,
) )
const topDeckCards = gameCards let topDeckCards
.filter((gameCard) => gameCard.zone === 'deck')
.sort((firstCard, secondCard) => secondCard.stackOrder - firstCard.stackOrder)
.slice(0, requestedCardCount)
topDeckCards.reverse().forEach((deckCard) => { if (isOnlineGame.value) {
const revealedInstanceIds =
await roomStore.drawPrivateDeckCards(requestedCardCount)
topDeckCards = (revealedInstanceIds || [])
.map((instanceId) => findGameCard(instanceId))
.filter(Boolean)
} else {
topDeckCards = gameCards
.filter((gameCard) => gameCard.zone === 'deck')
.sort(
(firstCard, secondCard) =>
secondCard.stackOrder - firstCard.stackOrder,
)
.slice(0, requestedCardCount)
}
;[...topDeckCards].reverse().forEach((deckCard) => {
deckCard.zone = 'reveal' deckCard.zone = 'reveal'
deckCard.isFaceUp = true deckCard.isFaceUp = true
deckCard.isTapped = false deckCard.isTapped = false
@@ -504,20 +743,21 @@ function revealTopDeckCards() {
selectedCardInstanceId.value = selectedCardInstanceId.value =
topDeckCards[topDeckCards.length - 1]?.instanceId || null topDeckCards[topDeckCards.length - 1]?.instanceId || null
arrangeRevealedCards() arrangeRevealedCards()
publishLocalGameState()
} }
/** /**
* 게임 시작 전 덱을 섞고 첫 손패 7장을 준비한다. * 게임 시작 전 덱을 섞고 첫 손패 7장을 준비한다.
* *
* @returns {void} * @returns {Promise<void>}
*/ */
function prepareInitialHand() { async function prepareInitialHand() {
if (!deckQueryResult.hasDeckQuery || gameCards.length < 14) return if (!deckQueryResult.hasDeckQuery || gameCards.length < 14) return
shuffleLocalDeck() await shuffleLocalDeck()
for (let drawIndex = 0; drawIndex < 7; drawIndex += 1) { for (let drawIndex = 0; drawIndex < 7; drawIndex += 1) {
drawTopDeckCard() await drawTopDeckCard()
} }
selectedCardInstanceId.value = null selectedCardInstanceId.value = null
@@ -529,18 +769,30 @@ function prepareInitialHand() {
* 기존 손패를 제외한 덱에서 새 7장을 먼저 뽑은 뒤 기존 손패를 덱에 * 기존 손패를 제외한 덱에서 새 7장을 먼저 뽑은 뒤 기존 손패를 덱에
* 되돌리고 섞는다. * 되돌리고 섞는다.
* *
* @returns {void} * @returns {Promise<void>}
*/ */
function mulliganInitialHand() { async function mulliganInitialHand() {
if (hasUsedMulligan.value || isInitialHandConfirmed.value) return if (hasUsedMulligan.value || isInitialHandConfirmed.value) return
const previousHandCards = gameCards.filter( const previousHandCards = gameCards.filter(
(gameCard) => gameCard.zone === 'hand', (gameCard) => gameCard.zone === 'hand',
) )
const replacementCards = gameCards let replacementCards
.filter((gameCard) => gameCard.zone === 'deck')
.sort((firstCard, secondCard) => secondCard.stackOrder - firstCard.stackOrder) if (isOnlineGame.value) {
.slice(0, 7) const replacementInstanceIds = await roomStore.drawPrivateDeckCards(7)
replacementCards = (replacementInstanceIds || [])
.map((instanceId) => findGameCard(instanceId))
.filter(Boolean)
} else {
replacementCards = gameCards
.filter((gameCard) => gameCard.zone === 'deck')
.sort(
(firstCard, secondCard) =>
secondCard.stackOrder - firstCard.stackOrder,
)
.slice(0, 7)
}
replacementCards.forEach((replacementCard) => { replacementCards.forEach((replacementCard) => {
moveGameCardToZone(replacementCard.instanceId, 'hand') moveGameCardToZone(replacementCard.instanceId, 'hand')
@@ -550,24 +802,37 @@ function mulliganInitialHand() {
moveGameCardToZone(previousHandCard.instanceId, 'deck', 'top') moveGameCardToZone(previousHandCard.instanceId, 'deck', 'top')
}) })
shuffleLocalDeck() await shuffleLocalDeck()
arrangeLocalHandCards() arrangeLocalHandCards()
hasUsedMulligan.value = true hasUsedMulligan.value = true
selectedCardInstanceId.value = null selectedCardInstanceId.value = null
publishLocalGameState()
} }
/** /**
* 첫 손패를 확정하고 덱 위 7장을 라이프로 배치한다. * 첫 손패를 확정하고 덱 위 7장을 라이프로 배치한다.
* *
* @returns {void} * @returns {Promise<void>}
*/ */
function confirmInitialHand() { async function confirmInitialHand() {
if (isInitialHandConfirmed.value) return if (isInitialHandConfirmed.value) return
const lifeCards = gameCards let lifeCards
.filter((gameCard) => gameCard.zone === 'deck')
.sort((firstCard, secondCard) => secondCard.stackOrder - firstCard.stackOrder) if (isOnlineGame.value) {
.slice(0, 7) const lifeInstanceIds = await roomStore.drawPrivateDeckCards(7)
lifeCards = (lifeInstanceIds || [])
.map((instanceId) => findGameCard(instanceId))
.filter(Boolean)
} else {
lifeCards = gameCards
.filter((gameCard) => gameCard.zone === 'deck')
.sort(
(firstCard, secondCard) =>
secondCard.stackOrder - firstCard.stackOrder,
)
.slice(0, 7)
}
lifeCards.forEach((lifeCard) => { lifeCards.forEach((lifeCard) => {
moveGameCardToZone(lifeCard.instanceId, 'life') moveGameCardToZone(lifeCard.instanceId, 'life')
@@ -575,6 +840,11 @@ function confirmInitialHand() {
isInitialHandConfirmed.value = true isInitialHandConfirmed.value = true
selectedCardInstanceId.value = null selectedCardInstanceId.value = null
publishLocalGameState()
if (isOnlineGame.value) {
void roomStore.confirmGameSetup()
}
} }
/** /**
@@ -588,14 +858,15 @@ function toggleSelectedCardUntapLock() {
selectedCard.isUntapLocked = !selectedCard.isUntapLocked selectedCard.isUntapLocked = !selectedCard.isUntapLocked
closeCardContextMenu() closeCardContextMenu()
publishLocalGameState()
} }
/** /**
* 내 턴을 시작하고 잠기지 않은 필드 카드를 언탭한 뒤 AP와 드로우를 처리한다. * 내 턴 시작 효과로 잠기지 않은 필드 카드를 언탭한 뒤 AP와 드로우를 처리한다.
* *
* @returns {void} * @returns {Promise<void>}
*/ */
function startLocalTurn() { async function applyLocalTurnStart() {
if (!isInitialHandConfirmed.value) return if (!isInitialHandConfirmed.value) return
turnNumber.value += 1 turnNumber.value += 1
@@ -619,8 +890,25 @@ function startLocalTurn() {
const shouldDrawCard = !(isFirstPlayer.value && turnNumber.value === 1) const shouldDrawCard = !(isFirstPlayer.value && turnNumber.value === 1)
if (shouldDrawCard) { if (shouldDrawCard) {
drawTopDeckCard() await drawTopDeckCard()
} }
publishLocalGameState()
}
/**
* 로컬 게임에서는 턴을 시작하고 온라인 게임에서는 현재 턴을 종료한다.
*
* @returns {void}
*/
async function handleTurnControl() {
if (isOnlineGame.value) {
if (!isMyTurn.value) return
void roomStore.endCurrentTurn()
return
}
await applyLocalTurnStart()
} }
/** /**
@@ -818,18 +1106,60 @@ function moveDraggedCard(pointerEvent) {
* @returns {void} * @returns {void}
*/ */
function finishCardDrag() { function finishCardDrag() {
const movedCardInstanceId = activeDragState.cardInstanceId
draggingCardInstanceId.value = null draggingCardInstanceId.value = null
activeDragState.cardInstanceId = null activeDragState.cardInstanceId = null
window.removeEventListener('pointermove', moveDraggedCard) window.removeEventListener('pointermove', moveDraggedCard)
window.removeEventListener('pointerup', finishCardDrag) window.removeEventListener('pointerup', finishCardDrag)
window.removeEventListener('pointercancel', finishCardDrag) window.removeEventListener('pointercancel', finishCardDrag)
if (movedCardInstanceId) {
publishLocalGameState()
}
} }
watch(
() => roomStore.gameState,
(nextGameState) => {
if (
!isOnlineGame.value ||
!isInitialHandConfirmed.value ||
nextGameState?.currentTurnPlayerId !== roomStore.playerId ||
!nextGameState.turnSequence ||
nextGameState.turnSequence === lastAppliedTurnSequence.value
) {
return
}
lastAppliedTurnSequence.value = nextGameState.turnSequence
void applyLocalTurnStart()
},
{ deep: true, immediate: true },
)
watch(
isOnlineGame,
(hasOnlineGameConnection) => {
if (!hasOnlineGameConnection) return
if (!hasRestoredLocalSnapshot.value) {
didRestoreLocalSnapshot.value = restoreLocalGameSnapshot()
hasRestoredLocalSnapshot.value = true
}
roomStore.requestOpponentGameState()
publishLocalGameState()
},
{ immediate: true },
)
onMounted(() => { onMounted(() => {
document.addEventListener('fullscreenchange', handleFullscreenChange) document.addEventListener('fullscreenchange', handleFullscreenChange)
window.addEventListener('keydown', handleGameKeyboardShortcut) window.addEventListener('keydown', handleGameKeyboardShortcut)
window.addEventListener('pointerdown', closeCardContextMenu) window.addEventListener('pointerdown', closeCardContextMenu)
prepareInitialHand() if (!didRestoreLocalSnapshot.value) {
void prepareInitialHand()
}
}) })
onBeforeUnmount(() => { onBeforeUnmount(() => {
@@ -843,10 +1173,12 @@ onBeforeUnmount(() => {
<template> <template>
<section <section
ref="gameScreenElement" ref="gameScreenElement"
class="relative flex h-screen w-screen items-center justify-center overflow-hidden bg-black text-slate-100" class="relative flex h-screen w-screen flex-col items-center justify-center overflow-hidden bg-black text-slate-100"
> >
<div class="flex h-full w-full items-center justify-center overflow-hidden"> <div
<div class="max-h-screen w-full max-w-[calc(100vh*16/9)]"> class="flex min-h-0 w-full flex-1 items-center justify-center overflow-hidden"
>
<div class="max-h-[86vh] w-full max-w-[calc(86vh*16/9)]">
<div <div
ref="playboardElement" ref="playboardElement"
class="relative aspect-video w-full overflow-hidden bg-red-950" class="relative aspect-video w-full overflow-hidden bg-red-950"
@@ -980,7 +1312,7 @@ onBeforeUnmount(() => {
</div> </div>
<GameCard <GameCard
v-for="gameCard in gameCards" v-for="gameCard in boardGameCards"
:key="gameCard.instanceId" :key="gameCard.instanceId"
:card="gameCard" :card="gameCard"
:is-dragging="draggingCardInstanceId === gameCard.instanceId" :is-dragging="draggingCardInstanceId === gameCard.instanceId"
@@ -991,6 +1323,13 @@ onBeforeUnmount(() => {
@context-menu-requested="openCardContextMenu" @context-menu-requested="openCardContextMenu"
/> />
<GameCard
v-for="opponentGameCard in opponentGameCards"
:key="opponentGameCard.instanceId"
:card="opponentGameCard"
:is-interactive="false"
/>
<div <div
class="pointer-events-none absolute right-3 top-3 rounded-lg border border-white/15 bg-slate-950/80 px-3 py-2 text-right text-[10px] leading-5 text-slate-300 backdrop-blur sm:text-xs" class="pointer-events-none absolute right-3 top-3 rounded-lg border border-white/15 bg-slate-950/80 px-3 py-2 text-right text-[10px] leading-5 text-slate-300 backdrop-blur sm:text-xs"
> >
@@ -1033,6 +1372,42 @@ onBeforeUnmount(() => {
</div> </div>
</div> </div>
<section
class="relative z-[1200] flex h-[14vh] min-h-24 w-full shrink-0 items-center border-t border-white/15 bg-slate-950/95 px-3 shadow-[0_-12px_30px_rgba(0,0,0,0.45)]"
aria-label="내 손패"
>
<div class="mr-3 w-20 shrink-0 text-center">
<p class="text-[10px] font-black tracking-[0.16em] text-red-300">
HAND
</p>
<p class="mt-1 text-2xl font-black">{{ localHandCardCount }}</p>
</div>
<div
class="flex min-w-0 flex-1 items-center gap-1.5 overflow-x-auto overflow-y-hidden py-2"
>
<GameCard
v-for="handCard in localHandCards"
:key="`hand-${handCard.instanceId}`"
:card="handCard"
display-mode="hand"
:is-dragging="draggingCardInstanceId === handCard.instanceId"
:is-selected="selectedCardInstanceId === handCard.instanceId"
@drag-started="beginCardDrag"
@selected="selectGameCard"
@double-clicked="handleCardDoubleClick"
@context-menu-requested="openCardContextMenu"
/>
</div>
<p
v-if="localHandCards.length === 0"
class="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 text-xs font-bold text-slate-600"
>
손패가 없습니다.
</p>
</section>
<button <button
type="button" type="button"
class="absolute right-3 top-3 z-[1100] rounded-lg border border-white/15 bg-slate-950/75 px-3 py-2 text-[10px] font-bold text-slate-200 opacity-30 backdrop-blur transition hover:opacity-100 focus:opacity-100 sm:text-xs" class="absolute right-3 top-3 z-[1100] rounded-lg border border-white/15 bg-slate-950/75 px-3 py-2 text-[10px] font-bold text-slate-200 opacity-30 backdrop-blur transition hover:opacity-100 focus:opacity-100 sm:text-xs"
@@ -1044,10 +1419,22 @@ onBeforeUnmount(() => {
<button <button
v-if="isInitialHandConfirmed" v-if="isInitialHandConfirmed"
type="button" type="button"
class="absolute left-3 top-3 z-[1100] rounded-lg border border-white/15 bg-slate-950/75 px-3 py-2 text-[10px] font-bold text-slate-200 opacity-35 backdrop-blur transition hover:opacity-100 focus:opacity-100 disabled:cursor-not-allowed disabled:opacity-20 sm:text-xs" class="absolute left-3 top-3 z-[1100] rounded-lg border px-4 py-2 text-[10px] font-black backdrop-blur transition sm:text-xs"
@click="startLocalTurn" :class="
isOnlineGame && isMyTurn
? 'border-emerald-300/60 bg-emerald-600/90 text-white shadow-lg shadow-emerald-950'
: 'border-white/15 bg-slate-950/75 text-slate-200'
"
:disabled="isOnlineGame && !isMyTurn"
@click="handleTurnControl"
> >
턴 시작 · {{ turnNumber }}턴 · AP {{ availableActionPoints }}/3 {{
isOnlineGame
? isMyTurn
? `내 차례 · 턴 종료 · AP ${availableActionPoints}/3`
: turnStatusLabel
: `턴 시작 · ${turnNumber}턴 · AP ${availableActionPoints}/3`
}}
</button> </button>
<div <div
@@ -1066,7 +1453,10 @@ onBeforeUnmount(() => {
손패를 확정하면 덱 위 7장이 라이프로 배치됩니다. 손패를 확정하면 덱 위 7장이 라이프로 배치됩니다.
</p> </p>
<div class="mt-5 grid grid-cols-2 gap-2 rounded-2xl bg-white/5 p-1"> <div
v-if="!isOnlineGame"
class="mt-5 grid grid-cols-2 gap-2 rounded-2xl bg-white/5 p-1"
>
<button <button
type="button" type="button"
class="rounded-xl px-4 py-3 text-sm font-black transition" class="rounded-xl px-4 py-3 text-sm font-black transition"
@@ -1075,7 +1465,7 @@ onBeforeUnmount(() => {
? 'bg-red-600 text-white' ? 'bg-red-600 text-white'
: 'text-slate-400 hover:bg-white/5' : 'text-slate-400 hover:bg-white/5'
" "
@click="isFirstPlayer = true" @click="localFirstPlayerChoice = true"
> >
선공 선공
</button> </button>
@@ -1087,12 +1477,24 @@ onBeforeUnmount(() => {
? 'bg-red-600 text-white' ? 'bg-red-600 text-white'
: 'text-slate-400 hover:bg-white/5' : 'text-slate-400 hover:bg-white/5'
" "
@click="isFirstPlayer = false" @click="localFirstPlayerChoice = false"
> >
후공 후공
</button> </button>
</div> </div>
<div
v-else
class="mt-5 rounded-2xl border border-amber-300/25 bg-amber-300/10 px-4 py-3 text-center"
>
<p class="text-xs font-bold tracking-[0.14em] text-amber-200">
서버 무작위 선후공
</p>
<p class="mt-1 text-xl font-black">
{{ isFirstPlayer ? '선공' : '후공' }}
</p>
</div>
<div class="mt-5 flex gap-2"> <div class="mt-5 flex gap-2">
<button <button
type="button" type="button"

View File

@@ -2,14 +2,27 @@ import { defineStore } from 'pinia'
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import { getRoomSocket } from '../services/socket.js' import { getRoomSocket } from '../services/socket.js'
const ROOM_SESSION_STORAGE_KEY = 'playuniare-room-session'
export const useRoomStore = defineStore('room', () => { export const useRoomStore = defineStore('room', () => {
const socket = getRoomSocket() const socket = getRoomSocket()
const connectionState = ref(socket.connected ? 'connected' : 'connecting') const connectionState = ref(socket.connected ? 'connected' : 'connecting')
const connectionError = ref('') const connectionError = ref('')
const actionError = ref('') const actionError = ref('')
const playerId = ref(socket.id || '') const playerId = ref('')
const roomState = ref(null) const roomState = ref(null)
const gameStartPayload = ref(null) const gameStartPayload = ref(null)
const gameState = ref(null)
const opponentGameState = ref({
playerId: '',
revision: 0,
cards: [],
counts: {
deck: 0,
hand: 0,
life: 0,
},
})
let hasInitializedListeners = false let hasInitializedListeners = false
const roomCode = computed(() => roomState.value?.roomCode || '') const roomCode = computed(() => roomState.value?.roomCode || '')
@@ -25,7 +38,9 @@ export const useRoomStore = defineStore('room', () => {
const localPlayerReady = computed( const localPlayerReady = computed(
() => Boolean(localPlayer.value?.isReady), () => Boolean(localPlayer.value?.isReady),
) )
const remotePlayerConnected = computed(() => Boolean(remotePlayer.value)) const remotePlayerConnected = computed(
() => Boolean(remotePlayer.value?.isConnected),
)
const remotePlayerReady = computed( const remotePlayerReady = computed(
() => Boolean(remotePlayer.value?.isReady), () => Boolean(remotePlayer.value?.isReady),
) )
@@ -37,6 +52,80 @@ export const useRoomStore = defineStore('room', () => {
), ),
) )
/**
* 브라우저에 저장된 재접속 세션을 읽는다.
*
* @returns {object | null} 저장된 방 복구 정보
*/
function readStoredRoomSession() {
try {
const storedSession = localStorage.getItem(ROOM_SESSION_STORAGE_KEY)
return storedSession ? JSON.parse(storedSession) : null
} catch {
return null
}
}
/**
* 새로고침 후 복구할 방 세션 정보를 브라우저에 저장한다.
*
* @param {object} roomSession 저장할 방 세션
* @returns {void}
*/
function saveRoomSession(roomSession) {
localStorage.setItem(
ROOM_SESSION_STORAGE_KEY,
JSON.stringify(roomSession),
)
}
/**
* 더 이상 유효하지 않은 방 복구 정보를 삭제한다.
*
* @returns {void}
*/
function clearStoredRoomSession() {
localStorage.removeItem(ROOM_SESSION_STORAGE_KEY)
}
/**
* 현재 플레이어의 브라우저 게임 스냅샷을 삭제한다.
*
* @returns {void}
*/
function clearStoredGameSnapshot() {
const storedRoomSession = readStoredRoomSession()
if (!storedRoomSession?.roomCode || !storedRoomSession?.playerId) return
localStorage.removeItem(
`playuniare-game-${storedRoomSession.roomCode}-${storedRoomSession.playerId}`,
)
}
/**
* 방과 게임에 관련된 클라이언트 상태를 초기화한다.
*
* @returns {void}
*/
function resetLocalRoomState() {
clearStoredGameSnapshot()
clearStoredRoomSession()
playerId.value = ''
roomState.value = null
gameState.value = null
gameStartPayload.value = null
opponentGameState.value = {
playerId: '',
revision: 0,
cards: [],
counts: {
deck: 0,
hand: 0,
life: 0,
},
}
}
/** /**
* Socket.IO 연결과 방 상태 이벤트를 한 번만 등록한다. * Socket.IO 연결과 방 상태 이벤트를 한 번만 등록한다.
* *
@@ -46,15 +135,34 @@ export const useRoomStore = defineStore('room', () => {
if (hasInitializedListeners) return if (hasInitializedListeners) return
hasInitializedListeners = true hasInitializedListeners = true
socket.on('connect', () => { socket.on('connect', async () => {
playerId.value = socket.id
connectionState.value = 'connected' connectionState.value = 'connected'
connectionError.value = '' connectionError.value = ''
const storedRoomSession = readStoredRoomSession()
if (!storedRoomSession) return
const response = await emitWithAcknowledgement(
'room:resume',
storedRoomSession,
)
if (!response.ok) {
clearStoredRoomSession()
roomState.value = null
gameState.value = null
playerId.value = ''
return
}
playerId.value = response.playerId
roomState.value = response.room
gameState.value = response.gameState
gameStartPayload.value = response.gameStartPayload || null
}) })
socket.on('disconnect', () => { socket.on('disconnect', () => {
connectionState.value = 'disconnected' connectionState.value = 'disconnected'
roomState.value = null
}) })
socket.on('connect_error', (socketError) => { socket.on('connect_error', (socketError) => {
@@ -70,9 +178,30 @@ export const useRoomStore = defineStore('room', () => {
socket.on('game:started', (nextGameStartPayload) => { socket.on('game:started', (nextGameStartPayload) => {
gameStartPayload.value = nextGameStartPayload gameStartPayload.value = nextGameStartPayload
gameState.value = nextGameStartPayload.gameState || null
})
socket.on('game:state', (nextGameState) => {
gameState.value = nextGameState
})
socket.on('game:opponent-state', (nextOpponentGameState) => {
if (
Number(nextOpponentGameState?.revision) <
Number(opponentGameState.value.revision)
) {
return
}
opponentGameState.value = nextOpponentGameState
})
socket.on('room:closed', (roomClosedInformation) => {
resetLocalRoomState()
actionError.value =
roomClosedInformation?.message || '참가 중이던 방이 종료되었습니다.'
}) })
} }
/** /**
* Socket.IO 승인 응답을 Promise로 변환해 이벤트를 전송한다. * Socket.IO 승인 응답을 Promise로 변환해 이벤트를 전송한다.
* *
@@ -116,6 +245,11 @@ export const useRoomStore = defineStore('room', () => {
playerId.value = response.playerId playerId.value = response.playerId
roomState.value = response.room roomState.value = response.room
saveRoomSession({
roomCode: response.room.roomCode,
playerId: response.playerId,
resumeToken: response.resumeToken,
})
return true return true
} }
@@ -139,6 +273,11 @@ export const useRoomStore = defineStore('room', () => {
playerId.value = response.playerId playerId.value = response.playerId
roomState.value = response.room roomState.value = response.room
saveRoomSession({
roomCode: response.room.roomCode,
playerId: response.playerId,
resumeToken: response.resumeToken,
})
return true return true
} }
@@ -192,27 +331,199 @@ export const useRoomStore = defineStore('room', () => {
gameStartPayload.value = null gameStartPayload.value = null
} }
/**
* 첫 손패와 라이프 준비 완료를 서버에 알린다.
*
* @returns {Promise<boolean>} 준비 완료 반영 여부
*/
async function confirmGameSetup() {
const response = await emitWithAcknowledgement('game:confirm-setup')
if (!response.ok) {
actionError.value = response.error
return false
}
return true
}
/**
* 내 공개 카드 상태와 비공개 영역 장수를 상대에게 전달한다.
*
* @param {object} playerGameState 공개 가능한 내 게임 상태
* @returns {Promise<boolean>} 서버 반영 여부
*/
async function publishPlayerGameState(playerGameState) {
const response = await emitWithAcknowledgement(
'game:publish-player-state',
playerGameState,
)
if (!response.ok) {
actionError.value = response.error
return false
}
return true
}
/**
* 서버에서 현재 비공개 덱 순서를 무작위로 섞는다.
*
* @param {string[]} instanceIds 현재 덱 카드 식별자
* @returns {Promise<string[] | null>} 서버가 결정한 아래에서 위 순서
*/
async function shufflePrivateDeck(instanceIds) {
const response = await emitWithAcknowledgement(
'game:shuffle-private-deck',
instanceIds,
)
if (!response.ok) {
actionError.value = response.error
return null
}
return response.instanceIds
}
/**
* 서버 권위 덱 위에서 지정한 장수의 식별자를 가져온다.
*
* @param {number} cardCount 가져올 카드 장수
* @returns {Promise<string[] | null>} 위에서부터 뽑힌 카드 식별자
*/
async function drawPrivateDeckCards(cardCount) {
const response = await emitWithAcknowledgement(
'game:draw-private-deck',
cardCount,
)
if (!response.ok) {
actionError.value = response.error
return null
}
return response.instanceIds
}
/**
* 카드 한 장을 서버 권위 덱의 위 또는 아래에 배치한다.
*
* @param {string} instanceId 배치할 카드 식별자
* @param {'top' | 'bottom'} placement 배치 위치
* @returns {Promise<boolean>} 서버 반영 여부
*/
async function placePrivateDeckCard(instanceId, placement) {
const response = await emitWithAcknowledgement(
'game:place-private-deck-card',
{ instanceId, placement },
)
if (!response.ok) {
actionError.value = response.error
return false
}
return true
}
/**
* 카드 한 장을 서버 권위 덱 순서에서 제거한다.
*
* @param {string} instanceId 제거할 카드 식별자
* @returns {Promise<boolean>} 서버 반영 여부
*/
async function removePrivateDeckCard(instanceId) {
const response = await emitWithAcknowledgement(
'game:remove-private-deck-card',
instanceId,
)
if (!response.ok) {
actionError.value = response.error
return false
}
return true
}
/**
* 서버에 저장된 상대 공개 카드 상태를 요청한다.
*
* @returns {void}
*/
function requestOpponentGameState() {
socket.emit('game:request-opponent-state')
}
/**
* 현재 내 턴을 종료하고 상대에게 넘긴다.
*
* @returns {Promise<boolean>} 턴 종료 반영 여부
*/
async function endCurrentTurn() {
const response = await emitWithAcknowledgement('game:end-turn')
if (!response.ok) {
actionError.value = response.error
return false
}
return true
}
/**
* 현재 방을 명시적으로 종료하고 로컬 복구 정보도 삭제한다.
*
* @returns {Promise<boolean>} 방 나가기 처리 여부
*/
async function leavePrivateRoom() {
const response = await emitWithAcknowledgement('room:leave')
resetLocalRoomState()
if (!response.ok) {
actionError.value =
response.error || '서버 응답 없이 로컬 방 상태를 초기화했습니다.'
return false
}
actionError.value = ''
return true
}
initializeRoomConnection() initializeRoomConnection()
return { return {
actionError, actionError,
canStartOnlineGame, canStartOnlineGame,
clearGameStartPayload, clearGameStartPayload,
confirmGameSetup,
connectionError, connectionError,
connectionState, connectionState,
createPrivateRoom, createPrivateRoom,
drawPrivateDeckCards,
endCurrentTurn,
gameStartPayload, gameStartPayload,
gameState,
joinPrivateRoom, joinPrivateRoom,
leavePrivateRoom,
localPlayer, localPlayer,
localPlayerReady, localPlayerReady,
playerId, playerId,
players, players,
placePrivateDeckCard,
publishPlayerGameState,
registerPlayerDeck, registerPlayerDeck,
remotePlayer, remotePlayer,
remotePlayerConnected, remotePlayerConnected,
remotePlayerReady, remotePlayerReady,
removePrivateDeckCard,
requestOpponentGameState,
roomCode, roomCode,
roomState, roomState,
setLocalPlayerReady, setLocalPlayerReady,
shufflePrivateDeck,
opponentGameState,
} }
}) })

View File

@@ -1,7 +1,48 @@
<script setup> <script setup>
import { watch } from 'vue'
import { useRouter } from 'vue-router'
import GameBoard from '../components/game-board/GameBoard.vue' import GameBoard from '../components/game-board/GameBoard.vue'
import { useRoomStore } from '../stores/room.js'
const router = useRouter()
const roomStore = useRoomStore()
let hadActiveRoom = Boolean(roomStore.roomCode)
watch(
() => roomStore.roomCode,
async (nextRoomCode) => {
if (nextRoomCode) {
hadActiveRoom = true
return
}
if (hadActiveRoom) {
await router.replace('/')
}
},
)
/**
* 진행 중인 온라인 방을 종료하고 대기실로 돌아간다.
*
* @returns {Promise<void>}
*/
async function leaveGameRoom() {
await roomStore.leavePrivateRoom()
await router.replace('/')
}
</script> </script>
<template> <template>
<GameBoard /> <div class="relative">
<GameBoard />
<button
v-if="roomStore.roomCode"
type="button"
class="fixed right-3 top-14 z-[4000] rounded-lg border border-red-300/40 bg-red-950/85 px-3 py-2 text-xs font-black text-red-100 opacity-45 shadow-lg backdrop-blur transition hover:opacity-100 focus:opacity-100"
@click="leaveGameRoom"
>
방 나가기
</button>
</div>
</template> </template>

View File

@@ -74,6 +74,10 @@ async function registerDeck() {
* @returns {void} * @returns {void}
*/ */
async function createRoom() { async function createRoom() {
if (roomStore.roomCode) {
await roomStore.leavePrivateRoom()
}
const hasCreatedRoom = await roomStore.createPrivateRoom() const hasCreatedRoom = await roomStore.createPrivateRoom()
if (hasCreatedRoom && deckUrlText.value.trim()) { if (hasCreatedRoom && deckUrlText.value.trim()) {
@@ -81,6 +85,15 @@ async function createRoom() {
} }
} }
/**
* 현재 참가 중인 방을 종료하고 대기실 초기 상태로 돌아간다.
*
* @returns {void}
*/
async function leaveRoom() {
await roomStore.leavePrivateRoom()
}
/** /**
* 입력한 비공개 방 코드로 참가를 시도한다. * 입력한 비공개 방 코드로 참가를 시도한다.
* *
@@ -218,6 +231,22 @@ async function startLocalGameTest() {
</span> </span>
</div> </div>
<p class="mt-4 text-sm text-slate-400">{{ roomStatusLabel }}</p> <p class="mt-4 text-sm text-slate-400">{{ roomStatusLabel }}</p>
<div class="mt-4 flex flex-wrap gap-2">
<button
type="button"
class="rounded-xl border border-white/15 px-4 py-2 text-sm font-black transition hover:bg-white/10"
@click="leaveRoom"
>
방 나가기
</button>
<button
type="button"
class="rounded-xl bg-red-600 px-4 py-2 text-sm font-black transition hover:bg-red-500"
@click="createRoom"
>
현재 방 종료 후 새 방 만들기
</button>
</div>
</div> </div>
<div class="mt-6 grid gap-3 sm:grid-cols-2"> <div class="mt-6 grid gap-3 sm:grid-cols-2">