feat: expand experimental online multiplayer
This commit is contained in:
@@ -32,7 +32,16 @@ npm install
|
||||
|
||||
## 개발 서버 실행
|
||||
|
||||
실시간 대기실을 사용하므로 터미널 두 개를 연다.
|
||||
가장 간단한 방법은 프로젝트 폴더에서 다음 명령 하나를 실행하는 것이다.
|
||||
|
||||
```bash
|
||||
npm run dev:all
|
||||
```
|
||||
|
||||
이 명령은 Vue 개발 서버와 Socket.IO 서버를 동시에 실행한다. 화면에 표시되는
|
||||
Vite의 `Local` 주소를 브라우저에서 연다.
|
||||
|
||||
각 서버 로그를 별도 터미널에서 보고 싶다면 터미널 두 개를 연다.
|
||||
|
||||
첫 번째 터미널에서 Socket.IO 서버를 실행한다.
|
||||
|
||||
@@ -57,6 +66,12 @@ Local: http://localhost:5173/
|
||||
|
||||
Vite는 브라우저의 `/socket.io` 요청을 로컬 `31873` 포트로 전달한다. 두
|
||||
개발 서버를 종료하려면 각각의 터미널에서 `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
|
||||
git pull
|
||||
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:all`을 사용한다.
|
||||
- 서버 터미널에 `listening on port 31873` 메시지가 있는지 확인한다.
|
||||
- 포트를 바꿨다면 Vue와 Socket.IO 서버를 같은 `SOCKET_SERVER_PORT`로
|
||||
실행했는지 확인한다.
|
||||
- 브라우저 개발자 도구 Network 탭에서 `/socket.io` 요청을 확인한다.
|
||||
|
||||
### `EMFILE: too many open files`가 나오는 경우
|
||||
|
||||
이 프로젝트의 기본 `dev:server`는 파일 감시를 사용하지 않으므로 최신
|
||||
`package.json`에서는 발생하지 않아야 한다. 기존 터미널을 모두 종료하고
|
||||
다음 명령을 다시 실행한다.
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev:all
|
||||
```
|
||||
|
||||
### 패키지 오류가 발생하는 경우
|
||||
|
||||
먼저 다음을 다시 실행한다.
|
||||
|
||||
@@ -85,3 +85,58 @@
|
||||
- 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 암호학적 난수를 사용하고 결과 순서를 해당
|
||||
플레이어에게만 반환한다.
|
||||
- 드로우, 여러 장 공개와 라이프 배치는 서버 비공개 덱의 맨 위 식별자를
|
||||
제거한 결과로 처리한다.
|
||||
- 덱 위·아래 이동은 서버 순서에도 반영해 화면 장수와 실제 드로우 결과가
|
||||
어긋나지 않도록 했다.
|
||||
- 공개 카드 상태에는 단조 증가 리비전을 추가해 지연된 이전 상태가 최신
|
||||
상태를 덮지 못하게 했다.
|
||||
|
||||
18
docs/map.md
18
docs/map.md
@@ -15,6 +15,9 @@
|
||||
- 서버 기반 방 생성·방 코드 참가
|
||||
- 양쪽 플레이어의 좌석·덱 등록·준비 상태
|
||||
- 양쪽 준비 완료 시 게임 화면 자동 이동
|
||||
- 방 나가기
|
||||
- 현재 방 종료 후 새 방 만들기
|
||||
- 진행 중 게임 세션 발견 시 게임 화면 자동 복귀
|
||||
- 유효한 덱으로 로컬 게임 테스트
|
||||
|
||||
### `/game`
|
||||
@@ -33,14 +36,23 @@
|
||||
- 불러온 덱을 내 덱 영역에 뒷면으로 쌓기
|
||||
- 잘못된 덱 데이터 오류 안내
|
||||
- 내 덱 1장 드로우와 셔플
|
||||
- 온라인 서버 권위 셔플·드로우·여러 장 공개
|
||||
- 덱 위 1~10장 공개
|
||||
- 중앙 공개 영역 자동 정렬
|
||||
- 하단 손패 자동 정렬
|
||||
- 플레이매트와 분리된 화면 하단 한 줄 손패 트레이
|
||||
- 축소 손패 카드와 가로 스크롤
|
||||
- 우클릭 메뉴의 손패·덱 위·덱 아래·무덤 이동
|
||||
- 첫 손패 7장
|
||||
- 선후공 선택과 1회 멀리건
|
||||
- 로컬 선후공 선택과 1회 멀리건
|
||||
- 온라인 서버 무작위 선후공 지정
|
||||
- 라이프 7장 세팅
|
||||
- 턴 시작 AP·드로우·일괄 언탭
|
||||
- 현재 차례 강조와 서버 기반 턴 종료·교대
|
||||
- 상대 공개 카드·덱·손패·라이프 장수 실시간 표시
|
||||
- 상대 카드 반대편 좌표 표시와 조작 차단
|
||||
- 새로고침·단기 연결 끊김의 같은 좌석 재접속
|
||||
- 게임 화면의 방 나가기
|
||||
- 상대가 방을 나가면 대기실 자동 복귀
|
||||
- 카드별 자동 언탭 제외 잠금
|
||||
- 마우스·터치 포인터 기반 자유 드래그
|
||||
- 카드 더블클릭 탭·언탭
|
||||
@@ -50,7 +62,7 @@
|
||||
- 선택 카드 좌표·레이어·앞뒤·탭 상태 표시
|
||||
- 좌우 플레이어 정보 패널
|
||||
- 라이프, 덱·손패 장수, 연결 상태와 메모 자리
|
||||
- 다음 변경: 게임 화면의 카드 상태를 Socket.IO 서버와 동기화
|
||||
- 다음 변경: 카드 조작 이벤트별 세부 검증과 진행 중 표시
|
||||
- 문서 제목과 기본 언어: `index.html`의 `Play Uniare`, 한국어
|
||||
|
||||
## 예정 화면
|
||||
|
||||
43
docs/spec.md
43
docs/spec.md
@@ -84,7 +84,26 @@
|
||||
- 방 상태와 덱 등록 결과는 Socket.IO 서버가 최종 검증한다.
|
||||
- 두 플레이어가 모두 준비하면 서버가 각 플레이어에게 자신의 덱 쿼리와
|
||||
좌석을 전달하고 `/game`으로 자동 이동시킨다.
|
||||
- 서버가 게임 시작 시 두 플레이어 중 한 명을 무작위 선공으로 지정한다.
|
||||
- 온라인 게임에서는 사용자가 선공·후공을 직접 선택할 수 없다.
|
||||
- 양쪽 플레이어가 첫 손패와 라이프를 확정한 뒤 선공 플레이어의 턴부터
|
||||
시작한다.
|
||||
- 현재 차례의 플레이어만 턴 종료를 요청할 수 있으며 서버가 상대에게 차례를
|
||||
넘긴다.
|
||||
- 방은 서버 메모리에 저장하므로 서버 재시작 시 종료된다.
|
||||
- 브라우저 새로고침이나 짧은 네트워크 끊김에는 재접속 식별자로 같은 좌석을
|
||||
복구한다.
|
||||
- 내 덱 순서, 손패, 카드 위치와 준비 상태는 플레이어별 브라우저 저장소에
|
||||
보관해 새로고침 후 복구한다.
|
||||
- 개인 저장 상태는 상대 플레이어에게 전송하지 않는다.
|
||||
- 연결이 끊긴 플레이어의 자리는 60초 동안 보존하고, 그 안에 복구되지 않으면
|
||||
진행 중인 게임을 종료한다.
|
||||
- 진행 중인 게임이 있는 사용자가 대기실이나 사이트 첫 화면으로 돌아오면
|
||||
서버가 자신의 덱 쿼리와 좌석을 다시 전달해 게임 화면으로 복귀시킨다.
|
||||
- 명시적인 `방 나가기`는 방 전체를 종료하고 양쪽 플레이어의 재접속 정보와
|
||||
개인 게임 스냅샷을 삭제한다.
|
||||
- 대기실에서 `현재 방 종료 후 새 방 만들기`로 먹통이 된 방을 정리하고 즉시
|
||||
새 초대 코드를 만들 수 있다.
|
||||
- 기능 개발을 위해 유효한 덱으로 `/game`에 진입하는 로컬 게임 테스트를
|
||||
제공한다.
|
||||
|
||||
@@ -94,6 +113,9 @@
|
||||
- 상대 효과로 내 카드가 제거될 때에도 내가 직접 내 카드를 이동한다.
|
||||
- 서버는 모든 카드 조작에서 요청 사용자와 카드 소유자를 비교한다.
|
||||
- 상대 손패에는 장수만 공개하고 카드 식별 정보는 공개하지 않는다.
|
||||
- 각 플레이어는 자신의 공개 카드 상태만 서버에 제출할 수 있다.
|
||||
- 서버는 필드, 공개, 무덤, 라이프 카드만 상대에게 전달한다.
|
||||
- 덱과 손패는 카드 목록을 보내지 않고 장수만 전달한다.
|
||||
|
||||
## 화면 좌표
|
||||
|
||||
@@ -113,6 +135,9 @@
|
||||
|
||||
- 로컬 모드: 브라우저 메모리, 추후 복구가 필요하면 localStorage 검토
|
||||
- 온라인 모드: 서버가 방 상태의 최종 권위를 가진다.
|
||||
- 온라인 개인 비공개 상태: 현재 단계에서는 플레이어 브라우저 저장소에
|
||||
암호화 없이 저장한다. 공용 PC에서는 게임 후 브라우저 데이터를 정리해야
|
||||
한다.
|
||||
- 초기 서버: Node.js와 Socket.IO, 메모리 기반 방 상태
|
||||
- 운영 안정화 단계: Redis 또는 데이터베이스 저장 검토
|
||||
- 서버의 공개 방 상태를 Pinia가 수신해 화면에 표시한다.
|
||||
@@ -133,13 +158,25 @@
|
||||
- 키보드 포커스로도 카드를 선택할 수 있어야 한다.
|
||||
- 개발 기준 화면에서는 같은 카드의 독립 인스턴스 3장으로 겹침과 선택
|
||||
동작을 검증한다.
|
||||
- 온라인 게임에서 내 공개 카드는 상대 화면의 반대편 좌표로 변환해 표시한다.
|
||||
- 상대 카드는 선택, 드래그, 더블클릭, 우클릭 조작을 할 수 없다.
|
||||
- 공개 상태 전송에는 증가하는 리비전 번호를 붙이고, 서버와 상대 브라우저는
|
||||
이전 리비전이 늦게 도착하면 무시한다.
|
||||
|
||||
## 덱·손패·무덤 동작
|
||||
|
||||
- 덱 카드는 보드의 내 덱 위치에 뒷면으로 겹쳐 표시한다.
|
||||
- 온라인 게임에서 서버는 카드 내용이 아닌 인스턴스 식별자 순서만 비공개로
|
||||
관리한다.
|
||||
- 온라인 셔플은 서버의 암호학적 난수로 순서를 결정한다.
|
||||
- 온라인 드로우, 덱 위 여러 장 공개, 라이프 배치는 서버가 반환한 맨 위 카드
|
||||
식별자를 사용한다.
|
||||
- 덱 위·아래 이동은 서버의 비공개 덱 순서에도 같은 위치로 반영한다.
|
||||
- `1장 드로우`는 가장 높은 `stackOrder`의 덱 카드를 손패로 이동한다.
|
||||
- 손패 카드는 앞면으로 표시하고 보드 하단 중앙에 자동 정렬한다.
|
||||
- 손패 카드가 많아지면 제한된 폭 안에서 카드 간격을 줄인다.
|
||||
- 손패는 플레이매트 위가 아니라 화면 하단의 별도 한 줄 트레이에 표시한다.
|
||||
- 손패 카드는 필드 카드보다 작게 표시하고 장수가 많아지면 가로로 스크롤한다.
|
||||
- 플레이매트의 `ACTION POINT AREA`는 손패 카드로 가리지 않는다.
|
||||
- 손패·덱·무덤 카드를 드래그하면 필드 카드로 전환한다.
|
||||
- 셔플은 덱 카드의 레이어 순서를 무작위로 다시 배정하고 모두 뒷면·언탭으로
|
||||
정리한다.
|
||||
@@ -170,11 +207,15 @@
|
||||
- 턴 시작 시 내 필드의 탭 카드를 모두 언탭한다.
|
||||
- `isUntapLocked`가 설정된 카드는 턴 시작 자동 언탭에서 제외한다.
|
||||
- 언탭 제외 잠금은 카드 우클릭 메뉴에서 설정·해제한다.
|
||||
- 온라인 게임에서는 서버의 현재 차례가 내 플레이어로 바뀔 때 턴 시작
|
||||
언탭, AP 갱신과 드로우를 한 번 적용한다.
|
||||
|
||||
## 게임 화면
|
||||
|
||||
- 실제 게임 화면에는 고정 헤더를 표시하지 않는다.
|
||||
- 16:9 플레이 보드를 뷰포트 안에서 가능한 가장 크게 표시한다.
|
||||
- 하단 손패 트레이 높이를 제외한 영역에서 16:9 플레이 보드를 가능한 가장
|
||||
크게 표시한다.
|
||||
- 남는 공간은 검은색 레터박스로 처리한다.
|
||||
- `F` 키 또는 우측 상단의 반투명 버튼으로 브라우저 전체화면을 전환한다.
|
||||
- `Esc` 키로 브라우저 전체화면과 카드 우클릭 메뉴를 닫을 수 있다.
|
||||
|
||||
20
docs/todo.md
20
docs/todo.md
@@ -47,10 +47,20 @@
|
||||
- [x] 방 만들기·참가 대기실 UI
|
||||
- [x] 방 만들기·참가 서버 동기화
|
||||
- [x] 플레이어 좌석 할당
|
||||
- [ ] 게임 카드 플레이어 소유권
|
||||
- [ ] 카드 조작 권한 서버 검증
|
||||
- [ ] 상대 손패 비공개 전송
|
||||
- [ ] 상태 스냅샷과 재접속
|
||||
- [x] 게임 카드 플레이어 소유권 기반 상태 제출
|
||||
- [x] 상대 카드 직접 조작 차단
|
||||
- [x] 상대 손패·덱 카드 식별 정보 비공개
|
||||
- [x] 공개 카드 이동·탭·뒤집기·영역 상태 동기화
|
||||
- [x] 서버 무작위 선후공과 현재 차례 동기화
|
||||
- [ ] 카드 조작 이벤트별 세부 서버 검증
|
||||
- [x] 덱 인스턴스 순서와 셔플의 서버 권위 처리
|
||||
- [x] 서버 권위 드로우·여러 장 공개·라이프 배치
|
||||
- [x] 60초 유예 기반 같은 좌석 재접속
|
||||
- [x] 브라우저 저장소 기반 내 비공개 덱·손패 상태 재접속 복구
|
||||
- [x] 진행 중 게임의 대기실·첫 화면 접근 시 자동 복귀
|
||||
- [x] 방 나가기와 새 방 만들기 복구 동선
|
||||
- [ ] 전체 게임 상태 스냅샷 복구
|
||||
- [x] 명시적인 방 종료 시 플레이어별 로컬 스냅샷 자동 삭제
|
||||
- [x] 연결 종료 시 방 참가자 정리
|
||||
- [x] NAS WebSocket 프록시 배포
|
||||
- [ ] 비어 있지 않은 방의 유휴 만료 처리
|
||||
@@ -64,6 +74,8 @@
|
||||
- [ ] 이미지 로딩 실패 폴백
|
||||
- [ ] 접근성 키보드 조작 범위 검토
|
||||
- [ ] 서버 재시작 시 방 종료 안내
|
||||
- [x] 공개 카드 상태 리비전으로 오래된 제출 무시
|
||||
- [ ] 비공개 덱 조작 중 중복 클릭 잠금
|
||||
- [ ] Redis 또는 상태 저장 필요성 검토
|
||||
|
||||
## 확인이 필요한 정책
|
||||
|
||||
@@ -64,3 +64,42 @@
|
||||
- 방을 이동할 때 이전 Socket.IO 채널에서 확실히 나가도록 정리했다.
|
||||
- Nginx의 `/socket.io` 프록시와 Docker 내부 실시간 서버 서비스를 추가했다.
|
||||
- 방 생성부터 양쪽 게임 시작까지 검증하는 2인 통합 테스트를 추가했다.
|
||||
- 온라인 선후공을 서버 무작위 지정으로 변경하고 양쪽의 중복 선공 선택을
|
||||
제거했다.
|
||||
- 양쪽 손패 확정 후 선공부터 시작하는 현재 차례와 턴 종료 교대를 추가했다.
|
||||
- 필드·공개·무덤·라이프 카드 상태와 비공개 영역 장수를 상대 화면에
|
||||
동기화했다.
|
||||
- 상대 카드는 반대편 좌표로 표시하고 직접 조작할 수 없도록 했다.
|
||||
- 손패와 덱 카드 목록이 상대에게 전달되지 않도록 서버 공개 상태를 제한했다.
|
||||
- 통합 테스트 범위를 선후공, 초기 준비, 공개 카드 비공개 필터, 턴 교대까지
|
||||
확장했다.
|
||||
- 플레이매트의 Action Point 영역을 침범하던 손패를 화면 하단 전용 트레이로
|
||||
분리했다.
|
||||
- 손패 카드를 축소된 한 줄 목록으로 표시하고 가로 스크롤을 지원한다.
|
||||
- 새로고침과 짧은 네트워크 끊김 후 같은 방·좌석·턴을 복구하는 재접속
|
||||
식별자를 추가했다.
|
||||
- 연결이 끊긴 플레이어의 자리를 60초간 보존하고 재접속 통합 테스트를
|
||||
추가했다.
|
||||
- 내 덱 순서, 손패, 카드 위치, 멀리건과 준비 상태를 플레이어별 브라우저
|
||||
저장소에 보관해 새로고침 후 복구하도록 했다.
|
||||
- 로컬에서 Vue만 실행해 웹소켓 오류가 발생하는 문제를 예방하도록
|
||||
`npm run dev:all` 통합 개발 명령을 추가했다.
|
||||
- macOS 파일 감시 한도에서 Socket.IO 개발 서버가 `EMFILE`로 종료되는
|
||||
문제를 피하도록 서버 개발 명령의 `--watch`를 제거했다.
|
||||
- 대기실과 게임 화면에 `방 나가기`를 추가하고 한쪽이 나가면 양쪽 모두
|
||||
대기실 상태로 복귀하도록 했다.
|
||||
- 먹통이 된 방을 정리하고 즉시 새 초대 코드를 만드는 버튼을 추가했다.
|
||||
- 진행 중인 게임에서 대기실로 이동하거나 브라우저를 다시 열어도 게임
|
||||
화면으로 자동 복귀하도록 재접속 응답을 보강했다.
|
||||
- 방 종료 시 재접속 세션과 플레이어별 게임 스냅샷을 삭제하도록 했다.
|
||||
|
||||
## 2026-06-22
|
||||
|
||||
- 온라인 덱 셔플을 클라이언트 난수 대신 서버의 암호학적 난수로 변경했다.
|
||||
- 서버가 플레이어별 비공개 덱 인스턴스 순서를 관리하도록 추가했다.
|
||||
- 드로우, 여러 장 공개, 라이프 배치와 덱 위·아래 이동을 서버 순서에
|
||||
연결했다.
|
||||
- 공개 카드 상태에 리비전 번호를 추가해 늦게 도착한 이전 상태를 무시하도록
|
||||
했다.
|
||||
- 서버 통합 테스트에 비공개 덱 셔플·드로우·되돌리기와 오래된 리비전 거부를
|
||||
추가했다.
|
||||
|
||||
260
package-lock.json
generated
260
package-lock.json
generated
@@ -17,6 +17,7 @@
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.3.1",
|
||||
"@vitejs/plugin-vue": "^6.0.6",
|
||||
"concurrently": "^10.0.3",
|
||||
"tailwindcss": "^4.3.1",
|
||||
"vite": "^8.0.12"
|
||||
}
|
||||
@@ -1006,6 +1007,32 @@
|
||||
"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": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/ast-kit/-/ast-kit-2.2.0.tgz",
|
||||
@@ -1057,6 +1084,19 @@
|
||||
"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": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz",
|
||||
@@ -1072,6 +1112,46 @@
|
||||
"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": {
|
||||
"version": "0.2.4",
|
||||
"resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz",
|
||||
@@ -1152,6 +1232,13 @@
|
||||
"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": {
|
||||
"version": "6.6.9",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"version": "2.0.2",
|
||||
"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_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": {
|
||||
"version": "4.2.11",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/scule/-/scule-1.3.0.tgz",
|
||||
"integrity": "sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==",
|
||||
"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": {
|
||||
"version": "4.8.3",
|
||||
"resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.8.3.tgz",
|
||||
@@ -1957,6 +2100,40 @@
|
||||
"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": {
|
||||
"version": "2.2.6",
|
||||
"resolved": "https://registry.npmjs.org/superjson/-/superjson-2.2.6.tgz",
|
||||
@@ -1969,6 +2146,19 @@
|
||||
"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": {
|
||||
"version": "4.3.1",
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.1.tgz",
|
||||
@@ -2006,12 +2196,22 @@
|
||||
"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": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"license": "0BSD",
|
||||
"optional": true
|
||||
"devOptional": true,
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/ufo": {
|
||||
"version": "1.6.4",
|
||||
@@ -2251,6 +2451,24 @@
|
||||
"integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==",
|
||||
"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": {
|
||||
"version": "8.21.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
|
||||
@@ -2280,6 +2498,16 @@
|
||||
"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": {
|
||||
"version": "2.9.0",
|
||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz",
|
||||
@@ -2294,6 +2522,34 @@
|
||||
"funding": {
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"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",
|
||||
"test:server": "node server/integration-test.js",
|
||||
"build": "vite build",
|
||||
@@ -21,6 +22,7 @@
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.3.1",
|
||||
"@vitejs/plugin-vue": "^6.0.6",
|
||||
"concurrently": "^10.0.3",
|
||||
"tailwindcss": "^4.3.1",
|
||||
"vite": "^8.0.12"
|
||||
}
|
||||
|
||||
547
server/index.js
547
server/index.js
@@ -1,5 +1,5 @@
|
||||
import { createServer } from 'node:http'
|
||||
import { randomInt } from 'node:crypto'
|
||||
import { randomInt, randomUUID } from 'node:crypto'
|
||||
import { Server } from 'socket.io'
|
||||
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_LENGTH = 6
|
||||
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 socketRoomCodes = new Map()
|
||||
const socketPlayerIds = new Map()
|
||||
|
||||
/**
|
||||
* 사람이 읽기 쉬운 비공개 방 코드를 생성한다.
|
||||
@@ -42,7 +46,7 @@ function createPublicRoomState(room) {
|
||||
players: Array.from(room.players.values()).map((player) => ({
|
||||
playerId: player.playerId,
|
||||
seat: player.seat,
|
||||
isConnected: true,
|
||||
isConnected: player.isConnected,
|
||||
isReady: player.isReady,
|
||||
hasRegisteredDeck: Boolean(player.deckRegistration?.isValid),
|
||||
deckSeriesName: player.deckRegistration?.seriesName || '',
|
||||
@@ -64,6 +68,142 @@ function broadcastRoomState(socketServer, 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) {
|
||||
const roomCode = socketRoomCodes.get(socketId)
|
||||
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
|
||||
}
|
||||
@@ -89,14 +230,20 @@ function leaveCurrentRoom(socketServer, socket) {
|
||||
const socketId = socket.id
|
||||
const roomCode = socketRoomCodes.get(socketId)
|
||||
const room = roomCode ? rooms.get(roomCode) : null
|
||||
const playerId = socketPlayerIds.get(socketId)
|
||||
|
||||
socketRoomCodes.delete(socketId)
|
||||
socketPlayerIds.delete(socketId)
|
||||
if (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) {
|
||||
rooms.delete(roomCode)
|
||||
@@ -104,9 +251,71 @@ function leaveCurrentRoom(socketServer, socket) {
|
||||
}
|
||||
|
||||
room.hasStarted = false
|
||||
room.gameState = null
|
||||
Array.from(room.players.values()).forEach((remainingPlayer) => {
|
||||
remainingPlayer.isReady = false
|
||||
})
|
||||
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
|
||||
|
||||
room.hasStarted = true
|
||||
const firstPlayer = players[randomInt(players.length)]
|
||||
room.gameState = {
|
||||
firstPlayerId: firstPlayer.playerId,
|
||||
currentTurnPlayerId: null,
|
||||
turnSequence: 0,
|
||||
setupConfirmedPlayerIds: new Set(),
|
||||
}
|
||||
broadcastRoomState(socketServer, room)
|
||||
|
||||
players.forEach((player) => {
|
||||
socketServer.to(player.playerId).emit('game:started', {
|
||||
socketServer.to(player.socketId).emit('game:started', {
|
||||
roomCode: room.roomCode,
|
||||
seat: player.seat,
|
||||
gameQuery: player.deckRegistration.gameQuery,
|
||||
gameState: {
|
||||
firstPlayerId: room.gameState.firstPlayerId,
|
||||
currentTurnPlayerId: null,
|
||||
turnSequence: 0,
|
||||
setupConfirmedPlayerIds: [],
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
broadcastGameState(socketServer, room)
|
||||
}
|
||||
|
||||
const httpServer = createServer((request, response) => {
|
||||
@@ -156,26 +380,37 @@ socketServer.on('connection', (socket) => {
|
||||
leaveCurrentRoom(socketServer, socket)
|
||||
|
||||
const roomCode = createUniqueRoomCode()
|
||||
const playerId = randomUUID()
|
||||
const resumeToken = randomUUID()
|
||||
const room = {
|
||||
roomCode,
|
||||
hasStarted: false,
|
||||
players: new Map(),
|
||||
}
|
||||
const player = {
|
||||
playerId: socket.id,
|
||||
playerId,
|
||||
resumeToken,
|
||||
socketId: socket.id,
|
||||
seat: 'player-one',
|
||||
isConnected: true,
|
||||
isReady: false,
|
||||
deckRegistration: null,
|
||||
publicCards: [],
|
||||
gameCounts: null,
|
||||
publicStateRevision: 0,
|
||||
privateDeckOrder: [],
|
||||
}
|
||||
|
||||
room.players.set(socket.id, player)
|
||||
room.players.set(playerId, player)
|
||||
rooms.set(roomCode, room)
|
||||
socketRoomCodes.set(socket.id, roomCode)
|
||||
socketPlayerIds.set(socket.id, playerId)
|
||||
socket.join(roomCode)
|
||||
|
||||
acknowledge?.({
|
||||
ok: true,
|
||||
playerId: socket.id,
|
||||
playerId,
|
||||
resumeToken,
|
||||
room: createPublicRoomState(room),
|
||||
})
|
||||
broadcastRoomState(socketServer, room)
|
||||
@@ -205,25 +440,102 @@ socketServer.on('connection', (socket) => {
|
||||
|
||||
leaveCurrentRoom(socketServer, socket)
|
||||
|
||||
const playerId = randomUUID()
|
||||
const resumeToken = randomUUID()
|
||||
const player = {
|
||||
playerId: socket.id,
|
||||
playerId,
|
||||
resumeToken,
|
||||
socketId: socket.id,
|
||||
seat: 'player-two',
|
||||
isConnected: true,
|
||||
isReady: false,
|
||||
deckRegistration: null,
|
||||
publicCards: [],
|
||||
gameCounts: null,
|
||||
publicStateRevision: 0,
|
||||
privateDeckOrder: [],
|
||||
}
|
||||
|
||||
room.players.set(socket.id, player)
|
||||
room.players.set(playerId, player)
|
||||
socketRoomCodes.set(socket.id, roomCode)
|
||||
socketPlayerIds.set(socket.id, playerId)
|
||||
socket.join(roomCode)
|
||||
|
||||
acknowledge?.({
|
||||
ok: true,
|
||||
playerId: socket.id,
|
||||
playerId,
|
||||
resumeToken,
|
||||
room: createPublicRoomState(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) => {
|
||||
const roomContext = findSocketRoomContext(socket.id)
|
||||
|
||||
@@ -267,12 +579,217 @@ socketServer.on('connection', (socket) => {
|
||||
startRoomGameWhenReady(socketServer, roomContext.room)
|
||||
})
|
||||
|
||||
socket.on('room:leave', () => {
|
||||
leaveCurrentRoom(socketServer, socket)
|
||||
socket.on('game:confirm-setup', (acknowledge) => {
|
||||
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', () => {
|
||||
leaveCurrentRoom(socketServer, socket)
|
||||
preserveDisconnectedPlayer(socketServer, socket)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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',
|
||||
transports: ['websocket', 'polling'],
|
||||
})
|
||||
let resumedSecondPlayerSocket = null
|
||||
|
||||
try {
|
||||
await Promise.all([
|
||||
@@ -149,11 +163,191 @@ async function runIntegrationTest() {
|
||||
assert.equal(secondStartPayload.seat, 'player-two')
|
||||
assert.equal(firstStartPayload.roomCode, secondStartPayload.roomCode)
|
||||
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 {
|
||||
firstPlayerSocket.disconnect()
|
||||
secondPlayerSocket.disconnect()
|
||||
resumedSecondPlayerSocket?.disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,15 @@ const props = defineProps({
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isInteractive: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
displayMode: {
|
||||
type: String,
|
||||
default: 'board',
|
||||
validator: (displayMode) => ['board', 'hand'].includes(displayMode),
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits([
|
||||
@@ -24,12 +33,19 @@ const emit = defineEmits([
|
||||
])
|
||||
const hasImageError = ref(false)
|
||||
|
||||
const positionStyle = computed(() => ({
|
||||
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)`,
|
||||
}))
|
||||
const positionStyle = computed(() =>
|
||||
props.displayMode === 'hand'
|
||||
? {
|
||||
zIndex: props.card.stackOrder,
|
||||
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}
|
||||
*/
|
||||
function startCardDrag(pointerEvent) {
|
||||
if (pointerEvent.button !== 0) return
|
||||
if (!props.isInteractive || pointerEvent.button !== 0) return
|
||||
|
||||
emit('selected', props.card.instanceId)
|
||||
emit('drag-started', {
|
||||
@@ -63,6 +79,7 @@ function handleImageError() {
|
||||
* @returns {void}
|
||||
*/
|
||||
function handleDoubleClick(mouseEvent) {
|
||||
if (!props.isInteractive) return
|
||||
mouseEvent.preventDefault()
|
||||
emit('double-clicked', props.card.instanceId)
|
||||
}
|
||||
@@ -74,6 +91,7 @@ function handleDoubleClick(mouseEvent) {
|
||||
* @returns {void}
|
||||
*/
|
||||
function requestContextMenu(mouseEvent) {
|
||||
if (!props.isInteractive) return
|
||||
mouseEvent.preventDefault()
|
||||
emit('selected', props.card.instanceId)
|
||||
emit('context-menu-requested', {
|
||||
@@ -87,14 +105,19 @@ function requestContextMenu(mouseEvent) {
|
||||
<template>
|
||||
<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="
|
||||
isDragging
|
||||
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="[
|
||||
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)]'
|
||||
: 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-lg shadow-black/50 hover:shadow-xl'
|
||||
"
|
||||
: 'cursor-grab shadow-lg shadow-black/50 hover:shadow-xl',
|
||||
]"
|
||||
:style="positionStyle"
|
||||
:aria-label="`${card.cardNumber} 카드 이동`"
|
||||
@pointerdown="startCardDrag"
|
||||
|
||||
@@ -5,10 +5,12 @@ import {
|
||||
onMounted,
|
||||
reactive,
|
||||
ref,
|
||||
watch,
|
||||
} from 'vue'
|
||||
import GameCard from '../cards/GameCard.vue'
|
||||
import standardPlayboardImage from '../../assets/playboards/union-arena-standard.png'
|
||||
import { parseDeckQuery } from '../../services/deckQuery.js'
|
||||
import { useRoomStore } from '../../stores/room.js'
|
||||
|
||||
const PLAYBOARD_ASPECT_RATIO = 16 / 9
|
||||
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_MAXIMUM_WIDTH = 0.42
|
||||
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 gameScreenElement = ref(null)
|
||||
@@ -98,9 +111,13 @@ const isInitialHandConfirmed = ref(
|
||||
!deckQueryResult.hasDeckQuery || gameCards.length < 14,
|
||||
)
|
||||
const hasUsedMulligan = ref(false)
|
||||
const isFirstPlayer = ref(true)
|
||||
const localFirstPlayerChoice = ref(true)
|
||||
const turnNumber = 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 deckLoadStatusLabel = deckQueryResult.hasDeckQuery
|
||||
? `덱 ${gameCards.length}장 불러옴`
|
||||
@@ -124,6 +141,34 @@ const activeDragState = {
|
||||
const selectedGameCard = computed(() =>
|
||||
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(() => {
|
||||
if (!selectedGameCard.value) return '카드를 선택하세요'
|
||||
@@ -140,6 +185,14 @@ const localDeckCardCount = computed(
|
||||
const localHandCardCount = computed(
|
||||
() => 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(
|
||||
() => gameCards.filter((gameCard) => gameCard.zone === 'reveal').length,
|
||||
)
|
||||
@@ -159,17 +212,27 @@ const playerInformationPanels = computed(() => [
|
||||
positionClass: 'left-[1.4%] top-[8%]',
|
||||
label: 'OPPONENT',
|
||||
displayName: '상대 플레이어',
|
||||
connectionLabel: '연결 대기',
|
||||
lifeCount: 7,
|
||||
deckCountLabel: '—',
|
||||
handCountLabel: '—',
|
||||
connectionLabel: isOnlineGame.value
|
||||
? isMyTurn.value
|
||||
? '대기 중'
|
||||
: '진행 중'
|
||||
: '연결 대기',
|
||||
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',
|
||||
positionClass: 'right-[1.4%] bottom-[8%]',
|
||||
label: 'YOU',
|
||||
displayName: '내 플레이어',
|
||||
connectionLabel: '로컬 모드',
|
||||
connectionLabel: isOnlineGame.value ? turnStatusLabel.value : '로컬 모드',
|
||||
lifeCount: deckQueryResult.hasDeckQuery ? localLifeCardCount.value : 7,
|
||||
deckCountLabel: String(localDeckCardCount.value),
|
||||
handCountLabel: String(localHandCardCount.value),
|
||||
@@ -199,6 +262,113 @@ function findGameCard(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
|
||||
|
||||
gameCard.isFaceUp = !gameCard.isFaceUp
|
||||
publishLocalGameState()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -243,6 +414,7 @@ function toggleGameCardTap(cardInstanceId) {
|
||||
if (!gameCard) return
|
||||
|
||||
gameCard.isTapped = !gameCard.isTapped
|
||||
publishLocalGameState()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -266,6 +438,7 @@ function bringSelectedCardToFront() {
|
||||
|
||||
highestStackOrder.value += 1
|
||||
selectedCard.stackOrder = highestStackOrder.value
|
||||
publishLocalGameState()
|
||||
closeCardContextMenu()
|
||||
}
|
||||
|
||||
@@ -415,6 +588,19 @@ function moveGameCardToZone(
|
||||
if (previousZone === 'life' || destinationZone === 'life') {
|
||||
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() {
|
||||
const topDeckCard = gameCards
|
||||
.filter((gameCard) => gameCard.zone === 'deck')
|
||||
.sort((firstCard, secondCard) => secondCard.stackOrder - firstCard.stackOrder)[0]
|
||||
async function drawTopDeckCard() {
|
||||
let topDeckCard
|
||||
|
||||
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
|
||||
|
||||
@@ -450,49 +646,92 @@ function drawTopDeckCard() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 내 덱 카드 순서를 무작위로 섞고 모두 뒷면으로 정리한다.
|
||||
* 서버 또는 로컬에서 정한 덱 순서를 카드 레이어에 반영한다.
|
||||
*
|
||||
* @param {string[]} orderedInstanceIds 아래에서 위 순서의 카드 식별자
|
||||
* @returns {void}
|
||||
*/
|
||||
function shuffleLocalDeck() {
|
||||
const deckCards = gameCards.filter((gameCard) => gameCard.zone === 'deck')
|
||||
function applyPrivateDeckOrder(orderedInstanceIds) {
|
||||
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
|
||||
deckCard.stackOrder = highestStackOrder.value
|
||||
deckCard.position = { ...LOCAL_DECK_POSITION }
|
||||
deckCard.isFaceUp = 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
|
||||
closeCardContextMenu()
|
||||
publishLocalGameState()
|
||||
}
|
||||
|
||||
/**
|
||||
* 덱 위에서 지정된 수만큼 공개 영역으로 이동한다.
|
||||
*
|
||||
* @returns {void}
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
function revealTopDeckCards() {
|
||||
async function revealTopDeckCards() {
|
||||
const requestedCardCount = clampNumber(
|
||||
Number(revealRequestCount.value) || 1,
|
||||
1,
|
||||
10,
|
||||
)
|
||||
const topDeckCards = gameCards
|
||||
.filter((gameCard) => gameCard.zone === 'deck')
|
||||
.sort((firstCard, secondCard) => secondCard.stackOrder - firstCard.stackOrder)
|
||||
.slice(0, requestedCardCount)
|
||||
let topDeckCards
|
||||
|
||||
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.isFaceUp = true
|
||||
deckCard.isTapped = false
|
||||
@@ -504,20 +743,21 @@ function revealTopDeckCards() {
|
||||
selectedCardInstanceId.value =
|
||||
topDeckCards[topDeckCards.length - 1]?.instanceId || null
|
||||
arrangeRevealedCards()
|
||||
publishLocalGameState()
|
||||
}
|
||||
|
||||
/**
|
||||
* 게임 시작 전 덱을 섞고 첫 손패 7장을 준비한다.
|
||||
*
|
||||
* @returns {void}
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
function prepareInitialHand() {
|
||||
async function prepareInitialHand() {
|
||||
if (!deckQueryResult.hasDeckQuery || gameCards.length < 14) return
|
||||
|
||||
shuffleLocalDeck()
|
||||
await shuffleLocalDeck()
|
||||
|
||||
for (let drawIndex = 0; drawIndex < 7; drawIndex += 1) {
|
||||
drawTopDeckCard()
|
||||
await drawTopDeckCard()
|
||||
}
|
||||
|
||||
selectedCardInstanceId.value = null
|
||||
@@ -529,18 +769,30 @@ function prepareInitialHand() {
|
||||
* 기존 손패를 제외한 덱에서 새 7장을 먼저 뽑은 뒤 기존 손패를 덱에
|
||||
* 되돌리고 섞는다.
|
||||
*
|
||||
* @returns {void}
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
function mulliganInitialHand() {
|
||||
async function mulliganInitialHand() {
|
||||
if (hasUsedMulligan.value || isInitialHandConfirmed.value) return
|
||||
|
||||
const previousHandCards = gameCards.filter(
|
||||
(gameCard) => gameCard.zone === 'hand',
|
||||
)
|
||||
const replacementCards = gameCards
|
||||
.filter((gameCard) => gameCard.zone === 'deck')
|
||||
.sort((firstCard, secondCard) => secondCard.stackOrder - firstCard.stackOrder)
|
||||
.slice(0, 7)
|
||||
let replacementCards
|
||||
|
||||
if (isOnlineGame.value) {
|
||||
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) => {
|
||||
moveGameCardToZone(replacementCard.instanceId, 'hand')
|
||||
@@ -550,24 +802,37 @@ function mulliganInitialHand() {
|
||||
moveGameCardToZone(previousHandCard.instanceId, 'deck', 'top')
|
||||
})
|
||||
|
||||
shuffleLocalDeck()
|
||||
await shuffleLocalDeck()
|
||||
arrangeLocalHandCards()
|
||||
hasUsedMulligan.value = true
|
||||
selectedCardInstanceId.value = null
|
||||
publishLocalGameState()
|
||||
}
|
||||
|
||||
/**
|
||||
* 첫 손패를 확정하고 덱 위 7장을 라이프로 배치한다.
|
||||
*
|
||||
* @returns {void}
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
function confirmInitialHand() {
|
||||
async function confirmInitialHand() {
|
||||
if (isInitialHandConfirmed.value) return
|
||||
|
||||
const lifeCards = gameCards
|
||||
.filter((gameCard) => gameCard.zone === 'deck')
|
||||
.sort((firstCard, secondCard) => secondCard.stackOrder - firstCard.stackOrder)
|
||||
.slice(0, 7)
|
||||
let lifeCards
|
||||
|
||||
if (isOnlineGame.value) {
|
||||
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) => {
|
||||
moveGameCardToZone(lifeCard.instanceId, 'life')
|
||||
@@ -575,6 +840,11 @@ function confirmInitialHand() {
|
||||
|
||||
isInitialHandConfirmed.value = true
|
||||
selectedCardInstanceId.value = null
|
||||
publishLocalGameState()
|
||||
|
||||
if (isOnlineGame.value) {
|
||||
void roomStore.confirmGameSetup()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -588,14 +858,15 @@ function toggleSelectedCardUntapLock() {
|
||||
|
||||
selectedCard.isUntapLocked = !selectedCard.isUntapLocked
|
||||
closeCardContextMenu()
|
||||
publishLocalGameState()
|
||||
}
|
||||
|
||||
/**
|
||||
* 내 턴을 시작하고 잠기지 않은 필드 카드를 언탭한 뒤 AP와 드로우를 처리한다.
|
||||
* 내 턴 시작 효과로 잠기지 않은 필드 카드를 언탭한 뒤 AP와 드로우를 처리한다.
|
||||
*
|
||||
* @returns {void}
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
function startLocalTurn() {
|
||||
async function applyLocalTurnStart() {
|
||||
if (!isInitialHandConfirmed.value) return
|
||||
|
||||
turnNumber.value += 1
|
||||
@@ -619,8 +890,25 @@ function startLocalTurn() {
|
||||
|
||||
const shouldDrawCard = !(isFirstPlayer.value && turnNumber.value === 1)
|
||||
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}
|
||||
*/
|
||||
function finishCardDrag() {
|
||||
const movedCardInstanceId = activeDragState.cardInstanceId
|
||||
draggingCardInstanceId.value = null
|
||||
activeDragState.cardInstanceId = null
|
||||
window.removeEventListener('pointermove', moveDraggedCard)
|
||||
window.removeEventListener('pointerup', 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(() => {
|
||||
document.addEventListener('fullscreenchange', handleFullscreenChange)
|
||||
window.addEventListener('keydown', handleGameKeyboardShortcut)
|
||||
window.addEventListener('pointerdown', closeCardContextMenu)
|
||||
prepareInitialHand()
|
||||
if (!didRestoreLocalSnapshot.value) {
|
||||
void prepareInitialHand()
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
@@ -843,10 +1173,12 @@ onBeforeUnmount(() => {
|
||||
<template>
|
||||
<section
|
||||
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 class="max-h-screen w-full max-w-[calc(100vh*16/9)]">
|
||||
<div
|
||||
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
|
||||
ref="playboardElement"
|
||||
class="relative aspect-video w-full overflow-hidden bg-red-950"
|
||||
@@ -980,7 +1312,7 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
|
||||
<GameCard
|
||||
v-for="gameCard in gameCards"
|
||||
v-for="gameCard in boardGameCards"
|
||||
:key="gameCard.instanceId"
|
||||
:card="gameCard"
|
||||
:is-dragging="draggingCardInstanceId === gameCard.instanceId"
|
||||
@@ -991,6 +1323,13 @@ onBeforeUnmount(() => {
|
||||
@context-menu-requested="openCardContextMenu"
|
||||
/>
|
||||
|
||||
<GameCard
|
||||
v-for="opponentGameCard in opponentGameCards"
|
||||
:key="opponentGameCard.instanceId"
|
||||
:card="opponentGameCard"
|
||||
:is-interactive="false"
|
||||
/>
|
||||
|
||||
<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"
|
||||
>
|
||||
@@ -1033,6 +1372,42 @@ onBeforeUnmount(() => {
|
||||
</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
|
||||
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"
|
||||
@@ -1044,10 +1419,22 @@ onBeforeUnmount(() => {
|
||||
<button
|
||||
v-if="isInitialHandConfirmed"
|
||||
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"
|
||||
@click="startLocalTurn"
|
||||
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"
|
||||
: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>
|
||||
|
||||
<div
|
||||
@@ -1066,7 +1453,10 @@ onBeforeUnmount(() => {
|
||||
손패를 확정하면 덱 위 7장이 라이프로 배치됩니다.
|
||||
</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
|
||||
type="button"
|
||||
class="rounded-xl px-4 py-3 text-sm font-black transition"
|
||||
@@ -1075,7 +1465,7 @@ onBeforeUnmount(() => {
|
||||
? 'bg-red-600 text-white'
|
||||
: 'text-slate-400 hover:bg-white/5'
|
||||
"
|
||||
@click="isFirstPlayer = true"
|
||||
@click="localFirstPlayerChoice = true"
|
||||
>
|
||||
선공
|
||||
</button>
|
||||
@@ -1087,12 +1477,24 @@ onBeforeUnmount(() => {
|
||||
? 'bg-red-600 text-white'
|
||||
: 'text-slate-400 hover:bg-white/5'
|
||||
"
|
||||
@click="isFirstPlayer = false"
|
||||
@click="localFirstPlayerChoice = false"
|
||||
>
|
||||
후공
|
||||
</button>
|
||||
</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">
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -2,14 +2,27 @@ import { defineStore } from 'pinia'
|
||||
import { computed, ref } from 'vue'
|
||||
import { getRoomSocket } from '../services/socket.js'
|
||||
|
||||
const ROOM_SESSION_STORAGE_KEY = 'playuniare-room-session'
|
||||
|
||||
export const useRoomStore = defineStore('room', () => {
|
||||
const socket = getRoomSocket()
|
||||
const connectionState = ref(socket.connected ? 'connected' : 'connecting')
|
||||
const connectionError = ref('')
|
||||
const actionError = ref('')
|
||||
const playerId = ref(socket.id || '')
|
||||
const playerId = ref('')
|
||||
const roomState = 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
|
||||
|
||||
const roomCode = computed(() => roomState.value?.roomCode || '')
|
||||
@@ -25,7 +38,9 @@ export const useRoomStore = defineStore('room', () => {
|
||||
const localPlayerReady = computed(
|
||||
() => Boolean(localPlayer.value?.isReady),
|
||||
)
|
||||
const remotePlayerConnected = computed(() => Boolean(remotePlayer.value))
|
||||
const remotePlayerConnected = computed(
|
||||
() => Boolean(remotePlayer.value?.isConnected),
|
||||
)
|
||||
const remotePlayerReady = computed(
|
||||
() => 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 연결과 방 상태 이벤트를 한 번만 등록한다.
|
||||
*
|
||||
@@ -46,15 +135,34 @@ export const useRoomStore = defineStore('room', () => {
|
||||
if (hasInitializedListeners) return
|
||||
hasInitializedListeners = true
|
||||
|
||||
socket.on('connect', () => {
|
||||
playerId.value = socket.id
|
||||
socket.on('connect', async () => {
|
||||
connectionState.value = 'connected'
|
||||
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', () => {
|
||||
connectionState.value = 'disconnected'
|
||||
roomState.value = null
|
||||
})
|
||||
|
||||
socket.on('connect_error', (socketError) => {
|
||||
@@ -70,9 +178,30 @@ export const useRoomStore = defineStore('room', () => {
|
||||
|
||||
socket.on('game:started', (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로 변환해 이벤트를 전송한다.
|
||||
*
|
||||
@@ -116,6 +245,11 @@ export const useRoomStore = defineStore('room', () => {
|
||||
|
||||
playerId.value = response.playerId
|
||||
roomState.value = response.room
|
||||
saveRoomSession({
|
||||
roomCode: response.room.roomCode,
|
||||
playerId: response.playerId,
|
||||
resumeToken: response.resumeToken,
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -139,6 +273,11 @@ export const useRoomStore = defineStore('room', () => {
|
||||
|
||||
playerId.value = response.playerId
|
||||
roomState.value = response.room
|
||||
saveRoomSession({
|
||||
roomCode: response.room.roomCode,
|
||||
playerId: response.playerId,
|
||||
resumeToken: response.resumeToken,
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -192,27 +331,199 @@ export const useRoomStore = defineStore('room', () => {
|
||||
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()
|
||||
|
||||
return {
|
||||
actionError,
|
||||
canStartOnlineGame,
|
||||
clearGameStartPayload,
|
||||
confirmGameSetup,
|
||||
connectionError,
|
||||
connectionState,
|
||||
createPrivateRoom,
|
||||
drawPrivateDeckCards,
|
||||
endCurrentTurn,
|
||||
gameStartPayload,
|
||||
gameState,
|
||||
joinPrivateRoom,
|
||||
leavePrivateRoom,
|
||||
localPlayer,
|
||||
localPlayerReady,
|
||||
playerId,
|
||||
players,
|
||||
placePrivateDeckCard,
|
||||
publishPlayerGameState,
|
||||
registerPlayerDeck,
|
||||
remotePlayer,
|
||||
remotePlayerConnected,
|
||||
remotePlayerReady,
|
||||
removePrivateDeckCard,
|
||||
requestOpponentGameState,
|
||||
roomCode,
|
||||
roomState,
|
||||
setLocalPlayerReady,
|
||||
shufflePrivateDeck,
|
||||
opponentGameState,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,7 +1,48 @@
|
||||
<script setup>
|
||||
import { watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
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>
|
||||
|
||||
<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>
|
||||
|
||||
@@ -74,6 +74,10 @@ async function registerDeck() {
|
||||
* @returns {void}
|
||||
*/
|
||||
async function createRoom() {
|
||||
if (roomStore.roomCode) {
|
||||
await roomStore.leavePrivateRoom()
|
||||
}
|
||||
|
||||
const hasCreatedRoom = await roomStore.createPrivateRoom()
|
||||
|
||||
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>
|
||||
</div>
|
||||
<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 class="mt-6 grid gap-3 sm:grid-cols-2">
|
||||
|
||||
Reference in New Issue
Block a user