## Gun selector — picks best gun×power, computes aim angle, gates firing. ## Fires highest power with acceptable hit rate when the gun is aimed within a ## range-dependent angular tolerance and gunHeat == 0. import std/math import gun_interface import virtual_bullets const ## ── Range-aware firing gate ──────────────────────────────────────────────── ## A real shot departs with whatever misalignment the gun had at fire time, ## while a virtual bullet is spawned exactly on the prediction and carries zero ## aim error. At distance `d` the target subtends an angular half-width of ## `atan(BotRadius / d)`, so a fixed degree threshold is simultaneously too ## loose at long range (throws away shots that cannot hit) and too tight up ## close (holds fire when the bot is already inside the hit cone). ## ## We therefore derive the tolerance from the target's angular radius: ## ## tolDeg = radToDeg(arctan(BotRadius * SafetyFactor / distPx)) ## ## clamped to [MinAimThresholdDeg, MaxAimThresholdDeg]. ## ## SafetyFactor shrinks/expands the accepted cone: 1.0 == the full geometric ## half-width, < 1.0 is stricter. Fitted empirically from real-shot data ## (Task A, 2611 real shots behind a wide-open 20 deg measurement gate). ## The geometric model is only weakly identified: prediction error dominates ## the hit rate, and the measured 50%-hit knee is noisy (0.9-1.4x the ## geometric cone at 200-800 px; the 400-600 px bucket is ill-defined because ## its baseline hit rate is already ~50%). Simulating the gate directly on the ## measurement data showed 0.6 Pareto-dominates the old fixed 2.0 deg gate ## (61.4% vs 60.2% hit rate with MORE shots), and the live sweep confirms the ## observed preference for tighter gates. 0.6 is the shipped compromise: ## tighter than the raw geometry while still loosening close range. SafetyFactor* = 0.6 ## Floor: keeps the tolerance strictly positive so a perfectly aligned gun can ## always fire at any range, and guards the gate against collapsing to 0 ## (a never-fire deadlock) at extreme distances. MinAimThresholdDeg* = 0.05 ## Ceiling: at point-blank range the geometric cone grows without bound; a ## >10 deg misalignment is a coin toss even at ~100 px, so cap it here. MaxAimThresholdDeg* = 10.0 proc aimToleranceDeg*(distPx: float): float = ## Angular half-width (deg) the gun may be off by and still plausibly hit a ## target `distPx` px away, scaled by SafetyFactor and clamped. ## ## Degenerate distances (0 or unavailable) fall back to the ceiling rather than ## dividing by zero; NaN is treated the same way (the `not (distPx > 0.0)` ## test is false for NaN). +Inf falls through to arctan(0) == 0 and then the ## floor, which is correct: an infinitely distant target is a point. if not (distPx > 0.0): return MaxAimThresholdDeg result = radToDeg(arctan(BotRadius * SafetyFactor / distPx)) if result < MinAimThresholdDeg: result = MinAimThresholdDeg elif result > MaxAimThresholdDeg: result = MaxAimThresholdDeg proc aimAngle*(selfX, selfY, targetX, targetY: float): float = ## Absolute bearing in degrees (0=East, CCW+) toward (targetX, targetY). result = radToDeg(arctan2(targetY - selfY, targetX - selfX)) proc shouldFire*(currentGunDir, targetAngle, gunHeat, distPx: float): bool = ## Returns true when the gun is within the range-aware angular tolerance and ## cool enough to fire. `distPx` is the distance (px) to the aim point. var delta = (targetAngle - currentGunDir) mod 360.0 if delta > 180.0: delta -= 360.0 elif delta < -180.0: delta += 360.0 abs(delta) <= aimToleranceDeg(distPx) and gunHeat <= 0.0 proc selectShot*(t: VirtualTracker, targetId: int = -1): (GunId, int, float) = ## Returns (gunId, powerBinIdx, power) — the shot to take this tick. ## Pass targetId to pick the best gun for that specific enemy. let gunId = t.bestGun(targetId) let (binIdx, power) = t.bestPower(gunId, targetId) result = (gunId, binIdx, power)