Compare commits

...

19 Commits

Author SHA1 Message Date
sokol
18fe0332ae Fix: Default player count and initialize player type rows 2026-02-22 11:20:13 +03:00
sokol
ed27ca93ab Update documentation for all recent features 2026-02-22 11:08:02 +03:00
sokol
3439d04a55 Feature: Map size configuration 2026-02-22 11:04:11 +03:00
sokol
f3be577a32 Fix AI multiple moves and player colors 2026-02-21 21:25:22 +03:00
sokol
1d04a99bd7 Fix map clipping - reduce HEX_SIZE to 14 and properly center 2026-02-21 21:12:30 +03:00
sokol
cb97e167fb Fix 3 critical bugs 2026-02-21 21:06:36 +03:00
sokol
e427f1c68d Fix AI bot turn chain for multiple bots - prevent race conditions 2026-02-21 21:01:57 +03:00
sokol
64c81da166 Fix AI bot turn completion - remove isAIThinking block in endTurn() 2026-02-21 20:48:29 +03:00
sokol
064f3ae263 Fix map width - reduce HEX_SIZE to 16 for full map visibility 2026-02-21 20:44:55 +03:00
sokol
4cb5be95f8 Fix map rendering and AI bot bugs 2026-02-21 20:42:58 +03:00
sokol
afebcbca1a Add AI bot tests and update documentation 2026-02-21 20:34:42 +03:00
sokol
a0f6276e5d Remove src directory - simplify project structure 2026-02-21 20:15:00 +03:00
sokol
d71529b030 Update documentation for multi-player and AI features 2026-02-21 18:34:55 +03:00
sokol
62bd946509 Add multi-player support (2-4 players) and AI bots 2026-02-21 18:33:11 +03:00
sokol
dbe71dbda6 Fix attack and supply distribution rules 2026-02-21 18:28:32 +03:00
sokol
254287c124 Implement solid territory supply system 2026-02-21 17:59:41 +03:00
sokol
7035f0457b Remove Player 2 stats from UI - show only current player info 2026-02-21 17:48:38 +03:00
sokol
f19e178217 Remove unused Attack button - attack happens automatically on target click 2026-02-21 17:45:44 +03:00
sokol
f6855022dc Remove duplicate public/map.js - use single src/map.js for both Node.js and browser 2026-02-21 17:41:09 +03:00
12 changed files with 3135 additions and 698 deletions

459
QWEN.md
View File

