/** * Hexo Game UI - Canvas Rendering and Interactions * Supports 2-4 players with AI bots */ import { HexMap, CELL_TYPES } from './map.js'; import { AIBot } from './ai-bot.js'; // Game constants const HEX_SIZE = 16; const MAP_SIZE = 20; const ANIMATION_DURATION = 300; // Player colors const PLAYER_COLORS = { 1: '#4ecca3', 2: '#e94560', 3: '#f9ed69', 4: '#a8e6cf' }; // Colors const COLORS = { blocked: '#2a2a4a', empty: '#3a5a6a', player1: '#4ecca3', player2: '#e94560', player3: '#f9ed69', player4: '#a8e6cf', highlight: 'rgba(255, 255, 255, 0.3)', selected: 'rgba(233, 69, 96, 0.6)', target: 'rgba(78, 204, 163, 0.5)', stroke: '#1a1a2e', text: '#eeeeee', textDark: '#1a1a2e', dice: '#ffffff', diceBorder: '#333333' }; /** * Game UI Controller */ class GameUI { constructor() { this.canvas = document.getElementById('game-canvas'); this.ctx = this.canvas.getContext('2d'); this.map = null; this.selectedCell = null; this.currentTarget = null; this.currentPlayer = 1; this.playerCount = 2; this.playerTypes = {}; // 1: 'human', 2: 'ai', etc. this.aiBots = {}; this.gamePhase = 'movement'; this.hasMoved = false; this.isAIThinking = false; this.offsetX = 0; this.offsetY = 0; this.init(); } init() { this.setupStartScreen(); this.setupEventListeners(); } setupStartScreen() { const playerCountSelect = document.getElementById('player-count'); const playerTypeRows = document.querySelectorAll('.player-type-row'); playerCountSelect.addEventListener('change', (e) => { const count = parseInt(e.target.value); playerTypeRows.forEach((row, index) => { row.style.display = index < count ? 'flex' : 'none'; }); }); document.getElementById('start-game-btn').addEventListener('click', () => { this.startGame(); }); document.getElementById('back-menu-btn').addEventListener('click', () => { this.showStartScreen(); }); } showStartScreen() { document.getElementById('start-screen').style.display = 'flex'; document.getElementById('game-screen').style.display = 'none'; } startGame() { // Get settings this.playerCount = parseInt(document.getElementById('player-count').value); for (let i = 1; i <= this.playerCount; i++) { const typeSelect = document.getElementById(`player${i}-type`); this.playerTypes[i] = typeSelect.value; } // Show game screen document.getElementById('start-screen').style.display = 'none'; document.getElementById('game-screen').style.display = 'flex'; this.newGame(); } newGame() { this.map = new HexMap(MAP_SIZE); this.selectedCell = null; this.currentTarget = null; this.currentPlayer = 1; this.gamePhase = 'movement'; this.hasMoved = false; this.isAIThinking = false; // Initialize AI bots AFTER map is created this.aiBots = {}; for (let i = 1; i <= this.playerCount; i++) { if (this.playerTypes[i] === 'ai') { this.aiBots[i] = new AIBot(i, this.map, this); } } // Initialize starting positions for all players this.initializePlayers(); this.centerMap(); this.render(); this.createPlayerCards(); this.updateUI(); this.log(`New game started with ${this.playerCount} players!`); // Start first player's turn (AI if needed) this.checkAndRunAITurn(); } createPlayerCards() { const container = document.getElementById('players-container'); container.innerHTML = ''; for (let i = 1; i <= this.playerCount; i++) { const card = document.createElement('div'); card.className = `player-card player-${i}${i === 1 ? ' active' : ''}${this.playerTypes[i] === 'ai' ? ' ai-controlled' : ''}`; card.id = `player${i}-card`; card.innerHTML = `

Player ${i}${this.playerTypes[i] === 'ai' ? ' (AI)' : ''}

