# 웹 카탄 게임 만들기 - 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 = `
${player.name} ${totalScore}점
${breakdown.map(item => `
${item.emoji} ${item.label} ${item.count > 0 ? `(${item.count})` : ''} +${item.points}
`).join('')}
`; scoresContainer.appendChild(playerDiv); }); modal.classList.add('active'); // 버튼 이벤트 document.getElementById('restart-game').onclick = () => { modal.classList.remove('active'); this.game.restart(); }; document.getElementById('back-to-menu').onclick = () => { modal.classList.remove('active'); this.showSetupScreen(); }; } // 설정 화면으로 돌아가기 showSetupScreen() { document.getElementById('game-screen').classList.remove('active'); document.getElementById('setup-screen').classList.add('active'); } } ``` --- ## Step 5: 특별 점수 표시 CSS ```css /* css/ui.css에 추가 */ /* 최장 도로 하이라이트 */ .longest-road-highlight { stroke-width: 8 !important; filter: drop-shadow(0 0 4px gold); animation: road-glow 2s infinite; } @keyframes road-glow { 0%, 100% { filter: drop-shadow(0 0 4px gold); } 50% { filter: drop-shadow(0 0 8px gold); } } /* 특별 배지 */ .special-badge { display: inline-flex; align-items: center; gap: 4px; margin-top: 8px; padding: 4px 10px; border-radius: 4px; font-size: 11px; font-weight: bold; } .special-badge.longest-road { background: linear-gradient(135deg, #f39c12, #e67e22); color: white; } .special-badge.largest-army { background: linear-gradient(135deg, #9b59b6, #8e44ad); color: white; } /* 게임 종료 모달 */ #game-over-modal .modal-content { max-width: 500px; text-align: center; } #winner-text { font-size: 24px; margin-bottom: 20px; } #final-scores { text-align: left; margin-bottom: 20px; } .final-score-player { background: var(--background-color); border-radius: 8px; padding: 15px; margin-bottom: 10px; } .final-score-player.winner { border: 2px solid gold; box-shadow: 0 0 10px rgba(255, 215, 0, 0.3); } .score-header { display: flex; justify-content: space-between; align-items: center; padding-left: 10px; margin-bottom: 10px; } .score-header .name { font-weight: bold; font-size: 16px; } .score-header .total { font-size: 20px; font-weight: bold; color: var(--warning-color); } .score-breakdown { font-size: 13px; } .breakdown-item { display: flex; justify-content: space-between; padding: 4px 0; color: var(--text-muted); } /* 점수 표시 강조 */ .player-points { position: relative; } .player-points.near-win { color: #e74c3c; animation: pulse-warning 1s infinite; } @keyframes pulse-warning { 0%, 100% { transform: scale(1); } 50% { transform: scale(1.1); } } ``` --- ## Step 6: 점수 추적 UI 업데이트 ```javascript // UIManager에 추가 updatePlayersPanel() { const container = document.getElementById('players-info'); container.innerHTML = ''; this.game.players.forEach((player, index) => { const isCurrentTurn = index === this.game.currentPlayerIndex; const publicScore = this.game.scoreManager.getPublicScore(player.id); const isNearWin = publicScore >= 8; const div = document.createElement('div'); div.className = `player-info ${isCurrentTurn ? 'current-turn' : ''}`; // 특별 점수 배지 let badges = ''; if (player.hasLongestRoad) { badges += '
🛤️ 최장 도로
'; } if (player.hasLargestArmy) { badges += '
⚔️ 최대 기사단
'; } div.innerHTML = `
${player.name} ${publicScore}점
🛤️ ${player.roads} 🏠 ${player.settlements} 🏰 ${player.cities} 📦 ${player.getTotalResources()} 🃏 ${player.devCards.length + player.newDevCards.length} ${player.usedKnights > 0 ? `⚔️ ${player.usedKnights}` : ''}
${badges} `; container.appendChild(div); }); } ``` --- ## 다음 단계 예고 Part 8에서는 **AI 시스템**을 구현합니다: - 쉬움/보통/어려움 난이도별 AI 전략 - 셋업 단계 AI 의사결정 - 건설 및 거래 AI - 개발 카드 사용 AI --- *이전 파트: [Part 6 - 개발 카드 시스템](make-web-katan-game-v6.md)* *다음 파트: [Part 8 - AI 구현](make-web-katan-game-v8.md)*