mirror of
https://github.com/ElCeejo/creatura.git
synced 2025-05-21 15:23:17 -04:00
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.
632 lines
No EOL
23 KiB
Lua
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 |