feat(ModularBot): melee targeting — multi-enemy tracker, per-enemy gun fitness, radar auto-switch
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -19,8 +19,9 @@ proc shouldFire*(currentGunDir, targetAngle, gunHeat: float): bool =
|
||||
elif delta < -180.0: delta += 360.0
|
||||
abs(delta) <= AimThresholdDeg and gunHeat <= 0.0
|
||||
|
||||
proc selectShot*(t: VirtualTracker): (GunId, int, float) =
|
||||
proc selectShot*(t: VirtualTracker, targetId: int = -1): (GunId, int, float) =
|
||||
## Returns (gunId, powerBinIdx, power) — the shot to take this tick.
|
||||
let gunId = t.bestGun()
|
||||
let (binIdx, power) = t.bestPower(gunId)
|
||||
## 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)
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
## Calls onResult() on the owning gun when a bullet resolves.
|
||||
|
||||
import std/math
|
||||
import std/tables
|
||||
import gun_interface
|
||||
|
||||
const
|
||||
@@ -19,6 +20,7 @@ type
|
||||
VirtualBullet* = object
|
||||
gunId*: GunId
|
||||
powerBin*: int ## index into PowerBins
|
||||
targetId*: int ## enemy bot ID this bullet was aimed at
|
||||
fireX*, fireY*: float
|
||||
aimX*, aimY*: float ## predicted target (absolute)
|
||||
bulletSpeed*: float
|
||||
@@ -36,12 +38,13 @@ type
|
||||
bins*: array[len(PowerBins), FitnessWindow]
|
||||
|
||||
VirtualTracker* = object
|
||||
bullets*: array[MaxBullets, VirtualBullet]
|
||||
head*: int ## ring buffer head
|
||||
fitness*: seq[GunFitness] ## indexed by GunId
|
||||
bullets*: array[MaxBullets, VirtualBullet]
|
||||
head*: int ## ring buffer head
|
||||
numGuns*: int
|
||||
fitness*: Table[int, seq[GunFitness]] ## keyed by enemy bot ID, indexed by GunId
|
||||
|
||||
proc initTracker*(numGuns: int): VirtualTracker =
|
||||
result.fitness = newSeq[GunFitness](numGuns)
|
||||
result.numGuns = numGuns
|
||||
|
||||
proc hitRate*(fw: FitnessWindow): float =
|
||||
## Returns fraction of hits in the rolling window. 0.0 when no data.
|
||||
@@ -58,8 +61,11 @@ proc record(fw: var FitnessWindow, hit: bool) =
|
||||
|
||||
proc spawnBullets*(t: var VirtualTracker, gunId: GunId,
|
||||
predictions: array[len(PowerBins), GunPrediction],
|
||||
state: WorldState) =
|
||||
state: WorldState, targetId: int) =
|
||||
## Call once per gun per tick with predictions for all power bins.
|
||||
## Lazily creates fitness entry for targetId on first spawn.
|
||||
if targetId notin t.fitness:
|
||||
t.fitness[targetId] = newSeq[GunFitness](t.numGuns)
|
||||
for binIdx in 0..<len(PowerBins):
|
||||
let power = PowerBins[binIdx]
|
||||
let speed = bulletSpeed(power)
|
||||
@@ -69,6 +75,7 @@ proc spawnBullets*(t: var VirtualTracker, gunId: GunId,
|
||||
t.bullets[slot] = VirtualBullet(
|
||||
gunId: gunId,
|
||||
powerBin: binIdx,
|
||||
targetId: targetId,
|
||||
fireX: state.selfX,
|
||||
fireY: state.selfY,
|
||||
aimX: pred.x,
|
||||
@@ -80,17 +87,32 @@ proc spawnBullets*(t: var VirtualTracker, gunId: GunId,
|
||||
)
|
||||
t.head = (t.head + 1) mod MaxBullets
|
||||
|
||||
const StaleTicks* = 20 ## discard bullet if target not seen within this many ticks
|
||||
|
||||
proc tickBullets*(t: var VirtualTracker, state: WorldState,
|
||||
enemies: Table[int, tuple[x, y: float, lastSeenTick: int, alive: bool]],
|
||||
onResolved: proc(gunId: GunId, binIdx: int, e: FeedbackEvent)) =
|
||||
## Advance all active bullets one tick. Resolve when bullet reaches target distance.
|
||||
## Uses per-target enemy position from enemies table for accurate miss distance.
|
||||
## Discards bullet without scoring if target is dead or stale (> StaleTicks).
|
||||
for i in 0..<MaxBullets:
|
||||
var b = addr t.bullets[i]
|
||||
if not b.active: continue
|
||||
b.travelDist += b.bulletSpeed
|
||||
if b.travelDist < b.fireDist: continue
|
||||
|
||||
# Resolved: compute miss distance against current enemy position
|
||||
# Direction from fire point to aim point
|
||||
# Resolved: look up the correct enemy position
|
||||
var ex, ey: float
|
||||
if b.targetId in enemies:
|
||||
let e = enemies[b.targetId]
|
||||
if not e.alive or (state.tick - e.lastSeenTick) > StaleTicks:
|
||||
b.active = false
|
||||
continue
|
||||
ex = e.x; ey = e.y
|
||||
else:
|
||||
# No data for this target — fall back to selected enemy in state
|
||||
ex = state.enemyX; ey = state.enemyY
|
||||
|
||||
let dx = b.aimX - b.fireX
|
||||
let dy = b.aimY - b.fireY
|
||||
let dist = hypot(dx, dy)
|
||||
@@ -98,15 +120,16 @@ proc tickBullets*(t: var VirtualTracker, state: WorldState,
|
||||
if dist < 1e-6: (b.aimX, b.aimY)
|
||||
else: (b.fireX + dx / dist * b.travelDist,
|
||||
b.fireY + dy / dist * b.travelDist)
|
||||
let missDist = hypot(bx - state.enemyX, by - state.enemyY)
|
||||
let missDist = hypot(bx - ex, by - ey)
|
||||
let hit = missDist < BotRadius
|
||||
|
||||
t.fitness[b.gunId].bins[b.powerBin].record(hit)
|
||||
if b.targetId in t.fitness:
|
||||
t.fitness[b.targetId][b.gunId].bins[b.powerBin].record(hit)
|
||||
|
||||
let fe = FeedbackEvent(
|
||||
prediction: GunPrediction(x: b.aimX, y: b.aimY),
|
||||
actualX: state.enemyX,
|
||||
actualY: state.enemyY,
|
||||
actualX: ex,
|
||||
actualY: ey,
|
||||
bulletPower: PowerBins[b.powerBin],
|
||||
missDistance: missDist,
|
||||
hit: hit,
|
||||
@@ -114,41 +137,59 @@ proc tickBullets*(t: var VirtualTracker, state: WorldState,
|
||||
onResolved(b.gunId, b.powerBin, fe)
|
||||
b.active = false
|
||||
|
||||
proc bestPower*(t: VirtualTracker, gunId: GunId): (int, float) =
|
||||
proc fitnessFor(t: VirtualTracker, targetId: int): seq[GunFitness] =
|
||||
## Returns fitness seq for targetId, or merges all enemies as fallback.
|
||||
## ponytail: merge is O(enemies*guns*bins), fine for small counts
|
||||
if targetId >= 0 and targetId in t.fitness:
|
||||
return t.fitness[targetId]
|
||||
# Aggregate across all enemies
|
||||
result = newSeq[GunFitness](t.numGuns)
|
||||
for perEnemy in t.fitness.values:
|
||||
for gunId in 0..<t.numGuns:
|
||||
for binIdx in 0..<len(PowerBins):
|
||||
let src = perEnemy[gunId].bins[binIdx]
|
||||
var dst = addr result[gunId].bins[binIdx]
|
||||
for k in 0..<min(src.count, WindowSize):
|
||||
dst[].record(src.hits[k])
|
||||
|
||||
proc bestPower*(t: VirtualTracker, gunId: GunId, targetId: int = -1): (int, float) =
|
||||
## Returns (binIdx, power) with highest power that has >= MinHitRate.
|
||||
## Falls back to lowest power bin if nothing qualifies yet.
|
||||
## Uses per-enemy fitness when targetId >= 0 and data exists; else aggregate.
|
||||
let fit = t.fitnessFor(targetId)
|
||||
result = (0, PowerBins[0])
|
||||
for binIdx in countdown(len(PowerBins) - 1, 0):
|
||||
let rate = t.fitness[gunId].bins[binIdx].hitRate()
|
||||
if rate >= MinHitRate or t.fitness[gunId].bins[binIdx].count == 0:
|
||||
let rate = fit[gunId].bins[binIdx].hitRate()
|
||||
if rate >= MinHitRate or fit[gunId].bins[binIdx].count == 0:
|
||||
return (binIdx, PowerBins[binIdx])
|
||||
|
||||
proc bestGun*(t: VirtualTracker): GunId =
|
||||
proc bestGun*(t: VirtualTracker, targetId: int = -1): GunId =
|
||||
## Pick gun with highest hit rate across all power bins.
|
||||
## Guns with fewer than MinObsBeforeCompete observations across all bins are
|
||||
## skipped unless every gun is below threshold (then fall back to best of all).
|
||||
## Guns with fewer than MinObsBeforeCompete observations are skipped
|
||||
## unless every gun is below threshold (then fall back to best of all).
|
||||
## Uses per-enemy fitness when targetId >= 0 and data exists; else aggregate.
|
||||
## ponytail: O(n*bins), fine for small gun counts
|
||||
proc bestAmong(t: VirtualTracker, requireMin: bool): GunId =
|
||||
let fit = t.fitnessFor(targetId)
|
||||
proc bestAmong(fit: seq[GunFitness], requireMin: bool): GunId =
|
||||
var bestRate = -1.0
|
||||
result = 0
|
||||
for gunId in 0..<t.fitness.len:
|
||||
for gunId in 0..<fit.len:
|
||||
var maxCount = 0
|
||||
for binIdx in 0..<len(PowerBins):
|
||||
maxCount = max(maxCount, t.fitness[gunId].bins[binIdx].count)
|
||||
maxCount = max(maxCount, fit[gunId].bins[binIdx].count)
|
||||
if requireMin and maxCount < MinObsBeforeCompete: continue
|
||||
for binIdx in 0..<len(PowerBins):
|
||||
let r = t.fitness[gunId].bins[binIdx].hitRate()
|
||||
let r = fit[gunId].bins[binIdx].hitRate()
|
||||
if r > bestRate:
|
||||
bestRate = r
|
||||
result = gunId
|
||||
|
||||
# Try gated selection first; fall back to ungated if nothing qualifies
|
||||
var anyQualifies = false
|
||||
for gunId in 0..<t.fitness.len:
|
||||
for gunId in 0..<fit.len:
|
||||
for binIdx in 0..<len(PowerBins):
|
||||
if t.fitness[gunId].bins[binIdx].count >= MinObsBeforeCompete:
|
||||
if fit[gunId].bins[binIdx].count >= MinObsBeforeCompete:
|
||||
anyQualifies = true
|
||||
break
|
||||
if anyQualifies: break
|
||||
|
||||
result = if anyQualifies: t.bestAmong(true) else: t.bestAmong(false)
|
||||
result = if anyQualifies: fit.bestAmong(true) else: fit.bestAmong(false)
|
||||
|
||||
Reference in New Issue
Block a user