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

122
QWEN.md Normal file
View File

@@ -0,0 +1,122 @@
# Project Context: hexo
## Project Overview
**hexo** is an educational game project - a clone of [DiceWars](https://www.gamedesign.jp/games/dicewars/). The project is in early development stage.
### Game Concept
A strategy dice game played on a hexagonal grid where players command armies of dice and battle to conquer territories.
### Core Game Mechanics
#### Map System
- Generatable hexagonal grid map (20x20 cells)
- Each cell can be either playable or blocked/impassable
- Each field can hold up to 8 dice
- Each player-owned field provides +1 supply unit to the player
#### Dice System
- Standard 6-sided dice
- Unit strength calculation formula:
```
F = (cnt-1) * full_dice + current_dice
```
Where:
- `cnt` = number of dice on the field
- `full_dice` = maximum die value (6)
- `current_dice` = top die current value (1-6, calculated during gameplay)
#### Game Rules
1. **Setup**: Multiple dice are placed on the map for each player at the start
2. **Movement Conditions**: A user can move a unit if its strength > 1
- When moving: strength-1 transfers to target cell, strength 1 remains on source cell
3. **Combat**: When attacking a cell with enemy dice, both players roll:
- **Attacker**: `F_attack = rnd(F-1)` (random value less than original strength minus 1)
- **Defender**: `F_defence = current_strength` (full field strength)
- **Victory**: If `F_attack > F_defence`, attacker wins; otherwise defender repels the attack
- **On Victory**: Attacker leaves strength 1 on source cell, transfers `F_attack-1` to target
- **On Defeat**: Defender retains `F_defence - F_attack` (minimum 1)
4. **Supply Phase**: After all players have moved, each receives supply:
- `S = sum(Cell)` where Cell = supply value from each player-owned cell
- Maximum per cell: `8 * full_dice` (48 dice points)
- If all cells already have maximum strength, no supply is added
## Directory Structure
```
hexo/
├── README.md # Game specifications and rules (in Russian)
├── QWEN.md # This file - project context for AI assistance
├── .gitignore # Git ignore rules (Python-focused template)
├── jsdom-pkg/ # Local copy of jsdom library
│ └── package/
│ └── lib/
│ ├── api.js # Main jsdom API entry point
│ └── jsdom/ # jsdom core implementation
│ ├── browser/ # Browser emulation (Window, parser, resources)
│ ├── living/ # DOM living standard implementations
│ ├── generated/ # Auto-generated Web IDL bindings
│ └── ... # Various DOM/CSS/SVG/XHR implementations
└── node_modules/
└── jsdom/ # Installed jsdom dependency
```
## Technology Stack
- **Runtime**: Node.js (inferred from jsdom usage)
- **Core Library**: [jsdom](https://github.com/jsdom/jsdom) - JavaScript implementation of DOM and HTML standards
- Provides browser-like environment for server-side JavaScript
- Enables DOM manipulation, event handling, and web API emulation
## Development Status
**Early Stage**: The project currently contains:
- Game design documentation (README.md)
- jsdom library setup (both local copy and node_modules installation)
- No main game source files yet
## Building and Running
> **TODO**: Build and run commands are not yet defined. The project is in initial setup phase.
Expected setup once development begins:
```bash
# Install dependencies
npm install
# Run the game (TBD)
npm start
# Run tests (TBD)
npm test
```
## Development Conventions
> **TODO**: Coding standards and testing practices are not yet established.
### Inferred Practices (based on jsdom usage)
- JavaScript/TypeScript expected for implementation
- DOM-based rendering likely planned (given jsdom inclusion)
- Game logic will need to implement:
- Hexagonal grid generation
- Dice mechanics and randomization
- Turn-based combat system
- Player state management
## Key Implementation Areas
When development begins, focus on these components:
1. **Map Generator**: Hexagonal grid creation with passable/impassable cells
2. **Dice Engine**: Randomization and strength calculation
3. **Combat System**: Attack/defense resolution logic
4. **Game State**: Player turns, unit positions, victory conditions
5. **UI/Rendering**: Visual representation of the game board
## Notes
- The `.gitignore` file appears to be a Python template and may need to be updated for a JavaScript project
- The `jsdom-pkg` directory contains a local copy of jsdom, possibly for offline development or custom modifications
- Game rules are documented in Russian in README.md

View File

@@ -1,2 +1,37 @@
# hexo
Это учебный проект. Игра, клон https://www.gamedesign.jp/games/dicewars/
1. Генерируемкая карта
1. ккаждое поле - ячейка гексагон
2. размер карты 20 х 20 ячеек
3. каждое поле может быть доступно для игроков или быть непроходимо для всех
4. каджое принадлежащее игроку поле дет ему +1 единицу снабжения
2. игрок управляет кубиками
1. кубик игральный 6 граннный
2. на каждом поле может быть до 8 кубиков
3. сила текущего юнита (ячейка с кубиками) расчитывается,
F = (cnt-1)*full_dice + current_dice,
где cnt - количество кубиков,
full_dice - максимальное значение на кубике = 6
сurrent_dice - верхний кубик с текущим значением от 1 до 6, расчитываемое в ходе игры
3. правила игры
1. вначале на карты помещается несколько кубиков для каждых игроков
2. юзер может ходить юнитом, если
1. его сила больше 1, при этом на захваченную клетку перемещается сила-1, а на исходной остается кубик с силой 1
2. если на захватываемой клетке вражеские кубики, то оба игрока бросают кости
1. у нападающего количество максимальной силыменьше на 1 исходной, F_attac=rnd(F-1)
2. у защищающегося полная сила поля, F_defence=ктв(F)
3.
4. условия победы
1. если F_attac>F_defence - то победа нападающего иначе защищающийся отбил атаку
2. при победе нападающего он оставляет на исходной клетке кубик с силой 1, на захватываемую переводит F_attac-1,
3. при пройгрыше у защищающегося отсается F_defence-F_attac, но не меньше 1
3. После того, как все игроки походили, каждый получает снабжение по формуле
S=sum(Cell), где Cell - значение снабжения с каждой клетки, принадлежащей игроку
1. Саксимальное кол-во на клетке может быть 8*Full_dice
2. Если все клетки игрока имеют максимальное колво силы, то ничего не добавляется.

17
package.json Normal file
View File

@@ -0,0 +1,17 @@
{
"name": "hexo",
"version": "0.1.0",
"description": "Educational dice game - DiceWars clone",
"main": "src/index.js",
"scripts": {
"start": "node src/index.js",
"serve": "node server.js",
"test": "node --test"
},
"keywords": ["game", "dicewars", "hexagon"],
"author": "",
"license": "MIT",
"dependencies": {
"jsdom": "file:./jsdom-pkg/package"
}
}

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;
}
}

