3a1149359d
With 12 guns × 4 power bins = 48 bullets/tick, 512 slots overflow before slow bullets resolve (~36 ticks travel). 2048 gives headroom. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
136 lines
4.8 KiB
Nim
136 lines
4.8 KiB
Nim
## Virtual bullet tracker.
|
||
## Spawns virtual bullets per gun×power bin every tick (no real firing).
|
||
## Resolves by travel distance. Rolling window fitness per gun×power.
|
||
## Calls onResult() on the owning gun when a bullet resolves.
|
||
|
||
import std/math
|
||
import gun_interface
|
||
|
||
const
|
||
PowerBins* = [1.0, 1.5, 2.0, 3.0] ## 4 bins; ponytail: fixed array, add runtime config if needed
|
||
WindowSize* = 50 ## rolling window ticks for fitness
|
||
MaxBullets* = 2048 ## hard cap; ponytail: ring buffer, resize if more guns added
|
||
MinHitRate* = 0.40 ## 40% threshold for acceptable power selection
|
||
|
||
type
|
||
GunId* = int ## index into the guns seq
|
||
|
||
VirtualBullet* = object
|
||
gunId*: GunId
|
||
powerBin*: int ## index into PowerBins
|
||
fireX*, fireY*: float
|
||
aimX*, aimY*: float ## predicted target (absolute)
|
||
bulletSpeed*: float
|
||
travelDist*: float ## accumulated px so far
|
||
fireDist*: float ## distance to target at fire time
|
||
active*: bool
|
||
|
||
FitnessWindow* = object
|
||
## Ring buffer of hit booleans.
|
||
hits*: array[WindowSize, bool]
|
||
count*: int ## total samples so far (capped at WindowSize for rate)
|
||
head*: int
|
||
|
||
GunFitness* = object
|
||
bins*: array[len(PowerBins), FitnessWindow]
|
||
|
||
VirtualTracker* = object
|
||
bullets*: array[MaxBullets, VirtualBullet]
|
||
head*: int ## ring buffer head
|
||
fitness*: seq[GunFitness] ## indexed by GunId
|
||
|
||
proc initTracker*(numGuns: int): VirtualTracker =
|
||
result.fitness = newSeq[GunFitness](numGuns)
|
||
|
||
proc hitRate*(fw: FitnessWindow): float =
|
||
## Returns fraction of hits in the rolling window. 0.0 when no data.
|
||
if fw.count == 0: return 0.0
|
||
let n = min(fw.count, WindowSize)
|
||
var h = 0
|
||
for i in 0..<n: h += (if fw.hits[i]: 1 else: 0)
|
||
result = h.float / n.float
|
||
|
||
proc record(fw: var FitnessWindow, hit: bool) =
|
||
fw.hits[fw.head] = hit
|
||
fw.head = (fw.head + 1) mod WindowSize
|
||
inc fw.count
|
||
|
||
proc spawnBullets*(t: var VirtualTracker, gunId: GunId,
|
||
predictions: array[len(PowerBins), GunPrediction],
|
||
state: WorldState) =
|
||
## Call once per gun per tick with predictions for all power bins.
|
||
for binIdx in 0..<len(PowerBins):
|
||
let power = PowerBins[binIdx]
|
||
let speed = bulletSpeed(power)
|
||
let pred = predictions[binIdx]
|
||
let fireDist = hypot(pred.x - state.selfX, pred.y - state.selfY)
|
||
let slot = t.head mod MaxBullets
|
||
t.bullets[slot] = VirtualBullet(
|
||
gunId: gunId,
|
||
powerBin: binIdx,
|
||
fireX: state.selfX,
|
||
fireY: state.selfY,
|
||
aimX: pred.x,
|
||
aimY: pred.y,
|
||
bulletSpeed: speed,
|
||
travelDist: 0.0,
|
||
fireDist: fireDist,
|
||
active: true,
|
||
)
|
||
t.head = (t.head + 1) mod MaxBullets
|
||
|
||
proc tickBullets*(t: var VirtualTracker, state: WorldState,
|
||
onResolved: proc(gunId: GunId, binIdx: int, e: FeedbackEvent)) =
|
||
## Advance all active bullets one tick. Resolve when bullet reaches target distance.
|
||
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
|
||
let dx = b.aimX - b.fireX
|
||
let dy = b.aimY - b.fireY
|
||
let dist = hypot(dx, dy)
|
||
let (bx, by) =
|
||
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 hit = missDist < BotRadius
|
||
|
||
t.fitness[b.gunId].bins[b.powerBin].record(hit)
|
||
|
||
let fe = FeedbackEvent(
|
||
prediction: GunPrediction(x: b.aimX, y: b.aimY),
|
||
actualX: state.enemyX,
|
||
actualY: state.enemyY,
|
||
bulletPower: PowerBins[b.powerBin],
|
||
missDistance: missDist,
|
||
hit: hit,
|
||
)
|
||
onResolved(b.gunId, b.powerBin, fe)
|
||
b.active = false
|
||
|
||
proc bestPower*(t: VirtualTracker, gunId: GunId): (int, float) =
|
||
## Returns (binIdx, power) with highest power that has >= MinHitRate.
|
||
## Falls back to lowest power bin if nothing qualifies yet.
|
||
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:
|
||
return (binIdx, PowerBins[binIdx])
|
||
|
||
proc bestGun*(t: VirtualTracker): GunId =
|
||
## Pick gun with highest hit rate across all power bins.
|
||
## ponytail: O(n*bins), fine for small gun counts
|
||
var bestRate = -1.0
|
||
result = 0
|
||
for gunId in 0..<t.fitness.len:
|
||
for binIdx in 0..<len(PowerBins):
|
||
let r = t.fitness[gunId].bins[binIdx].hitRate()
|
||
if r > bestRate:
|
||
bestRate = r
|
||
result = gunId
|