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
|
## 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
|
### 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
|
- **2-4 Players**: Support for multiple human and/or AI players
|
||||||
- Generatable hexagonal grid map (20x20 cells)
|
- **Dynamic Map Sizes**: 10×10, 15×15, 20×20, 25×25 hexagonal grids
|
||||||
- Each cell can be either playable or blocked/impassable
|
- **AI Bots**: Computer-controlled players with smart move selection and thinking delay
|
||||||
- Each field can hold up to 8 dice
|
- Makes multiple moves per turn until no valid moves remain
|
||||||
- Each player-owned field provides +1 supply unit to the player
|
- Prioritizes favorable attacks, then expansion, then reinforcement
|
||||||
|
- 1000ms thinking delay between moves for natural gameplay
|
||||||
#### Dice System
|
- **Hexagonal Grid**: Proper axial coordinate system with 6-direction adjacency
|
||||||
- Standard 6-sided dice
|
- **Dice Combat**: Roll-based battle system with strength calculation
|
||||||
- Unit strength calculation formula:
|
- **Solid Territory Supply**: Supply = size of largest connected territory
|
||||||
```
|
- **Flexible Setup**: Any combination of human/AI players (hotseat, full AI, or mixed)
|
||||||
F = (cnt-1) * full_dice + current_dice
|
- **88 Passing Tests**: Comprehensive test coverage for all game mechanics
|
||||||
```
|
|
||||||
Where:
|
|
||||||
- `cnt` = number of dice on the field
|
|
||||||
- `full_dice` = maximum die value (6)
|
|
||||||
- `current_dice` = top die current value (1-6, calculated during gameplay)
|
|
||||||
|
|
||||||
#### Game Rules
|
|
||||||
1. **Setup**: Multiple dice are placed on the map for each player at the start
|
|
||||||
2. **Movement Conditions**: A user can move a unit if its strength > 1
|
|
||||||
- When moving: strength-1 transfers to target cell, strength 1 remains on source cell
|
|
||||||
3. **Combat**: When attacking a cell with enemy dice, both players roll:
|
|
||||||
- **Attacker**: `F_attack = rnd(F-1)` (random value less than original strength minus 1)
|
|
||||||
- **Defender**: `F_defence = current_strength` (full field strength)
|
|
||||||
- **Victory**: If `F_attack > F_defence`, attacker wins; otherwise defender repels the attack
|
|
||||||
- **On Victory**: Attacker leaves strength 1 on source cell, transfers `F_attack-1` to target
|
|
||||||
- **On Defeat**: Defender retains `F_defence - F_attack` (minimum 1)
|
|
||||||
4. **Supply Phase**: After all players have moved, each receives supply:
|
|
||||||
- `S = sum(Cell)` where Cell = supply value from each player-owned cell
|
|
||||||
- Maximum per cell: `8 * full_dice` (48 dice points)
|
|
||||||
- If all cells already have maximum strength, no supply is added
|
|
||||||
|
|
||||||
## Directory Structure
|
## Directory Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
hexo/
|
hexo/
|
||||||
├── README.md # Game specifications and rules (in Russian)
|
├── README.md # Game rules and documentation (Russian)
|
||||||
├── QWEN.md # This file - project context for AI assistance
|
├── QWEN.md # This file - project context
|
||||||
├── .gitignore # Git ignore rules (Python-focused template)
|
├── package.json # NPM configuration
|
||||||
├── jsdom-pkg/ # Local copy of jsdom library
|
├── server.js # Simple HTTP server for development
|
||||||
│ └── package/
|
├── .gitignore # Git ignore rules
|
||||||
│ └── lib/
|
├── jsdom-pkg/ # Local jsdom library copy
|
||||||
│ ├── api.js # Main jsdom API entry point
|
├── public/ # All application files (no src/ directory)
|
||||||
│ └── jsdom/ # jsdom core implementation
|
│ ├── index.html # Main HTML page with start screen and game UI
|
||||||
│ ├── browser/ # Browser emulation (Window, parser, resources)
|
│ ├── styles.css # Game UI styles
|
||||||
│ ├── living/ # DOM living standard implementations
|
│ ├── game.js # Main game logic and canvas rendering (GameUI class)
|
||||||
│ ├── generated/ # Auto-generated Web IDL bindings
|
│ ├── map.js # HexMap module (map generation, cells, supply)
|
||||||
│ └── ... # Various DOM/CSS/SVG/XHR implementations
|
│ └── ai-bot.js # AI bot player logic (AIBot class)
|
||||||
└── node_modules/
|
└── test/ # Unit tests (Node.js built-in test runner)
|
||||||
└── jsdom/ # Installed jsdom dependency
|
├── map.test.js # Map and cell tests
|
||||||
|
└── ai-bot.test.js # AI bot tests
|
||||||
```
|
```
|
||||||
|
|
||||||
## Technology Stack
|
## Technology Stack
|
||||||
|
|
||||||
- **Runtime**: Node.js (inferred from jsdom usage)
|
| Layer | Technology |
|
||||||
- **Core Library**: [jsdom](https://github.com/jsdom/jsdom) - JavaScript implementation of DOM and HTML standards
|
|-------|------------|
|
||||||
- Provides browser-like environment for server-side JavaScript
|
| **Runtime** | Node.js |
|
||||||
- Enables DOM manipulation, event handling, and web API emulation
|
| **Frontend** | Vanilla JavaScript (ES Modules), HTML5 Canvas, CSS3 |
|
||||||
|
| **Backend** | Simple HTTP server (`server.js`) |
|
||||||
## Development Status
|
| **Testing** | Node.js built-in test runner (`node --test`) |
|
||||||
|
| **Dependencies** | jsdom (local copy in `jsdom-pkg/`) |
|
||||||
**Early Stage**: The project currently contains:
|
|
||||||
- Game design documentation (README.md)
|
|
||||||
- jsdom library setup (both local copy and node_modules installation)
|
|
||||||
- No main game source files yet
|
|
||||||
|
|
||||||
## Building and Running
|
## 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
|
```bash
|
||||||
# Install dependencies
|
# Start web server (http://localhost:8080)
|
||||||
npm install
|
npm run serve
|
||||||
|
|
||||||
# Run the game (TBD)
|
# Run console demo
|
||||||
npm start
|
npm start
|
||||||
|
|
||||||
# Run tests (TBD)
|
# Run tests (88 tests)
|
||||||
npm test
|
npm test
|
||||||
```
|
```
|
||||||
|
|
||||||
## Development Conventions
|
## 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)
|
## Core Game Mechanics
|
||||||
- JavaScript/TypeScript expected for implementation
|
|
||||||
- DOM-based rendering likely planned (given jsdom inclusion)
|
### Map System
|
||||||
- Game logic will need to implement:
|
|
||||||
- Hexagonal grid generation
|
The map system supports **dynamic sizes** (10×10, 15×15, 20×20, 25×25):
|
||||||
- Dice mechanics and randomization
|
|
||||||
- Turn-based combat system
|
```javascript
|
||||||
- Player state management
|
// 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
|
## 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
|
```javascript
|
||||||
2. **Dice Engine**: Randomization and strength calculation
|
class HexMap {
|
||||||
3. **Combat System**: Attack/defense resolution logic
|
constructor(size = 20) {
|
||||||
4. **Game State**: Player turns, unit positions, victory conditions
|
this.size = size;
|
||||||
5. **UI/Rendering**: Visual representation of the game board
|
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
|
## Notes
|
||||||
|
|
||||||
- The `.gitignore` file appears to be a Python template and may need to be updated for a JavaScript project
|
- 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
|
- 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
|
- 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/`
|
||||||
|
|||||||
234
README.md
234
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. игрок управляет кубиками
|
```bash
|
||||||
1. кубик игральный 6 граннный
|
npm run serve # Запустить веб-сервер (http://localhost:8080)
|
||||||
2. на каждом поле может быть до 8 кубиков
|
npm test # Запустить тесты (88 тестов)
|
||||||
3. сила текущего юнита (ячейка с кубиками) расчитывается,
|
npm start # Консольная версия карты
|
||||||
F = (cnt-1)*full_dice + current_dice,
|
```
|
||||||
где cnt - количество кубиков,
|
|
||||||
full_dice - максимальное значение на кубике = 6
|
|
||||||
сurrent_dice - верхний кубик с текущим значением от 1 до 6, расчитываемое в ходе игры
|
|
||||||
|
|
||||||
3. правила игры
|
## Экран запуска
|
||||||
1. вначале на карты помещается несколько кубиков для каждых игроков
|
|
||||||
2. юзер может ходить юнитом, если
|
При запуске игры открывается экран настройки:
|
||||||
1. его сила больше 1, при этом на захваченную клетку перемещается сила-1, а на исходной остается кубик с силой 1
|
|
||||||
2. если на захватываемой клетке вражеские кубики, то оба игрока бросают кости
|
1. **Выберите количество игроков**: 2, 3 или 4
|
||||||
1. у нападающего количество максимальной силыменьше на 1 исходной, F_attac=rnd(F-1)
|
2. **Выберите размер карты**:
|
||||||
2. у защищающегося полная сила поля, F_defence=ктв(F)
|
- **Small (10×10)** — Быстрая игра на маленькой карте
|
||||||
3.
|
- **Medium (15×15)** — Сбалансированный размер
|
||||||
4. условия победы
|
- **Large (20×20)** — Стандартный размер (по умолчанию)
|
||||||
1. если F_attac>F_defence - то победа нападающего иначе защищающийся отбил атаку
|
- **Extra Large (25×25)** — Большая карта для длительных игр
|
||||||
2. при победе нападающего он оставляет на исходной клетке кубик с силой 1, на захватываемую переводит F_attac-1,
|
3. **Настройте тип каждого игрока**:
|
||||||
3. при пройгрыше у защищающегося отсается F_defence-F_attac, но не меньше 1
|
- **Human** — управление человеком (клики мышью)
|
||||||
3. После того, как все игроки походили, каждый получает снабжение по формуле
|
- **AI Bot** — управление компьютером
|
||||||
S=sum(Cell), где Cell - значение снабжения с каждой клетки, принадлежащей игроку
|
4. Нажмите **Start Game** для начала игры
|
||||||
1. Саксимальное кол-во на клетке может быть 8*Full_dice
|
|
||||||
2. Если все клетки игрока имеют максимальное колво силы, то ничего не добавляется.
|
### Комбинации игроков
|
||||||
|
|
||||||
|
Можно создавать любые комбинации:
|
||||||
|
- Все игроки — люди (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",
|
"name": "hexo",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"description": "Educational dice game - DiceWars clone",
|
"description": "Educational dice game - DiceWars clone",
|
||||||
"main": "src/index.js",
|
"main": "public/game.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node src/index.js",
|
"start": "node server.js",
|
||||||
"serve": "node server.js",
|
"serve": "node server.js",
|
||||||
"test": "node --test"
|
"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));
|
||||||
|
}
|
||||||
|
}
|
||||||
503
public/game.js
503
public/game.js
@@ -1,20 +1,32 @@
|
|||||||
/**
|
/**
|
||||||
* Hexo Game UI - Canvas Rendering and Interactions
|
* Hexo Game UI - Canvas Rendering and Interactions
|
||||||
|
* Supports 2-4 players with AI bots
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { HexMap, CELL_TYPES } from './map.js';
|
import { HexMap, CELL_TYPES } from './map.js';
|
||||||
|
import { AIBot } from './ai-bot.js';
|
||||||
|
|
||||||
// Game constants
|
// Game constants
|
||||||
const HEX_SIZE = 18;
|
const HEX_SIZE = 14;
|
||||||
const MAP_SIZE = 20;
|
const DEFAULT_MAP_SIZE = 20;
|
||||||
const ANIMATION_DURATION = 300;
|
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
|
// Colors
|
||||||
const COLORS = {
|
const COLORS = {
|
||||||
blocked: '#2a2a4a',
|
blocked: '#2a2a4a',
|
||||||
empty: '#3a5a6a',
|
empty: '#3a5a6a',
|
||||||
player1: '#4ecca3',
|
player1: '#4ecca3',
|
||||||
player2: '#e94560',
|
player2: '#e94560',
|
||||||
|
player3: '#f9ed69',
|
||||||
|
player4: '#00adb5',
|
||||||
highlight: 'rgba(255, 255, 255, 0.3)',
|
highlight: 'rgba(255, 255, 255, 0.3)',
|
||||||
selected: 'rgba(233, 69, 96, 0.6)',
|
selected: 'rgba(233, 69, 96, 0.6)',
|
||||||
target: 'rgba(78, 204, 163, 0.5)',
|
target: 'rgba(78, 204, 163, 0.5)',
|
||||||
@@ -33,61 +45,169 @@ class GameUI {
|
|||||||
this.canvas = document.getElementById('game-canvas');
|
this.canvas = document.getElementById('game-canvas');
|
||||||
this.ctx = this.canvas.getContext('2d');
|
this.ctx = this.canvas.getContext('2d');
|
||||||
this.map = null;
|
this.map = null;
|
||||||
|
this.mapSize = DEFAULT_MAP_SIZE;
|
||||||
this.selectedCell = null;
|
this.selectedCell = null;
|
||||||
this.currentTarget = null;
|
this.currentTarget = null;
|
||||||
this.currentPlayer = 1;
|
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.hasMoved = false;
|
||||||
|
this.isAIThinking = false;
|
||||||
|
this.isProcessingTurn = false; // Prevent re-entrancy during turn processing
|
||||||
|
|
||||||
this.offsetX = 0;
|
this.offsetX = 0;
|
||||||
this.offsetY = 0;
|
this.offsetY = 0;
|
||||||
|
|
||||||
this.init();
|
this.init();
|
||||||
}
|
}
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
|
this.setupStartScreen();
|
||||||
this.setupEventListeners();
|
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();
|
this.newGame();
|
||||||
console.log('Game initialized');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
newGame() {
|
newGame() {
|
||||||
this.map = new HexMap(MAP_SIZE);
|
this.map = new HexMap(this.mapSize);
|
||||||
console.log('Map created, cells:', this.map.cells.size);
|
|
||||||
this.selectedCell = null;
|
this.selectedCell = null;
|
||||||
this.currentTarget = null;
|
this.currentTarget = null;
|
||||||
this.currentPlayer = 1;
|
this.currentPlayer = 1;
|
||||||
this.gamePhase = 'movement';
|
this.gamePhase = 'movement';
|
||||||
this.hasMoved = false;
|
this.hasMoved = false;
|
||||||
|
this.isAIThinking = false;
|
||||||
// Initialize starting positions
|
this.isProcessingTurn = false;
|
||||||
|
|
||||||
|
// 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.initializePlayers();
|
||||||
|
|
||||||
this.centerMap();
|
this.centerMap();
|
||||||
this.render();
|
this.render();
|
||||||
|
this.createPlayerCards();
|
||||||
this.updateUI();
|
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() {
|
initializePlayers() {
|
||||||
// Get random empty cells for each player
|
// Place starting units for each player at fixed positions that are always passable
|
||||||
const emptyCells = this.map.getEmptyCells();
|
// Use corners of the map to ensure they don't overlap
|
||||||
|
// Positions scale with map size
|
||||||
// Shuffle and pick starting positions
|
const offset = Math.max(1, Math.floor(this.mapSize / 10));
|
||||||
const shuffled = emptyCells.sort(() => Math.random() - 0.5);
|
const positions = [
|
||||||
|
{ q: offset, r: offset }, // Player 1 - top-left
|
||||||
// Player 1 starting position (top-left area)
|
{ q: this.mapSize - 1 - offset, r: this.mapSize - 1 - offset }, // Player 2 - bottom-right
|
||||||
const p1Cell = shuffled.find(c => c.q < 8 && c.r < 8);
|
{ q: offset, r: this.mapSize - 1 - offset }, // Player 3 - bottom-left
|
||||||
if (p1Cell) {
|
{ q: this.mapSize - 1 - offset, r: offset } // Player 4 - top-right
|
||||||
this.map.setOwner(p1Cell.q, p1Cell.r, 1);
|
];
|
||||||
p1Cell.setStrength(8); // Starting strength
|
|
||||||
}
|
for (let i = 1; i <= this.playerCount; i++) {
|
||||||
|
const pos = positions[i - 1];
|
||||||
// Player 2 starting position (bottom-right area)
|
if (!pos) continue;
|
||||||
const p2Cell = shuffled.find(c => c.q > 10 && c.r > 10 && c.type === CELL_TYPES.EMPTY);
|
|
||||||
if (p2Cell) {
|
// Force the cell to be passable and set ownership
|
||||||
this.map.setOwner(p2Cell.q, p2Cell.r, 2);
|
const cell = this.map.getCell(pos.q, pos.r);
|
||||||
p2Cell.setStrength(8);
|
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,35 +216,42 @@ class GameUI {
|
|||||||
const canvasHeight = this.canvas.height;
|
const canvasHeight = this.canvas.height;
|
||||||
|
|
||||||
const sqrt3 = Math.sqrt(3);
|
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;
|
// Calculate actual map bounds based on dynamic map size
|
||||||
this.offsetY = (canvasHeight - mapHeight) / 2 + HEX_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() {
|
setupEventListeners() {
|
||||||
this.canvas.addEventListener('click', (e) => this.handleClick(e));
|
this.canvas.addEventListener('click', (e) => this.handleClick(e));
|
||||||
this.canvas.addEventListener('mousemove', (e) => this.handleMouseMove(e));
|
this.canvas.addEventListener('mousemove', (e) => this.handleMouseMove(e));
|
||||||
|
|
||||||
document.getElementById('end-turn-btn').addEventListener('click', () => this.endTurn());
|
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());
|
document.getElementById('cancel-btn').addEventListener('click', () => this.cancelSelection());
|
||||||
}
|
}
|
||||||
|
|
||||||
hexToPixel(q, r) {
|
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);
|
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 x = this.offsetX + HEX_SIZE * sqrt3 * (q + r/2);
|
||||||
const y = this.offsetY + HEX_SIZE * 1.5 * r;
|
const y = this.offsetY + HEX_SIZE * 1.5 * r;
|
||||||
|
|
||||||
return { x, y };
|
return { x, y };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,22 +266,12 @@ class GameUI {
|
|||||||
const qi = Math.round(q);
|
const qi = Math.round(q);
|
||||||
const ri = Math.round(r);
|
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 { q: qi, r: ri };
|
||||||
}
|
}
|
||||||
return null;
|
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) {
|
getValidTargets(q, r) {
|
||||||
const directions = [
|
const directions = [
|
||||||
[+1, -1], // north-east
|
[+1, -1], // north-east
|
||||||
@@ -169,7 +286,7 @@ class GameUI {
|
|||||||
for (const [dq, dr] of directions) {
|
for (const [dq, dr] of directions) {
|
||||||
const nq = q + dq;
|
const nq = q + dq;
|
||||||
const nr = r + dr;
|
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);
|
const cell = this.map.getCell(nq, nr);
|
||||||
if (cell && cell.isPassable()) {
|
if (cell && cell.isPassable()) {
|
||||||
targets.push(cell);
|
targets.push(cell);
|
||||||
@@ -181,14 +298,11 @@ class GameUI {
|
|||||||
|
|
||||||
drawHex(q, r, fillStyle, strokeStyle = COLORS.stroke, lineWidth = 2) {
|
drawHex(q, r, fillStyle, strokeStyle = COLORS.stroke, lineWidth = 2) {
|
||||||
const { x, y } = this.hexToPixel(q, r);
|
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 = [];
|
const vertices = [];
|
||||||
for (let i = 0; i < 6; i++) {
|
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({
|
vertices.push({
|
||||||
x: x + size * Math.cos(angle),
|
x: x + size * Math.cos(angle),
|
||||||
y: y + size * Math.sin(angle)
|
y: y + size * Math.sin(angle)
|
||||||
@@ -216,14 +330,12 @@ class GameUI {
|
|||||||
const { x, y } = this.hexToPixel(cell.q, cell.r);
|
const { x, y } = this.hexToPixel(cell.q, cell.r);
|
||||||
const strength = cell.getStrength();
|
const strength = cell.getStrength();
|
||||||
|
|
||||||
// Draw strength number
|
|
||||||
this.ctx.fillStyle = COLORS.text;
|
this.ctx.fillStyle = COLORS.text;
|
||||||
this.ctx.font = 'bold 11px Arial';
|
this.ctx.font = 'bold 11px Arial';
|
||||||
this.ctx.textAlign = 'center';
|
this.ctx.textAlign = 'center';
|
||||||
this.ctx.textBaseline = 'middle';
|
this.ctx.textBaseline = 'middle';
|
||||||
this.ctx.fillText(strength.toString(), x, y - 3);
|
this.ctx.fillText(strength.toString(), x, y - 3);
|
||||||
|
|
||||||
// Draw dice count indicator (small dots)
|
|
||||||
if (cell.dice.length > 1) {
|
if (cell.dice.length > 1) {
|
||||||
const dotY = y + 8;
|
const dotY = y + 8;
|
||||||
for (let i = 0; i < Math.min(cell.dice.length, 5); i++) {
|
for (let i = 0; i < Math.min(cell.dice.length, 5); i++) {
|
||||||
@@ -239,9 +351,7 @@ class GameUI {
|
|||||||
if (!cell.isOwned()) return;
|
if (!cell.isOwned()) return;
|
||||||
|
|
||||||
const { x, y } = this.hexToPixel(cell.q, cell.r);
|
const { x, y } = this.hexToPixel(cell.q, cell.r);
|
||||||
const ownerColor = cell.getOwner() === 1 ? COLORS.player1 : COLORS.player2;
|
const ownerColor = PLAYER_COLORS[cell.getOwner()] || COLORS.player1;
|
||||||
|
|
||||||
// Draw border matching the hexagon shape
|
|
||||||
const size = HEX_SIZE * 0.98 - 3;
|
const size = HEX_SIZE * 0.98 - 3;
|
||||||
|
|
||||||
const vertices = [];
|
const vertices = [];
|
||||||
@@ -265,40 +375,40 @@ class GameUI {
|
|||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
// Clear canvas
|
|
||||||
this.ctx.fillStyle = COLORS.stroke;
|
this.ctx.fillStyle = COLORS.stroke;
|
||||||
this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
|
this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
|
||||||
|
|
||||||
if (!this.map) return;
|
if (!this.map) return;
|
||||||
|
|
||||||
// Draw all cells
|
for (let r = 0; r < this.mapSize; r++) {
|
||||||
for (let r = 0; r < MAP_SIZE; r++) {
|
for (let q = 0; q < this.mapSize; q++) {
|
||||||
for (let q = 0; q < MAP_SIZE; q++) {
|
|
||||||
const cell = this.map.getCell(q, r);
|
const cell = this.map.getCell(q, r);
|
||||||
let color;
|
let color;
|
||||||
|
|
||||||
if (cell.type === CELL_TYPES.BLOCKED) {
|
if (cell.type === CELL_TYPES.BLOCKED) {
|
||||||
color = COLORS.blocked;
|
color = COLORS.blocked;
|
||||||
} else if (cell.type === CELL_TYPES.PLAYER1) {
|
} else if (cell.type === CELL_TYPES.PLAYER1) {
|
||||||
color = COLORS.player1;
|
color = COLORS.player1;
|
||||||
} else if (cell.type === CELL_TYPES.PLAYER2) {
|
} else if (cell.type === CELL_TYPES.PLAYER2) {
|
||||||
color = COLORS.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 {
|
} else {
|
||||||
color = COLORS.empty;
|
color = COLORS.empty;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply transparency for non-owned cells
|
|
||||||
if (!cell.isOwned()) {
|
if (!cell.isOwned()) {
|
||||||
color = this.hexToRgba(color, 0.6);
|
color = this.hexToRgba(color, 0.6);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.drawHex(q, r, color);
|
this.drawHex(q, r, color);
|
||||||
this.drawOwnerIndicator(cell);
|
this.drawOwnerIndicator(cell);
|
||||||
this.drawDice(cell);
|
this.drawDice(cell);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Draw selection highlight
|
|
||||||
if (this.selectedCell) {
|
if (this.selectedCell) {
|
||||||
this.drawHex(
|
this.drawHex(
|
||||||
this.selectedCell.q,
|
this.selectedCell.q,
|
||||||
@@ -308,7 +418,6 @@ class GameUI {
|
|||||||
3
|
3
|
||||||
);
|
);
|
||||||
|
|
||||||
// Highlight valid targets (not blocked, not own)
|
|
||||||
const targets = this.getValidTargets(this.selectedCell.q, this.selectedCell.r);
|
const targets = this.getValidTargets(this.selectedCell.q, this.selectedCell.r);
|
||||||
for (const target of targets) {
|
for (const target of targets) {
|
||||||
if (target.getOwner() !== this.currentPlayer) {
|
if (target.getOwner() !== this.currentPlayer) {
|
||||||
@@ -316,7 +425,7 @@ class GameUI {
|
|||||||
target.q,
|
target.q,
|
||||||
target.r,
|
target.r,
|
||||||
COLORS.target,
|
COLORS.target,
|
||||||
COLORS.player1,
|
PLAYER_COLORS[this.currentPlayer] || COLORS.player1,
|
||||||
2
|
2
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -333,6 +442,7 @@ class GameUI {
|
|||||||
|
|
||||||
handleClick(e) {
|
handleClick(e) {
|
||||||
if (this.gamePhase !== 'movement') return;
|
if (this.gamePhase !== 'movement') return;
|
||||||
|
if (this.playerTypes[this.currentPlayer] === 'ai') return;
|
||||||
|
|
||||||
const rect = this.canvas.getBoundingClientRect();
|
const rect = this.canvas.getBoundingClientRect();
|
||||||
const x = e.clientX - rect.left;
|
const x = e.clientX - rect.left;
|
||||||
@@ -344,29 +454,22 @@ class GameUI {
|
|||||||
const cell = this.map.getCell(hexPos.q, hexPos.r);
|
const cell = this.map.getCell(hexPos.q, hexPos.r);
|
||||||
if (!cell || !cell.isPassable()) return;
|
if (!cell || !cell.isPassable()) return;
|
||||||
|
|
||||||
// Handle cell selection
|
|
||||||
if (this.selectedCell === null) {
|
if (this.selectedCell === null) {
|
||||||
// Select own cell with strength > 1
|
|
||||||
if (cell.getOwner() === this.currentPlayer && cell.getStrength() > 1) {
|
if (cell.getOwner() === this.currentPlayer && cell.getStrength() > 1) {
|
||||||
this.selectedCell = cell;
|
this.selectedCell = cell;
|
||||||
this.instruction = `Select target cell or cancel`;
|
|
||||||
this.updateUI();
|
this.updateUI();
|
||||||
this.render();
|
this.render();
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Check if clicking on same cell - deselect
|
|
||||||
if (cell === this.selectedCell) {
|
if (cell === this.selectedCell) {
|
||||||
this.cancelSelection();
|
this.cancelSelection();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if valid target using isometric-aware adjacency
|
|
||||||
const targets = this.getValidTargets(this.selectedCell.q, this.selectedCell.r);
|
const targets = this.getValidTargets(this.selectedCell.q, this.selectedCell.r);
|
||||||
const isValidTarget = targets.some(n => n.q === cell.q && n.r === cell.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) {
|
if (cell.getOwner() === this.currentPlayer) {
|
||||||
// Select different own cell instead
|
|
||||||
if (cell.getStrength() > 1) {
|
if (cell.getStrength() > 1) {
|
||||||
this.selectedCell = cell;
|
this.selectedCell = cell;
|
||||||
this.updateUI();
|
this.updateUI();
|
||||||
@@ -385,52 +488,48 @@ class GameUI {
|
|||||||
}
|
}
|
||||||
|
|
||||||
handleMouseMove(e) {
|
handleMouseMove(e) {
|
||||||
// Could add hover effects here
|
// Hover effects could be added here
|
||||||
}
|
}
|
||||||
|
|
||||||
executeAttack() {
|
executeAttack() {
|
||||||
if (!this.selectedCell || !this.currentTarget) return;
|
if (!this.selectedCell || !this.currentTarget) return;
|
||||||
|
|
||||||
const attacker = this.selectedCell;
|
const attacker = this.selectedCell;
|
||||||
const defender = this.currentTarget;
|
const defender = this.currentTarget;
|
||||||
const attackStrength = attacker.getStrength() - 1;
|
const attackStrength = attacker.getStrength() - 1;
|
||||||
|
|
||||||
if (defender.type === CELL_TYPES.EMPTY || defender.getOwner() !== this.currentPlayer) {
|
if (defender.type === CELL_TYPES.EMPTY || defender.getOwner() !== this.currentPlayer) {
|
||||||
// Attack empty or enemy cell
|
|
||||||
let defenseStrength = defender.getStrength();
|
let defenseStrength = defender.getStrength();
|
||||||
|
|
||||||
if (defenseStrength > 0) {
|
if (defenseStrength > 0) {
|
||||||
// Combat! Roll dice
|
|
||||||
const attackRoll = Math.floor(Math.random() * attackStrength) + 1;
|
const attackRoll = Math.floor(Math.random() * attackStrength) + 1;
|
||||||
const defenseRoll = Math.floor(Math.random() * defenseStrength) + 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) {
|
if (attackRoll > defenseRoll) {
|
||||||
// Attacker wins
|
|
||||||
const remainingStrength = attackRoll - 1;
|
const remainingStrength = attackRoll - 1;
|
||||||
attacker.setStrength(1);
|
attacker.setStrength(1);
|
||||||
|
|
||||||
if (remainingStrength > 0) {
|
if (remainingStrength > 0) {
|
||||||
defender.setStrength(remainingStrength);
|
defender.setStrength(remainingStrength);
|
||||||
this.map.setOwner(defender.q, defender.r, this.currentPlayer);
|
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 {
|
} else {
|
||||||
// Defender wins
|
|
||||||
const remainingDefense = defenseRoll - attackRoll;
|
const remainingDefense = defenseRoll - attackRoll;
|
||||||
defender.setStrength(Math.max(1, remainingDefense));
|
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 {
|
} else {
|
||||||
// Move to empty cell - transfer attackStrength (original - 1)
|
|
||||||
attacker.setStrength(1);
|
attacker.setStrength(1);
|
||||||
defender.setStrength(attackStrength);
|
defender.setStrength(attackStrength);
|
||||||
this.map.setOwner(defender.q, defender.r, this.currentPlayer);
|
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}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.hasMoved = true;
|
this.hasMoved = true;
|
||||||
this.cancelSelection();
|
this.cancelSelection();
|
||||||
this.render();
|
this.render();
|
||||||
@@ -444,47 +543,103 @@ class GameUI {
|
|||||||
this.render();
|
this.render();
|
||||||
}
|
}
|
||||||
|
|
||||||
endTurn() {
|
async endTurn() {
|
||||||
if (this.gamePhase !== 'movement') return;
|
if (this.gamePhase !== 'movement') {
|
||||||
|
console.log(`[GAME] endTurn() called but gamePhase is '${this.gamePhase}', ignoring`);
|
||||||
// Apply supply
|
return;
|
||||||
const supply = this.map.calculateSupply(this.currentPlayer);
|
}
|
||||||
this.distributeSupply(supply);
|
|
||||||
|
// Prevent re-entrancy - only one turn processing at a time
|
||||||
this.log(`Player ${this.currentPlayer} received ${supply} supply`);
|
if (this.isProcessingTurn) {
|
||||||
|
console.log(`[GAME] endTurn() called but isProcessingTurn is true, ignoring`);
|
||||||
// Switch player
|
return;
|
||||||
this.currentPlayer = this.currentPlayer === 1 ? 2 : 1;
|
}
|
||||||
this.hasMoved = false;
|
|
||||||
|
this.isProcessingTurn = true;
|
||||||
this.cancelSelection();
|
console.log(`[GAME] endTurn() - Player ${this.currentPlayer} ending turn`);
|
||||||
this.updateUI();
|
|
||||||
this.render();
|
try {
|
||||||
|
// Apply supply
|
||||||
this.log(`Player ${this.currentPlayer}'s turn`);
|
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`);
|
||||||
|
|
||||||
|
// Next player
|
||||||
|
const previousPlayer = this.currentPlayer;
|
||||||
|
this.currentPlayer = (this.currentPlayer % this.playerCount) + 1;
|
||||||
|
this.hasMoved = false;
|
||||||
|
|
||||||
|
console.log(`[GAME] Turn transition: P${previousPlayer} -> P${this.currentPlayer}`);
|
||||||
|
|
||||||
|
this.cancelSelection();
|
||||||
|
this.updateUI();
|
||||||
|
this.render();
|
||||||
|
|
||||||
|
this.log(`Player ${this.currentPlayer}'s turn`);
|
||||||
|
console.log(`[GAME] Player ${this.currentPlayer}'s turn started (${this.playerTypes[this.currentPlayer]})`);
|
||||||
|
|
||||||
|
// Reset isProcessingTurn BEFORE awaiting AI turn
|
||||||
|
// This allows the next AI's endTurn() call to proceed
|
||||||
|
this.isProcessingTurn = false;
|
||||||
|
|
||||||
|
// Check if next player is AI and await completion
|
||||||
|
await this.checkAndRunAITurn();
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[GAME] Error in endTurn():`, error);
|
||||||
|
this.log(`Error during turn transition: ${error.message}`, 'error');
|
||||||
|
this.isProcessingTurn = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if current player is AI and run their turn
|
||||||
|
* @returns {Promise<boolean>} - true if AI turn was run, false if human turn
|
||||||
|
*/
|
||||||
|
async checkAndRunAITurn() {
|
||||||
|
if (this.playerTypes[this.currentPlayer] === 'ai' && this.aiBots[this.currentPlayer]) {
|
||||||
|
console.log(`[AI] Player ${this.currentPlayer} is AI, starting turn`);
|
||||||
|
this.isAIThinking = true;
|
||||||
|
this.updateUI();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Run AI turn and wait for it to complete
|
||||||
|
await this.aiBots[this.currentPlayer].playTurn();
|
||||||
|
console.log(`[AI] Player ${this.currentPlayer} AI turn completed`);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[AI] Error during Player ${this.currentPlayer} AI turn:`, error);
|
||||||
|
this.log(`AI error: ${error.message}`, 'error');
|
||||||
|
this.isAIThinking = false;
|
||||||
|
this.updateUI();
|
||||||
|
// Still advance to next player even on error
|
||||||
|
return true;
|
||||||
|
} finally {
|
||||||
|
// Always reset the flag when AI turn completes (success or error)
|
||||||
|
this.isAIThinking = false;
|
||||||
|
console.log(`[AI] Player ${this.currentPlayer} isAIThinking reset to false`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log(`[AI] Player ${this.currentPlayer} is human, no AI turn needed`);
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
distributeSupply(supply) {
|
distributeSupply(supply) {
|
||||||
const playerCells = this.map.getPlayerCells(this.currentPlayer);
|
const playerCells = this.map.getPlayerCells(this.currentPlayer);
|
||||||
|
|
||||||
// Find cells that can receive more dice
|
|
||||||
const eligibleCells = playerCells.filter(cell => !cell.isMaxStrength());
|
const eligibleCells = playerCells.filter(cell => !cell.isMaxStrength());
|
||||||
|
|
||||||
if (eligibleCells.length === 0 || supply === 0) return;
|
if (eligibleCells.length === 0 || supply === 0) return;
|
||||||
|
|
||||||
// Distribute supply randomly among eligible cells
|
|
||||||
let remainingSupply = supply;
|
let remainingSupply = supply;
|
||||||
while (remainingSupply > 0 && eligibleCells.length > 0) {
|
while (remainingSupply > 0 && eligibleCells.length > 0) {
|
||||||
const randomCell = eligibleCells[Math.floor(Math.random() * eligibleCells.length)];
|
const randomCell = eligibleCells[Math.floor(Math.random() * eligibleCells.length)];
|
||||||
const currentStrength = randomCell.getStrength();
|
const currentStrength = randomCell.getStrength();
|
||||||
|
|
||||||
if (currentStrength < 48) {
|
if (currentStrength < 48) {
|
||||||
const addStrength = Math.min(remainingSupply, 48 - currentStrength);
|
randomCell.setStrength(currentStrength + 1);
|
||||||
const newStrength = currentStrength + addStrength;
|
remainingSupply--;
|
||||||
randomCell.setStrength(newStrength);
|
|
||||||
remainingSupply -= addStrength;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (randomCell.isMaxStrength()) {
|
if (randomCell.isMaxStrength()) {
|
||||||
eligibleCells.splice(eligibleCells.indexOf(randomCell), 1);
|
eligibleCells.splice(eligibleCells.indexOf(randomCell), 1);
|
||||||
}
|
}
|
||||||
@@ -492,70 +647,64 @@ class GameUI {
|
|||||||
}
|
}
|
||||||
|
|
||||||
updateUI() {
|
updateUI() {
|
||||||
// Update player stats
|
// Update all player cards
|
||||||
const p1Cells = this.map.getPlayerCells(1);
|
for (let i = 1; i <= this.playerCount; i++) {
|
||||||
const p2Cells = this.map.getPlayerCells(2);
|
const playerCells = this.map.getPlayerCells(i);
|
||||||
|
const playerStrength = playerCells.reduce((sum, c) => sum + c.getStrength(), 0);
|
||||||
const p1Strength = p1Cells.reduce((sum, c) => sum + c.getStrength(), 0);
|
const playerSupply = this.map.calculateSupply(i);
|
||||||
const p2Strength = p2Cells.reduce((sum, c) => sum + c.getStrength(), 0);
|
|
||||||
|
document.getElementById(`player${i}-cells`).textContent = playerCells.length;
|
||||||
document.getElementById('player1-cells').textContent = p1Cells.length;
|
document.getElementById(`player${i}-supply`).textContent = playerSupply;
|
||||||
document.getElementById('player1-supply').textContent = this.map.calculateSupply(1);
|
document.getElementById(`player${i}-strength`).textContent = playerStrength;
|
||||||
document.getElementById('player1-strength').textContent = p1Strength;
|
|
||||||
|
const card = document.getElementById(`player${i}-card`);
|
||||||
document.getElementById('player2-cells').textContent = p2Cells.length;
|
card.classList.toggle('active', i === this.currentPlayer);
|
||||||
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('current-turn').textContent = this.currentPlayer;
|
||||||
document.getElementById('game-phase').textContent = this.gamePhase;
|
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');
|
const instruction = document.getElementById('action-instruction');
|
||||||
if (this.selectedCell) {
|
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 {
|
} else {
|
||||||
instruction.textContent = 'Select a cell with dice to move';
|
instruction.textContent = 'Select a cell with dice to move';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update buttons
|
const cellInfo = document.getElementById('selected-cell-info');
|
||||||
const attackBtn = document.getElementById('attack-btn');
|
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 cancelBtn = document.getElementById('cancel-btn');
|
||||||
const endTurnBtn = document.getElementById('end-turn-btn');
|
const endTurnBtn = document.getElementById('end-turn-btn');
|
||||||
|
|
||||||
attackBtn.disabled = !this.selectedCell;
|
cancelBtn.disabled = !this.selectedCell || this.playerTypes[this.currentPlayer] === 'ai';
|
||||||
cancelBtn.disabled = !this.selectedCell;
|
endTurnBtn.disabled = this.playerTypes[this.currentPlayer] === 'ai';
|
||||||
endTurnBtn.disabled = !this.hasMoved;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
log(message, type = '') {
|
log(message, type = '') {
|
||||||
const logContainer = document.getElementById('battle-log');
|
const logContainer = document.getElementById('battle-log');
|
||||||
const entry = document.createElement('div');
|
const entry = document.createElement('div');
|
||||||
entry.className = `log-entry ${type}`;
|
entry.className = `log-entry ${type}`;
|
||||||
entry.textContent = `[Turn ${this.currentPlayer}] ${message}`;
|
entry.textContent = `[P${this.currentPlayer}] ${message}`;
|
||||||
logContainer.insertBefore(entry, logContainer.firstChild);
|
logContainer.insertBefore(entry, logContainer.firstChild);
|
||||||
|
|
||||||
// Keep only last 50 entries
|
|
||||||
while (logContainer.children.length > 50) {
|
while (logContainer.children.length > 50) {
|
||||||
logContainer.removeChild(logContainer.lastChild);
|
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
|
// Initialize game when DOM is ready
|
||||||
|
|||||||
@@ -8,100 +8,153 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="game-container">
|
<div class="game-container">
|
||||||
<!-- Header -->
|
<!-- Start Screen -->
|
||||||
<header class="game-header">
|
<div id="start-screen" class="start-screen">
|
||||||
<h1>HEXO</h1>
|
<div class="start-panel">
|
||||||
<p class="subtitle">DiceWars Clone</p>
|
<h1>HEXO</h1>
|
||||||
</header>
|
<p class="subtitle">DiceWars Clone</p>
|
||||||
|
|
||||||
|
<div class="setup-section">
|
||||||
|
<h3>Game Setup</h3>
|
||||||
|
|
||||||
|
<div class="setup-group">
|
||||||
|
<label for="player-count">Total Players:</label>
|
||||||
|
<select id="player-count">
|
||||||
|
<option value="2" selected>2 Players</option>
|
||||||
|
<option value="3">3 Players</option>
|
||||||
|
<option value="4">4 Players</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Game Area -->
|
<div class="setup-group">
|
||||||
<div class="game-area">
|
<label for="map-size">Map Size:</label>
|
||||||
<!-- Left Panel - Player Info -->
|
<select id="map-size">
|
||||||
<aside class="side-panel left-panel">
|
<option value="10">Small (10x10)</option>
|
||||||
<div class="player-card player-1 active" id="player1-card">
|
<option value="15">Medium (15x15)</option>
|
||||||
<h3>Player 1</h3>
|
<option value="20" selected>Large (20x20)</option>
|
||||||
<div class="player-stats">
|
<option value="25">Extra Large (25x25)</option>
|
||||||
<div class="stat">
|
</select>
|
||||||
<span class="stat-label">Cells:</span>
|
</div>
|
||||||
<span class="stat-value" id="player1-cells">0</span>
|
|
||||||
</div>
|
<div class="setup-group">
|
||||||
<div class="stat">
|
<label>Player Types:</label>
|
||||||
<span class="stat-label">Supply:</span>
|
<div id="player-types">
|
||||||
<span class="stat-value" id="player1-supply">0</span>
|
<div class="player-type-row">
|
||||||
</div>
|
<span>Player 1:</span>
|
||||||
<div class="stat">
|
<select id="player1-type">
|
||||||
<span class="stat-label">Total Strength:</span>
|
<option value="human">Human</option>
|
||||||
<span class="stat-value" id="player1-strength">0</span>
|
<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>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="player-card player-2" id="player2-card">
|
<button class="btn btn-primary btn-large" id="start-game-btn">Start Game</button>
|
||||||
<h3>Player 2</h3>
|
|
||||||
<div class="player-stats">
|
|
||||||
<div class="stat">
|
|
||||||
<span class="stat-label">Cells:</span>
|
|
||||||
<span class="stat-value" id="player2-cells">0</span>
|
|
||||||
</div>
|
|
||||||
<div class="stat">
|
|
||||||
<span class="stat-label">Supply:</span>
|
|
||||||
<span class="stat-value" id="player2-supply">0</span>
|
|
||||||
</div>
|
|
||||||
<div class="stat">
|
|
||||||
<span class="stat-label">Total Strength:</span>
|
|
||||||
<span class="stat-value" id="player2-strength">0</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</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 -->
|
<!-- Game Screen -->
|
||||||
<footer class="game-footer">
|
<div id="game-screen" class="game-screen" style="display:none;">
|
||||||
<p>Click on your cell with dice, then click adjacent cell to attack</p>
|
<!-- Header -->
|
||||||
</footer>
|
<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>
|
</div>
|
||||||
|
|
||||||
<script type="module" src="game.js"></script>
|
<script type="module" src="game.js"></script>
|
||||||
|
|||||||
191
public/map.js
191
public/map.js
@@ -1,36 +1,55 @@
|
|||||||
/**
|
/**
|
||||||
* Hexagonal grid map for the DiceWars game.
|
* 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 MAP_SIZE = 20;
|
||||||
const CELL_TYPES = {
|
const CELL_TYPES = {
|
||||||
EMPTY: 0,
|
EMPTY: 0, // Passable, unowned
|
||||||
BLOCKED: 1,
|
BLOCKED: 1, // Impassable terrain
|
||||||
PLAYER1: 2,
|
PLAYER1: 2, // Player 1 owned
|
||||||
PLAYER2: 3,
|
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 {
|
class HexCell {
|
||||||
constructor(q, r, type = CELL_TYPES.EMPTY) {
|
constructor(q, r, type = CELL_TYPES.EMPTY) {
|
||||||
this.q = q;
|
this.q = q; // Axial coordinate q
|
||||||
this.r = r;
|
this.r = r; // Axial coordinate r
|
||||||
this.type = type;
|
this.type = type; // Cell ownership/type
|
||||||
this.dice = [];
|
this.dice = []; // Array of dice values on this cell
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate unit strength: F = (cnt-1)*full_dice + current_dice
|
||||||
|
*/
|
||||||
getStrength() {
|
getStrength() {
|
||||||
if (this.dice.length === 0) return 0;
|
if (this.dice.length === 0) return 0;
|
||||||
const cnt = this.dice.length;
|
const cnt = this.dice.length;
|
||||||
const fullDice = 6;
|
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;
|
return (cnt - 1) * fullDice + currentDice;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if cell has maximum strength (8 dice with 6 on top)
|
||||||
|
*/
|
||||||
isMaxStrength() {
|
isMaxStrength() {
|
||||||
return this.dice.length >= 8 && this.getStrength() >= 48;
|
return this.dice.length >= 8 && this.getStrength() >= 48;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a die to this cell
|
||||||
|
*/
|
||||||
addDie(value) {
|
addDie(value) {
|
||||||
if (this.dice.length < 8) {
|
if (this.dice.length < 8) {
|
||||||
this.dice.push(value);
|
this.dice.push(value);
|
||||||
@@ -39,6 +58,9 @@ class HexCell {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove dice from cell, leaving specified strength
|
||||||
|
*/
|
||||||
setStrength(targetStrength) {
|
setStrength(targetStrength) {
|
||||||
if (targetStrength <= 0) {
|
if (targetStrength <= 0) {
|
||||||
this.dice = [];
|
this.dice = [];
|
||||||
@@ -60,23 +82,34 @@ class HexCell {
|
|||||||
}
|
}
|
||||||
|
|
||||||
isOwned() {
|
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() {
|
getOwner() {
|
||||||
if (this.type === CELL_TYPES.PLAYER1) return 1;
|
if (this.type === CELL_TYPES.PLAYER1) return 1;
|
||||||
if (this.type === CELL_TYPES.PLAYER2) return 2;
|
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;
|
return 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hexagonal map generator and manager
|
||||||
|
*/
|
||||||
class HexMap {
|
class HexMap {
|
||||||
constructor(size = MAP_SIZE) {
|
constructor(size = MAP_SIZE) {
|
||||||
this.size = size;
|
this.size = size;
|
||||||
this.cells = new Map();
|
this.cells = new Map(); // Key: "q,r", Value: HexCell
|
||||||
this.generate();
|
this.generate();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate the hexagonal grid
|
||||||
|
*/
|
||||||
generate() {
|
generate() {
|
||||||
this.cells.clear();
|
this.cells.clear();
|
||||||
|
|
||||||
@@ -89,36 +122,64 @@ class HexMap {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get cell by axial coordinates
|
||||||
|
*/
|
||||||
getCell(q, r) {
|
getCell(q, r) {
|
||||||
const key = this.getKey(q, r);
|
const key = this.getKey(q, r);
|
||||||
return this.cells.get(key);
|
return this.cells.get(key);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get cell by key string
|
||||||
|
*/
|
||||||
getCellByKey(key) {
|
getCellByKey(key) {
|
||||||
return this.cells.get(key);
|
return this.cells.get(key);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate key from coordinates
|
||||||
|
*/
|
||||||
getKey(q, r) {
|
getKey(q, r) {
|
||||||
return `${q},${r}`;
|
return `${q},${r}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all passable cells
|
||||||
|
*/
|
||||||
getPassableCells() {
|
getPassableCells() {
|
||||||
return Array.from(this.cells.values()).filter(cell => cell.isPassable());
|
return Array.from(this.cells.values()).filter(cell => cell.isPassable());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all empty (unowned) passable cells
|
||||||
|
*/
|
||||||
getEmptyCells() {
|
getEmptyCells() {
|
||||||
return Array.from(this.cells.values()).filter(
|
return Array.from(this.cells.values()).filter(
|
||||||
cell => cell.isPassable() && !cell.isOwned()
|
cell => cell.isPassable() && !cell.isOwned()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all cells owned by a player
|
||||||
|
*/
|
||||||
getPlayerCells(playerId) {
|
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(
|
return Array.from(this.cells.values()).filter(
|
||||||
cell => cell.type === targetType
|
cell => cell.type === targetType
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get neighboring cells (6 directions in hex grid)
|
||||||
|
*/
|
||||||
getNeighbors(q, r) {
|
getNeighbors(q, r) {
|
||||||
const directions = [
|
const directions = [
|
||||||
[1, 0], [1, -1], [0, -1],
|
[1, 0], [1, -1], [0, -1],
|
||||||
@@ -139,26 +200,112 @@ class HexMap {
|
|||||||
return neighbors;
|
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) {
|
calculateSupply(playerId) {
|
||||||
const playerCells = this.getPlayerCells(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) {
|
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) {
|
setOwner(q, r, playerId) {
|
||||||
const cell = this.getCell(q, r);
|
const cell = this.getCell(q, r);
|
||||||
if (cell && cell.isPassable()) {
|
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 true;
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear cell ownership
|
||||||
|
*/
|
||||||
clearOwner(q, r) {
|
clearOwner(q, r) {
|
||||||
const cell = this.getCell(q, r);
|
const cell = this.getCell(q, r);
|
||||||
if (cell) {
|
if (cell) {
|
||||||
@@ -170,4 +317,10 @@ class HexMap {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ES Module exports for browser
|
||||||
export { HexMap, HexCell, CELL_TYPES, MAP_SIZE };
|
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;
|
--accent-secondary: #00adb5;
|
||||||
--player1-color: #4ecca3;
|
--player1-color: #4ecca3;
|
||||||
--player2-color: #e94560;
|
--player2-color: #e94560;
|
||||||
|
--player3-color: #f9ed69;
|
||||||
|
--player4-color: #00adb5;
|
||||||
--blocked-color: #2a2a4a;
|
--blocked-color: #2a2a4a;
|
||||||
--empty-color: #3a5a6a;
|
--empty-color: #3a5a6a;
|
||||||
--highlight-color: rgba(255, 255, 255, 0.3);
|
--highlight-color: rgba(255, 255, 255, 0.3);
|
||||||
@@ -30,6 +32,110 @@ body {
|
|||||||
overflow: hidden;
|
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 {
|
.game-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -89,6 +195,7 @@ body {
|
|||||||
padding: 12px;
|
padding: 12px;
|
||||||
border-left: 4px solid transparent;
|
border-left: 4px solid transparent;
|
||||||
transition: all 0.3s ease;
|
transition: all 0.3s ease;
|
||||||
|
margin-bottom: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.player-card.active {
|
.player-card.active {
|
||||||
@@ -96,7 +203,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.player-1 {
|
.player-1 {
|
||||||
border-left-color: var(--player1-color);
|
border-left-color: #4ecca3;
|
||||||
}
|
}
|
||||||
|
|
||||||
.player-1.active {
|
.player-1.active {
|
||||||
@@ -104,13 +211,44 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.player-2 {
|
.player-2 {
|
||||||
border-left-color: var(--player2-color);
|
border-left-color: #e94560;
|
||||||
}
|
}
|
||||||
|
|
||||||
.player-2.active {
|
.player-2.active {
|
||||||
background: linear-gradient(135deg, var(--bg-panel), rgba(233, 69, 96, 0.1));
|
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 {
|
.player-card h3 {
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
@@ -231,6 +369,32 @@ body {
|
|||||||
min-height: 40px;
|
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 {
|
.action-buttons {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
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 { describe, it } = require('node:test');
|
||||||
const assert = require('node:assert');
|
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', () => {
|
describe('HexCell', () => {
|
||||||
it('should create a cell with axial coordinates', () => {
|
it('should create a cell with axial coordinates', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user