## STRAFE — body pinned perpendicular to the threat; reversals by SIGN FLIP. ## ## ── The idea (the owner's design) ─────────────────────────────────────────── ## TFIL changes its left<->right direction by TURNING the body. Job j85 measured ## the cost of that turn: the speed at the reversal tick drops to ~0.6 px/tick ## (from ~6.4), takes 7-8 ticks to recover, and the hit rate on reversal ticks ## is 1.40x baseline (peaking 1.84x at 6-10 ticks) — the shipped mover also has ## the LOWEST mean speed of the arms tested (4.69 vs 5.37 px/tick). ## ## In Tank Royale the backward speed EQUALS the forward speed (measured ## +8.000 / -8.000 over 75,518 ticks), so a reversal is free if it is done by ## flipping the sign of `setForward` instead of turning the hull around. STRAFE ## exploits exactly that: ## ## * keep the BODY pinned ~perpendicular to the threat (`threat axis` below), ## so "move left" and "move right" are both along the body axis; ## * pick a random safe tile on the perpendicular line (forward or backward) ## and move to it with `setForward(±8)`; ## * NEVER turn to face a movement target — the only turns are small ## corrections that keep the heading inside a band around the perpendicular. ## ## Bonus physics: the bot is a 36 px square. Projected width is 36 px side-on ## or head-on but 50.9 px at 45 degrees, so staying near 90 degrees avoids the ## worst orientation (up to 29% smaller target). ## ## ── Threat axis (point 1 of the spec) ─────────────────────────────────────── ## When a bullet is in flight the axis is the INCOMING BULLET's direction: a ## bullet comes from where the enemy WAS when it fired, which at long range ## differs from its current position by 100+ px. When the sky is clear the axis ## falls back to the PERPENDICULAR of the enemy bearing. ## ## ── Heading band, not an exact pin (point 2) ──────────────────────────────── ## The body heading is kept inside `TR_STRAFE_BAND` degrees of the perpendicular ## line (`lineDir`); the band offset is re-randomised on every pick so the ## heading is not a constant. Only turns when OUTSIDE the band, and turns the ## short way (the folded deviation is in [-90, 90]). ## ## ── Candidate tiles (point 3) ─────────────────────────────────────────────── ## Tiles on the perpendicular line through the current position, both forward ## and backward, within `TR_STRAFE_REACH` px, inside the arena, with a small ## PERPENDICULAR JITTER of `±TR_STRAFE_SPREAD` tiles (the owner's "spread a ## little"). A tile is acceptable when the max heat ON THE STRAIGHT-LINE PATH ## from the bot is <= `PathDangerThreshold` (10.0) — the SAME safety rule TFIL ## uses. The heat field itself is the SHIPPED TFIL field (jobs j105/j106: the ## time-indexed bullet model + the pillar-free default) — see the reuse note ## below. ## ## ── Move by sign only (point 5) ───────────────────────────────────────────── ## `speed = MaxSpeed * sign`, where `sign` is +1 when the chosen tile lies along ## the current heading and -1 when it lies opposite. There is NO turn-to-target ## anywhere in this mover. ## ## ── Randomised dwell (point 6) ────────────────────────────────────────────── ## The target is re-picked after `rand(TR_STRAFE_DWELL_MIN .. TR_STRAFE_DWELL_MAX)` ## ticks, on arrival, or immediately when the chosen tile's heat spikes by ## `DangerReplanThreshold` (a serious threat). This is the PRIMARY anti-pattern ## defence: a periodic reversal is trivially learnable by DrussGT's pattern gun, ## so the reversal TIMING is randomised and the offline entropy gate (B) checks ## it. ## ## ── Reuse of the j105/j106 heat machinery ─────────────────────────────────── ## This module does NOT re-implement the time-indexed bullet model. It imports ## `movements/the_floor_is_lava` and calls its EXPORTED `heatDecay(dt)` and ## `bulletMagScale(power)`, and reads its exported `PillarHotness` / ## `PillarRadiance` (0/0 = the shipped pillar-free default) and `TfilHeatTime` ## (for the debug corridor reach). So `TR_TFIL_HEAT_TIME` / `TR_TFIL_HEAT_TAU` / ## `TR_TFIL_HEAT_POWER_GAIN` / `TR_TFIL_PILLAR_ON` drive the STRAFE field exactly ## as they drive TFIL's. The bullet tracking, heat painting and path sampling are ## copied from the shipped mover (the same pattern `the_floor_is_lava_ring.nim` ## uses) because the shipped file must stay byte-identical and its private ## constants are not exported. ## ## ── Env knobs (all read at module init) ───────────────────────────────────── ## TR_MOVEMENT = strafe (selects this engine; default stays tfil) ## TR_STRAFE_BAND 20.0 heading band half-width (deg) ## TR_STRAFE_SPREAD 1 perpendicular jitter (tiles, ±) ## TR_STRAFE_REACH 144.0 along-line reach (px) ## TR_STRAFE_DWELL_MIN 6 min ticks before a re-pick ## TR_STRAFE_DWELL_MAX 20 max ticks before a re-pick ## TR_STRAFE_LOG off presence-based: echo one line per pick ## TR_STRAFE_CORRIDOR_HEAT 20.0 lava per corridor tile (shipped TFIL value) ## TR_STRAFE_WALL_HOTNESS 30.0 peak wall radiance (shipped TFIL value) ## TR_STRAFE_WALL_RADIANCE 10.0 wall radiance falloff (shipped TFIL value) ## ## The default `TR_MOVEMENT=tfil` path is untouched; this module is only ever ## called when the bot explicitly selects `strafe`. import std/[math, random, os] from std/strutils import parseFloat, parseInt, strip import std/strformat import gun_harness/gun_interface import movement_harness/movement_interface import robocode_tankroyale_botapi/graphics import robocode_tankroyale_botapi/color # j105/j106 reuse: the exported time-indexed heat helpers + pillar globals. import movements/the_floor_is_lava const GridSize = 36.0 const MaxSpeed = 8.0 const BulletCoreRadiusMin = 9.0 ## core radius at power 0.1 const BulletCoreRadiusMax = 54.0 ## core radius at power 3.0 const BulletAuraExtMin = 36.0 ## aura extension at power 3.0 (slow) const BulletAuraExtMax = 54.0 ## aura extension at power 0.1 (fast) const BulletCore = 10.0 ## lava per bullet-overlapping tile (shipped default) const BulletAura = 5.0 ## lava for aura ring tiles (shipped default) const EnemyCoreRadius = 18.0 ## half of 36px body const EnemyAuraRadius = 54.0 ## 18 + 36 const EnemyCore = 40.0 ## lava per tile overlapping enemy body const EnemyAura = 10.0 ## lava per tile in enemy aura ring const CorridorHeatDefault = 20.0 ## shipped TFIL corridor heat const WallHotnessDefault = 30.0 ## shipped TFIL wall radiance peak const WallRadianceDefault = 10.0 ## shipped TFIL wall radiance falloff ## Heat shape is override-able so the two candidate fields can be compared on ## one binary (the shipped field vs the ring variant's retune). Default = the ## SHIPPED TFIL field, so the strafe mover is judged on the same field as the ## baseline. `TR_STRAFE_CORRIDOR_HEAT` / `TR_STRAFE_WALL_HOTNESS` / ## `TR_STRAFE_WALL_RADIANCE` are strafe-specific (the ring mover's own field is ## controlled by its separate `TR_TFIL_*` names). var StrafeCorridorHeat* = CorridorHeatDefault StrafeWallHotness* = WallHotnessDefault StrafeWallRadiance* = WallRadianceDefault const PathSampleStep = 18.0 ## ~half a tile const PathDangerThreshold = 10.0 ## max lava on path; above this = unsafe const DangerReplanThreshold = 25.0 ## serious-threat replan (bullet core) const MaxTrackedBullets = 20 ## hard cap on tracked bullets # ── Env knobs ──────────────────────────────────────────────────────────────── proc getEnvFloat(name: string, default: float): float = let s = getEnv(name, "") if s.len == 0: return default try: result = parseFloat(s.strip()) except ValueError: result = default proc getEnvInt(name: string, default: int): int = let s = getEnv(name, "") if s.len == 0: return default try: result = parseInt(s.strip()) except ValueError: result = default const DefaultStrafeBand = 20.0 DefaultStrafeSpread = 1 DefaultStrafeReach = 144.0 DefaultStrafeDwellMin = 6 DefaultStrafeDwellMax = 20 var StrafeBand* = DefaultStrafeBand StrafeSpread* = DefaultStrafeSpread StrafeReach* = DefaultStrafeReach StrafeDwellMin* = DefaultStrafeDwellMin StrafeDwellMax* = DefaultStrafeDwellMax StrafeLog* = false proc loadStrafeHeatEnv*() = ## Re-read the heat-shape overrides. Exposed so a gate can restore the shipped ## field after temporarily retuning it on the SAME process. StrafeCorridorHeat = getEnvFloat("TR_STRAFE_CORRIDOR_HEAT", CorridorHeatDefault) StrafeWallHotness = getEnvFloat("TR_STRAFE_WALL_HOTNESS", WallHotnessDefault) StrafeWallRadiance = getEnvFloat("TR_STRAFE_WALL_RADIANCE", WallRadianceDefault) proc loadStrafeEnv*() = ## Read the strafe knobs. Called once at module init; callable again after ## `putEnv` so a gate script can exercise the arms in one process. StrafeBand = max(0.0, min(90.0, getEnvFloat("TR_STRAFE_BAND", DefaultStrafeBand))) StrafeSpread = max(0, getEnvInt("TR_STRAFE_SPREAD", DefaultStrafeSpread)) StrafeReach = max(GridSize, getEnvFloat("TR_STRAFE_REACH", DefaultStrafeReach)) StrafeDwellMin = max(1, getEnvInt("TR_STRAFE_DWELL_MIN", DefaultStrafeDwellMin)) StrafeDwellMax = max(StrafeDwellMin, getEnvInt("TR_STRAFE_DWELL_MAX", DefaultStrafeDwellMax)) StrafeLog = existsEnv("TR_STRAFE_LOG") loadStrafeHeatEnv() loadStrafeEnv() # ── small angle helpers ────────────────────────────────────────────────────── proc wrap180(d: float): float {.inline.} = result = d while result > 180.0: result -= 360.0 while result < -180.0: result += 360.0 const DegToRad = PI / 180.0 # ── module types ───────────────────────────────────────────────────────────── type TrackedBullet = object originX, originY: float x, y: float velX, velY: float ## speed * cos(heading), speed * sin(heading) power: float alive: bool age: int StrafeModule* = object debugGraphics*: bool cols*, rows*: int marginX*, marginY*: float arenaWidth*, arenaHeight*: float lava: seq[float] bullets: seq[TrackedBullet] prevEnergy: seq[tuple[id: int, energy: float]] # ── decision state ── targetX*, targetY*: float ## chosen tile centre (world coords) targetValid*: bool targetLava: float ## heat at the chosen tile when picked dwell*: int ## ticks remaining on the current target dir*: float ## commanded sign: +1 forward, -1 backward bandOffset: float ## random in [-band, band], re-rolled per pick lineDir*: float ## undirected strafe line bearing (deg) # ── diagnostics (gate B + GUI) ── callCount*: int picks*: int lastPickCall*: int ## callCount at the last pick (interval source) lastCandCount*: int ## candidates generated at the last pick lastSafeCount*: int ## of those, path-safe at the last pick fallbackPicks*: int ## picks where the safe pool was empty reversals*: int ## sign flips of the commanded direction lineDirFlips*: int ## times the axis orientation flipped lastCmdSign*: float lastLineForward: float lastBotX, lastBotY: float lastTileCol, lastTileRow: int proc initStrafe*(): StrafeModule = StrafeModule(debugGraphics: false) proc removeBulletNear*(m: var StrafeModule, x, y: float) = ## Mark the tracked bullet closest to (x,y) within GridSize tolerance as dead. var bestIdx = -1 var bestD2 = GridSize * GridSize for i, b in m.bullets: let d2 = (b.x - x)*(b.x - x) + (b.y - y)*(b.y - y) if d2 < bestD2: bestD2 = d2 bestIdx = i if bestIdx >= 0: m.bullets.del(bestIdx) proc prevEnergyGet(m: StrafeModule, id: int): float = for e in m.prevEnergy: if e.id == id: return e.energy 100.0 proc prevEnergySet(m: var StrafeModule, id: int, energy: float) = for i in 0..= 0.09 and drop <= 3.01: let speed = 20.0 - 3.0 * drop let dist = sqrt((ws.selfX - ei.x)^2 + (ws.selfY - ei.y)^2) let travelTime = dist / speed let predX = ws.selfX + ws.selfSpeed * cos(ws.selfHeading * DegToRad) * travelTime let predY = ws.selfY + ws.selfSpeed * sin(ws.selfHeading * DegToRad) * travelTime let heading = arctan2(predY - ei.y, predX - ei.x) if m.bullets.len >= MaxTrackedBullets: m.bullets.del(0) m.bullets.add TrackedBullet( originX: ei.x, originY: ei.y, x: ei.x, y: ei.y, velX: speed * cos(heading), velY: speed * sin(heading), power: drop, alive: true, age: 0) proc advanceBullets(m: var StrafeModule, selfX, selfY: float) = var i = 0 while i < m.bullets.len: var b = m.bullets[i] b.x += b.velX b.y += b.velY b.age += 1 let dx = selfX - b.x let dy = selfY - b.y let dot = b.velX * dx + b.velY * dy let outOfBounds = b.x < 0.0 or b.x > m.arenaWidth or b.y < 0.0 or b.y > m.arenaHeight if dot < 0.0 or outOfBounds or b.age > 200: b.alive = false m.bullets[i] = b if b.alive: inc i else: m.bullets.del(i) # ── heat field (SHIPPED TFIL field; time model + pillar via the import) ────── proc buildHeat(m: var StrafeModule, ws: WorldState) = for i in 0.. 0.0: b.velX / bSpeed else: 0.0 let bUy = if bSpeed > 0.0: b.velY / bSpeed else: 0.0 let bMag = bulletMagScale(b.power) let colMin = max(0, int(floor((bx - auraR - m.marginX) / GridSize))) let colMax = min(m.cols-1, int(floor((bx + auraR - m.marginX) / GridSize))) let rowMin = max(0, int(floor((by - auraR - m.marginY) / GridSize))) let rowMax = min(m.rows-1, int(floor((by + auraR - m.marginY) / GridSize))) for row in rowMin..rowMax: for col in colMin..colMax: let x0 = m.marginX + col.float * GridSize let y0 = m.marginY + row.float * GridSize let nearX = clamp(bx, x0, x0 + GridSize) let nearY = clamp(by, y0, y0 + GridSize) let dx = nearX - bx let dy = nearY - by let d2 = dx*dx + dy*dy if d2 <= coreR * coreR: let along = dx * bUx + dy * bUy m.lava[row * m.cols + col] += BulletCore * bMag * heatDecay(along / bSpeed) elif d2 <= auraR * auraR: let along = dx * bUx + dy * bUy m.lava[row * m.cols + col] += BulletAura * bMag * heatDecay(along / bSpeed) # Corridors (rotated rectangle from the bullet to the wall, auraR wide). for b in m.bullets: let speed = sqrt(b.velX * b.velX + b.velY * b.velY) if speed < 0.001: continue let dx = b.velX / speed let dy = b.velY / speed let px = -dy let py = dx var tMin = Inf if dx > 0.0: tMin = min(tMin, (m.arenaWidth - b.x) / dx) elif dx < 0.0: tMin = min(tMin, (0.0 - b.x) / dx) if dy > 0.0: tMin = min(tMin, (m.arenaHeight - b.y) / dy) elif dy < 0.0: tMin = min(tMin, (0.0 - b.y) / dy) if tMin == 0.0: continue let (_, auraR) = bulletRadii(b.power) let bMag = bulletMagScale(b.power) let wx = b.x + dx * tMin let wy = b.y + dy * tMin let c0x = b.x + px * auraR; let c0y = b.y + py * auraR let c1x = b.x - px * auraR; let c1y = b.y - py * auraR let c2x = wx - px * auraR; let c2y = wy - py * auraR let c3x = wx + px * auraR; let c3y = wy + py * auraR let xMin = min(min(c0x, c1x), min(c2x, c3x)) let xMax = max(max(c0x, c1x), max(c2x, c3x)) let yMin = min(min(c0y, c1y), min(c2y, c3y)) let yMax = max(max(c0y, c1y), max(c2y, c3y)) let colMin = max(0, int(floor((xMin - m.marginX) / GridSize))) let colMax = min(m.cols-1, int(floor((xMax - m.marginX) / GridSize))) let rowMin = max(0, int(floor((yMin - m.marginY) / GridSize))) let rowMax = min(m.rows-1, int(floor((yMax - m.marginY) / GridSize))) for row in rowMin..rowMax: for col in colMin..colMax: let cx = m.marginX + (col.float + 0.5) * GridSize let cy = m.marginY + (row.float + 0.5) * GridSize let relX = cx - b.x let relY = cy - b.y let along = relX * dx + relY * dy let perp = relX * px + relY * py if along >= 0.0 and along <= tMin and perp >= -auraR and perp <= auraR: m.lava[row * m.cols + col] += StrafeCorridorHeat * bMag * heatDecay(along / speed) # Enemy auras for ei in ws.enemies: let ex = ei.x let ey = ei.y let colMin = max(0, int(floor((ex - EnemyAuraRadius - m.marginX) / GridSize))) let colMax = min(m.cols-1, int(floor((ex + EnemyAuraRadius - m.marginX) / GridSize))) let rowMin = max(0, int(floor((ey - EnemyAuraRadius - m.marginY) / GridSize))) let rowMax = min(m.rows-1, int(floor((ey + EnemyAuraRadius - m.marginY) / GridSize))) for row in rowMin..rowMax: for col in colMin..colMax: let x0 = m.marginX + col.float * GridSize let y0 = m.marginY + row.float * GridSize let nearX = clamp(ex, x0, x0 + GridSize) let nearY = clamp(ey, y0, y0 + GridSize) let dx = nearX - ex let dy = nearY - ey let d2 = dx*dx + dy*dy if d2 <= EnemyCoreRadius * EnemyCoreRadius: m.lava[row * m.cols + col] += EnemyCore elif d2 <= EnemyAuraRadius * EnemyAuraRadius: m.lava[row * m.cols + col] += EnemyAura # Wall radiance for row in 0..= m.arenaWidth or wy < 0.0 or wy >= m.arenaHeight: continue let (c, r) = m.tileAt(wx, wy) if c == bc and r == br: continue var dup = false for e in cands: if e.col == c and e.row == r: dup = true; break if dup: continue let cx = m.marginX + (c.float + 0.5) * GridSize let cy = m.marginY + (r.float + 0.5) * GridSize cands.add Cand(col: c, row: r, x: cx, y: cy, pathHeat: m.pathMaxHeat(ws.selfX, ws.selfY, cx, cy), along: along) var safe: seq[Cand] for c in cands: if c.pathHeat <= PathDangerThreshold: safe.add c m.lastCandCount = cands.len m.lastSafeCount = safe.len var pool: seq[Cand] if safe.len > 0: pool = safe elif cands.len > 0: # Fallback (never freeze): the two coolest tiles on the line, over # threshold but still the least dangerous direction to move. var sorted = cands for i in 1..= 0 and sorted[j].pathHeat > key.pathHeat: sorted[j + 1] = sorted[j] dec j sorted[j + 1] = key let take = min(2, sorted.len) for i in 0..= 0.0: 1.0 else: -1.0 if m.lastCmdSign == 0.0: m.lastCmdSign = m.dir # Randomised dwell + randomisation inside the band (point 2 / point 6). m.dwell = rand(StrafeDwellMin..StrafeDwellMax) m.bandOffset = rand(2.0 * StrafeBand) - StrafeBand m.lastPickCall = m.callCount inc m.picks if StrafeLog: let reason = if safe.len == 0: "fallback" else: "pick" echo fmt"[strafe] {reason} n={cands.len} safe={safe.len} " & fmt"along={pool[chosen].along.int} dir={m.dir.int} " & fmt"dwell={m.dwell} line={lineForward.int} band={m.bandOffset:.0f}" if safe.len == 0: echo fmt"[strafe] WARNING: no SAFE tile on the line " & fmt"(cands={cands.len}, least-hot={pool[chosen].pathHeat:.1f})" # ── main entry point ───────────────────────────────────────────────────────── proc computeMove*(m: var StrafeModule, ws: WorldState): MoveCommand = if m.cols == 0: m.initGrid(ws.arenaWidth, ws.arenaHeight) # Soft reset on a position jump (a ram teleport moved us). let jumpDist = sqrt((ws.selfX - m.lastBotX)^2 + (ws.selfY - m.lastBotY)^2) let jumped = (m.callCount > 0) and (jumpDist > 12.0) if jumped: m.dwell = 0 m.targetValid = false m.bullets = @[] m.prevEnergy = @[] for ei in ws.enemies: m.prevEnergySet(ei.id, ei.energy) m.advanceBullets(ws.selfX, ws.selfY) m.detectFires(ws) m.buildHeat(ws) # ── threat axis -> perpendicular line ── let threat = m.threatBearing(ws) let lineAngle = threat + 90.0 m.lineDir = lineAngle # Forward orientation = the half of the (undirected) line nearest our heading. var lineForward = lineAngle let hRel = wrap180(ws.selfHeading - lineAngle) if hRel > 90.0 or hRel < -90.0: lineForward = lineAngle + 180.0 if m.picks > 0: let fl = abs(wrap180(lineForward - m.lastLineForward)) if fl > 90.0: inc m.lineDirFlips m.lastLineForward = lineForward # ── target (re)pick ── var serious = false if m.targetValid: let (tc, tr) = m.tileAt(m.targetX, m.targetY) if m.lavaAt(tc, tr) > m.targetLava + DangerReplanThreshold: serious = true if hypot(m.targetX - ws.selfX, m.targetY - ws.selfY) < GridSize * 0.75: m.dwell = 0 if (not m.targetValid) or m.dwell <= 0 or serious: m.pickTarget(ws, lineForward) else: dec m.dwell # ── heading band: turn ONLY to stay perpendicular, never to the target ── var turnRate = 0.0 let mtr = 10.0 - 0.75 * abs(ws.selfSpeed) let dev = wrap180(ws.selfHeading - lineForward) if abs(dev) > StrafeBand: let targetHeading = lineForward + m.bandOffset turnRate = clamp(wrap180(targetHeading - ws.selfHeading), -mtr, mtr) # ── move by sign only ── # Never freeze: if there is no valid target (the rare all-candidates-outside # case) keep driving on the last sign. The normal path picks a target every # dwell, and the fallback pool is non-empty whenever any candidate tile exists. if m.targetValid and m.dir != m.lastCmdSign: inc m.reversals m.lastCmdSign = m.dir result = (speed: MaxSpeed * (if m.dir != 0.0: m.dir else: 1.0), turnRate: turnRate) # ── GUI overlay (the user watches this) ── if m.debugGraphics: let ux = cos(lineForward * DegToRad) let uy = sin(lineForward * DegToRad) # Strafe line across the arena (cyan). setStrokeColor(fromHex("#00FFFF")) setStrokeWidth(1.0) drawLine(ws.selfX - ux * 600.0, ws.selfY - uy * 600.0, ws.selfX + ux * 600.0, ws.selfY + uy * 600.0) # Threat axis (enemy/bullet bearing) as a faint grey line through us. let tx = cos(threat * DegToRad) let ty = sin(threat * DegToRad) setStrokeColor(fromHex("#888888")) drawLine(ws.selfX - tx * 600.0, ws.selfY - ty * 600.0, ws.selfX + tx * 600.0, ws.selfY + ty * 600.0) # Candidate line reach up to kmax tiles: safe tiles bright, unsafe dim. let kmax = max(1, int(StrafeReach / GridSize)) let spread = max(0, StrafeSpread) let px = -uy let py = ux for k in 1..kmax: for s in [-1.0, 1.0]: let along = s * k.float * GridSize for j in -spread..spread: let wx = ws.selfX + ux * along + px * (j.float * GridSize) let wy = ws.selfY + uy * along + py * (j.float * GridSize) if wx < 0.0 or wx >= m.arenaWidth or wy < 0.0 or wy >= m.arenaHeight: continue let (c, r) = m.tileAt(wx, wy) let x0 = m.marginX + c.float * GridSize let y0 = m.marginY + r.float * GridSize if m.lavaAt(c, r) <= PathDangerThreshold: setStrokeColor(fromHex("#00FF00")) setStrokeWidth(1.0) else: setStrokeColor(fromHex("#804000")) setStrokeWidth(1.0) drawRectangle(x0, y0, GridSize, GridSize) # Chosen target tile (magenta fill) + the sign-coloured movement ray. if m.targetValid: let (cc, cr) = m.tileAt(m.targetX, m.targetY) let gx0 = m.marginX + cc.float * GridSize let gy0 = m.marginY + cr.float * GridSize setStrokeColor(fromHex("#FF00FF")) setStrokeWidth(2.5) drawRectangle(gx0, gy0, GridSize, GridSize) setFillColor(fromHex("#FF00FF")) fillCircle(m.targetX, m.targetY, 5.0) # movement ray: green forward, red backward (the sign flip is the point) setStrokeColor(if m.dir >= 0.0: fromHex("#00FF00") else: fromHex("#FF2222")) setStrokeWidth(2.0) drawLine(ws.selfX, ws.selfY, ws.selfX + ux * m.dir * StrafeReach, ws.selfY + uy * m.dir * StrafeReach) # Heading band: the two ±band boundary rays (yellow) around the line. setStrokeColor(fromHex("#FFFF00")) setStrokeWidth(1.0) let bl = (lineForward - StrafeBand) * DegToRad let br2 = (lineForward + StrafeBand) * DegToRad drawLine(ws.selfX, ws.selfY, ws.selfX + cos(bl) * 70.0, ws.selfY + sin(bl) * 70.0) drawLine(ws.selfX, ws.selfY, ws.selfX + cos(br2) * 70.0, ws.selfY + sin(br2) * 70.0) # Current heading ray (blue). setStrokeColor(fromHex("#3399FF")) setStrokeWidth(2.0) drawLine(ws.selfX, ws.selfY, ws.selfX + cos(ws.selfHeading * DegToRad) * 60.0, ws.selfY + sin(ws.selfHeading * DegToRad) * 60.0) # snapshot for the next call m.lastBotX = ws.selfX m.lastBotY = ws.selfY m.lastTileCol = clamp(int((ws.selfX - m.marginX) / GridSize), 0, m.cols - 1) m.lastTileRow = clamp(int((ws.selfY - m.marginY) / GridSize), 0, m.rows - 1) m.callCount += 1