@@ -2,121 +2,418 @@
## 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.
**hexo** is an educational game project a clone of [DiceWars](https://www.gamedesign.jp/games/dicewars/).
### Game Concept
A strategy dice game played on a hexagonal grid where players command armies of dice and battle to conquer territories.
A strategy dice game played on a hexagonal grid where players command armies of dice and battle to conquer territories. The last player standing wins.
### Core Game Mechanics
### Features
#### 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
- **2-4 Players**: Support for multiple human and/or AI players
- **Dynamic Map Sizes**: 10×10, 15×15, 20×20, 25×25 hexagonal grids
- **AI Bots**: Computer-controlled players with smart move selection and thinking delay
- Makes multiple moves per turn until no valid moves remain
- Prioritizes favorable attacks, then expansion, then reinforcement
- 1000ms thinking delay between moves for natural gameplay
- **Hexagonal Grid**: Proper axial coordinate system with 6-direction adjacency
- **Dice Combat**: Roll-based battle system with strength calculation
- **Solid Territory Supply**: Supply = size of largest connected territory
- **Flexible Setup**: Any combination of human/AI players (hotseat, full AI, or mixed)
- **88 Passing Tests**: Comprehensive test coverage for all game mechanics
## 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
├── README.md # Game rules and documentation (Russian)
├── QWEN.md # This file - project context
├── package.json # NPM configuration
├── server.js # Simple HTTP server for development
├── .gitignore # Git ignore rules
├── jsdom-pkg/ # Local jsdom library copy
├── public/ # All application files (no src/ directory)
├── index.html # Main HTML page with start screen and game UI
├── styles.css # Game UI styles
├── game.js # Main game logic and canvas rendering (GameUI class)
├── map.js # HexMap module (map generation, cells, supply)
└── ai-bot.js # AI bot player logic (AIBot class)
└── test/ # Unit tests (Node.js built-in test runner)
── map.test.js # Map and cell tests
└── ai-bot.test.js # AI bot tests
```
## 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
| Layer | Technology |
|-------|------------|
| **Runtime** | Node.js |
| **Frontend** | Vanilla JavaScript (ES Modules), HTML5 Canvas, CSS3 |
| **Backend** | Simple HTTP server (`server.js`) |
| **Testing** | Node.js built-in test runner (`node --test`) |
| **Dependencies** | jsdom (local copy in `jsdom-pkg/`) |
## 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
# Start web server (http://localhost:8080)
npm run serve
# Run the game (TBD)
# Run console demo
npm start
# Run tests (TBD)
# Run tests (88 tests)
npm test
```
## Development Conventions
> **TODO**: Coding standards and testing practices are not yet established.
- **ES Modules** for browser code (`import`/`export`)
- **CommonJS** for Node.js code (`require`/`module.exports`)
- **Dual exports** in `map.js` for both ES and CommonJS compatibility
- **Tests** use Node.js built-in `node:test` module
### 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
## Core Game Mechanics
### Map System
The map system supports **dynamic sizes** (10×10, 15×15, 20×20, 25×25):
```javascript
// Create map with custom size
const map = new HexMap(15); // 15x15 grid
// Default size is 20x20
const defaultMap = new HexMap();
```
**Key characteristics:**
- Hexagonal grid using **axial coordinates** (q, r)
- Each cell can be passable or blocked/impassable (~15% blocked)
- Each cell can hold up to **8 dice**
- Cells connect to **6 neighbors** (hexagonal adjacency)
- Neighbor calculation respects map boundaries
### Dice System
- Standard 6-sided dice
- **Unit strength calculation formula:**
```
F = (cnt - 1) × 6 + current_dice
```
Where:
- `cnt` = number of dice on the cell
- `current_dice` = top die value (1-6)
**Examples:**
| Dice Array | Calculation | Strength |
|------------|-------------|----------|
| `[4]` | (1-1)×6 + 4 | 4 |
| `[6]` | (1-1)×6 + 6 | 6 |
| `[6, 1]` | (2-1)×6 + 1 | 7 |
| `[6, 6, 2]` | (3-1)×6 + 2 | 14 |
| `[6, 6, 6, 6, 6, 6, 6, 6]` | (8-1)×6 + 6 | 48 (max) |
### Game Rules
1. **Setup**: Each player starts with strength 8 at their starting position
2. **Movement**: Can move if strength > 1
- Source cell left with 1, target receives strength-1
3. **Combat**: Both sides roll dice (1 to their strength)
- **Attacker wins**: Takes cell with `attack_roll - 1`, source becomes 1
- **Defender wins**: Attacker reduced to 1, defender keeps `defense_roll - attack_roll` (min 1)
4. **Supply**: After turn ends, player receives supply = largest connected territory size
- Distributed 1 by 1 to random non-max cells
- Max per cell: 48 (8 dice × 6)
5. **Victory**: Last player with cells on the map wins
### Player Configuration
| Player | Color | HEX Code | Starting Position |
|--------|-------|----------|-------------------|
| P1 | 🟢 Green | `#4ecca3` | (offset, offset) |
| P2 | 🔴 Red | `#e94560` | (size-1-offset, size-1-offset) |
| P3 | 🟡 Yellow | `#f9ed69` | (offset, size-1-offset) |
| P4 | 🔵 Cyan | `#00adb5` | (size-1-offset, offset) |
Where `offset = max(1, floor(mapSize / 10))`
Each player starts with **strength 8** at their starting position.
### Starting Positions by Map Size
| Map Size | Offset | P1 | P2 | P3 | P4 |
|----------|--------|----|----|----|----|
| 10×10 | 1 | (1,1) | (8,8) | (1,8) | (8,1) |
| 15×15 | 1 | (1,1) | (13,13) | (1,13) | (13,1) |
| 20×20 | 2 | (2,2) | (17,17) | (2,17) | (17,2) |
| 25×25 | 2 | (2,2) | (22,22) | (2,22) | (22,2) |
## AI Bot Logic
### Implementation Details (`ai-bot.js`)
The AI bot is implemented in the `AIBot` class with the following behavior:
#### Multiple Moves Per Turn
Unlike a simple single-move AI, this bot makes **multiple moves per turn**:
```javascript
async playTurn() {
let moveCount = 0;
const maxMovesPerTurn = 50; // Prevent infinite loops
const maxConsecutiveNoMoves = 3;
while (consecutiveNoMoves < maxConsecutiveNoMoves && moveCount < maxMovesPerTurn) {
const moves = this.findPossibleMoves(playerCells);
if (moves.length === 0) {
consecutiveNoMoves++;
continue;
}
consecutiveNoMoves = 0;
moves.sort((a, b) => this.movePriority(b) - this.movePriority(a));
await this.wait(this.thinkingTime); // 1000ms delay
this.executeMove(moves[0]);
moveCount++;
}
this.gameUI.endTurn();
}
```
#### Thinking Delay
- **Delay**: 1000ms between moves
- **Purpose**: Creates natural gameplay feel, allows human players to follow AI actions
#### Move Evaluation
The AI evaluates all possible moves using a priority system:
```javascript
movePriority(move) {
let priority = 0;
// Attack weak enemies (highest priority)
if (move.type === 'attack') {
if (move.attackStrength > move.defenseStrength) {
priority += 100; // Likely to win
priority += move.attackStrength - move.defenseStrength;
} else {
priority -= 50; // Risky attack
}
}
// Expand to empty cells (medium priority)
if (move.type === 'expand') {
priority += 50;
priority += move.attackStrength; // Stronger placement = better
}
// Prefer moves that create strong positions
priority += move.attackStrength * 0.5;
return priority;
}
```
#### Move Types and Priorities
| Type | Condition | Priority | Description |
|------|-----------|----------|-------------|
| **Attack (favorable)** | attack > defense | 100+ | Attack enemy with higher strength |
| **Expand** | empty cell | 50+ | Capture empty cells |
| **Attack (risky)** | attack ≤ defense | -50 | Attack enemy with equal/higher strength |
#### Turn Flow
1. Get all player cells
2. Find all possible moves (neighbors that are not own cells)
3. Sort moves by priority
4. Wait for thinking delay (1000ms)
5. Execute best move
6. Repeat until no moves available (max 50 moves per turn)
7. End turn
### Integration with Game UI
The AI bot integrates with the main game through:
- **`gameUI.selectedCell`** / **`gameUI.currentTarget`**: Set before executing moves
- **`gameUI.executeAttack()`**: Called to perform the actual attack
- **`gameUI.endTurn()`**: Called when AI has no more moves
- **`gameUI.isAIThinking`**: Flag to prevent user interaction during AI turn
## Key Implementation Areas
When development begins, focus on these components:
### 1. Map Generator (`map.js`)
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
```javascript
class HexMap {
constructor(size = 20) {
this.size = size;
this.cells = new Map(); // Key: "q,r", Value: HexCell
this.generate();
}
generate() {
for (let q = 0; q < this.size; q++) {
for (let r = 0; r < this.size; r++) {
const type = Math.random() < 0.15 ? CELL_TYPES.BLOCKED : CELL_TYPES.EMPTY;
this.cells.set(`${q},${r}`, new HexCell(q, r, type));
}
}
}
}
```
**Features:**
- Hexagonal grid creation with passable/impassable cells
- Cell ownership tracking
- Neighbor calculation (6 directions)
- Supply calculation (largest connected territory via BFS)
### 2. Dice Engine (`game.js`)
```javascript
// Strength calculation
getStrength() {
if (this.dice.length === 0) return 0;
const cnt = this.dice.length;
const currentDice = this.dice[this.dice.length - 1];
return (cnt - 1) * 6 + currentDice;
}
// Set strength (reconstruct dice array)
setStrength(targetStrength) {
if (targetStrength <= 0) {
this.dice = [];
return;
}
const cnt = Math.floor((targetStrength - 1) / 6) + 1;
const remainder = targetStrength - (cnt - 1) * 6;
this.dice = new Array(cnt - 1).fill(6);
if (remainder > 0) this.dice.push(remainder);
}
```
### 3. Combat System (`game.js`)
```javascript
executeAttack() {
const attackStrength = attacker.getStrength() - 1;
const defenseStrength = defender.getStrength();
const attackRoll = Math.floor(Math.random() * attackStrength) + 1;
const defenseRoll = Math.floor(Math.random() * defenseStrength) + 1;
if (attackRoll > defenseRoll) {
// Attacker wins
defender.setStrength(attackRoll - 1);
this.map.setOwner(defender.q, defender.r, this.currentPlayer);
} else {
// Defender wins
defender.setStrength(Math.max(1, defenseRoll - attackRoll));
}
attacker.setStrength(1);
}
```
### 4. Game State (`game.js`)
- **Player turns management**: Circular turn order (P1 → P2 → P3 → P4 → P1)
- **Unit positions tracking**: Via `HexMap` cell ownership
- **Victory conditions**: Last player standing (checked when player loses all cells)
- **AI turn handling**: Async/await pattern for sequential AI moves
### 5. UI/Rendering (`game.js`)
```javascript
class GameUI {
constructor() {
this.canvas = document.getElementById('game-canvas');
this.ctx = this.canvas.getContext('2d');
this.mapSize = 20; // Dynamic, configurable
this.playerCount = 2; // 2-4 players
this.playerTypes = {}; // {1: 'human', 2: 'ai', ...}
}
// Hex to pixel conversion for rendering
hexToPixel(q, r) {
const sqrt3 = Math.sqrt(3);
const x = this.offsetX + HEX_SIZE * sqrt3 * (q + r/2);
const y = this.offsetY + HEX_SIZE * 1.5 * r;
return { x, y };
}
}
```
**Features:**
- HTML5 Canvas rendering
- Hexagon drawing with proper coordinates
- Player color indicators
- Dice visualization (strength + die count dots)
- Battle log
### 6. AI Bot (`ai-bot.js`)
**Key features:**
- Move evaluation and prioritization
- Thinking delay for natural gameplay
- Multiple moves per turn
- Integration with game UI for move execution
- Boundary and blocked cell awareness
## Test Coverage
The project has **88 passing tests** covering:
### Map Tests (`map.test.js`)
| Category | Tests |
|----------|-------|
| **Dynamic Map Sizes** | 7 tests (10×10, 15×15, 20×20, 25×25) |
| **HexCell** | 6 tests (creation, strength, max strength, dice, passability) |
| **HexMap** | 9 tests (generation, neighbors, ownership, supply) |
| **Target Selection** | 5 tests (attack enemies, capture empty, block own cells) |
### AI Bot Tests (`ai-bot.test.js`)
| Category | Tests |
|----------|-------|
| **Instantiation** | 3 tests |
| **findPossibleMoves** | 8 tests |
| **movePriority** | 6 tests |
| **AI prefers attacking weak enemies** | 3 tests |
| **AI does not select moves with strength ≤ 1** | 4 tests |
| **AI respects map boundaries** | 4 tests |
| **executeMove** | 2 tests |
| **playTurn** | 2 tests |
| **Integration tests** | 3 tests |
| **Four AI bots stress test** | 1 test |
| **Different map sizes** | 8 tests |
### Running Tests
```bash
npm test
# Output:
# ✔ tests 88
# ✔ suites 27
# ✔ pass 88
# ✔ fail 0
```
## 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
- All game logic runs in the browser (no server-side game state)
- The project uses **no `src/` directory** — all source files are in `public/`

230
README.md
View File

@@ -1,37 +1,205 @@
# hexo
# HEXO
Это учебный проект. Игра, клон https://www.gamedesign.jp/games/dicewars/
Учебный проект. Игра, клон [DiceWars](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, расчитываемое в ходе игры
```bash
npm run serve # Запустить веб-сервер (http://localhost:8080)
npm test # Запустить тесты (88 тестов)
npm start # Консольная версия карты
```
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. Если все клетки игрока имеют максимальное колво силы, то ничего не добавляется.
## Экран запуска
При запуске игры открывается экран настройки:
1. **Выберите количество игроков**: 2, 3 или 4
2. **Выберите размер карты**:
- **Small (10×10)** — Быстрая игра на маленькой карте
- **Medium (15×15)** — Сбалансированный размер
- **Large (20×20)** — Стандартный размер (по умолчанию)
- **Extra Large (25×25)** — Большая карта для длительных игр
3. **Настройте тип каждого игрока**:
- **Human** — управление человеком (клики мышью)
- **AI Bot** — управление компьютером
4. Нажмите **Start Game** для начала игры
### Комбинации игроков
Можно создавать любые комбинации:
- Все игроки — люди (Hotseat)
- Все игроки — AI боты (наблюдение за игрой ИИ)
- Смешанный режим (например, P1-Human, P2-AI, P3-Human, P4-AI)
## Цвета игроков
| Игрок | Цвет | HEX |
|-------|------|-----|
| P1 | 🟢 Зелёный | `#4ecca3` |
| P2 | 🔴 Красный | `#e94560` |
| P3 | 🟡 Жёлтый | `#f9ed69` |
| P4 | 🔵 Бирюзовый | `#00adb5` |
## AI Bot
### Как работает
AI бот автоматически играет за выбранного игрока:
1. **Анализ поля** — бот оценивает все возможные ходы
2. **Приоритеты ходов**:
- 🎯 Атака слабого противника (высокий шанс победы) — приоритет 100+
- 📈 Захват пустых клеток (расширение территории) — приоритет 50+
- 💪 Укрепление позиций (перемещение к сильным клеткам)
3. **Задержка мышления** — 1000 мс перед каждым ходом для естественности геймплея
4. **Несколько ходов за turn** — AI делает все возможные ходы подряд, затем завершает ход
### Индикаторы AI
- В карточке игрока отображается метка **(AI)**
- Во время хода AI в панели действий показано: *"AI is thinking..."*
- Кнопки управления отключены во время хода AI
### Логика принятия решений
```javascript
movePriority(move) {
let priority = 0;
// Атака слабого врага (высший приоритет)
if (move.type === 'attack') {
if (move.attackStrength > move.defenseStrength) {
priority += 100; // Высокий шанс победы
priority += move.attackStrength - move.defenseStrength;
} else {
priority -= 50; // Рискованная атака
}
}
// Расширение на пустые клетки (средний приоритет)
if (move.type === 'expand') {
priority += 50;
priority += move.attackStrength;
}
// Бонус за сильную позицию
priority += move.attackStrength * 0.5;
return priority;
}
```
## Правила игры
### 1. Карта
- Гексагональная сетка **10×10**, **15×15**, **20×20** или **25×25** ячеек
- Каждая ячейка может быть:
- **Проходима** (пустая или принадлежит игроку)
- **Непроходима** (заблокирована, серый цвет, ~15% карты)
### 2. Игровые единицы
- **Кубик**: 6-гранный, значения 1-6
- На поле может быть до **8 кубиков** на клетку
- **Сила юнита**: `F = (cnt-1) × 6 + current_dice`
- `cnt` — количество кубиков
- `current_dice` — значение верхнего кубика
**Примеры расчёта силы:**
| Кубики | Сила |
|--------|------|
| [4] | 4 |
| [6] | 6 |
| [6, 1] | 7 |
| [6, 6, 2] | 14 |
| [6, 6, 6, 6, 6, 6, 6, 6] | 48 (максимум) |
### 3. Ход игры
#### Перемещение/Атака
- Можно ходить, если **сила > 1**
- На целевую клетку переходит `сила-1`, на исходной остаётся **1**
- При атаке оба игрока бросают кости:
- **Атакующий**: `attack_roll = rnd(1..сила-1)`
- **Защищающийся**: `defense_roll = rnd(1..сила)`
#### Результат боя
| Условие | Результат |
|---------|-----------|
| `attack_roll > defense_roll` | Атакующий побеждает, занимает клетку с `attack_roll-1`. На исходной клетке остаётся **1** |
| `attack_roll <= defense_roll` | Атака отбита. Атакующий уменьшается до **1**. Защитник остаётся с `defense_roll - attack_roll` (мин. **1**) |
#### Снабжение
После хода игрок получает снабжение:
- **Снабжение = размер наибольшей непрерывной территории**
- **Непрерывная территория** — связанные между собой клетки игрока
- Если территория разорвана врагом — считается только наибольший кусок
- Снабжение распределяется по **1 единице** случайным клеткам (не максимальным)
- **Максимум на клетке**: 48 (8 кубиков × 6)
### 4. Победа
**Последний игрок**, оставшийся с клетками на карте, побеждает.
## Управление
| Действие | Управление |
|----------|------------|
| Выбрать клетку | Клик на свою клетку с кубиками |
| Атаковать | Клик на соседнюю вражескую/пустую клетку |
| Отменить выбор | Кнопка **Cancel** или клик на ту же клетку |
| Завершить ход | Кнопка **End Turn** |
| Вернуться в меню | Кнопка **Main Menu** |
### Индикаторы на экране
- **Выделенная клетка** — красная подсветка
- **Доступные цели** — зелёная подсветка соседних клеток
- **Лог боёв** — правая панель с историей действий
- **Инфо о клетке** — сила и количество кубиков выбранной клетки
## Настройки игры
Игра поддерживает:
- **2-4 игрока** (любая комбинация людей и AI)
- **4 размера карты**: 10×10, 15×15, 20×20, 25×25
- **Случайная генерация карты** при каждом запуске
- **Случайные стартовые позиции** для игроков
## Стартовые позиции
Игроки размещаются в углах карты (позиции масштабируются с размером карты):
| Игрок | Позиция | Описание |
|-------|---------|----------|
| P1 | (offset, offset) | Верхний-левый угол |
| P2 | (size-1-offset, size-1-offset) | Нижний-правый угол |
| P3 | (offset, size-1-offset) | Нижний-левый угол |
| P4 | (size-1-offset, offset) | Верхний-правый угол |
`offset = max(1, floor(size / 10))`
Каждый игрок начинает с **силой 8** на своей стартовой позиции.
## Тестирование
Проект покрыт **88 тестами**, проверяющими:
- **Динамические размеры карт** (10×10, 15×15, 20×20, 25×25)
- **HexCell**: создание, расчёт силы, максимальная сила, добавление кубиков
- **HexMap**: генерация, соседи, снабжение, владение
- **Логика выбора целей**: атака врагов, захват пустых клеток, блокировка своих клеток
- **AIBot**: поиск ходов, приоритеты, выполнение ходов, завершение хода
- **Интеграционные тесты**: 4 AI бота одновременно, разные размеры карт
```bash
npm test
```
Все тесты проходят успешно ✅

View File

@@ -2,9 +2,9 @@
"name": "hexo",
"version": "0.1.0",
"description": "Educational dice game - DiceWars clone",
"main": "src/index.js",
"main": "public/game.js",
"scripts": {
"start": "node src/index.js",
"start": "node server.js",
"serve": "node server.js",
"test": "node --test"
},

193
public/ai-bot.js Normal file
View File

@@ -0,0 +1,193 @@
/**
* AI Bot for Hexo game
* Controls computer-controlled players
*/
import { CELL_TYPES } from './map.js';
export class AIBot {
constructor(playerId, map, gameUI) {
this.playerId = playerId;
this.map = map;
this.gameUI = gameUI;
this.thinkingTime = 1000; // ms delay between moves
}
/**
* Execute AI turn - makes MULTIPLE moves until no valid moves remain, then ends turn
* According to game rules, ANY cell with strength > 1 can move if it has valid targets
*/
async playTurn() {
console.log(`[AI-BOT P${this.playerId}] === Turn started ===`);
console.log(`[AI-BOT P${this.playerId}] Thinking...`);
try {
// Get all player cells
let playerCells = this.map.getPlayerCells(this.playerId);
console.log(`[AI-BOT P${this.playerId}] Has ${playerCells.length} cells`);
if (playerCells.length === 0) {
console.log(`[AI-BOT P${this.playerId}] No cells, ending turn`);
await this.wait(this.thinkingTime);
this.gameUI.endTurn();
return;
}
let moveCount = 0;
let consecutiveNoMoves = 0;
const maxConsecutiveNoMoves = 3; // Prevent infinite loops
const maxMovesPerTurn = 50; // Maximum moves per turn to prevent infinite loops in tests
// Loop: keep finding and executing moves until no more valid moves exist
while (consecutiveNoMoves < maxConsecutiveNoMoves && moveCount < maxMovesPerTurn) {
// Re-fetch player cells each iteration (board state changes)
playerCells = this.map.getPlayerCells(this.playerId);
if (playerCells.length === 0) {
console.log(`[AI-BOT P${this.playerId}] No cells remaining, ending turn`);
break;
}
// Find all possible moves from current board state
const moves = this.findPossibleMoves(playerCells);
console.log(`[AI-BOT P${this.playerId}] Found ${moves.length} possible moves (move #${moveCount + 1})`);
if (moves.length === 0) {
// No moves available this iteration
consecutiveNoMoves++;
console.log(`[AI-BOT P${this.playerId}] No valid moves this iteration (${consecutiveNoMoves}/${maxConsecutiveNoMoves})`);
if (consecutiveNoMoves >= maxConsecutiveNoMoves) {
console.log(`[AI-BOT P${this.playerId}] No more valid moves after ${moveCount} moves, ending turn`);
break;
}
// Small delay before re-checking
await this.wait(200);
continue;
}
// Reset counter when we find valid moves
consecutiveNoMoves = 0;
// Check if we've reached max moves limit
if (moveCount >= maxMovesPerTurn) {
console.log(`[AI-BOT P${this.playerId}] Reached max moves limit (${maxMovesPerTurn}), ending turn`);
break;
}
// Sort moves by priority (attack > expand > reinforce)
moves.sort((a, b) => this.movePriority(b) - this.movePriority(a));
// Execute best move
const bestMove = moves[0];
console.log(`[AI-BOT P${this.playerId}] Selected move: from (${bestMove.from.q},${bestMove.from.r}) to (${bestMove.to.q},${bestMove.to.r}), type=${bestMove.type}, attackStr=${bestMove.attackStrength}, defStr=${bestMove.defenseStrength}`);
// Wait for thinking time between moves
await this.wait(this.thinkingTime);
// Execute the move
this.gameUI.selectedCell = bestMove.from;
this.gameUI.currentTarget = bestMove.to;
this.gameUI.executeAttack();
moveCount++;
console.log(`[AI-BOT P${this.playerId}] Move #${moveCount} executed`);
// Clear selection after move (if method exists)
if (typeof this.gameUI.cancelSelection === 'function') {
this.gameUI.cancelSelection();
}
}
// End turn after all moves are executed
console.log(`[AI-BOT P${this.playerId}] Total moves this turn: ${moveCount}`);
console.log(`[AI-BOT P${this.playerId}] Calling endTurn()`);
this.gameUI.endTurn();
console.log(`[AI-BOT P${this.playerId}] === Turn completed ===`);
} catch (error) {
console.error(`[AI-BOT P${this.playerId}] Error during turn:`, error);
// Still end turn on error to prevent game from getting stuck
this.gameUI.endTurn();
throw error; // Re-throw so caller knows there was an error
}
}
/**
* Find all possible moves for AI
*/
findPossibleMoves(playerCells) {
const moves = [];
for (const cell of playerCells) {
if (cell.getStrength() <= 1) continue;
const neighbors = this.map.getNeighbors(cell.q, cell.r);
for (const neighbor of neighbors) {
// Skip own cells
if (neighbor.getOwner() === this.playerId) continue;
const attackStrength = cell.getStrength() - 1;
const defenseStrength = neighbor.getStrength();
moves.push({
from: cell,
to: neighbor,
attackStrength,
defenseStrength,
type: neighbor.type === CELL_TYPES.EMPTY ? 'expand' : 'attack'
});
}
}
return moves;
}
/**
* Calculate move priority (higher = better)
*/
movePriority(move) {
let priority = 0;
// Prefer attacks on weak enemies
if (move.type === 'attack') {
if (move.attackStrength > move.defenseStrength) {
priority += 100; // Likely to win
priority += move.attackStrength - move.defenseStrength;
} else {
priority -= 50; // Risky attack
}
}
// Prefer expanding to empty cells
if (move.type === 'expand') {
priority += 50;
priority += move.attackStrength; // Stronger placement = better
}
// Prefer moves that create strong positions
priority += move.attackStrength * 0.5;
return priority;
}
/**
* Execute a move
*/
executeMove(move) {
this.gameUI.selectedCell = move.from;
this.gameUI.currentTarget = move.to;
this.gameUI.executeAttack();
}
/**
* Wait for specified time
*/
wait(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
}

View File

@@ -1,20 +1,32 @@
/**
* Hexo Game UI - Canvas Rendering and Interactions
* Supports 2-4 players with AI bots
*/
import { HexMap, CELL_TYPES } from './map.js';
import { AIBot } from './ai-bot.js';
// Game constants
const HEX_SIZE = 18;
const MAP_SIZE = 20;
const HEX_SIZE = 14;
const DEFAULT_MAP_SIZE = 20;
const ANIMATION_DURATION = 300;
// Player colors
const PLAYER_COLORS = {
1: '#4ecca3', // teal/green
2: '#e94560', // red/pink
3: '#f9ed69', // yellow
4: '#00adb5' // cyan/blue
};
// Colors
const COLORS = {
blocked: '#2a2a4a',
empty: '#3a5a6a',
player1: '#4ecca3',
player2: '#e94560',
player3: '#f9ed69',
player4: '#00adb5',
highlight: 'rgba(255, 255, 255, 0.3)',
selected: 'rgba(233, 69, 96, 0.6)',
target: 'rgba(78, 204, 163, 0.5)',
@@ -33,11 +45,17 @@ class GameUI {
this.canvas = document.getElementById('game-canvas');
this.ctx = this.canvas.getContext('2d');
this.map = null;
this.mapSize = DEFAULT_MAP_SIZE;
this.selectedCell = null;
this.currentTarget = null;
this.currentPlayer = 1;
this.gamePhase = 'movement'; // movement, supply, gameover
this.playerCount = 2;
this.playerTypes = {}; // 1: 'human', 2: 'ai', etc.
this.aiBots = {};
this.gamePhase = 'movement';
this.hasMoved = false;
this.isAIThinking = false;
this.isProcessingTurn = false; // Prevent re-entrancy during turn processing
this.offsetX = 0;
this.offsetY = 0;
@@ -46,48 +64,150 @@ class GameUI {
}
init() {
this.setupStartScreen();
this.setupEventListeners();
}
setupStartScreen() {
const playerCountSelect = document.getElementById('player-count');
const playerTypeRows = document.querySelectorAll('.player-type-row');
// Initialize player type rows visibility based on default selection
const initialCount = parseInt(playerCountSelect.value);
playerTypeRows.forEach((row, index) => {
row.style.display = index < initialCount ? 'flex' : 'none';
});
playerCountSelect.addEventListener('change', (e) => {
const count = parseInt(e.target.value);
playerTypeRows.forEach((row, index) => {
row.style.display = index < count ? 'flex' : 'none';
});
});
document.getElementById('start-game-btn').addEventListener('click', () => {
this.startGame();
});
document.getElementById('back-menu-btn').addEventListener('click', () => {
this.showStartScreen();
});
}
showStartScreen() {
document.getElementById('start-screen').style.display = 'flex';
document.getElementById('game-screen').style.display = 'none';
}
startGame() {
// Get settings
this.playerCount = parseInt(document.getElementById('player-count').value);
this.mapSize = parseInt(document.getElementById('map-size').value);
for (let i = 1; i <= this.playerCount; i++) {
const typeSelect = document.getElementById(`player${i}-type`);
this.playerTypes[i] = typeSelect.value;
}
// Show game screen
document.getElementById('start-screen').style.display = 'none';
document.getElementById('game-screen').style.display = 'flex';
this.newGame();
console.log('Game initialized');
}
newGame() {
this.map = new HexMap(MAP_SIZE);
console.log('Map created, cells:', this.map.cells.size);
this.map = new HexMap(this.mapSize);
this.selectedCell = null;
this.currentTarget = null;
this.currentPlayer = 1;
this.gamePhase = 'movement';
this.hasMoved = false;
this.isAIThinking = false;
this.isProcessingTurn = false;
// Initialize starting positions
// Initialize AI bots AFTER map is created
this.aiBots = {};
for (let i = 1; i <= this.playerCount; i++) {
if (this.playerTypes[i] === 'ai') {
this.aiBots[i] = new AIBot(i, this.map, this);
}
}
// Initialize starting positions for all players
this.initializePlayers();
this.centerMap();
this.render();
this.createPlayerCards();
this.updateUI();
this.log('New game started! Player 1\'s turn.');
this.log(`New game started with ${this.playerCount} players!`);
console.log(`[GAME] New game started with ${this.playerCount} players`);
// Start first player's turn (AI if needed)
this.checkAndRunAITurn();
}
createPlayerCards() {
const container = document.getElementById('players-container');
container.innerHTML = '';
for (let i = 1; i <= this.playerCount; i++) {
const card = document.createElement('div');
card.className = `player-card player-${i}${i === 1 ? ' active' : ''}${this.playerTypes[i] === 'ai' ? ' ai-controlled' : ''}`;
card.id = `player${i}-card`;
card.innerHTML = `
<h3>Player ${i}${this.playerTypes[i] === 'ai' ? ' (AI)' : ''}</h3>
<div class="player-stats">
<div class="stat">
<span class="stat-label">Cells:</span>
<span class="stat-value" id="player${i}-cells">0</span>
</div>
<div class="stat">
<span class="stat-label">Supply:</span>
<span class="stat-value" id="player${i}-supply">0</span>
</div>
<div class="stat">
<span class="stat-label">Strength:</span>
<span class="stat-value" id="player${i}-strength">0</span>
</div>
</div>
`;
container.appendChild(card);
}
}
initializePlayers() {
// Get random empty cells for each player
const emptyCells = this.map.getEmptyCells();
// Place starting units for each player at fixed positions that are always passable
// Use corners of the map to ensure they don't overlap
// Positions scale with map size
const offset = Math.max(1, Math.floor(this.mapSize / 10));
const positions = [
{ q: offset, r: offset }, // Player 1 - top-left
{ q: this.mapSize - 1 - offset, r: this.mapSize - 1 - offset }, // Player 2 - bottom-right
{ q: offset, r: this.mapSize - 1 - offset }, // Player 3 - bottom-left
{ q: this.mapSize - 1 - offset, r: offset } // Player 4 - top-right
];
// Shuffle and pick starting positions
const shuffled = emptyCells.sort(() => Math.random() - 0.5);
for (let i = 1; i <= this.playerCount; i++) {
const pos = positions[i - 1];
if (!pos) continue;
// 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
// Force the cell to be passable and set ownership
const cell = this.map.getCell(pos.q, pos.r);
if (cell) {
// Make sure cell is passable
if (cell.type === CELL_TYPES.BLOCKED) {
cell.type = CELL_TYPES.EMPTY;
}
this.map.setOwner(pos.q, pos.r, i);
cell.setStrength(8);
console.log(`[GAME] Player ${i} placed at (${pos.q}, ${pos.r})`);
} else {
console.error(`[GAME] Failed to place Player ${i} at (${pos.q}, ${pos.r})`);
}
// 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);
}
}
@@ -96,12 +216,28 @@ class GameUI {
const canvasHeight = this.canvas.height;
const sqrt3 = Math.sqrt(3);
// Map dimensions for pointy-top hex grid
const mapWidth = HEX_SIZE * sqrt3 * (MAP_SIZE + MAP_SIZE/2);
const mapHeight = HEX_SIZE * 1.5 * (MAP_SIZE - 1) + HEX_SIZE * 2;
this.offsetX = (canvasWidth - mapWidth) / 2 + HEX_SIZE * sqrt3;
this.offsetY = (canvasHeight - mapHeight) / 2 + HEX_SIZE;
// Calculate actual map bounds based on dynamic map size
// For pointy-top hex grid:
// - Width spans from q=0 to q=mapSize-1, with r offset
// - Rightmost point: (q=mapSize-1, r=mapSize-1) at x = HEX_SIZE * sqrt3 * (mapSize - 1 + (mapSize - 1) / 2)
// - Height spans from r=0 to r=mapSize-1
// - Bottommost point: (any q, r=mapSize-1) at y = HEX_SIZE * 1.5 * (mapSize - 1)
const mapWidth = HEX_SIZE * sqrt3 * ((this.mapSize - 1) + (this.mapSize - 1) / 2);
const mapHeight = HEX_SIZE * 1.5 * (this.mapSize - 1);
// Add padding for hex radius (hex extends beyond center point)
const hexRadius = HEX_SIZE;
const totalWidth = mapWidth + 2 * hexRadius;
const totalHeight = mapHeight + 2 * hexRadius;
// Center the map on canvas
this.offsetX = (canvasWidth - totalWidth) / 2 + hexRadius;
this.offsetY = (canvasHeight - totalHeight) / 2 + hexRadius;
console.log(`[GAME] Map: canvas=${canvasWidth}x${canvasHeight}, map=${mapWidth.toFixed(0)}x${mapHeight.toFixed(0)}, total=${totalWidth.toFixed(0)}x${totalHeight.toFixed(0)}`);
console.log(`[GAME] Offset: x=${this.offsetX.toFixed(1)}, y=${this.offsetY.toFixed(1)}`);
}
setupEventListeners() {
@@ -109,22 +245,13 @@ class GameUI {
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) {
// Pointy-top hex grid with proper adjacency
// For pointy-top: width = sqrt(3) * size, height = 2 * size
// Horizontal spacing = width = sqrt(3) * size
// Vertical spacing = 3/4 * height = 1.5 * size
const sqrt3 = Math.sqrt(3);
// Convert axial to pixel coordinates for pointy-top hex
const x = this.offsetX + HEX_SIZE * sqrt3 * (q + r/2);
const y = this.offsetY + HEX_SIZE * 1.5 * r;
return { x, y };
}
@@ -139,22 +266,12 @@ class GameUI {
const qi = Math.round(q);
const ri = Math.round(r);
if (qi >= 0 && qi < MAP_SIZE && ri >= 0 && ri < MAP_SIZE) {
if (qi >= 0 && qi < this.mapSize && ri >= 0 && ri < this.mapSize) {
return { q: qi, r: ri };
}
return null;
}
/**
* Get neighboring cells for pointy-top hex grid
* Directions for pointy-top hex in axial coordinates:
* - north-east: (+1, -1)
* - north-west: (0, -1)
* - west: (-1, 0)
* - south-west: (-1, +1)
* - south-east: (0, +1)
* - east: (+1, 0)
*/
getValidTargets(q, r) {
const directions = [
[+1, -1], // north-east
@@ -169,7 +286,7 @@ class GameUI {
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) {
if (nq >= 0 && nq < this.mapSize && nr >= 0 && nr < this.mapSize) {
const cell = this.map.getCell(nq, nr);
if (cell && cell.isPassable()) {
targets.push(cell);
@@ -181,14 +298,11 @@ class GameUI {
drawHex(q, r, fillStyle, strokeStyle = COLORS.stroke, lineWidth = 2) {
const { x, y } = this.hexToPixel(q, r);
const size = HEX_SIZE * 0.98;
// Draw pointy-top hexagon - size matches grid spacing
const size = HEX_SIZE * 0.98; // Almost full size for tight fit
// Pointy-top hexagon vertices (flat sides left/right)
const vertices = [];
for (let i = 0; i < 6; i++) {
const angle = Math.PI / 6 + (Math.PI / 3) * i; // Start at 30 degrees
const angle = Math.PI / 6 + (Math.PI / 3) * i;
vertices.push({
x: x + size * Math.cos(angle),
y: y + size * Math.sin(angle)
@@ -216,14 +330,12 @@ class GameUI {
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++) {
@@ -239,9 +351,7 @@ class GameUI {
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 ownerColor = PLAYER_COLORS[cell.getOwner()] || COLORS.player1;
const size = HEX_SIZE * 0.98 - 3;
const vertices = [];
@@ -265,15 +375,13 @@ class GameUI {
}
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++) {
for (let r = 0; r < this.mapSize; r++) {
for (let q = 0; q < this.mapSize; q++) {
const cell = this.map.getCell(q, r);
let color;
@@ -283,11 +391,14 @@ class GameUI {
color = COLORS.player1;
} else if (cell.type === CELL_TYPES.PLAYER2) {
color = COLORS.player2;
} else if (cell.type === CELL_TYPES.PLAYER3) {
color = COLORS.player3;
} else if (cell.type === CELL_TYPES.PLAYER4) {
color = COLORS.player4;
} else {
color = COLORS.empty;
}
// Apply transparency for non-owned cells
if (!cell.isOwned()) {
color = this.hexToRgba(color, 0.6);
}
@@ -298,7 +409,6 @@ class GameUI {
}
}
// Draw selection highlight
if (this.selectedCell) {
this.drawHex(
this.selectedCell.q,
@@ -308,7 +418,6 @@ class GameUI {
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) {
@@ -316,7 +425,7 @@ class GameUI {
target.q,
target.r,
COLORS.target,
COLORS.player1,
PLAYER_COLORS[this.currentPlayer] || COLORS.player1,
2
);
}
@@ -333,6 +442,7 @@ class GameUI {
handleClick(e) {
if (this.gamePhase !== 'movement') return;
if (this.playerTypes[this.currentPlayer] === 'ai') return;
const rect = this.canvas.getBoundingClientRect();
const x = e.clientX - rect.left;
@@ -344,29 +454,22 @@ class GameUI {
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();
@@ -385,7 +488,7 @@ class GameUI {
}
handleMouseMove(e) {
// Could add hover effects here
// Hover effects could be added here
}
executeAttack() {
@@ -396,38 +499,34 @@ class GameUI {
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}`);
this.log(`P${this.currentPlayer} 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');
this.log(`Victory! Captured 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');
attacker.setStrength(1);
this.log(`Attack repelled! Defender has ${Math.max(1, remainingDefense)}`, '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.log(`Captured empty cell with strength ${attackStrength}`);
}
}
@@ -444,45 +543,101 @@ class GameUI {
this.render();
}
endTurn() {
if (this.gamePhase !== 'movement') return;
async endTurn() {
if (this.gamePhase !== 'movement') {
console.log(`[GAME] endTurn() called but gamePhase is '${this.gamePhase}', ignoring`);
return;
}
// Prevent re-entrancy - only one turn processing at a time
if (this.isProcessingTurn) {
console.log(`[GAME] endTurn() called but isProcessingTurn is true, ignoring`);
return;
}
this.isProcessingTurn = true;
console.log(`[GAME] endTurn() - Player ${this.currentPlayer} ending turn`);
try {
// Apply supply
const supply = this.map.calculateSupply(this.currentPlayer);
this.distributeSupply(supply);
this.log(`Player ${this.currentPlayer} received ${supply} supply`);
console.log(`[GAME] Player ${this.currentPlayer} received ${supply} supply`);
// Switch player
this.currentPlayer = this.currentPlayer === 1 ? 2 : 1;
// Next player
const previousPlayer = this.currentPlayer;
this.currentPlayer = (this.currentPlayer % this.playerCount) + 1;
this.hasMoved = false;
console.log(`[GAME] Turn transition: P${previousPlayer} -> P${this.currentPlayer}`);
this.cancelSelection();
this.updateUI();
this.render();
this.log(`Player ${this.currentPlayer}'s turn`);
console.log(`[GAME] Player ${this.currentPlayer}'s turn started (${this.playerTypes[this.currentPlayer]})`);
// Reset isProcessingTurn BEFORE awaiting AI turn
// This allows the next AI's endTurn() call to proceed
this.isProcessingTurn = false;
// Check if next player is AI and await completion
await this.checkAndRunAITurn();
} catch (error) {
console.error(`[GAME] Error in endTurn():`, error);
this.log(`Error during turn transition: ${error.message}`, 'error');
this.isProcessingTurn = false;
}
}
/**
* Check if current player is AI and run their turn
* @returns {Promise<boolean>} - true if AI turn was run, false if human turn
*/
async checkAndRunAITurn() {
if (this.playerTypes[this.currentPlayer] === 'ai' && this.aiBots[this.currentPlayer]) {
console.log(`[AI] Player ${this.currentPlayer} is AI, starting turn`);
this.isAIThinking = true;
this.updateUI();
try {
// Run AI turn and wait for it to complete
await this.aiBots[this.currentPlayer].playTurn();
console.log(`[AI] Player ${this.currentPlayer} AI turn completed`);
return true;
} catch (error) {
console.error(`[AI] Error during Player ${this.currentPlayer} AI turn:`, error);
this.log(`AI error: ${error.message}`, 'error');
this.isAIThinking = false;
this.updateUI();
// Still advance to next player even on error
return true;
} finally {
// Always reset the flag when AI turn completes (success or error)
this.isAIThinking = false;
console.log(`[AI] Player ${this.currentPlayer} isAIThinking reset to false`);
}
}
console.log(`[AI] Player ${this.currentPlayer} is human, no AI turn needed`);
return false;
}
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;
randomCell.setStrength(currentStrength + 1);
remainingSupply--;
}
if (randomCell.isMaxStrength()) {
@@ -492,70 +647,64 @@ class GameUI {
}
updateUI() {
// Update player stats
const p1Cells = this.map.getPlayerCells(1);
const p2Cells = this.map.getPlayerCells(2);
// Update all player cards
for (let i = 1; i <= this.playerCount; i++) {
const playerCells = this.map.getPlayerCells(i);
const playerStrength = playerCells.reduce((sum, c) => sum + c.getStrength(), 0);
const playerSupply = this.map.calculateSupply(i);
const p1Strength = p1Cells.reduce((sum, c) => sum + c.getStrength(), 0);
const p2Strength = p2Cells.reduce((sum, c) => sum + c.getStrength(), 0);
document.getElementById(`player${i}-cells`).textContent = playerCells.length;
document.getElementById(`player${i}-supply`).textContent = playerSupply;
document.getElementById(`player${i}-strength`).textContent = playerStrength;
document.getElementById('player1-cells').textContent = p1Cells.length;
document.getElementById('player1-supply').textContent = this.map.calculateSupply(1);
document.getElementById('player1-strength').textContent = p1Strength;
const card = document.getElementById(`player${i}-card`);
card.classList.toggle('active', i === this.currentPlayer);
}
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
// Update map size display
document.getElementById('map-size-display').textContent = `${this.mapSize}x${this.mapSize}`;
const instruction = document.getElementById('action-instruction');
if (this.selectedCell) {
instruction.textContent = `Select target to attack (strength: ${this.selectedCell.getStrength()})`;
instruction.textContent = `Select target (strength: ${this.selectedCell.getStrength()})`;
} else if (this.playerTypes[this.currentPlayer] === 'ai') {
instruction.textContent = 'AI is thinking...';
} else {
instruction.textContent = 'Select a cell with dice to move';
}
// Update buttons
const attackBtn = document.getElementById('attack-btn');
const cellInfo = document.getElementById('selected-cell-info');
cellInfo.style.display = 'block';
if (this.selectedCell) {
document.getElementById('cell-strength').textContent = this.selectedCell.getStrength();
document.getElementById('cell-dice').textContent = this.selectedCell.dice.length;
} else {
document.getElementById('cell-strength').textContent = '-';
document.getElementById('cell-dice').textContent = '-';
}
const cancelBtn = document.getElementById('cancel-btn');
const endTurnBtn = document.getElementById('end-turn-btn');
attackBtn.disabled = !this.selectedCell;
cancelBtn.disabled = !this.selectedCell;
endTurnBtn.disabled = !this.hasMoved;
cancelBtn.disabled = !this.selectedCell || this.playerTypes[this.currentPlayer] === 'ai';
endTurnBtn.disabled = this.playerTypes[this.currentPlayer] === 'ai';
}
log(message, type = '') {
const logContainer = document.getElementById('battle-log');
const entry = document.createElement('div');
entry.className = `log-entry ${type}`;
entry.textContent = `[Turn ${this.currentPlayer}] ${message}`;
entry.textContent = `[P${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

View File

@@ -8,6 +8,75 @@
</head>
<body>
<div class="game-container">
<!-- Start Screen -->
<div id="start-screen" class="start-screen">
<div class="start-panel">
<h1>HEXO</h1>
<p class="subtitle">DiceWars Clone</p>
<div class="setup-section">
<h3>Game Setup</h3>
<div class="setup-group">
<label for="player-count">Total Players:</label>
<select id="player-count">
<option value="2" selected>2 Players</option>
<option value="3">3 Players</option>
<option value="4">4 Players</option>
</select>
</div>
<div class="setup-group">
<label for="map-size">Map Size:</label>
<select id="map-size">
<option value="10">Small (10x10)</option>
<option value="15">Medium (15x15)</option>
<option value="20" selected>Large (20x20)</option>
<option value="25">Extra Large (25x25)</option>
</select>
</div>
<div class="setup-group">
<label>Player Types:</label>
<div id="player-types">
<div class="player-type-row">
<span>Player 1:</span>
<select id="player1-type">
<option value="human">Human</option>
<option value="ai">AI Bot</option>
</select>
</div>
<div class="player-type-row">
<span>Player 2:</span>
<select id="player2-type">
<option value="human">Human</option>
<option value="ai" selected>AI Bot</option>
</select>
</div>
<div class="player-type-row" style="display:none;">
<span>Player 3:</span>
<select id="player3-type">
<option value="human">Human</option>
<option value="ai">AI Bot</option>
</select>
</div>
<div class="player-type-row" style="display:none;">
<span>Player 4:</span>
<select id="player4-type">
<option value="human">Human</option>
<option value="ai">AI Bot</option>
</select>
</div>
</div>
</div>
<button class="btn btn-primary btn-large" id="start-game-btn">Start Game</button>
</div>
</div>
</div>
<!-- Game Screen -->
<div id="game-screen" class="game-screen" style="display:none;">
<!-- Header -->
<header class="game-header">
<h1>HEXO</h1>
@@ -18,40 +87,8 @@
<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 id="players-container">
<!-- Player cards will be inserted here -->
</div>
<div class="game-info">
@@ -64,10 +101,14 @@
<span>Phase:</span>
<span id="game-phase">Movement</span>
</div>
<div class="info-item">
<span>Map Size:</span>
<span id="map-size-display">20x20</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>
<button class="btn btn-secondary" id="back-menu-btn">Main Menu</button>
</aside>
<!-- Canvas -->
@@ -83,9 +124,20 @@
<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>
<button class="btn btn-action" id="cancel-btn" style="width:100%" disabled>Cancel</button>
</div>
<div class="selected-cell-info" id="selected-cell-info">
<h3>Selected Cell</h3>
<div class="cell-stats">
<div class="stat">
<span class="stat-label">Strength:</span>
<span class="stat-value" id="cell-strength">-</span>
</div>
<div class="stat">
<span class="stat-label">Dice:</span>
<span class="stat-value" id="cell-dice">-</span>
</div>
</div>
</div>
@@ -103,6 +155,7 @@
<p>Click on your cell with dice, then click adjacent cell to attack</p>
</footer>
</div>
</div>
<script type="module" src="game.js"></script>
</body>

View File

@@ -1,36 +1,55 @@
/**
* Hexagonal grid map for the DiceWars game.
* Browser version (ES Module)
*
* 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,
BLOCKED: 1,
PLAYER1: 2,
PLAYER2: 3,
EMPTY: 0, // Passable, unowned
BLOCKED: 1, // Impassable terrain
PLAYER1: 2, // Player 1 owned
PLAYER2: 3, // Player 2 owned
PLAYER3: 4, // Player 3 owned
PLAYER4: 5, // Player 4 owned
};
/**
* Represents a single hex cell on the map
*/
class HexCell {
constructor(q, r, type = CELL_TYPES.EMPTY) {
this.q = q;
this.r = r;
this.type = type;
this.dice = [];
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];
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);
@@ -39,6 +58,9 @@ class HexCell {
return false;
}
/**
* Remove dice from cell, leaving specified strength
*/
setStrength(targetStrength) {
if (targetStrength <= 0) {
this.dice = [];
@@ -60,23 +82,34 @@ class HexCell {
}
isOwned() {
return this.type === CELL_TYPES.PLAYER1 || this.type === CELL_TYPES.PLAYER2;
return this.type === CELL_TYPES.PLAYER1 ||
this.type === CELL_TYPES.PLAYER2 ||
this.type === CELL_TYPES.PLAYER3 ||
this.type === CELL_TYPES.PLAYER4;
}
getOwner() {
if (this.type === CELL_TYPES.PLAYER1) return 1;
if (this.type === CELL_TYPES.PLAYER2) return 2;
if (this.type === CELL_TYPES.PLAYER3) return 3;
if (this.type === CELL_TYPES.PLAYER4) return 4;
return 0;
}
}
/**
* Hexagonal map generator and manager
*/
class HexMap {
constructor(size = MAP_SIZE) {
this.size = size;
this.cells = new Map();
this.cells = new Map(); // Key: "q,r", Value: HexCell
this.generate();
}
/**
* Generate the hexagonal grid
*/
generate() {
this.cells.clear();
@@ -89,36 +122,64 @@ class HexMap {
}
}
/**
* 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;
const typeMap = {
1: CELL_TYPES.PLAYER1,
2: CELL_TYPES.PLAYER2,
3: CELL_TYPES.PLAYER3,
4: CELL_TYPES.PLAYER4
};
const targetType = typeMap[playerId];
if (!targetType) return [];
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],
@@ -139,26 +200,112 @@ class HexMap {
return neighbors;
}
/**
* Calculate supply for a player: S = size of largest solid (connected) territory
* A solid territory is a connected region of player's cells
*/
calculateSupply(playerId) {
const playerCells = this.getPlayerCells(playerId);
let supply = 0;
if (playerCells.length === 0) return 0;
// Build adjacency map for player cells
const cellMap = new Map();
for (const cell of playerCells) {
cellMap.set(this.getKey(cell.q, cell.r), cell);
}
// Find connected components using BFS
const visited = new Set();
let maxTerritory = 0;
for (const cell of playerCells) {
supply += 1;
const key = this.getKey(cell.q, cell.r);
if (visited.has(key)) continue;
// BFS to find size of this territory
let territorySize = 0;
const queue = [cell];
visited.add(key);
while (queue.length > 0) {
const current = queue.shift();
territorySize++;
// Get all neighbors that are also player cells
const neighbors = this.getNeighbors(current.q, current.r);
for (const neighbor of neighbors) {
if (neighbor.getOwner() === playerId) {
const nKey = this.getKey(neighbor.q, neighbor.r);
if (!visited.has(nKey)) {
visited.add(nKey);
queue.push(neighbor);
}
}
}
}
return supply;
maxTerritory = Math.max(maxTerritory, territorySize);
}
return maxTerritory;
}
/**
* 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.type === CELL_TYPES.PLAYER3) return 'P3';
if (cell.type === CELL_TYPES.PLAYER4) return 'P4';
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;
const typeMap = {
1: CELL_TYPES.PLAYER1,
2: CELL_TYPES.PLAYER2,
3: CELL_TYPES.PLAYER3,
4: CELL_TYPES.PLAYER4
};
cell.type = typeMap[playerId] || CELL_TYPES.EMPTY;
return true;
}
return false;
}
/**
* Clear cell ownership
*/
clearOwner(q, r) {
const cell = this.getCell(q, r);
if (cell) {
@@ -170,4 +317,10 @@ class HexMap {
}
}
// 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 };
}

View File

@@ -14,6 +14,8 @@
--accent-secondary: #00adb5;
--player1-color: #4ecca3;
--player2-color: #e94560;
--player3-color: #f9ed69;
--player4-color: #00adb5;
--blocked-color: #2a2a4a;
--empty-color: #3a5a6a;
--highlight-color: rgba(255, 255, 255, 0.3);
@@ -30,6 +32,110 @@ body {
overflow: hidden;
}
/* Start Screen */
.start-screen {
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
background: var(--bg-primary);
}
.start-panel {
background: var(--bg-secondary);
padding: 40px;
border-radius: 12px;
text-align: center;
max-width: 450px;
width: 90%;
border: 2px solid var(--accent-primary);
}
.start-panel h1 {
font-size: 2.5rem;
color: var(--accent-primary);
text-transform: uppercase;
letter-spacing: 4px;
margin-bottom: 5px;
}
.start-panel .subtitle {
font-size: 1rem;
color: var(--text-secondary);
margin-bottom: 30px;
}
.setup-section {
text-align: left;
}
.setup-section h3 {
font-size: 1.1rem;
color: var(--text-primary);
margin-bottom: 20px;
text-transform: uppercase;
letter-spacing: 1px;
}
.setup-group {
margin-bottom: 20px;
}
.setup-group label {
display: block;
font-size: 0.9rem;
color: var(--text-secondary);
margin-bottom: 8px;
}
.setup-group select {
width: 100%;
padding: 10px;
background: var(--bg-panel);
border: 1px solid var(--accent-secondary);
border-radius: 6px;
color: var(--text-primary);
font-size: 0.9rem;
}
.player-type-row {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
padding: 8px;
background: var(--bg-panel);
border-radius: 6px;
}
.player-type-row span {
font-size: 0.85rem;
color: var(--text-primary);
}
.player-type-row select {
width: 120px;
padding: 6px;
background: var(--bg-primary);
border: 1px solid var(--accent-secondary);
border-radius: 4px;
color: var(--text-primary);
font-size: 0.85rem;
}
.btn-large {
width: 100%;
padding: 15px;
font-size: 1rem;
margin-top: 10px;
}
.game-screen {
display: flex;
flex-direction: column;
height: 100vh;
}
.game-container {
display: flex;
flex-direction: column;
@@ -89,6 +195,7 @@ body {
padding: 12px;
border-left: 4px solid transparent;
transition: all 0.3s ease;
margin-bottom: 10px;
}
.player-card.active {
@@ -96,7 +203,7 @@ body {
}
.player-1 {
border-left-color: var(--player1-color);
border-left-color: #4ecca3;
}
.player-1.active {
@@ -104,13 +211,44 @@ body {
}
.player-2 {
border-left-color: var(--player2-color);
border-left-color: #e94560;
}
.player-2.active {
background: linear-gradient(135deg, var(--bg-panel), rgba(233, 69, 96, 0.1));
}
.player-3 {
border-left-color: #f9ed69;
}
.player-3.active {
background: linear-gradient(135deg, var(--bg-panel), rgba(249, 237, 105, 0.1));
}
.player-4 {
border-left-color: #00adb5;
}
.player-4.active {
background: linear-gradient(135deg, var(--bg-panel), rgba(0, 173, 181, 0.1));
}
.player-card.ai-controlled {
opacity: 0.8;
}
.player-card.ai-controlled::after {
content: 'AI';
position: absolute;
top: 5px;
right: 8px;
font-size: 0.7rem;
background: var(--accent-secondary);
padding: 2px 6px;
border-radius: 4px;
}
.player-card h3 {
font-size: 1rem;
margin-bottom: 10px;
@@ -231,6 +369,32 @@ body {
min-height: 40px;
}
/* Selected Cell Info */
.selected-cell-info {
background: var(--bg-panel);
border-radius: 8px;
padding: 12px;
margin-top: 10px;
}
.selected-cell-info h3 {
font-size: 0.9rem;
margin-bottom: 10px;
color: var(--text-secondary);
}
.cell-stats {
display: flex;
flex-direction: column;
gap: 6px;
}
.cell-stats .stat {
display: flex;
justify-content: space-between;
font-size: 0.85rem;
}
.action-buttons {
display: flex;
gap: 8px;

View File

@@ -1,28 +0,0 @@
/**
* 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)}%`);

View File

@@ -1,268 +0,0 @@
/**
* 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 };
}

1467
test/ai-bot.test.js Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,95 @@
const { describe, it } = require('node:test');
const assert = require('node:assert');
const { HexMap, HexCell, CELL_TYPES, MAP_SIZE } = require('../src/map.js');
const { HexMap, HexCell, CELL_TYPES, MAP_SIZE } = require('../public/map.js');
describe('HexMap - Dynamic Map Sizes', () => {
it('should create a 10x10 map', () => {
const map = new HexMap(10);
assert.strictEqual(map.size, 10);
assert.strictEqual(map.cells.size, 10 * 10);
});
it('should create a 15x15 map', () => {
const map = new HexMap(15);
assert.strictEqual(map.size, 15);
assert.strictEqual(map.cells.size, 15 * 15);
});
it('should create a 20x20 map', () => {
const map = new HexMap(20);
assert.strictEqual(map.size, 20);
assert.strictEqual(map.cells.size, 20 * 20);
});
it('should create a 25x25 map', () => {
const map = new HexMap(25);
assert.strictEqual(map.size, 25);
assert.strictEqual(map.cells.size, 25 * 25);
});
it('should generate cells with correct coordinates for all map sizes', () => {
const sizes = [10, 15, 20, 25];
for (const size of sizes) {
const map = new HexMap(size);
for (let q = 0; q < size; q++) {
for (let r = 0; r < size; r++) {
const cell = map.getCell(q, r);
assert.ok(cell, `Cell at ${q},${r} should exist for size ${size}`);
assert.strictEqual(cell.q, q);
assert.strictEqual(cell.r, r);
}
}
}
});
it('should have correct neighbor counts for different map sizes', () => {
const sizes = [10, 15, 20, 25];
for (const size of sizes) {
const map = new HexMap(size);
// 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 centerQ = Math.floor(size / 2);
const centerR = Math.floor(size / 2);
const centerNeighbors = map.getNeighbors(centerQ, centerR);
assert.strictEqual(centerNeighbors.length, 6, `Center cell should have 6 neighbors for size ${size}`);
// Corner cell should have 2 neighbors
const cornerNeighbors = map.getNeighbors(0, 0);
assert.strictEqual(cornerNeighbors.length, 2, `Corner cell should have 2 neighbors for size ${size}`);
}
});
it('should calculate supply correctly for different map sizes', () => {
const sizes = [10, 15, 20, 25];
for (const size of sizes) {
const map = new HexMap(size);
// Clear any existing ownership
map.cells.forEach(cell => {
cell.type = CELL_TYPES.EMPTY;
});
// Create a connected territory of 5 cells
for (let i = 0; i < 5; i++) {
map.setOwner(i, 0, 1);
}
const supply = map.calculateSupply(1);
assert.strictEqual(supply, 5, `Supply should be 5 for connected territory in size ${size}`);
}
});
});
describe('HexCell', () => {
it('should create a cell with axial coordinates', () => {