feat: add multiplayer lobby and game board

This commit is contained in:
2026-06-19 16:13:55 +09:00
parent bb5f663e48
commit 1af3c71f8a
29 changed files with 4156 additions and 148 deletions

View File

@@ -1,3 +1,2 @@
PLAYUNIARE_PRODUCTION_HTTP_PORT=41809
VITE_CARD_ASSET_BASE_URL=/card-assets
VITE_SOCKET_SERVER_URL=http://playuniare.sori.studio

View File

@@ -20,9 +20,9 @@
- 프런트엔드: Vue 3 Composition API와 `<script setup>`
- 빌드 도구: Vite
- 스타일: Tailwind CSS 우선
- 상태 관리: Pinia 도입 예정
- 화면 이동: Vue Router 도입 예정
- 실시간 통신: Socket.IO 도입 예정
- 상태 관리: Pinia
- 화면 이동: Vue Router
- 실시간 통신: Node.js와 Socket.IO
- 배포: Docker Compose와 Nginx
- 운영 환경: UGREEN NAS
@@ -206,8 +206,10 @@ function createGameCardInstances(importedCards) {
### 1단계: 로컬 싱글 플레이
- 대기실과 덱 URL 등록
- 덱 쿼리 파싱과 검증
- 카드 인스턴스 생성
- 첫 손패, 멀리건, 라이프 세팅
- 덱 셔플과 드로우
- 카드 이동, 뒤집기, 탭
- 손패, 필드, 무덤, 공개 영역
@@ -215,9 +217,9 @@ function createGameCardInstances(importedCards) {
### 2단계: 2인 세션
- 로비와 방 생성·참가
- 플레이어 좌석과 소유권
- Socket.IO 서버
- 로비와 방 생성·참가 완료
- 플레이어 좌석 할당 완료
- Socket.IO 서버와 NAS 리버스 프록시 완료
- 실시간 카드 이벤트 동기화
- 상대 손패 비공개 처리
- 연결 종료와 재접속 복구

View File

@@ -8,6 +8,26 @@ RUN npm ci
COPY . .
RUN npm run build
FROM node:22-alpine AS server-runtime
WORKDIR /app
ENV NODE_ENV=production
ENV SOCKET_SERVER_PORT=31873
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY server ./server
COPY src/services/deckRegistration.js ./src/services/deckRegistration.js
EXPOSE 31873
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD node -e "fetch('http://127.0.0.1:31873/health').then((response) => { if (!response.ok) process.exit(1) }).catch(() => process.exit(1))"
CMD ["node", "server/index.js"]
FROM nginx:1.27-alpine AS runtime
RUN adduser -D -H -u 1000 -G wheel cardreader \

View File

@@ -1,11 +1,24 @@
name: playuniare-production
services:
playuniare-server:
build:
context: .
dockerfile: Dockerfile
target: server-runtime
container_name: playuniare-server
restart: unless-stopped
expose:
- '31873'
playuniare-web:
build:
context: .
dockerfile: Dockerfile
container_name: playuniare-web
restart: unless-stopped
depends_on:
playuniare-server:
condition: service_healthy
ports:
- '${PLAYUNIARE_PRODUCTION_HTTP_PORT:-41809}:80'

View File

@@ -23,6 +23,19 @@ server {
add_header X-Content-Type-Options "nosniff";
}
location /socket.io/ {
proxy_pass http://playuniare-server:31873;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 65s;
proxy_buffering off;
}
location ~* \.(?:css|js|jpg|jpeg|gif|png|svg|ico|webp|woff2?)$ {
expires 7d;
add_header Cache-Control "public, max-age=604800, immutable";

View File

@@ -9,8 +9,8 @@
- 운영 주소는 현재 `http://playuniare.sori.studio`다.
- HTTPS는 인증서와 리버스 프록시 구성이 완료된 뒤 전환한다.
현재 프로젝트는 Vue 정적 프런트엔드만 포함한다. 실시간 서버가 추가되면 같은
Compose 프로젝트 안에 Node.js 서비스를 추가한다.
현재 프로젝트는 Vue 정적 프런트엔드와 Node.js Socket.IO 서버를 같은
Compose 프로젝트로 배포한다.
## 배포 구조
@@ -25,9 +25,14 @@ NAS 리버스 프록시
↓
Play Uniare Nginx 컨테이너
├── / → Vue 빌드 결과
└── /card-assets/ → 기존 Uniare 카드 이미지
├── /card-assets/ → 기존 Uniare 카드 이미지
└── /socket.io/ → Docker 내부 playuniare-server:31873
```
`31873`은 Docker 내부 통신에만 사용하며 NAS 호스트에는 공개하지 않는다.
외부에서는 웹 주소와 같은 `http://playuniare.sori.studio/socket.io` 경로로
접속한다.
카드 이미지는 복사하지 않고 다음 폴더를 읽기 전용으로 연결한다.
```text
@@ -101,11 +106,11 @@ cp .env.example .env
```text
PLAYUNIARE_PRODUCTION_HTTP_PORT=41809
VITE_CARD_ASSET_BASE_URL=/card-assets
VITE_SOCKET_SERVER_URL=http://playuniare.sori.studio
```
현재 Socket.IO 서버는 구현 전이므로 `VITE_SOCKET_SERVER_URL`은 아직 실제
기능에 사용되지 않는다.
운영 Socket.IO 컨테이너는 내부 포트 `31873`을 사용한다. 브라우저는 현재
도메인의 `/socket.io`를 사용하므로 별도 공개 Socket.IO 주소가 필요하지
않다.
### 4. 포트 사용 여부 확인
@@ -139,7 +144,8 @@ docker compose \
ps
```
`playuniare-web`이 `healthy` 또는 `running`이면 정상이다.
`playuniare-web`과 `playuniare-server`가 모두 `healthy` 또는 `running`이면
정상이다.
### 6. 리버스 프록시 연결
@@ -163,10 +169,12 @@ http://playuniare.sori.studio
카드 이미지:
```text
http://playuniare.sori.studio/card-assets/ua03/EX01BT_HTR-2-001.png
http://playuniare.sori.studio/card-assets/ua01/EX02BT_CGH-2-007.png
```
두 주소가 모두 열리면 현재 정적 배포와 카드 자산 연결은 정상이다.
두 주소가 모두 열리면 정적 배포와 카드 자산 연결은 정상이다. 추가로 서로
다른 브라우저 또는 시크릿 창 두 개를 열어 한쪽에서 방을 만들고 다른 쪽에서
코드로 참가해 실시간 연결을 확인한다.
## 새 버전 업데이트
@@ -329,7 +337,7 @@ docker exec playuniare-web ps aux
```bash
docker exec playuniare-web \
su -s /bin/sh cardreader -c \
'head -c 10 /srv/uniare-card-images/ua03/EX01BT_HTR-2-001.png >/dev/null && echo READ_OK'
'head -c 10 /srv/uniare-card-images/ua01/EX02BT_CGH-2-007.png >/dev/null && echo READ_OK'
```
## 데이터 마이그레이션
@@ -353,11 +361,12 @@ Git에 포함된 버전 관리 마이그레이션 코드를 실행하는 방식
- [ ] 컨테이너 상태와 로그 확인
- [ ] `http://playuniare.sori.studio` 확인
- [ ] `/card-assets/<series>/<fileName>` 확인
- [ ] 두 브라우저의 방 생성·참가와 준비 상태 동기화 확인
- [ ] `.env`, 토큰, 비밀번호가 Git에 없는지 확인
## 향후 실시간 서버
## 실시간 서버 구조
Socket.IO 서버가 추가되면 브라우저에 서버 포트를 직접 공개하지 않는다.
브라우저에 Socket.IO 서버 포트를 직접 공개하지 않는다.
```text
브라우저
@@ -367,5 +376,16 @@ Socket.IO 서버가 추가되면 브라우저에 서버 포트를 직접 공개
```
Docker 내부에서는 다른 컨테이너와 같은 포트를 사용해도 충돌하지 않는다.
Socket.IO 서비스는 `ports` 대신 내부 통신용 `expose`를 사용하는 방향을
우선한다.
Socket.IO 서비스는 `ports` 대신 내부 통신용 `expose`를 사용한다.
실시간 서버 로그만 확인하려면 다음 명령을 사용한다.
```bash
docker compose \
-f compose.yaml \
-f compose.nas.yaml \
logs --tail=100 playuniare-server
```
현재 방 상태는 서버 메모리에만 저장된다. `playuniare-server`를 재시작하거나
재배포하면 진행 중인 방은 종료되므로, 이용자가 없는 시간에 업데이트한다.

View File

@@ -32,6 +32,16 @@ npm install
## 개발 서버 실행
실시간 대기실을 사용하므로 터미널 두 개를 연다.
첫 번째 터미널에서 Socket.IO 서버를 실행한다.
```bash
npm run dev:server
```
두 번째 터미널에서 Vue 개발 서버를 실행한다.
```bash
npm run dev
```
@@ -45,7 +55,8 @@ Local: http://localhost:5173/
해당 주소를 브라우저에서 연다. 개발 중 코드를 저장하면 화면이 자동으로
갱신된다.
개발 서버를 종료하려면 실행 중인 터미널에서 `Control + C`를 누른다.
Vite는 브라우저의 `/socket.io` 요청을 로컬 `31873` 포트로 전달한다. 두
개발 서버를 종료하려면 각각의 터미널에서 `Control + C`를 누른다.
## 같은 네트워크의 다른 기기에서 테스트
@@ -80,13 +91,14 @@ npm run preview
로컬 전용 값은 프로젝트 루트의 `.env.local`에 작성한다.
```text
VITE_CARD_ASSET_BASE_URL=http://127.0.0.1:5500/images
VITE_SOCKET_SERVER_URL=http://localhost:31873
VITE_CARD_ASSET_BASE_URL=http://playuniare.sori.studio/card-assets
```
- `VITE_`로 시작하는 값은 브라우저에 노출될 수 있다.
- 비밀번호, 개인키, NAS 관리자 계정은 넣지 않는다.
- `.env.local`은 Git에 커밋하지 않는다.
- 로컬 개발에서 실제 카드 이미지를 확인하려면 운영 카드 자산 주소를
`VITE_CARD_ASSET_BASE_URL`에 사용한다.
## 일반적인 작업 순서
@@ -99,10 +111,14 @@ npm run dev
작업이 끝난 뒤:
```bash
npm run test:server
npm run build
git status
```
`npm run test:server`는 `npm run dev:server`가 실행 중일 때 방 생성, 참가,
덱 등록, 양쪽 준비와 게임 시작 이벤트를 검증한다.
변경 문서를 확인한 후 커밋하고 푸시한다.
## 자주 발생하는 문제
@@ -112,9 +128,24 @@ git status
Vite가 다른 포트를 자동으로 제시할 수 있다. 터미널에 새로 표시된 주소로
접속한다. 반드시 5173 포트를 고정할 필요는 없다.
향후 로컬 Socket.IO 서버는 기본적으로 `31873` 포트를 사용한다. 이 포트도
이미 사용 중이라면 서버 환경변수에서 다른 포트로 변경하고
`VITE_SOCKET_SERVER_URL`을 같은 값으로 맞춘다.
로컬 Socket.IO 서버는 기본적으로 `31873` 포트를 사용한다. 이 포트가 이미
사용 중이면 두 터미널에서 같은 환경변수를 사용한다.
```bash
SOCKET_SERVER_PORT=31874 npm run dev:server
SOCKET_SERVER_PORT=31874 npm run dev
```
운영에서는 `31873`을 Docker 내부에서만 사용하므로 NAS의 다른 컨테이너가
내부에서 같은 번호를 사용해도 충돌하지 않는다.
### 실시간 서버에 연결되지 않는 경우
- `npm run dev:server` 터미널이 실행 중인지 확인한다.
- 서버 터미널에 `listening on port 31873` 메시지가 있는지 확인한다.
- 포트를 바꿨다면 Vue와 Socket.IO 서버를 같은 `SOCKET_SERVER_PORT`로
실행했는지 확인한다.
- 브라우저 개발자 도구 Network 탭에서 `/socket.io` 요청을 확인한다.
### 패키지 오류가 발생하는 경우

View File

@@ -67,3 +67,21 @@
- 테스트 Compose 파일과 테스트 포트 환경변수를 제거했다.
- 운영 주소를 `http://playuniare.sori.studio`로 확인했다.
- 영구 데이터가 생길 때 적용할 마이그레이션 운영 원칙을 문서화했다.
## 2026-06-19: 대기실과 게임 화면 분리
- Vue Router를 도입해 `/` 대기실과 `/game` 게임 화면을 분리했다.
- Pinia를 도입해 방 코드와 플레이어 준비 상태를 관리한다.
- 덱 등록 시 50장, 동일 카드, 트리거, AP 기본 규칙을 검사한다.
- 게임 시작 준비로 첫 손패, 1회 멀리건, 라이프 7장을 구현했다.
## 2026-06-19: Socket.IO 온라인 대기실
- Node.js Socket.IO 서버를 추가하고 방 상태를 서버 메모리에서 관리한다.
- 6자리 방 생성, 방 코드 참가, 서버 덱 재검증, 준비 상태 동기화를 구현했다.
- 양쪽 준비 완료 시 플레이어별 좌석과 자신의 덱 쿼리를 전달한다.
- 브라우저는 같은 출처의 `/socket.io`로 연결하고 Nginx가 Docker 내부
`playuniare-server:31873`으로 프록시한다.
- Socket.IO 포트는 NAS 호스트에 직접 공개하지 않는다.
- 현재 서버 재시작 시 방이 사라지는 단순한 구조를 유지하고, 게임 상태
동기화와 재접속 복구는 후속 단계에서 구현한다.

View File

@@ -4,10 +4,53 @@
### `/`
- 파일: `src/App.vue`
- 역할: 프로젝트 준비 상태를 보여주는 초기 랜딩 화면
- 현재 기능: 제품 목표 안내
- 다음 변경: 로비 화면과 게임 화면을 Vue Router로 분리
- 파일: `src/views/LobbyView.vue`
- 역할: 비공개 게임 대기실
- 현재 기능:
- 방 생성과 6자리 초대 코드
- 방 코드 참가 입력
- 덱 결과 URL 등록
- 50장·동일 카드·트리거·AP 기본 검증
- Socket.IO 서버 연결 상태
- 서버 기반 방 생성·방 코드 참가
- 양쪽 플레이어의 좌석·덱 등록·준비 상태
- 양쪽 준비 완료 시 게임 화면 자동 이동
- 유효한 덱으로 로컬 게임 테스트
### `/game`
- 파일: `src/views/GameView.vue`
- 주요 컴포넌트: `src/components/game-board/GameBoard.vue`
- 플레이 보드 자산: `src/assets/playboards/union-arena-standard.png`
- 역할: 실제 카드 조작 게임 화면
- 현재 기능:
- 헤더 없는 전체 화면 16:9 플레이 보드
- `F` 키 브라우저 전체화면 전환
- 카드 크기 기준 표시·숨김
- 실제 카드 이미지 한 장 표시
- 독립 카드 인스턴스 3장 겹침 표시
- 기존 덱 빌더 `series`·`cards` 쿼리 불러오기
- 불러온 덱을 내 덱 영역에 뒷면으로 쌓기
- 잘못된 덱 데이터 오류 안내
- 내 덱 1장 드로우와 셔플
- 덱 위 1~10장 공개
- 중앙 공개 영역 자동 정렬
- 하단 손패 자동 정렬
- 우클릭 메뉴의 손패·덱 위·덱 아래·무덤 이동
- 첫 손패 7장
- 선후공 선택과 1회 멀리건
- 라이프 7장 세팅
- 턴 시작 AP·드로우·일괄 언탭
- 카드별 자동 언탭 제외 잠금
- 마우스·터치 포인터 기반 자유 드래그
- 카드 더블클릭 탭·언탭
- 카드 우클릭 조작 메뉴
- 우클릭 메뉴의 앞면·뒷면 전환
- 선택 카드 레이어 순서 올리기
- 선택 카드 좌표·레이어·앞뒤·탭 상태 표시
- 좌우 플레이어 정보 패널
- 라이프, 덱·손패 장수, 연결 상태와 메모 자리
- 다음 변경: 게임 화면의 카드 상태를 Socket.IO 서버와 동기화
- 문서 제목과 기본 언어: `index.html`의 `Play Uniare`, 한국어
## 예정 화면
@@ -28,17 +71,45 @@
- 카드 조작 메뉴
- 연결 상태와 재접속 안내
## 카드 우클릭 메뉴
- 선택 카드 상태에 따라 `앞면으로`·`뒷면으로`, `탭`·`언탭` 문구를 바꾼다.
- 선택 카드를 가장 위 레이어로 올릴 수 있다.
- 선택 카드를 손패, 덱 위, 덱 아래, 무덤으로 이동할 수 있다.
- 화면 가장자리에서 메뉴가 잘리지 않도록 위치를 제한한다.
- 바깥 영역 클릭 또는 `Esc` 키로 닫는다.
## 덱 쿼리 처리
- 파일: `src/services/deckQuery.js`
- 쿼리가 있으면 카드 수량을 독립 인스턴스로 확장한다.
- 쿼리가 없으면 연습 카드 3장을 사용한다.
- 카드 이미지 주소는 `VITE_CARD_ASSET_BASE_URL`을 기준으로 만든다.
## 덱 조작 패널
- 플레이 보드의 내 덱 옆에 작은 반투명 패널로 표시한다.
- 현재 덱 장수를 표시한다.
- 덱이 비어 있으면 드로우를 비활성화한다.
- 덱이 2장 미만이면 셔플을 비활성화한다.
- 공개할 카드 수를 1~10장으로 지정한다.
- 공개 중인 카드 장수를 표시한다.
## 예정 디렉터리 구조
```text
src/
├── components/
│ ├── cards/
│ │ └── GameCard.vue
│ ├── game-board/
│ │ └── GameBoard.vue
│ └── room/
├── composables/
├── services/
├── stores/
├── router/
├── stores/
├── types/
├── utils/
└── views/
@@ -48,6 +119,7 @@ src/
- `composables`: Vue 반응형 조합 로직
- `services`: 쿼리 파싱, API, Socket.IO 연결
- `stores`: Pinia 기반 게임·방·사용자 상태
- `router`: 대기실과 게임 화면 경로
- `types`: JSDoc 타입 정의
- `utils`: Vue에 의존하지 않는 순수 함수
- `views`: Router 단위 화면

View File

@@ -4,7 +4,7 @@
- 프로젝트명: Play Uniare
- 목적: Union Arena 덱을 불러와 카드 조작 중심의 로컬·온라인 플레이 제공
- 현재 단계: 프로젝트 기반 구성
- 현재 단계: 로컬 카드 조작과 온라인 대기실 연결
- 프런트엔드: Vue 3, Vite, Tailwind CSS
- 배포 대상: UGREEN NAS의 Docker 환경
@@ -24,6 +24,9 @@
- 혼자 연습하는 로컬 모드
- 방 코드 기반 2인 세션
- 각 플레이어가 자신의 카드만 조작하는 권한 모델
- 비공개 방 코드 기반 대기실
- 덱 URL 등록과 기본 덱 구성 검증
- 양쪽 플레이어 준비 완료 후 게임 시작
### 제외
@@ -47,9 +50,44 @@
3. `series`, `imgSrc`, `Number`, `count`를 검증한다.
4. 카드 수량만큼 고유한 게임 카드 인스턴스를 만든다.
5. 이미지 주소는 환경변수 기반 카드 자산 원본과 조합한다.
6. 각 카드의 `count`만큼 고유한 `instanceId`를 가진 게임 카드로 확장한다.
7. 불러온 카드는 내 덱 영역에 뒷면으로 쌓는다.
장기적으로는 축약된 덱 코드 또는 서버의 `deckId` 방식으로 전환한다.
- 카드 종류별 수량은 1~100 범위만 허용한다.
- 한 번에 불러오는 전체 카드 수는 최대 100장으로 제한한다.
- 잘못된 카드 항목은 제외하고 화면에 오류 내용을 표시한다.
- `series`와 이미지 파일명은 허용된 문자와 확장자만 사용한다.
- 덱 쿼리가 없을 때에는 조작 검증용 연습 카드 3장을 표시한다.
## 덱 등록 검증
- 대기실에서 덱 빌더 결과 페이지 전체 URL을 등록한다.
- `series` 값 하나를 작품 식별자로 사용해 단일 작품 덱임을 확인한다.
- 메인 덱은 정확히 50장이어야 한다.
- 동일 카드 제한은 현재 데이터의 `Number`를 기준으로 최대 4장까지 검사한다.
- FINAL, COLOR, SPECIAL 트리거는 각각 합계 4장까지 허용한다.
- AP 색상 카드는 메인 덱에 포함하지 않고 별도 3장을 사용하는 것으로
검사한다.
- 현재 카드 데이터에는 카드 이름 전용 필드가 없으므로 동일 이름 제한을 카드
번호 기준으로 대체한다. 향후 이름 필드가 추가되면 이름 기준으로 전환한다.
## 대기실과 방
- `/`는 비공개 게임 대기실이다.
- 방장은 6자리 초대 코드를 생성한다.
- 참가자는 초대 코드를 입력해 같은 방에 참가한다.
- 각 플레이어는 자신의 덱 URL을 등록하고 준비 완료를 누른다.
- 두 플레이어의 연결·덱 등록·준비 완료가 모두 확인되어야 온라인 게임을
시작한다.
- 방 상태와 덱 등록 결과는 Socket.IO 서버가 최종 검증한다.
- 두 플레이어가 모두 준비하면 서버가 각 플레이어에게 자신의 덱 쿼리와
좌석을 전달하고 `/game`으로 자동 이동시킨다.
- 방은 서버 메모리에 저장하므로 서버 재시작 시 종료된다.
- 기능 개발을 위해 유효한 덱으로 `/game`에 진입하는 로컬 게임 테스트를
제공한다.
## 권한 모델
- 플레이어는 자기 카드만 이동·뒤집기·탭·정리할 수 있다.
@@ -60,9 +98,16 @@
## 화면 좌표
- 배경 이미지는 카드 배치 가이드로 사용한다.
- 표준 플레이 보드 원본은 3840×2160의 16:9 이미지다.
- 표준 카드 비율은 너비÷높이 약 `0.716`으로 사용한다.
- 표준 카드 너비는 플레이 보드 전체 너비의 `5.75%`를 기준으로 한다.
- 실제 영역은 배경 위의 투명한 드롭 영역으로 정의한다.
- 자유 배치 좌표는 보드 너비·높이 기준 0부터 1 사이 값으로 저장한다.
- 드래그 중인 카드는 보드 경계를 벗어나지 않도록 카드 반쪽 크기만큼 좌표를
제한한다.
- 창 크기가 변해도 같은 상대 위치를 유지해야 한다.
- 보드 좌우 여백은 플레이어별 라이프, 덱·손패 장수, 연결 상태와 메모를
표시하는 정보 패널 영역으로 활용한다.
## 상태 저장
@@ -70,6 +115,70 @@
- 온라인 모드: 서버가 방 상태의 최종 권위를 가진다.
- 초기 서버: Node.js와 Socket.IO, 메모리 기반 방 상태
- 운영 안정화 단계: Redis 또는 데이터베이스 저장 검토
- 서버의 공개 방 상태를 Pinia가 수신해 화면에 표시한다.
- 공개 방 상태에는 상대 덱의 작품명과 장수만 포함하며 카드 목록은 포함하지
않는다.
## 카드 직접 조작
- 포인터 입력으로 카드를 선택하고 자유롭게 이동한다.
- 카드 더블클릭으로 탭·언탭을 전환한다.
- 카드 우클릭 메뉴에서 앞면·뒷면, 탭·언탭, 맨 위로 이동을 조작한다.
- 탭 상태는 카드를 시계 방향으로 90도 회전해 표현한다.
- 카드를 드래그하거나 `맨 위로`를 누르면 `stackOrder`를 증가시킨다.
- 여러 카드가 겹친 경우 가장 큰 `stackOrder`를 가진 카드를 위에 표시한다.
- 탭 카드의 보드 경계는 회전된 실제 너비와 높이를 기준으로 제한한다.
- 뒷면 전용 이미지가 확정되기 전에는 공통 Union Arena 카드 뒷면 스타일을
CSS로 표시한다.
- 키보드 포커스로도 카드를 선택할 수 있어야 한다.
- 개발 기준 화면에서는 같은 카드의 독립 인스턴스 3장으로 겹침과 선택
동작을 검증한다.
## 덱·손패·무덤 동작
- 덱 카드는 보드의 내 덱 위치에 뒷면으로 겹쳐 표시한다.
- `1장 드로우`는 가장 높은 `stackOrder`의 덱 카드를 손패로 이동한다.
- 손패 카드는 앞면으로 표시하고 보드 하단 중앙에 자동 정렬한다.
- 손패 카드가 많아지면 제한된 폭 안에서 카드 간격을 줄인다.
- 손패·덱·무덤 카드를 드래그하면 필드 카드로 전환한다.
- 셔플은 덱 카드의 레이어 순서를 무작위로 다시 배정하고 모두 뒷면·언탭으로
정리한다.
- 우클릭 메뉴에서 선택 카드를 손패, 덱 위, 덱 아래, 무덤으로 이동한다.
- 덱 위 카드는 가장 큰 `stackOrder`, 덱 아래 카드는 가장 작은
`stackOrder`를 사용한다.
- 무덤 카드는 앞면으로 무덤 기준 위치에 겹쳐 표시한다.
- 플레이어 정보 패널의 덱·손패 장수는 현재 영역 상태에서 계산한다.
- 덱 패널에서 1~10장 사이의 공개 수량을 입력한다.
- 공개 명령은 덱 위에서 지정한 장수를 가져와 보드 중앙 공개 영역에 앞면으로
정렬한다.
- 공개 영역의 카드는 우클릭 메뉴로 각각 덱 위·덱 아래·손패·무덤에
정리한다.
- 공개 영역 카드가 이동하면 남은 공개 카드를 다시 중앙 정렬한다.
## 게임 준비와 턴 시작
- 유효한 덱으로 게임에 진입하면 덱을 섞고 첫 손패 7장을 드로우한다.
- 멀리건은 한 번만 가능하다.
- 멀리건은 기존 손패를 제외한 덱에서 새 7장을 먼저 뽑고, 기존 손패를 덱에
되돌린 뒤 섞는다.
- 손패 확정 시 덱 위 7장을 보지 않고 라이프 영역에 뒷면으로 배치한다.
- AP 카드는 별도 3장을 사용하며 현재 화면에서는 사용 가능 AP를 숫자로
표시한다.
- 선공 1턴 AP 1, 2턴 AP 2, 3턴 이후 AP 3으로 갱신한다.
- 후공 1턴 AP 2, 2턴 이후 AP 3으로 갱신한다.
- 선공 첫 턴에는 자동 드로우하지 않고 그 외 턴 시작에는 1장 드로우한다.
- 턴 시작 시 내 필드의 탭 카드를 모두 언탭한다.
- `isUntapLocked`가 설정된 카드는 턴 시작 자동 언탭에서 제외한다.
- 언탭 제외 잠금은 카드 우클릭 메뉴에서 설정·해제한다.
## 게임 화면
- 실제 게임 화면에는 고정 헤더를 표시하지 않는다.
- 16:9 플레이 보드를 뷰포트 안에서 가능한 가장 크게 표시한다.
- 남는 공간은 검은색 레터박스로 처리한다.
- `F` 키 또는 우측 상단의 반투명 버튼으로 브라우저 전체화면을 전환한다.
- `Esc` 키로 브라우저 전체화면과 카드 우클릭 메뉴를 닫을 수 있다.
- 카드 크기 기준 가이드는 기본적으로 숨긴다.
## 포트 정책
@@ -86,14 +195,16 @@
경로를 사용한다.
- 포트 번호는 코드에 하드코딩하지 않고 환경변수와 Compose 설정으로 관리한다.
## 환경변수 예정
## 환경변수
```text
VITE_CARD_ASSET_BASE_URL=/card-assets
VITE_SOCKET_SERVER_URL=http://playuniare.sori.studio
```
실제 운영 값은 `.env`에 두고 Git에 커밋하지 않는다.
브라우저 Socket.IO 클라이언트는 별도 서버 URL 없이 현재 접속한 출처의
`/socket.io`를 사용한다. 로컬 서버 포트를 바꿀 때만 실행 명령에
`SOCKET_SERVER_PORT`를 지정한다. 실제 운영 값은 `.env`에 두고 Git에
커밋하지 않는다.
## NAS 카드 이미지 공유
@@ -120,6 +231,8 @@ Play Uniare 컨테이너 내부:
- Nginx 작업 프로세스는 NAS 카드 파일 소유자와 같은 UID `1000`의
`cardreader` 사용자로 실행한다.
- 카드 이미지 URL은 `/card-assets/<series>/<fileName>` 형식을 사용한다.
- 프런트엔드는 `VITE_CARD_ASSET_BASE_URL`과 시리즈·파일명을 조합해 카드
이미지 URL을 생성한다.
- 같은 Play Uniare 도메인으로 제공되므로 별도의 CORS 설정이 필요하지 않다.
## 배포 환경

View File

@@ -7,42 +7,59 @@
- [x] Docker 정적 배포 기반 작성
- [x] 개발·배포·AI 작업 문서 작성
- [ ] ESLint와 Prettier 규칙 확정
- [ ] Vue Router 설치와 화면 구조 생성
- [ ] Pinia 설치와 상태 구조 생성
- [x] Vue Router 설치와 대기실·게임 화면 분리
- [x] Pinia 설치와 방 상태 구조 생성
- [ ] 테스트 도구 도입 범위 결정
- [ ] `GameBoard.vue`의 영역 상태와 조작 로직을 composable·store로 분리
## 1단계: 덱 불러오기와 로컬 플레이
- [ ] 기존 덱 쿼리 파서 작성
- [ ] 외부 입력 검증 스키마 작성
- [x] 기존 덱 쿼리 파서 작성
- [x] 외부 입력 기본 검증 작성
- [x] 대기실 덱 URL 등록과 50장 기본 규칙 검증
- [x] NAS 카드 이미지 읽기 전용 공유 경로 구성
- [ ] 카드 이미지 원본 환경변수를 프런트엔드 코드에 연결
- [ ] 카드 인스턴스 생성
- [ ] 게임 보드 배경과 영역 정의
- [ ] 덱 셔플과 드로우
- [ ] 카드 드래그 이동
- [ ] 앞면·뒷면 전환
- [ ] 탭·언탭
- [ ] 덱·손패·필드·무덤 이동
- [ ] 덱 위 N장 공개와 정리
- [x] 카드 이미지 원본 환경변수를 프런트엔드 코드에 연결
- [x] 덱 데이터 기반 카드 인스턴스 생성
- [x] 표준 플레이 보드 배경과 카드 크기 기준 정의
- [x] 좌우 플레이어 정보 패널 자리 정의
- [ ] 플레이 보드 드롭 영역 좌표 정의
- [x] 덱 셔플과 1장 드로우
- [x] 단일 카드 정규화 좌표 드래그 이동
- [x] 여러 카드 드래그와 겹침 순서 처리
- [x] 단일 카드 앞면·뒷면 전환
- [x] 단일 카드 탭·언탭
- [x] 카드 더블클릭 탭·언탭
- [x] 카드 우클릭 조작 메뉴
- [x] `F` 키 전체화면 전환
- [x] 덱·손패·필드·무덤 기본 이동
- [x] 덱 위 N장 공개와 개별 정리
- [x] 첫 손패 7장과 1회 멀리건
- [x] 라이프 7장 세팅
- [x] 선후공별 AP와 턴 시작 드로우
- [x] 턴 시작 일괄 언탭과 카드별 제외 잠금
- [ ] 로컬 상태 초기화
## 2단계: 온라인 2인 세션
- [ ] Node.js 실시간 서버 구성
- [ ] Socket.IO 연결
- [ ] 방 만들기와 방 코드 참가
- [ ] 좌석과 플레이어 소유권
- [x] Node.js 실시간 서버 구성
- [x] Socket.IO 연결
- [x] 방 만들기와 방 코드 참가
- [x] 방 만들기·참가 대기실 UI
- [x] 방 만들기·참가 서버 동기화
- [x] 플레이어 좌석 할당
- [ ] 게임 카드 플레이어 소유권
- [ ] 카드 조작 권한 서버 검증
- [ ] 상대 손패 비공개 전송
- [ ] 상태 스냅샷과 재접속
- [ ] 연결 종료 처리
- [ ] NAS WebSocket 프록시 배포
- [x] 연결 종료 시 방 참가자 정리
- [x] NAS WebSocket 프록시 배포
- [ ] 비어 있지 않은 방의 유휴 만료 처리
## 3단계: 안정화
- [ ] 모바일·태블릿 Pointer Events 검증
- [ ] 카드 겹침과 z-index 조작 UX
- [x] 선택 카드 맨 위 레이어 이동
- [x] 여러 카드 겹침 순서 조작 UX 검증
- [ ] 잘못된 덱 입력 오류 화면
- [ ] 이미지 로딩 실패 폴백
- [ ] 접근성 키보드 조작 범위 검토
@@ -51,7 +68,8 @@
## 확인이 필요한 정책
- [ ] 카드 뒷면 이미지와 플레이매트 자산 확정
- [x] 표준 플레이매트 자산 확정
- [ ] 카드 뒷면 이미지 확정
- [ ] 한 방의 최대 플레이어와 관전자 정책
- [ ] 방 유지 시간
- [ ] 사용하지 않는 방 자동 삭제 시간

View File

@@ -21,3 +21,46 @@
배포 문서를 단순화했다.
- 운영 주소 `http://playuniare.sori.studio`와 향후 데이터 마이그레이션
원칙을 문서화했다.
- 3840×2160 표준 플레이 보드를 반응형 화면에 적용하고 카드 크기 기준
가이드를 추가했다.
- 보드 좌우 여백에 양쪽 플레이어의 라이프와 상태 정보를 표시하는 패널을
배치했다.
- `/card-assets`의 실제 카드 한 장을 보드 기준 크기로 표시하고, 보드
정규화 좌표를 사용하는 마우스·터치 드래그를 구현했다.
- 로컬 개발에서 운영 카드 자산을 사용할 수 있도록 `.env.local` 예시를
현재 운영 주소로 수정했다.
- 화면과 배포 점검에 사용하는 기준 카드를
`ua01/EX02BT_CGH-2-007.png`로 변경했다.
- 선택 카드 조작 바를 추가하고 앞면·뒷면, 탭·언탭, 맨 위 레이어 이동을
구현했다.
- 카드 뒷면 임시 스타일과 키보드 포커스 선택을 추가했다.
- 독립 카드 인스턴스 3장을 겹쳐 배치하고 선택·드래그 시 최상단으로 올리는
레이어 처리를 추가했다.
- 선택 카드의 정규화 좌표와 상태를 표시하고 탭 회전 상태의 보드 경계 계산을
보강했다.
- 기존 덱 빌더의 `series`와 `cards` 쿼리를 검증해 카드 수량만큼 독립
인스턴스로 확장하는 덱 파서를 추가했다.
- 불러온 카드를 내 덱 영역에 뒷면으로 쌓고 잘못된 항목은 오류로 표시한다.
- 게임 화면의 고정 헤더를 제거하고 16:9 보드가 뷰포트를 최대한 채우도록
변경했다.
- `F` 키 전체화면, 카드 더블클릭 탭·언탭, 우클릭 카드 조작 메뉴를
추가했다.
- 내 덱의 1장 드로우와 셔플을 구현하고 손패 카드를 화면 하단에 자동
정렬하도록 했다.
- 우클릭 메뉴에 손패, 덱 위, 덱 아래, 무덤 이동 명령을 추가하고 영역별
덱·손패 카운터를 연결했다.
- 덱 위에서 1~10장을 공개해 중앙에 정렬하고 각 카드를 기존 우클릭 메뉴로
정리하는 기능을 추가했다.
- Vue Router와 Pinia를 도입하고 메인 화면을 비공개 방 대기실로 변경했다.
- 덱 URL 등록과 50장·동일 카드 4장·트리거별 4장·AP 분리 검증을 추가했다.
- 게임 진입 후 첫 손패 7장, 1회 멀리건, 라이프 7장 세팅을 구현했다.
- 턴 시작 시 선후공별 AP·드로우를 처리하고 잠기지 않은 내 필드 카드를
일괄 언탭하도록 했다.
- 게임 보드 기능이 커짐에 따라 후속 작업에서 영역 상태와 조작 로직을
composable·store로 분리할 항목을 등록했다.
- Socket.IO 서버와 실제 방 생성·참가·덱 등록·준비 동기화를 추가했다.
- 두 플레이어가 모두 준비하면 각 좌석에 맞는 게임 시작 정보를 전달하도록
구현했다.
- 방을 이동할 때 이전 Socket.IO 채널에서 확실히 나가도록 정리했다.
- Nginx의 `/socket.io` 프록시와 Docker 내부 실시간 서버 서비스를 추가했다.
- 방 생성부터 양쪽 게임 시작까지 검증하는 2인 통합 테스트를 추가했다.

931
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -5,11 +5,18 @@
"type": "module",
"scripts": {
"dev": "vite",
"dev:server": "node --watch server/index.js",
"start:server": "node server/index.js",
"test:server": "node server/integration-test.js",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"vue": "^3.5.34"
"pinia": "^3.0.4",
"socket.io": "^4.8.3",
"socket.io-client": "^4.8.3",
"vue": "^3.5.34",
"vue-router": "^5.1.0"
},
"devDependencies": {
"@tailwindcss/vite": "^4.3.1",

281
server/index.js Normal file
View File

@@ -0,0 +1,281 @@
import { createServer } from 'node:http'
import { randomInt } from 'node:crypto'
import { Server } from 'socket.io'
import { validateDeckRegistrationUrl } from '../src/services/deckRegistration.js'
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 rooms = new Map()
const socketRoomCodes = new Map()
/**
* 사람이 읽기 쉬운 비공개 방 코드를 생성한다.
*
* @returns {string} 현재 방 목록과 겹치지 않는 방 코드
*/
function createUniqueRoomCode() {
let roomCode = ''
do {
roomCode = Array.from({ length: ROOM_CODE_LENGTH }, () => {
const characterIndex = randomInt(ROOM_CODE_CHARACTERS.length)
return ROOM_CODE_CHARACTERS[characterIndex]
}).join('')
} while (rooms.has(roomCode))
return roomCode
}
/**
* 클라이언트에 공개할 안전한 방 상태를 만든다.
*
* @param {object} room 방 상태
* @returns {object} 덱 상세 정보를 제외한 방 상태
*/
function createPublicRoomState(room) {
return {
roomCode: room.roomCode,
hasStarted: room.hasStarted,
players: Array.from(room.players.values()).map((player) => ({
playerId: player.playerId,
seat: player.seat,
isConnected: true,
isReady: player.isReady,
hasRegisteredDeck: Boolean(player.deckRegistration?.isValid),
deckSeriesName: player.deckRegistration?.seriesName || '',
deckCardCount: player.deckRegistration?.totalCardCount || 0,
})),
}
}
/**
* 방 참가자 모두에게 최신 공개 상태를 전송한다.
*
* @param {Server} socketServer Socket.IO 서버
* @param {object} room 방 상태
* @returns {void}
*/
function broadcastRoomState(socketServer, room) {
socketServer
.to(room.roomCode)
.emit('room:state', createPublicRoomState(room))
}
/**
* 소켓이 현재 참가 중인 방과 플레이어 상태를 찾는다.
*
* @param {string} socketId 소켓 식별자
* @returns {{ room: object, player: object } | null} 방과 플레이어 상태
*/
function findSocketRoomContext(socketId) {
const roomCode = socketRoomCodes.get(socketId)
const room = roomCode ? rooms.get(roomCode) : null
const player = room?.players.get(socketId)
return room && player ? { room, player } : null
}
/**
* 현재 소켓을 기존 방에서 제거하고 빈 방을 정리한다.
*
* @param {Server} socketServer Socket.IO 서버
* @param {import('socket.io').Socket} socket 방을 나갈 소켓
* @returns {void}
*/
function leaveCurrentRoom(socketServer, socket) {
const socketId = socket.id
const roomCode = socketRoomCodes.get(socketId)
const room = roomCode ? rooms.get(roomCode) : null
socketRoomCodes.delete(socketId)
if (roomCode) {
socket.leave(roomCode)
}
if (!room) return
room.players.delete(socketId)
if (room.players.size === 0) {
rooms.delete(roomCode)
return
}
room.hasStarted = false
broadcastRoomState(socketServer, room)
}
/**
* 양쪽 플레이어가 준비되면 각자 자신의 덱 정보로 게임 시작을 알린다.
*
* @param {Server} socketServer Socket.IO 서버
* @param {object} room 방 상태
* @returns {void}
*/
function startRoomGameWhenReady(socketServer, room) {
const players = Array.from(room.players.values())
const canStartGame =
players.length === MAXIMUM_ROOM_PLAYERS &&
players.every(
(player) => player.isReady && player.deckRegistration?.isValid,
)
if (!canStartGame || room.hasStarted) return
room.hasStarted = true
broadcastRoomState(socketServer, room)
players.forEach((player) => {
socketServer.to(player.playerId).emit('game:started', {
roomCode: room.roomCode,
seat: player.seat,
gameQuery: player.deckRegistration.gameQuery,
})
})
}
const httpServer = createServer((request, response) => {
if (request.url === '/health') {
response.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' })
response.end('ok')
return
}
response.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' })
response.end('not found')
})
const socketServer = new Server(httpServer, {
path: '/socket.io',
})
socketServer.on('connection', (socket) => {
socket.on('room:create', (acknowledge) => {
leaveCurrentRoom(socketServer, socket)
const roomCode = createUniqueRoomCode()
const room = {
roomCode,
hasStarted: false,
players: new Map(),
}
const player = {
playerId: socket.id,
seat: 'player-one',
isReady: false,
deckRegistration: null,
}
room.players.set(socket.id, player)
rooms.set(roomCode, room)
socketRoomCodes.set(socket.id, roomCode)
socket.join(roomCode)
acknowledge?.({
ok: true,
playerId: socket.id,
room: createPublicRoomState(room),
})
broadcastRoomState(socketServer, room)
})
socket.on('room:join', (requestedRoomCode, acknowledge) => {
const roomCode =
typeof requestedRoomCode === 'string'
? requestedRoomCode.trim().toUpperCase()
: ''
const room = rooms.get(roomCode)
if (!room) {
acknowledge?.({ ok: false, error: '존재하지 않는 방 코드입니다.' })
return
}
if (room.hasStarted) {
acknowledge?.({ ok: false, error: '이미 게임이 시작된 방입니다.' })
return
}
if (room.players.size >= MAXIMUM_ROOM_PLAYERS) {
acknowledge?.({ ok: false, error: '방 인원이 가득 찼습니다.' })
return
}
leaveCurrentRoom(socketServer, socket)
const player = {
playerId: socket.id,
seat: 'player-two',
isReady: false,
deckRegistration: null,
}
room.players.set(socket.id, player)
socketRoomCodes.set(socket.id, roomCode)
socket.join(roomCode)
acknowledge?.({
ok: true,
playerId: socket.id,
room: createPublicRoomState(room),
})
broadcastRoomState(socketServer, room)
})
socket.on('player:register-deck', (deckUrlText, acknowledge) => {
const roomContext = findSocketRoomContext(socket.id)
if (!roomContext) {
acknowledge?.({ ok: false, error: '먼저 방에 참가해주세요.' })
return
}
const deckRegistration = validateDeckRegistrationUrl(
typeof deckUrlText === 'string' ? deckUrlText : '',
)
roomContext.player.deckRegistration = deckRegistration.isValid
? deckRegistration
: null
roomContext.player.isReady = false
acknowledge?.({
ok: deckRegistration.isValid,
validation: deckRegistration,
})
broadcastRoomState(socketServer, roomContext.room)
})
socket.on('player:set-ready', (requestedReadyState, acknowledge) => {
const roomContext = findSocketRoomContext(socket.id)
if (!roomContext) {
acknowledge?.({ ok: false, error: '먼저 방에 참가해주세요.' })
return
}
if (requestedReadyState && !roomContext.player.deckRegistration?.isValid) {
acknowledge?.({ ok: false, error: '유효한 덱을 먼저 등록해주세요.' })
return
}
roomContext.player.isReady = Boolean(requestedReadyState)
acknowledge?.({ ok: true })
broadcastRoomState(socketServer, roomContext.room)
startRoomGameWhenReady(socketServer, roomContext.room)
})
socket.on('room:leave', () => {
leaveCurrentRoom(socketServer, socket)
})
socket.on('disconnect', () => {
leaveCurrentRoom(socketServer, socket)
})
})
httpServer.listen(SERVER_PORT, '0.0.0.0', () => {
console.log(`Play Uniare Socket.IO server listening on port ${SERVER_PORT}`)
})

160
server/integration-test.js Normal file
View File

@@ -0,0 +1,160 @@
import assert from 'node:assert/strict'
import { io } from 'socket.io-client'
const SOCKET_SERVER_PORT = process.env.SOCKET_SERVER_PORT || '31873'
const SOCKET_SERVER_URL = `http://127.0.0.1:${SOCKET_SERVER_PORT}`
/**
* 테스트에 사용할 유효한 50장 덱 URL을 만든다.
*
* @returns {string} 덱 등록용 결과 페이지 URL
*/
function createValidDeckUrl() {
const cards = Array.from({ length: 13 }, (_, cardIndex) => ({
imgSrc: `/ua01/TEST-${String(cardIndex + 1).padStart(3, '0')}.png`,
Number: `TEST-${String(cardIndex + 1).padStart(3, '0')}`,
count: cardIndex === 12 ? 2 : 4,
color: 'Blue',
trigger: null,
}))
const deckUrl = new URL('http://uniare.sori.studio/result.html')
deckUrl.searchParams.set('series', 'ua01')
deckUrl.searchParams.set('seriesName', 'Code Geass')
deckUrl.searchParams.set('cards', JSON.stringify(cards))
return deckUrl.toString()
}
/**
* 연결 완료 또는 오류를 Promise로 기다린다.
*
* @param {import('socket.io-client').Socket} socket 확인할 클라이언트 소켓
* @returns {Promise<void>} 연결 완료 Promise
*/
function waitForConnection(socket) {
return new Promise((resolve, reject) => {
socket.once('connect', resolve)
socket.once('connect_error', reject)
})
}
/**
* Socket.IO 승인 응답이 있는 이벤트를 전송한다.
*
* @param {import('socket.io-client').Socket} socket 이벤트를 전송할 소켓
* @param {string} eventName 이벤트 이름
* @param {unknown[]} eventArguments 이벤트 인자
* @returns {Promise<object>} 서버 승인 응답
*/
function emitWithAcknowledgement(socket, eventName, ...eventArguments) {
return new Promise((resolve, reject) => {
socket.timeout(3000).emit(
eventName,
...eventArguments,
(timeoutError, response) => {
if (timeoutError) {
reject(timeoutError)
return
}
resolve(response)
},
)
})
}
/**
* 게임 시작 이벤트를 한 번 기다린다.
*
* @param {import('socket.io-client').Socket} socket 이벤트를 기다릴 소켓
* @returns {Promise<object>} 게임 시작 정보
*/
function waitForGameStart(socket) {
return new Promise((resolve) => {
socket.once('game:started', resolve)
})
}
/**
* 두 클라이언트의 방 생성부터 게임 시작까지 검증한다.
*
* @returns {Promise<void>} 검증 완료 Promise
*/
async function runIntegrationTest() {
const firstPlayerSocket = io(SOCKET_SERVER_URL, {
path: '/socket.io',
transports: ['websocket', 'polling'],
})
const secondPlayerSocket = io(SOCKET_SERVER_URL, {
path: '/socket.io',
transports: ['websocket', 'polling'],
})
try {
await Promise.all([
waitForConnection(firstPlayerSocket),
waitForConnection(secondPlayerSocket),
])
const createRoomResponse = await emitWithAcknowledgement(
firstPlayerSocket,
'room:create',
)
assert.equal(createRoomResponse.ok, true)
assert.equal(createRoomResponse.room.players.length, 1)
const joinRoomResponse = await emitWithAcknowledgement(
secondPlayerSocket,
'room:join',
createRoomResponse.room.roomCode,
)
assert.equal(joinRoomResponse.ok, true)
assert.equal(joinRoomResponse.room.players.length, 2)
const validDeckUrl = createValidDeckUrl()
const firstDeckResponse = await emitWithAcknowledgement(
firstPlayerSocket,
'player:register-deck',
validDeckUrl,
)
const secondDeckResponse = await emitWithAcknowledgement(
secondPlayerSocket,
'player:register-deck',
validDeckUrl,
)
assert.equal(firstDeckResponse.ok, true)
assert.equal(secondDeckResponse.ok, true)
const firstGameStart = waitForGameStart(firstPlayerSocket)
const secondGameStart = waitForGameStart(secondPlayerSocket)
await emitWithAcknowledgement(
firstPlayerSocket,
'player:set-ready',
true,
)
await emitWithAcknowledgement(
secondPlayerSocket,
'player:set-ready',
true,
)
const [firstStartPayload, secondStartPayload] = await Promise.all([
firstGameStart,
secondGameStart,
])
assert.equal(firstStartPayload.seat, 'player-one')
assert.equal(secondStartPayload.seat, 'player-two')
assert.equal(firstStartPayload.roomCode, secondStartPayload.roomCode)
assert.match(firstStartPayload.gameQuery, /series=ua01/)
console.log('Socket.IO 2인 방 통합 테스트를 통과했습니다.')
} finally {
firstPlayerSocket.disconnect()
secondPlayerSocket.disconnect()
}
}
await runIntegrationTest()

View File

@@ -1,40 +1,3 @@
<template>
<main class="min-h-screen bg-slate-950 px-6 py-16 text-slate-100">
<section class="mx-auto max-w-4xl">
<p class="mb-4 text-sm font-semibold tracking-[0.24em] text-cyan-400">
PLAY UNIARE
</p>
<h1 class="text-4xl font-black tracking-tight sm:text-6xl">
Union Arena Simulator
</h1>
<p class="mt-6 max-w-2xl text-base leading-7 text-slate-400 sm:text-lg">
덱 빌더에서 전달받은 카드로 혼자 연습하거나, 방을 만들어 상대와
실시간으로 플레이하는 시뮬레이터를 준비하고 있습니다.
</p>
<div class="mt-10 grid gap-4 sm:grid-cols-3">
<article class="rounded-2xl border border-slate-800 bg-slate-900 p-5">
<p class="text-sm font-bold text-cyan-300">01</p>
<h2 class="mt-3 text-lg font-bold">덱 불러오기</h2>
<p class="mt-2 text-sm leading-6 text-slate-400">
기존 덱 빌더의 쿼리 데이터를 안전하게 변환합니다.
</p>
</article>
<article class="rounded-2xl border border-slate-800 bg-slate-900 p-5">
<p class="text-sm font-bold text-cyan-300">02</p>
<h2 class="mt-3 text-lg font-bold">카드 조작</h2>
<p class="mt-2 text-sm leading-6 text-slate-400">
이동, 뒤집기, 탭과 덱·손패·무덤 이동을 제공합니다.
</p>
</article>
<article class="rounded-2xl border border-slate-800 bg-slate-900 p-5">
<p class="text-sm font-bold text-cyan-300">03</p>
<h2 class="mt-3 text-lg font-bold">2인 세션</h2>
<p class="mt-2 text-sm leading-6 text-slate-400">
각 플레이어가 자신의 카드만 조작하도록 동기화합니다.
</p>
</article>
</div>
</section>
</main>
<RouterView />
</template>

Binary file not shown.

After

Width:  |  Height:  |  Size: 201 KiB

View File

@@ -0,0 +1,152 @@
<script setup>
import { computed, ref } from 'vue'
const props = defineProps({
card: {
type: Object,
required: true,
},
isDragging: {
type: Boolean,
default: false,
},
isSelected: {
type: Boolean,
default: false,
},
})
const emit = defineEmits([
'context-menu-requested',
'double-clicked',
'drag-started',
'selected',
])
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)`,
}))
/**
* 카드 포인터 입력을 플레이 보드 드래그 처리기로 전달한다.
*
* @param {PointerEvent} pointerEvent 카드에서 시작된 포인터 이벤트
* @returns {void}
*/
function startCardDrag(pointerEvent) {
if (pointerEvent.button !== 0) return
emit('selected', props.card.instanceId)
emit('drag-started', {
cardInstanceId: props.card.instanceId,
pointerEvent,
})
}
/**
* 카드 이미지 로딩 실패 상태를 표시한다.
*
* @returns {void}
*/
function handleImageError() {
hasImageError.value = true
}
/**
* 카드 더블클릭을 탭 상태 전환 요청으로 전달한다.
*
* @param {MouseEvent} mouseEvent 더블클릭 이벤트
* @returns {void}
*/
function handleDoubleClick(mouseEvent) {
mouseEvent.preventDefault()
emit('double-clicked', props.card.instanceId)
}
/**
* 브라우저 기본 메뉴를 막고 카드 조작 메뉴 표시를 요청한다.
*
* @param {MouseEvent} mouseEvent 우클릭 이벤트
* @returns {void}
*/
function requestContextMenu(mouseEvent) {
mouseEvent.preventDefault()
emit('selected', props.card.instanceId)
emit('context-menu-requested', {
cardInstanceId: props.card.instanceId,
clientX: mouseEvent.clientX,
clientY: mouseEvent.clientY,
})
}
</script>
<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
? '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'
"
:style="positionStyle"
:aria-label="`${card.cardNumber} 카드 이동`"
@pointerdown="startCardDrag"
@focus="emit('selected', card.instanceId)"
@dblclick="handleDoubleClick"
@contextmenu="requestContextMenu"
>
<img
v-if="card.isFaceUp && !hasImageError"
:src="card.imageUrl"
:alt="card.cardNumber"
class="pointer-events-none h-full w-full rounded-[5%] object-cover"
draggable="false"
@error="handleImageError"
/>
<span
v-else-if="card.isFaceUp"
class="flex h-full w-full flex-col items-center justify-center rounded-[5%] border border-cyan-200/70 bg-slate-900 px-[8%] text-center text-[clamp(5px,0.45vw,9px)] font-bold leading-tight text-cyan-100"
>
<span>카드 이미지</span>
<span class="mt-[6%] text-slate-400">로딩 대기</span>
</span>
<span
v-else
class="flex h-full w-full items-center justify-center overflow-hidden rounded-[5%] border-[clamp(1px,0.18vw,3px)] border-amber-100 bg-[radial-gradient(circle_at_center,_#7f1d1d_0%,_#450a0a_55%,_#020617_100%)] p-[8%]"
>
<span
class="flex aspect-square w-full rotate-45 items-center justify-center rounded-[12%] border border-amber-200/80 bg-red-950/80 shadow-inner shadow-black"
>
<span
class="-rotate-45 text-center text-[clamp(5px,0.48vw,10px)] font-black tracking-[0.08em] text-amber-100"
>
UNION<br />ARENA
</span>
</span>
</span>
<span
v-if="isSelected"
class="pointer-events-none absolute -right-[10%] -top-[7%] flex aspect-square w-[24%] items-center justify-center rounded-full border border-yellow-100 bg-yellow-400 text-[clamp(6px,0.5vw,10px)] font-black text-slate-950 shadow-md"
>
✓
</span>
<span
v-if="card.isUntapLocked"
class="pointer-events-none absolute -left-[9%] -top-[7%] flex aspect-square w-[24%] items-center justify-center rounded-full border border-violet-100 bg-violet-500 text-[clamp(5px,0.42vw,9px)] font-black text-white shadow-md"
aria-label="언탭 제외 잠금"
>
LOCK
</span>
</button>
</template>

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,11 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import './style.css'
import App from './App.vue'
import { router } from './router/index.js'
createApp(App).mount('#app')
const application = createApp(App)
application.use(createPinia())
application.use(router)
application.mount('#app')

21
src/router/index.js Normal file
View File

@@ -0,0 +1,21 @@
import { createRouter, createWebHistory } from 'vue-router'
import GameView from '../views/GameView.vue'
import LobbyView from '../views/LobbyView.vue'
const routes = [
{
path: '/',
name: 'lobby',
component: LobbyView,
},
{
path: '/game',
name: 'game',
component: GameView,
},
]
export const router = createRouter({
history: createWebHistory(),
routes,
})

197
src/services/deckQuery.js Normal file
View File

@@ -0,0 +1,197 @@
const SERIES_KEY_PATTERN = /^[a-z0-9-]+$/i
const IMAGE_FILE_NAME_PATTERN = /^[a-z0-9_.-]+\.(png|webp|jpe?g)$/i
const MAXIMUM_CARD_COUNT = 100
const MAXIMUM_DECK_SIZE = 100
/**
* JSON 문자열을 일반 인코딩과 이중 인코딩 형식으로 순서대로 해석한다.
*
* @param {string} serializedCards 쿼리에서 읽은 카드 JSON 문자열
* @returns {unknown} 파싱된 JSON 값
*/
function parseSerializedCards(serializedCards) {
try {
return JSON.parse(serializedCards)
} catch {
return JSON.parse(decodeURIComponent(serializedCards))
}
}
/**
* 이미지 식별자에서 안전한 파일명만 추출한다.
*
* @param {unknown} imageSource 카드 데이터의 이미지 경로
* @returns {string | null} 검증된 이미지 파일명
*/
function extractImageFileName(imageSource) {
if (typeof imageSource !== 'string') return null
const imageFileName = imageSource.split('/').pop()
if (!imageFileName || !IMAGE_FILE_NAME_PATTERN.test(imageFileName)) return null
return imageFileName
}
/**
* 덱 카드 한 종류를 실제 게임 카드 인스턴스로 확장한다.
*
* @param {object} importedCard 덱 빌더에서 전달된 카드
* @param {number} importedCardIndex 원본 카드 배열 순서
* @param {string} seriesKey 시리즈 식별자
* @param {string} cardAssetBaseUrl 카드 이미지 기본 주소
* @param {number} startingStackOrder 시작 레이어 순서
* @returns {{ cards: object[], errorMessage: string | null }} 확장된 카드와 오류
*/
function expandImportedCard(
importedCard,
importedCardIndex,
seriesKey,
cardAssetBaseUrl,
startingStackOrder,
) {
const imageFileName = extractImageFileName(importedCard?.imgSrc)
const cardNumber =
typeof importedCard?.Number === 'string' ? importedCard.Number.trim() : ''
const cardCount = Number(importedCard?.count)
if (!imageFileName || !cardNumber) {
return {
cards: [],
errorMessage: `${importedCardIndex + 1}번째 카드의 이미지 또는 카드 번호가 올바르지 않습니다.`,
}
}
if (
!Number.isInteger(cardCount) ||
cardCount < 1 ||
cardCount > MAXIMUM_CARD_COUNT
) {
return {
cards: [],
errorMessage: `${cardNumber}의 수량이 1~${MAXIMUM_CARD_COUNT} 범위를 벗어났습니다.`,
}
}
const cards = Array.from({ length: cardCount }, (_, cardCopyIndex) => ({
instanceId: `local-player-card-${startingStackOrder + cardCopyIndex}`,
ownerId: 'local-player',
cardNumber,
imageUrl: `${cardAssetBaseUrl}/${seriesKey}/${imageFileName}`,
position: {
x: 0.744,
y: 0.63,
},
zone: 'deck',
isFaceUp: false,
isTapped: false,
isUntapLocked: false,
stackOrder: startingStackOrder + cardCopyIndex,
}))
return {
cards,
errorMessage: null,
}
}
/**
* 현재 URL 쿼리에서 기존 덱 빌더 형식의 덱을 읽고 게임 카드로 변환한다.
*
* @param {string} searchQuery `window.location.search` 형식의 쿼리 문자열
* @param {string} cardAssetBaseUrl 카드 이미지 기본 주소
* @returns {{ hasDeckQuery: boolean, seriesKey: string | null, cards: object[], errors: string[] }} 덱 변환 결과
*/
export function parseDeckQuery(searchQuery, cardAssetBaseUrl) {
const searchParameters = new URLSearchParams(searchQuery)
const seriesKey = searchParameters.get('series')
const serializedCards = searchParameters.get('cards')
const hasDeckQuery = Boolean(seriesKey || serializedCards)
if (!hasDeckQuery) {
return {
hasDeckQuery: false,
seriesKey: null,
cards: [],
errors: [],
}
}
if (!seriesKey || !SERIES_KEY_PATTERN.test(seriesKey)) {
return {
hasDeckQuery: true,
seriesKey: null,
cards: [],
errors: ['시리즈 식별자가 없거나 올바르지 않습니다.'],
}
}
if (!serializedCards) {
return {
hasDeckQuery: true,
seriesKey,
cards: [],
errors: ['카드 데이터가 없습니다.'],
}
}
let importedCards
try {
importedCards = parseSerializedCards(serializedCards)
} catch {
return {
hasDeckQuery: true,
seriesKey,
cards: [],
errors: ['카드 데이터를 JSON으로 해석하지 못했습니다.'],
}
}
if (!Array.isArray(importedCards)) {
return {
hasDeckQuery: true,
seriesKey,
cards: [],
errors: ['카드 데이터가 배열 형식이 아닙니다.'],
}
}
const cards = []
const errors = []
const deckSizeErrorMessage = `전체 덱은 최대 ${MAXIMUM_DECK_SIZE}장까지만 불러올 수 있습니다.`
importedCards.forEach((importedCard, importedCardIndex) => {
if (cards.length >= MAXIMUM_DECK_SIZE) {
if (!errors.includes(deckSizeErrorMessage)) {
errors.push(deckSizeErrorMessage)
}
return
}
const expansionResult = expandImportedCard(
importedCard,
importedCardIndex,
seriesKey,
cardAssetBaseUrl,
cards.length + 1,
)
const remainingCardCapacity = MAXIMUM_DECK_SIZE - cards.length
cards.push(...expansionResult.cards.slice(0, remainingCardCapacity))
if (expansionResult.errorMessage) {
errors.push(expansionResult.errorMessage)
}
if (expansionResult.cards.length > remainingCardCapacity) {
errors.push(deckSizeErrorMessage)
}
})
return {
hasDeckQuery: true,
seriesKey,
cards,
errors,
}
}

View File

@@ -0,0 +1,180 @@
const SERIES_KEY_PATTERN = /^[a-z0-9-]+$/i
const MAIN_DECK_SIZE = 50
const SAME_CARD_LIMIT = 4
const TRIGGER_LIMIT = 4
const LIMITED_TRIGGER_TYPES = ['FINAL', 'COLOR', 'SPECIAL']
/**
* 덱 URL의 카드 JSON을 일반 인코딩과 이중 인코딩 형식으로 해석한다.
*
* @param {string} serializedCards 카드 JSON 문자열
* @returns {unknown} 파싱된 카드 값
*/
function parseDeckCards(serializedCards) {
try {
return JSON.parse(serializedCards)
} catch {
return JSON.parse(decodeURIComponent(serializedCards))
}
}
/**
* 카드 수량을 안전한 정수로 변환한다.
*
* @param {unknown} cardCount 카드 수량
* @returns {number} 유효하지 않으면 0인 카드 수량
*/
function normalizeCardCount(cardCount) {
const normalizedCount = Number(cardCount)
return Number.isInteger(normalizedCount) && normalizedCount > 0
? normalizedCount
: 0
}
/**
* 기존 덱 빌더 URL을 파싱하고 유니온 아레나 기본 덱 조건을 검증한다.
*
* @param {string} deckUrlText 사용자가 붙여넣은 덱 URL
* @returns {{
* isValid: boolean,
* errors: string[],
* warnings: string[],
* seriesKey: string | null,
* seriesName: string,
* totalCardCount: number,
* cards: object[],
* gameQuery: string
* }} 덱 등록 결과
*/
export function validateDeckRegistrationUrl(deckUrlText) {
const errors = []
const warnings = []
let deckUrl
try {
deckUrl = new URL(deckUrlText.trim())
} catch {
return {
isValid: false,
errors: ['올바른 덱 결과 페이지 주소를 입력해주세요.'],
warnings,
seriesKey: null,
seriesName: '',
totalCardCount: 0,
cards: [],
gameQuery: '',
}
}
const seriesKey = deckUrl.searchParams.get('series')
const seriesName = deckUrl.searchParams.get('seriesName') || seriesKey || ''
const serializedCards = deckUrl.searchParams.get('cards')
if (!seriesKey || !SERIES_KEY_PATTERN.test(seriesKey)) {
errors.push('작품을 확인할 수 없는 덱 주소입니다.')
}
if (!serializedCards) {
errors.push('카드 정보가 없는 덱 주소입니다.')
}
let cards = []
if (serializedCards) {
try {
const parsedCards = parseDeckCards(serializedCards)
if (Array.isArray(parsedCards)) {
cards = parsedCards
} else {
errors.push('카드 정보가 배열 형식이 아닙니다.')
}
} catch {
errors.push('카드 정보를 해석하지 못했습니다.')
}
}
const totalCardCount = cards.reduce(
(totalCount, card) => totalCount + normalizeCardCount(card?.count),
0,
)
if (cards.length > 0 && totalCardCount !== MAIN_DECK_SIZE) {
errors.push(
`메인 덱은 정확히 ${MAIN_DECK_SIZE}장이어야 합니다. 현재 ${totalCardCount}장입니다.`,
)
}
const cardNumberCounts = new Map()
const triggerCounts = Object.fromEntries(
LIMITED_TRIGGER_TYPES.map((triggerType) => [triggerType, 0]),
)
let actionPointCardCount = 0
cards.forEach((card, cardIndex) => {
const cardCount = normalizeCardCount(card?.count)
const cardNumber =
typeof card?.Number === 'string' ? card.Number.trim() : ''
if (!cardNumber || cardCount === 0) {
errors.push(`${cardIndex + 1}번째 카드 정보 또는 수량이 올바르지 않습니다.`)
return
}
cardNumberCounts.set(
cardNumber,
(cardNumberCounts.get(cardNumber) || 0) + cardCount,
)
const triggerType =
typeof card?.trigger === 'string' ? card.trigger.toUpperCase() : null
if (triggerType && LIMITED_TRIGGER_TYPES.includes(triggerType)) {
triggerCounts[triggerType] += cardCount
}
if (card?.color === 'AP') {
actionPointCardCount += cardCount
}
})
cardNumberCounts.forEach((cardCount, cardNumber) => {
if (cardCount > SAME_CARD_LIMIT) {
errors.push(
`${cardNumber}은(는) ${SAME_CARD_LIMIT}장을 초과해 넣을 수 없습니다.`,
)
}
})
LIMITED_TRIGGER_TYPES.forEach((triggerType) => {
if (triggerCounts[triggerType] > TRIGGER_LIMIT) {
errors.push(
`${triggerType} 트리거는 합계 ${TRIGGER_LIMIT}장까지만 넣을 수 있습니다.`,
)
}
})
if (actionPointCardCount > 0) {
errors.push('AP 카드는 3장을 별도로 준비하며 메인 덱에 포함하지 않습니다.')
}
if (cards.length > 0 && cards.some((card) => card?.trigger === undefined)) {
warnings.push(
'일부 카드에 트리거 정보가 없어 트리거 제한을 완전히 확인하지 못했습니다.',
)
}
const gameSearchParameters = new URLSearchParams()
if (seriesKey) gameSearchParameters.set('series', seriesKey)
if (seriesName) gameSearchParameters.set('seriesName', seriesName)
if (serializedCards) gameSearchParameters.set('cards', serializedCards)
return {
isValid: errors.length === 0,
errors,
warnings,
seriesKey,
seriesName,
totalCardCount,
cards,
gameQuery: gameSearchParameters.toString(),
}
}

20
src/services/socket.js Normal file
View File

@@ -0,0 +1,20 @@
import { io } from 'socket.io-client'
let socketInstance
/**
* 같은 출처의 Socket.IO 서버 연결을 반환한다.
*
* @returns {import('socket.io-client').Socket} 재사용 가능한 소켓 연결
*/
export function getRoomSocket() {
if (!socketInstance) {
socketInstance = io({
path: '/socket.io',
autoConnect: true,
transports: ['websocket', 'polling'],
})
}
return socketInstance
}

218
src/stores/room.js Normal file
View File

@@ -0,0 +1,218 @@
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import { getRoomSocket } from '../services/socket.js'
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 roomState = ref(null)
const gameStartPayload = ref(null)
let hasInitializedListeners = false
const roomCode = computed(() => roomState.value?.roomCode || '')
const players = computed(() => roomState.value?.players || [])
const localPlayer = computed(
() =>
players.value.find((player) => player.playerId === playerId.value) || null,
)
const remotePlayer = computed(
() =>
players.value.find((player) => player.playerId !== playerId.value) || null,
)
const localPlayerReady = computed(
() => Boolean(localPlayer.value?.isReady),
)
const remotePlayerConnected = computed(() => Boolean(remotePlayer.value))
const remotePlayerReady = computed(
() => Boolean(remotePlayer.value?.isReady),
)
const canStartOnlineGame = computed(
() =>
players.value.length === 2 &&
players.value.every(
(player) => player.isReady && player.hasRegisteredDeck,
),
)
/**
* Socket.IO 연결과 방 상태 이벤트를 한 번만 등록한다.
*
* @returns {void}
*/
function initializeRoomConnection() {
if (hasInitializedListeners) return
hasInitializedListeners = true
socket.on('connect', () => {
playerId.value = socket.id
connectionState.value = 'connected'
connectionError.value = ''
})
socket.on('disconnect', () => {
connectionState.value = 'disconnected'
roomState.value = null
})
socket.on('connect_error', (socketError) => {
connectionState.value = 'error'
connectionError.value =
socketError?.message || '실시간 서버에 연결하지 못했습니다.'
})
socket.on('room:state', (nextRoomState) => {
roomState.value = nextRoomState
actionError.value = ''
})
socket.on('game:started', (nextGameStartPayload) => {
gameStartPayload.value = nextGameStartPayload
})
}
/**
* Socket.IO 승인 응답을 Promise로 변환해 이벤트를 전송한다.
*
* @param {string} eventName 전송할 이벤트 이름
* @param {unknown[]} eventArguments 이벤트 인자 목록
* @returns {Promise<object>} 서버 승인 응답
*/
function emitWithAcknowledgement(eventName, ...eventArguments) {
return new Promise((resolve) => {
socket.timeout(8000).emit(
eventName,
...eventArguments,
(timeoutError, response) => {
if (timeoutError) {
resolve({
ok: false,
error: '실시간 서버 응답 시간이 초과되었습니다.',
})
return
}
resolve(response || { ok: false, error: '서버 응답이 없습니다.' })
},
)
})
}
/**
* 실제 서버에 새로운 비공개 방 생성을 요청한다.
*
* @returns {Promise<boolean>} 방 생성 성공 여부
*/
async function createPrivateRoom() {
actionError.value = ''
const response = await emitWithAcknowledgement('room:create')
if (!response.ok) {
actionError.value = response.error
return false
}
playerId.value = response.playerId
roomState.value = response.room
return true
}
/**
* 실제 서버에 비공개 방 참가를 요청한다.
*
* @param {string} requestedRoomCode 참가할 방 코드
* @returns {Promise<boolean>} 방 참가 성공 여부
*/
async function joinPrivateRoom(requestedRoomCode) {
actionError.value = ''
const response = await emitWithAcknowledgement(
'room:join',
requestedRoomCode,
)
if (!response.ok) {
actionError.value = response.error
return false
}
playerId.value = response.playerId
roomState.value = response.room
return true
}
/**
* 서버에서 덱을 재검증하고 현재 플레이어에게 등록한다.
*
* @param {string} deckUrlText 등록할 덱 결과 URL
* @returns {Promise<object>} 서버 덱 검증 결과
*/
async function registerPlayerDeck(deckUrlText) {
actionError.value = ''
const response = await emitWithAcknowledgement(
'player:register-deck',
deckUrlText,
)
if (!response.ok && response.error) {
actionError.value = response.error
}
return response
}
/**
* 현재 플레이어의 준비 상태를 서버에 설정한다.
*
* @param {boolean} isReady 준비 완료 여부
* @returns {Promise<boolean>} 변경 성공 여부
*/
async function setLocalPlayerReady(isReady) {
actionError.value = ''
const response = await emitWithAcknowledgement(
'player:set-ready',
isReady,
)
if (!response.ok) {
actionError.value = response.error
return false
}
return true
}
/**
* 소비한 게임 시작 정보를 초기화한다.
*
* @returns {void}
*/
function clearGameStartPayload() {
gameStartPayload.value = null
}
initializeRoomConnection()
return {
actionError,
canStartOnlineGame,
clearGameStartPayload,
connectionError,
connectionState,
createPrivateRoom,
gameStartPayload,
joinPrivateRoom,
localPlayer,
localPlayerReady,
playerId,
players,
registerPlayerDeck,
remotePlayer,
remotePlayerConnected,
remotePlayerReady,
roomCode,
roomState,
setLocalPlayerReady,
}
})

7
src/views/GameView.vue Normal file
View File

@@ -0,0 +1,7 @@
<script setup>
import GameBoard from '../components/game-board/GameBoard.vue'
</script>
<template>
<GameBoard />
</template>

383
src/views/LobbyView.vue Normal file
View File

@@ -0,0 +1,383 @@
<script setup>
import { computed, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { useRoomStore } from '../stores/room.js'
import { validateDeckRegistrationUrl } from '../services/deckRegistration.js'
const router = useRouter()
const roomStore = useRoomStore()
const deckUrlText = ref('')
const requestedRoomCode = ref('')
const deckRegistration = ref(null)
const hasRegisteredDeck = computed(
() => Boolean(deckRegistration.value?.isValid),
)
const roomStatusLabel = computed(() => {
if (!roomStore.roomCode) return '방을 만들거나 초대 코드를 입력하세요.'
if (!roomStore.remotePlayerConnected) return '상대 플레이어 접속 대기 중'
if (!roomStore.remotePlayerReady) return '상대 플레이어 준비 대기 중'
return '양쪽 플레이어가 연결되었습니다.'
})
const connectionStatusLabel = computed(() => {
const statusLabels = {
connected: '실시간 서버 연결됨',
connecting: '실시간 서버 연결 중',
disconnected: '실시간 서버 연결 끊김',
error: '실시간 서버 연결 오류',
}
return statusLabels[roomStore.connectionState] || '실시간 서버 상태 확인 중'
})
watch(deckUrlText, async () => {
deckRegistration.value = null
if (roomStore.localPlayerReady) {
await roomStore.setLocalPlayerReady(false)
}
})
watch(
() => roomStore.gameStartPayload,
async (gameStartPayload) => {
if (!gameStartPayload?.gameQuery) return
const roomSearchParameters = new URLSearchParams(gameStartPayload.gameQuery)
roomSearchParameters.set('room', gameStartPayload.roomCode)
roomSearchParameters.set('seat', gameStartPayload.seat)
roomStore.clearGameStartPayload()
await router.push(`/game?${roomSearchParameters.toString()}`)
},
)
/**
* 입력한 덱 URL을 검증하고 로비에 등록한다.
*
* @returns {void}
*/
async function registerDeck() {
const localValidation = validateDeckRegistrationUrl(deckUrlText.value)
deckRegistration.value = localValidation
if (!localValidation.isValid || !roomStore.roomCode) return
const serverResponse = await roomStore.registerPlayerDeck(deckUrlText.value)
if (serverResponse.validation) {
deckRegistration.value = serverResponse.validation
}
}
/**
* 새로운 비공개 방을 만든다.
*
* @returns {void}
*/
async function createRoom() {
const hasCreatedRoom = await roomStore.createPrivateRoom()
if (hasCreatedRoom && deckUrlText.value.trim()) {
await registerDeck()
}
}
/**
* 입력한 비공개 방 코드로 참가를 시도한다.
*
* @returns {void}
*/
async function joinRoom() {
if (!requestedRoomCode.value.trim()) return
const hasJoinedRoom = await roomStore.joinPrivateRoom(
requestedRoomCode.value,
)
if (hasJoinedRoom && deckUrlText.value.trim()) {
await registerDeck()
}
}
/**
* 유효한 덱을 등록한 플레이어의 준비 상태를 전환한다.
*
* @returns {void}
*/
async function toggleReady() {
if (!hasRegisteredDeck.value) return
await roomStore.setLocalPlayerReady(!roomStore.localPlayerReady)
}
/**
* 등록된 덱으로 로컬 게임 화면을 연다.
*
* @returns {Promise<void>}
*/
async function startLocalGameTest() {
if (!deckRegistration.value?.isValid) return
await router.push(`/game?${deckRegistration.value.gameQuery}`)
}
</script>
<template>
<main class="min-h-screen bg-slate-950 px-5 py-10 text-slate-100 sm:px-8">
<div class="mx-auto max-w-6xl">
<header class="flex flex-col gap-4 border-b border-white/10 pb-8">
<p class="text-xs font-black tracking-[0.28em] text-red-400">
PLAY UNIARE
</p>
<div class="grid gap-5 lg:grid-cols-[1fr_auto] lg:items-end">
<div>
<h1 class="text-3xl font-black tracking-tight sm:text-5xl">
비공개 게임 대기실
</h1>
<p class="mt-4 max-w-3xl leading-7 text-slate-400">
방을 만들거나 초대 코드로 참가한 뒤, 덱 빌더 결과 주소를
등록하세요. 두 플레이어가 모두 준비하면 게임 보드로 이동합니다.
</p>
</div>
<div
class="rounded-2xl border border-white/10 bg-white/5 px-5 py-4 text-sm"
>
<p class="font-bold text-slate-300">현재 멀티플레이 상태</p>
<p
class="mt-1"
:class="
roomStore.connectionState === 'connected'
? 'text-emerald-300'
: 'text-amber-300'
"
>
{{ connectionStatusLabel }}
</p>
<p
v-if="roomStore.connectionError"
class="mt-1 text-xs text-red-300"
>
{{ roomStore.connectionError }}
</p>
</div>
</div>
</header>
<div class="mt-8 grid gap-6 lg:grid-cols-2">
<section class="rounded-3xl border border-white/10 bg-slate-900 p-6">
<p class="text-xs font-black tracking-[0.2em] text-red-400">ROOM</p>
<h2 class="mt-2 text-2xl font-black">비공개 방</h2>
<div v-if="!roomStore.roomCode" class="mt-6 grid gap-3 sm:grid-cols-2">
<button
type="button"
class="rounded-2xl bg-red-600 px-5 py-4 font-black transition hover:bg-red-500"
:disabled="roomStore.connectionState !== 'connected'"
@click="createRoom"
>
새 방 만들기
</button>
<div class="flex gap-2">
<input
v-model="requestedRoomCode"
type="text"
maxlength="6"
class="min-w-0 flex-1 rounded-2xl border border-white/15 bg-black/25 px-4 py-3 uppercase outline-none transition focus:border-red-400"
placeholder="방 코드"
aria-label="참가할 비공개 방 코드"
@keyup.enter="joinRoom"
/>
<button
type="button"
class="rounded-2xl border border-white/15 px-4 font-black transition hover:bg-white/10"
:disabled="roomStore.connectionState !== 'connected'"
@click="joinRoom"
>
참가
</button>
</div>
</div>
<div
v-else
class="mt-6 rounded-2xl border border-red-400/30 bg-red-950/30 p-5"
>
<div class="flex items-end justify-between gap-4">
<div>
<p class="text-xs font-bold text-slate-400">ROOM CODE</p>
<p class="mt-1 font-mono text-3xl font-black tracking-[0.2em]">
{{ roomStore.roomCode }}
</p>
</div>
<span
class="rounded-full border border-amber-300/30 bg-amber-300/10 px-3 py-1.5 text-xs font-bold text-amber-200"
>
{{
roomStore.localPlayer?.seat === 'player-one'
? 'PLAYER 1'
: 'PLAYER 2'
}}
</span>
</div>
<p class="mt-4 text-sm text-slate-400">{{ roomStatusLabel }}</p>
</div>
<div class="mt-6 grid gap-3 sm:grid-cols-2">
<article class="rounded-2xl border border-white/10 bg-black/20 p-4">
<p class="text-xs font-bold text-slate-400">
{{ roomStore.localPlayer?.seat === 'player-two' ? 'PLAYER 2' : 'PLAYER 1' }}
· YOU
</p>
<p class="mt-2 font-black">
{{ roomStore.localPlayerReady ? '준비 완료' : '준비 전' }}
</p>
<p class="mt-1 text-sm text-slate-500">
{{
roomStore.localPlayer?.hasRegisteredDeck
? `${roomStore.localPlayer.deckSeriesName} · ${roomStore.localPlayer.deckCardCount}장`
: '덱을 등록해주세요.'
}}
</p>
</article>
<article class="rounded-2xl border border-white/10 bg-black/20 p-4">
<p class="text-xs font-bold text-slate-400">
{{ roomStore.localPlayer?.seat === 'player-two' ? 'PLAYER 1' : 'PLAYER 2' }}
</p>
<p class="mt-2 font-black">
{{
roomStore.remotePlayer
? roomStore.remotePlayer.isReady
? '준비 완료'
: '준비 전'
: '접속 대기'
}}
</p>
<p class="mt-1 text-sm text-slate-500">
{{
roomStore.remotePlayer?.hasRegisteredDeck
? `${roomStore.remotePlayer.deckSeriesName} · ${roomStore.remotePlayer.deckCardCount}장`
: roomStore.remotePlayer
? '덱 등록 대기'
: '초대 코드를 상대에게 전달하세요.'
}}
</p>
</article>
</div>
<p
v-if="roomStore.actionError"
class="mt-4 rounded-xl border border-red-400/30 bg-red-950/30 px-4 py-3 text-sm text-red-200"
>
{{ roomStore.actionError }}
</p>
</section>
<section class="rounded-3xl border border-white/10 bg-slate-900 p-6">
<p class="text-xs font-black tracking-[0.2em] text-red-400">DECK</p>
<h2 class="mt-2 text-2xl font-black">내 덱 등록</h2>
<p class="mt-3 text-sm leading-6 text-slate-400">
기존 덱 빌더의 결과 페이지 전체 주소를 붙여넣으세요.
</p>
<textarea
v-model="deckUrlText"
class="mt-5 min-h-32 w-full resize-y rounded-2xl border border-white/15 bg-black/25 p-4 text-sm outline-none transition focus:border-red-400"
placeholder="http://uniare.sori.studio/result.html?series=..."
aria-label="덱 빌더 결과 주소"
/>
<button
type="button"
class="mt-3 w-full rounded-2xl bg-white px-5 py-3.5 font-black text-slate-950 transition hover:bg-slate-200"
@click="registerDeck"
>
덱 검사하고 등록
</button>
<div
v-if="deckRegistration"
class="mt-5 rounded-2xl border p-4"
:class="
deckRegistration.isValid
? 'border-emerald-400/30 bg-emerald-950/25'
: 'border-red-400/30 bg-red-950/25'
"
>
<p class="font-black">
{{
deckRegistration.isValid
? `${deckRegistration.seriesName} · ${deckRegistration.totalCardCount}장`
: '덱 조건을 확인해주세요.'
}}
</p>
<ul
v-if="deckRegistration.errors.length"
class="mt-3 list-disc space-y-1 pl-5 text-sm text-red-200"
>
<li v-for="errorMessage in deckRegistration.errors" :key="errorMessage">
{{ errorMessage }}
</li>
</ul>
<ul
v-if="deckRegistration.warnings.length"
class="mt-3 list-disc space-y-1 pl-5 text-sm text-amber-200"
>
<li
v-for="warningMessage in deckRegistration.warnings"
:key="warningMessage"
>
{{ warningMessage }}
</li>
</ul>
</div>
</section>
</div>
<section
class="mt-6 grid gap-4 rounded-3xl border border-white/10 bg-slate-900 p-6 lg:grid-cols-[1fr_auto]"
>
<div>
<h2 class="text-xl font-black">게임 시작 조건</h2>
<p class="mt-2 text-sm leading-6 text-slate-400">
메인 덱 50장, 같은 카드 최대 4장, FINAL·COLOR·SPECIAL 트리거
각각 최대 4장을 확인합니다. AP 카드는 3장을 별도로 사용합니다.
</p>
</div>
<div class="flex flex-wrap items-center gap-2">
<button
type="button"
class="rounded-2xl border border-white/15 px-5 py-3 font-black transition enabled:hover:bg-white/10 disabled:cursor-not-allowed disabled:opacity-40"
:disabled="
!hasRegisteredDeck ||
!roomStore.roomCode ||
!roomStore.localPlayer?.hasRegisteredDeck
"
@click="toggleReady"
>
{{ roomStore.localPlayerReady ? '게임 시작 취소' : '게임 시작 준비' }}
</button>
<span
class="rounded-2xl border border-white/10 bg-black/20 px-5 py-3 text-sm font-bold"
:class="
roomStore.canStartOnlineGame
? 'text-emerald-300'
: 'text-slate-500'
"
>
{{
roomStore.canStartOnlineGame
? '양쪽 준비 완료 · 게임 시작 중'
: '두 플레이어가 모두 준비하면 자동 시작'
}}
</span>
<button
type="button"
class="rounded-2xl bg-amber-300 px-5 py-3 font-black text-slate-950 transition enabled:hover:bg-amber-200 disabled:cursor-not-allowed disabled:opacity-40"
:disabled="!hasRegisteredDeck"
@click="startLocalGameTest"
>
로컬 게임 테스트
</button>
</div>
</section>
</div>
</main>
</template>

View File

@@ -1,7 +1,31 @@
import { defineConfig } from 'vite'
import { defineConfig, loadEnv } from 'vite'
import vue from '@vitejs/plugin-vue'
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
plugins: [vue(), tailwindcss()],
})
/**
* 실행 환경에 맞는 Vite 개발 서버와 프록시 설정을 만든다.
*
* @param {{ mode: string }} configurationContext Vite 실행 모드
* @returns {import('vite').UserConfig} Vite 설정
*/
function createViteConfiguration({ mode }) {
const environment = loadEnv(mode, process.cwd(), '')
const socketServerPort =
process.env.SOCKET_SERVER_PORT ||
environment.SOCKET_SERVER_PORT ||
'31873'
return {
plugins: [vue(), tailwindcss()],
server: {
proxy: {
'/socket.io': {
target: `http://127.0.0.1:${socketServerPort}`,
ws: true,
},
},
},
}
}
export default defineConfig(createViteConfiguration)