STRAFE: range control (tilt) + corner-stall escape; shipped TFIL default untouched
Task j111. Two changes to the TR_MOVEMENT=strafe engine, both OFF the shipped tfil path; the binary default is still tfil. RANGE CONTROL (a hypothesis under test, no default changed elsewhere): the body is still pinned ~perpendicular to the threat, but the line is tilted by the range error: lineAngle = threat + 90 + appliedTilt, with the tilt zero inside +/-TR_STRAFE_RANGE_TOL around TR_STRAFE_RANGE (200 px, chosen because it is exactly TR_POWER_FAR_DIST) and clamped to +/-TR_STRAFE_TILT_MAX. A tilt alone cannot change range (the picker chooses both ends at random), so the picker also PREFERS the end that reduces |distance - target| with a probability that grows with |tilt|; both ends stay possible. The tilt sign is aligned to the ENEMY bearing, since is the bullet direction (roughly its opposite) when a bullet is in flight. Knobs: TR_STRAFE_RANGE (200), TR_STRAFE_RANGE_TOL (25), TR_STRAFE_TILT_MAX (15), TR_STRAFE_TILT_GAIN (0.10), all registered in env_report.nim (emit + knownEnvNames). NOT claimed to be better: j107 measured that drifting 25-30 px closer made damage/run and wins WORSE. CORNER STALL (a real defect): a line whose in-arena candidate set was empty set targetValid=false and kept driving on the last sign, so the bot could oscillate inside a corner tile forever. Three defenses: (1) a deterministic corner guard projects the outward component off the line whenever BOTH ends are outside, so the line becomes wall-parallel and a candidate always exists; (2) a degenerate line (<=1 candidate) falls back to a radial search for the coolest in-arena tile and commits the sign; (3) a commanded move with no displacement for StuckFlipTicks (5) ticks flips the sign. Both warnings now reach the [strafe] log. GUI/log: the tilt is drawn as the existing strafe line (it is lineForward), plus a green/red ray toward the enemy (length = |distance-target|) and white text d=.. tgt=.. tilt=..; a red disc marks a stuck tick. The existing overlays and the j110 heat grid are unchanged. Gates (offline, kinematic replay of the DrussGT fixtures; see common_libs/tests/measure_strafe_range_stall.nim): on the j110 field all four corners that were 100% confined inside 72 px / 22.6 px max before now escape (<=2.6% confined, 209-741 px); the achieved |distance-200| falls on 3 of 4 fixtures (mean -16% to -30%); mean |turnRate| and the 8.00 px/tick speed are essentially unchanged (no-turn property survives). Reversal-interval entropy falls 5.85 -> 5.31 bits (still above TFIL's 5.09): the range bias costs some reversal randomness while closing.
This commit is contained in:
@@ -60,6 +60,43 @@
|
||||
## so the reversal TIMING is randomised and the offline entropy gate (B) checks
|
||||
## it.
|
||||
##
|
||||
## ── Range control: a small TILT of the strafe line (j111) ────────────────────
|
||||
## Motion exactly perpendicular to the threat does not change the distance to
|
||||
## the enemy, so pure strafe holds range BY CONSTRUCTION. To honour the owner's
|
||||
## "we still need to try to go near the optimal distance", the line is tilted:
|
||||
##
|
||||
## lineAngle = threat + 90 + appliedTilt
|
||||
## tilt = clamp(gain * (distance - TR_STRAFE_RANGE) beyond the dead band)
|
||||
##
|
||||
## Inside `±TR_STRAFE_RANGE_TOL` around `TR_STRAFE_RANGE` the tilt is EXACTLY 0,
|
||||
## i.e. today's pure perpendicular strafe; that is meant to be the common case.
|
||||
## Outside it the line leans so that one of its two ends is closer to the enemy
|
||||
## (too far) or farther from it (too close). Because `threat` is the enemy
|
||||
## bearing when the sky is clear but roughly its OPPOSITE when a bullet is in
|
||||
## flight, the tilt's sign is aligned to the enemy bearing first, so a positive
|
||||
## `tilt` always leans the SAME physical way. The magnitude is clamped to
|
||||
## `±TR_STRAFE_TILT_MAX`, so the body still barely turns.
|
||||
##
|
||||
## A tilt ALONE cannot change the range: the picker chooses a random tile on
|
||||
## BOTH ends of the line, so the radial drift averages to zero. The mover
|
||||
## therefore also PREFERS the line end whose tile reduces |distance - target|,
|
||||
## with a probability that grows with |tilt| (0.5 = no preference inside the
|
||||
## dead band, up to 0.9 when the error is at `TILT_MAX`). Both ends stay
|
||||
## possible, so the reversal randomness the pattern gun feeds on survives.
|
||||
##
|
||||
## ── Corner stall: always leave a corner (j111) ──────────────────────────────
|
||||
## Candidate tiles outside the arena used to be silently skipped; when NONE
|
||||
## survived, `targetValid` went false and the mover "kept driving on the last
|
||||
## sign". In a corner whose outward direction was that sign the bot oscillated
|
||||
## inside a tile forever (the owner's "goes straight to a corner and never come
|
||||
## back"). Three defenses now guarantee an escape:
|
||||
## 1. a DEGENERATE line (<= 1 in-arena candidate) falls back to a small radial
|
||||
## search for the coolest in-arena tile and COMMITS the sign, so a
|
||||
## near-perpendicular target cannot flip it back and forth;
|
||||
## 2. a commanded move that produces no displacement for `StuckFlipTicks`
|
||||
## ticks flips the sign;
|
||||
## 3. the `[strafe] WARNING` line fires for both cases so the GUI log shows 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
|
||||
@@ -80,6 +117,10 @@
|
||||
## 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_RANGE 200.0 target enemy distance (px; == TR_POWER_FAR_DIST)
|
||||
## TR_STRAFE_RANGE_TOL 25.0 dead-band half-width (px): tilt = 0 inside
|
||||
## TR_STRAFE_TILT_MAX 15.0 max line tilt off the perpendicular (deg)
|
||||
## TR_STRAFE_TILT_GAIN 0.10 deg of tilt per px of error BEYOND the band
|
||||
## TR_STRAFE_HEAT_GRID 1 draw the full heat grid (0 = hide it)
|
||||
## TR_STRAFE_BULLET_CORE 20.0 lava per bullet-overlapping tile (retune)
|
||||
## TR_STRAFE_BULLET_AURA 10.0 lava for aura ring tiles (retune)
|
||||
@@ -179,6 +220,18 @@ const
|
||||
DefaultStrafeReach = 144.0
|
||||
DefaultStrafeDwellMin = 6
|
||||
DefaultStrafeDwellMax = 20
|
||||
## Range control (j111). 200 px is NOT invented here: it is exactly
|
||||
## TR_POWER_FAR_DIST, the distance at which the bot's own power policy stops
|
||||
## treating the enemy as close, so the mover and the gun agree on "range".
|
||||
DefaultStrafeRange = 200.0
|
||||
DefaultStrafeRangeTol = 25.0
|
||||
DefaultStrafeTiltMax = 15.0
|
||||
DefaultStrafeTiltGain = 0.10
|
||||
|
||||
## 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
|
||||
## out the server applying the first command a tick late.
|
||||
const StuckFlipTicks = 5
|
||||
|
||||
var
|
||||
StrafeBand* = DefaultStrafeBand
|
||||
@@ -187,6 +240,10 @@ var
|
||||
StrafeDwellMin* = DefaultStrafeDwellMin
|
||||
StrafeDwellMax* = DefaultStrafeDwellMax
|
||||
StrafeLog* = false
|
||||
StrafeRange* = DefaultStrafeRange
|
||||
StrafeRangeTol* = DefaultStrafeRangeTol
|
||||
StrafeTiltMax* = DefaultStrafeTiltMax
|
||||
StrafeTiltGain* = DefaultStrafeTiltGain
|
||||
## 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.
|
||||
@@ -210,6 +267,10 @@ proc loadStrafeEnv*() =
|
||||
StrafeDwellMin = max(1, getEnvInt("TR_STRAFE_DWELL_MIN", DefaultStrafeDwellMin))
|
||||
StrafeDwellMax = max(StrafeDwellMin, getEnvInt("TR_STRAFE_DWELL_MAX", DefaultStrafeDwellMax))
|
||||
StrafeLog = existsEnv("TR_STRAFE_LOG")
|
||||
StrafeRange = max(0.0, getEnvFloat("TR_STRAFE_RANGE", DefaultStrafeRange))
|
||||
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))
|
||||
StrafeHeatGrid = getEnvBool("TR_STRAFE_HEAT_GRID", true)
|
||||
loadStrafeHeatEnv()
|
||||
|
||||
@@ -251,6 +312,9 @@ type
|
||||
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)
|
||||
# ── range control (j111) ──
|
||||
rangeDist*: float ## enemy distance this tick (-1 = unknown)
|
||||
rangeTilt*: float ## applied line tilt this tick (deg)
|
||||
# ── diagnostics (gate B + GUI) ──
|
||||
callCount*: int
|
||||
picks*: int
|
||||
@@ -258,6 +322,10 @@ type
|
||||
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
|
||||
escapePicks*: int ## picks where the line was degenerate -> radial escape
|
||||
cornerGuards*: int ## ticks the line was projected off the arena corner
|
||||
stuckTicks*: int ## consecutive commanded ticks with no displacement
|
||||
stuckFlips*: int ## sign flips forced by the stuck detector
|
||||
reversals*: int ## sign flips of the commanded direction
|
||||
lineDirFlips*: int ## times the axis orientation flipped
|
||||
lastCmdSign*: float
|
||||
@@ -306,12 +374,18 @@ proc resetRound*(m: var StrafeModule) =
|
||||
m.dir = 1.0
|
||||
m.bandOffset = 0.0
|
||||
m.lineDir = 0.0
|
||||
m.rangeDist = -1.0
|
||||
m.rangeTilt = 0.0
|
||||
m.callCount = 0
|
||||
m.picks = 0
|
||||
m.lastPickCall = 0
|
||||
m.lastCandCount = 0
|
||||
m.lastSafeCount = 0
|
||||
m.fallbackPicks = 0
|
||||
m.escapePicks = 0
|
||||
m.cornerGuards = 0
|
||||
m.stuckTicks = 0
|
||||
m.stuckFlips = 0
|
||||
m.reversals = 0
|
||||
m.lineDirFlips = 0
|
||||
m.lastCmdSign = 0.0
|
||||
@@ -534,6 +608,29 @@ proc threatBearing(m: StrafeModule, ws: WorldState): float =
|
||||
return arctan2(ei.y - ws.selfY, ei.x - ws.selfX) * 180.0 / PI
|
||||
return 0.0
|
||||
|
||||
# ── range: current enemy bearing / distance ──────────────────────────────────
|
||||
|
||||
type
|
||||
RangeInfo = object
|
||||
found: bool
|
||||
bearing: float ## bot -> enemy, degrees
|
||||
dist: float
|
||||
ex, ey: float
|
||||
|
||||
proc enemyRange(ws: WorldState): RangeInfo =
|
||||
## Current target enemy if set, else the first tracked enemy.
|
||||
if ws.enemyX != 0.0 or ws.enemyY != 0.0:
|
||||
let dx = ws.enemyX - ws.selfX
|
||||
let dy = ws.enemyY - ws.selfY
|
||||
return RangeInfo(found: true, bearing: arctan2(dy, dx) * 180.0 / PI,
|
||||
dist: hypot(dx, dy), ex: ws.enemyX, ey: ws.enemyY)
|
||||
for ei in ws.enemies:
|
||||
let dx = ei.x - ws.selfX
|
||||
let dy = ei.y - ws.selfY
|
||||
return RangeInfo(found: true, bearing: arctan2(dy, dx) * 180.0 / PI,
|
||||
dist: hypot(dx, dy), ex: ei.x, ey: ei.y)
|
||||
RangeInfo(found: false)
|
||||
|
||||
# ── target picking ───────────────────────────────────────────────────────────
|
||||
|
||||
type
|
||||
@@ -543,7 +640,36 @@ type
|
||||
pathHeat: float
|
||||
along: float ## signed offset along the line (+ = forward of lineForward)
|
||||
|
||||
proc pickTarget(m: var StrafeModule, ws: WorldState, lineForward: float) =
|
||||
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
|
||||
## (least path heat, then least tile lava, then nearest). Near a corner the
|
||||
## wall radiance makes the interior the coolest, so this always points back
|
||||
## into the arena.
|
||||
var bestPh = Inf
|
||||
var bestLava = Inf
|
||||
var bestD2 = Inf
|
||||
let (bc, br) = m.tileAt(ws.selfX, ws.selfY)
|
||||
const R = 4
|
||||
for dr in -R..R:
|
||||
for dc in -R..R:
|
||||
if dc == 0 and dr == 0: continue
|
||||
let c = bc + dc
|
||||
let r = br + dr
|
||||
if c < 0 or c >= m.cols or r < 0 or r >= m.rows: continue
|
||||
let cx = m.marginX + (c.float + 0.5) * GridSize
|
||||
let cy = m.marginY + (r.float + 0.5) * GridSize
|
||||
let ph = m.pathMaxHeat(ws.selfX, ws.selfY, cx, cy)
|
||||
let lv = m.lavaAt(c, r)
|
||||
let d2 = (dc * dc + dr * dr).float
|
||||
if ph < bestPh - 1e-9 or
|
||||
(abs(ph - bestPh) <= 1e-9 and (lv < bestLava - 1e-9 or
|
||||
(abs(lv - bestLava) <= 1e-9 and d2 < bestD2))):
|
||||
bestPh = ph; bestLava = lv; bestD2 = d2
|
||||
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) =
|
||||
let ux = cos(lineForward * DegToRad)
|
||||
let uy = sin(lineForward * DegToRad)
|
||||
let px = -uy
|
||||
@@ -596,10 +722,38 @@ proc pickTarget(m: var StrafeModule, ws: WorldState, lineForward: float) =
|
||||
for i in 0..<take: pool.add sorted[i]
|
||||
inc m.fallbackPicks
|
||||
|
||||
# ── corner defense: a degenerate line (< 2 in-arena candidates) escapes ──
|
||||
let degenerate = cands.len <= 1
|
||||
var escaped = false
|
||||
if degenerate:
|
||||
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)]
|
||||
escaped = true
|
||||
inc m.escapePicks
|
||||
|
||||
if pool.len == 0:
|
||||
m.targetValid = false
|
||||
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
|
||||
|
||||
# ── 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
|
||||
# soft (the far end stays possible) so the reversal randomness survives.
|
||||
if rng.found and tiltMag > 0.0 and StrafeTiltMax > 0.0:
|
||||
let curErr = abs(rng.dist - StrafeRange)
|
||||
var approach: seq[Cand]
|
||||
for c in pool:
|
||||
let nd = hypot(rng.ex - c.x, rng.ey - c.y)
|
||||
if abs(nd - StrafeRange) < curErr: approach.add c
|
||||
if approach.len > 0 and approach.len < pool.len:
|
||||
let p = clamp(0.5 + 0.4 * (tiltMag / StrafeTiltMax), 0.0, 0.9)
|
||||
if rand(1.0) < p: pool = approach
|
||||
|
||||
let chosen = rand(pool.high)
|
||||
m.targetX = pool[chosen].x
|
||||
m.targetY = pool[chosen].y
|
||||
@@ -610,7 +764,12 @@ proc pickTarget(m: var StrafeModule, ws: WorldState, lineForward: float) =
|
||||
let hx = cos(ws.selfHeading * DegToRad)
|
||||
let hy = sin(ws.selfHeading * DegToRad)
|
||||
let alongDot = (m.targetX - ws.selfX) * hx + (m.targetY - ws.selfY) * hy
|
||||
m.dir = if alongDot >= 0.0: 1.0 else: -1.0
|
||||
if 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
|
||||
else:
|
||||
m.dir = if alongDot >= 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).
|
||||
@@ -620,13 +779,15 @@ proc pickTarget(m: var StrafeModule, ws: WorldState, lineForward: float) =
|
||||
inc m.picks
|
||||
|
||||
if StrafeLog:
|
||||
let reason = if safe.len == 0: "fallback" else: "pick"
|
||||
let reason = if escaped: "escape" elif 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:
|
||||
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:
|
||||
echo fmt"[strafe] WARNING: no SAFE tile on the line " &
|
||||
fmt"(cands={cands.len}, least-hot={pool[chosen].pathHeat:.1f})"
|
||||
fmt"(cands={cands.len}, least-hot={pool[chosen].pathHeat:.1f}" &
|
||||
(if escaped: ", radial escape" else: "") & ")"
|
||||
|
||||
# ── main entry point ─────────────────────────────────────────────────────────
|
||||
|
||||
@@ -649,9 +810,61 @@ proc computeMove*(m: var StrafeModule, ws: WorldState): MoveCommand =
|
||||
m.detectFires(ws)
|
||||
m.buildHeat(ws)
|
||||
|
||||
# ── threat axis -> perpendicular line ──
|
||||
# ── threat axis -> perpendicular line, TILTED by the range error (j111) ──
|
||||
let threat = m.threatBearing(ws)
|
||||
let lineAngle = threat + 90.0
|
||||
let rng = enemyRange(ws)
|
||||
m.rangeDist = if rng.found: rng.dist else: -1.0
|
||||
var tilt = 0.0
|
||||
if rng.found and StrafeTiltMax > 0.0 and StrafeTiltGain > 0.0:
|
||||
let err = rng.dist - StrafeRange
|
||||
if abs(err) > StrafeRangeTol:
|
||||
let excess = (abs(err) - StrafeRangeTol) * (if err > 0.0: 1.0 else: -1.0)
|
||||
tilt = clamp(StrafeTiltGain * excess, -StrafeTiltMax, StrafeTiltMax)
|
||||
# `threat` is the enemy bearing when the sky is clear, but roughly its
|
||||
# OPPOSITE when it comes from an incoming bullet; align the tilt so a positive
|
||||
# `tilt` always leans the SAME physical way (toward the enemy when too far).
|
||||
var appliedTilt = tilt
|
||||
if tilt != 0.0 and rng.found and
|
||||
cos(wrap180(threat - rng.bearing) * DegToRad) > 0.0:
|
||||
appliedTilt = -tilt
|
||||
var lineAngle = threat + 90.0 + appliedTilt
|
||||
|
||||
# ── 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
|
||||
# candidate tile is always in the arena and the bot slides along the wall
|
||||
# instead of pressing into the corner -- the deterministic half of the stall
|
||||
# fix; the radial escape in `pickTarget` is the backup for the rare case where
|
||||
# even a projected line has no usable tile.
|
||||
block:
|
||||
let ux0 = cos(lineAngle * DegToRad)
|
||||
let uy0 = sin(lineAngle * DegToRad)
|
||||
let r = StrafeReach
|
||||
let x1 = ws.selfX + ux0 * r
|
||||
let y1 = ws.selfY + uy0 * r
|
||||
let x2 = ws.selfX - ux0 * r
|
||||
let y2 = ws.selfY - uy0 * r
|
||||
let out1 = x1 < 0.0 or x1 >= m.arenaWidth or y1 < 0.0 or y1 >= m.arenaHeight
|
||||
let out2 = x2 < 0.0 or x2 >= m.arenaWidth or y2 < 0.0 or y2 >= m.arenaHeight
|
||||
if out1 and out2:
|
||||
var gx = ux0
|
||||
var gy = uy0
|
||||
const WallMargin = 2.0 * GridSize
|
||||
if ws.selfX < WallMargin and gx < 0.0: gx = 0.0
|
||||
if ws.selfX > m.arenaWidth - WallMargin and gx > 0.0: gx = 0.0
|
||||
if ws.selfY < WallMargin and gy < 0.0: gy = 0.0
|
||||
if ws.selfY > m.arenaHeight - WallMargin and gy > 0.0: gy = 0.0
|
||||
if abs(gx) < 1e-6 and abs(gy) < 1e-6:
|
||||
# Dead corner: run parallel to whichever wall is nearest.
|
||||
let mx = min(ws.selfX, m.arenaWidth - ws.selfX)
|
||||
let my = min(ws.selfY, m.arenaHeight - ws.selfY)
|
||||
if mx <= my: gx = 0.0; gy = 1.0
|
||||
else: gx = 1.0; gy = 0.0
|
||||
lineAngle = arctan2(gy, gx) * 180.0 / PI
|
||||
appliedTilt = 0.0 # the line is wall-parallel now; no range tilt on it
|
||||
inc m.cornerGuards
|
||||
|
||||
m.rangeTilt = appliedTilt
|
||||
m.lineDir = lineAngle
|
||||
|
||||
# Forward orientation = the half of the (undirected) line nearest our heading.
|
||||
@@ -673,7 +886,7 @@ 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)
|
||||
m.pickTarget(ws, lineForward, rng, abs(appliedTilt))
|
||||
else:
|
||||
dec m.dwell
|
||||
|
||||
@@ -685,10 +898,26 @@ proc computeMove*(m: var StrafeModule, ws: WorldState): MoveCommand =
|
||||
let targetHeading = lineForward + m.bandOffset
|
||||
turnRate = clamp(wrap180(targetHeading - ws.selfHeading), -mtr, mtr)
|
||||
|
||||
# ── stuck detector: a commanded move that does not move us flips the sign ──
|
||||
if m.callCount > 0 and not jumped:
|
||||
let disp = hypot(ws.selfX - m.lastBotX, ws.selfY - m.lastBotY)
|
||||
if disp < 0.5:
|
||||
inc m.stuckTicks
|
||||
else:
|
||||
m.stuckTicks = 0
|
||||
if m.stuckTicks >= StuckFlipTicks:
|
||||
m.dir = if m.dir >= 0.0: -1.0 else: 1.0
|
||||
m.lastCmdSign = m.dir
|
||||
m.stuckTicks = 0
|
||||
inc m.stuckFlips
|
||||
if StrafeLog:
|
||||
echo fmt"[strafe] WARNING: no displacement for {StuckFlipTicks} ticks " &
|
||||
fmt"-> flipping the sign (pressed into a corner/wall)"
|
||||
|
||||
# ── 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.
|
||||
# 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.
|
||||
if m.targetValid and m.dir != m.lastCmdSign:
|
||||
inc m.reversals
|
||||
m.lastCmdSign = m.dir
|
||||
@@ -775,6 +1004,31 @@ 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)
|
||||
|
||||
# ── 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
|
||||
# close (backing off), white text = the numbers on the log line.
|
||||
if rng.found:
|
||||
let err = rng.dist - StrafeRange
|
||||
let rb = rng.bearing * DegToRad
|
||||
setStrokeColor(if err >= 0.0: fromHex("#00FF88") else: fromHex("#FF4444"))
|
||||
setStrokeWidth(3.0)
|
||||
let rl = min(abs(err), 120.0)
|
||||
drawLine(ws.selfX, ws.selfY,
|
||||
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}",
|
||||
ws.selfX + 22.0, ws.selfY - 22.0)
|
||||
|
||||
# ── stuck marker (j111) ──
|
||||
if m.stuckTicks > 0:
|
||||
setStrokeColor(fromHex("#FF0000"))
|
||||
setStrokeWidth(3.0)
|
||||
drawCircle(ws.selfX, ws.selfY, 24.0)
|
||||
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.
|
||||
setStrokeColor(fromHex("#FFFF00"))
|
||||
setStrokeWidth(1.0)
|
||||
|
||||
Reference in New Issue
Block a user