init.lua621 строк
---@diagnostic disable: undefined-global
-- lib/pathing: gridnav reachability between world points, cached per caller. moved out of
-- uTEA/heroes/clockwerk/rules/pathing (#132) unchanged; api and deps in README.md.
--
-- Owns: path cache on m.cog_push when m provided (the name is clockwerk's, kept for 1.x)
-- Must not: cast abilities or emit intents.
local Pathing = {}
---@class pathing.Deps
---@field C table tunables. required: COG_AIM_ARRIVE_TOLERANCE, COG_PATH_QUICK_REACH_DIST, COG_PATH_CACHE_TTL, COG_PATH_STICKY_MAX; optional: COG_LOCAL_NAV_CACHE_TTL, COG_LOCAL_REACH_CACHE_TTL, COG_LOCAL_NAV_RADIUS_CELLS, COG_LOCAL_NAV_MAX_STEP_Z (README.md)
---@field U { now: fun(): number } the clock cached answers age by
---@field MarsArena? table mars arena walls, `pos_inside_wall(pos, team)` and `segment_crosses_wall(from, to, team)`; nil ignores them
---@class pathing.Result
---@field reachable boolean? false when no path; nil when the path budget deferred the query
---@field clear boolean a straight walk, no detour
---@field endpoint Vector where the walk ends: `to`, or the last point of a partial path
---@field walk_dist number? path length in world units, when reachable
---@field local_nav boolean? answered by the local flood around `from`, without GridNav.GetPath
---@field deferred boolean? the budget ran out; ask again next tick
---@class pathing.Cache
---@field cog_push table where the library keeps its state; the caller sets `_tick_t`, `_path_budget`, `_path_budget_reserve`, `_path_cache`, `_walk_cache` (README.md "the cache table")
---Builds a reachability query set over GridNav. Each call returns fresh closures; two builds share nothing.
---Every query takes an optional cache table `m`: without it nothing is cached and the budget is unlimited.
---```lua
---local P = require("lib/pathing").build({ C = C, U = { now = GameRules.GetGameTime } })
---if P.can_reach_world_pos(Entity.GetAbsOrigin(me), target) then ... end
---```
---@param deps pathing.Deps tunables, clock, optional mars walls
---@return pathing.Query
function Pathing.build(deps)
local C = deps.C
local MarsArena = deps.MarsArena
local U = deps.U
local COG_AIM_ARRIVE_TOLERANCE = C.COG_AIM_ARRIVE_TOLERANCE
local COG_PATH_QUICK_REACH_DIST = C.COG_PATH_QUICK_REACH_DIST
local COG_PATH_CACHE_TTL = C.COG_PATH_CACHE_TTL
local COG_PATH_STICKY_MAX = C.COG_PATH_STICKY_MAX
local COG_LOCAL_NAV_CACHE_TTL = C.COG_LOCAL_NAV_CACHE_TTL or 1.0
local COG_LOCAL_REACH_CACHE_TTL = C.COG_LOCAL_REACH_CACHE_TTL or 1.0
local COG_LOCAL_NAV_RADIUS_CELLS = C.COG_LOCAL_NAV_RADIUS_CELLS or 24
local COG_LOCAL_NAV_MAX_STEP_Z = C.COG_LOCAL_NAV_MAX_STEP_Z or 96
local function vector_coords(pos_or_grid)
if not pos_or_grid or not pos_or_grid.GetX or not pos_or_grid.GetY then return nil end
return math.floor(pos_or_grid:GetX()), math.floor(pos_or_grid:GetY())
end
---Integer x, y of a grid point.
---@param grid_pos Vector a GridNav grid point
---@return integer?, integer? # nil when `grid_pos` is not a vector
local function grid_coords(grid_pos)
return vector_coords(grid_pos)
end
local function world_grid_coords(world_pos)
if not world_pos then return nil end
local g = GridNav.WorldToGrid(world_pos)
if not g then return nil end
return vector_coords(g)
end
local function cache_key_from_coords(fx, fy, tx, ty)
if not fx or not tx then return nil end
return string.format("%d:%d->%d:%d", fx, fy, tx, ty)
end
---The key a query between two world points is cached under: "fx:fy->tx:ty" in grid cells.
---@param from_pos Vector
---@param to_pos Vector
---@return string? # nil when a point is off the grid
local function path_cache_key(from_pos, to_pos)
local fx, fy = world_grid_coords(from_pos)
local tx, ty = world_grid_coords(to_pos)
return cache_key_from_coords(fx, fy, tx, ty)
end
local function grid_path_cache_key(from_grid, to_grid)
local fx, fy = grid_coords(from_grid)
local tx, ty = grid_coords(to_grid)
return cache_key_from_coords(fx, fy, tx, ty)
end
local function local_team_for_walk()
local lp = Heroes and Heroes.GetLocal and Heroes.GetLocal() or nil
if lp and Entity.GetTeamNum then return Entity.GetTeamNum(lp) end
return nil
end
local function pos_blocked_by_mars_wall(pos)
if not MarsArena then return false end
local team = local_team_for_walk()
return MarsArena.pos_inside_wall(pos, team) and true or false
end
local function prune_path_sticky(sticky, t)
if not sticky then return end
local count = 0
for _ in pairs(sticky) do count = count + 1 end
if count <= COG_PATH_STICKY_MAX then return end
for key, entry in pairs(sticky) do
if t - (entry.t or 0) >= COG_PATH_CACHE_TTL then
sticky[key] = nil
end
end
end
local function lookup_path_entry(m, key)
if not key or not m or not m.cog_push then return nil end
local tick_cache = m.cog_push._path_cache
if tick_cache and tick_cache[key] ~= nil then return tick_cache[key] end
local sticky = m.cog_push._path_sticky
if sticky then
local entry = sticky[key]
local t = m.cog_push._tick_t or U.now()
if entry and t - (entry.t or 0) < COG_PATH_CACHE_TTL then
if tick_cache then tick_cache[key] = entry end
return entry
end
end
return nil
end
local function store_path_entry(m, key, entry)
if not key or not entry or not m or not m.cog_push then return end
if m.cog_push._path_cache then m.cog_push._path_cache[key] = entry end
local sticky = m.cog_push._path_sticky
if not sticky then sticky = {}; m.cog_push._path_sticky = sticky end
entry.t = m.cog_push._tick_t or U.now()
sticky[key] = entry
prune_path_sticky(sticky, entry.t)
end
---Whether a GridNav.GetPath call is still allowed this tick (`_path_budget` above `_path_budget_reserve`).
---@param m pathing.Cache?
---@return boolean # true when `m` sets no budget
local function path_budget_available(m)
if not m or not m.cog_push or m.cog_push._path_budget == nil then return true end
local reserve = m.cog_push._path_budget_reserve or 0
return m.cog_push._path_budget > reserve
end
local function consume_path_budget(m)
if not m or not m.cog_push or m.cog_push._path_budget == nil then return true end
if not path_budget_available(m) then return false end
m.cog_push._path_budget = m.cog_push._path_budget - 1
return true
end
---Marks `m` as having deferred a query, so the caller knows to ask again next tick.
---@param m pathing.Cache?
local function note_path_scan_deferred(m)
if m and m.cog_push then m.cog_push._path_scan_deferred = true end
end
local function is_walkable_node(node)
return node and (node & 0x103) == 1
end
local function grid_key(gx, gy)
return tostring(gx) .. ":" .. tostring(gy)
end
local function query_grid_world_pos(gx, gy)
if not GridNav or not GridNav.GridToWorld then return nil end
if GridNav.GetGridIndex and GridNav.GetGridPoint then
local idx = GridNav.GetGridIndex(gx, gy)
if idx then
local gp = GridNav.GetGridPoint(idx)
if gp then
local wp = GridNav.GridToWorld(gp)
if wp then return wp end
end
end
end
return GridNav.GridToWorld(Vector(gx, gy, 0))
end
local function cached_grid_world_pos(m, gx, gy)
if not gx or not gy then return nil end
local now = m and m.cog_push and (m.cog_push._tick_t or U.now()) or U.now()
local cache = nil
if m and m.cog_push then
cache = m.cog_push._local_nav_world
if not cache then
cache = {}
m.cog_push._local_nav_world = cache
end
end
if cache then
local key = grid_key(gx, gy)
local entry = cache[key]
if entry and now - (entry.t or 0) < COG_LOCAL_NAV_CACHE_TTL then
return entry.pos
end
local pos = query_grid_world_pos(gx, gy)
cache[key] = { pos = pos, t = now }
return pos
end
return query_grid_world_pos(gx, gy)
end
local function grid_step_height_clear(m, ax, ay, bx, by)
if COG_LOCAL_NAV_MAX_STEP_Z <= 0 then return true end
local a = cached_grid_world_pos(m, ax, ay)
local b = cached_grid_world_pos(m, bx, by)
if not a or not b then return true end
return math.abs((a:GetZ() or 0) - (b:GetZ() or 0)) <= COG_LOCAL_NAV_MAX_STEP_Z
end
local function segment_height_clear(from_pos, to_pos, m)
if COG_LOCAL_NAV_MAX_STEP_Z <= 0 or not from_pos or not to_pos then return true end
local last_x, last_y = world_grid_coords(from_pos)
if not last_x then return true end
for i = 1, 8 do
local t = i / 8
local p = Vector(
from_pos:GetX() + (to_pos:GetX() - from_pos:GetX()) * t,
from_pos:GetY() + (to_pos:GetY() - from_pos:GetY()) * t,
from_pos:GetZ())
local gx, gy = world_grid_coords(p)
if gx and (gx ~= last_x or gy ~= last_y) then
if not grid_step_height_clear(m, last_x, last_y, gx, gy) then
return false
end
last_x, last_y = gx, gy
end
end
return true
end
local function cached_grid_walkable(m, gx, gy)
if not gx or not gy then return false end
local now = m and m.cog_push and (m.cog_push._tick_t or U.now()) or U.now()
local cache = nil
if m and m.cog_push then
cache = m.cog_push._local_nav_nodes
if not cache then
cache = {}
m.cog_push._local_nav_nodes = cache
end
if now - (m.cog_push._local_nav_prune_t or 0) >= COG_LOCAL_NAV_CACHE_TTL then
for key, entry in pairs(cache) do
if now - (entry.t or 0) >= COG_LOCAL_NAV_CACHE_TTL * 2 then
cache[key] = nil
end
end
local world_cache = m.cog_push._local_nav_world
if world_cache then
for key, entry in pairs(world_cache) do
if now - (entry.t or 0) >= COG_LOCAL_NAV_CACHE_TTL * 2 then
world_cache[key] = nil
end
end
end
m.cog_push._local_nav_prune_t = now
end
end
if cache then
local key = grid_key(gx, gy)
local entry = cache[key]
if entry and now - (entry.t or 0) < COG_LOCAL_NAV_CACHE_TTL then
return entry.walkable
end
local node = GridNav.GetNode(gx, gy)
local walkable = is_walkable_node(node)
cache[key] = { walkable = walkable, t = now }
return walkable
end
return is_walkable_node(GridNav.GetNode(gx, gy))
end
---Drops the local reach flood, so the next query floods again from its own start cell.
---@param m pathing.Cache?
local function clear_local_reach_cache(m)
if m and m.cog_push then m.cog_push._local_reach_cache = nil end
end
local LOCAL_REACH_NEIGHBORS = {
{ 1, 0 }, { -1, 0 }, { 0, 1 }, { 0, -1 },
{ 1, 1 }, { 1, -1 }, { -1, 1 }, { -1, -1 },
}
local function build_local_reach_cache(m, sx, sy, radius)
if not (m and m.cog_push and sx and sy) then return nil end
radius = radius or COG_LOCAL_NAV_RADIUS_CELLS
local now = m.cog_push._tick_t or U.now()
local existing = m.cog_push._local_reach_cache
if existing
and existing.sx == sx
and existing.sy == sy
and (existing.radius or 0) >= radius
and now - (existing.t or 0) < COG_LOCAL_REACH_CACHE_TTL then
return existing
end
m.cog_push._local_nav_nodes = m.cog_push._local_nav_nodes or {}
local reach = {
key = string.format("%d:%d:%d", sx, sy, radius),
t = now,
sx = sx,
sy = sy,
radius = radius,
cells = {},
}
m.cog_push._local_reach_cache = reach
if not cached_grid_walkable(m, sx, sy) then return reach end
local min_x, max_x = sx - radius, sx + radius
local min_y, max_y = sy - radius, sy + radius
local qx, qy, head, tail = { sx }, { sy }, 1, 1
reach.cells[grid_key(sx, sy)] = true
while head <= tail do
local x, y = qx[head], qy[head]
head = head + 1
for _, step in ipairs(LOCAL_REACH_NEIGHBORS) do
local nx, ny = x + step[1], y + step[2]
if nx >= min_x and nx <= max_x and ny >= min_y and ny <= max_y then
local nkey = grid_key(nx, ny)
if not reach.cells[nkey] then
local diagonal = step[1] ~= 0 and step[2] ~= 0
local corner_clear = true
if diagonal then
corner_clear = cached_grid_walkable(m, x + step[1], y)
and cached_grid_walkable(m, x, y + step[2])
and grid_step_height_clear(m, x, y, x + step[1], y)
and grid_step_height_clear(m, x, y, x, y + step[2])
and grid_step_height_clear(m, x + step[1], y, nx, ny)
and grid_step_height_clear(m, x, y + step[2], nx, ny)
end
if corner_clear
and grid_step_height_clear(m, x, y, nx, ny)
and cached_grid_walkable(m, nx, ny) then
reach.cells[nkey] = true
tail = tail + 1
qx[tail], qy[tail] = nx, ny
end
end
end
end
end
return reach
end
local function local_reach_between_cells(m, sx, sy, tx, ty)
if not (m and m.cog_push and sx and sy and tx and ty) then return nil end
if not GridNav or not GridNav.GetNode then return nil end
local radius = COG_LOCAL_NAV_RADIUS_CELLS
if math.abs(tx - sx) > radius or math.abs(ty - sy) > radius then return nil end
local reach = build_local_reach_cache(m, sx, sy, radius)
if not reach then return nil end
return reach.cells[grid_key(tx, ty)] == true
end
local function local_reach_check_world(from_pos, to_pos, m)
if not (from_pos and to_pos) then return nil end
local sx, sy = world_grid_coords(from_pos)
local tx, ty = world_grid_coords(to_pos)
if MarsArena then
local team = local_team_for_walk()
if pos_blocked_by_mars_wall(to_pos) then return false end
if MarsArena.segment_crosses_wall(from_pos, to_pos, team) then return false end
end
return local_reach_between_cells(m, sx, sy, tx, ty)
end
---Floods walkable cells around `from_pos` and returns the flood, for drawing it.
---@param m pathing.Cache?
---@param from_pos Vector
---@param radius integer? cells, COG_LOCAL_NAV_RADIUS_CELLS by default
---@return table? # `{ cells = { ["x:y"] = true } }`, nil off the grid
local function debug_local_reach_cache(m, from_pos, radius)
if not from_pos then return nil end
local sx, sy = world_grid_coords(from_pos)
if not sx then return nil end
return build_local_reach_cache(m, sx, sy, radius)
end
local function local_reach_check_grid(from_grid, to_grid, m)
local sx, sy = grid_coords(from_grid)
local tx, ty = grid_coords(to_grid)
return local_reach_between_cells(m, sx, sy, tx, ty)
end
local function path_last_world(from_pos, path)
local last_world = from_pos
if not path or #path < 2 then return last_world end
for i = 2, #path do
local gp = GridNav.GetGridPoint(path[i])
if gp then last_world = gp end
end
return last_world
end
local function path_walk_dist(from_pos, to_pos, path, is_clear)
if not (from_pos and to_pos) then return nil end
if is_clear then return (from_pos - to_pos):Length2D() end
if not path or #path == 0 then return nil end
local prev = from_pos
local dist = 0
for i = 1, #path do
local gp = GridNav.GetGridPoint(path[i])
local p = nil
if gp and gp.GetX and GridNav and GridNav.GridToWorld then
p = GridNav.GridToWorld(gp)
elseif gp and gp.GetX then
p = gp
end
if p then
local step = (p - prev):Length2D()
if step > 0 and step < 2048 then
dist = dist + step
prev = p
end
end
end
local last = (to_pos - prev):Length2D()
if last > 0 and last < 2048 then dist = dist + last end
if dist <= 1 then return (from_pos - to_pos):Length2D() end
return dist
end
---Whether the grid cell under a world point is walkable.
---@param pos Vector
---@param m pathing.Cache?
---@return boolean # true off the grid; false for nil `pos`
local function is_world_walkable(pos, m)
if not pos then return false end
local gx, gy = world_grid_coords(pos)
if not gx then return true end
local key = gx .. ":" .. gy
local cache = m and m.cog_push and m.cog_push._walk_cache
if cache and cache[key] ~= nil then return cache[key] end
local ok = cached_grid_walkable(m, gx, gy)
if cache then cache[key] = ok end
return ok
end
---Whether a straight walk works, without GridNav.GetPath: both ends walkable, no wall or cliff between, closer than COG_PATH_QUICK_REACH_DIST.
---@param from_pos Vector
---@param to_pos Vector
---@param m pathing.Cache?
---@return boolean # false means "not straight", not "unreachable"
local function quick_reach_check(from_pos, to_pos, m)
if not from_pos or not to_pos then return false end
local dist = (from_pos - to_pos):Length2D()
if dist <= COG_AIM_ARRIVE_TOLERANCE then return true end
if not is_world_walkable(from_pos, m) or not is_world_walkable(to_pos, m) then return false end
if MarsArena then
local team = local_team_for_walk()
if pos_blocked_by_mars_wall(to_pos) then return false end
if MarsArena.segment_crosses_wall(from_pos, to_pos, team) then return false end
end
if not segment_height_clear(from_pos, to_pos, m) then return false end
if dist > COG_PATH_QUICK_REACH_DIST then return false end
if GridNav.IsLineClear then
if GridNav.IsLineClear(from_pos, to_pos) then return true end
end
for i = 1, 3 do
local t = i / 4
local mid = Vector(
from_pos:GetX() + (to_pos:GetX() - from_pos:GetX()) * t,
from_pos:GetY() + (to_pos:GetY() - from_pos:GetY()) * t,
from_pos:GetZ())
if not is_world_walkable(mid, m) then return false end
end
return true
end
---The full answer for a walk between two world points: reachable, straight or not, where it ends and how long it is.
---Tries the cache, then a straight walk, then the local flood, then GridNav.GetPath while the budget lasts.
---@param from_pos Vector
---@param to_pos Vector
---@param m pathing.Cache?
---@return pathing.Result
local function cog_path_query(from_pos, to_pos, m)
if not from_pos or not to_pos then
return { reachable = false, clear = false, endpoint = from_pos }
end
if (from_pos - to_pos):Length2D() <= COG_AIM_ARRIVE_TOLERANCE then
return { reachable = true, clear = true, endpoint = to_pos }
end
local key = path_cache_key(from_pos, to_pos)
local cached = lookup_path_entry(m, key)
if cached then return cached end
if quick_reach_check(from_pos, to_pos, m) then
local entry = {
reachable = true,
clear = true,
endpoint = to_pos,
walk_dist = (from_pos - to_pos):Length2D(),
}
store_path_entry(m, key, entry)
return entry
end
local local_reach = local_reach_check_world(from_pos, to_pos, m)
if local_reach == true then
local entry = {
reachable = true,
clear = false,
endpoint = to_pos,
local_nav = true,
walk_dist = (from_pos - to_pos):Length2D(),
}
store_path_entry(m, key, entry)
return entry
end
if not path_budget_available(m) then
note_path_scan_deferred(m)
return { reachable = nil, clear = false, endpoint = from_pos, deferred = true }
end
local gs = GridNav.WorldToGrid(from_pos)
local ge = GridNav.WorldToGrid(to_pos)
if not gs or not ge then
local entry = { reachable = true, clear = true, endpoint = to_pos }
store_path_entry(m, key, entry)
return entry
end
consume_path_budget(m)
local path, is_clear = GridNav.GetPath(gs, ge)
local reachable = path and #path > 0 or false
if reachable and MarsArena and pos_blocked_by_mars_wall(to_pos) then
reachable = false
is_clear = false
end
if reachable and MarsArena then
local team = local_team_for_walk()
if MarsArena.segment_crosses_wall(from_pos, to_pos, team) then
reachable = false
is_clear = false
end
end
local entry = {
reachable = reachable,
clear = is_clear and reachable,
endpoint = is_clear and to_pos or path_last_world(from_pos, path),
walk_dist = reachable and path_walk_dist(from_pos, to_pos, path, is_clear) or nil,
}
store_path_entry(m, key, entry)
return entry
end
---Whether a unit at `from_pos` can walk to `to_pos`.
---@param from_pos Vector
---@param to_pos Vector
---@param m pathing.Cache?
---@return boolean? # nil when the path budget deferred the query
local function can_reach_world_pos(from_pos, to_pos, m)
local result = cog_path_query(from_pos, to_pos, m)
if result.deferred then return nil end
return result.reachable
end
---Same as `can_reach_world_pos`, for two GridNav grid points.
---@param from_grid Vector
---@param to_grid Vector
---@param m pathing.Cache?
---@return boolean? # nil when deferred; true when either point is nil
local function can_reach_grid_pos(from_grid, to_grid, m)
if not from_grid or not to_grid then return true end
local key = grid_path_cache_key(from_grid, to_grid)
local cached = lookup_path_entry(m, key)
if cached then
if cached.deferred then return nil end
return cached.reachable
end
local local_reach = local_reach_check_grid(from_grid, to_grid, m)
if local_reach == true then
local entry = {
reachable = true,
clear = false,
endpoint = to_grid,
local_nav = true,
}
store_path_entry(m, key, entry)
return true
end
if not path_budget_available(m) then
note_path_scan_deferred(m)
return nil
end
consume_path_budget(m)
local path = GridNav.GetPath(from_grid, to_grid)
local reachable = path and #path > 0 or false
local entry = { reachable = reachable, clear = reachable, endpoint = reachable and to_grid or from_grid }
store_path_entry(m, key, entry)
return reachable
end
---@class pathing.Query
---What `build` returns: the queries, closed over one set of deps.
return {
grid_coords = grid_coords,
path_cache_key = path_cache_key,
is_world_walkable = is_world_walkable,
quick_reach_check = quick_reach_check,
cog_path_query = cog_path_query,
can_reach_world_pos = can_reach_world_pos,
can_reach_grid_pos = can_reach_grid_pos,
path_budget_available = path_budget_available,
note_path_scan_deferred = note_path_scan_deferred,
clear_local_reach_cache = clear_local_reach_cache,
debug_local_reach_cache = debug_local_reach_cache,
}
end
return Pathing