Cells: 0
Supply: 0
Strength: 0
`; container.appendChild(card); } } initializePlayers() { const emptyCells = this.map.getEmptyCells(); const shuffled = emptyCells.sort(() => Math.random() - 0.5); // Place starting units for each player const positions = [ { q: 2, r: 2 }, // Player 1 - top area { q: MAP_SIZE - 3, r: MAP_SIZE - 3 }, // Player 2 - bottom area { q: 2, r: MAP_SIZE - 3 }, // Player 3 { q: MAP_SIZE - 3, r: 2 } // Player 4 ]; for (let i = 1; i <= this.playerCount; i++) { const pos = positions[i - 1] || shuffled[i]; if (pos) { const cell = this.map.getCell(pos.q, pos.r); if (cell && cell.isPassable()) { this.map.setOwner(pos.q, pos.r, i); cell.setStrength(8); } } } } centerMap() { const canvasWidth = this.canvas.width; const canvasHeight = this.canvas.height; const sqrt3 = Math.sqrt(3); // Calculate map bounds with proper padding for hex visibility // For pointy-top hex: width = sqrt(3) * size, height = 2 * size // Rightmost hex (q=19, r=19): x = HEX_SIZE * sqrt3 * (19 + 19/2) = HEX_SIZE * sqrt3 * 28.5 // Leftmost hex (q=0, r=0): x = 0 // Add padding for full hex visibility (hex radius on each side) const hexPadding = HEX_SIZE * 2; // Extra padding for hex radius const mapWidth = HEX_SIZE * sqrt3 * ((MAP_SIZE - 1) + (MAP_SIZE - 1) / 2); const mapHeight = HEX_SIZE * 1.5 * (MAP_SIZE - 1); this.offsetX = hexPadding; this.offsetY = hexPadding; } setupEventListeners() { this.canvas.addEventListener('click', (e) => this.handleClick(e)); this.canvas.addEventListener('mousemove', (e) => this.handleMouseMove(e)); document.getElementById('end-turn-btn').addEventListener('click', () => this.endTurn()); document.getElementById('cancel-btn').addEventListener('click', () => this.cancelSelection()); } hexToPixel(q, r) { const sqrt3 = Math.sqrt(3); const x = this.offsetX + HEX_SIZE * sqrt3 * (q + r/2); const y = this.offsetY + HEX_SIZE * 1.5 * r; return { x, y }; } pixelToHex(x, y) { const adjX = x - this.offsetX; const adjY = y - this.offsetY; const sqrt3 = Math.sqrt(3); const q = (adjX / (HEX_SIZE * sqrt3) - adjY / (HEX_SIZE * 1.5) / 2); const r = adjY / (HEX_SIZE * 1.5); const qi = Math.round(q); const ri = Math.round(r); if (qi >= 0 && qi < MAP_SIZE && ri >= 0 && ri < MAP_SIZE) { return { q: qi, r: ri }; } return null; } getValidTargets(q, r) { const directions = [ [+1, -1], // north-east [0, -1], // north-west [-1, 0], // west [-1, +1], // south-west [0, +1], // south-east [+1, 0] // east ]; const targets = []; for (const [dq, dr] of directions) { const nq = q + dq; const nr = r + dr; if (nq >= 0 && nq < MAP_SIZE && nr >= 0 && nr < MAP_SIZE) { const cell = this.map.getCell(nq, nr); if (cell && cell.isPassable()) { targets.push(cell); } } } return targets; } drawHex(q, r, fillStyle, strokeStyle = COLORS.stroke, lineWidth = 2) { const { x, y } = this.hexToPixel(q, r); const size = HEX_SIZE * 0.98; const vertices = []; for (let i = 0; i < 6; i++) { const angle = Math.PI / 6 + (Math.PI / 3) * i; vertices.push({ x: x + size * Math.cos(angle), y: y + size * Math.sin(angle) }); } this.ctx.beginPath(); this.ctx.moveTo(vertices[0].x, vertices[0].y); for (let i = 1; i < vertices.length; i++) { this.ctx.lineTo(vertices[i].x, vertices[i].y); } this.ctx.closePath(); this.ctx.fillStyle = fillStyle; this.ctx.fill(); this.ctx.strokeStyle = strokeStyle; this.ctx.lineWidth = lineWidth; this.ctx.stroke(); } drawDice(cell) { if (cell.dice.length === 0) return; const { x, y } = this.hexToPixel(cell.q, cell.r); const strength = cell.getStrength(); this.ctx.fillStyle = COLORS.text; this.ctx.font = 'bold 11px Arial'; this.ctx.textAlign = 'center'; this.ctx.textBaseline = 'middle'; this.ctx.fillText(strength.toString(), x, y - 3); if (cell.dice.length > 1) { const dotY = y + 8; for (let i = 0; i < Math.min(cell.dice.length, 5); i++) { this.ctx.beginPath(); this.ctx.arc(x - 6 + i * 4, dotY, 1.5, 0, Math.PI * 2); this.ctx.fillStyle = COLORS.dice; this.ctx.fill(); } } } drawOwnerIndicator(cell) { if (!cell.isOwned()) return; const { x, y } = this.hexToPixel(cell.q, cell.r); const ownerColor = PLAYER_COLORS[cell.getOwner()] || COLORS.player1; const size = HEX_SIZE * 0.98 - 3; const vertices = []; for (let i = 0; i < 6; i++) { const angle = Math.PI / 6 + (Math.PI / 3) * i; vertices.push({ x: x + size * Math.cos(angle), y: y + size * Math.sin(angle) }); } this.ctx.beginPath(); this.ctx.moveTo(vertices[0].x, vertices[0].y); for (let i = 1; i < vertices.length; i++) { this.ctx.lineTo(vertices[i].x, vertices[i].y); } this.ctx.closePath(); this.ctx.strokeStyle = ownerColor; this.ctx.lineWidth = 3; this.ctx.stroke(); } render() { this.ctx.fillStyle = COLORS.stroke; this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height); if (!this.map) return; for (let r = 0; r < MAP_SIZE; r++) { for (let q = 0; q < MAP_SIZE; q++) { const cell = this.map.getCell(q, r); let color; if (cell.type === CELL_TYPES.BLOCKED) { color = COLORS.blocked; } else if (cell.type === CELL_TYPES.PLAYER1) { color = COLORS.player1; } else if (cell.type === CELL_TYPES.PLAYER2) { color = COLORS.player2; } else if (cell.type === CELL_TYPES.PLAYER3) { color = COLORS.player3; } else if (cell.type === CELL_TYPES.PLAYER4) { color = COLORS.player4; } else { color = COLORS.empty; } if (!cell.isOwned()) { color = this.hexToRgba(color, 0.6); } this.drawHex(q, r, color); this.drawOwnerIndicator(cell); this.drawDice(cell); } } if (this.selectedCell) { this.drawHex( this.selectedCell.q, this.selectedCell.r, COLORS.selected, COLORS.text, 3 ); const targets = this.getValidTargets(this.selectedCell.q, this.selectedCell.r); for (const target of targets) { if (target.getOwner() !== this.currentPlayer) { this.drawHex( target.q, target.r, COLORS.target, PLAYER_COLORS[this.currentPlayer] || COLORS.player1, 2 ); } } } } hexToRgba(hex, alpha) { const r = parseInt(hex.slice(1, 3), 16); const g = parseInt(hex.slice(3, 5), 16); const b = parseInt(hex.slice(5, 7), 16); return `rgba(${r}, ${g}, ${b}, ${alpha})`; } handleClick(e) { if (this.gamePhase !== 'movement') return; if (this.playerTypes[this.currentPlayer] === 'ai') return; const rect = this.canvas.getBoundingClientRect(); const x = e.clientX - rect.left; const y = e.clientY - rect.top; const hexPos = this.pixelToHex(x, y); if (!hexPos) return; const cell = this.map.getCell(hexPos.q, hexPos.r); if (!cell || !cell.isPassable()) return; if (this.selectedCell === null) { if (cell.getOwner() === this.currentPlayer && cell.getStrength() > 1) { this.selectedCell = cell; this.updateUI(); this.render(); } } else { if (cell === this.selectedCell) { this.cancelSelection(); return; } const targets = this.getValidTargets(this.selectedCell.q, this.selectedCell.r); const isValidTarget = targets.some(n => n.q === cell.q && n.r === cell.r); if (cell.getOwner() === this.currentPlayer) { if (cell.getStrength() > 1) { this.selectedCell = cell; this.updateUI(); this.render(); } return; } if (isValidTarget) { this.currentTarget = cell; this.executeAttack(); } else { this.cancelSelection(); } } } handleMouseMove(e) { // Hover effects could be added here } executeAttack() { if (!this.selectedCell || !this.currentTarget) return; const attacker = this.selectedCell; const defender = this.currentTarget; const attackStrength = attacker.getStrength() - 1; if (defender.type === CELL_TYPES.EMPTY || defender.getOwner() !== this.currentPlayer) { let defenseStrength = defender.getStrength(); if (defenseStrength > 0) { const attackRoll = Math.floor(Math.random() * attackStrength) + 1; const defenseRoll = Math.floor(Math.random() * defenseStrength) + 1; this.log(`P${this.currentPlayer} Attack: ${attackStrength} vs ${defenseStrength} | Roll: ${attackRoll} vs ${defenseRoll}`); if (attackRoll > defenseRoll) { const remainingStrength = attackRoll - 1; attacker.setStrength(1); if (remainingStrength > 0) { defender.setStrength(remainingStrength); this.map.setOwner(defender.q, defender.r, this.currentPlayer); this.log(`Victory! Captured with strength ${remainingStrength}`, 'victory'); } } else { const remainingDefense = defenseRoll - attackRoll; defender.setStrength(Math.max(1, remainingDefense)); attacker.setStrength(1); this.log(`Attack repelled! Defender has ${Math.max(1, remainingDefense)}`, 'defeat'); } } else { attacker.setStrength(1); defender.setStrength(attackStrength); this.map.setOwner(defender.q, defender.r, this.currentPlayer); this.log(`Captured empty cell with strength ${attackStrength}`); } } this.hasMoved = true; this.cancelSelection(); this.render(); this.updateUI(); } cancelSelection() { this.selectedCell = null; this.currentTarget = null; this.updateUI(); this.render(); } async endTurn() { if (this.gamePhase !== 'movement') return; if (this.isAIThinking) return; // Apply supply const supply = this.map.calculateSupply(this.currentPlayer); this.distributeSupply(supply); this.log(`Player ${this.currentPlayer} received ${supply} supply`); // Next player this.currentPlayer = (this.currentPlayer % this.playerCount) + 1; this.hasMoved = false; this.cancelSelection(); this.updateUI(); this.render(); this.log(`Player ${this.currentPlayer}'s turn`); // Check if next player is AI this.checkAndRunAITurn(); } checkAndRunAITurn() { if (this.playerTypes[this.currentPlayer] === 'ai' && this.aiBots[this.currentPlayer]) { this.isAIThinking = true; this.updateUI(); // Run AI turn this.aiBots[this.currentPlayer].playTurn().then(() => { this.isAIThinking = false; // AI will call endTurn() when done }); } } distributeSupply(supply) { const playerCells = this.map.getPlayerCells(this.currentPlayer); const eligibleCells = playerCells.filter(cell => !cell.isMaxStrength()); if (eligibleCells.length === 0 || supply === 0) return; let remainingSupply = supply; while (remainingSupply > 0 && eligibleCells.length > 0) { const randomCell = eligibleCells[Math.floor(Math.random() * eligibleCells.length)]; const currentStrength = randomCell.getStrength(); if (currentStrength < 48) { randomCell.setStrength(currentStrength + 1); remainingSupply--; } if (randomCell.isMaxStrength()) { eligibleCells.splice(eligibleCells.indexOf(randomCell), 1); } } } updateUI() { // Update all player cards for (let i = 1; i <= this.playerCount; i++) { const playerCells = this.map.getPlayerCells(i); const playerStrength = playerCells.reduce((sum, c) => sum + c.getStrength(), 0); const playerSupply = this.map.calculateSupply(i); document.getElementById(`player${i}-cells`).textContent = playerCells.length; document.getElementById(`player${i}-supply`).textContent = playerSupply; document.getElementById(`player${i}-strength`).textContent = playerStrength; const card = document.getElementById(`player${i}-card`); card.classList.toggle('active', i === this.currentPlayer); } document.getElementById('current-turn').textContent = this.currentPlayer; document.getElementById('game-phase').textContent = this.gamePhase; const instruction = document.getElementById('action-instruction'); if (this.selectedCell) { instruction.textContent = `Select target (strength: ${this.selectedCell.getStrength()})`; } else if (this.playerTypes[this.currentPlayer] === 'ai') { instruction.textContent = 'AI is thinking...'; } else { instruction.textContent = 'Select a cell with dice to move'; } const cellInfo = document.getElementById('selected-cell-info'); cellInfo.style.display = 'block'; if (this.selectedCell) { document.getElementById('cell-strength').textContent = this.selectedCell.getStrength(); document.getElementById('cell-dice').textContent = this.selectedCell.dice.length; } else { document.getElementById('cell-strength').textContent = '-'; document.getElementById('cell-dice').textContent = '-'; } const cancelBtn = document.getElementById('cancel-btn'); const endTurnBtn = document.getElementById('end-turn-btn'); cancelBtn.disabled = !this.selectedCell || this.playerTypes[this.currentPlayer] === 'ai'; endTurnBtn.disabled = this.playerTypes[this.currentPlayer] === 'ai'; } log(message, type = '') { const logContainer = document.getElementById('battle-log'); const entry = document.createElement('div'); entry.className = `log-entry ${type}`; entry.textContent = `[P${this.currentPlayer}] ${message}`; logContainer.insertBefore(entry, logContainer.firstChild); while (logContainer.children.length > 50) { logContainer.removeChild(logContainer.lastChild); } } } // Initialize game when DOM is ready document.addEventListener('DOMContentLoaded', () => { window.game = new GameUI(); });