/**
* 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 = 18;
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;
if (this.playerTypes[i] === 'ai') {
this.aiBots[i] = new AIBot(i, this.map, this);
}
}
// 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 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);
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('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();
});