2024-10-25 01:24:10 +02:00

625 lines
18 KiB
Lua

-- Maximum allowed elements in the open list before aborting
local MAX_OPEN = 300
rp_pathfinder = {}
-- Returns true if node is walkable
local function walkable_default(node)
local def = minetest.registered_nodes[node.name]
if not def or def.walkable then
return true
else
return false
end
end
-- Returns true is node is "blocking"
local blocking_default = walkable_default
-- Returns true if node is climbable
-- * node: Node table
-- * dir: Check for vertical climb restriction:
-- * 1: Check if can climb up (no 'disable_jump' group)
-- * -1: Check if can climb down (no 'disable_descend' group)
-- * nil: Ignore climb restrictions
local function climbable_default(node, dir)
local def = minetest.registered_nodes[node.name]
if not def then
return false
elseif def.climbable then
if dir then
if dir == 1 then
return minetest.get_item_group(node.name, "disable_jump") == 0
elseif dir == -1 then
return minetest.get_item_group(node.name, "disable_descend") == 0
else
error("[rp_pathfinder] climbable: invalid dir argument!")
end
else
return true
end
else
return false
end
end
-- Returns true player can jump from node
local function jumpable(node)
return minetest.get_item_group(node.name, "disable_jump") == 0
end
-- Return the height of the given node or pessimistic_height
-- if the node is too complex. This function is not 100% accurate
-- and does not cover all possible node definitions and param2 values.
-- TODO: Cover all possible node definitions and param2 values.
local function get_node_height(node, pessimistic_height)
local def = minetest.registered_nodes[node.name]
if not def then
return 1
end
if not def.walkable then
return 0
end
local node_box
if def.collision_box then
node_box = def.collision_box
elseif def.node_box then
node_box = def.node_box
end
if node_box then
if node_box.type == "regular" then
return 1
elseif node_box.type == "fixed" or (node_box.type == "connected" and node_box.fixed) then
local max_y = 0
local fixed = node_box.fixed
if type(fixed[1]) == "table" then
for f=1, #fixed do
max_y = math.max(max_y, fixed[f][5])
end
else
max_y = fixed[5]
end
return (0.5 + max_y)
elseif node_box.type == "leveled" and def.paramtype2 == "leveled" then
if node.param2 == 0 then
return def.leveled or 0
else
return (node.param2 % 128) / 64
end
else
return pessimistic_height
end
else
-- No node box, this is a normal cube
return 1
end
end
-- 2D distance heuristic between pos1 and pos2
local function get_distance_2d(pos1, pos2)
local distX = math.abs(pos1.x - pos2.x)
local distZ = math.abs(pos1.z - pos2.z)
-- Manhattan distance
return distX + distZ
end
-- Get actual cost to walk from pos1 to pos2 (which must be a neighbor)
-- * pos1: Origin position
-- * pos2: Target position (neighbor of pos1)
-- * get_node: get_node function
-- * get_floor_cost(node): Function that, given a node table, returns the
-- cost of walking *on* this node (default cost is 1)
local function get_neighbor_cost(pos1, pos2, get_node, get_floor_cost)
if not get_floor_cost then
return 1
end
local floor = vector.offset(pos2, 0, -1, 0)
local floornode = get_node(floor)
return get_floor_cost(floornode)
end
-- Checks nodes above pos to be non-blocking.
-- Returns true if all nodes are non-blocking,
-- false otherwise
--
-- * pos: Start position (will not be checked)
-- * nodes_above: number of nodes above pos to check
-- * nh: Node handers table
-- * get_node: Note getter function
local function check_height_clearance(pos, nodes_above, nh, get_node)
if nodes_above <= 0 then
-- Trivial: No nodes need to be checked
return true
end
local npos = table.copy(pos)
local nnode
local height = 0
repeat
height = height + 1
npos.y = npos.y + 1
nnode = get_node(npos)
if nh.blocking(nnode) then
return false
end
until height >= nodes_above
return true
end
local function vertical_walk(start_pos, vdir, max_height, stop_func, stop_value, get_node, precise_height)
local pos = table.copy(start_pos)
local height = 0
local ok = false
local final_node
while height < max_height do
pos.y = pos.y + vdir
local node = get_node(pos)
height = height + 1
if stop_func(node) == stop_value then
ok = true
break
end
final_node = node
end
-- Precide height mode:
-- Reduce returned height if the final node is node a standard cube,
-- e.g. a slab
if precise_height and ok then
if not final_node then
final_node = get_node(start_pos)
end
local nheight
local pessimistic
if vdir > 0 then
pessimistic = 2
else
pessimistic = 0
end
nheight = get_node_height(final_node, pessimistic)
height = height - (1 - nheight)
end
if ok then
return pos, height
end
end
-- Simulate falling with a given drop_height limit
-- and returns the final node we land *in*
local function drop_down(pos, drop_height, stop_at_climb, nh, get_node)
local stop = function(node)
return nh.blocking(node) or nh.walkable(node) or (stop_at_climb and nh.climbable(node))
end
local dpos = table.copy(pos)
-- Get the first blocking or walkable node below neighbor
-- add 1 node to drop height because
-- we need an 1 node offset for the floor (on which we drop on top)
drop_height = drop_height + 1
local floor = vertical_walk(dpos, -1, drop_height, stop, true, get_node)
if not floor then
return nil
end
local fnode = get_node(floor)
if nh.blocking(fnode) and not nh.walkable(fnode) then
-- If node is blocking but not walkable, we must not take it;
-- its a potential danger
return nil
else
floor.y = floor.y + 1
return floor
end
end
local function get_neighbor_floor_pos(neighbor_pos, current_pos, clear_height, jump_height, drop_height, climb, nh, get_node)
local npos = table.copy(neighbor_pos)
local nnode = get_node(npos)
-- Climb
if climb then
-- If neighbor is climbable
if nh.climbable(nnode) and not nh.blocking(nnode) then
return npos
-- If node *below* neighbor is climbable
elseif not nh.blocking(nnode) then
local bpos = vector.offset(npos, 0, -1, 0)
local bnode = get_node(bpos)
if nh.climbable(bnode) and not nh.blocking(bnode) then
return npos
end
end
end
-- Drop down
if not nh.walkable(nnode) and not nh.blocking(nnode) then
local floor = drop_down(npos, drop_height, false, nh, get_node)
return floor
-- Jump
else
local stop = function(node)
return (not nh.walkable(node)) or nh.blocking(node)
end
-- Get the first non-walkable node above the neighbor
local target_pos, height = vertical_walk(npos, 1, jump_height, stop, true, get_node, true)
if target_pos then
-- Check if the floor node is lowered and if yes,
-- increase the required jump height by the difference.
-- E.g. a slab of height 0.5 would increase the
-- required jump height by 0.5
local floor = vector.offset(current_pos, 0, -1, 0)
local fnode = get_node(floor)
-- for normal nodes, add_height will be 0.
-- for overhigh nodes, add_height will be 0.
-- for nodes with a height lower than 1 (like slabs)
-- add_height will increase by the difference from a full node.
local add_height = math.max(0, 1-get_node_height(fnode, 0))
height = height + add_height
-- Check if we could still jump high enough
if jump_height < height then
return
end
-- Also check the nodes above current pos for any blocking nodes,
-- since this is where the player has to jump
-- If the top node is non-walkable,
-- we don't want to jump on it
local tnode = get_node(vector.offset(target_pos, 0, -1, 0))
if not nh.walkable(tnode) then
return
end
-- Also take height clearance into account
height = height + (clear_height - 1)
local jump_blocking_pos = vertical_walk(current_pos, 1, height, nh.blocking, true, get_node)
if not jump_blocking_pos then
return target_pos
end
end
end
end
-- 4 neighbors: the 4 cardinal directions
local neighbor_dirs_2d = {
{ x = -1, y = 0, z = 0 },
{ x = 0, y = 0, z = -1 },
{ x = 0, y = 0, z = 1 },
{ x = 1, y = 0, z = 0 },
}
-- Reverse a list of values
local reverse_list = function(list)
local reverse_list = {}
for i=#list, 1, -1 do
local elem = list[i]
table.insert(reverse_list, elem)
end
return reverse_list
end
-- Constructs the final path if found
local function build_finished_path(closed_set, start_hash, final_hash)
-- Basically, we walk backwards from the final node until we reached the start node
local path = {}
-- Start from the end ...
local index = final_hash
while start_hash ~= index do
if not closed_set[index] then
-- If this happens, this must be an error in the algorithm
-- as the pathfinder has botched the closed_set somehow.
error("rp_pathfinder: closed_set["..index.."] is nil in build_finished_path!")
end
table.insert(path, closed_set[index].pos)
-- ... go to the previous node ...
index = closed_set[index].parent
end
table.insert(path, closed_set[index].pos)
local reverse_path = reverse_list(path)
return reverse_path
end
function rp_pathfinder.get_voxelmanip_for_path(pos1, pos2, searchdistance)
local min_pos = {
x = math.min(pos1.x, pos2.x) - searchdistance,
y = math.min(pos1.y, pos2.y) - searchdistance,
z = math.min(pos1.z, pos2.z) - searchdistance,
}
local max_pos = {
x = math.max(pos1.x, pos2.x) + searchdistance,
y = math.max(pos1.y, pos2.y) + searchdistance,
z = math.max(pos1.z, pos2.z) + searchdistance,
}
return minetest.get_voxel_manip(min_pos, max_pos)
end
-- The main pathfinding function (see API.md)
function rp_pathfinder.find_path(pos1, pos2, searchdistance, options, timeout)
-- Keep track of time
local start_time = minetest.get_us_time()
-- round positions if not done by former functions
pos1 = vector.round(pos1)
pos2 = vector.round(pos2)
-- Trivial: pos1 and pos2 are the same
if vector.equals(pos1, pos2) then
return { pos1 }
end
local min_pos = {
x = math.min(pos1.x, pos2.x) - searchdistance,
y = math.min(pos1.y, pos2.y) - searchdistance,
z = math.min(pos1.z, pos2.z) - searchdistance,
}
local max_pos = {
x = math.max(pos1.x, pos2.x) + searchdistance,
y = math.max(pos1.y, pos2.y) + searchdistance,
z = math.max(pos1.z, pos2.z) + searchdistance,
}
-- Options
if not options then
options = {}
end
local clear_height = math.max(1, options.clear_height or 1)
local max_drop = options.max_drop or 0
local max_jump = options.max_jump or 0
local respect_disable_jump = options.respect_disable_jump or false
local respect_climb_restrictions = options.respect_climb_restrictions
local get_floor_cost = options.get_floor_cost
if respect_climb_restrictions == nil then
respect_climb_restrictions = true
end
local climb = options.climb or false
local nh = {
walkable = options.handler_walkable or walkable_default,
blocking = options.handler_blocking or blocking_default,
climbable = options.handler_climbable or climbable_default,
}
local get_node
if options.use_vmanip then
local vmanip
if options.vmanip then
vmanip = options.vmanip
else
vmanip = minetest.get_voxel_manip(min_pos, max_pos)
end
get_node = function(pos)
return vmanip:get_node_at(pos)
end
else
get_node = minetest.get_node
end
-- Can't make a path if start or end node
-- are blocking
local target_node = get_node(pos2)
if nh.blocking(target_node) then
-- End position blocked
return nil, "pos2_blocked"
end
local start_node = get_node(pos1)
if nh.blocking(start_node) then
-- Start position blocked
return nil, "pos1_blocked"
end
-- Simulate an initial drop from pos1
pos1 = drop_down(pos1, max_drop, climb, nh, get_node)
if not pos1 then
return nil, "pos1_too_high"
end
local start_hash = minetest.hash_node_position(pos1)
local final_hash = minetest.hash_node_position(pos2)
local open_set = {}
local closed_set = {}
local open_set_size = 0
-- Helper functions to set and get search nodes
local set_search_node = function(set, hash, values)
if set == open_set then
if not set[hash] and values ~= nil then
open_set_size = open_set_size + 1
elseif set[hash] and values == nil then
open_set_size = open_set_size - 1
end
end
set[hash] = values
end
local get_search_node = function(set, hash)
return set[hash]
end
local get_next_search_node = function(set)
return next(set)
end
--[[ Syntax of a single search node for the A* search:
{
pos: World position of the Luanti node that this search node represents
parent: Reference to preceding node in the search (nil for start node)
h: Heuristic cost estimate from node to finish
g: Total cost from start to this node
f: Equals g+h
}
]]
-- Add the first search node to open set at the start
local h_first = get_neighbor_cost(pos1, pos2, get_node, get_floor_cost)
set_search_node(open_set, start_hash, {
pos = pos1,
parent = nil,
h = h_first,
g = 0,
f = h_first,
})
-- Node has 4 neighbors: 4 cardinal directions
local neighbor_dirs = neighbor_dirs_2d
while open_set_size > 0 do
-- Find node with lowest f cost (f value)
local current_hash, current_data = get_next_search_node(open_set)
for hash, data in pairs(open_set) do
if data.f < open_set[current_hash].f or data.f == current_data.f and data.h < current_data.h then
current_hash = hash
current_data = data
end
end
set_search_node(open_set, current_hash, nil)
set_search_node(closed_set, current_hash, current_data)
-- Target position found: Return path
if current_hash == final_hash then
return build_finished_path(closed_set, start_hash, current_hash)
end
local current_pos = current_data.pos
local current_node = get_node(current_pos)
local current_neighbor_dirs = neighbor_dirs
local current_max_jump = max_jump
local current_max_drop = max_drop
if climb then
current_neighbor_dirs = table.copy(neighbor_dirs)
if respect_climb_restrictions then
if nh.climbable(current_node) then
if nh.climbable(current_node, 1) then
table.insert(current_neighbor_dirs, {x=0,y=1,z=0})
end
if nh.climbable(current_node, -1) then
table.insert(current_neighbor_dirs, {x=0,y=-1,z=0})
end
current_max_jump = 0
else
table.insert(current_neighbor_dirs, {x=0,y=-1,z=0})
end
else
if nh.climbable(current_node) then
table.insert(current_neighbor_dirs, {x=0,y=1,z=0})
current_max_jump = 0
end
table.insert(current_neighbor_dirs, {x=0,y=-1,z=0})
end
if current_max_jump > 0 then
local below_pos = vector.offset(current_pos, 0, -1, 0)
local below_node = get_node(below_pos)
if nh.climbable(below_node) then
current_max_jump = 0
end
end
end
-- Prevent jumping from disable_jump nodes (if enabled)
if respect_disable_jump and max_jump > 0 then
local current_jumpable = jumpable(current_node)
local below_current_pos = table.copy(current_pos)
below_current_pos.y = below_current_pos.y - 1
local below_current_node = get_node(below_current_pos)
local below_jumpable = jumpable(below_current_node)
if not current_jumpable or not below_jumpable then
current_max_jump = 0
end
end
local neighbors = {}
for n=1, #current_neighbor_dirs do
local ndir = current_neighbor_dirs[n]
local x, y, z = ndir.x, ndir.y, ndir.z
local neighbor_pos = {x = current_pos.x + x, y = current_pos.y + y, z = current_pos.z + z}
if vector.in_area(neighbor_pos, min_pos, max_pos) then
local neighbor = get_node(neighbor_pos)
-- Check height clearance of raw (unmodified) neighbor
if check_height_clearance(neighbor_pos, clear_height-1, nh, get_node) then
-- Get floor position of neighbor. Implements jumping up or falling down.
local neighbor_floor
if y == 0 then
neighbor_floor = get_neighbor_floor_pos(neighbor_pos, current_pos, clear_height,
current_max_jump, current_max_drop, climb, nh, get_node)
-- In case of Y change, we do a climb check
elseif climb then
if not nh.blocking(neighbor) then
local safe_floor = true
-- If we climb downwards, check if the node below our destination
-- is safe to stand on
if y < 0 then
local below = vector.offset(neighbor_pos, 0, -1, 0)
local bnode = get_node(below)
safe_floor = nh.walkable(bnode) or nh.climbable(bnode)
end
if safe_floor then
neighbor_floor = neighbor_pos
end
end
end
if neighbor_floor then
-- Check height clearance of modified neighbor
if check_height_clearance(neighbor_floor, clear_height-1, nh, get_node) then
local hash = minetest.hash_node_position(neighbor_floor)
table.insert(neighbors, {
hash = hash,
pos = neighbor_floor,
})
end
end
end
end
end
for _, neighbor in pairs(neighbors) do
local in_closed_list = get_search_node(closed_set, neighbor.hash) ~= nil
if neighbor.hash ~= current_hash and not in_closed_list then
local g = 0 -- cost from start
local h -- estimated cost from search node to finish
local f -- g+h
local neighbor_cost = current_data.g + get_neighbor_cost(current_data.pos, neighbor.pos, get_node, get_floor_cost)
local neighbor_data = get_search_node(open_set, neighbor.hash)
local neighbor_exists
if neighbor_data then
g = neighbor_data.g
neighbor_exists = true
else
neighbor_exists = false
end
if not neighbor_exists or neighbor_cost < g then
h = get_neighbor_cost(neighbor.pos, pos2, get_node, get_floor_cost)
g = neighbor_cost
f = g + h
set_search_node(open_set, neighbor.hash, {
pos = neighbor.pos,
parent = current_hash,
f = f,
g = g,
h = h,
})
end
end
end
if open_set_size > MAX_OPEN then
-- Path complexity limit reached
return nil, "path_complexity_reached"
end
local end_time = minetest.get_us_time()
if (end_time - start_time)/1000000 > timeout then
-- Aborting due to timeout
return nil, "timeout"
end
end
-- No path exists within searched area
return nil, "no_path"
end