# 웹 카탄 게임 만들기 - Part 4: 건설 시스템 ## 이번 파트에서 구현할 내용 - 도로 건설 로직 - 정착지 건설 로직 - 도시 업그레이드 - 초기 배치 단계 (Setup Phase) - 건설 UI 인터랙션 --- ## Step 1: Building 클래스 구현 ```javascript // js/game/Building.js class Building { constructor(type, playerId, locationId) { this.type = type; // 'road', 'settlement', 'city' this.playerId = playerId; this.locationId = locationId; this.createdAt = Date.now(); } } // 건설 관리자 class BuildingManager { constructor(game) { this.game = game; } // 도로 건설 buildRoad(edgeId, playerId, free = false) { const edge = this.game.board.edges[edgeId]; const player = this.game.players[playerId]; // 검증 if (edge.road) { throw new Error('이미 도로가 있습니다.'); } if (!free && !player.hasResources(BUILD_COSTS.ROAD)) { throw new Error('자원이 부족합니다.'); } // 자원 차감 if (!free) { player.removeResources(BUILD_COSTS.ROAD); } // 도로 배치 edge.road = new Building('road', playerId, edgeId); player.roads++; logMessage(`${player.name}이(가) 도로를 건설했습니다.`); // 최장 도로 확인 this.game.checkLongestRoad(); return edge.road; } // 정착지 건설 buildSettlement(vertexId, playerId, free = false) { const vertex = this.game.board.vertices[vertexId]; const player = this.game.players[playerId]; // 검증 if (vertex.building) { throw new Error('이미 건물이 있습니다.'); } if (!free && !player.hasResources(BUILD_COSTS.SETTLEMENT)) { throw new Error('자원이 부족합니다.'); } // 거리 규칙 확인 for (const adjVertexId of vertex.adjacentVertices) { if (this.game.board.vertices[adjVertexId].building) { throw new Error('인접한 곳에 건물이 있어 건설할 수 없습니다.'); } } // 자원 차감 if (!free) { player.removeResources(BUILD_COSTS.SETTLEMENT); } // 정착지 배치 vertex.building = new Building('settlement', playerId, vertexId); player.settlements++; logMessage(`${player.name}이(가) 정착지를 건설했습니다.`); // 항구 확인 if (vertex.port) { logMessage(` → ${vertex.port.name} 항구 획득!`); } return vertex.building; } // 도시 업그레이드 upgradeToCity(vertexId, playerId) { const vertex = this.game.board.vertices[vertexId]; const player = this.game.players[playerId]; // 검증 if (!vertex.building || vertex.building.type !== 'settlement') { throw new Error('정착지가 없습니다.'); } if (vertex.building.playerId !== playerId) { throw new Error('다른 플레이어의 정착지입니다.'); } if (!player.hasResources(BUILD_COSTS.CITY)) { throw new Error('자원이 부족합니다.'); } // 자원 차감 player.removeResources(BUILD_COSTS.CITY); // 도시로 업그레이드 vertex.building.type = 'city'; player.settlements--; player.cities++; logMessage(`${player.name}이(가) 도시로 업그레이드했습니다.`); return vertex.building; } // 건설 가능한 도로 위치 반환 getValidRoadLocations(playerId, setupSettlementVertexId = null) { const validEdges = []; this.game.board.edges.forEach(edge => { // 이미 도로가 있으면 제외 if (edge.road) return; // 셋업 단계: 방금 지은 정착지에 연결된 변만 if (setupSettlementVertexId !== null) { if (edge.vertexIds.includes(setupSettlementVertexId)) { validEdges.push(edge.id); } return; } // 일반 단계: 자신의 건물이나 도로에 연결 const connected = edge.vertexIds.some(vertexId => { const vertex = this.game.board.vertices[vertexId]; // 자신의 건물에 연결 if (vertex.building && vertex.building.playerId === playerId) { return true; } // 자신의 도로에 연결 return vertex.adjacentEdges.some(adjEdgeId => { if (adjEdgeId === edge.id) return false; const adjEdge = this.game.board.edges[adjEdgeId]; return adjEdge.road && adjEdge.road.playerId === playerId; }); }); if (connected) { validEdges.push(edge.id); } }); return validEdges; } // 건설 가능한 정착지 위치 반환 getValidSettlementLocations(playerId, isSetupPhase = false) { const validVertices = []; this.game.board.vertices.forEach(vertex => { // 이미 건물이 있으면 제외 if (vertex.building) return; // 거리 규칙: 인접 정점에 건물 있으면 제외 const tooClose = vertex.adjacentVertices.some(adjId => this.game.board.vertices[adjId].building ); if (tooClose) return; // 셋업 단계가 아니면 도로 연결 필요 if (!isSetupPhase) { const hasRoad = vertex.adjacentEdges.some(edgeId => { const edge = this.game.board.edges[edgeId]; return edge.road && edge.road.playerId === playerId; }); if (!hasRoad) return; } validVertices.push(vertex.id); }); return validVertices; } // 업그레이드 가능한 정착지 위치 반환 getValidCityLocations(playerId) { return this.game.board.vertices .filter(v => v.building && v.building.type === 'settlement' && v.building.playerId === playerId) .map(v => v.id); } } ``` --- ## Step 2: 초기 배치 단계 (Setup Phase) ```javascript // Game 클래스에 추가 class Game { // ... 기존 코드 ... // 게임 시작 startGame(playerCount, aiDifficulty) { // 보드 초기화 this.board = new Board(); this.board.initialize(); // 플레이어 생성 this.createPlayers(playerCount, aiDifficulty); // 건설 관리자 this.buildingManager = new BuildingManager(this); // 셋업 단계 시작 this.phase = GAME_PHASES.SETUP; this.setupRound = 1; // 1라운드, 2라운드 this.setupStep = 'settlement'; // 'settlement' 또는 'road' logMessage('🎮 게임이 시작됩니다!'); logMessage('📍 초기 배치 단계: 정착지와 도로를 배치하세요.'); this.processSetupPhase(); } createPlayers(playerCount, aiDifficulty) { this.players = []; // 인간 플레이어 this.players.push(new Player(0, '플레이어', PLAYER_COLORS[0], false)); // AI 플레이어 for (let i = 1; i < playerCount; i++) { this.players.push(new Player( i, `AI ${i}`, PLAYER_COLORS[i], true, aiDifficulty )); } } // 셋업 단계 진행 async processSetupPhase() { if (this.phase !== GAME_PHASES.SETUP) return; const currentPlayer = this.getCurrentPlayer(); if (currentPlayer.isAI) { // AI 턴 처리 await this.processAISetupTurn(); } // 인간 플레이어는 UI에서 선택 대기 } // 셋업 - 정착지 배치 완료 async setupPlaceSettlement(vertexId) { const player = this.getCurrentPlayer(); // 정착지 건설 (무료) this.buildingManager.buildSettlement(vertexId, player.id, true); // 2라운드면 인접 타일에서 자원 획득 if (this.setupRound === 2) { const vertex = this.board.vertices[vertexId]; vertex.adjacentTiles.forEach(tileId => { const tile = this.board.tiles[tileId]; const resource = tile.getInfo().resource; if (resource) { player.addResources({ [resource]: 1 }); logMessage(` → ${getResourceEmoji(resource)} 획득`); } }); } // 도로 배치 단계로 this.setupStep = 'road'; this.lastSetupSettlementVertex = vertexId; } // 셋업 - 도로 배치 완료 async setupPlaceRoad(edgeId) { const player = this.getCurrentPlayer(); // 도로 건설 (무료) this.buildingManager.buildRoad(edgeId, player.id, true); // 다음 플레이어로 this.advanceSetupTurn(); } // 셋업 턴 진행 advanceSetupTurn() { this.setupStep = 'settlement'; this.lastSetupSettlementVertex = null; if (this.setupRound === 1) { // 1라운드: 순방향 this.currentPlayerIndex++; if (this.currentPlayerIndex >= this.players.length) { // 2라운드 시작: 역방향 this.setupRound = 2; this.currentPlayerIndex = this.players.length - 1; logMessage('📍 2라운드 시작: 역순으로 배치합니다.'); } } else { // 2라운드: 역방향 this.currentPlayerIndex--; if (this.currentPlayerIndex < 0) { // 셋업 완료! this.finishSetup(); return; } } this.processSetupPhase(); } // 셋업 완료 finishSetup() { this.phase = GAME_PHASES.ROLL_DICE; this.currentPlayerIndex = 0; this.turnNumber = 1; logMessage('🎲 초기 배치 완료! 게임을 시작합니다.'); logMessage(`${this.getCurrentPlayer().name}의 턴입니다. 주사위를 굴리세요.`); } // AI 셋업 턴 처리 async processAISetupTurn() { await delay(500); // 시각적 딜레이 const player = this.getCurrentPlayer(); const ai = this.getAIForPlayer(player); if (this.setupStep === 'settlement') { // AI가 정착지 위치 선택 const validLocations = this.buildingManager.getValidSettlementLocations(player.id, true); const chosen = ai.chooseSetupSettlement(validLocations, this.board); await this.setupPlaceSettlement(chosen); await delay(300); } if (this.setupStep === 'road') { // AI가 도로 위치 선택 const validLocations = this.buildingManager.getValidRoadLocations( player.id, this.lastSetupSettlementVertex ); const chosen = ai.chooseSetupRoad(validLocations, this.board); await this.setupPlaceRoad(chosen); } } } ``` --- ## Step 3: 건설 UI 인터랙션 ```javascript // UIManager에 추가 class UIManager { // ... 기존 코드 ... bindBuildingEvents() { // 도로 건설 버튼 document.getElementById('btn-build-road').addEventListener('click', () => { this.startBuildRoad(); }); // 정착지 건설 버튼 document.getElementById('btn-build-settlement').addEventListener('click', () => { this.startBuildSettlement(); }); // 도시 건설 버튼 document.getElementById('btn-build-city').addEventListener('click', () => { this.startBuildCity(); }); } // 도로 건설 모드 startBuildRoad() { const player = this.game.getCurrentPlayer(); const validLocations = this.game.buildingManager.getValidRoadLocations(player.id); if (validLocations.length === 0) { logMessage('⚠️ 도로를 건설할 수 있는 위치가 없습니다.'); return; } this.showBuildingCost('ROAD'); this.boardRenderer.setSelectionMode('edge', (edgeId) => { this.confirmBuild('road', edgeId); }, validLocations); } // 정착지 건설 모드 startBuildSettlement() { const player = this.game.getCurrentPlayer(); const validLocations = this.game.buildingManager.getValidSettlementLocations(player.id); if (validLocations.length === 0) { logMessage('⚠️ 정착지를 건설할 수 있는 위치가 없습니다.'); return; } this.showBuildingCost('SETTLEMENT'); this.boardRenderer.setSelectionMode('vertex', (vertexId) => { this.confirmBuild('settlement', vertexId); }, validLocations); } // 도시 건설 모드 startBuildCity() { const player = this.game.getCurrentPlayer(); const validLocations = this.game.buildingManager.getValidCityLocations(player.id); if (validLocations.length === 0) { logMessage('⚠️ 도시로 업그레이드할 정착지가 없습니다.'); return; } this.showBuildingCost('CITY'); this.boardRenderer.setSelectionMode('vertex', (vertexId) => { this.confirmBuild('city', vertexId); }, validLocations); } // 건설 확인 confirmBuild(type, locationId) { try { const player = this.game.getCurrentPlayer(); switch (type) { case 'road': this.game.buildingManager.buildRoad(locationId, player.id); break; case 'settlement': this.game.buildingManager.buildSettlement(locationId, player.id); break; case 'city': this.game.buildingManager.upgradeToCity(locationId, player.id); break; } this.boardRenderer.clearSelectionMode(); this.updateUI(); // 승리 확인 if (player.getVictoryPoints() >= VICTORY_POINTS_TO_WIN) { this.game.endGame(player); } } catch (error) { logMessage(`⚠️ ${error.message}`); } } // 셋업 단계 UI handleSetupPhase() { const player = this.game.getCurrentPlayer(); if (player.isAI) return; if (this.game.setupStep === 'settlement') { logMessage(`${player.name}: 정착지를 배치하세요.`); const validLocations = this.game.buildingManager.getValidSettlementLocations(player.id, true); this.boardRenderer.setSelectionMode('vertex', async (vertexId) => { await this.game.setupPlaceSettlement(vertexId); this.boardRenderer.clearSelectionMode(); this.updateUI(); this.handleSetupPhase(); }, validLocations); } else if (this.game.setupStep === 'road') { logMessage(`${player.name}: 도로를 배치하세요.`); const validLocations = this.game.buildingManager.getValidRoadLocations( player.id, this.game.lastSetupSettlementVertex ); this.boardRenderer.setSelectionMode('edge', async (edgeId) => { await this.game.setupPlaceRoad(edgeId); this.boardRenderer.clearSelectionMode(); this.updateUI(); if (this.game.phase === GAME_PHASES.SETUP) { this.handleSetupPhase(); } }, validLocations); } } // 건설 비용 표시 showBuildingCost(buildingType) { const costs = BUILD_COSTS[buildingType]; const costStr = Object.entries(costs) .map(([r, a]) => `${getResourceEmoji(r)}×${a}`) .join(' '); logMessage(`📝 건설 비용: ${costStr}`); logMessage('💡 유효한 위치를 클릭하세요. (ESC로 취소)'); } // ESC 키로 선택 취소 bindCancelKey() { document.addEventListener('keydown', (e) => { if (e.key === 'Escape') { this.boardRenderer.clearSelectionMode(); logMessage('선택이 취소되었습니다.'); } }); } } ``` --- ## Step 4: 최장 도로 계산 ```javascript // Game 클래스에 추가 class Game { // 최장 도로 확인 checkLongestRoad() { let longestLength = 4; // 최소 5개 이상이어야 함 let longestPlayer = null; this.players.forEach(player => { const length = this.calculateLongestRoad(player.id); if (length > longestLength) { longestLength = length; longestPlayer = player; } }); // 최장 도로 업데이트 this.players.forEach(player => { const hadIt = player.hasLongestRoad; player.hasLongestRoad = (player === longestPlayer); if (!hadIt && player.hasLongestRoad) { logMessage(`🛤️ ${player.name}이(가) 최장 도로를 획득했습니다!`); } }); } // 플레이어의 최장 도로 길이 계산 (DFS) calculateLongestRoad(playerId) { const playerEdges = this.board.edges.filter(e => e.road && e.road.playerId === playerId ); if (playerEdges.length === 0) return 0; let maxLength = 0; // 각 도로에서 시작하여 DFS playerEdges.forEach(startEdge => { const visited = new Set(); const length = this.dfsRoad(startEdge.id, playerId, visited); maxLength = Math.max(maxLength, length); }); return maxLength; } dfsRoad(edgeId, playerId, visited) { if (visited.has(edgeId)) return 0; visited.add(edgeId); const edge = this.board.edges[edgeId]; let maxBranch = 0; // 양 끝점에서 연결된 도로 탐색 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 branchLength = this.dfsRoad(adjEdgeId, playerId, new Set(visited)); maxBranch = Math.max(maxBranch, branchLength); } }); }); return 1 + maxBranch; } } ``` --- ## 다음 단계 예고 Part 5에서는 **거래 시스템**을 구현합니다: - 은행과의 4:1 거래 - 항구 거래 (3:1, 2:1) - 플레이어 간 거래 제안 --- *이전 파트: [Part 3 - 자원 시스템 및 주사위](make-web-katan-game-v3.md)* *다음 파트: [Part 5 - 거래 시스템](make-web-katan-game-v5.md)*