# 웹 카탄 게임 만들기 - Part 3: 자원 시스템 및 주사위 ## 이번 파트에서 구현할 내용 - Player 클래스와 자원 관리 - 주사위 굴리기 시스템 - 자원 분배 로직 - UI에 자원 표시 - 숫자 7과 도둑 처리 --- ## Step 1: Player 클래스 구현 ```javascript // js/game/Player.js class Player { constructor(id, name, color, isAI = false, aiDifficulty = null) { this.id = id; this.name = name; this.color = color; this.isAI = isAI; this.aiDifficulty = aiDifficulty; // 자원 this.resources = { [RESOURCES.GRAIN]: 0, [RESOURCES.BRICK]: 0, [RESOURCES.LUMBER]: 0, [RESOURCES.WOOL]: 0, [RESOURCES.ORE]: 0 }; // 개발 카드 this.devCards = []; // 보유 카드 this.usedKnights = 0; // 사용한 기사 카드 수 this.newDevCards = []; // 이번 턴에 구매한 카드 (사용 불가) // 건물 수 this.settlements = 0; this.cities = 0; this.roads = 0; // 특별 점수 this.hasLongestRoad = false; this.hasLargestArmy = false; // 최대 건물 수 this.maxSettlements = 5; this.maxCities = 4; this.maxRoads = 15; } // 총 자원 수 getTotalResources() { return Object.values(this.resources).reduce((sum, count) => sum + count, 0); } // 승점 계산 getVictoryPoints() { let points = 0; points += this.settlements; // 정착지 1점 points += this.cities * 2; // 도시 2점 if (this.hasLongestRoad) points += 2; // 최장 도로 2점 if (this.hasLargestArmy) points += 2; // 최대 기사단 2점 // 승점 개발 카드 this.devCards.forEach(card => { if (card.type === 'VICTORY_POINT') points += 1; }); return points; } // 자원 추가 addResources(resources) { for (const [resource, amount] of Object.entries(resources)) { this.resources[resource] = (this.resources[resource] || 0) + amount; } } // 자원 차감 removeResources(resources) { for (const [resource, amount] of Object.entries(resources)) { this.resources[resource] = Math.max(0, (this.resources[resource] || 0) - amount); } } // 자원 충분한지 확인 hasResources(cost) { for (const [resource, amount] of Object.entries(cost)) { if ((this.resources[resource] || 0) < amount) { return false; } } return true; } // 건설 가능 여부 확인 canBuild(buildingType) { switch (buildingType) { case 'road': return this.roads < this.maxRoads && this.hasResources(BUILD_COSTS.ROAD); case 'settlement': return this.settlements < this.maxSettlements && this.hasResources(BUILD_COSTS.SETTLEMENT); case 'city': return this.settlements > 0 && this.cities < this.maxCities && this.hasResources(BUILD_COSTS.CITY); case 'devCard': return this.hasResources(BUILD_COSTS.DEV_CARD); default: return false; } } // 랜덤하게 자원 하나 가져가기 (도둑용) stealRandomResource() { const availableResources = []; for (const [resource, count] of Object.entries(this.resources)) { for (let i = 0; i < count; i++) { availableResources.push(resource); } } if (availableResources.length === 0) return null; const stolen = randomChoice(availableResources); this.resources[stolen]--; return stolen; } // 7이 나왔을 때 자원 절반 버리기 discardHalfResources() { const total = this.getTotalResources(); if (total <= 7) return {}; const toDiscard = Math.floor(total / 2); const discarded = {}; let discardedCount = 0; // AI는 가장 많은 자원부터 버림 while (discardedCount < toDiscard) { const maxResource = Object.entries(this.resources) .filter(([_, count]) => count > 0) .sort((a, b) => b[1] - a[1])[0]; if (!maxResource) break; const [resource] = maxResource; this.resources[resource]--; discarded[resource] = (discarded[resource] || 0) + 1; discardedCount++; } return discarded; } // 턴 종료 시 새 개발카드를 사용 가능하게 이동 endTurn() { this.devCards.push(...this.newDevCards); this.newDevCards = []; } } ``` --- ## Step 2: 주사위 시스템 ```javascript // js/game/Dice.js class DiceManager { constructor() { this.dice1 = 1; this.dice2 = 1; this.isRolling = false; } // 주사위 굴리기 roll() { this.dice1 = Math.floor(Math.random() * 6) + 1; this.dice2 = Math.floor(Math.random() * 6) + 1; return this.getTotal(); } getTotal() { return this.dice1 + this.dice2; } // 애니메이션과 함께 굴리기 async rollWithAnimation(dice1El, dice2El) { if (this.isRolling) return null; this.isRolling = true; const diceEmojis = ['⚀', '⚁', '⚂', '⚃', '⚄', '⚅']; // 애니메이션 클래스 추가 dice1El.classList.add('rolling'); dice2El.classList.add('rolling'); // 애니메이션 동안 랜덤하게 표시 const animationDuration = 500; const frameRate = 50; const frames = animationDuration / frameRate; for (let i = 0; i < frames; i++) { dice1El.textContent = diceEmojis[Math.floor(Math.random() * 6)]; dice2El.textContent = diceEmojis[Math.floor(Math.random() * 6)]; await delay(frameRate); } // 최종 결과 this.roll(); dice1El.textContent = diceEmojis[this.dice1 - 1]; dice2El.textContent = diceEmojis[this.dice2 - 1]; // 애니메이션 클래스 제거 dice1El.classList.remove('rolling'); dice2El.classList.remove('rolling'); this.isRolling = false; return this.getTotal(); } } ``` --- ## Step 3: 자원 분배 로직 ```javascript // Game 클래스에 추가할 메서드들 class Game { constructor() { this.board = new Board(); this.players = []; this.currentPlayerIndex = 0; this.phase = GAME_PHASES.SETUP; this.diceManager = new DiceManager(); this.turnNumber = 0; } // 주사위 결과에 따른 자원 분배 distributeResources(diceRoll) { if (diceRoll === 7) { return this.handleRobber(); } const production = {}; // 해당 숫자의 타일 찾기 this.board.tiles.forEach(tile => { if (tile.number !== diceRoll || tile.hasRobber) return; const resource = tile.getInfo().resource; if (!resource) return; // 타일에 인접한 건물 확인 tile.vertexIds.forEach(vertexId => { const vertex = this.board.vertices[vertexId]; if (!vertex.building) return; const playerId = vertex.building.playerId; const amount = vertex.building.type === 'city' ? 2 : 1; // 자원 추가 this.players[playerId].addResources({ [resource]: amount }); // 로그용 if (!production[playerId]) production[playerId] = {}; production[playerId][resource] = (production[playerId][resource] || 0) + amount; }); }); // 로그 출력 for (const [playerId, resources] of Object.entries(production)) { const player = this.players[playerId]; const resourceList = Object.entries(resources) .map(([r, a]) => `${getResourceEmoji(r)} ${a}`) .join(', '); logMessage(`${player.name}이(가) ${resourceList}을(를) 획득했습니다.`); } return production; } // 7이 나왔을 때 처리 handleRobber() { // 1. 8장 이상 가진 플레이어는 절반 버림 this.players.forEach(player => { if (player.getTotalResources() > 7) { const discarded = player.discardHalfResources(); const discardList = Object.entries(discarded) .map(([r, a]) => `${getResourceEmoji(r)} ${a}`) .join(', '); logMessage(`${player.name}이(가) ${discardList}을(를) 버렸습니다.`); } }); // 2. 도둑 이동 단계로 this.phase = GAME_PHASES.ROBBER; return { robber: true }; } // 도둑 이동 처리 moveRobber(tileId, victimId = null) { this.board.moveRobber(tileId); // 피해자에게서 자원 훔치기 if (victimId !== null && victimId !== this.currentPlayerIndex) { const victim = this.players[victimId]; const stolen = victim.stealRandomResource(); if (stolen) { this.players[this.currentPlayerIndex].addResources({ [stolen]: 1 }); logMessage( `${this.getCurrentPlayer().name}이(가) ` + `${victim.name}에게서 ${getResourceEmoji(stolen)}을(를) 훔쳤습니다.` ); } } this.phase = GAME_PHASES.MAIN; } getCurrentPlayer() { return this.players[this.currentPlayerIndex]; } } ``` --- ## Step 4: UI 자원 표시 ```javascript // js/ui/UIManager.js class UIManager { constructor(game) { this.game = game; this.boardRenderer = null; } initialize() { this.boardRenderer = new BoardRenderer(this.game.board, 'game-board'); this.boardRenderer.initialize(); this.boardRenderer.render(); this.bindEvents(); this.updateUI(); } // 자원 표시 업데이트 updateResourcesDisplay() { const container = document.getElementById('my-resources'); const player = this.game.players[0]; // 인간 플레이어 container.innerHTML = ''; const resourceList = [ { key: RESOURCES.GRAIN, emoji: '🌾', name: '밀' }, { key: RESOURCES.BRICK, emoji: '🧱', name: '벽돌' }, { key: RESOURCES.LUMBER, emoji: '🪵', name: '목재' }, { key: RESOURCES.WOOL, emoji: '🐑', name: '양모' }, { key: RESOURCES.ORE, emoji: '�ite', name: '광석' } ]; resourceList.forEach(({ key, emoji, name }) => { const div = document.createElement('div'); div.className = 'resource-item'; div.innerHTML = ` ${emoji} ${player.resources[key]} ${name} `; container.appendChild(div); }); } // 플레이어 정보 패널 업데이트 updatePlayersPanel() { const container = document.getElementById('players-info'); container.innerHTML = ''; this.game.players.forEach((player, index) => { const isCurrentTurn = index === this.game.currentPlayerIndex; const div = document.createElement('div'); div.className = `player-info ${isCurrentTurn ? 'current-turn' : ''}`; div.innerHTML = `
${player.name} ${player.getVictoryPoints()}점
🛤️ ${player.roads} 🏠 ${player.settlements} 🏰 ${player.cities} 📦 ${player.getTotalResources()} 🃏 ${player.devCards.length + player.newDevCards.length}
${player.hasLongestRoad ? '
🛤️ 최장 도로
' : ''} ${player.hasLargestArmy ? '
⚔️ 최대 기사단
' : ''} `; container.appendChild(div); }); } // 현재 턴 표시 업데이트 updateCurrentTurn() { const el = document.getElementById('current-player-name'); const player = this.game.getCurrentPlayer(); el.textContent = player.name; el.style.color = player.color; } // 액션 버튼 활성화/비활성화 updateActionButtons() { const player = this.game.getCurrentPlayer(); const isHumanTurn = !player.isAI; const isMainPhase = this.game.phase === GAME_PHASES.MAIN; document.getElementById('btn-build-road').disabled = !isHumanTurn || !isMainPhase || !player.canBuild('road'); document.getElementById('btn-build-settlement').disabled = !isHumanTurn || !isMainPhase || !player.canBuild('settlement'); document.getElementById('btn-build-city').disabled = !isHumanTurn || !isMainPhase || !player.canBuild('city'); document.getElementById('btn-buy-card').disabled = !isHumanTurn || !isMainPhase || !player.canBuild('devCard'); document.getElementById('btn-trade').disabled = !isHumanTurn || !isMainPhase; document.getElementById('btn-end-turn').disabled = !isHumanTurn || !isMainPhase; document.getElementById('roll-dice').disabled = !isHumanTurn || this.game.phase !== GAME_PHASES.ROLL_DICE; } // 전체 UI 업데이트 updateUI() { this.updateResourcesDisplay(); this.updatePlayersPanel(); this.updateCurrentTurn(); this.updateActionButtons(); this.boardRenderer.renderBuildings(); this.boardRenderer.renderRobber(); } bindEvents() { // 주사위 굴리기 document.getElementById('roll-dice').addEventListener('click', async () => { const dice1El = document.getElementById('dice1'); const dice2El = document.getElementById('dice2'); const total = await this.game.diceManager.rollWithAnimation(dice1El, dice2El); if (total === null) return; logMessage(`🎲 주사위 결과: ${total}`); this.game.distributeResources(total); if (total !== 7) { this.game.phase = GAME_PHASES.MAIN; } this.updateUI(); }); // 턴 종료 document.getElementById('btn-end-turn').addEventListener('click', () => { this.game.endTurn(); this.updateUI(); }); } } ``` --- ## Step 5: 자원 표시 CSS ```css /* css/ui.css 추가 */ .resources-display { background: var(--panel-color); padding: 15px; border-radius: 8px; margin-bottom: 15px; } .resources-display h3 { margin-bottom: 10px; font-size: 14px; color: var(--text-muted); } #my-resources { display: grid; grid-template-columns: repeat(5, 1fr); gap: 8px; } .resource-item { display: flex; flex-direction: column; align-items: center; padding: 8px; background: var(--background-color); border-radius: 6px; } .resource-emoji { font-size: 24px; margin-bottom: 4px; } .resource-count { font-size: 18px; font-weight: bold; color: var(--text-color); } .resource-name { font-size: 10px; color: var(--text-muted); } /* 플레이어 정보 패널 */ .player-info { background: var(--panel-color); padding: 12px; margin-bottom: 10px; border-radius: 8px; transition: all 0.3s ease; } .player-info.current-turn { background: rgba(52, 152, 219, 0.2); box-shadow: 0 0 10px rgba(52, 152, 219, 0.3); } .player-header { display: flex; justify-content: space-between; align-items: center; padding-left: 10px; margin-bottom: 8px; } .player-name { font-weight: bold; } .player-points { font-size: 18px; font-weight: bold; color: var(--warning-color); } .player-stats { display: flex; gap: 10px; font-size: 12px; color: var(--text-muted); } .special-badge { margin-top: 8px; padding: 4px 8px; background: var(--warning-color); color: var(--background-color); border-radius: 4px; font-size: 11px; display: inline-block; } ``` --- ## 다음 단계 예고 Part 4에서는 **건설 시스템**을 구현합니다: - 도로 건설 로직 - 정착지 건설 로직 - 도시 업그레이드 - 초기 배치 단계 --- *이전 파트: [Part 2 - 헥사곤 게임 보드](make-web-katan-game-v2.md)* *다음 파트: [Part 4 - 건설 시스템](make-web-katan-game-v4.md)*