init.lua145 lines
--- the lane as a polyline: where a point stands on it, which way it runs there, and how far a
--- creep walks along it. one copy of what awareness, naga, natureprophet and clockwerk each
--- carried as "nearest waypoint, then straight to the next", which cuts every corner.
---@class lane_geom
local M = {}
local sqrt, atan2 = math.sqrt, math.atan2 or function(y, x) return math.atan(y, x) end
local function xy(p)
if p.GetX then return p:GetX(), p:GetY() end
return p.x, p.y
end
-- every waypoint as plain numbers, once per list: the callers hand over the same table each frame
local cache = setmetatable({}, { __mode = "k" })
local function flat(wps)
local f = cache[wps]
if f then return f end
f = { n = #wps, x = {}, y = {}, s = {} }
local acc = 0
for i = 1, #wps do
f.x[i], f.y[i] = xy(wps[i])
if i > 1 then
local dx, dy = f.x[i] - f.x[i - 1], f.y[i] - f.y[i - 1]
acc = acc + sqrt(dx * dx + dy * dy)
end
f.s[i] = acc
end
f.len = acc
cache[wps] = f
return f
end
-- arc length (from waypoint 1) of the point on the polyline closest to (px, py), and how far off
local function project(f, px, py)
local best_s, best_d2 = 0, math.huge
for i = 1, f.n - 1 do
local ax, ay = f.x[i], f.y[i]
local vx, vy = f.x[i + 1] - ax, f.y[i + 1] - ay
local l2 = vx * vx + vy * vy
local t = 0
if l2 > 0 then
t = ((px - ax) * vx + (py - ay) * vy) / l2
if t < 0 then t = 0 elseif t > 1 then t = 1 end
end
local qx, qy = ax + vx * t, ay + vy * t
local d2 = (px - qx) ^ 2 + (py - qy) ^ 2
if d2 < best_d2 then best_d2, best_s = d2, f.s[i] + t * sqrt(l2) end
end
return best_s, sqrt(best_d2)
end
-- the point at arc length s (clamped to the ends)
local function at(f, s)
if s <= 0 then return f.x[1], f.y[1] end
if s >= f.len then return f.x[f.n], f.y[f.n] end
for i = 1, f.n - 1 do
if s <= f.s[i + 1] then
local l = f.s[i + 1] - f.s[i]
local t = l > 0 and (s - f.s[i]) / l or 0
return f.x[i] + (f.x[i + 1] - f.x[i]) * t, f.y[i] + (f.y[i + 1] - f.y[i]) * t
end
end
return f.x[f.n], f.y[f.n]
end
--- arc length along the lane of the point nearest to `pos`, and its distance from the lane.
---@param wps table[] waypoints in index order
---@param pos table a point with GetX/GetY or x/y
---@return number s
---@return number off
function M.project(wps, pos)
if not wps or #wps < 2 or not pos then return 0, math.huge end
local px, py = xy(pos)
return project(flat(wps), px, py)
end
--- unit heading of the lane at `pos`, going the way `dir` says (+1 = rising waypoint index,
--- -1 = falling). taken over `span` units either side of the point, so a corner turns the
--- arrow over `span` rather than flipping it at the waypoint. nil for a lane of one point.
---@param wps table[]
---@param pos table
---@param dir integer
---@param span? number default 150
---@return number|nil dx
---@return number|nil dy
function M.heading(wps, pos, dir, span)
if not wps or #wps < 2 or not pos then return nil end
local f = flat(wps)
local px, py = xy(pos)
local s = project(f, px, py)
span = span or 150
local ax, ay = at(f, s - span)
local bx, by = at(f, s + span)
local dx, dy = bx - ax, by - ay
local len = sqrt(dx * dx + dy * dy)
if len < 1 then return nil end
if dir < 0 then dx, dy = -dx, -dy end
return dx / len, dy / len
end
--- where a creep that stands at `pos` is after walking `dist` along the lane: projected on
--- the lane first, then along it, corners included. stops at the lane's end.
---@param wps table[]
---@param pos table
---@param dist number
---@param dir integer +1 rising waypoint index, -1 falling
---@return number x
---@return number y
function M.walk(wps, pos, dist, dir)
local px, py = xy(pos)
if not wps or #wps < 2 then return px, py end
local f = flat(wps)
local s = project(f, px, py)
return at(f, s + (dir < 0 and -dist or dist))
end
--- `walk` as a Vector (z of the start point kept), for callers that keep Vectors.
---@param wps table[]
---@param pos table
---@param dist number
---@param dir integer
---@return table
function M.walk_point(wps, pos, dist, dir)
local x, y = M.walk(wps, pos, dist, dir)
local z = pos.GetZ and pos:GetZ() or pos.z or 0
local V = rawget(_G, "Vector")
if V then return V(x, y, z) end
return { x = x, y = y, z = z }
end
--- heading as an angle in world axes, radians (atan2(dy, dx)); nil as `heading`.
---@param wps table[]
---@param pos table
---@param dir integer
---@param span? number default 150
---@return number|nil
function M.angle(wps, pos, dir, span)
local dx, dy = M.heading(wps, pos, dir, span)
if not dx then return nil end
return atan2(dy, dx)
end
return M