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.
908 lines
39 KiB
Nim
908 lines
39 KiB
Nim
## TFIL-RING — The Floor Is Lava, range-weighted. A COPY of
|
||
## `the_floor_is_lava.nim` that keeps that mover's semantics and adds ONE
|
||
## behavioural lever: the per-tick random tile pick is re-weighted so tiles at
|
||
## the bot's preferred TARGET RANGE are drawn more often.
|
||
##
|
||
## ── Why ─────────────────────────────────────────────────────────────────────
|
||
## ModularBot's measured real hit rate vs DrussGT is strongly range-dependent
|
||
## (21.6% at 0-100px, 27.1% at 100-200px, then falling off: 19.3% at 200-300,
|
||
## 10.9% at 300-400, 6.8% at 400-600, 5.4% at 600-800). The bot shoots from
|
||
## ~450px on average, where it hits ~5%. The plain TFIL mover has NO range
|
||
## preference, so it drifts; this mover SHIFTS the random distribution toward a
|
||
## configured band. 100-200 is DrussGT-only data and MUST be re-measured per
|
||
## adversary (set TR_TFIL_RANGE_LO/HI).
|
||
##
|
||
## ── What is preserved (safety stays a HARD constraint) ──────────────────────
|
||
## The reachable hull, the cool-tile pool, the path-heat scoring/sort, the
|
||
## `PathDangerThreshold = 10.0` safety filter and the `safeTiles.len < 2`
|
||
## promote fallback are IDENTICAL to `the_floor_is_lava.nim`. The weighting is
|
||
## applied ONLY to the final draw over `candidates`, which is exactly the pool
|
||
## the unweighted `rand(candidates.high)` picked from. It can therefore never
|
||
## select a tile the old code would have rejected — a monotone refinement of
|
||
## the same safe set. The per-tick randomness is deliberately KEPT (a measured
|
||
## A/B showed committing to the "best" tile made hit rate WORSE, 7.02% ->
|
||
## 5.10%); only its distribution is tilted, never removed.
|
||
##
|
||
## ── The one change ──────────────────────────────────────────────────────────
|
||
## For each candidate tile, d = distance from the tile CENTRE to the current
|
||
## TARGET enemy (ws.enemyX/enemyY). Band weight with a FLAT TOP:
|
||
##
|
||
## rangeW(d) = 1.0 if lo <= d <= hi
|
||
## exp(-((lo - d)/K)^2) if d < lo
|
||
## exp(-((d - hi)/K)^2) if d > hi
|
||
##
|
||
## The flat top matters: a Gaussian centred on the band midpoint would collapse
|
||
## the band to a point and destroy the within-band hedge. Then a
|
||
## temperature-shaped categorical draw:
|
||
##
|
||
## w_i = rangeW(d_i) ^ (1/T); chosen ~ Categorical(w)
|
||
##
|
||
## T -> infinity gives uniform weights (the old distribution). Small safe pools
|
||
## (len < 4, i.e. melee) are always drawn uniformly, so shaping cannot drag the
|
||
## bot toward the enemy while the tiles are hot.
|
||
##
|
||
## ── Env knobs (read once at module init, like the gun rack) ─────────────────
|
||
## TR_TFIL_RANGE_LO default 100.0 band lower edge (px)
|
||
## TR_TFIL_RANGE_HI default 200.0 band upper edge (px)
|
||
## TR_TFIL_RANGE_TEMP default 0.4 softmax temperature; <= 0 = OFF path
|
||
## TR_TFIL_RANGE_K default 60.0 Gaussian falloff scale (px)
|
||
## TR_TFIL_CORRIDOR_HEAT default 10.0 lava per corridor-overlapping tile
|
||
## TR_TFIL_WALL_HOTNESS default 15.0 peak wall radiance at a wall tile
|
||
## TR_MOVEMENT_LOG=1 log band/range-class changes (not/tick)
|
||
## `TR_TFIL_RANGE_TEMP=0` calls plain `rand(candidates.high)` exactly as the
|
||
## original mover did, so the same binary can serve as the control arm.
|
||
##
|
||
## ── Heat-field knobs (the deviation from the original TFIL) ─────────────────
|
||
## Measured (offline, DrussGT fixtures): corridors are 21.4% and walls 56.4% of
|
||
## the over-threshold set, so they dominate the field. The ACTUAL values in this
|
||
## file (read at module init, `let` block further down) are:
|
||
## CorridorHeat = 10.0 — exactly `PathDangerThreshold` (10.0). The safety rule
|
||
## is `pathMaxHeat <= PathDangerThreshold`, so a corridor
|
||
## tile sitting on a path is still SAFE: a corridor alone
|
||
## can no longer make a path unsafe.
|
||
## WallHotness = 15.0 — ABOVE the threshold at the OUTERMOST ring only (the
|
||
## second ring sits at exactly 10.0, the threshold, and
|
||
## is safe), so wall radiance alone CAN poison the outer
|
||
## ring but no deeper.
|
||
## WallRadiance = 5.0 — the falloff that produces the two rings above.
|
||
## BulletCore/BulletAura = 20.0/10.0 — the bullet's own core is ABOVE the
|
||
## threshold, which is what makes an incoming bullet
|
||
## dangerous on its own.
|
||
## Both soft knobs are env-overridable (TR_TFIL_CORRIDOR_HEAT /
|
||
## TR_TFIL_WALL_HOTNESS) for an A/B.
|
||
## CHANGED vs the original mover: BulletCore/BulletAura (20/10 here, 10/5 in
|
||
## the original), WallRadiance (5.0 here, 10.0 in the original). UNCHANGED:
|
||
## EnemyCore/EnemyAura and their radii, PillarHotness/PillarRadiance (0/0
|
||
## default) and PathDangerThreshold (10.0).
|
||
##
|
||
## WARNING: an earlier revision of THIS comment claimed CorridorHeat = 5.0 and
|
||
## WallHotness = 10.0. That did NOT match the code — it was a stale copy of an
|
||
## earlier retune PROPOSAL, and a job that read the comment handed out
|
||
## sub-threshold heat values that left the field empty. The code has always been
|
||
## 10.0 / 15.0; the comment now says so.
|
||
|
||
import std/[math, random, os, strformat]
|
||
from std/strutils import parseFloat, strip # selective: strutils.fromHex clashes with color.fromHex
|
||
import gun_harness/gun_interface
|
||
import movement_harness/movement_interface
|
||
import robocode_tankroyale_botapi/graphics
|
||
import robocode_tankroyale_botapi/color
|
||
|
||
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 = 20.0 ## lava accumulation per bullet-overlapping tile
|
||
const BulletAura = 10.0 ## lava accumulation for aura ring tiles
|
||
|
||
const EnemyCoreRadius = 18.0 ## half of 36px body
|
||
const EnemyAuraRadius = 54.0 ## 18 + 36
|
||
|
||
const EnemyCore = 40.0 ## lava per tile overlapping enemy body circle
|
||
const EnemyAura = 10.0 ## lava per tile in enemy aura ring
|
||
|
||
## NOTE: CorridorHeat and WallHotness are NOT const here (unlike the original
|
||
## `the_floor_is_lava.nim`): they are env-overridable and read at module init,
|
||
## below, in the same style as the range-weighting knobs.
|
||
const WallRadiance = 5.0
|
||
|
||
const PillarHotness = 0.0
|
||
const PillarRadiance = 0.0
|
||
|
||
const CommitTicks = 5 ## ticks to commit to a dodge point
|
||
const MinCommitTicks = 0 ## must commit for this many ticks before danger replan allowed
|
||
const DangerReplanThreshold = 25.0 ## replan on serious threats only (bullet core), not corridors/auras
|
||
const CoolestLevels = 2 ## how many distinct lava values count as "cool"
|
||
const MaxTrackedBullets = 20 ## hard cap on tracked bullets
|
||
|
||
# ── Range-weighting knobs (read once at module init, like the gun rack) ──────
|
||
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
|
||
|
||
const
|
||
DefaultRangeLo = 100.0
|
||
DefaultRangeHi = 200.0
|
||
DefaultRangeTemp = 0.4
|
||
DefaultRangeK = 60.0
|
||
## Minimum safe-pool size before range shaping applies. Below this the draw is
|
||
## uniform: a 2-3 tile melee pool must not be pulled toward the enemy while
|
||
## those tiles are hot.
|
||
MinRingPool = 4
|
||
|
||
let RangeLo* = getEnvFloat("TR_TFIL_RANGE_LO", DefaultRangeLo)
|
||
let RangeHi* = getEnvFloat("TR_TFIL_RANGE_HI", DefaultRangeHi)
|
||
let RangeTemp* = getEnvFloat("TR_TFIL_RANGE_TEMP", DefaultRangeTemp)
|
||
let RangeK* = getEnvFloat("TR_TFIL_RANGE_K", DefaultRangeK)
|
||
let MovementLog* = existsEnv("TR_MOVEMENT_LOG")
|
||
|
||
# ── Heat-field knobs (see the rationale in the file header) ──────────────────
|
||
let CorridorHeat* = getEnvFloat("TR_TFIL_CORRIDOR_HEAT", 10.0)
|
||
let WallHotness* = getEnvFloat("TR_TFIL_WALL_HOTNESS", 15.0)
|
||
|
||
proc rangeWeight*(d, lo, hi, k: float): float =
|
||
## Flat-topped range weight. Exactly 1.0 for d inside [lo, hi]; Gaussian
|
||
## falloff with scale `k` outside. The flat top is deliberate: a Gaussian
|
||
## centred ON the band would collapse it to a point, whereas this keeps every
|
||
## in-band tile equally likely and preserves the within-band hedge. `k <= 0`
|
||
## degrades to a hard band edge (1 inside, 0 outside).
|
||
if d >= lo and d <= hi: return 1.0
|
||
if k <= 0.0: return 0.0
|
||
let z = (if d < lo: (lo - d) / k else: (d - hi) / k)
|
||
exp(-z * z)
|
||
|
||
proc bandWeights*(dists: openArray[float],
|
||
lo, hi, k, temp: float): seq[float] =
|
||
## Categorical weights over a candidate pool. `temp` is the softmax
|
||
## temperature: `temp -> inf` drives every weight to 1.0 (uniform = the old
|
||
## distribution). `temp <= 0` (the explicit OFF path) and pools smaller than
|
||
## `MinRingPool` also return uniform weights.
|
||
result = newSeq[float](dists.len)
|
||
let uniform = temp <= 0.0 or dists.len < MinRingPool
|
||
for i in 0..<dists.len:
|
||
if uniform:
|
||
result[i] = 1.0
|
||
else:
|
||
result[i] = pow(rangeWeight(dists[i], lo, hi, k), 1.0 / temp)
|
||
|
||
proc weightedIndex*(weights: openArray[float], u: float): int =
|
||
## PURE categorical draw: given weights proportional to the probabilities and
|
||
## a uniform variate `u` in [0, 1), return the selected index in
|
||
## 0..<weights.len. The RNG output is injected as `u`, so this is
|
||
## unit-testable with no battle. It always returns an index INSIDE the pool,
|
||
## never outside. Falls back to a uniform index when the weights sum to zero
|
||
## (keeps the function total; with the default K every weight is > 0).
|
||
if weights.len == 0: return 0
|
||
var total = 0.0
|
||
for w in weights: total += w
|
||
if not (total > 0.0):
|
||
return min(weights.high, int(u * weights.len.float))
|
||
let target = u * total
|
||
var acc = 0.0
|
||
for i in 0..<weights.len:
|
||
acc += weights[i]
|
||
if target < acc: return i
|
||
weights.high
|
||
|
||
proc weightedIndex*(weights: openArray[float], rng: var Rand): int =
|
||
## Convenience wrapper for callers holding a `Rand` to inject.
|
||
weightedIndex(weights, rand(rng, 1.0))
|
||
|
||
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)
|
||
|
||
type
|
||
TrackedBullet = object
|
||
originX, originY: float
|
||
x, y: float
|
||
velX, velY: float ## speed * cos(heading), speed * sin(heading)
|
||
power: float
|
||
alive: bool
|
||
age: int ## ticks alive; die if > 200
|
||
|
||
TFILRingModule* = object
|
||
debugGraphics*: bool
|
||
## Preferred target-range band (px from the target enemy). The BOT sets this
|
||
## each tick from the existing ram decision: (RangeLo, RangeHi) while
|
||
## holding, (0.0, 50.0) while ramming. Ram is therefore just a band, so one
|
||
## movement engine covers both modes. Defaults to (RangeLo, RangeHi).
|
||
band*: tuple[lo, hi: float]
|
||
cols, rows: int
|
||
marginX, marginY: float
|
||
arenaWidth, arenaHeight: float
|
||
lava: seq[float] # flat row-major, index = row*cols + col
|
||
bullets: seq[TrackedBullet]
|
||
prevEnergy: seq[tuple[id: int, energy: float]] # enemy id -> last known energy
|
||
commitTarget: tuple[x, y: float] ## world coords of committed dodge point
|
||
commitTicks: int ## ticks remaining on commitment
|
||
commitLava: float ## lava at commit time (for spike detection)
|
||
blockedTile: tuple[col, row: int; active: bool] ## excluded from next pick after danger replan
|
||
cachedHull: seq[tuple[x, y: float]]
|
||
cachedInsideTiles: seq[tuple[col, row: int]]
|
||
callCount: int ## computeMove call count; 0 = never called
|
||
lastBotX, lastBotY: float ## bot position at last call; used to detect position jumps
|
||
lastTileCol, lastTileRow: int ## grid tile at last call; used to detect gradual displacement
|
||
# Ring-mode observability (TR_MOVEMENT_LOG): last logged range class and
|
||
# band, so the log fires on change rather than every tick.
|
||
lastLogClass: int ## -1 = never; 0 near, 1 in-band, 2 far
|
||
lastLogBandLo: float
|
||
lastLogBandHi: float
|
||
loggedOnce: bool
|
||
|
||
proc initTFILRing*(): TFILRingModule =
|
||
TFILRingModule(debugGraphics: false, band: (lo: RangeLo, hi: RangeHi))
|
||
|
||
proc removeBulletNear*(m: var TFILRingModule, x, y: float) =
|
||
## Mark the tracked bullet closest to (x,y) within GridSize tolerance as dead.
|
||
var bestIdx = -1
|
||
var bestD2 = GridSize * GridSize # tolerance²
|
||
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: TFILRingModule, id: int): float =
|
||
for e in m.prevEnergy:
|
||
if e.id == id: return e.energy
|
||
100.0
|
||
|
||
proc prevEnergySet(m: var TFILRingModule, 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 TFILRingModule) =
|
||
## No-op: the SVG buffer is a module-level global cleared by the framework
|
||
## after every go(). Exists so callers can signal "TFIL is inactive this tick".
|
||
discard
|
||
|
||
proc resetRound*(m: var TFILRingModule) =
|
||
m.bullets = @[]
|
||
m.prevEnergy = @[]
|
||
m.commitTicks = 0
|
||
m.cachedHull = @[]
|
||
m.cachedInsideTiles = @[]
|
||
m.blockedTile = (col: 0, row: 0, active: false)
|
||
m.callCount = 0
|
||
m.lastBotX = 0.0
|
||
m.lastBotY = 0.0
|
||
m.lastTileCol = 0
|
||
m.lastTileRow = 0
|
||
m.band = (lo: RangeLo, hi: RangeHi)
|
||
m.lastLogClass = -1
|
||
m.lastLogBandLo = RangeLo
|
||
m.lastLogBandHi = RangeHi
|
||
m.loggedOnce = false
|
||
|
||
proc initGrid(m: var TFILRingModule, 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) # all 0.0
|
||
|
||
proc detectFires(m: var TFILRingModule, ws: WorldState) =
|
||
## Check all enemies for energy drops; spawn a tracked bullet per confirmed fire.
|
||
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
|
||
# Linear prediction: aim at where we will be when the bullet arrives
|
||
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 * PI / 180.0) * travelTime
|
||
let predY = ws.selfY + ws.selfSpeed * sin(ws.selfHeading * PI / 180.0) * travelTime
|
||
let heading = arctan2(predY - ei.y, predX - ei.x)
|
||
if m.bullets.len >= MaxTrackedBullets:
|
||
m.bullets.del(0) # ponytail: drop oldest; fine for 20-bullet cap
|
||
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 TFILRingModule, selfX, selfY: float) =
|
||
## Advance positions and reap bullets that are: passed us, out of bounds, or too old.
|
||
var i = 0
|
||
while i < m.bullets.len:
|
||
var b = m.bullets[i]
|
||
b.x += b.velX
|
||
b.y += b.velY
|
||
b.age += 1
|
||
# Death conditions (any triggers removal):
|
||
# 1. Passed us (dot < 0, moving away)
|
||
# 2. Out of arena bounds
|
||
# 3. Too old (>200 ticks)
|
||
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)
|
||
|
||
type CorridorGeom = object
|
||
dx, dy: float ## unit heading
|
||
px, py: float ## unit perpendicular
|
||
tMin: float ## distance to wall
|
||
bx, by: float ## bullet origin
|
||
|
||
proc corridorGeom(b: TrackedBullet, arenaWidth, arenaHeight: float): CorridorGeom =
|
||
let speed = sqrt(b.velX * b.velX + b.velY * b.velY)
|
||
if speed < 0.001: return
|
||
let dx = b.velX / speed
|
||
let dy = b.velY / speed
|
||
var tMin = Inf
|
||
if dx > 0.0: tMin = min(tMin, (arenaWidth - b.x) / dx)
|
||
elif dx < 0.0: tMin = min(tMin, (0.0 - b.x) / dx)
|
||
if dy > 0.0: tMin = min(tMin, (arenaHeight - b.y) / dy)
|
||
elif dy < 0.0: tMin = min(tMin, (0.0 - b.y) / dy)
|
||
CorridorGeom(dx: dx, dy: dy, px: -dy, py: dx, tMin: tMin, bx: b.x, by: b.y)
|
||
|
||
proc lavaAt(m: TFILRingModule, col, row: int): float =
|
||
m.lava[row * m.cols + col]
|
||
|
||
proc tileAt(m: TFILRingModule, 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 pointInHull(px, py: float, hull: seq[(float, float)]): bool =
|
||
var inside = false
|
||
var j = hull.high
|
||
for i in 0..hull.high:
|
||
if ((hull[i][1] > py) != (hull[j][1] > py)) and
|
||
(px < (hull[j][0] - hull[i][0]) * (py - hull[i][1]) / (hull[j][1] - hull[i][1]) + hull[i][0]):
|
||
inside = not inside
|
||
j = i
|
||
inside
|
||
|
||
proc computeReachableHull(x0, y0, heading0, speed0,
|
||
arenaW, arenaH: float, ticks: int = 50): seq[(float, float)] =
|
||
## Simulate `ticks` ticks at various turn rates / target speeds.
|
||
## Returns convex hull (gift-wrap) of final positions.
|
||
const TargetSpeeds = [8.0, 4.0, -4.0, -8.0]
|
||
const NumRates = 11
|
||
|
||
var pts: seq[(float, float)]
|
||
pts.add (x0, y0) # always reachable: stay
|
||
|
||
for tSpeed in TargetSpeeds:
|
||
# Max turn rate at target speed (approximate; actual varies per tick but close enough)
|
||
let mtr = 10.0 - 0.75 * abs(tSpeed)
|
||
for ri in 0..<NumRates:
|
||
let turnRate = if NumRates == 1: 0.0
|
||
else: -mtr + (2.0 * mtr / (NumRates - 1).float) * ri.float
|
||
var x = x0; var y = y0
|
||
var h = heading0; var spd = speed0
|
||
for _ in 0..<ticks:
|
||
# Accelerate toward target
|
||
if spd < tSpeed: spd = min(spd + 1.0, tSpeed)
|
||
elif spd > tSpeed: spd = max(spd - 1.0, tSpeed)
|
||
spd = clamp(spd, -MaxSpeed, MaxSpeed)
|
||
# Turn (clamp to current max turn rate)
|
||
let curMtr = 10.0 - 0.75 * abs(spd)
|
||
let tr = clamp(turnRate, -curMtr, curMtr)
|
||
h += tr
|
||
let hr = h * PI / 180.0
|
||
x = clamp(x + spd * cos(hr), 0.0, arenaW)
|
||
y = clamp(y + spd * sin(hr), 0.0, arenaH)
|
||
pts.add (x, y)
|
||
|
||
# Gift-wrap convex hull (O(n²), ~45 points — fine)
|
||
# Find leftmost point as start
|
||
var startIdx = 0
|
||
for i in 1..<pts.len:
|
||
if pts[i][0] < pts[startIdx][0] or
|
||
(pts[i][0] == pts[startIdx][0] and pts[i][1] < pts[startIdx][1]):
|
||
startIdx = i
|
||
|
||
var hull: seq[(float, float)]
|
||
var cur = startIdx
|
||
while true:
|
||
hull.add pts[cur]
|
||
var next = 0
|
||
for i in 1..<pts.len:
|
||
if next == cur:
|
||
next = i
|
||
continue
|
||
let ax = pts[next][0] - pts[cur][0]
|
||
let ay = pts[next][1] - pts[cur][1]
|
||
let bx = pts[i][0] - pts[cur][0]
|
||
let by = pts[i][1] - pts[cur][1]
|
||
let cross = ax * by - ay * bx
|
||
if cross < 0.0: # i is more counterclockwise
|
||
next = i
|
||
cur = next
|
||
if cur == startIdx: break
|
||
hull
|
||
|
||
proc computeMove*(m: var TFILRingModule, ws: WorldState): MoveCommand =
|
||
if m.cols == 0:
|
||
m.initGrid(ws.arenaWidth, ws.arenaHeight)
|
||
|
||
# Soft reset: detect gap by position jump (rammer moved us across ticks)
|
||
# 12px threshold: above single-tick max movement (8px) but catches even short ram gaps
|
||
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.commitTicks = 0 # force replan — old target invalid
|
||
m.cachedHull = @[] # stale position/heading
|
||
m.cachedInsideTiles = @[]
|
||
m.bullets = @[] # bullet positions are hopelessly stale
|
||
m.blockedTile = (col: 0, row: 0, active: false)
|
||
# Re-snapshot prevEnergy so energy changes during ramming aren't misread as fires
|
||
m.prevEnergy = @[]
|
||
for ei in ws.enemies:
|
||
m.prevEnergySet(ei.id, ei.energy)
|
||
|
||
# Tile-change replan: catches gradual displacement that position threshold misses
|
||
if (not jumped) and (m.callCount > 0) and (m.commitTicks > 0):
|
||
let curTileCol = clamp(int((ws.selfX - m.marginX) / GridSize), 0, m.cols - 1)
|
||
let curTileRow = clamp(int((ws.selfY - m.marginY) / GridSize), 0, m.rows - 1)
|
||
if curTileCol != m.lastTileCol or curTileRow != m.lastTileRow:
|
||
m.commitTicks = 0
|
||
m.cachedHull = @[]
|
||
m.cachedInsideTiles = @[]
|
||
|
||
# Per-tick: advance existing bullets, detect new fires
|
||
m.advanceBullets(ws.selfX, ws.selfY)
|
||
m.detectFires(ws)
|
||
|
||
# Recompute lava from scratch each tick
|
||
for i in 0..<m.lava.len: m.lava[i] = 0.0
|
||
for b in m.bullets:
|
||
let bx = b.x
|
||
let by = b.y
|
||
let (coreR, auraR) = bulletRadii(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:
|
||
m.lava[row * m.cols + col] += BulletCore
|
||
elif d2 <= auraR * auraR:
|
||
m.lava[row * m.cols + col] += BulletAura
|
||
|
||
# Corridor heat — rotated rectangle from bullet position to arena wall, auraR wide
|
||
for b in m.bullets:
|
||
let cg = corridorGeom(b, m.arenaWidth, m.arenaHeight)
|
||
if cg.tMin == 0.0: continue # zero-speed bullet, skip
|
||
let (_, auraR) = bulletRadii(b.power)
|
||
let wx = cg.bx + cg.dx * cg.tMin
|
||
let wy = cg.by + cg.dy * cg.tMin
|
||
# Bounding box of the 4 corners
|
||
let c0x = cg.bx + cg.px * auraR; let c0y = cg.by + cg.py * auraR
|
||
let c1x = cg.bx - cg.px * auraR; let c1y = cg.by - cg.py * auraR
|
||
let c2x = wx - cg.px * auraR; let c2y = wy - cg.py * auraR
|
||
let c3x = wx + cg.px * auraR; let c3y = wy + cg.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
|
||
# Project tile center onto heading and perpendicular axes
|
||
let relX = cx - cg.bx
|
||
let relY = cy - cg.by
|
||
let along = relX * cg.dx + relY * cg.dy
|
||
let perp = relX * cg.px + relY * cg.py
|
||
if along >= 0.0 and along <= cg.tMin and perp >= -auraR and perp <= auraR:
|
||
m.lava[row * m.cols + col] += CorridorHeat
|
||
|
||
# Enemy heat auras — core (18px) and aura ring (54px), same pattern as bullets
|
||
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 heat — additive with bullet heat
|
||
for row in 0..<m.rows:
|
||
for col in 0..<m.cols:
|
||
let heat = max(0.0, WallHotness - col.float * WallRadiance) +
|
||
max(0.0, WallHotness - (m.cols-1-col).float * WallRadiance) +
|
||
max(0.0, WallHotness - row.float * WallRadiance) +
|
||
max(0.0, WallHotness - (m.rows-1-row).float * WallRadiance)
|
||
m.lava[row * m.cols + col] += heat
|
||
|
||
# Pillar radiance heat — center 1×1, 1×2, 2×1, or 2×2 depending on grid parity
|
||
let pc0 = if m.cols mod 2 == 1: m.cols div 2 else: m.cols div 2 - 1
|
||
let pc1 = m.cols div 2 # same as pc0 when odd, pc0+1 when even
|
||
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)
|
||
|
||
if m.debugGraphics:
|
||
# Compute max lava for heat gradient
|
||
var maxLava = 0.0
|
||
for v in m.lava:
|
||
if v > maxLava: maxLava = v
|
||
|
||
# Non-zero tiles: colored border + colored value text (yellow→orange→red)
|
||
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)
|
||
|
||
# Draw tracked bullet circles
|
||
setStrokeColor(RED)
|
||
setStrokeWidth(1.0)
|
||
setFillColor(RED)
|
||
for b in m.bullets:
|
||
let (coreR, auraR) = bulletRadii(b.power)
|
||
drawCircle(b.x, b.y, coreR)
|
||
fillCircle(b.x, b.y, 3.0)
|
||
setStrokeColor(fromHex("#FF8800")) # orange aura
|
||
setStrokeWidth(1.0)
|
||
drawCircle(b.x, b.y, auraR)
|
||
setStrokeColor(RED)
|
||
setStrokeWidth(1.0)
|
||
|
||
# Danger corridor: rotated rectangle projecting each bullet forward to arena wall
|
||
setStrokeColor(fromHex("#AAAAAA"))
|
||
setStrokeWidth(1.0)
|
||
for b in m.bullets:
|
||
let (_, auraR) = bulletRadii(b.power)
|
||
let cg = corridorGeom(b, m.arenaWidth, m.arenaHeight)
|
||
if cg.tMin == 0.0: continue
|
||
let wx = cg.bx + cg.dx * cg.tMin
|
||
let wy = cg.by + cg.dy * cg.tMin
|
||
let corners: seq[(float, float)] = @[
|
||
(cg.bx + cg.px * auraR, cg.by + cg.py * auraR),
|
||
(cg.bx - cg.px * auraR, cg.by - cg.py * auraR),
|
||
(wx - cg.px * auraR, wy - cg.py * auraR),
|
||
(wx + cg.px * auraR, wy + cg.py * auraR),
|
||
]
|
||
drawPolygon(corners)
|
||
|
||
# Enemy core (cyan) and aura (green)
|
||
setStrokeColor(fromHex("#00FFFF")) # cyan core
|
||
setStrokeWidth(1.5)
|
||
for ei in ws.enemies:
|
||
drawCircle(ei.x, ei.y, EnemyCoreRadius)
|
||
setStrokeColor(fromHex("#00CC00")) # green aura
|
||
setStrokeWidth(1.0)
|
||
for ei in ws.enemies:
|
||
drawCircle(ei.x, ei.y, EnemyAuraRadius)
|
||
|
||
# ── Tile-based Dodge System ───────────────────────────────────────────────────
|
||
let botCol = clamp(int((ws.selfX - m.marginX) / GridSize), 0, m.cols - 1)
|
||
let botRow = clamp(int((ws.selfY - m.marginY) / GridSize), 0, m.rows - 1)
|
||
|
||
# Hull + inside-tiles: only recompute on replan tick (commitTicks == 0)
|
||
type TileRef = tuple[col, row: int]
|
||
if m.commitTicks == 0:
|
||
let hull = computeReachableHull(ws.selfX, ws.selfY, ws.selfHeading, ws.selfSpeed,
|
||
m.arenaWidth, m.arenaHeight, 50)
|
||
# store as named-field seq to match cachedHull type
|
||
m.cachedHull = @[]
|
||
for p in hull: m.cachedHull.add (x: p[0], y: p[1])
|
||
m.cachedInsideTiles = @[]
|
||
if hull.len >= 3:
|
||
for row in 0..<m.rows:
|
||
for col in 0..<m.cols:
|
||
let cx = m.marginX + (col.float + 0.5) * GridSize
|
||
let cy = m.marginY + (row.float + 0.5) * GridSize
|
||
if pointInHull(cx, cy, hull):
|
||
m.cachedInsideTiles.add (col: col, row: row)
|
||
|
||
let insideTiles = m.cachedInsideTiles
|
||
|
||
# Find the CoolestLevels distinct lava values among inside-hull tiles
|
||
var distinctVals: seq[float]
|
||
for t in insideTiles:
|
||
let v = m.lavaAt(t.col, t.row)
|
||
var found = false
|
||
for dv in distinctVals:
|
||
if dv == v: found = true; break
|
||
if not found: distinctVals.add v
|
||
# Sort ascending (insertion sort — small N)
|
||
for i in 1..<distinctVals.len:
|
||
let key = distinctVals[i]
|
||
var j = i - 1
|
||
while j >= 0 and distinctVals[j] > key:
|
||
distinctVals[j + 1] = distinctVals[j]
|
||
dec j
|
||
distinctVals[j + 1] = key
|
||
|
||
# Collect tiles matching the CoolestLevels coolest distinct values
|
||
var coolTiles: seq[TileRef]
|
||
let numLevels = min(CoolestLevels, distinctVals.len)
|
||
for t in insideTiles:
|
||
let v = m.lavaAt(t.col, t.row)
|
||
for li in 0..<numLevels:
|
||
if v == distinctVals[li]:
|
||
coolTiles.add t
|
||
break
|
||
|
||
# Score each cool tile by MAX lava on the straight-line path from bot.
|
||
# A single hot tile on the path (corridor, bullet core, enemy aura) makes the whole path unsafe.
|
||
const PathSampleStep = 18.0 # ~half a tile
|
||
const PathDangerThreshold = 10.0 # max lava on path; above this = unsafe
|
||
type ScoredTile = tuple[col, row: int; pathMaxHeat: float]
|
||
var scoredTiles: seq[ScoredTile]
|
||
for t in coolTiles:
|
||
let tx = m.marginX + (t.col.float + 0.5) * GridSize
|
||
let ty = m.marginY + (t.row.float + 0.5) * GridSize
|
||
let ddx = tx - ws.selfX
|
||
let ddy = ty - ws.selfY
|
||
let lineDist = sqrt(ddx*ddx + ddy*ddy)
|
||
var pathMaxHeat = 0.0
|
||
if lineDist > 0.1:
|
||
let steps = max(1, int(lineDist / PathSampleStep))
|
||
for si in 0..steps:
|
||
let frac = si.float / steps.float
|
||
let sx = ws.selfX + ddx * frac
|
||
let sy = ws.selfY + ddy * frac
|
||
let (sc, sr) = m.tileAt(sx, sy)
|
||
pathMaxHeat = max(pathMaxHeat, m.lavaAt(sc, sr))
|
||
scoredTiles.add (col: t.col, row: t.row, pathMaxHeat: pathMaxHeat)
|
||
|
||
# Sort by pathMaxHeat ascending (insertion sort — small N)
|
||
for i in 1..<scoredTiles.len:
|
||
let key = scoredTiles[i]
|
||
var j = i - 1
|
||
while j >= 0 and scoredTiles[j].pathMaxHeat > key.pathMaxHeat:
|
||
scoredTiles[j + 1] = scoredTiles[j]
|
||
dec j
|
||
scoredTiles[j + 1] = key
|
||
|
||
# Absolute threshold filter: safe = path max lava <= PathDangerThreshold.
|
||
# Fallback: if everything is hot, keep the 2 coolest paths anyway.
|
||
var safeTiles: seq[ScoredTile]
|
||
var blockedTiles: seq[ScoredTile]
|
||
for t in scoredTiles:
|
||
if t.pathMaxHeat <= PathDangerThreshold: safeTiles.add t
|
||
else: blockedTiles.add t
|
||
if safeTiles.len < 2:
|
||
# Fallback: promote the least-hot blocked tiles until we have 2
|
||
# ponytail: O(n) scan on already-sorted seq — fine for small N
|
||
let needed = 2 - safeTiles.len
|
||
let promote = min(needed, blockedTiles.len)
|
||
for i in 0..<promote:
|
||
safeTiles.add blockedTiles[i]
|
||
blockedTiles = blockedTiles[promote ..< blockedTiles.len]
|
||
|
||
# Commitment logic
|
||
if m.commitTicks > 0:
|
||
# Only allow danger replan after MinCommitTicks have elapsed
|
||
let ticksElapsed = CommitTicks - m.commitTicks
|
||
if ticksElapsed >= MinCommitTicks:
|
||
let (cc, cr) = m.tileAt(m.commitTarget.x, m.commitTarget.y)
|
||
let curLava = m.lavaAt(cc, cr)
|
||
if curLava > m.commitLava + DangerReplanThreshold:
|
||
# Mark committed tile blocked so we don't re-pick it
|
||
m.blockedTile = (col: cc, row: cr, active: true)
|
||
m.commitTicks = 0 # replan
|
||
else:
|
||
dec m.commitTicks
|
||
else:
|
||
dec m.commitTicks
|
||
|
||
if m.commitTicks == 0 and safeTiles.len > 0:
|
||
# Filter out the blocked tile from candidates
|
||
var candidates: seq[ScoredTile]
|
||
for t in safeTiles:
|
||
if m.blockedTile.active and t.col == m.blockedTile.col and t.row == m.blockedTile.row:
|
||
continue
|
||
candidates.add t
|
||
if candidates.len == 0: candidates = safeTiles # all blocked → ignore block
|
||
# Safety is a HARD constraint: the weighting below only re-orders the draw
|
||
# AMONG `candidates`, which is exactly the pool the old `rand` picked from.
|
||
# It can never select a tile the unweighted code would have rejected
|
||
# (monotone refinement of the same safe set).
|
||
var chosen: int
|
||
if RangeTemp <= 0.0:
|
||
# Explicit OFF path: byte-for-byte the old uniform draw (control arm).
|
||
chosen = rand(candidates.high)
|
||
else:
|
||
var dists = newSeq[float](candidates.len)
|
||
for i, t in candidates:
|
||
let tx = m.marginX + (t.col.float + 0.5) * GridSize
|
||
let ty = m.marginY + (t.row.float + 0.5) * GridSize
|
||
dists[i] = hypot(tx - ws.enemyX, ty - ws.enemyY)
|
||
let weights = bandWeights(dists, m.band.lo, m.band.hi, RangeK, RangeTemp)
|
||
chosen = weightedIndex(weights, rand(1.0))
|
||
let ct = candidates[chosen]
|
||
m.commitTarget = (x: m.marginX + (ct.col.float + 0.5) * GridSize,
|
||
y: m.marginY + (ct.row.float + 0.5) * GridSize)
|
||
m.commitTicks = CommitTicks
|
||
m.commitLava = m.lavaAt(ct.col, ct.row)
|
||
m.blockedTile.active = false # clear after successful pick
|
||
|
||
# One concise log line on a range-class or band change (never per-tick).
|
||
if MovementLog:
|
||
let d = hypot(m.commitTarget.x - ws.enemyX, m.commitTarget.y - ws.enemyY)
|
||
let cls = if d < m.band.lo: 0 elif d <= m.band.hi: 1 else: 2
|
||
if (not m.loggedOnce) or cls != m.lastLogClass or
|
||
m.band.lo != m.lastLogBandLo or m.band.hi != m.lastLogBandHi:
|
||
let clsName = ["near", "band", "far"][cls]
|
||
echo fmt"[tfil_ring] mode=ring band=[{m.band.lo.int},{m.band.hi.int}] " &
|
||
fmt"chosenDist={d.int} class={clsName} cands={candidates.len} " &
|
||
fmt"corridorHeat={CorridorHeat} wallHotness={WallHotness}"
|
||
m.loggedOnce = true
|
||
m.lastLogClass = cls
|
||
m.lastLogBandLo = m.band.lo
|
||
m.lastLogBandHi = m.band.hi
|
||
|
||
if m.debugGraphics:
|
||
# Reachable hull perimeter (darker blue)
|
||
if m.cachedHull.len >= 3:
|
||
let hullPairs: seq[(float, float)] = block:
|
||
var s: seq[(float, float)]
|
||
for p in m.cachedHull: s.add (p.x, p.y)
|
||
s
|
||
setStrokeColor(fromHex("#336699"))
|
||
setStrokeWidth(1.0)
|
||
drawPolygon(hullPairs)
|
||
|
||
# Dim (dark cyan) for path-blocked cool tiles
|
||
setStrokeColor(fromHex("#006666"))
|
||
setStrokeWidth(1.0)
|
||
for t in blockedTiles:
|
||
let x0 = m.marginX + t.col.float * GridSize
|
||
let y0 = m.marginY + t.row.float * GridSize
|
||
drawRectangle(x0, y0, GridSize, GridSize)
|
||
|
||
# Bright cyan borders on safe-to-reach tiles
|
||
setStrokeColor(fromHex("#00FFFF"))
|
||
setStrokeWidth(2.0)
|
||
for t in safeTiles:
|
||
let x0 = m.marginX + t.col.float * GridSize
|
||
let y0 = m.marginY + t.row.float * GridSize
|
||
drawRectangle(x0, y0, GridSize, GridSize)
|
||
|
||
# Green on chosen tile
|
||
let (chosenCol, chosenRow) = m.tileAt(m.commitTarget.x, m.commitTarget.y)
|
||
let gx0 = m.marginX + chosenCol.float * GridSize
|
||
let gy0 = m.marginY + chosenRow.float * GridSize
|
||
setStrokeColor(fromHex("#00FF00"))
|
||
setStrokeWidth(2.5)
|
||
drawRectangle(gx0, gy0, GridSize, GridSize)
|
||
|
||
# Blue on bot tile
|
||
let bx0 = m.marginX + botCol.float * GridSize
|
||
let by0 = m.marginY + botRow.float * GridSize
|
||
setStrokeColor(fromHex("#0088FF"))
|
||
setStrokeWidth(2.5)
|
||
drawRectangle(bx0, by0, GridSize, GridSize)
|
||
|
||
# Committed target line
|
||
setStrokeColor(fromHex("#00FF00"))
|
||
setStrokeWidth(1.5)
|
||
drawLine(ws.selfX, ws.selfY, m.commitTarget.x, m.commitTarget.y)
|
||
|
||
# ── Ring-mode observability ─────────────────────────────────────────────
|
||
# Target band annulus around the current target enemy: two magenta rings at
|
||
# lo and hi radius read as the annulus between them. (lo == 0 in ram mode,
|
||
# so the inner ring is skipped there.)
|
||
setStrokeColor(fromHex("#FF00FF"))
|
||
setStrokeWidth(1.5)
|
||
if m.band.lo > 0.0: drawCircle(ws.enemyX, ws.enemyY, m.band.lo)
|
||
drawCircle(ws.enemyX, ws.enemyY, m.band.hi)
|
||
|
||
# Tint each safe tile by its range weight (red = 0, green = 1) and thicken
|
||
# the border with the weight, so the shaped distribution is visible.
|
||
setFont("Arial", 9.0)
|
||
for t in safeTiles:
|
||
let tx = m.marginX + (t.col.float + 0.5) * GridSize
|
||
let ty = m.marginY + (t.row.float + 0.5) * GridSize
|
||
let d = hypot(tx - ws.enemyX, ty - ws.enemyY)
|
||
let w = rangeWeight(d, m.band.lo, m.band.hi, RangeK)
|
||
let x0 = m.marginX + t.col.float * GridSize
|
||
let y0 = m.marginY + t.row.float * GridSize
|
||
setStrokeColor(fromRgb(255'u8, uint8(255.0 * (1.0 - w)), 0'u8))
|
||
setStrokeWidth(1.0 + 3.0 * w)
|
||
drawRectangle(x0, y0, GridSize, GridSize)
|
||
|
||
# Chosen tile: a filled magenta marker, distinct from the tinted candidates.
|
||
setFillColor(fromHex("#FF00FF"))
|
||
fillCircle(m.commitTarget.x, m.commitTarget.y, 6.0)
|
||
setStrokeColor(fromHex("#FFFFFF"))
|
||
setStrokeWidth(1.0)
|
||
drawCircle(m.commitTarget.x, m.commitTarget.y, 6.0)
|
||
|
||
# Update position snapshot and call counter for next gap detection
|
||
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
|
||
|
||
# ── Steering ─────────────────────────────────────────────────────────────────
|
||
let stepDx = m.commitTarget.x - ws.selfX
|
||
let stepDy = m.commitTarget.y - ws.selfY
|
||
let dist2 = stepDx*stepDx + stepDy*stepDy
|
||
if dist2 < 324.0: # already at target (18px radius)
|
||
return (speed: 0.0, turnRate: 0.0)
|
||
|
||
let targetBearing = arctan2(stepDy, stepDx) * 180.0 / PI
|
||
var delta = targetBearing - ws.selfHeading
|
||
while delta > 180.0: delta -= 360.0
|
||
while delta < -180.0: delta += 360.0
|
||
|
||
let maxTurnRate = 10.0 - 0.75 * abs(ws.selfSpeed)
|
||
var speed: float
|
||
var turnRate: float
|
||
if abs(delta) <= 90.0:
|
||
speed = MaxSpeed
|
||
turnRate = clamp(delta, -maxTurnRate, maxTurnRate)
|
||
else:
|
||
let flipped = if delta > 0.0: delta - 180.0 else: delta + 180.0
|
||
speed = -MaxSpeed
|
||
turnRate = clamp(flipped, -maxTurnRate, maxTurnRate)
|
||
|
||
result = (speed: speed, turnRate: turnRate)
|