Forked from
tome / Tales of MajEyal
1744 commits behind the upstream repository.
Game.lua 24.07 KiB
-- TE4 - T-Engine 4
-- Copyright (C) 2009 - 2019 Nicolas Casalini
--
-- This program is free software: you can redistribute it and/or modify
-- it under the terms of the GNU General Public License as published by
-- the Free Software Foundation, either version 3 of the License, or
-- (at your option) any later version.
--
-- This program is distributed in the hope that it will be useful,
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-- GNU General Public License for more details.
--
-- You should have received a copy of the GNU General Public License
-- along with this program. If not, see <http://www.gnu.org/licenses/>.
--
-- Nicolas Casalini "DarkGod"
-- darkgod@te4.org
require "engine.class"
require "engine.Mouse"
require "engine.DebugConsole"
local tween = require "tween"
local Shader = require "engine.Shader"
--- Represents a game
-- A module should subclass it and initialize anything it needs to play inside
-- @classmod engine.Game
module(..., package.seeall, class.make)
--- Sets up the default keyhandler
-- Also requests the display size and stores it in "w" and "h" properties
-- @param[type=Key] keyhandler the default keyhandler for this game
function _M:init(keyhandler)
self.key = keyhandler
self.level = nil
self.w, self.h, self.fullscreen = core.display.size()
self.dialogs = {}
self.save_name = ""
self.player_name = ""
self.mouse = engine.Mouse.new()
self.mouse:setCurrent()
self.uniques = {}
self.__savefile_version_tokens = {}
self:defaultMouseCursor()
end
--- Log a message
-- Redefine as needed
function _M.log(style, ...) end
--- Log something associated with an entity that is seen by the player
-- Redefine as needed
function _M.logSeen(e, style, ...) end
--- Log something associated with an entity if it is the player
-- Redefine as needed
function _M.logPlayer(e, style, ...) end
--- Default mouse cursor
function _M:defaultMouseCursor()
local UIBase = require "engine.ui.Base"
local ui = UIBase.ui or "dark"
if fs.exists("/data/gfx/"..ui.."-ui/mouse.png") and fs.exists("/data/gfx/"..ui.."-ui/mouse-down.png") then
self:setMouseCursor("/data/gfx/"..ui.."-ui/mouse.png", "/data/gfx/"..ui.."-ui/mouse-down.png", -4, -4)
else
self:setMouseCursor("/data/gfx/ui/mouse.png", "/data/gfx/ui/mouse-down.png", -4, -4)
end
end
--- Sets the mouse cursor
-- @string mouse image for mouse
-- @string[opt] mouse_down image for mouse click
-- @number offsetx
-- @number offsety
function _M:setMouseCursor(mouse, mouse_down, offsetx, offsety)
if type(mouse) == "string" then mouse = core.display.loadImage(mouse) end
if type(mouse_down) == "string" then mouse_down = core.display.loadImage(mouse_down) end
if mouse then
self.__cursor = { up=mouse, down=(mouse_down or mouse), ox=offsetx, oy=offsety }
if config.settings.mouse_cursor then
core.display.setMouseCursor(self.__cursor.ox, self.__cursor.oy, self.__cursor.up, self.__cursor.down)
else
core.display.setMouseCursor(0, 0, nil, nil)
end
end
end
--- Called whenever the cursor needs updating
function _M:updateMouseCursor()
if self.__cursor then
if config.settings.mouse_cursor then
core.display.setMouseCursor(self.__cursor.ox, self.__cursor.oy, self.__cursor.up, self.__cursor.down)
else
core.display.setMouseCursor(0, 0, nil, nil)
end
end
end
--- Called when the game is loaded
function _M:loaded()
self.w, self.h, self.fullscreen = core.display.size()
self.dialogs = {}
self.key = engine.Key.current
self.mouse = engine.Mouse.new()
self.mouse:setCurrent()
self.__coroutines = self.__coroutines or {}
self:setGamma(config.settings.gamma_correction / 100)
end
--- Defines the default fields to be saved by the savefile code
-- @param[type=table] t additional definitions to save
-- @return table of definitions
function _M:defaultSavedFields(t)
local def = {
w=true, h=true, zone=true, player=true, level=true, entities=true,
energy_to_act=true, energy_per_tick=true, turn=true, paused=true, save_name=true,
always_target=true, gfxmode=true, uniques=true, object_known_types=true,
memory_levels=true, achievement_data=true, factions=true, playing_musics=true,
state=true,
__savefile_version_tokens = true, bad_md5_loaded = true,
__persistent_hooks=true,
}
table.merge(def, t)
return def
end
--- Sets the player name
-- @string name
function _M:setPlayerName(name)
self.save_name = name
self.player_name = name
end
--- Do not touch!!
function _M:prerun()
if self.__persistent_hooks then for _, h in ipairs(self.__persistent_hooks) do
self:bindHook(h.hook, h.fct)
end end
end
--- Starts the game
-- Modules should reimplement it to do whatever their game needs
function _M:run()
end
--- Checks if the current character is "tainted" by cheating
-- @return false by default
function _M:isTainted()
return false
end
--- Sets the current level
-- @param level a `Level` (or subclass) object
function _M:setLevel(level)
self.level = level
end
--- Tells the game engine to play this game
function _M:setCurrent()
core.game.set_current_game(self)
_M.current = self
end
--- Displays the screen
-- Called by the engine core to redraw the screen every frame
-- @param nb_keyframes The number of elapsed keyframes since last draw (this can be 0). This is set by the engine
function _M:display(nb_keyframes)
nb_keyframes = nb_keyframes or 1
if self.flyers then
self.flyers:display(nb_keyframes)
end
-- Suppress the display of dialogs when drawing for a savefile screenshot.
if not core.display.redrawingForSavefileScreenshot() and #self.dialogs then
local last = self.dialogs[#self.dialogs]
for i = last and last.__show_only and #self.dialogs or 1, #self.dialogs do
local d = self.dialogs[i]
d:display()
d:toScreen(d.display_x, d.display_y, nb_keyframes)
end
end
-- Check profile thread events
self:handleEvents()
-- Check timers
if self._timers_cb and nb_keyframes > 0 then
local new = {}
local exec = {}
for cb, frames in pairs(self._timers_cb) do
frames = frames - nb_keyframes
if frames <= 0 then exec[#exec+1] = cb
else new[cb] = frames end
end
if next(new) then self._timers_cb = new
else self._timers_cb = nil end
for _, cb in ipairs(exec) do cb() end
end
-- Update tweening engine
if nb_keyframes > 0 then tween.update(nb_keyframes) end
end
--- Register a timer
-- @int seconds will be called in the given number of seconds
-- @func cb the callback function
function _M:registerTimer(seconds, cb)
self._timers_cb = self._timers_cb or {}
self._timers_cb[cb] = seconds * 30
end
--- Called when the game is focused/unfocused
-- @param[type=boolean] focus are we focused?
function _M:idling(focus)
self.has_os_focus = focus
-- print("Game got focus/unfocus", focus)
end
--- Handle pending events
function _M:handleEvents()
local evt = profile:popEvent()
while evt do
self:handleProfileEvent(evt)
evt = profile:popEvent()
end
end
--- Receives a profile event
-- Usually this just transfers it to the PlayerProfile class but you can overload it to handle special stuff
-- @param evt the event
function _M:handleProfileEvent(evt)
return profile:handleEvent(evt)
end
--- Returns the player
-- Reimplement it in your module, this can just return nil if you dont want/need
-- the engine adjusting stuff to the player or if you have many players or whatever
-- @param main if true the game should try to return the "main" player, if any
-- @return nil by default
function _M:getPlayer(main)
return nil
end
--- Returns current "campaign" name
-- @return "default" by default
function _M:getCampaign()
return "default"
end
--- Says if this savefile is usable or not
-- Reimplement it in your module, returning false when the player is dead
-- @return true by default
function _M:isLoadable()
return true
end
--- Gets/increment the savefile version
-- @param[opt] token if "new" this will create a new allowed save token and return it. Otherwise this checks the token against the allowed ones and returns true if it is allowed
-- @return uuid
-- @return true
function _M:saveVersion(token)
if token == "new" then
token = util.uuid()
self.__savefile_version_tokens[token] = true
return token
end
return self.__savefile_version_tokens[token]
end
--- This is the "main game loop", do something here
function _M:tick()
-- If any errors have occurred, save them and open the error dialog
local errs = core.game.checkError()
if errs then
if not self.errors or self.errors.turn ~= self.turn then self.errors = {turn=self.turn, first_error = errs} end
self.errors.last_error = errs table.insert(self.errors, (#self.errors%10) + 1, errs)
if config.settings.cheat then for id = #self.dialogs, 1, -1 do self:unregisterDialog(self.dialogs[id]) end end
self:registerDialog(require("engine.dialogs.ShowErrorStack").new(errs))
end
local stop = {}
local id, co = next(self.__coroutines)
while id do
local ok, err = coroutine.resume(co)
if not ok then
print(debug.traceback(co))
print("[COROUTINE] error", err)
end
if coroutine.status(co) == "dead" then
stop[#stop+1] = id
end
id, co = next(self.__coroutines, id)
end
if #stop > 0 then
for i = 1, #stop do
self.__coroutines[stop[i]] = nil
print("[COROUTINE] dead", stop[i])
end
end
Shader:cleanup()
if self.cleanSounds then self:cleanSounds() end
self:onTickEndExecute()
end
--- Run all registered tick end functions
-- Usually just let the engine call it
function _M:onTickEndExecute()
if self.on_tick_end and #self.on_tick_end > 0 then
local fs = self.on_tick_end
self.on_tick_end = {}
for i = 1, #fs do fs[i]() end
end
self.on_tick_end_names = nil
end
--- Register things to do on tick end
-- @func f function to do on tick end
-- @string name callback to reference the function
function _M:onTickEnd(f, name)
self.on_tick_end = self.on_tick_end or {}
if name then
self.on_tick_end_names = self.on_tick_end_names or {}
if self.on_tick_end_names[name] then return end
self.on_tick_end_names[name] = f
end
self.on_tick_end[#self.on_tick_end+1] = f
core.game.requestNextTick()
end
--- Returns a registered function to do on tick end by name
-- @string name callback to reference the function
function _M:onTickEndGet(name)
if not self.on_tick_end_names then return end
return self.on_tick_end_names[name]
end
--- Called when a zone leaves a level
-- @param level the level we're leaving
-- @param lev the new level
-- @param old_lev the old level (probably same value as level)
function _M:leaveLevel(level, lev, old_lev)
end
--- Called by the engine when the user tries to close the module
function _M:onQuit()
end
--- Called by the engine when the user tries to close the window
function _M:onExit()
if core.steam then core.steam.exit() end
core.game.exit_engine()
end
--- Sets up a `FlyingText` for general use
-- @param[type=FlyingText] fl
function _M:setFlyingText(fl)
self.flyers = fl
end
--- Registers a dialog to display
-- @param[type=Dialog] d
function _M:registerDialog(d)
if d.__refuse_dialog then return end
table.insert(self.dialogs, d)
self.dialogs[d] = #self.dialogs
d.__stack_id = #self.dialogs
if d.key then d.key:setCurrent() end
if d.mouse then d.mouse:setCurrent() end
if d.on_register then d:on_register() end
if self.onRegisterDialog then self:onRegisterDialog(d) end
end
--- Registers a dialog to display somewhere in the stack
-- @param[type=Dialog] d
-- @int pos the stack position (1=top, 2=second, ...)
function _M:registerDialogAt(d, pos)
if pos == 1 then return self:registerDialog(d) end
table.insert(self.dialogs, #self.dialogs - (pos - 2), d)
for i = 1, #self.dialogs do
local dd = self.dialogs[i]
self.dialogs[dd] = i
dd.__stack_id = i
end
if d.on_register then d:on_register() end
if self.onRegisterDialog then self:onRegisterDialog(d) end
end
--- Replaces a dialog to display with another
-- @param[type=Dialog] src old dialog
-- @param[type=Dialog] dest new dialog
function _M:replaceDialog(src, dest)
local id = src.__stack_id
-- Remove old one
self.dialogs[src] = nil
-- Update
self.dialogs[id] = dest
self.dialogs[dest] = id
dest.__stack_id = id
-- Give focus
if id == #self.dialogs then
if dest.key then dest.key:setCurrent() end
if dest.mouse then dest.mouse:setCurrent() end
end
if dest.on_register then dest:on_register(src) end
end
--- Undisplay a dialog, removing its own keyhandler if needed
-- @param[type=Dialog] d
function _M:unregisterDialog(d)
if not self.dialogs[d] then return end
table.remove(self.dialogs, self.dialogs[d])
self.dialogs[d] = nil
d:cleanup()
d:unload()
-- Update positions
for i, id in ipairs(self.dialogs) do id.__stack_id = i self.dialogs[id] = i end
local last = (#self.dialogs > 0) and self.dialogs[#self.dialogs] or self
if last.key then last.key:setCurrent() end
if last.mouse then last.mouse:setCurrent() end
if self.onUnregisterDialog then self:onUnregisterDialog(d) end
if last.on_recover_focus then last:on_recover_focus() end
end
--- Do we have a specific dialog
-- @param[type=Dialog] d
function _M:hasDialog(d)
return self.dialogs[d] and true or false
end
--- Do we have a dialog(s) running
-- @int[opt=0] nb how many dialogs minimum
function _M:hasDialogUp(nb)
nb = nb or 0
return #self.dialogs > nb
end
--- The C core gives us command line arguments
-- @param[type=table] args filled in by the C core
function _M:commandLineArgs(args)
for i, a in ipairs(args) do
print("Command line: ", a)
end
end
--- Called by savefile code to describe the current game
-- @return table
function _M:getSaveDescription()
return {
name = "player",
description = [[Busy adventuring!]],
}
end
--- Save a settings file
-- @string file
-- @param data
function _M:saveSettings(file, data)
core.game.resetLocale()
local restore = fs.getWritePath()
fs.setWritePath(engine.homepath)
local f, msg = fs.open("/settings/"..file..".cfg", "w")
if f then
f:write(data)
f:close()
else
print("WARNING: could not save settings in ", file, "::", data, "::", msg)
end
if restore then fs.setWritePath(restore) end
end
available_resolutions =
{
["800x600 Windowed"] = {800, 600, false},
["1024x768 Windowed"] = {1024, 768, false},
["1200x1024 Windowed"] = {1200, 1024, false},
["1280x720 Windowed"] = {1280, 720, false},
["1600x900 Windowed"] = {1600, 900, false},
["1600x1200 Windowed"] = {1600, 1200, false},
-- ["800x600 Fullscreen"] = {800, 600, true},
-- ["1024x768 Fullscreen"] = {1024, 768, true},
-- ["1200x1024 Fullscreen"] = {1200, 1024, true},
-- ["1600x1200 Fullscreen"] = {1600, 1200, true},
}
--- Get the available display modes for the monitor from the core
-- @function list
-- @local
local list = core.display.getModesList()
for _, m in ipairs(list) do
local ms = m.w.."x"..m.h.." Fullscreen"
if m.w >= 800 and m.h >= 600 and not available_resolutions[ms] then
available_resolutions[ms] = {m.w, m.h, true}
end
end
--- Change screen resolution
-- @string res should be in format like "800x600 Windowed"
-- @param[type=boolean] force try to force the resolution if it can't find it
function _M:setResolution(res, force)
local r = available_resolutions[res]
if force and not r then
local b = false
local _, _, w, h, f = res:find("([0-9][0-9][0-9]+)x([0-9][0-9][0-9]+)(.*)")
w = tonumber(w)
h = tonumber(h)
if f == " Fullscreen" then
f = true
elseif f == " Borderless" then
f = false
b = true
elseif f ~= " Windowed" then
-- If no windowed/fullscreen option sent, use the old value.
-- If no old value, opt for windowed mode.
f = self.fullscreen
else
f = false
end
if w and h then r = {w, h, f, b} end
end
if not r then return false, "unknown resolution" end
-- Change the window size
print("setResolution: switching resolution to", res, r[1], r[2], r[3], r[4], force and "(forced)")
local old_w, old_h, old_f, old_b, old_rw, old_rh = self.w, self.h, self.fullscreen, self.borderless
core.display.setWindowSize(r[1], r[2], r[3], r[4], config.settings.screen_zoom)
-- Don't write self.w/h/fullscreen yet
local new_w, new_h, new_f, new_b, new_rw, new_rh = core.display.size()
-- Check if a resolution change actually happened
if new_w ~= old_w or new_h ~= old_h or new_rw ~= old_rw or new_rh ~= old_rh or new_f ~= old_f or new_b ~= old_b then
print("setResolution: performing onResolutionChange...\n")
self:onResolutionChange()
-- onResolutionChange saves settings...
-- self:saveSettings("resolution", ("window.size = %q\n"):format(res))
else
print("setResolution: resolution change requested from same resolution!\n")
end
end
--- Called when screen resolution changes
function _M:onResolutionChange()
local ow, oh, of, ob = self.w, self.h, self.fullscreen, self.borderless
-- Save old values for a potential revert
if game and not self.change_res_dialog_oldw then
print("onResolutionChange: saving current resolution for potential revert.")
self.change_res_dialog_oldw, self.change_res_dialog_oldh, self.change_res_dialog_oldf = ow, oh, of
end
-- Get new resolution and save
local realw, realh
self.w, self.h, self.fullscreen, self.borderless, realw, realh = core.display.size()
realw, realh = realw or self.w, realh or self.h
config.settings.window.size = ("%dx%d%s"):format(realw, realh, self.fullscreen and " Fullscreen" or (self.borderless and " Borderless" or " Windowed"))
self:saveSettings("resolution", ("window.size = '%s'\n"):format(config.settings.window.size))
print("onResolutionChange: resolution changed to ", realw, realh, "from", ow, oh)
-- We do not even have a game yet
if not game then
print("onResolutionChange: no game yet!")
return
end
-- Redraw existing dialogs
self:updateVideoDialogs()
-- No actual resize
if ow == self.w and oh == self.h
and of == self.fullscreen and ob == self.borderless then
print("onResolutionChange: no actual resize, no confirm dialog.")
return
end
-- Extra game logic to be updated on a resize
if not self:checkResolutionChange(self.w, self.h, ow, oh) then
print("onResolutionChange: checkResolutionChange returned false, no confirm dialog.")
return
end
-- Do not repop if we just revert back
if self.change_res_dialog and type(self.change_res_dialog) == "string" and self.change_res_dialog == "revert" then
print("onResolutionChange: Reverting, no popup.")
return
end
-- Unregister old dialog if there was one
if self.change_res_dialog and type(self.change_res_dialog) == "table" then
print("onResolutionChange: Unregistering dialog")
self:unregisterDialog(self.change_res_dialog)
end
-- Are you sure you want to save these settings? Somewhat obnoxious...
-- self.change_res_dialog = require("engine.ui.Dialog"):yesnoPopup("Resolution changed", "Accept the new resolution?", function(ret)
-- if ret then
-- if not self.creating_player then self:saveGame() end
-- util.showMainMenu(false, nil, nil, self.__mod_info.short_name, self.save_name, false)
-- else
-- self.change_res_dialog = "revert"
-- self:setResolution(("%dx%d%s"):format(self.change_res_dialog_oldw, self.change_res_dialog_oldh, self.change_res_dialog_oldf and " Fullscreen" or " Windowed"), true)
-- self.change_res_dialog = nil
-- self.change_res_dialog_oldw, self.change_res_dialog_oldh, self.change_res_dialog_oldf = nil, nil, nil
-- end
-- end, "Accept", "Revert")
print("onResolutionChange: (Would have) created popup.")
end
--- Checks if we must reload to change resolution
-- @int w width
-- @int h height
-- @int ow original width
-- @int oh original height
function _M:checkResolutionChange(w, h, ow, oh)
return false
end
--- Called when the game window is moved around
-- @int x x coordinate
-- @int y y coordinate
function _M:onWindowMoved(x, y)
config.settings.window.pos = config.settings.window.pos or {}
config.settings.window.pos.x = x
config.settings.window.pos.y = y
self:saveSettings("window_pos", ("window.pos = {x=%d, y=%d}\n"):format(x, y))
-- Redraw existing dialogs
self:updateVideoDialogs()
end
--- Update any registered video options dialogs with the latest changes.
--
-- Note: If the title of the video options dialog changes, this
-- functionality will break.
function _M:updateVideoDialogs()
-- Update the video settings dialogs if any are registered.
-- We don't know which dialog (if any) is VideoOptions, so iterate through.
for i, v in ipairs(self.dialogs) do
if v.title == "Video Options" then
v.c_list:drawTree()
end
end
end
--- Sets the gamma of the window
-- By default it uses SDL gamma settings, but it can also use a fullscreen shader if available
-- @param gamma
function _M:setGamma(gamma)
if self.support_shader_gamma and core.shader.active() then
if self.full_fbo_shader then
-- Tell the shader which gamma to use
self.full_fbo_shader:setUniform("gamma", gamma)
-- Remove SDL gamma correction
core.display.setGamma(1)
print("[GAMMA] Setting gamma correction using fullscreen shader", gamma)
else
print("[GAMMA] Not setting gamma correction yet, no fullscreen shader found", gamma)
end
else
core.display.setGamma(gamma)
print("[GAMMA] Setting gamma correction using SDL", gamma)
end
end
--- Sets the gamma of the window only if using a fullscreen shader
-- @param gamma
function _M:setFullscreenShaderGamma(gamma)
if self.support_shader_gamma and core.shader.active() then
if self.full_fbo_shader then
-- Tell the shader which gamma to use
self.full_fbo_shader:setUniform("gamma", gamma)
print("[GAMMA] Setting gamma correction using fullscreen shader", gamma)
else
print("[GAMMA] Not setting gamma correction yet, no fullscreen shader found", gamma)
end
end
end
--- Requests the game to save
function _M:saveGame()
end
--- Saves the highscore of the current char
function _M:registerHighscore()
end
--- Add a coroutine to the pool
-- Coroutines registered will be run each game tick
-- @param id the id
-- @thread co the coroutine
function _M:registerCoroutine(id, co)
print("[COROUTINE] registering", id, co)
self.__coroutines[id] = co
end
--- Get the coroutine corresponding to the id
-- @param id the id
function _M:getCoroutine(id)
return self.__coroutines[id]
end
--- Ask a registered coroutine to cancel
-- The coroutine must accept a "cancel" action
-- @param id the id
function _M:cancelCoroutine(id)
local co = self.__coroutines[id]
if not co then return end
local ok, err = coroutine.resume(co, "cancel")
if not ok then
print(debug.traceback(co))
print("[COROUTINE] error", err)
end
if coroutine.status(co) == "dead" then
self.__coroutines[id] = nil
else
error("Told coroutine "..id.." to cancel, but it is not dead!")
end
end
--- Take a screenshot of the game
-- @param[type=boolean] for_savefile The screenshot will be used for savefile display
-- @return screenshot
function _M:takeScreenshot(for_savefile)
core.display.forceRedrawForScreenshot(for_savefile)
if for_savefile then
return core.display.getScreenshot(self.w / 4, self.h / 4, self.w / 2, self.h / 2)
else
return core.display.getScreenshot(0, 0, self.w, self.h)
end
end
--- Take a screenshot of the game and saves it to the screenshots folder
function _M:saveScreenshot()
local s = self:takeScreenshot()
if not s then return end
fs.mkdir("/screenshots")
local file = ("/screenshots/%s-%d.png"):format(self.__mod_info.version_string, os.time())
local f = fs.open(file, "w")
f:write(s)
f:close()
local Dialog = require "engine.ui.Dialog"
if core.steam then
local desc = self:getSaveDescription()
core.steam.screenshot(file, self.w, self.h, desc.description)
Dialog:simpleLongPopup("Screenshot taken!", "Screenshot should appear in your Steam client's #LIGHT_GREEN#Screenshots Library#LAST#.\nAlso available on disk: "..fs.getRealPath(file), 600)
else
Dialog:simplePopup("Screenshot taken!", "File: "..fs.getRealPath(file))
end
end
--- Register a hook that will be saved in the savefile
-- Obviously only run it once per hook per save
-- @string hook the hook to run on
-- @func fct the function to run
function _M:registerPersistentHook(hook, fct)
self.__persistent_hooks = self.__persistent_hooks or {}
table.insert(self.__persistent_hooks, {hook=hook, fct=fct})
self:bindHook(hook, fct)
end
-- get a text-compatible texture for a game entity (overload in module)
-- @param[type=Entity] en
-- @return ""
function _M:getGenericTextTiles(en)
return ""
end
--- Checks the presence of a specific addon
function _M:isAddonActive(name)
if not self.__mod_info then return end
if not self.__mod_info.addons then return end
return game.__mod_info.addons[name]
end