-- ToME - Tales of Maj'Eyal -- Copyright (C) 2009, 2010, 2011, 2012, 2013 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" local ActorAI = require "engine.interface.ActorAI" local Faction = require "engine.Faction" local Emote = require("engine.Emote") local Chat = require "engine.Chat" require "mod.class.Actor" module(..., package.seeall, class.inherit(mod.class.Actor, engine.interface.ActorAI)) function _M:init(t, no_default) mod.class.Actor.init(self, t, no_default) ActorAI.init(self, t) -- Grab default image name if none is set if not self.image and self.name ~= "unknown actor" then self.image = "npc/"..tostring(self.type or "unknown").."_"..tostring(self.subtype or "unknown"):lower():gsub("[^a-z0-9]", "_").."_"..(self.name or "unknown"):lower():gsub("[^a-z0-9]", "_")..".png" end end function _M:actBase() -- Reduce shoving pressure every turn if self.shove_pressure then if self._last_shove_pressure and (self.shove_pressure < self._last_shove_pressure) then self.shove_pressure = nil self._last_shove_pressure = nil else self._last_shove_pressure = self.shove_pressure self.shove_pressure = self.shove_pressure / 2 end end return mod.class.Actor.actBase(self) end function _M:act() while self:enoughEnergy() and not self.dead do -- Do basic actor stuff if not mod.class.Actor.act(self) then return end local old_energy = self.energy.value -- Compute FOV, if needed self:doFOV() -- Let the AI think .... beware of Shub ! self:doAI() if self.emote_random and self.x and self.y and game.level.map.seens(self.x, self.y) and rng.range(0, 999) < self.emote_random.chance * 10 then local e = util.getval(rng.table(self.emote_random)) if e then local dur = util.bound(#e, 30, 90) self:doEmote(e, dur) end end -- If AI did nothing, use energy anyway if not self.energy.used then self:useEnergy() end if old_energy == self.energy.value then break end -- Prevent infinite loops end end function _M:doFOV() -- If the actor has no special vision we can use the default cache if not self.special_vision then self:computeFOV(self.sight or 10, "block_sight", nil, nil, nil, true) else self:computeFOV(self.sight or 10, "block_sight") end end local function spotHostiles(self) local seen = {} if not self.x then return seen end -- Check for visible monsters, only see LOS actors, so telepathy wont prevent resting core.fov.calc_circle(self.x, self.y, game.level.map.w, game.level.map.h, self.sight or 10, function(_, x, y) return game.level.map:opaque(x, y) end, function(_, x, y) local actor = game.level.map(x, y, game.level.map.ACTOR) if actor and self:reactionToward(actor) < 0 and self:canSee(actor) and game.level.map.seens(x, y) then seen[#seen + 1] = {x=x,y=y,actor=actor} end end, nil) return seen end --- Try to auto use listed talents -- This should be called in your actors "act()" method function _M:automaticTalents() if self.no_automatic_talents then return end self:attr("_forbid_sounds", 1) for tid, c in pairs(self.talents_auto) do local t = self.talents_def[tid] local spotted = spotHostiles(self) if (t.mode ~= "sustained" or not self.sustain_talents[tid]) and not self.talents_cd[tid] and self:preUseTalent(t, true, true) and (not t.auto_use_check or t.auto_use_check(self, t)) then if (c == 1) or (c == 2 and #spotted <= 0) or (c == 3 and #spotted > 0) then if c ~= 2 then -- Do not fire hostile talents --self:useTalent(tid) else if not self:attr("blind") then self:useTalent(tid,nil,nil,nil,self) end end end if c == 4 and #spotted > 0 then for fid, foe in pairs(spotted) do if foe.x >= self.x-1 and foe.x <= self.x+1 and foe.y >= self.y-1 and foe.y <= self.y+1 then self:useTalent(tid) break end end end end end self:attr("_forbid_sounds", -1) end --- Create a line to target based on field of vision function _M:lineFOV(tx, ty, extra_block, block, sx, sy) sx = sx or self.x sy = sy or self.y local act = game.level.map(tx, ty, engine.Map.ACTOR) local sees_target = core.fov.distance(sx, sy, tx, ty) <= self.sight and game.level.map.lites(tx, ty) or act and self:canSee(act) and core.fov.distance(sx, sy, tx, ty) <= math.min(self.sight, math.max(self.heightened_senses or 0, self.infravision or 0)) local darkVisionRange if self:knowTalent(self.T_DARK_VISION) then local t = self:getTalentFromId(self.T_DARK_VISION) darkVisionRange = self:getTalentRange(t) end local inCreepingDark = false extra_block = type(extra_block) == "function" and extra_block or type(extra_block) == "string" and function(_, x, y) return game.level.map:checkAllEntities(x, y, extra_block) end -- This block function can be called *a lot*, so every conditional statement we move outside the function helps block = block or sees_target and (darkVisionRange and -- target is seen and source actor has dark vision function(_, x, y) if game.level.map:checkAllEntities(x, y, "creepingDark") then inCreepingDark = true end if inCreepingDark and core.fov.distance(sx, sy, x, y) > darkVisionRange then return true end return game.level.map:checkAllEntities(x, y, "block_sight") or game.level.map:checkEntity(x, y, engine.Map.TERRAIN, "block_move") and not game.level.map:checkEntity(x, y, engine.Map.TERRAIN, "pass_projectile") or extra_block and extra_block(self, x, y) end -- target is seen and source actor does NOT have dark vision or function(_, x, y) return game.level.map:checkAllEntities(x, y, "block_sight") or game.level.map:checkEntity(x, y, engine.Map.TERRAIN, "block_move") and not game.level.map:checkEntity(x, y, engine.Map.TERRAIN, "pass_projectile") or extra_block and extra_block(self, x, y) end) or darkVisionRange and -- target is NOT seen and source actor has dark vision (do we even need to check for creepingDark in this case?) function(_, x, y) if game.level.map:checkAllEntities(x, y, "creepingDark") then inCreepingDark = true end if inCreepingDark and core.fov.distance(sx, sy, x, y) > darkVisionRange then return true end if core.fov.distance(sx, sy, x, y) <= self.sight and game.level.map.lites(x, y) then return game.level.map:checkEntity(x, y, engine.Map.TERRAIN, "block_sight") or game.level.map:checkEntity(x, y, engine.Map.TERRAIN, "block_move") and not game.level.map:checkEntity(x, y, engine.Map.TERRAIN, "pass_projectile") or extra_block and extra_block(self, x, y) else return true end end or -- target is NOT seen and the source actor does NOT have dark vision function(_, x, y) if core.fov.distance(sx, sy, x, y) <= self.sight and game.level.map.lites(x, y) then return game.level.map:checkEntity(x, y, engine.Map.TERRAIN, "block_sight") or game.level.map:checkEntity(x, y, engine.Map.TERRAIN, "block_move") and not game.level.map:checkEntity(x, y, engine.Map.TERRAIN, "pass_projectile") or extra_block and extra_block(self, x, y) else return true end end return core.fov.line(sx, sy, tx, ty, block) end --- Give target to others function _M:seen_by(who) if self:hasEffect(self.EFF_VAULTED) and who and game.party:hasMember(who) then self:removeEffect(self.EFF_VAULTED, true, true) end -- Check if we can pass target if self.dont_pass_target then return end if not who.ai_target then return end if not who.ai_target.actor then return end if not who.ai_target.actor.x then return end -- Only receive targets from allies if self:reactionToward(who) <= 0 then return end -- Check if we can actually see the ally (range and obstacles) if not who.x or not self:hasLOS(who.x, who.y) then return end if self.ai_target.actor then -- Pass last seen coordinates if self.ai_target.actor == who.ai_target.actor then -- Adding some type-safety checks, but this isn't fixing the source of the errors local last_seen = {turn=0} if self.ai_state.target_last_seen and type(self.ai_state.target_last_seen) == "table" then last_seen = self.ai_state.target_last_seen end if who.ai_state.target_last_seen and type(who.ai_state.target_last_seen) == "table" and who.ai_state.target_last_seen.turn > last_seen.turn then last_seen = who.ai_state.target_last_seen end if last_seen.x and last_seen.y then self.ai_state.target_last_seen = last_seen who.ai_state.target_last_seen = last_seen end end return end if who.ai_state and who.ai_state.target_last_seen and type(who.ai_state.target_last_seen) == "table" then -- Don't believe allies if they saw the target far, far away if who.ai_state.target_last_seen.x and who.ai_state.target_last_seen.y and core.fov.distance(self.x, self.y, who.ai_state.target_last_seen.x, who.ai_state.target_last_seen.y) > self.sight then return end -- Don't believe allies if they saw the target over 10 turns ago if (game.turn - (who.ai_state.target_last_seen.turn or game.turn)) / (game.energy_to_act / game.energy_per_tick) > 10 then return end end -- And only trust the ally if they can actually see the target if not who:canSee(who.ai_target.actor) then return end self:setTarget(who.ai_target.actor, who.ai_state.target_last_seen) print("[TARGET] Passing target", self.name, "from", who.name, "to", who.ai_target.actor.name) end --- Check if we are angered -- @param src the angerer -- @param set true if value is the finite value, false if it is an increment -- @param value the value to add/subtract function _M:checkAngered(src, set, value) if not src.resolveSource then return end if not src.faction then return end if self.never_anger then return end if game.party:hasMember(self) then return end if self.summoner and self.summoner == src then return end -- Cant anger at our own faction unless it's the silly player if self.faction == src.faction and not src.player then return end local rsrc = src:resolveSource() local rid = rsrc.unique or rsrc.name if not self.reaction_actor then self.reaction_actor = {} end local was_hostile = self:reactionToward(src) < 0 if not set then self.reaction_actor[rid] = util.bound((self.reaction_actor[rid] or 0) + value, -200, 200) else self.reaction_actor[rid] = util.bound(value, -200, 200) end if not was_hostile and self:reactionToward(src) < 0 then if self.anger_emote then self:doEmote(self.anger_emote:gsub("@himher@", src.female and "her" or "him"), 30) end end end --- Counts down timedEffects, but need to avoid the damaged A* pathing function _M:timedEffects(filter) self._in_timed_effects = true mod.class.Actor.timedEffects(self, filter) self._in_timed_effects = nil end --- Called by ActorLife interface -- We use it to pass aggression values to the AIs function _M:onTakeHit(value, src) value = mod.class.Actor.onTakeHit(self, value, src) if not self.ai_target.actor and src and src.targetable and value > 0 then self.ai_target.actor = src end -- Switch to astar pathing temporarily if src and src == self.ai_target.actor and not self._in_timed_effects then self.ai_state.damaged_turns = 10 end -- Get angry if attacked by a friend if src and src ~= self and src.resolveSource and src.faction and self:reactionToward(src) >= 0 and value > 0 then self:checkAngered(src, false, -50) -- Call for help if we become hostile for i = 1, #self.fov.actors_dist do local act = self.fov.actors_dist[i] if act and act ~= self and self:reactionToward(act) > 0 and not act.dead and act.checkAngered then act:checkAngered(src, false, -50) end end end return value end function _M:die(src, death_note) if self.dead then self:disappear(src) self:deleteFromMap(game.level.map) return true end if src and Faction:get(self.faction) and Faction:get(self.faction).hostile_on_attack then Faction:setFactionReaction(self.faction, src.faction, Faction:factionReaction(self.faction, src.faction) - self.rank, true) end -- Get angry if attacked by a friend if src and src ~= self and src.resolveSource and src.faction then local rsrc = src:resolveSource() local rid = rsrc.unique or rsrc.name -- Call for help if we become hostile for i = 1, #self.fov.actors_dist do local act = self.fov.actors_dist[i] if act and act ~= self and act:reactionToward(rsrc) >= 0 and self:reactionToward(act) > 0 and not act.dead and act.checkAngered then act:checkAngered(src, false, -101) end end end -- Self resurrect, mouhaha! if self:attr("self_resurrect") then self:attr("self_resurrect", -1) game.logSeen(self, "#LIGHT_RED#%s rises from the dead!", self.name:capitalize()) -- src, not self as the source, to make sure the player knows his doom ;> local sx, sy = game.level.map:getTileToScreen(self.x, self.y) game.flyers:add(sx, sy, 30, (rng.range(0,2)-1) * 0.5, -3, "RESURRECT!", {255,120,0}) local effs = {} -- Go through all spell effects for eff_id, p in pairs(self.tmp) do local e = self.tempeffect_def[eff_id] effs[#effs+1] = {"effect", eff_id} end -- Go through all sustained spells for tid, act in pairs(self.sustain_talents) do if act then effs[#effs+1] = {"talent", tid} end end while #effs > 0 do local eff = rng.tableRemove(effs) if eff[1] == "effect" then self:removeEffect(eff[2]) else self:forceUseTalent(eff[2], {ignore_energy=true}) end end self.life = self.max_life self.mana = self.max_mana self.stamina = self.max_stamina self.equilibrium = 0 self.air = self.max_air self.dead = false self.died = (self.died or 0) + 1 self:move(self.x, self.y, true) self:check("on_resurrect", "basic_resurrect") if self:attr("self_resurrect_chat") then local chat = Chat.new(self.self_resurrect_chat, self, game.player) chat:invoke() self.self_resurrect_chat = nil end return end if self.rank >= 4 and game.state:allowRodRecall() and not self:attr("no_rod_recall") then local rod = game.zone:makeEntityByName(game.level, "object", "ROD_OF_RECALL") if rod then game.zone:addEntity(game.level, rod, "object", self.x, self.y) game.state:allowRodRecall(false) if self.define_as == "THE_MASTER" then world:gainAchievement("FIRST_BOSS_MASTER", src) elseif self.define_as == "GRAND_CORRUPTOR" then world:gainAchievement("FIRST_BOSS_GRAND_CORRUPTOR", src) elseif self.define_as == "PROTECTOR_MYSSIL" then world:gainAchievement("FIRST_BOSS_MYSSIL", src) elseif self.define_as == "URKIS" then world:gainAchievement("FIRST_BOSS_URKIS", src) end end end -- Ok the player managed to kill a boss dont bother him with tutorial anymore if self.rank >= 3.5 and not profile.mod.allow_build.tutorial_done then game:setAllowedBuild("tutorial_done") end return mod.class.Actor.die(self, src, death_note) end function _M:tooltip(x, y, seen_by) local str = mod.class.Actor.tooltip(self, x, y, seen_by) if not str then return end local killed = game:getPlayer(true).all_kills and (game:getPlayer(true).all_kills[self.name] or 0) or 0 local target = self.ai_target.actor str:add( true, ("Killed by you: %s"):format(killed), true, "Target: ", target and target.name or "none" ) -- Give hints to stealthed/invisible players about where the NPC is looking (if they have LOS) if target == game.player and (game.player:attr("stealth") or game.player:attr("invisible")) and game.player:hasLOS(self.x, self.y) then local tx, ty = self:aiSeeTargetPos(self.ai_target.actor) local dx, dy = tx - self.ai_target.actor.x, ty - self.ai_target.actor.y local offset = engine.Map:compassDirection(dx, dy) if offset then str:add(" looking " ..offset) if config.settings.cheat then str:add((" (%+d, %+d)"):format(dx, dy)) end else str:add(" looking at you.") end end if config.settings.cheat then str:add(true, "UID: "..self.uid, true, self.image) end return str end function _M:getTarget(typ) -- Free ourselves if self:attr("encased_in_ice") then return self.x, self.y, self -- Heal/buff/... ourselves elseif type(typ) == "table" and typ.first_target == "friend" and typ.default_target == self then return self.x, self.y, self -- Hit our foes else return ActorAI.getTarget(self, typ) end end --- Make emotes appear in the log too function _M:setEmote(e) game.logSeen(self, "%s says: '%s'", self.name:capitalize(), e.text) mod.class.Actor.setEmote(self, e) end --- Simple emote function _M:doEmote(text, dur, color) self:setEmote(Emote.new(text, dur, color)) end --- Call when added to a level -- Used to make escorts and such function _M:addedToLevel(level, x, y) if not self:attr("difficulty_boosted") then if game.difficulty == game.DIFFICULTY_NIGHTMARE and not game.party:hasMember(self) then -- Increase talent level for tid, lev in pairs(self.talents) do self:learnTalent(tid, true, math.ceil(lev / 2)) end self:attr("difficulty_boosted", 1) elseif game.difficulty == game.DIFFICULTY_INSANE and not game.party:hasMember(self) then -- Increase talent level for tid, lev in pairs(self.talents) do self:learnTalent(tid, true, lev) end self:attr("difficulty_boosted", 1) elseif game.difficulty == game.DIFFICULTY_MADNESS and not game.party:hasMember(self) then -- Increase talent level for tid, lev in pairs(self.talents) do self:learnTalent(tid, true, math.ceil(lev * 1.7)) end self:attr("difficulty_boosted", 1) end end -- Bosses that can pass through things should be smart about finding their target if self.rank > 3 and self.can_pass and type(self.can_pass) == "table" and next(self.can_pass) and self.ai_state and not self.ai_state.boss_ghost_no_astar then self.ai_state.ai_move = "move_astar" end return mod.class.Actor.addedToLevel(self, level, x, y) end --- Responsible for clearing ai target if needed -- Pass target to summoner if any function _M:clearAITarget() if self.ai_target.actor and (self.ai_target.actor.dead or not game.level:hasEntity(self.ai_target.actor)) and self.ai_target.actor.summoner then self.ai_target.actor = self.ai_target.actor.summoner -- You think you can cheat with summons ? let's cheat back ! -- yeah it's logical because .. hum .. yeah because the npc saw were the summon came from! local tx, ty = self:aiSeeTargetPos(self.ai_target.actor) self.ai_state.target_last_seen = {x=tx, y=ty, turn=game.turn} end if self.ai_target.actor and self.ai_target.actor.dead then self.ai_target.actor = nil end end local shove_algorithm = function(self) return 3 * self.rank + self.size_category * self.size_category end function _M:aiCanPass(x, y) -- If there is a friendly actor, add shove_pressure to it local target = game.level.map(x, y, engine.Map.ACTOR) if target and target ~= game.player and self:reactionToward(target) > 0 and not target:attr("never_move") then target.shove_pressure = (target.shove_pressure or 0) + shove_algorithm(self) + (self.shove_pressure or 0) -- Shove the target? if target.shove_pressure > shove_algorithm(target) * 1.7 then local dir = util.getDir(target.x, target.y, self.x, self.y) local sides = util.dirSides(dir, target.x, target.y) local check_order = {} if rng.percent(50) then table.insert(check_order, "left") table.insert(check_order, "right") else table.insert(check_order, "right") table.insert(check_order, "left") end if rng.percent(50) then table.insert(check_order, "hard_left") table.insert(check_order, "hard_right") else table.insert(check_order, "hard_right") table.insert(check_order, "hard_left") end for _, side in ipairs(check_order) do local check_dir = sides[side] local sx, sy = util.coordAddDir(target.x, target.y, check_dir) if target:canMove(sx, sy) and target:move(sx, sy) then game.logSeen(target, "%s shoves %s forward.", self.name:capitalize(), target.name) target.shove_pressure = nil target._last_shove_pressure = nil break end end return true end end return engine.interface.ActorAI.aiCanPass(self, x, y) end --- 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 -- @param target the target we are tracking -- @return x, y coords to move/cast to function _M:aiSeeTargetPos(target) if not target then return self.x, self.y end local tx, ty = target.x, target.y -- Special case, a boss that can pass walls can always home in on the target; otherwise it would get lost in the walls for a long while -- We check wall walking not on self but rather as a check if the target could reach us if self.rank > 3 and target.canMove and not target:canMove(self.x, self.y, true) then return util.bound(tx, 0, game.level.map.w - 1), util.bound(ty, 0, game.level.map.h - 1) end return ActorAI.aiSeeTargetPos(self, target) end