Skip to content
Snippets Groups Projects
Forked from tome / Tales of MajEyal
6144 commits behind the upstream repository.
Game.lua 92.34 KiB
-- ToME - Tales of Maj'Eyal
-- Copyright (C) 2009 - 2015 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.GameTurnBased"
require "engine.interface.GameMusic"
require "engine.interface.GameSound"
require "engine.interface.GameTargeting"
local KeyBind = require "engine.KeyBind"
local Savefile = require "engine.Savefile"
local DamageType = require "engine.DamageType"
local Zone = require "mod.class.Zone"
local Tiles = require "engine.Tiles"
local Map = require "engine.Map"
local Level = require "engine.Level"
local Birther = require "mod.dialogs.Birther"
local Astar = require "engine.Astar"
local DirectPath = require "engine.DirectPath"
local Shader = require "engine.Shader"
local HighScores = require "engine.HighScores"
local FontPackage = require "engine.FontPackage"

local NicerTiles = require "mod.class.NicerTiles"
local GameState = require "mod.class.GameState"
local Store = require "mod.class.Store"
local Trap = require "mod.class.Trap"
local Grid = require "mod.class.Grid"
local Actor = require "mod.class.Actor"
local Party = require "mod.class.Party"
local Player = require "mod.class.Player"
local NPC = require "mod.class.NPC"

local DebugConsole = require "engine.DebugConsole"
local FlyingText = require "engine.FlyingText"
local Tooltip = require "mod.class.Tooltip"
local BigNews = require "mod.class.BigNews"

local Calendar = require "engine.Calendar"
local Gestures = require "engine.ui.Gestures"

local Dialog = require "engine.ui.Dialog"
local MapMenu = require "mod.dialogs.MapMenu"

module(..., package.seeall, class.inherit(engine.GameTurnBased, engine.interface.GameMusic, engine.interface.GameSound, engine.interface.GameTargeting))

-- Difficulty settings
DIFFICULTY_EASY = 1
DIFFICULTY_NORMAL = 2
DIFFICULTY_NIGHTMARE = 3
DIFFICULTY_INSANE = 4
DIFFICULTY_MADNESS = 5
PERMADEATH_INFINITE = 1
PERMADEATH_MANY = 2
PERMADEATH_ONE = 3

-- Tell the engine that we have a fullscreen shader that supports gamma correction
support_shader_gamma = true

function _M:init()
	engine.GameTurnBased.init(self, engine.KeyBind.new(), 1000, 100)
	engine.interface.GameMusic.init(self)
	engine.interface.GameSound.init(self)

	-- Pause at birth
	self.paused = true

	-- Same init as when loaded from a savefile
	self:loaded()

	self.visited_zones = {}
	self.tiles_attachements = {}
	self.tiles_facing = {}
end

function _M:run()
	class:triggerHook{"ToME:run"}
	local ret = self:runReal()
	class:triggerHook{"ToME:runDone"}
	return ret
end

function _M:runReal()
	self.delayed_log_damage = {}
	self.delayed_log_messages = {}
	self.calendar = Calendar.new("/data/calendar_allied.lua", "Today is the %s %s of the %s year of the Age of Ascendancy of Maj'Eyal.\nThe time is %02d:%02d.", 122, 167, 11)

	self.uiset:activate()

	local flyfont, flysize = FontPackage:getFont("flyer")
	self.tooltip = Tooltip.new(self.uiset.init_font_mono, self.uiset.init_size_mono, {255,255,255}, {30,30,30,230})
	self.tooltip2 = Tooltip.new(self.uiset.init_font_mono, self.uiset.init_size_mono, {255,255,255}, {30,30,30,230})
	self.flyers = FlyingText.new(flyfont, flysize, flyfont, flysize + 3)
	self.flyers:enableShadow(0.6)
	game:setFlyingText(self.flyers)

	self.bignews = BigNews.new(FontPackage:getFont("bignews"))

	self.nicer_tiles = NicerTiles.new()

	-- Ok everything is good to go, activate the game in the engine!
	self:setCurrent()

	-- Start time
	self.real_starttime = os.time()

	self:setupDisplayMode(false, "postinit")
	if self.level and self.level.data.day_night then self.state:dayNightCycle() end
	if self.level and self.player then self.calendar = Calendar.new("/data/calendar_"..(self.player.calendar or "allied")..".lua", "Today is the %s %s of the %s year of the Age of Ascendancy of Maj'Eyal.\nThe time is %02d:%02d.", 122, 167, 11) end

	-- Setup inputs
	self:setupCommands()
	self:setupMouse()

	-- Starting from here we create a new game
	if self.player and self.player.dead then
		print("Player is dead, rebooting")
		util.showMainMenu()
		return
	end
	if not self.player then self:newGame() end

	engine.interface.GameTargeting.init(self)
	if self.target then self.target:enableFBORenderer("ui/targetshader.png", "target_fbo") end
	self.uiset.hotkeys_display.actor = self.player
	self.uiset.npcs_display.actor = self.player

	-- Run the current music if any
	self:onTickEnd(function()
		self:playMusic()
		if self.level then
			self.level.map:moveViewSurround(self.player.x, self.player.y, config.settings.tome.scroll_dist, config.settings.tome.scroll_dist)
		end
	end)

	-- Create the map scroll text overlay
	local lfont = FontPackage:get("bignews", true)
	lfont:setStyle("bold")
	local s = core.display.drawStringBlendedNewSurface(lfont, "<Scroll mode, press keys to scroll, caps lock to exit>", unpack(colors.simple(colors.GOLD)))
	lfont:setStyle("normal")
	self.caps_scroll = {s:glTexture()}
	self.caps_scroll.w, self.caps_scroll.h = s:getSize()

	self.zone_font = FontPackage:get("zone")

	self.inited = true

	if self.level and self.level.map then
		self.nicer_tiles:postProcessLevelTilesOnLoad(self.level)
	end
end

--- Resize the hotkeys
function _M:resizeIconsHotkeysToolbar()
	self.uiset:resizeIconsHotkeysToolbar()
end

--- Checks if the current character is "tainted" by cheating
function _M:isTainted()
	if config.settings.cheat then return true end
	return (game.player and game.player.__cheated) and true or false
end

--- Sets the player name
function _M:setPlayerName(name)
	name = name:removeColorCodes():gsub("#", " "):sub(1, 25)
	self.save_name = name
	self.player_name = name
	if self.party and self.party:findMember{main=true} then
		self.party:findMember{main=true}.name = name
	end
end