46
server.js Normal file
View File

@@ -0,0 +1,46 @@
const http = require('http');
const fs = require('fs');
const path = require('path');
const PORT = 8080;
const MIME_TYPES = {
'.html': 'text/html',
'.css': 'text/css',
'.js': 'application/javascript',
'.json': 'application/json',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.gif': 'image/gif',
'.svg': 'image/svg+xml',
'.ico': 'image/x-icon'
};
const server = http.createServer((req, res) => {
let filePath = req.url === '/' ? '/index.html' : req.url;
filePath = path.join(__dirname, 'public', filePath);
const ext = path.extname(filePath);
const contentType = MIME_TYPES[ext] || 'application/octet-stream';
fs.readFile(filePath, (err, content) => {
if (err) {
if (err.code === 'ENOENT') {
res.writeHead(404);
res.end('File not found');
} else {
res.writeHead(500);
res.end('Server error');
}
} else {
res.writeHead(200, { 'Content-Type': contentType });
res.end(content);
}
});
});
server.listen(PORT, () => {
console.log(`\n=== HEXO Game Server ===`);
console.log(`Server running at http://localhost:${PORT}/`);
console.log(`Press Ctrl+C to stop\n`);
});

28
src/index.js Normal file
View File

@@ -0,0 +1,28 @@
/**
* Hexo - DiceWars Clone
* Main entry point
*/
const { HexMap, CELL_TYPES } = require('./map.js');
console.log('=== HEXO - DiceWars Clone ===\n');
// Create and display the map
const map = new HexMap(20);
console.log('Generated Map (20x20 hexagonal grid):\n');
console.log('Legend: [██]=Blocked, [P1]=Player1, [P2]=Player2, [nn]=Strength, [ ]=Empty\n');
map.render();
// Show some statistics
const passableCells = map.getPassableCells().length;
const blockedCells = Array.from(map.cells.values()).filter(
cell => cell.type === CELL_TYPES.BLOCKED
).length;
console.log(`\n=== Map Statistics ===`);
console.log(`Total cells: ${map.size * map.size}`);
console.log(`Passable: ${passableCells}`);
console.log(`Blocked: ${blockedCells}`);
console.log(`Blocked %: ${((blockedCells / (map.size * map.size)) * 100).toFixed(1)}%`);

