Files
SirRoboGarage/ModularBot_garage/src/vbullet_draw.nim
T

245 lines
10 KiB
Nim

## Debug overlay for the virtual-bullet tracker (the training signal the gun
## selector learns from). DEFAULT OFF: when the env knobs are unset the shipped
## bot draws exactly what it drew before.
##
## Why this lives in `ModularBot_garage/src` and not next to the tracker: the
## tracker is shared with the offline range, while the overlay is a GUI-only
## concern of the live bot. It imports the bot API's debug-graphics module to
## emit SVG, and the tracker to read the bullets.
##
## Visual vocabulary (one colour per gun, the SAME table as the turret):
##
## path thin line from the fire point along the fire direction, as far as
## the bullet has actually travelled (`travelDist`).
## aim small hollow circle at the PREDICTED target position (`aimX/Y`).
## miss short line from the aim point to the closest-approach point, plus
## a HIT dot (filled) / MISS ring (hollow) there. This is the score.
##
## The colour table is `gunColors`, shared with `ModularBot.run`'s turret
## colouring, so the overlay and the turret can never drift apart.
##
## Env knobs (all read once at boot by ModularBot and registered in
## `env_report.nim`):
## TR_VBULLET_DEBUG presence/1 = on. default off.
## TR_VBULLET_DEBUG_GUN "" = selected gun only (default); "all"/"*" = every
## gun; otherwise a gun name (e.g. `Pattern`).
## TR_VBULLET_DEBUG_MAX cap on bullets drawn per tick. default 32.
import std/[math, os, strutils, algorithm]
import robocode_tankroyale_botapi
# The bot API also exports a case-insensitively identical `BOT_RADIUS`, so bring
# the harness module in qualified only and reference `gun_interface.BotRadius`.
from gun_harness/gun_interface import nil
import gun_harness/virtual_bullets as vb
const
VBulletDebugEnv* = "TR_VBULLET_DEBUG"
VBulletDebugGunEnv* = "TR_VBULLET_DEBUG_GUN"
VBulletDebugMaxEnv* = "TR_VBULLET_DEBUG_MAX"
VBulletDebugDefaultMax* = 32
## Bullets drawn per tick when `TR_VBULLET_DEBUG_MAX` is unset. With the
## default SELECTED-gun filter a gun spawns at most one flight per power bin
## per tick (~3-4 in flight), so 32 is generous while still bounding the
## worst case: at most 32 paths + 32 aim rings + miss vectors. A wall of
## 200 overlapping lines is a failed feature, so the cap is enforced.
VbFilterSelected* = -1
VbFilterAll* = -2
VbPathWidth* = 1.0
VbAimRadius* = 5.0
VbAimWidth* = 1.5
VbMarkRadius* = 3.0
# ── env resolution (pure, so a test can pin it) ──────────────────────────────
proc vBulletDebugEnabled*(): bool =
## Presence/1 = on; explicit 0/false/no/off = off; unset = off (shipped).
let v = getEnv(VBulletDebugEnv, "").strip().toLowerAscii()
v.len > 0 and v notin ["0", "false", "no", "off"]
proc vBulletDebugMaxBullets*(): int =
## `TR_VBULLET_DEBUG_MAX`, clamped to at least 1. Bad values fall back.
try:
result = max(1, parseInt(getEnv(VBulletDebugMaxEnv, "").strip()))
except ValueError:
result = VBulletDebugDefaultMax
proc resolveGunFilter*(spec: string, names: openArray[string]): int =
## "" -> selected gun only; "all"/"*" -> every gun; a gun name
## (case-insensitive) -> that gun; unknown -> selected (safe fallback).
let s = spec.strip()
if s.len == 0: return VbFilterSelected
let low = s.toLowerAscii()
if low == "all" or low == "*": return VbFilterAll
for i, n in names:
if cmpIgnoreCase(n, s) == 0: return i
VbFilterSelected
# ── the shared per-gun colour table ──────────────────────────────────────────
proc gunColors*(gunId: int): tuple[turret, bullet: string] =
## SINGLE source of truth for the per-gun colours, used by BOTH the live
## turret colouring in `ModularBot.run` and this overlay. Keep gun ids in
## step with `GunNames`.
case gunId
of 0: ("#FFFFFF", "#FFFFFF") # HeadOn — white
of 1: ("#3366FF", "#6699FF") # Linear — blue
of 2: ("#9933FF", "#CC66FF") # Tsetlin — violet
of 3: ("#33CC33", "#66FF66") # Circular — green
of 4: ("#FFCC00", "#FFE066") # GuessFactor — amber
of 5: ("#00CC44", "#66FF99") # Pattern — green
of 6: ("#CCCCCC", "#EEEEEE") # WallBounce — grey
of 7: ("#FF00FF", "#FF66FF") # Accel — magenta
of 8: ("#990000", "#CC3333") # StopShot — dark red
of 9: ("#006600", "#009900") # Displace — dark green
of 10: ("#996633", "#CC9966") # AvgLead — brown
of 11: ("#008888", "#00AAAA") # DecayGF — teal
of 12: ("#CC00CC", "#FF44FF") # KNN — purple
of 13: ("#00FFCC", "#66FFDD") # TMSelect — cyan
of 14: ("#AAFF00", "#CCFF66") # TMPattern — lime
of 15: ("#00AAFF", "#66CCFF") # TMHorizon — sky
of 16: ("#FF1493", "#FF69B4") # LEADGAIN — deep pink
else: ("#FFFFFF", "#FFFFFF")
proc gunTurretColor*(gunId: int): string = gunColors(gunId).turret
proc gunBulletColor*(gunId: int): string = gunColors(gunId).bullet
# ── primitives ───────────────────────────────────────────────────────────────
type
VbPrimKind* = enum
vpPath ## travelled path (line)
vpAim ## predicted aim point (hollow circle)
vpMiss ## aim -> closest-approach vector (line)
vpHit ## hit at closest approach (filled circle)
vpMissMark ## miss at closest approach (hollow circle)
VbPrim* = object
kind*: VbPrimKind
x1*, y1*: float ## line start / circle centre
x2*, y2*: float ## line end (circles ignore)
r*: float ## circle radius
gunId*: int
color*: string
VbResolved* = object
## One bullet resolution captured from `onResolved`, so the miss vector of
## a bullet that DISAPPEARED this tick is still visible (the tracker clears
## `active` the moment it resolves).
gunId*: int
aimX*, aimY*: float
actualX*, actualY*: float
hit*: bool
proc matchesFilter(gunId, selectedGun, filterGun: int): bool {.inline.} =
if filterGun == VbFilterAll: return true
if filterGun == VbFilterSelected: return gunId == selectedGun
gunId == filterGun
proc collectVirtualBulletPrims*(t: VirtualTracker, selectedGun, filterGun,
maxBullets: int): seq[VbPrim] =
## Pure: the draw primitives for the tracker's ACTIVE bullets, newest first,
## capped at `maxBullets` bullets (a bullet may add up to 4 primitives).
var drawn = 0
for k in 1 .. MaxBullets:
if drawn >= maxBullets: break
let i = (t.head - k + MaxBullets) mod MaxBullets
let b = t.bullets[i]
if not b.active: continue
if not matchesFilter(b.gunId, selectedGun, filterGun): continue
inc drawn
let dx = b.aimX - b.fireX
let dy = b.aimY - b.fireY
let dist = hypot(dx, dy)
var ux, uy: float
if dist > 1e-6:
ux = dx / dist
uy = dy / dist
let curX = b.fireX + ux * b.travelDist
let curY = b.fireY + uy * b.travelDist
let col = gunTurretColor(b.gunId)
result.add VbPrim(kind: vpPath, x1: b.fireX, y1: b.fireY, x2: curX, y2: curY,
gunId: b.gunId, color: col)
result.add VbPrim(kind: vpAim, x1: b.aimX, y1: b.aimY, r: VbAimRadius,
gunId: b.gunId, color: col)
if b.bestMissDist < Inf:
result.add VbPrim(kind: vpMiss, x1: b.aimX, y1: b.aimY,
x2: b.bestMissX, y2: b.bestMissY,
gunId: b.gunId, color: col)
result.add VbPrim(
kind: (if b.bestMissDist < gun_interface.BotRadius: vpHit else: vpMissMark),
x1: b.bestMissX, y1: b.bestMissY, r: VbMarkRadius,
gunId: b.gunId, color: col)
proc collectResolvedPrims*(resolved: seq[VbResolved], selectedGun, filterGun,
maxBullets: int): seq[VbPrim] =
## Pure: miss-vector + hit/miss marker for bullets that resolved this tick.
## Works for BOTH metrics (the point metric records no running best-miss).
var drawn = 0
for r in resolved:
if drawn >= maxBullets: break
if not matchesFilter(r.gunId, selectedGun, filterGun): continue
inc drawn
let col = gunTurretColor(r.gunId)
result.add VbPrim(kind: vpMiss, x1: r.aimX, y1: r.aimY,
x2: r.actualX, y2: r.actualY, gunId: r.gunId, color: col)
result.add VbPrim(kind: (if r.hit: vpHit else: vpMissMark),
x1: r.actualX, y1: r.actualY, r: VbMarkRadius,
gunId: r.gunId, color: col)
# ── emit to the GUI debug-graphics buffer ────────────────────────────────────
proc drawVbLegend(prims: seq[VbPrim], names: openArray[string]) =
## One colour swatch + gun name per gun that contributed a primitive, top-left
## corner. Cheap and it makes the colours self-explanatory.
var seen: seq[int]
for p in prims:
if p.gunId notin seen: seen.add p.gunId
seen.sort()
var y = 24.0
for g in seen:
if g < 0 or g >= names.len: continue
setFillColor(gunTurretColor(g))
fillRectangle(8.0, y - 10.0, 10.0, 10.0)
setFillColor("#FFFFFF")
setFont("Arial", 12.0)
drawText(names[g], 22.0, y)
y += 16.0
proc emitVbPrims*(prims: seq[VbPrim], names: openArray[string] = []): int =
## Emit primitives into the current tick's SVG buffer and return the count.
## Thin lines so a busy sky stays readable.
for p in prims:
case p.kind
of vpPath, vpMiss:
setStrokeColor(p.color)
setStrokeWidth(VbPathWidth)
drawLine(p.x1, p.y1, p.x2, p.y2)
of vpAim:
setStrokeColor(p.color)
setStrokeWidth(VbAimWidth)
drawCircle(p.x1, p.y1, p.r)
of vpHit:
setFillColor(p.color)
fillCircle(p.x1, p.y1, p.r)
of vpMissMark:
setStrokeColor(p.color)
setStrokeWidth(VbAimWidth)
drawCircle(p.x1, p.y1, p.r)
if names.len > 0:
drawVbLegend(prims, names)
result = prims.len
proc drawVirtualBullets*(t: VirtualTracker, resolved: seq[VbResolved],
selectedGun, filterGun, maxBullets: int,
names: openArray[string]): int =
## Entry point called once per tick by ModularBot. Returns primitives drawn.
let active = collectVirtualBulletPrims(t, selectedGun, filterGun, maxBullets)
let rest = collectResolvedPrims(resolved, selectedGun, filterGun, maxBullets)
var prims = active
prims.add rest
emitVbPrims(prims, names)