function _M:newGame()
	self.party = Party.new{}
	local player = Player.new{name=self.player_name, game_ender=true}
	self.party:addMember(player, {
		control="full",
		type="player",
		title="Main character",
		main=true,
		orders = {target=true, anchor=true, behavior=true, leash=true, talents=true},
	})
	self.party:setPlayer(player)

	-- Create the entity to store various game state things
	self.state = GameState.new{}
	local birth_done = function()
		if self.state.birth.__allow_rod_recall then game.state:allowRodRecall(true) self.state.birth.__allow_rod_recall = nil end
		if self.state.birth.__allow_transmo_chest and profile.mod.allow_build.birth_transmo_chest then
			self.state.birth.__allow_transmo_chest = nil
			local chest = game.zone:makeEntityByName(game.level, "object", "TRANSMO_CHEST")
			if chest then
				game.zone:addEntity(game.level, chest, "object")
				self.player:addObject(self.player:getInven("INVEN"), chest)
			end
		end

		for i = 1, 50 do
			local o = self.state:generateRandart{add_pool=true}
			self.zone.object_list[#self.zone.object_list+1] = o
		end

		if config.settings.cheat then self.player.__cheated = true end

		self.player:recomputeGlobalSpeed()

		-- Force the hotkeys to be sorted.
		self.player:sortHotkeys()

		-- Register the character online if possible
		self.player:getUUID()
		self:updateCurrentChar()
	end

	if not config.settings.tome.tactical_mode_set then
		self.always_target = true
	else
		self.always_target = config.settings.tome.tactical_mode
	end
	local nb_unlocks, max_unlocks = self:countBirthUnlocks()
	self.creating_player = true
	local birth; birth = Birther.new("Character Creation ("..nb_unlocks.."/"..max_unlocks.." unlocked birth options)", self.player, {"base", "world", "difficulty", "permadeath", "race", "subrace", "sex", "class", "subclass" }, function(loaded)
		if not loaded then
			self.calendar = Calendar.new("/data/calendar_"..(self.player.calendar or "allied")..".lua", "Today is the %s %s of the %s year of the Age of Ascendancy of Maj'Eyal.\nThe time is %02d:%02d.", 122, 167, 11)
			self.player:check("make_tile")
			self.player.make_tile = nil
			self.player:check("before_starting_zone")
			self.player:check("class_start_check")

			-- Configure & create the worldmap
			self.player.last_wilderness = self.player.default_wilderness[3] or "wilderness"
			game:onLevelLoad(self.player.last_wilderness.."-1", function(zone, level)
				game.player.wild_x, game.player.wild_y = game.player.default_wilderness[1], game.player.default_wilderness[2]
				if type(game.player.wild_x) == "string" and type(game.player.wild_y) == "string" then
					local spot = level:pickSpot{type=game.player.wild_x, subtype=game.player.wild_y} or {x=1,y=1}
					game.player.wild_x, game.player.wild_y = spot.x, spot.y
				end
			end)

			-- Generate
			if self.player.__game_difficulty then self:setupDifficulty(self.player.__game_difficulty) end
			self:setupPermadeath(self.player)
			--self:changeLevel(1, "test")
			self:changeLevel(self.player.starting_level or 1, self.player.starting_zone, {force_down=self.player.starting_level_force_down})
			
			print("[PLAYER BIRTH] resolve...")
			self.player:resolve()
			self.player:resolve(nil, true)
			self.player.energy.value = self.energy_to_act
			Map:setViewerFaction(self.player.faction)
			self.player:updateModdableTile()

			self.paused = true
			print("[PLAYER BIRTH] resolved!")
			local birthend = function()
				local d = require("engine.dialogs.ShowText").new("Welcome to #LIGHT_BLUE#Tales of Maj'Eyal", "intro-"..self.player.starting_intro, {name=self.player.name}, nil, nil, function()
					self.player:resetToFull()
					self.player:registerCharacterPlayed()
					self.player:onBirth(birth)
					-- For quickbirth
					savefile_pipe:push(self.player.name, "entity", self.party, "engine.CharacterVaultSave")
					self.creating_player = false
					self.player:grantQuest(self.player.starting_quest)

					birth_done()
					self.player:check("on_birth_done")
					self:setTacticalMode(self.always_target)

					if __module_extra_info.birth_done_script then loadstring(__module_extra_info.birth_done_script)() end
				end, true)
				self:registerDialog(d)
				if __module_extra_info.no_birth_popup then d.key:triggerVirtual("EXIT") end
			end

			if self.player.no_birth_levelup or __module_extra_info.no_birth_popup then birthend()
			else self.player:playerLevelup(birthend, true) end
		-- Player was loaded from a premade
		else
			self.calendar = Calendar.new("/data/calendar_"..(self.player.calendar or "allied")..".lua", "Today is the %s %s of the %s year of the Age of Ascendancy of Maj'Eyal.\nThe time is %02d:%02d.", 122, 167, 11)
			Map:setViewerFaction(self.player.faction)
			if self.player.__game_difficulty then self:setupDifficulty(self.player.__game_difficulty) end
			self:setupPermadeath(self.player)

			-- Configure & create the worldmap
			self.player.last_wilderness = self.player.default_wilderness[3] or "wilderness"
			game:onLevelLoad(self.player.last_wilderness.."-1", function(zone, level)
				game.player.wild_x, game.player.wild_y = game.player.default_wilderness[1], game.player.default_wilderness[2]
				if type(game.player.wild_x) == "string" and type(game.player.wild_y) == "string" then
					local spot = level:pickSpot{type=game.player.wild_x, subtype=game.player.wild_y} or {x=1,y=1}
					game.player.wild_x, game.player.wild_y = spot.x, spot.y
				end
			end)

			-- Tell the level gen code to add all the party
			self.to_re_add_actors = {}
			for act, _ in pairs(self.party.members) do if self.player ~= act then self.to_re_add_actors[act] = true end end

			self:changeLevel(self.player.starting_level or 1, self.player.starting_zone, {force_down=self.player.starting_level_force_down})
			self.player:grantQuest(self.player.starting_quest)
			self.creating_player = false

			-- Add all items so they regen correctly
			self.player:inventoryApplyAll(function(inven, item, o) game:addEntity(o) end)

			birth_done()
			self.player:check("on_birth_done")
			self:setTacticalMode(self.always_target)
		end
	end, quickbirth, 800, 600)
	self:registerDialog(birth)
end

function _M:setupDifficulty(d)
	self.difficulty = d
end
function _M:setupPermadeath(p)
	if p:attr("infinite_lifes") then self.permadeath = PERMADEATH_INFINITE
	elseif p:attr("easy_mode_lifes") then self.permadeath = PERMADEATH_MANY
	else self.permadeath = PERMADEATH_ONE
	end
end

function _M:loaded()
	engine.GameTurnBased.loaded(self)
	engine.interface.GameMusic.loaded(self)
	engine.interface.GameSound.loaded(self)
	Zone:setup{
		npc_class="mod.class.NPC", grid_class="mod.class.Grid", object_class="mod.class.Object", trap_class="mod.class.Trap",
		on_setup = function(zone)
			-- Increases zone level for higher difficulties
			if not zone.__applied_difficulty then
				zone.__applied_difficulty = true
				if self.difficulty == self.DIFFICULTY_NIGHTMARE then
					zone.base_level_range = table.clone(zone.level_range, true)
					zone.specific_base_level.object = -10 -zone.level_range[1]
					zone.level_range[1] = zone.level_range[1] * 1.5 + 3
					zone.level_range[2] = zone.level_range[2] * 1.5 + 3
				elseif self.difficulty == self.DIFFICULTY_INSANE then
					zone.base_level_range = table.clone(zone.level_range, true)
					zone.specific_base_level.object = -10 -zone.level_range[1]
					zone.level_range[1] = zone.level_range[1] * 1.5 + 5
					zone.level_range[2] = zone.level_range[2] * 1.5 + 5
				elseif self.difficulty == self.DIFFICULTY_MADNESS then
					zone.base_level_range = table.clone(zone.level_range, true)
					zone.specific_base_level.object = -10 -zone.level_range[1]
					zone.level_range[1] = zone.level_range[1] * 2.5 + 10
					zone.level_range[2] = zone.level_range[2] * 2.5 + 10
				end
			end
		end,
	}
	Zone.check_filter = function(...) return self.state:entityFilter(...) end
	Zone.default_prob_filter = true
	Zone.default_filter = function(...) return self.state:defaultEntityFilter(...) end
	Zone.alter_filter = function(...) return self.state:entityFilterAlter(...) end
	Zone.post_filter = function(...) return self.state:entityFilterPost(...) end
	Zone.ego_filter = function(...) return self.state:egoFilter(...) end

	self.uiset = (require("mod.class.uiset."..(config.settings.tome.uiset_mode or "Minimalist"))).new()

	Map:setViewerActor(self.player)
	self:setupDisplayMode(false, "init")
	self:setupDisplayMode(false, "postinit")
	if self.player then self.player.changed = true end
	self.key = engine.KeyBind.new()

	if self.always_target == true or self.always_target == "old" then Map:setViewerFaction(self.player.faction) end
	if self.player and config.settings.cheat then self.player.__cheated = true end
	self:updateCurrentChar()

	if self.zone and self.zone.on_loaded then self.zone.on_loaded(self.level.level) end
end

function _M:computeAttachementSpotsFromTable(ta)
	local base = ta.default_base or 64
	local res = { }

	for tile, data in pairs(ta.tiles or {}) do
		local base = data.base or base
		local yoff = data.yoff or 0
		local t = {}
		res[tile] = t
		for kind, d in pairs(data) do if kind ~= "base" and kind ~= "yoff" then
			t[kind] = { x=d.x / base, y=(d.y + yoff) / base }
		end end
	end

	for race, data in pairs(ta.dolls or {}) do
		local base = data.base or base
		for sex, d in pairs(data) do if sex ~= "base" then
			local t = {}
			res["dolls_"..race.."_"..sex] = t
			local yoff = d.yoff or 0
			local base = d.base or base
			for kind, d in pairs(d) do if kind ~= "yoff" and kind ~= "base" then
				t[kind] = { x=d.x / base, y=(d.y + yoff) / base }
			end end
		end end
	end

	self.tiles_attachements = res
end

function _M:computeAttachementSpots()
	local t = {}
	if fs.exists(Tiles.prefix.."attachements.lua") then
		print("Loading tileset attachements from ", Tiles.prefix.."attachements.lua")
		local f, err = loadfile(Tiles.prefix.."attachements.lua")
		if not f then print("Loading tileset attachements error", err)
		else
			setfenv(f, t)
			local ok, err = pcall(f)
			if not ok then print("Loading tileset attachements error", err) end
		end		
	end
	for _, file in ipairs(fs.list(Tiles.prefix)) do if file:find("^attachements%-.+.lua$") then
		print("Loading tileset attachements from ", Tiles.prefix..file)
		local f, err = loadfile(Tiles.prefix..file)
		if not f then print("Loading tileset attachements error", err)
		else
			setfenv(f, t)
			local ok, err = pcall(f)
			if not ok then print("Loading tileset attachements error", err) end
		end		
	end end
	self:computeAttachementSpotsFromTable(t)
end

function _M:computeFacingsFromTable(ta)
	local base = ta.default_base or 64
	local res = { }

	for tile, data in pairs(ta.tiles or {}) do
		res[tile] = data
	end

	for race, data in pairs(ta.dolls or {}) do
		local base = data.base or base
		for sex, d in pairs(data) do if sex ~= "base" then
			local t = {}
			res["dolls_"..race.."_"..sex] = d
		end end
	end

	self.tiles_facing = res
end

function _M:computeFacings()
	local t = {}
	if fs.exists(Tiles.prefix.."facings.lua") then
		print("Loading tileset facings from ", Tiles.prefix.."facings.lua")
		local f, err = loadfile(Tiles.prefix.."facings.lua")
		if not f then print("Loading tileset facings error", err)
		else
			setfenv(f, t)
			local ok, err = pcall(f)
			if not ok then print("Loading tileset facings error", err) end
		end		
	end
	for _, file in ipairs(fs.list(Tiles.prefix)) do if file:find("^facings%-.+.lua$") then
		print("Loading tileset facings from ", Tiles.prefix..file)
		local f, err = loadfile(Tiles.prefix..file)
		if not f then print("Loading tileset facings error", err)
		else
			setfenv(f, t)
			local ok, err = pcall(f)
			if not ok then print("Loading tileset facings error", err) end
		end		
	end end
	self:computeFacingsFromTable(t)
end
function _M:setupDisplayMode(reboot, mode)
	if not mode or mode == "init" then
		local gfx = config.settings.tome.gfx
		self:saveSettings("tome.gfx", ('tome.gfx = {tiles=%q, size=%q, tiles_custom_dir=%q, tiles_custom_moddable=%s, tiles_custom_adv=%s}\n'):format(gfx.tiles, gfx.size, gfx.tiles_custom_dir or "", gfx.tiles_custom_moddable and "true" or "false", gfx.tiles_custom_adv and "true" or "false"))

		if reboot then
			self.change_res_dialog = true
			self:saveGame()
			util.showMainMenu(false, nil, nil, self.__mod_info.short_name, self.save_name, false)
		end

		Map:resetTiles()
	end

	if not mode or mode == "postinit" then
		local gfx = config.settings.tome.gfx

		-- Select tiles
		Tiles.prefix = "/data/gfx/"..gfx.tiles.."/"
		if config.settings.tome.gfx.tiles == "customtiles" then
			Tiles.prefix = "/data/gfx/"..config.settings.tome.gfx.tiles_custom_dir.."/"
		end
		print("[DISPLAY MODE] Tileset: "..gfx.tiles)
		print("[DISPLAY MODE] Size: "..gfx.size)

		-- Load attachement spots for this tileset
		self:computeAttachementSpots()
		self:computeFacings()

		local do_bg = gfx.tiles == "ascii_full"
		local _, _, tw, th = gfx.size:find("^([0-9]+)x([0-9]+)$")
		tw, th = tonumber(tw), tonumber(th)
		if not tw then tw, th = 64, 64 end
		local pot_th = math.pow(2, math.ceil(math.log(th-0.1) / math.log(2.0)))
		local fsize = math.floor( pot_th/th*(0.7 * th + 5) )

		local map_x, map_y, map_w, map_h = self.uiset:getMapSize()
		if th <= 20 then
			Map:setViewPort(map_x, map_y, map_w, map_h, tw, th, "/data/font/FSEX300.ttf", pot_th, do_bg)
		else
			Map:setViewPort(map_x, map_y, map_w, map_h, tw, th, nil, fsize, do_bg)
		end

		-- Show a count for stacked objects
		Map.object_stack_count = true

		Map.tiles.use_images = true
		if gfx.tiles == "ascii" then
			Map.tiles.use_images = false
			Map.tiles.force_back_color = {r=0, g=0, b=0, a=255}
			Map.tiles.no_moddable_tiles = true
		elseif gfx.tiles == "ascii_full" then
			Map.tiles.use_images = false
			Map.tiles.no_moddable_tiles = true
		elseif gfx.tiles == "shockbolt" then
			Map.tiles.nicer_tiles = true
			if tw > 64 then Map.tiles.sharp_scaling = true end
		elseif gfx.tiles == "oldrpg" then
			Map.tiles.nicer_tiles = true
			Map.tiles.sharp_scaling = true
		elseif gfx.tiles == "customtiles" then
			Map.tiles.no_moddable_tiles = not config.settings.tome.gfx.tiles_custom_moddable
			Map.tiles.nicer_tiles = config.settings.tome.gfx.tiles_custom_adv
		end

		if self.level then
			if self.level.map.finished then
				self.level.map:recreate()
				self.level.map:moveViewSurround(self.player.x, self.player.y, 8, 8)
			end
			engine.interface.GameTargeting.init(self)
		end
		self:setupMiniMap()

		self:createFBOs()

		self:createMapGridLines()
	end
end

function _M:createMapGridLines()
	if not config.settings.tome.show_grid_lines then
		Map:setupGridLines(0, 0, 0, 0, 0)
	elseif self.posteffects and self.posteffects.line_grids and self.posteffects.line_grids.shad then
		Map:setupGridLines(6, unpack(colors.hex1alpha"d5990880"))
	else
		Map:setupGridLines(2, unpack(colors.hex1alpha"d5990880"))
	end
	if self.level and self.level.map then self.level.map:regenGridLines() end
end

function _M:createFBOs()
	print("[GAME] Creating FBOs")

	-- Create the framebuffer
	self.fbo = core.display.newFBO(Map.viewport.width, Map.viewport.height)
	if self.fbo then
		self.fbo_shader = Shader.new("main_fbo")
		self.posteffects = {
			wobbling = Shader.new("main_fbo/wobbling"),
			underwater = Shader.new("main_fbo/underwater"),
			motionblur = Shader.new("main_fbo/motionblur"),
			blur = Shader.new("main_fbo/blur"),
			timestop = Shader.new("main_fbo/timestop"),
			line_grids = Shader.new("main_fbo/line_grids"),
			gestures = Shader.new("main_fbo/gestures"),
		}
		self.posteffects_use = { self.fbo_shader.shad }
		if not self.fbo_shader.shad then self.fbo = nil self.fbo_shader = nil end 
		self.fbo2 = core.display.newFBO(Map.viewport.width, Map.viewport.height)

		if self.gestures and self.posteffects and self.posteffects.gestures and self.posteffects.gestures.shad then self.gestures.shader = self.posteffects.gestures.shad end
	end
	
	if self.player then self.player:updateMainShader() end

	self.full_fbo = core.display.newFBO(self.w, self.h)
	if self.full_fbo then self.full_fbo_shader = Shader.new("full_fbo") if not self.full_fbo_shader.shad then self.full_fbo = nil self.full_fbo_shader = nil end end

	if self.fbo and self.fbo2 then core.particles.defineFramebuffer(self.fbo)
	else core.particles.defineFramebuffer(nil) end

	if self.target then self.target:enableFBORenderer("ui/targetshader.png", "target_fbo") end

	Map:enableFBORenderer("target_fbo")

--	self.mm_fbo = core.display.newFBO(200, 200)
--	if self.mm_fbo then self.mm_fbo_shader = Shader.new("mm_fbo") if not self.mm_fbo_shader.shad then self.mm_fbo = nil self.mm_fbo_shader = nil end end
end

function _M:resizeMapViewport(w, h, x, y)
	x = x and math.floor(x) or Map.display_x
	y = y and math.floor(y) or Map.display_y
	w = math.floor(w)
	h = math.floor(h)

	-- convert from older faulty versionsPg
	if game.level.map and rawget(game.level.map, "display_x") == Map.display.x and rawget(game.level.map, "display_y") == Map.display_y then
		game.level.map.display_x, game.level.map.display_y = nil, nil
	end
	Map.display_x = x
	Map.display_y = y
	Map.viewport.width = w
	Map.viewport.height = h
	Map.viewport.mwidth = math.floor(w / Map.tile_w)
	Map.viewport.mheight = math.floor(h / Map.tile_h)

	self:createFBOs()

	if self.level then
		self.level.map:makeCMap()
		self.level.map:redisplay()
		if self.player then
			self.player:updateMainShader()
			self.level.map:moveViewSurround(self.player.x, self.player.y, config.settings.tome.scroll_dist, config.settings.tome.scroll_dist)
		end
	end
end

function _M:setupMiniMap()
	if self.level and self.level.map and self.level.map.finished then self.uiset:setupMinimap(self.level) end
end

--- Sets up a text flyers
function _M:setFlyingText(fl)
	self.flyers = fl
	function self.flyers:add(x, y, duration, xvel, yvel, str, color, bigfont)
		local slowness = (config.settings.tome.flyers_fade_time or 10)/10
		return FlyingText.add(fl, x, y, duration*slowness, xvel/slowness, yvel/slowness, str, color, bigfont)
	end
end

function _M:save()
	self.total_playtime = (self.total_playtime or 0) + (os.time() - (self.last_update or self.real_starttime))
	self.last_update = os.time()
	return class.save(self, self:defaultSavedFields{difficulty=true, permadeath=true, to_re_add_actors=true, party=true, _chronoworlds=true, total_playtime=true, on_level_load_fcts=true, visited_zones=true, bump_attack_disabled=true, show_npc_list=true, always_target=true}, true)
end

function _M:updateCurrentChar()
	if not self.party then return end
	local player = self.party:findMember{main=true}
	profile:currentCharacter(self.__mod_info.full_version_string, ("%s the level %d %s %s"):format(player.name, player.level, player.descriptor.subrace, player.descriptor.subclass), player.__te4_uuid)
end

function _M:getSaveDescription()
	local player = self.party:findMember{main=true}

	return {
		name = player.name,
		description = ([[%s the level %d %s %s.
Difficulty: %s / %s
Campaign: %s
Exploring level %d of %s.]]):format(
		player.name, player.level, player.descriptor.subrace, player.descriptor.subclass,
		player.descriptor.difficulty, player.descriptor.permadeath,
		player.descriptor.world,
		self.level and self.level.level or "--", self.zone and self.zone.name or "--"
		),
	}
end

function _M:getVaultDescription(e)
	e = e:findMember{main=true} -- Because vault "chars" are actualy parties for tome
	return {
		name = ([[%s the %s %s]]):format(e.name, e.descriptor.subrace, e.descriptor.subclass),
		descriptors = e.descriptor,
		description = ([[%s the %s %s.
Difficulty: %s / %s
Campaign: %s]]):format(
		e.name, e.descriptor.subrace, e.descriptor.subclass,
		e.descriptor.difficulty, e.descriptor.permadeath,
		e.descriptor.world
		),
	}
end

function _M:getStore(def)
	print("[STORE] Grabbing", def)
	return Store.stores_def[def]:clone()
end

function _M:leaveLevel(level, lev, old_lev)
	self.to_re_add_actors = self.to_re_add_actors or {}

	if level:hasEntity(self.player) then
		level.exited = level.exited or {}
		if lev > old_lev then
			level.exited.down = {x=self.player.x, y=self.player.y}
		else
			level.exited.up = {x=self.player.x, y=self.player.y}
		end
	end

	if level.no_remove_entities then return end

	level.last_turn = self.turn
	for act, _ in pairs(self.party.members) do
		if self.player ~= act and level:hasEntity(act) then
			level:removeEntity(act)
			self.to_re_add_actors[act] = true
		end
	end
	if level:hasEntity(self.player) then level:removeEntity(self.player) end
end

function _M:onLevelLoad(id, fct, data)
	if self.zone and self.level and id == self.zone.short_name.."-"..self.level.level then
		print("Direct execute of on level load", id, fct, data)
		fct(self.zone, self.level, data)
		return
	end

	self.on_level_load_fcts = self.on_level_load_fcts or {}
	self.on_level_load_fcts[id] = self.on_level_load_fcts[id] or {}
	local l = self.on_level_load_fcts[id]
	l[#l+1] = {fct=fct, data=data}
	print("Registering on level load", id, fct, data)
end

function _M:onLevelLoadRun()
	self.on_level_load_fcts = self.on_level_load_fcts or {}
	print("Running on level loads", self.zone.short_name.."-"..self.level.level)
	for i, fct in ipairs(self.on_level_load_fcts[self.zone.short_name.."-"..self.level.level] or {}) do
		fct.fct(self.zone, self.level, fct.data)
	end
	self.on_level_load_fcts[self.zone.short_name.."-"..self.level.level] = nil
end

function _M:changeLevel(lev, zone, params)
	params = params or {}
	if not self.player.can_change_level then
		self.logPlayer(self.player, "#LIGHT_RED#You may not change level without your own body!")
		return
	end
	if zone and not self.player.can_change_zone then
		self.logPlayer(self.player, "#LIGHT_RED#You may not leave the zone with this character!")
		return
	end
	if self.player:hasEffect(self.player.EFF_PARADOX_CLONE) or self.player:hasEffect(self.player.EFF_IMMINENT_PARADOX_CLONE) then
		self.logPlayer(self.player, "#LIGHT_RED#You cannot escape your fate by leaving the level!")
		return
	end

	-- Transmo!
	local p = self:getPlayer(true)
	local oldzone, oldlevel = game.zone, game.level
	if not params.direct_switch and p:attr("has_transmo") and p:transmoGetNumberItems() > 0 and not game.player.no_inventory_access then
		local d
		local titleupdator = self.player:getEncumberTitleUpdator(p:transmoGetName())
		d = self.player:showEquipInven(titleupdator(), nil, function(o, inven, item, button, event)
			if not o then return end
			local ud = require("mod.dialogs.UseItemDialog").new(event == "button", self.player, o, item, inven, function(_, _, _, stop)
				d:generate()
				d:generateList()
				d:updateTitle(titleupdator())
				if stop then self:unregisterDialog(d) end
			end, true)
			self:registerDialog(ud)
		end)
		d.unload = function()
			local inven = p:getInven("INVEN")
			for i = #inven, 1, -1 do
				local o = inven[i]
				if o.__transmo then
					p:transmoInven(inven, i, o)
				end
			end
			if game.zone == oldzone and game.level == oldlevel then
				self:changeLevelReal(lev, zone, params)
			end
		end
		-- Select the chest tab
		d.c_inven.dont_update_last_tabs = true
		d.c_inven:switchTab{kind="transmo"}
		p:transmoHelpPopup()
	else
		self:changeLevelReal(lev, zone, params)
	end
end

function _M:changeLevelReal(lev, zone, params)
	-- Unlock first!
	if not params.temporary_zone_shift_back and self.level and self.level.temp_shift_zone then
		self:changeLevelReal(1, "useless", {temporary_zone_shift_back=true})
	end

	local st = core.game.getTime()
	local sti = 1

	-- Flush particles remaining to draw
	core.particles.flushLast()

	-- Finish stuff registered for the previous level
	self:onTickEndExecute()

	if self.zone and self.level then self.party:leftLevel() end

	if self.player:isTalentActive(self.player.T_JUMPGATE) then
		self.player:forceUseTalent(self.player.T_JUMPGATE, {ignore_energy=true})
	end

	if self.player:isTalentActive(self.player.T_JUMPGATE_TWO) then
		self.player:forceUseTalent(self.player.T_JUMPGATE_TWO, {ignore_energy=true})
	end

	-- clear chrono worlds and their various effects
	if self._chronoworlds then self._chronoworlds = nil end

	local left_zone = self.zone
	local old_lev = (self.level and not zone) and self.level.level or -1000
	if params.keep_old_lev then old_lev = self.level.level end

	local force_recreate = false
	local recreate_nothing = false
	local popup = nil
	local afternicer = nil

	-- We only switch temporarily, keep the old one around
	if params.temporary_zone_shift then
		self:leaveLevel(self.level, lev, old_lev)

		local oz, ol = self.zone, self.level
		if type(zone) == "string" then
			self.zone = Zone.new(zone)
		else
			self.zone = zone
		end
		if type(self.zone.save_per_level) == "nil" then self.zone.save_per_level = config.settings.tome.save_zone_levels and true or false end

		self.zone:getLevel(self, lev, old_lev, true)
		self.visited_zones[self.zone.short_name] = true
		world:seenZone(self.zone.short_name)

		self.level.temp_shift_zone = oz
		self.level.temp_shift_level = ol
	-- We switch back
	elseif params.temporary_zone_shift_back then
		popup = Dialog:simpleWaiter("Loading level", "Please wait while loading the level...", nil, 10000)
		core.display.forceRedraw()

		local old = self.level

		if self.zone and self.zone.on_leave then
			local nl, nz, stop = self.zone.on_leave(lev, old_lev, zone)
			if stop then return end
			if nl then lev = nl end
			if nz then zone = nz end
		end

		if self.zone and self.level then self.player:onLeaveLevel(self.zone, self.level) end
		if self.zone then
			self.zone:leaveLevel(false, lev, old_lev)
			self.zone:leave()
		end

		self.zone = old.temp_shift_zone
		self.level = old.temp_shift_level

		self.visited_zones[self.zone.short_name] = true
		world:seenZone(self.zone.short_name)
--		if self.level.map.closed then
			force_recreate = true
--		else
--			print("Reloading back map without having it closed")
--			recreate_nothing = true
--		end
	-- We move to a new zone as normal
	elseif not params.temporary_zone_shift then
		if self.zone and self.zone.on_leave then
			local nl, nz, stop = self.zone.on_leave(lev, old_lev, zone)
			if stop then return end
			if nl then lev = nl end
			if nz then zone = nz end
		end

		if self.zone and self.level then self.player:onLeaveLevel(self.zone, self.level) end

		if zone then
			if self.zone then
				self.zone:leaveLevel(false, lev, old_lev)
				self.zone:leave()
			end
			if type(zone) == "string" then
				self.zone = Zone.new(zone)
			else
				self.zone = zone
			end
			if self.zone.tier1 then
				if lev == 1 and game.state:tier1Killed(game.state.birth.start_tier1_skip or 3) then
					self.zone.tier1 = nil
					Dialog:yesnoPopup("Easy!", "This zone is so easy for you that you stroll to the last area with ease.", function(ret) if ret then
						game:changeLevel(self.zone.max_level)
					end end, "Stroll", "Stay there")
				end
			end
			if type(self.zone.save_per_level) == "nil" then self.zone.save_per_level = config.settings.tome.save_zone_levels and true or false end
		end
		local _, new_level = self.zone:getLevel(self, lev, old_lev)
		self.visited_zones[self.zone.short_name] = true
		world:seenZone(self.zone.short_name)

		if new_level then
			afternicer = self.state:startEvents()
		end
	end

	-- Post process walls
	self.nicer_tiles:postProcessLevelTiles(self.level)

	-- Post process if needed once the nicer tiles are done
	if self.level.data and self.level.data.post_nicer_tiles then self.level.data.post_nicer_tiles(self.level) end

	-- After ? events ?
	if afternicer then afternicer() end

	-- Check if we need to switch the current guardian
	self.state:zoneCheckBackupGuardian()

	-- Check if we must do some special things on load of this level
	self:onLevelLoadRun()

	-- Decay level ?
	if self.level.last_turn and self.level.data.decay and self.level.last_turn + self.level.data.decay[1] * 10 < self.turn then
		local only = self.level.data.decay.only or nil
		if not only or only.actor then
--			local nb_actor, remain_actor = self.level:decay(Map.ACTOR, function(e) return not e.unique and not e.lore and not e.quest and self.level.last_turn + rng.range(self.level.data.decay[1], self.level.data.decay[2]) < self.turn * 10 end)
--			if not self.level.data.decay.no_respawn then
--				local gen = self.zone:getGenerator("actor", self.level)
--				if gen.regenFrom then gen:regenFrom(remain_actor) end
--			end
		end

		if not only or only.object then
			local nb_object, remain_object = self.level:decay(Map.OBJECT, function(e) return not e.unique and not e.lore and not e.quest and self.level.last_turn + rng.range(self.level.data.decay[1], self.level.data.decay[2]) < self.turn * 10 end)
--			if not self.level.data.decay.no_respawn then
--				local gen = self.zone:getGenerator("object", self.level)
--				if gen.regenFrom then gen:regenFrom(remain_object) end
--			end
		end
	end

	-- Move back to old wilderness position
	if self.zone.wilderness then
		self.player:move(self.player.wild_x, self.player.wild_y, true)
		self.player.last_wilderness = self.zone.short_name
	else
		local x, y = nil, nil
		if params.auto_zone_stair and left_zone then
			-- Dirty but quick
			local list = {}
			for i = 0, self.level.map.w - 1 do for j = 0, self.level.map.h - 1 do
				local idx = i + j * self.level.map.w
				if self.level.map.map[idx][Map.TERRAIN] and self.level.map.map[idx][Map.TERRAIN].change_zone == left_zone.short_name then
					list[#list+1] = {i, j}
				end
			end end
			if #list > 0 then x, y = unpack(rng.table(list)) end
		elseif params.auto_level_stair then
			-- Dirty but quick
			local list = {}
			for i = 0, self.level.map.w - 1 do for j = 0, self.level.map.h - 1 do
				local idx = i + j * self.level.map.w
				if self.level.map.map[idx][Map.TERRAIN] and not self.level.map.map[idx][Map.TERRAIN].change_zone and self.level.map.map[idx][Map.TERRAIN].change_level == old_lev - self.level.level then
					list[#list+1] = {i, j}
				end
			end end
			if #list > 0 then x, y = unpack(rng.table(list)) end
		end

		-- Default to stairs
		if not x then
			if lev > old_lev and not params.force_down then x, y = self.level.default_up.x, self.level.default_up.y
			else x, y = self.level.default_down.x, self.level.default_down.y
			end
			if not x then x, y = self.level.default_up.x, self.level.default_up.y end
		end

		-- Check if there is already an actor at that location, if so move it
		x = x or 1 y = y or 1
		local blocking_actor = self.level.map(x, y, engine.Map.ACTOR)
		if blocking_actor then
			local newx, newy = util.findFreeGrid(x, y, 20, true, {[Map.ACTOR]=true})
			if newx and newy then blocking_actor:move(newx, newy, true)
			else blocking_actor:teleportRandom(x, y, 200) end
		end
		if self.player:canMove(x, y) then
			self.player:move(x, y, true)
		else
			self.player:move(x, y, true)
			self.player:teleportRandom(x, y, 200)
		end
	end
	self.player.changed = true
	if self.to_re_add_actors and not self.zone.wilderness and not self.zone.stellar_map then for act, _ in pairs(self.to_re_add_actors) do
		local x, y = util.findFreeGrid(self.player.x, self.player.y, 20, true, {[Map.ACTOR]=true})
		if x then act:move(x, y, true) end
	end end

	-- Re add entities
	self.level:addEntity(self.player)
	if self.to_re_add_actors and not self.zone.wilderness and not self.zone.stellar_map then
		for act, _ in pairs(self.to_re_add_actors) do
			self.level:addEntity(act)
			act:setTarget(nil)
			if act.ai_state and act.ai_state.tactic_leash_anchor then
				act.ai_state.tactic_leash_anchor = self.player
			end
		end
		self.to_re_add_actors = nil
	end

	if self.zone.on_enter then
		self.zone.on_enter(lev, old_lev, zone)
	end

	self.player:onEnterLevel(self.zone, self.level)
	self.player:resetMoveAnim()

	local musics = {}
	local keep_musics = false
	if self.level.data.ambient_music then
		if self.level.data.ambient_music ~= "last" then
			if type(self.level.data.ambient_music) == "string" then musics[#musics+1] = self.level.data.ambient_music
			elseif type(self.level.data.ambient_music) == "table" then for i, name in ipairs(self.level.data.ambient_music) do musics[#musics+1] = name end
			elseif type(self.level.data.ambient_music) == "function" then for i, name in ipairs{self.level.data.ambient_music()} do musics[#musics+1] = name end
			end
		elseif self.level.data.ambient_music == "last" then
			keep_musics = true
		end
	end
	if not keep_musics then self:playAndStopMusic(unpack(musics)) end

	-- Update the minimap
	self:setupMiniMap()

	-- Tell the map to use path strings to speed up path calculations
	for uid, e in pairs(self.level.entities) do
		if e.getPathString then
			self.level.map:addPathString(e:getPathString())
		end
	end
	self.zone_name_s = nil

	-- Special stuff
	for uid, act in pairs(self.level.entities) do if act.removeEffectsFilter then act:removeEffectsFilter(function(e) return e.zone_wide_effect end, nil, nil, true) end end
	for uid, act in pairs(self.level.entities) do
		if act.setEffect then
			if self.level.data.zero_gravity then act:setEffect(act.EFF_ZERO_GRAVITY, 1, {})
			else act:removeEffect(act.EFF_ZERO_GRAVITY, nil, true) end
		end
	end
	if self.level.data.effects then
		for uid, act in pairs(self.level.entities) do 
			if act.setEffect then for _, effid in ipairs(self.level.data.effects) do
				act:setEffect(effid, 1, {})
			end end
		end
	end
	self.level.data.effects_allow = true

	-- Level feeling
	local feeling
	if self.level.special_feeling then
		feeling = self.level.special_feeling
	else
		local lev = self.zone.base_level + self.level.level - 1
		if self.zone.level_adjust_level then lev = self.zone:level_adjust_level(self.level) end
		local diff = lev - self.player.level
		if diff >= 5 then feeling = "You feel a thrill of terror and your heart begins to pound in your chest. You feel terribly threatened upon entering this area."
		elseif diff >= 2 then feeling = "You feel mildly anxious, and walk with caution."
		elseif diff >= -2 then feeling = nil
		elseif diff >= -5 then feeling = "You feel very confident walking into this place."
		else feeling = "You stride into this area without a second thought, while stifling a yawn. You feel your time might be better spent elsewhere."
		end
	end
	if feeling then self.log("#TEAL#%s", feeling) end

	-- Autosave
--	if config.settings.tome.autosave and not config.settings.cheat and ((left_zone and left_zone.short_name ~= "wilderness") or self.zone.save_per_level) and (left_zone and left_zone.short_name ~= self.zone.short_name) then self:saveGame() end

	self.player:onEnterLevelEnd(self.zone, self.level)

	-- Day/Night cycle
	if self.level.data.day_night then self.state:dayNightCycle() end

	if not recreate_nothing then
		--self.level.map:redisplay() -- not needed, reopen doest it
		self.level.map:reopen(true)
		if force_recreate then self.level.map:recreate() end
	end

	-- Anti stairscum
	if self.level.last_turn and self.level.last_turn < self.turn then
		local perc = util.bound(math.floor((self.turn - self.level.last_turn) / 10), 0, 10)
		for uid, target in pairs(self.level.entities) do
			if target.life and target.max_life and self.player:reactionToward(target) < 0 then
				target.life = util.bound(target.life + target.max_life * perc / 10, 0, target.max_life)
				target.changed = true
				target.talents_cd = {}

				local todel = {}
				for eff_id, p in pairs(target.tmp) do
					local e = target.tempeffect_def[eff_id]
					if e.status == "detrimental" then todel[#todel+1] = eff_id end
				end
				while #todel > 0 do
					target:removeEffect(table.remove(todel))
				end
			end
		end
	end

	if popup then popup:done() end

	self:dieClonesDie()
end

function _M:dieClonesDie()
	if not self.level then return end
	local p = self:getPlayer(true)
	if not p.puuid then return end
	for uid, e in pairs(self.level.entities) do
		if p.puuid == e.puuid and e ~= p then self.level:removeEntity(e) end
	end
end

function _M:getPlayer(main)
	if main then
		return self.party:findMember{main=true}
	else
		return self.player
	end
end

function _M:getCampaign()
	return self:getPlayer(true).descriptor.world
end

--- Says if this savefile is usable or not
function _M:isLoadable()
	if not self:getPlayer(true).dead or not self.player.dead then return true end
	return false
end

--- Clones the game world for chronomancy spells
function _M:chronoClone(name)
	self:getPlayer(true):attr("time_travel_times", 1)

	local d = Dialog:simpleWaiter("Chronomancy", "Folding the space time structure...")

	local to_reload = {}
	for uid, e in pairs(self.level.entities) do
		if type(e.project) == "table" and e.project.def and e.project.def.typ and e.project.def.typ.line_function then
			e.project.def.typ.line_function.line = { game.level.map.w, game.level.map.h, e.project.def.typ.line_function:export() }
			to_reload[#to_reload + 1] = e
		end
	end

	local ret = self:cloneFull()
	for uid, e in pairs(to_reload) do e:loaded() end

	if name then
		self._chronoworlds = self._chronoworlds or {}
		self._chronoworlds[name] = ret
		ret = nil
	end
	d:done()
	return ret
end

--- Restores a chronomancy clone
function _M:chronoRestore(name, remove)
	local ngame
	if type(name) == "string" then
		ngame = self._chronoworlds[name]
		if remove then self._chronoworlds[name] = nil end
	else ngame = name end
	if not ngame then return false end

	local d = Dialog:simpleWaiter("Chronomancy", "Unfolding the space time structure...")

	ngame:cloneReloaded()
	_G.game = ngame

	game.inited = nil
	game:runReal()
	game.key:setupRebootKeys() -- engine does it for us but not on chronoworld reload
	game.key:setCurrent()
	game.mouse:setCurrent()
	profile.chat:setupOnGame()

	core.wait.disable() -- "game" changed, we cant just unload the dialog, it doesnt exist anymore
	if game.player.resetMainShader then game.player:resetMainShader() end
	return true
end

--- Update the zone name, if needed
function _M:updateZoneName()
	local name
	if self.zone.display_name then
		name = self.zone.display_name()
	else
		local lev = self.level.level
		if self.level.data.reverse_level_display then lev = 1 + self.level.data.max_level - lev end
		if self.zone.max_level == 1 then
			name = self.zone.name
		else
			name = ("%s (%d)"):format(self.zone.name, lev)
		end
	end
	if self.zone_name_s and self.old_zone_name == name then return end

	local s = core.display.drawStringBlendedNewSurface(self.zone_font, name, unpack(colors.simple(colors.GOLD)))
	self.zone_name_w, self.zone_name_h = s:getSize()
	self.zone_name_s, self.zone_name_tw, self.zone_name_th = s:glTexture()
	self.old_zone_name = name
	print("Updating zone name", name)
end

function _M:tick()
	if self.level then
		self:targetOnTick()

		engine.GameTurnBased.tick(self)
		-- Fun stuff: this can make the game realtime, although calling it in display() will make it work better
		-- (since display is on a set FPS while tick() ticks as much as possible
		-- engine.GameEnergyBased.tick(self)
	else
		engine.Game.tick(self)
	end

	-- Check damages to log
	self:displayDelayedLogMessages()
	self:displayDelayedLogDamage()

	if self.tick_loopback then
		self.tick_loopback = nil
		return self:tick()
	end

	if savefile_pipe.saving then self.player.changed = true end
	if self.on_tick_end and #self.on_tick_end > 0 then return false end -- Force a new tick
	if self.creating_player then return true end
	if self.paused and not savefile_pipe.saving then return true end
end

-- Game Log management functions:
-- logVisible to determine if a message should be visible to the player
-- logMessage to add a message to the display
-- delayedLogMessage to queue an actor-specific message for display at the end of the current game tick
-- displayDelayedLogMessages() to display the queued messages (before combat damage messages)
-- delayedLogDamage to queue a combat (damage) message for display at the end of the current game tick
-- displayDelayedLogDamage to display the queued combat messages

-- output a message to the log based on the visibility of an actor to the player
function _M.logSeen(e, style, ...) 
	if e and e.player or (not e.dead and e.x and e.y and game.level and game.level.map.seens(e.x, e.y) and game.player:canSee(e)) then game.log(style, ...) end 
end

-- determine whether an action between 2 actors should produce a message in the log and if the player
-- can identify them
-- output: src, srcSeen: source display?, identify?
-- tgt, tgtSeen: target display?, identify?
-- output: Visible? and srcSeen (source is identified by the player), tgtSeen(target is identified by the player)
function _M:logVisible(source, target)
	-- target should display if it's the player, an actor in a seen tile, or a non-actor without coordinates
	local tgt = target and (target.player or (target.__is_actor and game.level.map.seens(target.x, target.y)) or (not target.__is_actor and not target.x))
	local tgtSeen = tgt and (target.player or game.player:canSee(target)) or false
	local src, srcSeen = false, false
--	local srcSeen = src and (not source.x or (game.player:canSee(source) and game.player:canSee(target)))
	-- Special cases
	if not source.x then -- special case: unpositioned source uses target parameters (for timed effects on target)
		if tgtSeen then
			src, srcSeen = tgt, tgtSeen
		else
			src, tgt = nil, nil
		end
	else -- source should display if it's the player or an actor in a seen tile, or same as target for non-actors
		src = source.player or (source.__is_actor and game.level.map.seens(source.x, source.y)) or (not source.__is_actor and tgt)
		srcSeen = src and game.player:canSee(source) or false
	end	
	
	return src or tgt or false, srcSeen, tgtSeen
end

-- Generate a message (string) for the log with possible source and target,
-- highlighting the player and taking visibility into account
-- srcSeen, tgtSeen = can player see(identify) the source, target?
-- style text message to display, may contain:
-- #source#|#Source# -> <displayString>..self.name|self.name:capitalize()
-- #target#|#Target# -> target.name|target.name:capitalize()
function _M:logMessage(source, srcSeen, target, tgtSeen, style, ...)
	style = style:format(...)
	local srcname = "something"
	local Dstring
		if source.player then
			srcname = "#fbd578#"..source.name.."#LAST#"
		elseif srcSeen then
			srcname = engine.Entity.check(source, "getName") or source.name or "unknown"
		end
		if srcname ~= "something" then Dstring = source.__is_actor and source.getDisplayString and source:getDisplayString() end
	style = style:gsub("#source#", srcname)
	style = style:gsub("#Source#", (Dstring or "")..srcname:capitalize())
	if target then
		local tgtname = "something"
			if target.player then
				tgtname = "#fbd578#"..target.name.."#LAST#"
			elseif tgtSeen then
				tgtname = engine.Entity.check(target, "getName") or target.name or "unknown"
			end
		style = style:gsub("#target#", tgtname)
		style = style:gsub("#Target#", tgtname:capitalize())
	end
	return style
end

-- log an entity-specific message for display later with displayDelayedLogDamage
-- only one message (processed with logMessage) will be logged for each source and label
-- useful to avoid spamming repeated messages
-- target is optional and is used only to resolve the msg
function _M:delayedLogMessage(source, target, label, msg, ...)
	local visible, srcSeen, tgtSeen = self:logVisible(source, target)
	if visible then
		self.delayed_log_messages[source] = self.delayed_log_messages[source] or {}
		local src = self.delayed_log_messages[source]
		src[label] = self:logMessage(source, srcSeen, target, tgtSeen, msg, ...)
	end
end

-- display the delayed log messages
function _M:displayDelayedLogMessages()
	if not self.uiset or not self.uiset.logdisplay then return end
	for src, msgs in pairs(self.delayed_log_messages) do
		for label, msg in pairs(msgs) do
			game.uiset.logdisplay(self:logMessage(src, true, nil, nil, msg))
		end
	end
	self.delayed_log_messages = {}
end

-- Note: There can be up to a 1 tick delay in displaying log information
function _M:displayDelayedLogDamage()
	if not self.uiset or not self.uiset.logdisplay then return end
	for src, tgts in pairs(self.delayed_log_damage) do
		for target, dams in pairs(tgts) do
			if #dams.descs > 1 then
				game.uiset.logdisplay(self:logMessage(src, dams.srcSeen, target, dams.tgtSeen, "#Source# hits #Target# for %s (%0.0f total damage)%s.", table.concat(dams.descs, ", "), dams.total, dams.healing<0 and (" #LIGHT_GREEN#[%0.0f healing]#LAST#"):format(-dams.healing) or ""))
			else
				if dams.healing >= 0 then
					game.uiset.logdisplay(self:logMessage(src, dams.srcSeen, target, dams.tgtSeen, "#Source# hits #Target# for %s damage.", table.concat(dams.descs, ", ")))
				elseif src == target then
					game.uiset.logdisplay(self:logMessage(src, dams.srcSeen, target, dams.tgtSeen, "#Source# receives %s.", table.concat(dams.descs, ", ")))
				else
					game.uiset.logdisplay(self:logMessage(src, dams.srcSeen, target, dams.tgtSeen, "#Target# receives %s from #Source#.", table.concat(dams.descs, ", ")))
				end
			end
			local rsrc = src.resolveSource and src:resolveSource() or src
			local rtarget = target.resolveSource and target:resolveSource() or target
			local x, y = target.x or -1, target.y or -1
			local sx, sy = self.level.map:getTileToScreen(x, y)
			if target.dead then
				if dams.tgtSeen and (rsrc == self.player or rtarget == self.player or self.party:hasMember(rsrc) or self.party:hasMember(rtarget)) then
					self.flyers:add(sx, sy, 30, (rng.range(0,2)-1) * 0.5, rng.float(-2.5, -1.5), ("Kill (%d)!"):format(dams.total), {255,0,255}, true)
					self:delayedLogMessage(target, nil,  "death", self:logMessage(src, dams.srcSeen, target, dams.tgtSeen, "#{bold}##Source# killed #Target#!#{normal}#"))
				end
			elseif dams.total > 0 or dams.healing == 0 then
				if dams.tgtSeen and (rsrc == self.player or self.party:hasMember(rsrc)) then
					self.flyers:add(sx, sy, 30, (rng.range(0,2)-1) * 0.5, rng.float(-3, -2), tostring(-math.ceil(dams.total)), {0,255,dams.is_crit and 200 or 0}, dams.is_crit)
				elseif dams.tgtSeen and (rtarget == self.player or self.party:hasMember(rtarget)) then
					self.flyers:add(sx, sy, 30, (rng.range(0,2)-1) * 0.5, -rng.float(-3, -2), tostring(-math.ceil(dams.total)), {255,dams.is_crit and 200 or 0,0}, dams.is_crit)
				end
			end
		end
	end
	if self.delayed_death_message then game.log(self.delayed_death_message) end
	self.delayed_death_message = nil
	self.delayed_log_damage = {}
end

-- log and collate combat damage for later display with displayDelayedLogDamage
function _M:delayedLogDamage(src, target, dam, desc, crit)
	if not target or not src then return end
	src = src.__project_source or src -- assign message to indirect damage source if available
	local visible, srcSeen, tgtSeen = self:logVisible(src, target)
	if visible then -- only log damage the player is aware of
		self.delayed_log_damage[src] = self.delayed_log_damage[src] or {}
		self.delayed_log_damage[src][target] = self.delayed_log_damage[src][target] or {total=0, healing=0, descs={}}
		local t = self.delayed_log_damage[src][target]
		t.descs[#t.descs+1] = desc
		if dam>=0 then
			t.total = t.total + dam
		else
			t.healing = t.healing + dam
		end
		t.is_crit = t.is_crit or crit
		t.srcSeen = srcSeen
		t.tgtSeen = tgtSeen
	end
end

--- Called every game turns
function _M:onTurn()
	if self.zone then
		if self.zone.on_turn then self.zone:on_turn() end
	end

	-- Process overlay effects
	self.level.map:processEffects(self.turn % 10 ~= 0)

	-- The following happens only every 10 game turns (once for every turn of 1 mod speed actors)
	if self.turn % 10 ~= 0 then return end

	-- Day/Night cycle
	if self.level.data.day_night then self.state:dayNightCycle() end

	if not self.day_of_year or self.day_of_year ~= self.calendar:getDayOfYear(self.turn) then
		self.log(self.calendar:getTimeDate(self.turn))
		self.day_of_year = self.calendar:getDayOfYear(self.turn)
	end

	if self.turn % 500 ~= 0 then return end
	self:dieClonesDie()
end

function _M:updateFOV()
	self.player:playerFOV()
end

function _M:displayMap(nb_keyframes)
	-- Now the map, if any
	if self.level and self.level.map and self.level.map.finished then
		local map = self.level.map

		-- Display the map and compute FOV for the player if needed
		local changed = map.changed
		if changed then self:updateFOV() end

		-- Ugh I dont like that but .. special case for timestop, for now it'll do!
		if self.player and self.player:attr("timestopping") and self.player.x and self.posteffects and self.posteffects.timestop and self.posteffects.timestop.shad then
			self.posteffects.timestop.shad:paramNumber2("texSize", map.viewport.width, map.viewport.height)
			local sx, sy = map:getTileToScreen(self.player.x, self.player.y)
			self.posteffects.timestop.shad:paramNumber2("playerPos", sx + map.tile_w / 2, sy + map.tile_h / 2)
			self.posteffects.timestop.shad:paramNumber("tick_real", core.game.getTime())
		end

		-- Display using Framebuffer, so that we can use shaders and all
		if self.fbo then
			self.fbo:use(true)
				if self.level.data.background then self.level.data.background(self.level, 0, 0, nb_keyframes) end
				map:display(0, 0, nb_keyframes, config.settings.tome.smooth_fov, self.fbo)
				if self.level.data.foreground then self.level.data.foreground(self.level, 0, 0, nb_keyframes) end
				if self.level.data.weather_particle then self.state:displayWeather(self.level, self.level.data.weather_particle, nb_keyframes) end
				if self.level.data.weather_shader then self.state:displayWeatherShader(self.level, self.level.data.weather_shader, map.display_x, map.display_y, nb_keyframes) end
			self.fbo:use(false, self.full_fbo)

			-- 2nd pass to apply distorting particles
			self.fbo2:use(true)
				self.fbo:toScreen(0, 0, map.viewport.width, map.viewport.height)
				core.particles.drawAlterings()
				if self.posteffects and self.posteffects.line_grids and self.posteffects.line_grids.shad then self.posteffects.line_grids.shad:use(true) end
				map._map:toScreenLineGrids(map.display_x, map.display_y)
				if self.posteffects and self.posteffects.line_grids and self.posteffects.line_grids.shad then self.posteffects.line_grids.shad:use(false) end
				if config.settings.tome.smooth_fov then map._map:drawSeensTexture(0, 0, nb_keyframes) end
			self.fbo2:use(false, self.full_fbo)

			_2DNoise:bind(1, false)
			self.fbo2:postEffects(self.fbo, self.full_fbo, map.display_x, map.display_y, map.viewport.width, map.viewport.height, unpack(self.posteffects_use))
			if self.target then self.target:display(nil, nil, self.full_fbo, nb_keyframes) end

		-- Basic display; no FBOs
		else
			if self.level.data.background then self.level.data.background(self.level, map.display_x, map.display_y, nb_keyframes) end
			map:display(nil, nil, nb_keyframes, config.settings.tome.smooth_fov, nil)
			if self.target then self.target:display(nil, nil, self.full_fbo, nb_keyframes) end
			if self.level.data.foreground then self.level.data.foreground(self.level, map.display_x, map.display_y, nb_keyframes) end
			if self.level.data.weather_particle then self.state:displayWeather(self.level, self.level.data.weather_particle, nb_keyframes) end
			if self.level.data.weather_shader then self.state:displayWeatherShader(self.level, self.level.data.weather_shader, map.display_x, map.display_y, nb_keyframes) end
			core.particles.drawAlterings()
			if self.posteffects and self.posteffects.line_grids and self.posteffects.line_grids.shad then self.posteffects.line_grids.shad:use(true) end
			map._map:toScreenLineGrids(map.display_x, map.display_y)
			if self.posteffects and self.posteffects.line_grids and self.posteffects.line_grids.shad then self.posteffects.line_grids.shad:use(false) end
			if config.settings.tome.smooth_fov then map._map:drawSeensTexture(map.display_x, map.display_y, nb_keyframes) end
		end

		-- Handle ambient sounds
		if self.level.data.ambient_bg_sounds then self.state:playAmbientSounds(self.level, self.level.data.ambient_bg_sounds, nb_keyframes) end

		if not self.zone_name_s then self:updateZoneName() end

		-- emotes display
		map:displayEmotes(nb_keyframe or 1)

		-- Mouse gestures
		self.gestures:update()
		-- self.gestures:display(map.display_x, map.display_y + map.viewport.height - self.gestures.font_h - 5)
		self.gestures:display(map.display_x, map.display_y, nb_keyframes)

		-- Inform the player that map is in scroll mode
		if core.key.modState("caps") then
			local w = map.viewport.width * 0.5
			local h = w * self.caps_scroll.h / self.caps_scroll.w
			self.caps_scroll[1]:toScreenFull(
				map.display_x + (map.viewport.width - w) / 2,
				map.display_y + (map.viewport.height - h) / 2,
				w, h,
				self.caps_scroll[2] * w / self.caps_scroll.w, self.caps_scroll[3] * h / self.caps_scroll.h,
				1, 1, 1, 0.5
			)
		end
	end
end

--- Called when screen resolution changes
function _M:checkResolutionChange(w, h, ow, oh)
	self:createFBOs()

	return self.uiset:handleResolutionChange(w, h, ow, oh)
end

function _M:display(nb_keyframes)
	-- If switching resolution, blank everything but the dialog
	if self.change_res_dialog then engine.GameTurnBased.display(self, nb_keyframes) return end

	-- Reset gamma setting, something somewhere is disrupting it, this is a stop gap solution
	if self.support_shader_gamma and self.full_fbo_shader and self.full_fbo_shader.shad then self.full_fbo_shader.shad:uniGamma(config.settings.gamma_correction / 100) end

	if self.full_fbo then self.full_fbo:use(true) end

	-- Now the ui
	self.uiset:display(nb_keyframes)

	-- "Big News"
	self.bignews:display(nb_keyframes)

	if self.player then self.player.changed = false end

	engine.GameTurnBased.display(self, nb_keyframes)

	-- Tooltip is displayed over all else, even dialogs but before FBO
	local mx, my, button = core.mouse.get()

	self.old_ctrl_state = self.ctrl_state
	self.ctrl_state = core.key.modState("ctrl")

	-- if tooltip is in way of mouse and its not locked then move it
	if self.tooltip.w and mx > self.w - self.tooltip.w and my > Tooltip:tooltip_bound_y2() - self.tooltip.h and not self.tooltip.locked then
		self:targetDisplayTooltip(Map.display_x, self.h, self.old_ctrl_state~=self.ctrl_state, nb_keyframes )
	else
		self:targetDisplayTooltip(self.w, self.h, self.old_ctrl_state~=self.ctrl_state, nb_keyframes )
	end

	if self.full_fbo then
		self.full_fbo:use(false)
		self.full_fbo:toScreen(0, 0, self.w, self.h, self.full_fbo_shader.shad)
	end

end

--- Called when a dialog is registered to appear on screen
function _M:onRegisterDialog(d)
	-- Clean up tooltip
	self.tooltip_x, self.tooltip_y = nil, nil
	self.tooltip2_x, self.tooltip2_y = nil, nil
	if self.player then self.player:updateMainShader() end

--	if self.player and self.player.runStop then self.player:runStop("dialog poping up") end
--	if self.player and self.player.restStop then self.player:restStop("dialog poping up") end
end
function _M:onUnregisterDialog(d)
	-- Clean up tooltip
	self.tooltip_x, self.tooltip_y = nil, nil
	self.tooltip2_x, self.tooltip2_y = nil, nil
	if self.player then self.player:updateMainShader() self.player.changed = true end
end

function _M:setTacticalMode(mode, silent)
	local vs = "true"
	if mode == "old" then
		self.always_target = "old"
		vs = "'old'"
		Map:setViewerFaction(self.player.faction)
		if not silent then self.log("Showing big healthbars and tactical borders.") end
	elseif mode == "health" then
		self.always_target = "health"
		vs = "'health'"
		Map:setViewerFaction(nil)
		if not silent then self.log("Showing healthbars only.") end
	elseif mode == nil then
		self.always_target = nil
		vs = "nil"
		Map:setViewerFaction(nil)
		if not silent then self.log("Showing no tactical information.") end
	elseif mode == true then
		self.always_target = true
		vs = "true"
		Map:setViewerFaction(self.player.faction)
		if not silent then self.log("Showing small healthbars and tactical borders.") end
	end

	config.settings.tome.tactical_mode = self.always_target
	config.settings.tome.tactical_mode_set = true
	game:saveSettings("tome.tactical_mode", ([[
tome.tactical_mode = %s
tome.tactical_mode_set = true
]]):format(vs))
end

function _M:setupCommands()
	-- Make targeting work
	self.normal_key = self.key
	self:targetSetupKey()

	-- Activate profiler keybinds
	self.key:setupProfiler()

	-- Activate mouse gestures
	self.gestures = Gestures.new("Gesture: ", self.key, true)
	if self.posteffects and self.posteffects.gestures and self.posteffects.gestures.shad then self.gestures.shader = self.posteffects.gestures.shad end

	-- Helper function to not allow some actions on the wilderness map
	local not_wild = function(f, bypass) return function(...) if self.zone and (not self.zone.wilderness or (bypass and bypass())) then f(...) else self.logPlayer(self.player, "You cannot do that on the world map.") end end end

	-- Debug mode
	self.key:addCommands{
		[{"_d","ctrl"}] = function() if config.settings.cheat then
			local g = self.level.map(self.player.x, self.player.y, Map.TERRAIN)
			print(g.define_as, g.image, g.z)
			for i, a in ipairs(g.add_mos or {}) do print(" => ", a.image) end
			local add = g.add_displays
			if add then for i, e in ipairs(add) do
				print(" -", e.image, e.z or "+"..i)
				for i, a in ipairs(e.add_mos or {}) do print("   => ", a.image, (a.display_x or 0).."x"..(a.display_y or 0)) end
			end end
			print("---")
			local mos = {}
			g:getMapObjects(game.level.map.tiles, mos, 1)
			table.print(mos)
			print("===============")
		end end,
		[{"_g","ctrl"}] = function() if config.settings.cheat then
			local o = game.zone:makeEntityByName(game.level, "object", "RIFT_SWORD", true)
			if o then
				o:identify(true)
				game.zone:addEntity(game.level, o, "object", game.player.x, game.player.y-1)
			end
do return end
			local f, err = loadfile("/data/general/events/fearscape-portal.lua")
			print(f, err)
			setfenv(f, setmetatable({level=self.level, zone=self.zone}, {__index=_G}))
			print(pcall(f))
do return end
			self:registerDialog(require("mod.dialogs.DownloadCharball").new())
		end end,
		[{"_f","ctrl"}] = function() if config.settings.cheat then
			self.player.quests["love-melinda"] = nil
			self.player:grantQuest("love-melinda")
			self.player:hasQuest("love-melinda"):melindaCompanion(self.player, "Defiler", "Corruptor")
		end end,
		[{"_UP","ctrl"}] = function()
			game.tooltip.container.scrollbar.pos = util.minBound(game.tooltip.container.scrollbar.pos - 1, 0, game.tooltip.container.scrollbar.max)
		end,
		[{"_DOWN","ctrl"}] = function()
			game.tooltip.container.scrollbar.pos = util.minBound(game.tooltip.container.scrollbar.pos + 1, 0, game.tooltip.container.scrollbar.max)
		end,
		[{"_HOME","ctrl"}] = function()
			game.tooltip.container.scrollbar.pos = 0
		end,
		[{"_END","ctrl"}] = function()
			game.tooltip.container.scrollbar.pos = game.tooltip.container.scrollbar.max
		end,
	}

	self.key.any_key = function(sym)
		-- Control resets the tooltip
		if sym == self.key._LCTRL or sym == self.key._RCTRL then
			self.player.changed = true
			self.tooltip.old_tmx = nil
		elseif sym == self.key._LSHIFT or sym == self.key._RSHIFT then
			self.player.changed = true
		end
	end
	self.key:unicodeInput(true)
	self.key:addBinds
	{
		-- Movements
		MOVE_LEFT = function() if core.key.modState("caps") and self.level then self.level.map:scrollDir(4) else self.player:moveDir(4) end end,
		MOVE_RIGHT = function() if core.key.modState("caps") and self.level then self.level.map:scrollDir(6) else self.player:moveDir(6) end end,
		MOVE_UP = function() if core.key.modState("caps") and self.level then self.level.map:scrollDir(8) else self.player:moveDir(8) end end,
		MOVE_DOWN = function() if core.key.modState("caps") and self.level then self.level.map:scrollDir(2) else self.player:moveDir(2) end end,
		MOVE_LEFT_UP = function() if core.key.modState("caps") and self.level then self.level.map:scrollDir(7) else self.player:moveDir(7) end end,
		MOVE_LEFT_DOWN = function() if core.key.modState("caps") and self.level then self.level.map:scrollDir(1) else self.player:moveDir(1) end end,
		MOVE_RIGHT_UP = function() if core.key.modState("caps") and self.level then self.level.map:scrollDir(9) else self.player:moveDir(9) end end,
		MOVE_RIGHT_DOWN = function() if core.key.modState("caps") and self.level then self.level.map:scrollDir(3) else self.player:moveDir(3) end end,
		MOVE_STAY = function() if core.key.modState("caps") and self.level then self.level.map:centerViewAround(self.player.x, self.player.y) else if self.player:enoughEnergy() then self.player:describeFloor(self.player.x, self.player.y) self.player:waitTurn() end end end,

		RUN = function()
			self.log("Run in which direction?")
			local co = coroutine.create(function()
				local x, y = self.player:getTarget{type="hit", no_restrict=true, range=1, immediate_keys=true, default_target=self.player}
				if x and y then self.player:runInit(util.getDir(x, y, self.player.x, self.player.y)) end
			end)
			local ok, err = coroutine.resume(co)
			if not ok and err then print(debug.traceback(co)) error(err) end
		end,

		RUN_AUTO = function()
			local ae = function() if self.level and self.zone then
				local seen = {}
				-- Check for visible monsters.  Only see LOS actors, so telepathy wont prevent it
				core.fov.calc_circle(self.player.x, self.player.y, self.level.map.w, self.level.map.h, self.player.sight or 10,
					function(_, x, y) return self.level.map:opaque(x, y) end,
					function(_, x, y)
						local actor = self.level.map(x, y, self.level.map.ACTOR)
						if actor and actor ~= self.player and self.player:reactionToward(actor) < 0 and
							self.player:canSee(actor) and self.level.map.seens(x, y) then seen[#seen + 1] = {x=x, y=y, actor=actor} end
					end, nil)
				if self.zone.no_autoexplore or self.level.no_autoexplore then
					self.log("You may not auto-explore this level.")
				elseif #seen > 0 then
					local dir = game.level.map:compassDirection(seen[1].x - self.player.x, seen[1].y - self.player.y)
					self.log("You may not auto-explore with enemies in sight (%s to the %s%s)!", seen[1].actor.name, dir, self.level.map:isOnScreen(seen[1].x, seen[1].y) and "" or " - offscreen")
					for _, node in ipairs(seen) do
						node.actor:addParticles(engine.Particles.new("notice_enemy", 1))
					end
				elseif not self.player:autoExplore() then
					self.log("There is nowhere left to explore.")
				end
			end end

			if config.settings.tome.rest_before_explore then
				local ok = false
				self.player:restInit(nil, nil, nil, function() ok = self.player.resting.rested_fully end, function() if ok then self:onTickEnd(ae) self.tick_loopback = true end end)
			else
				ae()
			end
		end,

		RUN_LEFT = function() self.player:runInit(4) end,
		RUN_RIGHT = function() self.player:runInit(6) end,
		RUN_UP = function() self.player:runInit(8) end,
		RUN_DOWN = function() self.player:runInit(2) end,
		RUN_LEFT_UP = function() self.player:runInit(7) end,
		RUN_LEFT_DOWN = function() self.player:runInit(1) end,
		RUN_RIGHT_UP = function() self.player:runInit(9) end,
		RUN_RIGHT_DOWN = function() self.player:runInit(3) end,

		ATTACK_OR_MOVE_LEFT = function() self.player:attackOrMoveDir(4) end,
		ATTACK_OR_MOVE_RIGHT = function() self.player:attackOrMoveDir(6) end,
		ATTACK_OR_MOVE_UP = function() self.player:attackOrMoveDir(8) end,
		ATTACK_OR_MOVE_DOWN = function() self.player:attackOrMoveDir(2) end,
		ATTACK_OR_MOVE_LEFT_UP = function() self.player:attackOrMoveDir(7) end,
		ATTACK_OR_MOVE_LEFT_DOWN = function() self.player:attackOrMoveDir(1) end,
		ATTACK_OR_MOVE_RIGHT_UP = function() self.player:attackOrMoveDir(9) end,
		ATTACK_OR_MOVE_RIGHT_DOWN = function() self.player:attackOrMoveDir(3) end,

		-- Hotkeys
		-- bindings done after
		HOTKEY_PREV_PAGE = not_wild(function() self.player:prevHotkeyPage() self.log("Hotkey page %d is now displayed.", self.player.hotkey_page) end),
		HOTKEY_NEXT_PAGE = not_wild(function() self.player:nextHotkeyPage() self.log("Hotkey page %d is now displayed.", self.player.hotkey_page) end),

		-- Party commands
		SWITCH_PARTY_1 = not_wild(function() self.party:select(1) end),
		SWITCH_PARTY_2 = not_wild(function() self.party:select(2) end),
		SWITCH_PARTY_3 = not_wild(function() self.party:select(3) end),
		SWITCH_PARTY_4 = not_wild(function() self.party:select(4) end),
		SWITCH_PARTY_5 = not_wild(function() self.party:select(5) end),
		SWITCH_PARTY_6 = not_wild(function() self.party:select(6) end),
		SWITCH_PARTY_7 = not_wild(function() self.party:select(7) end),
		SWITCH_PARTY_8 = not_wild(function() self.party:select(8) end),
		SWITCH_PARTY = not_wild(function() self:registerDialog(require("mod.dialogs.PartySelect").new()) end),
		ORDER_PARTY_1 = not_wild(function() self.party:giveOrders(1) end),
		ORDER_PARTY_2 = not_wild(function() self.party:giveOrders(2) end),
		ORDER_PARTY_3 = not_wild(function() self.party:giveOrders(3) end),
		ORDER_PARTY_4 = not_wild(function() self.party:giveOrders(4) end),
		ORDER_PARTY_5 = not_wild(function() self.party:giveOrders(5) end),
		ORDER_PARTY_6 = not_wild(function() self.party:giveOrders(6) end),
		ORDER_PARTY_7 = not_wild(function() self.party:giveOrders(7) end),
		ORDER_PARTY_8 = not_wild(function() self.party:giveOrders(8) end),

		-- Actions
		CHANGE_LEVEL = function()
			local e = self.level.map(self.player.x, self.player.y, Map.TERRAIN)
			if self.player:enoughEnergy() and e.change_level then
				if self.player:attr("never_move") then self.log("You cannot currently leave the level.") return end

				local stop = {}
				for eff_id, p in pairs(self.player.tmp) do
					local e = self.player.tempeffect_def[eff_id]
					if e.status == "detrimental" and not e.no_stop_enter_worlmap then stop[#stop+1] = e.desc end
				end

				if e.change_zone and #stop > 0 and e.change_zone:find("^wilderness") then
					self.log("You cannot go into the wilds with the following effects: %s", table.concat(stop, ", "))
				else
					-- Do not unpause, the player is allowed first move on next level
					if e.change_level_check and e:change_level_check(self.player) then return end
					self:changeLevel(e.change_zone and e.change_level or self.level.level + e.change_level, e.change_zone, {keep_old_lev=e.keep_old_lev, force_down=e.force_down, auto_zone_stair=e.change_zone_auto_stairs, auto_level_stair=e.change_level_auto_stairs, temporary_zone_shift_back=e.change_level_shift_back})
				end
			else
				self.log("There is no way out of this level here.")
			end
		end,

		REST = function()
			self.player:restInit()
		end,

		PICKUP_FLOOR = not_wild(function()
			if self.player.no_inventory_access then return end
			self.player:playerPickup()
		end),
		DROP_FLOOR = function()
			if self.player.no_inventory_access then return end
			self.player:playerDrop()
		end,
		SHOW_INVENTORY = function()
			if self.player.no_inventory_access then return end
			local d
			local titleupdator = self.player:getEncumberTitleUpdator("Inventory")
			d = self.player:showEquipInven(titleupdator(), nil, function(o, inven, item, button, event)
				if not o then return end
				local ud = require("mod.dialogs.UseItemDialog").new(event == "button", self.player, o, item, inven, function(_, _, _, stop)
					d:generate()
					d:generateList()
					d:updateTitle(titleupdator())
					if stop then self:unregisterDialog(d) end
				end)
				self:registerDialog(ud)
			end)
		end,
		SHOW_EQUIPMENT = "SHOW_INVENTORY",
		WEAR_ITEM = function()
			if self.player.no_inventory_access then return end
			self.player:playerWear()
		end,
		TAKEOFF_ITEM = function()
			if self.player.no_inventory_access then return end
			self.player:playerTakeoff()
		end,
		USE_ITEM = not_wild(function()
			if self.player.no_inventory_access then return end
			self.player:playerUseItem()
		end),

		QUICK_SWITCH_WEAPON = function()
			if self.player.no_inventory_access then return end
			self.player:quickSwitchWeapons()
		end,

		USE_TALENTS = not_wild(function()
			self:registerDialog(require("mod.dialogs.UseTalents").new(self.player))
		end),

		LEVELUP = function()
			if self.player.no_levelup_access then return end
			self.player:playerLevelup(nil, false)
		end,

		TOGGLE_AUTOTALENT = function()
			self.player.no_automatic_talents = not self.player.no_automatic_talents
			game.log("#GOLD#Automatic talent usage: %s", not self.player.no_automatic_talents and "#LIGHT_GREEN#enabled" or "#LIGHT_RED#disabled")
		end,

		SAVE_GAME = function()
			self:saveGame()
		end,

		SHOW_QUESTS = function()
			self:registerDialog(require("engine.dialogs.ShowQuests").new(self.party:findMember{main=true}))
		end,

		SHOW_CHARACTER_SHEET = function()
			self:registerDialog(require("mod.dialogs.CharacterSheet").new(self.player))
		end,

		SHOW_MESSAGE_LOG = function()
			self:registerDialog(require("mod.dialogs.ShowChatLog").new("Message Log", 0.6, self.uiset.logdisplay, profile.chat))
		end,

		-- Show time
		SHOW_TIME = function()
			self.log(self.calendar:getTimeDate(self.turn))
		end,
		-- Exit the game
		QUIT_GAME = function()
			self:onQuit()
		end,
		-- Lua console
		LUA_CONSOLE = function()
			if config.settings.cheat then
				self:registerDialog(DebugConsole.new())
			end
		end,
		-- Debug dialog
		DEBUG_MODE = function()
			if config.settings.cheat then
				self:registerDialog(require("mod.dialogs.debug.DebugMain").new())
			end
		end,

		-- Toggle monster list
		TOGGLE_NPC_LIST = function()
			self.show_npc_list = not self.show_npc_list
			self.player.changed = true

			if (self.show_npc_list) then
				self.log("Displaying creatures.")
			else
				self.log("Displaying talents.")
			end
		end,

		SCREENSHOT = function() self:saveScreenshot() end,

		HELP = "EXIT",
		EXIT = function()
			if self.tooltip.locked then
				self.tooltip.locked = false
				self.tooltip.container.focused = self.tooltip.locked
				game.log("Tooltip %s", self.tooltip.locked and "locked" or "unlocked")
			end
			local menu
			local l = {
				"resume",
				{ "Show Achievements", function() self:unregisterDialog(menu) self:registerDialog(require("mod.dialogs.ShowAchievements").new("Tales of Maj'Eyal Achievements", self.player)) end },
				{ "Show known Lore", function() self:unregisterDialog(menu) self:registerDialog(require("mod.dialogs.ShowLore").new("Tales of Maj'Eyal Lore", self.party)) end },
				{ "Show ingredients", function() self:unregisterDialog(menu) self:registerDialog(require("mod.dialogs.ShowIngredients").new(self.party)) end },
				"highscores",
				{ "Inventory", function() self:unregisterDialog(menu) self.key:triggerVirtual("SHOW_INVENTORY") end },
				{ "Character Sheet", function() self:unregisterDialog(menu) self.key:triggerVirtual("SHOW_CHARACTER_SHEET") end },
				"keybinds",
				{"Game Options", function() self:unregisterDialog(menu) self:registerDialog(require("mod.dialogs.GameOptions").new()) end},
				"video",
				"sound",
				"save",
				"quit",
				"exit",
			}
			local adds = self.uiset:getMainMenuItems()
			for i = #adds, 1, -1 do table.insert(l, 10, adds[i]) end
			self:triggerHook{"Game:alterGameMenu", menu=l, unregister=function() self:unregisterDialog(menu) end}
			menu = require("engine.dialogs.GameMenu").new(l)
			self:registerDialog(menu)
		end,

		TACTICAL_DISPLAY = function()
			if self.always_target == true then
				self:setTacticalMode("old")
			elseif self.always_target == "old" then
				self:setTacticalMode("health")
			elseif self.always_target == "health" then
				self:setTacticalMode(nil)
			elseif self.always_target == nil then
				self:setTacticalMode(true)
			end
		end,

		LOOK_AROUND = function()
			self.log("Looking around... (direction keys to select interesting things, shift+direction keys to move freely)")
			local co = coroutine.create(function()
				local x, y = self.player:getTarget{type="hit", no_restrict=true, range=2000}
				if x and y then
					local tmx, tmy = self.level.map:getTileToScreen(x, y)
					self:registerDialog(MapMenu.new(tmx, tmy, x, y))
				end
			end)
			local ok, err = coroutine.resume(co)
			if not ok and err then print(debug.traceback(co)) error(err) end
		end,

		LOCK_TOOLTIP = function()
			if not self.tooltip.empty then
				self.tooltip.locked = not self.tooltip.locked
				self.tooltip.container.focused = self.tooltip.locked
				game.log("Tooltip %s", self.tooltip.locked and "locked" or "unlocked")
			end
		end,

		LOCK_TOOLTIP_COMPARE = function()
			if not self.tooltip.empty then
				self.tooltip.locked = not self.tooltip.locked
				self.tooltip.container.focused = self.tooltip.locked
				game.log("Tooltip %s", self.tooltip.locked and "locked" or "unlocked")
			end
		end,

		SHOW_MAP = function()
			if config.settings.tome.uiset_mode == "Minimalist" then
				self.uiset.mm_mode = util.boundWrap((self.uiset.mm_mode or 2) + 1, 1, 3)
				if self.uiset.mm_mode == 1 then
					self.uiset.no_minimap = true
				elseif self.uiset.mm_mode == 2 then
					self.uiset.no_minimap = false
				elseif self.uiset.mm_mode == 3 then
					game:registerDialog(require("mod.dialogs.ShowMap").new(function() self.uiset.mm_mode = 1 self.uiset.no_minimap = true end))
				end
			else
				game:registerDialog(require("mod.dialogs.ShowMap").new())
			end
		end,

		USERCHAT_SHOW_TALK = function()
			self.show_userchat = not self.show_userchat
		end,

		TOGGLE_UI = function()
			self.uiset:toggleUI()
		end,

		TOGGLE_BUMP_ATTACK = function()
			local game_or_player = not config.settings.tome.actor_based_movement_mode and self or game.player

			if game_or_player.bump_attack_disabled then
				self.log("Movement Mode: #LIGHT_GREEN#Default#LAST#.")
				game_or_player.bump_attack_disabled = false
			else
				self.log("Movement Mode: #LIGHT_RED#Passive#LAST#.")
				game_or_player.bump_attack_disabled = true
			end
		end
	}
	engine.interface.PlayerHotkeys:bindAllHotkeys(self.key, not_wild(function(i)
		self:targetTriggerHotkey(i)
		self.player:activateHotkey(i)
	end, function() return self.player.allow_talents_worldmap end))

	self.key:setCurrent()
end

function _M:setupMouse(reset)
	if reset == nil or reset then self.mouse:reset() end

	local cur_obj = nil
	local outline = Shader.new("objectsoutline").shad

	self.mouse:registerZone(Map.display_x, Map.display_y, Map.viewport.width, Map.viewport.height, function(button, mx, my, xrel, yrel, bx, by, event, extra)
		if not self.uiset:isLocked() or not game.level then return end

		local tmx, tmy = game.level.map:getMouseTile(mx, my)
		if core.shader.allow("adv") and outline then
			local o = self.level.map(tmx, tmy, Map.OBJECT)
			if cur_obj and cur_obj._mo then cur_obj._mo:shader(nil) end
			if o and o._mo and not o.shader then
				outline:uniTextSize(Map.tile_w, Map.tile_h)
				o._mo:shader(outline)
				cur_obj = o
			end
		end

		self.tooltip.add_map_str = extra and extra.log_str


		if game.tooltip.locked then
			if button == "wheelup" and event == "button" then
				game.tooltip.container.scrollbar.pos = util.minBound(game.tooltip.container.scrollbar.pos - 1, 0, game.tooltip.container.scrollbar.max)
			elseif button == "wheeldown" and event == "button" then
				game.tooltip.container.scrollbar.pos = util.minBound(game.tooltip.container.scrollbar.pos + 1, 0, game.tooltip.container.scrollbar.max)
			end
			if button == "middle" then
				if not game.tooltip.container.draging then
					game.tooltip.container.draging = true
					game.tooltip.container.drag_x_start = mx
					game.tooltip.container.drag_y_start = my
				else
					game.tooltip.container.scrollbar.pos = util.minBound(game.tooltip.container.scrollbar.pos + my - game.tooltip.container.drag_y_start, 0, game.tooltip.container.scrollbar.max)
					game.tooltip.container.drag_x_start = mx
					game.tooltip.container.drag_y_start = my
				end
			else
				game.tooltip.container.draging = false
			end
		end

		-- Handle targeting
		if not config.settings.tome.disable_mouse_targeting and self:targetMouse(button, mx, my, xrel, yrel, event) then return end

		-- Cheat kill
		if config.settings.cheat and button == "right" and core.key.modState("ctrl") and core.key.modState("shift") and not xrel and not yrel and event == "button" and self.zone and not self.zone.wilderness then
			local target = game.level.map(tmx, tmy, Map.ACTOR)
			if target then
				target:die(game.player)
			end
			return
		end

		-- Handle Use menu
		if button == "right" then
			if event == "motion" then
				self.gestures:changeMouseButton(true)
				self.gestures:mouseMove(mx, my)
			elseif event == "button" then
				if not self.gestures:isGesturing() then
					if not xrel and not yrel then
						-- Handle Use menu
						self:mouseRightClick(mx, my, extra)
						return
					end
				else
					self.gestures:changeMouseButton(false)
					self.gestures:useGesture()
					self.gestures:reset()
				end
			end
		end

		-- Default left button action
		if button == "left" and not xrel and not yrel and event == "button" and self.zone and not self.zone.wilderness then if self:mouseLeftClick(mx, my) then return end end

		-- Default middle button action
		if button == "middle" and not xrel and not yrel and event == "button" and self.zone and not self.zone.wilderness then if self:mouseMiddleClick(mx, my) then return end end

		-- Handle the mouse movement/scrolling
		self.player:mouseHandleDefault(self.key, self.key == self.normal_key, button, mx, my, xrel, yrel, event)
	end, nil, "playmap")

	self.uiset:setupMouse(self.mouse)

	if not reset then self.mouse:setCurrent() end
end

--- Left mouse click on the map
function _M:mouseLeftClick(mx, my)
	if not self.level then return end
	local tmx, tmy = self.level.map:getMouseTile(mx, my)
	local p = self.player
	local a = self.level.map(tmx, tmy, Map.ACTOR)
	if not p:canSee(a) then return end
	if not p.auto_shoot_talent then return end
	local t = p:getTalentFromId(p.auto_shoot_talent)
	if not t then return end

	local target_dist = core.fov.distance(p.x, p.y, a.x, a.y)

	if p:enoughEnergy() and p:reactionToward(a) < 0 and p:knowTalent(t) and not p:isTalentCoolingDown(t) and p:preUseTalent(t, true, true) and target_dist <= p:getTalentRange(t) and p:canProject({type="hit"}, a.x, a.y) then
		p:useTalent(t.id, nil, nil, nil, a)
		return true
	end
end
--- Middle mouse click on the map
function _M:mouseMiddleClick(mx, my)
	if not self.level then return end
	local tmx, tmy = self.level.map:getMouseTile(mx, my)
	local p = self.player
	local a = self.level.map(tmx, tmy, Map.ACTOR)
	if not p:canSee(a) then return end
	if not p.auto_shoot_midclick_talent then return end
	local t = p:getTalentFromId(p.auto_shoot_midclick_talent)
	if not t then return end

	local target_dist = core.fov.distance(p.x, p.y, a.x, a.y)

	if p:enoughEnergy() and p:reactionToward(a) < 0 and p:knowTalent(t) and not p:isTalentCoolingDown(t) and p:preUseTalent(t, true, true) and target_dist <= p:getTalentRange(t) and p:canProject({type="hit"}, a.x, a.y) then
		p:useTalent(t.id, nil, nil, nil, a)
		return true
	end
end

--- Right mouse click on the map
function _M:mouseRightClick(mx, my, extra)
	if not self.level then return end
	local tmx, tmy = self.level.map:getMouseTile(mx, my)
	self:registerDialog(MapMenu.new(mx, my, tmx, tmy, extra and extra.add_map_action))
end

--- Ask if we really want to close, if so, save the game first
function _M:onQuit()
	self.player:runStop("quitting")
	self.player:restStop("quitting")

	if not self.quit_dialog and not self.player.dead and not self:hasDialogUp() then
		self.quit_dialog = Dialog:yesnoPopup("Save and go back to main menu?", "Save and go back to main menu?", function(ok)
			if ok then
				-- savefile_pipe is created as a global by the engine
				self:saveGame()
				util.showMainMenu()
			end
			self.quit_dialog = nil
		end)
	end
end

function _M:onExit()
	self.player:runStop("quitting")
	self.player:restStop("quitting")

	if not self.quit_dialog and not self.player.dead and not self:hasDialogUp() then
		self.quit_dialog = Dialog:yesnoPopup("Save and exit game?", "Save and exit game?", function(ok)
			if ok then
				-- savefile_pipe is created as a global by the engine
				self:saveGame()
				savefile_pipe:forceWait()
				engine.GameTurnBased.onExit(self)
			end
			self.quit_dialog = nil
		end)
	end
end

--- Called when we leave the module
function _M:onDealloc()
	local time = os.time() - self.real_starttime
	print("Played ToME for "..time.." seconds")
end

function _M:saveVersion(token)
	if token == "new" then
		token = util.uuid()
		self.__savefile_version_tokens[token] = true
		return token
	end
--	return true
	return self.__savefile_version_tokens[token]
end

--- When a save is being made, stop running/resting
function _M:onSavefilePush()
	self.player:runStop("saving")
	self.player:restStop("saving")
end

--- When a save has been done, if it's a zone or level, also save the main game
function _M:onSavefilePushed(savename, type, object, class)
	if config.settings.cheat then return end -- Dont annoy debug
	if type == "zone" or type == "level" then self:onTickEnd(function() self:saveGame() end) end
end

--- Saves the highscore of the current char
function _M:registerHighscore()
	local player = self:getPlayer(true)
	local campaign = player.descriptor.world

	local details = {
		world = player.descriptor.world,
		subrace = player.descriptor.subrace,
		subclass = player.descriptor.subclass,
		difficulty = player.descriptor.difficulty,
		level = player.level,
		name = player.name,
		where = self.zone and self.zone.name or "???",
		dlvl = self.level and self.level.level or 1
	}
	if campaign == 'Arena' then
		details.score = self.level.arena.score
	else
		-- fallback score based on xp, this is a placeholder
		details.score = math.floor(10 * (player.level + (player.exp / player:getExpChart(player.level)))) + math.floor(player.money / 100)
	end

	if player.dead then
		details.killedby = player.killedBy and player.killedBy.name or "???"
		HighScores.registerScore(campaign, details)
	else
		HighScores.noteLivingScore(campaign, player.name, details)
	end
end

--- Requests the game to save
function _M:saveGame()
	self:registerHighscore()

	if self.party then for actor, _ in pairs(self.party.members) do engine.interface.PlayerHotkeys:updateQuickHotkeys(actor) end end

	-- savefile_pipe is created as a global by the engine
	local clone = savefile_pipe:push(self.save_name, "game", self)
	world:saveWorld()
	if not self.creating_player then
		local oldplayer = self.player
		self.party:setPlayer(self:getPlayer(true), true)

		_G.game = clone
		print("Saving JSON", pcall(function() if not game.player.__no_save_json then
			local party = game.party:cloneFull()
			party.__te4_uuid = game:getPlayer(true).__te4_uuid
			for m, _ in pairs(party.members) do
				m:stripForExport()
			end
			party:stripForExport()
			game.player:saveUUID(party)
		end end))
		_G.game = self

		self.party:setPlayer(oldplayer, true)
	end
	self.log("Saving game...")
end

--- Take a screenshot of the game
-- @param for_savefile The screenshot will be used for savefile display
function _M:takeScreenshot(for_savefile)
	if for_savefile then
		self.suppressDialogs = true
		core.display.forceRedraw()

		local x, y = self.w / 4, self.h / 4
		if self.level then
			x, y = self.level.map:getTileToScreen(self.player.x, self.player.y)
			x, y = x - self.w / 4, y - self.h / 4
			x, y = util.bound(x, 0, self.w / 2), util.bound(y, 0, self.h / 2)
		end
		local sc = core.display.getScreenshot(x, y, self.w / 2, self.h / 2)

		self.suppressDialogs = nil
		core.display.forceRedraw()

		return sc
	else
		return core.display.getScreenshot(0, 0, self.w, self.h)
	end
end

function _M:setAllowedBuild(what, notify)
	-- Do not unlock things in easy mode
	--if self.difficulty == self.DIFFICULTY_EASY then return end

	local old = profile.mod.allow_build[what]
	profile:saveModuleProfile("allow_build", {name=what})

	if old then return end

	if notify then
		self.state:checkDonation() -- They gained someting nice, they could be more receptive
		self:registerDialog(require("mod.dialogs.UnlockDialog").new(what))

		if type(unlocks_list[what]) == "string" then self.party.on_death_show_achieved[#self.party.on_death_show_achieved+1] = "Unlocked: "..unlocks_list[what] end
	end

	return true
end

function _M:unlockBackground(kind, name)
	if not config.settings['unlock_background_'..kind] then
		game.log("#ANTIQUE_WHITE#Splash screen unlocked: #GOLD#"..name)
	end
	config.settings['unlock_background_'..kind] = true
	local save = {}
	for k, v in pairs(config.settings) do if k:find("^unlock_background_") then
		save[#save+1] = k.."=true"
	end end
	game:saveSettings("unlock_background", table.concat(save, "\n"))
end

function _M:playSoundNear(who, name)
	if who and (not who.attr or not who:attr("_forbid_sounds")) and self.level and self.level.map.seens(who.x, who.y) then
		local pos = {x=0,y=0,z=0}
		if self.player and self.player.x then pos.x, pos.y = who.x - self.player.x, who.y - self.player.y end
		self:playSound(name, pos)
	end
end

--- Create a random lore object and place it
function _M:placeRandomLoreObjectScale(base, nb, level)
	local dist = ({
		[5] = { {1}, {2,3}, {4,5} }, -- 5 => 3
		korpul = { {1,2}, {3,4} }, -- 5 => 3
		maze = { {1,2,3,4},{5,6,7} }, -- 5 => 3
		daikara = { {1}, {2}, {3}, {4,5} },
		[7] = { {1,2}, {3,4}, {5,6}, {7} }, -- 7 => 4
	})[nb][level]
	if not dist then return end
	for _, i in ipairs(dist) do self:placeRandomLoreObject(base..i) end
end

--- Create a random lore object and place it
function _M:placeRandomLoreObject(define)
	if type(define) == "table" then define = rng.table(define) end
	local o = self.zone:makeEntityByName(self.level, "object", define)
	if not o then return end
	if o.checkFilter and not o:checkFilter({}) then return end

	local x, y = rng.range(0, self.level.map.w-1), rng.range(0, self.level.map.h-1)
	local tries = 0
	while (self.level.map:checkEntity(x, y, Map.TERRAIN, "block_move") or self.level.map(x, y, Map.OBJECT) or self.level.map.room_map[x][y].special) and tries < 100 do
		x, y = rng.range(0, self.level.map.w-1), rng.range(0, self.level.map.h-1)
		tries = tries + 1
	end
	if tries < 100 then
		self.zone:addEntity(self.level, o, "object", x, y)
		print("Placed lore", o.name, x, y)
		o:identify(true)
	end
end

unlocks_list = {
	birth_transmo_chest = "Birth option: Transmogrification Chest",
	birth_zigur_sacrifice = "Birth option: Zigur sacrifice",
	cosmetic_race_human_redhead = "Cosmetic: Redheads",
	cosmetic_race_dwarf_female_beard = "Cosmetic: Female dwarves facial pilosity",
	
	difficulty_insane = "Difficulty: Insane",
	difficulty_madness = "Difficulty: Madness",

	campaign_infinite_dungeon = "Campaign: Infinite Dungeon",
	campaign_arena = "Campaign: The Arena",

	undead_ghoul = "Race: Ghoul",
	undead_skeleton = "Race: Skeleton",
	yeek = "Race: Yeek",

	mage = "Class: Archmage",
	mage_tempest = "Class tree: Storm",
	mage_geomancer = "Class tree: Stone",
	mage_pyromancer = "Class tree: Wildfire",
	mage_cryomancer = "Class tree: Uttercold",
	mage_necromancer = "Class: Necromancer",
	cosmetic_class_alchemist_drolem = "Class feature: Alchemist's Drolem",

	rogue_marauder = "Class: Marauder",
	rogue_skirmisher = "Class: Skirmisher",
	rogue_poisons = "Class tree: Poisons",

	divine_anorithil = "Class: Anorithil",
	divine_sun_paladin = "Class: Sun Paladin",

	wilder_wyrmic = "Class: Wyrmic",
	wilder_summoner = "Class: Summoner",
	wilder_oozemancer = "Class: Oozemancer",

	corrupter_reaver = "Class: Reaver",
	corrupter_corruptor = "Class: Corruptor",

	afflicted_cursed = "Class: Cursed",
	afflicted_doomed = "Class: Doomed",

	chronomancer_temporal_warden = "Class: Temporal Warden",
	chronomancer_paradox_mage = "Class: Paradox Mage",

	psionic_mindslayer = "Class: Mindslayer",
	psionic_solipsist = "Class: Solipsist",

	warrior_brawler = "Class: Brawler",

	adventurer = "Class: Adventurer",
}

--- Returns the current number of birth unlocks and the max
function _M:countBirthUnlocks()
	local nb = 0
	local max = 0

	for name, _ in pairs(self.unlocks_list) do
		max = max + 1
		if profile.mod.allow_build[name] then nb = nb + 1 end
	end
	return nb, max
end

-- get a text-compatible texture (icon) for an entity
function _M:getGenericTextTiles(en)
	local disp = en
	if not disp then return "" end
	if not en.getDisplayString then
		if en.display_entity and en.display_entity.getDisplayString then
			disp = en.display_entity 
		else
			return ""
		end
	end
	disp:getMapObjects(game.uiset.hotkeys_display_icons.tiles, {}, 1)
	return tostring((disp:getDisplayString() or ""):toTString())
end