ActorAI.lua 23.3 KB
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511
-- TE4 - T-Engine 4
-- Copyright (C) 2009 - 2019 Nicolas Casalini
--
-- This program is free software: you can redistribute it and/or modify
-- it under the terms of the GNU General Public License as published by
-- the Free Software Foundation, either version 3 of the License, or
-- (at your option) any later version.
--
-- This program is distributed in the hope that it will be useful,
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-- GNU General Public License for more details.
--
-- You should have received a copy of the GNU General Public License
-- along with this program.  If not, see <http://www.gnu.org/licenses/>.
--
-- Nicolas Casalini "DarkGod"
-- darkgod@te4.org

require "engine.class"
require "engine.Actor"
local Map = require "engine.Map"

--- Handles actors artificial intelligence (or dumbness ... ;)
-- @classmod engine.generator.interface.ActorAI
module(..., package.seeall, class.make)

_M.ai_def = {}

--- Level of output detail to the game log for various AIs
-- Higher level -> more detail, level 0 is no additional output, 4 is very verbose
config.settings.log_detail_ai = config.settings.log_detail_ai or 0

--- Define an AI
-- @param[type=string] name a unique AI label
-- @param[type=function] fct a function to invoke (as _M.ai_def[name](self, ...)) when running the ai
function _M:newAI(name, fct)
	_M.ai_def[name] = fct
end

--- Load and run all files in a director to define new AIs
-- Static!
-- @param[type=string] dir directory containing AI definition files
-- loader defines the environment variables:
--		Map = require("engine.Map")
-- 		newAI = function(name, fct) to run _M:newAI(name, fct)
function _M:loadDefinition(dir)
	for i, file in ipairs(fs.list(dir)) do
		if file:find("%.lua$") then
			local f, err = loadfile(dir.."/"..file)
			if not f and err then error(err) end
			setfenv(f, setmetatable({
				Map = require("engine.Map"),
				newAI = function(name, fct) self:newAI(name, fct) end,
			}, {__index=_G}))
			f()
		end
	end
end

function _M:init(t)
	self:autoLoadedAI()
end

function _M:autoLoadedAI()
	self.ai_state = self.ai_state or {}
	self.ai_target = self.ai_target or {}
	self.ai_actors_seen = self.ai_actors_seen or {}  -- List of actors the AI has had LOS of at least once (regardless of target)

	-- Make the table with weak values, so that threat list does not prevent garbage collection
	setmetatable(self.ai_target, {__mode='v'})

	self.ai_actors_seen = self.ai_actors_seen or {}
	setmetatable(self.ai_actors_seen, {__mode='k'})

	-- Rebuild volative ai state, as it is not saved
	self.ai_state_volatile = self.ai_state_volatile or {}
end

function _M:aiCanPass(x, y)
	-- Nothing blocks, just go on
	if not game.level.map:checkAllEntities(x, y, "block_move", self, nil, true) then return true end

	-- If there is an other actor, check hostility, if hostile, we move to attack
	local target = game.level.map(x, y, Map.ACTOR)
	if target and self:reactionToward(target) < 0 then return true end

	-- If there is a target (not hostile) and we can move it, do so
	if target and self:attr("move_body") then return true end

	return false
end

function _M:aiPathingBlockCheck(x, y, target)

end

