Implement hexagonal map and isometric game UI
This commit is contained in:
122
QWEN.md
Normal file
122
QWEN.md
Normal 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
|
||||
35
README.md
35
README.md
@@ -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
17
package.json
Normal 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
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();
|
||||
});
|
||||
109
public/index.html
Normal file
109
public/index.html
Normal 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
173
public/map.js
Normal 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
378
public/styles.css
Normal 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
46
server.js
Normal 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
28
src/index.js
Normal 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
268
src/map.js
Normal 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
400
test/map.test.js
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user