288 lines
13 KiB
Nim
288 lines
13 KiB
Nim
## Geometry overlay (TR_GEO_DEBUG) — a DRAW-ONLY visual aid.
|
|
##
|
|
## No decision, no movement, no gun change: this module only emits SVG for the
|
|
## GUI. DEFAULT OFF, so the shipped bot is byte-for-byte unchanged. It exists so
|
|
## the owner can reason about a specific piece of geometry:
|
|
##
|
|
## * the two bots are the endpoints of a DIAMETER, so both lie exactly on the
|
|
## circle (centre = midpoint, radius = distance / 2);
|
|
## * a line along OUR heading through our position;
|
|
## * a line along the ENEMY heading through the enemy position;
|
|
## * the intersection points of each line with the circle, marked. A line
|
|
## through a point that is ON the circle meets it twice — the bot itself and
|
|
## ONE OTHER point — and the OTHER point is drawn visually distinct (that is
|
|
## the one worth looking at). A tangent line's two points coincide; we draw
|
|
## what the maths gives;
|
|
## * the TRIANGLE whose corners are the ENEMY heading line's OTHER
|
|
## intersection, the enemy, and our bot — three vertices the rest of the
|
|
## overlay already computes. Drawn YELLOW (#FFFF00), stroke width 1.5,
|
|
## with a small yellow hollow ring (r=6) on each distinct corner, so it is
|
|
## not confused with the grey circle / cyan / orange heading lines. A
|
|
## zero-length edge (a tangent enemy heading puts corner 1 on top of
|
|
## corner 2; or corner 1 on top of corner 3 when the enemy heading points
|
|
## straight at us) is SKIPPED rather than emitted as a degenerate line.
|
|
##
|
|
## Env knobs (read once at boot by ModularBot, registered in env_report.nim):
|
|
## TR_GEO_DEBUG presence/1 = on. default OFF.
|
|
## TR_DEBUG_DRAW presence/1 = on (default); 0/false/no/off disables every
|
|
## mover's `debugGraphics` and the virtual-bullet overlay, so
|
|
## the geometry overlay is the ONLY thing left on screen.
|
|
##
|
|
## The geometry is pure and unit-tested by `tests/test_geo_overlay.nim`; only
|
|
## `emitGeoPrims` touches the GUI buffer.
|
|
|
|
import std/[math, os, strutils]
|
|
import robocode_tankroyale_botapi
|
|
|
|
const
|
|
GeoDebugEnv* = "TR_GEO_DEBUG"
|
|
DebugDrawEnv* = "TR_DEBUG_DRAW"
|
|
|
|
const DegToRad = PI / 180.0
|
|
|
|
# ── env resolution (pure, so a test can pin it) ──────────────────────────────
|
|
|
|
proc envFlagOn(value: string, default: bool): bool =
|
|
## "" -> `default`; explicit 0/false/no/off -> false; anything else -> true.
|
|
let s = value.strip().toLowerAscii()
|
|
if s.len == 0: return default
|
|
s notin ["0", "false", "no", "off"]
|
|
|
|
proc geoDebugEnabled*(): bool =
|
|
## `TR_GEO_DEBUG`: default OFF (the shipped bot draws nothing new).
|
|
envFlagOn(getEnv(GeoDebugEnv, ""), false)
|
|
|
|
proc debugDrawEnabled*(): bool =
|
|
## `TR_DEBUG_DRAW`: default ON. When off, every mover is built with
|
|
## `debugGraphics = false` and the virtual-bullet overlay is skipped.
|
|
envFlagOn(getEnv(DebugDrawEnv, ""), true)
|
|
|
|
# ── the geometry (pure) ──────────────────────────────────────────────────────
|
|
|
|
type
|
|
GeomCircle* = object
|
|
cx*, cy*, r*: float
|
|
|
|
GeomPoint* = object
|
|
x*, y*: float
|
|
|
|
proc makeDiameterCircle*(ax, ay, bx, by: float): GeomCircle =
|
|
## The circle whose DIAMETER is the segment a-b: centre at the midpoint,
|
|
## radius half the distance. Both a and b therefore lie on the circumference.
|
|
## A degenerate (coincident) pair yields r = 0; callers skip the draw.
|
|
result.cx = (ax + bx) * 0.5
|
|
result.cy = (ay + by) * 0.5
|
|
result.r = 0.5 * hypot(bx - ax, by - ay)
|
|
|
|
proc distTo*(p: GeomPoint, c: GeomCircle): float {.inline.} =
|
|
hypot(p.x - c.cx, p.y - c.cy)
|
|
|
|
proc onCircle*(p: GeomPoint, c: GeomCircle): bool {.inline.} =
|
|
abs(p.distTo(c) - c.r) < 1e-6
|
|
|
|
proc lineCircleIntersections*(px, py, headingDeg: float,
|
|
c: GeomCircle): seq[GeomPoint] =
|
|
## The two intersection points of the INFINITE line through (px, py) along
|
|
## `headingDeg` with circle `c`. The line is parametrised P(t) = p + t*d with
|
|
## d a unit vector, so the quadratic has a = 1 and the roots are signed
|
|
## distances along d. Returns the two roots when the line meets the circle
|
|
## (coincident when tangent), and nothing when it misses.
|
|
##
|
|
## Tank Royale bearings: 0° = east, positive counter-clockwise.
|
|
let h = headingDeg * DegToRad
|
|
let dx = cos(h)
|
|
let dy = sin(h)
|
|
let ox = px - c.cx
|
|
let oy = py - c.cy
|
|
let b = 2.0 * (ox * dx + oy * dy)
|
|
let cc = ox * ox + oy * oy - c.r * c.r
|
|
let disc = b * b - 4.0 * cc
|
|
if disc < 0.0: return @[]
|
|
let s = sqrt(disc)
|
|
let t1 = (-b - s) * 0.5
|
|
let t2 = (-b + s) * 0.5
|
|
result.add GeomPoint(x: px + t1 * dx, y: py + t1 * dy)
|
|
result.add GeomPoint(x: px + t2 * dx, y: py + t2 * dy)
|
|
|
|
type
|
|
GeoPrimKind* = enum
|
|
gpCircle ## the diameter circle outline
|
|
gpLineSelf ## line along OUR heading
|
|
gpLineEnemy ## line along the ENEMY heading
|
|
gpMarkSelf ## intersection that coincides with the bot (hollow ring)
|
|
gpMarkOther ## the OTHER intersection (filled, distinct)
|
|
gpTriangleEdge ## one edge of the enemy-heading triangle
|
|
gpTriangleCorner ## a DISTINCT corner of that triangle (hollow ring)
|
|
|
|
GeoPrim* = object
|
|
kind*: GeoPrimKind
|
|
x1*, y1*: float ## lines: first endpoint
|
|
x2*, y2*: float ## lines: second endpoint
|
|
cx*, cy*, r*: float ## circles: centre + radius
|
|
color*: string
|
|
|
|
const
|
|
GeoCircleColor* = "#888888" # grey — the diameter circle
|
|
GeoSelfLineColor* = "#00FFFF" # cyan — OUR heading line
|
|
GeoEnemyLineColor* = "#FF8000" # orange— ENEMY heading line
|
|
GeoSelfMarkColor* = "#FFFFFF" # white — the bot itself (hollow ring)
|
|
GeoOtherMarkColor* = "#FF00FF" # magenta — the OTHER intersection (filled)
|
|
GeoTriangleColor* = "#FFFF00" # yellow — the requested triangle (edges+corner rings)
|
|
GeoCircleWidth* = 1.0
|
|
GeoLineWidth* = 1.0
|
|
GeoMarkWidth* = 1.5
|
|
GeoTriangleWidth* = 1.5
|
|
GeoSelfMarkRadius* = 4.0
|
|
GeoOtherMarkRadius* = 4.0
|
|
GeoTriangleCornerRadius* = 6.0
|
|
GeoZeroLen* = 1e-6
|
|
## Below this two triangle corners are the SAME point: the edge between
|
|
## them is degenerate (skipped) and they share a single corner ring.
|
|
GeoLineMargin* = 10.0
|
|
## Extra length (px) the heading segment extends beyond the far
|
|
## intersection; a segment of half-length 2r + margin always contains both
|
|
## intersections because a chord of a circle is at most 2r long.
|
|
|
|
proc pickSelfOther*(pts: seq[GeomPoint], ax, ay: float):
|
|
tuple[self, other: GeomPoint, hasOther: bool] =
|
|
## The intersection nearest the anchor `(ax, ay)` is the bot itself (the
|
|
## anchor lies ON the circle, so the distance is ~0); the farther one is the
|
|
## OTHER point. `hasOther` distinguishes a real second point from a tangent
|
|
## coincidence.
|
|
if pts.len == 0: return
|
|
var iSelf = 0
|
|
var dSelf = hypot(pts[0].x - ax, pts[0].y - ay)
|
|
for i in 1 ..< pts.len:
|
|
let d = hypot(pts[i].x - ax, pts[i].y - ay)
|
|
if d < dSelf: dSelf = d; iSelf = i
|
|
result.self = pts[iSelf]
|
|
var iOther = -1
|
|
var dOther = 0.0
|
|
for i in 0 ..< pts.len:
|
|
if i == iSelf: continue
|
|
let d = hypot(pts[i].x - ax, pts[i].y - ay)
|
|
if iOther < 0 or d > dOther: iOther = i; dOther = d
|
|
if iOther >= 0:
|
|
result.other = pts[iOther]
|
|
result.hasOther = true
|
|
|
|
proc headingSegment(px, py, headingDeg, halfLen: float): tuple[x1, y1, x2, y2: float] =
|
|
## Segment centred on (px, py), `halfLen` long each way along `headingDeg`.
|
|
let h = headingDeg * DegToRad
|
|
let dx = cos(h) * halfLen
|
|
let dy = sin(h) * halfLen
|
|
(px - dx, py - dy, px + dx, py + dy)
|
|
|
|
proc samePoint(a, b: GeomPoint): bool {.inline.} =
|
|
hypot(b.x - a.x, b.y - a.y) < GeoZeroLen
|
|
|
|
proc trianglePrims*(enemyOther, enemyPos, selfPos: GeomPoint): seq[GeoPrim] =
|
|
## DRAW-ONLY: the owner-requested triangle whose three corners are, in order,
|
|
## the ENEMY heading line's OTHER intersection with the diameter circle, the
|
|
## enemy itself, and our bot. Returns its three edges plus one hollow corner
|
|
## ring per DISTINCT corner, all in `GeoTriangleColor` (yellow, width 1.5).
|
|
##
|
|
## Degenerate handling, no new geometry is derived:
|
|
## * a zero-length edge is SKIPPED instead of drawn as a degenerate line.
|
|
## This happens when the enemy heading is TANGENT to the circle (corner 1
|
|
## coincides with corner 2) or points straight at us (corner 1 coincides
|
|
## with corner 3);
|
|
## * coincident corners share a single ring, so no zero-radius garbage.
|
|
let vs = [enemyOther, enemyPos, selfPos]
|
|
for i in 0 ..< 3:
|
|
let a = vs[i]
|
|
let b = vs[(i + 1) mod 3]
|
|
if samePoint(a, b): continue
|
|
result.add GeoPrim(kind: gpTriangleEdge, x1: a.x, y1: a.y, x2: b.x, y2: b.y,
|
|
color: GeoTriangleColor)
|
|
for i in 0 ..< 3:
|
|
var dup = false
|
|
for j in 0 ..< i:
|
|
if samePoint(vs[i], vs[j]): dup = true; break
|
|
if dup: continue
|
|
result.add GeoPrim(kind: gpTriangleCorner, cx: vs[i].x, cy: vs[i].y,
|
|
r: GeoTriangleCornerRadius, color: GeoTriangleColor)
|
|
|
|
proc collectGeoPrims*(selfX, selfY, selfHeading,
|
|
enemyX, enemyY, enemyHeading: float): seq[GeoPrim] =
|
|
## Pure: the primitives the overlay draws. Empty in the degenerate case where
|
|
## the two bots coincide (distance ~0) — skip rather than divide by zero.
|
|
let c = makeDiameterCircle(selfX, selfY, enemyX, enemyY)
|
|
if c.r < 1e-6: return
|
|
|
|
result.add GeoPrim(kind: gpCircle, cx: c.cx, cy: c.cy, r: c.r,
|
|
color: GeoCircleColor)
|
|
|
|
let halfLen = 2.0 * c.r + GeoLineMargin
|
|
|
|
# OUR heading line + its two intersection points.
|
|
let segSelf = headingSegment(selfX, selfY, selfHeading, halfLen)
|
|
result.add GeoPrim(kind: gpLineSelf, x1: segSelf.x1, y1: segSelf.y1,
|
|
x2: segSelf.x2, y2: segSelf.y2, color: GeoSelfLineColor)
|
|
let ipSelf = lineCircleIntersections(selfX, selfY, selfHeading, c)
|
|
let soSelf = pickSelfOther(ipSelf, selfX, selfY)
|
|
if soSelf.hasOther:
|
|
result.add GeoPrim(kind: gpMarkSelf, cx: soSelf.self.x, cy: soSelf.self.y,
|
|
r: GeoSelfMarkRadius, color: GeoSelfMarkColor)
|
|
result.add GeoPrim(kind: gpMarkOther, cx: soSelf.other.x, cy: soSelf.other.y,
|
|
r: GeoOtherMarkRadius, color: GeoOtherMarkColor)
|
|
|
|
# ENEMY heading line + its two intersection points.
|
|
let segEnemy = headingSegment(enemyX, enemyY, enemyHeading, halfLen)
|
|
result.add GeoPrim(kind: gpLineEnemy, x1: segEnemy.x1, y1: segEnemy.y1,
|
|
x2: segEnemy.x2, y2: segEnemy.y2, color: GeoEnemyLineColor)
|
|
let ipEnemy = lineCircleIntersections(enemyX, enemyY, enemyHeading, c)
|
|
let soEnemy = pickSelfOther(ipEnemy, enemyX, enemyY)
|
|
if soEnemy.hasOther:
|
|
result.add GeoPrim(kind: gpMarkSelf, cx: soEnemy.self.x, cy: soEnemy.self.y,
|
|
r: GeoSelfMarkRadius, color: GeoSelfMarkColor)
|
|
result.add GeoPrim(kind: gpMarkOther, cx: soEnemy.other.x,
|
|
cy: soEnemy.other.y, r: GeoOtherMarkRadius,
|
|
color: GeoOtherMarkColor)
|
|
|
|
# The owner-requested TRIANGLE: the enemy heading's OTHER intersection,
|
|
# the enemy, and our bot. All three corners were already computed just
|
|
# above — nothing is re-derived here.
|
|
for p in trianglePrims(soEnemy.other,
|
|
GeomPoint(x: enemyX, y: enemyY),
|
|
GeomPoint(x: selfX, y: selfY)):
|
|
result.add p
|
|
|
|
# ── emit to the GUI debug-graphics buffer ────────────────────────────────────
|
|
|
|
proc emitGeoPrims*(prims: seq[GeoPrim]): int =
|
|
## Emit the primitives into the current tick's SVG buffer. Thin lines.
|
|
for p in prims:
|
|
case p.kind
|
|
of gpCircle:
|
|
setStrokeColor(p.color)
|
|
setStrokeWidth(GeoCircleWidth)
|
|
drawCircle(p.cx, p.cy, p.r)
|
|
of gpLineSelf, gpLineEnemy:
|
|
setStrokeColor(p.color)
|
|
setStrokeWidth(GeoLineWidth)
|
|
drawLine(p.x1, p.y1, p.x2, p.y2)
|
|
of gpMarkSelf:
|
|
setStrokeColor(p.color)
|
|
setStrokeWidth(GeoMarkWidth)
|
|
drawCircle(p.cx, p.cy, p.r)
|
|
of gpMarkOther:
|
|
setFillColor(p.color)
|
|
fillCircle(p.cx, p.cy, p.r)
|
|
of gpTriangleEdge:
|
|
setStrokeColor(p.color)
|
|
setStrokeWidth(GeoTriangleWidth)
|
|
drawLine(p.x1, p.y1, p.x2, p.y2)
|
|
of gpTriangleCorner:
|
|
setStrokeColor(p.color)
|
|
setStrokeWidth(GeoTriangleWidth)
|
|
drawCircle(p.cx, p.cy, p.r)
|
|
result = prims.len
|
|
|
|
proc drawGeoOverlay*(selfX, selfY, selfHeading,
|
|
enemyX, enemyY, enemyHeading: float): int =
|
|
## Entry point called once per tick by ModularBot when `TR_GEO_DEBUG` is on.
|
|
## Returns the number of primitives drawn.
|
|
emitGeoPrims(collectGeoPrims(selfX, selfY, selfHeading,
|
|
enemyX, enemyY, enemyHeading))
|