Use A* for footpath route finding

master
Ciaran Gultnieks 2014-04-15 22:47:22 +01:00
parent e4c8f373e8
commit cb1dbba0d4
6 changed files with 309 additions and 68 deletions

View File

@ -308,6 +308,9 @@ Track the given person (location and distance away) on the HUD.
Use without a parameter to switch it off.
### /people footpath_update
Update the footpath network.
## Autonomy and Activation
People are 'autonomous' - i.e. they will continue to act even when no player
@ -345,13 +348,15 @@ navigation as a player would.
Path junctions are marked up by creating a 1x1x1 area above the junction
node. They are always named `path_xxxxx`, where `xxxxx` is the unique name for
that junction.
that junction - e.g. `path_My House`.
Additionally, above each node leading away from the junction, a 1x1x1 area
with the name `to_xxxxx` is used. In this case, `xxxxx` is a list of path
junction names, each with `;` appended - for example `to_My House;Your House;`.
One further option is `to_*` which indicates a route to anywhere not explicity
pointed to by another junction exit.
with the name `to_xxxxx` is used. In this case, `xxxxx` is the name of the
path node this direction leads to - e.g. `to_Your House`.
Changes to the footpath network only take effect when the `/people footpath_update`
command is used. At this point, if anything is wrong with the network, this
will be indicated, and the previous network will remain in effect.
If you tell a person to go to a named place (e.g. {"go", name="My House"}) they
will first look for `path_My House`, and then also find the nearest path

View File

