# 웹 카탄 게임 만들기 - Part 7: 특별 점수 시스템 ## 이번 파트에서 구현할 내용 - 최장 도로 시각적 표시 및 로직 완성 - 최대 기사단 시각적 표시 - 승리 조건 확인 - 게임 종료 처리 - 점수 요약 화면 --- ## Step 1: 점수 계산 시스템 완성 ```javascript // js/game/ScoreManager.js class ScoreManager { constructor(game) { this.game = game; } // 플레이어의 공개 점수 (다른 플레이어가 볼 수 있는) getPublicScore(playerId) { const player = this.game.players[playerId]; let score = 0; score += player.settlements; // 정착지 1점 score += player.cities * 2; // 도시 2점 if (player.hasLongestRoad) score += 2; if (player.hasLargestArmy) score += 2; return score; } // 플레이어의 실제 점수 (비공개 승점 카드 포함) getActualScore(playerId) { const player = this.game.players[playerId]; let score = this.getPublicScore(playerId); // 승점 개발 카드 player.devCards.forEach(card => { if (card.type === 'VICTORY_POINT') score += 1; }); player.newDevCards.forEach(card => { if (card.type === 'VICTORY_POINT') score += 1; }); return score; } // 승리자 확인 checkWinner() { for (const player of this.game.players) { if (this.getActualScore(player.id) >= VICTORY_POINTS_TO_WIN) { return player; } } return null; } // 점수 상세 내역 getScoreBreakdown(playerId) { const player = this.game.players[playerId]; const breakdown = []; if (player.settlements > 0) { breakdown.push({ label: '정착지', count: player.settlements, points: player.settlements, emoji: '🏠' }); } if (player.cities > 0) { breakdown.push({ label: '도시', count: player.cities, points: player.cities * 2, emoji: '🏰' }); } if (player.hasLongestRoad) { breakdown.push({ label: '최장 도로', count: this.game.calculateLongestRoad(playerId), points: 2, emoji: '🛤️' }); } if (player.hasLargestArmy) { breakdown.push({ label: '최대 기사단', count: player.usedKnights, points: 2, emoji: '⚔️' }); } // 승점 카드 const vpCards = [...player.devCards, ...player.newDevCards] .filter(c => c.type === 'VICTORY_POINT').length; if (vpCards > 0) { breakdown.push({ label: '승점 카드', count: vpCards, points: vpCards, emoji: '🏆' }); } return breakdown; } // 게임 통계 getGameStats(playerId) { const player = this.game.players[playerId]; return { roadsBuilt: player.roads, settlementsBuilt: player.settlements, citiesBuilt: player.cities, knightsPlayed: player.usedKnights, devCardsBought: player.devCards.length + player.newDevCards.length + player.usedKnights, totalResourcesGained: 0, // 추적 필요 tradesCompleted: 0 // 추적 필요 }; } } ``` --- ## Step 2: 최장 도로 시각적 표시 ```javascript // BoardRenderer에 추가 class BoardRenderer { // ... 기존 코드 ... // 최장 도로 하이라이트 highlightLongestRoad(playerId) { const longestPath = this.findLongestRoadPath(playerId); // 기존 하이라이트 제거 document.querySelectorAll('.longest-road-highlight').forEach(el => { el.classList.remove('longest-road-highlight'); }); // 최장 도로에 하이라이트 추가 longestPath.forEach(edgeId => { const line = this.buildingsLayer.querySelector(`[data-road-edge="${edgeId}"]`); if (line) { line.classList.add('longest-road-highlight'); } }); } // 최장 도로 경로 찾기 (DFS) findLongestRoadPath(playerId) { const playerEdges = this.board.edges.filter(e => e.road && e.road.playerId === playerId ); if (playerEdges.length === 0) return []; let longestPath = []; // 각 도로에서 시작하여 DFS playerEdges.forEach(startEdge => { const path = this.dfsLongestPath(startEdge.id, playerId, new Set(), []); if (path.length > longestPath.length) { longestPath = path; } }); return longestPath; } dfsLongestPath(edgeId, playerId, visited, currentPath) { if (visited.has(edgeId)) return currentPath; visited.add(edgeId); currentPath = [...currentPath, edgeId]; const edge = this.board.edges[edgeId]; let longestPath = currentPath; edge.vertexIds.forEach(vertexId => { const vertex = this.board.vertices[vertexId]; // 다른 플레이어 건물이 있으면 통과 불가 if (vertex.building && vertex.building.playerId !== playerId) { return; } vertex.adjacentEdges.forEach(adjEdgeId => { if (adjEdgeId === edgeId) return; const adjEdge = this.board.edges[adjEdgeId]; if (adjEdge.road && adjEdge.road.playerId === playerId) { const newPath = this.dfsLongestPath( adjEdgeId, playerId, new Set(visited), currentPath ); if (newPath.length > longestPath.length) { longestPath = newPath; } } }); }); return longestPath; } // 도로 렌더링 시 data 속성 추가 renderBuildings() { this.buildingsLayer.innerHTML = ''; // 도로 렌더링 this.board.edges.forEach(edge => { if (edge.road) { const line = document.createElementNS(this.svgNS, 'line'); line.setAttribute('x1', edge.x1); line.setAttribute('y1', edge.y1); line.setAttribute('x2', edge.x2); line.setAttribute('y2', edge.y2); line.setAttribute('stroke', PLAYER_COLORS[edge.road.playerId]); line.setAttribute('stroke-width', '6'); line.setAttribute('stroke-linecap', 'round'); line.setAttribute('data-road-edge', edge.id); this.buildingsLayer.appendChild(line); } }); // 건물 렌더링 (이전과 동일) this.board.vertices.forEach(vertex => { if (vertex.building) { const color = PLAYER_COLORS[vertex.building.playerId]; if (vertex.building.type === 'settlement') { this.drawSettlement(vertex.x, vertex.y, color); } else if (vertex.building.type === 'city') { this.drawCity(vertex.x, vertex.y, color); } } }); // 최장 도로 보유자 하이라이트 this.game.players.forEach(player => { if (player.hasLongestRoad) { this.highlightLongestRoad(player.id); } }); } } ``` --- ## Step 3: 게임 종료 처리 ```javascript // Game 클래스에 추가 class Game { // 게임 종료 endGame(winner) { this.phase = GAME_PHASES.GAME_OVER; this.winner = winner; logMessage(`🎉 ${winner.name}이(가) 승리했습니다!`); logMessage(`최종 점수: ${this.scoreManager.getActualScore(winner.id)}점`); // 게임 종료 모달 표시 this.uiManager.showGameOverModal(winner); } // 턴 종료 시 승리 확인 endTurn() { const currentPlayer = this.getCurrentPlayer(); currentPlayer.endTurn(); // 개발 카드 매니저 리셋 this.devCardManager.onTurnEnd(); // 승리 확인 const winner = this.scoreManager.checkWinner(); if (winner) { this.endGame(winner); return; } // 다음 플레이어 this.currentPlayerIndex = (this.currentPlayerIndex + 1) % this.players.length; this.turnNumber++; // 다음 턴 시작 this.phase = GAME_PHASES.ROLL_DICE; const nextPlayer = this.getCurrentPlayer(); logMessage(`━━━ ${nextPlayer.name}의 턴 ━━━`); // AI 턴이면 자동 진행 if (nextPlayer.isAI) { this.processAITurn(); } this.uiManager.updateUI(); } } ``` --- ## Step 4: 게임 종료 UI ```javascript // UIManager에 추가 class UIManager { showGameOverModal(winner) { const modal = document.getElementById('game-over-modal'); const winnerText = document.getElementById('winner-text'); const scoresContainer = document.getElementById('final-scores'); // 승자 표시 if (winner.id === 0) { winnerText.textContent = '🎉 축하합니다! 승리했습니다!'; winnerText.style.color = '#27ae60'; } else { winnerText.textContent = `😢 ${winner.name}이(가) 승리했습니다`; winnerText.style.color = '#e74c3c'; } // 점수 상세 scoresContainer.innerHTML = ''; this.game.players.forEach(player => { const breakdown = this.game.scoreManager.getScoreBreakdown(player.id); const totalScore = this.game.scoreManager.getActualScore(player.id); const playerDiv = document.createElement('div'); playerDiv.className = `final-score-player ${player === winner ? 'winner' : ''}`; playerDiv.innerHTML = `