/** * Hexo Game UI - Canvas Rendering and Interactions */ import { HexMap, CELL_TYPES } from './map.js'; // Game constants const HEX_SIZE = 18; const MAP_SIZE = 20; const ANIMATION_DURATION = 300; // Colors const COLORS = { blocked: '#2a2a4a', empty: '#3a5a6a', player1: '#4ecca3', player2: '#e94560', 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.gamePhase = 'movement'; // movement, supply, gameover this.hasMoved = false; this.offsetX = 0; this.offsetY = 0; this.init(); } init() { this.setupEventListeners(); this.newGame(); console.log('Game initialized'); } newGame() { this.map = new HexMap(MAP_SIZE); console.log('Map created, cells:', this.map.cells.size); this.selectedCell = null; this.currentTarget = null; this.currentPlayer = 1; this.gamePhase = 'movement'; this.hasMoved = false; // Initialize starting positions this.initializePlayers(); this.centerMap(); this.render(); this.updateUI(); this.log('New game started! Player 1\'s turn.'); } initializePlayers() { // Get random empty cells for each player const emptyCells = this.map.getEmptyCells(); // Shuffle and pick starting positions const shuffled = emptyCells.sort(() => Math.random() - 0.5); // Player 1 starting position (top-left area) const p1Cell = shuffled.find(c => c.q < 8 && c.r < 8); if (p1Cell) { this.map.setOwner(p1Cell.q, p1Cell.r, 1); p1Cell.setStrength(8); // Starting strength } // Player 2 starting position (bottom-right area) const p2Cell = shuffled.find(c => c.q > 10 && c.r > 10 && c.type === CELL_TYPES.EMPTY); if (p2Cell) { this.map.setOwner(p2Cell.q, p2Cell.r, 2); p2Cell.setStrength(8); } } centerMap() { const canvasWidth = this.canvas.width; const canvasHeight = this.canvas.height; const sqrt3 = Math.sqrt(3); // Map dimensions for pointy-top hex grid const mapWidth = HEX_SIZE * sqrt3 * (MAP_SIZE + MAP_SIZE/2); const mapHeight = HEX_SIZE * 1.5 * (MAP_SIZE - 1) + HEX_SIZE * 2; this.offsetX = (canvasWidth - mapWidth) / 2 + HEX_SIZE * sqrt3; this.offsetY = (canvasHeight - mapHeight) / 2 + HEX_SIZE; } 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('new-game-btn').addEventListener('click', () => this.newGame()); document.getElementById('cancel-btn').addEventListener('click', () => this.cancelSelection()); } hexToPixel(q, r) { // Pointy-top hex grid with proper adjacency // For pointy-top: width = sqrt(3) * size, height = 2 * size // Horizontal spacing = width = sqrt(3) * size // Vertical spacing = 3/4 * height = 1.5 * size const sqrt3 = Math.sqrt(3); // Convert axial to pixel coordinates for pointy-top hex 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; } /** * Get neighboring cells for pointy-top hex grid * Directions for pointy-top hex in axial coordinates: * - north-east: (+1, -1) * - north-west: (0, -1) * - west: (-1, 0) * - south-west: (-1, +1) * - south-east: (0, +1) * - east: (+1, 0) */ 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); // Draw pointy-top hexagon - size matches grid spacing const size = HEX_SIZE * 0.98; // Almost full size for tight fit // Pointy-top hexagon vertices (flat sides left/right) const vertices = []; for (let i = 0; i < 6; i++) { const angle = Math.PI / 6 + (Math.PI / 3) * i; // Start at 30 degrees 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(); // Draw strength number 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); // Draw dice count indicator (small dots) 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 = cell.getOwner() === 1 ? COLORS.player1 : COLORS.player2; // Draw border matching the hexagon shape 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() { // Clear canvas this.ctx.fillStyle = COLORS.stroke; this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height); if (!this.map) return; // Draw all cells 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 { color = COLORS.empty; } // Apply transparency for non-owned cells if (!cell.isOwned()) { color = this.hexToRgba(color, 0.6); } this.drawHex(q, r, color); this.drawOwnerIndicator(cell); this.drawDice(cell); } } // Draw selection highlight if (this.selectedCell) { this.drawHex( this.selectedCell.q, this.selectedCell.r, COLORS.selected, COLORS.text, 3 ); // Highlight valid targets (not blocked, not own) 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, 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; 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; // Handle cell selection if (this.selectedCell === null) { // Select own cell with strength > 1 if (cell.getOwner() === this.currentPlayer && cell.getStrength() > 1) { this.selectedCell = cell; this.instruction = `Select target cell or cancel`; this.updateUI(); this.render(); } } else { // Check if clicking on same cell - deselect if (cell === this.selectedCell) { this.cancelSelection(); return; } // Check if valid target using isometric-aware adjacency const targets = this.getValidTargets(this.selectedCell.q, this.selectedCell.r); const isValidTarget = targets.some(n => n.q === cell.q && n.r === cell.r); // Cannot move to own cells - only attack enemy or capture empty if (cell.getOwner() === this.currentPlayer) { // Select different own cell instead if (cell.getStrength() > 1) { this.selectedCell = cell; this.updateUI(); this.render(); } return; } if (isValidTarget) { this.currentTarget = cell; this.executeAttack(); } else { this.cancelSelection(); } } } handleMouseMove(e) { // Could add hover effects 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) { // Attack empty or enemy cell let defenseStrength = defender.getStrength(); if (defenseStrength > 0) { // Combat! Roll dice const attackRoll = Math.floor(Math.random() * attackStrength) + 1; const defenseRoll = Math.floor(Math.random() * defenseStrength) + 1; this.log(`Attack: ${attackStrength} vs ${defenseStrength} | Roll: ${attackRoll} vs ${defenseRoll}`); if (attackRoll > defenseRoll) { // Attacker wins 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 cell with strength ${remainingStrength}`, 'victory'); } } else { // Defender wins const remainingDefense = defenseRoll - attackRoll; defender.setStrength(Math.max(1, remainingDefense)); this.log(`Attack repelled! Defender has ${Math.max(1, remainingDefense)} strength`, 'defeat'); } } else { // Move to empty cell - transfer attackStrength (original - 1) attacker.setStrength(1); defender.setStrength(attackStrength); this.map.setOwner(defender.q, defender.r, this.currentPlayer); this.log(`Moved to 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(); } endTurn() { if (this.gamePhase !== 'movement') return; // Apply supply const supply = this.map.calculateSupply(this.currentPlayer); this.distributeSupply(supply); this.log(`Player ${this.currentPlayer} received ${supply} supply`); // Switch player this.currentPlayer = this.currentPlayer === 1 ? 2 : 1; this.hasMoved = false; this.cancelSelection(); this.updateUI(); this.render(); this.log(`Player ${this.currentPlayer}'s turn`); } distributeSupply(supply) { const playerCells = this.map.getPlayerCells(this.currentPlayer); // Find cells that can receive more dice const eligibleCells = playerCells.filter(cell => !cell.isMaxStrength()); if (eligibleCells.length === 0 || supply === 0) return; // Distribute supply randomly among eligible cells 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) { const addStrength = Math.min(remainingSupply, 48 - currentStrength); const newStrength = currentStrength + addStrength; randomCell.setStrength(newStrength); remainingSupply -= addStrength; } if (randomCell.isMaxStrength()) { eligibleCells.splice(eligibleCells.indexOf(randomCell), 1); } } } updateUI() { // Update current player stats const currentCells = this.map.getPlayerCells(this.currentPlayer); const currentStrength = currentCells.reduce((sum, c) => sum + c.getStrength(), 0); const currentSupply = this.map.calculateSupply(this.currentPlayer); // Update player 1 card (only player for now) document.getElementById('player1-cells').textContent = currentCells.length; document.getElementById('player1-supply').textContent = currentSupply; document.getElementById('player1-strength').textContent = currentStrength; // Update game info document.getElementById('current-turn').textContent = this.currentPlayer; document.getElementById('game-phase').textContent = this.gamePhase; // Update instruction const instruction = document.getElementById('action-instruction'); if (this.selectedCell) { instruction.textContent = `Select target to attack (strength: ${this.selectedCell.getStrength()})`; } else { instruction.textContent = 'Select a cell with dice to move'; } // Update selected cell info - always show with placeholder if nothing selected 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 = '-'; } // Update buttons const cancelBtn = document.getElementById('cancel-btn'); const endTurnBtn = document.getElementById('end-turn-btn'); cancelBtn.disabled = !this.selectedCell; endTurnBtn.disabled = !this.hasMoved; } log(message, type = '') { const logContainer = document.getElementById('battle-log'); const entry = document.createElement('div'); entry.className = `log-entry ${type}`; entry.textContent = `[Turn ${this.currentPlayer}] ${message}`; logContainer.insertBefore(entry, logContainer.firstChild); // Keep only last 50 entries while (logContainer.children.length > 50) { logContainer.removeChild(logContainer.lastChild); } } showMessage(message) { const overlay = document.getElementById('message-overlay'); const messageEl = document.getElementById('overlay-message'); messageEl.textContent = message; overlay.classList.add('visible'); setTimeout(() => { overlay.classList.remove('visible'); }, 2000); } } // Initialize game when DOM is ready document.addEventListener('DOMContentLoaded', () => { window.game = new GameUI(); });