/** * Adaptive Tactics - Game Rules * Movement, attack range, hit/damage calculations, terrain modifiers */ import { MapData } from './models.js'; /** * Calculate movement range for a unit * Returns array of [row, col] positions the unit can move to */ export function calculateMoveRange(unit, map, allUnits) { const reachable = []; const visited = new Map(); const queue = [[unit.row, unit.col, unit.mov]]; visited.set(`${unit.row},${unit.col}`, unit.mov); while (queue.length > 0) { const [row, col, remaining] = queue.shift(); // Add to reachable if not starting position if (row !== unit.row || col !== unit.col) { const tile = map.getTile(row, col); // Can only move to unoccupied tiles if (tile && !tile.isOccupied()) { reachable.push([row, col]); } } if (remaining <= 0) continue; // Check adjacent tiles const directions = [[-1, 0], [1, 0], [0, -1], [0, 1]]; for (const [dr, dc] of directions) { const newRow = row + dr; const newCol = col + dc; const key = `${newRow},${newCol}`; const tile = map.getTile(newRow, newCol); if (!tile || !tile.isPassable()) continue; // Can pass through allied units but not enemies const occupant = tile.unit; if (occupant && occupant.faction !== unit.faction) continue; const newRemaining = remaining - 1; const prevRemaining = visited.get(key) ?? -1; if (newRemaining > prevRemaining) { visited.set(key, newRemaining); queue.push([newRow, newCol, newRemaining]); } } } return reachable; } /** * Calculate attack range from a position with a weapon * Returns array of [row, col] positions that can be attacked */ export function calculateAttackRange(row, col, weapon, map) { const targets = []; if (!weapon) return targets; for (let r = 1; r <= map.rows; r++) { for (let c = 1; c <= map.cols; c++) { const dist = MapData.getDistance(row, col, r, c); if (dist >= weapon.minRange && dist <= weapon.maxRange) { const tile = map.getTile(r, c); if (tile && tile.isPassable()) { targets.push([r, c]); } } } } return targets; } /** * Calculate heal range from a position with a healing weapon * Returns array of [row, col] positions that can be healed */ export function calculateHealRange(row, col, weapon, map, allUnits, faction) { const targets = []; if (!weapon || !weapon.isHealingWeapon()) return targets; for (let r = 1; r <= map.rows; r++) { for (let c = 1; c <= map.cols; c++) { const dist = MapData.getDistance(row, col, r, c); if (dist >= weapon.minRange && dist <= weapon.maxRange) { const tile = map.getTile(r, c); if (tile && tile.unit && tile.unit.faction === faction && tile.unit.hp < tile.unit.maxHp) { targets.push([r, c]); } } } } return targets; } /** * Calculate displayed hit chance * Formula: weapon_hit + skill × 2 */ export function calculateDisplayedHit(attacker, weapon) { return weapon.hit + (attacker.skill * 2); } /** * Calculate displayed avoid * Formula: speed × 2 + terrain_avoid */ export function calculateDisplayedAvoid(defender, terrainAvoid = 0) { return (defender.speed * 2) + terrainAvoid; } /** * Calculate actual hit chance * Formula: displayed_hit - displayed_avoid */ export function calculateHitChance(attacker, defender, weapon, terrainAvoid = 0) { const hit = calculateDisplayedHit(attacker, weapon); const avoid = calculateDisplayedAvoid(defender, terrainAvoid); return Math.max(0, Math.min(100, hit - avoid)); } /** * 2RN hit check (average of two random rolls) * This makes displayed hit rates feel more accurate */ export function rollHit(hitChance) { const roll1 = Math.random() * 100; const roll2 = Math.random() * 100; const avgRoll = (roll1 + roll2) / 2; return avgRoll < hitChance; } /** * Calculate damage * Physical: power + weapon_might - defender_def * Magic: power + weapon_might - defender_res */ export function calculateDamage(attacker, defender, weapon, terrainDef = 0) { const might = attacker.power + weapon.might; let defense; if (weapon.kind === 'magic') { defense = defender.res; } else { defense = defender.def + terrainDef; } return Math.max(0, might - defense); } /** * Check if attacker can double (speed gap >= 4) */ export function canDouble(attacker, defender, speedGap = 4) { return attacker.speed >= defender.speed + speedGap; } /** * Check if defender can counterattack * Defender can counter if their equipped weapon can reach the attacker's range */ export function canCounter(attacker, defender, distance) { const defenderWeapon = defender.getEquippedWeapon(); if (!defenderWeapon) return false; if (defenderWeapon.isHealingWeapon()) return false; return defenderWeapon.canReach(distance); } /** * Get terrain avoid bonus for a tile */ export function getTerrainAvoid(tile) { if (!tile) return 0; return tile.getAvoidBonus(); } /** * Get terrain defense bonus for a tile */ export function getTerrainDef(tile) { if (!tile) return 0; return tile.getDefBonus(); } /** * Calculate full combat preview */ export function calculateCombatPreview(attacker, defender, map, gameRules) { const attackerWeapon = attacker.getEquippedWeapon(); const defenderWeapon = defender.getEquippedWeapon(); const distance = MapData.getDistance(attacker.row, attacker.col, defender.row, defender.col); const defenderTile = map.getTile(defender.row, defender.col); const terrainAvoid = getTerrainAvoid(defenderTile); const terrainDef = getTerrainDef(defenderTile); const speedGap = gameRules?.double_speed_gap ?? 4; const preview = { attacker: { name: attacker.name, hp: attacker.hp, damage: 0, hit: 0, doubles: false }, defender: { name: defender.name, hp: defender.hp, damage: 0, hit: 0, doubles: false, canCounter: false } }; // Attacker stats if (attackerWeapon) { preview.attacker.damage = calculateDamage(attacker, defender, attackerWeapon, terrainDef); preview.attacker.hit = calculateHitChance(attacker, defender, attackerWeapon, terrainAvoid); preview.attacker.doubles = canDouble(attacker, defender, speedGap); } // Defender counter stats const defCanCounter = canCounter(attacker, defender, distance); preview.defender.canCounter = defCanCounter; if (defCanCounter && defenderWeapon) { const attackerTile = map.getTile(attacker.row, attacker.col); const attackerTerrainAvoid = getTerrainAvoid(attackerTile); const attackerTerrainDef = getTerrainDef(attackerTile); preview.defender.damage = calculateDamage(defender, attacker, defenderWeapon, attackerTerrainDef); preview.defender.hit = calculateHitChance(defender, attacker, defenderWeapon, attackerTerrainAvoid); preview.defender.doubles = canDouble(defender, attacker, speedGap); } return preview; }