Compare commits

...

4 Commits

4 changed files with 586 additions and 0 deletions

View File

@ -0,0 +1,79 @@
local modname = minetest.get_current_modname()
local modpath = minetest.get_modpath(modname)
local storage = minetest.get_mod_storage()
local mod = mcl2_pathfinding
dofile(modpath.."/functions.lua")
minetest.register_async_dofile(modpath.."/functions.lua")
local EXPAND_SIZE = mod.EXPAND_SIZE
local FALL_DAMAGE_DISTANCE = mod.FALL_DAMAGE_DISTANCE
local MOB_HEIGHT_EXPAND_SIZE = mod.MOB_HEIGHT_EXPAND_SIZE
mod.load_pathfinding_data = function(pos, callback)
local state = {
content_id_cache = {},
navigation_rules = {},
inverted_navigation_rules = {},
navigation_lut = {
compression_mapping = {
count = 0,
},
},
node_base = vector.new(
math.floor(pos.x / 16) * 16,
math.floor(pos.y / 16) * 16,
math.floor(pos.z / 16) * 16
)
}
-- Get voxel data
local node_base = state.node_base
local vm = minetest.get_voxel_manip(); state.vm = vm
state.bound = {
min = vector.offset(node_base, 0 - EXPAND_SIZE, 0 - FALL_DAMAGE_DISTANCE, 0 - EXPAND_SIZE),
max = vector.offset(node_base, 16 + EXPAND_SIZE, 16 + MOB_HEIGHT_EXPAND_SIZE, 16 + EXPAND_SIZE)
}
local emin,emax = vm:read_from_map( state.bound.min, state.bound.max )
state.min = emin
state.max = emax
local data = vm:get_data(); state.data = data
-- Hash the map data to compare with the existing calculated data
local current_hash = mod.hash_voxel_data(data, emin, emax)
-- Check the current voxel data against existing data to see if we can reuse a previously computed LUT
local storage_key = tostring(node_base.x)..","..tostring(node_base.y)..","..tostring(node_base.z)
local old_data = storage:get_string(storage_key)
local need_refresh = true
if old_data then
local ds = minetest.deserialize(old_data)
if ds and ds.hash == current_hash then
callback(ds.lut)
return
end
end
-- Note: because this function can run inside an asynchronous worker thread, this function can't
-- access anything besides globals
local function process(state)
local mod = mcl2_pathfinding
return mod.process_recalculate_pathfinding_data(state)
end
local function finish(node_base, lut)
local storage_key = tostring(node_base.x)..","..tostring(node_base.y)..","..tostring(node_base.z)
local new_data = minetest.serialize({ hash = current_hash, lut = lut })
storage:set_string(storage_key,new_data)
callback(data)
end
-- use async environemt if supported
if minetest.handle_async then
minetest.handle_async(process, finish, state)
-- and use synchronous processing if not supported
else
finish(process(state))
end
end

View File

