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:
2026-09-20 11:58:57 +02:00
parent 6d16081fcc
commit 651ce80620
4 changed files with 495 additions and 20 deletions
@@ -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,
)
+30 -20
View File
@@ -6,6 +6,8 @@
import std/math
import gun_harness/gun_interface
import movement_harness/movement_interface
import movement_harness/gunheat_tracker
import movement_harness/bullet_shadows
const
VB_BINS* = 31 ## GF bins from -1 to +1
@@ -34,12 +36,13 @@ type
waves: array[MaxWaves, VBWave]
waveCount: int
waveHead: int ## ring buffer head
prevEnemyEnergy: float
gunheat: GunheatTracker
shadows*: ShadowTracker ## our bullets in flight for shadow computation
arenaW, arenaH: float
proc initVirtualBodyTracker*(numModules: int): VirtualBodyTracker =
result.numModules = numModules
result.prevEnemyEnergy = 100.0
result.numModules = numModules
result.gunheat = initGunheatTracker()
# Seed bins so we have a uniform prior before any real hits
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:
t.bodies[i] = VirtualBody(x: startX, y: startY,
heading: startHeading, speed: startSpeed)
t.waveCount = 0
t.waveHead = 0
t.prevEnemyEnergy = 100.0
t.waveCount = 0
t.waveHead = 0
t.gunheat.resetRound()
proc registerHit*(t: var VirtualBodyTracker, bulletPower: float, bulletHeadingDeg: 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.
## Call AFTER collecting all module commands for this tick.
# --- Fire detection ---
let drop = t.prevEnemyEnergy - state.enemyEnergy
t.prevEnemyEnergy = state.enemyEnergy
if drop >= 0.1 and drop <= 3.0:
let bspeed = 20.0 - 3.0 * drop
let bearingRad = arctan2(state.selfY - state.enemyY, state.selfX - state.enemyX)
let d = hypot(state.selfX - state.enemyX, state.selfY - state.enemyY)
# --- Fire detection via gunheat tracker ---
let waveEvents = t.gunheat.tick(state)
for ev in waveEvents:
let bspeed = 20.0 - 3.0 * ev.bulletPower
let bearingRad = arctan2(state.selfY - ev.fireY, state.selfX - ev.fireX)
let d = hypot(state.selfX - ev.fireX, state.selfY - ev.fireY)
let slot = t.waveHead mod MaxWaves
t.waves[slot] = VBWave(
fireX: state.enemyX,
fireY: state.enemyY,
fireX: ev.fireX,
fireY: ev.fireY,
fireBearingRad: bearingRad,
speed: bspeed,
radius: 0.0,
fireTick: state.tick,
startDist: d,
speed: bspeed,
radius: 0.0,
fireTick: ev.tick,
startDist: d,
)
t.waveHead = (t.waveHead + 1) mod MaxWaves
if t.waveCount < MaxWaves: inc t.waveCount
@@ -119,6 +121,9 @@ proc tick*[N: static int](t: var VirtualBodyTracker, state: WorldState,
if i < N:
advanceBody(t.bodies[i], cmds[i], state.arenaWidth, state.arenaHeight)
# --- Advance our bullets (for shadow tracking) ---
t.shadows.tick()
# --- Advance waves and score ---
# ponytail: O(waves * modules), small counts, fine
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]
if w.speed <= 0.0: continue
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:
let bx = t.bodies[mi].x
let by = t.bodies[mi].y
@@ -140,7 +148,9 @@ proc tick*[N: static int](t: var VirtualBodyTracker, state: WorldState,
if maxA >= 1e-9:
let gf = clamp(off / maxA, -1.0, 1.0)
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 =
## Returns index of module with lowest accumulated danger score.