268
src/map.js Normal file
View File

@@ -0,0 +1,268 @@
/**
* Hexagonal grid map for the DiceWars game.
*
* Map is a 20x20 hexagonal grid where each cell can be:
* - passable (playable)
* - impassable (blocked)
*
* Uses axial coordinates (q, r) for hexagon positioning.
*/
const MAP_SIZE = 20;
const CELL_TYPES = {
EMPTY: 0, // Passable, unowned
BLOCKED: 1, // Impassable terrain
PLAYER1: 2, // Player 1 owned
PLAYER2: 3, // Player 2 owned
};
/**
* Represents a single hex cell on the map
*/
class HexCell {
constructor(q, r, type = CELL_TYPES.EMPTY) {
this.q = q; // Axial coordinate q
this.r = r; // Axial coordinate r
this.type = type; // Cell ownership/type
this.dice = []; // Array of dice values on this cell
}
/**
* Calculate unit strength: F = (cnt-1)*full_dice + current_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]; // Top die
return (cnt - 1) * fullDice + currentDice;
}
/**
* Check if cell has maximum strength (8 dice with 6 on top)
*/
isMaxStrength() {
return this.dice.length >= 8 && this.getStrength() >= 48;
}
/**
* Add a die to this cell
*/
addDie(value) {
if (this.dice.length < 8) {
this.dice.push(value);
return true;
}
return false;
}
/**
* Remove dice from cell, leaving specified strength
*/
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;
}
}
/**
* Hexagonal map generator and manager
*/
class HexMap {
constructor(size = MAP_SIZE) {
this.size = size;
this.cells = new Map(); // Key: "q,r", Value: HexCell
this.generate();
}
/**
* Generate the hexagonal grid
*/
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));
}
}
}
/**
* Get cell by axial coordinates
*/
getCell(q, r) {
const key = this.getKey(q, r);
return this.cells.get(key);
}
/**
* Get cell by key string
*/
getCellByKey(key) {
return this.cells.get(key);
}
/**
* Generate key from coordinates
*/
getKey(q, r) {
return `${q},${r}`;
}
/**
* Get all passable cells
*/
getPassableCells() {
return Array.from(this.cells.values()).filter(cell => cell.isPassable());
}
/**
* Get all empty (unowned) passable cells
*/
getEmptyCells() {
return Array.from(this.cells.values()).filter(
cell => cell.isPassable() && !cell.isOwned()
);
}
/**
* Get all cells owned by a player
*/
getPlayerCells(playerId) {
const targetType = playerId === 1 ? CELL_TYPES.PLAYER1 : CELL_TYPES.PLAYER2;
return Array.from(this.cells.values()).filter(
cell => cell.type === targetType
);
}
/**
* Get neighboring cells (6 directions in hex grid)
*/
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;
}
/**
* Calculate supply for a player: S = sum of all owned cells
*/
calculateSupply(playerId) {
const playerCells = this.getPlayerCells(playerId);
let supply = 0;
for (const cell of playerCells) {
supply += 1; // Each owned cell gives +1 supply
}
return supply;
}
/**
* Render map to console (simplified ASCII representation)
*/
render() {
let output = '';
for (let r = 0; r < this.size; r++) {
// Offset every other row for hex appearance
const offset = r % 2 === 0 ? 0 : 2;
output += ' '.repeat(offset);
for (let q = 0; q < this.size; q++) {
const cell = this.getCell(q, r);
const symbol = this.getCellSymbol(cell);
output += `[${symbol}]`;
}
output += '\n';
}
console.log(output);
}
/**
* Get symbol for cell visualization
*/
getCellSymbol(cell) {
if (cell.type === CELL_TYPES.BLOCKED) return '██';
if (cell.type === CELL_TYPES.PLAYER1) return 'P1';
if (cell.type === CELL_TYPES.PLAYER2) return 'P2';
if (cell.dice.length > 0) return cell.getStrength().toString().padStart(2, ' ');
return ' ';
}
/**
* Set cell ownership
*/
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;
}
/**
* Clear cell ownership
*/
clearOwner(q, r) {
const cell = this.getCell(q, r);
if (cell) {
cell.type = CELL_TYPES.EMPTY;
cell.dice = [];
return true;
}
return false;
}
}
// ES Module exports for browser
export { HexMap, HexCell, CELL_TYPES, MAP_SIZE };
// CommonJS exports for Node.js
if (typeof module !== 'undefined' && module.exports) {
module.exports = { HexMap, HexCell, CELL_TYPES, MAP_SIZE };
}

