## Pattern-matching gun: searches movement history for a matching sequence, ## then plays it forward to predict future position. ## Reference: https://robowiki.net/wiki/Pattern_Matching ## Coordinate system: 0° = East, CCW positive (Tank Royale standard). import std/math import gun_harness/gun_interface const HistorySize* = 500 PatternLen* = 10 # ticks used as search key; ponytail: fixed, expose if tuning needed type MoveTick = object velocity: float ## signed speed (px/tick) headingDelta: float ## heading change in radians this tick PatternMatcherGun* = object buf: array[HistorySize, MoveTick] head: int ## next write index (circular) count: int ## filled entries (capped at HistorySize) prevHeading: float prevSpeed: float prevTick: int hasPrev: bool # per-tick cache — avoid re-searching for multiple power bins cacheTick: int cacheX: float cacheY: float cacheValid: bool debugGraphics*: bool # --- circular buffer helpers --- proc write(g: var PatternMatcherGun, m: MoveTick) {.inline.} = g.buf[g.head] = m g.head = (g.head + 1) mod HistorySize if g.count < HistorySize: inc g.count proc readAt(g: PatternMatcherGun, i: int): MoveTick {.inline.} = ## i = 0 is oldest, i = count-1 is newest g.buf[(g.head - g.count + i + HistorySize * 2) mod HistorySize] # --- linear fallback (same style as linear.nim) --- proc linearPredict(state: WorldState, bulletSpeed: float): (float, float) = let dist = hypot(state.enemyX - state.selfX, state.enemyY - state.selfY) let t = dist / bulletSpeed let hRad = degToRad(state.enemyHeading) let ex = clamp(state.enemyX + cos(hRad) * state.enemySpeed * t, BotRadius, state.arenaWidth - BotRadius) let ey = clamp(state.enemyY + sin(hRad) * state.enemySpeed * t, BotRadius, state.arenaHeight - BotRadius) (ex, ey) # --- pattern search + play-forward --- proc searchAndProject(g: PatternMatcherGun, state: WorldState, bulletSpeed: float): (float, float) = ## Returns projected (x, y). Falls back to linear if history too short. if g.count < PatternLen * 2: return linearPredict(state, bulletSpeed) # key = last PatternLen entries let keyStart = g.count - PatternLen # scan backwards for best match (exclude the key itself) var bestScore = Inf var bestMatch = -1 let scanEnd = g.count - PatternLen - 1 # last valid match start for i in countdown(scanEnd, 0): var score = 0.0 for k in 0 ..< PatternLen: let a = g.readAt(keyStart + k) let b = g.readAt(i + k) let dv = a.velocity - b.velocity let dh = a.headingDelta - b.headingDelta score += dv * dv + dh * dh if score < bestScore: bestScore = score bestMatch = i if bestMatch < 0: return linearPredict(state, bulletSpeed) # play forward from bestMatch + PatternLen let playStart = bestMatch + PatternLen let playAvail = g.count - 1 - playStart # ticks we can replay # iterative time estimate let dist0 = hypot(state.enemyX - state.selfX, state.enemyY - state.selfY) var t = dist0 / bulletSpeed var ex = state.enemyX var ey = state.enemyY for _ in 0..4: let steps = min(int(t + 0.5), playAvail) ex = state.enemyX ey = state.enemyY var heading = degToRad(state.enemyHeading) var speed = state.enemySpeed for s in 0 ..< steps: let m = g.readAt(playStart + s) heading += m.headingDelta speed = m.velocity ex += cos(heading) * speed ey += sin(heading) * speed # if we ran out of replay data, coast linearly from last simulated pos let remaining = t - steps.float if remaining > 0.0: ex += cos(heading) * speed * remaining ey += sin(heading) * speed * remaining let ndx = ex - state.selfX let ndy = ey - state.selfY t = sqrt(ndx * ndx + ndy * ndy) / bulletSpeed ex = clamp(ex, BotRadius, state.arenaWidth - BotRadius) ey = clamp(ey, BotRadius, state.arenaHeight - BotRadius) (ex, ey) # --- Gun interface --- proc predict*(g: var PatternMatcherGun, state: WorldState, bulletSpeed: float): GunPrediction = if bulletSpeed <= 0.0: return GunPrediction(x: state.enemyX, y: state.enemyY) # Update history once per tick if g.hasPrev and state.tick > g.prevTick: var dh = degToRad(state.enemyHeading) - degToRad(g.prevHeading) # wrap to [-π, π] while dh > PI: dh -= 2.0 * PI while dh < -PI: dh += 2.0 * PI g.write(MoveTick(velocity: g.prevSpeed, headingDelta: dh)) if not g.hasPrev or state.tick > g.prevTick: g.prevHeading = state.enemyHeading g.prevSpeed = state.enemySpeed g.prevTick = state.tick g.hasPrev = true g.cacheValid = false # new tick invalidates cache # Return cached result for same-tick calls (multiple power bins) if g.cacheValid and state.tick == g.cacheTick: return GunPrediction(x: g.cacheX, y: g.cacheY) let (px, py) = g.searchAndProject(state, bulletSpeed) g.cacheX = px g.cacheY = py g.cacheTick = state.tick g.cacheValid = true GunPrediction(x: px, y: py) proc onResult*(g: var PatternMatcherGun, e: FeedbackEvent) = discard # pattern matcher learns from movement observation, not feedback