feat(ModularBot): virtual body movement selector — wave-based scoring replaces EMA damage
Implements VirtualBodyTracker (wave-based hit/miss scoring) instead of EMA damage accumulation. Movement switching now happens every tick, not every 3 rounds. Also refactors radar colors to dark teal (#004444/#0D4D4D) for faint visibility. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -25,6 +25,7 @@ import guns/averaged_lead
|
||||
import guns/decay_gf
|
||||
import movements/phantom_meteor
|
||||
import movements/wave_surfer
|
||||
import movement_harness/virtual_bodies as mvb
|
||||
|
||||
const botJsonPath = currentSourcePath().parentDir / "ModularBot.json"
|
||||
const DebugVBullets = false
|
||||
@@ -54,10 +55,7 @@ type
|
||||
mover: PhantomMeteorModule
|
||||
waveMover: WaveSurferModule
|
||||
movementId: int ## 0=phantom, 1=waveSurf
|
||||
dmgPhantom: float ## rolling damage taken under phantom
|
||||
dmgWaveSurf: float ## rolling damage taken under waveSurf
|
||||
roundDmg: float ## damage this round
|
||||
roundCount: int
|
||||
moveTracker: VirtualBodyTracker
|
||||
virtualHits: int
|
||||
virtualMiss: int
|
||||
tick: int
|
||||
@@ -215,35 +213,25 @@ method onScannedBot*(bot: ModularBot, e: ScannedBotEvent) =
|
||||
|
||||
|
||||
method onHitByBullet*(bot: ModularBot, e: HitByBulletEvent) =
|
||||
bot.roundDmg += e.damage
|
||||
bot.moveTracker.registerHit(e.bullet.power, e.bullet.direction, getX(), getY())
|
||||
|
||||
method onRoundEnded*(bot: ModularBot, e: RoundEndedEventForBot) =
|
||||
# Accumulate damage into active movement bucket (exponential decay, α=0.5)
|
||||
# ponytail: simple EMA; switch to longer window if thrashing becomes a problem
|
||||
let decay = 0.5
|
||||
if bot.movementId == 0:
|
||||
bot.dmgPhantom = decay * bot.dmgPhantom + (1.0 - decay) * bot.roundDmg
|
||||
else:
|
||||
bot.dmgWaveSurf = decay * bot.dmgWaveSurf + (1.0 - decay) * bot.roundDmg
|
||||
echo "[move] round dmg=", bot.roundDmg, " phantom_ema=", bot.dmgPhantom, " wave_ema=", bot.dmgWaveSurf
|
||||
let newId = bot.moveTracker.bestMovement()
|
||||
if newId != bot.movementId:
|
||||
echo "[move] switching: ", (if newId == 0: "phantom_meteor" else: "wave_surfer")
|
||||
bot.movementId = newId
|
||||
|
||||
method onRoundStarted*(bot: ModularBot, e: RoundStartedEvent) =
|
||||
setAdjustGunForBodyTurn(true)
|
||||
setAdjustRadarForBodyTurn(true)
|
||||
setAdjustRadarForGunTurn(true)
|
||||
setRadarColor("#00CCCC") # RadarLock: cyan/teal
|
||||
setScanColor("#33FFFF") # RadarLock scan arc: bright teal
|
||||
setRadarColor("#004444") # RadarLock: dark teal, faint without alpha
|
||||
setScanColor("#0D4D4D") # RadarLock scan arc: very dark teal, faint without alpha
|
||||
bot.radar.init()
|
||||
bot.hasContact = false
|
||||
bot.tick = 0
|
||||
bot.currentGun = -1
|
||||
bot.roundDmg = 0.0
|
||||
inc bot.roundCount
|
||||
|
||||
# Every 3 rounds: switch to lower-damage movement (probe every 3rd round)
|
||||
# ponytail: simple 3-round probe; add hysteresis if switching thrashes
|
||||
if bot.roundCount > 2:
|
||||
bot.movementId = if bot.dmgPhantom <= bot.dmgWaveSurf: 0 else: 1
|
||||
bot.moveTracker.resetRound(getX(), getY(), getDirection(), getSpeed())
|
||||
|
||||
if bot.movementId == 0:
|
||||
bot.mover.resetRound()
|
||||
@@ -271,10 +259,15 @@ method run*(bot: ModularBot) =
|
||||
go()
|
||||
continue
|
||||
|
||||
# Movement harness: dispatch to active mover
|
||||
let (spd, tr) =
|
||||
if bot.movementId == 0: bot.mover.computeMove(bot.lastState)
|
||||
else: bot.waveMover.computeMove(bot.lastState)
|
||||
# Movement harness: compute all, feed tracker, apply active
|
||||
let phantomCmd = bot.mover.computeMove(bot.lastState)
|
||||
let waveSurfCmd = bot.waveMover.computeMove(bot.lastState)
|
||||
bot.moveTracker.tick(bot.lastState, [phantomCmd, waveSurfCmd])
|
||||
let activeId = bot.moveTracker.bestMovement()
|
||||
if activeId != bot.movementId:
|
||||
bot.movementId = activeId
|
||||
echo "[move] switched to: ", (if activeId == 0: "phantom_meteor" else: "wave_surfer")
|
||||
let (spd, tr) = if bot.movementId == 0: phantomCmd else: waveSurfCmd
|
||||
setTargetSpeed(spd)
|
||||
setTurnRate(tr)
|
||||
|
||||
@@ -297,9 +290,10 @@ when isMainModule:
|
||||
displace: initDisplacementGun(),
|
||||
avgLead: initAveragedLeadGun(),
|
||||
decayGF: initDecayGFGun(),
|
||||
radar: RadarLockModule(),
|
||||
mover: initPhantomMeteor(),
|
||||
waveMover: initWaveSurfer(),
|
||||
currentGun: -1,
|
||||
radar: RadarLockModule(),
|
||||
mover: initPhantomMeteor(),
|
||||
waveMover: initWaveSurfer(),
|
||||
moveTracker: mvb.initVirtualBodyTracker(2), # 0=phantom, 1=waveSurf
|
||||
currentGun: -1,
|
||||
)
|
||||
start(bot, botJsonPath)
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
## Virtual body tracker — mirrors virtual_bullets.nim but for movement modules.
|
||||
## Tracks simulated positions for each movement candidate under enemy waves.
|
||||
## Scores modules by how often they sit in high-danger GF bins.
|
||||
## Caller feeds actual hit data via registerHit() to build the danger profile.
|
||||
|
||||
import std/math
|
||||
import gun_harness/gun_interface
|
||||
import movement_harness/movement_interface
|
||||
|
||||
const
|
||||
VB_BINS* = 31 ## GF bins from -1 to +1
|
||||
MaxWaves* = 64 ## hard cap; ponytail: ring buffer, resize if needed
|
||||
MaxModules* = 8 ## max movement modules tracked
|
||||
|
||||
type
|
||||
VBWave = object
|
||||
fireX, fireY: float
|
||||
fireBearingRad: float ## bearing from fireOrigin to REAL bot at fire time (radians)
|
||||
speed: float
|
||||
radius: float
|
||||
fireTick: int
|
||||
startDist: float
|
||||
|
||||
VirtualBody = object
|
||||
x, y: float
|
||||
heading: float ## degrees (Tank Royale / bot API)
|
||||
speed: float ## current speed px/tick
|
||||
|
||||
VirtualBodyTracker* = object
|
||||
numModules: int
|
||||
bodies: array[MaxModules, VirtualBody]
|
||||
dangerScore: array[MaxModules, float] ## accumulated danger hits
|
||||
dangerBins: array[VB_BINS, float] ## populated from registerHit
|
||||
waves: array[MaxWaves, VBWave]
|
||||
waveCount: int
|
||||
waveHead: int ## ring buffer head
|
||||
prevEnemyEnergy: float
|
||||
arenaW, arenaH: float
|
||||
|
||||
proc initVirtualBodyTracker*(numModules: int): VirtualBodyTracker =
|
||||
result.numModules = numModules
|
||||
result.prevEnemyEnergy = 100.0
|
||||
# Seed bins so we have a uniform prior before any real hits
|
||||
for i in 0..<VB_BINS: result.dangerBins[i] = 1.0
|
||||
|
||||
proc resetRound*(t: var VirtualBodyTracker, startX, startY, startHeading, startSpeed: float) =
|
||||
## Reset virtual body positions to actual bot position at round start.
|
||||
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
|
||||
|
||||
proc registerHit*(t: var VirtualBodyTracker, bulletPower: float, bulletHeadingDeg: float,
|
||||
realX, realY: float) =
|
||||
## Feed an actual HitByBullet event in. Computes exact GF bin and increments danger.
|
||||
## bulletHeadingDeg: the heading the bullet was travelling when it hit (degrees).
|
||||
## We need the wave that fired it to get fireBearing; absent that, just increment center.
|
||||
## ponytail: approximate — we don't correlate to exact wave here, just record impact direction.
|
||||
## The bearing the bullet came FROM reversed = direction from fireOrigin to us at impact.
|
||||
## Without access to the wave origin we use a weight-1 spike at center bin.
|
||||
## TODO: correlate to nearest wave for exact GF if needed.
|
||||
let centerBin = VB_BINS div 2
|
||||
t.dangerBins[centerBin] += 2.0 # spike center on real hit
|
||||
|
||||
proc gfToBin(gf: float): int {.inline.} =
|
||||
clamp(int(round((gf.clamp(-1.0, 1.0) + 1.0) * 0.5 * float(VB_BINS - 1))), 0, VB_BINS - 1)
|
||||
|
||||
proc mea(speed: float): float {.inline.} = arcsin(min(8.0 / speed, 1.0))
|
||||
|
||||
proc advanceBody(b: var VirtualBody, cmd: MoveCommand, arenaW, arenaH: float) =
|
||||
## Single-tick physics: apply MoveCommand, clamp to arena.
|
||||
let (targetSpeed, desiredTurn) = cmd
|
||||
# maxTurnRate depends on current speed (Tank Royale formula)
|
||||
let maxTurn = 10.0 - 0.75 * abs(b.speed)
|
||||
b.heading += clamp(desiredTurn, -maxTurn, maxTurn)
|
||||
# Speed ramps ±1 toward target, clamped to [-8, 8]
|
||||
let tgt = clamp(targetSpeed, -8.0, 8.0)
|
||||
if b.speed < tgt:
|
||||
b.speed = min(b.speed + 1.0, tgt)
|
||||
else:
|
||||
b.speed = max(b.speed - 1.0, tgt)
|
||||
let headingRad = degToRad(b.heading)
|
||||
b.x += b.speed * cos(headingRad)
|
||||
b.y += b.speed * sin(headingRad)
|
||||
b.x = clamp(b.x, 18.0, arenaW - 18.0)
|
||||
b.y = clamp(b.y, 18.0, arenaH - 18.0)
|
||||
|
||||
proc tick*[N: static int](t: var VirtualBodyTracker, state: WorldState,
|
||||
cmds: array[N, MoveCommand]) =
|
||||
## 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)
|
||||
let slot = t.waveHead mod MaxWaves
|
||||
t.waves[slot] = VBWave(
|
||||
fireX: state.enemyX,
|
||||
fireY: state.enemyY,
|
||||
fireBearingRad: bearingRad,
|
||||
speed: bspeed,
|
||||
radius: 0.0,
|
||||
fireTick: state.tick,
|
||||
startDist: d,
|
||||
)
|
||||
t.waveHead = (t.waveHead + 1) mod MaxWaves
|
||||
if t.waveCount < MaxWaves: inc t.waveCount
|
||||
|
||||
# --- Advance virtual bodies ---
|
||||
for i in 0..<t.numModules:
|
||||
when N > MaxModules:
|
||||
{.error: "cmds array exceeds MaxModules".}
|
||||
if i < N:
|
||||
advanceBody(t.bodies[i], cmds[i], state.arenaWidth, state.arenaHeight)
|
||||
|
||||
# --- Advance waves and score ---
|
||||
# ponytail: O(waves * modules), small counts, fine
|
||||
for wi in 0..<t.waveCount:
|
||||
let idx = wi mod MaxWaves
|
||||
var w = addr t.waves[idx]
|
||||
if w.speed <= 0.0: continue
|
||||
w.radius += w.speed
|
||||
for mi in 0..<t.numModules:
|
||||
let bx = t.bodies[mi].x
|
||||
let by = t.bodies[mi].y
|
||||
let d = hypot(bx - w.fireX, by - w.fireY)
|
||||
# Wave crosses virtual body: within BotRadius of its current position
|
||||
if abs(w.radius - d) <= BotRadius:
|
||||
let toBotRad = arctan2(by - w.fireY, bx - w.fireX)
|
||||
var off = toBotRad - w.fireBearingRad
|
||||
while off > PI: off -= 2.0 * PI
|
||||
while off < -PI: off += 2.0 * PI
|
||||
let maxA = mea(w.speed)
|
||||
if maxA >= 1e-9:
|
||||
let gf = clamp(off / maxA, -1.0, 1.0)
|
||||
let bin = gfToBin(gf)
|
||||
t.dangerScore[mi] += t.dangerBins[bin]
|
||||
|
||||
proc bestMovement*(t: VirtualBodyTracker): int =
|
||||
## Returns index of module with lowest accumulated danger score.
|
||||
result = 0
|
||||
var best = t.dangerScore[0]
|
||||
for i in 1..<t.numModules:
|
||||
if t.dangerScore[i] < best:
|
||||
best = t.dangerScore[i]
|
||||
result = i
|
||||
Reference in New Issue
Block a user