Files
SirRoboGarage/common_libs/movements/phantom_meteor.nim
T

317 lines
12 KiB
Nim

## 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..<NumBins: h.bins[i] = 1.0
GravityEngine(histogram: h, phantoms: @[], waves: @[], prevEnemyEnergy: {-1: 100.0}.toTable)
proc gfToBin(gf: float64): int =
int(((gf.clamp(-1.0,1.0) + 1.0) / 2.0 * float64(NumBins-1)).round).clamp(0, NumBins-1)
proc mea(speed: float64): float64 = arcsin(min(8.0/speed, 1.0))
proc detectFire(eng: var GravityEngine, id: int, energy: float64): tuple[fired: bool; power: float64] =
let prev = eng.prevEnemyEnergy.getOrDefault(id, 100.0)
let drop = prev - energy
eng.prevEnemyEnergy[id] = energy
if drop >= 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..<NumPhantoms:
let gf = if NumPhantoms == 1: 0.0 else: -1.0 + float64(i)/float64(NumPhantoms-1)*2.0
let angle = base + gf * maxA
let weight = eng.histogram.bins[gfToBin(gf)]
eng.phantoms.add PhantomBullet(
pos: enemyPos, vel: vec2(bspeed*cos(angle), bspeed*sin(angle)),
weight: weight, alive: true, ticks: 0)
proc tickPhantoms(eng: var GravityEngine) =
for i in 0..<eng.phantoms.len:
if not eng.phantoms[i].alive: continue
eng.phantoms[i].pos = eng.phantoms[i].pos + eng.phantoms[i].vel
inc eng.phantoms[i].ticks
if eng.phantoms[i].ticks >= 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 (additive — blends with phantom forces)
let minW = min(min(botPos.x, arenaW-botPos.x), min(botPos.y, arenaH-botPos.y))
if minW < VeryClose:
var ex = 0.0; var ey = 0.0
if botPos.x < VeryClose: ex = 1.0
if arenaW - botPos.x < VeryClose: ex = -1.0
if botPos.y < VeryClose: ey = 1.0
if arenaH - botPos.y < VeryClose: ey = -1.0
let wm = sqrt(ex*ex + ey*ey)
if wm > 0.1: total = total + normalize(vec2(ex, ey)) * 500.0
# 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)
# Priority 4: weak wall repulsion
let dL = max(botPos.x, WallMinDist)
let dR = max(arenaW - botPos.x, WallMinDist)
let dB = max(botPos.y, WallMinDist)
let dT = max(arenaH - botPos.y, WallMinDist)
total = total + vec2(KWall*0.5/(dL*dL) - KWall*0.5/(dR*dR),
KWall*0.5/(dB*dB) - KWall*0.5/(dT*dT))
total
# ── MovementModule wrapper ────────────────────────────────────────────────────
type PhantomMeteorModule* = object
engine: GravityEngine
gunheat: GunheatTracker
proc initPhantomMeteor*(): PhantomMeteorModule =
PhantomMeteorModule(engine: initEngine(), gunheat: initGunheatTracker())
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
# ── Ram override ─────────────────────────────────────────────────────────────
let ramDist = dist(botPos, enemyPos)
let ramFinisher = ramDist < 300.0 and ws.enemyEnergy < 20.0 and ws.selfEnergy > ws.enemyEnergy
let ramOpportunity = ramDist < 80.0 and ws.selfEnergy > ws.enemyEnergy + 10.0
let ramDesperation = ws.selfEnergy < 5.0 and ws.enemyEnergy < 5.0 and ramDist < 150.0
if ramFinisher or ramOpportunity or ramDesperation:
let tag = if ramDesperation: "desperation" elif ramFinisher: "finisher" else: "opportunity"
echo CLR_CYAN & "[ram:" & tag & "] dist=" & $int(ramDist) &
" selfE=" & $int(ws.selfEnergy) & " enemyE=" & $int(ws.enemyEnergy) & CLR_RST
let headingRad = arctan2(enemyPos.y - botPos.y, enemyPos.x - botPos.x)
let desiredDeg = radToDeg(headingRad)
var delta = desiredDeg - ws.selfHeading
while delta > 180.0: delta -= 360.0
while delta < -180.0: delta += 360.0
return (speed: 8.0, turnRate: delta.clamp(-10.0, 10.0))
# 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)
# Threat centroid (inverse-distance weighted) for perpendicular direction.
# With 1 enemy this equals the single enemy bearing — identical to prior behavior.
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)
# Two perpendicular directions to centroid bearing (±90°)
let perpCCW = vec2(-sin(centroidBearingRad), cos(centroidBearingRad)) # +90°
let perpCW = vec2( sin(centroidBearingRad), -cos(centroidBearingRad)) # -90°
# Pick perpendicular direction that aligns with force vector
let fn = force.normalize
let desiredDeg =
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))