Compare commits
19 Commits
139a732378
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
18fe0332ae | ||
|
|
ed27ca93ab | ||
|
|
3439d04a55 | ||
|
|
f3be577a32 | ||
|
|
1d04a99bd7 | ||
|
|
cb97e167fb | ||
|
|
e427f1c68d | ||
|
|
64c81da166 | ||
|
|
064f3ae263 | ||
|
|
4cb5be95f8 | ||
|
|
afebcbca1a | ||
|
|
a0f6276e5d | ||
|
|
d71529b030 | ||
|
|
62bd946509 | ||
|
|
dbe71dbda6 | ||
|
|
254287c124 | ||
|
|
7035f0457b | ||
|
|
f19e178217 | ||
|
|
f6855022dc |
459
QWEN.md
459
QWEN.md
@@ -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
230
README.md
@@ -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
|
||||
```
|
||||
|
||||
Все тесты проходят успешно ✅
|
||||
|
||||
@@ -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
193
public/ai-bot.js
Normal 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));
|
||||
}
|
||||
}
|
||||
433
public/game.js
433
public/game.js
@@ -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
|
||||
}
|
||||
|
||||
// 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);
|
||||
// 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})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
// Apply supply
|
||||
const supply = this.map.calculateSupply(this.currentPlayer);
|
||||
this.distributeSupply(supply);
|
||||
// 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.log(`Player ${this.currentPlayer} received ${supply} supply`);
|
||||
this.isProcessingTurn = true;
|
||||
console.log(`[GAME] endTurn() - Player ${this.currentPlayer} ending turn`);
|
||||
|
||||
// Switch player
|
||||
this.currentPlayer = this.currentPlayer === 1 ? 2 : 1;
|
||||
this.hasMoved = false;
|
||||
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`);
|
||||
|
||||
this.cancelSelection();
|
||||
this.updateUI();
|
||||
this.render();
|
||||
// Next player
|
||||
const previousPlayer = this.currentPlayer;
|
||||
this.currentPlayer = (this.currentPlayer % this.playerCount) + 1;
|
||||
this.hasMoved = false;
|
||||
|
||||
this.log(`Player ${this.currentPlayer}'s turn`);
|
||||
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
|
||||
|
||||
@@ -8,100 +8,153 @@
|
||||
</head>
|
||||
<body>
|
||||
<div class="game-container">
|
||||
<!-- Header -->
|
||||
<header class="game-header">
|
||||
<h1>HEXO</h1>
|
||||
<p class="subtitle">DiceWars Clone</p>
|
||||
</header>
|
||||
<!-- Start Screen -->
|
||||
<div id="start-screen" class="start-screen">
|
||||
<div class="start-panel">
|
||||
<h1>HEXO</h1>
|
||||
<p class="subtitle">DiceWars Clone</p>
|
||||
|
||||
<!-- 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 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>
|
||||
</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>
|
||||
<button class="btn btn-primary btn-large" id="start-game-btn">Start Game</button>
|
||||
</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>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<footer class="game-footer">
|
||||
<p>Click on your cell with dice, then click adjacent cell to attack</p>
|
||||
</footer>
|
||||
<!-- Game Screen -->
|
||||
<div id="game-screen" class="game-screen" style="display:none;">
|
||||
<!-- 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 id="players-container">
|
||||
<!-- Player cards will be inserted here -->
|
||||
</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 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="back-menu-btn">Main Menu</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>
|
||||
<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>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<script type="module" src="game.js"></script>
|
||||
|
||||
187
public/map.js
187
public/map.js
@@ -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) {
|
||||
supply += 1;
|
||||
cellMap.set(this.getKey(cell.q, cell.r), cell);
|
||||
}
|
||||
|
||||
return supply;
|
||||
// Find connected components using BFS
|
||||
const visited = new Set();
|
||||
let maxTerritory = 0;
|
||||
|
||||
for (const cell of playerCells) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
28
src/index.js
28
src/index.js
@@ -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)}%`);
|
||||
268
src/map.js
268
src/map.js
@@ -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
1467
test/ai-bot.test.js
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user