Spaces:
Running
Running
| /** | |
| * 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; | |
| } | |