# 웹 카탄 게임 만들기 - Part 8: AI 구현 ## 이번 파트에서 구현할 내용 - AI 기본 클래스 구조 - 쉬움 난이도 AI (랜덤 선택) - 보통 난이도 AI (규칙 기반) - 어려움 난이도 AI (전략적 평가) - AI 의사결정 딜레이 (자연스러운 플레이) --- ## Step 1: AI 기본 클래스 ```javascript // js/ai/AI.js class AI { constructor(player, game) { this.player = player; this.game = game; this.actionDelay = 500; // 밀리초 } // 셋업 단계: 정착지 위치 선택 chooseSetupSettlement(validLocations, board) { throw new Error('서브클래스에서 구현해야 합니다.'); } // 셋업 단계: 도로 위치 선택 chooseSetupRoad(validLocations, board) { throw new Error('서브클래스에서 구현해야 합니다.'); } // 건설 위치 선택 chooseBuildLocation(type, validLocations) { throw new Error('서브클래스에서 구현해야 합니다.'); } // 도둑 배치 위치 선택 chooseRobberPlacement(board) { throw new Error('서브클래스에서 구현해야 합니다.'); } // 거래 평가 evaluateTrade(offer, request) { throw new Error('서브클래스에서 구현해야 합니다.'); } // 풍년 카드: 자원 선택 chooseYearOfPlentyResources() { throw new Error('서브클래스에서 구현해야 합니다.'); } // 독점 카드: 자원 선택 chooseMonopolyResource(players) { throw new Error('서브클래스에서 구현해야 합니다.'); } // 메인 턴 진행 async takeTurn() { throw new Error('서브클래스에서 구현해야 합니다.'); } // 유틸리티: 정점의 가치 평가 evaluateVertexValue(vertexId, board) { const vertex = board.vertices[vertexId]; let value = 0; // 인접 타일의 자원과 확률로 평가 vertex.adjacentTiles.forEach(tileId => { const tile = board.tiles[tileId]; if (!tile.getInfo().resource) return; // 주사위 확률 (6, 8이 가장 높음) const probability = this.getDiceProbability(tile.number); value += probability; // 자원 희소성 가중치 const resourceWeight = this.getResourceWeight(tile.getInfo().resource); value += resourceWeight * probability; }); // 항구 보너스 if (vertex.port) { value += 5; } return value; } // 주사위 확률 (36분의 X) getDiceProbability(number) { const probs = { 2: 1, 3: 2, 4: 3, 5: 4, 6: 5, 8: 5, 9: 4, 10: 3, 11: 2, 12: 1 }; return probs[number] || 0; } // 자원 가중치 (희소성 기반) getResourceWeight(resource) { const weights = { [RESOURCES.ORE]: 1.2, // 도시와 개발카드에 필요 [RESOURCES.GRAIN]: 1.1, // 도시와 개발카드에 필요 [RESOURCES.BRICK]: 1.0, [RESOURCES.LUMBER]: 1.0, [RESOURCES.WOOL]: 0.9 }; return weights[resource] || 1; } } ``` --- ## Step 2: 쉬움 난이도 AI ```javascript // js/ai/EasyAI.js class EasyAI extends AI { constructor(player, game) { super(player, game); this.actionDelay = 800; // 조금 더 천천히 } // 셋업: 랜덤 선택 chooseSetupSettlement(validLocations, board) { return randomChoice(validLocations); } chooseSetupRoad(validLocations, board) { return randomChoice(validLocations); } chooseBuildLocation(type, validLocations) { return randomChoice(validLocations); } // 도둑: 랜덤 타일 (자기 건물 없는 곳) chooseRobberPlacement(board) { const validTiles = board.tiles.filter(tile => { if (tile.type === 'DESERT') return false; if (tile.id === board.robberTileId) return false; // 자기 건물이 있는 타일 피하기 const hasOwnBuilding = tile.vertexIds.some(vId => { const vertex = board.vertices[vId]; return vertex.building && vertex.building.playerId === this.player.id; }); return !hasOwnBuilding; }); const tile = randomChoice(validTiles); // 피해자 선택: 랜덤 const victims = this.getPlayersOnTile(tile, board); const victim = victims.length > 0 ? randomChoice(victims) : null; return { tileId: tile.id, victimId: victim }; } getPlayersOnTile(tile, board) { const players = new Set(); tile.vertexIds.forEach(vId => { const vertex = board.vertices[vId]; if (vertex.building && vertex.building.playerId !== this.player.id) { players.add(vertex.building.playerId); } }); return Array.from(players); } // 거래: 자원이 충분하면 수락 evaluateTrade(offer, request) { const totalOffer = Object.values(offer).reduce((a, b) => a + b, 0); const totalRequest = Object.values(request).reduce((a, b) => a + b, 0); // 요청이 제안보다 적거나 같으면 수락 return totalRequest <= totalOffer; } chooseYearOfPlentyResources() { const resources = Object.values(RESOURCES); return [randomChoice(resources), randomChoice(resources)]; } chooseMonopolyResource(players) { return randomChoice(Object.values(RESOURCES)); } // 메인 턴: 간단한 로직 async takeTurn() { await delay(this.actionDelay); // 랜덤하게 건설 시도 const buildOptions = ['road', 'settlement', 'city', 'devCard']; shuffleArray(buildOptions); for (const option of buildOptions) { if (this.player.canBuild(option)) { await this.tryBuild(option); break; } } // 턴 종료 await delay(this.actionDelay); } async tryBuild(type) { const buildingManager = this.game.buildingManager; let validLocations; switch (type) { case 'road': validLocations = buildingManager.getValidRoadLocations(this.player.id); if (validLocations.length > 0) { const location = this.chooseBuildLocation(type, validLocations); buildingManager.buildRoad(location, this.player.id); } break; case 'settlement': validLocations = buildingManager.getValidSettlementLocations(this.player.id); if (validLocations.length > 0) { const location = this.chooseBuildLocation(type, validLocations); buildingManager.buildSettlement(location, this.player.id); } break; case 'city': validLocations = buildingManager.getValidCityLocations(this.player.id); if (validLocations.length > 0) { const location = this.chooseBuildLocation(type, validLocations); buildingManager.upgradeToCity(location, this.player.id); } break; case 'devCard': this.game.devCardManager.buyCard(this.player.id); break; } } } ``` --- ## Step 3: 보통 난이도 AI ```javascript // js/ai/MediumAI.js class MediumAI extends AI { constructor(player, game) { super(player, game); this.actionDelay = 600; } // 셋업: 가치 평가 기반 선택 chooseSetupSettlement(validLocations, board) { let bestLocation = validLocations[0]; let bestValue = -Infinity; validLocations.forEach(vertexId => { const value = this.evaluateVertexValue(vertexId, board); if (value > bestValue) { bestValue = value; bestLocation = vertexId; } }); return bestLocation; } chooseSetupRoad(validLocations, board) { // 확장 가능성 고려 let bestEdge = validLocations[0]; let bestValue = -Infinity; validLocations.forEach(edgeId => { const edge = board.edges[edgeId]; let value = 0; // 연결되는 정점의 가치 평가 edge.vertexIds.forEach(vId => { const vertex = board.vertices[vId]; if (!vertex.building) { // 거리 규칙을 만족하는지 확인 const canBuild = !vertex.adjacentVertices.some(adjId => board.vertices[adjId].building ); if (canBuild) { value += this.evaluateVertexValue(vId, board); } } }); if (value > bestValue) { bestValue = value; bestEdge = edgeId; } }); return bestEdge; } chooseBuildLocation(type, validLocations) { if (type === 'road') { return this.chooseBestRoadLocation(validLocations); } else if (type === 'settlement') { return this.chooseSetupSettlement(validLocations, this.game.board); } else if (type === 'city') { return this.chooseBestCityLocation(validLocations); } return randomChoice(validLocations); } chooseBestRoadLocation(validLocations) { // 최장 도로 또는 정착지 확장을 위한 도로 let bestEdge = validLocations[0]; let bestValue = -Infinity; validLocations.forEach(edgeId => { const edge = this.game.board.edges[edgeId]; let value = 0; // 새 정착지 위치로 이어지는지 확인 edge.vertexIds.forEach(vId => { const vertex = this.game.board.vertices[vId]; if (!vertex.building && this.game.board.canBuildAtVertex(vId, this.player.id)) { value += this.evaluateVertexValue(vId, this.game.board) * 0.5; } }); // 최장 도로 연장 가능성 value += this.evaluateRoadExtension(edgeId); if (value > bestValue) { bestValue = value; bestEdge = edgeId; } }); return bestEdge; } evaluateRoadExtension(edgeId) { // 현재 도로 길이 대비 연장 효과 const currentLength = this.game.calculateLongestRoad(this.player.id); // 간단히 연결된 도로 수로 평가 const edge = this.game.board.edges[edgeId]; let connectedRoads = 0; edge.vertexIds.forEach(vId => { const vertex = this.game.board.vertices[vId]; vertex.adjacentEdges.forEach(adjEdgeId => { if (adjEdgeId !== edgeId) { const adjEdge = this.game.board.edges[adjEdgeId]; if (adjEdge.road && adjEdge.road.playerId === this.player.id) { connectedRoads++; } } }); }); return connectedRoads * 2; } chooseBestCityLocation(validLocations) { // 가장 생산성 높은 정착지를 도시로 let bestVertex = validLocations[0]; let bestValue = -Infinity; validLocations.forEach(vertexId => { const value = this.evaluateVertexValue(vertexId, this.game.board); if (value > bestValue) { bestValue = value; bestVertex = vertexId; } }); return bestVertex; } chooseRobberPlacement(board) { // 선두 플레이어에게 피해 주기 let bestTile = null; let bestValue = -Infinity; let bestVictim = null; board.tiles.forEach(tile => { if (tile.type === 'DESERT') return; if (tile.id === board.robberTileId) return; const victims = this.getPlayersOnTile(tile, board); if (victims.length === 0) return; // 자기 건물 없는지 확인 const hasOwnBuilding = tile.vertexIds.some(vId => { const vertex = board.vertices[vId]; return vertex.building && vertex.building.playerId === this.player.id; }); if (hasOwnBuilding) return; // 선두 플레이어에게 피해 victims.forEach(victimId => { const victimPlayer = this.game.players[victimId]; const victimScore = this.game.scoreManager.getPublicScore(victimId); let value = victimScore * 2; // 생산성 높은 타일 우선 value += this.getDiceProbability(tile.number); if (value > bestValue) { bestValue = value; bestTile = tile; bestVictim = victimId; } }); }); return { tileId: bestTile ? bestTile.id : board.tiles.find(t => t.type !== 'DESERT').id, victimId: bestVictim }; } getPlayersOnTile(tile, board) { const players = new Set(); tile.vertexIds.forEach(vId => { const vertex = board.vertices[vId]; if (vertex.building && vertex.building.playerId !== this.player.id) { players.add(vertex.building.playerId); } }); return Array.from(players); } evaluateTrade(offer, request) { // 필요한 자원인지 평가 let offerValue = 0; let requestValue = 0; Object.entries(offer).forEach(([resource, amount]) => { offerValue += amount * this.getResourceNeed(resource); }); Object.entries(request).forEach(([resource, amount]) => { requestValue += amount * this.getResourceNeed(resource); }); // 받는 것이 주는 것보다 필요하면 수락 return requestValue > offerValue * 0.8; } getResourceNeed(resource) { const current = this.player.resources[resource]; // 부족한 자원일수록 가치 높음 return Math.max(0, 3 - current); } chooseYearOfPlentyResources() { // 가장 필요한 자원 2개 const needs = Object.values(RESOURCES).map(r => ({ resource: r, need: this.getResourceNeed(r) })); needs.sort((a, b) => b.need - a.need); return [needs[0].resource, needs[1].resource]; } chooseMonopolyResource(players) { // 다른 플레이어가 가장 많이 가진 자원 let bestResource = RESOURCES.GRAIN; let maxTotal = 0; Object.values(RESOURCES).forEach(resource => { let total = 0; players.forEach((p, index) => { if (index !== this.player.id) { total += p.resources[resource]; } }); if (total > maxTotal) { maxTotal = total; bestResource = resource; } }); return bestResource; } async takeTurn() { await delay(this.actionDelay); // 우선순위에 따른 건설 await this.executeBuildStrategy(); // 은행 거래 시도 await this.tryBankTrade(); await delay(this.actionDelay); } async executeBuildStrategy() { // 1. 도시 우선 (2점) if (this.player.canBuild('city')) { const locations = this.game.buildingManager.getValidCityLocations(this.player.id); if (locations.length > 0) { const location = this.chooseBestCityLocation(locations); this.game.buildingManager.upgradeToCity(location, this.player.id); return; } } // 2. 정착지 (1점) if (this.player.canBuild('settlement')) { const locations = this.game.buildingManager.getValidSettlementLocations(this.player.id); if (locations.length > 0) { const location = this.chooseBuildLocation('settlement', locations); this.game.buildingManager.buildSettlement(location, this.player.id); return; } } // 3. 도로 (정착지 위치 확보) if (this.player.canBuild('road')) { const locations = this.game.buildingManager.getValidRoadLocations(this.player.id); if (locations.length > 0) { const location = this.chooseBestRoadLocation(locations); this.game.buildingManager.buildRoad(location, this.player.id); return; } } // 4. 개발 카드 if (this.player.canBuild('devCard')) { this.game.devCardManager.buyCard(this.player.id); } } async tryBankTrade() { // 4개 이상 있는 자원을 필요한 자원으로 교환 const ratios = this.game.tradeManager.getTradeRatios(this.player.id); for (const [resource, count] of Object.entries(this.player.resources)) { const ratio = ratios[resource]; if (count >= ratio) { // 가장 필요한 자원으로 교환 const needs = Object.values(RESOURCES) .filter(r => r !== resource) .map(r => ({ resource: r, need: this.getResourceNeed(r) })); needs.sort((a, b) => b.need - a.need); if (needs[0].need > 0) { try { this.game.tradeManager.tradeWithBank( this.player.id, resource, ratio, needs[0].resource ); } catch (e) { // 거래 실패 무시 } } } } } } ``` --- ## Step 4: 어려움 난이도 AI ```javascript // js/ai/HardAI.js class HardAI extends MediumAI { constructor(player, game) { super(player, game); this.actionDelay = 400; } // 더 정교한 정점 평가 evaluateVertexValue(vertexId, board) { let value = super.evaluateVertexValue(vertexId, board); const vertex = board.vertices[vertexId]; // 다양한 자원 확보 보너스 const resources = new Set(); vertex.adjacentTiles.forEach(tileId => { const resource = board.tiles[tileId].getInfo().resource; if (resource) resources.add(resource); }); value += resources.size * 3; // 자원 다양성 보너스 // 확장 가능성 평가 let expansionPotential = 0; vertex.adjacentEdges.forEach(edgeId => { const edge = board.edges[edgeId]; const otherVertexId = edge.vertexIds.find(v => v !== vertexId); const otherVertex = board.vertices[otherVertexId]; if (!otherVertex.building) { const canBuild = !otherVertex.adjacentVertices.some(adjId => board.vertices[adjId].building ); if (canBuild) { expansionPotential += this.evaluateVertexValue(otherVertexId, board) * 0.3; } } }); value += expansionPotential; return value; } // 전략적 거래 평가 evaluateTrade(offer, request) { // 현재 전략에 따른 평가 const strategy = this.determineStrategy(); let offerValue = 0; let requestValue = 0; Object.entries(offer).forEach(([resource, amount]) => { offerValue += amount * this.getStrategicResourceValue(resource, strategy); }); Object.entries(request).forEach(([resource, amount]) => { requestValue += amount * this.getStrategicResourceValue(resource, strategy); }); return requestValue > offerValue; } determineStrategy() { const score = this.game.scoreManager.getPublicScore(this.player.id); // 게임 후반: 승점 집중 if (score >= 7) return 'victory'; // 최장 도로 경쟁 const roadLength = this.game.calculateLongestRoad(this.player.id); if (roadLength >= 4 && !this.player.hasLongestRoad) return 'longest_road'; // 최대 기사단 경쟁 if (this.player.usedKnights >= 2) return 'largest_army'; // 기본: 확장 return 'expand'; } getStrategicResourceValue(resource, strategy) { const base = this.getResourceNeed(resource); switch (strategy) { case 'victory': // 도시 자원 우선 if (resource === RESOURCES.ORE || resource === RESOURCES.GRAIN) { return base * 1.5; } break; case 'longest_road': // 도로 자원 우선 if (resource === RESOURCES.BRICK || resource === RESOURCES.LUMBER) { return base * 1.5; } break; case 'largest_army': // 개발카드 자원 우선 if (resource === RESOURCES.ORE || resource === RESOURCES.GRAIN || resource === RESOURCES.WOOL) { return base * 1.3; } break; } return base; } async takeTurn() { await delay(this.actionDelay); const strategy = this.determineStrategy(); // 전략에 따른 행동 switch (strategy) { case 'victory': await this.executeVictoryStrategy(); break; case 'longest_road': await this.executeLongestRoadStrategy(); break; case 'largest_army': await this.executeLargestArmyStrategy(); break; default: await this.executeBuildStrategy(); } // 개발 카드 사용 await this.tryUseDevCard(); // 은행 거래 await this.tryBankTrade(); await delay(this.actionDelay); } async executeVictoryStrategy() { // 도시 최우선 if (this.player.canBuild('city')) { const locations = this.game.buildingManager.getValidCityLocations(this.player.id); if (locations.length > 0) { const location = this.chooseBestCityLocation(locations); this.game.buildingManager.upgradeToCity(location, this.player.id); return; } } // 승점 개발카드 if (this.player.canBuild('devCard')) { this.game.devCardManager.buyCard(this.player.id); } } async executeLongestRoadStrategy() { // 도로 우선 if (this.player.canBuild('road')) { const locations = this.game.buildingManager.getValidRoadLocations(this.player.id); if (locations.length > 0) { const location = this.chooseBestRoadLocation(locations); this.game.buildingManager.buildRoad(location, this.player.id); return; } } await this.executeBuildStrategy(); } async executeLargestArmyStrategy() { // 개발카드 우선 if (this.player.canBuild('devCard')) { this.game.devCardManager.buyCard(this.player.id); return; } await this.executeBuildStrategy(); } async tryUseDevCard() { // 기사 카드 사용 (최대 기사단 확보) for (let i = 0; i < this.player.devCards.length; i++) { const card = this.player.devCards[i]; if (card.type === 'KNIGHT' && this.game.devCardManager.canUseCard(this.player.id, i)) { await this.game.devCardManager.useCard(this.player.id, i); break; } } } } ``` --- ## Step 5: AI 팩토리 및 통합 ```javascript // Game 클래스에 추가 class Game { getAIForPlayer(player) { if (!player.isAI) return null; switch (player.aiDifficulty) { case AI_DIFFICULTY.EASY: return new EasyAI(player, this); case AI_DIFFICULTY.MEDIUM: return new MediumAI(player, this); case AI_DIFFICULTY.HARD: return new HardAI(player, this); default: return new MediumAI(player, this); } } async processAITurn() { const player = this.getCurrentPlayer(); if (!player.isAI) return; const ai = this.getAIForPlayer(player); // 주사위 굴리기 await delay(ai.actionDelay); const diceRoll = this.diceManager.roll(); this.uiManager.updateDice(diceRoll); logMessage(`🎲 ${player.name} 주사위: ${diceRoll}`); this.distributeResources(diceRoll); // 7이면 도둑 처리 if (diceRoll === 7) { await delay(ai.actionDelay); const { tileId, victimId } = ai.chooseRobberPlacement(this.board); this.moveRobber(tileId, victimId); } this.phase = GAME_PHASES.MAIN; this.uiManager.updateUI(); // AI 턴 실행 await ai.takeTurn(); // 턴 종료 this.endTurn(); } } ``` --- ## 다음 단계 예고 Part 9에서는 **게임 설정 및 UI 완성**을 구현합니다: - 게임 설정 화면 완성 - 튜토리얼 시스템 - UI 폴리싱 및 반응성 개선 - 게임 로그 개선 --- *이전 파트: [Part 7 - 특별 점수 시스템](make-web-katan-game-v7.md)* *다음 파트: [Part 9 - 게임 설정 및 UI 완성](make-web-katan-game-v9.md)*