feat(ModularBot): KNN gun, gunheat tracker, bullet shadows — inspired by DrussGT
New DrussGT-inspired modules: - KNNGun: K-nearest-neighbor statistical targeting using GF density peaks - GunheatTracker: dual-heat system (predicted + confirmed) for 1-2 tick lead - ShadowTracker: computes GF regions safe from in-flight bullets (enemy wave dodge) VirtualBodyTracker now integrates gunheat for earlier fire detection and shadows for safe-zone multiplier (90% reduction in danger zones). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,259 @@
|
|||||||
|
## KNN gun: K-nearest-neighbor statistical targeting inspired by DrussGT's DC gun.
|
||||||
|
## Builds a feature vector per scan, stores resolved GF outcomes, queries KNN at
|
||||||
|
## predict time and picks the GF with the highest Gaussian-weighted density.
|
||||||
|
## ponytail: linear scan O(n*k), cap at 2000 obs — KD-tree if perf matters at scale.
|
||||||
|
|
||||||
|
import std/[math]
|
||||||
|
import gun_harness/gun_interface
|
||||||
|
|
||||||
|
const
|
||||||
|
MaxObs = 2000 # ring-buffer cap
|
||||||
|
KCap = 50 # hard ceiling on K
|
||||||
|
KernelW = 0.3 # Gaussian kernel width multiplier
|
||||||
|
DensityBins = 60 # scan resolution for peak-GF search
|
||||||
|
|
||||||
|
type
|
||||||
|
Obs = object
|
||||||
|
feat: array[7, float] # normalized feature vector
|
||||||
|
gf: float # observed GF at wave resolution
|
||||||
|
|
||||||
|
KNNWave = object
|
||||||
|
fireX, fireY: float
|
||||||
|
fireBearing: float
|
||||||
|
feat: array[7, float]
|
||||||
|
|
||||||
|
KNNGun* = object
|
||||||
|
obs: seq[Obs]
|
||||||
|
obsHead: int # ring-buffer write index
|
||||||
|
waves: seq[KNNWave]
|
||||||
|
# per-tick cache
|
||||||
|
cachedTick: int
|
||||||
|
cachedWaveStored: bool
|
||||||
|
# rolling normalization ranges
|
||||||
|
featMin: array[7, float]
|
||||||
|
featMax: array[7, float]
|
||||||
|
# state for feature extraction
|
||||||
|
lastSpeed: float
|
||||||
|
lastDirection: float # +1 or -1
|
||||||
|
timeSinceDirChange: int
|
||||||
|
|
||||||
|
proc initKNNGun*(): KNNGun =
|
||||||
|
result.cachedTick = -1
|
||||||
|
result.lastDirection = 1.0
|
||||||
|
for i in 0..6:
|
||||||
|
result.featMin[i] = 1e18
|
||||||
|
result.featMax[i] = -1e18
|
||||||
|
|
||||||
|
# ── helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
proc normFeat(g: KNNGun, raw: array[7, float]): array[7, float] =
|
||||||
|
for i in 0..6:
|
||||||
|
let span = g.featMax[i] - g.featMin[i]
|
||||||
|
result[i] = if span > 1e-9: (raw[i] - g.featMin[i]) / span else: 0.0
|
||||||
|
|
||||||
|
proc updateMinMax(g: var KNNGun, raw: array[7, float]) =
|
||||||
|
for i in 0..6:
|
||||||
|
if raw[i] < g.featMin[i]: g.featMin[i] = raw[i]
|
||||||
|
if raw[i] > g.featMax[i]: g.featMax[i] = raw[i]
|
||||||
|
|
||||||
|
proc buildFeatures(state: WorldState, lastSpeed, lastDir: float,
|
||||||
|
tsdc: int): array[7, float] =
|
||||||
|
let dx = state.enemyX - state.selfX
|
||||||
|
let dy = state.enemyY - state.selfY
|
||||||
|
let dist = sqrt(dx*dx + dy*dy)
|
||||||
|
let arenaDiag = sqrt(state.arenaWidth*state.arenaWidth + state.arenaHeight*state.arenaHeight)
|
||||||
|
|
||||||
|
# bearing to enemy (0°=East, standard Tank Royale)
|
||||||
|
let bearing = arctan2(dy, dx)
|
||||||
|
# angle of enemy heading relative to bearing
|
||||||
|
let relHead = state.enemyHeading - bearing
|
||||||
|
let latVel = state.enemySpeed * sin(relHead)
|
||||||
|
let advVel = state.enemySpeed * (-cos(relHead))
|
||||||
|
let accel = state.enemySpeed - lastSpeed # signed delta
|
||||||
|
|
||||||
|
# wall distances: how far enemy can travel fwd/bwd before hitting wall
|
||||||
|
# approximate: project enemy heading to nearest wall in each axis
|
||||||
|
let ex = state.enemyX
|
||||||
|
let ey = state.enemyY
|
||||||
|
let eh = state.enemyHeading
|
||||||
|
# forward distances to each wall in heading direction
|
||||||
|
let fwdX = if cos(eh) > 0: (state.arenaWidth - ex) / max(abs(cos(eh)), 1e-9)
|
||||||
|
else: ex / max(abs(cos(eh)), 1e-9)
|
||||||
|
let fwdY = if sin(eh) > 0: (state.arenaHeight - ey) / max(abs(sin(eh)), 1e-9)
|
||||||
|
else: ey / max(abs(sin(eh)), 1e-9)
|
||||||
|
let fwdDist = min(fwdX, fwdY)
|
||||||
|
# backward = forward in opposite direction
|
||||||
|
let bwdX = if cos(eh) < 0: (state.arenaWidth - ex) / max(abs(cos(eh)), 1e-9)
|
||||||
|
else: ex / max(abs(cos(eh)), 1e-9)
|
||||||
|
let bwdY = if sin(eh) < 0: (state.arenaHeight - ey) / max(abs(sin(eh)), 1e-9)
|
||||||
|
else: ey / max(abs(sin(eh)), 1e-9)
|
||||||
|
let bwdDist = min(bwdX, bwdY)
|
||||||
|
|
||||||
|
result[0] = abs(latVel) / 8.0
|
||||||
|
result[1] = clamp(advVel / 8.0, -1.0, 1.0) * 0.5 + 0.5 # shift to [0,1]
|
||||||
|
result[2] = clamp(dist / arenaDiag, 0.0, 1.0)
|
||||||
|
result[3] = clamp(accel / 2.0, -1.0, 1.0) * 0.5 + 0.5
|
||||||
|
result[4] = clamp(float(tsdc) / 100.0, 0.0, 1.0)
|
||||||
|
result[5] = clamp(fwdDist / arenaDiag, 0.0, 1.0)
|
||||||
|
result[6] = clamp(bwdDist / arenaDiag, 0.0, 1.0)
|
||||||
|
|
||||||
|
proc euclidean(a, b: array[7, float]): float {.inline.} =
|
||||||
|
for i in 0..6:
|
||||||
|
let d = a[i] - b[i]
|
||||||
|
result += d * d
|
||||||
|
result = sqrt(result)
|
||||||
|
|
||||||
|
# ── Gun interface ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
proc predict*(g: var KNNGun, state: WorldState, bulletSpd: float): GunPrediction =
|
||||||
|
if bulletSpd <= 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 bearing = arctan2(dy, dx)
|
||||||
|
let mea = arcsin(clamp(8.0 / bulletSpd, -1.0, 1.0))
|
||||||
|
|
||||||
|
# Track direction change — update state once per tick
|
||||||
|
if state.tick != g.cachedTick:
|
||||||
|
g.cachedTick = state.tick
|
||||||
|
g.cachedWaveStored = false
|
||||||
|
|
||||||
|
let relHead = state.enemyHeading - bearing
|
||||||
|
let latVel = state.enemySpeed * sin(relHead)
|
||||||
|
let newDir = if latVel >= 0: 1.0 else: -1.0
|
||||||
|
if newDir != g.lastDirection and abs(latVel) > 0.01:
|
||||||
|
g.timeSinceDirChange = 0
|
||||||
|
g.lastDirection = newDir
|
||||||
|
else:
|
||||||
|
inc g.timeSinceDirChange
|
||||||
|
|
||||||
|
# Store wave once per tick
|
||||||
|
if not g.cachedWaveStored:
|
||||||
|
let feat = buildFeatures(state, g.lastSpeed, g.lastDirection, g.timeSinceDirChange)
|
||||||
|
g.updateMinMax(feat)
|
||||||
|
g.waves.add KNNWave(
|
||||||
|
fireX: state.selfX,
|
||||||
|
fireY: state.selfY,
|
||||||
|
fireBearing: bearing,
|
||||||
|
feat: feat,
|
||||||
|
)
|
||||||
|
g.lastSpeed = state.enemySpeed
|
||||||
|
g.cachedWaveStored = true
|
||||||
|
|
||||||
|
# Cold start — no data yet
|
||||||
|
if g.obs.len == 0:
|
||||||
|
return GunPrediction(
|
||||||
|
x: clamp(state.selfX + cos(bearing) * dist, BotRadius, state.arenaWidth - BotRadius),
|
||||||
|
y: clamp(state.selfY + sin(bearing) * dist, BotRadius, state.arenaHeight - BotRadius),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Build query feature vector (use current state)
|
||||||
|
let queryRaw = buildFeatures(state, g.lastSpeed, g.lastDirection, g.timeSinceDirChange)
|
||||||
|
let query = g.normFeat(queryRaw)
|
||||||
|
|
||||||
|
# KNN: linear scan, pick k = max(5, min(sqrt(n), KCap))
|
||||||
|
# Fall back to head-on when not enough neighbors to be meaningful
|
||||||
|
let n = g.obs.len
|
||||||
|
if n < 5:
|
||||||
|
return GunPrediction(
|
||||||
|
x: clamp(state.selfX + cos(bearing) * dist, BotRadius, state.arenaWidth - BotRadius),
|
||||||
|
y: clamp(state.selfY + sin(bearing) * dist, BotRadius, state.arenaHeight - BotRadius),
|
||||||
|
)
|
||||||
|
let k = max(5, min(int(sqrt(float(n))), KCap))
|
||||||
|
|
||||||
|
# Partial selection: maintain k-best by tracking max distance in result set
|
||||||
|
# ponytail: O(n*k) insertion; fine for n<=2000, k<=50
|
||||||
|
var bestDists = newSeq[float](k)
|
||||||
|
var bestGFs = newSeq[float](k)
|
||||||
|
var worstIdx = 0
|
||||||
|
var filled = 0
|
||||||
|
|
||||||
|
for i in 0..<n:
|
||||||
|
let normFeat = g.normFeat(g.obs[i].feat)
|
||||||
|
let d = euclidean(query, normFeat)
|
||||||
|
if filled < k:
|
||||||
|
bestDists[filled] = d
|
||||||
|
bestGFs[filled] = g.obs[i].gf
|
||||||
|
inc filled
|
||||||
|
if filled == k:
|
||||||
|
# find worst
|
||||||
|
worstIdx = 0
|
||||||
|
for j in 1..<k:
|
||||||
|
if bestDists[j] > bestDists[worstIdx]: worstIdx = j
|
||||||
|
elif d < bestDists[worstIdx]:
|
||||||
|
bestDists[worstIdx] = d
|
||||||
|
bestGFs[worstIdx] = g.obs[i].gf
|
||||||
|
worstIdx = 0
|
||||||
|
for j in 1..<k:
|
||||||
|
if bestDists[j] > bestDists[worstIdx]: worstIdx = j
|
||||||
|
|
||||||
|
if filled == 0:
|
||||||
|
return GunPrediction(
|
||||||
|
x: clamp(state.selfX + cos(bearing) * dist, BotRadius, state.arenaWidth - BotRadius),
|
||||||
|
y: clamp(state.selfY + sin(bearing) * dist, BotRadius, state.arenaHeight - BotRadius),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Inverse-distance weights, Gaussian (same as DrussGT getBearingGaussian)
|
||||||
|
var sumDist = 1e-30
|
||||||
|
for i in 0..<filled: sumDist += bestDists[i]
|
||||||
|
let invAvg = float(filled) / sumDist
|
||||||
|
|
||||||
|
# Find GF range of neighbors
|
||||||
|
var gfMin = bestGFs[0]
|
||||||
|
var gfMax = bestGFs[0]
|
||||||
|
for i in 1..<filled:
|
||||||
|
if bestGFs[i] < gfMin: gfMin = bestGFs[i]
|
||||||
|
if bestGFs[i] > gfMax: gfMax = bestGFs[i]
|
||||||
|
|
||||||
|
# Scan DensityBins points in [gfMin, gfMax] for peak density
|
||||||
|
let span = max(gfMax - gfMin, 1e-9)
|
||||||
|
let step = span / float(DensityBins - 1)
|
||||||
|
var bestGF = gfMin
|
||||||
|
var bestScore = -1.0
|
||||||
|
|
||||||
|
for b in 0..<DensityBins:
|
||||||
|
let testGF = gfMin + float(b) * step
|
||||||
|
var score = 0.0
|
||||||
|
for i in 0..<filled:
|
||||||
|
let w = exp(-0.5 * (bestDists[i] * invAvg) * (bestDists[i] * invAvg))
|
||||||
|
let dg = (testGF - bestGFs[i]) / max(span * KernelW, 1e-9)
|
||||||
|
score += w * exp(-0.5 * dg * dg)
|
||||||
|
if score > bestScore:
|
||||||
|
bestScore = score
|
||||||
|
bestGF = testGF
|
||||||
|
|
||||||
|
let aimAngle = bearing + clamp(bestGF, -1.0, 1.0) * mea
|
||||||
|
let px = state.selfX + cos(aimAngle) * dist
|
||||||
|
let py = state.selfY + sin(aimAngle) * dist
|
||||||
|
|
||||||
|
GunPrediction(
|
||||||
|
x: clamp(px, BotRadius, state.arenaWidth - BotRadius),
|
||||||
|
y: clamp(py, BotRadius, state.arenaHeight - BotRadius),
|
||||||
|
)
|
||||||
|
|
||||||
|
proc onResult*(g: var KNNGun, e: FeedbackEvent) =
|
||||||
|
if g.waves.len == 0: return
|
||||||
|
|
||||||
|
let w = g.waves[0]
|
||||||
|
g.waves.delete(0)
|
||||||
|
|
||||||
|
let speed = bulletSpeed(e.bulletPower)
|
||||||
|
let mea = arcsin(clamp(8.0 / speed, -1.0, 1.0))
|
||||||
|
let ax = e.actualX - w.fireX
|
||||||
|
let ay = e.actualY - w.fireY
|
||||||
|
var delta = arctan2(ay, ax) - w.fireBearing
|
||||||
|
while delta > PI: delta -= 2.0 * PI
|
||||||
|
while delta < -PI: delta += 2.0 * PI
|
||||||
|
|
||||||
|
let gf = if mea > 1e-10: clamp(delta / mea, -1.0, 1.0) else: 0.0
|
||||||
|
|
||||||
|
g.updateMinMax(w.feat)
|
||||||
|
|
||||||
|
if g.obs.len < MaxObs:
|
||||||
|
g.obs.add Obs(feat: w.feat, gf: gf)
|
||||||
|
else:
|
||||||
|
# ring buffer
|
||||||
|
g.obs[g.obsHead] = Obs(feat: w.feat, gf: gf)
|
||||||
|
g.obsHead = (g.obsHead + 1) mod MaxObs
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
## Bullet shadow tracker — computes GF regions guaranteed safe because
|
||||||
|
## our in-flight bullets would intercept an enemy bullet traveling there.
|
||||||
|
##
|
||||||
|
## Geometry: 0° = East, X = East, Y = North (Tank Royale).
|
||||||
|
## Algorithm mirrors DrussGT EnemyWave.logShadow: simulate each of our bullets
|
||||||
|
## forward tick-by-tick, find where it intersects the expanding enemy wave ring,
|
||||||
|
## convert intersection points to GF values.
|
||||||
|
|
||||||
|
import std/math
|
||||||
|
import gun_harness/gun_interface
|
||||||
|
|
||||||
|
const
|
||||||
|
MaxBullets* = 32 ## slots; ponytail: simple array, 1 bullet/tick max
|
||||||
|
|
||||||
|
type
|
||||||
|
MyBullet* = object
|
||||||
|
x*, y*: float
|
||||||
|
headingRad*: float
|
||||||
|
speed*: float
|
||||||
|
alive*: bool
|
||||||
|
|
||||||
|
ShadowTracker* = object
|
||||||
|
bullets*: array[MaxBullets, MyBullet]
|
||||||
|
numSlots: int ## high-water mark
|
||||||
|
|
||||||
|
BulletShadow* = object
|
||||||
|
## GF range [gfLow, gfHigh] shadowed by one of our bullets for a wave.
|
||||||
|
gfLow*, gfHigh*: float
|
||||||
|
|
||||||
|
# ── bullet lifecycle ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
proc addBullet*(st: var ShadowTracker, x, y, headingRad, power: float) =
|
||||||
|
for i in 0..<MaxBullets:
|
||||||
|
if not st.bullets[i].alive:
|
||||||
|
st.bullets[i] = MyBullet(x: x, y: y, headingRad: headingRad,
|
||||||
|
speed: bulletSpeed(power), alive: true)
|
||||||
|
if i >= st.numSlots: st.numSlots = i + 1
|
||||||
|
return
|
||||||
|
|
||||||
|
proc removeBullet*(st: var ShadowTracker, idx: int) {.inline.} =
|
||||||
|
if idx >= 0 and idx < MaxBullets:
|
||||||
|
st.bullets[idx].alive = false
|
||||||
|
|
||||||
|
proc removeBulletNear*(st: var ShadowTracker, x, y: float) =
|
||||||
|
## Kill the live bullet slot closest to (x, y). Used when a hit event fires
|
||||||
|
## with the bullet's last known position.
|
||||||
|
var bestIdx = -1
|
||||||
|
var bestDist = 1e18
|
||||||
|
for i in 0..<st.numSlots:
|
||||||
|
if not st.bullets[i].alive: continue
|
||||||
|
let d = hypot(st.bullets[i].x - x, st.bullets[i].y - y)
|
||||||
|
if d < bestDist:
|
||||||
|
bestDist = d
|
||||||
|
bestIdx = i
|
||||||
|
if bestIdx >= 0:
|
||||||
|
st.bullets[bestIdx].alive = false
|
||||||
|
|
||||||
|
proc tick*(st: var ShadowTracker) =
|
||||||
|
## Advance all live bullets one tick (call once per game tick).
|
||||||
|
for i in 0..<st.numSlots:
|
||||||
|
if st.bullets[i].alive:
|
||||||
|
st.bullets[i].x += st.bullets[i].speed * cos(st.bullets[i].headingRad)
|
||||||
|
st.bullets[i].y += st.bullets[i].speed * sin(st.bullets[i].headingRad)
|
||||||
|
|
||||||
|
# ── shadow computation ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
proc getShadows*(st: ShadowTracker,
|
||||||
|
waveFireX, waveFireY: float,
|
||||||
|
waveBearingRad: float, ## bearing from enemy to us at fire time
|
||||||
|
waveRadius: float, ## current radius of the wave (px)
|
||||||
|
waveSpeed: float): seq[BulletShadow] =
|
||||||
|
## For each live bullet, simulate it forward against the expanding wave ring.
|
||||||
|
## Returns GF ranges [gfLow, gfHigh] that are shadowed.
|
||||||
|
##
|
||||||
|
## Caller supplies wave parameters directly to avoid coupling to VBWave.
|
||||||
|
let maxEA = arcsin(min(8.0 / waveSpeed, 1.0))
|
||||||
|
if maxEA < 1e-9: return
|
||||||
|
|
||||||
|
for bi in 0..<st.numSlots:
|
||||||
|
let b = st.bullets[bi]
|
||||||
|
if not b.alive: continue
|
||||||
|
|
||||||
|
let dx = b.speed * cos(b.headingRad)
|
||||||
|
let dy = b.speed * sin(b.headingRad)
|
||||||
|
var prevDist = hypot(b.x - waveFireX, b.y - waveFireY)
|
||||||
|
|
||||||
|
# ponytail: 300-tick horizon covers arena diagonal / min bullet speed
|
||||||
|
for step in 1..300:
|
||||||
|
let bx = b.x + float(step) * dx
|
||||||
|
let by = b.y + float(step) * dy
|
||||||
|
let curDist = hypot(bx - waveFireX, by - waveFireY)
|
||||||
|
let waveAt = waveRadius + float(step - 1) * waveSpeed
|
||||||
|
let waveNext = waveRadius + float(step) * waveSpeed
|
||||||
|
|
||||||
|
# Bullet crossed the ring: was outside at step-1, inside at step, and approaching
|
||||||
|
if curDist < waveNext and prevDist > waveAt and curDist < prevDist:
|
||||||
|
# Angular half-width of bot at crossing distance
|
||||||
|
let ringR = (waveAt + waveNext) * 0.5
|
||||||
|
let angHalf = arctan(BotRadius / max(ringR, 1.0))
|
||||||
|
# Center angle of intersection point (absolute bearing from wave origin)
|
||||||
|
let centerAngle = arctan2(by - waveFireY, bx - waveFireX)
|
||||||
|
# Convert to GF
|
||||||
|
var off = centerAngle - waveBearingRad
|
||||||
|
while off > PI: off -= 2.0 * PI
|
||||||
|
while off < -PI: off += 2.0 * PI
|
||||||
|
let gfCenter = clamp(off / maxEA, -1.0, 1.0)
|
||||||
|
let gfHalf = angHalf / maxEA
|
||||||
|
|
||||||
|
result.add BulletShadow(
|
||||||
|
gfLow: clamp(gfCenter - gfHalf, -1.0, 1.0),
|
||||||
|
gfHigh: clamp(gfCenter + gfHalf, -1.0, 1.0),
|
||||||
|
)
|
||||||
|
break # one shadow per bullet per wave
|
||||||
|
|
||||||
|
if curDist > prevDist: break # bullet diverging — no future crossing
|
||||||
|
prevDist = curDist
|
||||||
|
|
||||||
|
proc isShadowed*(shadows: openArray[BulletShadow], gf: float): bool {.inline.} =
|
||||||
|
for s in shadows:
|
||||||
|
if gf >= s.gfLow and gf <= s.gfHigh: return true
|
||||||
|
false
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
## Gunheat wave prediction — mirrors DrussGT's enemyGunHeat / imaginaryGunHeat pattern.
|
||||||
|
## Tracks two heat values:
|
||||||
|
## confirmedHeat — reset from actual energy-drop fire detections
|
||||||
|
## predictedHeat — reset speculatively when enemy COULD fire (heat near 0)
|
||||||
|
## This lets us create a "predicted" wave 1-2 ticks before energy drop confirms it.
|
||||||
|
##
|
||||||
|
## Gun cooling rate in Tank Royale: 0.1/tick.
|
||||||
|
## Initial gun heat at round start: 3.0 → first possible fire at tick 30.
|
||||||
|
|
||||||
|
import std/math
|
||||||
|
import gun_harness/gun_interface
|
||||||
|
|
||||||
|
const
|
||||||
|
GunCoolingRate* = 0.1
|
||||||
|
InitialGunHeat* = 3.0
|
||||||
|
|
||||||
|
type
|
||||||
|
WaveEventKind* = enum
|
||||||
|
wePredicted ## enemy CAN fire this tick (heat just reached 0)
|
||||||
|
weConfirmed ## energy drop confirmed a fire last tick
|
||||||
|
|
||||||
|
WaveEvent* = object
|
||||||
|
kind*: WaveEventKind
|
||||||
|
fireX*, fireY*: float ## enemy position at (predicted/confirmed) fire time
|
||||||
|
bulletPower*: float
|
||||||
|
tick*: int
|
||||||
|
|
||||||
|
GunheatTracker* = object
|
||||||
|
confirmedHeat*: float ## heat from last confirmed fire (energy drop)
|
||||||
|
predictedHeat*: float ## heat from last predicted fire (imaginary wave)
|
||||||
|
prevEnemyEnergy*: float
|
||||||
|
canFireTick*: int ## tick when enemy next confirmed able to fire
|
||||||
|
|
||||||
|
proc initGunheatTracker*(): GunheatTracker =
|
||||||
|
result.confirmedHeat = InitialGunHeat
|
||||||
|
result.predictedHeat = InitialGunHeat
|
||||||
|
result.prevEnemyEnergy = 100.0
|
||||||
|
result.canFireTick = -1
|
||||||
|
|
||||||
|
proc resetRound*(gt: var GunheatTracker) =
|
||||||
|
gt.confirmedHeat = InitialGunHeat
|
||||||
|
gt.predictedHeat = InitialGunHeat
|
||||||
|
gt.prevEnemyEnergy = 100.0
|
||||||
|
gt.canFireTick = -1
|
||||||
|
|
||||||
|
proc tick*(gt: var GunheatTracker, state: WorldState): seq[WaveEvent] =
|
||||||
|
## Call once per tick with current world state.
|
||||||
|
## Returns any wave events created this tick (0, 1, or 2 entries).
|
||||||
|
|
||||||
|
# Cool both heat values
|
||||||
|
gt.confirmedHeat = max(0.0, gt.confirmedHeat - GunCoolingRate)
|
||||||
|
gt.predictedHeat = max(gt.confirmedHeat, gt.predictedHeat - GunCoolingRate)
|
||||||
|
|
||||||
|
# Check energy drop for confirmed fire (fired last tick)
|
||||||
|
let drop = gt.prevEnemyEnergy - state.enemyEnergy
|
||||||
|
gt.prevEnemyEnergy = state.enemyEnergy
|
||||||
|
|
||||||
|
if drop >= 0.1 and drop <= 3.0 and gt.confirmedHeat == 0.0:
|
||||||
|
let power = drop
|
||||||
|
# ponytail: subtract one coolingRate because they fired last tick, not this tick
|
||||||
|
gt.confirmedHeat = 1.0 + power / 5.0 - GunCoolingRate
|
||||||
|
gt.predictedHeat = gt.confirmedHeat
|
||||||
|
gt.canFireTick = state.tick
|
||||||
|
result.add WaveEvent(
|
||||||
|
kind: weConfirmed,
|
||||||
|
fireX: state.enemyX,
|
||||||
|
fireY: state.enemyY,
|
||||||
|
bulletPower: power,
|
||||||
|
tick: state.tick,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Predicted wave: confirmedHeat just reached 0 AND no confirmed fire this tick
|
||||||
|
elif gt.confirmedHeat == 0.0 and gt.predictedHeat == 0.0:
|
||||||
|
# Enemy CAN fire. Guess power = 2.0 (sensible default, caller can override).
|
||||||
|
# ponytail: flat prior on power; upgrade to BulletPowerPredictor if needed
|
||||||
|
let guessedPower = 2.0
|
||||||
|
gt.predictedHeat = 1.0 + guessedPower / 5.0 + GunCoolingRate # +cooling: fire next tick
|
||||||
|
gt.canFireTick = state.tick
|
||||||
|
result.add WaveEvent(
|
||||||
|
kind: wePredicted,
|
||||||
|
fireX: state.enemyX,
|
||||||
|
fireY: state.enemyY,
|
||||||
|
bulletPower: guessedPower,
|
||||||
|
tick: state.tick,
|
||||||
|
)
|
||||||
@@ -6,6 +6,8 @@
|
|||||||
import std/math
|
import std/math
|
||||||
import gun_harness/gun_interface
|
import gun_harness/gun_interface
|
||||||
import movement_harness/movement_interface
|
import movement_harness/movement_interface
|
||||||
|
import movement_harness/gunheat_tracker
|
||||||
|
import movement_harness/bullet_shadows
|
||||||
|
|
||||||
const
|
const
|
||||||
VB_BINS* = 31 ## GF bins from -1 to +1
|
VB_BINS* = 31 ## GF bins from -1 to +1
|
||||||
@@ -34,12 +36,13 @@ type
|
|||||||
waves: array[MaxWaves, VBWave]
|
waves: array[MaxWaves, VBWave]
|
||||||
waveCount: int
|
waveCount: int
|
||||||
waveHead: int ## ring buffer head
|
waveHead: int ## ring buffer head
|
||||||
prevEnemyEnergy: float
|
gunheat: GunheatTracker
|
||||||
|
shadows*: ShadowTracker ## our bullets in flight for shadow computation
|
||||||
arenaW, arenaH: float
|
arenaW, arenaH: float
|
||||||
|
|
||||||
proc initVirtualBodyTracker*(numModules: int): VirtualBodyTracker =
|
proc initVirtualBodyTracker*(numModules: int): VirtualBodyTracker =
|
||||||
result.numModules = numModules
|
result.numModules = numModules
|
||||||
result.prevEnemyEnergy = 100.0
|
result.gunheat = initGunheatTracker()
|
||||||
# Seed bins so we have a uniform prior before any real hits
|
# Seed bins so we have a uniform prior before any real hits
|
||||||
for i in 0..<VB_BINS: result.dangerBins[i] = 1.0
|
for i in 0..<VB_BINS: result.dangerBins[i] = 1.0
|
||||||
|
|
||||||
@@ -48,9 +51,9 @@ proc resetRound*(t: var VirtualBodyTracker, startX, startY, startHeading, startS
|
|||||||
for i in 0..<t.numModules:
|
for i in 0..<t.numModules:
|
||||||
t.bodies[i] = VirtualBody(x: startX, y: startY,
|
t.bodies[i] = VirtualBody(x: startX, y: startY,
|
||||||
heading: startHeading, speed: startSpeed)
|
heading: startHeading, speed: startSpeed)
|
||||||
t.waveCount = 0
|
t.waveCount = 0
|
||||||
t.waveHead = 0
|
t.waveHead = 0
|
||||||
t.prevEnemyEnergy = 100.0
|
t.gunheat.resetRound()
|
||||||
|
|
||||||
proc registerHit*(t: var VirtualBodyTracker, bulletPower: float, bulletHeadingDeg: float,
|
proc registerHit*(t: var VirtualBodyTracker, bulletPower: float, bulletHeadingDeg: float,
|
||||||
realX, realY: float) =
|
realX, realY: float) =
|
||||||
@@ -92,22 +95,21 @@ proc tick*[N: static int](t: var VirtualBodyTracker, state: WorldState,
|
|||||||
## Per-tick update. cmds[i] is computeMove() output of module i.
|
## Per-tick update. cmds[i] is computeMove() output of module i.
|
||||||
## Call AFTER collecting all module commands for this tick.
|
## Call AFTER collecting all module commands for this tick.
|
||||||
|
|
||||||
# --- Fire detection ---
|
# --- Fire detection via gunheat tracker ---
|
||||||
let drop = t.prevEnemyEnergy - state.enemyEnergy
|
let waveEvents = t.gunheat.tick(state)
|
||||||
t.prevEnemyEnergy = state.enemyEnergy
|
for ev in waveEvents:
|
||||||
if drop >= 0.1 and drop <= 3.0:
|
let bspeed = 20.0 - 3.0 * ev.bulletPower
|
||||||
let bspeed = 20.0 - 3.0 * drop
|
let bearingRad = arctan2(state.selfY - ev.fireY, state.selfX - ev.fireX)
|
||||||
let bearingRad = arctan2(state.selfY - state.enemyY, state.selfX - state.enemyX)
|
let d = hypot(state.selfX - ev.fireX, state.selfY - ev.fireY)
|
||||||
let d = hypot(state.selfX - state.enemyX, state.selfY - state.enemyY)
|
|
||||||
let slot = t.waveHead mod MaxWaves
|
let slot = t.waveHead mod MaxWaves
|
||||||
t.waves[slot] = VBWave(
|
t.waves[slot] = VBWave(
|
||||||
fireX: state.enemyX,
|
fireX: ev.fireX,
|
||||||
fireY: state.enemyY,
|
fireY: ev.fireY,
|
||||||
fireBearingRad: bearingRad,
|
fireBearingRad: bearingRad,
|
||||||
speed: bspeed,
|
speed: bspeed,
|
||||||
radius: 0.0,
|
radius: 0.0,
|
||||||
fireTick: state.tick,
|
fireTick: ev.tick,
|
||||||
startDist: d,
|
startDist: d,
|
||||||
)
|
)
|
||||||
t.waveHead = (t.waveHead + 1) mod MaxWaves
|
t.waveHead = (t.waveHead + 1) mod MaxWaves
|
||||||
if t.waveCount < MaxWaves: inc t.waveCount
|
if t.waveCount < MaxWaves: inc t.waveCount
|
||||||
@@ -119,6 +121,9 @@ proc tick*[N: static int](t: var VirtualBodyTracker, state: WorldState,
|
|||||||
if i < N:
|
if i < N:
|
||||||
advanceBody(t.bodies[i], cmds[i], state.arenaWidth, state.arenaHeight)
|
advanceBody(t.bodies[i], cmds[i], state.arenaWidth, state.arenaHeight)
|
||||||
|
|
||||||
|
# --- Advance our bullets (for shadow tracking) ---
|
||||||
|
t.shadows.tick()
|
||||||
|
|
||||||
# --- Advance waves and score ---
|
# --- Advance waves and score ---
|
||||||
# ponytail: O(waves * modules), small counts, fine
|
# ponytail: O(waves * modules), small counts, fine
|
||||||
for wi in 0..<t.waveCount:
|
for wi in 0..<t.waveCount:
|
||||||
@@ -126,6 +131,9 @@ proc tick*[N: static int](t: var VirtualBodyTracker, state: WorldState,
|
|||||||
var w = addr t.waves[idx]
|
var w = addr t.waves[idx]
|
||||||
if w.speed <= 0.0: continue
|
if w.speed <= 0.0: continue
|
||||||
w.radius += w.speed
|
w.radius += w.speed
|
||||||
|
# Compute bullet shadows for this wave once; reuse across all virtual bodies
|
||||||
|
let waveShadows = t.shadows.getShadows(w.fireX, w.fireY,
|
||||||
|
w.fireBearingRad, w.radius, w.speed)
|
||||||
for mi in 0..<t.numModules:
|
for mi in 0..<t.numModules:
|
||||||
let bx = t.bodies[mi].x
|
let bx = t.bodies[mi].x
|
||||||
let by = t.bodies[mi].y
|
let by = t.bodies[mi].y
|
||||||
@@ -140,7 +148,9 @@ proc tick*[N: static int](t: var VirtualBodyTracker, state: WorldState,
|
|||||||
if maxA >= 1e-9:
|
if maxA >= 1e-9:
|
||||||
let gf = clamp(off / maxA, -1.0, 1.0)
|
let gf = clamp(off / maxA, -1.0, 1.0)
|
||||||
let bin = gfToBin(gf)
|
let bin = gfToBin(gf)
|
||||||
t.dangerScore[mi] += t.dangerBins[bin]
|
# Shadow zones are guaranteed safe — reduce danger by 90%
|
||||||
|
let shadowMul = if isShadowed(waveShadows, gf): 0.1 else: 1.0
|
||||||
|
t.dangerScore[mi] += t.dangerBins[bin] * shadowMul
|
||||||
|
|
||||||
proc bestMovement*(t: VirtualBodyTracker): int =
|
proc bestMovement*(t: VirtualBodyTracker): int =
|
||||||
## Returns index of module with lowest accumulated danger score.
|
## Returns index of module with lowest accumulated danger score.
|
||||||
|
|||||||
Reference in New Issue
Block a user