@@ -0,0 +1,672 @@
## TFIL — The Floor Is Lava. Built incrementally.
import std / math
import std / random
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 = 10 .0 ## lava accumulation per bullet-overlapping tile
const BulletAura = 5 .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
const CorridorHeat = 20 .0
const WallHotness = 30 .0
const WallRadiance = 10 .0
const PillarHotness = 30 .0
const PillarRadiance = 10 .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
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 ]
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
proc initTFIL * ( ) : TFILModule = TFILModule ( debugGraphics : false )
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 =
for e in m . prevEnergy :
if e . id = = id : return e . energy
100 .0
proc prevEnergySet ( m : var TFILModule , 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 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 . 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
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 detectFires ( m : var TFILModule , 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 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 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 . 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 'u 8 , uint8 ( 255 .0 * ( 1 .0 - t ) ) , 0 'u 8 )
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
let chosen = rand ( candidates . high )
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
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 )
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 )