From 25385de4d413ccda47c5f37fd663a7672453bea9 Mon Sep 17 00:00:00 2001 From: sokol Date: Sat, 21 Feb 2026 17:23:09 +0300 Subject: [PATCH] Implement hexagonal map and isometric game UI --- QWEN.md | 122 ++++++++++ README.md | 35 +++ package.json | 17 ++ public/game.js | 570 ++++++++++++++++++++++++++++++++++++++++++++++ public/index.html | 109 +++++++++ public/map.js | 173 ++++++++++++++ public/styles.css | 378 ++++++++++++++++++++++++++++++ server.js | 46 ++++ src/index.js | 28 +++ src/map.js | 268 ++++++++++++++++++++++ test/map.test.js | 400 ++++++++++++++++++++++++++++++++ 11 files changed, 2146 insertions(+) create mode 100644 QWEN.md create mode 100644 package.json create mode 100644 public/game.js create mode 100644 public/index.html create mode 100644 public/map.js create mode 100644 public/styles.css create mode 100644 server.js create mode 100644 src/index.js create mode 100644 src/map.js create mode 100644 test/map.test.js diff --git a/QWEN.md b/QWEN.md new file mode 100644 index 0000000..55def0f --- /dev/null +++ b/QWEN.md @@ -0,0 +1,122 @@ +# Project Context: hexo + +## Project Overview + +**hexo** is an educational game project - a clone of [DiceWars](https://www.gamedesign.jp/games/dicewars/). The project is in early development stage. + +### Game Concept + +A strategy dice game played on a hexagonal grid where players command armies of dice and battle to conquer territories. + +### Core Game Mechanics + +#### Map System +- Generatable hexagonal grid map (20x20 cells) +- Each cell can be either playable or blocked/impassable +- Each field can hold up to 8 dice +- Each player-owned field provides +1 supply unit to the player + +#### Dice System +- Standard 6-sided dice +- Unit strength calculation formula: + ``` + F = (cnt-1) * full_dice + current_dice + ``` + Where: + - `cnt` = number of dice on the field + - `full_dice` = maximum die value (6) + - `current_dice` = top die current value (1-6, calculated during gameplay) + +#### Game Rules +1. **Setup**: Multiple dice are placed on the map for each player at the start +2. **Movement Conditions**: A user can move a unit if its strength > 1 + - When moving: strength-1 transfers to target cell, strength 1 remains on source cell +3. **Combat**: When attacking a cell with enemy dice, both players roll: + - **Attacker**: `F_attack = rnd(F-1)` (random value less than original strength minus 1) + - **Defender**: `F_defence = current_strength` (full field strength) + - **Victory**: If `F_attack > F_defence`, attacker wins; otherwise defender repels the attack + - **On Victory**: Attacker leaves strength 1 on source cell, transfers `F_attack-1` to target + - **On Defeat**: Defender retains `F_defence - F_attack` (minimum 1) +4. **Supply Phase**: After all players have moved, each receives supply: + - `S = sum(Cell)` where Cell = supply value from each player-owned cell + - Maximum per cell: `8 * full_dice` (48 dice points) + - If all cells already have maximum strength, no supply is added + +## Directory Structure + +``` +hexo/ +├── README.md # Game specifications and rules (in Russian) +├── QWEN.md # This file - project context for AI assistance +├── .gitignore # Git ignore rules (Python-focused template) +├── jsdom-pkg/ # Local copy of jsdom library +│ └── package/ +│ └── lib/ +│ ├── api.js # Main jsdom API entry point +│ └── jsdom/ # jsdom core implementation +│ ├── browser/ # Browser emulation (Window, parser, resources) +│ ├── living/ # DOM living standard implementations +│ ├── generated/ # Auto-generated Web IDL bindings +│ └── ... # Various DOM/CSS/SVG/XHR implementations +└── node_modules/ + └── jsdom/ # Installed jsdom dependency +``` + +## Technology Stack + +- **Runtime**: Node.js (inferred from jsdom usage) +- **Core Library**: [jsdom](https://github.com/jsdom/jsdom) - JavaScript implementation of DOM and HTML standards + - Provides browser-like environment for server-side JavaScript + - Enables DOM manipulation, event handling, and web API emulation + +## Development Status + +**Early Stage**: The project currently contains: +- Game design documentation (README.md) +- jsdom library setup (both local copy and node_modules installation) +- No main game source files yet + +## Building and Running + +> **TODO**: Build and run commands are not yet defined. The project is in initial setup phase. + +Expected setup once development begins: +```bash +# Install dependencies +npm install + +# Run the game (TBD) +npm start + +# Run tests (TBD) +npm test +``` + +## Development Conventions + +> **TODO**: Coding standards and testing practices are not yet established. + +### Inferred Practices (based on jsdom usage) +- JavaScript/TypeScript expected for implementation +- DOM-based rendering likely planned (given jsdom inclusion) +- Game logic will need to implement: + - Hexagonal grid generation + - Dice mechanics and randomization + - Turn-based combat system + - Player state management + +## Key Implementation Areas + +When development begins, focus on these components: + +1. **Map Generator**: Hexagonal grid creation with passable/impassable cells +2. **Dice Engine**: Randomization and strength calculation +3. **Combat System**: Attack/defense resolution logic +4. **Game State**: Player turns, unit positions, victory conditions +5. **UI/Rendering**: Visual representation of the game board + +## Notes + +- The `.gitignore` file appears to be a Python template and may need to be updated for a JavaScript project +- The `jsdom-pkg` directory contains a local copy of jsdom, possibly for offline development or custom modifications +- Game rules are documented in Russian in README.md diff --git a/README.md b/README.md index 816bc1e..9c6b5ca 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,37 @@ # hexo +Это учебный проект. Игра, клон https://www.gamedesign.jp/games/dicewars/ + +1. Генерируемкая карта + 1. ккаждое поле - ячейка гексагон + 2. размер карты 20 х 20 ячеек + 3. каждое поле может быть доступно для игроков или быть непроходимо для всех + 4. каджое принадлежащее игроку поле дет ему +1 единицу снабжения + +2. игрок управляет кубиками + 1. кубик игральный 6 граннный + 2. на каждом поле может быть до 8 кубиков + 3. сила текущего юнита (ячейка с кубиками) расчитывается, + F = (cnt-1)*full_dice + current_dice, + где cnt - количество кубиков, + full_dice - максимальное значение на кубике = 6 + сurrent_dice - верхний кубик с текущим значением от 1 до 6, расчитываемое в ходе игры + +3. правила игры + 1. вначале на карты помещается несколько кубиков для каждых игроков + 2. юзер может ходить юнитом, если + 1. его сила больше 1, при этом на захваченную клетку перемещается сила-1, а на исходной остается кубик с силой 1 + 2. если на захватываемой клетке вражеские кубики, то оба игрока бросают кости + 1. у нападающего количество максимальной силыменьше на 1 исходной, F_attac=rnd(F-1) + 2. у защищающегося полная сила поля, F_defence=ктв(F) + 3. + 4. условия победы + 1. если F_attac>F_defence - то победа нападающего иначе защищающийся отбил атаку + 2. при победе нападающего он оставляет на исходной клетке кубик с силой 1, на захватываемую переводит F_attac-1, + 3. при пройгрыше у защищающегося отсается F_defence-F_attac, но не меньше 1 + 3. После того, как все игроки походили, каждый получает снабжение по формуле + S=sum(Cell), где Cell - значение снабжения с каждой клетки, принадлежащей игроку + 1. Саксимальное кол-во на клетке может быть 8*Full_dice + 2. Если все клетки игрока имеют максимальное колво силы, то ничего не добавляется. + + \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..079b69b --- /dev/null +++ b/package.json @@ -0,0 +1,17 @@ +{ + "name": "hexo", + "version": "0.1.0", + "description": "Educational dice game - DiceWars clone", + "main": "src/index.js", + "scripts": { + "start": "node src/index.js", + "serve": "node server.js", + "test": "node --test" + }, + "keywords": ["game", "dicewars", "hexagon"], + "author": "", + "license": "MIT", + "dependencies": { + "jsdom": "file:./jsdom-pkg/package" + } +} diff --git a/public/game.js b/public/game.js new file mode 100644 index 0000000..6b502cf --- /dev/null +++ b/public/game.js @@ -0,0 +1,570 @@ +/** + * Hexo Game UI - Canvas Rendering and Interactions + */ + +import { HexMap, CELL_TYPES } from './map.js'; + +// Game constants +const HEX_SIZE = 20; +const MAP_SIZE = 20; +const ANIMATION_DURATION = 300; +const ISO_ANGLE = Math.PI / 6; // 30 degrees for isometric view + +// Colors +const COLORS = { + blocked: '#2a2a4a', + empty: '#3a5a6a', + player1: '#4ecca3', + player2: '#e94560', + highlight: 'rgba(255, 255, 255, 0.3)', + selected: 'rgba(233, 69, 96, 0.6)', + target: 'rgba(78, 204, 163, 0.5)', + stroke: '#1a1a2e', + text: '#eeeeee', + textDark: '#1a1a2e', + dice: '#ffffff', + diceBorder: '#333333' +}; + +/** + * Game UI Controller + */ +class GameUI { + constructor() { + this.canvas = document.getElementById('game-canvas'); + this.ctx = this.canvas.getContext('2d'); + this.map = null; + this.selectedCell = null; + this.currentTarget = null; + this.currentPlayer = 1; + this.gamePhase = 'movement'; // movement, supply, gameover + this.hasMoved = false; + + this.offsetX = 0; + this.offsetY = 0; + + this.init(); + } + + init() { + this.setupEventListeners(); + this.newGame(); + console.log('Game initialized'); + } + + newGame() { + this.map = new HexMap(MAP_SIZE); + console.log('Map created, cells:', this.map.cells.size); + this.selectedCell = null; + this.currentTarget = null; + this.currentPlayer = 1; + this.gamePhase = 'movement'; + this.hasMoved = false; + + // Initialize starting positions + this.initializePlayers(); + + this.centerMap(); + this.render(); + this.updateUI(); + this.log('New game started! Player 1\'s turn.'); + } + + initializePlayers() { + // Get random empty cells for each player + const emptyCells = this.map.getEmptyCells(); + + // Shuffle and pick starting positions + const shuffled = emptyCells.sort(() => Math.random() - 0.5); + + // Player 1 starting position (top-left area) + const p1Cell = shuffled.find(c => c.q < 8 && c.r < 8); + if (p1Cell) { + this.map.setOwner(p1Cell.q, p1Cell.r, 1); + p1Cell.setStrength(8); // Starting strength + } + + // Player 2 starting position (bottom-right area) + const p2Cell = shuffled.find(c => c.q > 10 && c.r > 10 && c.type === CELL_TYPES.EMPTY); + if (p2Cell) { + this.map.setOwner(p2Cell.q, p2Cell.r, 2); + p2Cell.setStrength(8); + } + } + + centerMap() { + const canvasWidth = this.canvas.width; + const canvasHeight = this.canvas.height; + + // Calculate map dimensions for isometric view + const mapWidth = MAP_SIZE * HEX_SIZE * 2; + const mapHeight = MAP_SIZE * HEX_SIZE; + + this.offsetX = canvasWidth / 2; + this.offsetY = HEX_SIZE * 2; + } + + setupEventListeners() { + this.canvas.addEventListener('click', (e) => this.handleClick(e)); + this.canvas.addEventListener('mousemove', (e) => this.handleMouseMove(e)); + + document.getElementById('end-turn-btn').addEventListener('click', () => this.endTurn()); + document.getElementById('new-game-btn').addEventListener('click', () => this.newGame()); + document.getElementById('attack-btn').addEventListener('click', () => this.executeAttack()); + document.getElementById('cancel-btn').addEventListener('click', () => this.cancelSelection()); + } + + hexToPixel(q, r) { + // Isometric projection for pointy-top hex grid + // q axis goes top-left to bottom-right + // r axis goes top-right to bottom-left + const isoX = (q - r) * HEX_SIZE * 1.5; + const isoY = (q + r) * HEX_SIZE * 0.75; + + return { + x: this.offsetX + isoX, + y: this.offsetY + isoY + }; + } + + pixelToHex(x, y) { + const adjX = x - this.offsetX; + const adjY = y - this.offsetY; + + // Reverse isometric projection + // From: isoX = (q - r) * HEX_SIZE * 1.5 + // isoY = (q + r) * HEX_SIZE * 0.75 + const q = (adjX / (HEX_SIZE * 1.5) + adjY / (HEX_SIZE * 0.75)) / 2; + const r = (adjY / (HEX_SIZE * 0.75) - adjX / (HEX_SIZE * 1.5)) / 2; + + const qi = Math.round(q); + const ri = Math.round(r); + + if (qi >= 0 && qi < MAP_SIZE && ri >= 0 && ri < MAP_SIZE) { + return { q: qi, r: ri }; + } + return null; + } + + /** + * Get neighboring cells for isometric hex grid + * Directions for pointy-top hex in axial coordinates with isometric projection: + * hexToPixel: isoX = (q-r)*1.5, isoY = (q+r)*0.75 + * + * Visual directions: + * - top: (-1, -1) - both decrease + * - bottom: (+1, +1) - both increase + * - top-left: (-1, 0) - q decreases + * - bottom-left: (0, +1) - r increases + * - top-right: (0, -1) - r decreases + * - bottom-right: (+1, 0) - q increases + */ + getValidTargets(q, r) { + const directions = [ + [+1, 0], // bottom-right + [+1, +1], // bottom (straight down) + [0, +1], // bottom-left + [-1, 0], // top-left + [-1, -1], // top (straight up) + [0, -1] // top-right + ]; + + const targets = []; + for (const [dq, dr] of directions) { + const nq = q + dq; + const nr = r + dr; + if (nq >= 0 && nq < MAP_SIZE && nr >= 0 && nr < MAP_SIZE) { + const cell = this.map.getCell(nq, nr); + if (cell && cell.isPassable()) { + targets.push(cell); + } + } + } + return targets; + } + + drawHex(q, r, fillStyle, strokeStyle = COLORS.stroke, lineWidth = 2) { + const { x, y } = this.hexToPixel(q, r); + + // Draw elongated hexagon for isometric view + const hexWidth = HEX_SIZE * 1.3; + const hexHeight = HEX_SIZE * 0.75; + + // Hexagon vertices for isometric view (elongated horizontally) + const vertices = [ + { x: x + hexWidth, y: y }, // right + { x: x + hexWidth * 0.5, y: y - hexHeight }, // top-right + { x: x - hexWidth * 0.5, y: y - hexHeight }, // top-left + { x: x - hexWidth, y: y }, // left + { x: x - hexWidth * 0.5, y: y + hexHeight }, // bottom-left + { x: x + hexWidth * 0.5, y: y + hexHeight } // bottom-right + ]; + + this.ctx.beginPath(); + this.ctx.moveTo(vertices[0].x, vertices[0].y); + for (let i = 1; i < vertices.length; i++) { + this.ctx.lineTo(vertices[i].x, vertices[i].y); + } + this.ctx.closePath(); + + this.ctx.fillStyle = fillStyle; + this.ctx.fill(); + + this.ctx.strokeStyle = strokeStyle; + this.ctx.lineWidth = lineWidth; + this.ctx.stroke(); + } + + drawDice(cell) { + if (cell.dice.length === 0) return; + + const { x, y } = this.hexToPixel(cell.q, cell.r); + const strength = cell.getStrength(); + + // Draw strength number + this.ctx.fillStyle = COLORS.text; + this.ctx.font = 'bold 11px Arial'; + this.ctx.textAlign = 'center'; + this.ctx.textBaseline = 'middle'; + this.ctx.fillText(strength.toString(), x, y - 3); + + // Draw dice count indicator (small dots) + if (cell.dice.length > 1) { + const dotY = y + 8; + for (let i = 0; i < Math.min(cell.dice.length, 5); i++) { + this.ctx.beginPath(); + this.ctx.arc(x - 6 + i * 4, dotY, 1.5, 0, Math.PI * 2); + this.ctx.fillStyle = COLORS.dice; + this.ctx.fill(); + } + } + } + + drawOwnerIndicator(cell) { + if (!cell.isOwned()) return; + + const { x, y } = this.hexToPixel(cell.q, cell.r); + const ownerColor = cell.getOwner() === 1 ? COLORS.player1 : COLORS.player2; + + // Draw border matching the hexagon shape + const hexWidth = HEX_SIZE * 1.3 - 3; + const hexHeight = HEX_SIZE * 0.75 - 3; + + const vertices = [ + { x: x + hexWidth, y: y }, + { x: x + hexWidth * 0.5, y: y - hexHeight }, + { x: x - hexWidth * 0.5, y: y - hexHeight }, + { x: x - hexWidth, y: y }, + { x: x - hexWidth * 0.5, y: y + hexHeight }, + { x: x + hexWidth * 0.5, y: y + hexHeight } + ]; + + this.ctx.beginPath(); + this.ctx.moveTo(vertices[0].x, vertices[0].y); + for (let i = 1; i < vertices.length; i++) { + this.ctx.lineTo(vertices[i].x, vertices[i].y); + } + this.ctx.closePath(); + this.ctx.strokeStyle = ownerColor; + this.ctx.lineWidth = 3; + this.ctx.stroke(); + } + + render() { + // Clear canvas + this.ctx.fillStyle = COLORS.stroke; + this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height); + + if (!this.map) return; + + // Draw all cells + for (let r = 0; r < MAP_SIZE; r++) { + for (let q = 0; q < MAP_SIZE; q++) { + const cell = this.map.getCell(q, r); + let color; + + if (cell.type === CELL_TYPES.BLOCKED) { + color = COLORS.blocked; + } else if (cell.type === CELL_TYPES.PLAYER1) { + color = COLORS.player1; + } else if (cell.type === CELL_TYPES.PLAYER2) { + color = COLORS.player2; + } else { + color = COLORS.empty; + } + + // Apply transparency for non-owned cells + if (!cell.isOwned()) { + color = this.hexToRgba(color, 0.6); + } + + this.drawHex(q, r, color); + this.drawOwnerIndicator(cell); + this.drawDice(cell); + } + } + + // Draw selection highlight + if (this.selectedCell) { + this.drawHex( + this.selectedCell.q, + this.selectedCell.r, + COLORS.selected, + COLORS.text, + 3 + ); + + // Highlight valid targets (not blocked, not own) + const targets = this.getValidTargets(this.selectedCell.q, this.selectedCell.r); + for (const target of targets) { + if (target.getOwner() !== this.currentPlayer) { + this.drawHex( + target.q, + target.r, + COLORS.target, + COLORS.player1, + 2 + ); + } + } + } + } + + hexToRgba(hex, alpha) { + const r = parseInt(hex.slice(1, 3), 16); + const g = parseInt(hex.slice(3, 5), 16); + const b = parseInt(hex.slice(5, 7), 16); + return `rgba(${r}, ${g}, ${b}, ${alpha})`; + } + + handleClick(e) { + if (this.gamePhase !== 'movement') return; + + const rect = this.canvas.getBoundingClientRect(); + const x = e.clientX - rect.left; + const y = e.clientY - rect.top; + + const hexPos = this.pixelToHex(x, y); + if (!hexPos) return; + + const cell = this.map.getCell(hexPos.q, hexPos.r); + if (!cell || !cell.isPassable()) return; + + // Handle cell selection + if (this.selectedCell === null) { + // Select own cell with strength > 1 + if (cell.getOwner() === this.currentPlayer && cell.getStrength() > 1) { + this.selectedCell = cell; + this.instruction = `Select target cell or cancel`; + this.updateUI(); + this.render(); + } + } else { + // Check if clicking on same cell - deselect + if (cell === this.selectedCell) { + this.cancelSelection(); + return; + } + + // Check if valid target using isometric-aware adjacency + const targets = this.getValidTargets(this.selectedCell.q, this.selectedCell.r); + const isValidTarget = targets.some(n => n.q === cell.q && n.r === cell.r); + + // Cannot move to own cells - only attack enemy or capture empty + if (cell.getOwner() === this.currentPlayer) { + // Select different own cell instead + if (cell.getStrength() > 1) { + this.selectedCell = cell; + this.updateUI(); + this.render(); + } + return; + } + + if (isValidTarget) { + this.currentTarget = cell; + this.executeAttack(); + } else { + this.cancelSelection(); + } + } + } + + handleMouseMove(e) { + // Could add hover effects here + } + + executeAttack() { + if (!this.selectedCell || !this.currentTarget) return; + + const attacker = this.selectedCell; + const defender = this.currentTarget; + const attackStrength = attacker.getStrength() - 1; + + if (defender.type === CELL_TYPES.EMPTY || defender.getOwner() !== this.currentPlayer) { + // Attack empty or enemy cell + let defenseStrength = defender.getStrength(); + + if (defenseStrength > 0) { + // Combat! Roll dice + const attackRoll = Math.floor(Math.random() * attackStrength) + 1; + const defenseRoll = Math.floor(Math.random() * defenseStrength) + 1; + + this.log(`Attack: ${attackStrength} vs ${defenseStrength} | Roll: ${attackRoll} vs ${defenseRoll}`); + + if (attackRoll > defenseRoll) { + // Attacker wins + const remainingStrength = attackRoll - 1; + attacker.setStrength(1); + + if (remainingStrength > 0) { + defender.setStrength(remainingStrength); + this.map.setOwner(defender.q, defender.r, this.currentPlayer); + this.log(`Victory! Captured cell with strength ${remainingStrength}`, 'victory'); + } + } else { + // Defender wins + const remainingDefense = defenseRoll - attackRoll; + defender.setStrength(Math.max(1, remainingDefense)); + this.log(`Attack repelled! Defender has ${Math.max(1, remainingDefense)} strength`, 'defeat'); + } + } else { + // Move to empty cell - transfer attackStrength (original - 1) + attacker.setStrength(1); + defender.setStrength(attackStrength); + this.map.setOwner(defender.q, defender.r, this.currentPlayer); + this.log(`Moved to empty cell with strength ${attackStrength}`); + } + } + + this.hasMoved = true; + this.cancelSelection(); + this.render(); + this.updateUI(); + } + + cancelSelection() { + this.selectedCell = null; + this.currentTarget = null; + this.updateUI(); + this.render(); + } + + endTurn() { + if (this.gamePhase !== 'movement') return; + + // Apply supply + const supply = this.map.calculateSupply(this.currentPlayer); + this.distributeSupply(supply); + + this.log(`Player ${this.currentPlayer} received ${supply} supply`); + + // Switch player + this.currentPlayer = this.currentPlayer === 1 ? 2 : 1; + this.hasMoved = false; + + this.cancelSelection(); + this.updateUI(); + this.render(); + + this.log(`Player ${this.currentPlayer}'s turn`); + } + + distributeSupply(supply) { + const playerCells = this.map.getPlayerCells(this.currentPlayer); + + // Find cells that can receive more dice + const eligibleCells = playerCells.filter(cell => !cell.isMaxStrength()); + + if (eligibleCells.length === 0 || supply === 0) return; + + // Distribute supply randomly among eligible cells + let remainingSupply = supply; + while (remainingSupply > 0 && eligibleCells.length > 0) { + const randomCell = eligibleCells[Math.floor(Math.random() * eligibleCells.length)]; + const currentStrength = randomCell.getStrength(); + + if (currentStrength < 48) { + const addStrength = Math.min(remainingSupply, 48 - currentStrength); + const newStrength = currentStrength + addStrength; + randomCell.setStrength(newStrength); + remainingSupply -= addStrength; + } + + if (randomCell.isMaxStrength()) { + eligibleCells.splice(eligibleCells.indexOf(randomCell), 1); + } + } + } + + updateUI() { + // Update player stats + const p1Cells = this.map.getPlayerCells(1); + const p2Cells = this.map.getPlayerCells(2); + + const p1Strength = p1Cells.reduce((sum, c) => sum + c.getStrength(), 0); + const p2Strength = p2Cells.reduce((sum, c) => sum + c.getStrength(), 0); + + document.getElementById('player1-cells').textContent = p1Cells.length; + document.getElementById('player1-supply').textContent = this.map.calculateSupply(1); + document.getElementById('player1-strength').textContent = p1Strength; + + document.getElementById('player2-cells').textContent = p2Cells.length; + document.getElementById('player2-supply').textContent = this.map.calculateSupply(2); + document.getElementById('player2-strength').textContent = p2Strength; + + // Update active player + document.getElementById('player1-card').classList.toggle('active', this.currentPlayer === 1); + document.getElementById('player2-card').classList.toggle('active', this.currentPlayer === 2); + + // Update game info + document.getElementById('current-turn').textContent = this.currentPlayer; + document.getElementById('game-phase').textContent = this.gamePhase; + + // Update instruction + const instruction = document.getElementById('action-instruction'); + if (this.selectedCell) { + instruction.textContent = `Select target to attack (strength: ${this.selectedCell.getStrength()})`; + } else { + instruction.textContent = 'Select a cell with dice to move'; + } + + // Update buttons + const attackBtn = document.getElementById('attack-btn'); + const cancelBtn = document.getElementById('cancel-btn'); + const endTurnBtn = document.getElementById('end-turn-btn'); + + attackBtn.disabled = !this.selectedCell; + cancelBtn.disabled = !this.selectedCell; + endTurnBtn.disabled = !this.hasMoved; + } + + log(message, type = '') { + const logContainer = document.getElementById('battle-log'); + const entry = document.createElement('div'); + entry.className = `log-entry ${type}`; + entry.textContent = `[Turn ${this.currentPlayer}] ${message}`; + logContainer.insertBefore(entry, logContainer.firstChild); + + // Keep only last 50 entries + while (logContainer.children.length > 50) { + logContainer.removeChild(logContainer.lastChild); + } + } + + showMessage(message) { + const overlay = document.getElementById('message-overlay'); + const messageEl = document.getElementById('overlay-message'); + messageEl.textContent = message; + overlay.classList.add('visible'); + + setTimeout(() => { + overlay.classList.remove('visible'); + }, 2000); + } +} + +// Initialize game when DOM is ready +document.addEventListener('DOMContentLoaded', () => { + window.game = new GameUI(); +}); diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..2ec74b1 --- /dev/null +++ b/public/index.html @@ -0,0 +1,109 @@ + + + + + + HEXO - DiceWars Clone + + + +
+ +
+

