/** * 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 = 14; const DEFAULT_MAP_SIZE = 20; const ANIMATION_DURATION = 300; // Player colors const PLAYER_COLORS = { 1: '#4ecca3', // teal/green 2: '#e94560', // red/pink 3: '#f9ed69', // yellow 4: '#00adb5' // cyan/blue }; // Colors const COLORS = { blocked: '#2a2a4a', empty: '#3a5a6a', player1: '#4ecca3', player2: '#e94560', player3: '#f9ed69', player4: '#00adb5', 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.mapSize = DEFAULT_MAP_SIZE; 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.isProcessingTurn = false; // Prevent re-entrancy during turn processing 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); this.mapSize = parseInt(document.getElementById('map-size').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(this.mapSize); this.selectedCell = null; this.currentTarget = null; this.currentPlayer = 1; this.gamePhase = 'movement'; this.hasMoved = false; this.isAIThinking = false; this.isProcessingTurn = 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!`); console.log(`[GAME] 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() { // Place starting units for each player at fixed positions that are always passable // Use corners of the map to ensure they don't overlap // Positions scale with map size const offset = Math.max(1, Math.floor(this.mapSize / 10)); const positions = [ { q: offset, r: offset }, // Player 1 - top-left { q: this.mapSize - 1 - offset, r: this.mapSize - 1 - offset }, // Player 2 - bottom-right { q: offset, r: this.mapSize - 1 - offset }, // Player 3 - bottom-left { q: this.mapSize - 1 - offset, r: offset } // Player 4 - top-right ]; for (let i = 1; i <= this.playerCount; i++) { const pos = positions[i - 1]; if (!pos) continue; // Force the cell to be passable and set ownership const cell = this.map.getCell(pos.q, pos.r); if (cell) { // Make sure cell is passable if (cell.type === CELL_TYPES.BLOCKED) { cell.type = CELL_TYPES.EMPTY; } this.map.setOwner(pos.q, pos.r, i); cell.setStrength(8); console.log(`[GAME] Player ${i} placed at (${pos.q}, ${pos.r})`); } else { console.error(`[GAME] Failed to place Player ${i} at (${pos.q}, ${pos.r})`); } } } centerMap() { const canvasWidth = this.canvas.width; const canvasHeight = this.canvas.height; const sqrt3 = Math.sqrt(3); // Calculate actual map bounds based on dynamic map size // For pointy-top hex grid: // - Width spans from q=0 to q=mapSize-1, with r offset // - Rightmost point: (q=mapSize-1, r=mapSize-1) at x = HEX_SIZE * sqrt3 * (mapSize - 1 + (mapSize - 1) / 2) // - Height spans from r=0 to r=mapSize-1 // - Bottommost point: (any q, r=mapSize-1) at y = HEX_SIZE * 1.5 * (mapSize - 1) const mapWidth = HEX_SIZE * sqrt3 * ((this.mapSize - 1) + (this.mapSize - 1) / 2); const mapHeight = HEX_SIZE * 1.5 * (this.mapSize - 1); // Add padding for hex radius (hex extends beyond center point) const hexRadius = HEX_SIZE; const totalWidth = mapWidth + 2 * hexRadius; const totalHeight = mapHeight + 2 * hexRadius; // Center the map on canvas this.offsetX = (canvasWidth - totalWidth) / 2 + hexRadius; this.offsetY = (canvasHeight - totalHeight) / 2 + hexRadius; console.log(`[GAME] Map: canvas=${canvasWidth}x${canvasHeight}, map=${mapWidth.toFixed(0)}x${mapHeight.toFixed(0)}, total=${totalWidth.toFixed(0)}x${totalHeight.toFixed(0)}`); console.log(`[GAME] Offset: x=${this.offsetX.toFixed(1)}, y=${this.offsetY.toFixed(1)}`); } 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 < this.mapSize && ri >= 0 && ri < this.mapSize) { 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 < this.mapSize && nr >= 0 && nr < this.mapSize) { 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 < this.mapSize; r++) { for (let q = 0; q < this.mapSize; 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') { console.log(`[GAME] endTurn() called but gamePhase is '${this.gamePhase}', ignoring`); return; } // Prevent re-entrancy - only one turn processing at a time if (this.isProcessingTurn) { console.log(`[GAME] endTurn() called but isProcessingTurn is true, ignoring`); return; } this.isProcessingTurn = true; console.log(`[GAME] endTurn() - Player ${this.currentPlayer} ending turn`); try { // Apply supply const supply = this.map.calculateSupply(this.currentPlayer); this.distributeSupply(supply); this.log(`Player ${this.currentPlayer} received ${supply} supply`); console.log(`[GAME] Player ${this.currentPlayer} received ${supply} supply`); // Next player const previousPlayer = this.currentPlayer; this.currentPlayer = (this.currentPlayer % this.playerCount) + 1; this.hasMoved = false; console.log(`[GAME] Turn transition: P${previousPlayer} -> P${this.currentPlayer}`); this.cancelSelection(); this.updateUI(); this.render(); this.log(`Player ${this.currentPlayer}'s turn`); console.log(`[GAME] Player ${this.currentPlayer}'s turn started (${this.playerTypes[this.currentPlayer]})`); // Reset isProcessingTurn BEFORE awaiting AI turn // This allows the next AI's endTurn() call to proceed this.isProcessingTurn = false; // Check if next player is AI and await completion await this.checkAndRunAITurn(); } catch (error) { console.error(`[GAME] Error in endTurn():`, error); this.log(`Error during turn transition: ${error.message}`, 'error'); this.isProcessingTurn = false; } } /** * Check if current player is AI and run their turn * @returns {Promise} - true if AI turn was run, false if human turn */ async checkAndRunAITurn() { if (this.playerTypes[this.currentPlayer] === 'ai' && this.aiBots[this.currentPlayer]) { console.log(`[AI] Player ${this.currentPlayer} is AI, starting turn`); this.isAIThinking = true; this.updateUI(); try { // Run AI turn and wait for it to complete await this.aiBots[this.currentPlayer].playTurn(); console.log(`[AI] Player ${this.currentPlayer} AI turn completed`); return true; } catch (error) { console.error(`[AI] Error during Player ${this.currentPlayer} AI turn:`, error); this.log(`AI error: ${error.message}`, 'error'); this.isAIThinking = false; this.updateUI(); // Still advance to next player even on error return true; } finally { // Always reset the flag when AI turn completes (success or error) this.isAIThinking = false; console.log(`[AI] Player ${this.currentPlayer} isAIThinking reset to false`); } } console.log(`[AI] Player ${this.currentPlayer} is human, no AI turn needed`); return false; } 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; // Update map size display document.getElementById('map-size-display').textContent = `${this.mapSize}x${this.mapSize}`; 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(); });