# 웹 카탄 게임 만들기 - Part 5: 거래 시스템 ## 이번 파트에서 구현할 내용 - 은행과의 4:1 거래 - 항구 시스템 (3:1 일반 항구, 2:1 특수 항구) - 플레이어 간 거래 제안 - 거래 UI 모달 --- ## Step 1: 항구 시스템 설정 ```javascript // js/utils/constants.js에 추가 // 항구 타입 const PORT_TYPES = { GENERIC: { ratio: 3, resource: null, name: '3:1 항구' }, GRAIN: { ratio: 2, resource: RESOURCES.GRAIN, name: '밀 항구' }, BRICK: { ratio: 2, resource: RESOURCES.BRICK, name: '벽돌 항구' }, LUMBER: { ratio: 2, resource: RESOURCES.LUMBER, name: '목재 항구' }, WOOL: { ratio: 2, resource: RESOURCES.WOOL, name: '양모 항구' }, ORE: { ratio: 2, resource: RESOURCES.ORE, name: '광석 항구' } }; // 표준 항구 배치 (정점 ID와 항구 타입) const STANDARD_PORTS = [ { vertexIds: [0, 1], type: 'GENERIC' }, { vertexIds: [3, 4], type: 'GRAIN' }, { vertexIds: [14, 15], type: 'ORE' }, { vertexIds: [7, 17], type: 'GENERIC' }, { vertexIds: [26, 37], type: 'WOOL' }, { vertexIds: [45, 46], type: 'GENERIC' }, { vertexIds: [47, 48], type: 'GENERIC' }, { vertexIds: [50, 51], type: 'BRICK' }, { vertexIds: [28, 38], type: 'LUMBER' } ]; ``` --- ## Step 2: TradeManager 클래스 구현 ```javascript // js/game/TradeManager.js class TradeManager { constructor(game) { this.game = game; } // 플레이어의 거래 비율 계산 getTradeRatios(playerId) { const player = this.game.players[playerId]; const ratios = { [RESOURCES.GRAIN]: 4, [RESOURCES.BRICK]: 4, [RESOURCES.LUMBER]: 4, [RESOURCES.WOOL]: 4, [RESOURCES.ORE]: 4 }; // 플레이어가 소유한 정점 확인 this.game.board.vertices.forEach(vertex => { if (!vertex.building || vertex.building.playerId !== playerId) return; if (!vertex.port) return; const portType = PORT_TYPES[vertex.port.type]; if (portType.resource) { // 특수 항구: 해당 자원 2:1 ratios[portType.resource] = Math.min(ratios[portType.resource], 2); } else { // 일반 항구: 모든 자원 3:1 for (const resource of Object.values(RESOURCES)) { ratios[resource] = Math.min(ratios[resource], 3); } } }); return ratios; } // 은행 거래 실행 tradeWithBank(playerId, giveResource, giveAmount, receiveResource) { const player = this.game.players[playerId]; const ratios = this.getTradeRatios(playerId); const ratio = ratios[giveResource]; // 검증 if (giveAmount < ratio || giveAmount % ratio !== 0) { throw new Error(`${ratio}개 단위로 거래해야 합니다.`); } if (player.resources[giveResource] < giveAmount) { throw new Error('자원이 부족합니다.'); } if (giveResource === receiveResource) { throw new Error('같은 자원으로 교환할 수 없습니다.'); } const receiveAmount = giveAmount / ratio; // 거래 실행 player.resources[giveResource] -= giveAmount; player.resources[receiveResource] += receiveAmount; logMessage( `${player.name}이(가) ${getResourceEmoji(giveResource)}×${giveAmount}을(를) ` + `${getResourceEmoji(receiveResource)}×${receiveAmount}(으)로 교환했습니다.` ); return { given: giveAmount, received: receiveAmount }; } // 거래 가능 여부 확인 canTradeWithBank(playerId, resource) { const player = this.game.players[playerId]; const ratios = this.getTradeRatios(playerId); return player.resources[resource] >= ratios[resource]; } // 플레이어 간 거래 제안 proposePlayerTrade(fromPlayerId, toPlayerId, offer, request) { const fromPlayer = this.game.players[fromPlayerId]; const toPlayer = this.game.players[toPlayerId]; // 제안자 자원 확인 for (const [resource, amount] of Object.entries(offer)) { if (fromPlayer.resources[resource] < amount) { throw new Error('제안할 자원이 부족합니다.'); } } return { from: fromPlayerId, to: toPlayerId, offer: offer, request: request, status: 'pending' }; } // 거래 수락 acceptTrade(trade) { const fromPlayer = this.game.players[trade.from]; const toPlayer = this.game.players[trade.to]; // 수락자 자원 확인 for (const [resource, amount] of Object.entries(trade.request)) { if (toPlayer.resources[resource] < amount) { throw new Error('거래에 필요한 자원이 부족합니다.'); } } // 자원 교환 for (const [resource, amount] of Object.entries(trade.offer)) { fromPlayer.resources[resource] -= amount; toPlayer.resources[resource] += amount; } for (const [resource, amount] of Object.entries(trade.request)) { toPlayer.resources[resource] -= amount; fromPlayer.resources[resource] += amount; } const offerStr = Object.entries(trade.offer) .map(([r, a]) => `${getResourceEmoji(r)}×${a}`) .join(', '); const requestStr = Object.entries(trade.request) .map(([r, a]) => `${getResourceEmoji(r)}×${a}`) .join(', '); logMessage( `💱 ${fromPlayer.name}과(와) ${toPlayer.name}이(가) 거래했습니다: ` + `${offerStr} ↔ ${requestStr}` ); trade.status = 'accepted'; return trade; } // 거래 거절 declineTrade(trade) { trade.status = 'declined'; logMessage(`${this.game.players[trade.to].name}이(가) 거래를 거절했습니다.`); return trade; } } ``` --- ## Step 3: 거래 UI 모달 ```html ``` --- ## Step 4: 거래 UI JavaScript ```javascript // js/ui/TradeUI.js class TradeUI { constructor(game, uiManager) { this.game = game; this.uiManager = uiManager; // 선택된 자원 this.selectedGive = null; this.selectedReceive = null; this.giveAmount = 0; // 플레이어 거래 this.playerOffer = {}; this.playerRequest = {}; this.tradeTarget = null; this.bindEvents(); } bindEvents() { // 거래 버튼 document.getElementById('btn-trade').addEventListener('click', () => { this.openTradeModal(); }); // 모달 닫기 document.querySelector('#trade-modal .close-btn').addEventListener('click', () => { this.closeTradeModal(); }); // 탭 전환 document.querySelectorAll('.trade-tab').forEach(tab => { tab.addEventListener('click', (e) => { this.switchTab(e.target.dataset.tab); }); }); // 은행 거래 실행 document.getElementById('execute-bank-trade').addEventListener('click', () => { this.executeBankTrade(); }); // 플레이어 거래 제안 document.getElementById('propose-trade').addEventListener('click', () => { this.proposePlayerTrade(); }); } openTradeModal() { document.getElementById('trade-modal').classList.add('active'); this.renderBankTradePanel(); this.renderPlayerTradePanel(); } closeTradeModal() { document.getElementById('trade-modal').classList.remove('active'); this.resetSelections(); } switchTab(tab) { document.querySelectorAll('.trade-tab').forEach(t => t.classList.remove('active')); document.querySelectorAll('.trade-panel').forEach(p => p.classList.remove('active')); document.querySelector(`.trade-tab[data-tab="${tab}"]`).classList.add('active'); document.getElementById(`${tab}-trade-panel`).classList.add('active'); } // 은행 거래 패널 렌더링 renderBankTradePanel() { const player = this.game.players[0]; const ratios = this.game.tradeManager.getTradeRatios(0); // 줄 자원 선택 const giveContainer = document.getElementById('bank-give-resources'); giveContainer.innerHTML = ''; Object.values(RESOURCES).forEach(resource => { const count = player.resources[resource]; const ratio = ratios[resource]; const canTrade = count >= ratio; const div = document.createElement('div'); div.className = `trade-resource-item ${canTrade ? '' : 'disabled'} ${this.selectedGive === resource ? 'selected' : ''}`; div.innerHTML = ` ${getResourceEmoji(resource)} ${count} ${ratio}:1 `; if (canTrade) { div.addEventListener('click', () => this.selectGiveResource(resource)); } giveContainer.appendChild(div); }); // 받을 자원 선택 const receiveContainer = document.getElementById('bank-receive-resources'); receiveContainer.innerHTML = ''; Object.values(RESOURCES).forEach(resource => { const isSelected = this.selectedReceive === resource; const isSameAsGive = this.selectedGive === resource; const div = document.createElement('div'); div.className = `trade-resource-item ${isSameAsGive ? 'disabled' : ''} ${isSelected ? 'selected' : ''}`; div.innerHTML = ` ${getResourceEmoji(resource)} ${getResourceName(resource)} `; if (!isSameAsGive) { div.addEventListener('click', () => this.selectReceiveResource(resource)); } receiveContainer.appendChild(div); }); this.updateBankTradeSummary(); } selectGiveResource(resource) { this.selectedGive = resource; const ratios = this.game.tradeManager.getTradeRatios(0); this.giveAmount = ratios[resource]; this.renderBankTradePanel(); } selectReceiveResource(resource) { this.selectedReceive = resource; this.renderBankTradePanel(); } updateBankTradeSummary() { const summary = document.getElementById('bank-trade-summary'); const btn = document.getElementById('execute-bank-trade'); if (this.selectedGive && this.selectedReceive) { const ratios = this.game.tradeManager.getTradeRatios(0); const ratio = ratios[this.selectedGive]; const receiveAmount = this.giveAmount / ratio; summary.innerHTML = ` ${getResourceEmoji(this.selectedGive)}×${this.giveAmount} ➡️ ${getResourceEmoji(this.selectedReceive)}×${receiveAmount} `; btn.disabled = false; } else { summary.innerHTML = '교환할 자원을 선택하세요'; btn.disabled = true; } } executeBankTrade() { try { this.game.tradeManager.tradeWithBank( 0, this.selectedGive, this.giveAmount, this.selectedReceive ); this.resetSelections(); this.renderBankTradePanel(); this.uiManager.updateUI(); } catch (error) { logMessage(`⚠️ ${error.message}`); } } // 플레이어 거래 패널 렌더링 renderPlayerTradePanel() { const player = this.game.players[0]; // 제안 자원 const offerContainer = document.getElementById('player-offer-resources'); offerContainer.innerHTML = ''; Object.values(RESOURCES).forEach(resource => { const count = player.resources[resource]; const offered = this.playerOffer[resource] || 0; const div = document.createElement('div'); div.className = 'resource-input-item'; div.innerHTML = ` ${getResourceEmoji(resource)} (${count}) ${offered} `; offerContainer.appendChild(div); }); // 요청 자원 const requestContainer = document.getElementById('player-request-resources'); requestContainer.innerHTML = ''; Object.values(RESOURCES).forEach(resource => { const requested = this.playerRequest[resource] || 0; const div = document.createElement('div'); div.className = 'resource-input-item'; div.innerHTML = ` ${getResourceEmoji(resource)} ${requested} `; requestContainer.appendChild(div); }); // 버튼 이벤트 document.querySelectorAll('.plus-btn, .minus-btn').forEach(btn => { btn.addEventListener('click', (e) => { const resource = e.target.dataset.resource; const type = e.target.dataset.type; const isPlus = e.target.classList.contains('plus-btn'); this.adjustResourceAmount(resource, type, isPlus); }); }); // 거래 대상 const targetContainer = document.getElementById('trade-target-players'); targetContainer.innerHTML = ''; this.game.players.forEach((p, index) => { if (index === 0) return; // 자기 자신 제외 const div = document.createElement('div'); div.className = `trade-target-item ${this.tradeTarget === index ? 'selected' : ''}`; div.innerHTML = ` ${p.name} 📦 ${p.getTotalResources()} `; div.addEventListener('click', () => { this.tradeTarget = index; this.renderPlayerTradePanel(); }); targetContainer.appendChild(div); }); } adjustResourceAmount(resource, type, isPlus) { const player = this.game.players[0]; const target = type === 'offer' ? this.playerOffer : this.playerRequest; const current = target[resource] || 0; if (isPlus) { if (type === 'offer' && current >= player.resources[resource]) return; target[resource] = current + 1; } else { if (current <= 0) return; target[resource] = current - 1; } this.renderPlayerTradePanel(); } proposePlayerTrade() { if (this.tradeTarget === null) { logMessage('⚠️ 거래 대상을 선택하세요.'); return; } const hasOffer = Object.values(this.playerOffer).some(v => v > 0); const hasRequest = Object.values(this.playerRequest).some(v => v > 0); if (!hasOffer || !hasRequest) { logMessage('⚠️ 교환할 자원을 선택하세요.'); return; } // AI에게 거래 제안 const targetPlayer = this.game.players[this.tradeTarget]; if (targetPlayer.isAI) { const ai = this.game.getAIForPlayer(targetPlayer); const accepted = ai.evaluateTrade(this.playerOffer, this.playerRequest); if (accepted) { try { const trade = this.game.tradeManager.proposePlayerTrade( 0, this.tradeTarget, this.playerOffer, this.playerRequest ); this.game.tradeManager.acceptTrade(trade); this.resetSelections(); this.closeTradeModal(); this.uiManager.updateUI(); } catch (error) { logMessage(`⚠️ ${error.message}`); } } else { logMessage(`${targetPlayer.name}이(가) 거래를 거절했습니다.`); } } } resetSelections() { this.selectedGive = null; this.selectedReceive = null; this.giveAmount = 0; this.playerOffer = {}; this.playerRequest = {}; this.tradeTarget = null; } } ``` --- ## Step 5: 거래 UI CSS ```css /* css/ui.css에 추가 */ .trade-modal-content { width: 600px; max-width: 90vw; } .trade-tabs { display: flex; gap: 10px; margin-bottom: 20px; } .trade-tab { flex: 1; padding: 10px; background: var(--background-color); border: none; color: var(--text-muted); } .trade-tab.active { background: var(--secondary-color); color: white; } .trade-panel { display: none; } .trade-panel.active { display: block; } .trade-section { margin-bottom: 20px; } .trade-section h3 { margin-bottom: 10px; font-size: 14px; color: var(--text-muted); } .trade-resources { display: flex; gap: 10px; flex-wrap: wrap; } .trade-resource-item { display: flex; flex-direction: column; align-items: center; padding: 10px 15px; background: var(--background-color); border-radius: 8px; cursor: pointer; transition: all 0.2s; border: 2px solid transparent; } .trade-resource-item:hover:not(.disabled) { border-color: var(--secondary-color); } .trade-resource-item.selected { border-color: var(--success-color); background: rgba(39, 174, 96, 0.2); } .trade-resource-item.disabled { opacity: 0.4; cursor: not-allowed; } .trade-resource-item .emoji { font-size: 24px; } .trade-resource-item .count { font-weight: bold; } .trade-resource-item .ratio { font-size: 11px; color: var(--text-muted); } .trade-arrow { text-align: center; font-size: 24px; margin: 15px 0; } .trade-summary { text-align: center; padding: 15px; background: var(--background-color); border-radius: 8px; margin-bottom: 15px; font-size: 18px; } /* 자원 입력 */ .resource-input-item { display: flex; align-items: center; gap: 8px; padding: 8px; background: var(--background-color); border-radius: 6px; margin-bottom: 8px; } .resource-input-item .emoji { font-size: 20px; width: 30px; } .resource-input-item .available { color: var(--text-muted); font-size: 12px; } .resource-input-item .amount { width: 30px; text-align: center; font-weight: bold; } .minus-btn, .plus-btn { width: 28px; height: 28px; border-radius: 4px; background: var(--panel-color); color: var(--text-color); font-size: 16px; padding: 0; } /* 거래 대상 */ .trade-target-item { display: flex; justify-content: space-between; padding: 10px; background: var(--background-color); border-radius: 6px; margin-bottom: 8px; cursor: pointer; border: 2px solid transparent; } .trade-target-item.selected { border-color: var(--secondary-color); } ``` --- ## 다음 단계 예고 Part 6에서는 **개발 카드 시스템**을 구현합니다: - 개발 카드 덱 생성 - 기사 카드 (도둑 이동) - 도로 건설, 풍년, 독점 카드 - 승점 카드 --- *이전 파트: [Part 4 - 건설 시스템](make-web-katan-game-v4.md)* *다음 파트: [Part 6 - 개발 카드 시스템](make-web-katan-game-v6.md)*