creatura/pathfinder.lua
Jordan Leppert 076e8423ae Movement Improvements
Pathfinding movement functions for A* and theta have been combined into one generic function.
Ensure next goal is always set if there is a valid path.
Checking if mob has reached the next goal improved - now works no matter the mob height, and the height of the node below the goal.
mob:pos_in_box now can take a size param that is a table. Using this to add stepheight to the box height, to fix issues with mobs stepping up onto next node before hitting the goal.
Mobs now won't overshoot their goal, if they're very close to it (less than mob width) their speed reduces.
The more a mob is turning, the less forward velocity it has. This should fix spinning a lot.

mob:pos_in_box now can take a size param that is a table, containing the separate width and height.

pathfinding.moveable no longer pads an extra 0.2 around the width of mob when checking if there is sufficient space for the mob.

pathfinding.get_neighbors now uses max_fall for the maximum distance we can go down, instead of stepheight.
2022-02-23 18:00:42 +00:00

632 lines
No EOL
23 KiB
Lua

-----------------
-- Pathfinding --
-----------------
local a_star_alloted_time = tonumber(minetest.settings:get("creatura_a_star_alloted_time")) or 500
local theta_star_alloted_time = tonumber(minetest.settings:get("creatura_theta_star_alloted_time")) or 700
local floor = math.floor
local abs = math.abs
local function is_node_walkable(name)
local def = minetest.registered_nodes[name]
return def and def.walkable
end
local function is_node_liquid(name)
local def = minetest.registered_nodes[name]
return def and def.drawtype == "liquid"
end
local function moveable(pos, width, height)
local pos1 = {
x = pos.x - width,
y = pos.y,
z = pos.z - width,
}
local pos2 = {
x = pos.x + width,
y = pos.y,
z = pos.z + width,
}
for z = pos1.z, pos2.z do
for x = pos1.x, pos2.x do
local pos3 = {x = x, y = pos.y + height, z = z}
local pos4 = {x = x, y = pos.y, z = z}
local ray = minetest.raycast(pos3, pos4, false, false)
for pointed_thing in ray do
if pointed_thing.type == "node" then
return false
end
end
end
end
return true
end
creatura.get_node_height = function(name, force_node_box)
local def = minetest.registered_nodes[name]
if not def then return 0.5 end
if def.walkable then
if def.drawtype == "nodebox" then
if def.collision_box and not force_node_box
and (def.collision_box.type == "fixed" or def.collision_box.type == "connected") then
if type(def.collision_box.fixed[1]) == "number" then
return 0.5 + def.collision_box.fixed[5]
elseif type(def.collision_box.fixed[1]) == "table" then
return 0.5 + def.collision_box.fixed[1][5]
else
return 1
end
elseif def.node_box
and (def.node_box.type == "fixed" or def.node_box.type == "connected") then
if type(def.node_box.fixed[1]) == "number" then
return 0.5 + def.node_box.fixed[5]
elseif type(def.node_box.fixed[1]) == "table" then
return 0.5 + def.node_box.fixed[1][5]
else
return 1
end
else
return 1
end
else
return 1
end
else
return 1
end
end
creatura.get_ground_level = function(pos, max_up, max_down, current_node_height)
for y = math.ceil(max_up) + 1, -(math.ceil(max_down)) - 1, -1 do
local pos2 = vector.new(pos.x, pos.y + y, pos.z)
local node = minetest.get_node(pos2)
local node_under = minetest.get_node(pos2 + vector.new(0, -1, 0))
if not is_node_walkable(node.name) and is_node_walkable(node_under.name) then
local node_height = creatura.get_node_height(node_under.name)
local y_diff = y - 1 + node_height - current_node_height
if y_diff <= max_up and y_diff >= (-max_down) then
return pos2
end
end
end
return nil
end
local function get_distance(start_pos, end_pos)
local distX = abs(start_pos.x - end_pos.x)
local distZ = abs(start_pos.z - end_pos.z)
if distX > distZ then
return 14 * distZ + 10 * (distX - distZ)
else
return 14 * distX + 10 * (distZ - distX)
end
end
local function get_distance_to_neighbor(start_pos, end_pos)
local distX = abs(start_pos.x - end_pos.x)
local distY = abs(start_pos.y - end_pos.y)
local distZ = abs(start_pos.z - end_pos.z)
if distX > distZ then
return (14 * distZ + 10 * (distX - distZ)) * (distY + 1)
else
return (14 * distX + 10 * (distZ - distX)) * (distY + 1)
end
end
local function is_on_ground(pos)
local ground = {
x = pos.x,
y = pos.y - 1,
z = pos.z
}
if is_node_walkable(minetest.get_node(ground).name) then
return true
end
return false
end
local function vec_raise(v, n)
return {x = v.x, y = v.y + n, z = v.z}
end
-- Find a path from start to goal
local function get_neighbors(self, pos, goal, swim, fly, climb, tbl, open, closed)
local width = self.width
local height = self.height
local result = {}
local max_up = self.stepheight or 1
local max_down = self.max_fall or 1
local node_name = minetest.get_node(pos).name
-- Get the height of the node collision box (and of its node box, if different)
local node_height = 0
local node_height_node_box = 0
if is_node_walkable(node_name) then
node_height = creatura.get_node_height(node_name)
node_height_node_box = creatura.get_node_height(node_name, true)
else
node_height = creatura.get_node_height(minetest.get_node(pos + vector.new(0, -1, 0)).name) - 1
node_height_node_box = creatura.get_node_height(minetest.get_node(pos + vector.new(0, -1, 0)).name, true) - 1
end
-- Calculate the height difference between the collision and node boxes
-- (This is because the mob will be standing on the collision box, but the
-- raycast checks will collide with the node box, so we must avoid it)
local node_height_diff = node_height_node_box - node_height
for i = 1, #tbl do
local neighbor = vector.add(pos, tbl[i])
if not open[minetest.hash_node_position(neighbor)]
and not closed[minetest.hash_node_position(neighbor)] then
local neighbor_x
local neighbor_z
if tbl[i].y == 0
and not fly
and not swim then
neighbor = creatura.get_ground_level(neighbor, max_up, max_down, node_height)
if neighbor and tbl[i].x ~= 0 and tbl[i].z ~= 0 then
-- This is a diagonal, check both corners are clear and same Y
neighbor_x = creatura.get_ground_level(vector.new(neighbor.x, neighbor.y, pos.z), max_up, max_down, node_height)
neighbor_z = creatura.get_ground_level(vector.new(pos.x, neighbor.y, neighbor.z), max_up, max_down, node_height)
if not neighbor_x or not neighbor_z
or neighbor_x.y ~= neighbor.y
or neighbor_z.y ~= neighbor.y then
neighbor = nil
end
end
end
if neighbor then
local can_move = true
if swim then
local neighbor_node = minetest.get_node(neighbor)
can_move = is_node_liquid(neighbor_node.name)
end
-- Adjust entity Y in clearance check by this much
local y_adjustment = -0.49
-- Adjust entity height in clearance check by this much
local h_adjustment = -0.02
-- Get the height of the node collision box, and the difference to the node box
local neighbor_height = creatura.get_node_height(minetest.get_node(neighbor + vector.new(0, -1, 0)).name) - 1
local neighbor_height_node_box = creatura.get_node_height(minetest.get_node(neighbor + vector.new(0, -1, 0)).name, true) - 1
local neighbor_height_diff = neighbor_height_node_box - neighbor_height
-- Check there is enough vertical clearance to move to this node
local height_clearance = math.max(pos.y + node_height - neighbor.y - neighbor_height, 0)
if not moveable(vec_raise(neighbor, y_adjustment + neighbor_height + neighbor_height_diff), width, height + h_adjustment + height_clearance - neighbor_height_diff) then
can_move = false
end
if tbl[i].x ~= 0 and tbl[i].z ~= 0 then
-- If target node is diagonal, check the orthogonal nodes too
if not moveable(vec_raise(neighbor_x, y_adjustment + neighbor_height + neighbor_height_diff), width, height + h_adjustment + height_clearance + neighbor_height_diff)
or not moveable(vec_raise(neighbor_z, y_adjustment + neighbor_height + neighbor_height_diff), width, height + h_adjustment + height_clearance + neighbor_height_diff) then
can_move = false
end
end
-- If we're going upwards, check there's enough clearance above our head
height_clearance = math.max(neighbor.y + neighbor_height - pos.y - node_height, 0)
if height_clearance > 0 and not moveable(vec_raise(pos, y_adjustment + node_height + node_height_diff), width, height + h_adjustment + height_clearance - node_height_diff) then
can_move = false
end
if (can_move
or (climb
and neighbor.x == pos.x
and neighbor.z == pos.z))
and (not swim
or is_node_liquid(minetest.get_node(neighbor).name)) then
table.insert(result, neighbor)
end
end
end
end
return result
end
function creatura.find_path(self, start, goal, obj_width, obj_height, max_open, climb, fly, swim)
climb = climb or false
fly = fly or false
swim = swim or false
start = self._path_data.start or start
self._path_data.start = start
local path_neighbors = {
{x = 1, y = 0, z = 0},
{x = 1, y = 0, z = 1},
{x = 0, y = 0, z = 1},
{x = -1, y = 0, z = 1},
{x = -1, y = 0, z = 0},
{x = -1, y = 0, z = -1},
{x = 0, y = 0, z = -1},
{x = 1, y = 0, z = -1}
}
if climb then
table.insert(path_neighbors, {x = 0, y = 1, z = 0})
end
if fly
or swim then
path_neighbors = {
-- Central
{x = 1, y = 0, z = 0},
{x = 0, y = 0, z = 1},
{x = -1, y = 0, z = 0},
{x = 0, y = 0, z = -1},
-- Directly Up or Down
{x = 0, y = 1, z = 0},
{x = 0, y = -1, z = 0}
}
end
local function find_path(self, start, goal)
local us_time = minetest.get_us_time()
start = {
x = floor(start.x + 0.5),
y = floor(start.y + 0.5),
z = floor(start.z + 0.5)
}
goal = {
x = floor(goal.x + 0.5),
y = floor(goal.y + 0.5),
z = floor(goal.z + 0.5)
}
if goal.x == start.x
and goal.z == start.z then -- No path can be found
return nil
end
local openSet = self._path_data.open or {}
local closedSet = self._path_data.closed or {}
local start_index = minetest.hash_node_position(start)
openSet[start_index] = {
pos = start,
parent = nil,
gScore = 0,
fScore = get_distance(start, goal)
}
local count = self._path_data.count or 1
while count > 0 do
if minetest.get_us_time() - us_time > a_star_alloted_time then
self._path_data = {
start = start,
open = openSet,
closed = closedSet,
count = count
}
return
end
-- Initialize ID and data
local current_id
local current
-- Get an initial id in open set
for i, v in pairs(openSet) do
current_id = i
current = v
break
end
-- Find lowest f cost
for i, v in pairs(openSet) do
if v.fScore < current.fScore then
current_id = i
current = v
end
end
-- Add lowest fScore to closedSet and remove from openSet
openSet[current_id] = nil
closedSet[current_id] = current
self._path_data.open = openSet
self._path_data.closedSet = closedSet
-- Reconstruct path if end is reached
if ((is_on_ground(goal)
or fly)
and current_id == minetest.hash_node_position(goal))
or (not fly
and not is_on_ground(goal)
and goal.x == current.pos.x
and goal.z == current.pos.z) then
local path = {}
local fail_safe = 0
for k, v in pairs(closedSet) do
fail_safe = fail_safe + 1
end
repeat
if not closedSet[current_id] then return end
table.insert(path, closedSet[current_id].pos)
current_id = closedSet[current_id].parent
until current_id == start_index or #path >= fail_safe
if not closedSet[current_id] then self._path_data = {} return nil end
table.insert(path, closedSet[current_id].pos)
local reverse_path = {}
repeat table.insert(reverse_path, table.remove(path)) until #path == 0
self._path_data = {}
return reverse_path
end
count = count - 1
local adjacent = get_neighbors(self, current.pos, goal, swim, fly, climb, path_neighbors, openSet, closedSet)
-- Go through neighboring nodes
for i = 1, #adjacent do
local neighbor = {
pos = adjacent[i],
parent = current_id,
gScore = 0,
fScore = 0
}
local neighbor_id = minetest.hash_node_position(neighbor.pos)
local neighbour_gScore = current.gScore + get_distance_to_neighbor(current.pos, neighbor.pos)
if (not openSet[neighbor_id]
or neighbour_gScore < openSet[neighbor_id].gScore)
and not closedSet[neighbor_id] then
if not openSet[neighbor_id] then
count = count + 1
end
local hCost = get_distance_to_neighbor(neighbor.pos, goal)
neighbor.gScore = neighbour_gScore
neighbor.fScore = neighbour_gScore + hCost
openSet[neighbor_id] = neighbor
end
end
if count > (max_open or 100) then
self._path_data = {}
return
end
end
self._path_data = {}
return nil
end
return find_path(self, start, goal)
end
------------
-- Theta* --
------------
function get_line_of_sight(a, b)
local steps = floor(vector.distance(a, b))
local line = {}
for i = 0, steps do
local pos
if steps > 0 then
pos = {
x = a.x + (b.x - a.x) * (i / steps),
y = a.y + (b.y - a.y) * (i / steps),
z = a.z + (b.z - a.z) * (i / steps)
}
else
pos = a
end
table.insert(line, pos)
end
if #line < 1 then
return false
else
for i = 1, #line do
local node = minetest.get_node(line[i])
if minetest.registered_nodes[node.name].walkable then
return false
end
end
end
return true
end
function creatura.find_theta_path(self, start, goal, obj_width, obj_height, max_open, climb, fly, swim)
climb = climb or false
fly = fly or false
swim = swim or false
start = self._path_data.start or start
self._path_data.start = start
local path_neighbors = {
{x = 1, y = 0, z = 0},
{x = 0, y = 0, z = 1},
{x = -1, y = 0, z = 0},
{x = 0, y = 0, z = -1},
}
if climb then
table.insert(path_neighbors, {x = 0, y = 1, z = 0})
end
if fly
or swim then
path_neighbors = {
-- Central
{x = 1, y = 0, z = 0},
{x = 0, y = 0, z = 1},
{x = -1, y = 0, z = 0},
{x = 0, y = 0, z = -1},
-- Directly Up or Down
{x = 0, y = 1, z = 0},
{x = 0, y = -1, z = 0}
}
end
local function find_path(self, start, goal)
local us_time = minetest.get_us_time()
start = {
x = floor(start.x + 0.5),
y = floor(start.y + 0.5),
z = floor(start.z + 0.5)
}
goal = {
x = floor(goal.x + 0.5),
y = floor(goal.y + 0.5),
z = floor(goal.z + 0.5)
}
if goal.x == start.x
and goal.z == start.z then -- No path can be found
return nil
end
local openSet = self._path_data.open or {}
local closedSet = self._path_data.closed or {}
local start_index = minetest.hash_node_position(start)
openSet[start_index] = {
pos = start,
parent = nil,
gScore = 0,
fScore = get_distance(start, goal)
}
local count = self._path_data.count or 1
while count > 0 do
if minetest.get_us_time() - us_time > theta_star_alloted_time then
self._path_data = {
start = start,
open = openSet,
closed = closedSet,
count = count
}
return
end
-- Initialize ID and data
local current_id
local current
-- Get an initial id in open set
for i, v in pairs(openSet) do
current_id = i
current = v
break
end
-- Find lowest f cost
for i, v in pairs(openSet) do
if v.fScore < current.fScore then
current_id = i
current = v
end
end
-- Add lowest fScore to closedSet and remove from openSet
openSet[current_id] = nil
closedSet[current_id] = current
-- Reconstruct path if end is reached
if (is_on_ground(goal)
and current_id == minetest.hash_node_position(goal))
or (not is_on_ground(goal)
and goal.x == current.pos.x
and goal.z == current.pos.z) then
local path = {}
local fail_safe = 0
for k, v in pairs(closedSet) do
fail_safe = fail_safe + 1
end
repeat
if not closedSet[current_id] then return end
table.insert(path, closedSet[current_id].pos)
current_id = closedSet[current_id].parent
until current_id == start_index or #path >= fail_safe
if not closedSet[current_id] then self._path_data = {} return nil end
table.insert(path, closedSet[current_id].pos)
local reverse_path = {}
repeat table.insert(reverse_path, table.remove(path)) until #path == 0
self._path_data = {}
return reverse_path
end
count = count - 1
local adjacent = get_neighbors(self, current.pos, goal, swim, fly, climb, path_neighbors, openSet, closedSet)
-- Go through neighboring nodes
for i = 1, #adjacent do
local neighbor = {
pos = adjacent[i],
parent = current_id,
gScore = 0,
fScore = 0
}
if not openSet[minetest.hash_node_position(neighbor.pos)]
and not closedSet[minetest.hash_node_position(neighbor.pos)] then
local current_parent = closedSet[current.parent] or closedSet[start_index]
if not current_parent then
current_parent = openSet[current.parent] or openSet[start_index]
end
if current_parent
and get_line_of_sight(current_parent.pos, neighbor.pos) then
local temp_gScore = current_parent.gScore + get_distance_to_neighbor(current_parent.pos, neighbor.pos)
local new_gScore = 999
if openSet[minetest.hash_node_position(neighbor.pos)] then
new_gScore = openSet[minetest.hash_node_position(neighbor.pos)].gScore
end
if temp_gScore < new_gScore then
local hCost = get_distance_to_neighbor(neighbor.pos, goal)
neighbor.gScore = temp_gScore
neighbor.fScore = temp_gScore + hCost
neighbor.parent = minetest.hash_node_position(current_parent.pos)
if openSet[minetest.hash_node_position(neighbor.pos)] then
openSet[minetest.hash_node_position(neighbor.pos)] = nil
end
openSet[minetest.hash_node_position(neighbor.pos)] = neighbor
count = count + 1
end
else
local temp_gScore = current.gScore + get_distance_to_neighbor(current_parent.pos, neighbor.pos)
local new_gScore = 999
if openSet[minetest.hash_node_position(neighbor.pos)] then
new_gScore = openSet[minetest.hash_node_position(neighbor.pos)].gScore
end
if temp_gScore < new_gScore then
local hCost = get_distance_to_neighbor(neighbor.pos, goal)
neighbor.gScore = temp_gScore
neighbor.fScore = temp_gScore + hCost
if openSet[minetest.hash_node_position(neighbor.pos)] then
openSet[minetest.hash_node_position(neighbor.pos)] = nil
end
openSet[minetest.hash_node_position(neighbor.pos)] = neighbor
count = count + 1
end
end
end
end
if count > (max_open or 100) then
self._path_data = {}
return
end
end
self._path_data = {}
return nil
end
return find_path(self, start, goal)
end