Implement hexagonal map and isometric game UI

This commit is contained in:
sokol
2026-02-21 17:23:09 +03:00
parent afd48af0b8
commit 25385de4d4
11 changed files with 2146 additions and 0 deletions

570
public/game.js Normal file
View 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();
});

109
public/index.html Normal file
View File

@@ -0,0 +1,109 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>HEXO - DiceWars Clone</title>
<link rel="stylesheet" href="styles.css">
</head>
<body>
<div class="game-container">
<!-- Header -->
<header class="game-header">
<h1>HEXO</h1>
<p class="subtitle">DiceWars Clone</p>
</header>
<!-- Game Area -->
<div class="game-area">
<!-- Left Panel - Player Info -->
<aside class="side-panel left-panel">
<div class="player-card player-1 active" id="player1-card">
<h3>Player 1</h3>
<div class="player-stats">
<div class="stat">
<span class="stat-label">Cells:</span>
<span class="stat-value" id="player1-cells">0</span>
</div>
<div class="stat">
<span class="stat-label">Supply:</span>
<span class="stat-value" id="player1-supply">0</span>
</div>
<div class="stat">
<span class="stat-label">Total Strength:</span>
<span class="stat-value" id="player1-strength">0</span>
</div>
</div>
</div>
<div class="player-card player-2" id="player2-card">
<h3>Player 2</h3>
<div class="player-stats">
<div class="stat">
<span class="stat-label">Cells:</span>
<span class="stat-value" id="player2-cells">0</span>
</div>
<div class="stat">
<span class="stat-label">Supply:</span>
<span class="stat-value" id="player2-supply">0</span>
</div>
<div class="stat">
<span class="stat-label">Total Strength:</span>
<span class="stat-value" id="player2-strength">0</span>
</div>
</div>
</div>
<div class="game-info">
<h3>Game Info</h3>
<div class="info-item">
<span>Turn:</span>
<span id="current-turn">1</span>
</div>
<div class="info-item">
<span>Phase:</span>
<span id="game-phase">Movement</span>
</div>
</div>
<button class="btn btn-primary" id="end-turn-btn">End Turn</button>
<button class="btn btn-secondary" id="new-game-btn">New Game</button>
</aside>
<!-- Canvas -->
<main class="canvas-container">
<canvas id="game-canvas" width="800" height="800"></canvas>
<div class="canvas-overlay" id="message-overlay">
<span id="overlay-message"></span>
</div>
</main>
<!-- Right Panel - Actions & Log -->
<aside class="side-panel right-panel">
<div class="actions-panel">
<h3>Actions</h3>
<p class="instruction" id="action-instruction">Select a cell to move</p>
<div class="action-buttons">
<button class="btn btn-action" id="attack-btn" disabled>Attack</button>
<button class="btn btn-action" id="cancel-btn" disabled>Cancel</button>
</div>
</div>
<div class="battle-log">
<h3>Battle Log</h3>
<div class="log-entries" id="battle-log">
<div class="log-entry">Game started...</div>
</div>
</div>
</aside>
</div>
<!-- Footer -->
<footer class="game-footer">
<p>Click on your cell with dice, then click adjacent cell to attack</p>
</footer>
</div>
<script type="module" src="game.js"></script>
</body>
</html>

173
public/map.js Normal file
View File

