From 11e4d9828fe8c05339cc0cab5b7b29eec6b3f625 Mon Sep 17 00:00:00 2001 From: Kalabasa Date: Tue, 2 Feb 2016 00:08:44 +0800 Subject: [PATCH] Rewrote pathfinder to use navigational sectors and "waypoints" instead of grids. --- mods/defense/debug.lua | 173 ++++---- mods/defense/director.lua | 123 +++--- mods/defense/init.lua | 16 +- mods/defense/mob.lua | 41 +- mods/defense/music.lua | 4 +- mods/defense/pathfinder.lua | 818 +++++++++++++++++++++--------------- 6 files changed, 639 insertions(+), 536 deletions(-) diff --git a/mods/defense/debug.lua b/mods/defense/debug.lua index 46d7370..346710b 100644 --- a/mods/defense/debug.lua +++ b/mods/defense/debug.lua @@ -1,122 +1,115 @@ +function defense:toggle_debug(on) + self.debug = on + if self.debug then + regeneration.rate = 100 + minetest.set_timeofday(0.3) + return true, "Debug mode activated" + else + regeneration.rate = self.regeneration_rate + return true, "Debug mode deactivated" + end +end + minetest.register_chatcommand("debug", { description = "Toggle Defense mod debug mode", privs = {server=true}, func = function(name) - defense.debug = not defense.debug - if defense.debug then - regeneration.rate = 100 - minetest.set_timeofday(0.3) - return true, "Debug mode activated" - else - regeneration.rate = defense.regeneration_rate - return true, "Debug mode deactivated" - end + return defense:toggle_debug(not defense.debug) end, }) + -- Pathfinder debugger -local path_interval = 3 -local path_class = nil -local path_visit = nil -local path_visited = nil -local path_waiting = {} -local path_active = {} -local path_timer = 0 -local path_count = 0 -local path_directions = { - {x=0, y=0, z=0}, - {x=0, y=-1, z=0}, - {x=0, y=1, z=0}, - {x=0, y=0, z=-1}, - {x=1, y=0, z=0}, - {x=-1, y=0, z=0}, - {x=0, y=0, z=1}, -} +local pf_player = nil +local pf_class_name = nil +local pf_update_interval = 1.0 -minetest.register_chatcommand("debug_path", { - params = "[]", - description = "Debug the pathfinder flow field", +minetest.register_chatcommand("debug_pf", { + description = "Debug the pathfinder", + params = "", privs = {server=true}, func = function(name, class) - if not defense.debug then - return false, "Debug mode required!" - end - - if not class or class == "" then - path_class = nil - return true, "Pathfinder debug off" - else + if class then if defense.pathfinder.classes[class] then - local pos = minetest.get_player_by_name(name):getpos() - pos = { - x = math.floor(pos.x + 0.5), - y = math.floor(pos.y + 0.5), - z = math.floor(pos.z + 0.5) - } - path_visited = {} - path_visit = Queue.new() Queue.push(path_visit, pos) - path_timer = path_interval - path_class = class - return true + pf_class_name = class + pf_player = minetest.get_player_by_name(name) + return true, "Pathfinder debugger for " .. pf_class_name .. " activated" else - return false, "Invalid class!" + return false, "No pathfinder class of that name" end + else + pf_class_name = nil + pf_player = nil + return true, "Pathfinder debugger deactivated" end end, }) -minetest.register_node("defense:debug_path", { +minetest.register_node("defense:debug_pf", { drawtype = "allfaces", - visual_scale = 1.0, tiles = {"defense_debug_path.png"}, - use_texture_alpha = true, - paramtype = "light", - sunlight_propagates = true, + light_source = 14, + groups = {dig_immediate = 3}, + drop = "", walkable = false, - groups = {dig_immediate=3}, }) -local function path_update() - for _=1,Queue.size(path_visit) do - local pos = Queue.pop(path_visit) - table.insert(path_waiting, pos) - path_visited[pos.x .. ":" .. pos.y .. ":" .. pos.z] = true - for _,dir in ipairs(path_directions) do - local nxt = vector.add(pos, dir) - if not path_visited[nxt.x .. ":" .. nxt.y .. ":" .. nxt.z] then - Queue.push(path_visit, nxt) - end - end - end - - for _,pos in ipairs(path_active) do +minetest.register_abm({ + nodenames = {"defense:debug_pf"}, + interval = 2.0, + chance = 1, + action = function(pos) minetest.remove_node(pos) - end - path_active = {} + end, +}) - for i=#path_waiting,1,-1 do - local pos = path_waiting[i] - local field = defense.pathfinder:get_field(path_class, pos) - if field and field.distance == path_count then - minetest.place_node(pos, {name="defense:debug_path"}) - table.remove(path_waiting, i) - table.insert(path_active, pos) +local function pf_update() + if pf_class_name then + local pathfinder = defense.pathfinder + local pos = pf_player:getpos() + local sector = pathfinder.find_containing_sector(pathfinder.classes[pf_class_name], math.floor(pos.x + 0.5), math.floor(pos.y + 0.5), math.floor(pos.z + 0.5)) + if sector then + local distance_str = sector.distance + if sector.distance == nil then + distance_str = "nil" + end + + local bounds_str = "(" .. sector.min_x .. "," .. sector.min_y .. "," .. sector.min_z .. ";" .. sector.max_x .. "," .. sector.max_y .. "," .. sector.max_z .. ")" + + local links_str = "" + for i,l in pairs(sector.links) do + links_str = links_str .. " " .. i + end + links_str = "[" .. links_str .. " ]" + + defense:log("You are in sector " .. sector.id .. " {d=" .. distance_str .. " b=" .. bounds_str .. " l=" .. links_str .. "}") + + for z = sector.min_z,sector.max_z do + for y = sector.min_y,sector.max_y do + for x = sector.min_x,sector.max_x do + if (x == sector.min_x or x == sector.max_x) + and (y == sector.min_y or y == sector.max_y) + and (z == sector.min_z or z == sector.max_z) then + local pos = {x=x,y=y,z=z} + local node = minetest.get_node_or_nil(pos) + if node and node.name == "air" then + minetest.set_node(pos, {name="defense:debug_pf"}) + end + end + end + end + end + else + defense:log("You are not in a sector") end end - - path_count = path_count + 1 - - if path_count > 20 then - path_class = nil - end end +local pf_last_update_time = 0 minetest.register_globalstep(function(dtime) - if path_class then - path_timer = path_timer - dtime - if path_timer <= 0 then - path_timer = path_interval - path_update() - end + local gt = minetest.get_gametime() + if pf_last_update_time + pf_update_interval < gt then + pf_update() + pf_last_update_time = gt end end) \ No newline at end of file diff --git a/mods/defense/director.lua b/mods/defense/director.lua index a7045e6..b591079 100644 --- a/mods/defense/director.lua +++ b/mods/defense/director.lua @@ -1,6 +1,6 @@ defense.director = {} local director = defense.director -director.call_interval = 1.0 +director.update_interval = 1.0 director.intensity_decay = 0.93 director.max_entities = 50 director.spawn_list = { @@ -14,7 +14,7 @@ director.spawn_list = { probability = 0.4, day_start = 0, spawn_time = 14.0, - spawn_location = "ground", + spawn_location_type = "ground", }, { description = "Unggoy horde", @@ -26,7 +26,7 @@ director.spawn_list = { probability = 0.8, day_start = 1, spawn_time = 31.0, - spawn_location = "ground", + spawn_location_type = "ground", }, { description = "Aranay group", @@ -38,7 +38,7 @@ director.spawn_list = { probability = 0.3, day_start = 0, spawn_time = 18.0, - spawn_location = "ground", + spawn_location_type = "ground", }, { description = "Paniki group", @@ -50,7 +50,7 @@ director.spawn_list = { probability = 0.6, day_start = 0, spawn_time = 9.0, - spawn_location = "air", + spawn_location_type = "air", }, { description = "Sarangay", @@ -62,7 +62,7 @@ director.spawn_list = { probability = 0.4, day_start = 2, spawn_time = 90.0, - spawn_location = "ground", + spawn_location_type = "ground", }, { description = "Botete", @@ -74,7 +74,7 @@ director.spawn_list = { probability = 0.4, day_start = 1, spawn_time = 90.0, - spawn_location = "air", + spawn_location_type = "air", }, } @@ -90,43 +90,15 @@ for _,m in ipairs(director.spawn_list) do spawn_timers[m.description] = m.spawn_time/2 end -function director:on_interval() - self:update_intensity() - if defense.debug then - -- minetest.chat_send_all("Intensity: " .. self.intensity) - end - - if self.cooldown_timer <= 0 then - if defense:is_dark() and #minetest.luaentities < self.max_entities and not defense.debug then - self:spawn_monsters() - end - - if self.intensity > 0.5 then - self.cooldown_timer = math.random(5, 5 + 80 * (self.intensity - 0.5)) - end - else - self.cooldown_timer = self.cooldown_timer - self.call_interval - if defense.debug then - minetest.chat_send_all("Cooldown: " .. self.cooldown_timer) - end - end - - for k,v in pairs(spawn_timers) do - if v > 0 then - spawn_timers[k] = v - self.call_interval - end - end -end - -function director:spawn_monsters() +local function spawn_monsters() -- Filter eligible monsters local filtered = {} - for _,m in ipairs(self.spawn_list) do + for _,m in ipairs(director.spawn_list) do if spawn_timers[m.description] <= 0 - and self:get_day_count() >= m.day_start + and defense.get_day_count() >= m.day_start and math.random() < m.probability - and self.intensity >= m.intensity_min - and self.intensity <= m.intensity_max then + and director.intensity >= m.intensity_min + and director.intensity <= m.intensity_max then table.insert(filtered, m) end end @@ -136,23 +108,18 @@ function director:spawn_monsters() local monster = filtered[math.random(#filtered)] -- Determine group size - local intr = math.max(0, math.min(1, self.intensity + math.random() * 2 - 1)) + local intr = math.max(0, math.min(1, director.intensity + math.random() * 2 - 1)) local group_size = math.floor(0.5 + monster.group_max + (monster.group_min - monster.group_max) * intr) -- Find the spawn position - local pos = self:find_spawn_position(monster.spawn_location) + local pos = find_spawn_position(monster.spawn_location_type) if not pos then - if defense.debug then - minetest.chat_send_all("No spawn point found for " .. monster.description .. "!") - end + defense:log("No spawn point found for " .. monster.description .. "!") return false end -- Spawn - if defense.debug then - minetest.chat_send_all("Spawn " .. monster.description .. " (" .. group_size .. " " .. - monster.name .. ") at " .. minetest.pos_to_string(pos)) - end + defense:log("Spawn " .. monster.description .. " (" .. group_size .. " " .. monster.name .. ") at " .. minetest.pos_to_string(pos)) repeat minetest.after(group_size * (math.random() * 0.2), function() local obj = minetest.add_entity(pos, monster.name) @@ -163,7 +130,7 @@ function director:spawn_monsters() return true end -function director:find_spawn_position(spawn_location) +local function find_spawn_position(spawn_location_type) local players = minetest.get_connected_players() if #players == 0 then return nil @@ -185,7 +152,7 @@ function director:find_spawn_position(spawn_location) local a = math.random() * 2 * math.pi pos.x = pos.x + math.cos(a) * r pos.z = pos.z + math.sin(a) * r - if spawn_location == "ground" then + if spawn_location_type == "ground" then -- Move pos to on ground pos.y = pos.y + 10 local d = -1 @@ -205,7 +172,7 @@ function director:find_spawn_position(spawn_location) break end end - elseif spawn_location == "air" then + elseif spawn_location_type == "air" then -- Move pos up pos.y = pos.y + 12 + math.random() * 12 local node = minetest.get_node_or_nil(pos) @@ -236,7 +203,7 @@ function director:find_spawn_position(spawn_location) return nil end -function director:update_intensity() +local function update_intensity() local players = minetest.get_connected_players() if #players == 0 then return @@ -258,19 +225,37 @@ function director:update_intensity() last_average_health = average_health last_mob_count = mob_count - self.intensity = math.max(0, math.min(1, self.intensity * self.intensity_decay + delta)) + director.intensity = math.max(0, math.min(1, director.intensity * director.intensity_decay + delta)) end -function director:get_day_count() - local time_speed = minetest.setting_get("time_speed") - return math.floor(minetest.get_gametime() * time_speed / 86400) +local function update() + update_intensity() + + if director.cooldown_timer <= 0 then + if defense:is_dark() and #minetest.luaentities < director.max_entities and not defense.debug then + spawn_monsters() + end + + if director.intensity > 0.5 then + director.cooldown_timer = math.random(5, 5 + 80 * (director.intensity - 0.5)) + end + else + director.cooldown_timer = director.cooldown_timer - director.update_interval + defense:log("Cooldown: " .. director.cooldown_timer) + end + + for k,v in pairs(spawn_timers) do + if v > 0 then + spawn_timers[k] = v - director.update_interval + end + end end -function director:save() +local function save() local file = assert(io.open(minetest.get_worldpath() .. "/defense_director_state.txt", "w")) local data = { - intensity = self.intensity, - cooldown_timer = self.cooldown_timer, + intensity = director.intensity, + cooldown_timer = director.cooldown_timer, spawn_timers = spawn_timers, last_average_health = last_average_health, last_mob_count = last_mob_count, @@ -279,13 +264,13 @@ function director:save() assert(file:close()) end -function director:load() +local function load() local file = io.open(minetest.get_worldpath() .. "/defense_director_state.txt", "r") if file then local data = minetest.deserialize(file:read("*all")) if data then - self.intensity = data.intensity - self.cooldown_timer = data.cooldown_timer + director.intensity = data.intensity + director.cooldown_timer = data.cooldown_timer last_average_health = data.last_average_health last_mob_count = data.last_mob_count for k,v in pairs(data.spawn_timers) do @@ -297,15 +282,15 @@ function director:load() end minetest.register_on_shutdown(function() - director:save() + save() end) -director:load() +load() -local last_call_time = 0 +local last_update_time = 0 minetest.register_globalstep(function(dtime) local gt = minetest.get_gametime() - if last_call_time + director.call_interval < gt then - director:on_interval() - last_call_time = gt + if last_update_time + director.update_interval < gt then + update() + last_update_time = gt end end) \ No newline at end of file diff --git a/mods/defense/init.lua b/mods/defense/init.lua index 1e1f987..497d3f9 100644 --- a/mods/defense/init.lua +++ b/mods/defense/init.lua @@ -9,11 +9,23 @@ local function dofile2(file) dofile(modpath .. "/" .. file) end +local time_speed = minetest.setting_get("time_speed") +function defense:get_day_count() + return math.floor(minetest.get_gametime() * time_speed / 86400) +end + function defense:is_dark() local tod = minetest.get_timeofday() return tod < 0.2 or tod > 0.8 or defense.debug end +function defense:log(message) + if self.debug then + minetest.chat_send_all("[debug] " .. message) + end + minetest.debug(message) +end + dofile2("util.lua") dofile2("Queue.lua") @@ -29,4 +41,6 @@ dofile2("mobs/sarangay.lua") dofile2("mobs/paniki.lua") dofile2("mobs/botete.lua") -dofile2("debug.lua") \ No newline at end of file +dofile2("debug.lua") + +defense:toggle_debug(true) \ No newline at end of file diff --git a/mods/defense/mob.lua b/mods/defense/mob.lua index f5b9d7c..7acdcf5 100644 --- a/mods/defense/mob.lua +++ b/mods/defense/mob.lua @@ -13,7 +13,7 @@ mobs.default_prototype = { id = 0, smart_path = true, mass = 1, - movement = "ground", -- "ground"/"air" + movement = "ground", -- "ground"/"air"/"crawl" move_speed = 1, jump_height = 1, armor = 0, @@ -34,6 +34,8 @@ mobs.default_prototype = { cache_find_nearest_player = nil, } +local reg_nodes = minetest.registered_nodes + local function vec_zero() return {x=0, y=0, z=0} end function mobs.default_prototype:on_activate(staticdata) @@ -83,7 +85,7 @@ function mobs.default_prototype:on_step(dtime) -- Remove when far enough and may not reach the player at all local nearest = self:find_nearest_player() if self.life_timer <= 0 then - if nearest.distance > 6 then + if nearest.distance > 12 then self.object:remove() end else @@ -154,16 +156,9 @@ function mobs.default_prototype:hunt() self:do_attack(nearest.player) end if nearest.distance > self.attack_range or nearest.distance < self.attack_range/2-1 then - local pos = self.object:getpos() - local direction = nil - if self.smart_path and nearest.distance < defense.pathfinder.path_max_range then - direction = defense.pathfinder:get_direction(self.name, pos) - end + -- TODO Use pathfinder - if direction then - minetest.chat_send_all("dir:" .. minetest.pos_to_string(direction)) - self.destination = vector.add(pos, vector.multiply(direction, 1.2)) - else + if not self.destination then local r = math.max(0, self.attack_range - 2) local dir = vector.direction(nearest.position, self.object:getpos()) self.destination = vector.add(nearest.position, vector.multiply(dir, r)) @@ -239,7 +234,7 @@ function mobs.default_prototype:is_standing() } for _,c in ipairs(corners) do local node = minetest.get_node_or_nil(c) - if not node or minetest.registered_nodes[node.name].walkable then + if not node or reg_nodes[node.name].walkable then self.cache_is_standing = true return true end @@ -316,7 +311,7 @@ function mobs.default_prototype:calculate_wall_normal() if xi ~= 2 and yi ~= 2 and zi ~= 2 then local sp = vector.add(p, {x=xs[xi], y=ys[yi], z=zs[zi]}) local node = minetest.get_node_or_nil(sp) - if node and minetest.registered_nodes[node.name].walkable then + if node and reg_nodes[node.name].walkable then normal = vector.add(normal, {x=normals[xi], y=normals[yi], z=normals[zi]}) count = count + 1 end @@ -398,15 +393,7 @@ function mobs.move_method:ground(dtime, destination) -- Check for jump local jump = nil if self.smart_path then - local p = self.object:getpos() - if destination.y > p.y + 0.55 then - for y=p.y,p.y+self.jump_height do - jump = defense.pathfinder:get_direction(self.name, {x=p.x, y=y, z=p.z}) - if not jump or (jump.x ~= 0 or jump.z ~= 0) then - break - end - end - end + -- TODO Jump to destination else if dist > 1 then local p = self.object:getpos() @@ -422,7 +409,7 @@ function mobs.move_method:ground(dtime, destination) } for _,f in ipairs(fronts) do local node = minetest.get_node_or_nil(vector.add(p, f)) - if not node or minetest.registered_nodes[node.name].walkable then + if not node or reg_nodes[node.name].walkable then jump = vector.direction(self.object:getpos(), destination) break end @@ -488,14 +475,10 @@ function mobs.register_mob(name, def) if defense.pathfinder and prototype.smart_path then defense.pathfinder:register_class(name, { - size = { - x = math.ceil(prototype.collisionbox[4] - prototype.collisionbox[1]), - y = math.ceil(prototype.collisionbox[5] - prototype.collisionbox[2]), - z = math.ceil(prototype.collisionbox[6] - prototype.collisionbox[3]) - }, collisionbox = prototype.collisionbox, jump_height = math.floor(prototype.jump_height), - cost_method = def.pathfinder_cost or defense.pathfinder.cost_method[prototype.movement] + path_check = def.pathfinder_check or defense.pathfinder.default_path_check[prototype.movement], + cost_method = def.pathfinder_cost or defense.pathfinder.default_cost_method[prototype.movement], }) end diff --git a/mods/defense/music.lua b/mods/defense/music.lua index 213c35a..cf55617 100644 --- a/mods/defense/music.lua +++ b/mods/defense/music.lua @@ -8,7 +8,7 @@ local current_music = nil local last_intensity = 0 local last_update_time = 0 -function music:update() +local function update() local time = os.time() if current_level > 0 then if time < last_update_time + music.loop_length then @@ -77,5 +77,5 @@ function music:update() end minetest.register_globalstep(function(dtime) - music:update() + update() end) \ No newline at end of file diff --git a/mods/defense/pathfinder.lua b/mods/defense/pathfinder.lua index 599897a..13fdf9e 100644 --- a/mods/defense/pathfinder.lua +++ b/mods/defense/pathfinder.lua @@ -1,407 +1,535 @@ defense.pathfinder = {} local pathfinder = defense.pathfinder -pathfinder.path_max_range = 32 -pathfinder.path_max_range_far = 64 -pathfinder.classes = {} -local chunk_size = 16 +pathfinder.update_interval = 2.0 +pathfinder.max_sector_size = 16 +pathfinder.max_sector_count = 800 +pathfinder.max_distance = 100 +pathfinder.class_names = {} --- State -local fields = {} -local visit_queues = {} -local visit_queue_far = Queue.new() -local player_last_update = {} -local morning_reset = false +local classes = {} +local next_sector_id = 1 --- local cid_data = {} --- minetest.after(0, function() --- for name, def in pairs(minetest.registered_nodes) do --- cid_data[minetest.get_content_id(name)] = { --- name = name, --- walkable = def.walkable, --- } --- end --- end) +local reg_nodes = minetest.registered_nodes +local neighbors = { + {x =-1, y = 0, z = 0}, + {x = 1, y = 0, z = 0}, + {x = 0, y =-1, z = 0}, + {x = 0, y = 1, z = 0}, + {x = 0, y = 0, z =-1}, + {x = 0, y = 0, z = 1}, +} -function pathfinder:register_class(class, properties) - self.classes[class] = properties - fields[class] = fields[class] or {} - visit_queues[class] = Queue.new() +pathfinder.classes = classes -- For debug + +function pathfinder:register_class(class_name, properties) + table.insert(pathfinder.class_names, class_name) + classes[class_name] = { + name = class_name, + sectors = {}, + sector_seeds = Queue.new(), + jump_height = properties.jump_height, + path_check = properties.path_check, + cost_method = properties.cost_method, + x_offset = properties.collisionbox[1], + y_offset = properties.collisionbox[2], + z_offset = properties.collisionbox[3], + x_size = math.ceil(properties.collisionbox[4] - properties.collisionbox[1]), + y_size = math.ceil(properties.collisionbox[5] - properties.collisionbox[2]), + z_size = math.ceil(properties.collisionbox[6] - properties.collisionbox[3]), + } end --- Returns a number -function pathfinder:get_distance(class, position) - local field = self:get_field(class, position) - if not field then - return nil +local function sector_contains(sector, x, y, z) + return x <= sector.max_x and x >= sector.min_x + and y <= sector.max_y and y >= sector.min_y + and z <= sector.max_z and z >= sector.min_z +end + +local function find_containing_sector(class, x, y, z) + for i,s in pairs(class.sectors) do + if (sector_contains(s, x, y, z)) then + return s + end end - return field.distance + return nil end --- Returns a vector -function pathfinder:get_direction(class, position) - local directions = { - [0]={x=0, y=0, z=0}, - {x=0, y=-1, z=0}, - {x=0, y=1, z=0}, - {x=0, y=0, z=-1}, - {x=1, y=0, z=0}, - {x=-1, y=0, z=0}, - {x=0, y=0, z=1}, - } +pathfinder.find_containing_sector = find_containing_sector -- For debug - local total = vector.new(0, 0, 0) - local count = 0 +local function get_player_pos(player) + local pos = player:getpos() + return math.floor(pos.x + 0.5), math.floor(pos.y + 0.5), math.floor(pos.z + 0.5) +end - local ipos = {x=math.floor(position.x), y=math.floor(position.y), z=math.floor(position.z)} - local cells = { - ipos, - {x=ipos.x + 1, y=ipos.y, z=ipos.z}, - {x=ipos.x - 1, y=ipos.y, z=ipos.z}, - {x=ipos.x, y=ipos.y + 1, z=ipos.z}, - {x=ipos.x, y=ipos.y - 1, z=ipos.z}, - {x=ipos.x, y=ipos.y, z=ipos.z + 1}, - {x=ipos.x, y=ipos.y, z=ipos.z - 1}, - } - for _,p in ipairs(cells) do - local field = self:get_field(class, p) - if field then - local last_time = player_last_update[field.player] or field.time - if field.time + field.distance * 4 > last_time then - local direction = directions[field.direction] - local weight = 1/(1 + field.distance) - total = vector.add(total, vector.multiply(direction, weight)) +-- Deletes and queues a sector for regeneration +local function invalidate_sector(sector, class) + local id = sector.id + class.sectors[id] = nil + + for i,l in pairs(sector.links) do + l.links[id] = nil + end + + Queue.push(class.sector_seeds, {sector.min_x,sector.min_y,sector.min_z, nil,0}) + -- TODO what if replacement seed is blocked? +end + +-- Calculates the distances for each sector +local function calculate_distances(class) + local cost_method = class.cost_method + + local sectors = class.sectors + for i,s in pairs(sectors) do + s.distance = nil + end + + local visited_ids = {} + local visit = Queue.new() + + local players = minetest.get_connected_players() + if #players then + for _,p in ipairs(players) do + local x, y, z = get_player_pos(p) + + local sector = find_containing_sector(class, x, y, z) + if sector then + sector.distance = 0 + Queue.push(visit, sector) end end end - if total.x ~= 0 or total.y ~= 0 or total.z ~= 0 then - return vector.normalize(total) - else - return nil - end -end + while Queue.size(visit) > 0 do + local sector = Queue.pop(visit) + visited_ids[sector.id] = true --- Returns a table {time, distance} -function pathfinder:get_field(class, position, no_position_adjust) - local collisionbox = self.classes[class].collisionbox - if not no_position_adjust then - position.x = position.x + collisionbox[1] + 0.01 - position.y = position.y + collisionbox[2] + 0.01 - position.z = position.z + collisionbox[3] + 0.01 - end - local x = math.floor(position.x) - local y = math.floor(position.y) - local z = math.floor(position.z) - - local chunk_key = math.floor(x/chunk_size) .. - ":" .. math.floor(y/chunk_size) .. - ":" .. math.floor(z/chunk_size) - local chunk = fields[class][chunk_key] - if not chunk then - return nil - end - - local cx = x % chunk_size - local cy = y % chunk_size - local cz = z % chunk_size - local index = (cy * chunk_size + cz) * chunk_size + cx - return chunk[index] -end - -function pathfinder:set_field(class, position, player, distance, direction, time, no_position_adjust) - local collisionbox = self.classes[class].collisionbox - if not no_position_adjust then - position.x = position.x + collisionbox[1] + 0.01 - position.y = position.y + collisionbox[2] + 0.01 - position.z = position.z + collisionbox[3] + 0.01 - end - local x = math.floor(position.x) - local y = math.floor(position.y) - local z = math.floor(position.z) - - local chunk_key = math.floor(x/chunk_size) .. - ":" .. math.floor(y/chunk_size) .. - ":" .. math.floor(z/chunk_size) - local chunk = fields[class][chunk_key] - if not chunk then - chunk = {} - fields[class][chunk_key] = chunk - end - - local cx = x % chunk_size - local cy = y % chunk_size - local cz = z % chunk_size - local index = (cy * chunk_size + cz) * chunk_size + cx - chunk[index] = {time=time, direction=direction, distance=distance, player=player} -end - -function pathfinder:delete_field(class, position) - local collisionbox = self.classes[class].collisionbox - local x = math.floor(position.x) - local y = math.floor(position.y) - local z = math.floor(position.z) - - local chunk_key = math.floor(x/chunk_size) .. - ":" .. math.floor(y/chunk_size) .. - ":" .. math.floor(z/chunk_size) - local chunk = fields[class][chunk_key] - if not chunk then - return - end - - local cx = x % chunk_size - local cy = y % chunk_size - local cz = z % chunk_size - local index = (cy * chunk_size + cz) * chunk_size + cx - chunk[index] = nil -end - -function pathfinder:update(dtime) - if not defense:is_dark() then - -- reset flow fields in the morning - if not morning_reset then - morning_reset = true - player_last_update = {} - for c,_ in pairs(self.classes) do - fields[c] = {} - visit_queues[c] = Queue.new() + local distance = sector.distance + for i,l in pairs(sector.links) do + if not visited_ids[i] then + local cost = cost_method(sector, l) + local new_ldist = distance + cost + local ldist = l.distance + if ldist == nil or ldist > new_ldist then + l.distance = new_ldist + Queue.push(visit, l) + end end end - return end - morning_reset = false +end - local neighborhood = { - {x=0, y=1, z=0}, - {x=0, y=-1, z=0}, - {x=0, y=0, z=1}, - {x=-1, y=0, z=0}, - {x=1, y=0, z=0}, - {x=0, y=0, z=-1}, +-- Returns array of {x,y,z,parent,parent_dir} +local function find_sector_exits(sector, class) + local sides = { + {0,1,1, + sector.max_x + 1, sector.min_y, sector.min_z, + sector.max_x + 1, sector.max_y, sector.max_z}, + {0,1,1, + sector.min_x - 1, sector.min_y, sector.min_z, + sector.min_x - 1, sector.max_y, sector.max_z}, + {1,0,1, + sector.min_x, sector.max_y + 1, sector.min_z, + sector.max_x, sector.max_y + 1, sector.max_z}, + {1,0,1, + sector.min_x, sector.min_y - 1, sector.min_z, + sector.max_x, sector.min_y - 1, sector.max_z}, + {1,1,0, + sector.min_x, sector.min_y, sector.max_z + 1, + sector.max_x, sector.max_y, sector.max_z + 1}, + {1,1,0, + sector.min_x, sector.min_y, sector.min_z - 1, + sector.max_x, sector.max_y, sector.min_z - 1}, } - -- Update the field - local max_iter = 100 - math.floor(defense.director.intensity * 90) - local total_queues_size = 0 - for c,class in pairs(self.classes) do - local vq = visit_queues[c] - local size = Queue.size(vq) - for i=1,math.min(size,max_iter) do - local current = Queue.pop(vq) - for di,n in ipairs(neighborhood) do - local npos = vector.add(current.position, n) - npos.x = math.floor(npos.x + 0.5) - npos.y = math.floor(npos.y + 0.5) - npos.z = math.floor(npos.z + 0.5) - local cost = class.cost_method(class, npos, current.position) - if cost and cost < self.path_max_range_far then - local next_distance = current.distance + cost - local neighbor_field = self:get_field(c, npos, true) - if not neighbor_field or - neighbor_field.time < current.time and - neighbor_field.direction ~= di or - neighbor_field.time == current.time and - (neighbor_field.distance > next_distance or - neighbor_field.distance == next_distance and - math.random() < 0.5) then - self:set_field(c, npos, current.player, next_distance, di, current.time, true) - if next_distance < self.path_max_range or current.far and next_distance < self.path_max_range_far then - if size < 800 then - Queue.push(vq, { - position = npos, - player = current.player, - distance = next_distance, - direction = di, - time = current.time, - }) - end - elseif next_distance < self.path_max_range_far then - Queue.push(visit_queue_far, { - far = true, - class = c, - position = npos, - player = current.player, - distance = next_distance, - direction = di, - time = current.time, - }) - else - self:delete_field(c, npos) + local path_check = class.path_check + local tmp_vec = vector.new() + local function path_check_i(x, y, z) + tmp_vec.x = x + tmp_vec.y = y + tmp_vec.z = z + return path_check(class, tmp_vec, nil) + end + + local exits = {} + + -- Find passable nodes that are cornered by >=2 different sector or passability nodes + for i,s in ipairs(sides) do + local xs = s[1] + local ys = s[2] + local zs = s[3] + local min_x = s[4] + local min_y = s[5] + local min_z = s[6] + local max_x = s[7] + local max_y = s[8] + local max_z = s[9] + + local map = {} + + for z = min_z,max_z,zs do + for y = min_y,max_y,ys do + for x = min_x,max_x,xs do + + tmp_vec.x = x + tmp_vec.y = y + tmp_vec.z = z + local hash = minetest.hash_node_position(tmp_vec) + + if path_check_i(x, y, z) then + + local val = 0 + local sector = find_containing_sector(class, x, y, z) + if sector then + val = sector.id end + + local edges = 0 + + if xs ~= 0 then + tmp_vec.x = x - xs + tmp_vec.y = y + tmp_vec.z = z + if val ~= map[minetest.hash_node_position(tmp_vec)] then + edges = edges + 1 + end + end + if ys ~= 0 then + tmp_vec.x = x + tmp_vec.y = y - ys + tmp_vec.z = z + if val ~= map[minetest.hash_node_position(tmp_vec)] then + edges = edges + 1 + end + end + if zs ~= 0 then + tmp_vec.x = x + tmp_vec.y = y + tmp_vec.z = z - zs + if val ~= map[minetest.hash_node_position(tmp_vec)] then + edges = edges + 1 + end + end + + if edges >= 2 then + table.insert(exits, {x,y,z, sector,i}) + end + + map[hash] = val + else + map[hash] = -1 + end + + if xs == 0 then break end + end + if ys == 0 then break end + end + if zs == 0 then break end + end + end + + return exits +end + +-- Returns a sector object {id, distance, links, min_x,min_y,min_z, max_x,max_y,max_z} +local function generate_sector(class, x, y, z, origin_dir) + local max_sector_span = pathfinder.max_sector_size - 1 + local path_check = class.path_check + + local min_x = -math.huge + local min_y = -math.huge + local min_z = -math.huge + local max_x = math.huge + local max_y = math.huge + local max_z = math.huge + + local half_mss = math.floor(max_sector_span / 2) + local half_mss2 = math.ceil(max_sector_span / 2) + local size_min_x = x - half_mss + local size_min_y = y - half_mss + local size_min_z = z - half_mss + local size_max_x = x + half_mss2 + local size_max_y = y + half_mss2 + local size_max_z = z + half_mss2 + + local visited = {} + local visit = Queue.new() + + Queue.push(visit, {x=x,y=y,z=z}) + visited[minetest.hash_node_position(visit[1])] = true + + while Queue.size(visit) > 0 do + local pos = Queue.pop(visit) + + for i,n in ipairs(neighbors) do + local nxt = vector.add(pos, n) + local nhash = minetest.hash_node_position(nxt) + local nx = nxt.x + local ny = nxt.y + local nz = nxt.z + + if not visited[nhash] + and nx <= max_x and nx >= min_x + and ny <= max_y and ny >= min_y + and nz <= max_z and nz >= min_z then + visited[nhash] = true + + local passable = path_check(class, nxt, pos) + + if passable == nil then return nil end + + if passable and origin_dir ~= i + and not find_containing_sector(class, nx, ny, nz) + and nx <= size_max_x and nx >= size_min_x + and ny <= size_max_y and ny >= size_min_y + and nz <= size_max_z and nz >= size_min_z then + Queue.push(visit, nxt) + else + if i == 1 then + min_x = pos.x + size_max_x = min_x + max_sector_span + elseif i == 2 then + max_x = pos.x + size_min_x = max_x - max_sector_span + elseif i == 3 then + min_y = pos.y + size_max_y = min_y + max_sector_span + elseif i == 4 then + max_y = pos.y + size_min_y = max_y - max_sector_span + elseif i == 5 then + min_z = pos.z + size_max_z = min_z + max_sector_span + elseif i == 6 then + max_z = pos.z + size_min_z = max_z - max_sector_span end end + end end - total_queues_size = total_queues_size + math.max(0, size - max_iter) + end - -- Update far fields - if total_queues_size == 0 then - local size = Queue.size(visit_queue_far) - for i=1,math.min(size,max_iter/2) do - local current = Queue.pop(visit_queue_far) - Queue.push(visit_queues[current.class], current) + local id = next_sector_id + next_sector_id = next_sector_id + 1 + + return { + id = id, + distance = nil, + links = {}, + min_x = min_x, + min_y = min_y, + min_z = min_z, + max_x = max_x, + max_y = max_y, + max_z = max_z, + } +end + +-- Removes sectors and seeds too far away from players +local function prune_sectors(class) + defense:log("Pruning sectors...") + local max_distance = pathfinder.max_distance + local sectors = class.sectors + local sector_seeds = class.sector_seeds + + local players = minetest.get_connected_players() + + -- Remove sectors + local to_remove = {} + + for i,s in pairs(sectors) do + if s.distance == nil or s.distance > max_distance then + to_remove[i] = true end end - -- Update player positions - local time = minetest.get_gametime() - for _,p in ipairs(minetest.get_connected_players()) do - local pos = p:getpos() - for c,_ in pairs(self.classes) do - for y=math.floor(pos.y),math.ceil(pos.y) do - local tp = {x=pos.x, y=y, z=pos.z} - local field = self:get_field(c, tp) - if not field or field.distance > 0 then - local name = p:get_player_name() - self:set_field(c, tp, name, 0, 0, time) - Queue.push(visit_queues[c], {position=tp, player=name, distance=0, direction=0, time=time}) - player_last_update[name] = time - end + for i,_ in pairs(to_remove) do + local s = sectors[i] + sectors[i] = nil + + for __,l in pairs(s.links) do + if not to_remove[l.id] then + invalidate_sector(l, class) end end end + + -- Remove seeds + for i = sector_seeds.last,sector_seeds.first,-1 do + local seed = sector_seeds[i] + local seed_pos = {x=seed[1], y=seed[2], z=seed[3]} + + local far = true + for _,p in ipairs(players) do + local x, y, z = get_player_pos(p) + if vector.distance({x=x,y=y,z=z}, seed_pos) <= max_distance then + far = false + end + end + + if far then + Queue.remove(sector_seeds, i) + end + end end -pathfinder.cost_method = {} -function pathfinder.cost_method.air(class, pos, parent) - -- Check if in solid - for y=pos.y,pos.y+class.size.y-1 do - for z=pos.z,pos.z+class.size.z-1 do - for x=pos.x,pos.x+class.size.x-1 do - local node = minetest.get_node_or_nil({x=x, y=y, z=z}) - if not node then return nil end - if minetest.registered_nodes[node.name].walkable then - return math.huge - end - end - end - end - return 1 -end -function pathfinder.cost_method.ground(class, pos, parent) - -- Check if in solid - for z=pos.z,pos.z+class.size.z-1 do - for x=pos.x,pos.x+class.size.x-1 do - for y=pos.y,pos.y+class.size.y-1 do - local node = minetest.get_node_or_nil({x=x, y=y, z=z}) - if not node then return nil end - if minetest.registered_nodes[node.name].walkable then - return math.huge + +local function update_class(class) + local max_sector_count = pathfinder.max_sector_count + local max_distance = pathfinder.max_distance + local sectors = class.sectors + local sector_seeds = class.sector_seeds + local path_check = class.path_check + + local should_refresh_distances = false + + -- Generate new seeds from player positions + local players = minetest.get_connected_players() + if #players then + for _,p in ipairs(players) do + local x, y, z = get_player_pos(p) + + local sector = find_containing_sector(class, x, y, z) + if not sector then + Queue.push_back(sector_seeds, {x,y,z, nil,0}) + should_refresh_distances = true + else + local distance = sector.distance + if distance == nil or distance > 0 then + should_refresh_distances = true end end end end - -- Check if on top of solid - local ground_distance = 9999 - for z=pos.z,pos.z+class.size.z-1 do - for x=pos.x,pos.x+class.size.x-1 do - for y=pos.y-1,pos.y-class.jump_height-1,-1 do - local node = minetest.get_node_or_nil({x=x, y=y, z=z}) - if not node then return nil end - if minetest.registered_nodes[node.name].walkable then - ground_distance = math.min(ground_distance, pos.y - y) - if ground_distance == 1 then - return 1 + -- Grow sector seeds into sectors + local sector_count = 0 + for _,__ in pairs(sectors) do + sector_count = sector_count + 1 + end + + local unready_seeds = {} + local target_sector_count = math.min(sector_count + math.max(math.ceil(100 / (math.log(sector_count + 10))), 1), max_sector_count) + while sector_count < target_sector_count and Queue.size(sector_seeds) > 0 do + local seed = Queue.pop(sector_seeds) + local x = seed[1] + local y = seed[2] + local z = seed[3] + + if not find_containing_sector(class, x, y, z) and path_check(class, {x=x,y=y,z=z}, nil) then + local new_sector = generate_sector(class, x, y, z, seed[5]) + local parent = seed[4] + + if new_sector and (not parent or parent.distance == nil or parent.distance < max_distance) then + local id = new_sector.id + sectors[id] = new_sector + sector_count = sector_count + 1 + + -- Link parent + if parent then + new_sector.links[parent.id] = parent + parent.links[id] = new_sector + end + + -- Generate new seeds and link adjacent sectors + local exits = find_sector_exits(new_sector, class) + for i,e in ipairs(exits) do + local exited_sector = find_containing_sector(class, e[1], e[2], e[3]) + if exited_sector then + if not exited_sector.links[new_sector.id] then + exited_sector.links[new_sector.id] = new_sector + new_sector.links[exited_sector.id] = exited_sector + end + else + Queue.push(sector_seeds, e) end - break end - end - end - end - if ground_distance > 1 then - if ground_distance <= class.jump_height + 1 then - local ledges = { - {x=pos.x + class.size.x, y=pos.y - 1, z=pos.z}, - {x=pos.x - 1, y=pos.y - 1, z=pos.z}, - {x=pos.x, y=pos.y - 1, z=pos.z + class.size.z}, - {x=pos.x, y=pos.y - 1, z=pos.z - 1}, - } - for _,l in ipairs(ledges) do - local node = minetest.get_node_or_nil(l) - if not node then return nil end - if minetest.registered_nodes[node.name].walkable then - return 1 + ground_distance - end + should_refresh_distances = true + else + table.insert(unready_seeds, seed) end end - -- Check if this is a fall - if parent.y < pos.y then - return 2 - end - - return math.huge end + -- Update sector distance values + if should_refresh_distances then + calculate_distances(class) + end + + -- Requeue seeds outside of loaded area + for _,s in ipairs(unready_seeds) do + Queue.push(sector_seeds, s) + end + + defense:log(class.name .. ": There are " .. sector_count .. " sectors, " .. Queue.size(sector_seeds) .. " seeds.") + + -- Prune excess sectors + if sector_count + Queue.size(sector_seeds) >= max_sector_count then + prune_sectors(class) + end +end + +local function update() + for n,c in pairs(classes) do + update_class(c) + end +end + + +-- Cost methods + +pathfinder.default_cost_method = {} +function pathfinder.default_cost_method.air(src_sector, dst_sector) + local dx = ((dst_sector.min_x + dst_sector.max_x) - (src_sector.min_x + src_sector.max_x)) / 2 + local dy = ((dst_sector.min_y + dst_sector.max_y) - (src_sector.min_y + src_sector.max_y)) / 2 + local dz = ((dst_sector.min_z + dst_sector.max_z) - (src_sector.min_z + src_sector.max_z)) / 2 + return math.sqrt(dx*dx + dy*dy + dz*dz) +end +function pathfinder.default_cost_method.ground(src_sector, dst_sector) return 1 end -function pathfinder.cost_method.crawl(class, pos, parent) - -- Check if in solid - for y=pos.y,pos.y+class.size.y-1 do - for z=pos.z,pos.z+class.size.z-1 do - for x=pos.x,pos.x+class.size.x-1 do - local node = minetest.get_node_or_nil({x=x, y=y, z=z}) +function pathfinder.default_cost_method.crawl(src_sector, dst_sector) + return 1 +end + + +-- Path checks + +pathfinder.default_path_check = {} +function pathfinder.default_path_check.air(class, pos, parent) + local tmp_vec = vector.new() + for z = pos.z, pos.z + class.z_size - 1 do + for y = pos.y, pos.y + class.y_size - 1 do + for x = pos.x, pos.x + class.x_size - 1 do + tmp_vec.x = x + tmp_vec.y = y + tmp_vec.z = z + local node = minetest.get_node_or_nil(tmp_vec) if not node then return nil end - if minetest.registered_nodes[node.name].walkable then - return math.huge + if reg_nodes[node.name].walkable then + return false end end end end - - -- Check if touching solid - -- xz-plane - for x=pos.x-1,pos.x+class.size.x do - for z=pos.z-1,pos.z+class.size.z do - local node_n = minetest.get_node_or_nil({x=x, y=pos.y-1, z=z}) - if not node_n then return nil end - if minetest.registered_nodes[node_n.name].walkable then - return 1 - end - local node_p = minetest.get_node_or_nil({x=x, y=pos.y+class.size.y, z=z}) - if not node_p then return nil end - if minetest.registered_nodes[node_p.name].walkable then - return 1 - end - end - end - -- xy-plane - for x=pos.x,pos.x+class.size.x-1 do - for y=pos.y,pos.y+class.size.y-1 do - local node_n = minetest.get_node_or_nil({x=x, y=y, z=pos.z-1}) - if not node_n then return nil end - if minetest.registered_nodes[node_n.name].walkable then - return 1 - end - local node_p = minetest.get_node_or_nil({x=x, y=y, z=pos.z+class.size.z}) - if not node_p then return nil end - if minetest.registered_nodes[node_p.name].walkable then - return 1 - end - end - end - -- yz-plane - for y=pos.y,pos.y+class.size.y-1 do - for z=pos.z,pos.z+class.size.z-1 do - local node_n = minetest.get_node_or_nil({x=pos.x-1, y=y, z=z}) - if not node_n then return nil end - if minetest.registered_nodes[node_n.name].walkable then - return 1 - end - local node_p = minetest.get_node_or_nil({x=pos.x+class.size.z, y=y, z=z}) - if not node_p then return nil end - if minetest.registered_nodes[node_p.name].walkable then - return 1 - end - end - end - - return math.huge + return true +end +function pathfinder.default_path_check.ground(class, pos, parent) + return false +end +function pathfinder.default_path_check.crawl(class, pos, parent) + return false end + +local last_update_time = 0 minetest.register_globalstep(function(dtime) - pathfinder:update(dtime) + local gt = minetest.get_gametime() + if last_update_time + pathfinder.update_interval < gt then + update() + last_update_time = gt + end end) \ No newline at end of file