STRAFE: curved wings + guaranteed wall/corner escape; shipped TFIL default untouched

Task j112. Two changes to the TR_MOVEMENT=strafe engine, both OFF the shipped
tfil path; the binary default is still tfil.

CURVED WINGS: the candidate set was the straight 1-D line through the bot, which
a bounded segment always terminates at a wall. It is now an adaptive parabola
with the vertex on the bot:
  point(y) = bot + yhat*y + xhat*kappa(y)*y^2
xhat is the unit vector away from the nearest wall(s) (summed inward normals, so
a corner yields the diagonal). kappa grows as the wall approaches and saturates
at TR_STRAFE_KAPPA; every wing point is clamped inside TR_STRAFE_WALL_SAFE, so
the wing FLATTENS and runs parallel to the wall instead of touching it. In open
space kappa == 0 and the wing is exactly the old straight line. The wing chord
at the reach tilts the heading band toward the interior by atan(kappa*reach)
(capped by TR_STRAFE_WING_MAX); the body still only turns slowly to follow that
tangent, never to face the target, and reversals are still setForward sign flips.

GUARANTEED ESCAPE: with every candidate over threshold the old fallback minimised
pathMaxHeat, whose gradient points AT the wall (the shortest path has the least
wall exposure), so the least-hot tile was the adjacent one and led further along
the wall. Near a wall the picker now ranks by the DESTINATION (farthest from the
wall, then coolest tile) and commands the sign whose velocity has a positive
component along the wall-away normal. That sign is re-asserted EVERY tick, so
speed*heading . away >= 0 while escape is active: the clearance cannot fall.
mode=escape reaches the [strafe] log. A mild wall-margin bias
(TR_STRAFE_WALL_BIAS) prefers higher-clearance tiles when near a wall.

GUI/log: the curved wings are drawn as an orange polyline (candidates follow the
curve), a white ray + ESCAPE label marks the escape, and the [strafe] line now
carries wall=<dist> kappa=<..> mode=<pick|fallback|escape|radial>.