--- Move one step to the given target if possible
-- This tries the most direct route, if not available it checks sides and always tries to get closer
function _M:moveDirection(x, y, force)
	if not self.x or not self.y or not x or not y then return false end
	local l = line.new(self.x, self.y, x, y)
	local lx, ly = l()
	if lx and ly then

		-- if we are blocked, try some other way
		if not self:aiCanPass(lx, ly) then
			local dir = util.getDir(lx, ly, self.x, self.y)
			local list = util.dirSides(dir, self.x, self.y)
			local l = {}
			-- Find possibilities
			for _, dir in pairs(list) do
				local dx, dy = util.coordAddDir(self.x, self.y, dir)
				if self:aiCanPass(dx, dy) then
					l[#l+1] = {dx,dy, core.fov.distance(x,y,dx,dy)^2}
				end
			end
			-- Move to closest
			if #l > 0 then
				table.sort(l, function(a,b) return a[3]<b[3] end)
				return self:move(l[1][1], l[1][2], force)
			end
		else
			return self:move(lx, ly, force)
		end
	end
end

--- Responsible for clearing ai target if needed
function _M:clearAITarget()
	if self.ai_target.actor and self.ai_target.actor.dead then self.ai_target.actor = nil end
end

--- Main entry point for AIs
function _M:doAI()
	if self.dead or not self.ai then return end

	-- If we have a target but it is dead (it was not yet garbage collected but it'll come)
	-- we forget it
	self:clearAITarget()

	-- Keep track of actors we've actually seen at least once in our own FOV, NPC calls doFOV right before doAI
	for i,v in ipairs(self.fov.actors_dist) do
		self.ai_actors_seen[v] = true
	end

	-- Update the ai_target table
	local target_pos = self.ai_target.actor and self.fov and self.fov.actors and self.fov.actors[self.ai_target.actor]
	if target_pos then
		local tx, ty = self:aiSeeTargetPos(self.ai_target.actor)
		self.ai_state.target_last_seen=table.merge(self.ai_state.target_last_seen or {}, {x=tx, y=ty, turn=self.fov_last_turn}) -- Merge to keep obfuscation data
	end

	return self:runAI(self.ai)
end

--- Run a specific AI for an actor
-- @param[type=string] ai the name of the ai to run
-- @param ... additional arguments to be passed to the AI
-- @return the result of self.ai_def[ai](self, ...)
function _M:runAI(ai, ...)
	if not (ai and self.ai_def[ai]) then
		print("[runAI] UNDEFINED AI", ai, "for", self.uid, self.name)
		return
	elseif not self.x then
		print("[runAI] CANNOT RUN AI for actor", self.uid, self.name, "(no location)")
		return
	end
	if config.settings.log_detail_ai > 1 then print("[ActorAI:runAI]", self.uid, self.name, "running AI:", ai, ...) end
	return _M.ai_def[ai](self, ...)
end

--- Get coordinates and reference to the current ai target
-- @param[type=table, optional] typ (resolved) targeting parameters
-- @return x coordinate to target
-- @return y coordinate to target
-- @return target actor @ x, y
--	returns the result of typ.talent.onAIGetTarget(self, typ.talent) (if defined)
function _M:getTarget(typ)
	if type(typ) == "table" then
		if typ.talent and typ.talent.onAIGetTarget then -- target according to talent
			return typ.talent.onAIGetTarget(self, typ.talent)
		elseif typ.first_target == "friend" and typ.default_target == self then -- special case: target self
			return self.x, self.y, self
		elseif typ.grid_params then -- target according to AI grid parameters
			return self:getTargetGrid(typ, typ.grid_params)
		end
	end
	-- target current ai_target
	local tx, ty = self:aiSeeTargetPos(self.ai_target.actor)
	local target = game.level.map(tx, ty, Map.ACTOR)
	return tx, ty, target
end

--- Sets the current AI target
-- @param [type=Entity, optional] target the target to set (assign nil to clear the target)
-- @param [type=table, optional] last_seen data for use by aiSeeTargetPos
-- When targeting a new entity, checks self.on_acquire_target and target.on_targeted
function _M:setTarget(target, last_seen)
	local old_target = self.ai_target.actor
	self.ai_target.actor = target
	if last_seen then
		self.ai_state.target_last_seen = last_seen
	else
		local target_pos = target and (self.fov and self.fov.actors and self.fov.actors[target] or {x=target.x, y=target.y}) or {x=self.x, y=self.y} --No FOV: aiSeeTargetPos will assume new target position is ~3 turns old by default (AI_LOCATION_GUESS_ERROR)
		self.ai_state.target_last_seen=table.merge(self.ai_state.target_last_seen or {}, {x=target_pos.x, y=target_pos.y, turn=game.turn}) -- Merge to keep obfuscation data
	end
	if target and target ~= old_target and game.level:hasEntity(target) then
		self:check("on_acquire_target", target)
		target:check("on_targeted", self)
	end
end

--- Called before a talent is used by the AI -- determines if the talent SHOULD (not CAN) be used.
-- Redefine as needed (This version always returns true)
-- @param[type=table] talent the talent (not the id, the table)
-- @param[type=boolean] silent no messages will be outputted
-- @param[type=boolean] fake no actions are taken, only checks
-- @return[1] true to continue
-- @return[2] false to stop
function _M:aiPreUseTalent(talent, silent, fake)
	return true
end

--- AI helper functions

_M.AI_LOCATION_GUESS_ERROR = 3  -- Start position guess errors at ~3 grids

--- Returns the seen coords of the target
-- This will usually return the exact coords, but if the target is only partially visible (or not at all)
-- it will return estimates, to throw the AI a bit off (up to 10 tiles error)
-- @param [type=Entity, optional] target the target we are tracking, defaults to self
-- @param [type=number, optional] add_spread the amount to add to the spread, default 0 (cache is not updated if this param exists)
-- @param [type=number, optional] max_spread the maximum spread used, default 10 (cache is not updated if this param exists)
-- @return x coord to move/cast to
-- @return y coord to move/cast to
function _M:aiSeeTargetPos(target, add_spread, max_spread)
	if not target then return self.x, self.y end
	local tx, ty = target.x, target.y
	local LSeen = self.ai_state.target_last_seen
	local do_cache = not (add_spread or max_spread)  -- Don't update our stored guess if we asked for a special spread
	local add_spread = add_spread or 0
	local max_spread = max_spread or 10
	if type(LSeen) ~= "table" then return tx, ty end
	local spread = 0

	-- Guess Cache turn to update position guess (so it's consistent during a turn)
	-- Set last cache turn before game turn to make sure it gets run the first time.
	LSeen.GCache_turn = LSeen.GCache_turn or game.turn - self.AI_LOCATION_GUESS_ERROR * game.energy_to_act/game.energy_per_tick

	-- Guess Cache known turn for spread calculation (self.ai_state.target_last_seen.turn
	-- can't be used because it's needed by FOV code)
	LSeen.GCknown_turn = LSeen.GCknown_turn or game.turn - self.AI_LOCATION_GUESS_ERROR * game.energy_to_act/game.energy_per_tick

	-- Check if target is currently seen
	local see, chance = self:canSee(target)
	if see and self:hasLOS(target.x, target.y) then -- canSee doesn't check LOS
		LSeen.GCache_x, LSeen.GCache_y = nil, nil
		LSeen.GCknown_turn = game.turn
		LSeen.GCache_turn = game.turn
	else
		if target == self.ai_target.actor and (((LSeen.GCache_turn or 0) + 10 <= game.turn and LSeen.x) or not do_cache) then  -- If we haven't updated cache yet this turn or were not using cache
			if target.last_special_movement and LSeen.GCknown_turn and (target.last_special_movement > LSeen.GCknown_turn) then 
				spread = max_spread  -- If the target has done a "special" movement, such as teleporting a long distance or out of LOS, max out our randomness so we don't cheat chasing them with teleports or whatever
			else
				spread = spread + math.min(max_spread, add_spread + (self:attr("ai_spread_add") or 0) + math.floor((game.turn - (LSeen.GCknown_turn or game.turn)) / (game.energy_to_act / game.energy_per_tick))) -- Limit spread to 10 tiles
			end
			tx, ty = util.bound(tx + rng.range(-spread, spread), 0, game.level.map.w - 1), util.bound(ty + rng.range(-spread, spread), 0, game.level.map.h - 1)
			
			-- Inertial average with last guess: can specify another method here to make the targeting position less random
			if LSeen.GCache_x and not do_cache then -- update guess with new random position
				tx = math.floor(LSeen.GCache_x + (tx-LSeen.GCache_x)/2)
				ty = math.floor(LSeen.GCache_y + (ty-LSeen.GCache_y)/2)
			end

			-- try to find a reasonable spot if target can't be at estimated position
			local act = game.level.map(tx, ty, Map.ACTOR)
			if (act and act ~= target and self:canSee(act)) or (target.canMove and not target:canMove(tx, ty, true)) then
				local nx, ny, grids = util.findFreeGrid(tx, ty, math.max(1, spread), false)
				if not grids then  -- sometimes there is no free grid at low spreads, so try again a bit wider on failure
					nx, ny, grids = util.findFreeGrid(tx, ty, 3, false) 
				end
				if grids then
					for i, grid in ipairs(grids) do
						act = game.level.map(grid[1], grid[2], Map.ACTOR)
						if not act or (act == target or act ~= self and not self:canSee(act)) then
							tx, ty = grid[1], grid[2]
							break
						end
					end
				end
			end
			if do_cache then
				LSeen.GCache_x, LSeen.GCache_y = tx, ty
				LSeen.GCache_turn = game.turn
			else
				-- If were not using cache return early
				return tx, ty
			end
		end
		if LSeen.GCache_x then return LSeen.GCache_x, LSeen.GCache_y end
	end
	return tx, ty -- Fall through to correct coords
end

--- Generate a list of target entities (Actors) a talent MAY affect if used
-- performs a projection of the talent using its targeting and other parameters (but with no effects)
-- @param[type=table] t the talent definition
-- @param[type=Entity, optional] aitarget the AIs target (actor) for the talent, used to target the projection
-- @param[type=table, optional] tg targeting table to use (for interpretation by engine.Target:getType())
--		defaults to self:getTalentTarget(t) or a direct "hit" or "bolt" attack
-- @param[type=boolean, optional] all_targets set true to gather all targets (ignoring selffire, friendlyfire)
-- @param[type=number, optional] ax, ay target coordinates for the talent, defaults to (in order):
--		t.onAIGetTarget(self, t), <tg.x, tg.y>, self:aiSeeTargetPos(aitarget)
-- @return[1] a list of entities that may be affected
-- @return[2] a single entity (actor) that may be affected (use __CLASSNAME to distinguish from list)
-- @return[1] selffire percent chance to affect self, 0 - 100 (based on tg)
-- @return[1] friendlyfire percent chance to affect an ally, 0 - 100 (based on tg)
-- @return[1] the base talent targeting table (if used)
function _M:aiTalentTargets(t, aitarget, tg, all_targets, ax, ay)
	local selffire, friendlyfire, targets = 100, 100
	local log_detail = config.settings.log_detail_ai or 0
	local requires_target = self:getTalentRequiresTarget(t)
	tg = tg or self:getTalentTarget(t)
	if t.onAIGetTarget and not (ax and ay) then -- get talent-specific target
		ax, ay, aitarget = t.onAIGetTarget(self, t)
	elseif not requires_target and not tg then -- no targeting info: target self only
		if log_detail > 2 then print("[aiTalentTargets] ", t.id, "may affect (SELF ONLY)", self.name, self.uid, "at", self.x, self.y) end
		targets, selffire, friendlyfire = self, 100, 100
	end
		
	if not targets then
		-- default direct hit or "bolt" attack
		local typ = engine.Target:getType(tg or {type=util.getval(t.direct_hit, self, t) and "hit" or "bolt"})
		if log_detail > 3 then print("[aiTalentTargets]", t.id, "using targeting parameters", typ) table.print(typ, "\t_typ_") end
		-- special case for some beneficial effects
		if typ.first_target == "friend" and typ.default_target == self and not t.onAIGetTarget then
			targets, selffire, friendlyfire = self, 100, 100
		else -- use targeting info to build target list
			ax, ay = ax or typ.x, ay or typ.y
			if not (ax and ay) then
				ax, ay = self:aiSeeTargetPos(aitarget)
			end
			selffire = typ.selffire and (type(typ.selffire) == "number" and typ.selffire or 100) or 0
			friendlyfire = typ.friendlyfire and (type(typ.friendlyfire) == "number" and typ.friendlyfire or 100) or 0
			if all_targets then	typ.selffire, typ.friendlyfire = 100, 100 end
			targets = {}
			if ax and ay then
				self:project(typ, ax, ay, function(px, py)
					local tgt = game.level.map(px, py, typ.scan_on or Map.ACTOR)
					if tgt and not tgt.dead then
						if log_detail > 2 then print("[aiTalentTargets]", t.id, "may affect", px, py, "actor:", tgt.uid, tgt.name) end
						targets[#targets+1] = tgt
					end
				end)
			end
		end
	end
	return targets, selffire, friendlyfire, tg
end

--- Generate a list of talents that may be used by the AI
-- @param[type=Entity, optional] aitarget target for the AI, used to check range, etc.
-- @param[type=table, optional] t_filter filter passed to self:filterTalent(t, filter)
--		Requires the ActorTalents interface. Only talents that pass the filter may be used.
--		may set flags: allow_dumb_use (ignore talent.no_dumb_use) and ignore_cd (allow talents on cooldown)
-- @param[type=table, optional, defalt: self.talents] t_list list of talent ids to pick from
-- 		if t_list is different than self.talents, it will be pruned of unusable (but not filtered) talent ids
-- @return list of talent id's for talents that can be used
-- talent criteria:
--		talents excluded if talent.no_npc_use or (unless filtered) talent.no_dumb_use are set
--		talent must not be cooling down (unless t_filter.ignore_cd)
--		self:preUseTalent(talent, ...) and self:aiPreUseTalent(talent, ...) must both return true
--		activated talents: checks self:getTalentRequiresTarget(t) or self:canProject(tg, tx, ty)
function _M:aiGetAvailableTalents(aitarget, t_filter, t_list)
	local log_detail = config.settings.log_detail_ai or 0
	if log_detail > 1 then print("[aiGetAvailableTalents]:", self.uid, self.name, "checking talents with target",aitarget and aitarget.uid, aitarget and aitarget.name, "list:", t_list) end
	local avail = {}
	t_list = t_list or self.talents
	local tx, ty = self:aiSeeTargetPos(aitarget)
	local prune, allow_dumb_use, ignore_cd
	if t_filter then allow_dumb_use, ignore_cd = t_filter.allow_dumb_use, t_filter.ignore_cd end
	for tid, _ in pairs(t_list) do
		local t = self:getTalentFromId(tid)
		if t and t.mode ~= "passive" and not t.no_npc_use then
			prune = false
			if (not t.no_dumb_use or allow_dumb_use) and (ignore_cd or not self:isTalentCoolingDown(t)) and (not t_filter or self:filterTalent(t, t_filter)) then
				if self:preUseTalent(t, true, true) and self:aiPreUseTalent(t, true, true) then
					local tx, ty, aitarget = tx, ty, aitarget
					if t.onAIGetTarget then -- handle special targeting (for heals and other friendly effects)
						tx, ty, aitarget = t.onAIGetTarget(self, t)
						if not (tx and ty) then
							tx, ty = self:aiSeeTargetPos(aitarget)
						end
					end
					local requires_target = t.requires_target ~= nil and self:getTalentRequiresTarget(t)
					local tg
					if requires_target then
						tg = self:getTalentTarget(t)
						if tg then -- modify targeting
							if tg == t.target then tg = table.clone(tg) end
							if tg.type ~= "hit" and tg.type ~= "bolt" then
								tg.type = not tg.stop_block and util.getval(t.direct_hit, self, t) and "hit" or "bolt"
							end
						else -- default targeting
							tg = {type=util.getval(t.direct_hit, self, t) and "hit" or "bolt"}	
						end
						tg.range = (self:getTalentRange(t) or 0) + (self:getTalentRadius(t) or 0)
					end
					
					if not tg or aitarget and self:canProject(tg, tx, ty) then
						avail[#avail+1] = tid
						if log_detail > 2 then
							if t.mode == "sustained" then
								print(self.name, self.uid, "::ai may "..(self.sustain_talents[tid] and "de-activate" or "activate").." talent", tid, t.name)
							else
								print(self.name, self.uid, "::ai may use talent", tid, t.name)
							end
						end
					else prune = true
					end
				else -- remove unusable talents from t_list (for additional calls with the same list)
					prune = true
				end
			end
		else prune = true
		end
		if prune and t_list ~= self.talents then
			t_list[tid] = nil
		end
	end
	return avail
end

--- Randomly find a grid, as close to the desired distance from target coordinates as possible 
--	(called by ActorAI:getTarget for NPCs that need to approach/retreat from a target grid)
-- @param[type=table, optional] tg = targeting table determining which grids can be reached via ActorProject:project
--		(defaults to a radius 10 ball at range 1 centered on self)
-- @param[type=table, optional] params parameters for finding acceptable grids:
-- 		center_x, center_y <default: self.x, self.y> = start location for projection (point to move from)
-- 		anchor_x, anchor_y <default: self.x, self.y> = anchor point (point to approach/avoid)
--		anchor_target <default: true> use self.ai_target.actor as the anchor point if possible
-- 		want_range <default: 1> = desired range between destination and anchor point
--		range <default max of tg.radius, tg.range> = maximum range from center point for grids searched
--		max_delta = required change in range - want_range for grids searched (negative -> get closer to want_range)
--		can_move <default: true> -- check self:canMove when determining acceptable grids
-- 		check = function(x, y) that must return true on acceptable grids
--	If tg.target_x, tg.target_y are set, the projection will be performed on the area specified, otherwise, all grids in a circle (radius = max radius or range) centered on center_x, center_y will be searched
-- @return[1] nil if no target grid could be found
-- @return[2] x coordinate of target grid
-- @return[2] y coordinate of target grid
-- @return[2] new range between target grid and anchor point
function _M:getTargetGrid(tg, params)
	local ax, ay, anchor_target, cx, cy, want_range, max_delta, can_move, grid_check
	local log_detail = config.settings.log_detail_ai or 0
	if params then -- set grid search_params
		cx, cy = params.center_x, params.center_y
		ax, ay = params.anchor_x, params.anchor_y
		want_range = params.want_range
		max_delta = params.max_delta
		grid_check = params.check
		can_move = params.can_move
		anchor_target = params.anchor_target
	end
	if anchor_target == nil then anchor_target = true end
	if can_move == nil then can_move = true end
	cx, cy = cx or self.x, cy or self.y
	if anchor_target and not (ax and ay) then
		if self.ai_target.actor then ax, ay = self:aiSeeTargetPos(self.ai_target.actor)	else ax, ay = cx, cy end
	end
	want_range = want_range or 0 -- defaults to closein to anchor point

	-- create a modified targeting table to search grids in a circle
	local tgs = tg and table.clone(tg) or {type="ball", range=0, radius=10}
	if tgs.target_x and tgs.target_y then -- Project with the targeting table provided
	
	else -- or project with a modified targeting table in a circle around the center point
		tgs.type = "ball"
		tgs.radius = params and params.range or math.max((tgs.radius or 0), (tgs.range or 0))
		if tgs.radius == 0 then tgs.radius = want_range + dist end
		tgs.range = 0
	end
	tgs.start_x = tgs.start_x or cx
	tgs.start_y = tgs.start_y or cy
	
	local dist, dist_val = core.fov.distance(cx, cy, ax, ay)

	if log_detail > 1 then print(("[getTargetGrid] searching grids center(%d, %d), anchor(%d, %d), radius %s, want_range:%d vs %d(max_delta:%s)"):format(cx, cy, ax, ay, tgs.radius, want_range, dist, max_delta))
		if log_detail > 2 then print(" working target table:", tgs) table.print(tgs, "\t_tgs_ ") end
	end
	if max_delta then max_delta = math.abs(dist - want_range) + max_delta end -- max diff from want_range allowed
	local grid = {x=cx, y=cy, dist_val=math.huge}
	
	local grid_val = function(px, py, t, self)
		dist = core.fov.distance(ax, ay, px, py)
		if (not max_delta or math.abs(dist - want_range) <= max_delta) and (not can_move or self:canMove(px, py)) then -- allowed grid
			if log_detail > 3 then print("[getTargetGrid] grid at", px, py, "is open, anchor dist:", dist) end
			dist_val = math.abs(dist - want_range) + rng.float(0, .1)
			if dist_val < grid.dist_val and (not grid_check or grid_check(px, py)) then -- better grid
				if log_detail > 2 then print("[getTargetGrid] updating best grid to", px, py, "dist:", dist, "dist_val:", dist_val) end
				grid.x, grid.y, grid.dist, grid.dist_val = px, py, dist, dist_val
			end
		end
	end
	local grids, stop_x, stop_y = self:project(tgs, tgs.target_x or cx, tgs.target_y or cy, grid_val)
	if grid.dist_val < math.huge then
		if log_detail > 1 then print("[getTargetGrid] found target grid at:", grid.x, grid.y, "anchor dist:", grid.dist) end
		return grid.x, grid.y, grid.dist
	elseif log_detail > 1 then print("[getTargetGrid] no acceptable grids found.")
	end
end