@@ -0,0 +1,173 @@
/**
* Hexagonal grid map for the DiceWars game.
* Browser version (ES Module)
*/
const MAP_SIZE = 20;
const CELL_TYPES = {
EMPTY: 0,
BLOCKED: 1,
PLAYER1: 2,
PLAYER2: 3,
};
class HexCell {
constructor(q, r, type = CELL_TYPES.EMPTY) {
this.q = q;
this.r = r;
this.type = type;
this.dice = [];
}
getStrength() {
if (this.dice.length === 0) return 0;
const cnt = this.dice.length;
const fullDice = 6;
const currentDice = this.dice[this.dice.length - 1];
return (cnt - 1) * fullDice + currentDice;
}
isMaxStrength() {
return this.dice.length >= 8 && this.getStrength() >= 48;
}
addDie(value) {
if (this.dice.length < 8) {
this.dice.push(value);
return true;
}
return false;
}
setStrength(targetStrength) {
if (targetStrength <= 0) {
this.dice = [];
return;
}
const fullDice = 6;
const cnt = Math.floor((targetStrength - 1) / fullDice) + 1;
const remainder = targetStrength - (cnt - 1) * fullDice;
this.dice = new Array(cnt - 1).fill(6);
if (remainder > 0) {
this.dice.push(remainder);
}
}
isPassable() {
return this.type !== CELL_TYPES.BLOCKED;
}
isOwned() {
return this.type === CELL_TYPES.PLAYER1 || this.type === CELL_TYPES.PLAYER2;
}
getOwner() {
if (this.type === CELL_TYPES.PLAYER1) return 1;
if (this.type === CELL_TYPES.PLAYER2) return 2;
return 0;
}
}
class HexMap {
constructor(size = MAP_SIZE) {
this.size = size;
this.cells = new Map();
this.generate();
}
generate() {
this.cells.clear();
for (let q = 0; q < this.size; q++) {
for (let r = 0; r < this.size; r++) {
const key = this.getKey(q, r);
const type = Math.random() < 0.15 ? CELL_TYPES.BLOCKED : CELL_TYPES.EMPTY;
this.cells.set(key, new HexCell(q, r, type));
}
}
}
getCell(q, r) {
const key = this.getKey(q, r);
return this.cells.get(key);
}
getCellByKey(key) {
return this.cells.get(key);
}
getKey(q, r) {
return `${q},${r}`;
}
getPassableCells() {
return Array.from(this.cells.values()).filter(cell => cell.isPassable());
}
getEmptyCells() {
return Array.from(this.cells.values()).filter(
cell => cell.isPassable() && !cell.isOwned()
);
}
getPlayerCells(playerId) {
const targetType = playerId === 1 ? CELL_TYPES.PLAYER1 : CELL_TYPES.PLAYER2;
return Array.from(this.cells.values()).filter(
cell => cell.type === targetType
);
}
getNeighbors(q, r) {
const directions = [
[1, 0], [1, -1], [0, -1],
[-1, 0], [-1, 1], [0, 1]
];
const neighbors = [];
for (const [dq, dr] of directions) {
const nq = q + dq;
const nr = r + dr;
if (nq >= 0 && nq < this.size && nr >= 0 && nr < this.size) {
const cell = this.getCell(nq, nr);
if (cell && cell.isPassable()) {
neighbors.push(cell);
}
}
}
return neighbors;
}
calculateSupply(playerId) {
const playerCells = this.getPlayerCells(playerId);
let supply = 0;
for (const cell of playerCells) {
supply += 1;
}
return supply;
}
setOwner(q, r, playerId) {
const cell = this.getCell(q, r);
if (cell && cell.isPassable()) {
cell.type = playerId === 1 ? CELL_TYPES.PLAYER1 : CELL_TYPES.PLAYER2;
return true;
}
return false;
}
clearOwner(q, r) {
const cell = this.getCell(q, r);
if (cell) {
cell.type = CELL_TYPES.EMPTY;
cell.dice = [];
return true;
}
return false;
}
}
export { HexMap, HexCell, CELL_TYPES, MAP_SIZE };

378
public/styles.css Normal file
View File

