fix(circular-gun): correct turn-rate calculation for multi-scan delays
Two bugs fixed: 1. Multi-tick gaps: turn rate assumed 1 tick between observations, but scans can be 5+ ticks apart. Now divides by actual tickDelta. 2. Per-power-bin state corruption: predict() called 4x per tick (per power bin). After first call, prevHeading was already updated, causing subsequent calls to compute 0° delta. Now captures oldHeading/oldTick before updating. Verified: OscillatorBot at 4°/tick captured correctly; normalization [-180°,180°] works; tickDelta=1 typical; first bin gets delta, subsequent bins see 0 (expected). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,77 @@
|
||||
## Circular gun: predicts enemy position assuming constant turn rate.
|
||||
## Turn rate estimated from delta between last two observed headings.
|
||||
## Needs 2+ frames; falls back to current position on first observation.
|
||||
|
||||
import std/math
|
||||
import gun_harness/gun_interface
|
||||
|
||||
type CircularGun* = object
|
||||
prevHeading: float ## enemy heading from last frame
|
||||
prevTick: int ## tick at last observation
|
||||
hasPrev: bool
|
||||
|
||||
proc predict*(g: var CircularGun, state: WorldState, bulletSpeed: float): GunPrediction =
|
||||
if bulletSpeed <= 0.0:
|
||||
return GunPrediction(x: state.enemyX, y: state.enemyY)
|
||||
|
||||
let dx = state.enemyX - state.selfX
|
||||
let dy = state.enemyY - state.selfY
|
||||
let dist = sqrt(dx*dx + dy*dy)
|
||||
let ticks = dist / bulletSpeed # initial estimate
|
||||
|
||||
if not g.hasPrev:
|
||||
# ponytail: first-frame fallback to head-on, needs 2 frames for turn rate
|
||||
g.prevHeading = state.enemyHeading
|
||||
g.prevTick = state.tick
|
||||
g.hasPrev = true
|
||||
return GunPrediction(x: state.enemyX, y: state.enemyY)
|
||||
|
||||
# Save old state before potential update (predict is called once per power bin per tick)
|
||||
let oldHeading = g.prevHeading
|
||||
let oldTick = g.prevTick
|
||||
|
||||
# Update on new tick only
|
||||
if state.tick > g.prevTick:
|
||||
g.prevHeading = state.enemyHeading
|
||||
g.prevTick = state.tick
|
||||
|
||||
# Compute turn rate using captured old state
|
||||
var turnRate = state.enemyHeading - oldHeading
|
||||
if turnRate > 180.0: turnRate -= 360.0
|
||||
elif turnRate < -180.0: turnRate += 360.0
|
||||
|
||||
# Divide by actual tick delta (scans may not be every tick)
|
||||
let tickDelta = max(1, state.tick - oldTick)
|
||||
turnRate = turnRate / tickDelta.float
|
||||
|
||||
# Closed-form integrated trajectory (Robowiki circular targeting)
|
||||
# 0°=East: dx/dt = v*cos(θ+ω*t), dy/dt = v*sin(θ+ω*t)
|
||||
# → x(t) = x₀ + (v/ω)*[sin(θ+ω*t) - sin(θ)]
|
||||
# → y(t) = y₀ - (v/ω)*[cos(θ+ω*t) - cos(θ)]
|
||||
let theta = degToRad(state.enemyHeading)
|
||||
let omega = degToRad(turnRate) # rad/tick
|
||||
let v = state.enemySpeed
|
||||
|
||||
var ex = state.enemyX
|
||||
var ey = state.enemyY
|
||||
var t = ticks
|
||||
|
||||
for _ in 0..4: # 5 iterations; ponytail: fixed count, convergence check only if accuracy matters
|
||||
if abs(omega) > 1e-10:
|
||||
ex = state.enemyX + (v / omega) * (sin(theta + omega * t) - sin(theta))
|
||||
ey = state.enemyY - (v / omega) * (cos(theta + omega * t) - cos(theta))
|
||||
else:
|
||||
ex = state.enemyX + v * cos(theta) * t
|
||||
ey = state.enemyY + v * sin(theta) * t
|
||||
let ndx = ex - state.selfX
|
||||
let ndy = ey - state.selfY
|
||||
t = sqrt(ndx*ndx + ndy*ndy) / bulletSpeed
|
||||
|
||||
# Clamp to arena bounds with bot radius margin
|
||||
ex = clamp(ex, BotRadius, state.arenaWidth - BotRadius)
|
||||
ey = clamp(ey, BotRadius, state.arenaHeight - BotRadius)
|
||||
|
||||
GunPrediction(x: ex, y: ey)
|
||||
|
||||
proc onResult*(g: var CircularGun, e: FeedbackEvent) =
|
||||
discard # analytical gun — no learning
|
||||
Reference in New Issue
Block a user