From 1af3c71f8a8f2ad7adc22e4f7b8c7f7b17a5b690 Mon Sep 17 00:00:00 2001 From: zenn Date: Fri, 19 Jun 2026 16:13:55 +0900 Subject: [PATCH] feat: add multiplayer lobby and game board --- .env.example | 1 - AGENTS.md | 14 +- Dockerfile | 20 + compose.yaml | 13 + docker/nginx.conf | 13 + docs/deploy.md | 48 +- docs/development.md | 43 +- docs/history.md | 18 + docs/map.md | 80 +- docs/spec.md | 121 +- docs/todo.md | 60 +- docs/update.md | 43 + package-lock.json | 931 ++++++++++++- package.json | 9 +- server/index.js | 281 ++++ server/integration-test.js | 160 +++ src/App.vue | 39 +- .../playboards/union-arena-standard.png | Bin 0 -> 205933 bytes src/components/cards/GameCard.vue | 152 +++ src/components/game-board/GameBoard.vue | 1192 +++++++++++++++++ src/main.js | 8 +- src/router/index.js | 21 + src/services/deckQuery.js | 197 +++ src/services/deckRegistration.js | 180 +++ src/services/socket.js | 20 + src/stores/room.js | 218 +++ src/views/GameView.vue | 7 + src/views/LobbyView.vue | 383 ++++++ vite.config.js | 32 +- 29 files changed, 4156 insertions(+), 148 deletions(-) create mode 100644 server/index.js create mode 100644 server/integration-test.js create mode 100644 src/assets/playboards/union-arena-standard.png create mode 100644 src/components/cards/GameCard.vue create mode 100644 src/components/game-board/GameBoard.vue create mode 100644 src/router/index.js create mode 100644 src/services/deckQuery.js create mode 100644 src/services/deckRegistration.js create mode 100644 src/services/socket.js create mode 100644 src/stores/room.js create mode 100644 src/views/GameView.vue create mode 100644 src/views/LobbyView.vue diff --git a/.env.example b/.env.example index 7b10e9a..60b29fa 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,2 @@ PLAYUNIARE_PRODUCTION_HTTP_PORT=41809 VITE_CARD_ASSET_BASE_URL=/card-assets -VITE_SOCKET_SERVER_URL=http://playuniare.sori.studio diff --git a/AGENTS.md b/AGENTS.md index af8e588..37808ea 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,9 +20,9 @@ - 프런트엔드: Vue 3 Composition API와 ` + + diff --git a/src/components/game-board/GameBoard.vue b/src/components/game-board/GameBoard.vue new file mode 100644 index 0000000..7c02b84 --- /dev/null +++ b/src/components/game-board/GameBoard.vue @@ -0,0 +1,1192 @@ + + + diff --git a/src/main.js b/src/main.js index 2425c0f..54b3ffe 100644 --- a/src/main.js +++ b/src/main.js @@ -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') diff --git a/src/router/index.js b/src/router/index.js new file mode 100644 index 0000000..4482e47 --- /dev/null +++ b/src/router/index.js @@ -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, +}) diff --git a/src/services/deckQuery.js b/src/services/deckQuery.js new file mode 100644 index 0000000..c499205 --- /dev/null +++ b/src/services/deckQuery.js @@ -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, + } +} diff --git a/src/services/deckRegistration.js b/src/services/deckRegistration.js new file mode 100644 index 0000000..0cb5cb6 --- /dev/null +++ b/src/services/deckRegistration.js @@ -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(), + } +} diff --git a/src/services/socket.js b/src/services/socket.js new file mode 100644 index 0000000..aa1b44b --- /dev/null +++ b/src/services/socket.js @@ -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 +} diff --git a/src/stores/room.js b/src/stores/room.js new file mode 100644 index 0000000..cfadc55 --- /dev/null +++ b/src/stores/room.js @@ -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} 서버 승인 응답 + */ + 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} 방 생성 성공 여부 + */ + 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} 방 참가 성공 여부 + */ + 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} 서버 덱 검증 결과 + */ + 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} 변경 성공 여부 + */ + 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, + } +}) diff --git a/src/views/GameView.vue b/src/views/GameView.vue new file mode 100644 index 0000000..a3e8051 --- /dev/null +++ b/src/views/GameView.vue @@ -0,0 +1,7 @@ + + + diff --git a/src/views/LobbyView.vue b/src/views/LobbyView.vue new file mode 100644 index 0000000..4387ade --- /dev/null +++ b/src/views/LobbyView.vue @@ -0,0 +1,383 @@ + + +