Skip to content
Snippets Groups Projects
Player.lua 62.1 KiB
Newer Older
dg's avatar
dg committed
-- ToME - Tales of Maj'Eyal
DarkGod's avatar
DarkGod committed
-- Copyright (C) 2009 - 2019 Nicolas Casalini
dg's avatar
dg committed
--
-- 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

dg's avatar
dg committed
require "engine.class"
dg's avatar
dg committed
require "mod.class.Actor"
dg's avatar
dg committed
require "engine.interface.PlayerRest"
dg's avatar
dg committed
require "engine.interface.PlayerRun"
dg's avatar
dg committed
require "engine.interface.PlayerHotkeys"
dg's avatar
dg committed
require "engine.interface.PlayerSlide"
require "engine.interface.PlayerMouse"
require "mod.class.interface.PlayerStats"
dg's avatar
dg committed
require "mod.class.interface.PlayerDumpJSON"
require "mod.class.interface.PlayerExplore"
require "mod.class.interface.PartyDeath"
dg's avatar
dg committed
local Map = require "engine.Map"
dg's avatar
dg committed
local Dialog = require "engine.ui.Dialog"
local ActorTalents = require "engine.interface.ActorTalents"
dg's avatar
dg committed

dg's avatar
dg committed
--- Defines the player for ToME
-- It is a normal actor, with some redefined methods to handle user interaction.<br/>
dg's avatar
dg committed
-- It is also able to run and rest and use hotkeys
dg's avatar
dg committed
module(..., package.seeall, class.inherit(
	mod.class.Actor,
	engine.interface.PlayerRest,
dg's avatar
dg committed
	engine.interface.PlayerRun,
dg's avatar
dg committed
	engine.interface.PlayerHotkeys,
	engine.interface.PlayerSlide,
	mod.class.interface.PlayerStats,
	mod.class.interface.PlayerDumpJSON,
	mod.class.interface.PlayerExplore,
	mod.class.interface.PartyDeath
dg's avatar
dg committed
))
dg's avatar
dg committed

-- Allow character registration even after birth
allow_late_uuid = true

dg's avatar
dg committed
function _M:init(t, no_default)
dg's avatar
dg committed
	t.display=t.display or '@'
	t.color_r=t.color_r or 230
	t.color_g=t.color_g or 230
	t.color_b=t.color_b or 230

	t.unique = t.unique or "player"
dg's avatar
dg committed
	t.player = true
	if type(t.open_door) == "nil" then t.open_door = true end
dg's avatar
dg committed
	t.type = t.type or "humanoid"
	t.subtype = t.subtype or "player"
	t.faction = t.faction or "players"
dg's avatar
dg committed

	t.ai_state = t.ai_state or {talent_in=1, ai_move="move_astar"}
dg's avatar
dg committed
	if t.fixed_rating == nil then t.fixed_rating = true end
dg's avatar
dg committed

	-- Dont give free resists & higher stat max to players
	t.resists_cap = t.resists_cap or {}
dg's avatar
dg committed

dg's avatar
dg committed
	t.lite = t.lite or 0
dg's avatar
dg committed

dg's avatar
dg committed
	t.rank = t.rank or 3
dg's avatar
dg committed
	t.shader_old_life = 0
dg's avatar
dg committed
	t.old_psi = 0
dg's avatar
dg committed

	t.money_value_multiplier = t.money_value_multiplier or 1 -- changes amounts in gold piles and such
dg's avatar
dg committed

dg's avatar
dg committed
	mod.class.Actor.init(self, t, no_default)
	engine.interface.PlayerHotkeys.init(self, t)
dg's avatar
dg committed
	self.descriptor = self.descriptor or {}
	self.died_times = self.died_times or {}
	self.last_learnt_talents = self.last_learnt_talents or { class={}, generic={} }
	self.puuid = self.puuid or util.uuid()
dg's avatar
dg committed

	self.damage_log = self.damage_log or {weapon={}}
	self.damage_intake_log = self.damage_intake_log or {weapon={}}
	self.talent_kind_log = self.talent_kind_log or {}
dg's avatar
dg committed
end

