## 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))