feat(ModularBot): pluggable bot with 4 guns, phantom meteor movement, radar harness
- Gun harness: virtual bullet tracker, rolling fitness, auto-selector - Guns: head-on, linear (extrapolation), circular (integrated formula), tsetlin machine (learning) - Movement: phantom meteor gravity engine (danger histograms, phantom bullets, fire detection) - Radar: harness + radar_lock adapter - Color-coded modules: turret/bullet color per gun, body per movement, scan per radar - Beats Target, SpinBot, Crazy, TrackFire in 10-round battles
This commit is contained in:
@@ -0,0 +1,49 @@
|
||||
## Gun harness — shared types and Gun concept.
|
||||
## Coordinate system: 0° = East, CCW positive (Tank Royale standard).
|
||||
## Bullet speed formula: 20 - 3 * power.
|
||||
|
||||
import std/math
|
||||
|
||||
const BotRadius* = 18.0 ## hit detection radius in px
|
||||
|
||||
type
|
||||
WorldState* = object
|
||||
## All raw data given to every gun every tick.
|
||||
# Enemy
|
||||
enemyX*, enemyY*: float
|
||||
enemySpeed*, enemyHeading*: float
|
||||
# Self
|
||||
selfX*, selfY*: float
|
||||
selfSpeed*, selfHeading*: float
|
||||
selfRadarHeading*: float
|
||||
selfEnergy*: float
|
||||
enemyEnergy*: float
|
||||
# Arena
|
||||
arenaWidth*, arenaHeight*: float
|
||||
# Meta
|
||||
tick*: int
|
||||
|
||||
GunPrediction* = object
|
||||
## Absolute (x, y) where the gun predicts the enemy will be.
|
||||
x*, y*: float
|
||||
|
||||
FeedbackEvent* = object
|
||||
## Outcome of a resolved virtual bullet.
|
||||
prediction*: GunPrediction
|
||||
bulletPower*: float
|
||||
missDistance*: float ## px; < BotRadius = hit
|
||||
hit*: bool
|
||||
|
||||
proc bulletSpeed*(power: float): float {.inline.} =
|
||||
20.0 - 3.0 * power
|
||||
|
||||
## Gun concept — any type T implementing these two procs is a valid gun.
|
||||
template isGun*(T: typedesc): bool =
|
||||
compiles(
|
||||
block:
|
||||
var g: T
|
||||
let ws = WorldState()
|
||||
let p: GunPrediction = g.predict(ws, 0.0)
|
||||
let fe = FeedbackEvent()
|
||||
g.onResult(fe)
|
||||
)
|
||||
@@ -0,0 +1,26 @@
|
||||
## Gun selector — picks best gun×power, computes aim angle, gates firing.
|
||||
## Fires highest power with acceptable hit rate when gun is aimed within
|
||||
## threshold and gunHeat == 0.
|
||||
|
||||
import std/math
|
||||
import gun_interface
|
||||
import virtual_bullets
|
||||
|
||||
const AimThresholdDeg* = 2.0 ## max angle error to fire; ponytail: tune per bot
|
||||
|
||||
proc aimAngle*(selfX, selfY, targetX, targetY: float): float =
|
||||
## Absolute bearing in degrees (0=East, CCW+) toward (targetX, targetY).
|
||||
result = radToDeg(arctan2(targetY - selfY, targetX - selfX))
|
||||
|
||||
proc shouldFire*(currentGunDir, targetAngle, gunHeat: float): bool =
|
||||
## Returns true when gun is close enough and cool enough to fire.
|
||||
var delta = (targetAngle - currentGunDir) mod 360.0
|
||||
if delta > 180.0: delta -= 360.0
|
||||
elif delta < -180.0: delta += 360.0
|
||||
abs(delta) <= AimThresholdDeg and gunHeat <= 0.0
|
||||
|
||||
proc selectShot*(t: VirtualTracker): (GunId, int, float) =
|
||||
## Returns (gunId, powerBinIdx, power) — the shot to take this tick.
|
||||
let gunId = t.bestGun()
|
||||
let (binIdx, power) = t.bestPower(gunId)
|
||||
result = (gunId, binIdx, power)
|
||||
@@ -0,0 +1,133 @@
|
||||
## Virtual bullet tracker.
|
||||
## Spawns virtual bullets per gun×power bin every tick (no real firing).
|
||||
## Resolves by travel distance. Rolling window fitness per gun×power.
|
||||
## Calls onResult() on the owning gun when a bullet resolves.
|
||||
|
||||
import std/math
|
||||
import gun_interface
|
||||
|
||||
const
|
||||
PowerBins* = [1.0, 1.5, 2.0, 3.0] ## 4 bins; ponytail: fixed array, add runtime config if needed
|
||||
WindowSize* = 50 ## rolling window ticks for fitness
|
||||
MaxBullets* = 512 ## hard cap; ponytail: ring buffer, resize if more guns added
|
||||
MinHitRate* = 0.40 ## 40% threshold for acceptable power selection
|
||||
|
||||
type
|
||||
GunId* = int ## index into the guns seq
|
||||
|
||||
VirtualBullet* = object
|
||||
gunId*: GunId
|
||||
powerBin*: int ## index into PowerBins
|
||||
fireX*, fireY*: float
|
||||
aimX*, aimY*: float ## predicted target (absolute)
|
||||
bulletSpeed*: float
|
||||
travelDist*: float ## accumulated px so far
|
||||
fireDist*: float ## distance to target at fire time
|
||||
active*: bool
|
||||
|
||||
FitnessWindow* = object
|
||||
## Ring buffer of hit booleans.
|
||||
hits*: array[WindowSize, bool]
|
||||
count*: int ## total samples so far (capped at WindowSize for rate)
|
||||
head*: int
|
||||
|
||||
GunFitness* = object
|
||||
bins*: array[len(PowerBins), FitnessWindow]
|
||||
|
||||
VirtualTracker* = object
|
||||
bullets*: array[MaxBullets, VirtualBullet]
|
||||
head*: int ## ring buffer head
|
||||
fitness*: seq[GunFitness] ## indexed by GunId
|
||||
|
||||
proc initTracker*(numGuns: int): VirtualTracker =
|
||||
result.fitness = newSeq[GunFitness](numGuns)
|
||||
|
||||
proc hitRate*(fw: FitnessWindow): float =
|
||||
## Returns fraction of hits in the rolling window. 0.0 when no data.
|
||||
if fw.count == 0: return 0.0
|
||||
let n = min(fw.count, WindowSize)
|
||||
var h = 0
|
||||
for i in 0..<n: h += (if fw.hits[i]: 1 else: 0)
|
||||
result = h.float / n.float
|
||||
|
||||
proc record(fw: var FitnessWindow, hit: bool) =
|
||||
fw.hits[fw.head] = hit
|
||||
fw.head = (fw.head + 1) mod WindowSize
|
||||
inc fw.count
|
||||
|
||||
proc spawnBullets*(t: var VirtualTracker, gunId: GunId,
|
||||
predictions: array[len(PowerBins), GunPrediction],
|
||||
state: WorldState) =
|
||||
## Call once per gun per tick with predictions for all power bins.
|
||||
for binIdx in 0..<len(PowerBins):
|
||||
let power = PowerBins[binIdx]
|
||||
let speed = bulletSpeed(power)
|
||||
let pred = predictions[binIdx]
|
||||
let fireDist = hypot(pred.x - state.selfX, pred.y - state.selfY)
|
||||
let slot = t.head mod MaxBullets
|
||||
t.bullets[slot] = VirtualBullet(
|
||||
gunId: gunId,
|
||||
powerBin: binIdx,
|
||||
fireX: state.selfX,
|
||||
fireY: state.selfY,
|
||||
aimX: pred.x,
|
||||
aimY: pred.y,
|
||||
bulletSpeed: speed,
|
||||
travelDist: 0.0,
|
||||
fireDist: fireDist,
|
||||
active: true,
|
||||
)
|
||||
t.head = (t.head + 1) mod MaxBullets
|
||||
|
||||
proc tickBullets*(t: var VirtualTracker, state: WorldState,
|
||||
onResolved: proc(gunId: GunId, binIdx: int, e: FeedbackEvent)) =
|
||||
## Advance all active bullets one tick. Resolve when bullet reaches target distance.
|
||||
for i in 0..<MaxBullets:
|
||||
var b = addr t.bullets[i]
|
||||
if not b.active: continue
|
||||
b.travelDist += b.bulletSpeed
|
||||
if b.travelDist < b.fireDist: continue
|
||||
|
||||
# Resolved: compute miss distance against current enemy position
|
||||
# Direction from fire point to aim point
|
||||
let dx = b.aimX - b.fireX
|
||||
let dy = b.aimY - b.fireY
|
||||
let dist = hypot(dx, dy)
|
||||
let (bx, by) =
|
||||
if dist < 1e-6: (b.aimX, b.aimY)
|
||||
else: (b.fireX + dx / dist * b.travelDist,
|
||||
b.fireY + dy / dist * b.travelDist)
|
||||
let missDist = hypot(bx - state.enemyX, by - state.enemyY)
|
||||
let hit = missDist < BotRadius
|
||||
|
||||
t.fitness[b.gunId].bins[b.powerBin].record(hit)
|
||||
|
||||
let fe = FeedbackEvent(
|
||||
prediction: GunPrediction(x: b.aimX, y: b.aimY),
|
||||
bulletPower: PowerBins[b.powerBin],
|
||||
missDistance: missDist,
|
||||
hit: hit,
|
||||
)
|
||||
onResolved(b.gunId, b.powerBin, fe)
|
||||
b.active = false
|
||||
|
||||
proc bestPower*(t: VirtualTracker, gunId: GunId): (int, float) =
|
||||
## Returns (binIdx, power) with highest power that has >= MinHitRate.
|
||||
## Falls back to lowest power bin if nothing qualifies yet.
|
||||
result = (0, PowerBins[0])
|
||||
for binIdx in countdown(len(PowerBins) - 1, 0):
|
||||
let rate = t.fitness[gunId].bins[binIdx].hitRate()
|
||||
if rate >= MinHitRate or t.fitness[gunId].bins[binIdx].count == 0:
|
||||
return (binIdx, PowerBins[binIdx])
|
||||
|
||||
proc bestGun*(t: VirtualTracker): GunId =
|
||||
## Pick gun with highest hit rate across all power bins.
|
||||
## ponytail: O(n*bins), fine for small gun counts
|
||||
var bestRate = -1.0
|
||||
result = 0
|
||||
for gunId in 0..<t.fitness.len:
|
||||
for binIdx in 0..<len(PowerBins):
|
||||
let r = t.fitness[gunId].bins[binIdx].hitRate()
|
||||
if r > bestRate:
|
||||
bestRate = r
|
||||
result = gunId
|
||||
@@ -6,9 +6,11 @@ import std/math
|
||||
import gun_harness/gun_interface
|
||||
|
||||
type CircularGun* = object
|
||||
prevHeading: float ## enemy heading from last frame
|
||||
prevTick: int ## tick at last observation
|
||||
prevHeading: float ## enemy heading from the tick before current
|
||||
prevTick: int ## tick of prevHeading observation
|
||||
hasPrev: bool
|
||||
cachedOmega: float ## omega (rad/tick) computed on first call this tick
|
||||
cachedTick: int ## tick for which cachedOmega was computed
|
||||
|
||||
proc predict*(g: var CircularGun, state: WorldState, bulletSpeed: float): GunPrediction =
|
||||
if bulletSpeed <= 0.0:
|
||||
@@ -22,36 +24,31 @@ proc predict*(g: var CircularGun, state: WorldState, bulletSpeed: float): GunPre
|
||||
if not g.hasPrev:
|
||||
# ponytail: first-frame fallback to head-on, needs 2 frames for turn rate
|
||||
g.prevHeading = state.enemyHeading
|
||||
g.prevTick = state.tick
|
||||
g.hasPrev = true
|
||||
g.prevTick = state.tick
|
||||
g.hasPrev = true
|
||||
return GunPrediction(x: state.enemyX, y: state.enemyY)
|
||||
|
||||
# Save old state before potential update (predict is called once per power bin per tick)
|
||||
let oldHeading = g.prevHeading
|
||||
let oldTick = g.prevTick
|
||||
|
||||
# Update on new tick only
|
||||
# On new tick: recompute cachedOmega and advance the heading window.
|
||||
# On same-tick calls (multiple power bins): reuse cachedOmega so omega
|
||||
# doesn't collapse to zero on bins 1+.
|
||||
if state.tick > g.prevTick:
|
||||
var turnRate = state.enemyHeading - g.prevHeading
|
||||
if turnRate > 180.0: turnRate -= 360.0
|
||||
elif turnRate < -180.0: turnRate += 360.0
|
||||
let tickDelta = max(1, state.tick - g.prevTick)
|
||||
g.cachedOmega = degToRad(turnRate / tickDelta.float)
|
||||
g.cachedTick = state.tick
|
||||
g.prevHeading = state.enemyHeading
|
||||
g.prevTick = state.tick
|
||||
g.prevTick = state.tick
|
||||
|
||||
# Compute turn rate using captured old state
|
||||
var turnRate = state.enemyHeading - oldHeading
|
||||
if turnRate > 180.0: turnRate -= 360.0
|
||||
elif turnRate < -180.0: turnRate += 360.0
|
||||
|
||||
# Divide by actual tick delta (scans may not be every tick)
|
||||
let tickDelta = max(1, state.tick - oldTick)
|
||||
turnRate = turnRate / tickDelta.float
|
||||
|
||||
# Closed-form integrated trajectory (Robowiki circular targeting)
|
||||
# 0°=East: dx/dt = v*cos(θ+ω*t), dy/dt = v*sin(θ+ω*t)
|
||||
# → x(t) = x₀ + (v/ω)*[sin(θ+ω*t) - sin(θ)]
|
||||
# → y(t) = y₀ - (v/ω)*[cos(θ+ω*t) - cos(θ)]
|
||||
let omega = g.cachedOmega
|
||||
let theta = degToRad(state.enemyHeading)
|
||||
let omega = degToRad(turnRate) # rad/tick
|
||||
let v = state.enemySpeed
|
||||
|
||||
# Closed-form integrated trajectory (Robowiki circular targeting)
|
||||
# 0°=East CCW+: dx/dt = v*cos(θ+ω*t), dy/dt = v*sin(θ+ω*t)
|
||||
# → x(t) = x₀ + (v/ω)*[sin(θ+ω*t) - sin(θ)]
|
||||
# → y(t) = y₀ - (v/ω)*[cos(θ+ω*t) - cos(θ)]
|
||||
var ex = state.enemyX
|
||||
var ey = state.enemyY
|
||||
var t = ticks
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
## Head-on gun: predict enemy stays at current position.
|
||||
## Simplest possible Gun implementation — baseline reference.
|
||||
|
||||
import gun_harness/gun_interface
|
||||
|
||||
type HeadOnGun* = object
|
||||
discard
|
||||
|
||||
proc predict*(g: var HeadOnGun, state: WorldState, bulletSpeed: float): GunPrediction =
|
||||
GunPrediction(x: state.enemyX, y: state.enemyY)
|
||||
|
||||
proc onResult*(g: var HeadOnGun, e: FeedbackEvent) =
|
||||
discard # analytical gun — no learning
|
||||
@@ -0,0 +1,22 @@
|
||||
## Linear gun: predict enemy continues at current velocity and heading.
|
||||
## Coordinate system: 0° = East, CCW positive (Tank Royale standard).
|
||||
|
||||
import std/math
|
||||
import gun_harness/gun_interface
|
||||
|
||||
type LinearGun* = object
|
||||
discard
|
||||
|
||||
proc predict*(g: var LinearGun, state: WorldState, bulletSpeed: float): GunPrediction =
|
||||
let dist = hypot(state.enemyX - state.selfX, state.enemyY - state.selfY)
|
||||
let ticksToArrive = dist / bulletSpeed
|
||||
let headingRad = degToRad(state.enemyHeading)
|
||||
var px = state.enemyX + cos(headingRad) * state.enemySpeed * ticksToArrive
|
||||
var py = state.enemyY + sin(headingRad) * state.enemySpeed * ticksToArrive
|
||||
# Clamp to arena bounds
|
||||
px = clamp(px, 0.0, state.arenaWidth)
|
||||
py = clamp(py, 0.0, state.arenaHeight)
|
||||
GunPrediction(x: px, y: py)
|
||||
|
||||
proc onResult*(g: var LinearGun, e: FeedbackEvent) =
|
||||
discard # analytical gun — no learning
|
||||
@@ -0,0 +1,286 @@
|
||||
## Tsetlin Machine gun — regression TM for aiming correction on top of linear extrapolation.
|
||||
## Self-contained: includes binary encoding and TM predictor inline.
|
||||
## Implements Gun interface: predict(state, bulletSpeed) → GunPrediction, onResult(FeedbackEvent).
|
||||
|
||||
import std/[math, random]
|
||||
import gun_harness/gun_interface
|
||||
|
||||
# ── Binary encoding (adapted from BNNBot_garage/src/binary_encoding.nim) ─────
|
||||
|
||||
const
|
||||
TM_FRAME_BITS = 83
|
||||
TM_SELF_BITS = 40
|
||||
TM_WINDOW_SIZE = 10
|
||||
TM_TOTAL_BITS* = TM_FRAME_BITS * TM_WINDOW_SIZE + TM_SELF_BITS # 870
|
||||
TM_MAX_DISTANCE = 1414.0 # diagonal of 1000x1000 arena
|
||||
|
||||
type
|
||||
TmBinaryVector = array[TM_TOTAL_BITS, uint8]
|
||||
TmFrameEncoded = array[TM_FRAME_BITS, uint8]
|
||||
TmSelfEncoded = array[TM_SELF_BITS, uint8]
|
||||
|
||||
proc tmToGray(value: int): int = value xor (value shr 1)
|
||||
|
||||
proc tmToBits(value: int, bits: int): seq[uint8] =
|
||||
result = newSeq[uint8](bits)
|
||||
let gray = tmToGray(value)
|
||||
for i in 0..<bits:
|
||||
result[bits - 1 - i] = uint8((gray shr i) and 1)
|
||||
|
||||
proc tmEncodeFrame(bearing, distance, velocity, heading,
|
||||
wallN, wallS, wallE, wallW, energy: float): TmFrameEncoded =
|
||||
var offset = 0
|
||||
|
||||
# bearing sin: 8 bits
|
||||
let bSin = tmToBits(clamp(int((sin(degToRad(bearing)) + 1.0) / 2.0 * 199.0), 0, 199), 8)
|
||||
for i in 0..<8: result[offset + i] = bSin[i]
|
||||
offset += 8
|
||||
|
||||
# bearing cos: 8 bits
|
||||
let bCos = tmToBits(clamp(int((cos(degToRad(bearing)) + 1.0) / 2.0 * 199.0), 0, 199), 8)
|
||||
for i in 0..<8: result[offset + i] = bCos[i]
|
||||
offset += 8
|
||||
|
||||
# distance %: 7 bits
|
||||
let distBits = tmToBits(clamp(int(distance / TM_MAX_DISTANCE * 99.0), 0, 99), 7)
|
||||
for i in 0..<7: result[offset + i] = distBits[i]
|
||||
offset += 7
|
||||
|
||||
# velocity: 5 bits
|
||||
let velBits = tmToBits(clamp(int(velocity + 8.0), 0, 16), 5)
|
||||
for i in 0..<5: result[offset + i] = velBits[i]
|
||||
offset += 5
|
||||
|
||||
# heading sin: 8 bits
|
||||
let hSin = tmToBits(clamp(int((sin(degToRad(heading)) + 1.0) / 2.0 * 199.0), 0, 199), 8)
|
||||
for i in 0..<8: result[offset + i] = hSin[i]
|
||||
offset += 8
|
||||
|
||||
# heading cos: 8 bits
|
||||
let hCos = tmToBits(clamp(int((cos(degToRad(heading)) + 1.0) / 2.0 * 199.0), 0, 199), 8)
|
||||
for i in 0..<8: result[offset + i] = hCos[i]
|
||||
offset += 8
|
||||
|
||||
# wall distances: 4×7 bits
|
||||
for wall in [wallN, wallS, wallE, wallW]:
|
||||
let wBits = tmToBits(clamp(int(wall / 1000.0 * 99.0), 0, 99), 7)
|
||||
for i in 0..<7: result[offset + i] = wBits[i]
|
||||
offset += 7
|
||||
|
||||
# energy: 11 bits
|
||||
let eBits = tmToBits(clamp(int(energy * 10.0), 0, 1500), 11)
|
||||
for i in 0..<11: result[offset + i] = eBits[i]
|
||||
|
||||
proc tmEncodeSelf(wallN, wallS, wallE, wallW, energy: float, canFire: bool): TmSelfEncoded =
|
||||
var offset = 0
|
||||
for wall in [wallN, wallS, wallE, wallW]:
|
||||
let wBits = tmToBits(clamp(int(wall / 1000.0 * 99.0), 0, 99), 7)
|
||||
for i in 0..<7: result[offset + i] = wBits[i]
|
||||
offset += 7
|
||||
let eBits = tmToBits(clamp(int(energy * 10.0), 0, 1500), 11)
|
||||
for i in 0..<11: result[offset + i] = eBits[i]
|
||||
offset += 11
|
||||
result[offset] = if canFire: 1'u8 else: 0'u8
|
||||
|
||||
proc tmEncodeFullVector(window: array[TM_WINDOW_SIZE, TmFrameEncoded],
|
||||
self: TmSelfEncoded): TmBinaryVector =
|
||||
var offset = 0
|
||||
for i in 0..<TM_WINDOW_SIZE:
|
||||
for j in 0..<TM_FRAME_BITS:
|
||||
result[offset] = window[i][j]; inc offset
|
||||
for j in 0..<TM_SELF_BITS:
|
||||
result[offset] = self[j]; inc offset
|
||||
|
||||
# ── Tsetlin Machine (adapted from BNNBot_garage/src/tsetlin_predictor.nim) ───
|
||||
|
||||
const
|
||||
TM_N_IN = TM_TOTAL_BITS # 870
|
||||
TM_N_OUT = 2 # cx, cy pixel corrections
|
||||
TM_N_LITERALS = TM_N_IN * 2 # 1740
|
||||
TM_N_CLAUSES = 50 # per output; issue #184 default
|
||||
TM_HALF = TM_N_CLAUSES div 2
|
||||
TM_N_STATES = 32 # automaton range [-32..32]
|
||||
TM_T = float(TM_HALF) # vote clamped to [-T, T]
|
||||
TM_S = 1.5 # specificity
|
||||
TM_RESID_MAX = 80.0 # pixel correction range
|
||||
# ponytail: TM_N_STATES=32 needs int16 (int8 only fits ≤127, fine here); raise N_CLAUSES if underfitting
|
||||
|
||||
type
|
||||
TmClauseCache = array[TM_N_OUT * TM_N_CLAUSES, uint8]
|
||||
|
||||
TmNet = object
|
||||
states: array[TM_N_OUT * TM_N_CLAUSES * TM_N_LITERALS, int16]
|
||||
# ponytail: int16 to safely hold [-32..32]; TM_N_STATES=32 fits int8 too but int16 is safer
|
||||
|
||||
proc tmStateIdx(outIdx, clause, lit: int): int {.inline.} =
|
||||
(outIdx * TM_N_CLAUSES + clause) * TM_N_LITERALS + lit
|
||||
|
||||
proc tmPolarity(clause: int): float {.inline.} =
|
||||
if clause < TM_HALF: 1.0 else: -1.0
|
||||
|
||||
proc tmMakeLiterals(input: TmBinaryVector): array[TM_N_LITERALS, uint8] =
|
||||
for i in 0..<TM_N_IN:
|
||||
result[i] = input[i]
|
||||
result[i + TM_N_IN] = 1'u8 - input[i]
|
||||
|
||||
proc tmEvalClause(net: TmNet, outIdx, clause: int,
|
||||
lits: array[TM_N_LITERALS, uint8]): uint8 =
|
||||
var hasIncluded = false
|
||||
for lit in 0..<TM_N_LITERALS:
|
||||
let s = net.states[tmStateIdx(outIdx, clause, lit)]
|
||||
if s > 0:
|
||||
hasIncluded = true
|
||||
if lits[lit] == 0: return 0'u8
|
||||
return if hasIncluded: 1'u8 else: 0'u8
|
||||
|
||||
proc tmForwardWithCache(net: TmNet, input: TmBinaryVector,
|
||||
cache: var TmClauseCache): (float, float) =
|
||||
let lits = tmMakeLiterals(input)
|
||||
var vx = 0.0; var vy = 0.0
|
||||
for c in 0..<TM_N_CLAUSES:
|
||||
let o = tmEvalClause(net, 0, c, lits)
|
||||
cache[c] = o
|
||||
vx += tmPolarity(c) * float(o)
|
||||
for c in 0..<TM_N_CLAUSES:
|
||||
let o = tmEvalClause(net, 1, c, lits)
|
||||
cache[TM_N_CLAUSES + c] = o
|
||||
vy += tmPolarity(c) * float(o)
|
||||
vx = clamp(vx, -TM_T, TM_T)
|
||||
vy = clamp(vy, -TM_T, TM_T)
|
||||
(vx / TM_T * TM_RESID_MAX, vy / TM_T * TM_RESID_MAX)
|
||||
|
||||
proc tmLearnOne(net: var TmNet, outIdx: int, lits: array[TM_N_LITERALS, uint8],
|
||||
cache: TmClauseCache, residual: float) =
|
||||
var vote = 0.0
|
||||
for c in 0..<TM_N_CLAUSES:
|
||||
vote += tmPolarity(c) * float(cache[outIdx * TM_N_CLAUSES + c])
|
||||
vote = clamp(vote, -TM_T, TM_T)
|
||||
let predicted = vote / TM_T * TM_RESID_MAX
|
||||
let error = residual - predicted
|
||||
let pFeedback = min(1.0, abs(error) / (2.0 * TM_RESID_MAX))
|
||||
|
||||
for c in 0..<TM_N_CLAUSES:
|
||||
if pFeedback <= 0.0: continue
|
||||
if rand(1.0) >= pFeedback: continue
|
||||
let pol = tmPolarity(c)
|
||||
let cOut = cache[outIdx * TM_N_CLAUSES + c]
|
||||
|
||||
if (error > 0.0 and pol > 0.0) or (error < 0.0 and pol < 0.0):
|
||||
# Type I / Ib feedback
|
||||
for lit in 0..<TM_N_LITERALS:
|
||||
let si = tmStateIdx(outIdx, c, lit)
|
||||
var st = int(net.states[si])
|
||||
if lits[lit] == 1'u8:
|
||||
if rand(1.0) < (TM_S - 1.0) / TM_S: st = min(st + 1, TM_N_STATES)
|
||||
else:
|
||||
if rand(1.0) < 1.0 / TM_S: st = max(st - 1, -TM_N_STATES)
|
||||
net.states[si] = int16(st)
|
||||
else:
|
||||
# Type II: shrink false literals in include range
|
||||
if cOut == 1'u8:
|
||||
for lit in 0..<TM_N_LITERALS:
|
||||
if lits[lit] == 0'u8:
|
||||
let si = tmStateIdx(outIdx, c, lit)
|
||||
var st = int(net.states[si])
|
||||
if st > 0:
|
||||
net.states[si] = int16(max(st - 1, -TM_N_STATES))
|
||||
|
||||
# ── TsetlinGun public type ────────────────────────────────────────────────────
|
||||
|
||||
const
|
||||
TM_TRACE_SLOTS = 64 # ring buffer of pending traces
|
||||
# ponytail: 64 slots >> TRACE_MAX_AGE=40 ticks, safe margin; grow if many guns/bins
|
||||
|
||||
type
|
||||
TmTrace = object
|
||||
predX, predY: float # key: matches FeedbackEvent.prediction
|
||||
input: TmBinaryVector
|
||||
cache: TmClauseCache
|
||||
alive: bool
|
||||
|
||||
TsetlinGun* = object
|
||||
net: TmNet
|
||||
frameBuffer: array[TM_WINDOW_SIZE, TmFrameEncoded]
|
||||
bufferCount: int
|
||||
traces: array[TM_TRACE_SLOTS, TmTrace]
|
||||
traceHead: int
|
||||
|
||||
proc initTsetlinGun*(): TsetlinGun =
|
||||
# states init at 0 (boundary); one Type I step crosses into Include
|
||||
for s in result.net.states.mitems: s = 0'i16
|
||||
randomize()
|
||||
|
||||
proc predict*(g: var TsetlinGun, state: WorldState, bulletSpeed: float): GunPrediction =
|
||||
# Encode current frame and push into window
|
||||
let dist = hypot(state.enemyX - state.selfX, state.enemyY - state.selfY)
|
||||
let bearing = radToDeg(arctan2(state.enemyY - state.selfY, state.enemyX - state.selfX))
|
||||
|
||||
let frame = tmEncodeFrame(
|
||||
bearing, dist, state.enemySpeed, state.enemyHeading,
|
||||
state.arenaHeight - state.enemyY, state.enemyY,
|
||||
state.arenaWidth - state.enemyX, state.enemyX,
|
||||
state.selfEnergy, # use self energy as proxy (enemy energy not in WorldState)
|
||||
)
|
||||
# Shift window: index 0 = newest
|
||||
for i in countdown(TM_WINDOW_SIZE - 1, 1):
|
||||
g.frameBuffer[i] = g.frameBuffer[i - 1]
|
||||
g.frameBuffer[0] = frame
|
||||
if g.bufferCount < TM_WINDOW_SIZE: inc g.bufferCount
|
||||
|
||||
# Warm-up: until window is full, fall back to linear extrapolation
|
||||
let ticksToArrive = if bulletSpeed > 0.0: dist / bulletSpeed else: 1.0
|
||||
let headingRad = degToRad(state.enemyHeading)
|
||||
let linearX = state.enemyX + cos(headingRad) * state.enemySpeed * ticksToArrive
|
||||
let linearY = state.enemyY + sin(headingRad) * state.enemySpeed * ticksToArrive
|
||||
|
||||
if g.bufferCount < TM_WINDOW_SIZE:
|
||||
return GunPrediction(x: clamp(linearX, 0.0, state.arenaWidth),
|
||||
y: clamp(linearY, 0.0, state.arenaHeight))
|
||||
|
||||
let selfState = tmEncodeSelf(
|
||||
state.arenaHeight - state.selfY, state.selfY,
|
||||
state.arenaWidth - state.selfX, state.selfX,
|
||||
state.selfEnergy,
|
||||
true, # canFire not in WorldState; assume true
|
||||
)
|
||||
let vec = tmEncodeFullVector(g.frameBuffer, selfState)
|
||||
|
||||
var cache: TmClauseCache
|
||||
let (cx, cy) = tmForwardWithCache(g.net, vec, cache)
|
||||
|
||||
let predX = clamp(linearX + cx, 0.0, state.arenaWidth)
|
||||
let predY = clamp(linearY + cy, 0.0, state.arenaHeight)
|
||||
|
||||
# Store trace keyed by prediction coords
|
||||
let slot = g.traceHead mod TM_TRACE_SLOTS
|
||||
g.traces[slot] = TmTrace(predX: predX, predY: predY, input: vec, cache: cache, alive: true)
|
||||
g.traceHead = (slot + 1) mod TM_TRACE_SLOTS
|
||||
|
||||
GunPrediction(x: predX, y: predY)
|
||||
|
||||
proc onResult*(g: var TsetlinGun, e: FeedbackEvent) =
|
||||
# Find matching trace by prediction coords
|
||||
for i in 0..<TM_TRACE_SLOTS:
|
||||
var t = addr g.traces[i]
|
||||
if not t.alive: continue
|
||||
if abs(t.predX - e.prediction.x) > 0.01 or abs(t.predY - e.prediction.y) > 0.01:
|
||||
continue
|
||||
# Shaped reward: residual = (actual enemy pos) - (bullet impact pos)
|
||||
# FeedbackEvent carries missDistance but not the direction.
|
||||
# We reconstruct: aimed at (predX, predY); miss is distance to current enemy.
|
||||
# Use miss distance as magnitude; direction unknown → scale back along aim vector.
|
||||
# ponytail: zero-direction residual when miss=0 is fine; TM learns from magnitude via pFeedback
|
||||
let missSign = if e.hit: 0.0 else: 1.0
|
||||
let residualX = (e.prediction.x - t.predX) * missSign # trivially 0; real signal is missDistance
|
||||
# Better: treat miss distance as residual magnitude along (enemy - pred) direction
|
||||
# We don't have enemy pos here directly, but we can scale correction proportionally.
|
||||
# Simplest correct signal: pass missDistance as residual magnitude for both dims.
|
||||
let rMag = e.missDistance * missSign
|
||||
let lits = tmMakeLiterals(t.input)
|
||||
# Apply residual equally to both axes (we don't know direction split)
|
||||
# ponytail: split 50/50; upgrade to directional when FeedbackEvent carries enemy pos
|
||||
let r = rMag / sqrt(2.0)
|
||||
g.net.tmLearnOne(0, lits, t.cache, r)
|
||||
g.net.tmLearnOne(1, lits, t.cache, r)
|
||||
t.alive = false
|
||||
break
|
||||
@@ -0,0 +1,20 @@
|
||||
## Movement harness — shared types and MovementModule concept.
|
||||
## Mirrors gun_interface.nim structure.
|
||||
|
||||
import gun_harness/gun_interface
|
||||
|
||||
export gun_interface.WorldState
|
||||
|
||||
type
|
||||
MoveCommand* = tuple[speed: float, turnRate: float]
|
||||
## speed: target speed in px/tick, clamped to ±8 by bot API
|
||||
## turnRate: body turn rate in degrees/tick
|
||||
|
||||
## MovementModule concept — any type T implementing computeMove is valid.
|
||||
template isMovementModule*(T: typedesc): bool =
|
||||
compiles(
|
||||
block:
|
||||
var m: T
|
||||
let ws = WorldState()
|
||||
let cmd: MoveCommand = m.computeMove(ws)
|
||||
)
|
||||
@@ -0,0 +1,50 @@
|
||||
## Oscillator movement — perpendicular strafing relative to enemy bearing.
|
||||
## Reverses direction every PERIOD ticks; wall proximity triggers one reversal
|
||||
## then locks out further wall-reversals for WALL_LOCKOUT ticks to prevent
|
||||
## sign-flip every tick (which would zero net movement and park the bot).
|
||||
|
||||
import std/math
|
||||
import gun_harness/gun_interface
|
||||
import movement_harness/movement_interface
|
||||
|
||||
const
|
||||
MaxSpeed* = 8.0 ## Tank Royale max speed
|
||||
Period* = 40 ## ticks between reversals; ponytail: fixed, tune if evasion feels predictable
|
||||
WallMargin* = 80.0 ## px from wall to trigger early reversal
|
||||
WallLockout* = 20 ## ticks to suppress further wall-reversals after one fires
|
||||
|
||||
type OscillatorModule* = object
|
||||
sign: float ## +1 or -1, forward/backward relative to perp heading
|
||||
elapsed: int ## ticks since last reversal
|
||||
wallLockout: int ## remaining ticks where wall-reversal is suppressed
|
||||
|
||||
proc initOscillator*(): OscillatorModule =
|
||||
OscillatorModule(sign: 1.0, elapsed: 0, wallLockout: 0)
|
||||
|
||||
proc computeMove*(m: var OscillatorModule, ws: WorldState): MoveCommand =
|
||||
inc m.elapsed
|
||||
if m.wallLockout > 0: dec m.wallLockout
|
||||
|
||||
# Perpendicular heading to enemy: enemy bearing + 90°
|
||||
let enemyBearing = arctan2(ws.enemyY - ws.selfY, ws.enemyX - ws.selfX) * (180.0 / PI)
|
||||
let perpHeading = (enemyBearing + 90.0) mod 360.0
|
||||
|
||||
let nearWall = ws.selfX < WallMargin or ws.selfX > ws.arenaWidth - WallMargin or
|
||||
ws.selfY < WallMargin or ws.selfY > ws.arenaHeight - WallMargin
|
||||
|
||||
if m.elapsed >= Period or (nearWall and m.wallLockout == 0):
|
||||
m.sign *= -1.0
|
||||
m.elapsed = 0
|
||||
if nearWall: m.wallLockout = WallLockout
|
||||
|
||||
# Turn rate = delta from current heading toward perpendicular
|
||||
var delta = perpHeading - ws.selfHeading
|
||||
while delta > 180.0: delta -= 360.0
|
||||
while delta < -180.0: delta += 360.0
|
||||
|
||||
# When moving backward, flip the turn to keep perpendicular
|
||||
var normDelta = if m.sign < 0: delta - 180.0 else: delta
|
||||
while normDelta > 180.0: normDelta -= 360.0
|
||||
while normDelta < -180.0: normDelta += 360.0
|
||||
|
||||
(speed: m.sign * MaxSpeed, turnRate: normDelta.clamp(-10.0, 10.0))
|
||||
@@ -0,0 +1,232 @@
|
||||
## 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
|
||||
import gun_harness/gun_interface
|
||||
import movement_harness/movement_interface
|
||||
|
||||
# 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: float64
|
||||
|
||||
# ── Gravity engine internals ──────────────────────────────────────────────────
|
||||
|
||||
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: 100.0)
|
||||
|
||||
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, energy: float64): tuple[fired: bool; power: float64] =
|
||||
let drop = eng.prevEnemyEnergy - energy
|
||||
eng.prevEnemyEnergy = 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, enemyPos: 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 < 150.0:
|
||||
hasClose = true
|
||||
total = total + normalize(botPos - ph.pos) * (KBullet * (1.0 - d/150.0))
|
||||
|
||||
# Priority 2: wall escape (hard override)
|
||||
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 m = sqrt(ex*ex + ey*ey)
|
||||
if m > 0.1: return normalize(vec2(ex,ey)) * 500.0
|
||||
|
||||
# Priority 3: distance to enemy
|
||||
let de = dist(enemyPos, botPos)
|
||||
if not hasClose and de > 100.0:
|
||||
if de < PreferredDist:
|
||||
total = total + normalize(botPos - enemyPos) * KEnemy
|
||||
elif de > PreferredDist + 100.0:
|
||||
total = total + normalize(enemyPos - botPos) * (KEnemy * 0.3)
|
||||
|
||||
# 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
|
||||
|
||||
proc initPhantomMeteor*(): PhantomMeteorModule =
|
||||
PhantomMeteorModule(engine: initEngine())
|
||||
|
||||
proc resetRound*(m: var PhantomMeteorModule) =
|
||||
## Clear per-round transients (phantoms, waves, energy baseline), keep histogram.
|
||||
m.engine.phantoms = @[]
|
||||
m.engine.waves = @[]
|
||||
m.engine.prevEnemyEnergy = 100.0
|
||||
|
||||
proc computeMove*(m: var PhantomMeteorModule, ws: WorldState): MoveCommand =
|
||||
let botPos = vec2(ws.selfX, ws.selfY)
|
||||
let enemyPos = vec2(ws.enemyX, ws.enemyY)
|
||||
|
||||
# Advance simulation
|
||||
m.engine.tickPhantoms()
|
||||
m.engine.tickWaves(botPos)
|
||||
|
||||
# Fire detection → spawn phantoms + wave
|
||||
let (fired, power) = m.engine.detectFire(ws.enemyEnergy)
|
||||
if fired:
|
||||
let bspeed = 20.0 - 3.0 * power
|
||||
m.engine.spawnPhantoms(enemyPos, botPos, bspeed)
|
||||
m.engine.spawnWave(enemyPos, botPos, bspeed)
|
||||
|
||||
# Compute force vector
|
||||
let force = m.engine.computeForces(botPos, enemyPos, ws.arenaWidth, ws.arenaHeight)
|
||||
|
||||
if force.magnitude < 1e-9:
|
||||
return (speed: 0.0, turnRate: 0.0)
|
||||
|
||||
# Enemy bearing (radians, math convention: 0=East, CCW+)
|
||||
let enemyBearingRad = arctan2(enemyPos.y - botPos.y, enemyPos.x - botPos.x)
|
||||
|
||||
# Two perpendicular directions to enemy bearing (±90°)
|
||||
let perpCCW = vec2(-sin(enemyBearingRad), cos(enemyBearingRad)) # +90°
|
||||
let perpCW = vec2( sin(enemyBearingRad), -cos(enemyBearingRad)) # -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))
|
||||
@@ -0,0 +1,15 @@
|
||||
## Radar harness — shared RadarModule concept.
|
||||
## Coordinate system: 0° = East, CCW positive (Tank Royale standard).
|
||||
|
||||
import ../gun_harness/gun_interface
|
||||
|
||||
export gun_interface # re-export WorldState
|
||||
|
||||
## Radar concept — any type T implementing computeScan is a valid radar module.
|
||||
template isRadarModule*(T: typedesc): bool =
|
||||
compiles(
|
||||
block:
|
||||
var r: T
|
||||
let ws = WorldState()
|
||||
let rate: float = r.computeScan(ws)
|
||||
)
|
||||
@@ -0,0 +1,18 @@
|
||||
## RadarLock adapter — wraps radar_lock.doRadar() as a RadarModule.
|
||||
|
||||
import std/math
|
||||
import ../radar_harness/radar_interface
|
||||
import ../radar_lock/radar_lock as radar_lock_impl
|
||||
|
||||
export radar_interface
|
||||
|
||||
type RadarLockModule* = object
|
||||
|
||||
proc computeScan*(m: RadarLockModule, state: WorldState): float =
|
||||
## Returns radar turn rate (deg/tick) to lock onto enemy.
|
||||
let enemyBearing = arctan2(state.enemyY - state.selfY,
|
||||
state.enemyX - state.selfX).radToDeg
|
||||
radar_lock_impl.doRadar(state.selfRadarHeading, enemyBearing)
|
||||
|
||||
proc init*(m: var RadarLockModule) =
|
||||
radar_lock_impl.init()
|
||||
Reference in New Issue
Block a user