## phantom_meteor.nim — PhantomMeteor gravity engine as a MovementModule. ## Wraps gravity.nim (GravityEngine) to satisfy the MovementModule concept. ## Fire detection, phantom bullets, waves, and danger histogram are all internal. ## All angles in radians internally; interface outputs degrees for bot API. import std/[math, tables] import gun_harness/gun_interface import movement_harness/movement_interface import movement_harness/gunheat_tracker # Inline the gravity engine types and logic here to keep the module self-contained. # We re-export nothing from gravity.nim — it's not on the common_libs path. # ponytail: copy instead of import; if gravity.nim moves to common_libs, collapse. # ── Vec2 (local, unexported) ────────────────────────────────────────────────── type Vec2 = object x, y: float64 proc vec2(x, y: float64): Vec2 {.inline.} = Vec2(x: x, y: y) proc `+`(a, b: Vec2): Vec2 {.inline.} = vec2(a.x+b.x, a.y+b.y) proc `-`(a, b: Vec2): Vec2 {.inline.} = vec2(a.x-b.x, a.y-b.y) proc `*`(a: Vec2, s: float64): Vec2 {.inline.} = vec2(a.x*s, a.y*s) proc magnitude(v: Vec2): float64 {.inline.} = sqrt(v.x*v.x + v.y*v.y) proc normalize(v: Vec2): Vec2 = let m = v.magnitude if m < 1e-9: vec2(0.0, 0.0) else: vec2(v.x/m, v.y/m) proc dist(a, b: Vec2): float64 {.inline.} = (a-b).magnitude # ── Gravity engine types ────────────────────────────────────────────────────── const NumBins = 41 type DangerHistogram = object bins: array[NumBins, float64] PhantomBullet = object pos, vel: Vec2 weight: float64 alive: bool ticks: int Wave = object origin: Vec2 heading: float64 speed: float64 radius: float64 startDist: float64 GravityEngine = object histogram: DangerHistogram phantoms: seq[PhantomBullet] waves: seq[Wave] prevEnemyEnergy: Table[int, float64] # keyed by enemy id; id=-1 for legacy single-enemy # ── Gravity engine internals ────────────────────────────────────────────────── const CLR_CYAN = "\e[36m" CLR_RST = "\e[0m" const KBullet = 1500.0 KWall = 4000.0 KEnemy = 300.0 PreferredDist = 400.0 NumPhantoms = 25 MinDist = 20.0 WallMinDist = 40.0 VeryClose = 40.0 proc initEngine(): GravityEngine = var h: DangerHistogram for i in 0..= 0.1 and drop <= 3.0: (true, drop) else: (false, 0.0) proc spawnPhantoms(eng: var GravityEngine, enemyPos, botPos: Vec2, bspeed: float64) = let base = arctan2(botPos.y - enemyPos.y, botPos.x - enemyPos.x) let maxA = mea(bspeed) for i in 0..= 50: eng.phantoms[i].alive = false if eng.phantoms.len > 200: var live: seq[PhantomBullet] for p in eng.phantoms: if p.alive: live.add p eng.phantoms = live proc spawnWave(eng: var GravityEngine, enemyPos, botPos: Vec2, bspeed: float64) = eng.waves.add Wave( origin: enemyPos, heading: arctan2(botPos.y - enemyPos.y, botPos.x - enemyPos.x), speed: bspeed, radius: 0.0, startDist: dist(enemyPos, botPos)) proc tickWaves(eng: var GravityEngine, botPos: Vec2) = var i = 0 while i < eng.waves.len: eng.waves[i].radius += eng.waves[i].speed if eng.waves[i].radius >= eng.waves[i].startDist: let toBot = arctan2(botPos.y - eng.waves[i].origin.y, botPos.x - eng.waves[i].origin.x) var off = toBot - eng.waves[i].heading while off > PI: off -= 2.0*PI while off < -PI: off += 2.0*PI let maxA = mea(eng.waves[i].speed) if maxA >= 1e-9: let gf = (off / maxA).clamp(-1.0, 1.0) eng.histogram.bins[gfToBin(gf)] += 1.0 eng.waves.del(i) else: inc i proc computeForces(eng: GravityEngine, botPos: Vec2, enemies: openArray[Vec2], arenaW, arenaH: float64): Vec2 = var total = vec2(0.0, 0.0) # Priority 1: nearby phantoms var hasClose = false for ph in eng.phantoms: if ph.alive: let d = dist(ph.pos, botPos) if d < 250.0: hasClose = true total = total + normalize(botPos - ph.pos) * (KBullet * (1.0 - d/250.0)) # Priority 2: wall escape — scales as inverse-square so it dominates bullets near walls var ex = 0.0; var ey = 0.0 let dLeft = botPos.x let dRight = arenaW - botPos.x let dBot = botPos.y let dTop = arenaH - botPos.y if dLeft < WallMinDist: ex += KWall / (dLeft * dLeft) if dRight < WallMinDist: ex -= KWall / (dRight * dRight) if dBot < WallMinDist: ey += KWall / (dBot * dBot) if dTop < WallMinDist: ey -= KWall / (dTop * dTop) total = total + vec2(ex, ey) # Priority 3: repulsion from ALL enemies; attraction only toward nearest. # ponytail: linear ramp per enemy, attraction only toward nearest to avoid being pulled into a crowd if not hasClose and enemies.len > 0: const rampWidth = 150.0 # Find nearest enemy for possible attraction var nearestDist = Inf var nearestIdx = 0 for i, ep in enemies: let d = dist(ep, botPos) if d < nearestDist: nearestDist = d nearestIdx = i for i, ep in enemies: let de = dist(ep, botPos) if de <= 100.0: continue let distErr = de - PreferredDist let t = clamp(distErr / rampWidth, -1.0, 1.0) if t < 0.0: # Too close → repel from this enemy total = total + normalize(botPos - ep) * (KEnemy * abs(t)) elif i == nearestIdx: # Too far from nearest only → mild attraction total = total + normalize(ep - botPos) * (KEnemy * t) total # ── MovementModule wrapper ──────────────────────────────────────────────────── type PhantomMeteorModule* = object engine: GravityEngine gunheat: GunheatTracker debugGraphics*: bool proc initPhantomMeteor*(): PhantomMeteorModule = PhantomMeteorModule(engine: initEngine(), gunheat: initGunheatTracker(), debugGraphics: false) proc resetRound*(m: var PhantomMeteorModule) = ## Clear per-round transients (phantoms, waves, energy baseline), keep histogram. m.engine.phantoms = @[] m.engine.waves = @[] m.engine.prevEnemyEnergy = {-1: 100.0}.toTable m.gunheat.resetRound() proc computeMove*(m: var PhantomMeteorModule, ws: WorldState): MoveCommand = let botPos = vec2(ws.selfX, ws.selfY) let enemyPos = vec2(ws.enemyX, ws.enemyY) # Build enemies Vec2 seq (falls back to single target when ws.enemies is empty) var enemyVecs: seq[Vec2] if ws.enemies.len > 0: for ei in ws.enemies: enemyVecs.add vec2(ei.x, ei.y) else: enemyVecs.add enemyPos # Advance simulation m.engine.tickPhantoms() m.engine.tickWaves(botPos) # Fire detection via gunheat tracker for the primary target (1-2 ticks earlier than energy drop). var spawnedThisTick = false for ev in m.gunheat.tick(ws): let bspeed = 20.0 - 3.0 * ev.bulletPower let evPos = vec2(ev.fireX, ev.fireY) case ev.kind of wePredicted: m.engine.spawnPhantoms(evPos, botPos, bspeed) m.engine.spawnWave(evPos, botPos, bspeed) spawnedThisTick = true of weConfirmed: if not spawnedThisTick: m.engine.spawnPhantoms(evPos, botPos, bspeed) m.engine.spawnWave(evPos, botPos, bspeed) spawnedThisTick = true # Energy-drop fallback for primary target; also sync prevEnergy so non-target check is clean. if not spawnedThisTick: let (fired, power) = m.engine.detectFire(-1, ws.enemyEnergy) if fired: let bspeed = 20.0 - 3.0 * power m.engine.spawnPhantoms(enemyPos, botPos, bspeed) m.engine.spawnWave(enemyPos, botPos, bspeed) else: discard m.engine.detectFire(-1, ws.enemyEnergy) # Energy-drop detection for non-target enemies (raw energy comparison; no gunheat needed) for ei in ws.enemies: if ei.id == -1: continue # skip sentinel let (fired, power) = m.engine.detectFire(ei.id, ei.energy) if fired: let bspeed = 20.0 - 3.0 * power let epos = vec2(ei.x, ei.y) m.engine.spawnPhantoms(epos, botPos, bspeed) m.engine.spawnWave(epos, botPos, bspeed) # Compute force vector let force = m.engine.computeForces(botPos, enemyVecs, ws.arenaWidth, ws.arenaHeight) if force.magnitude < 1e-9: return (speed: 0.0, turnRate: 0.0) # Corner detection: near 2 walls simultaneously → hard escape override. # ponytail: WallMinDist threshold reused; increase if bot still traps at higher speeds. let nearL = botPos.x < WallMinDist let nearR = ws.arenaWidth - botPos.x < WallMinDist let nearB = botPos.y < WallMinDist let nearT = ws.arenaHeight - botPos.y < WallMinDist let wallCount = (if nearL: 1 else: 0) + (if nearR: 1 else: 0) + (if nearB: 1 else: 0) + (if nearT: 1 else: 0) let cornered = wallCount >= 2 let desiredDeg = if cornered: # Escape direction = inward wall normals averaged; ignore bullet/enemy pulls. # Enemy perp is irrelevant here — any direction away from corner is correct. var escX = 0.0; var escY = 0.0 if nearL: escX += 1.0 if nearR: escX -= 1.0 if nearB: escY += 1.0 if nearT: escY -= 1.0 let escDir = normalize(vec2(escX, escY)) radToDeg(arctan2(escDir.y, escDir.x)) else: # Normal open-field: snap to perp that best aligns with force vector. var cx = 0.0; var cy = 0.0; var wsum = 0.0 for ep in enemyVecs: let d = max(dist(ep, botPos), 1.0) let w = 1.0 / d cx += ep.x * w; cy += ep.y * w; wsum += w let centroid = if wsum > 1e-9: vec2(cx/wsum, cy/wsum) else: enemyPos let centroidBearingRad = arctan2(centroid.y - botPos.y, centroid.x - botPos.x) let perpCCW = vec2(-sin(centroidBearingRad), cos(centroidBearingRad)) let perpCW = vec2( sin(centroidBearingRad), -cos(centroidBearingRad)) let fn = force.normalize if fn.x * perpCCW.x + fn.y * perpCCW.y >= fn.x * perpCW.x + fn.y * perpCW.y: radToDeg(arctan2(perpCCW.y, perpCCW.x)) else: radToDeg(arctan2(perpCW.y, perpCW.x)) # Delta from current heading var delta = desiredDeg - ws.selfHeading while delta > 180.0: delta -= 360.0 while delta < -180.0: delta += 360.0 # Dot-product trick: if |delta| > 90 → reverse, less turning let goForward = abs(delta) <= 90.0 if not goForward: delta = if delta >= 0.0: delta - 180.0 else: delta + 180.0 (speed: if goForward: 8.0 else: -8.0, turnRate: delta.clamp(-10.0, 10.0))