function _M:registerOnBirthForceWear(data)
	self._on_birth = self._on_birth or {}
	self._on_birth[#self._on_birth+1] = function()
		local o = game.zone:makeEntityByName(game.level, "object", data, true)
		o:identify(true)
		local ro = self:wearObject(o, true, true)
		if ro then
			if type(ro) == "table" then self:addObject(self:getInven(self.INVEN_INVEN), ro) end
		elseif not ro then
			self:addObject(self:getInven(self.INVEN_INVEN), o)
		end

	end
end

function _M:registerOnBirth(f)
	self._on_birth = self._on_birth or {}
	self._on_birth[#self._on_birth+1] = f
end

function _M:onBirth(birther)
	-- Make a list of random escort levels
	local race_def = birther.birth_descriptor_def.race[self.descriptor.race]
dg's avatar
dg committed
	local subrace_def = birther.birth_descriptor_def.subrace[self.descriptor.subrace]
	local world = birther.birth_descriptor_def.world[self.descriptor.world]
dg's avatar
dg committed
	local def = subrace_def.random_escort_possibilities or race_def.random_escort_possibilities
	if world.random_escort_possibilities then def = world.random_escort_possibilities end -- World overrides
dg's avatar
dg committed
	if def then
dg's avatar
dg committed
		for i, zd in ipairs(def) do for j = zd[2], zd[3] do zones[#zones+1] = {zd[1], j} end end
		self.random_escort_levels = {}
		for i = 1, world.random_escort_possibilities_max or 9 do
			local z = rng.tableRemove(zones)
			print("Random escort on", z[1], z[2])
			self.random_escort_levels[z[1]] = self.random_escort_levels[z[1]] or {}
			self.random_escort_levels[z[1]][z[2]] = true
		end
	end

	for i, f in ipairs(self._on_birth or {}) do f(self, birther) end
	self._on_birth = nil
end

function _M:onEnterLevel(zone, level)
dg's avatar
dg committed
	-- Save where we entered
	self.entered_level = {x=self.x, y=self.y}

dg's avatar
dg committed
	-- mark entrance (if applicable) as noticed
	game.level.map.attrs(self.x, self.y, "noticed", true)

	local escort_zone_name = zone.short_name
	local escort_zone_offset = 0

	if zone.tier1_escort then
		escort_zone_offset = zone.tier1_escort - 1

		self.entered_tier1_zones = self.entered_tier1_zones or {}
		self.entered_tier1_zones.seen = self.entered_tier1_zones.seen or {}
		self.entered_tier1_zones.nb = self.entered_tier1_zones.nb or 0
		if not self.entered_tier1_zones.seen[zone.short_name] then
			self.entered_tier1_zones.nb = self.entered_tier1_zones.nb + 1
			self.entered_tier1_zones.seen[zone.short_name] = self.entered_tier1_zones.nb
		end

		escort_zone_name = "tier1."..self.entered_tier1_zones.seen[zone.short_name]
		print("Entering tier1 zone for escort", escort_zone_name, escort_zone_offset, level.level - escort_zone_offset)
		if self.random_escort_levels and self.random_escort_levels[escort_zone_name] then
			table.print(self.random_escort_levels[escort_zone_name])
		end
	end

	-- Fire random escort quest
	if self.random_escort_levels and self.random_escort_levels[escort_zone_name] and self.random_escort_levels[escort_zone_name][level.level - escort_zone_offset] then
		if self:triggerHook{"Player:onEnterLevel:generateEscort", zone=zone, level=level} then
			-- nothing
		elseif game:isCampaign("Maj'Eyal") then
			self:grantQuest("escort-duty")
		end

	-- Cancel effects
	local effs = {}
	for eff_id, p in pairs(self.tmp) do
		if self.tempeffect_def[eff_id].cancel_on_level_change then
			effs[#effs+1] = eff_id
			if type(self.tempeffect_def[eff_id].cancel_on_level_change) == "function" then self.tempeffect_def[eff_id].cancel_on_level_change(self, p) end
	for i, eff_id in ipairs(effs) do self:removeEffect(eff_id, nil, true) end

	-- Clear existing player created effects on the map
	for i, eff in ipairs(level.map.effects) do
		if (eff.src and (eff.src.player or (eff.src.summoner and eff.src:resolveSource().player))) then
			eff.duration = 0
			eff.grids = {}
			print("[onEnterLevel] Cancelling player created effect ", tostring(eff.name))
	-- Clear existing player created entities from the map
	local todel = {}
	for uid, ent in pairs(level.entities) do
		if ((ent.summoner and ent.summoner.player) or (ent.src and ent.src.player)) and not game.party:hasMember(ent) then
			print("[onEnterLevel] Terminating player created entity ", uid, ent.name, ent:getEntityKind())
			if ent.temporary then ent.temporary = 0 end
			if ent.summon_time then ent.summon_time = 0 end
			if ent.duration then ent.duration = 0 end
			if ent:getEntityKind() == "projectile" then
				todel[#todel+1] = ent
			end
		end
	end
	for _, ent in ipairs(todel) do
		level:removeEntity(ent, true)
		ent.dead = true
	end
DarkGod's avatar
DarkGod committed

	self:fireTalentCheck("callbackOnChangeLevel", "enter", zone, level)
DarkGod's avatar
DarkGod committed

	game:updateCurrentChar()
function _M:onEnterLevelEnd(zone, level)
	if level._player_enter_scatter then return end
	level._player_enter_scatter = true
	if level.data.generator and level.data.generator.map and (level.data.generator.map.class == "engine.generator.map.MapScript" or level.data.generator.map.class == "engine.generator.map.Static") and not level.data.static_force_scatter then return end
DarkGod's avatar
DarkGod committed
	self:project({type="ball", radius=5}, self.x, self.y, function(px, py)
		local a = level.map(px, py, Map.ACTOR)
		if a and self:reactionToward(a) < 0 then
			a:teleportRandom(self.x, self.y, 50, 5)
		end
	end)
function _M:onLeaveLevel(zone, level)
	if self:hasEffect(self.EFF_FEED) then
		self:removeEffect(self.EFF_FEED, true)
	end

	-- Fail past escort quests
	local eid = "escort-duty-"..zone.short_name.."-"..level.level
	if self:hasQuest(eid) and not self:hasQuest(eid):isEnded() then
		local q = self:hasQuest(eid)
		q.abandoned = true
		self:setQuestStatus(eid, q.FAILED)
	end
DarkGod's avatar
DarkGod committed

	self:fireTalentCheck("callbackOnChangeLevel", "leave", zone, level)
-- Wilderness encounter
function _M:onWorldEncounter(target, x, y)
dg's avatar
dg committed
	if target.on_encounter then
		if x and y and game.level.map(x, y, Map.ACTOR) == target then
			game.level.map:remove(x, y, Map.ACTOR)
		end
dg's avatar
dg committed
		game.state:handleWorldEncounter(target)
	end
function _M:describeFloor(x, y, force)
	if self.old_x == x and self.old_y == y and not force then return end
dg's avatar
dg committed

	-- Autopickup things on the floor
	if self:getInven(self.INVEN_INVEN) and not self.no_inventory_access and not (self:attr("sleep") and not self:attr("lucid_dreamer")) then
DarkGod's avatar
DarkGod committed
		local i, nb = game.level.map:getObjectTotal(x, y), 0
		local obj = game.level.map:getObject(x, y, i)
		while obj do
dg's avatar
dg committed
			if obj.auto_pickup and self:pickupFloor(i, true) then desc = false end
			if desc and self:attr("has_transmo") and obj.__transmo == nil then
dg's avatar
dg committed
				obj.__transmo_pre = true
				if self:pickupFloor(i, true) then
					desc = false
					if self:transmoFilter(obj, self) then obj.__transmo = true end
dg's avatar
dg committed
				obj.__transmo_pre = nil
				if self:attr("auto_id") and obj:getPowerRank() <= self.auto_id then obj:identify(true) end
				nb = nb + 1
				game.logSeen(self, "There is an item here: %s", obj:getName{do_color=true})
			end
DarkGod's avatar
DarkGod committed
			i = i - 1
			obj = game.level.map:getObject(x, y, i)
		end
	end

	local g = game.level.map(x, y, game.level.map.TERRAIN)
	if g and g.change_level then
Otowa Kotori's avatar
Otowa Kotori committed
		game.logPlayer(self, "#YELLOW_GREEN#There is %s here (press '<', '>' or right click to use).", g:getName():a_an())
		local sx, sy = game.level.map:getTileToScreen(x, y, true)
Otowa Kotori's avatar
Otowa Kotori committed
		game.flyers:add(sx, sy, 60, 0, -1.5, ("Level change (%s)!"):tformat(g:getName()), colors.simple(colors.YELLOW_GREEN), true)
function _M:openVault(vault_id)
	local v = game.level.vaults_list[vault_id]
	if not v then return end

	print("Vault id", vault_id, "opens:", v.x, v.y, v.w, v.h)
	for i = v.x, v.x + v.w - 1 do for j = v.y, v.y + v.h - 1 do
		if  game.level.map.attrs(i, j, "vault_id") == vault_id then
			-- game.level.map.attrs(i, j, "vault_id", false)
			 local act = game.level.map(i, j, Map.ACTOR)
			 if act and not act.player then
			 	act:removeEffect(act.EFF_VAULTED, true, true)
			 end
		end
	end end
end

dg's avatar
dg committed
function _M:move(x, y, force)
	local ox, oy = self.x, self.y

	if not force and self:enoughEnergy() and game.level.map:checkEntity(x, y, Map.TRAP, "is_store") then
		game.level.map:checkEntity(x, y, Map.TRAP, "block_move", self, true)
		return false
	end

dg's avatar
dg committed
	local moved = mod.class.Actor.move(self, x, y, force)
	if not moved and self.encumbered then
		game.logPlayer(self, "#FF0000#You carry too much--you are encumbered!")
		game.logPlayer(self, "#FF0000#Drop some of your items.")
	end

	if not force and ox == self.x and oy == self.y and self.doPlayerSlide then
		self.doPlayerSlide = nil
		local tx, ty = self:tryPlayerSlide(x, y, false)
		if tx then moved = self:move(tx, ty, false) end
	end
	self.doPlayerSlide = nil

dg's avatar
dg committed
	if moved then
		game.level.map:moveViewSurround(self.x, self.y, config.settings.tome.scroll_dist, config.settings.tome.scroll_dist)
dg's avatar
dg committed
		game.level.map.attrs(self.x, self.y, "walked", true)
dg's avatar
dg committed

		if self.describeFloor then self:describeFloor(self.x, self.y) end

		if not force and game.level.map.attrs(self.x, self.y, "vault_id") and not game.level.map.attrs(self.x, self.y, "vault_only_door_open") then self:openVault(game.level.map.attrs(self.x, self.y, "vault_id")) end
dg's avatar
dg committed
	end
dg's avatar
dg committed

dg's avatar
dg committed
--	if not force and ox == self.x and oy == self.y and self.tryPlayerSlide then
--		x, y = self:tryPlayerSlide(x, y, false)
--		self.tryPlayerSlide = false
--		moved = self:move(x, y, false)
--		self.tryPlayerSlide = nil
--	end

dg's avatar
dg committed
	-- Update wilderness coords
	if game.zone.wilderness and not force then
dg's avatar
dg committed
		-- Cheat with time
		game.turn = game.turn + 1000
dg's avatar
dg committed
		self.wild_x, self.wild_y = self.x, self.y
		if self.x ~= ox or self.y ~= oy then
			game.state:worldDirectorAI()
		end
dg's avatar
dg committed
	end

dg's avatar
dg committed
	-- Update zone name
	if game.zone.variable_zone_name then game:updateZoneName() end

dg's avatar
dg committed
	self.old_x, self.old_y = self.x, self.y

dg's avatar
dg committed
	return moved
end

function _M:actBase()
	mod.class.Actor.actBase(self)
dg's avatar
dg committed

dg's avatar
dg committed
	-- Run out of time ?
	if self.summon_time then
		self.summon_time = self.summon_time - 1
		if self.summon_time <= 0 then
Otowa Kotori's avatar
Otowa Kotori committed
			game.logPlayer(self, "#PINK#Your summoned %s disappears.", self:getName())
dg's avatar
dg committed
			self:die()
			return true
		end
	end
--- Entry point for Player actions
function _M:act()
	if not mod.class.Actor.act(self) then return end
dg's avatar
dg committed

	-- Funky shader things !
	self:updateMainShader()

DarkGod's avatar
DarkGod committed
	if config.settings.tome.life_lost_warning and self.shader_old_life then
		local perc = (self.shader_old_life - self.life) / (self.max_life - self.die_at)
		if perc > (config.settings.tome.life_lost_warning / 100) then
			game.bignews:say(100, "#LIGHT_RED#LIFE LOST WARNING!")
			game.key.disable_until = core.game.getTime() + 2000
			game.mouse.disable_until = core.game.getTime() + 2000
		end
	end

dg's avatar
dg committed
	self.shader_old_life = self.life
dg's avatar
dg committed
	self.old_psi = self.psi
	self.old_healwarn = (self:attr("no_healing") or ((self.healing_factor or 1) <= 0))
dg's avatar
dg committed
	-- Clean log flasher
dg's avatar
dg committed

	-- update feed/beckoned immediately before the player moves for best visual consistency (this is not perfect but looks much better than updating mid-move)
	if self:hasEffect(self.EFF_FEED) then
		self.tempeffect_def[self.EFF_FEED].updateFeed(self, self:hasEffect(self.EFF_FEED))
	elseif self:hasEffect(self.EFF_FED_UPON) then
		local fed_upon_eff = self:hasEffect(self.EFF_FED_UPON)
		fed_upon_eff.src.tempeffect_def[fed_upon_eff.src.EFF_FEED].updateFeed(fed_upon_eff.src, fed_upon_eff.src:hasEffect(self.EFF_FEED))
	end

dg's avatar
dg committed
	-- Resting ? Running ? Otherwise pause
	if self.player and self:enoughEnergy() then
		if self:restStep() then
			while self:enoughEnergy() and self:restStep() do end
			while self:enoughEnergy() and self:runStep() do end
		end
		
		if self:enoughEnergy() then
			game.paused = true
			if game.uiset.logdisplay:getNewestLine() ~= "" then game.log("") end
		end
	elseif not self.player then
		self:useEnergy()
dg's avatar
dg committed
	end
dg's avatar
dg committed
end
dg's avatar
dg committed

function _M:useEnergy(val)
	mod.class.Actor.useEnergy(self, val)
	if self.player and self.energy.value < game.energy_to_act then
		game.paused = false
		self:fireTalentCheck("callbackOnActEnd")
	end
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
	if config.settings.cheat then str:add(true, "UID: "..self.uid, true, self.image) end

	return str
end

DarkGod's avatar
DarkGod committed
function _M:resetMainShader()
	self.shader_old_life = nil
	self.old_air = nil
	self.old_psi = nil
	self.old_healwarn = nil
	self:updateMainShader()
end

--- Funky shader stuff
function _M:updateMainShader()
	if game.fbo_shader then
DarkGod's avatar
DarkGod committed
		local pf = game.posteffects or {}
		-- Set shader HP warning
dg's avatar
dg committed
		if self.life ~= self.shader_old_life then
			if (self.life - self.die_at) < (self.max_life - self.die_at) / 2 then game.fbo_shader:setUniform("hp_warning", 1 - (self.life / (self.max_life - self.die_at)))
			else game.fbo_shader:setUniform("hp_warning", 0) end
		end
		-- Set shader air warning
		if self.air ~= self.old_air then
			if self.air < self.max_air / 2 then game.fbo_shader:setUniform("air_warning", 1 - (self.air / self.max_air))
			else game.fbo_shader:setUniform("air_warning", 0) end
		end
dg's avatar
dg committed
		if self:attr("solipsism_threshold") and self.psi ~= self.old_psi then
			local solipsism_power = self:attr("solipsism_threshold") - self:getPsi()/self:getMaxPsi()
			if solipsism_power > 0 then game.fbo_shader:setUniform("solipsism_warning", solipsism_power)
			else game.fbo_shader:setUniform("solipsism_warning", 0) end
		end
DarkGod's avatar
DarkGod committed
		if ((self:attr("no_healing") or ((self.healing_factor or 1) <= 0)) ~= self.old_healwarn) and not self:attr("no_healing_no_warning") then
			if (self:attr("no_healing") or ((self.healing_factor or 1) <= 0)) then
				game.fbo_shader:setUniform("intensify", {0.3,1.3,0.3,1})
			else
				game.fbo_shader:setUniform("intensify", {0,0,0,0})
		-- Colorize shader
		if self:attr("stealth") and self:attr("stealth") > 0 then game.fbo_shader:setUniform("colorize", {0.9,0.9,0.9,0.4})
		elseif self:attr("invisible") and self:attr("invisible") > 0 then game.fbo_shader:setUniform("colorize", {0.3,0.4,0.9,0.3})
		elseif self:attr("unstoppable") then game.fbo_shader:setUniform("colorize", {1,0.2,0,1})
		elseif self:attr("lightning_speed") then game.fbo_shader:setUniform("colorize", {0.2,0.3,1,1})
		elseif game.level and game.level.data.is_eidolon_plane then game.fbo_shader:setUniform("colorize", {1,1,1,1})
--		elseif game:hasDialogUp() then game.fbo_shader:setUniform("colorize", {0.9,0.9,0.9})
		else game.fbo_shader:setUniform("colorize", {0,0,0,0}) -- Disable
		-- Blur shader
		if config.settings.tome.fullscreen_confusion and pf.blur and pf.blur.shad then
			if self:attr("confused") and self.confused >= 1 then pf.blur.shad:uniBlur(2) effects[pf.blur.shad] = true
			elseif self:attr("sleep") and not self:attr("lucid_dreamer") and self.sleep >= 1 then pf.blur.shad:uniBlur(2) effects[pf.blur.shad] = true
		-- Moving Blur shader
		if pf.motionblur and pf.motionblur.shad then
			if self:attr("invisible") then pf.motionblur.shad:uniMotionblur(3) effects[pf.motionblur.shad] = true
			elseif self:attr("lightning_speed") then pf.motionblur.shad:uniMotionblur(2) effects[pf.motionblur.shad] = true
			elseif game.level and game.level.data and game.level.data.motionblur then pf.motionblur.shad:uniMotionblur(game.level.data.motionblur) effects[pf.motionblur.shad] = true
			end
		if game.level and game.level.data and game.level.data.underwater and pf.underwater and pf.underwater.shad then effects[pf.underwater.shad] = true

		-- Wobbling shader
		if config.settings.tome.fullscreen_stun and pf.wobbling and pf.wobbling.shad then
			if self:attr("stunned") and self.stunned >= 1 then pf.wobbling.shad:uniWobbling(1) effects[pf.wobbling.shad] = true
			elseif self:attr("dazed") and self.dazed >= 1 then pf.wobbling.shad:uniWobbling(0.7) effects[pf.wobbling.shad] = true
			end
DarkGod's avatar
DarkGod committed
		-- Timestop shader
		if self:attr("timestopping") and pf.timestop and pf.timestop.shad then
			effects[pf.timestop.shad] = true
			pf.timestop.shad:paramNumber("tick_start", core.game.getTime())
		end

		-- Sharpen shader
		if config.settings.tome.sharpen_display and config.settings.tome.sharpen_display > 1 then
			effects[pf.sharpen.shad] = true
			pf.sharpen.shad:paramNumber("sharpen_power", config.settings.tome.sharpen_display)
		end

		game.posteffects_use = table.keys(effects)
		game.posteffects_use[#game.posteffects_use+1] = game.fbo_shader.shad
dg's avatar
dg committed
-- Precompute FOV form, for speed
local fovdist = {}
for i = 0, 30 * 30 do
dg's avatar
dg committed
	fovdist[i] = math.max((20 - math.sqrt(i)) / 17, 0.6)
local wild_fovdist = {}
for i = 0, 10 * 10 do
	wild_fovdist[i] = math.max((5 - math.sqrt(i)) / 1.4, 0.6)
end
dg's avatar
dg committed
function _M:playerFOV()
DarkGod's avatar
DarkGod committed
	-- Safety
	if not self.x or not game.level then return end

dg's avatar
dg committed
	-- Clean FOV before computing it
	game.level.map:cleanFOV()

	-- Do wilderness stuff, nothing else
	if game.zone.wilderness then
		self:computeFOV(game.zone.wilderness_see_radius, "block_sight", function(x, y, dx, dy, sqdist) game.level.map:applyLite(x, y, wild_fovdist[sqdist]) end, true, true, true)
		return
	end

dg's avatar
dg committed
	-- Compute ESP FOV, using cache
dg's avatar
dg committed
	if (self.esp_all and self.esp_all > 0) or next(self.esp) then
		self:computeFOV(self.esp_range or 10, "block_esp", function(x, y) game.level.map:applyESP(x, y, 0.6) end, true, true, true)
	end
dg's avatar
dg committed

	-- Handle Sense spell, a simple FOV, using cache. Note that this means some terrain features can be made to block sensing
	if self:attr("detect_range") then
		self:computeFOV(self:attr("detect_range"), "block_sense", function(x, y)
			local ok = false
			if self:attr("detect_actor") and game.level.map(x, y, game.level.map.ACTOR) then ok = true end
			if self:attr("detect_object") and game.level.map(x, y, game.level.map.OBJECT) then ok = true end
			if self:attr("detect_trap") and game.level.map(x, y, game.level.map.TRAP) then
				game.level.map(x, y, game.level.map.TRAP):setKnown(self, true, x, y)
				game.level.map.remembers(x, y, true)
dg's avatar
dg committed
				game.level.map:updateMap(x, y)
dg's avatar
dg committed
				ok = true
			end

			if ok then
dg's avatar
dg committed
				if self.detect_function then self.detect_function(self, x, y) end
				game.level.map.seens(x, y, 0.6)
dg's avatar
dg committed
			end
		end, true, true, true)
	end
	-- See everything and ignore all forms of blocking, dev mode feature
	if self:attr("omnivision") then
		self:computeFOV(self:attr("omnivision"), "we_need_useless_string_not_nil", function(x, y)
			local ok = false
			if game.level.map(x, y, game.level.map.ACTOR) then ok = true end
			if game.level.map(x, y, game.level.map.OBJECT) then ok = true end
			if game.level.map(x, y, game.level.map.TRAP) then
				game.level.map(x, y, game.level.map.TRAP):setKnown(self, true, x, y)
				game.level.map.remembers(x, y, true)
				game.level.map:updateMap(x, y)
				ok = true
			end

			if ok then
				game.level.map.seens(x, y, 0.6)
			end
		end, true, true, true)
	end

	-- Handle arcane eye
	if self:hasEffect(self.EFF_ARCANE_EYE) then
		local eff = self:hasEffect(self.EFF_ARCANE_EYE)
		local map = game.level.map

		core.fov.calc_circle(
			eff.x, eff.y, game.level.map.w, game.level.map.h, eff.radius, function(_, x, y) if map:checkAllEntities(x, y, "block_sight", self) then return true end end,
			function(_, x, y)
				local t = map(x, y, map.ACTOR)
dg's avatar
dg committed
				if t and (eff.true_seeing or self:canSee(t)) then
					map.seens(x, y, 1)
					if self.can_see_cache[t] then self.can_see_cache[t]["nil/nil"] = {true, 100} end
					if t ~= self then t:setEffect(t.EFF_ARCANE_EYE_SEEN, 1, {src=self, true_seeing=eff.true_seeing}) end
dg's avatar
dg committed
				end
			end,
			cache and map._fovcache["block_sight"]
		)
	end
	core.fov.calc_circle(self.x, self.y, game.level.map.w, game.level.map.h, 10,
		function(d, x, y)end, -- block
		function(d, x, y) -- apply
			local act = game.level.map(x, y, game.level.map.ACTOR)
			if act then
			local eff = act:hasEffect(act.EFF_MARKED)
				if eff and eff.src==self then
					game.level.map.seens(x, y, 0.6)
				end
			end
		end,
	nil)

dg's avatar
dg committed
	-- Handle Preternatural Senses talent, a simple FOV, using cache.
	if self:knowTalent(self.T_PRETERNATURAL_SENSES) then
		local t = self:getTalentFromId(self.T_PRETERNATURAL_SENSES)
		local range = self:getTalentRange(t)
		self:computeFOV(range, "block_sense", function(x, y)
			if game.level.map(x, y, game.level.map.ACTOR) then
				game.level.map.seens(x, y, 0.6)
dg's avatar
dg committed
			end
		end, true, true, true)
		local effStalker = self:hasEffect(self.EFF_STALKER)
		if effStalker then
			if core.fov.distance(self.x, self.y, effStalker.target.x, effStalker.target.y) <= 10 then
				game.level.map.seens(effStalker.target.x, effStalker.target.y, 0.6)
			end
		end
dg's avatar
dg committed
	end
dg's avatar
dg committed

Tawny Harte's avatar
Tawny Harte committed
	--Handle Mark Prey Vision
DarkGod's avatar
DarkGod committed
	if self:hasEffect(self.EFF_PREDATOR) then
		local uid, e = next(game.level.entities)
		while uid do
			if e.marked_prey then
Tawny Harte's avatar
Tawny Harte committed
				game.level.map.seens(e.x, e.y, 0.6)
Tawny Harte's avatar
Tawny Harte committed
			end
DarkGod's avatar
DarkGod committed
			uid, e = next(game.level.entities, uid)
Tawny Harte's avatar
Tawny Harte committed
		end
	end

DarkGod's avatar
DarkGod committed
	if self:knowTalent(self.T_SHADOW_SENSES) then
		local t = self:getTalentFromId(self.T_SHADOW_SENSES)
		local range = self:getTalentRange(t)
		local sqsense = range * range

		for shadow, _ in pairs(game.party.members) do if shadow.is_doomed_shadow and not shadow.dead and shadow.x then
			local arr = shadow.fov.actors_dist
			local tbl = shadow.fov.actors
			local act
			game.level.map:apply(shadow.x, shadow.y, 0.6)
			game.level.map:applyExtraLite(shadow.x, shadow.y)
			for i = 1, #arr do
				act = arr[i]
				if act and not act.dead and act.x and tbl[act] and shadow:canSee(act) and tbl[act].sqdist <= sqsense then
					game.level.map.seens(act.x, act.y, 0.6)
				end
			end
	if not self:attr("blind") then
		-- Handle infravision/heightened_senses which allow to see outside of lite radius but with LOS
		-- Note: Overseer of Nations bonus already factored into attributes
		if self:attr("infravision") or self:attr("heightened_senses") then
			local radius = math.max((self.heightened_senses or 0), (self.infravision or 0))
			radius = math.min(radius, self.sight)
			local rad2 = math.max(1, math.floor(radius / 4))
			self:computeFOV(radius, "block_sight", function(x, y, dx, dy, sqdist) if game.level.map(x, y, game.level.map.ACTOR) then game.level.map.seens(x, y, fovdist[sqdist]) end end, true, true, true)
			self:computeFOV(rad2, "block_sight", function(x, y, dx, dy, sqdist) game.level.map:applyLite(x, y, fovdist[sqdist]) end, true, true, true)
		end

		-- Compute both the normal and the lite FOV, using cache
		-- Do it last so it overrides others
		self:computeFOV(self.sight or 10, "block_sight", function(x, y, dx, dy, sqdist)
			game.level.map:apply(x, y, fovdist[sqdist])
		end, true, false, true)
DarkGod's avatar
DarkGod committed
		local lradius = self.lite
		if self.radiance_aura and lradius < self.radiance_aura then lradius = self.radiance_aura end
		if self.lite <= 0 then game.level.map:applyLite(self.x, self.y)
		else
			self:computeFOV(lradius, "block_sight", function(x, y, dx, dy, sqdist)
				game.level.map:applyExtraLite(x, y) 
			end, 
			true, true, true)
		end

		-- For each entity, generate lite
		local uid, e = next(game.level.entities)
		while uid do
			if e ~= self and ((e.lite and e.lite > 0) or (e.radiance_aura and e.radiance_aura > 0)) and e.computeFOV then
				e:computeFOV(math.max(e.lite or 0, e.radiance_aura or 0), "block_sight", function(x, y, dx, dy, sqdist) game.level.map:applyExtraLite(x, y, fovdist[sqdist]) end, true, true)
			end
			uid, e = next(game.level.entities, uid)
		end
	else
		self:computeFOV(self.sight or 10, "block_sight") -- Still compute FOV so NPCs may target us even while blinded
		-- Inner Sight; works even while blinded
		if self:attr("blind_sight") then
			self:computeFOV(self:attr("blind_sight"), "block_sight", function(x, y, dx, dy, sqdist) game.level.map:applyLite(x, y, 0.6) end, true, true, true)
dg's avatar
dg committed
end

dg's avatar
dg committed
function _M:doFOV()
	self:playerFOV()
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(x, y, Map.ACTOR)
	local sees_target = game.level.map.seens(tx, ty)

dg's avatar
dg committed
	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

	block = block or function(_, x, y)
		if sees_target then
			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
dg's avatar
dg committed
				extra_block and extra_block(self, x, y)
		elseif core.fov.distance(sx, sy, x, y) <= self.sight and (game.level.map.remembers(x, y) or game.level.map.seens(x, y)) then
			return game.level.map:checkEntity(x, y, 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
dg's avatar
dg committed
				extra_block and extra_block(self, x, y)
		else
			return true
		end
	end

	return core.fov.line(sx, sy, tx, ty, block)
end

dg's avatar
dg committed
--- Called before taking a hit, overload mod.class.Actor:onTakeHit() to stop resting and running
function _M:onTakeHit(value, src, death_note)
Otowa Kotori's avatar
Otowa Kotori committed
	self:runStop(_t"taken damage")
	self:restStop(_t"taken damage")
	local ret = mod.class.Actor.onTakeHit(self, value, src, death_note)
	if self.life < self.max_life * 0.3 then
		local sx, sy = game.level.map:getTileToScreen(self.x, self.y, true)
Otowa Kotori's avatar
Otowa Kotori committed
		game.flyers:add(sx, sy, 30, (rng.range(0,2)-1) * 0.5, 2, _t"LOW HEALTH!", {255,0,0}, true)
dg's avatar
dg committed
	end

	-- Hit direction warning
	if src.x and src.y and (self.x ~= src.x or self.y ~= src.y) then
		local range = core.fov.distance(src.x, src.y, self.x, self.y)
		if range > 1 then
			local angle = math.atan2(src.y - self.y, src.x - self.x)
			game.level.map:particleEmitter(self.x, self.y, 1, "hit_warning", {angle=math.deg(angle)})
		end
	end

dg's avatar
dg committed
	return ret
dg's avatar
dg committed
end

function _M:on_set_temporary_effect(eff_id, e, p)
	local ret = mod.class.Actor.on_set_temporary_effect(self, eff_id, e, p)
	if e.status == "detrimental" and not e.no_stop_resting and p.dur > 0 then
Otowa Kotori's avatar
Otowa Kotori committed
		self:runStop(_t"detrimental status effect")
		self:restStop(_t"detrimental status effect")

	return ret
function _M:heal(value, src)
	-- Difficulty settings
	if game.difficulty == game.DIFFICULTY_EASY then
DarkGod's avatar
DarkGod committed
	return mod.class.Actor.heal(self, value, src)
dg's avatar
dg committed
function _M:die(src, death_note)
Otowa Kotori's avatar
Otowa Kotori committed
	if self.runStop then self:runStop(_t"died") end
	if self.restStop then self:restStop(_t"died") end
dg's avatar
dg committed
	return self:onPartyDeath(src, death_note)
dg's avatar
dg committed
end
dg's avatar
dg committed

dg's avatar
dg committed
--- Suffocate a bit, lose air
function _M:suffocate(value, src, death_msg)
	local dead, affected = mod.class.Actor.suffocate(self, value, src, death_msg)
	if affected and value > 0 and self.runStop then
		-- only stop autoexplore when air is less than 75% of max.
		if self.air < 0.75 * self.max_air and self.air < 100 then
Otowa Kotori's avatar
Otowa Kotori committed
			self:runStop(_t"suffocating")
			self:restStop(_t"suffocating")
dg's avatar
dg committed
	end
	return dead, affected
end

function _M:onChat()
Otowa Kotori's avatar
Otowa Kotori committed
	self:runStop(_t"chat started")
	self:restStop(_t"chat started")
dg's avatar
dg committed
function _M:setName(name)
	self.name = name
dg's avatar
dg committed
	game.save_name = name
dg's avatar
dg committed
end
dg's avatar
dg committed

dg's avatar
dg committed
--- Notify the player of available cooldowns
function _M:onTalentCooledDown(tid)
dg's avatar
dg committed
	if not self:knowTalent(tid) then return end
dg's avatar
dg committed
	local t = self:getTalentFromId(tid)

	local x, y = game.level.map:getTileToScreen(self.x, self.y, true)
Otowa Kotori's avatar
Otowa Kotori committed
	game.flyers:add(x, y, 30, -0.3, -3.5, ("%s available"):tformat(t.name:capitalize()), {0,255,00})
dg's avatar
dg committed
	game.log("#00ff00#%sTalent %s is ready to use.", (t.display_entity and t.display_entity:getDisplayString() or ""), t.name)
--- Tries to get a target from the player
dg's avatar
dg committed
function _M:getTarget(typ)
	if self:attr("encased_in_ice") then
dg's avatar
dg committed
		if type(typ) ~= "table" then
			return self.x, self.y, self
		end
		local orig_range = typ.range
		typ.range = 0
		local x, y, act = game:targetGetForPlayer(typ)
		typ.range = orig_range
		if x then
			return self.x, self.y, self
		else
			return
		end
dg's avatar
dg committed
		if type(typ) == "table" and typ.range and typ.range == 1 and config.settings.tome.immediate_melee_keys then
			local oldft = typ.first_target
dg's avatar
dg committed
			typ = table.clone(typ)
			typ.first_target = "friend"
			typ.immediate_keys = true
			typ.default_target = self
dg's avatar
dg committed
			if config.settings.tome.immediate_melee_keys_auto and not oldft and not typ.simple_dir_request then
				local foes = {}
				for _, c in pairs(util.adjacentCoords(self.x, self.y)) do
 					local target = game.level.map(c[1], c[2], Map.ACTOR)
 					if target and self:reactionToward(target) < 0 then foes[#foes+1] = target end
 				end
 				if #foes == 1 then
					game.target.target.entity = foes[1]
					game.target.target.x = foes[1].x
					game.target.target.y = foes[1].y
					return game.target.target.x, game.target.target.y, game.target.target.entity
				end
			end
dg's avatar
dg committed
		end
		return game:targetGetForPlayer(typ)
	end
dg's avatar
dg committed
end

dg's avatar
dg committed
--- Sets the current target
function _M:setTarget(target)
	return game:targetSetForPlayer(target)
dg's avatar
dg committed
end

local function spotHostiles(self, actors_only)
dg's avatar
dg committed
	local seen = {}
	if not self.x then return seen end

dg's avatar
dg committed
	-- Check for visible monsters, only see LOS actors, so telepathy wont prevent resting
dg's avatar
dg committed
	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)
dg's avatar
dg committed
		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
Otowa Kotori's avatar
Otowa Kotori committed
			seen[#seen + 1] = {x=x,y=y,actor=actor, entity=actor, name=actor:getName()}
dg's avatar
dg committed
	end, nil)

	if not actors_only then
		-- Check for projectiles in line of sight
		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 proj = game.level.map(x, y, game.level.map.PROJECTILE)
			if not proj or not game.level.map.seens(x, y) then return end

			-- trust ourselves but not our friends
			if proj.src and self == proj.src then return end
			local sx, sy = proj.start_x, proj.start_y
			local tx, ty

			-- Bresenham is too so check if we're anywhere near the mathematical line of flight
			if type(proj.project) == "table" then
				tx, ty = proj.project.def.x, proj.project.def.y
			elseif proj.homing then
				tx, ty = proj.homing.target.x, proj.homing.target.y
			end
			if tx and ty then
				local dist_to_line = math.abs((self.x - sx) * (ty - sy) - (self.y - sy) * (tx - sx)) / core.fov.distance(sx, sy, tx, ty)
				local our_way = ((self.x - x) * (tx - x) + (self.y - y) * (ty - y)) > 0
				if our_way and dist_to_line < 1.0 then
					seen[#seen+1] = {x=x, y=y, projectile=proj, entity=proj, name=(proj.getName and proj:getName()) or proj.name}
				end
			end
		end, nil)
	end
dg's avatar
dg committed
	return seen
end

_M.spotHostiles = spotHostiles

--- 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, true)
		local cd = self:getTalentCooldown(t) or (t.is_object_use and t.cycle_time(self, t)) or 0
		local turns_used = util.getval(t.no_energy, self, t) == true and 0 or 1
		if cd <= turns_used and t.mode ~= "sustained" then
			game.logPlayer(self, "Automatic use of talent %s #DARK_RED#skipped#LAST#: cooldown too low (%d).", self:getTalentDisplayName(t), cd)
		elseif (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) or (c == 5 and not self.in_combat) then
					uses[#uses+1] = {name=t.name, turns_used=turns_used, cd=cd, fct=function() self:useTalent(tid) end}
					if not self:attr("blind") then
						uses[#uses+1] = {name=t.name, turns_used=turns_used, cd=cd, fct=function() 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
						uses[#uses+1] = {name=t.name, turns_used=turns_used, cd=cd, fct=function() self:useTalent(tid) end}
		local an, nb = a.turns_used, b.turns_used
DarkGod's avatar
DarkGod committed
		if an < nb then return true
		elseif an > nb then return false
		else
			if a.cd > b.cd then return true
			else return false
			end
		end
	end)
	for _, use in ipairs(uses) do
		use.fct()
	self:attr("_forbid_sounds", -1)
--- We started resting
function _M:onRestStart()
dg's avatar
dg committed
	if self.resting and self:attr("equilibrium_regen_on_rest") and not self.resting.equilibrium_regen then
		self:attr("equilibrium_regen", self:attr("equilibrium_regen_on_rest"))
		self.resting.equilibrium_regen = self:attr("equilibrium_regen_on_rest")
	end
	if self.resting and self:attr("mana_regen_on_rest") and not self.resting.mana_regen then
		self:attr("mana_regen", self:attr("mana_regen_on_rest"))
		self.resting.mana_regen = self:attr("mana_regen_on_rest")
	end
	self:fireTalentCheck("callbackOnRest", "start")
end

--- We stopped resting
function _M:onRestStop()
dg's avatar
dg committed
	if self.resting and self.resting.equilibrium_regen then
		self:attr("equilibrium_regen", -self.resting.equilibrium_regen)
		self.resting.equilibrium_regen = nil
	end
	if self.resting and self.resting.mana_regen then
		self:attr("mana_regen", -self.resting.mana_regen)