400
test/map.test.js Normal file
View File

@@ -0,0 +1,400 @@
const { describe, it } = require('node:test');
const assert = require('node:assert');
const { HexMap, HexCell, CELL_TYPES, MAP_SIZE } = require('../src/map.js');
describe('HexCell', () => {
it('should create a cell with axial coordinates', () => {
const cell = new HexCell(3, 5);
assert.strictEqual(cell.q, 3);
assert.strictEqual(cell.r, 5);
assert.strictEqual(cell.type, CELL_TYPES.EMPTY);
assert.strictEqual(cell.dice.length, 0);
});
it('should calculate strength correctly', () => {
const cell = new HexCell(0, 0);
// Empty cell has 0 strength
assert.strictEqual(cell.getStrength(), 0);
// Single die with value 4 = strength 4
cell.dice = [4];
assert.strictEqual(cell.getStrength(), 4);
// Two dice: [6, 3] = (2-1)*6 + 3 = 9
cell.dice = [6, 3];
assert.strictEqual(cell.getStrength(), 9);
// Three dice: [6, 6, 2] = (3-1)*6 + 2 = 14
cell.dice = [6, 6, 2];
assert.strictEqual(cell.getStrength(), 14);
});
it('should detect max strength', () => {
const cell = new HexCell(0, 0);
cell.dice = [6, 6, 6, 6, 6, 6, 6, 6];
assert.strictEqual(cell.isMaxStrength(), true);
cell.dice = [6, 6, 6, 6, 6, 6, 6, 5];
assert.strictEqual(cell.isMaxStrength(), false);
});
it('should add dice up to limit', () => {
const cell = new HexCell(0, 0);
for (let i = 0; i < 8; i++) {
assert.strictEqual(cell.addDie(6), true);
}
// 9th die should fail
assert.strictEqual(cell.addDie(6), false);
assert.strictEqual(cell.dice.length, 8);
});
it('should set strength correctly', () => {
const cell = new HexCell(0, 0);
cell.setStrength(1);
assert.deepStrictEqual(cell.dice, [1]);
cell.setStrength(6);
assert.deepStrictEqual(cell.dice, [6]);
cell.setStrength(7);
assert.deepStrictEqual(cell.dice, [6, 1]);
cell.setStrength(14);
assert.deepStrictEqual(cell.dice, [6, 6, 2]);
cell.setStrength(0);
assert.deepStrictEqual(cell.dice, []);
});
it('should check passability and ownership', () => {
const emptyCell = new HexCell(0, 0, CELL_TYPES.EMPTY);
const blockedCell = new HexCell(0, 0, CELL_TYPES.BLOCKED);
const player1Cell = new HexCell(0, 0, CELL_TYPES.PLAYER1);
assert.strictEqual(emptyCell.isPassable(), true);
assert.strictEqual(blockedCell.isPassable(), false);
assert.strictEqual(emptyCell.isOwned(), false);
assert.strictEqual(player1Cell.isOwned(), true);
assert.strictEqual(emptyCell.getOwner(), 0);
assert.strictEqual(player1Cell.getOwner(), 1);
});
});
describe('HexMap', () => {
it('should create a 20x20 map by default', () => {
const map = new HexMap();
assert.strictEqual(map.size, MAP_SIZE);
assert.strictEqual(map.cells.size, MAP_SIZE * MAP_SIZE);
});
it('should generate cells with correct coordinates', () => {
const map = new HexMap(5);
for (let q = 0; q < 5; q++) {
for (let r = 0; r < 5; r++) {
const cell = map.getCell(q, r);
assert.ok(cell, `Cell at ${q},${r} should exist`);
assert.strictEqual(cell.q, q);
assert.strictEqual(cell.r, r);
}
}
});
it('should have some blocked cells', () => {
const map = new HexMap();
const blockedCells = Array.from(map.cells.values()).filter(
cell => cell.type === CELL_TYPES.BLOCKED
);
// Should have some blocked cells (roughly 15%)
assert.ok(blockedCells.length > 0, 'Should have at least one blocked cell');
assert.ok(blockedCells.length < map.cells.size / 2, 'Should not have more than 50% blocked');
});
it('should return passable cells', () => {
const map = new HexMap(5);
const passableCells = map.getPassableCells();
assert.ok(passableCells.length > 0);
passableCells.forEach(cell => {
assert.strictEqual(cell.isPassable(), true);
});
});
it('should return neighbors correctly', () => {
const map = new HexMap(5);
// Clear all blocks for predictable testing
map.cells.forEach(cell => {
if (cell.type === CELL_TYPES.BLOCKED) {
cell.type = CELL_TYPES.EMPTY;
}
});
// Center cell should have 6 neighbors
const centerNeighbors = map.getNeighbors(2, 2);
assert.strictEqual(centerNeighbors.length, 6);
// Corner cell should have 2 neighbors
const cornerNeighbors = map.getNeighbors(0, 0);
assert.strictEqual(cornerNeighbors.length, 2);
// Edge cell should have 3-4 neighbors
const edgeNeighbors = map.getNeighbors(0, 2);
assert.ok(edgeNeighbors.length >= 3 && edgeNeighbors.length <= 4);
});
it('should set and clear ownership', () => {
const map = new HexMap(5);
// Clear the map first
map.cells.forEach(cell => {
cell.type = CELL_TYPES.EMPTY;
});
// Set ownership on cells that exist
assert.strictEqual(map.setOwner(2, 2, 1), true);
const cell = map.getCell(2, 2);
assert.strictEqual(cell.type, CELL_TYPES.PLAYER1);
assert.strictEqual(cell.getOwner(), 1);
// Set player 2 ownership on adjacent cell (2,3 is a neighbor of 2,2)
assert.strictEqual(map.setOwner(2, 3, 2), true);
const cell2 = map.getCell(2, 3);
assert.strictEqual(cell2.type, CELL_TYPES.PLAYER2);
assert.strictEqual(cell2.getOwner(), 2);
// Clear ownership
assert.strictEqual(map.clearOwner(2, 2), true);
const clearedCell = map.getCell(2, 2);
assert.strictEqual(clearedCell.type, CELL_TYPES.EMPTY);
assert.strictEqual(clearedCell.getOwner(), 0);
});
it('should return player cells', () => {
const map = new HexMap(5);
// First clear any existing ownership
map.cells.forEach(cell => {
cell.type = CELL_TYPES.EMPTY;
});
map.setOwner(0, 0, 1);
map.setOwner(0, 1, 1);
map.setOwner(1, 0, 2);
const player1Cells = map.getPlayerCells(1);
const player2Cells = map.getPlayerCells(2);
assert.strictEqual(player1Cells.length, 2);
assert.strictEqual(player2Cells.length, 1);
});
it('should calculate supply correctly', () => {
const map = new HexMap(5);
// First clear any existing ownership
map.cells.forEach(cell => {
cell.type = CELL_TYPES.EMPTY;
});
map.setOwner(0, 0, 1);
map.setOwner(0, 1, 1);
map.setOwner(1, 0, 1);
const supply = map.calculateSupply(1);
assert.strictEqual(supply, 3); // 3 cells = 3 supply
});
it('should not allow setting owner on blocked cell', () => {
const map = new HexMap(5);
// Find a blocked cell
let blockedCell = null;
map.cells.forEach(cell => {
if (cell.type === CELL_TYPES.BLOCKED) {
blockedCell = cell;
}
});
if (blockedCell) {
assert.strictEqual(map.setOwner(blockedCell.q, blockedCell.r, 1), false);
}
});
});
describe('Target Cell Selection Logic', () => {
it('should allow attacking adjacent enemy cells', () => {
const map = new HexMap(5);
// Clear map
map.cells.forEach(cell => {
cell.type = CELL_TYPES.EMPTY;
});
// Set up: player 1 at (2,2), player 2 at adjacent (2,1)
// Neighbors of (2,2) in axial coords: (3,2), (3,1), (2,1), (1,2), (1,3), (2,3)
map.setOwner(2, 2, 1);
map.setOwner(2, 1, 2); // (2,1) is a neighbor of (2,2)
const p1Cell = map.getCell(2, 2);
p1Cell.setStrength(8);
const p2Cell = map.getCell(2, 1);
p2Cell.setStrength(6);
// Get neighbors of player 1 cell
const neighbors = map.getNeighbors(2, 2);
// Player 2 cell should be in neighbors
const hasP2Cell = neighbors.some(n => n.q === 2 && n.r === 1);
assert.strictEqual(hasP2Cell, true, 'Enemy cell should be adjacent');
// Valid target: not blocked and not own
// All 6 neighbors are valid because none of them are owned by player 1
// (only the center cell 2,2 is owned by player 1, but it's not a neighbor of itself)
const validTargets = neighbors.filter(n =>
n.type !== CELL_TYPES.BLOCKED && n.getOwner() !== 1
);
// Should have 6 valid targets (one is enemy at 2,1, rest are empty)
assert.strictEqual(validTargets.length, 6, 'Should have 6 valid targets (enemy + 5 empty)');
// Verify enemy cell is in valid targets
const enemyInTargets = validTargets.some(n => n.q === 2 && n.r === 1);
assert.strictEqual(enemyInTargets, true);
});
it('should allow capturing adjacent empty cells', () => {
const map = new HexMap(5);
// Clear map
map.cells.forEach(cell => {
cell.type = CELL_TYPES.EMPTY;
});
// Set up: player 1 at (2,2), empty at adjacent (2,3)
map.setOwner(2, 2, 1);
const p1Cell = map.getCell(2, 2);
p1Cell.setStrength(8);
const emptyCell = map.getCell(2, 3);
assert.strictEqual(emptyCell.type, CELL_TYPES.EMPTY);
// Get neighbors of player 1 cell
const neighbors = map.getNeighbors(2, 2);
// Empty cell should be in neighbors and be a valid target
const validTargets = neighbors.filter(n =>
n.type !== CELL_TYPES.BLOCKED && n.getOwner() !== 1
);
const hasEmptyCell = validTargets.some(n => n.q === 2 && n.r === 3);
assert.strictEqual(hasEmptyCell, true, 'Empty cell should be a valid target');
});
it('should NOT allow attacking own cells', () => {
const map = new HexMap(5);
// Clear map
map.cells.forEach(cell => {
cell.type = CELL_TYPES.EMPTY;
});
// Set up: player 1 owns two adjacent cells
map.setOwner(2, 2, 1);
map.setOwner(2, 3, 1);
const p1Cell1 = map.getCell(2, 2);
p1Cell1.setStrength(8);
const p1Cell2 = map.getCell(2, 3);
p1Cell2.setStrength(6);
// Get neighbors of first player 1 cell
const neighbors = map.getNeighbors(2, 2);
// Valid targets should NOT include own cells
const validTargets = neighbors.filter(n =>
n.type !== CELL_TYPES.BLOCKED && n.getOwner() !== 1
);
const hasOwnCell = validTargets.some(n => n.getOwner() === 1);
assert.strictEqual(hasOwnCell, false, 'Own cells should not be valid targets');
});
it('should NOT allow attacking through blocked cells', () => {
const map = new HexMap(5);
// Clear map
map.cells.forEach(cell => {
cell.type = CELL_TYPES.EMPTY;
});
// Set up: player 1 at (2,2), blocked at adjacent (2,3)
map.setOwner(2, 2, 1);
map.getCell(2, 3).type = CELL_TYPES.BLOCKED;
const p1Cell = map.getCell(2, 2);
p1Cell.setStrength(8);
// Get neighbors of player 1 cell
const neighbors = map.getNeighbors(2, 2);
// Blocked cell should NOT be in passable neighbors
const hasBlocked = neighbors.some(n => n.type === CELL_TYPES.BLOCKED);
assert.strictEqual(hasBlocked, false, 'Blocked cells should not be neighbors');
});
it('should correctly identify all valid targets from center position', () => {
const map = new HexMap(5);
// Clear map
map.cells.forEach(cell => {
cell.type = CELL_TYPES.EMPTY;
});
// Set up: player 1 at center (2,2)
map.setOwner(2, 2, 1);
const centerCell = map.getCell(2, 2);
centerCell.setStrength(8);
// Surround with different cell types
// (2,1) - empty
// (3,1) - enemy (player 2)
// (3,2) - blocked
// (2,3) - empty
// (1,3) - enemy (player 2)
// (1,2) - own (player 1)
map.setOwner(3, 1, 2);
map.getCell(3, 2).type = CELL_TYPES.BLOCKED;
map.setOwner(1, 3, 2);
map.setOwner(1, 2, 1);
const neighbors = map.getNeighbors(2, 2);
// Valid targets: empty cells and enemy cells, not blocked, not own
const validTargets = neighbors.filter(n =>
n.type !== CELL_TYPES.BLOCKED && n.getOwner() !== 1
);
// Should have 3 valid targets: (2,1) empty, (3,1) enemy, (2,3) empty
// (1,3) enemy, but need to check if it's actually a neighbor
assert.ok(validTargets.length >= 2, 'Should have at least 2 valid targets');
// Verify no own cells in valid targets
validTargets.forEach(t => {
assert.notStrictEqual(t.getOwner(), 1, 'Valid target should not be own cell');
});
// Verify no blocked cells in valid targets
validTargets.forEach(t => {
assert.notStrictEqual(t.type, CELL_TYPES.BLOCKED, 'Valid target should not be blocked');
});
});
});