# 웹 카탄 게임 만들기 - Part 2: 헥사곤 게임 보드 ## 이번 파트에서 구현할 내용 - SVG를 활용한 헥사곤 타일 그리기 - 카탄 보드 레이아웃 (3-4-5-4-3 배치) - 타일에 숫자 토큰 배치 - 정점(Vertex)과 변(Edge) 위치 계산 - 보드 렌더링 시스템 --- ## 헥사곤 기하학 이해하기 ### 평면 꼭지점(Flat-top) vs 뾰족한 꼭지점(Pointy-top) 카탄에서는 **평면 꼭지점(Flat-top)** 헥사곤을 사용합니다. ``` Flat-top Pointy-top ____ /\ / \ / \ / \ | | \ / | | \____/ \ / \/ ``` ### 헥사곤 좌표 계산 ```javascript // 헥사곤 크기 const HEX_SIZE = 50; // 중심에서 꼭지점까지의 거리 // Flat-top 헥사곤의 너비와 높이 const HEX_WIDTH = HEX_SIZE * 2; const HEX_HEIGHT = Math.sqrt(3) * HEX_SIZE; // 수평 간격 (열 사이) const HEX_HORIZ_SPACING = HEX_WIDTH * 0.75; // 수직 간격 (행 사이) const HEX_VERT_SPACING = HEX_HEIGHT; ``` ### 헥사곤 꼭지점 좌표 ```javascript // 중심점 (cx, cy)에서 6개의 꼭지점 계산 function getHexCorners(cx, cy, size) { const corners = []; for (let i = 0; i < 6; i++) { // Flat-top: 0도에서 시작 const angle = (60 * i) * Math.PI / 180; corners.push({ x: cx + size * Math.cos(angle), y: cy + size * Math.sin(angle) }); } return corners; } ``` --- ## Step 1: Tile 클래스 구현 ```javascript // js/game/Tile.js class Tile { constructor(id, type, row, col) { this.id = id; this.type = type; // TILE_TYPES의 키 this.row = row; // 보드에서의 행 this.col = col; // 보드에서의 열 this.number = null; // 주사위 숫자 (사막은 null) this.hasRobber = type === 'DESERT'; // 도둑 위치 // 화면 좌표 (나중에 계산) this.centerX = 0; this.centerY = 0; // 이 타일에 인접한 정점 ID들 this.vertexIds = []; // 이 타일에 인접한 변 ID들 this.edgeIds = []; } // 타일 정보 반환 getInfo() { const tileInfo = TILE_TYPES[this.type]; return { resource: tileInfo.resource, name: tileInfo.name, color: tileInfo.color }; } // 자원 생산 (정착지/도시 소유자에게) produceResources(diceRoll, buildings) { if (this.number !== diceRoll || this.hasRobber) { return {}; } const resource = this.getInfo().resource; if (!resource) return {}; const production = {}; // 이 타일의 정점에 있는 건물 확인 this.vertexIds.forEach(vertexId => { const building = buildings.find(b => b.vertexId === vertexId); if (building) { const playerId = building.playerId; const amount = building.type === 'city' ? 2 : 1; if (!production[playerId]) { production[playerId] = {}; } production[playerId][resource] = (production[playerId][resource] || 0) + amount; } }); return production; } } ``` --- ## Step 2: Board 클래스 구현 ```javascript // js/game/Board.js class Board { constructor() { this.tiles = []; // 모든 타일 this.vertices = []; // 모든 정점 (건물 배치 위치) this.edges = []; // 모든 변 (도로 배치 위치) this.robberTileId = null; // 도둑이 있는 타일 ID // 헥사곤 설정 this.hexSize = 50; this.hexWidth = this.hexSize * 2; this.hexHeight = Math.sqrt(3) * this.hexSize; // 보드 중심 위치 this.centerX = 400; this.centerY = 350; } // 보드 초기화 initialize() { this.createTiles(); this.assignNumbers(); this.calculatePositions(); this.createVerticesAndEdges(); } // 타일 생성 (3-4-5-4-3 레이아웃) createTiles() { const tileTypes = shuffleArray([...STANDARD_TILES]); let tileIndex = 0; BOARD_LAYOUT.forEach((tilesInRow, row) => { for (let col = 0; col < tilesInRow; col++) { const type = tileTypes[tileIndex]; const tile = new Tile(tileIndex, type, row, col); if (type === 'DESERT') { this.robberTileId = tileIndex; } this.tiles.push(tile); tileIndex++; } }); } // 숫자 토큰 배치 assignNumbers() { const numbers = shuffleArray([...STANDARD_NUMBERS]); let numberIndex = 0; this.tiles.forEach(tile => { if (tile.type !== 'DESERT') { tile.number = numbers[numberIndex++]; } }); } // 각 타일의 화면 좌표 계산 calculatePositions() { const layout = BOARD_LAYOUT; const maxTilesInRow = Math.max(...layout); let tileIndex = 0; layout.forEach((tilesInRow, row) => { const rowOffset = (maxTilesInRow - tilesInRow) * this.hexWidth * 0.75 / 2; const y = this.centerY + (row - 2) * this.hexHeight; for (let col = 0; col < tilesInRow; col++) { const tile = this.tiles[tileIndex]; const x = this.centerX + rowOffset + col * this.hexWidth * 0.75 - (maxTilesInRow - 1) * this.hexWidth * 0.75 / 2; tile.centerX = x; tile.centerY = y; tileIndex++; } }); } // 정점(Vertex)과 변(Edge) 생성 createVerticesAndEdges() { const vertexMap = new Map(); const edgeMap = new Map(); this.tiles.forEach(tile => { const corners = this.getHexCorners(tile.centerX, tile.centerY); corners.forEach((corner, i) => { const key = `${Math.round(corner.x)},${Math.round(corner.y)}`; if (!vertexMap.has(key)) { const vertexId = this.vertices.length; this.vertices.push({ id: vertexId, x: corner.x, y: corner.y, adjacentTiles: [tile.id], adjacentVertices: [], adjacentEdges: [], building: null, port: null }); vertexMap.set(key, vertexId); } else { const vertexId = vertexMap.get(key); this.vertices[vertexId].adjacentTiles.push(tile.id); } tile.vertexIds.push(vertexMap.get(key)); }); for (let i = 0; i < 6; i++) { const corner1 = corners[i]; const corner2 = corners[(i + 1) % 6]; const key1 = `${Math.round(corner1.x)},${Math.round(corner1.y)}`; const key2 = `${Math.round(corner2.x)},${Math.round(corner2.y)}`; const edgeKey = [key1, key2].sort().join('|'); if (!edgeMap.has(edgeKey)) { const edgeId = this.edges.length; const vertexId1 = vertexMap.get(key1); const vertexId2 = vertexMap.get(key2); this.edges.push({ id: edgeId, vertexIds: [vertexId1, vertexId2], adjacentTiles: [tile.id], x1: corner1.x, y1: corner1.y, x2: corner2.x, y2: corner2.y, road: null }); edgeMap.set(edgeKey, edgeId); this.vertices[vertexId1].adjacentEdges.push(edgeId); this.vertices[vertexId2].adjacentEdges.push(edgeId); if (!this.vertices[vertexId1].adjacentVertices.includes(vertexId2)) { this.vertices[vertexId1].adjacentVertices.push(vertexId2); } if (!this.vertices[vertexId2].adjacentVertices.includes(vertexId1)) { this.vertices[vertexId2].adjacentVertices.push(vertexId1); } } else { const edgeId = edgeMap.get(edgeKey); this.edges[edgeId].adjacentTiles.push(tile.id); } tile.edgeIds.push(edgeMap.get(edgeKey)); } }); } // 헥사곤 꼭지점 좌표 계산 getHexCorners(cx, cy) { const corners = []; for (let i = 0; i < 6; i++) { const angle = (60 * i) * Math.PI / 180; corners.push({ x: cx + this.hexSize * Math.cos(angle), y: cy + this.hexSize * Math.sin(angle) }); } return corners; } // 도둑 이동 moveRobber(tileId) { this.robberTileId = tileId; this.tiles.forEach(tile => { tile.hasRobber = (tile.id === tileId); }); } // 정점에 건설 가능한지 확인 canBuildAtVertex(vertexId, playerId, isSetupPhase = false) { const vertex = this.vertices[vertexId]; if (vertex.building) return false; for (const adjVertexId of vertex.adjacentVertices) { if (this.vertices[adjVertexId].building) return false; } if (!isSetupPhase) { const hasAdjacentRoad = vertex.adjacentEdges.some(edgeId => { const edge = this.edges[edgeId]; return edge.road && edge.road.playerId === playerId; }); if (!hasAdjacentRoad) return false; } return true; } // 변에 도로 건설 가능한지 확인 canBuildRoadAtEdge(edgeId, playerId, isSetupPhase = false) { const edge = this.edges[edgeId]; if (edge.road) return false; if (!isSetupPhase) { const connectedToOwn = edge.vertexIds.some(vertexId => { const vertex = this.vertices[vertexId]; if (vertex.building && vertex.building.playerId === playerId) { return true; } return vertex.adjacentEdges.some(adjEdgeId => { if (adjEdgeId === edgeId) return false; const adjEdge = this.edges[adjEdgeId]; return adjEdge.road && adjEdge.road.playerId === playerId; }); }); if (!connectedToOwn) return false; } return true; } } ``` --- ## Step 3: BoardRenderer 클래스 구현 ```javascript // js/ui/BoardRenderer.js class BoardRenderer { constructor(board, containerId) { this.board = board; this.container = document.getElementById(containerId); this.svgNS = 'http://www.w3.org/2000/svg'; this.svg = null; this.selectionMode = null; this.onSelect = null; } initialize() { this.svg = document.createElementNS(this.svgNS, 'svg'); this.svg.setAttribute('width', '800'); this.svg.setAttribute('height', '700'); this.svg.setAttribute('viewBox', '0 0 800 700'); this.svg.id = 'board-svg'; this.tilesLayer = this.createGroup('tiles-layer'); this.edgesLayer = this.createGroup('edges-layer'); this.verticesLayer = this.createGroup('vertices-layer'); this.numbersLayer = this.createGroup('numbers-layer'); this.buildingsLayer = this.createGroup('buildings-layer'); this.robberLayer = this.createGroup('robber-layer'); this.container.appendChild(this.svg); } createGroup(id) { const group = document.createElementNS(this.svgNS, 'g'); group.id = id; this.svg.appendChild(group); return group; } render() { this.renderTiles(); this.renderEdges(); this.renderVertices(); this.renderNumbers(); this.renderRobber(); } renderTiles() { this.tilesLayer.innerHTML = ''; this.board.tiles.forEach(tile => { const hex = this.createHexagon( tile.centerX, tile.centerY, this.board.hexSize, tile.getInfo().color ); hex.setAttribute('data-tile-id', tile.id); hex.classList.add('tile'); hex.addEventListener('click', () => this.handleTileClick(tile.id)); this.tilesLayer.appendChild(hex); }); } createHexagon(cx, cy, size, fill) { const points = []; for (let i = 0; i < 6; i++) { const angle = (60 * i) * Math.PI / 180; points.push(`${cx + size * Math.cos(angle)},${cy + size * Math.sin(angle)}`); } const polygon = document.createElementNS(this.svgNS, 'polygon'); polygon.setAttribute('points', points.join(' ')); polygon.setAttribute('fill', fill); polygon.setAttribute('stroke', '#2c3e50'); polygon.setAttribute('stroke-width', '2'); return polygon; } renderNumbers() { this.numbersLayer.innerHTML = ''; this.board.tiles.forEach(tile => { if (tile.number === null) return; const circle = document.createElementNS(this.svgNS, 'circle'); circle.setAttribute('cx', tile.centerX); circle.setAttribute('cy', tile.centerY); circle.setAttribute('r', '18'); circle.setAttribute('fill', '#f5f5dc'); circle.setAttribute('stroke', '#333'); this.numbersLayer.appendChild(circle); const text = document.createElementNS(this.svgNS, 'text'); text.setAttribute('x', tile.centerX); text.setAttribute('y', tile.centerY + 6); text.setAttribute('text-anchor', 'middle'); text.setAttribute('font-size', '16'); text.setAttribute('font-weight', 'bold'); text.setAttribute('fill', (tile.number === 6 || tile.number === 8) ? '#c0392b' : '#2c3e50'); text.textContent = tile.number; this.numbersLayer.appendChild(text); }); } renderVertices() { this.verticesLayer.innerHTML = ''; this.board.vertices.forEach(vertex => { const circle = document.createElementNS(this.svgNS, 'circle'); circle.setAttribute('cx', vertex.x); circle.setAttribute('cy', vertex.y); circle.setAttribute('r', '8'); circle.setAttribute('fill', 'transparent'); circle.setAttribute('data-vertex-id', vertex.id); circle.classList.add('vertex'); circle.addEventListener('click', () => this.handleVertexClick(vertex.id)); this.verticesLayer.appendChild(circle); }); } renderEdges() { this.edgesLayer.innerHTML = ''; this.board.edges.forEach(edge => { const line = document.createElementNS(this.svgNS, 'line'); line.setAttribute('x1', edge.x1); line.setAttribute('y1', edge.y1); line.setAttribute('x2', edge.x2); line.setAttribute('y2', edge.y2); line.setAttribute('stroke', 'transparent'); line.setAttribute('stroke-width', '8'); line.setAttribute('data-edge-id', edge.id); line.classList.add('edge'); line.addEventListener('click', () => this.handleEdgeClick(edge.id)); this.edgesLayer.appendChild(line); }); } renderRobber() { this.robberLayer.innerHTML = ''; const robberTile = this.board.tiles[this.board.robberTileId]; if (!robberTile) return; const text = document.createElementNS(this.svgNS, 'text'); text.setAttribute('x', robberTile.centerX); text.setAttribute('y', robberTile.centerY + 30); text.setAttribute('text-anchor', 'middle'); text.setAttribute('font-size', '24'); text.textContent = '🥷'; this.robberLayer.appendChild(text); } renderBuildings() { this.buildingsLayer.innerHTML = ''; this.board.edges.forEach(edge => { if (edge.road) { const line = document.createElementNS(this.svgNS, 'line'); line.setAttribute('x1', edge.x1); line.setAttribute('y1', edge.y1); line.setAttribute('x2', edge.x2); line.setAttribute('y2', edge.y2); line.setAttribute('stroke', PLAYER_COLORS[edge.road.playerId]); line.setAttribute('stroke-width', '6'); line.setAttribute('stroke-linecap', 'round'); this.buildingsLayer.appendChild(line); } }); this.board.vertices.forEach(vertex => { if (vertex.building) { const color = PLAYER_COLORS[vertex.building.playerId]; if (vertex.building.type === 'settlement') { this.drawSettlement(vertex.x, vertex.y, color); } else if (vertex.building.type === 'city') { this.drawCity(vertex.x, vertex.y, color); } } }); } drawSettlement(x, y, color) { const house = document.createElementNS(this.svgNS, 'polygon'); const size = 10; const points = [ `${x},${y - size}`, `${x + size},${y}`, `${x + size},${y + size}`, `${x - size},${y + size}`, `${x - size},${y}` ]; house.setAttribute('points', points.join(' ')); house.setAttribute('fill', color); house.setAttribute('stroke', '#2c3e50'); house.setAttribute('stroke-width', '2'); this.buildingsLayer.appendChild(house); } drawCity(x, y, color) { const rect = document.createElementNS(this.svgNS, 'rect'); rect.setAttribute('x', x - 12); rect.setAttribute('y', y - 8); rect.setAttribute('width', '24'); rect.setAttribute('height', '20'); rect.setAttribute('fill', color); rect.setAttribute('stroke', '#2c3e50'); this.buildingsLayer.appendChild(rect); const tower = document.createElementNS(this.svgNS, 'rect'); tower.setAttribute('x', x - 5); tower.setAttribute('y', y - 18); tower.setAttribute('width', '10'); tower.setAttribute('height', '12'); tower.setAttribute('fill', color); tower.setAttribute('stroke', '#2c3e50'); this.buildingsLayer.appendChild(tower); } setSelectionMode(mode, callback, validIds = []) { this.selectionMode = mode; this.onSelect = callback; this.validIds = new Set(validIds); this.highlightValidLocations(mode, validIds); } clearSelectionMode() { this.selectionMode = null; this.onSelect = null; this.validIds = new Set(); this.clearHighlights(); } highlightValidLocations(mode, validIds) { this.clearHighlights(); if (mode === 'vertex') { validIds.forEach(id => { const circle = this.verticesLayer.querySelector(`[data-vertex-id="${id}"]`); if (circle) { circle.classList.add('valid-location'); circle.setAttribute('fill', 'rgba(46, 204, 113, 0.5)'); circle.setAttribute('stroke', '#27ae60'); } }); } else if (mode === 'edge') { validIds.forEach(id => { const line = this.edgesLayer.querySelector(`[data-edge-id="${id}"]`); if (line) { line.classList.add('valid-location'); line.setAttribute('stroke', 'rgba(46, 204, 113, 0.5)'); } }); } } clearHighlights() { document.querySelectorAll('.valid-location').forEach(el => { el.classList.remove('valid-location'); el.setAttribute('fill', 'transparent'); el.setAttribute('stroke', 'transparent'); }); } handleVertexClick(vertexId) { if (this.selectionMode === 'vertex' && this.onSelect && this.validIds.has(vertexId)) { this.onSelect(vertexId); } } handleEdgeClick(edgeId) { if (this.selectionMode === 'edge' && this.onSelect && this.validIds.has(edgeId)) { this.onSelect(edgeId); } } handleTileClick(tileId) { if (this.selectionMode === 'tile' && this.onSelect && this.validIds.has(tileId)) { this.onSelect(tileId); } } } ``` --- ## Step 4: 보드 CSS 스타일 ```css /* css/board.css */ #game-board { display: flex; justify-content: center; align-items: center; padding: 20px; } #board-svg { background: radial-gradient(circle, #3498db 0%, #1a5276 100%); border-radius: 10px; box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3); } .tile { cursor: default; transition: filter 0.2s ease; } .tile:hover { filter: brightness(1.1); } .vertex.valid-location { cursor: pointer; animation: pulse 1s infinite; } @keyframes pulse { 0%, 100% { r: 8; } 50% { r: 12; } } .edge.valid-location { cursor: pointer; stroke: rgba(46, 204, 113, 0.7) !important; stroke-width: 10 !important; } #dice-area { display: flex; align-items: center; justify-content: center; gap: 20px; margin-top: 20px; } .dice { font-size: 48px; background: white; padding: 10px 15px; border-radius: 10px; box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2); } .dice.rolling { animation: shake 0.5s ease-in-out; } @keyframes shake { 0%, 100% { transform: rotate(0deg); } 25% { transform: rotate(-15deg); } 75% { transform: rotate(15deg); } } ``` --- ## 다음 단계 예고 Part 3에서는 **자원 시스템과 주사위**를 구현합니다. --- *이전 파트: [Part 1 - 프로젝트 소개](make-web-katan-game-v1.md)* *다음 파트: [Part 3 - 자원 시스템 및 주사위](make-web-katan-game-v3.md)*