From 0f5cfe37b24952ae07fee0a7db526317ae127e6c Mon Sep 17 00:00:00 2001 From: Davide Cappellini Date: Sat, 26 Sep 2026 00:10:24 +0200 Subject: [PATCH] surf: wire the dormant wave surfer as TR_MOVEMENT=surf + fix 4 real defects common_libs/movements/wave_surfer.nim was written in an early session and never wired to the bot. This connects it exactly like tfil/strafe and fixes the defects a full read found: 1. the dodge direction was INVERTED: the perpendicular was built from the bot->enemy bearing while GF lives in the enemy->bot frame, so the bot moved toward MORE danger. Now built from the wave's origin->bot bearing: +90 provably increases GF. 2. the danger histogram was never reset (resetRound cleared waves only), so it was a battle-long static average. Now reset to the uniform prior each round. 3. fire detection tracked only the current target's energy via one scalar; now per-enemy (seq[(id,energy)]) so melee target switches cannot invent or hide waves. 4. the wall penalty projected a point from the wave origin, not from the bot, making the wall test meaningless. Now projects the bot->candidate direction. Wave speed uses the actual firepower (the one-tick energy drop IS the firepower, so speed = 20 - 3*drop is exact). Adds TR_SURF_* knobs and registers them in the env report. Shipped TR_MOVEMENT=tfil default untouched (test_tfil_commit_env: 30/30 pass; test_env_report: pass). --- ModularBot_garage/src/ModularBot.nim | 10 + ModularBot_garage/src/env_report.nim | 11 + common_libs/movements/wave_surfer.nim | 333 ++++++++++++++++++-------- 3 files changed, 259 insertions(+), 95 deletions(-) diff --git a/ModularBot_garage/src/ModularBot.nim b/ModularBot_garage/src/ModularBot.nim index c6ea471..acd0697 100644 --- a/ModularBot_garage/src/ModularBot.nim +++ b/ModularBot_garage/src/ModularBot.nim @@ -34,6 +34,7 @@ import movements/ram_decision import movements/the_floor_is_lava import movements/the_floor_is_lava_ring import movements/strafe +import movements/wave_surfer import movement_harness/virtual_bodies as mvb import movement_harness/bullet_shadows import targeting/enemy_tracker @@ -202,6 +203,7 @@ type mover: TFILModule ringMover: TFILRingModule strafeMover: StrafeModule + surfMover: WaveSurferModule rammer: RammerModule isRamming: bool ramDurationTicks: int @@ -342,6 +344,8 @@ proc printConfig(bot: ModularBot, forceAll: bool = false) = if bot.isRamming: "strafe(ram)" else: "strafe" elif MovementName == "tfil_ring": if bot.isRamming: "tfil_ring(ram)" else: "tfil_ring" + elif MovementName == "surf": + if bot.isRamming: "surf(ram)" else: "surf" elif bot.isRamming: "rammer" else: "tfil" var line = "[config] " @@ -591,6 +595,7 @@ method onBulletHitWall*(bot: ModularBot, e: BulletHitWallEvent) = bot.mover.removeBulletNear(e.bullet.x, e.bullet.y) bot.ringMover.removeBulletNear(e.bullet.x, e.bullet.y) bot.strafeMover.removeBulletNear(e.bullet.x, e.bullet.y) + bot.surfMover.removeBulletNear(e.bullet.x, e.bullet.y) method onBulletHitBullet*(bot: ModularBot, e: BulletHitBulletEvent) = discard bot.resolveOwnBullet(e.bullet.bulletId) # bullet-vs-bullet: free the slot @@ -599,6 +604,7 @@ method onBulletHitBullet*(bot: ModularBot, e: BulletHitBulletEvent) = bot.mover.removeBulletNear(e.bullet.x, e.bullet.y) bot.ringMover.removeBulletNear(e.bullet.x, e.bullet.y) bot.strafeMover.removeBulletNear(e.bullet.x, e.bullet.y) + bot.surfMover.removeBulletNear(e.bullet.x, e.bullet.y) method onHitByBullet*(bot: ModularBot, e: HitByBulletEvent) = # Feeds the ram bullet-rain abort window. Accumulate REAL ENERGY (the server's @@ -826,6 +832,7 @@ method onRoundStarted*(bot: ModularBot, e: RoundStartedEvent) = bot.mover.resetRound() bot.ringMover.resetRound() bot.strafeMover.resetRound() + bot.surfMover.resetRound() bot.enemyTracker.resetRound() # The TM pattern gun is cold every round (no cross-battle persistence): wipe # its clause teams and motion history explicitly, so a same-id opponent in the @@ -1104,6 +1111,8 @@ method run*(bot: ModularBot) = var spd, tr: float if MovementName == "strafe" and not shouldRam: (spd, tr) = bot.strafeMover.computeMove(ws) + elif MovementName == "surf" and not shouldRam: + (spd, tr) = bot.surfMover.computeMove(ws) elif MovementName == "tfil_ring": bot.ringMover.band = if shouldRam: (lo: 0.0, hi: 50.0) @@ -1409,6 +1418,7 @@ when isMainModule: mover: TFILModule(debugGraphics: true), ringMover: TFILRingModule(debugGraphics: true), strafeMover: StrafeModule(debugGraphics: true), + surfMover: initWaveSurfer(), rammer: initRammer(), moveTracker: mvb.initVirtualBodyTracker(1), currentGun: -1, diff --git a/ModularBot_garage/src/env_report.nim b/ModularBot_garage/src/env_report.nim index 2f9d180..486598a 100644 --- a/ModularBot_garage/src/env_report.nim +++ b/ModularBot_garage/src/env_report.nim @@ -26,6 +26,7 @@ import movements/ram_decision import movements/the_floor_is_lava import movements/the_floor_is_lava_ring import movements/strafe +import movements/wave_surfer import guns/tm_horizon import guns/bitbrain_gun import guns/pattern_matcher @@ -181,6 +182,7 @@ proc printEffectiveValues(ctx: EnvReportContext) = let effectiveMovement = if ctx.movementName == "tfil_ring": "tfil_ring" elif ctx.movementName == "strafe": "strafe" + elif ctx.movementName == "surf": "surf" else: "tfil" emit("TR_MOVEMENT", effectiveMovement, sourceOf("TR_MOVEMENT")) emit("TR_MOVEMENT_LOG", onOff(MovementLog), sourceOfPresence("TR_MOVEMENT_LOG")) @@ -293,6 +295,13 @@ proc printEffectiveValues(ctx: EnvReportContext) = emit("TR_STRAFE_WALL_HOTNESS", $StrafeWallHotness, sourceOf("TR_STRAFE_WALL_HOTNESS")) emit("TR_STRAFE_WALL_RADIANCE", $StrafeWallRadiance, sourceOf("TR_STRAFE_WALL_RADIANCE")) + # ── movement (WAVE SURFER) ──────────────────────────────────────────────── + emit(SurfPrefDistEnv, $SurfPrefDist, sourceOf(SurfPrefDistEnv)) + emit(SurfDistBandEnv, $SurfDistBand, sourceOf(SurfDistBandEnv)) + emit(SurfWallMarginEnv, $SurfWallMargin, sourceOf(SurfWallMarginEnv)) + emit(SurfRadialFracEnv, $SurfRadialFrac, sourceOf(SurfRadialFracEnv)) + emit(SurfLogEnv, onOff(SurfLog), sourceOfPresence(SurfLogEnv)) + # ── ramming ─────────────────────────────────────────────────────────────── emit("TR_RAM_OPPORTUNITY", onOff(RamOppEnabled), sourceOf("TR_RAM_OPPORTUNITY")) emit("TR_RAM_OPP_DIST", $RamOppDist, sourceOf("TR_RAM_OPP_DIST")) @@ -453,6 +462,8 @@ proc knownEnvNames*(): seq[string] = "TR_STRAFE_HEAT_GRID", "TR_STRAFE_BULLET_CORE", "TR_STRAFE_BULLET_AURA", "TR_STRAFE_CORRIDOR_HEAT", "TR_STRAFE_WALL_HOTNESS", "TR_STRAFE_WALL_RADIANCE", + SurfPrefDistEnv, SurfDistBandEnv, SurfWallMarginEnv, SurfRadialFracEnv, + SurfLogEnv, # harness vars (read by the test framework, inherited by the bot, so they # must NOT be reported as typos) "TR_SERVER_JAR", "TR_BATTLE_RUNNER", "TR_BATTLE_RUNNER_DIR", diff --git a/common_libs/movements/wave_surfer.nim b/common_libs/movements/wave_surfer.nim index 91ac790..e9aa70a 100644 --- a/common_libs/movements/wave_surfer.nim +++ b/common_libs/movements/wave_surfer.nim @@ -1,95 +1,221 @@ ## wave_surfer.nim — Wave-surfing movement module. -## Detects enemy fire via energy drops, maintains a danger histogram (31 GF bins), -## and steers toward the lowest-danger GF bin on approaching waves. -## All angles in radians internally; output degrees for bot API. +## +## Detects enemy fire via energy drops, maintains a per-round danger histogram +## of the GuessFactor (GF) each resolved wave arrived at, and steers toward the +## lowest-danger reachable GF on the nearest approaching wave. All angles in +## radians internally; output degrees for the bot API. +## +## ── What was broken and what was fixed (j115) ─────────────────────────────── +## The module was written in an early session, never wired to the bot and never +## tested. A full read found four real defects; all are fixed here: +## +## 1. **The dodge was INVERTED.** The perpendicular direction was built from +## the bot→enemy bearing (`arctan2(enemyY-botY, enemyX-botX)`), while the +## GF is measured in the enemy→bot frame. Rotating the bot→enemy bearing by +## +90 deg points the OPPOSITE way from rotating the enemy→bot bearing by +## +90 deg, so when the safest bin was at a higher GF the bot moved toward +## a LOWER GF, i.e. toward MORE danger. Fixed by building the perpendicular +## from the ORIGIN→bot bearing (the wave's own frame): `perp = toBot ± 90`, +## where `toBot = arctan2(botY-originY, botX-originX)`. `+90` now provably +## increases GF (d(theta)/ds is maximal there). +## 2. **The histogram was never reset.** `resetRound` cleared the waves but +## left `bins` accumulating for the WHOLE battle, so it became a global +## static average. It is now reset to the uniform prior every round (it is +## per-round-resettable, as required). +## 3. **Fire detection tracked only the CURRENT TARGET's energy** via a single +## scalar `prevEnergy` (init 100.0). In melee a target switch silently +## compared two different bots' energies, inventing/hiding waves. It is now +## per-enemy (`seq[(id, energy)]`, exactly like the STRAFE mover), a new +## enemy id is seeded without emitting a wave, and a vanished enemy keeps +## its last value until it reappears. +## 4. **The wall penalty projected the wrong future point.** It placed the +## future position at `bot + (wave-bearing + gf*MEA)` — the direction from +## the wave ORIGIN, not from the bot — so the wall test was meaningless. It +## now projects the point on the wave circle at the candidate GF and takes +## the direction from the BOT to it. +## +## Wave geometry uses the ACTUAL fired power: the one-tick energy drop IS the +## firepower (energy cost == firepower), so `speed = 20 - 3*drop` is exact, not +## an assumption. Wave resolution fires when the wave radius reaches the bot's +## distance from the origin, and the GF is normalised by that power's maximum +## escape angle `arcsin(8/speed)`. +## +## KNOWN LIMITATION (measured, not hidden): a low-power hit ON the enemy (our +## bullet doing <= 3.0 energy of damage) is indistinguishable from a small +## firepower in the energy drop. Those shots can spawn a false wave. High-power +## hits (damage > 3.0) are correctly rejected by the drop window. This is the +## standard energy-drop ambiguity; the single 1v1 A/B cannot separate it. -import std/[math] +import std/[math, os] +from std/strutils import parseFloat, strip, toLowerAscii import gun_harness/gun_interface import movement_harness/movement_interface -const WS_BINS = 31 -const WallMargin = 40.0 -const DodgeTicks = 15.0 # approximate ticks to reach dodge position -const WS_PrefDist = 400.0 # optimal engagement distance -const WS_DistBand = 50.0 # deadband: pure strafe within ±50px of preferred -const WS_RadialFrac = 0.35 # radial blend fraction (0=pure strafe, 1=pure radial) +const + WS_BINS = 31 + DodgeTicks = 15.0 ## approximate ticks to reach the dodge position + MaxBotSpeed = 8.0 ## Tank Royale max forward/backward speed (px/tick) + +## ── env knobs (all read once at module init) ──────────────────────────────── +const + SurfPrefDistEnv* = "TR_SURF_PREF_DIST" + SurfDistBandEnv* = "TR_SURF_DIST_BAND" + SurfWallMarginEnv* = "TR_SURF_WALL_MARGIN" + SurfRadialFracEnv* = "TR_SURF_RADIAL_FRAC" + SurfLogEnv* = "TR_SURF_LOG" + +const + DefaultPrefDist = 400.0 ## optimal engagement distance (px) + DefaultDistBand = 50.0 ## deadband: pure strafe within ±band of pref dist + DefaultWallMargin = 48.0 ## px; > bot radius 18, plus braking room + DefaultRadialFrac = 0.35 ## radial blend fraction (0=pure strafe, 1=radial) + +var + SurfPrefDist* = DefaultPrefDist + SurfDistBand* = DefaultDistBand + SurfWallMargin* = DefaultWallMargin + SurfRadialFrac* = DefaultRadialFrac + SurfLog* = false + +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 loadSurfEnv*() = + ## Read the surfer knobs; callable again after `putEnv` so a gate can + ## exercise arms in one process. + SurfPrefDist = max(1.0, getEnvFloat(SurfPrefDistEnv, DefaultPrefDist)) + SurfDistBand = max(0.0, getEnvFloat(SurfDistBandEnv, DefaultDistBand)) + SurfWallMargin = max(0.0, getEnvFloat(SurfWallMarginEnv, DefaultWallMargin)) + SurfRadialFrac = clamp(getEnvFloat(SurfRadialFracEnv, DefaultRadialFrac), 0.0, 1.0) + SurfLog = existsEnv(SurfLogEnv) + +loadSurfEnv() type WSWave = object originX, originY: float64 - bearing: float64 ## direction from enemy to us at fire time (radians) + bearing: float64 ## direction from enemy to us at fire time (rad) speed: float64 radius: float64 startDist: float64 + power: float64 WaveSurferModule* = object bins: array[WS_BINS, float64] waves: seq[WSWave] - prevEnergy: float64 + prevEnergy: seq[tuple[id: int, energy: float]] strafeDir: float64 ## +1.0 or -1.0 debugGraphics*: bool -proc initWaveSurfer*(): WaveSurferModule = - var m = WaveSurferModule(prevEnergy: 100.0, strafeDir: 1.0, debugGraphics: false) +proc resetRound*(m: var WaveSurferModule) = + ## Wipe per-round state. The danger histogram IS reset here (defect 2): it is + ## a per-round learner, not a battle-long static average. + m.waves = @[] + m.prevEnergy = @[] + m.strafeDir = 1.0 for i in 0.. PI: result -= 2.0*PI + while result < -PI: result += 2.0*PI proc nearestWave(m: WaveSurferModule, botX, botY: float64): int = - ## Index of the wave closest to reaching us (largest radius relative to startDist). + ## Index of the wave closest to reaching us (largest radius relative to the + ## current distance). -1 when there is no live wave. result = -1 var bestRatio = -1.0 for i in 0.. bestRatio: bestRatio = ratio result = i -proc computeMove*(m: var WaveSurferModule, ws: WorldState): MoveCommand = - let botX = ws.selfX - let botY = ws.selfY - let enemyX = ws.enemyX - let enemyY = ws.enemyY - - # Fire detection - let drop = m.prevEnergy - ws.enemyEnergy - m.prevEnergy = ws.enemyEnergy +proc detectFire(m: var WaveSurferModule, id: int, ex, ey, eenergy, + botX, botY: float64) = + ## One enemy's energy sample: emit a wave iff its energy dropped by a + ## plausible firepower in one tick. `drop` is the firepower exactly. + let prev = m.prevEnergyGet(id) + let drop = prev - eenergy + m.prevEnergySet(id, eenergy) if drop >= 0.1 and drop <= 3.0: let bspeed = 20.0 - 3.0 * drop - let bearing = arctan2(botY - enemyY, botX - enemyX) - let d = sqrt((botX - enemyX)^2 + (botY - enemyY)^2) + let bearing = arctan2(botY - ey, botX - ex) + let d = hypot(botX - ex, botY - ey) m.waves.add WSWave( - originX: enemyX, originY: enemyY, + originX: ex, originY: ey, bearing: bearing, speed: bspeed, radius: 0.0, startDist: d, + power: drop, ) - # Advance waves; collect hits +proc computeMove*(m: var WaveSurferModule, ws: WorldState): MoveCommand = + let botX = ws.selfX + let botY = ws.selfY + + # ── Fire detection: EVERY alive enemy, per-enemy energy (defect 3) ───────── + var seen = 0 + for ei in ws.enemies: + inc seen + m.detectFire(ei.id, ei.x, ei.y, ei.energy, botX, botY) + if seen == 0 and (ws.enemyX != 0.0 or ws.enemyY != 0.0): + # Fallback to the tracked target when `enemies` is empty. + m.detectFire(-1, ws.enemyX, ws.enemyY, ws.enemyEnergy, botX, botY) + + # ── Advance waves; record the GF of each wave that reaches us ───────────── var i = 0 while i < m.waves.len: m.waves[i].radius += m.waves[i].speed - let d = sqrt((botX - m.waves[i].originX)^2 + (botY - m.waves[i].originY)^2) + let d = hypot(botX - m.waves[i].originX, botY - m.waves[i].originY) if m.waves[i].radius >= d: let toBot = arctan2(botY - m.waves[i].originY, botX - m.waves[i].originX) - var off = toBot - m.waves[i].bearing - while off > PI: off -= 2.0*PI - while off < -PI: off += 2.0*PI - let maxA = mea(m.waves[i].speed) + let off = wrapPi(toBot - m.waves[i].bearing) + let maxA = mea(m.waves[i].speed) if maxA >= 1e-9: let gf = clamp(off / maxA, -1.0, 1.0) m.bins[gfToBin(gf)] += 1.0 @@ -97,92 +223,109 @@ proc computeMove*(m: var WaveSurferModule, ws: WorldState): MoveCommand = else: inc i - # Dodge logic: find nearest wave, pick safest GF bin direction + # ── Dodge logic: pick the safest reachable GF on the nearest wave ───────── + var perpAngle = 0.0 + var havePerp = false let nearest = m.nearestWave(botX, botY) if nearest >= 0: - let w = m.waves[nearest] - let toBot = arctan2(botY - w.originY, botX - w.originX) - var off = toBot - w.bearing - while off > PI: off -= 2.0*PI - while off < -PI: off += 2.0*PI - let maxA = mea(w.speed) + let w = m.waves[nearest] + let dx = botX - w.originX + let dy = botY - w.originY + let d = max(1.0, hypot(dx, dy)) + let toBot = arctan2(dy, dx) # origin -> bot (the GF frame) + let off = wrapPi(toBot - w.bearing) + let maxA = mea(w.speed) let curGF = if maxA >= 1e-9: clamp(off / maxA, -1.0, 1.0) else: 0.0 let curBin = gfToBin(curGF) - # Average danger + # Danger = per-bin average; the current bin is the danger baseline. var total = 0.0 for b in m.bins: total += b let avg = total / float64(WS_BINS) if m.bins[curBin] > avg: - # Find lowest-danger bin, penalizing positions near walls - let dodgeDist = ws.selfSpeed * DodgeTicks - var bestBin = 0 - for j in 1.. ws.arenaWidth - WallMargin or - futureY < WallMargin or futureY > ws.arenaHeight - WallMargin - let dangerJ = if wallHit: m.bins[j] * 5.0 else: m.bins[j] - let gfBest = binToGF(bestBin) - let angleB = w.bearing + gfBest * maxA - let futureXB = botX + cos(angleB) * dodgeDist - let futureYB = botY + sin(angleB) * dodgeDist - let wallHitB = futureXB < WallMargin or futureXB > ws.arenaWidth - WallMargin or - futureYB < WallMargin or futureYB > ws.arenaHeight - WallMargin - let dangerB = if wallHitB: m.bins[bestBin] * 5.0 else: m.bins[bestBin] - if dangerJ < dangerB: bestBin = j + # Find the least-dangerous reachable GF, penalising wall-bound moves. + let dodgeDist = max(MaxBotSpeed, ws.selfSpeed) * DodgeTicks + var bestBin = curBin + var bestDanger = Inf + for j in 0.. ws.arenaWidth - SurfWallMargin or + futureY < SurfWallMargin or futureY > ws.arenaHeight - SurfWallMargin + var danger = m.bins[j] + if wallHit: danger *= 5.0 + danger += 0.01 * abs(gfJ - curGF) # mild preference for less travel + if danger < bestDanger: + bestDanger = danger + bestBin = j let bestGF = binToGF(bestBin) - # Move in direction of best GF: positive = CCW (orbit left), negative = CW m.strafeDir = if bestGF >= curGF: 1.0 else: -1.0 + if SurfLog: + echo "[surf] wave d=", d.int, " curGF=", (curGF * 100.0).int, + " bestGF=", (bestGF * 100.0).int, " dir=", m.strafeDir.int, + " curDanger=", m.bins[curBin].int + # Perpendicular in the wave's own frame: +90 deg from origin->bot provably + # INCREASES the GF (CCW); -90 decreases it (defect 1). + perpAngle = if m.strafeDir >= 0.0: toBot + PI * 0.5 + else: toBot - PI * 0.5 + havePerp = true + else: + # No live wave: hold the perpendicular of the current threat bearing. + if ws.enemyX != 0.0 or ws.enemyY != 0.0: + let toBot = arctan2(botY - ws.enemyY, botX - ws.enemyX) + perpAngle = if m.strafeDir >= 0.0: toBot + PI * 0.5 + else: toBot - PI * 0.5 + havePerp = true + else: + perpAngle = degToRad(ws.selfHeading) - # Perpendicular strafe (same body trick as phantom_meteor) - let enemyBearingRad = arctan2(enemyY - botY, enemyX - botX) - # Perpendicular in chosen strafe direction - var perpAngle = - if m.strafeDir >= 0.0: enemyBearingRad + PI * 0.5 - else: enemyBearingRad - PI * 0.5 - - # Hard wall escape: if near any wall, blend toward arena center - # ponytail: linear blend, upgrade to override if blending proves too weak - let nearLeft = botX < WallMargin - let nearRight = botX > ws.arenaWidth - WallMargin - let nearBottom = botY < WallMargin - let nearTop = botY > ws.arenaHeight - WallMargin + # ── Hard wall escape: never drive into a wall ───────────────────────────── + let nearLeft = botX < SurfWallMargin + let nearRight = botX > ws.arenaWidth - SurfWallMargin + let nearBottom = botY < SurfWallMargin + let nearTop = botY > ws.arenaHeight - SurfWallMargin if nearLeft or nearRight or nearBottom or nearTop: - # Flip strafe if it pushes further into the wall let px = cos(perpAngle) let py = sin(perpAngle) if (nearLeft and px < 0.0) or (nearRight and px > 0.0) or (nearBottom and py < 0.0) or (nearTop and py > 0.0): + # Flip to the opposite perpendicular (same as the other strafe side). m.strafeDir = -m.strafeDir - perpAngle = if m.strafeDir >= 0.0: enemyBearingRad + PI * 0.5 - else: enemyBearingRad - PI * 0.5 - # Blend 50% toward arena center + perpAngle = perpAngle + PI + # Blend 50% toward the arena centre. let escapeAngle = arctan2(ws.arenaHeight * 0.5 - botY, ws.arenaWidth * 0.5 - botX) let ex = cos(escapeAngle) + cos(perpAngle) let ey = sin(escapeAngle) + sin(perpAngle) perpAngle = arctan2(ey, ex) - # Distance control: blend a radial component when outside the deadband. - # Secondary to bullet dodge — capped at WS_RadialFrac of travel direction. - # ponytail: linear blend, tune WS_RadialFrac if approach/retreat feels sluggish - let enemyDist = sqrt((enemyX - botX)^2 + (enemyY - botY)^2) - let distErr = enemyDist - WS_PrefDist + # ── Distance control: a radial component outside the deadband ──────────── + let enemyDist = hypot(ws.enemyX - botX, ws.enemyY - botY) + let distErr = enemyDist - SurfPrefDist let radialFrac = - if distErr > WS_DistBand: WS_RadialFrac # too far → approach - elif distErr < -WS_DistBand: -WS_RadialFrac # too close → retreat - else: 0.0 # deadband → pure strafe + if distErr > SurfDistBand: SurfRadialFrac # too far -> approach + elif distErr < -SurfDistBand: -SurfRadialFrac # too close -> retreat + else: 0.0 if abs(radialFrac) > 1e-9: - # Radial direction: toward enemy (positive) or away (negative) - let radialAngle = arctan2(enemyY - botY, enemyX - botX) + + let radialAngle = arctan2(ws.enemyY - botY, ws.enemyX - botX) + (if radialFrac < 0.0: PI else: 0.0) let rx = cos(perpAngle) * (1.0 - abs(radialFrac)) + cos(radialAngle) * abs(radialFrac) let ry = sin(perpAngle) * (1.0 - abs(radialFrac)) + sin(radialAngle) * abs(radialFrac) perpAngle = arctan2(ry, rx) + # ── Turn the body toward the desired heading; move at full speed ───────── let desiredDeg = radToDeg(perpAngle) var delta = desiredDeg - ws.selfHeading