d21f7ce5f5
MEASURED LIVE (common_libs/tests/measure_fire_ghost_lag.py, 4 sessions, 1777 matched ghost spawns, both movers): the server dispatches a turn's fire AFTER our go() for that same turn, so a turn-T shot's energy drop first reaches our scan at turn T+1 — and a bullet takes its FIRST step during the turn it is fired, so the true bullet is already one whole bullet step (11-20 px) downrange. Both movers place the ghost at the SCANNED enemy position (where the bullet was born), so the whole ghost trajectory is the true one shifted one turn later and the arrival deadline is a full tick late. MEASURED: detection lag +1 tick on 100% of 1777 matched spawns; ghost-vs- observer displacement 19.06 px mean / 22.00 p90 (tfil) and 16.08 / 21.81 (strafe); arrival-deadline error 0.99 / 0.77 ticks. NOT a rendering artefact: the draw/advance order is correct (advanceBullets -> detectFires -> build). THE FIX: TR_FIRE_LAG (int, default 0 = today byte-for-byte) in the shared fire_tracker, applied by both movers at spawn: x = origin + dir*speed*lag. The deadline needs no separate change — both movers derive it from the ghost's own position, so a correct position gives a correct deadline. WITH IT: displacement 19.06 -> 5.37 px mean (the residue is the enemy's own <=8 px scan staleness) and the deadline error 0.99 -> 0.06 ticks. Guards: test_tfil_commit_env 77 -> 87 checks (default golden parity, exact n-step back-date, deadline shortens by exactly lag, junk/negative degrade to 0, reaped exactly one tick earlier); test_env_report + test_env_dotenv green. TR_FIRE_LAG registered in env_report + knownEnvNames + .env.example + docs/env_reference.md. Live A/B pre-registered in docs/movement_campaign.md (Batch 8) with its MDE stated up front; arms tools/ab/arms_fire_lag.txt. TR_FIRE_DIAG gains a per-round ROUND line (the tick->getTurn anchor) and a per-spawn SPAWN line (the ghost's drawn position).
1282 lines
56 KiB
Nim
1282 lines
56 KiB
Nim
## TFIL — The Floor Is Lava. Built incrementally.
|
||
|
||
import std/math
|
||
import std/random
|
||
import std/os
|
||
import std/strutils except fromHex # `fromHex` would clash with color.fromHex
|
||
import gun_harness/gun_interface
|
||
import movement_harness/movement_interface
|
||
import movement_harness/fire_tracker
|
||
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)
|
||
|
||
## j146: the bullet's OWN heat was a Nim `const`, so no env arm could move it
|
||
## and 10.0 is exactly `PathDangerThreshold` — a bullet is never dangerous on
|
||
## its own and the corridor carries the whole field. Same env-overridable-var
|
||
## pattern as `CorridorHeat` below; the DEFAULTS are today's `const`s, so the
|
||
## default field is bit-identical.
|
||
## TR_TFIL_BULLET_CORE default 10.0 lava per bullet-overlapping tile
|
||
## TR_TFIL_BULLET_AURA default 5.0 lava for aura ring tiles
|
||
const
|
||
DefaultBulletCore = 10.0
|
||
DefaultBulletAura = 5.0
|
||
var
|
||
BulletCore* = DefaultBulletCore
|
||
BulletAura* = DefaultBulletAura
|
||
|
||
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
|
||
|
||
## The shipped heat SHAPE, env-overridable since j119 (the same pattern j106
|
||
## used for the virtual pillar). The DEFAULTS below are the shipped values, so
|
||
## the default path is unchanged; the vars are read once at module init by
|
||
## `loadTfilHeatShapeEnv`. This is what makes the heat axis sweepable on ONE
|
||
## frozen binary (before this it was a Nim `const`, so no env arm could move it).
|
||
## TR_TFIL_CORRIDOR_HEAT default 20.0 lava per corridor-overlapping tile
|
||
## TR_TFIL_WALL_HOTNESS default 30.0 peak wall radiance
|
||
## TR_TFIL_WALL_RADIANCE default 10.0 wall radiance falloff
|
||
const
|
||
DefaultCorridorHeat = 20.0
|
||
DefaultWallHotness = 30.0
|
||
DefaultWallRadiance = 10.0
|
||
|
||
var
|
||
CorridorHeat* = DefaultCorridorHeat
|
||
WallHotness* = DefaultWallHotness
|
||
WallRadiance* = DefaultWallRadiance
|
||
|
||
## The "virtual centre pillar": heat OUR code paints on the arena centre even
|
||
## though the arena has NO physical pillar there. The shipped default is now
|
||
## OFF (0/0) — the bot must not avoid open centre floor for no reason. Set
|
||
## `TR_TFIL_PILLAR_ON=1` to restore the old 30/10 field for A/B (see
|
||
## `loadTfilPillarEnv`). The ring variant already ships 0/0.
|
||
const
|
||
PillarHotnessOn = 30.0
|
||
PillarRadianceOn = 10.0
|
||
var
|
||
PillarHotness* = 0.0
|
||
PillarRadiance* = 0.0
|
||
|
||
const CommitTicks = 15 ## ticks to commit to a dodge point
|
||
const MinCommitTicks = 5 ## 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
|
||
|
||
# ── Commit-behaviour knobs (A/B arms) ────────────────────────────────────────
|
||
#
|
||
# The shipped mover cancels its movement commitment whenever OUR tile changes
|
||
# (`ttrSelf`). With GridSize = 36 and speed up to 8 px/tick the bot crosses a
|
||
# tile boundary every ~5 ticks, so the 15-tick commitment is cancelled by the
|
||
# very motion it commands. `ttrOff` honours the commitment (the danger replan
|
||
# stays the safety valve); `ttrEnemy` keys the cancel to the TARGET's tile
|
||
# displacement, which is what the original comment claimed to do.
|
||
#
|
||
# Every default below reproduces the shipped mover byte-for-byte; see the
|
||
# default-path parity guard in `common_libs/tests/test_tfil_commit_env.nim`.
|
||
type
|
||
TfilTileReplan* = enum
|
||
ttrSelf, ttrOff, ttrEnemy
|
||
|
||
TfilReplanReason* = enum
|
||
rrNone, rrInit, rrTileSelf, rrTileEnemy, rrDanger, rrExpiry,
|
||
rrArrival, rrHyst ## j144: the tile was REACHED / the alternative won by a margin
|
||
|
||
proc tileReplanName*(m: TfilTileReplan): string =
|
||
case m
|
||
of ttrSelf: "self"
|
||
of ttrOff: "off"
|
||
of ttrEnemy: "enemy"
|
||
|
||
proc reasonName*(r: TfilReplanReason): string =
|
||
case r
|
||
of rrNone: "none"
|
||
of rrInit: "init"
|
||
of rrTileSelf: "tile_self"
|
||
of rrTileEnemy: "tile_enemy"
|
||
of rrDanger: "danger"
|
||
of rrExpiry: "expiry"
|
||
of rrArrival: "arrival"
|
||
of rrHyst: "hyst"
|
||
|
||
const
|
||
DefaultTfilCommitTicks = CommitTicks ## 15 — the shipped commitment length
|
||
NoRevForwardWeight = 3 ## forward:backward weight ratio (arm C)
|
||
ArriveRadius = 18.0 ## "we are on the committed tile" — same
|
||
## 18px radius the steering already uses
|
||
## at the bottom of this file
|
||
HullTicks = 50 ## the reachable-hull planning horizon. Past
|
||
## it the committed target is no longer
|
||
## guaranteed reachable, so the arrival
|
||
## commitment must release (stall escape).
|
||
|
||
var
|
||
TfilTileReplanMode*: TfilTileReplan = ttrSelf
|
||
TfilCommitTicks*: int = DefaultTfilCommitTicks
|
||
TfilNoRev*: bool = false
|
||
TfilCommitLogPath*: string = ""
|
||
## j144 — the commitment is held until the tile is REACHED, not for a fixed
|
||
## number of ticks. Every knob below defaults to OFF, so the shipped default
|
||
## path is byte-for-byte unchanged (see `test_tfil_commit_env.nim`).
|
||
## TR_TFIL_COMMIT_ARRIVAL 0/1 hold the committed tile until we are ON it
|
||
## (0 = shipped: fixed 15-tick dwell)
|
||
## TR_TFIL_COMMIT_MARGIN float leave it only if the best alternative tile
|
||
## is at least this much cooler (0 = no margin)
|
||
## TR_TFIL_NOREV_SPEED float while |speed| is below this, a mid-flight
|
||
## switch to the OPPOSITE side is refused
|
||
## (0 = off, today's behaviour)
|
||
TfilCommitArrival*: bool = false
|
||
TfilCommitMargin*: float = 0.0
|
||
TfilNoRevSpeed*: float = 0.0
|
||
## j145 — the turn-cost TIEBREAK, applied ONLY among tiles that already passed
|
||
## the hard heat filter. It is a WEIGHT on the draw, never a term in the heat
|
||
## score (see `turnWeights`).
|
||
## TR_TFIL_TURN_BIAS float the turn TIEBREAK's odds ratio: a
|
||
## straight-ahead safe tile is drawn
|
||
## `1 + bias` times as often as a 180 deg one
|
||
## (0 = off, today's uniform draw exactly)
|
||
## TR_TFIL_TURN_REF_DEG float turn below which there is no penalty (deg)
|
||
TfilTurnBias*: float = 0.0
|
||
TfilTurnRefDeg*: float = 45.0
|
||
## j134: the shared fire-detection correction (`TR_FIRE_FIX`, default on).
|
||
## Off = the shipped `prev - energy` detector byte-for-byte.
|
||
TfilFireFix*: bool = true
|
||
## j134/j147: the env-gated live trace (`TR_FIRE_DIAG`) — one `SPAWN` line per
|
||
## detected enemy fire, for the ghost-vs-observer position probe. Observability
|
||
## only; off by default.
|
||
TfilFireDiag*: bool = false
|
||
|
||
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"]
|
||
|
||
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 loadTfilCommitEnv*() =
|
||
## Read the commit knobs. Called once at module init; the guard test calls it
|
||
## again after `putEnv` so the non-default arms can be exercised in one process.
|
||
case getEnv("TR_TFIL_TILE_REPLAN", "self").strip().toLowerAscii()
|
||
of "off", "none", "never", "0", "false": TfilTileReplanMode = ttrOff
|
||
of "enemy", "target": TfilTileReplanMode = ttrEnemy
|
||
else: TfilTileReplanMode = ttrSelf
|
||
TfilCommitTicks = max(1, getEnvInt("TR_TFIL_COMMIT_TICKS", DefaultTfilCommitTicks))
|
||
TfilNoRev = getEnvBool("TR_TFIL_NO_REV", false)
|
||
TfilCommitLogPath = getEnv("TR_TFIL_COMMIT_LOG", "")
|
||
TfilCommitArrival = getEnvBool("TR_TFIL_COMMIT_ARRIVAL", false)
|
||
TfilCommitMargin = max(0.0, getEnvFloat("TR_TFIL_COMMIT_MARGIN", 0.0))
|
||
TfilNoRevSpeed = max(0.0, getEnvFloat("TR_TFIL_NOREV_SPEED", 0.0))
|
||
TfilTurnBias = max(0.0, getEnvFloat("TR_TFIL_TURN_BIAS", 0.0))
|
||
TfilTurnRefDeg = max(0.0, getEnvFloat("TR_TFIL_TURN_REF_DEG", 45.0))
|
||
TfilFireFix = getEnvBool("TR_FIRE_FIX", true)
|
||
TfilFireDiag = existsEnv("TR_FIRE_DIAG")
|
||
|
||
loadTfilCommitEnv()
|
||
|
||
proc loadTfilPillarEnv*() =
|
||
## Read the virtual-centre-pillar knob. OFF by default: the shipped field is
|
||
## the arena with no invented centre hazard. `TR_TFIL_PILLAR_ON=1` restores
|
||
## the pre-change 30/10 field so the two defaults can be A/B-ed offline.
|
||
if getEnvBool("TR_TFIL_PILLAR_ON", false):
|
||
PillarHotness = PillarHotnessOn
|
||
PillarRadiance = PillarRadianceOn
|
||
else:
|
||
PillarHotness = 0.0
|
||
PillarRadiance = 0.0
|
||
|
||
loadTfilPillarEnv()
|
||
|
||
proc loadTfilHeatShapeEnv*() =
|
||
## Read the shipped-mover heat-shape overrides. Called once at module init;
|
||
## callable again after `putEnv` so one process can A/B the fields. The
|
||
## defaults reproduce the shipped `const`s exactly, so the default field is
|
||
## bit-identical. The `TR_TFIL_CORRIDOR_HEAT` / `TR_TFIL_WALL_HOTNESS` names
|
||
## are shared with the ring mover (which reads its own copies with its own
|
||
## retuned defaults); `TR_TFIL_WALL_RADIANCE` is new here.
|
||
CorridorHeat = max(0.0, getEnvFloat("TR_TFIL_CORRIDOR_HEAT", DefaultCorridorHeat))
|
||
WallHotness = max(0.0, getEnvFloat("TR_TFIL_WALL_HOTNESS", DefaultWallHotness))
|
||
WallRadiance = max(0.0, getEnvFloat("TR_TFIL_WALL_RADIANCE", DefaultWallRadiance))
|
||
BulletCore = max(0.0, getEnvFloat("TR_TFIL_BULLET_CORE", DefaultBulletCore))
|
||
BulletAura = max(0.0, getEnvFloat("TR_TFIL_BULLET_AURA", DefaultBulletAura))
|
||
|
||
loadTfilHeatShapeEnv()
|
||
|
||
# ── Time-indexed bullet heat (TR_TFIL_HEAT_TIME=1, default OFF = shipped) ─────
|
||
#
|
||
# WHY: the flat model gives every bullet-overlapping tile the same heat and
|
||
# paints the bullet's corridor all the way to the arena wall, regardless of how
|
||
# far away or how weak the bullet still is. `CorridorHeat` (20) is twice
|
||
# `PathDangerThreshold` (10), so ONE weak far bullet saturates a 108px-wide
|
||
# swath from its nose to the wall, and a path the bullet will not reach until
|
||
# long after the bot has left it is already marked unsafe.
|
||
#
|
||
# WHAT: heat becomes a function of `dt`, the time (ticks) until the bullet
|
||
# REACHES that cell:
|
||
#
|
||
# dt = along / speed # along = distance from the bullet
|
||
# heat = magnitude(power) * decay(dt)
|
||
#
|
||
# * `decay(dt) = exp(-dt / tau)` is a function of TIME, not pixels. A fixed time
|
||
# constant `tau` therefore projects a PIXEL reach of `speed * tau`: a fast
|
||
# bullet's slope is longer, a slow one's shorter — DERIVED from the physics
|
||
# (`speed = 20 - 3*power`), not hand-tuned per power. `tau` = TR_TFIL_HEAT_TAU.
|
||
# * `magnitude(power)` scales the near-end heat with power from DAMAGE, not from
|
||
# hit chance: server damage is `calcBulletDamage = 4p`, linear in p, and
|
||
# `SCORE_PER_BULLET_DAMAGE = 1.0`, so a stronger bullet costs more when it
|
||
# hits. Hit probability is FLAT across power (docs/env_reference.md), so risk
|
||
# does NOT justify power scaling — the COST of the hit does. Floored at 1.0 so
|
||
# a weak bullet's near end is never LESS dangerous than the flat model.
|
||
# Gain = TR_TFIL_HEAT_POWER_GAIN.
|
||
#
|
||
# TIME-INDEXED PLANNER (NOT implemented, by design): because every source is
|
||
# already expressed as `f(dt)`, evaluating a cell at the tick the bot would
|
||
# ARRIVE there is the one-line change `heatDecay(dt - arrivalDelay)` — heat a
|
||
# later bullet's path by that bullet's lead on the bot's own arrival time, so
|
||
# the bot can use the path and leave before the bullet arrives. This is the real
|
||
# fix for the user's second point; the shipped move is unchanged until then.
|
||
var
|
||
TfilHeatTime*: bool = false
|
||
TfilHeatTau*: float = 9.0 ## decay time constant, ticks
|
||
TfilHeatPowerGain*: float = 1.0 ## extra near-end heat at max power (damage proxy)
|
||
|
||
proc loadTfilHeatEnv*() =
|
||
## Read the heat-model knobs. Called once at module init; also callable after
|
||
## `putEnv` so one process can A/B both models (the offline ruler does this).
|
||
TfilHeatTime = getEnvBool("TR_TFIL_HEAT_TIME", false)
|
||
TfilHeatTau = max(0.05, getEnvFloat("TR_TFIL_HEAT_TAU", 9.0))
|
||
TfilHeatPowerGain = max(0.0, getEnvFloat("TR_TFIL_HEAT_POWER_GAIN", 1.0))
|
||
|
||
loadTfilHeatEnv()
|
||
|
||
proc heatDecay*(dt: float): float =
|
||
## Fraction of a bullet's heat still present `dt` ticks before it arrives.
|
||
## Exactly 1.0 when the time model is off, so the default field is
|
||
## bit-identical to the flat model (multiplying any heat by 1.0 is exact).
|
||
if not TfilHeatTime or dt <= 0.0: return 1.0
|
||
exp(-dt / TfilHeatTau)
|
||
|
||
proc bulletMagScale*(power: float): float =
|
||
## Near-end heat multiplier from the bullet's DAMAGE (4p, linear in power),
|
||
## floored at 1.0. Exactly 1.0 when the time model is off.
|
||
if not TfilHeatTime: return 1.0
|
||
1.0 + TfilHeatPowerGain * (power / 3.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
|
||
|
||
TFILModule* = object
|
||
debugGraphics*: bool
|
||
cols, rows: int
|
||
marginX, marginY: float
|
||
arenaWidth, arenaHeight: float
|
||
lava: seq[float] # flat row-major, index = row*cols + col
|
||
bullets: seq[TrackedBullet]
|
||
fire: FireTracker ## shared energy-drop detector (j134)
|
||
commitTarget: tuple[x, y: float] ## world coords of committed dodge point
|
||
commitTicks: int ## ticks remaining on commitment
|
||
commitAge: int ## total ticks held on the current target
|
||
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
|
||
lastEnemyTileCol, lastEnemyTileRow: int ## grid tile of the target at last call (arm D)
|
||
replanReason: TfilReplanReason ## why the last commitment ended (log only)
|
||
lastPickCall: int ## callCount at the last pick (log only)
|
||
picks: int ## number of picks this round (log only)
|
||
lastPickPromoted: bool ## the last pick had to break the hard
|
||
## heat filter (fewer than 2 safe tiles)
|
||
lastPickMinTurn: float ## smallest |turn| available in the
|
||
## candidate set of the last pick (j145:
|
||
## lets a caller measure the REGRET of the
|
||
## draw instead of only the drawn value)
|
||
lastPickSafe: int ## how many SAFE tiles (pathMaxHeat
|
||
## <= PathDangerThreshold) the last pick
|
||
## drew from (j146: the size of the set
|
||
## the heat SHAPE leaves available)
|
||
|
||
proc initTFIL*(): TFILModule = TFILModule(debugGraphics: false, fire: initFireTracker())
|
||
|
||
proc removeBulletNear*(m: var TFILModule, 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: TFILModule, id: int): float = m.fire.prevEnergyGet(id)
|
||
|
||
proc prevEnergySet(m: var TFILModule, id: int, energy: float) =
|
||
m.fire.prevEnergySet(id, energy)
|
||
|
||
proc clearGraphics*(m: var TFILModule) =
|
||
## 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 TFILModule) =
|
||
m.bullets = @[]
|
||
m.fire.reset()
|
||
m.commitTicks = 0
|
||
m.commitAge = 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.lastEnemyTileCol = -1
|
||
m.lastEnemyTileRow = -1
|
||
m.replanReason = rrNone
|
||
m.lastPickCall = 0
|
||
m.picks = 0
|
||
m.lastPickPromoted = false
|
||
m.lastPickMinTurn = 0.0
|
||
m.lastPickSafe = 0
|
||
|
||
# ── Commit diagnostics (TR_TFIL_COMMIT_LOG, off by default) ──────────────────
|
||
# One JSONL line per computeMove call, used by the A/B to prove the treatment
|
||
# actually bit (decision interval, replan reason, reversal rate, speed).
|
||
var
|
||
tfilLogFile: File
|
||
tfilLogOpen = false
|
||
tfilLogPathOpen = ""
|
||
|
||
proc closeTfilCommitLog*() =
|
||
## Close the diagnostics stream. Needed because the log path is a knob: a
|
||
## caller (the A/B, or the guard test) may point it somewhere else mid-process.
|
||
if tfilLogOpen:
|
||
try: tfilLogFile.close()
|
||
except CatchableError: discard
|
||
tfilLogOpen = false
|
||
tfilLogPathOpen = ""
|
||
|
||
proc tfilLogWrite(line: string) =
|
||
if TfilCommitLogPath.len == 0: return
|
||
if tfilLogOpen and tfilLogPathOpen != TfilCommitLogPath: closeTfilCommitLog()
|
||
if not tfilLogOpen:
|
||
try:
|
||
tfilLogFile = open(TfilCommitLogPath, fmAppend)
|
||
tfilLogOpen = true
|
||
tfilLogPathOpen = TfilCommitLogPath
|
||
except CatchableError:
|
||
return
|
||
try:
|
||
tfilLogFile.writeLine(line)
|
||
tfilLogFile.flushFile()
|
||
except CatchableError:
|
||
discard
|
||
|
||
proc initGrid(m: var TFILModule, 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 spawnTrackedWave(m: var TFILModule, ws: WorldState, ei: EnemyInfo,
|
||
power: float) =
|
||
## One tracked bullet/wave for a confirmed fire of `power`: linear prediction
|
||
## of our position at arrival becomes the aim heading. (Was inlined in
|
||
## `detectFires`; extracted so the shared split can spawn several.)
|
||
let speed = 20.0 - 3.0 * power
|
||
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
|
||
# j147: back-date the shot by the measured detection lag (`TR_FIRE_LAG`,
|
||
# default 0 = untouched). See `movement_harness/fire_tracker.nim`.
|
||
var gx = ei.x
|
||
var gy = ei.y
|
||
if FireLag > 0:
|
||
gx += (speed * cos(heading)) * FireLag.float
|
||
gy += (speed * sin(heading)) * FireLag.float
|
||
m.bullets.add TrackedBullet(
|
||
originX: ei.x, originY: ei.y,
|
||
x: gx, y: gy,
|
||
velX: speed * cos(heading),
|
||
velY: speed * sin(heading),
|
||
power: power,
|
||
alive: true,
|
||
age: 0)
|
||
|
||
proc noteEnemyBulletHit*(m: var TFILModule, power: float) =
|
||
## `onHitByBullet` -> the shooter's `3*power` bonus (no-op when off).
|
||
if TfilFireFix:
|
||
m.fire.noteEnemyBulletHit(power)
|
||
|
||
proc noteDamageDealt*(m: var TFILModule, damage: float) =
|
||
## `onBulletHit` -> our same-tick damage to the enemy (no-op when off).
|
||
if TfilFireFix:
|
||
m.fire.noteDamageDealt(damage)
|
||
|
||
proc detectFires(m: var TFILModule, ws: WorldState) =
|
||
## Check all enemies for energy drops; spawn a tracked bullet per confirmed
|
||
## fire. The shared tracker corrects the delta and splits over-cap drops.
|
||
for ei in ws.enemies:
|
||
for p in m.fire.detect(ei.id, ei.energy, 0.09, 3.01, TfilFireFix):
|
||
m.spawnTrackedWave(ws, ei, p)
|
||
if TfilFireDiag and m.bullets.len > 0:
|
||
# Ghost-vs-observer probe: the tick we DETECTED the fire, our own
|
||
# position (the timeline anchor) and the ghost's DRAWN position.
|
||
let b = m.bullets[^1]
|
||
let sp = 20.0 - 3.0 * p
|
||
echo "[firediag] SPAWN tick=", ws.tick,
|
||
" sx=", ws.selfX, " sy=", ws.selfY,
|
||
" gx=", b.x, " gy=", b.y,
|
||
" p=", p,
|
||
" eta=", sqrt((ws.selfX - b.x)^2 + (ws.selfY - b.y)^2) / sp
|
||
m.fire.endScan()
|
||
|
||
proc advanceBullets(m: var TFILModule, 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: TFILModule, col, row: int): float =
|
||
m.lava[row * m.cols + col]
|
||
|
||
proc tileAt(m: TFILModule, 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 noRevWeights*(dirs: openArray[float], travelDeg: float): seq[int] =
|
||
## Soft no-reversal preference (arm C). `dirs` are candidate directions in
|
||
## degrees (world frame). Forward candidates (<=90 deg off the travel
|
||
## direction) get `NoRevForwardWeight`, every other candidate gets 1.
|
||
## A weight is NEVER 0, so the preference can only down-weight, never filter:
|
||
## the candidate pool can never be emptied by it. When no forward candidate
|
||
## exists the caller falls back to a uniform draw (all weights 1 anyway).
|
||
for d in dirs:
|
||
var a = d - travelDeg
|
||
while a > 180.0: a -= 360.0
|
||
while a < -180.0: a += 360.0
|
||
result.add (if abs(a) <= 90.0: NoRevForwardWeight else: 1)
|
||
|
||
proc norevPool*(offs: openArray[float], threshold: float): seq[int] =
|
||
## j144: which candidate tiles may a slow, mid-flight switch take?
|
||
## `offs` are the signed angles (deg) from the travel direction to each
|
||
## candidate, `threshold` is the speed gate (px/tick). Returns the indices
|
||
## that are NOT more than 90 deg off — i.e. the bot does not have to turn
|
||
## around to reach them.
|
||
## IF EVERY candidate is behind us the reversal is unavoidable (boxed in, or
|
||
## the field only offers rearward space), so the single LEAST-bad one is
|
||
## returned: a shallow turn, not a 180 deg flip. The result is NEVER empty,
|
||
## so the pick can never be starved. `threshold <= 0` means the knob is off
|
||
## and every candidate stays.
|
||
if offs.len == 0: return
|
||
if threshold <= 0.0: # the knob is off: no filtering at all
|
||
for i in 0..<offs.len: result.add i
|
||
return
|
||
var keep: seq[int]
|
||
for i, a in offs:
|
||
if abs(a) <= 90.0: keep.add i
|
||
if keep.len > 0: return keep
|
||
var best = 0
|
||
for i, a in offs:
|
||
if abs(a) < abs(offs[best]): best = i
|
||
@[best]
|
||
|
||
proc turnWeights*(turns: openArray[float], bias, refDeg: float): seq[int] =
|
||
## j145: the turn-cost TIEBREAK, as an integer draw weight per candidate:
|
||
##
|
||
## w = max(1, round(1 + bias * (1 - max(0, |turn| - refDeg) / 180)))
|
||
##
|
||
## i.e. a straight-ahead (within `refDeg`) safe tile is drawn `1 + bias` times
|
||
## as often as a 180 deg one, falling LINEARLY in between. Read `bias` as that
|
||
## odds ratio: bias 0 = today's uniform draw, bias 9 = 10:1.
|
||
## Only tiles that already passed the hard heat filter reach this function, so
|
||
## it can never rescue a hot one.
|
||
## WHY A WEIGHT AND NOT `heat + k*turnDeg`: mixing the two trades dodging for
|
||
## smoothness, which is backwards in a bullet-dodging game — the safety filter
|
||
## stays hard and the turn only re-orders the survivors.
|
||
## WHY NOT AN ARGMIN: job j51 (3142b70) measured that randomness in this tie
|
||
## is LOAD-BEARING for this bot — a deterministic argmin scored worse. So the
|
||
## draw stays random and only its TILT is new.
|
||
## The weight is floored at 1, so the pool can never be starved.
|
||
for t in turns:
|
||
result.add max(1, int(round(1.0 + bias *
|
||
(1.0 - max(0.0, t - refDeg) / 180.0))))
|
||
|
||
proc computeMove*(m: var TFILModule, 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.fire.prevEnergy = @[]
|
||
for ei in ws.enemies:
|
||
m.fire.prevEnergySet(ei.id, ei.energy)
|
||
|
||
# Tile-change replan — see the knob rationale at the top of the file.
|
||
# j144: with TR_TFIL_COMMIT_ARRIVAL the SELF-tile crossing is exactly the event
|
||
# that must NOT cancel a commitment: crossing a boundary is the very motion
|
||
# the commitment commands, and it fires every ~5 ticks (GridSize 36, speed 8).
|
||
# Under the shipped default (arrival off) this is the original block verbatim.
|
||
if (not jumped) and (m.callCount > 0) and (m.commitTicks > 0) and
|
||
not (TfilCommitArrival and TfilTileReplanMode == ttrSelf):
|
||
case TfilTileReplanMode
|
||
of ttrSelf:
|
||
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 = @[]
|
||
m.replanReason = rrTileSelf
|
||
of ttrEnemy:
|
||
# The comment's original intent: replan when the TARGET went stale, not
|
||
# when WE moved. Uses the primary enemy's tile displacement.
|
||
if ws.enemies.len > 0 and m.lastEnemyTileCol >= 0:
|
||
let ec = clamp(int((ws.enemies[0].x - m.marginX) / GridSize), 0, m.cols - 1)
|
||
let er = clamp(int((ws.enemies[0].y - m.marginY) / GridSize), 0, m.rows - 1)
|
||
if ec != m.lastEnemyTileCol or er != m.lastEnemyTileRow:
|
||
m.commitTicks = 0
|
||
m.cachedHull = @[]
|
||
m.cachedInsideTiles = @[]
|
||
m.replanReason = rrTileEnemy
|
||
of ttrOff:
|
||
discard # honour the commitment; the danger replan is the safety valve
|
||
|
||
# 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)
|
||
# Time model: project the tile's nearest point onto the bullet heading. When
|
||
# the model is off these factors are exactly 1.0, so heat is unchanged.
|
||
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] += BulletCore * bMag *
|
||
heatDecay(along / bSpeed)
|
||
elif d2 <= auraR * auraR:
|
||
let along = dx * bUx + dy * bUy
|
||
m.lava[row * m.cols + col] += BulletAura * bMag *
|
||
heatDecay(along / bSpeed)
|
||
|
||
# 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)
|
||
# Time model: the corridor gradient is `dt = along / speed` (see the heat
|
||
# block above). Off -> exactly 1.0, so the corridor is the flat shipped one.
|
||
let bSpeed = sqrt(b.velX * b.velX + b.velY * b.velY)
|
||
let bMag = bulletMagScale(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 * bMag *
|
||
heatDecay(along / bSpeed)
|
||
|
||
# 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
|
||
# When the time model is on, draw only as far as the corridor still blocks
|
||
# (heat > PathDangerThreshold = 10); otherwise the outline would claim a
|
||
# wall-to-wall threat the field no longer has. Off -> reach = cg.tMin.
|
||
var reach = cg.tMin
|
||
if TfilHeatTime:
|
||
let speed = sqrt(b.velX * b.velX + b.velY * b.velY)
|
||
let near = CorridorHeat * bulletMagScale(b.power)
|
||
reach = if speed > 0.0 and near > 10.0:
|
||
min(reach, -TfilHeatTau * ln(10.0 / near) * speed)
|
||
else: 0.0
|
||
let wx = cg.bx + cg.dx * reach
|
||
let wy = cg.by + cg.dy * reach
|
||
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)
|
||
|
||
# log-only bookkeeping for this tick
|
||
var pickedThisTick = false
|
||
var pickedRev = false
|
||
var pickedMidFlight = false
|
||
var pickedInterval = 0
|
||
var pickedTurn = 0.0 # log-only: |turn| to the tile that was chosen
|
||
var pickedPromoted = false ## log-only: the pick had to break the heat filter
|
||
|
||
# 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, HullTicks)
|
||
# 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
|
||
# j145: `turnDeg` is the |heading change| from the direction we are ALREADY
|
||
# travelling to the tile centre. It is carried on the candidate (never folded
|
||
# into `pathMaxHeat`) so the pick can bias among the safe tiles only.
|
||
type ScoredTile = tuple[col, row: int; pathMaxHeat: float; turnDeg: float]
|
||
|
||
proc pathMaxHeat(m: TFILModule, fx, fy, tx, ty: float): float =
|
||
## MAX lava on the straight-line segment (fx,fy) -> (tx,ty), sampled every
|
||
## ~half a tile. One function so the picker and the j144 hysteresis test
|
||
## score the committed target and the alternatives on the SAME scale.
|
||
let ddx = tx - fx
|
||
let ddy = ty - fy
|
||
let lineDist = sqrt(ddx*ddx + ddy*ddy)
|
||
if lineDist <= 0.1: return 0.0
|
||
let steps = max(1, int(lineDist / PathSampleStep))
|
||
var h = 0.0
|
||
for si in 0..steps:
|
||
let frac = si.float / steps.float
|
||
let (sc, sr) = m.tileAt(fx + ddx * frac, fy + ddy * frac)
|
||
h = max(h, m.lavaAt(sc, sr))
|
||
h
|
||
|
||
proc tileOffTravel(m: TFILModule, col, row: int,
|
||
sx, sy, travelDeg: float): float =
|
||
## Signed angle in degrees from the travel direction to the tile centre,
|
||
## folded into (-180, 180]. Positive = the tile lies counterclockwise.
|
||
let tx = m.marginX + (col.float + 0.5) * GridSize
|
||
let ty = m.marginY + (row.float + 0.5) * GridSize
|
||
result = arctan2(ty - sy, tx - sx) * 180.0 / PI - travelDeg
|
||
while result > 180.0: result -= 360.0
|
||
while result < -180.0: result += 360.0
|
||
|
||
# j145: the committed travel direction, needed both to score the turn cost of
|
||
# every candidate and to draw from it. Computed here (not in the pick block)
|
||
# because the candidates are scored before the pick.
|
||
let travelDeg = if ws.selfSpeed < -0.01: ws.selfHeading + 180.0
|
||
else: ws.selfHeading
|
||
|
||
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
|
||
scoredTiles.add (col: t.col, row: t.row,
|
||
pathMaxHeat: pathMaxHeat(m, ws.selfX, ws.selfY, tx, ty),
|
||
turnDeg: abs(tileOffTravel(m, t.col, t.row, ws.selfX,
|
||
ws.selfY, travelDeg)))
|
||
|
||
# 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]
|
||
m.lastPickPromoted = false # j145: set only by the promotion below, so it is
|
||
# always the flag OF THE PICK THAT JUST HAPPENED
|
||
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]
|
||
m.lastPickPromoted = true
|
||
blockedTiles = blockedTiles[promote ..< blockedTiles.len]
|
||
|
||
# Commitment logic. With every j144 knob at its default (all off) this is the
|
||
# original three-way test, unchanged. j144 adds two ways OUT of a commitment
|
||
# that are NOT a tile crossing, and turns the tick counter into a MINIMUM
|
||
# dwell: the target is held until we are actually standing on it.
|
||
let atTarget = (ws.selfX - m.commitTarget.x)^2 + (ws.selfY - m.commitTarget.y)^2 <
|
||
ArriveRadius * ArriveRadius
|
||
var commitEnd = rrNone
|
||
if m.commitTicks > 0:
|
||
inc m.commitAge
|
||
# Only allow a replan after MinCommitTicks have elapsed
|
||
let ticksElapsed = TfilCommitTicks - 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:
|
||
# GENUINE DANGER: the committed tile got hot. Block it so we don't
|
||
# immediately re-pick it, and replan. This is the safety valve and is
|
||
# deliberately independent of any boundary crossing.
|
||
m.blockedTile = (col: cc, row: cr, active: true)
|
||
commitEnd = rrDanger
|
||
elif TfilCommitArrival:
|
||
if atTarget:
|
||
# Reached. Only now is a new target allowed.
|
||
commitEnd = rrArrival
|
||
elif m.commitAge >= HullTicks:
|
||
# Stall escape: past the planner's own reachability horizon the
|
||
# committed tile is no longer guaranteed reachable (rammed, boxed in).
|
||
commitEnd = rrExpiry
|
||
elif TfilCommitMargin > 0.0:
|
||
# Hysteresis: "the path is still good" must not be able to switch us.
|
||
# Leave only when the best OTHER safe tile is cooler by > margin,
|
||
# measured on the same pathMaxHeat scale the picker uses.
|
||
var altBest = Inf
|
||
for t in safeTiles:
|
||
if t.col == cc and t.row == cr: continue
|
||
altBest = min(altBest, t.pathMaxHeat)
|
||
if altBest < Inf and
|
||
altBest < pathMaxHeat(m, ws.selfX, ws.selfY, m.commitTarget.x,
|
||
m.commitTarget.y) - TfilCommitMargin:
|
||
commitEnd = rrHyst
|
||
if commitEnd == rrNone:
|
||
dec m.commitTicks
|
||
if m.commitTicks == 0:
|
||
if TfilCommitArrival:
|
||
m.commitTicks = TfilCommitTicks # minimum dwell reached: renew, don't abandon
|
||
else:
|
||
m.replanReason = rrExpiry
|
||
else:
|
||
m.commitTicks = 0
|
||
m.replanReason = commitEnd
|
||
|
||
# j144: was the commitment we are about to replace still UNREACHED? A pick that
|
||
# replaces a target we had not yet got to is the owner's failure mode: the bot
|
||
# is still accelerating and the target flips under it. `picks == 0` means this
|
||
# is the first pick of the round, which is not a switch at all.
|
||
let midFlight = m.picks > 0 and not atTarget
|
||
|
||
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
|
||
# j144, no opposite-direction flip while still accelerating. Below the speed
|
||
# threshold the bot physically cannot complete a reversal before the bullet
|
||
# lands, so a mid-flight switch to the mirror side only destroys the dodge it
|
||
# already has. It is refused outright — and only for a MID-FLIGHT switch: if
|
||
# we are already standing on the committed tile (an arrival pick) the bot is
|
||
# free to go anywhere, and that is exactly the pick that must not be blocked.
|
||
if TfilNoRevSpeed > 0.0 and abs(ws.selfSpeed) < TfilNoRevSpeed and midFlight:
|
||
var offs: seq[float]
|
||
for t in candidates:
|
||
offs.add tileOffTravel(m, t.col, t.row, ws.selfX, ws.selfY, travelDeg)
|
||
let keep = norevPool(offs, TfilNoRevSpeed)
|
||
var narrowed: seq[ScoredTile]
|
||
for i in keep: narrowed.add candidates[i]
|
||
candidates = narrowed
|
||
var chosen = 0
|
||
if (TfilNoRev or TfilTurnBias > 0.0) and candidates.len >= 2:
|
||
# Soft preferences — down-weight, never filter, and only ever among tiles
|
||
# that already passed the hard heat filter above:
|
||
# arm C (TR_TFIL_NO_REV, off by default) — 3:1 forward vs rearward,
|
||
# a BINARY cut: it cannot tell 20 deg from 90, nor 91 from 179.
|
||
# j145 (TR_TFIL_TURN_BIAS, off by default) — a CONTINUOUS turn cost,
|
||
# `turnWeights` = 1 + bias * (1 - excess/180).
|
||
# The two multiply, so turning one on never silently drops the other.
|
||
var weights = if TfilTurnBias > 0.0:
|
||
block:
|
||
var turns: seq[float]
|
||
for t in candidates: turns.add t.turnDeg
|
||
turnWeights(turns, TfilTurnBias, TfilTurnRefDeg)
|
||
else:
|
||
var w1 = newSeq[int](candidates.len)
|
||
for i in 0..<w1.len: w1[i] = 1
|
||
w1
|
||
if TfilNoRev:
|
||
var dirs: seq[float]
|
||
for t in candidates:
|
||
let tx = m.marginX + (t.col.float + 0.5) * GridSize
|
||
let ty = m.marginY + (t.row.float + 0.5) * GridSize
|
||
dirs.add arctan2(ty - ws.selfY, tx - ws.selfX) * 180.0 / PI
|
||
let nw = noRevWeights(dirs, travelDeg)
|
||
for i in 0..<nw.len: weights[i] = weights[i] * nw[i]
|
||
var total = 0
|
||
var wMax = 0
|
||
for w in weights:
|
||
total += w
|
||
wMax = max(wMax, w)
|
||
if wMax <= 1:
|
||
# Fallback: no candidate is preferred (every tile is equally bad, or all
|
||
# are rearward) → uniform draw, so the pool can never empty and the pick
|
||
# degrades to exactly the shipped one.
|
||
chosen = rand(candidates.high)
|
||
else:
|
||
let r = rand(total - 1)
|
||
var acc = 0
|
||
chosen = weights.high
|
||
for i in 0..<weights.len:
|
||
acc += weights[i]
|
||
if r < acc:
|
||
chosen = i
|
||
break
|
||
else:
|
||
chosen = rand(candidates.high)
|
||
let ct = candidates[chosen]
|
||
pickedPromoted = m.lastPickPromoted
|
||
var minTurn = Inf
|
||
for t in candidates: minTurn = min(minTurn, t.turnDeg)
|
||
m.lastPickMinTurn = if minTurn == Inf: ct.turnDeg else: minTurn
|
||
m.lastPickSafe = candidates.len
|
||
m.commitTarget = (x: m.marginX + (ct.col.float + 0.5) * GridSize,
|
||
y: m.marginY + (ct.row.float + 0.5) * GridSize)
|
||
m.commitTicks = TfilCommitTicks
|
||
m.commitAge = 0
|
||
m.commitLava = m.lavaAt(ct.col, ct.row)
|
||
m.blockedTile.active = false # clear after successful pick
|
||
|
||
# log-only: reversal test against the travel direction
|
||
var rd = arctan2(m.commitTarget.y - ws.selfY, m.commitTarget.x - ws.selfX) *
|
||
180.0 / PI - travelDeg
|
||
while rd > 180.0: rd -= 360.0
|
||
while rd < -180.0: rd += 360.0
|
||
pickedThisTick = true
|
||
pickedRev = abs(rd) > 90.0
|
||
pickedTurn = abs(rd)
|
||
pickedMidFlight = midFlight
|
||
pickedInterval = m.callCount - m.lastPickCall
|
||
m.lastPickCall = m.callCount
|
||
inc m.picks
|
||
|
||
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)
|
||
|
||
# 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)
|
||
if ws.enemies.len > 0:
|
||
m.lastEnemyTileCol = clamp(int((ws.enemies[0].x - m.marginX) / GridSize), 0, m.cols - 1)
|
||
m.lastEnemyTileRow = clamp(int((ws.enemies[0].y - m.marginY) / GridSize), 0, m.rows - 1)
|
||
else:
|
||
m.lastEnemyTileCol = -1
|
||
m.lastEnemyTileRow = -1
|
||
m.callCount += 1
|
||
|
||
if TfilCommitLogPath.len > 0:
|
||
let reason = if pickedThisTick:
|
||
(if m.replanReason == rrNone: rrInit else: m.replanReason)
|
||
else: rrNone
|
||
tfilLogWrite("{\"tick\":" & $ws.tick & ",\"call\":" & $m.callCount &
|
||
",\"sp\":" & $ws.selfSpeed & ",\"ct\":" & $m.commitTicks &
|
||
",\"pick\":" & (if pickedThisTick: "1" else: "0") &
|
||
",\"turn\":" & $pickedTurn &
|
||
",\"promote\":" & (if pickedPromoted: "1" else: "0") &
|
||
",\"reason\":\"" & reasonName(reason) & "\",\"rev\":" &
|
||
(if pickedRev: "1" else: "0") & ",\"mid\":" &
|
||
(if pickedMidFlight: "1" else: "0") & ",\"interval\":" & $pickedInterval &
|
||
",\"picks\":" & $m.picks & "}")
|
||
if pickedThisTick: m.replanReason = rrNone
|
||
|
||
# ── 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)
|