HEXO

+

DiceWars Clone

+
+ + +
+ + + + +
+ +
+ +
+
+ + + +
+ + +
+

Click on your cell with dice, then click adjacent cell to attack

+
+
+ + + + diff --git a/public/map.js b/public/map.js new file mode 100644 index 0000000..c76a9c8 --- /dev/null +++ b/public/map.js @@ -0,0 +1,173 @@ +/** + * Hexagonal grid map for the DiceWars game. + * Browser version (ES Module) + */ + +const MAP_SIZE = 20; +const CELL_TYPES = { + EMPTY: 0, + BLOCKED: 1, + PLAYER1: 2, + PLAYER2: 3, +}; + +class HexCell { + constructor(q, r, type = CELL_TYPES.EMPTY) { + this.q = q; + this.r = r; + this.type = type; + this.dice = []; + } + + getStrength() { + if (this.dice.length === 0) return 0; + const cnt = this.dice.length; + const fullDice = 6; + const currentDice = this.dice[this.dice.length - 1]; + return (cnt - 1) * fullDice + currentDice; + } + + isMaxStrength() { + return this.dice.length >= 8 && this.getStrength() >= 48; + } + + addDie(value) { + if (this.dice.length < 8) { + this.dice.push(value); + return true; + } + return false; + } + + setStrength(targetStrength) { + if (targetStrength <= 0) { + this.dice = []; + return; + } + + const fullDice = 6; + const cnt = Math.floor((targetStrength - 1) / fullDice) + 1; + const remainder = targetStrength - (cnt - 1) * fullDice; + + this.dice = new Array(cnt - 1).fill(6); + if (remainder > 0) { + this.dice.push(remainder); + } + } + + isPassable() { + return this.type !== CELL_TYPES.BLOCKED; + } + + isOwned() { + return this.type === CELL_TYPES.PLAYER1 || this.type === CELL_TYPES.PLAYER2; + } + + getOwner() { + if (this.type === CELL_TYPES.PLAYER1) return 1; + if (this.type === CELL_TYPES.PLAYER2) return 2; + return 0; + } +} + +class HexMap { + constructor(size = MAP_SIZE) { + this.size = size; + this.cells = new Map(); + this.generate(); + } + + generate() { + this.cells.clear(); + + for (let q = 0; q < this.size; q++) { + for (let r = 0; r < this.size; r++) { + const key = this.getKey(q, r); + const type = Math.random() < 0.15 ? CELL_TYPES.BLOCKED : CELL_TYPES.EMPTY; + this.cells.set(key, new HexCell(q, r, type)); + } + } + } + + getCell(q, r) { + const key = this.getKey(q, r); + return this.cells.get(key); + } + + getCellByKey(key) { + return this.cells.get(key); + } + + getKey(q, r) { + return `${q},${r}`; + } + + getPassableCells() { + return Array.from(this.cells.values()).filter(cell => cell.isPassable()); + } + + getEmptyCells() { + return Array.from(this.cells.values()).filter( + cell => cell.isPassable() && !cell.isOwned() + ); + } + + getPlayerCells(playerId) { + const targetType = playerId === 1 ? CELL_TYPES.PLAYER1 : CELL_TYPES.PLAYER2; + return Array.from(this.cells.values()).filter( + cell => cell.type === targetType + ); + } + + getNeighbors(q, r) { + const directions = [ + [1, 0], [1, -1], [0, -1], + [-1, 0], [-1, 1], [0, 1] + ]; + + const neighbors = []; + for (const [dq, dr] of directions) { + const nq = q + dq; + const nr = r + dr; + if (nq >= 0 && nq < this.size && nr >= 0 && nr < this.size) { + const cell = this.getCell(nq, nr); + if (cell && cell.isPassable()) { + neighbors.push(cell); + } + } + } + return neighbors; + } + + calculateSupply(playerId) { + const playerCells = this.getPlayerCells(playerId); + let supply = 0; + + for (const cell of playerCells) { + supply += 1; + } + + return supply; + } + + setOwner(q, r, playerId) { + const cell = this.getCell(q, r); + if (cell && cell.isPassable()) { + cell.type = playerId === 1 ? CELL_TYPES.PLAYER1 : CELL_TYPES.PLAYER2; + return true; + } + return false; + } + + clearOwner(q, r) { + const cell = this.getCell(q, r); + if (cell) { + cell.type = CELL_TYPES.EMPTY; + cell.dice = []; + return true; + } + return false; + } +} + +export { HexMap, HexCell, CELL_TYPES, MAP_SIZE }; diff --git a/public/styles.css b/public/styles.css new file mode 100644 index 0000000..b86a09c --- /dev/null +++ b/public/styles.css @@ -0,0 +1,378 @@ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +:root { + --bg-primary: #1a1a2e; + --bg-secondary: #16213e; + --bg-panel: #0f3460; + --text-primary: #eee; + --text-secondary: #aaa; + --accent-primary: #e94560; + --accent-secondary: #00adb5; + --player1-color: #4ecca3; + --player2-color: #e94560; + --blocked-color: #2a2a4a; + --empty-color: #3a5a6a; + --highlight-color: rgba(255, 255, 255, 0.3); + --selected-color: rgba(233, 69, 96, 0.5); + --target-color: rgba(78, 204, 163, 0.5); + --hex-stroke: #1a1a2e; +} + +body { + font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; + background: var(--bg-primary); + color: var(--text-primary); + min-height: 100vh; + overflow: hidden; +} + +.game-container { + display: flex; + flex-direction: column; + height: 100vh; +} + +/* Header */ +.game-header { + background: var(--bg-secondary); + padding: 10px 20px; + text-align: center; + border-bottom: 2px solid var(--accent-primary); +} + +.game-header h1 { + font-size: 1.8rem; + color: var(--accent-primary); + text-transform: uppercase; + letter-spacing: 4px; +} + +.subtitle { + font-size: 0.9rem; + color: var(--text-secondary); +} + +/* Game Area */ +.game-area { + display: flex; + flex: 1; + overflow: hidden; +} + +/* Side Panels */ +.side-panel { + width: 220px; + background: var(--bg-secondary); + padding: 15px; + display: flex; + flex-direction: column; + gap: 15px; + overflow-y: auto; +} + +.left-panel { + border-right: 1px solid var(--bg-panel); +} + +.right-panel { + border-left: 1px solid var(--bg-panel); +} + +/* Player Cards */ +.player-card { + background: var(--bg-panel); + border-radius: 8px; + padding: 12px; + border-left: 4px solid transparent; + transition: all 0.3s ease; +} + +.player-card.active { + box-shadow: 0 0 15px rgba(233, 69, 96, 0.4); +} + +.player-1 { + border-left-color: var(--player1-color); +} + +.player-1.active { + background: linear-gradient(135deg, var(--bg-panel), rgba(78, 204, 163, 0.1)); +} + +.player-2 { + border-left-color: var(--player2-color); +} + +.player-2.active { + background: linear-gradient(135deg, var(--bg-panel), rgba(233, 69, 96, 0.1)); +} + +.player-card h3 { + font-size: 1rem; + margin-bottom: 10px; + text-transform: uppercase; + letter-spacing: 1px; +} + +.player-stats { + display: flex; + flex-direction: column; + gap: 6px; +} + +.stat { + display: flex; + justify-content: space-between; + font-size: 0.85rem; +} + +.stat-label { + color: var(--text-secondary); +} + +.stat-value { + font-weight: bold; + color: var(--text-primary); +} + +/* Game Info */ +.game-info { + background: var(--bg-panel); + border-radius: 8px; + padding: 12px; +} + +.game-info h3 { + font-size: 0.9rem; + margin-bottom: 10px; + color: var(--text-secondary); +} + +.info-item { + display: flex; + justify-content: space-between; + font-size: 0.85rem; + padding: 4px 0; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); +} + +.info-item:last-child { + border-bottom: none; +} + +/* Buttons */ +.btn { + padding: 10px 16px; + border: none; + border-radius: 6px; + cursor: pointer; + font-size: 0.9rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 1px; + transition: all 0.2s ease; +} + +.btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.btn-primary { + background: var(--accent-primary); + color: white; +} + +.btn-primary:hover:not(:disabled) { + background: #ff6b6b; + transform: translateY(-2px); +} + +.btn-secondary { + background: var(--bg-panel); + color: var(--text-primary); +} + +.btn-secondary:hover:not(:disabled) { + background: var(--accent-secondary); +} + +.btn-action { + background: var(--accent-secondary); + color: white; + flex: 1; +} + +.btn-action:hover:not(:disabled) { + background: #00cec9; +} + +/* Actions Panel */ +.actions-panel { + background: var(--bg-panel); + border-radius: 8px; + padding: 12px; +} + +.actions-panel h3 { + font-size: 0.9rem; + margin-bottom: 8px; + color: var(--text-secondary); +} + +.instruction { + font-size: 0.8rem; + color: var(--text-primary); + margin-bottom: 12px; + min-height: 40px; +} + +.action-buttons { + display: flex; + gap: 8px; +} + +/* Battle Log */ +.battle-log { + background: var(--bg-panel); + border-radius: 8px; + padding: 12px; + flex: 1; + display: flex; + flex-direction: column; + min-height: 200px; +} + +.battle-log h3 { + font-size: 0.9rem; + margin-bottom: 10px; + color: var(--text-secondary); +} + +.log-entries { + flex: 1; + overflow-y: auto; + font-size: 0.75rem; + font-family: 'Consolas', monospace; +} + +.log-entry { + padding: 4px 6px; + margin-bottom: 4px; + background: rgba(0, 0, 0, 0.2); + border-radius: 4px; + border-left: 3px solid var(--accent-secondary); +} + +.log-entry.attack { + border-left-color: var(--accent-primary); +} + +.log-entry.victory { + border-left-color: var(--player1-color); +} + +.log-entry.defeat { + border-left-color: var(--player2-color); +} + +/* Canvas Container */ +.canvas-container { + flex: 1; + display: flex; + justify-content: center; + align-items: center; + background: var(--bg-primary); + position: relative; + overflow: hidden; + min-width: 600px; + min-height: 600px; +} + +#game-canvas { + border-radius: 8px; + box-shadow: 0 10px 40px rgba(0, 0, 0, 0.4); + display: block; +} + +.canvas-overlay { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + background: rgba(26, 26, 46, 0.95); + padding: 30px 50px; + border-radius: 12px; + border: 2px solid var(--accent-primary); + display: none; + z-index: 10; +} + +.canvas-overlay.visible { + display: block; +} + +#overlay-message { + font-size: 1.5rem; + color: var(--accent-primary); + text-transform: uppercase; + letter-spacing: 3px; +} + +/* Footer */ +.game-footer { + background: var(--bg-secondary); + padding: 8px 20px; + text-align: center; + border-top: 1px solid var(--bg-panel); +} + +.game-footer p { + font-size: 0.8rem; + color: var(--text-secondary); +} + +/* Scrollbar */ +::-webkit-scrollbar { + width: 8px; +} + +::-webkit-scrollbar-track { + background: var(--bg-primary); +} + +::-webkit-scrollbar-thumb { + background: var(--bg-panel); + border-radius: 4px; +} + +::-webkit-scrollbar-thumb:hover { + background: var(--accent-secondary); +} + +/* Responsive */ +@media (max-width: 1200px) { + .side-panel { + width: 180px; + } +} + +@media (max-width: 900px) { + .game-area { + flex-direction: column; + } + + .side-panel { + width: 100%; + flex-direction: row; + flex-wrap: wrap; + } + + .canvas-container { + min-height: 400px; + } +} diff --git a/server.js b/server.js new file mode 100644 index 0000000..a7faada --- /dev/null +++ b/server.js @@ -0,0 +1,46 @@ +const http = require('http'); +const fs = require('fs'); +const path = require('path'); + +const PORT = 8080; + +const MIME_TYPES = { + '.html': 'text/html', + '.css': 'text/css', + '.js': 'application/javascript', + '.json': 'application/json', + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.gif': 'image/gif', + '.svg': 'image/svg+xml', + '.ico': 'image/x-icon' +}; + +const server = http.createServer((req, res) => { + let filePath = req.url === '/' ? '/index.html' : req.url; + filePath = path.join(__dirname, 'public', filePath); + + const ext = path.extname(filePath); + const contentType = MIME_TYPES[ext] || 'application/octet-stream'; + + fs.readFile(filePath, (err, content) => { + if (err) { + if (err.code === 'ENOENT') { + res.writeHead(404); + res.end('File not found'); + } else { + res.writeHead(500); + res.end('Server error'); + } + } else { + res.writeHead(200, { 'Content-Type': contentType }); + res.end(content); + } + }); +}); + +server.listen(PORT, () => { + console.log(`\n=== HEXO Game Server ===`); + console.log(`Server running at http://localhost:${PORT}/`); + console.log(`Press Ctrl+C to stop\n`); +}); diff --git a/src/index.js b/src/index.js new file mode 100644 index 0000000..fde38af --- /dev/null +++ b/src/index.js @@ -0,0 +1,28 @@ +/** + * Hexo - DiceWars Clone + * Main entry point + */ + +const { HexMap, CELL_TYPES } = require('./map.js'); + +console.log('=== HEXO - DiceWars Clone ===\n'); + +// Create and display the map +const map = new HexMap(20); + +console.log('Generated Map (20x20 hexagonal grid):\n'); +console.log('Legend: [██]=Blocked, [P1]=Player1, [P2]=Player2, [nn]=Strength, [ ]=Empty\n'); + +map.render(); + +// Show some statistics +const passableCells = map.getPassableCells().length; +const blockedCells = Array.from(map.cells.values()).filter( + cell => cell.type === CELL_TYPES.BLOCKED +).length; + +console.log(`\n=== Map Statistics ===`); +console.log(`Total cells: ${map.size * map.size}`); +console.log(`Passable: ${passableCells}`); +console.log(`Blocked: ${blockedCells}`); +console.log(`Blocked %: ${((blockedCells / (map.size * map.size)) * 100).toFixed(1)}%`); diff --git a/src/map.js b/src/map.js new file mode 100644 index 0000000..2704f91 --- /dev/null +++ b/src/map.js @@ -0,0 +1,268 @@ +/** + * Hexagonal grid map for the DiceWars game. + * + * Map is a 20x20 hexagonal grid where each cell can be: + * - passable (playable) + * - impassable (blocked) + * + * Uses axial coordinates (q, r) for hexagon positioning. + */ + +const MAP_SIZE = 20; +const CELL_TYPES = { + EMPTY: 0, // Passable, unowned + BLOCKED: 1, // Impassable terrain + PLAYER1: 2, // Player 1 owned + PLAYER2: 3, // Player 2 owned +}; + +/** + * Represents a single hex cell on the map + */ +class HexCell { + constructor(q, r, type = CELL_TYPES.EMPTY) { + this.q = q; // Axial coordinate q + this.r = r; // Axial coordinate r + this.type = type; // Cell ownership/type + this.dice = []; // Array of dice values on this cell + } + + /** + * Calculate unit strength: F = (cnt-1)*full_dice + current_dice + */ + getStrength() { + if (this.dice.length === 0) return 0; + const cnt = this.dice.length; + const fullDice = 6; + const currentDice = this.dice[this.dice.length - 1]; // Top die + return (cnt - 1) * fullDice + currentDice; + } + + /** + * Check if cell has maximum strength (8 dice with 6 on top) + */ + isMaxStrength() { + return this.dice.length >= 8 && this.getStrength() >= 48; + } + + /** + * Add a die to this cell + */ + addDie(value) { + if (this.dice.length < 8) { + this.dice.push(value); + return true; + } + return false; + } + + /** + * Remove dice from cell, leaving specified strength + */ + setStrength(targetStrength) { + if (targetStrength <= 0) { + this.dice = []; + return; + } + + const fullDice = 6; + const cnt = Math.floor((targetStrength - 1) / fullDice) + 1; + const remainder = targetStrength - (cnt - 1) * fullDice; + + this.dice = new Array(cnt - 1).fill(6); + if (remainder > 0) { + this.dice.push(remainder); + } + } + + isPassable() { + return this.type !== CELL_TYPES.BLOCKED; + } + + isOwned() { + return this.type === CELL_TYPES.PLAYER1 || this.type === CELL_TYPES.PLAYER2; + } + + getOwner() { + if (this.type === CELL_TYPES.PLAYER1) return 1; + if (this.type === CELL_TYPES.PLAYER2) return 2; + return 0; + } +} + +/** + * Hexagonal map generator and manager + */ +class HexMap { + constructor(size = MAP_SIZE) { + this.size = size; + this.cells = new Map(); // Key: "q,r", Value: HexCell + this.generate(); + } + + /** + * Generate the hexagonal grid + */ + generate() { + this.cells.clear(); + + for (let q = 0; q < this.size; q++) { + for (let r = 0; r < this.size; r++) { + const key = this.getKey(q, r); + const type = Math.random() < 0.15 ? CELL_TYPES.BLOCKED : CELL_TYPES.EMPTY; + this.cells.set(key, new HexCell(q, r, type)); + } + } + } + + /** + * Get cell by axial coordinates + */ + getCell(q, r) { + const key = this.getKey(q, r); + return this.cells.get(key); + } + + /** + * Get cell by key string + */ + getCellByKey(key) { + return this.cells.get(key); + } + + /** + * Generate key from coordinates + */ + getKey(q, r) { + return `${q},${r}`; + } + + /** + * Get all passable cells + */ + getPassableCells() { + return Array.from(this.cells.values()).filter(cell => cell.isPassable()); + } + + /** + * Get all empty (unowned) passable cells + */ + getEmptyCells() { + return Array.from(this.cells.values()).filter( + cell => cell.isPassable() && !cell.isOwned() + ); + } + + /** + * Get all cells owned by a player + */ + getPlayerCells(playerId) { + const targetType = playerId === 1 ? CELL_TYPES.PLAYER1 : CELL_TYPES.PLAYER2; + return Array.from(this.cells.values()).filter( + cell => cell.type === targetType + ); + } + + /** + * Get neighboring cells (6 directions in hex grid) + */ + getNeighbors(q, r) { + const directions = [ + [1, 0], [1, -1], [0, -1], + [-1, 0], [-1, 1], [0, 1] + ]; + + const neighbors = []; + for (const [dq, dr] of directions) { + const nq = q + dq; + const nr = r + dr; + if (nq >= 0 && nq < this.size && nr >= 0 && nr < this.size) { + const cell = this.getCell(nq, nr); + if (cell && cell.isPassable()) { + neighbors.push(cell); + } + } + } + return neighbors; + } + + /** + * Calculate supply for a player: S = sum of all owned cells + */ + calculateSupply(playerId) { + const playerCells = this.getPlayerCells(playerId); + let supply = 0; + + for (const cell of playerCells) { + supply += 1; // Each owned cell gives +1 supply + } + + return supply; + } + + /** + * Render map to console (simplified ASCII representation) + */ + render() { + let output = ''; + + for (let r = 0; r < this.size; r++) { + // Offset every other row for hex appearance + const offset = r % 2 === 0 ? 0 : 2; + output += ' '.repeat(offset); + + for (let q = 0; q < this.size; q++) { + const cell = this.getCell(q, r); + const symbol = this.getCellSymbol(cell); + output += `[${symbol}]`; + } + output += '\n'; + } + + console.log(output); + } + + /** + * Get symbol for cell visualization + */ + getCellSymbol(cell) { + if (cell.type === CELL_TYPES.BLOCKED) return '██'; + if (cell.type === CELL_TYPES.PLAYER1) return 'P1'; + if (cell.type === CELL_TYPES.PLAYER2) return 'P2'; + if (cell.dice.length > 0) return cell.getStrength().toString().padStart(2, ' '); + return ' '; + } + + /** + * Set cell ownership + */ + setOwner(q, r, playerId) { + const cell = this.getCell(q, r); + if (cell && cell.isPassable()) { + cell.type = playerId === 1 ? CELL_TYPES.PLAYER1 : CELL_TYPES.PLAYER2; + return true; + } + return false; + } + + /** + * Clear cell ownership + */ + clearOwner(q, r) { + const cell = this.getCell(q, r); + if (cell) { + cell.type = CELL_TYPES.EMPTY; + cell.dice = []; + return true; + } + return false; + } +} + +// ES Module exports for browser +export { HexMap, HexCell, CELL_TYPES, MAP_SIZE }; + +// CommonJS exports for Node.js +if (typeof module !== 'undefined' && module.exports) { + module.exports = { HexMap, HexCell, CELL_TYPES, MAP_SIZE }; +} diff --git a/test/map.test.js b/test/map.test.js new file mode 100644 index 0000000..87710d3 --- /dev/null +++ b/test/map.test.js @@ -0,0 +1,400 @@ +const { describe, it } = require('node:test'); +const assert = require('node:assert'); +const { HexMap, HexCell, CELL_TYPES, MAP_SIZE } = require('../src/map.js'); + +describe('HexCell', () => { + it('should create a cell with axial coordinates', () => { + const cell = new HexCell(3, 5); + assert.strictEqual(cell.q, 3); + assert.strictEqual(cell.r, 5); + assert.strictEqual(cell.type, CELL_TYPES.EMPTY); + assert.strictEqual(cell.dice.length, 0); + }); + + it('should calculate strength correctly', () => { + const cell = new HexCell(0, 0); + + // Empty cell has 0 strength + assert.strictEqual(cell.getStrength(), 0); + + // Single die with value 4 = strength 4 + cell.dice = [4]; + assert.strictEqual(cell.getStrength(), 4); + + // Two dice: [6, 3] = (2-1)*6 + 3 = 9 + cell.dice = [6, 3]; + assert.strictEqual(cell.getStrength(), 9); + + // Three dice: [6, 6, 2] = (3-1)*6 + 2 = 14 + cell.dice = [6, 6, 2]; + assert.strictEqual(cell.getStrength(), 14); + }); + + it('should detect max strength', () => { + const cell = new HexCell(0, 0); + cell.dice = [6, 6, 6, 6, 6, 6, 6, 6]; + assert.strictEqual(cell.isMaxStrength(), true); + + cell.dice = [6, 6, 6, 6, 6, 6, 6, 5]; + assert.strictEqual(cell.isMaxStrength(), false); + }); + + it('should add dice up to limit', () => { + const cell = new HexCell(0, 0); + + for (let i = 0; i < 8; i++) { + assert.strictEqual(cell.addDie(6), true); + } + + // 9th die should fail + assert.strictEqual(cell.addDie(6), false); + assert.strictEqual(cell.dice.length, 8); + }); + + it('should set strength correctly', () => { + const cell = new HexCell(0, 0); + + cell.setStrength(1); + assert.deepStrictEqual(cell.dice, [1]); + + cell.setStrength(6); + assert.deepStrictEqual(cell.dice, [6]); + + cell.setStrength(7); + assert.deepStrictEqual(cell.dice, [6, 1]); + + cell.setStrength(14); + assert.deepStrictEqual(cell.dice, [6, 6, 2]); + + cell.setStrength(0); + assert.deepStrictEqual(cell.dice, []); + }); + + it('should check passability and ownership', () => { + const emptyCell = new HexCell(0, 0, CELL_TYPES.EMPTY); + const blockedCell = new HexCell(0, 0, CELL_TYPES.BLOCKED); + const player1Cell = new HexCell(0, 0, CELL_TYPES.PLAYER1); + + assert.strictEqual(emptyCell.isPassable(), true); + assert.strictEqual(blockedCell.isPassable(), false); + assert.strictEqual(emptyCell.isOwned(), false); + assert.strictEqual(player1Cell.isOwned(), true); + assert.strictEqual(emptyCell.getOwner(), 0); + assert.strictEqual(player1Cell.getOwner(), 1); + }); +}); + +describe('HexMap', () => { + it('should create a 20x20 map by default', () => { + const map = new HexMap(); + assert.strictEqual(map.size, MAP_SIZE); + assert.strictEqual(map.cells.size, MAP_SIZE * MAP_SIZE); + }); + + it('should generate cells with correct coordinates', () => { + const map = new HexMap(5); + + for (let q = 0; q < 5; q++) { + for (let r = 0; r < 5; r++) { + const cell = map.getCell(q, r); + assert.ok(cell, `Cell at ${q},${r} should exist`); + assert.strictEqual(cell.q, q); + assert.strictEqual(cell.r, r); + } + } + }); + + it('should have some blocked cells', () => { + const map = new HexMap(); + const blockedCells = Array.from(map.cells.values()).filter( + cell => cell.type === CELL_TYPES.BLOCKED + ); + + // Should have some blocked cells (roughly 15%) + assert.ok(blockedCells.length > 0, 'Should have at least one blocked cell'); + assert.ok(blockedCells.length < map.cells.size / 2, 'Should not have more than 50% blocked'); + }); + + it('should return passable cells', () => { + const map = new HexMap(5); + const passableCells = map.getPassableCells(); + + assert.ok(passableCells.length > 0); + passableCells.forEach(cell => { + assert.strictEqual(cell.isPassable(), true); + }); + }); + + it('should return neighbors correctly', () => { + const map = new HexMap(5); + + // Clear all blocks for predictable testing + map.cells.forEach(cell => { + if (cell.type === CELL_TYPES.BLOCKED) { + cell.type = CELL_TYPES.EMPTY; + } + }); + + // Center cell should have 6 neighbors + const centerNeighbors = map.getNeighbors(2, 2); + assert.strictEqual(centerNeighbors.length, 6); + + // Corner cell should have 2 neighbors + const cornerNeighbors = map.getNeighbors(0, 0); + assert.strictEqual(cornerNeighbors.length, 2); + + // Edge cell should have 3-4 neighbors + const edgeNeighbors = map.getNeighbors(0, 2); + assert.ok(edgeNeighbors.length >= 3 && edgeNeighbors.length <= 4); + }); + + it('should set and clear ownership', () => { + const map = new HexMap(5); + + // Clear the map first + map.cells.forEach(cell => { + cell.type = CELL_TYPES.EMPTY; + }); + + // Set ownership on cells that exist + assert.strictEqual(map.setOwner(2, 2, 1), true); + const cell = map.getCell(2, 2); + assert.strictEqual(cell.type, CELL_TYPES.PLAYER1); + assert.strictEqual(cell.getOwner(), 1); + + // Set player 2 ownership on adjacent cell (2,3 is a neighbor of 2,2) + assert.strictEqual(map.setOwner(2, 3, 2), true); + const cell2 = map.getCell(2, 3); + assert.strictEqual(cell2.type, CELL_TYPES.PLAYER2); + assert.strictEqual(cell2.getOwner(), 2); + + // Clear ownership + assert.strictEqual(map.clearOwner(2, 2), true); + const clearedCell = map.getCell(2, 2); + assert.strictEqual(clearedCell.type, CELL_TYPES.EMPTY); + assert.strictEqual(clearedCell.getOwner(), 0); + }); + + it('should return player cells', () => { + const map = new HexMap(5); + + // First clear any existing ownership + map.cells.forEach(cell => { + cell.type = CELL_TYPES.EMPTY; + }); + + map.setOwner(0, 0, 1); + map.setOwner(0, 1, 1); + map.setOwner(1, 0, 2); + + const player1Cells = map.getPlayerCells(1); + const player2Cells = map.getPlayerCells(2); + + assert.strictEqual(player1Cells.length, 2); + assert.strictEqual(player2Cells.length, 1); + }); + + it('should calculate supply correctly', () => { + const map = new HexMap(5); + + // First clear any existing ownership + map.cells.forEach(cell => { + cell.type = CELL_TYPES.EMPTY; + }); + + map.setOwner(0, 0, 1); + map.setOwner(0, 1, 1); + map.setOwner(1, 0, 1); + + const supply = map.calculateSupply(1); + assert.strictEqual(supply, 3); // 3 cells = 3 supply + }); + + it('should not allow setting owner on blocked cell', () => { + const map = new HexMap(5); + + // Find a blocked cell + let blockedCell = null; + map.cells.forEach(cell => { + if (cell.type === CELL_TYPES.BLOCKED) { + blockedCell = cell; + } + }); + + if (blockedCell) { + assert.strictEqual(map.setOwner(blockedCell.q, blockedCell.r, 1), false); + } + }); +}); + +describe('Target Cell Selection Logic', () => { + it('should allow attacking adjacent enemy cells', () => { + const map = new HexMap(5); + + // Clear map + map.cells.forEach(cell => { + cell.type = CELL_TYPES.EMPTY; + }); + + // Set up: player 1 at (2,2), player 2 at adjacent (2,1) + // Neighbors of (2,2) in axial coords: (3,2), (3,1), (2,1), (1,2), (1,3), (2,3) + map.setOwner(2, 2, 1); + map.setOwner(2, 1, 2); // (2,1) is a neighbor of (2,2) + + const p1Cell = map.getCell(2, 2); + p1Cell.setStrength(8); + + const p2Cell = map.getCell(2, 1); + p2Cell.setStrength(6); + + // Get neighbors of player 1 cell + const neighbors = map.getNeighbors(2, 2); + + // Player 2 cell should be in neighbors + const hasP2Cell = neighbors.some(n => n.q === 2 && n.r === 1); + assert.strictEqual(hasP2Cell, true, 'Enemy cell should be adjacent'); + + // Valid target: not blocked and not own + // All 6 neighbors are valid because none of them are owned by player 1 + // (only the center cell 2,2 is owned by player 1, but it's not a neighbor of itself) + const validTargets = neighbors.filter(n => + n.type !== CELL_TYPES.BLOCKED && n.getOwner() !== 1 + ); + + // Should have 6 valid targets (one is enemy at 2,1, rest are empty) + assert.strictEqual(validTargets.length, 6, 'Should have 6 valid targets (enemy + 5 empty)'); + + // Verify enemy cell is in valid targets + const enemyInTargets = validTargets.some(n => n.q === 2 && n.r === 1); + assert.strictEqual(enemyInTargets, true); + }); + + it('should allow capturing adjacent empty cells', () => { + const map = new HexMap(5); + + // Clear map + map.cells.forEach(cell => { + cell.type = CELL_TYPES.EMPTY; + }); + + // Set up: player 1 at (2,2), empty at adjacent (2,3) + map.setOwner(2, 2, 1); + + const p1Cell = map.getCell(2, 2); + p1Cell.setStrength(8); + + const emptyCell = map.getCell(2, 3); + assert.strictEqual(emptyCell.type, CELL_TYPES.EMPTY); + + // Get neighbors of player 1 cell + const neighbors = map.getNeighbors(2, 2); + + // Empty cell should be in neighbors and be a valid target + const validTargets = neighbors.filter(n => + n.type !== CELL_TYPES.BLOCKED && n.getOwner() !== 1 + ); + + const hasEmptyCell = validTargets.some(n => n.q === 2 && n.r === 3); + assert.strictEqual(hasEmptyCell, true, 'Empty cell should be a valid target'); + }); + + it('should NOT allow attacking own cells', () => { + const map = new HexMap(5); + + // Clear map + map.cells.forEach(cell => { + cell.type = CELL_TYPES.EMPTY; + }); + + // Set up: player 1 owns two adjacent cells + map.setOwner(2, 2, 1); + map.setOwner(2, 3, 1); + + const p1Cell1 = map.getCell(2, 2); + p1Cell1.setStrength(8); + + const p1Cell2 = map.getCell(2, 3); + p1Cell2.setStrength(6); + + // Get neighbors of first player 1 cell + const neighbors = map.getNeighbors(2, 2); + + // Valid targets should NOT include own cells + const validTargets = neighbors.filter(n => + n.type !== CELL_TYPES.BLOCKED && n.getOwner() !== 1 + ); + + const hasOwnCell = validTargets.some(n => n.getOwner() === 1); + assert.strictEqual(hasOwnCell, false, 'Own cells should not be valid targets'); + }); + + it('should NOT allow attacking through blocked cells', () => { + const map = new HexMap(5); + + // Clear map + map.cells.forEach(cell => { + cell.type = CELL_TYPES.EMPTY; + }); + + // Set up: player 1 at (2,2), blocked at adjacent (2,3) + map.setOwner(2, 2, 1); + map.getCell(2, 3).type = CELL_TYPES.BLOCKED; + + const p1Cell = map.getCell(2, 2); + p1Cell.setStrength(8); + + // Get neighbors of player 1 cell + const neighbors = map.getNeighbors(2, 2); + + // Blocked cell should NOT be in passable neighbors + const hasBlocked = neighbors.some(n => n.type === CELL_TYPES.BLOCKED); + assert.strictEqual(hasBlocked, false, 'Blocked cells should not be neighbors'); + }); + + it('should correctly identify all valid targets from center position', () => { + const map = new HexMap(5); + + // Clear map + map.cells.forEach(cell => { + cell.type = CELL_TYPES.EMPTY; + }); + + // Set up: player 1 at center (2,2) + map.setOwner(2, 2, 1); + const centerCell = map.getCell(2, 2); + centerCell.setStrength(8); + + // Surround with different cell types + // (2,1) - empty + // (3,1) - enemy (player 2) + // (3,2) - blocked + // (2,3) - empty + // (1,3) - enemy (player 2) + // (1,2) - own (player 1) + map.setOwner(3, 1, 2); + map.getCell(3, 2).type = CELL_TYPES.BLOCKED; + map.setOwner(1, 3, 2); + map.setOwner(1, 2, 1); + + const neighbors = map.getNeighbors(2, 2); + + // Valid targets: empty cells and enemy cells, not blocked, not own + const validTargets = neighbors.filter(n => + n.type !== CELL_TYPES.BLOCKED && n.getOwner() !== 1 + ); + + // Should have 3 valid targets: (2,1) empty, (3,1) enemy, (2,3) empty + // (1,3) enemy, but need to check if it's actually a neighbor + assert.ok(validTargets.length >= 2, 'Should have at least 2 valid targets'); + + // Verify no own cells in valid targets + validTargets.forEach(t => { + assert.notStrictEqual(t.getOwner(), 1, 'Valid target should not be own cell'); + }); + + // Verify no blocked cells in valid targets + validTargets.forEach(t => { + assert.notStrictEqual(t.type, CELL_TYPES.BLOCKED, 'Valid target should not be blocked'); + }); + }); +});