Gates (offline, kinematic replay of the DrussGT fixtures; see
common_libs/tests/measure_strafe_wings.nim, plus the reused j108/j111 gates):
wall occupancy (within 54 px) falls 25.6->4.3 / 18.3->4.1 / 23.8->4.3 / 27.8->4.7
percent and the longest continuous wall run 191->31 / 49->25 / 208->47 / 246->27
ticks; corner-region occupancy 4.7->0.0 percent with the longest corner run
71->7. The escape sweep (3520 start x heading x enemy runs, 110k escape ticks)
shows ZERO per-tick guarantee violations and a worst corner run of 21 ticks.
Open-space parity is bit-identical (kappa == 0), reversals are still sign flips
(0 non-sign commands), and mean turn/speed are unchanged (OFF 4.42 deg/tick,
31.3 percent no-turn vs ON 4.45 / 30.7; reversal-interval entropy 5.309 -> 5.311
bits). The fixtures are OPEN-LOOP, so these are veto-capable checks, not a live
win claim.
This commit is contained in:
2026-09-25 23:51:12 +02:00
parent ed25ce29ad
commit ccff7e3e4a
3 changed files with 690 additions and 36 deletions
+269 -36
View File
@@ -97,6 +97,31 @@
## ticks flips the sign;
## 3. the `[strafe] WARNING` line fires for both cases so the GUI log shows it.
##
## ── Curved wings + guaranteed escape (j112) ────────────────────────────────
## The candidate set used to be the straight 1-D line through the bot. A bounded
## segment always ends at a wall, so the bot was FORCED into an edge/corner; and
## the all-hot fallback took the lowest `pathMaxHeat`, whose gradient points AT
## the wall (the shortest path has the least wall exposure). The line is now an
## ADAPTIVE PARABOLA with the vertex on the bot:
##
## point(y) = bot + yhat * y + xhat * kappa(y) * y^2
##
## `yhat` is the strafe axis, `xhat` is the unit vector AWAY from the nearest
## wall(s). `kappa` grows as the wall approaches and saturates at
## `TR_STRAFE_KAPPA`; every wing point is clamped inside `TR_STRAFE_WALL_SAFE`,
## so the wing FLATTENS and runs parallel to the wall instead of touching it. In
## OPEN SPACE `kappa == 0` and the wing is exactly the old straight line. The
## wing's chord at the reach tilts the heading band toward the interior
## (`atan(kappa*reach)`, capped by `TR_STRAFE_WING_MAX`); the body still only
## turns slowly to follow that tangent, never to face the target.
##
## ESCAPE: when every candidate is over threshold AND the bot is within
## `TR_STRAFE_WALL_MARGIN`, the picker ranks by the DESTINATION (farthest from
## the wall, then coolest tile) and commands the sign whose velocity has a
## positive component along the wall-away normal. Because that sign is re-asserted
## EVERY tick, `speed * heading . away >= 0` while escape is active: the
## clearance can never fall. The `[strafe]` log marks it `mode=escape`.
##
## ── 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
@@ -127,6 +152,12 @@
## TR_STRAFE_CORRIDOR_HEAT 10.0 lava per corridor tile (== threshold)
## TR_STRAFE_WALL_HOTNESS 15.0 peak wall radiance (retune)
## TR_STRAFE_WALL_RADIANCE 5.0 wall radiance falloff (retune)
## TR_STRAFE_KAPPA 0.0025 wing curvature at the wall (1/px; 0 = straight)
## TR_STRAFE_WALL_MARGIN 108.0 px range over which the wing bends
## TR_STRAFE_WING_MAX 30.0 cap on the wing's line tilt (deg)
## TR_STRAFE_WALL_BIAS 0.35 P(prefer a tile farther from the wall)
## TR_STRAFE_WALL_SAFE 24.0 px a wing point never lands nearer
## TR_STRAFE_ESCAPE 1 the all-hot guaranteed wall escape
##
## The default `TR_MOVEMENT=tfil` path is untouched; this module is only ever
## called when the bot explicitly selects `strafe`.
@@ -227,6 +258,13 @@ const
DefaultStrafeRangeTol = 25.0
DefaultStrafeTiltMax = 15.0
DefaultStrafeTiltGain = 0.10
## Curved wings + wall escape (j112). In OPEN SPACE kappa is 0 and the wing
## is exactly the straight line above; the correction exists only near a wall.
DefaultStrafeKappa = 0.0025 ## wing curvature AT the wall (1/px)
DefaultStrafeWallMargin = 108.0 ## px: range over which the wing bends
DefaultStrafeWingMax = 30.0 ## deg: cap on the wing's line tilt
DefaultStrafeWallBias = 0.35 ## P(prefer a tile farther from the wall)
DefaultStrafeWallSafe = 24.0 ## px: a wing point never lands closer
## Stuck detector: a commanded move with < 0.5 px displacement this many ticks
## in a row flips the sign. Small enough to look instant, large enough to ride
@@ -244,6 +282,12 @@ var
StrafeRangeTol* = DefaultStrafeRangeTol
StrafeTiltMax* = DefaultStrafeTiltMax
StrafeTiltGain* = DefaultStrafeTiltGain
StrafeKappa* = DefaultStrafeKappa
StrafeWallMargin* = DefaultStrafeWallMargin
StrafeWingMax* = DefaultStrafeWingMax
StrafeWallBias* = DefaultStrafeWallBias
StrafeWallSafe* = DefaultStrafeWallSafe
StrafeEscape* = true
## GUI: draw the full lava field (every non-zero tile, value-labelled) the
## way TFIL does. Default ON; `TR_STRAFE_HEAT_GRID=0` hides the field so the
## strafe overlays can be read on their own.
@@ -271,6 +315,12 @@ proc loadStrafeEnv*() =
StrafeRangeTol = max(0.0, getEnvFloat("TR_STRAFE_RANGE_TOL", DefaultStrafeRangeTol))
StrafeTiltMax = max(0.0, min(80.0, getEnvFloat("TR_STRAFE_TILT_MAX", DefaultStrafeTiltMax)))
StrafeTiltGain = max(0.0, getEnvFloat("TR_STRAFE_TILT_GAIN", DefaultStrafeTiltGain))
StrafeKappa = max(0.0, getEnvFloat("TR_STRAFE_KAPPA", DefaultStrafeKappa))
StrafeWallMargin = max(0.0, getEnvFloat("TR_STRAFE_WALL_MARGIN", DefaultStrafeWallMargin))
StrafeWingMax = max(0.0, min(89.0, getEnvFloat("TR_STRAFE_WING_MAX", DefaultStrafeWingMax)))
StrafeWallBias = max(0.0, min(1.0, getEnvFloat("TR_STRAFE_WALL_BIAS", DefaultStrafeWallBias)))
StrafeWallSafe = max(0.0, getEnvFloat("TR_STRAFE_WALL_SAFE", DefaultStrafeWallSafe))
StrafeEscape = getEnvBool("TR_STRAFE_ESCAPE", true)
StrafeHeatGrid = getEnvBool("TR_STRAFE_HEAT_GRID", true)
loadStrafeHeatEnv()
@@ -315,6 +365,15 @@ type
# ── range control (j111) ──
rangeDist*: float ## enemy distance this tick (-1 = unknown)
rangeTilt*: float ## applied line tilt this tick (deg)
# ── curved wings + wall escape (j112) ──
wallDist*: float ## distance from the bot to the nearest wall (px)
kappa*: float ## applied wing curvature this tick (1/px)
wingTilt*: float ## applied line tilt toward the wall-away normal (deg)
escapeActive*: bool ## this tick runs on the wall-away escape bearing
escapeBearing*: float ## bearing of the wall-away normal when escaping
wallEscapePicks*: int ## picks forced inward by the all-hot escape
escapeModeTicks*: int ## ticks the escape bearing was in effect
lastMode*: string ## "pick" | "fallback" | "escape" | "radial"
# ── diagnostics (gate B + GUI) ──
callCount*: int
picks*: int
@@ -376,6 +435,14 @@ proc resetRound*(m: var StrafeModule) =
m.lineDir = 0.0
m.rangeDist = -1.0
m.rangeTilt = 0.0
m.wallDist = 1e9
m.kappa = 0.0
m.wingTilt = 0.0
m.escapeActive = false
m.escapeBearing = 0.0
m.wallEscapePicks = 0
m.escapeModeTicks = 0
m.lastMode = ""
m.callCount = 0
m.picks = 0
m.lastPickCall = 0
@@ -638,8 +705,30 @@ type
col, row: int
x, y: float
pathHeat: float
destHeat: float ## heat of the DESTINATION tile (not the path)
clearance: float ## distance from the tile centre to the nearest wall (px)
along: float ## signed offset along the line (+ = forward of lineForward)
proc wallClearance(m: StrafeModule, x, y: float): float {.inline.} =
## Distance from (x,y) to the nearest wall.
min(min(x, m.arenaWidth - x), min(y, m.arenaHeight - y))
proc wallAwayDir(m: StrafeModule, x, y: float): tuple[ax, ay: float] =
## Unit vector AWAY from the nearest wall(s). Sums the inward normal of every
## wall within `TR_STRAFE_WALL_MARGIN`, so a CORNER yields the diagonal and
## the away direction has a non-negative dot with every binding wall's outward
## gradient. (0,0) when no wall is within the margin == open space.
var ax = 0.0
var ay = 0.0
let mm = max(1.0, StrafeWallMargin)
if x < mm: ax += (mm - x) / mm
if m.arenaWidth - x < mm: ax -= (mm - (m.arenaWidth - x)) / mm
if y < mm: ay += (mm - y) / mm
if m.arenaHeight - y < mm: ay -= (mm - (m.arenaHeight - y)) / mm
let n = hypot(ax, ay)
if n > 1e-9: (ax / n, ay / n)
else: (0.0, 0.0)
proc radialEscape(m: StrafeModule, ws: WorldState): tuple[found: bool, col, row: int, x, y, pathHeat: float] =
## The corner defense: when the strafe line has no usable in-arena tile,
## search a small radial neighbourhood and return the COOLEST in-arena tile
@@ -669,7 +758,7 @@ proc radialEscape(m: StrafeModule, ws: WorldState): tuple[found: bool, col, row:
result = (found: true, col: c, row: r, x: cx, y: cy, pathHeat: ph)
proc pickTarget(m: var StrafeModule, ws: WorldState, lineForward: float,
rng: RangeInfo, tiltMag: float) =
rng: RangeInfo, tiltMag, kappa: float) =
let ux = cos(lineForward * DegToRad)
let uy = sin(lineForward * DegToRad)
let px = -uy
@@ -677,15 +766,29 @@ proc pickTarget(m: var StrafeModule, ws: WorldState, lineForward: float,
let kmax = max(1, int(StrafeReach / GridSize))
let spread = max(0, StrafeSpread)
let (bc, br) = m.tileAt(ws.selfX, ws.selfY)
let (awX, awY) = m.wallAwayDir(ws.selfX, ws.selfY)
let nearWall = m.wallDist < StrafeWallMargin
# `wings` is false only when BOTH wings and escape are off; then the candidate
# geometry is EXACTLY the pre-j112 straight line, so the gate's "before" arm
# reproduces the old mover bit for bit (open-space parity).
let wings = StrafeKappa > 0.0 or StrafeEscape
m.escapeActive = false
var cands: seq[Cand]
for k in 1..kmax:
for s in [-1.0, 1.0]:
let along = s * k.float * GridSize
let off = kappa * along * along # parabola, vertex on the bot
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
var wx = ws.selfX + ux * along + awX * off + px * (j.float * GridSize)
var wy = ws.selfY + uy * along + awY * off + py * (j.float * GridSize)
if wings:
# SAFETY MARGIN: clamp every wing point inside it, so the wing
# flattens and runs parallel to the wall instead of touching it.
wx = clamp(wx, StrafeWallSafe, m.arenaWidth - StrafeWallSafe)
wy = clamp(wy, StrafeWallSafe, m.arenaHeight - StrafeWallSafe)
elif wx < 0.0 or wx >= 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
@@ -696,6 +799,8 @@ proc pickTarget(m: var StrafeModule, ws: WorldState, lineForward: float,
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),
destHeat: m.lavaAt(c, r),
clearance: m.wallClearance(cx, cy),
along: along)
var safe: seq[Cand]
@@ -705,22 +810,48 @@ proc pickTarget(m: var StrafeModule, ws: WorldState, lineForward: float,
m.lastSafeCount = safe.len
var pool: seq[Cand]
var mode = "pick"
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..<sorted.len:
let key = sorted[i]
var j = i - 1
while j >= 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..<take: pool.add sorted[i]
inc m.fallbackPicks
if StrafeEscape and nearWall:
# ── GUARANTEED ESCAPE (B) ─────────────────────────────────────────────
# Every tile is over threshold. The old rule minimised `pathMaxHeat`,
# but near a wall EVERY path is dominated by the same wall tile and the
# SHORTEST path has the least wall exposure, so the "least hot" tile was
# always the adjacent one and led further along the wall. Rank by the
# DESTINATION instead: farthest from the wall first, then coolest tile.
var sorted = cands
for i in 1..<sorted.len:
let key = sorted[i]
var j = i - 1
while j >= 0 and (sorted[j].clearance < key.clearance or
(sorted[j].clearance == key.clearance and
sorted[j].destHeat > key.destHeat)):
sorted[j + 1] = sorted[j]
dec j
sorted[j + 1] = key
pool = @[sorted[0]]
mode = "escape"
m.escapeActive = true
if awX != 0.0 or awY != 0.0:
m.escapeBearing = arctan2(awY, awX) * 180.0 / PI
inc m.wallEscapePicks
else:
# 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..<sorted.len:
let key = sorted[i]
var j = i - 1
while j >= 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..<take: pool.add sorted[i]
mode = "fallback"
inc m.fallbackPicks
# ── corner defense: a degenerate line (< 2 in-arena candidates) escapes ──
let degenerate = cands.len <= 1
@@ -729,17 +860,35 @@ proc pickTarget(m: var StrafeModule, ws: WorldState, lineForward: float,
let esc = m.radialEscape(ws)
if esc.found:
pool = @[Cand(col: esc.col, row: esc.row, x: esc.x, y: esc.y,
pathHeat: esc.pathHeat, along: 0.0)]
pathHeat: esc.pathHeat,
destHeat: m.lavaAt(esc.col, esc.row),
clearance: m.wallClearance(esc.x, esc.y),
along: 0.0)]
escaped = true
mode = "radial"
inc m.escapePicks
if StrafeEscape and nearWall:
m.escapeActive = true
if awX != 0.0 or awY != 0.0:
m.escapeBearing = arctan2(awY, awX) * 180.0 / PI
if pool.len == 0:
m.targetValid = false
m.lastMode = "none"
if StrafeLog:
echo fmt"[strafe] WARNING: no SAFE tile on the line and no in-arena " &
fmt"escape (cands={cands.len}); keeping the last sign"
return
# ── wall-margin bias (C): a MILD preference for a tile farther from the
# wall, only within the margin and only when one is on offer. ──
if StrafeWallBias > 0.0 and nearWall and pool.len > 1:
var improved: seq[Cand]
for c in pool:
if c.clearance > m.wallDist + 1.0: improved.add c
if improved.len > 0 and rand(1.0) < StrafeWallBias:
pool = improved
# ── range control: prefer the line end that closes the range error ──
# A tilt alone cannot move the range (the picker chooses BOTH ends at
# random); this is what turns the tilt into a net radial drift. The bias is
@@ -759,12 +908,20 @@ proc pickTarget(m: var StrafeModule, ws: WorldState, lineForward: float,
m.targetY = pool[chosen].y
m.targetLava = m.lavaAt(pool[chosen].col, pool[chosen].row)
m.targetValid = true
m.lastMode = mode
# sign is frozen for the whole dwell: reversal timing == dwell timing.
let hx = cos(ws.selfHeading * DegToRad)
let hy = sin(ws.selfHeading * DegToRad)
let alongDot = (m.targetX - ws.selfX) * hx + (m.targetY - ws.selfY) * hy
if escaped and m.lastCmdSign != 0.0 and abs(alongDot) < GridSize:
if m.escapeActive and (awX != 0.0 or awY != 0.0):
# GUARANTEE (B): pick the sign whose velocity has a POSITIVE component
# AWAY from the wall. `hdot` = (heading . away); the speed is MaxSpeed*dir
# along the heading, so dir = sign(hdot) makes (speed*heading . away) >= 0
# and the wall clearance cannot fall.
let hdot = cos(wrap180(ws.selfHeading - m.escapeBearing) * DegToRad)
m.dir = if hdot >= 0.0: 1.0 else: -1.0
elif escaped and m.lastCmdSign != 0.0 and abs(alongDot) < GridSize:
# COMMIT: the escape tile is near-perpendicular to the heading, so letting
# it set the sign re-creates the corner oscillation. Keep sliding.
m.dir = m.lastCmdSign
@@ -779,15 +936,15 @@ proc pickTarget(m: var StrafeModule, ws: WorldState, lineForward: float,
inc m.picks
if StrafeLog:
let reason = if escaped: "escape" elif safe.len == 0: "fallback" else: "pick"
echo fmt"[strafe] {reason} n={cands.len} safe={safe.len} " &
echo fmt"[strafe] {mode} 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} " &
fmt"dist={rng.dist:.0f} target={StrafeRange:.0f} tilt={m.rangeTilt:.1f}"
if escaped or safe.len == 0:
fmt"dist={rng.dist:.0f} target={StrafeRange:.0f} tilt={m.rangeTilt:.1f} " &
fmt"wall={m.wallDist:.0f} kappa={kappa:.4f} mode={mode}"
if escaped or safe.len == 0 or mode == "escape":
echo fmt"[strafe] WARNING: no SAFE tile on the line " &
fmt"(cands={cands.len}, least-hot={pool[chosen].pathHeat:.1f}" &
(if escaped: ", radial escape" else: "") & ")"
fmt"(cands={cands.len}, clearance={pool[chosen].clearance:.0f}, " &
fmt"destHeat={pool[chosen].destHeat:.1f}, mode={mode})"
# ── main entry point ─────────────────────────────────────────────────────────
@@ -829,6 +986,20 @@ proc computeMove*(m: var StrafeModule, ws: WorldState): MoveCommand =
appliedTilt = -tilt
var lineAngle = threat + 90.0 + appliedTilt
# ── curved wings (j112): kappa grows as the wall approaches, saturates at
# TR_STRAFE_KAPPA, and is EXACTLY 0 in open space (the wing is then today's
# straight line). The parabola's chord at the reach is tilted toward the
# wall-away normal by atan(kappa*reach), capped by TR_STRAFE_WING_MAX, so the
# body follows the arc's local tangent instead of turning to face the target.
m.wallDist = m.wallClearance(ws.selfX, ws.selfY)
let prox = if StrafeWallMargin > 0.0:
clamp((StrafeWallMargin - m.wallDist) / StrafeWallMargin, 0.0, 1.0)
else: 0.0
m.kappa = StrafeKappa * prox
let (awX, awY) = m.wallAwayDir(ws.selfX, ws.selfY)
let wingTilt = min(arctan(m.kappa * StrafeReach) * 180.0 / PI, StrafeWingMax)
m.wingTilt = wingTilt
# ── corner guard: the line must never point OUT of the arena on BOTH ends ──
# If both ends are outside, project away the outward component of the line
# direction so it becomes parallel to the nearest wall. Then at least one
@@ -864,6 +1035,16 @@ proc computeMove*(m: var StrafeModule, ws: WorldState): MoveCommand =
appliedTilt = 0.0 # the line is wall-parallel now; no range tilt on it
inc m.cornerGuards
# Apply the wing tilt AFTER the corner guard, so the curve survives in a
# corner: the wall-parallel line is leaned back toward the interior.
if wingTilt > 0.0 and (awX != 0.0 or awY != 0.0):
let awayBearing = arctan2(awY, awX) * 180.0 / PI
lineAngle += (if wrap180(awayBearing - lineAngle) >= 0.0: wingTilt
else: -wingTilt)
if m.escapeActive and m.wallDist >= StrafeWallMargin:
m.escapeActive = false
m.rangeTilt = appliedTilt
m.lineDir = lineAngle
@@ -886,16 +1067,22 @@ proc computeMove*(m: var StrafeModule, ws: WorldState): MoveCommand =
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, rng, abs(appliedTilt))
m.pickTarget(ws, lineForward, rng, abs(appliedTilt), m.kappa)
else:
dec m.dwell
# ── heading band: turn ONLY to stay perpendicular, never to the target ──
# ── heading band: turn ONLY to stay on the line / arc tangent, never to the
# target. In escape mode the band reference is the wall-away normal, so the
# body slowly rotates inward until the sign can push us off the wall. ──
var turnRate = 0.0
let mtr = 10.0 - 0.75 * abs(ws.selfSpeed)
let dev = wrap180(ws.selfHeading - lineForward)
var bandRef = lineForward
if m.escapeActive:
bandRef = m.escapeBearing
inc m.escapeModeTicks
let dev = wrap180(ws.selfHeading - bandRef)
if abs(dev) > StrafeBand:
let targetHeading = lineForward + m.bandOffset
let targetHeading = bandRef + m.bandOffset
turnRate = clamp(wrap180(targetHeading - ws.selfHeading), -mtr, mtr)
# ── stuck detector: a commanded move that does not move us flips the sign ──
@@ -915,6 +1102,14 @@ proc computeMove*(m: var StrafeModule, ws: WorldState): MoveCommand =
fmt"-> flipping the sign (pressed into a corner/wall)"
# ── move by sign only ──
# ESCAPE (B): keep the commanded sign aligned to the wall-away normal on
# EVERY tick, not only at pick time. If the heading crosses the normal during
# the dwell the sign flips with it, so `speed * heading . away` is ALWAYS
# >= 0: the clearance cannot fall while escaping. That is the guarantee, and
# it is a sign flip (setForward(±)), never a 180-degree turn.
if m.escapeActive and (awX != 0.0 or awY != 0.0):
let hdot = cos(wrap180(ws.selfHeading - m.escapeBearing) * DegToRad)
m.dir = if hdot >= 0.0: 1.0 else: -1.0
# The picker now guarantees a target whenever any in-arena tile exists (the
# radial escape), so "keep the last sign" is only the truly boxed-in case --
# and the stuck detector above still flips out of that.
@@ -965,6 +1160,26 @@ proc computeMove*(m: var StrafeModule, ws: WorldState): MoveCommand =
drawLine(ws.selfX - tx * 600.0, ws.selfY - ty * 600.0,
ws.selfX + tx * 600.0, ws.selfY + ty * 600.0)
# ── CURVED WINGS (j112): the path actually offered to the picker.
# point(y) = bot + yhat*y + xhat*kappa*y^2, clamped to the safety margin so
# the wing flattens and runs parallel to the wall instead of touching it.
# In open space kappa == 0, so this collapses onto the cyan line above.
if m.kappa > 0.0 and (awX != 0.0 or awY != 0.0):
setStrokeColor(fromHex("#FFA500"))
setStrokeWidth(3.0)
for sgn in [-1.0, 1.0]:
var prevX = ws.selfX
var prevY = ws.selfY
for si in 1..24:
let y = sgn * StrafeReach * (si.float / 24.0)
let off = m.kappa * y * y
var wx = ws.selfX + ux * y + awX * off
var wy = ws.selfY + uy * y + awY * off
wx = clamp(wx, StrafeWallSafe, m.arenaWidth - StrafeWallSafe)
wy = clamp(wy, StrafeWallSafe, m.arenaHeight - StrafeWallSafe)
drawLine(prevX, prevY, wx, wy)
prevX = wx; prevY = wy
# Candidate line reach up to kmax tiles: safe tiles bright, unsafe dim.
let kmax = max(1, int(StrafeReach / GridSize))
let spread = max(0, StrafeSpread)
@@ -973,10 +1188,15 @@ proc computeMove*(m: var StrafeModule, ws: WorldState): MoveCommand =
for k in 1..kmax:
for s in [-1.0, 1.0]:
let along = s * k.float * GridSize
let off = m.kappa * along * along
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
var wx = ws.selfX + ux * along + awX * off + px * (j.float * GridSize)
var wy = ws.selfY + uy * along + awY * off + py * (j.float * GridSize)
if m.kappa > 0.0:
wx = clamp(wx, StrafeWallSafe, m.arenaWidth - StrafeWallSafe)
wy = clamp(wy, StrafeWallSafe, m.arenaHeight - StrafeWallSafe)
elif 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
@@ -1004,6 +1224,17 @@ proc computeMove*(m: var StrafeModule, ws: WorldState): MoveCommand =
drawLine(ws.selfX, ws.selfY,
ws.selfX + ux * m.dir * StrafeReach, ws.selfY + uy * m.dir * StrafeReach)
# ── ESCAPE marker (j112): the wall-away ray + a label, so the user can see
# the guaranteed-escape mode engage. ──
if m.escapeActive:
setStrokeColor(fromHex("#FFFFFF"))
setStrokeWidth(3.0)
drawLine(ws.selfX, ws.selfY,
ws.selfX + awX * 110.0, ws.selfY + awY * 110.0)
setFillColor(fromHex("#FF0000"))
setFont("Arial", 15.0)
drawText("ESCAPE", ws.selfX + 24.0, ws.selfY + 36.0)
# ── range-control marker (j111) ──
# A ray toward the enemy whose LENGTH is |distance - target| and whose
# COLOUR encodes the wanted motion: green = too far (closing in), red = too
@@ -1018,7 +1249,8 @@ proc computeMove*(m: var StrafeModule, ws: WorldState): MoveCommand =
ws.selfX + cos(rb) * rl, ws.selfY + sin(rb) * rl)
setFillColor(fromHex("#FFFFFF"))
setFont("Arial", 12.0)
drawText(fmt"d={rng.dist:.0f} tgt={StrafeRange:.0f} tilt={appliedTilt:.1f}",
drawText(fmt"d={rng.dist:.0f} tgt={StrafeRange:.0f} tilt={appliedTilt:.1f} " &
fmt"wall={m.wallDist:.0f} k={m.kappa:.4f}",
ws.selfX + 22.0, ws.selfY - 22.0)
# ── stuck marker (j111) ──
@@ -1029,11 +1261,12 @@ proc computeMove*(m: var StrafeModule, ws: WorldState): MoveCommand =
setFillColor(fromHex("#FF0000"))
fillCircle(ws.selfX - 27.0, ws.selfY - 27.0, 5.0)
# Heading band: the two ±band boundary rays (yellow) around the line.
# Heading band: the two ±band boundary rays (yellow) around the effective
# reference (the arc line, or the wall-away bearing in escape mode).
setStrokeColor(fromHex("#FFFF00"))
setStrokeWidth(1.0)
let bl = (lineForward - StrafeBand) * DegToRad
let br2 = (lineForward + StrafeBand) * DegToRad
let bl = (bandRef - StrafeBand) * DegToRad
let br2 = (bandRef + StrafeBand) * DegToRad
drawLine(ws.selfX, ws.selfY,
ws.selfX + cos(bl) * 70.0, ws.selfY + sin(bl) * 70.0)
drawLine(ws.selfX, ws.selfY,