@@ -0,0 +1,378 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
:root {
--bg-primary: #1a1a2e;
--bg-secondary: #16213e;
--bg-panel: #0f3460;
--text-primary: #eee;
--text-secondary: #aaa;
--accent-primary: #e94560;
--accent-secondary: #00adb5;
--player1-color: #4ecca3;
--player2-color: #e94560;
--blocked-color: #2a2a4a;
--empty-color: #3a5a6a;
--highlight-color: rgba(255, 255, 255, 0.3);
--selected-color: rgba(233, 69, 96, 0.5);
--target-color: rgba(78, 204, 163, 0.5);
--hex-stroke: #1a1a2e;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
min-height: 100vh;
overflow: hidden;
}
.game-container {
display: flex;
flex-direction: column;
height: 100vh;
}
/* Header */
.game-header {
background: var(--bg-secondary);
padding: 10px 20px;
text-align: center;
border-bottom: 2px solid var(--accent-primary);
}
.game-header h1 {
font-size: 1.8rem;
color: var(--accent-primary);
text-transform: uppercase;
letter-spacing: 4px;
}
.subtitle {
font-size: 0.9rem;
color: var(--text-secondary);
}
/* Game Area */
.game-area {
display: flex;
flex: 1;
overflow: hidden;
}
/* Side Panels */
.side-panel {
width: 220px;
background: var(--bg-secondary);
padding: 15px;
display: flex;
flex-direction: column;
gap: 15px;
overflow-y: auto;
}
.left-panel {
border-right: 1px solid var(--bg-panel);
}
.right-panel {
border-left: 1px solid var(--bg-panel);
}
/* Player Cards */
.player-card {
background: var(--bg-panel);
border-radius: 8px;
padding: 12px;
border-left: 4px solid transparent;
transition: all 0.3s ease;
}
.player-card.active {
box-shadow: 0 0 15px rgba(233, 69, 96, 0.4);
}
.player-1 {
border-left-color: var(--player1-color);
}
.player-1.active {
background: linear-gradient(135deg, var(--bg-panel), rgba(78, 204, 163, 0.1));
}
.player-2 {
border-left-color: var(--player2-color);
}
.player-2.active {
background: linear-gradient(135deg, var(--bg-panel), rgba(233, 69, 96, 0.1));
}
.player-card h3 {
font-size: 1rem;
margin-bottom: 10px;
text-transform: uppercase;
letter-spacing: 1px;
}
.player-stats {
display: flex;
flex-direction: column;
gap: 6px;
}
.stat {
display: flex;
justify-content: space-between;
font-size: 0.85rem;
}
.stat-label {
color: var(--text-secondary);
}
.stat-value {
font-weight: bold;
color: var(--text-primary);
}
/* Game Info */
.game-info {
background: var(--bg-panel);
border-radius: 8px;
padding: 12px;
}
.game-info h3 {
font-size: 0.9rem;
margin-bottom: 10px;
color: var(--text-secondary);
}
.info-item {
display: flex;
justify-content: space-between;
font-size: 0.85rem;
padding: 4px 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.info-item:last-child {
border-bottom: none;
}
/* Buttons */
.btn {
padding: 10px 16px;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 0.9rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 1px;
transition: all 0.2s ease;
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-primary {
background: var(--accent-primary);
color: white;
}
.btn-primary:hover:not(:disabled) {
background: #ff6b6b;
transform: translateY(-2px);
}
.btn-secondary {
background: var(--bg-panel);
color: var(--text-primary);
}
.btn-secondary:hover:not(:disabled) {
background: var(--accent-secondary);
}
.btn-action {
background: var(--accent-secondary);
color: white;
flex: 1;
}
.btn-action:hover:not(:disabled) {
background: #00cec9;
}
/* Actions Panel */
.actions-panel {
background: var(--bg-panel);
border-radius: 8px;
padding: 12px;
}
.actions-panel h3 {
font-size: 0.9rem;
margin-bottom: 8px;
color: var(--text-secondary);
}
.instruction {
font-size: 0.8rem;
color: var(--text-primary);
margin-bottom: 12px;
min-height: 40px;
}
.action-buttons {
display: flex;
gap: 8px;
}
/* Battle Log */
.battle-log {
background: var(--bg-panel);
border-radius: 8px;
padding: 12px;
flex: 1;
display: flex;
flex-direction: column;
min-height: 200px;
}
.battle-log h3 {
font-size: 0.9rem;
margin-bottom: 10px;
color: var(--text-secondary);
}
.log-entries {
flex: 1;
overflow-y: auto;
font-size: 0.75rem;
font-family: 'Consolas', monospace;
}
.log-entry {
padding: 4px 6px;
margin-bottom: 4px;
background: rgba(0, 0, 0, 0.2);
border-radius: 4px;
border-left: 3px solid var(--accent-secondary);
}
.log-entry.attack {
border-left-color: var(--accent-primary);
}
.log-entry.victory {
border-left-color: var(--player1-color);
}
.log-entry.defeat {
border-left-color: var(--player2-color);
}
/* Canvas Container */
.canvas-container {
flex: 1;
display: flex;
justify-content: center;
align-items: center;
background: var(--bg-primary);
position: relative;
overflow: hidden;
min-width: 600px;
min-height: 600px;
}
#game-canvas {
border-radius: 8px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.4);
display: block;
}
.canvas-overlay {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: rgba(26, 26, 46, 0.95);
padding: 30px 50px;
border-radius: 12px;
border: 2px solid var(--accent-primary);
display: none;
z-index: 10;
}
.canvas-overlay.visible {
display: block;
}
#overlay-message {
font-size: 1.5rem;
color: var(--accent-primary);
text-transform: uppercase;
letter-spacing: 3px;
}
/* Footer */
.game-footer {
background: var(--bg-secondary);
padding: 8px 20px;
text-align: center;
border-top: 1px solid var(--bg-panel);
}
.game-footer p {
font-size: 0.8rem;
color: var(--text-secondary);
}
/* Scrollbar */
::-webkit-scrollbar {
width: 8px;
}
::-webkit-scrollbar-track {
background: var(--bg-primary);
}
::-webkit-scrollbar-thumb {
background: var(--bg-panel);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--accent-secondary);
}
/* Responsive */
@media (max-width: 1200px) {
.side-panel {
width: 180px;
}
}
@media (max-width: 900px) {
.game-area {
flex-direction: column;
}
.side-panel {
width: 100%;
flex-direction: row;
flex-wrap: wrap;
}
.canvas-container {
min-height: 400px;
}
}