Implement hexagonal map and isometric game UI
This commit is contained in:
570
public/game.js
Normal file
570
public/game.js
Normal file
@@ -0,0 +1,570 @@
|
||||
/**
|
||||
* Hexo Game UI - Canvas Rendering and Interactions
|
||||
*/
|
||||
|
||||
import { HexMap, CELL_TYPES } from './map.js';
|
||||
|
||||
// Game constants
|
||||
const HEX_SIZE = 20;
|
||||
const MAP_SIZE = 20;
|
||||
const ANIMATION_DURATION = 300;
|
||||
const ISO_ANGLE = Math.PI / 6; // 30 degrees for isometric view
|
||||
|
||||
// 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;
|
||||
|
||||
// Calculate map dimensions for isometric view
|
||||
const mapWidth = MAP_SIZE * HEX_SIZE * 2;
|
||||
const mapHeight = MAP_SIZE * HEX_SIZE;
|
||||
|
||||
this.offsetX = canvasWidth / 2;
|
||||
this.offsetY = HEX_SIZE * 2;
|
||||
}
|
||||
|
||||
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('attack-btn').addEventListener('click', () => this.executeAttack());
|
||||
document.getElementById('cancel-btn').addEventListener('click', () => this.cancelSelection());
|
||||
}
|
||||
|
||||
hexToPixel(q, r) {
|
||||
// Isometric projection for pointy-top hex grid
|
||||
// q axis goes top-left to bottom-right
|
||||
// r axis goes top-right to bottom-left
|
||||
const isoX = (q - r) * HEX_SIZE * 1.5;
|
||||
const isoY = (q + r) * HEX_SIZE * 0.75;
|
||||
|
||||
return {
|
||||
x: this.offsetX + isoX,
|
||||
y: this.offsetY + isoY
|
||||
};
|
||||
}
|
||||
|
||||
pixelToHex(x, y) {
|
||||
const adjX = x - this.offsetX;
|
||||
const adjY = y - this.offsetY;
|
||||
|
||||
// Reverse isometric projection
|
||||
// From: isoX = (q - r) * HEX_SIZE * 1.5
|
||||
// isoY = (q + r) * HEX_SIZE * 0.75
|
||||
const q = (adjX / (HEX_SIZE * 1.5) + adjY / (HEX_SIZE * 0.75)) / 2;
|
||||
const r = (adjY / (HEX_SIZE * 0.75) - adjX / (HEX_SIZE * 1.5)) / 2;
|
||||
|
||||
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 isometric hex grid
|
||||
* Directions for pointy-top hex in axial coordinates with isometric projection:
|
||||
* hexToPixel: isoX = (q-r)*1.5, isoY = (q+r)*0.75
|
||||
*
|
||||
* Visual directions:
|
||||
* - top: (-1, -1) - both decrease
|
||||
* - bottom: (+1, +1) - both increase
|
||||
* - top-left: (-1, 0) - q decreases
|
||||
* - bottom-left: (0, +1) - r increases
|
||||
* - top-right: (0, -1) - r decreases
|
||||
* - bottom-right: (+1, 0) - q increases
|
||||
*/
|
||||
getValidTargets(q, r) {
|
||||
const directions = [
|
||||
[+1, 0], // bottom-right
|
||||
[+1, +1], // bottom (straight down)
|
||||
[0, +1], // bottom-left
|
||||
[-1, 0], // top-left
|
||||
[-1, -1], // top (straight up)
|
||||
[0, -1] // top-right
|
||||
];
|
||||
|
||||
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 elongated hexagon for isometric view
|
||||
const hexWidth = HEX_SIZE * 1.3;
|
||||
const hexHeight = HEX_SIZE * 0.75;
|
||||
|
||||
// Hexagon vertices for isometric view (elongated horizontally)
|
||||
const vertices = [
|
||||
{ x: x + hexWidth, y: y }, // right
|
||||
{ x: x + hexWidth * 0.5, y: y - hexHeight }, // top-right
|
||||
{ x: x - hexWidth * 0.5, y: y - hexHeight }, // top-left
|
||||
{ x: x - hexWidth, y: y }, // left
|
||||
{ x: x - hexWidth * 0.5, y: y + hexHeight }, // bottom-left
|
||||
{ x: x + hexWidth * 0.5, y: y + hexHeight } // bottom-right
|
||||
];
|
||||
|
||||
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 hexWidth = HEX_SIZE * 1.3 - 3;
|
||||
const hexHeight = HEX_SIZE * 0.75 - 3;
|
||||
|
||||
const vertices = [
|
||||
{ x: x + hexWidth, y: y },
|
||||
{ x: x + hexWidth * 0.5, y: y - hexHeight },
|
||||
{ x: x - hexWidth * 0.5, y: y - hexHeight },
|
||||
{ x: x - hexWidth, y: y },
|
||||
{ x: x - hexWidth * 0.5, y: y + hexHeight },
|
||||
{ x: x + hexWidth * 0.5, y: y + hexHeight }
|
||||
];
|
||||
|
||||
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 player stats
|
||||
const p1Cells = this.map.getPlayerCells(1);
|
||||
const p2Cells = this.map.getPlayerCells(2);
|
||||
|
||||
const p1Strength = p1Cells.reduce((sum, c) => sum + c.getStrength(), 0);
|
||||
const p2Strength = p2Cells.reduce((sum, c) => sum + c.getStrength(), 0);
|
||||
|
||||
document.getElementById('player1-cells').textContent = p1Cells.length;
|
||||
document.getElementById('player1-supply').textContent = this.map.calculateSupply(1);
|
||||
document.getElementById('player1-strength').textContent = p1Strength;
|
||||
|
||||
document.getElementById('player2-cells').textContent = p2Cells.length;
|
||||
document.getElementById('player2-supply').textContent = this.map.calculateSupply(2);
|
||||
document.getElementById('player2-strength').textContent = p2Strength;
|
||||
|
||||
// Update active player
|
||||
document.getElementById('player1-card').classList.toggle('active', this.currentPlayer === 1);
|
||||
document.getElementById('player2-card').classList.toggle('active', this.currentPlayer === 2);
|
||||
|
||||
// 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 buttons
|
||||
const attackBtn = document.getElementById('attack-btn');
|
||||
const cancelBtn = document.getElementById('cancel-btn');
|
||||
const endTurnBtn = document.getElementById('end-turn-btn');
|
||||
|
||||
attackBtn.disabled = !this.selectedCell;
|
||||
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();
|
||||
});
|
||||
Reference in New Issue
Block a user