@ -0,0 +1,465 @@
mcl2_pathfinding = mcl2_pathfinding or {}
local mod = mcl2_pathfinding
local modname = "mcl2_pathfinding"
local EXPAND_SIZE = 1
mod.EXPAND_SIZE = EXPAND_SIZE
local FALL_DAMAGE_DISTANCE = 3
mod.FALL_DAMAGE_DISTANCE = FALL_DAMAGE_DISTANCE
local MOB_HEIGHT_EXPAND_SIZE = 2
mod.MOB_HEIGHT_EXPAND_SIZE = MOB_HEIGHT_EXPAND_SIZE
local function get_node_at(state, pos, idx)
if not idx then idx = state.area:index(pos.x, pos.y, pos.z) end
local cid = state.data[idx]
if not cid then return "unloaded" end
if state.content_id_cache[cid] then return state.content_id_cache[cid] end
local name = minetest.get_name_from_content_id(cid)
state.content_id_cache[cid] = name
return name
end
local air_height_topside_cache = {}
local function get_node_air_height_topside(state, pos, name)
if not name then name = get_node_at(state, pos) end
if name == "air" then return 1 end
local nodedef = minetest.registered_nodes[name]
if not nodedef then
print("Missing node definition for "..tostring(name)..", treating as solid")
return 0
end
if nodedef.drawtype == "nodebox" then
local max = -0.5
if nodedef.node_box.type == "fixed" then
for _,part in ipairs(nodedef.node_box.fixed) do
if part[5] > max then
max = part[5]
end
end
local air_height_topside = 0.5 - max
air_height_topside_cache[name] = air_height_topside
return air_height_topside
end
else
-- TODO: finish implement partial block heights
end
return 0
end
local air_height_underside_cache = {}
local function get_node_air_height_underside(state, pos, name)
if not name then name = get_node_at(state, pos) end
if name == "air" then return 1 end
local nodedef = minetest.registered_nodes[name]
if not nodedef then
print("Missing node definition for "..tostring(name)..", treating as solid")
return 0
end
local node_air_height_underside = nodedef["_"..modname.."air_height_underside"] or air_height_underside_cache[name]
if node_air_height_underside then return node_air_height_underside end
if nodedef.drawtype == "nodebox" then
local min = 0.5
if nodedef.node_box.type == "fixed" then
for _,part in ipairs(nodedef.node_box.fixed) do
if part[5] < min then
min = part[5]
end
end
-- Cache for future use
local air_height_underside = 0.5 + min
air_height_underside_cache[name] = air_height_underside
return air_height_underside
end
end
-- TODO: implement partial block heights
return 0
end
local RULE_NO_NAV = { no_nav = true }
local NODE_NEIGHBORS = {
vector.new( 1,0, 0),
vector.new(-1,0, 0),
vector.new( 0,0, 1),
vector.new( 0,0,-1),
}
local NODE_NEIGHBOR_JUMPS = {
vector.new( 1,1, 0),
vector.new(-1,1, 0),
vector.new( 0,1, 1),
vector.new( 0,1,-1),
}
local NODE_NEIGHBOR_DROP_ONE = {
vector.new( 1,-1, 0),
vector.new(-1,-1, 0),
vector.new( 0,-1, 1),
vector.new( 0,-1,-1),
}
local NODE_NEIGHBOR_DROP_TWO = {
vector.new( 1,-1, 0),
vector.new(-1,-1, 0),
vector.new( 0,-1, 1),
vector.new( 0,-1,-1),
}
local ACTION_WALK = nil
local ACTION_JUMP = {}
local ACTION_OPEN = {}
local ACTION_CLIMB = {}
local function get_navigation_rules_for_node(state, node_pos)
local area = state.area
local idx = area:index(node_pos.x,node_pos.y,node_pos.z)
if not idx then return RULE_NO_NAV end
-- Check if we already have generated rules for this node
local navigation_rules = state.navigation_rules
if navigation_rules[idx] then return navigation_rules[idx] end
-- Bounds check
if node_pos.x < state.bound.min.x then return RULE_NO_NAV end
if node_pos.y < state.bound.min.y then return RULE_NO_NAV end
if node_pos.z < state.bound.min.z then return RULE_NO_NAV end
if node_pos.x > state.bound.max.x then return RULE_NO_NAV end
if node_pos.y > state.bound.max.y then return RULE_NO_NAV end
if node_pos.z > state.bound.max.z then return RULE_NO_NAV end
-- Lookup node information
local name = get_node_at(state, node_pos, idx)
local nodedef = minetest.registered_nodes[name]
-- Check if navigable: Need to have at least 2 height for horizontal travel thru this node
local height = 0
if name == "air" then
-- Make sure we have a solid block below us
if get_node_air_height_topside(state, vector.offset(node_pos,0,-1,0)) == 1 then
-- Would fall thru and navigate thru node below here
navigation_rules[idx] = RULE_NO_NAV
return RULE_NO_NAV
end
height = 1
end
-- Handle solid and semi-solid block
height = get_node_air_height_topside(state, node_pos, name)
if height == 1 then
-- Solid
navigation_rules[idx] = RULE_NO_NAV
return RULE_NO_NAV
end
local node_underside_height = 0
local above_block = node_pos
repeat
above_block = vector.offset(above_block,0,1,0)
node_underside_height = get_node_air_height_underside(state, above_block)
height = height + node_underside_height
until node_underside_height ~= 1 or height >= 3
-- Handle too small spaces
if height < 2 then
navigation_rules[idx] = RULE_NO_NAV
return RULE_NO_NAV
end
-- We can navigate thru this node
local rule = {
height = height,
size = 1, -- TODO: calculate a clearance value for mobs larger than 1 block wide
pos = node_pos,
neighbors = {},
actions = {},
}
navigation_rules[idx] = rule
-- Update compressed rules map. This is used to store lookup tables in a smaller amount of memory
-- for better performance
local compression_mapping = state.navigation_lut.compression_mapping
compression_mapping.count = compression_mapping.count + 1
compression_mapping[minetest.hash_node_position(node_pos)] = {
idx = compression_mapping.count,
pos = node_pos
}
-- Now check neighbors to see if we can move there
local neighbors = rule.neighbors
local actions = rule.actions
local function check_neighbors(state, node_pos, dir, neighbors, actions, i)
-- Check direct neighbor
local neighbor_pos = vector.add(node_pos, dir)
local neighbor_rule = get_navigation_rules_for_node(state, neighbor_pos)
if not neighbor_rule.no_nav then
neighbors[#neighbors + 1] = dir
return
end
-- Check jumps
neighbor_rule = get_navigation_rules_for_node(state, vector.offset(neighbor_pos,0,1,0))
if not neighbor_rule.no_nav then
local idx = #neighbors + 1
neighbors[idx] = NODE_NEIGHBOR_JUMPS[i]
actions[idx] = ACTION_JUMP
return
end
-- Check drop one block
neighbor_rule = get_navigation_rules_for_node(state, vector.offset(neighbor_pos,0,-1,0))
if not neighbor_rule.no_nav then
neighbors[#neighbors + 1] = NODE_NEIGHBOR_DROP_ONE[i]
return
end
-- Check drop two blocks
neighbor_rule = get_navigation_rules_for_node(state, vector.offset(neighbor_pos,0,-1,0))
if not neighbor_rule.no_nav then
neighbors[#neighbors + 1] = NODE_NEIGHBOR_DROP_TWO[i]
end
end
for i,dir in ipairs(NODE_NEIGHBORS) do
check_neighbors(state, node_pos, dir, neighbors, actions, i)
end
--print("node_pos="..tostring(node_pos)..",name="..tostring(name)..",rule="..dump(rule))
return rule
end
local function gen_navigation_rules(state)
local miny = state.bound.min.y
local maxy = state.bound.max.y
local minx = state.bound.min.x
local maxx = state.bound.max.x
-- Generate raw navigation rules for every node in the mapblock
for z = (state.bound.min.z),(state.bound.max.z) do
for y = miny,maxy do
for x = minx,maxx do
get_navigation_rules_for_node(state, vector.new(x,y,z))
end
end
end
-- Invert navigation rules. Currently the data is all connections that you can go
-- to from a given node. We need all the connections that will get you to a given
-- node so that we can generate a gradient lookup table to get to any node from
-- any other node in this mapblock.
--
-- While we are doing this, we will also create the rule index for the lookup table
local area = state.area
local inverted_navigation_rules = state.inverted_navigation_rules
local rule_table = { index={} }
state.rule_table = rule_table
for vm_idx,rule in pairs(state.navigation_rules) do
if rule.neighbors then
for _,neighbor_dir in pairs(rule.neighbors) do
local neighbor_pos = vector.add(neighbor_dir, rule.pos)
local neighbor_idx = area:index(neighbor_pos.x, neighbor_pos.y, neighbor_pos.z)
if neighbor_idx then
local neighbor_inverted_rules = inverted_navigation_rules[neighbor_idx]
if not neighbor_inverted_rules then
neighbor_inverted_rules = { count=0 }
inverted_navigation_rules[neighbor_idx] = neighbor_inverted_rules
end
local new_count = neighbor_inverted_rules.count + 1
neighbor_inverted_rules.count = new_count
neighbor_inverted_rules[new_count] = vector.multiply(neighbor_dir, -1)
end
local dir_hash = minetest.hash_node_position(neighbor_dir)
if not rule_table.index[dir_hash] then
local idx = #rule_table + 1
rule_table[idx] = neighbor_dir
rule_table.index[dir_hash] = idx
end
end
end
end
rule_table.present = nil
end
local HEX = { "0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "A", "B", "C", "D", "E", "F" }
local function gen_navigation_lut(state, target_node_pos)
-- Based on Dijkstra's Algorithm for shortest path calculation
-- We want the minimum distance from every navigable node to
-- `node_pos'
local comp_map = state.navigation_lut.compression_mapping
local hash_pos = minetest.hash_node_position
local area = state.area
local distances = {}
local inv_nav_rules = state.inverted_navigation_rules
-- Start with `node_pops' as the only open node with a distance of 0
local open = { target_node_pos }
distances[comp_map[hash_pos(target_node_pos)].idx] = 0
-- While we have nodes left to compute a distance for
while #open > 0 do
-- Select next node to process. This will be the node
-- with the shortest dinstance of all open nodes
local next_node_idx = 1
local min_distance = nil
for i,node in ipairs(open) do
local distance = distances[comp_map[hash_pos(node)].idx]
if not min_distance or distance < min_distance then
next_node_idx = i
min_distance = distance
end
end
-- Remove the next node from the open list
local node_pos = open[next_node_idx]
open[next_node_idx] = open[#open]
open[#open] = nil
-- Process the node
local node_hash = hash_pos(node_pos)
local node_idx = area:index(node_pos.x, node_pos.y, node_pos.z)
local inv_nav_rule = inv_nav_rules[node_idx]
local distance = min_distance
-- Update neighbor distances
if inv_nav_rule then
for i=1,inv_nav_rule.count do
local neighbor_pos = vector.add( inv_nav_rule[i], node_pos )
local neighbor_idx = comp_map[hash_pos(neighbor_pos)].idx
local neighbor_dist = distances[neighbor_idx]
if not neighbor_dist then
-- Neighbor hasn't been seen before, add to open list
open[#open + 1] = neighbor_pos
end
if not neighbor_dist or distance + 1 < neighbor_dist then
distances[neighbor_idx] = distance + 1
end
end
end
end
-- Now that we have the shortest distance from every navigable node to the target
-- node, we can calculate a lookup table that gives us the next neighbor connection
-- to take that is along the shortest path to the target node
local best_directions = {}
local navigation_rules = state.navigation_rules
local rule_table_index = state.rule_table.index
for _,v in pairs(comp_map) do
local pos = v.pos
local vm_idx = area:index(pos.x, pos.y, pos.z)
local rule = navigation_rules[vm_idx]
-- Pick the neighbor rule that has the lowest distance to the target node
-- This neighbor is the next step along the shortest past to the target
-- node from this position
local best_rule = nil
local best_distance = nil
for i,dir in ipairs(rule.neighbors) do
local neighbor_pos = vector.add(dir, rule.pos)
local neighbor_idx = comp_map[hash_pos(neighbor_pos)].idx
local neighbor_distance = distances[neighbor_idx]
if not best_distance or ( neighbor_distance and neighbor_distance < best_distance ) then
best_distance = distances[neighbor_idx]
best_rule = dir
end
end
-- Store the best direction as an index into the rule table for this block
local value = 0
if best_rule then
local best_dir_hash = hash_pos(best_rule)
value = rule_table_index[best_dir_hash]
end
best_directions[comp_map[hash_pos(rule.pos)].idx] = value
end
-- Create the lookup table
local join_list = {}
for i,best_dir_idx in ipairs(best_directions) do
join_list[i] = string.char(best_dir_idx) --HEX[best_dir_idx+1]
end
local lut = table.concat(join_list,"")
local target_node_hash = hash_pos(target_node_pos)
local target_node_idx = comp_map[target_node_hash].idx
state.navigation_lut[target_node_idx] = lut
print(tostring(target_node_idx).." => "..lut)
end
-- Precomputes a lookup table for each node to get the next direction to take to get to any other node
-- in the map block. This will take some time, but shouldn't have to be done very often (only when a change
-- in the map data makes it impossible to get to the target node)
local function gen_all_navigation_luts(state)
local navigation_lut = state.navigation_lut
local count = navigation_lut.compression_mapping.count
navigation_lut.compression_mapping.count = nil
-- Compute the navigation data for each node that is navigable
for _,v in pairs(navigation_lut.compression_mapping) do
local pos = v.pos
gen_navigation_lut(state, pos)
end
end
-- Calculate a hash value given voxel data
local function hash_voxel_data(data, min, max)
local area = VoxelArea:new{ MinEdge = min, MaxEdge = max }
local content_id_cache = {}
local node_list = {}
local minx = min.x
local maxx = max.x
local miny = min.x
local maxy = max.x
for z = min.z,max.z do
for y = miny,maxy do
for x = minx,maxx do
local idx = area:index(x,y,z)
local cid = data[idx]
local node_name = "unloaded"
if cid then
node_name = content_id_cache[cid]
if not node_name then
node_name = minetest.get_name_from_content_id(cid)
content_id_cache[cid] = node_name
end
end
node_list[#node_list + 1] = node_name
end
end
end
return minetest.sha1(table.concat(node_list, ""))
end
mod.hash_voxel_data = hash_voxel_data
-- This packages the lookup table data into a more compact representation for disk storage
local function pack_navigation_lut(navigation_lut)
return minetest.encode_base64(minetest.compress(minetest.serialize(navigation_lut), "zstd" ))
end
mod.pack_navigation_lut = pack_navigation_lut
-- The inverse of pack_navigation_lut
local function unpack_navigation_lut(packed)
local binary = minetest.decode_base64(packed)
local uncompressed = minetest.decompress(binary, "zstd")
return minetest.deserialize(uncompressed)
end
mod.unpack_navigation_lut = unpack_navigation_lut
-- Calculate the navigation lookup table for a given region
local function process_recalculate_pathfinding_data(state)
state.area = VoxelArea:new{ MinEdge = state.min, MaxEdge = state.max }
gen_navigation_rules(state)
gen_all_navigation_luts(state)
return state.node_base,pack_navigation_lut(state.navigation_lut)
end
mod.process_recalculate_pathfinding_data = process_recalculate_pathfinding_data

View File

@ -0,0 +1,38 @@
local modname = minetest.get_current_modname()
local modpath = minetest.get_modpath(modname)
local S = minetest.get_translator(modname)
local mod = {}
mcl2_pathfinding = mod
dofile(modpath.."/api.lua")
minetest.register_chatcommand("test_pathfinding",{
description = S("Test pathfinding"),
params = "",
privs = {},
func = function(name)
local player = minetest.get_player_by_name(name)
mcl2_pathfinding.load_pathfinding_data(player:get_pos(),function(lut)
return
end)
end
})
if minetest.get_modpath("worldedit_commands") then
minetest.register_chatcommand("test_path",{
description = S("Test path planning"),
params = "",
privs = { worldedit=true },
func = function(name)
local start = worldedit.pos1[name]
local stop = worldedit.pos2[name]
mcl2_pathfinding.clear_path_debug()
local path = mcl2_pathfinding.find_path(start, stop)
mcl2_pathfinding.debug_path(path)
end
})
end

View File

@ -0,0 +1,4 @@
name = mcl2_pathfinding
author = teknomunk
description = Adds a pathfinding API
optional_depends = worldedit_commands