@ -2,54 +2,6 @@
local dbg
if moddebug then dbg=moddebug.dbg("people") else dbg={v1=function() end,v2=function() end,v3=function() end} end
--- Find the next node along a footpath
-- All positions are exact (rounded) node positions.
-- @param curpos The position of a current footpath node
-- @param lastpos The position of the previous footpath node
-- @param samedironly True to only look in the current direction of travel
-- @return The position of the next footpath node, or nil
local footpath_findnext = function(curpos, lastpos, samedironly)
--dbg.v3("footpath_findnext, cur="..minetest.pos_to_string(curpos)..", last="..minetest.pos_to_string(lastpos)..", same="..dump(samedironly))
local xz
if samedironly then
xz = {}
else
xz = {{x=1,z=0}, {x=0,z=1}, {x=-1,z=0}, {x=0,z=-1}}
end
-- Favour the direction we're already going
-- TODO - that means we could check it twice (but then again, only
-- if we reach an invalid bit of footpath!
table.insert(xz, 1, {x=curpos.x-lastpos.x, z=curpos.z-lastpos.z})
for y = 1, -1, -1 do
for _, cxz in ipairs(xz) do
local x = cxz.x
local z = cxz.z
local npos = vector.add(curpos, vector.new(x, y, z))
if not vector.equals(npos, lastpos) then
local n = minetest.get_node(npos)
if n.name == 'default:cobble' or
n.name == 'stairs:stair_cobble' or
n.name == 'default:wood' or
n.name == 'stairs:stair_wood' then
local n = minetest.get_node({x=npos.x,y=npos.y+1,z=npos.z})
if n.name == "default:snow" then
-- Snow is walkable, but we will either walk over it
-- or dig it out of the way, so that's fine...
--dbg.v3("footpath_findnext found (snow-covered) "..minetest.pos_to_string(npos))
return npos
end
-- Otherwise, so long as there's nothing walkable above the
-- node it should be a valid footpath node...
if not minetest.registered_nodes[n.name].walkable then
--dbg.v3("footpath_findnext found "..minetest.pos_to_string(npos))
return npos
end
end
end
end
end
return nil
end
people.actions.go = function(state)
@ -74,7 +26,7 @@ people.actions.go = function(state)
-- No path node, so look for any area with the actual name and
-- just go there. (TODO: could then find the nearest path junction
-- to it and head for that)
local nearest = areas:findNearestArea(state.pos, "$"..people.escape_pattern(state.action.name).."^")
local nearest = areas:findNearestArea(state.pos, "^"..people.escape_pattern(state.action.name).."$")
if not nearest then
dbg.v2(state.ent.name.." couldn't find location "..state.action.name)
return true
@ -113,15 +65,18 @@ people.actions.go = function(state)
-- We're at a junction
dbg.v2(state.ent.name.." is at footpath junction "..curpatharea.name.." - looking for route to "..state.action.name)
state.action.lastpathpos = vector.new(state.pos.x, state.pos.y - 0.5, state.pos.z)
local nextpathdest = areas:findNearestArea(state.pos, "^to_.*"..people.escape_pattern(state.action.name)..";.*", 3)
local nextpathdest = people.get_footpath_route(curpatharea.name, "path_"..state.action.name)
if not nextpathdest then
nextpathdest = areas:findNearestArea(state.pos, "^to_%*$", 3)
end
if not nextpathdest then
dbg.v2(state.ent.name.." can't find next footpath direction")
dbg.v2(state.ent.name.." can't find route")
return true
end
dbg.v2(state.ent.name.." leaving junction via "..nextpathdest.name.." "..minetest.pos_to_string(nextpathdest.pos1))
local tonodename = "^to_"..string.sub(nextpathdest[2], 6).."$"
nextpathdest = areas:findNearestArea(state.pos, tonodename)
if not nextpathdest then
dbg.v2(state.ent.name.." can't find first route node "..tonodename)
return true
end
dbg.v2(state.ent.name.." leaving junction via "..nextpathdest.name)
state.action.pos = vector.add(nextpathdest.pos1, {x=0, y=-0.5, z=0})
return false
@ -135,7 +90,7 @@ people.actions.go = function(state)
-- extra 0.5 allowing for dodgy step-ups, for now...
local thispathpos = vector.round({x=state.pos.x, y=state.pos.y + 0.5, z=state.pos.z})
thispathpos.y = thispathpos.y - 1
local nextpathpos = footpath_findnext(thispathpos, state.action.lastpathpos, false)
local nextpathpos = people.footpath_findnext(thispathpos, state.action.lastpathpos, false)
if not nextpathpos then
dbg.v1(state.ent.name.." can't find next footpath node at "..
minetest.pos_to_string(thispathpos).." after "..
@ -146,7 +101,7 @@ people.actions.go = function(state)
if not distance then distance = 10 end
while distance > 2 do
-- Look ahead a bit while going in a straight line, to increase speed
local aheadpathpos = footpath_findnext(nextpathpos, thispathpos, true)
local aheadpathpos = people.footpath_findnext(nextpathpos, thispathpos, true)
if aheadpathpos then
thispathpos = nextpathpos
nextpathpos = aheadpathpos

View File

@ -55,6 +55,24 @@ subcmd.help = {
end
}
subcmd.footpath_update = {
params = "",
desc = "Update footpath node graph",
exec = function(playername, args)
if not minetest.check_player_privs(playername, {server=true}) then
return "Only admins can update the footpath node graph"
end
response = people.footpath_make_pathnodes()
if response then
return response
end
return "Footpath node graph updated successfully"
end
}
subcmd.create = {
params = "<name>",
desc = "Create a person with the given name at your current location",

259
footpath.lua Normal file
View File

@ -0,0 +1,259 @@
local dbg
if moddebug then dbg=moddebug.dbg("people") else dbg={v1=function() end,v2=function() end,v3=function() end} end
local pathnodes = nil
local routecache = {}
--- Save the current footpath network status.
people.footpath_save = function()
if not pathnodes then return end
local f = io.open(minetest.get_worldpath().."/people_footpaths", "w+")
if f then
f:write(minetest.serialize(pathnodes))
f:close()
end
end
--- Load the footpath network status.
people.footpath_load = function()
local f = io.open(minetest.get_worldpath().."/people_footpaths", "r")
if f then
pathnodes = minetest.deserialize(f:read("*all"))
f:close()
end
end
--- Build the path nodes graph.
-- This is intended to be called via a chat command by a server administrator.
-- It builds a graph by interrogating the defined areas. This graph is then
-- used when finding paths.
-- @return A string describing an error in the setup (in which case no changes
-- are made), or nil on success (in which case, the new graph will
-- take effect).
people.footpath_make_pathnodes = function()
newpathnodes = {}
local allnodes = areas:getAreas({x=0,y=0,z=0}, "^path_.*", nil)
for _, pn in pairs(allnodes) do
if not vector.equals(pn.pos1, pn.pos2) then
return "Path node "..pn.name.." is the wrong size"
end
newpathnodes[pn.name] = {name=pn.name, pos=pn.pos1, neighbours={}}
end
for _, pn in ipairs(allnodes) do
local exits = areas:getAreas(pn.pos1, "^to_.*", 1.5)
for _, tn in pairs(exits) do
if tn.name ~= "to_*" then
exname = tn.name:sub(4)
if not newpathnodes["path_"..exname] then
return "Exit "..exname.." at "..minetest.pos_to_string(tn.pos1).." has no destination"
end
table.insert(newpathnodes[pn.name].neighbours, newpathnodes["path_"..exname])
end
end
end
for _, pn in ipairs(newpathnodes) do
for _, nn in ipairs(pn.neighbours) do
local ok = false
for nnn in nn.neighbours do
if nnn == pn then
ok = true
break
end
end
if not ok then
return "Route from "..pn.name.." at "..minetest.pos_to_string(pn.pos1).." to "..nn.name.." does not return"
end
end
end
pathnodes = newpathnodes
routecache = {}
people.footpath_save()
return nil
end
--- Fast removal of a value from a table
-- Avoids shuffling by swapping the last item with the one to be removed.
-- @param t The table
-- @param v The value to remove
local function table_fast_remove(t, v)
for i, tv in ipairs(t) do
if tv == v then
t[i] = t[#t]
t[#t] = nil
return
end
end
end
--- Check if a table contains the given value
-- @param t The table
-- @param v The value to check for
-- @return True if the table contains the value
local function table_contains(t, v)
for _, tv in ipairs(t) do
if tv == v then return true end
end
return false
end
--- Choose the best node from the list of open ones
-- @param open List of nodes
-- @param f Corresponding list of f scores for those node
-- @return The node with the lowest f score
local function choose_best(open, f)
local lowest, best = 1/0, nil
for _, node in ipairs(open) do
local ts = f[node]
if ts < lowest then
lowest, best = ts, node
end
end
return best
end
--- Reverse-iterate along the found route to construct the final path.
local function get_path(path, prev, current)
if not prev[current] then return path end
table.insert(path, 1, prev[current])
return get_path(path, prev, prev[current])
end
--- Use A* to calculate the route from start to goal. Paths are cached.
local function astar(start, goal)
if not routecache[start] then
routecache[start] = {}
elseif routecache[start][goal] then
dbg.v3("astar used cached path")
return routecache[start][goal]
end
local g = {[start]=0}
local f = {[start]=vector.distance(start.pos, goal.pos)}
local open = {start}
local closed = {}
local prev = {}
while #open > 0 do
local current = choose_best(open, f)
if current == goal then
local path = get_path({}, prev, goal)
table.insert(path, goal)
routecache[start][goal] = path
return path
end
table_fast_remove(open, current)
table.insert(closed, current)
for _, neighbour in ipairs(current.neighbours) do
if not table_contains(closed, neighbour) then
local newg = g[current] + vector.distance(current.pos, neighbour.pos)
if not table_contains(open, neighbour) or newg < g[neighbour] then
prev[neighbour] = current
g[neighbour] = newg
f[neighbour] = g[neighbour] + vector.distance(neighbour.pos, goal.pos)
if not table_contains(open, neighbour) then
table.insert(open, neighbour)
end
end
end
end
end
return nil
end
--- Get a footpath route
-- @param start Starting node name, e.g. "path_My House"
-- @param goal Goal node name, e.g. "path_Your House"
-- @return A list of path node names defining the route, including the start
-- and the goal, or nil if no route was found.
people.get_footpath_route = function(start, goal)
if not pathnodes then return nil end
if not pathnodes[start] then
dbg.v3("Route start "..start.." doesn't exist")
return nil
end
if not pathnodes[goal] then
dbg.v3("Route goal "..goal.." doesn't exist")
return nil
end
path = astar(pathnodes[start], pathnodes[goal])
if not path then
dbg.v3("Route from "..start.." to "..goal.." not found")
return nil
end
rpath = {}
for _, n in ipairs(path) do
table.insert(rpath, n.name)
end
dbg.v3("Route from "..start.." to "..goal.." is "..dump(rpath))
return rpath
end
--- Find the next node along a footpath
-- All positions are exact (rounded) node positions.
-- @param curpos The position of a current footpath node
-- @param lastpos The position of the previous footpath node
-- @param samedironly True to only look in the current direction of travel
-- @return The position of the next footpath node, or nil
people.footpath_findnext = function(curpos, lastpos, samedironly)
--dbg.v3("footpath_findnext, cur="..minetest.pos_to_string(curpos)..", last="..minetest.pos_to_string(lastpos)..", same="..dump(samedironly))
local xz
if samedironly then
xz = {}
else
xz = {{x=1,z=0}, {x=0,z=1}, {x=-1,z=0}, {x=0,z=-1}}
end
-- Favour the direction we're already going
-- TODO - that means we could check it twice (but then again, only
-- if we reach an invalid bit of footpath!
table.insert(xz, 1, {x=curpos.x-lastpos.x, z=curpos.z-lastpos.z})
for y = 1, -1, -1 do
for _, cxz in ipairs(xz) do
local x = cxz.x
local z = cxz.z
local npos = vector.add(curpos, vector.new(x, y, z))
if not vector.equals(npos, lastpos) then
local n = minetest.get_node(npos)
if n.name == 'default:cobble' or
n.name == 'stairs:stair_cobble' or
n.name == 'default:wood' or
n.name == 'stairs:stair_wood' then
local n = minetest.get_node({x=npos.x,y=npos.y+1,z=npos.z})
if n.name == "default:snow" then
-- Snow is walkable, but we will either walk over it
-- or dig it out of the way, so that's fine...
--dbg.v3("footpath_findnext found (snow-covered) "..minetest.pos_to_string(npos))
return npos
end
-- Otherwise, so long as there's nothing walkable above the
-- node it should be a valid footpath node...
if not minetest.registered_nodes[n.name].walkable then
--dbg.v3("footpath_findnext found "..minetest.pos_to_string(npos))
return npos
end
end
end
end
end
return nil
end

View File

@ -9,6 +9,8 @@ local people_modpath = minetest.get_modpath("people")
dofile(people_modpath .. "/commands.lua")
dofile(people_modpath .. "/tracking.lua")
dofile(people_modpath .. "/form.lua")
dofile(people_modpath .. "/footpath.lua")
-- Contains all action handlers. Each action handler takes a 'state' table as
-- a parameter, and returns true if the current action is complete, false
@ -25,6 +27,8 @@ dofile(people_modpath .. "/actions/place.lua")
dofile(people_modpath .. "/actions/gather.lua")
dofile(people_modpath .. "/actions/stash_and_retrieve.lua")
people.footpath_load()
people.presets = {}
for _, p in ipairs({"FollowOwner", "SimpleCommands", "RouteWalker", "FarmHand"}) do
local file = io.open(minetest.get_modpath("people").."/presets/"..p..".lua", "r")
@ -632,12 +636,12 @@ minetest.register_globalstep(function(dtime)
player:hud_remove(pshow.id)
else
local pos, col
local ent = people.people[person_name].entity
local ent = people.people[pshow.name].entity
if ent then
pos = ent.object:getpos()
col = 0xFF00FF
else
pos = vector.new(people.people[person_name].pos)
pos = vector.new(people.people[pshow.name].pos)
col = 0xFFFF00
end
pos.y = pos.y + 1.5

View File

@ -66,12 +66,12 @@ if event.type == "program" then
{"go", pos={x=174, y=11.5, z=319}},
{"go", name="buildshop"},
{"go", pos={x=127, y=12.5,y=-54}},
{"go", name="Building Supplies Shop"},
{"go", pos={x=127, y=12.5, z=-54}},
{"wait", time=20},
{"go", pos={x=133, y=12.5,y=-54}},
{"go", pos={x=133, y=12.5, z=-54}},
{"wait", time=20},
{"go", pos={x=127, y=12.5,y=-54}},
{"go", pos={x=127, y=12.5, z=-54}},
{"wait", time=60},
{"go", name="Building Supplies Shop"},