1ea84f5b6e
Three defects the owner hit as "no heat tiles anymore" under TR_MOVEMENT=strafe. 1. The strafe overlay drew ONLY the tiles on its strafe line, so the computed heat field was essentially invisible. It now draws the WHOLE field exactly as TFIL does (every non-zero tile, yellow->orange->red ramp by field max, integer value label) behind the same debugGraphics flag, with TR_STRAFE_HEAT_GRID=0 to hide it. The strafe overlays draw on top, unchanged. 2. STRAFE carried the SHIPPED bullet constants (core 10 / aura 5), so a bullet's own heat sat exactly ON PathDangerThreshold (10.0) and a bullet was never dangerous on its own in this mover; it only ever bit through its corridor. Defaults are now the retune's 20/10, exposed as TR_STRAFE_BULLET_CORE / TR_STRAFE_BULLET_AURA. 3. The ring mover's header documented CorridorHeat 5.0 / WallHotness 10.0 while the code has always been 10.0 / 15.0. A job read the comment and handed out sub-threshold heat values, which emptied the field. The comment now states the real values and their actual behaviour; no code values changed. Also sets strafe's heat defaults to the retune shape (bullet 20/10, corridor 10, wall 15/5, pillar 0), documented with the reason. Gate A re-run (j110, offline DrussGT fixture, measure_strafe_gates.nim): corrected DEFAULT : 24.6% of picks with ZERO safe tile, mean 11.17 safe j108 shipped field: 63.4% / 3.70 (reproduced exactly) j108 ring retune : 8.1% / 18.41 (reproduced exactly) bullet isolated : 11.4% / 17.07 The corrected default beats the shipped field but is WORSE than j108's retune row: the bullet retune alone costs 8.1 -> 11.4, the corridor/wall retune accounts for the rest. That is the deliberate price of making a bullet dangerous. Guards green: test_env_report 24 PASS, test_tfil_commit_env 30 PASS (shipped TFIL default untouched, byte-for-byte), test_tfil_ring_weights 24 PASS. The three new knobs are registered in the boot env report so the tree-scan guard stays clean.
800 lines
35 KiB
Nim
800 lines
35 KiB
Nim
## 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 is a strafe-specific RETUNE of that field (bullet 20/10,
|
|
## corridor 10, wall 15/5, pillar off — see the defaults block below), not the
|
|
## shipped TFIL field: with the shipped shape a safe tile existed on only 36.6%
|
|
## of picks (j108 Gate A). The time-indexed bullet model + pillar-free default
|
|
## are still reused, 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_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)
|
|
## 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)
|
|
##
|
|
## 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, toLowerAscii
|
|
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)
|
|
|
|
## ── Heat-shape defaults: the RETUNE, not the shipped TFIL field ─────────────
|
|
## STRAFE deliberately runs a DIFFERENT default heat shape from the shipped
|
|
## mover. The shipped field (corridor 20, wall 30/10) left a safe tile on the
|
|
## perpendicular line on only 36.6% of picks (j108 Gate A: 63.4% fallback, mean
|
|
## 3.70 safe candidates); this retune raises that to 75.4% (j110 Gate A: 24.6%
|
|
## fallback, mean 11.17 safe candidates). It is a deliberate middle ground: the
|
|
## bullet's own core MUST be dangerous (defect 2), and that by construction
|
|
## costs some safe tiles versus a field whose bullet core sat below threshold.
|
|
## BulletCore 20 / BulletAura 10 — a bullet's OWN heat (20) is now ABOVE
|
|
## PathDangerThreshold (10), so the bullet itself is the danger. With the
|
|
## shipped 10/5 a bullet core sat exactly ON the threshold and was
|
|
## therefore NEVER dangerous on its own; it only ever bit through its
|
|
## corridor. This is the ring mover's retune, adopted here.
|
|
## CorridorHeat 10 — exactly the threshold, so one corridor tile alone still
|
|
## cannot make a path unsafe (the rule is <= threshold); corridors nudge.
|
|
## WallHotness 15 / WallRadiance 5 — only the outer wall ring is a hard threat.
|
|
## Pillar 0/0 — off, exactly like the shipped default (imported globals).
|
|
## All five are env-overridable per run via the TR_STRAFE_* names below.
|
|
const BulletCoreDefault = 20.0 ## lava per bullet-overlapping tile (retune)
|
|
const BulletAuraDefault = 10.0 ## lava for aura ring tiles (retune)
|
|
|
|
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 = 10.0 ## corridor heat (== PathDangerThreshold)
|
|
const WallHotnessDefault = 15.0 ## wall radiance peak (retune)
|
|
const WallRadianceDefault = 5.0 ## wall radiance falloff (retune)
|
|
|
|
## Heat shape is override-able so the shipped field and the retune can be
|
|
## compared on one binary. The DEFAULTS are the retune (see the block above);
|
|
## the knobs are strafe-specific, so the ring mover's own `TR_TFIL_*` names do
|
|
## not touch this field and vice versa.
|
|
var
|
|
StrafeBulletCore* = BulletCoreDefault
|
|
StrafeBulletAura* = BulletAuraDefault
|
|
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
|
|
|
|
proc getEnvBool(name: string, default: bool): bool =
|
|
let s = getEnv(name, "").strip().toLowerAscii()
|
|
if s.len == 0: return default
|
|
s in ["1", "true", "on", "yes"]
|
|
|
|
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
|
|
## 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.
|
|
StrafeHeatGrid* = true
|
|
|
|
proc loadStrafeHeatEnv*() =
|
|
## Re-read the heat-shape overrides. Exposed so a gate can restore the default
|
|
## field after temporarily retuning it on the SAME process.
|
|
StrafeBulletCore = getEnvFloat("TR_STRAFE_BULLET_CORE", BulletCoreDefault)
|
|
StrafeBulletAura = getEnvFloat("TR_STRAFE_BULLET_AURA", BulletAuraDefault)
|
|
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")
|
|
StrafeHeatGrid = getEnvBool("TR_STRAFE_HEAT_GRID", true)
|
|
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..<m.prevEnergy.len:
|
|
if m.prevEnergy[i].id == id:
|
|
m.prevEnergy[i].energy = energy
|
|
return
|
|
m.prevEnergy.add((id: id, energy: energy))
|
|
|
|
proc clearGraphics*(m: var StrafeModule) =
|
|
## No-op: the SVG buffer is a module-level global cleared by the framework
|
|
## after every go(). Exists so callers can signal "STRAFE is inactive".
|
|
discard
|
|
|
|
proc resetRound*(m: var StrafeModule) =
|
|
m.bullets = @[]
|
|
m.prevEnergy = @[]
|
|
m.targetValid = false
|
|
m.targetLava = 0.0
|
|
m.dwell = 0
|
|
m.dir = 1.0
|
|
m.bandOffset = 0.0
|
|
m.lineDir = 0.0
|
|
m.callCount = 0
|
|
m.picks = 0
|
|
m.lastPickCall = 0
|
|
m.lastCandCount = 0
|
|
m.lastSafeCount = 0
|
|
m.fallbackPicks = 0
|
|
m.reversals = 0
|
|
m.lineDirFlips = 0
|
|
m.lastCmdSign = 0.0
|
|
m.lastLineForward = 0.0
|
|
m.lastBotX = 0.0
|
|
m.lastBotY = 0.0
|
|
m.lastTileCol = 0
|
|
m.lastTileRow = 0
|
|
|
|
proc initGrid(m: var StrafeModule, arenaWidth, arenaHeight: float) =
|
|
m.cols = int(arenaWidth / GridSize)
|
|
m.rows = int(arenaHeight / GridSize)
|
|
m.marginX = (arenaWidth - m.cols.float * GridSize) / 2.0
|
|
m.marginY = (arenaHeight - m.rows.float * GridSize) / 2.0
|
|
m.arenaWidth = arenaWidth
|
|
m.arenaHeight = arenaHeight
|
|
m.lava = newSeq[float](m.cols * m.rows)
|
|
|
|
proc lavaAt(m: StrafeModule, col, row: int): float {.inline.} =
|
|
m.lava[row * m.cols + col]
|
|
|
|
proc tileAt(m: StrafeModule, wx, wy: float): tuple[col, row: int] =
|
|
(col: clamp(int((wx - m.marginX) / GridSize), 0, m.cols - 1),
|
|
row: clamp(int((wy - m.marginY) / GridSize), 0, m.rows - 1))
|
|
|
|
proc bulletRadii(power: float): tuple[core, aura: float] =
|
|
let t = (power - 0.1) / 2.9
|
|
let core = BulletCoreRadiusMin + t * (BulletCoreRadiusMax - BulletCoreRadiusMin)
|
|
let auraExt = BulletAuraExtMax - t * (BulletAuraExtMax - BulletAuraExtMin)
|
|
(core, core + auraExt)
|
|
|
|
# ── bullet tracking (copied from the shipped TFIL mover) ─────────────────────
|
|
|
|
proc detectFires(m: var StrafeModule, ws: WorldState) =
|
|
for ei in ws.enemies:
|
|
let prev = m.prevEnergyGet(ei.id)
|
|
let drop = prev - ei.energy
|
|
m.prevEnergySet(ei.id, ei.energy)
|
|
if drop >= 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..<m.lava.len: m.lava[i] = 0.0
|
|
|
|
# Bullet cores / auras (j105 time model: heat is a function of time-to-arrive).
|
|
for b in m.bullets:
|
|
let bx = b.x
|
|
let by = b.y
|
|
let (coreR, auraR) = bulletRadii(b.power)
|
|
let bSpeed = sqrt(b.velX * b.velX + b.velY * b.velY)
|
|
let bUx = if bSpeed > 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] += StrafeBulletCore * bMag * heatDecay(along / bSpeed)
|
|
elif d2 <= auraR * auraR:
|
|
let along = dx * bUx + dy * bUy
|
|
m.lava[row * m.cols + col] += StrafeBulletAura * 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.rows:
|
|
for col in 0..<m.cols:
|
|
let heat = max(0.0, StrafeWallHotness - col.float * StrafeWallRadiance) +
|
|
max(0.0, StrafeWallHotness - (m.cols-1-col).float * StrafeWallRadiance) +
|
|
max(0.0, StrafeWallHotness - row.float * StrafeWallRadiance) +
|
|
max(0.0, StrafeWallHotness - (m.rows-1-row).float * StrafeWallRadiance)
|
|
m.lava[row * m.cols + col] += heat
|
|
|
|
# Pillar radiance (imported globals; 0/0 = the shipped pillar-free default).
|
|
let pc0 = if m.cols mod 2 == 1: m.cols div 2 else: m.cols div 2 - 1
|
|
let pc1 = m.cols div 2
|
|
let pr0 = if m.rows mod 2 == 1: m.rows div 2 else: m.rows div 2 - 1
|
|
let pr1 = m.rows div 2
|
|
for row in 0..<m.rows:
|
|
for col in 0..<m.cols:
|
|
var minDist = int.high
|
|
for pcol in pc0..pc1:
|
|
for prow in pr0..pr1:
|
|
let d = max(abs(col - pcol), abs(row - prow))
|
|
if d < minDist: minDist = d
|
|
m.lava[row * m.cols + col] += max(0.0, PillarHotness - minDist.float * PillarRadiance)
|
|
|
|
proc pathMaxHeat(m: StrafeModule, x0, y0, x1, y1: float): float =
|
|
let dx = x1 - x0
|
|
let dy = y1 - y0
|
|
let dist = sqrt(dx*dx + dy*dy)
|
|
if dist < 0.1: return 0.0
|
|
let steps = max(1, int(dist / PathSampleStep))
|
|
for si in 0..steps:
|
|
let f = si.float / steps.float
|
|
let (c, r) = m.tileAt(x0 + dx * f, y0 + dy * f)
|
|
result = max(result, m.lavaAt(c, r))
|
|
|
|
# ── the threat axis ──────────────────────────────────────────────────────────
|
|
|
|
proc threatBearing(m: StrafeModule, ws: WorldState): float =
|
|
## Incoming bullet direction when a bullet is in flight (nearest one), else
|
|
## the enemy bearing.
|
|
var bestD = Inf
|
|
var found = false
|
|
for b in m.bullets:
|
|
let d = hypot(b.x - ws.selfX, b.y - ws.selfY)
|
|
if d < bestD:
|
|
bestD = d
|
|
result = arctan2(b.velY, b.velX) * 180.0 / PI
|
|
found = true
|
|
if found: return result
|
|
if ws.enemyX != 0.0 or ws.enemyY != 0.0:
|
|
return arctan2(ws.enemyY - ws.selfY, ws.enemyX - ws.selfX) * 180.0 / PI
|
|
for ei in ws.enemies:
|
|
return arctan2(ei.y - ws.selfY, ei.x - ws.selfX) * 180.0 / PI
|
|
return 0.0
|
|
|
|
# ── target picking ───────────────────────────────────────────────────────────
|
|
|
|
type
|
|
Cand = object
|
|
col, row: int
|
|
x, y: float
|
|
pathHeat: float
|
|
along: float ## signed offset along the line (+ = forward of lineForward)
|
|
|
|
proc pickTarget(m: var StrafeModule, ws: WorldState, lineForward: float) =
|
|
let ux = cos(lineForward * DegToRad)
|
|
let uy = sin(lineForward * DegToRad)
|
|
let px = -uy
|
|
let py = ux
|
|
let kmax = max(1, int(StrafeReach / GridSize))
|
|
let spread = max(0, StrafeSpread)
|
|
let (bc, br) = m.tileAt(ws.selfX, ws.selfY)
|
|
|
|
var cands: seq[Cand]
|
|
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)
|
|
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..<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 pool.len == 0:
|
|
m.targetValid = false
|
|
return
|
|
|
|
let chosen = rand(pool.high)
|
|
m.targetX = pool[chosen].x
|
|
m.targetY = pool[chosen].y
|
|
m.targetLava = m.lavaAt(pool[chosen].col, pool[chosen].row)
|
|
m.targetValid = true
|
|
|
|
# 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
|
|
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).
|
|
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:
|
|
# The WHOLE lava field, drawn exactly as TFIL draws it: every non-zero tile
|
|
# stroked in a yellow->orange->red ramp scaled by the field maximum and
|
|
# labelled with its integer value. This is the picture the owner is used to
|
|
# from TFIL; the strafe overlays below draw ON TOP of it. `TR_STRAFE_HEAT_GRID=0`
|
|
# hides it.
|
|
if StrafeHeatGrid:
|
|
var maxLava = 0.0
|
|
for v in m.lava:
|
|
if v > maxLava: maxLava = v
|
|
setFont("Arial", 10.0)
|
|
for row in 0..<m.rows:
|
|
for col in 0..<m.cols:
|
|
let val = m.lava[row * m.cols + col]
|
|
if val == 0.0: continue
|
|
let t = if maxLava > 0.0: val / maxLava else: 0.0
|
|
let heatColor = fromRgb(255'u8, uint8(255.0 * (1.0 - t)), 0'u8)
|
|
let x0 = m.marginX + col.float * GridSize
|
|
let y0 = m.marginY + row.float * GridSize
|
|
setStrokeColor(heatColor)
|
|
setStrokeWidth(1.0)
|
|
drawRectangle(x0, y0, GridSize, GridSize)
|
|
setFillColor(heatColor)
|
|
drawText($int(val), x0 + 12.0, y0 + 22.0)
|
|
|
|
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
|