# 웹 카탄 게임 만들기 - 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)*