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

View File

@@ -8,6 +8,26 @@ RUN npm ci
COPY . . COPY . .
RUN npm run build 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 FROM nginx:1.27-alpine AS runtime
RUN adduser -D -H -u 1000 -G wheel cardreader \ RUN adduser -D -H -u 1000 -G wheel cardreader \

View File

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

View File

@@ -23,6 +23,19 @@ server {
add_header X-Content-Type-Options "nosniff"; 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?)$ { location ~* \.(?:css|js|jpg|jpeg|gif|png|svg|ico|webp|woff2?)$ {
expires 7d; expires 7d;
add_header Cache-Control "public, max-age=604800, immutable"; add_header Cache-Control "public, max-age=604800, immutable";

View File

@@ -9,8 +9,8 @@
- 운영 주소는 현재 `http://playuniare.sori.studio`다. - 운영 주소는 현재 `http://playuniare.sori.studio`다.
- HTTPS는 인증서와 리버스 프록시 구성이 완료된 뒤 전환한다. - HTTPS는 인증서와 리버스 프록시 구성이 완료된 뒤 전환한다.
현재 프로젝트는 Vue 정적 프런트엔드만 포함한다. 실시간 서버가 추가되면 같은 현재 프로젝트는 Vue 정적 프런트엔드와 Node.js Socket.IO 서버를 같은
Compose 프로젝트 안에 Node.js 서비스를 추가한다. Compose 프로젝트로 배포한다.
## 배포 구조 ## 배포 구조
@@ -25,9 +25,14 @@ NAS 리버스 프록시
↓ ↓
Play Uniare Nginx 컨테이너 Play Uniare Nginx 컨테이너
├── / → Vue 빌드 결과 ├── / → Vue 빌드 결과
└── /card-assets/ → 기존 Uniare 카드 이미지 ├── /card-assets/ → 기존 Uniare 카드 이미지
└── /socket.io/ → Docker 내부 playuniare-server:31873
``` ```
`31873`은 Docker 내부 통신에만 사용하며 NAS 호스트에는 공개하지 않는다.
외부에서는 웹 주소와 같은 `http://playuniare.sori.studio/socket.io` 경로로
접속한다.
카드 이미지는 복사하지 않고 다음 폴더를 읽기 전용으로 연결한다. 카드 이미지는 복사하지 않고 다음 폴더를 읽기 전용으로 연결한다.
```text ```text
@@ -101,11 +106,11 @@ cp .env.example .env
```text ```text
PLAYUNIARE_PRODUCTION_HTTP_PORT=41809 PLAYUNIARE_PRODUCTION_HTTP_PORT=41809
VITE_CARD_ASSET_BASE_URL=/card-assets 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. 포트 사용 여부 확인 ### 4. 포트 사용 여부 확인
@@ -139,7 +144,8 @@ docker compose \
ps ps
``` ```
`playuniare-web`이 `healthy` 또는 `running`이면 정상이다. `playuniare-web`과 `playuniare-server`가 모두 `healthy` 또는 `running`이면
정상이다.
### 6. 리버스 프록시 연결 ### 6. 리버스 프록시 연결
@@ -163,10 +169,12 @@ http://playuniare.sori.studio
카드 이미지: 카드 이미지:
```text ```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 ```bash
docker exec playuniare-web \ docker exec playuniare-web \
su -s /bin/sh cardreader -c \ 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` 확인 - [ ] `http://playuniare.sori.studio` 확인
- [ ] `/card-assets/<series>/<fileName>` 확인 - [ ] `/card-assets/<series>/<fileName>` 확인
- [ ] 두 브라우저의 방 생성·참가와 준비 상태 동기화 확인
- [ ] `.env`, 토큰, 비밀번호가 Git에 없는지 확인 - [ ] `.env`, 토큰, 비밀번호가 Git에 없는지 확인
## 향후 실시간 서버 ## 실시간 서버 구조
Socket.IO 서버가 추가되면 브라우저에 서버 포트를 직접 공개하지 않는다. 브라우저에 Socket.IO 서버 포트를 직접 공개하지 않는다.
```text ```text
브라우저 브라우저
@@ -367,5 +376,16 @@ Socket.IO 서버가 추가되면 브라우저에 서버 포트를 직접 공개
``` ```
Docker 내부에서는 다른 컨테이너와 같은 포트를 사용해도 충돌하지 않는다. 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 ```bash
npm run dev 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`에 작성한다. 로컬 전용 값은 프로젝트 루트의 `.env.local`에 작성한다.
```text ```text
VITE_CARD_ASSET_BASE_URL=http://127.0.0.1:5500/images VITE_CARD_ASSET_BASE_URL=http://playuniare.sori.studio/card-assets
VITE_SOCKET_SERVER_URL=http://localhost:31873
``` ```
- `VITE_`로 시작하는 값은 브라우저에 노출될 수 있다. - `VITE_`로 시작하는 값은 브라우저에 노출될 수 있다.
- 비밀번호, 개인키, NAS 관리자 계정은 넣지 않는다. - 비밀번호, 개인키, NAS 관리자 계정은 넣지 않는다.
- `.env.local`은 Git에 커밋하지 않는다. - `.env.local`은 Git에 커밋하지 않는다.
- 로컬 개발에서 실제 카드 이미지를 확인하려면 운영 카드 자산 주소를
`VITE_CARD_ASSET_BASE_URL`에 사용한다.
## 일반적인 작업 순서 ## 일반적인 작업 순서
@@ -99,10 +111,14 @@ npm run dev
작업이 끝난 뒤: 작업이 끝난 뒤:
```bash ```bash
npm run test:server
npm run build npm run build
git status git status
``` ```
`npm run test:server`는 `npm run dev:server`가 실행 중일 때 방 생성, 참가,
덱 등록, 양쪽 준비와 게임 시작 이벤트를 검증한다.
변경 문서를 확인한 후 커밋하고 푸시한다. 변경 문서를 확인한 후 커밋하고 푸시한다.
## 자주 발생하는 문제 ## 자주 발생하는 문제
@@ -112,9 +128,24 @@ git status
Vite가 다른 포트를 자동으로 제시할 수 있다. 터미널에 새로 표시된 주소로 Vite가 다른 포트를 자동으로 제시할 수 있다. 터미널에 새로 표시된 주소로
접속한다. 반드시 5173 포트를 고정할 필요는 없다. 접속한다. 반드시 5173 포트를 고정할 필요는 없다.
향후 로컬 Socket.IO 서버는 기본적으로 `31873` 포트를 사용한다. 이 포트도 로컬 Socket.IO 서버는 기본적으로 `31873` 포트를 사용한다. 이 포트가 이미
이미 사용 중이라면 서버 환경변수에서 다른 포트로 변경하고 사용 중이면 두 터미널에서 같은 환경변수를 사용한다.
`VITE_SOCKET_SERVER_URL`을 같은 값으로 맞춘다.
```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 파일과 테스트 포트 환경변수를 제거했다. - 테스트 Compose 파일과 테스트 포트 환경변수를 제거했다.
- 운영 주소를 `http://playuniare.sori.studio`로 확인했다. - 운영 주소를 `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` - 파일: `src/views/LobbyView.vue`
- 역할: 프로젝트 준비 상태를 보여주는 초기 랜딩 화면 - 역할: 비공개 게임 대기실
- 현재 기능: 제품 목표 안내 - 현재 기능:
- 다음 변경: 로비 화면과 게임 화면을 Vue Router로 분리 - 방 생성과 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`, 한국어 - 문서 제목과 기본 언어: `index.html`의 `Play Uniare`, 한국어
## 예정 화면 ## 예정 화면
@@ -28,17 +71,45 @@
- 카드 조작 메뉴 - 카드 조작 메뉴
- 연결 상태와 재접속 안내 - 연결 상태와 재접속 안내
## 카드 우클릭 메뉴
- 선택 카드 상태에 따라 `앞면으로`·`뒷면으로`, `탭`·`언탭` 문구를 바꾼다.
- 선택 카드를 가장 위 레이어로 올릴 수 있다.
- 선택 카드를 손패, 덱 위, 덱 아래, 무덤으로 이동할 수 있다.
- 화면 가장자리에서 메뉴가 잘리지 않도록 위치를 제한한다.
- 바깥 영역 클릭 또는 `Esc` 키로 닫는다.
## 덱 쿼리 처리
- 파일: `src/services/deckQuery.js`
- 쿼리가 있으면 카드 수량을 독립 인스턴스로 확장한다.
- 쿼리가 없으면 연습 카드 3장을 사용한다.
- 카드 이미지 주소는 `VITE_CARD_ASSET_BASE_URL`을 기준으로 만든다.
## 덱 조작 패널
- 플레이 보드의 내 덱 옆에 작은 반투명 패널로 표시한다.
- 현재 덱 장수를 표시한다.
- 덱이 비어 있으면 드로우를 비활성화한다.
- 덱이 2장 미만이면 셔플을 비활성화한다.
- 공개할 카드 수를 1~10장으로 지정한다.
- 공개 중인 카드 장수를 표시한다.
## 예정 디렉터리 구조 ## 예정 디렉터리 구조
```text ```text
src/ src/
├── components/ ├── components/
│ ├── cards/ │ ├── cards/
│ │ └── GameCard.vue
│ ├── game-board/ │ ├── game-board/
│ │ └── GameBoard.vue
│ └── room/ │ └── room/
├── composables/ ├── composables/
├── services/ ├── services/
├── stores/ ├── stores/
├── router/
├── stores/
├── types/ ├── types/
├── utils/ ├── utils/
└── views/ └── views/
@@ -48,6 +119,7 @@ src/
- `composables`: Vue 반응형 조합 로직 - `composables`: Vue 반응형 조합 로직
- `services`: 쿼리 파싱, API, Socket.IO 연결 - `services`: 쿼리 파싱, API, Socket.IO 연결
- `stores`: Pinia 기반 게임·방·사용자 상태 - `stores`: Pinia 기반 게임·방·사용자 상태
- `router`: 대기실과 게임 화면 경로
- `types`: JSDoc 타입 정의 - `types`: JSDoc 타입 정의
- `utils`: Vue에 의존하지 않는 순수 함수 - `utils`: Vue에 의존하지 않는 순수 함수
- `views`: Router 단위 화면 - `views`: Router 단위 화면

View File

@@ -4,7 +4,7 @@
- 프로젝트명: Play Uniare - 프로젝트명: Play Uniare
- 목적: Union Arena 덱을 불러와 카드 조작 중심의 로컬·온라인 플레이 제공 - 목적: Union Arena 덱을 불러와 카드 조작 중심의 로컬·온라인 플레이 제공
- 현재 단계: 프로젝트 기반 구성 - 현재 단계: 로컬 카드 조작과 온라인 대기실 연결
- 프런트엔드: Vue 3, Vite, Tailwind CSS - 프런트엔드: Vue 3, Vite, Tailwind CSS
- 배포 대상: UGREEN NAS의 Docker 환경 - 배포 대상: UGREEN NAS의 Docker 환경
@@ -24,6 +24,9 @@
- 혼자 연습하는 로컬 모드 - 혼자 연습하는 로컬 모드
- 방 코드 기반 2인 세션 - 방 코드 기반 2인 세션
- 각 플레이어가 자신의 카드만 조작하는 권한 모델 - 각 플레이어가 자신의 카드만 조작하는 권한 모델
- 비공개 방 코드 기반 대기실
- 덱 URL 등록과 기본 덱 구성 검증
- 양쪽 플레이어 준비 완료 후 게임 시작
### 제외 ### 제외
@@ -47,9 +50,44 @@
3. `series`, `imgSrc`, `Number`, `count`를 검증한다. 3. `series`, `imgSrc`, `Number`, `count`를 검증한다.
4. 카드 수량만큼 고유한 게임 카드 인스턴스를 만든다. 4. 카드 수량만큼 고유한 게임 카드 인스턴스를 만든다.
5. 이미지 주소는 환경변수 기반 카드 자산 원본과 조합한다. 5. 이미지 주소는 환경변수 기반 카드 자산 원본과 조합한다.
6. 각 카드의 `count`만큼 고유한 `instanceId`를 가진 게임 카드로 확장한다.
7. 불러온 카드는 내 덱 영역에 뒷면으로 쌓는다.
장기적으로는 축약된 덱 코드 또는 서버의 `deckId` 방식으로 전환한다. 장기적으로는 축약된 덱 코드 또는 서버의 `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 사이 값으로 저장한다. - 자유 배치 좌표는 보드 너비·높이 기준 0부터 1 사이 값으로 저장한다.
- 드래그 중인 카드는 보드 경계를 벗어나지 않도록 카드 반쪽 크기만큼 좌표를
제한한다.
- 창 크기가 변해도 같은 상대 위치를 유지해야 한다. - 창 크기가 변해도 같은 상대 위치를 유지해야 한다.
- 보드 좌우 여백은 플레이어별 라이프, 덱·손패 장수, 연결 상태와 메모를
표시하는 정보 패널 영역으로 활용한다.
## 상태 저장 ## 상태 저장
@@ -70,6 +115,70 @@
- 온라인 모드: 서버가 방 상태의 최종 권위를 가진다. - 온라인 모드: 서버가 방 상태의 최종 권위를 가진다.
- 초기 서버: Node.js와 Socket.IO, 메모리 기반 방 상태 - 초기 서버: Node.js와 Socket.IO, 메모리 기반 방 상태
- 운영 안정화 단계: Redis 또는 데이터베이스 저장 검토 - 운영 안정화 단계: 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 설정으로 관리한다. - 포트 번호는 코드에 하드코딩하지 않고 환경변수와 Compose 설정으로 관리한다.
## 환경변수 예정 ## 환경변수
```text ```text
VITE_CARD_ASSET_BASE_URL=/card-assets 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 카드 이미지 공유 ## NAS 카드 이미지 공유
@@ -120,6 +231,8 @@ Play Uniare 컨테이너 내부:
- Nginx 작업 프로세스는 NAS 카드 파일 소유자와 같은 UID `1000`의 - Nginx 작업 프로세스는 NAS 카드 파일 소유자와 같은 UID `1000`의
`cardreader` 사용자로 실행한다. `cardreader` 사용자로 실행한다.
- 카드 이미지 URL은 `/card-assets/<series>/<fileName>` 형식을 사용한다. - 카드 이미지 URL은 `/card-assets/<series>/<fileName>` 형식을 사용한다.
- 프런트엔드는 `VITE_CARD_ASSET_BASE_URL`과 시리즈·파일명을 조합해 카드
이미지 URL을 생성한다.
- 같은 Play Uniare 도메인으로 제공되므로 별도의 CORS 설정이 필요하지 않다. - 같은 Play Uniare 도메인으로 제공되므로 별도의 CORS 설정이 필요하지 않다.
## 배포 환경 ## 배포 환경

View File

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

View File

@@ -21,3 +21,46 @@
배포 문서를 단순화했다. 배포 문서를 단순화했다.
- 운영 주소 `http://playuniare.sori.studio`와 향후 데이터 마이그레이션 - 운영 주소 `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", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "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", "build": "vite build",
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "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": { "devDependencies": {
"@tailwindcss/vite": "^4.3.1", "@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> <template>
<main class="min-h-screen bg-slate-950 px-6 py-16 text-slate-100"> <RouterView />
<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>
</template> </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 { createApp } from 'vue'
import { createPinia } from 'pinia'
import './style.css' import './style.css'
import App from './App.vue' 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 vue from '@vitejs/plugin-vue'
import tailwindcss from '@tailwindcss/vite' 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)