├── .gitignore ├── Cron.lua ├── EventProxy.lua ├── GameHUD.lua ├── GameLocale.lua ├── GameSession.lua ├── GameSettings.lua ├── GameUI.lua ├── LICENSE ├── README.md ├── Ref.lua └── mods ├── Cron ├── Cron.lua └── init.lua ├── GameSession-Events ├── GameSession.lua └── init.lua ├── GameSession-KillStats ├── GameHUD.lua ├── GameSession.lua ├── init.lua └── sessions │ └── .keep ├── GameSession-Reload ├── GameSession.lua ├── init.lua └── sessions │ └── .keep ├── GameSettings-Demo ├── GameSettings.lua └── init.lua ├── GameUI-Events ├── GameUI.lua └── init.lua ├── GameUI-Observe ├── GameUI.lua └── init.lua └── GameUI-WhereAmI ├── GameUI.lua └── init.lua /.gitignore: -------------------------------------------------------------------------------- 1 | .dev 2 | .idea 3 | *.log 4 | *.sqlite3 5 | /export.lua 6 | /init.lua 7 | /settings.lua 8 | /bin 9 | /mods/GameSession-KillStats/sessions/*.lua 10 | /mods/GameSession-Reload/sessions/*.lua 11 | -------------------------------------------------------------------------------- /Cron.lua: -------------------------------------------------------------------------------- 1 | --[[ 2 | Cron.lua 3 | Timed Tasks Manager 4 | 5 | Copyright (c) 2021 psiberx 6 | ]] 7 | 8 | local Cron = { version = '1.0.3' } 9 | 10 | local timers = {} 11 | local counter = 0 12 | local prune = false 13 | 14 | ---@param timeout number 15 | ---@param recurring boolean 16 | ---@param callback function 17 | ---@param args 18 | ---@return any 19 | local function addTimer(timeout, recurring, callback, args) 20 | if type(timeout) ~= 'number' then 21 | return 22 | end 23 | 24 | if timeout < 0 then 25 | return 26 | end 27 | 28 | if type(recurring) ~= 'boolean' then 29 | return 30 | end 31 | 32 | if type(callback) ~= 'function' then 33 | if type(args) == 'function' then 34 | callback, args = args, callback 35 | else 36 | return 37 | end 38 | end 39 | 40 | if type(args) ~= 'table' then 41 | args = { arg = args } 42 | end 43 | 44 | counter = counter + 1 45 | 46 | local timer = { 47 | id = counter, 48 | callback = callback, 49 | recurring = recurring, 50 | timeout = timeout, 51 | active = true, 52 | halted = false, 53 | delay = timeout, 54 | args = args, 55 | } 56 | 57 | if args.id == nil then 58 | args.id = timer.id 59 | end 60 | 61 | if args.interval == nil then 62 | args.interval = timer.timeout 63 | end 64 | 65 | if args.Halt == nil then 66 | args.Halt = Cron.Halt 67 | end 68 | 69 | if args.Pause == nil then 70 | args.Pause = Cron.Pause 71 | end 72 | 73 | if args.Resume == nil then 74 | args.Resume = Cron.Resume 75 | end 76 | 77 | table.insert(timers, timer) 78 | 79 | return timer.id 80 | end 81 | 82 | ---@param timeout number 83 | ---@param callback function 84 | ---@param data 85 | ---@return any 86 | function Cron.After(timeout, callback, data) 87 | return addTimer(timeout, false, callback, data) 88 | end 89 | 90 | ---@param timeout number 91 | ---@param callback function 92 | ---@param data 93 | ---@return any 94 | function Cron.Every(timeout, callback, data) 95 | return addTimer(timeout, true, callback, data) 96 | end 97 | 98 | ---@param callback function 99 | ---@param data 100 | ---@return any 101 | function Cron.NextTick(callback, data) 102 | return addTimer(0, false, callback, data) 103 | end 104 | 105 | ---@param timerId any 106 | ---@return void 107 | function Cron.Halt(timerId) 108 | if type(timerId) == 'table' then 109 | timerId = timerId.id 110 | end 111 | 112 | for _, timer in ipairs(timers) do 113 | if timer.id == timerId then 114 | timer.active = false 115 | timer.halted = true 116 | prune = true 117 | break 118 | end 119 | end 120 | end 121 | 122 | ---@param timerId any 123 | ---@return void 124 | function Cron.Pause(timerId) 125 | if type(timerId) == 'table' then 126 | timerId = timerId.id 127 | end 128 | 129 | for _, timer in ipairs(timers) do 130 | if timer.id == timerId then 131 | if not timer.halted then 132 | timer.active = false 133 | end 134 | break 135 | end 136 | end 137 | end 138 | 139 | ---@param timerId any 140 | ---@return void 141 | function Cron.Resume(timerId) 142 | if type(timerId) == 'table' then 143 | timerId = timerId.id 144 | end 145 | 146 | for _, timer in ipairs(timers) do 147 | if timer.id == timerId then 148 | if not timer.halted then 149 | timer.active = true 150 | end 151 | break 152 | end 153 | end 154 | end 155 | 156 | ---@param delta number 157 | ---@return void 158 | function Cron.Update(delta) 159 | if #timers == 0 then 160 | return 161 | end 162 | 163 | for _, timer in ipairs(timers) do 164 | if timer.active then 165 | timer.delay = timer.delay - delta 166 | 167 | if timer.delay <= 0 then 168 | if timer.recurring then 169 | timer.delay = timer.delay + timer.timeout 170 | else 171 | timer.active = false 172 | timer.halted = true 173 | prune = true 174 | end 175 | 176 | timer.callback(timer.args) 177 | end 178 | end 179 | end 180 | 181 | if prune then 182 | prune = false 183 | for i = #timers, 1, -1 do 184 | if timers[i].halted then 185 | table.remove(timers, i) 186 | end 187 | end 188 | end 189 | end 190 | 191 | return Cron -------------------------------------------------------------------------------- /EventProxy.lua: -------------------------------------------------------------------------------- 1 | --[[ 2 | EventProxy.lua 3 | Event Handler Manager 4 | 5 | Copyright (c) 2021 psiberx 6 | ]] 7 | 8 | local Cron = require('Cron') 9 | local Ref = require('Ref') 10 | 11 | ---@class EventHandler 12 | ---@field catcher IScriptable 13 | ---@field method string 14 | ---@field target IScriptable 15 | ---@field event any 16 | ---@field callback function 17 | 18 | ---@type table 19 | local observers = {} 20 | 21 | local cleanUpInterval = 30.0 22 | 23 | local knownTypes = { 24 | ['inkPointerEvent'] = 'sampleStyleManagerGameController::OnState3', 25 | ['inkWidget'] = 'sampleUISoundsLogicController::OnPress', 26 | } 27 | 28 | local knownEvents = { 29 | ['OnPress'] = 'inkPointerEvent', 30 | ['OnRelease'] = 'inkPointerEvent', 31 | ['OnHold'] = 'inkPointerEvent', 32 | ['OnRepeat'] = 'inkPointerEvent', 33 | ['OnRelative'] = 'inkPointerEvent', 34 | ['OnAxis'] = 'inkPointerEvent', 35 | ['OnEnter'] = 'inkPointerEvent', 36 | ['OnLeave'] = 'inkPointerEvent', 37 | ['OnHoverOver'] = 'inkPointerEvent', 38 | ['OnHoverOut'] = 'inkPointerEvent', 39 | ['OnPreOnPress'] = 'inkPointerEvent', 40 | ['OnPreOnRelease'] = 'inkPointerEvent', 41 | ['OnPreOnHold'] = 'inkPointerEvent', 42 | ['OnPreOnRepeat'] = 'inkPointerEvent', 43 | ['OnPreOnRelative'] = 'inkPointerEvent', 44 | ['OnPreOnAxis'] = 'inkPointerEvent', 45 | ['OnPostOnPress'] = 'inkPointerEvent', 46 | ['OnPostOnRelease'] = 'inkPointerEvent', 47 | ['OnPostOnHold'] = 'inkPointerEvent', 48 | ['OnPostOnRepeat'] = 'inkPointerEvent', 49 | ['OnPostOnRelative'] = 'inkPointerEvent', 50 | ['OnPostOnAxis'] = 'inkPointerEvent', 51 | ['OnLinkPressed'] = 'inkWidget', 52 | } 53 | 54 | ---@param message string 55 | local function warn(message) 56 | spdlog.warning(message) 57 | print(message) 58 | end 59 | 60 | ---@param signature string 61 | ---@return string, string 62 | local function parseSignature(signature) 63 | return signature:match('^(.+)::(.+)$') 64 | end 65 | 66 | ---@param event string 67 | ---@return string, string|nil 68 | local function parseProxyEvent(event) 69 | return event:match('^(.+)@(.+)$') or event, nil 70 | end 71 | 72 | ---@param proxy string 73 | ---@return string 74 | local function resolveProxyByType(proxy) 75 | return knownTypes[proxy] or proxy 76 | end 77 | 78 | ---@param proxy string 79 | ---@return string, string 80 | local function resolveProxyByEvent(proxy) 81 | local event, type = parseProxyEvent(proxy) 82 | 83 | if not type then 84 | type = knownEvents[event] 85 | 86 | if not type then 87 | type = 'inkWidget' -- Fallback to custom callback 88 | end 89 | end 90 | 91 | return resolveProxyByType(type), event 92 | end 93 | 94 | ---@param target IScriptable 95 | ---@return boolean 96 | local function isGlobalInput(target) 97 | return target:IsA('gameuiWidgetGameController') or target:IsA('inkWidgetLogicController') 98 | end 99 | 100 | ---@param handler EventHandler 101 | local function registerCallback(handler) 102 | if isGlobalInput(handler.target) then 103 | handler.target:RegisterToGlobalInputCallback(handler.event, handler.catcher, handler.method) 104 | else 105 | handler.target:RegisterToCallback(handler.event, handler.catcher, handler.method) 106 | end 107 | end 108 | 109 | ---@param handler EventHandler 110 | local function unregisterCallback(handler) 111 | if isGlobalInput(handler.target) then 112 | handler.target:UnregisterFromGlobalInputCallback(handler.event, handler.catcher, handler.method) 113 | else 114 | handler.target:UnregisterFromCallback(handler.event, handler.catcher, handler.method) 115 | end 116 | end 117 | 118 | ---@param proxy string 119 | ---@param target IScriptable 120 | ---@param event string 121 | ---@param callback function 122 | local function addEventHandler(proxy, target, event, callback) 123 | local class, method = parseSignature(proxy) 124 | 125 | if not class then 126 | return 127 | end 128 | 129 | local handlers = observers[proxy] 130 | 131 | if not handlers then 132 | handlers = {} 133 | 134 | local observer = function(self, ...) 135 | local hash = Ref.Hash(self) 136 | 137 | if handlers[hash] then 138 | handlers[hash].callback(handlers[hash].target, select(1, ...)) 139 | end 140 | end 141 | 142 | Cron.NextTick(function() 143 | Observe(class, method, observer) 144 | end) 145 | 146 | Cron.Every(cleanUpInterval, function() 147 | local counter = 0 148 | for hash, handler in pairs(handlers) do 149 | if Ref.IsExpired(handler.catcher) or Ref.IsExpired(handler.target) then 150 | handlers[hash] = nil 151 | else 152 | counter = counter + 1 153 | end 154 | end 155 | end) 156 | 157 | observers[proxy] = handlers 158 | end 159 | 160 | local catcher = NewObject(class) 161 | local hash = Ref.Hash(catcher) 162 | 163 | if handlers[hash] then 164 | warn(('[EventProxy] %s: Hash conflict %08X '):format(proxy, hash)) 165 | end 166 | 167 | local handler = { 168 | catcher = catcher, 169 | method = method, 170 | target = Ref.Weak(target), 171 | event = event, 172 | callback = callback, 173 | } 174 | 175 | registerCallback(handler) 176 | 177 | handlers[hash] = handler 178 | end 179 | 180 | ---@param proxy string 181 | ---@param target IScriptable 182 | ---@param event string 183 | ---@param callback function 184 | local function removeEventHandler(proxy, target, event, callback) 185 | local handlers = observers[proxy] 186 | 187 | if not handlers then 188 | return 189 | end 190 | 191 | for hash, handler in pairs(handlers) do 192 | if Ref.IsExpired(handler.catcher) or Ref.IsExpired(handler.target) then 193 | handlers[hash] = nil 194 | elseif handler.event == event and handler.callback == callback and Ref.Equals(handler.target, target) then 195 | unregisterCallback(handler) 196 | handlers[hash] = nil 197 | break 198 | end 199 | end 200 | end 201 | 202 | local function removeAllEventHandlers() 203 | for signature, handlers in pairs(observers) do 204 | for hash, handler in pairs(handlers) do 205 | if Ref.IsDefined(handler.target) and Ref.IsDefined(handler.catcher) then 206 | unregisterCallback(handler) 207 | end 208 | handlers[hash] = nil 209 | end 210 | observers[signature] = nil 211 | end 212 | end 213 | 214 | local EventProxy = { version = '1.0.2' } 215 | 216 | ---@type table 217 | EventProxy.Type = knownTypes 218 | 219 | ---@type table 220 | EventProxy.Event = knownEvents 221 | 222 | ---@param type string 223 | ---@param proxy string 224 | function EventProxy.RegisterProxy(type, proxy) 225 | knownTypes[type] = proxy 226 | end 227 | 228 | ---@param event string 229 | ---@param type string 230 | function EventProxy.RegisterEvent(event, type) 231 | knownEvents[event] = type 232 | end 233 | 234 | ---@param target IScriptable 235 | ---@param event string 236 | ---@param callback function 237 | function EventProxy.RegisterCallback(target, event, callback) 238 | local _proxy, _event = resolveProxyByEvent(event) 239 | 240 | addEventHandler(_proxy, target, _event, callback) 241 | end 242 | 243 | ---@param target IScriptable 244 | ---@param event string 245 | ---@param callback function 246 | function EventProxy.UnregisteCallback(target, event, callback) 247 | local _proxy, _event = resolveProxyByEvent(event) 248 | 249 | removeEventHandler(_proxy, target, _event, callback) 250 | end 251 | 252 | ---@param target IScriptable 253 | ---@param event string 254 | ---@param callback function 255 | function EventProxy.RegisterPointerCallback(target, event, callback) 256 | addEventHandler(knownTypes.inkPointerEvent, target, event, callback) 257 | end 258 | 259 | ---@param target IScriptable 260 | ---@param event string 261 | ---@param callback function 262 | function EventProxy.UnregisterPointerCallback(target, event, callback) 263 | removeEventHandler(knownTypes.inkPointerEvent, target, event, callback) 264 | end 265 | 266 | ---@param target IScriptable 267 | ---@param event string 268 | ---@param callback function 269 | function EventProxy.RegisterCustomCallback(target, event, callback) 270 | addEventHandler(knownTypes.inkWidget, target, event, callback) 271 | end 272 | 273 | ---@param target IScriptable 274 | ---@param event string 275 | ---@param callback function 276 | function EventProxy.UnregisterCustomCallback(target, event, callback) 277 | removeEventHandler(knownTypes.inkWidget, target, event, callback) 278 | end 279 | 280 | function EventProxy.UnregisterAllCallbacks() 281 | removeAllEventHandlers() 282 | end 283 | 284 | return EventProxy -------------------------------------------------------------------------------- /GameHUD.lua: -------------------------------------------------------------------------------- 1 | --[[ 2 | GameHUD.lua 3 | 4 | Copyright (c) 2021 psiberx 5 | ]] 6 | 7 | local GameHUD = { version = '0.4.1' } 8 | 9 | function GameHUD.Initialize() 10 | -- Fix warning message for patch 1.3 11 | local gameVersionNum = EnumValueFromString('gameGameVersion', 'Current') 12 | if gameVersionNum >= 1300 and gameVersionNum <= 1301 then 13 | Override('WarningMessageGameController', 'UpdateWidgets', function(self) 14 | if self.simpleMessage.isShown and self.simpleMessage.message ~= '' then 15 | self.root:StopAllAnimations() 16 | 17 | inkTextRef.SetLetterCase(self.mainTextWidget, textLetterCase.UpperCase) 18 | inkTextRef.SetText(self.mainTextWidget, self.simpleMessage.message) 19 | 20 | Game.GetAudioSystem():Play('ui_jingle_chip_malfunction') 21 | 22 | self.animProxyShow = self:PlayLibraryAnimation('warning') 23 | 24 | local fakeAnim = inkAnimTransparency.new() 25 | fakeAnim:SetStartTransparency(1.00) 26 | fakeAnim:SetEndTransparency(1.00) 27 | fakeAnim:SetDuration(3.1) 28 | 29 | local fakeAnimDef = inkAnimDef.new() 30 | fakeAnimDef:AddInterpolator(fakeAnim) 31 | 32 | self.animProxyTimeout = self.root:PlayAnimation(fakeAnimDef) 33 | self.animProxyTimeout:RegisterToCallback(inkanimEventType.OnFinish, self, 'OnShown') 34 | 35 | self.root:SetVisible(true) 36 | elseif self.animProxyShow then 37 | self.animProxyShow:RegisterToCallback(inkanimEventType.OnFinish, self, 'OnHidden') 38 | self.animProxyShow:Resume() 39 | end 40 | end) 41 | 42 | Override('WarningMessageGameController', 'OnShown', function(self) 43 | self.animProxyShow:Pause() 44 | self:SetTimeout(self.simpleMessage.duration) 45 | end) 46 | end 47 | end 48 | 49 | function GameHUD.ShowMessage(text) 50 | if text == nil or text == "" then 51 | return 52 | end 53 | 54 | local message = SimpleScreenMessage.new() 55 | message.message = text 56 | message.isShown = true 57 | 58 | local blackboardDefs = Game.GetAllBlackboardDefs() 59 | local blackboardUI = Game.GetBlackboardSystem():Get(blackboardDefs.UI_Notifications) 60 | 61 | blackboardUI:SetVariant( 62 | blackboardDefs.UI_Notifications.OnscreenMessage, 63 | ToVariant(message), 64 | true 65 | ) 66 | end 67 | 68 | function GameHUD.ShowWarning(text, duration) 69 | if text == nil or text == "" then 70 | return 71 | end 72 | 73 | local message = SimpleScreenMessage.new() 74 | message.message = text 75 | message.duration = duration 76 | message.isShown = true 77 | 78 | local blackboardDefs = Game.GetAllBlackboardDefs() 79 | local blackboardUI = Game.GetBlackboardSystem():Get(blackboardDefs.UI_Notifications) 80 | 81 | blackboardUI:SetVariant( 82 | blackboardDefs.UI_Notifications.WarningMessage, 83 | ToVariant(message), 84 | true 85 | ) 86 | end 87 | 88 | return GameHUD -------------------------------------------------------------------------------- /GameLocale.lua: -------------------------------------------------------------------------------- 1 | local GameLocale = { version = '0.8.1' } 2 | 3 | local languageGroupPath = '/language' 4 | local languageInterfaceVarName = 'OnScreen' 5 | local languageSubtitleVarName = 'Subtitles' 6 | local languageVoiceVarName = 'VoiceOver' 7 | 8 | local defaultLanguage = 'en-us' 9 | local currentLanguage = { 10 | [languageInterfaceVarName] = defaultLanguage, 11 | [languageSubtitleVarName] = defaultLanguage, 12 | [languageVoiceVarName] = defaultLanguage, 13 | } 14 | 15 | local translationDir = 'data/lang' 16 | local translationData = {} 17 | 18 | ---@type inkTextWidget 19 | local dummyTextWidget 20 | 21 | local function getLanguageFromSettings(languageVarName) 22 | return Game.NameToString(Game.GetSettingsSystem():GetVar(languageGroupPath, languageVarName):GetValue()) 23 | end 24 | 25 | local function getCurrentLanguage(languageVarName) 26 | return currentLanguage[languageVarName] or defaultLanguage 27 | end 28 | 29 | local function updateCurrentLanguage(languageVarName) 30 | currentLanguage[languageVarName] = getLanguageFromSettings(languageVarName) 31 | end 32 | 33 | local function loadTranslationData(targetLanguage) 34 | local chunk = loadfile(translationDir .. '/' .. targetLanguage) 35 | 36 | if chunk then 37 | translationData[targetLanguage] = chunk() 38 | else 39 | translationData[targetLanguage] = {} 40 | end 41 | 42 | if not translationData[defaultLanguage] then 43 | chunk = loadfile(translationDir .. '/' .. defaultLanguage) 44 | translationData[defaultLanguage] = chunk and chunk() or {} 45 | end 46 | end 47 | 48 | local function refreshTranslationData() 49 | for translationLanguage, _ in pairs(translationData) do 50 | if translationLanguage ~= defaultLanguage then 51 | local isUsed = false 52 | 53 | for _, activeLanguage in pairs(currentLanguage) do 54 | if translationLanguage == activeLanguage then 55 | isUsed = true 56 | break 57 | end 58 | end 59 | 60 | if not isUsed then 61 | translationData[translationLanguage] = nil 62 | end 63 | end 64 | end 65 | end 66 | 67 | local function getLocalizedText(key, targetLanguage) 68 | if translationData[targetLanguage] == nil then 69 | loadTranslationData(targetLanguage) 70 | end 71 | 72 | if translationData[targetLanguage][key] == nil then 73 | local translation = Game.GetLocalizedText(key) 74 | 75 | if translation ~= '' and translation ~= key then 76 | translationData[targetLanguage][key] = translation 77 | elseif targetLanguage ~= defaultLanguage then 78 | translationData[targetLanguage][key] = getLocalizedText(key, defaultLanguage) 79 | else 80 | translationData[targetLanguage][key] = key 81 | end 82 | end 83 | 84 | return translationData[targetLanguage][key] 85 | end 86 | 87 | local function getLocalizedDate(timestamp) 88 | if not dummyTextWidget then 89 | dummyTextWidget = inkTextWidget.new() 90 | end 91 | 92 | dummyTextWidget:SetDateTimeByTimestamp(timestamp) 93 | 94 | return dummyTextWidget.text 95 | end 96 | 97 | function GameLocale.Initialize(translationDataDir) 98 | if translationDataDir then 99 | translationDir = translationDataDir 100 | end 101 | 102 | languageGroupPath = CName.new(languageGroupPath) 103 | 104 | updateCurrentLanguage(languageInterfaceVarName) 105 | updateCurrentLanguage(languageSubtitleVarName) 106 | updateCurrentLanguage(languageVoiceVarName) 107 | 108 | Observe('SettingsMainGameController', 'OnVarModified', function(_, groupPath, varName, _, reason) 109 | if groupPath == languageGroupPath and reason == InGameConfigChangeReason.Accepted then 110 | updateCurrentLanguage(Game.NameToString(varName)) 111 | refreshTranslationData() 112 | end 113 | end) 114 | end 115 | 116 | function GameLocale.GetInterfaceLanguage() 117 | return getCurrentLanguage(languageInterfaceVarName) 118 | end 119 | 120 | function GameLocale.GetSubtitleLanguage() 121 | return getCurrentLanguage(languageSubtitleVarName) 122 | end 123 | 124 | function GameLocale.GetAudioLanguage() 125 | return getCurrentLanguage(languageVoiceVarName) 126 | end 127 | 128 | function GameLocale.Text(key) 129 | return getLocalizedText(key, currentLanguage[languageInterfaceVarName]) 130 | end 131 | 132 | function GameLocale.Subtitle(key) 133 | return getLocalizedText(key, currentLanguage[languageSubtitleVarName]) 134 | end 135 | 136 | function GameLocale.ActionHold(action) 137 | return ('(%s) %s'):format( 138 | getLocalizedText('Gameplay-Devices-Interactions-Helpers-Hold', currentLanguage[languageInterfaceVarName]), 139 | getLocalizedText(action, currentLanguage[languageInterfaceVarName]) 140 | ) 141 | end 142 | 143 | function GameLocale.Date(timestamp) 144 | return getLocalizedDate(timestamp) 145 | end 146 | 147 | --function GameLocale.GetTranslator() 148 | -- return getLocalizedText 149 | --end 150 | 151 | return GameLocale -------------------------------------------------------------------------------- /GameSession.lua: -------------------------------------------------------------------------------- 1 | --[[ 2 | GameSession.lua 3 | Reactive Session Observer 4 | Persistent Session Manager 5 | 6 | Copyright (c) 2021 psiberx 7 | ]] 8 | 9 | local GameSession = { 10 | version = '1.4.5', 11 | framework = '1.19.0' 12 | } 13 | 14 | GameSession.Event = { 15 | Start = 'Start', 16 | End = 'End', 17 | Pause = 'Pause', 18 | Blur = 'Blur', 19 | Resume = 'Resume', 20 | Death = 'Death', 21 | Update = 'Update', 22 | Load = 'Load', 23 | Save = 'Save', 24 | Clean = 'Clean', 25 | LoadData = 'LoadData', 26 | SaveData = 'SaveData', 27 | } 28 | 29 | GameSession.Scope = { 30 | Session = 'Session', 31 | Pause = 'Pause', 32 | Blur = 'Blur', 33 | Death = 'Death', 34 | Saves = 'Saves', 35 | Persistence = 'Persistence', 36 | } 37 | 38 | local initialized = {} 39 | local listeners = {} 40 | 41 | local eventScopes = { 42 | [GameSession.Event.Update] = {}, 43 | [GameSession.Event.Load] = { [GameSession.Scope.Saves] = true }, 44 | [GameSession.Event.Save] = { [GameSession.Scope.Saves] = true }, 45 | [GameSession.Event.Clean] = { [GameSession.Scope.Saves] = true }, 46 | [GameSession.Event.LoadData] = { [GameSession.Scope.Saves] = true, [GameSession.Scope.Persistence] = true }, 47 | [GameSession.Event.SaveData] = { [GameSession.Scope.Saves] = true, [GameSession.Scope.Persistence] = true }, 48 | } 49 | 50 | local isLoaded = false 51 | local isPaused = true 52 | local isBlurred = false 53 | local isDead = false 54 | 55 | local sessionDataDir 56 | local sessionDataRef 57 | local sessionDataTmpl 58 | local sessionDataRelaxed = false 59 | 60 | local sessionKeyValue = 0 61 | local sessionKeyFactName = '_psxgs_session_key' 62 | 63 | -- Error Handling -- 64 | 65 | local function raiseError(msg) 66 | print('[GameSession] ' .. msg) 67 | error(msg, 2) 68 | end 69 | 70 | -- Event Dispatching -- 71 | 72 | local function addEventListener(event, callback) 73 | if not listeners[event] then 74 | listeners[event] = {} 75 | end 76 | 77 | table.insert(listeners[event], callback) 78 | end 79 | 80 | local function dispatchEvent(event, state) 81 | if listeners[event] then 82 | state.event = event 83 | 84 | for _, callback in ipairs(listeners[event]) do 85 | callback(state) 86 | end 87 | 88 | state.event = nil 89 | end 90 | end 91 | 92 | -- State Observing -- 93 | 94 | local stateProps = { 95 | { current = 'isLoaded', previous = 'wasLoaded', event = { on = GameSession.Event.Start, off = GameSession.Event.End, scope = GameSession.Scope.Session } }, 96 | { current = 'isPaused', previous = 'wasPaused', event = { on = GameSession.Event.Pause, off = GameSession.Event.Resume, scope = GameSession.Scope.Pause } }, 97 | { current = 'isBlurred', previous = 'wasBlurred', event = { on = GameSession.Event.Blur, off = GameSession.Event.Resume, scope = GameSession.Scope.Blur } }, 98 | { current = 'isDead', previous = 'wasWheel', event = { on = GameSession.Event.Death, scope = GameSession.Scope.Death } }, 99 | { current = 'timestamp' }, 100 | { current = 'timestamps' }, 101 | { current = 'sessionKey' }, 102 | } 103 | 104 | local previousState = {} 105 | 106 | local function updateLoaded(loaded) 107 | local changed = isLoaded ~= loaded 108 | 109 | isLoaded = loaded 110 | 111 | return changed 112 | end 113 | 114 | local function updatePaused(isMenuActive) 115 | isPaused = not isLoaded or isMenuActive 116 | end 117 | 118 | local function updateBlurred(isBlurActive) 119 | isBlurred = isBlurActive 120 | end 121 | 122 | local function updateDead(isPlayerDead) 123 | isDead = isPlayerDead 124 | end 125 | 126 | local function isPreGame() 127 | return GetSingleton('inkMenuScenario'):GetSystemRequestsHandler():IsPreGame() 128 | end 129 | 130 | local function refreshCurrentState() 131 | local player = Game.GetPlayer() 132 | local blackboardDefs = Game.GetAllBlackboardDefs() 133 | local blackboardUI = Game.GetBlackboardSystem():Get(blackboardDefs.UI_System) 134 | local blackboardPM = Game.GetBlackboardSystem():Get(blackboardDefs.PhotoMode) 135 | 136 | local menuActive = blackboardUI:GetBool(blackboardDefs.UI_System.IsInMenu) 137 | local blurActive = blackboardUI:GetBool(blackboardDefs.UI_System.CircularBlurEnabled) 138 | local photoModeActive = blackboardPM:GetBool(blackboardDefs.PhotoMode.IsActive) 139 | local tutorialActive = Game.GetTimeSystem():IsTimeDilationActive('UI_TutorialPopup') 140 | 141 | if not isLoaded then 142 | updateLoaded(player:IsAttached() and not isPreGame()) 143 | end 144 | 145 | updatePaused(menuActive or photoModeActive or tutorialActive) 146 | updateBlurred(blurActive) 147 | updateDead(player:IsDeadNoStatPool()) 148 | end 149 | 150 | local function determineEvents(currentState) 151 | local events = { GameSession.Event.Update } 152 | local firing = {} 153 | 154 | for _, stateProp in ipairs(stateProps) do 155 | local currentValue = currentState[stateProp.current] 156 | local previousValue = previousState[stateProp.current] 157 | 158 | if stateProp.event and (not stateProp.parent or currentState[stateProp.parent]) then 159 | local reqSatisfied = true 160 | 161 | if stateProp.event.reqs then 162 | for reqProp, reqValue in pairs(stateProp.event.reqs) do 163 | if tostring(currentState[reqProp]) ~= tostring(reqValue) then 164 | reqSatisfied = false 165 | break 166 | end 167 | end 168 | end 169 | 170 | if reqSatisfied then 171 | if stateProp.event.change and previousValue ~= nil then 172 | if tostring(currentValue) ~= tostring(previousValue) then 173 | if not firing[stateProp.event.change] then 174 | table.insert(events, stateProp.event.change) 175 | firing[stateProp.event.change] = true 176 | end 177 | end 178 | end 179 | 180 | if stateProp.event.on and currentValue and not previousValue then 181 | if not firing[stateProp.event.on] then 182 | table.insert(events, stateProp.event.on) 183 | firing[stateProp.event.on] = true 184 | end 185 | elseif stateProp.event.off and not currentValue and previousValue then 186 | if not firing[stateProp.event.off] then 187 | table.insert(events, 1, stateProp.event.off) 188 | firing[stateProp.event.off] = true 189 | end 190 | end 191 | end 192 | end 193 | end 194 | 195 | return events 196 | end 197 | 198 | local function notifyObservers() 199 | local currentState = GameSession.GetState() 200 | local stateChanged = false 201 | 202 | for _, stateProp in ipairs(stateProps) do 203 | local currentValue = currentState[stateProp.current] 204 | local previousValue = previousState[stateProp.current] 205 | 206 | if tostring(currentValue) ~= tostring(previousValue) then 207 | stateChanged = true 208 | break 209 | end 210 | end 211 | 212 | if stateChanged then 213 | local events = determineEvents(currentState) 214 | 215 | for _, event in ipairs(events) do 216 | if listeners[event] then 217 | if event ~= GameSession.Event.Update then 218 | currentState.event = event 219 | end 220 | 221 | for _, callback in ipairs(listeners[event]) do 222 | callback(currentState) 223 | end 224 | 225 | currentState.event = nil 226 | end 227 | end 228 | 229 | previousState = currentState 230 | end 231 | end 232 | 233 | local function pushCurrentState() 234 | previousState = GameSession.GetState() 235 | end 236 | 237 | -- Session Key -- 238 | 239 | local function generateSessionKey() 240 | return os.time() 241 | end 242 | 243 | local function getSessionKey() 244 | return sessionKeyValue 245 | end 246 | 247 | local function setSessionKey(sessionKey) 248 | sessionKeyValue = sessionKey 249 | end 250 | 251 | local function isEmptySessionKey(sessionKey) 252 | if not sessionKey then 253 | sessionKey = getSessionKey() 254 | end 255 | 256 | return not sessionKey or sessionKey == 0 257 | end 258 | 259 | local function readSessionKey() 260 | return Game.GetQuestsSystem():GetFactStr(sessionKeyFactName) 261 | end 262 | 263 | local function writeSessionKey(sessionKey) 264 | Game.GetQuestsSystem():SetFactStr(sessionKeyFactName, sessionKey) 265 | end 266 | 267 | local function initSessionKey() 268 | local sessionKey = readSessionKey() 269 | 270 | if isEmptySessionKey(sessionKey) then 271 | sessionKey = generateSessionKey() 272 | writeSessionKey(sessionKey) 273 | end 274 | 275 | setSessionKey(sessionKey) 276 | end 277 | 278 | local function renewSessionKey() 279 | local sessionKey = getSessionKey() 280 | local savedKey = readSessionKey() 281 | local nextKey = generateSessionKey() 282 | 283 | if sessionKey == savedKey or savedKey < nextKey - 1 then 284 | sessionKey = generateSessionKey() 285 | writeSessionKey(sessionKey) 286 | else 287 | sessionKey = savedKey 288 | end 289 | 290 | setSessionKey(sessionKey) 291 | end 292 | 293 | local function setSessionKeyName(sessionKeyName) 294 | sessionKeyFactName = sessionKeyName 295 | end 296 | 297 | -- Session Data -- 298 | 299 | local function exportSessionData(t, max, depth, result) 300 | if type(t) ~= 'table' then 301 | return '{}' 302 | end 303 | 304 | max = max or 63 305 | depth = depth or 0 306 | 307 | local indent = string.rep('\t', depth) 308 | local output = result or {} 309 | 310 | table.insert(output, '{\n') 311 | 312 | for k, v in pairs(t) do 313 | local ktype = type(k) 314 | local vtype = type(v) 315 | 316 | local kstr = '' 317 | if ktype == 'string' then 318 | kstr = string.format('[%q] = ', k) 319 | else 320 | kstr = string.format('[%s] = ', tostring(k)) 321 | end 322 | 323 | local vstr = '' 324 | if vtype == 'string' then 325 | vstr = string.format('%q', v) 326 | elseif vtype == 'table' then 327 | if depth < max then 328 | table.insert(output, string.format('\t%s%s', indent, kstr)) 329 | exportSessionData(v, max, depth + 1, output) 330 | table.insert(output, ',\n') 331 | end 332 | elseif vtype == 'userdata' then 333 | vstr = tostring(v) 334 | if vstr:find('^userdata:') or vstr:find('^sol%.') then 335 | if not sessionDataRelaxed then 336 | --vtype = vstr:match('^sol%.(.+):') 337 | if ktype == 'string' then 338 | raiseError(('Cannot store userdata in the %q field.'):format(k)) 339 | --raiseError(('Cannot store userdata of type %q in the %q field.'):format(vtype, k)) 340 | else 341 | raiseError(('Cannot store userdata in the list.')) 342 | --raiseError(('Cannot store userdata of type %q.'):format(vtype)) 343 | end 344 | else 345 | vstr = '' 346 | end 347 | end 348 | elseif vtype == 'function' or vtype == 'thread' then 349 | if not sessionDataRelaxed then 350 | if ktype == 'string' then 351 | raiseError(('Cannot store %s in the %q field.'):format(vtype, k)) 352 | else 353 | raiseError(('Cannot store %s.'):format(vtype)) 354 | end 355 | end 356 | else 357 | vstr = tostring(v) 358 | end 359 | 360 | if vstr ~= '' then 361 | table.insert(output, string.format('\t%s%s%s,\n', indent, kstr, vstr)) 362 | end 363 | end 364 | 365 | if not result and #output == 1 then 366 | return '{}' 367 | end 368 | 369 | table.insert(output, indent .. '}') 370 | 371 | if not result then 372 | return table.concat(output) 373 | end 374 | end 375 | 376 | local function importSessionData(s) 377 | local chunk = loadstring('return ' .. s, '') 378 | 379 | return chunk and chunk() or {} 380 | end 381 | 382 | -- Session File IO -- 383 | 384 | local function irpairs(tbl) 385 | local function iter(t, i) 386 | i = i - 1 387 | if i ~= 0 then 388 | return i, t[i] 389 | end 390 | end 391 | 392 | return iter, tbl, #tbl + 1 393 | end 394 | 395 | local function findSessionTimestampByKey(targetKey, isTemporary) 396 | if sessionDataDir and not isEmptySessionKey(targetKey) then 397 | local pattern = '^' .. (isTemporary and '!' or '') .. '(%d+)%.lua$' 398 | 399 | for _, sessionFile in irpairs(dir(sessionDataDir)) do 400 | if sessionFile.name:find(pattern) then 401 | local sessionReader = io.open(sessionDataDir .. '/' .. sessionFile.name, 'r') 402 | local sessionHeader = sessionReader:read('l') 403 | sessionReader:close() 404 | 405 | local sessionKeyStr = sessionHeader:match('^-- (%d+)$') 406 | if sessionKeyStr then 407 | local sessionKey = tonumber(sessionKeyStr) 408 | if sessionKey == targetKey then 409 | return tonumber((sessionFile.name:match(pattern))) 410 | end 411 | end 412 | end 413 | end 414 | end 415 | 416 | return nil 417 | end 418 | 419 | local function writeSessionFile(sessionTimestamp, sessionKey, isTemporary, sessionData) 420 | if not sessionDataDir then 421 | return 422 | end 423 | 424 | local sessionPath = sessionDataDir .. '/' .. (isTemporary and '!' or '') .. sessionTimestamp .. '.lua' 425 | local sessionFile = io.open(sessionPath, 'w') 426 | 427 | if not sessionFile then 428 | raiseError(('Cannot write session file %q.'):format(sessionPath)) 429 | end 430 | 431 | sessionFile:write('-- ') 432 | sessionFile:write(sessionKey) 433 | sessionFile:write('\n') 434 | sessionFile:write('return ') 435 | sessionFile:write(exportSessionData(sessionData)) 436 | sessionFile:close() 437 | end 438 | 439 | local function readSessionFile(sessionTimestamp, sessionKey, isTemporary) 440 | if not sessionDataDir then 441 | return nil 442 | end 443 | 444 | if not sessionTimestamp then 445 | sessionTimestamp = findSessionTimestampByKey(sessionKey, isTemporary) 446 | 447 | if not sessionTimestamp then 448 | return nil 449 | end 450 | end 451 | 452 | local sessionPath = sessionDataDir .. '/' .. (isTemporary and '!' or '') .. sessionTimestamp .. '.lua' 453 | local sessionChunk = loadfile(sessionPath) 454 | 455 | if type(sessionChunk) ~= 'function' then 456 | sessionPath = sessionDataDir .. '/' .. (sessionTimestamp + 1) .. '.lua' 457 | sessionChunk = loadfile(sessionPath) 458 | 459 | if type(sessionChunk) ~= 'function' then 460 | return nil 461 | end 462 | end 463 | 464 | return sessionChunk() 465 | end 466 | 467 | local function writeSessionFileFor(sessionMeta, sessionData) 468 | writeSessionFile(sessionMeta.timestamp, sessionMeta.sessionKey, sessionMeta.isTemporary, sessionData) 469 | end 470 | 471 | local function readSessionFileFor(sessionMeta) 472 | return readSessionFile(sessionMeta.timestamp, sessionMeta.sessionKey, sessionMeta.isTemporary) 473 | end 474 | 475 | local function removeSessionFile(sessionTimestamp, isTemporary) 476 | if not sessionDataDir then 477 | return 478 | end 479 | 480 | local sessionPath = sessionDataDir .. '/' .. (isTemporary and '!' or '') .. sessionTimestamp .. '.lua' 481 | 482 | os.remove(sessionPath) 483 | end 484 | 485 | local function cleanUpSessionFiles(sessionTimestamps) 486 | if not sessionDataDir then 487 | return 488 | end 489 | 490 | local validNames = {} 491 | 492 | for _, sessionTimestamp in ipairs(sessionTimestamps) do 493 | validNames[tostring(sessionTimestamp)] = true 494 | validNames[tostring(sessionTimestamp + 1)] = true 495 | end 496 | 497 | for _, sessionFile in pairs(dir(sessionDataDir)) do 498 | local sessionTimestamp = sessionFile.name:match('^!?(%d+)%.lua$') 499 | 500 | if sessionTimestamp and not validNames[sessionTimestamp] then 501 | os.remove(sessionDataDir .. '/' .. sessionFile.name) 502 | end 503 | end 504 | end 505 | 506 | -- Session Meta -- 507 | 508 | local function getSessionMetaForSaving(isTemporary) 509 | return { 510 | sessionKey = getSessionKey(), 511 | timestamp = os.time(), 512 | isTemporary = isTemporary, 513 | } 514 | end 515 | 516 | local function getSessionMetaForLoading(isTemporary) 517 | return { 518 | sessionKey = getSessionKey(), 519 | timestamp = findSessionTimestampByKey(getSessionKey(), isTemporary) or 0, 520 | isTemporary = isTemporary, 521 | } 522 | end 523 | 524 | local function extractSessionMetaForLoading(saveInfo) 525 | return { 526 | sessionKey = 0, -- Cannot be retrieved from save metadata 527 | timestamp = tonumber(saveInfo.timestamp), 528 | isTemporary = false, 529 | } 530 | end 531 | 532 | -- Initialization -- 533 | 534 | local function initialize(event) 535 | if not initialized.data then 536 | for _, stateProp in ipairs(stateProps) do 537 | if stateProp.event then 538 | local eventScope = stateProp.event.scope or stateProp.event.change 539 | 540 | if eventScope then 541 | for _, eventKey in ipairs({ 'change', 'on', 'off' }) do 542 | local eventName = stateProp.event[eventKey] 543 | 544 | if eventName then 545 | if not eventScopes[eventName] then 546 | eventScopes[eventName] = {} 547 | end 548 | 549 | eventScopes[eventName][eventScope] = true 550 | end 551 | end 552 | 553 | if eventScope ~= GameSession.Scope.Persistence then 554 | eventScopes[GameSession.Event.Update][eventScope] = true 555 | end 556 | end 557 | end 558 | end 559 | 560 | initialized.data = true 561 | end 562 | 563 | local required = eventScopes[event] or eventScopes[GameSession.Event.Update] 564 | 565 | -- Session State 566 | 567 | if required[GameSession.Scope.Session] and not initialized[GameSession.Scope.Session] then 568 | Observe('QuestTrackerGameController', 'OnInitialize', function() 569 | --spdlog.error(('QuestTrackerGameController::OnInitialize()')) 570 | 571 | if updateLoaded(true) then 572 | updatePaused(false) 573 | updateBlurred(false) 574 | updateDead(false) 575 | notifyObservers() 576 | end 577 | end) 578 | 579 | Observe('QuestTrackerGameController', 'OnUninitialize', function() 580 | --spdlog.error(('QuestTrackerGameController::OnUninitialize()')) 581 | 582 | if Game.GetPlayer() == nil then 583 | if updateLoaded(false) then 584 | updatePaused(true) 585 | updateBlurred(false) 586 | updateDead(false) 587 | notifyObservers() 588 | end 589 | end 590 | end) 591 | 592 | initialized[GameSession.Scope.Session] = true 593 | end 594 | 595 | -- Pause State 596 | 597 | if required[GameSession.Scope.Pause] and not initialized[GameSession.Scope.Pause] then 598 | local fastTravelActive, fastTravelStart 599 | 600 | Observe('gameuiPopupsManager', 'OnMenuUpdate', function(_, isInMenu) 601 | --spdlog.error(('gameuiPopupsManager::OnMenuUpdate(%s)'):format(tostring(isInMenu))) 602 | 603 | if not fastTravelActive then 604 | updatePaused(isInMenu) 605 | notifyObservers() 606 | end 607 | end) 608 | 609 | Observe('gameuiPhotoModeMenuController', 'OnShow', function() 610 | --spdlog.error(('PhotoModeMenuController::OnShow()')) 611 | 612 | updatePaused(true) 613 | notifyObservers() 614 | end) 615 | 616 | Observe('gameuiPhotoModeMenuController', 'OnHide', function() 617 | --spdlog.error(('PhotoModeMenuController::OnHide()')) 618 | 619 | updatePaused(false) 620 | notifyObservers() 621 | end) 622 | 623 | Observe('gameuiTutorialPopupGameController', 'PauseGame', function(_, tutorialActive) 624 | --spdlog.error(('gameuiTutorialPopupGameController::PauseGame(%s)'):format(tostring(tutorialActive))) 625 | 626 | updatePaused(tutorialActive) 627 | notifyObservers() 628 | end) 629 | 630 | Observe('FastTravelSystem', 'OnUpdateFastTravelPointRecordRequest', function(_, request) 631 | --spdlog.error(('FastTravelSystem::OnUpdateFastTravelPointRecordRequest()')) 632 | 633 | fastTravelStart = request.pointRecord 634 | end) 635 | 636 | Observe('FastTravelSystem', 'OnPerformFastTravelRequest', function(self, request) 637 | --spdlog.error(('FastTravelSystem::OnPerformFastTravelRequest()')) 638 | 639 | if self.isFastTravelEnabledOnMap then 640 | local fastTravelDestination = request.pointData and request.pointData.pointRecord or nil 641 | 642 | if tostring(fastTravelStart) ~= tostring(fastTravelDestination) then 643 | fastTravelActive = true 644 | else 645 | fastTravelStart = nil 646 | end 647 | end 648 | end) 649 | 650 | Observe('FastTravelSystem', 'OnLoadingScreenFinished', function(_, finished) 651 | --spdlog.error(('FastTravelSystem::OnLoadingScreenFinished(%s)'):format(tostring(finished))) 652 | 653 | if finished then 654 | fastTravelActive = false 655 | fastTravelStart = nil 656 | updatePaused(false) 657 | notifyObservers() 658 | end 659 | end) 660 | 661 | initialized[GameSession.Scope.Pause] = true 662 | end 663 | 664 | -- Blur State 665 | 666 | if required[GameSession.Scope.Blur] and not initialized[GameSession.Scope.Blur] then 667 | local popupControllers = { 668 | ['PhoneDialerGameController'] = { 669 | ['Show'] = true, 670 | ['Hide'] = false, 671 | }, 672 | ['RadialWheelController'] = { 673 | ['RefreshSlots'] = { initialized = true }, 674 | ['Shutdown'] = false, 675 | }, 676 | ['VehicleRadioPopupGameController'] = { 677 | ['OnInitialize'] = true, 678 | ['OnClose'] = false, 679 | }, 680 | ['VehiclesManagerPopupGameController'] = { 681 | ['OnInitialize'] = true, 682 | ['OnClose'] = false, 683 | }, 684 | } 685 | 686 | for popupController, popupEvents in pairs(popupControllers) do 687 | for popupEvent, popupState in pairs(popupEvents) do 688 | Observe(popupController, popupEvent, function(self) 689 | --spdlog.error(('%s::%s()'):format(popupController, popupEvent)) 690 | 691 | if isLoaded then 692 | if type(popupState) == 'table' then 693 | local popupActive = true 694 | for prop, value in pairs(popupState) do 695 | if self[prop] ~= value then 696 | popupActive = false 697 | break 698 | end 699 | end 700 | updateBlurred(popupActive) 701 | else 702 | updateBlurred(popupState) 703 | end 704 | 705 | notifyObservers() 706 | end 707 | end) 708 | end 709 | end 710 | 711 | Observe('PhoneMessagePopupGameController', 'SetTimeDilatation', function(_, popupActive) 712 | --spdlog.error(('PhoneMessagePopupGameController::SetTimeDilatation()')) 713 | 714 | updateBlurred(popupActive) 715 | notifyObservers() 716 | end) 717 | 718 | initialized[GameSession.Scope.Blur] = true 719 | end 720 | 721 | -- Death State 722 | 723 | if required[GameSession.Scope.Death] and not initialized[GameSession.Scope.Death] then 724 | Observe('PlayerPuppet', 'OnDeath', function() 725 | --spdlog.error(('PlayerPuppet::OnDeath()')) 726 | 727 | updateDead(true) 728 | notifyObservers() 729 | end) 730 | 731 | initialized[GameSession.Scope.Death] = true 732 | end 733 | 734 | -- Saving and Loading 735 | 736 | if required[GameSession.Scope.Saves] and not initialized[GameSession.Scope.Saves] then 737 | local sessionLoadList = {} 738 | local sessionLoadRequest = {} 739 | 740 | if not isPreGame() then 741 | initSessionKey() 742 | end 743 | 744 | ---@param self PlayerPuppet 745 | Observe('PlayerPuppet', 'OnTakeControl', function(self) 746 | --spdlog.error(('PlayerPuppet::OnTakeControl()')) 747 | 748 | if self:GetEntityID().hash ~= 1ULL then 749 | return 750 | end 751 | 752 | if not isPreGame() then 753 | -- Expand load request with session key from facts 754 | sessionLoadRequest.sessionKey = readSessionKey() 755 | 756 | -- Try to resolve timestamp from session key 757 | if not sessionLoadRequest.timestamp then 758 | sessionLoadRequest.timestamp = findSessionTimestampByKey( 759 | sessionLoadRequest.sessionKey, 760 | sessionLoadRequest.isTemporary 761 | ) 762 | end 763 | 764 | -- Dispatch load event 765 | dispatchEvent(GameSession.Event.Load, sessionLoadRequest) 766 | end 767 | 768 | -- Reset session load request 769 | sessionLoadRequest = {} 770 | end) 771 | 772 | ---@param self PlayerPuppet 773 | Observe('PlayerPuppet', 'OnGameAttached', function(self) 774 | --spdlog.error(('PlayerPuppet::OnGameAttached()')) 775 | 776 | if self:IsReplacer() then 777 | return 778 | end 779 | 780 | if not isPreGame() then 781 | -- Store new session key in facts 782 | renewSessionKey() 783 | end 784 | end) 785 | 786 | Observe('LoadListItem', 'SetMetadata', function(_, saveInfo) 787 | if saveInfo == nil then 788 | saveInfo = _ 789 | end 790 | 791 | --spdlog.error(('LoadListItem::SetMetadata()')) 792 | 793 | -- Fill the session list from saves metadata 794 | sessionLoadList[saveInfo.saveIndex] = extractSessionMetaForLoading(saveInfo) 795 | end) 796 | 797 | Observe('LoadGameMenuGameController', 'LoadSaveInGame', function(_, saveIndex) 798 | --spdlog.error(('LoadGameMenuGameController::LoadSaveInGame(%d)'):format(saveIndex)) 799 | 800 | if #sessionLoadList == 0 then 801 | return 802 | end 803 | 804 | -- Make a load request from selected save 805 | sessionLoadRequest = sessionLoadList[saveIndex] 806 | 807 | -- Collect timestamps for existing saves 808 | local existingTimestamps = {} 809 | for _, sessionMeta in pairs(sessionLoadList) do 810 | table.insert(existingTimestamps, sessionMeta.timestamp) 811 | end 812 | 813 | -- Dispatch clean event 814 | dispatchEvent(GameSession.Event.Clean, { timestamps = existingTimestamps }) 815 | end) 816 | 817 | Observe('gameuiInGameMenuGameController', 'OnSavingComplete', function(_, success) 818 | if type(success) ~= 'boolean' then 819 | success = _ 820 | end 821 | 822 | --spdlog.error(('gameuiInGameMenuGameController::OnSavingComplete(%s)'):format(tostring(success))) 823 | 824 | if success then 825 | -- Dispatch 826 | dispatchEvent(GameSession.Event.Save, getSessionMetaForSaving()) 827 | 828 | -- Store new session key in facts 829 | renewSessionKey() 830 | end 831 | end) 832 | 833 | initialized[GameSession.Scope.Saves] = true 834 | end 835 | 836 | -- Persistence 837 | 838 | if required[GameSession.Scope.Persistence] and not initialized[GameSession.Scope.Persistence] then 839 | addEventListener(GameSession.Event.Save, function(sessionMeta) 840 | local sessionData = sessionDataRef or {} 841 | 842 | dispatchEvent(GameSession.Event.SaveData, sessionData) 843 | 844 | writeSessionFileFor(sessionMeta, sessionData) 845 | end) 846 | 847 | addEventListener(GameSession.Event.Load, function(sessionMeta) 848 | local sessionData = readSessionFileFor(sessionMeta) or {} 849 | 850 | if sessionDataTmpl then 851 | local defaultData = importSessionData(sessionDataTmpl) 852 | for prop, value in pairs(defaultData) do 853 | if sessionData[prop] == nil then 854 | sessionData[prop] = value 855 | end 856 | end 857 | end 858 | 859 | dispatchEvent(GameSession.Event.LoadData, sessionData) 860 | 861 | if sessionDataRef then 862 | for prop, _ in pairs(sessionDataRef) do 863 | sessionDataRef[prop] = nil 864 | end 865 | 866 | for prop, value in pairs(sessionData) do 867 | sessionDataRef[prop] = value 868 | end 869 | end 870 | 871 | if sessionMeta.isTemporary then 872 | removeSessionFile(sessionMeta.timestamp, true) 873 | end 874 | end) 875 | 876 | addEventListener(GameSession.Event.Clean, function(sessionMeta) 877 | cleanUpSessionFiles(sessionMeta.timestamps) 878 | end) 879 | 880 | initialized[GameSession.Scope.Persistence] = true 881 | end 882 | 883 | -- Initial state 884 | 885 | if not initialized.state then 886 | refreshCurrentState() 887 | pushCurrentState() 888 | 889 | initialized.state = true 890 | end 891 | end 892 | 893 | -- Public Interface -- 894 | 895 | function GameSession.Observe(event, callback) 896 | if type(event) == 'string' then 897 | initialize(event) 898 | elseif type(event) == 'function' then 899 | callback, event = event, GameSession.Event.Update 900 | initialize(event) 901 | else 902 | if not event then 903 | initialize(GameSession.Event.Update) 904 | elseif type(event) == 'table' then 905 | for _, evt in ipairs(event) do 906 | GameSession.Observe(evt, callback) 907 | end 908 | end 909 | return 910 | end 911 | 912 | if type(callback) == 'function' then 913 | addEventListener(event, callback) 914 | end 915 | end 916 | 917 | function GameSession.Listen(event, callback) 918 | if type(event) == 'function' then 919 | initialize(GameSession.Event.Update) 920 | callback = event 921 | for _, evt in pairs(GameSession.Event) do 922 | if evt ~= GameSession.Event.Update and not eventScopes[evt][GameSession.Scope.Persistence] then 923 | GameSession.Observe(evt, callback) 924 | end 925 | end 926 | else 927 | GameSession.Observe(event, callback) 928 | end 929 | end 930 | 931 | GameSession.On = GameSession.Listen 932 | 933 | setmetatable(GameSession, { 934 | __index = function(_, key) 935 | local event = string.match(key, '^On(%w+)$') 936 | 937 | if event and GameSession.Event[event] then 938 | rawset(GameSession, key, function(callback) 939 | GameSession.Observe(event, callback) 940 | end) 941 | 942 | return rawget(GameSession, key) 943 | end 944 | end 945 | }) 946 | 947 | function GameSession.IsLoaded() 948 | return isLoaded 949 | end 950 | 951 | function GameSession.IsPaused() 952 | return isPaused 953 | end 954 | 955 | function GameSession.IsBlurred() 956 | return isBlurred 957 | end 958 | 959 | function GameSession.IsDead() 960 | return isDead 961 | end 962 | 963 | function GameSession.GetKey() 964 | return getSessionKey() 965 | end 966 | 967 | function GameSession.GetState() 968 | local currentState = {} 969 | 970 | currentState.isLoaded = GameSession.IsLoaded() 971 | currentState.isPaused = GameSession.IsPaused() 972 | currentState.isBlurred = GameSession.IsBlurred() 973 | currentState.isDead = GameSession.IsDead() 974 | 975 | for _, stateProp in ipairs(stateProps) do 976 | if stateProp.previous then 977 | currentState[stateProp.previous] = previousState[stateProp.current] 978 | end 979 | end 980 | 981 | return currentState 982 | end 983 | 984 | local function exportValue(value) 985 | if type(value) == 'userdata' then 986 | value = string.format('%q', value.value) 987 | elseif type(value) == 'string' then 988 | value = string.format('%q', value) 989 | elseif type(value) == 'table' then 990 | value = '{ ' .. table.concat(value, ', ') .. ' }' 991 | else 992 | value = tostring(value) 993 | end 994 | 995 | return value 996 | end 997 | 998 | function GameSession.ExportState(state) 999 | local export = {} 1000 | 1001 | if state.event then 1002 | table.insert(export, 'event = ' .. string.format('%q', state.event)) 1003 | end 1004 | 1005 | for _, stateProp in ipairs(stateProps) do 1006 | local value = state[stateProp.current] 1007 | 1008 | if value and (not stateProp.parent or state[stateProp.parent]) then 1009 | table.insert(export, stateProp.current .. ' = ' .. exportValue(value)) 1010 | end 1011 | end 1012 | 1013 | for _, stateProp in ipairs(stateProps) do 1014 | if stateProp.previous then 1015 | local currentValue = state[stateProp.current] 1016 | local previousValue = state[stateProp.previous] 1017 | 1018 | if previousValue and previousValue ~= currentValue then 1019 | table.insert(export, stateProp.previous .. ' = ' .. exportValue(previousValue)) 1020 | end 1021 | end 1022 | end 1023 | 1024 | return '{ ' .. table.concat(export, ', ') .. ' }' 1025 | end 1026 | 1027 | function GameSession.PrintState(state) 1028 | print('[GameSession] ' .. GameSession.ExportState(state)) 1029 | end 1030 | 1031 | function GameSession.IdentifyAs(sessionName) 1032 | setSessionKeyName(sessionName) 1033 | end 1034 | 1035 | function GameSession.StoreInDir(sessionDir) 1036 | sessionDataDir = sessionDir 1037 | 1038 | initialize(GameSession.Event.SaveData) 1039 | end 1040 | 1041 | function GameSession.Persist(sessionData, relaxedMode) 1042 | if type(sessionData) ~= 'table' then 1043 | raiseError(('Session data must be a table, received %s.'):format(type(sessionData))) 1044 | end 1045 | 1046 | sessionDataRef = sessionData 1047 | sessionDataRelaxed = relaxedMode and true or false 1048 | sessionDataTmpl = exportSessionData(sessionData) 1049 | 1050 | initialize(GameSession.Event.SaveData) 1051 | end 1052 | 1053 | function GameSession.TrySave() 1054 | if Game.GetSettingsSystem() then 1055 | dispatchEvent(GameSession.Event.Save, getSessionMetaForSaving(true)) 1056 | end 1057 | end 1058 | 1059 | function GameSession.TryLoad() 1060 | if not isPreGame() and not isEmptySessionKey() then 1061 | dispatchEvent(GameSession.Event.Load, getSessionMetaForLoading(true)) 1062 | end 1063 | end 1064 | 1065 | return GameSession -------------------------------------------------------------------------------- /GameSettings.lua: -------------------------------------------------------------------------------- 1 | --[[ 2 | GameSettings.lua 3 | Game Settings Manager 4 | 5 | Copyright (c) 2021 psiberx 6 | ]] 7 | 8 | local GameSettings = { version = '1.0.4' } 9 | 10 | local module = {} 11 | 12 | function module.parsePath(setting) 13 | return setting:match('^(/.+)/([A-Za-z0-9_]+)$') 14 | end 15 | 16 | function module.makePath(groupPath, varName) 17 | return groupPath .. '/' .. varName 18 | end 19 | 20 | function module.isBoolType(target) 21 | if type(target) == 'userdata' then 22 | target = target.value 23 | end 24 | 25 | return target == 'Bool' 26 | end 27 | 28 | function module.isNameType(target) 29 | if type(target) == 'userdata' then 30 | target = target.value 31 | end 32 | 33 | return target == 'Name' or target == 'NameList' 34 | end 35 | 36 | function module.isNumberType(target) 37 | if type(target) == 'userdata' then 38 | target = target.value 39 | end 40 | 41 | return target == 'Int' or target == 'Float' 42 | end 43 | 44 | function module.isIntType(target) 45 | if type(target) == 'userdata' then 46 | target = target.value 47 | end 48 | 49 | return target == 'Int' or target == 'IntList' 50 | end 51 | 52 | function module.isFloatType(target) 53 | if type(target) == 'userdata' then 54 | target = target.value 55 | end 56 | 57 | return target == 'Float' or target == 'FloatList' 58 | end 59 | 60 | function module.isListType(target) 61 | if type(target) == 'userdata' then 62 | target = target.value 63 | end 64 | 65 | return target == 'IntList' or target == 'FloatList' or target == 'StringList' or target == 'NameList' 66 | end 67 | 68 | function module.exportVar(var) 69 | local output = {} 70 | 71 | output.path = module.makePath(Game.NameToString(var:GetGroupPath()), Game.NameToString(var:GetName())) 72 | output.value = var:GetValue() 73 | output.type = var:GetType().value 74 | 75 | if module.isNameType(output.type) then 76 | output.value = Game.NameToString(output.value) 77 | end 78 | 79 | if module.isNumberType(output.type) then 80 | output.min = var:GetMinValue() 81 | output.max = var:GetMaxValue() 82 | output.step = var:GetStepValue() 83 | end 84 | 85 | if module.isListType(output.type) then 86 | output.index = var:GetIndex() + 1 87 | output.options = var:GetValues() 88 | 89 | if module.isNameType(output.type) then 90 | for i, option in ipairs(output.options) do 91 | output.options[i] = Game.NameToString(option) 92 | end 93 | end 94 | end 95 | 96 | return output 97 | end 98 | 99 | function module.exportVars(isPreGame, group, output) 100 | if type(group) ~= 'userdata' then 101 | group = Game.GetSettingsSystem():GetRootGroup() 102 | end 103 | 104 | if type(isPreGame) ~= 'bool' then 105 | isPreGame = GetSingleton('inkMenuScenario'):GetSystemRequestsHandler():IsPreGame() 106 | end 107 | 108 | if not output then 109 | output = {} 110 | end 111 | 112 | for _, var in ipairs(group:GetVars(isPreGame)) do 113 | table.insert(output, module.exportVar(var)) 114 | end 115 | 116 | for _, child in ipairs(group:GetGroups(isPreGame)) do 117 | module.exportVars(isPreGame, child, output) 118 | end 119 | 120 | table.sort(output, function(a, b) 121 | return a.path < b.path 122 | end) 123 | 124 | return output 125 | end 126 | 127 | function GameSettings.Has(setting) 128 | local path, name = module.parsePath(setting) 129 | 130 | return Game.GetSettingsSystem():HasVar(path, name) 131 | end 132 | 133 | function GameSettings.Var(setting) 134 | local path, name = module.parsePath(setting) 135 | 136 | local var = Game.GetSettingsSystem():GetVar(path, name) 137 | 138 | if not var then 139 | return nil 140 | end 141 | 142 | return module.exportVar(var) 143 | end 144 | 145 | function GameSettings.Get(setting) 146 | local path, name = module.parsePath(setting) 147 | 148 | local var = Game.GetSettingsSystem():GetVar(path, name) 149 | 150 | if not var then 151 | return nil 152 | end 153 | 154 | return var:GetValue() 155 | end 156 | 157 | function GameSettings.GetIndex(setting) 158 | local path, name = module.parsePath(setting) 159 | 160 | local var = Game.GetSettingsSystem():GetVar(path, name) 161 | 162 | if not var or not module.isListType(var:GetType()) then 163 | return nil 164 | end 165 | 166 | return var:GetIndex() + 1 167 | end 168 | 169 | function GameSettings.Set(setting, value) 170 | local path, name = module.parsePath(setting) 171 | local var = Game.GetSettingsSystem():GetVar(path, name) 172 | 173 | if not var then 174 | return 175 | end 176 | 177 | if module.isListType(var:GetType()) then 178 | local index = var:GetIndexFor(value) 179 | 180 | if index then 181 | var:SetIndex(index) 182 | end 183 | else 184 | var:SetValue(value) 185 | end 186 | end 187 | 188 | function GameSettings.SetIndex(setting, index) 189 | local path, name = module.parsePath(setting) 190 | 191 | local var = Game.GetSettingsSystem():GetVar(path, name) 192 | 193 | if not var or not module.isListType(var:GetType()) then 194 | return 195 | end 196 | 197 | var:SetIndex(index - 1) 198 | end 199 | 200 | function GameSettings.Toggle(setting) 201 | local path, name = module.parsePath(setting) 202 | 203 | local var = Game.GetSettingsSystem():GetVar(path, name) 204 | 205 | if not var or not module.isBoolType(var:GetType()) then 206 | return 207 | end 208 | 209 | var:Toggle() 210 | end 211 | 212 | function GameSettings.ToggleAll(settings) 213 | local state = not GameSettings.Get(settings[1]) 214 | 215 | for _, setting in ipairs(settings) do 216 | GameSettings.Set(setting, state) 217 | end 218 | end 219 | 220 | function GameSettings.ToggleGroup(path) 221 | local group = Game.GetSettingsSystem():GetGroup(path) 222 | local vars = group:GetVars(false) 223 | local state = nil 224 | 225 | for _, var in ipairs(vars) do 226 | if module.isBoolType(var:GetType()) then 227 | -- Invert the first bool option 228 | if state == nil then 229 | state = not var:GetValue() 230 | end 231 | 232 | var:SetValue(state) 233 | end 234 | end 235 | end 236 | 237 | -- set all booleans in the group to val 238 | function GameSettings.SetGroupBool(path, val) 239 | local group = Game.GetSettingsSystem():GetGroup(path) 240 | local vars = group:GetVars(false) 241 | 242 | for _, var in ipairs(vars) do 243 | if module.isBoolType(var:GetType()) then 244 | var:SetValue(val) 245 | end 246 | end 247 | end 248 | 249 | function GameSettings.Options(setting) 250 | local path, name = module.parsePath(setting) 251 | 252 | local var = Game.GetSettingsSystem():GetVar(path, name) 253 | 254 | if not var or not module.isListType(var:GetType()) then 255 | return nil 256 | end 257 | 258 | return var:GetValues(), var:GetIndex() + 1 259 | end 260 | 261 | function GameSettings.Reset(setting) 262 | local path, name = module.parsePath(setting) 263 | 264 | local var = Game.GetSettingsSystem():GetVar(path, name) 265 | 266 | if not var then 267 | return 268 | end 269 | 270 | var:RestoreDefault() 271 | end 272 | 273 | function GameSettings.NeedsConfirmation() 274 | return Game.GetSettingsSystem():NeedsConfirmation() 275 | end 276 | 277 | function GameSettings.NeedsReload() 278 | return Game.GetSettingsSystem():NeedsLoadLastCheckpoint() 279 | end 280 | 281 | function GameSettings.NeedsRestart() 282 | return Game.GetSettingsSystem():NeedsRestartToApply() 283 | end 284 | 285 | function GameSettings.Confirm() 286 | Game.GetSettingsSystem():ConfirmChanges() 287 | end 288 | 289 | function GameSettings.Reject() 290 | Game.GetSettingsSystem():RejectChanges() 291 | end 292 | 293 | function GameSettings.Save() 294 | GetSingleton('inkMenuScenario'):GetSystemRequestsHandler():RequestSaveUserSettings() 295 | end 296 | 297 | function GameSettings.ExportVars(isPreGame, group, output) 298 | return module.exportVars(isPreGame, group, output) 299 | end 300 | 301 | function GameSettings.Export(isPreGame) 302 | return module.exportVars(isPreGame) 303 | end 304 | 305 | function GameSettings.ExportTo(exportPath, isPreGame) 306 | local output = {} 307 | 308 | local vars = module.exportVars(isPreGame) 309 | 310 | for _, var in ipairs(vars) do 311 | local value = var.value 312 | local options 313 | 314 | if type(value) == 'string' then 315 | value = string.format('%q', value) 316 | end 317 | 318 | if var.options and #var.options > 1 then 319 | options = {} 320 | 321 | for i, option in ipairs(var.options) do 322 | --if type(option) == 'string' then 323 | -- option = string.format('%q', option) 324 | --end 325 | 326 | options[i] = option 327 | end 328 | 329 | options = ' -- ' .. table.concat(options, ' | ') 330 | elseif var.min then 331 | if module.isIntType(var.type) then 332 | options = (' -- %d to %d / %d'):format(var.min, var.max, var.step) 333 | else 334 | options = (' -- %.2f to %.2f / %.2f'):format(var.min, var.max, var.step) 335 | end 336 | end 337 | 338 | table.insert(output, (' ["%s"] = %s,%s'):format(var.path, value, options or '')) 339 | end 340 | 341 | table.insert(output, 1, '{') 342 | table.insert(output, '}') 343 | 344 | output = table.concat(output, '\n') 345 | 346 | if exportPath then 347 | if not exportPath:find('%.lua$') then 348 | exportPath = exportPath .. '.lua' 349 | end 350 | 351 | local exportFile = io.open(exportPath, 'w') 352 | 353 | if exportFile then 354 | exportFile:write('return ') 355 | exportFile:write(output) 356 | exportFile:close() 357 | end 358 | else 359 | return output 360 | end 361 | end 362 | 363 | function GameSettings.Import(settings) 364 | for setting, value in pairs(settings) do 365 | GameSettings.Set(setting, value) 366 | end 367 | end 368 | 369 | function GameSettings.ImportFrom(importPath) 370 | local importChunk = loadfile(importPath) 371 | 372 | if importChunk then 373 | GameSettings.Import(importChunk()) 374 | end 375 | end 376 | 377 | -- For importing the result of ExportVars directly 378 | function GameSettings.ImportVars(settings) 379 | for _, var in ipairs(settings) do 380 | GameSettings.Set(var.path, var.value) 381 | end 382 | end 383 | 384 | function GameSettings.DumpVars(isPreGame) 385 | return GameSettings.ExportTo(nil, isPreGame) 386 | end 387 | 388 | function GameSettings.PrintVars(isPreGame) 389 | print(GameSettings.DumpVars(isPreGame)) 390 | end 391 | 392 | return GameSettings -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Pavel Siberx 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Lua Kit for Cyber Engine Tweaks 2 | 3 | Set of independent modules and examples to help develop mods for Cyber Engine Tweaks. 4 | 5 | ## Modules 6 | 7 | ### `Cron.lua` 8 | 9 | Run one-off and repeating tasks. 10 | 11 | Right now only two type of tasks available: to run *after X secs* and to run *every X secs*. 12 | 13 | I plan to implement support of cron expressions and tie them to the time system of the game. 14 | 15 | ### `GameHUD.lua` 16 | 17 | Show in-game messages. 18 | 19 | ### `GameUI.lua` 20 | 21 | Track game UI state reactively. Doesn't use recurrent `onUpdate` checks. 22 | 23 | Current detections: 24 | 25 | - Menus 26 | * Main Menu (Load Game, Settings, Credits) 27 | * New Game (Difficulty, Life Path, Body Type, Customization, Attributes, Summary) 28 | * Pause Menu (Save Game, Load Game, Settings, Credits) 29 | * Death Menu (Load Game, Settings) 30 | * Hub (Backpack, Inventory, Cyberware, Character, Stats, Map, Crafting, Journal, Messages, Shards, Tarot, Database) 31 | * Vendor (Trade, RipperDoc, Drop Point) 32 | * Network Breach 33 | * Fast Travel 34 | * Stash 35 | - Tutorials 36 | - Loading Screen 37 | - Scenes (Cinematics, Limited Gameplay) 38 | - Vehicles (First Person, Third Person) 39 | - Scanning with Kiroshi Optics 40 | - Quickhacking with Cyberdeck 41 | - Devices (Computers, Terminals) 42 | - Popups (Phone, Call Vehicle, Radio) 43 | - Weapon Wheel 44 | - Fast Travel 45 | - Braindance (Playback, Editing) 46 | - Cyberspace 47 | - Johnny's Takeovers 48 | - Johnny's Memories 49 | - Photo Mode 50 | 51 | You can display own HUD elements and apply contextual logic depending on the current UI. 52 | 53 | ### `GameSession.lua` 54 | 55 | Track game session reactively and store data linked to a save file. 56 | 57 | Current detections: 58 | 59 | - Session Start (New Game, Load Game) 60 | - Session End (Load Game, Exit to Main Menu) 61 | - Saving and Loading (Manual Save, Quick Save, Auto Save) 62 | - Pause State (All Menus, Fast Travel, Photo Mode, Tutorials) 63 | - Blur State (Weapon Wheel, Phone, Call Vehicle, Radio) 64 | - Death State 65 | 66 | This module can be used to efficiently detect when a player 67 | is loading into the game or exiting the current game session. 68 | You can initialize mod state when the actual gameplay starts, 69 | and reset mod state and free resources when the game session ends. 70 | 71 | Data persistence feature particularly useful for gameplay mods 72 | for storing its internal state. 73 | 74 | ### `GameSettings.lua` 75 | 76 | Manage game settings. 77 | You can get and set current values, get option lists, 78 | and export all settings as a table or to a file. 79 | 80 | ## How to use 81 | 82 | ### Cron Tasks 83 | 84 | ```lua 85 | local Cron = require('Cron') 86 | 87 | registerForEvent('onInit', function() 88 | print(('[%s] Cron demo started'):format(os.date('%H:%M:%S'))) 89 | 90 | -- One-off timer 91 | Cron.After(5.0, function() 92 | print(('[%s] After 5.00 secs'):format(os.date('%H:%M:%S'))) 93 | end) 94 | 95 | -- Repeating self-halting timer with context 96 | Cron.Every(2.0, { tick = 1 }, function(timer) 97 | print(('[%s] Every %.2f secs #%d'):format(os.date('%H:%M:%S'), timer.interval, timer.tick)) 98 | 99 | if timer.tick < 5 then 100 | timer.tick = timer.tick + 1 101 | else 102 | timer:Halt() -- or Cron.Halt(timer) 103 | 104 | print(('[%s] Stopped after %.2f secs / %d ticks'):format(os.date('%H:%M:%S'), timer.interval * timer.tick, timer.tick)) 105 | end 106 | end) 107 | end) 108 | 109 | registerForEvent('onUpdate', function(delta) 110 | -- This is required for Cron to function 111 | Cron.Update(delta) 112 | end) 113 | ``` 114 | 115 | ### Track UI Events 116 | 117 | ```lua 118 | local GameUI = require('GameUI') 119 | 120 | registerForEvent('onInit', function() 121 | -- Listen for every UI event 122 | GameUI.Listen(function(state) 123 | GameUI.PrintState(state) 124 | end) 125 | end) 126 | ``` 127 | 128 | ### Track Fast Traveling 129 | 130 | ```lua 131 | local GameUI = require('GameUI') 132 | 133 | registerForEvent('onInit', function() 134 | GameUI.OnFastTravelStart(function() 135 | print('Fast Travel Started') 136 | end) 137 | 138 | GameUI.OnFastTravelFinish(function() 139 | print('Fast Travel Finished') 140 | end) 141 | end) 142 | ``` 143 | 144 | ### Track Session Events 145 | 146 | ```lua 147 | local GameSession = require('GameSession') 148 | 149 | registerForEvent('onInit', function() 150 | -- Listen for every session event 151 | GameSession.Listen(function(state) 152 | GameSession.PrintState(state) 153 | end) 154 | end) 155 | ``` 156 | 157 | ### Track Session Lifecycle 158 | 159 | ```lua 160 | local GameSession = require('GameSession') 161 | 162 | registerForEvent('onInit', function() 163 | GameSession.OnStart(function() 164 | -- Triggered once the load is complete and the player is in the game 165 | -- (after the loading screen for "Load Game" or "New Game") 166 | print('Game Session Started') 167 | end) 168 | 169 | GameSession.OnEnd(function() 170 | -- Triggered once the current game session has ended 171 | -- (when "Load Game" or "Exit to Main Menu" selected) 172 | print('Game Session Ended') 173 | end) 174 | end) 175 | ``` 176 | 177 | ### Persist Session Data 178 | 179 | ```lua 180 | local GameSession = require('GameSession') 181 | 182 | local userState = { 183 | consoleUses = 0 -- Initial state 184 | } 185 | 186 | registerForEvent('onInit', function() 187 | GameSession.StoreInDir('sessions') -- Set directory to store session data 188 | GameSession.Persist(userState) -- Link the data that should be watched and persisted 189 | GameSession.OnLoad(function() 190 | print('Console was opened', userState.consoleUses, 'time(s)') -- Show the number on load 191 | end) 192 | end) 193 | 194 | registerForEvent('onOverlayOpen', function() 195 | userState.consoleUses = userState.consoleUses + 1 -- Increase the number of console uses 196 | end) 197 | ``` 198 | 199 | ### Dump All Game Settings 200 | 201 | ```lua 202 | local GameSettings = require('GameSettings') 203 | 204 | registerHotkey('ExportSettings', 'Export all settings', function() 205 | GameSettings.ExportTo('settings.lua') 206 | end) 207 | ``` 208 | 209 | ### Switch FOV With Hotkey 210 | 211 | ```lua 212 | local GameSettings = require('GameSettings') 213 | 214 | registerHotkey('SwitchFOV', 'Switch FOV', function() 215 | local fov = GameSettings.Var('/graphics/basic/FieldOfView') 216 | 217 | fov.value = fov.value + fov.step 218 | 219 | if fov.value > fov.max then 220 | fov.value = fov.min 221 | end 222 | 223 | GameSettings.Set('/graphics/basic/FieldOfView', fov.value) 224 | 225 | if GameSettings.NeedsConfirmation() then 226 | GameSettings.Confirm() 227 | end 228 | 229 | print(('Current FOV: %.1f'):format(GameSettings.Get('/graphics/basic/FieldOfView'))) 230 | end) 231 | ``` 232 | 233 | ### Cycle Resolutions With Hotkey 234 | 235 | ```lua 236 | local GameSettings = require('GameSettings') 237 | 238 | registerHotkey('SwitchResolution', 'Switch resolution', function() 239 | -- You can get available options and current selection for lists 240 | local options, current = GameSettings.Options('/video/display/Resolution') 241 | local next = current + 1 242 | 243 | if next > #options then 244 | next = 1 245 | end 246 | 247 | GameSettings.Set('/video/display/Resolution', options[next]) 248 | 249 | if GameSettings.NeedsConfirmation() then 250 | GameSettings.Confirm() 251 | end 252 | 253 | print(('Switched resolution from %s to %s'):format(options[current], options[next])) 254 | end) 255 | ``` 256 | 257 | ### Toggle HUD With Hotkey 258 | 259 | Option 1 – Toggle all settings in the group: 260 | 261 | ```lua 262 | local GameSettings = require('GameSettings') 263 | 264 | registerHotkey('ToggleHUD', 'Toggle HUD', function() 265 | GameSettings.ToggleGroup('/interface/hud') 266 | end) 267 | ``` 268 | 269 | Option 2 – Toggle specific settings: 270 | 271 | ```lua 272 | local GameSettings = require('GameSettings') 273 | 274 | registerHotkey('ToggleHUD', 'Toggle HUD', function() 275 | GameSettings.ToggleAll({ 276 | '/interface/hud/action_buttons', 277 | '/interface/hud/activity_log', 278 | '/interface/hud/ammo_counter', 279 | '/interface/hud/healthbar', 280 | '/interface/hud/input_hints', 281 | '/interface/hud/johnny_hud', 282 | '/interface/hud/minimap', 283 | '/interface/hud/npc_healthbar', 284 | '/interface/hud/npc_names', 285 | '/interface/hud/object_markers', 286 | '/interface/hud/quest_tracker', 287 | '/interface/hud/stamina_oxygen', 288 | }) 289 | end) 290 | ``` 291 | 292 | ### Switch Blur With Hotkey 293 | 294 | ```lua 295 | registerHotkey('SwitchBlur', 'Switch blur', function() 296 | local options, current = GameSettings.Options('/graphics/basic/MotionBlur') 297 | local next = current + 1 298 | 299 | if next > #options then 300 | next = 1 301 | end 302 | 303 | GameSettings.Set('/graphics/basic/MotionBlur', options[next]) 304 | GameSettings.Save() -- Required for most graphics settings 305 | 306 | print(('Switched blur from %s to %s'):format(options[current], options[next])) 307 | end) 308 | ``` 309 | 310 | ## Examples 311 | 312 | - [Minimap HUD extension](https://github.com/psiberx/cp2077-cet-kit/blob/main/mods/GameUI-WhereAmI/init.lua) 313 | Uses `GameUI` to determine when to show or hide the widget. 314 | The widget is visible only on the default in-game HUD. 315 | ![WhereAmI](https://siberx.dev/cp2077-cet-demos/whereami-210223.jpg) 316 | - [Kill stats recorder](https://github.com/psiberx/cp2077-cet-kit/blob/main/mods/GameSession-KillStats/init.lua) 317 | Uses `GameSession` to store kill stats for each save file. 318 | Uses `GameHUD` to display on screen messages for kills. 319 | ![KillStats](https://siberx.dev/cp2077-cet-demos/killstats-210326.jpg) 320 | -------------------------------------------------------------------------------- /Ref.lua: -------------------------------------------------------------------------------- 1 | --[[ 2 | Ref.lua 3 | Strong & Weak References 4 | 5 | Copyright (c) 2021 psiberx 6 | ]] 7 | 8 | local Ref = { version = '1.0.0' } 9 | 10 | ---@type inkScriptWeakHashMap 11 | local weakMap 12 | 13 | ---@type inkScriptHashMap 14 | local strongMap 15 | 16 | ---@param o IScriptable 17 | ---@return boolean 18 | function Ref.IsDefined(o) 19 | return IsDefined(o) 20 | end 21 | 22 | ---@param o IScriptable 23 | ---@return boolean 24 | function Ref.IsExpired(o) 25 | return not IsDefined(o) 26 | end 27 | 28 | ---@param a IScriptable 29 | ---@param b IScriptable 30 | ---@return boolean 31 | function Ref.Equals(a, b) 32 | return Game['OperatorEqual;IScriptableIScriptable;Bool'](a, b) 33 | end 34 | 35 | ---@param a IScriptable 36 | ---@param b IScriptable 37 | ---@return boolean 38 | function Ref.NotEquals(a, b) 39 | return Game['OperatorNotEqual;IScriptableIScriptable;Bool'](a, b) 40 | end 41 | 42 | ---@param o IScriptable 43 | ---@return IScriptable 44 | function Ref.Weak(o) 45 | if not weakMap then 46 | weakMap = inkScriptWeakHashMap.new() 47 | weakMap:Insert(0, nil) 48 | end 49 | 50 | weakMap:Set(0, o) 51 | 52 | return weakMap:Get(0) 53 | end 54 | 55 | ---@param o IScriptable 56 | ---@return IScriptable 57 | function Ref.Strong(o) 58 | if not strongMap then 59 | strongMap = inkScriptHashMap.new() 60 | strongMap:Insert(0, nil) 61 | end 62 | 63 | strongMap:Set(0, o) 64 | 65 | local ref = strongMap:Get(0) 66 | 67 | strongMap:Set(0, nil) 68 | 69 | return ref 70 | end 71 | 72 | ---@param o IScriptable 73 | ---@return Int32 74 | function Ref.Hash(o) 75 | return CalcSeed(o) 76 | end 77 | 78 | return Ref -------------------------------------------------------------------------------- /mods/Cron/Cron.lua: -------------------------------------------------------------------------------- 1 | --[[ 2 | Cron.lua 3 | Timed Tasks Manager 4 | 5 | Copyright (c) 2021 psiberx 6 | ]] 7 | 8 | local Cron = { version = '1.0.3' } 9 | 10 | local timers = {} 11 | local counter = 0 12 | local prune = false 13 | 14 | ---@param timeout number 15 | ---@param recurring boolean 16 | ---@param callback function 17 | ---@param args 18 | ---@return any 19 | local function addTimer(timeout, recurring, callback, args) 20 | if type(timeout) ~= 'number' then 21 | return 22 | end 23 | 24 | if timeout < 0 then 25 | return 26 | end 27 | 28 | if type(recurring) ~= 'boolean' then 29 | return 30 | end 31 | 32 | if type(callback) ~= 'function' then 33 | if type(args) == 'function' then 34 | callback, args = args, callback 35 | else 36 | return 37 | end 38 | end 39 | 40 | if type(args) ~= 'table' then 41 | args = { arg = args } 42 | end 43 | 44 | counter = counter + 1 45 | 46 | local timer = { 47 | id = counter, 48 | callback = callback, 49 | recurring = recurring, 50 | timeout = timeout, 51 | active = true, 52 | halted = false, 53 | delay = timeout, 54 | args = args, 55 | } 56 | 57 | if args.id == nil then 58 | args.id = timer.id 59 | end 60 | 61 | if args.interval == nil then 62 | args.interval = timer.timeout 63 | end 64 | 65 | if args.Halt == nil then 66 | args.Halt = Cron.Halt 67 | end 68 | 69 | if args.Pause == nil then 70 | args.Pause = Cron.Pause 71 | end 72 | 73 | if args.Resume == nil then 74 | args.Resume = Cron.Resume 75 | end 76 | 77 | table.insert(timers, timer) 78 | 79 | return timer.id 80 | end 81 | 82 | ---@param timeout number 83 | ---@param callback function 84 | ---@param data 85 | ---@return any 86 | function Cron.After(timeout, callback, data) 87 | return addTimer(timeout, false, callback, data) 88 | end 89 | 90 | ---@param timeout number 91 | ---@param callback function 92 | ---@param data 93 | ---@return any 94 | function Cron.Every(timeout, callback, data) 95 | return addTimer(timeout, true, callback, data) 96 | end 97 | 98 | ---@param callback function 99 | ---@param data 100 | ---@return any 101 | function Cron.NextTick(callback, data) 102 | return addTimer(0, false, callback, data) 103 | end 104 | 105 | ---@param timerId any 106 | ---@return void 107 | function Cron.Halt(timerId) 108 | if type(timerId) == 'table' then 109 | timerId = timerId.id 110 | end 111 | 112 | for _, timer in ipairs(timers) do 113 | if timer.id == timerId then 114 | timer.active = false 115 | timer.halted = true 116 | prune = true 117 | break 118 | end 119 | end 120 | end 121 | 122 | ---@param timerId any 123 | ---@return void 124 | function Cron.Pause(timerId) 125 | if type(timerId) == 'table' then 126 | timerId = timerId.id 127 | end 128 | 129 | for _, timer in ipairs(timers) do 130 | if timer.id == timerId then 131 | if not timer.halted then 132 | timer.active = false 133 | end 134 | break 135 | end 136 | end 137 | end 138 | 139 | ---@param timerId any 140 | ---@return void 141 | function Cron.Resume(timerId) 142 | if type(timerId) == 'table' then 143 | timerId = timerId.id 144 | end 145 | 146 | for _, timer in ipairs(timers) do 147 | if timer.id == timerId then 148 | if not timer.halted then 149 | timer.active = true 150 | end 151 | break 152 | end 153 | end 154 | end 155 | 156 | ---@param delta number 157 | ---@return void 158 | function Cron.Update(delta) 159 | if #timers == 0 then 160 | return 161 | end 162 | 163 | for _, timer in ipairs(timers) do 164 | if timer.active then 165 | timer.delay = timer.delay - delta 166 | 167 | if timer.delay <= 0 then 168 | if timer.recurring then 169 | timer.delay = timer.delay + timer.timeout 170 | else 171 | timer.active = false 172 | timer.halted = true 173 | prune = true 174 | end 175 | 176 | timer.callback(timer.args) 177 | end 178 | end 179 | end 180 | 181 | if prune then 182 | prune = false 183 | for i = #timers, 1, -1 do 184 | if timers[i].halted then 185 | table.remove(timers, i) 186 | end 187 | end 188 | end 189 | end 190 | 191 | return Cron -------------------------------------------------------------------------------- /mods/Cron/init.lua: -------------------------------------------------------------------------------- 1 | local Cron = require('Cron') 2 | 3 | registerForEvent('onInit', function() 4 | print(('[%s] Cron demo started'):format(os.date('%H:%M:%S'))) 5 | 6 | -- One-off timer 7 | Cron.After(5.0, function() 8 | print(('[%s] After 5.00 secs'):format(os.date('%H:%M:%S'))) 9 | end) 10 | 11 | -- Repeating self-halting timer with context 12 | Cron.Every(2.0, { tick = 1 }, function(timer) 13 | print(('[%s] Every %.2f secs #%d'):format(os.date('%H:%M:%S'), timer.interval, timer.tick)) 14 | 15 | if timer.tick < 5 then 16 | timer.tick = timer.tick + 1 17 | else 18 | timer:Halt() -- or Cron.Halt(timer) 19 | 20 | print(('[%s] Stopped after %.2f secs / %d ticks'):format(os.date('%H:%M:%S'), timer.interval * timer.tick, timer.tick)) 21 | end 22 | end) 23 | end) 24 | 25 | registerForEvent('onUpdate', function(delta) 26 | -- This is required for Cron to function 27 | Cron.Update(delta) 28 | end) 29 | -------------------------------------------------------------------------------- /mods/GameSession-Events/GameSession.lua: -------------------------------------------------------------------------------- 1 | --[[ 2 | GameSession.lua 3 | Reactive Session Observer 4 | Persistent Session Manager 5 | 6 | Copyright (c) 2021 psiberx 7 | ]] 8 | 9 | local GameSession = { 10 | version = '1.4.5', 11 | framework = '1.19.0' 12 | } 13 | 14 | GameSession.Event = { 15 | Start = 'Start', 16 | End = 'End', 17 | Pause = 'Pause', 18 | Blur = 'Blur', 19 | Resume = 'Resume', 20 | Death = 'Death', 21 | Update = 'Update', 22 | Load = 'Load', 23 | Save = 'Save', 24 | Clean = 'Clean', 25 | LoadData = 'LoadData', 26 | SaveData = 'SaveData', 27 | } 28 | 29 | GameSession.Scope = { 30 | Session = 'Session', 31 | Pause = 'Pause', 32 | Blur = 'Blur', 33 | Death = 'Death', 34 | Saves = 'Saves', 35 | Persistence = 'Persistence', 36 | } 37 | 38 | local initialized = {} 39 | local listeners = {} 40 | 41 | local eventScopes = { 42 | [GameSession.Event.Update] = {}, 43 | [GameSession.Event.Load] = { [GameSession.Scope.Saves] = true }, 44 | [GameSession.Event.Save] = { [GameSession.Scope.Saves] = true }, 45 | [GameSession.Event.Clean] = { [GameSession.Scope.Saves] = true }, 46 | [GameSession.Event.LoadData] = { [GameSession.Scope.Saves] = true, [GameSession.Scope.Persistence] = true }, 47 | [GameSession.Event.SaveData] = { [GameSession.Scope.Saves] = true, [GameSession.Scope.Persistence] = true }, 48 | } 49 | 50 | local isLoaded = false 51 | local isPaused = true 52 | local isBlurred = false 53 | local isDead = false 54 | 55 | local sessionDataDir 56 | local sessionDataRef 57 | local sessionDataTmpl 58 | local sessionDataRelaxed = false 59 | 60 | local sessionKeyValue = 0 61 | local sessionKeyFactName = '_psxgs_session_key' 62 | 63 | -- Error Handling -- 64 | 65 | local function raiseError(msg) 66 | print('[GameSession] ' .. msg) 67 | error(msg, 2) 68 | end 69 | 70 | -- Event Dispatching -- 71 | 72 | local function addEventListener(event, callback) 73 | if not listeners[event] then 74 | listeners[event] = {} 75 | end 76 | 77 | table.insert(listeners[event], callback) 78 | end 79 | 80 | local function dispatchEvent(event, state) 81 | if listeners[event] then 82 | state.event = event 83 | 84 | for _, callback in ipairs(listeners[event]) do 85 | callback(state) 86 | end 87 | 88 | state.event = nil 89 | end 90 | end 91 | 92 | -- State Observing -- 93 | 94 | local stateProps = { 95 | { current = 'isLoaded', previous = 'wasLoaded', event = { on = GameSession.Event.Start, off = GameSession.Event.End, scope = GameSession.Scope.Session } }, 96 | { current = 'isPaused', previous = 'wasPaused', event = { on = GameSession.Event.Pause, off = GameSession.Event.Resume, scope = GameSession.Scope.Pause } }, 97 | { current = 'isBlurred', previous = 'wasBlurred', event = { on = GameSession.Event.Blur, off = GameSession.Event.Resume, scope = GameSession.Scope.Blur } }, 98 | { current = 'isDead', previous = 'wasWheel', event = { on = GameSession.Event.Death, scope = GameSession.Scope.Death } }, 99 | { current = 'timestamp' }, 100 | { current = 'timestamps' }, 101 | { current = 'sessionKey' }, 102 | } 103 | 104 | local previousState = {} 105 | 106 | local function updateLoaded(loaded) 107 | local changed = isLoaded ~= loaded 108 | 109 | isLoaded = loaded 110 | 111 | return changed 112 | end 113 | 114 | local function updatePaused(isMenuActive) 115 | isPaused = not isLoaded or isMenuActive 116 | end 117 | 118 | local function updateBlurred(isBlurActive) 119 | isBlurred = isBlurActive 120 | end 121 | 122 | local function updateDead(isPlayerDead) 123 | isDead = isPlayerDead 124 | end 125 | 126 | local function isPreGame() 127 | return GetSingleton('inkMenuScenario'):GetSystemRequestsHandler():IsPreGame() 128 | end 129 | 130 | local function refreshCurrentState() 131 | local player = Game.GetPlayer() 132 | local blackboardDefs = Game.GetAllBlackboardDefs() 133 | local blackboardUI = Game.GetBlackboardSystem():Get(blackboardDefs.UI_System) 134 | local blackboardPM = Game.GetBlackboardSystem():Get(blackboardDefs.PhotoMode) 135 | 136 | local menuActive = blackboardUI:GetBool(blackboardDefs.UI_System.IsInMenu) 137 | local blurActive = blackboardUI:GetBool(blackboardDefs.UI_System.CircularBlurEnabled) 138 | local photoModeActive = blackboardPM:GetBool(blackboardDefs.PhotoMode.IsActive) 139 | local tutorialActive = Game.GetTimeSystem():IsTimeDilationActive('UI_TutorialPopup') 140 | 141 | if not isLoaded then 142 | updateLoaded(player:IsAttached() and not isPreGame()) 143 | end 144 | 145 | updatePaused(menuActive or photoModeActive or tutorialActive) 146 | updateBlurred(blurActive) 147 | updateDead(player:IsDeadNoStatPool()) 148 | end 149 | 150 | local function determineEvents(currentState) 151 | local events = { GameSession.Event.Update } 152 | local firing = {} 153 | 154 | for _, stateProp in ipairs(stateProps) do 155 | local currentValue = currentState[stateProp.current] 156 | local previousValue = previousState[stateProp.current] 157 | 158 | if stateProp.event and (not stateProp.parent or currentState[stateProp.parent]) then 159 | local reqSatisfied = true 160 | 161 | if stateProp.event.reqs then 162 | for reqProp, reqValue in pairs(stateProp.event.reqs) do 163 | if tostring(currentState[reqProp]) ~= tostring(reqValue) then 164 | reqSatisfied = false 165 | break 166 | end 167 | end 168 | end 169 | 170 | if reqSatisfied then 171 | if stateProp.event.change and previousValue ~= nil then 172 | if tostring(currentValue) ~= tostring(previousValue) then 173 | if not firing[stateProp.event.change] then 174 | table.insert(events, stateProp.event.change) 175 | firing[stateProp.event.change] = true 176 | end 177 | end 178 | end 179 | 180 | if stateProp.event.on and currentValue and not previousValue then 181 | if not firing[stateProp.event.on] then 182 | table.insert(events, stateProp.event.on) 183 | firing[stateProp.event.on] = true 184 | end 185 | elseif stateProp.event.off and not currentValue and previousValue then 186 | if not firing[stateProp.event.off] then 187 | table.insert(events, 1, stateProp.event.off) 188 | firing[stateProp.event.off] = true 189 | end 190 | end 191 | end 192 | end 193 | end 194 | 195 | return events 196 | end 197 | 198 | local function notifyObservers() 199 | local currentState = GameSession.GetState() 200 | local stateChanged = false 201 | 202 | for _, stateProp in ipairs(stateProps) do 203 | local currentValue = currentState[stateProp.current] 204 | local previousValue = previousState[stateProp.current] 205 | 206 | if tostring(currentValue) ~= tostring(previousValue) then 207 | stateChanged = true 208 | break 209 | end 210 | end 211 | 212 | if stateChanged then 213 | local events = determineEvents(currentState) 214 | 215 | for _, event in ipairs(events) do 216 | if listeners[event] then 217 | if event ~= GameSession.Event.Update then 218 | currentState.event = event 219 | end 220 | 221 | for _, callback in ipairs(listeners[event]) do 222 | callback(currentState) 223 | end 224 | 225 | currentState.event = nil 226 | end 227 | end 228 | 229 | previousState = currentState 230 | end 231 | end 232 | 233 | local function pushCurrentState() 234 | previousState = GameSession.GetState() 235 | end 236 | 237 | -- Session Key -- 238 | 239 | local function generateSessionKey() 240 | return os.time() 241 | end 242 | 243 | local function getSessionKey() 244 | return sessionKeyValue 245 | end 246 | 247 | local function setSessionKey(sessionKey) 248 | sessionKeyValue = sessionKey 249 | end 250 | 251 | local function isEmptySessionKey(sessionKey) 252 | if not sessionKey then 253 | sessionKey = getSessionKey() 254 | end 255 | 256 | return not sessionKey or sessionKey == 0 257 | end 258 | 259 | local function readSessionKey() 260 | return Game.GetQuestsSystem():GetFactStr(sessionKeyFactName) 261 | end 262 | 263 | local function writeSessionKey(sessionKey) 264 | Game.GetQuestsSystem():SetFactStr(sessionKeyFactName, sessionKey) 265 | end 266 | 267 | local function initSessionKey() 268 | local sessionKey = readSessionKey() 269 | 270 | if isEmptySessionKey(sessionKey) then 271 | sessionKey = generateSessionKey() 272 | writeSessionKey(sessionKey) 273 | end 274 | 275 | setSessionKey(sessionKey) 276 | end 277 | 278 | local function renewSessionKey() 279 | local sessionKey = getSessionKey() 280 | local savedKey = readSessionKey() 281 | local nextKey = generateSessionKey() 282 | 283 | if sessionKey == savedKey or savedKey < nextKey - 1 then 284 | sessionKey = generateSessionKey() 285 | writeSessionKey(sessionKey) 286 | else 287 | sessionKey = savedKey 288 | end 289 | 290 | setSessionKey(sessionKey) 291 | end 292 | 293 | local function setSessionKeyName(sessionKeyName) 294 | sessionKeyFactName = sessionKeyName 295 | end 296 | 297 | -- Session Data -- 298 | 299 | local function exportSessionData(t, max, depth, result) 300 | if type(t) ~= 'table' then 301 | return '{}' 302 | end 303 | 304 | max = max or 63 305 | depth = depth or 0 306 | 307 | local indent = string.rep('\t', depth) 308 | local output = result or {} 309 | 310 | table.insert(output, '{\n') 311 | 312 | for k, v in pairs(t) do 313 | local ktype = type(k) 314 | local vtype = type(v) 315 | 316 | local kstr = '' 317 | if ktype == 'string' then 318 | kstr = string.format('[%q] = ', k) 319 | else 320 | kstr = string.format('[%s] = ', tostring(k)) 321 | end 322 | 323 | local vstr = '' 324 | if vtype == 'string' then 325 | vstr = string.format('%q', v) 326 | elseif vtype == 'table' then 327 | if depth < max then 328 | table.insert(output, string.format('\t%s%s', indent, kstr)) 329 | exportSessionData(v, max, depth + 1, output) 330 | table.insert(output, ',\n') 331 | end 332 | elseif vtype == 'userdata' then 333 | vstr = tostring(v) 334 | if vstr:find('^userdata:') or vstr:find('^sol%.') then 335 | if not sessionDataRelaxed then 336 | --vtype = vstr:match('^sol%.(.+):') 337 | if ktype == 'string' then 338 | raiseError(('Cannot store userdata in the %q field.'):format(k)) 339 | --raiseError(('Cannot store userdata of type %q in the %q field.'):format(vtype, k)) 340 | else 341 | raiseError(('Cannot store userdata in the list.')) 342 | --raiseError(('Cannot store userdata of type %q.'):format(vtype)) 343 | end 344 | else 345 | vstr = '' 346 | end 347 | end 348 | elseif vtype == 'function' or vtype == 'thread' then 349 | if not sessionDataRelaxed then 350 | if ktype == 'string' then 351 | raiseError(('Cannot store %s in the %q field.'):format(vtype, k)) 352 | else 353 | raiseError(('Cannot store %s.'):format(vtype)) 354 | end 355 | end 356 | else 357 | vstr = tostring(v) 358 | end 359 | 360 | if vstr ~= '' then 361 | table.insert(output, string.format('\t%s%s%s,\n', indent, kstr, vstr)) 362 | end 363 | end 364 | 365 | if not result and #output == 1 then 366 | return '{}' 367 | end 368 | 369 | table.insert(output, indent .. '}') 370 | 371 | if not result then 372 | return table.concat(output) 373 | end 374 | end 375 | 376 | local function importSessionData(s) 377 | local chunk = loadstring('return ' .. s, '') 378 | 379 | return chunk and chunk() or {} 380 | end 381 | 382 | -- Session File IO -- 383 | 384 | local function irpairs(tbl) 385 | local function iter(t, i) 386 | i = i - 1 387 | if i ~= 0 then 388 | return i, t[i] 389 | end 390 | end 391 | 392 | return iter, tbl, #tbl + 1 393 | end 394 | 395 | local function findSessionTimestampByKey(targetKey, isTemporary) 396 | if sessionDataDir and not isEmptySessionKey(targetKey) then 397 | local pattern = '^' .. (isTemporary and '!' or '') .. '(%d+)%.lua$' 398 | 399 | for _, sessionFile in irpairs(dir(sessionDataDir)) do 400 | if sessionFile.name:find(pattern) then 401 | local sessionReader = io.open(sessionDataDir .. '/' .. sessionFile.name, 'r') 402 | local sessionHeader = sessionReader:read('l') 403 | sessionReader:close() 404 | 405 | local sessionKeyStr = sessionHeader:match('^-- (%d+)$') 406 | if sessionKeyStr then 407 | local sessionKey = tonumber(sessionKeyStr) 408 | if sessionKey == targetKey then 409 | return tonumber((sessionFile.name:match(pattern))) 410 | end 411 | end 412 | end 413 | end 414 | end 415 | 416 | return nil 417 | end 418 | 419 | local function writeSessionFile(sessionTimestamp, sessionKey, isTemporary, sessionData) 420 | if not sessionDataDir then 421 | return 422 | end 423 | 424 | local sessionPath = sessionDataDir .. '/' .. (isTemporary and '!' or '') .. sessionTimestamp .. '.lua' 425 | local sessionFile = io.open(sessionPath, 'w') 426 | 427 | if not sessionFile then 428 | raiseError(('Cannot write session file %q.'):format(sessionPath)) 429 | end 430 | 431 | sessionFile:write('-- ') 432 | sessionFile:write(sessionKey) 433 | sessionFile:write('\n') 434 | sessionFile:write('return ') 435 | sessionFile:write(exportSessionData(sessionData)) 436 | sessionFile:close() 437 | end 438 | 439 | local function readSessionFile(sessionTimestamp, sessionKey, isTemporary) 440 | if not sessionDataDir then 441 | return nil 442 | end 443 | 444 | if not sessionTimestamp then 445 | sessionTimestamp = findSessionTimestampByKey(sessionKey, isTemporary) 446 | 447 | if not sessionTimestamp then 448 | return nil 449 | end 450 | end 451 | 452 | local sessionPath = sessionDataDir .. '/' .. (isTemporary and '!' or '') .. sessionTimestamp .. '.lua' 453 | local sessionChunk = loadfile(sessionPath) 454 | 455 | if type(sessionChunk) ~= 'function' then 456 | sessionPath = sessionDataDir .. '/' .. (sessionTimestamp + 1) .. '.lua' 457 | sessionChunk = loadfile(sessionPath) 458 | 459 | if type(sessionChunk) ~= 'function' then 460 | return nil 461 | end 462 | end 463 | 464 | return sessionChunk() 465 | end 466 | 467 | local function writeSessionFileFor(sessionMeta, sessionData) 468 | writeSessionFile(sessionMeta.timestamp, sessionMeta.sessionKey, sessionMeta.isTemporary, sessionData) 469 | end 470 | 471 | local function readSessionFileFor(sessionMeta) 472 | return readSessionFile(sessionMeta.timestamp, sessionMeta.sessionKey, sessionMeta.isTemporary) 473 | end 474 | 475 | local function removeSessionFile(sessionTimestamp, isTemporary) 476 | if not sessionDataDir then 477 | return 478 | end 479 | 480 | local sessionPath = sessionDataDir .. '/' .. (isTemporary and '!' or '') .. sessionTimestamp .. '.lua' 481 | 482 | os.remove(sessionPath) 483 | end 484 | 485 | local function cleanUpSessionFiles(sessionTimestamps) 486 | if not sessionDataDir then 487 | return 488 | end 489 | 490 | local validNames = {} 491 | 492 | for _, sessionTimestamp in ipairs(sessionTimestamps) do 493 | validNames[tostring(sessionTimestamp)] = true 494 | validNames[tostring(sessionTimestamp + 1)] = true 495 | end 496 | 497 | for _, sessionFile in pairs(dir(sessionDataDir)) do 498 | local sessionTimestamp = sessionFile.name:match('^!?(%d+)%.lua$') 499 | 500 | if sessionTimestamp and not validNames[sessionTimestamp] then 501 | os.remove(sessionDataDir .. '/' .. sessionFile.name) 502 | end 503 | end 504 | end 505 | 506 | -- Session Meta -- 507 | 508 | local function getSessionMetaForSaving(isTemporary) 509 | return { 510 | sessionKey = getSessionKey(), 511 | timestamp = os.time(), 512 | isTemporary = isTemporary, 513 | } 514 | end 515 | 516 | local function getSessionMetaForLoading(isTemporary) 517 | return { 518 | sessionKey = getSessionKey(), 519 | timestamp = findSessionTimestampByKey(getSessionKey(), isTemporary) or 0, 520 | isTemporary = isTemporary, 521 | } 522 | end 523 | 524 | local function extractSessionMetaForLoading(saveInfo) 525 | return { 526 | sessionKey = 0, -- Cannot be retrieved from save metadata 527 | timestamp = tonumber(saveInfo.timestamp), 528 | isTemporary = false, 529 | } 530 | end 531 | 532 | -- Initialization -- 533 | 534 | local function initialize(event) 535 | if not initialized.data then 536 | for _, stateProp in ipairs(stateProps) do 537 | if stateProp.event then 538 | local eventScope = stateProp.event.scope or stateProp.event.change 539 | 540 | if eventScope then 541 | for _, eventKey in ipairs({ 'change', 'on', 'off' }) do 542 | local eventName = stateProp.event[eventKey] 543 | 544 | if eventName then 545 | if not eventScopes[eventName] then 546 | eventScopes[eventName] = {} 547 | end 548 | 549 | eventScopes[eventName][eventScope] = true 550 | end 551 | end 552 | 553 | if eventScope ~= GameSession.Scope.Persistence then 554 | eventScopes[GameSession.Event.Update][eventScope] = true 555 | end 556 | end 557 | end 558 | end 559 | 560 | initialized.data = true 561 | end 562 | 563 | local required = eventScopes[event] or eventScopes[GameSession.Event.Update] 564 | 565 | -- Session State 566 | 567 | if required[GameSession.Scope.Session] and not initialized[GameSession.Scope.Session] then 568 | Observe('QuestTrackerGameController', 'OnInitialize', function() 569 | --spdlog.error(('QuestTrackerGameController::OnInitialize()')) 570 | 571 | if updateLoaded(true) then 572 | updatePaused(false) 573 | updateBlurred(false) 574 | updateDead(false) 575 | notifyObservers() 576 | end 577 | end) 578 | 579 | Observe('QuestTrackerGameController', 'OnUninitialize', function() 580 | --spdlog.error(('QuestTrackerGameController::OnUninitialize()')) 581 | 582 | if Game.GetPlayer() == nil then 583 | if updateLoaded(false) then 584 | updatePaused(true) 585 | updateBlurred(false) 586 | updateDead(false) 587 | notifyObservers() 588 | end 589 | end 590 | end) 591 | 592 | initialized[GameSession.Scope.Session] = true 593 | end 594 | 595 | -- Pause State 596 | 597 | if required[GameSession.Scope.Pause] and not initialized[GameSession.Scope.Pause] then 598 | local fastTravelActive, fastTravelStart 599 | 600 | Observe('gameuiPopupsManager', 'OnMenuUpdate', function(_, isInMenu) 601 | --spdlog.error(('gameuiPopupsManager::OnMenuUpdate(%s)'):format(tostring(isInMenu))) 602 | 603 | if not fastTravelActive then 604 | updatePaused(isInMenu) 605 | notifyObservers() 606 | end 607 | end) 608 | 609 | Observe('gameuiPhotoModeMenuController', 'OnShow', function() 610 | --spdlog.error(('PhotoModeMenuController::OnShow()')) 611 | 612 | updatePaused(true) 613 | notifyObservers() 614 | end) 615 | 616 | Observe('gameuiPhotoModeMenuController', 'OnHide', function() 617 | --spdlog.error(('PhotoModeMenuController::OnHide()')) 618 | 619 | updatePaused(false) 620 | notifyObservers() 621 | end) 622 | 623 | Observe('gameuiTutorialPopupGameController', 'PauseGame', function(_, tutorialActive) 624 | --spdlog.error(('gameuiTutorialPopupGameController::PauseGame(%s)'):format(tostring(tutorialActive))) 625 | 626 | updatePaused(tutorialActive) 627 | notifyObservers() 628 | end) 629 | 630 | Observe('FastTravelSystem', 'OnUpdateFastTravelPointRecordRequest', function(_, request) 631 | --spdlog.error(('FastTravelSystem::OnUpdateFastTravelPointRecordRequest()')) 632 | 633 | fastTravelStart = request.pointRecord 634 | end) 635 | 636 | Observe('FastTravelSystem', 'OnPerformFastTravelRequest', function(self, request) 637 | --spdlog.error(('FastTravelSystem::OnPerformFastTravelRequest()')) 638 | 639 | if self.isFastTravelEnabledOnMap then 640 | local fastTravelDestination = request.pointData and request.pointData.pointRecord or nil 641 | 642 | if tostring(fastTravelStart) ~= tostring(fastTravelDestination) then 643 | fastTravelActive = true 644 | else 645 | fastTravelStart = nil 646 | end 647 | end 648 | end) 649 | 650 | Observe('FastTravelSystem', 'OnLoadingScreenFinished', function(_, finished) 651 | --spdlog.error(('FastTravelSystem::OnLoadingScreenFinished(%s)'):format(tostring(finished))) 652 | 653 | if finished then 654 | fastTravelActive = false 655 | fastTravelStart = nil 656 | updatePaused(false) 657 | notifyObservers() 658 | end 659 | end) 660 | 661 | initialized[GameSession.Scope.Pause] = true 662 | end 663 | 664 | -- Blur State 665 | 666 | if required[GameSession.Scope.Blur] and not initialized[GameSession.Scope.Blur] then 667 | local popupControllers = { 668 | ['PhoneDialerGameController'] = { 669 | ['Show'] = true, 670 | ['Hide'] = false, 671 | }, 672 | ['RadialWheelController'] = { 673 | ['RefreshSlots'] = { initialized = true }, 674 | ['Shutdown'] = false, 675 | }, 676 | ['VehicleRadioPopupGameController'] = { 677 | ['OnInitialize'] = true, 678 | ['OnClose'] = false, 679 | }, 680 | ['VehiclesManagerPopupGameController'] = { 681 | ['OnInitialize'] = true, 682 | ['OnClose'] = false, 683 | }, 684 | } 685 | 686 | for popupController, popupEvents in pairs(popupControllers) do 687 | for popupEvent, popupState in pairs(popupEvents) do 688 | Observe(popupController, popupEvent, function(self) 689 | --spdlog.error(('%s::%s()'):format(popupController, popupEvent)) 690 | 691 | if isLoaded then 692 | if type(popupState) == 'table' then 693 | local popupActive = true 694 | for prop, value in pairs(popupState) do 695 | if self[prop] ~= value then 696 | popupActive = false 697 | break 698 | end 699 | end 700 | updateBlurred(popupActive) 701 | else 702 | updateBlurred(popupState) 703 | end 704 | 705 | notifyObservers() 706 | end 707 | end) 708 | end 709 | end 710 | 711 | Observe('PhoneMessagePopupGameController', 'SetTimeDilatation', function(_, popupActive) 712 | --spdlog.error(('PhoneMessagePopupGameController::SetTimeDilatation()')) 713 | 714 | updateBlurred(popupActive) 715 | notifyObservers() 716 | end) 717 | 718 | initialized[GameSession.Scope.Blur] = true 719 | end 720 | 721 | -- Death State 722 | 723 | if required[GameSession.Scope.Death] and not initialized[GameSession.Scope.Death] then 724 | Observe('PlayerPuppet', 'OnDeath', function() 725 | --spdlog.error(('PlayerPuppet::OnDeath()')) 726 | 727 | updateDead(true) 728 | notifyObservers() 729 | end) 730 | 731 | initialized[GameSession.Scope.Death] = true 732 | end 733 | 734 | -- Saving and Loading 735 | 736 | if required[GameSession.Scope.Saves] and not initialized[GameSession.Scope.Saves] then 737 | local sessionLoadList = {} 738 | local sessionLoadRequest = {} 739 | 740 | if not isPreGame() then 741 | initSessionKey() 742 | end 743 | 744 | ---@param self PlayerPuppet 745 | Observe('PlayerPuppet', 'OnTakeControl', function(self) 746 | --spdlog.error(('PlayerPuppet::OnTakeControl()')) 747 | 748 | if self:GetEntityID().hash ~= 1ULL then 749 | return 750 | end 751 | 752 | if not isPreGame() then 753 | -- Expand load request with session key from facts 754 | sessionLoadRequest.sessionKey = readSessionKey() 755 | 756 | -- Try to resolve timestamp from session key 757 | if not sessionLoadRequest.timestamp then 758 | sessionLoadRequest.timestamp = findSessionTimestampByKey( 759 | sessionLoadRequest.sessionKey, 760 | sessionLoadRequest.isTemporary 761 | ) 762 | end 763 | 764 | -- Dispatch load event 765 | dispatchEvent(GameSession.Event.Load, sessionLoadRequest) 766 | end 767 | 768 | -- Reset session load request 769 | sessionLoadRequest = {} 770 | end) 771 | 772 | ---@param self PlayerPuppet 773 | Observe('PlayerPuppet', 'OnGameAttached', function(self) 774 | --spdlog.error(('PlayerPuppet::OnGameAttached()')) 775 | 776 | if self:IsReplacer() then 777 | return 778 | end 779 | 780 | if not isPreGame() then 781 | -- Store new session key in facts 782 | renewSessionKey() 783 | end 784 | end) 785 | 786 | Observe('LoadListItem', 'SetMetadata', function(_, saveInfo) 787 | if saveInfo == nil then 788 | saveInfo = _ 789 | end 790 | 791 | --spdlog.error(('LoadListItem::SetMetadata()')) 792 | 793 | -- Fill the session list from saves metadata 794 | sessionLoadList[saveInfo.saveIndex] = extractSessionMetaForLoading(saveInfo) 795 | end) 796 | 797 | Observe('LoadGameMenuGameController', 'LoadSaveInGame', function(_, saveIndex) 798 | --spdlog.error(('LoadGameMenuGameController::LoadSaveInGame(%d)'):format(saveIndex)) 799 | 800 | if #sessionLoadList == 0 then 801 | return 802 | end 803 | 804 | -- Make a load request from selected save 805 | sessionLoadRequest = sessionLoadList[saveIndex] 806 | 807 | -- Collect timestamps for existing saves 808 | local existingTimestamps = {} 809 | for _, sessionMeta in pairs(sessionLoadList) do 810 | table.insert(existingTimestamps, sessionMeta.timestamp) 811 | end 812 | 813 | -- Dispatch clean event 814 | dispatchEvent(GameSession.Event.Clean, { timestamps = existingTimestamps }) 815 | end) 816 | 817 | Observe('gameuiInGameMenuGameController', 'OnSavingComplete', function(_, success) 818 | if type(success) ~= 'boolean' then 819 | success = _ 820 | end 821 | 822 | --spdlog.error(('gameuiInGameMenuGameController::OnSavingComplete(%s)'):format(tostring(success))) 823 | 824 | if success then 825 | -- Dispatch 826 | dispatchEvent(GameSession.Event.Save, getSessionMetaForSaving()) 827 | 828 | -- Store new session key in facts 829 | renewSessionKey() 830 | end 831 | end) 832 | 833 | initialized[GameSession.Scope.Saves] = true 834 | end 835 | 836 | -- Persistence 837 | 838 | if required[GameSession.Scope.Persistence] and not initialized[GameSession.Scope.Persistence] then 839 | addEventListener(GameSession.Event.Save, function(sessionMeta) 840 | local sessionData = sessionDataRef or {} 841 | 842 | dispatchEvent(GameSession.Event.SaveData, sessionData) 843 | 844 | writeSessionFileFor(sessionMeta, sessionData) 845 | end) 846 | 847 | addEventListener(GameSession.Event.Load, function(sessionMeta) 848 | local sessionData = readSessionFileFor(sessionMeta) or {} 849 | 850 | if sessionDataTmpl then 851 | local defaultData = importSessionData(sessionDataTmpl) 852 | for prop, value in pairs(defaultData) do 853 | if sessionData[prop] == nil then 854 | sessionData[prop] = value 855 | end 856 | end 857 | end 858 | 859 | dispatchEvent(GameSession.Event.LoadData, sessionData) 860 | 861 | if sessionDataRef then 862 | for prop, _ in pairs(sessionDataRef) do 863 | sessionDataRef[prop] = nil 864 | end 865 | 866 | for prop, value in pairs(sessionData) do 867 | sessionDataRef[prop] = value 868 | end 869 | end 870 | 871 | if sessionMeta.isTemporary then 872 | removeSessionFile(sessionMeta.timestamp, true) 873 | end 874 | end) 875 | 876 | addEventListener(GameSession.Event.Clean, function(sessionMeta) 877 | cleanUpSessionFiles(sessionMeta.timestamps) 878 | end) 879 | 880 | initialized[GameSession.Scope.Persistence] = true 881 | end 882 | 883 | -- Initial state 884 | 885 | if not initialized.state then 886 | refreshCurrentState() 887 | pushCurrentState() 888 | 889 | initialized.state = true 890 | end 891 | end 892 | 893 | -- Public Interface -- 894 | 895 | function GameSession.Observe(event, callback) 896 | if type(event) == 'string' then 897 | initialize(event) 898 | elseif type(event) == 'function' then 899 | callback, event = event, GameSession.Event.Update 900 | initialize(event) 901 | else 902 | if not event then 903 | initialize(GameSession.Event.Update) 904 | elseif type(event) == 'table' then 905 | for _, evt in ipairs(event) do 906 | GameSession.Observe(evt, callback) 907 | end 908 | end 909 | return 910 | end 911 | 912 | if type(callback) == 'function' then 913 | addEventListener(event, callback) 914 | end 915 | end 916 | 917 | function GameSession.Listen(event, callback) 918 | if type(event) == 'function' then 919 | initialize(GameSession.Event.Update) 920 | callback = event 921 | for _, evt in pairs(GameSession.Event) do 922 | if evt ~= GameSession.Event.Update and not eventScopes[evt][GameSession.Scope.Persistence] then 923 | GameSession.Observe(evt, callback) 924 | end 925 | end 926 | else 927 | GameSession.Observe(event, callback) 928 | end 929 | end 930 | 931 | GameSession.On = GameSession.Listen 932 | 933 | setmetatable(GameSession, { 934 | __index = function(_, key) 935 | local event = string.match(key, '^On(%w+)$') 936 | 937 | if event and GameSession.Event[event] then 938 | rawset(GameSession, key, function(callback) 939 | GameSession.Observe(event, callback) 940 | end) 941 | 942 | return rawget(GameSession, key) 943 | end 944 | end 945 | }) 946 | 947 | function GameSession.IsLoaded() 948 | return isLoaded 949 | end 950 | 951 | function GameSession.IsPaused() 952 | return isPaused 953 | end 954 | 955 | function GameSession.IsBlurred() 956 | return isBlurred 957 | end 958 | 959 | function GameSession.IsDead() 960 | return isDead 961 | end 962 | 963 | function GameSession.GetKey() 964 | return getSessionKey() 965 | end 966 | 967 | function GameSession.GetState() 968 | local currentState = {} 969 | 970 | currentState.isLoaded = GameSession.IsLoaded() 971 | currentState.isPaused = GameSession.IsPaused() 972 | currentState.isBlurred = GameSession.IsBlurred() 973 | currentState.isDead = GameSession.IsDead() 974 | 975 | for _, stateProp in ipairs(stateProps) do 976 | if stateProp.previous then 977 | currentState[stateProp.previous] = previousState[stateProp.current] 978 | end 979 | end 980 | 981 | return currentState 982 | end 983 | 984 | local function exportValue(value) 985 | if type(value) == 'userdata' then 986 | value = string.format('%q', value.value) 987 | elseif type(value) == 'string' then 988 | value = string.format('%q', value) 989 | elseif type(value) == 'table' then 990 | value = '{ ' .. table.concat(value, ', ') .. ' }' 991 | else 992 | value = tostring(value) 993 | end 994 | 995 | return value 996 | end 997 | 998 | function GameSession.ExportState(state) 999 | local export = {} 1000 | 1001 | if state.event then 1002 | table.insert(export, 'event = ' .. string.format('%q', state.event)) 1003 | end 1004 | 1005 | for _, stateProp in ipairs(stateProps) do 1006 | local value = state[stateProp.current] 1007 | 1008 | if value and (not stateProp.parent or state[stateProp.parent]) then 1009 | table.insert(export, stateProp.current .. ' = ' .. exportValue(value)) 1010 | end 1011 | end 1012 | 1013 | for _, stateProp in ipairs(stateProps) do 1014 | if stateProp.previous then 1015 | local currentValue = state[stateProp.current] 1016 | local previousValue = state[stateProp.previous] 1017 | 1018 | if previousValue and previousValue ~= currentValue then 1019 | table.insert(export, stateProp.previous .. ' = ' .. exportValue(previousValue)) 1020 | end 1021 | end 1022 | end 1023 | 1024 | return '{ ' .. table.concat(export, ', ') .. ' }' 1025 | end 1026 | 1027 | function GameSession.PrintState(state) 1028 | print('[GameSession] ' .. GameSession.ExportState(state)) 1029 | end 1030 | 1031 | function GameSession.IdentifyAs(sessionName) 1032 | setSessionKeyName(sessionName) 1033 | end 1034 | 1035 | function GameSession.StoreInDir(sessionDir) 1036 | sessionDataDir = sessionDir 1037 | 1038 | initialize(GameSession.Event.SaveData) 1039 | end 1040 | 1041 | function GameSession.Persist(sessionData, relaxedMode) 1042 | if type(sessionData) ~= 'table' then 1043 | raiseError(('Session data must be a table, received %s.'):format(type(sessionData))) 1044 | end 1045 | 1046 | sessionDataRef = sessionData 1047 | sessionDataRelaxed = relaxedMode and true or false 1048 | sessionDataTmpl = exportSessionData(sessionData) 1049 | 1050 | initialize(GameSession.Event.SaveData) 1051 | end 1052 | 1053 | function GameSession.TrySave() 1054 | if Game.GetSettingsSystem() then 1055 | dispatchEvent(GameSession.Event.Save, getSessionMetaForSaving(true)) 1056 | end 1057 | end 1058 | 1059 | function GameSession.TryLoad() 1060 | if not isPreGame() and not isEmptySessionKey() then 1061 | dispatchEvent(GameSession.Event.Load, getSessionMetaForLoading(true)) 1062 | end 1063 | end 1064 | 1065 | return GameSession -------------------------------------------------------------------------------- /mods/GameSession-Events/init.lua: -------------------------------------------------------------------------------- 1 | local GameSession = require('GameSession') 2 | 3 | registerForEvent('onInit', function() 4 | GameSession.Listen(function(state) 5 | GameSession.PrintState(state) 6 | end) 7 | end) 8 | -------------------------------------------------------------------------------- /mods/GameSession-KillStats/GameHUD.lua: -------------------------------------------------------------------------------- 1 | --[[ 2 | GameHUD.lua 3 | 4 | Copyright (c) 2021 psiberx 5 | ]] 6 | 7 | local GameHUD = { version = '0.4.1' } 8 | 9 | function GameHUD.Initialize() 10 | -- Fix warning message for patch 1.3 11 | local gameVersionNum = EnumValueFromString('gameGameVersion', 'Current') 12 | if gameVersionNum >= 1300 and gameVersionNum <= 1301 then 13 | Override('WarningMessageGameController', 'UpdateWidgets', function(self) 14 | if self.simpleMessage.isShown and self.simpleMessage.message ~= '' then 15 | self.root:StopAllAnimations() 16 | 17 | inkTextRef.SetLetterCase(self.mainTextWidget, textLetterCase.UpperCase) 18 | inkTextRef.SetText(self.mainTextWidget, self.simpleMessage.message) 19 | 20 | Game.GetAudioSystem():Play('ui_jingle_chip_malfunction') 21 | 22 | self.animProxyShow = self:PlayLibraryAnimation('warning') 23 | 24 | local fakeAnim = inkAnimTransparency.new() 25 | fakeAnim:SetStartTransparency(1.00) 26 | fakeAnim:SetEndTransparency(1.00) 27 | fakeAnim:SetDuration(3.1) 28 | 29 | local fakeAnimDef = inkAnimDef.new() 30 | fakeAnimDef:AddInterpolator(fakeAnim) 31 | 32 | self.animProxyTimeout = self.root:PlayAnimation(fakeAnimDef) 33 | self.animProxyTimeout:RegisterToCallback(inkanimEventType.OnFinish, self, 'OnShown') 34 | 35 | self.root:SetVisible(true) 36 | elseif self.animProxyShow then 37 | self.animProxyShow:RegisterToCallback(inkanimEventType.OnFinish, self, 'OnHidden') 38 | self.animProxyShow:Resume() 39 | end 40 | end) 41 | 42 | Override('WarningMessageGameController', 'OnShown', function(self) 43 | self.animProxyShow:Pause() 44 | self:SetTimeout(self.simpleMessage.duration) 45 | end) 46 | end 47 | end 48 | 49 | function GameHUD.ShowMessage(text) 50 | if text == nil or text == "" then 51 | return 52 | end 53 | 54 | local message = SimpleScreenMessage.new() 55 | message.message = text 56 | message.isShown = true 57 | 58 | local blackboardDefs = Game.GetAllBlackboardDefs() 59 | local blackboardUI = Game.GetBlackboardSystem():Get(blackboardDefs.UI_Notifications) 60 | 61 | blackboardUI:SetVariant( 62 | blackboardDefs.UI_Notifications.OnscreenMessage, 63 | ToVariant(message), 64 | true 65 | ) 66 | end 67 | 68 | function GameHUD.ShowWarning(text, duration) 69 | if text == nil or text == "" then 70 | return 71 | end 72 | 73 | local message = SimpleScreenMessage.new() 74 | message.message = text 75 | message.duration = duration 76 | message.isShown = true 77 | 78 | local blackboardDefs = Game.GetAllBlackboardDefs() 79 | local blackboardUI = Game.GetBlackboardSystem():Get(blackboardDefs.UI_Notifications) 80 | 81 | blackboardUI:SetVariant( 82 | blackboardDefs.UI_Notifications.WarningMessage, 83 | ToVariant(message), 84 | true 85 | ) 86 | end 87 | 88 | return GameHUD -------------------------------------------------------------------------------- /mods/GameSession-KillStats/GameSession.lua: -------------------------------------------------------------------------------- 1 | --[[ 2 | GameSession.lua 3 | Reactive Session Observer 4 | Persistent Session Manager 5 | 6 | Copyright (c) 2021 psiberx 7 | ]] 8 | 9 | local GameSession = { 10 | version = '1.4.5', 11 | framework = '1.19.0' 12 | } 13 | 14 | GameSession.Event = { 15 | Start = 'Start', 16 | End = 'End', 17 | Pause = 'Pause', 18 | Blur = 'Blur', 19 | Resume = 'Resume', 20 | Death = 'Death', 21 | Update = 'Update', 22 | Load = 'Load', 23 | Save = 'Save', 24 | Clean = 'Clean', 25 | LoadData = 'LoadData', 26 | SaveData = 'SaveData', 27 | } 28 | 29 | GameSession.Scope = { 30 | Session = 'Session', 31 | Pause = 'Pause', 32 | Blur = 'Blur', 33 | Death = 'Death', 34 | Saves = 'Saves', 35 | Persistence = 'Persistence', 36 | } 37 | 38 | local initialized = {} 39 | local listeners = {} 40 | 41 | local eventScopes = { 42 | [GameSession.Event.Update] = {}, 43 | [GameSession.Event.Load] = { [GameSession.Scope.Saves] = true }, 44 | [GameSession.Event.Save] = { [GameSession.Scope.Saves] = true }, 45 | [GameSession.Event.Clean] = { [GameSession.Scope.Saves] = true }, 46 | [GameSession.Event.LoadData] = { [GameSession.Scope.Saves] = true, [GameSession.Scope.Persistence] = true }, 47 | [GameSession.Event.SaveData] = { [GameSession.Scope.Saves] = true, [GameSession.Scope.Persistence] = true }, 48 | } 49 | 50 | local isLoaded = false 51 | local isPaused = true 52 | local isBlurred = false 53 | local isDead = false 54 | 55 | local sessionDataDir 56 | local sessionDataRef 57 | local sessionDataTmpl 58 | local sessionDataRelaxed = false 59 | 60 | local sessionKeyValue = 0 61 | local sessionKeyFactName = '_psxgs_session_key' 62 | 63 | -- Error Handling -- 64 | 65 | local function raiseError(msg) 66 | print('[GameSession] ' .. msg) 67 | error(msg, 2) 68 | end 69 | 70 | -- Event Dispatching -- 71 | 72 | local function addEventListener(event, callback) 73 | if not listeners[event] then 74 | listeners[event] = {} 75 | end 76 | 77 | table.insert(listeners[event], callback) 78 | end 79 | 80 | local function dispatchEvent(event, state) 81 | if listeners[event] then 82 | state.event = event 83 | 84 | for _, callback in ipairs(listeners[event]) do 85 | callback(state) 86 | end 87 | 88 | state.event = nil 89 | end 90 | end 91 | 92 | -- State Observing -- 93 | 94 | local stateProps = { 95 | { current = 'isLoaded', previous = 'wasLoaded', event = { on = GameSession.Event.Start, off = GameSession.Event.End, scope = GameSession.Scope.Session } }, 96 | { current = 'isPaused', previous = 'wasPaused', event = { on = GameSession.Event.Pause, off = GameSession.Event.Resume, scope = GameSession.Scope.Pause } }, 97 | { current = 'isBlurred', previous = 'wasBlurred', event = { on = GameSession.Event.Blur, off = GameSession.Event.Resume, scope = GameSession.Scope.Blur } }, 98 | { current = 'isDead', previous = 'wasWheel', event = { on = GameSession.Event.Death, scope = GameSession.Scope.Death } }, 99 | { current = 'timestamp' }, 100 | { current = 'timestamps' }, 101 | { current = 'sessionKey' }, 102 | } 103 | 104 | local previousState = {} 105 | 106 | local function updateLoaded(loaded) 107 | local changed = isLoaded ~= loaded 108 | 109 | isLoaded = loaded 110 | 111 | return changed 112 | end 113 | 114 | local function updatePaused(isMenuActive) 115 | isPaused = not isLoaded or isMenuActive 116 | end 117 | 118 | local function updateBlurred(isBlurActive) 119 | isBlurred = isBlurActive 120 | end 121 | 122 | local function updateDead(isPlayerDead) 123 | isDead = isPlayerDead 124 | end 125 | 126 | local function isPreGame() 127 | return GetSingleton('inkMenuScenario'):GetSystemRequestsHandler():IsPreGame() 128 | end 129 | 130 | local function refreshCurrentState() 131 | local player = Game.GetPlayer() 132 | local blackboardDefs = Game.GetAllBlackboardDefs() 133 | local blackboardUI = Game.GetBlackboardSystem():Get(blackboardDefs.UI_System) 134 | local blackboardPM = Game.GetBlackboardSystem():Get(blackboardDefs.PhotoMode) 135 | 136 | local menuActive = blackboardUI:GetBool(blackboardDefs.UI_System.IsInMenu) 137 | local blurActive = blackboardUI:GetBool(blackboardDefs.UI_System.CircularBlurEnabled) 138 | local photoModeActive = blackboardPM:GetBool(blackboardDefs.PhotoMode.IsActive) 139 | local tutorialActive = Game.GetTimeSystem():IsTimeDilationActive('UI_TutorialPopup') 140 | 141 | if not isLoaded then 142 | updateLoaded(player:IsAttached() and not isPreGame()) 143 | end 144 | 145 | updatePaused(menuActive or photoModeActive or tutorialActive) 146 | updateBlurred(blurActive) 147 | updateDead(player:IsDeadNoStatPool()) 148 | end 149 | 150 | local function determineEvents(currentState) 151 | local events = { GameSession.Event.Update } 152 | local firing = {} 153 | 154 | for _, stateProp in ipairs(stateProps) do 155 | local currentValue = currentState[stateProp.current] 156 | local previousValue = previousState[stateProp.current] 157 | 158 | if stateProp.event and (not stateProp.parent or currentState[stateProp.parent]) then 159 | local reqSatisfied = true 160 | 161 | if stateProp.event.reqs then 162 | for reqProp, reqValue in pairs(stateProp.event.reqs) do 163 | if tostring(currentState[reqProp]) ~= tostring(reqValue) then 164 | reqSatisfied = false 165 | break 166 | end 167 | end 168 | end 169 | 170 | if reqSatisfied then 171 | if stateProp.event.change and previousValue ~= nil then 172 | if tostring(currentValue) ~= tostring(previousValue) then 173 | if not firing[stateProp.event.change] then 174 | table.insert(events, stateProp.event.change) 175 | firing[stateProp.event.change] = true 176 | end 177 | end 178 | end 179 | 180 | if stateProp.event.on and currentValue and not previousValue then 181 | if not firing[stateProp.event.on] then 182 | table.insert(events, stateProp.event.on) 183 | firing[stateProp.event.on] = true 184 | end 185 | elseif stateProp.event.off and not currentValue and previousValue then 186 | if not firing[stateProp.event.off] then 187 | table.insert(events, 1, stateProp.event.off) 188 | firing[stateProp.event.off] = true 189 | end 190 | end 191 | end 192 | end 193 | end 194 | 195 | return events 196 | end 197 | 198 | local function notifyObservers() 199 | local currentState = GameSession.GetState() 200 | local stateChanged = false 201 | 202 | for _, stateProp in ipairs(stateProps) do 203 | local currentValue = currentState[stateProp.current] 204 | local previousValue = previousState[stateProp.current] 205 | 206 | if tostring(currentValue) ~= tostring(previousValue) then 207 | stateChanged = true 208 | break 209 | end 210 | end 211 | 212 | if stateChanged then 213 | local events = determineEvents(currentState) 214 | 215 | for _, event in ipairs(events) do 216 | if listeners[event] then 217 | if event ~= GameSession.Event.Update then 218 | currentState.event = event 219 | end 220 | 221 | for _, callback in ipairs(listeners[event]) do 222 | callback(currentState) 223 | end 224 | 225 | currentState.event = nil 226 | end 227 | end 228 | 229 | previousState = currentState 230 | end 231 | end 232 | 233 | local function pushCurrentState() 234 | previousState = GameSession.GetState() 235 | end 236 | 237 | -- Session Key -- 238 | 239 | local function generateSessionKey() 240 | return os.time() 241 | end 242 | 243 | local function getSessionKey() 244 | return sessionKeyValue 245 | end 246 | 247 | local function setSessionKey(sessionKey) 248 | sessionKeyValue = sessionKey 249 | end 250 | 251 | local function isEmptySessionKey(sessionKey) 252 | if not sessionKey then 253 | sessionKey = getSessionKey() 254 | end 255 | 256 | return not sessionKey or sessionKey == 0 257 | end 258 | 259 | local function readSessionKey() 260 | return Game.GetQuestsSystem():GetFactStr(sessionKeyFactName) 261 | end 262 | 263 | local function writeSessionKey(sessionKey) 264 | Game.GetQuestsSystem():SetFactStr(sessionKeyFactName, sessionKey) 265 | end 266 | 267 | local function initSessionKey() 268 | local sessionKey = readSessionKey() 269 | 270 | if isEmptySessionKey(sessionKey) then 271 | sessionKey = generateSessionKey() 272 | writeSessionKey(sessionKey) 273 | end 274 | 275 | setSessionKey(sessionKey) 276 | end 277 | 278 | local function renewSessionKey() 279 | local sessionKey = getSessionKey() 280 | local savedKey = readSessionKey() 281 | local nextKey = generateSessionKey() 282 | 283 | if sessionKey == savedKey or savedKey < nextKey - 1 then 284 | sessionKey = generateSessionKey() 285 | writeSessionKey(sessionKey) 286 | else 287 | sessionKey = savedKey 288 | end 289 | 290 | setSessionKey(sessionKey) 291 | end 292 | 293 | local function setSessionKeyName(sessionKeyName) 294 | sessionKeyFactName = sessionKeyName 295 | end 296 | 297 | -- Session Data -- 298 | 299 | local function exportSessionData(t, max, depth, result) 300 | if type(t) ~= 'table' then 301 | return '{}' 302 | end 303 | 304 | max = max or 63 305 | depth = depth or 0 306 | 307 | local indent = string.rep('\t', depth) 308 | local output = result or {} 309 | 310 | table.insert(output, '{\n') 311 | 312 | for k, v in pairs(t) do 313 | local ktype = type(k) 314 | local vtype = type(v) 315 | 316 | local kstr = '' 317 | if ktype == 'string' then 318 | kstr = string.format('[%q] = ', k) 319 | else 320 | kstr = string.format('[%s] = ', tostring(k)) 321 | end 322 | 323 | local vstr = '' 324 | if vtype == 'string' then 325 | vstr = string.format('%q', v) 326 | elseif vtype == 'table' then 327 | if depth < max then 328 | table.insert(output, string.format('\t%s%s', indent, kstr)) 329 | exportSessionData(v, max, depth + 1, output) 330 | table.insert(output, ',\n') 331 | end 332 | elseif vtype == 'userdata' then 333 | vstr = tostring(v) 334 | if vstr:find('^userdata:') or vstr:find('^sol%.') then 335 | if not sessionDataRelaxed then 336 | --vtype = vstr:match('^sol%.(.+):') 337 | if ktype == 'string' then 338 | raiseError(('Cannot store userdata in the %q field.'):format(k)) 339 | --raiseError(('Cannot store userdata of type %q in the %q field.'):format(vtype, k)) 340 | else 341 | raiseError(('Cannot store userdata in the list.')) 342 | --raiseError(('Cannot store userdata of type %q.'):format(vtype)) 343 | end 344 | else 345 | vstr = '' 346 | end 347 | end 348 | elseif vtype == 'function' or vtype == 'thread' then 349 | if not sessionDataRelaxed then 350 | if ktype == 'string' then 351 | raiseError(('Cannot store %s in the %q field.'):format(vtype, k)) 352 | else 353 | raiseError(('Cannot store %s.'):format(vtype)) 354 | end 355 | end 356 | else 357 | vstr = tostring(v) 358 | end 359 | 360 | if vstr ~= '' then 361 | table.insert(output, string.format('\t%s%s%s,\n', indent, kstr, vstr)) 362 | end 363 | end 364 | 365 | if not result and #output == 1 then 366 | return '{}' 367 | end 368 | 369 | table.insert(output, indent .. '}') 370 | 371 | if not result then 372 | return table.concat(output) 373 | end 374 | end 375 | 376 | local function importSessionData(s) 377 | local chunk = loadstring('return ' .. s, '') 378 | 379 | return chunk and chunk() or {} 380 | end 381 | 382 | -- Session File IO -- 383 | 384 | local function irpairs(tbl) 385 | local function iter(t, i) 386 | i = i - 1 387 | if i ~= 0 then 388 | return i, t[i] 389 | end 390 | end 391 | 392 | return iter, tbl, #tbl + 1 393 | end 394 | 395 | local function findSessionTimestampByKey(targetKey, isTemporary) 396 | if sessionDataDir and not isEmptySessionKey(targetKey) then 397 | local pattern = '^' .. (isTemporary and '!' or '') .. '(%d+)%.lua$' 398 | 399 | for _, sessionFile in irpairs(dir(sessionDataDir)) do 400 | if sessionFile.name:find(pattern) then 401 | local sessionReader = io.open(sessionDataDir .. '/' .. sessionFile.name, 'r') 402 | local sessionHeader = sessionReader:read('l') 403 | sessionReader:close() 404 | 405 | local sessionKeyStr = sessionHeader:match('^-- (%d+)$') 406 | if sessionKeyStr then 407 | local sessionKey = tonumber(sessionKeyStr) 408 | if sessionKey == targetKey then 409 | return tonumber((sessionFile.name:match(pattern))) 410 | end 411 | end 412 | end 413 | end 414 | end 415 | 416 | return nil 417 | end 418 | 419 | local function writeSessionFile(sessionTimestamp, sessionKey, isTemporary, sessionData) 420 | if not sessionDataDir then 421 | return 422 | end 423 | 424 | local sessionPath = sessionDataDir .. '/' .. (isTemporary and '!' or '') .. sessionTimestamp .. '.lua' 425 | local sessionFile = io.open(sessionPath, 'w') 426 | 427 | if not sessionFile then 428 | raiseError(('Cannot write session file %q.'):format(sessionPath)) 429 | end 430 | 431 | sessionFile:write('-- ') 432 | sessionFile:write(sessionKey) 433 | sessionFile:write('\n') 434 | sessionFile:write('return ') 435 | sessionFile:write(exportSessionData(sessionData)) 436 | sessionFile:close() 437 | end 438 | 439 | local function readSessionFile(sessionTimestamp, sessionKey, isTemporary) 440 | if not sessionDataDir then 441 | return nil 442 | end 443 | 444 | if not sessionTimestamp then 445 | sessionTimestamp = findSessionTimestampByKey(sessionKey, isTemporary) 446 | 447 | if not sessionTimestamp then 448 | return nil 449 | end 450 | end 451 | 452 | local sessionPath = sessionDataDir .. '/' .. (isTemporary and '!' or '') .. sessionTimestamp .. '.lua' 453 | local sessionChunk = loadfile(sessionPath) 454 | 455 | if type(sessionChunk) ~= 'function' then 456 | sessionPath = sessionDataDir .. '/' .. (sessionTimestamp + 1) .. '.lua' 457 | sessionChunk = loadfile(sessionPath) 458 | 459 | if type(sessionChunk) ~= 'function' then 460 | return nil 461 | end 462 | end 463 | 464 | return sessionChunk() 465 | end 466 | 467 | local function writeSessionFileFor(sessionMeta, sessionData) 468 | writeSessionFile(sessionMeta.timestamp, sessionMeta.sessionKey, sessionMeta.isTemporary, sessionData) 469 | end 470 | 471 | local function readSessionFileFor(sessionMeta) 472 | return readSessionFile(sessionMeta.timestamp, sessionMeta.sessionKey, sessionMeta.isTemporary) 473 | end 474 | 475 | local function removeSessionFile(sessionTimestamp, isTemporary) 476 | if not sessionDataDir then 477 | return 478 | end 479 | 480 | local sessionPath = sessionDataDir .. '/' .. (isTemporary and '!' or '') .. sessionTimestamp .. '.lua' 481 | 482 | os.remove(sessionPath) 483 | end 484 | 485 | local function cleanUpSessionFiles(sessionTimestamps) 486 | if not sessionDataDir then 487 | return 488 | end 489 | 490 | local validNames = {} 491 | 492 | for _, sessionTimestamp in ipairs(sessionTimestamps) do 493 | validNames[tostring(sessionTimestamp)] = true 494 | validNames[tostring(sessionTimestamp + 1)] = true 495 | end 496 | 497 | for _, sessionFile in pairs(dir(sessionDataDir)) do 498 | local sessionTimestamp = sessionFile.name:match('^!?(%d+)%.lua$') 499 | 500 | if sessionTimestamp and not validNames[sessionTimestamp] then 501 | os.remove(sessionDataDir .. '/' .. sessionFile.name) 502 | end 503 | end 504 | end 505 | 506 | -- Session Meta -- 507 | 508 | local function getSessionMetaForSaving(isTemporary) 509 | return { 510 | sessionKey = getSessionKey(), 511 | timestamp = os.time(), 512 | isTemporary = isTemporary, 513 | } 514 | end 515 | 516 | local function getSessionMetaForLoading(isTemporary) 517 | return { 518 | sessionKey = getSessionKey(), 519 | timestamp = findSessionTimestampByKey(getSessionKey(), isTemporary) or 0, 520 | isTemporary = isTemporary, 521 | } 522 | end 523 | 524 | local function extractSessionMetaForLoading(saveInfo) 525 | return { 526 | sessionKey = 0, -- Cannot be retrieved from save metadata 527 | timestamp = tonumber(saveInfo.timestamp), 528 | isTemporary = false, 529 | } 530 | end 531 | 532 | -- Initialization -- 533 | 534 | local function initialize(event) 535 | if not initialized.data then 536 | for _, stateProp in ipairs(stateProps) do 537 | if stateProp.event then 538 | local eventScope = stateProp.event.scope or stateProp.event.change 539 | 540 | if eventScope then 541 | for _, eventKey in ipairs({ 'change', 'on', 'off' }) do 542 | local eventName = stateProp.event[eventKey] 543 | 544 | if eventName then 545 | if not eventScopes[eventName] then 546 | eventScopes[eventName] = {} 547 | end 548 | 549 | eventScopes[eventName][eventScope] = true 550 | end 551 | end 552 | 553 | if eventScope ~= GameSession.Scope.Persistence then 554 | eventScopes[GameSession.Event.Update][eventScope] = true 555 | end 556 | end 557 | end 558 | end 559 | 560 | initialized.data = true 561 | end 562 | 563 | local required = eventScopes[event] or eventScopes[GameSession.Event.Update] 564 | 565 | -- Session State 566 | 567 | if required[GameSession.Scope.Session] and not initialized[GameSession.Scope.Session] then 568 | Observe('QuestTrackerGameController', 'OnInitialize', function() 569 | --spdlog.error(('QuestTrackerGameController::OnInitialize()')) 570 | 571 | if updateLoaded(true) then 572 | updatePaused(false) 573 | updateBlurred(false) 574 | updateDead(false) 575 | notifyObservers() 576 | end 577 | end) 578 | 579 | Observe('QuestTrackerGameController', 'OnUninitialize', function() 580 | --spdlog.error(('QuestTrackerGameController::OnUninitialize()')) 581 | 582 | if Game.GetPlayer() == nil then 583 | if updateLoaded(false) then 584 | updatePaused(true) 585 | updateBlurred(false) 586 | updateDead(false) 587 | notifyObservers() 588 | end 589 | end 590 | end) 591 | 592 | initialized[GameSession.Scope.Session] = true 593 | end 594 | 595 | -- Pause State 596 | 597 | if required[GameSession.Scope.Pause] and not initialized[GameSession.Scope.Pause] then 598 | local fastTravelActive, fastTravelStart 599 | 600 | Observe('gameuiPopupsManager', 'OnMenuUpdate', function(_, isInMenu) 601 | --spdlog.error(('gameuiPopupsManager::OnMenuUpdate(%s)'):format(tostring(isInMenu))) 602 | 603 | if not fastTravelActive then 604 | updatePaused(isInMenu) 605 | notifyObservers() 606 | end 607 | end) 608 | 609 | Observe('gameuiPhotoModeMenuController', 'OnShow', function() 610 | --spdlog.error(('PhotoModeMenuController::OnShow()')) 611 | 612 | updatePaused(true) 613 | notifyObservers() 614 | end) 615 | 616 | Observe('gameuiPhotoModeMenuController', 'OnHide', function() 617 | --spdlog.error(('PhotoModeMenuController::OnHide()')) 618 | 619 | updatePaused(false) 620 | notifyObservers() 621 | end) 622 | 623 | Observe('gameuiTutorialPopupGameController', 'PauseGame', function(_, tutorialActive) 624 | --spdlog.error(('gameuiTutorialPopupGameController::PauseGame(%s)'):format(tostring(tutorialActive))) 625 | 626 | updatePaused(tutorialActive) 627 | notifyObservers() 628 | end) 629 | 630 | Observe('FastTravelSystem', 'OnUpdateFastTravelPointRecordRequest', function(_, request) 631 | --spdlog.error(('FastTravelSystem::OnUpdateFastTravelPointRecordRequest()')) 632 | 633 | fastTravelStart = request.pointRecord 634 | end) 635 | 636 | Observe('FastTravelSystem', 'OnPerformFastTravelRequest', function(self, request) 637 | --spdlog.error(('FastTravelSystem::OnPerformFastTravelRequest()')) 638 | 639 | if self.isFastTravelEnabledOnMap then 640 | local fastTravelDestination = request.pointData and request.pointData.pointRecord or nil 641 | 642 | if tostring(fastTravelStart) ~= tostring(fastTravelDestination) then 643 | fastTravelActive = true 644 | else 645 | fastTravelStart = nil 646 | end 647 | end 648 | end) 649 | 650 | Observe('FastTravelSystem', 'OnLoadingScreenFinished', function(_, finished) 651 | --spdlog.error(('FastTravelSystem::OnLoadingScreenFinished(%s)'):format(tostring(finished))) 652 | 653 | if finished then 654 | fastTravelActive = false 655 | fastTravelStart = nil 656 | updatePaused(false) 657 | notifyObservers() 658 | end 659 | end) 660 | 661 | initialized[GameSession.Scope.Pause] = true 662 | end 663 | 664 | -- Blur State 665 | 666 | if required[GameSession.Scope.Blur] and not initialized[GameSession.Scope.Blur] then 667 | local popupControllers = { 668 | ['PhoneDialerGameController'] = { 669 | ['Show'] = true, 670 | ['Hide'] = false, 671 | }, 672 | ['RadialWheelController'] = { 673 | ['RefreshSlots'] = { initialized = true }, 674 | ['Shutdown'] = false, 675 | }, 676 | ['VehicleRadioPopupGameController'] = { 677 | ['OnInitialize'] = true, 678 | ['OnClose'] = false, 679 | }, 680 | ['VehiclesManagerPopupGameController'] = { 681 | ['OnInitialize'] = true, 682 | ['OnClose'] = false, 683 | }, 684 | } 685 | 686 | for popupController, popupEvents in pairs(popupControllers) do 687 | for popupEvent, popupState in pairs(popupEvents) do 688 | Observe(popupController, popupEvent, function(self) 689 | --spdlog.error(('%s::%s()'):format(popupController, popupEvent)) 690 | 691 | if isLoaded then 692 | if type(popupState) == 'table' then 693 | local popupActive = true 694 | for prop, value in pairs(popupState) do 695 | if self[prop] ~= value then 696 | popupActive = false 697 | break 698 | end 699 | end 700 | updateBlurred(popupActive) 701 | else 702 | updateBlurred(popupState) 703 | end 704 | 705 | notifyObservers() 706 | end 707 | end) 708 | end 709 | end 710 | 711 | Observe('PhoneMessagePopupGameController', 'SetTimeDilatation', function(_, popupActive) 712 | --spdlog.error(('PhoneMessagePopupGameController::SetTimeDilatation()')) 713 | 714 | updateBlurred(popupActive) 715 | notifyObservers() 716 | end) 717 | 718 | initialized[GameSession.Scope.Blur] = true 719 | end 720 | 721 | -- Death State 722 | 723 | if required[GameSession.Scope.Death] and not initialized[GameSession.Scope.Death] then 724 | Observe('PlayerPuppet', 'OnDeath', function() 725 | --spdlog.error(('PlayerPuppet::OnDeath()')) 726 | 727 | updateDead(true) 728 | notifyObservers() 729 | end) 730 | 731 | initialized[GameSession.Scope.Death] = true 732 | end 733 | 734 | -- Saving and Loading 735 | 736 | if required[GameSession.Scope.Saves] and not initialized[GameSession.Scope.Saves] then 737 | local sessionLoadList = {} 738 | local sessionLoadRequest = {} 739 | 740 | if not isPreGame() then 741 | initSessionKey() 742 | end 743 | 744 | ---@param self PlayerPuppet 745 | Observe('PlayerPuppet', 'OnTakeControl', function(self) 746 | --spdlog.error(('PlayerPuppet::OnTakeControl()')) 747 | 748 | if self:GetEntityID().hash ~= 1ULL then 749 | return 750 | end 751 | 752 | if not isPreGame() then 753 | -- Expand load request with session key from facts 754 | sessionLoadRequest.sessionKey = readSessionKey() 755 | 756 | -- Try to resolve timestamp from session key 757 | if not sessionLoadRequest.timestamp then 758 | sessionLoadRequest.timestamp = findSessionTimestampByKey( 759 | sessionLoadRequest.sessionKey, 760 | sessionLoadRequest.isTemporary 761 | ) 762 | end 763 | 764 | -- Dispatch load event 765 | dispatchEvent(GameSession.Event.Load, sessionLoadRequest) 766 | end 767 | 768 | -- Reset session load request 769 | sessionLoadRequest = {} 770 | end) 771 | 772 | ---@param self PlayerPuppet 773 | Observe('PlayerPuppet', 'OnGameAttached', function(self) 774 | --spdlog.error(('PlayerPuppet::OnGameAttached()')) 775 | 776 | if self:IsReplacer() then 777 | return 778 | end 779 | 780 | if not isPreGame() then 781 | -- Store new session key in facts 782 | renewSessionKey() 783 | end 784 | end) 785 | 786 | Observe('LoadListItem', 'SetMetadata', function(_, saveInfo) 787 | if saveInfo == nil then 788 | saveInfo = _ 789 | end 790 | 791 | --spdlog.error(('LoadListItem::SetMetadata()')) 792 | 793 | -- Fill the session list from saves metadata 794 | sessionLoadList[saveInfo.saveIndex] = extractSessionMetaForLoading(saveInfo) 795 | end) 796 | 797 | Observe('LoadGameMenuGameController', 'LoadSaveInGame', function(_, saveIndex) 798 | --spdlog.error(('LoadGameMenuGameController::LoadSaveInGame(%d)'):format(saveIndex)) 799 | 800 | if #sessionLoadList == 0 then 801 | return 802 | end 803 | 804 | -- Make a load request from selected save 805 | sessionLoadRequest = sessionLoadList[saveIndex] 806 | 807 | -- Collect timestamps for existing saves 808 | local existingTimestamps = {} 809 | for _, sessionMeta in pairs(sessionLoadList) do 810 | table.insert(existingTimestamps, sessionMeta.timestamp) 811 | end 812 | 813 | -- Dispatch clean event 814 | dispatchEvent(GameSession.Event.Clean, { timestamps = existingTimestamps }) 815 | end) 816 | 817 | Observe('gameuiInGameMenuGameController', 'OnSavingComplete', function(_, success) 818 | if type(success) ~= 'boolean' then 819 | success = _ 820 | end 821 | 822 | --spdlog.error(('gameuiInGameMenuGameController::OnSavingComplete(%s)'):format(tostring(success))) 823 | 824 | if success then 825 | -- Dispatch 826 | dispatchEvent(GameSession.Event.Save, getSessionMetaForSaving()) 827 | 828 | -- Store new session key in facts 829 | renewSessionKey() 830 | end 831 | end) 832 | 833 | initialized[GameSession.Scope.Saves] = true 834 | end 835 | 836 | -- Persistence 837 | 838 | if required[GameSession.Scope.Persistence] and not initialized[GameSession.Scope.Persistence] then 839 | addEventListener(GameSession.Event.Save, function(sessionMeta) 840 | local sessionData = sessionDataRef or {} 841 | 842 | dispatchEvent(GameSession.Event.SaveData, sessionData) 843 | 844 | writeSessionFileFor(sessionMeta, sessionData) 845 | end) 846 | 847 | addEventListener(GameSession.Event.Load, function(sessionMeta) 848 | local sessionData = readSessionFileFor(sessionMeta) or {} 849 | 850 | if sessionDataTmpl then 851 | local defaultData = importSessionData(sessionDataTmpl) 852 | for prop, value in pairs(defaultData) do 853 | if sessionData[prop] == nil then 854 | sessionData[prop] = value 855 | end 856 | end 857 | end 858 | 859 | dispatchEvent(GameSession.Event.LoadData, sessionData) 860 | 861 | if sessionDataRef then 862 | for prop, _ in pairs(sessionDataRef) do 863 | sessionDataRef[prop] = nil 864 | end 865 | 866 | for prop, value in pairs(sessionData) do 867 | sessionDataRef[prop] = value 868 | end 869 | end 870 | 871 | if sessionMeta.isTemporary then 872 | removeSessionFile(sessionMeta.timestamp, true) 873 | end 874 | end) 875 | 876 | addEventListener(GameSession.Event.Clean, function(sessionMeta) 877 | cleanUpSessionFiles(sessionMeta.timestamps) 878 | end) 879 | 880 | initialized[GameSession.Scope.Persistence] = true 881 | end 882 | 883 | -- Initial state 884 | 885 | if not initialized.state then 886 | refreshCurrentState() 887 | pushCurrentState() 888 | 889 | initialized.state = true 890 | end 891 | end 892 | 893 | -- Public Interface -- 894 | 895 | function GameSession.Observe(event, callback) 896 | if type(event) == 'string' then 897 | initialize(event) 898 | elseif type(event) == 'function' then 899 | callback, event = event, GameSession.Event.Update 900 | initialize(event) 901 | else 902 | if not event then 903 | initialize(GameSession.Event.Update) 904 | elseif type(event) == 'table' then 905 | for _, evt in ipairs(event) do 906 | GameSession.Observe(evt, callback) 907 | end 908 | end 909 | return 910 | end 911 | 912 | if type(callback) == 'function' then 913 | addEventListener(event, callback) 914 | end 915 | end 916 | 917 | function GameSession.Listen(event, callback) 918 | if type(event) == 'function' then 919 | initialize(GameSession.Event.Update) 920 | callback = event 921 | for _, evt in pairs(GameSession.Event) do 922 | if evt ~= GameSession.Event.Update and not eventScopes[evt][GameSession.Scope.Persistence] then 923 | GameSession.Observe(evt, callback) 924 | end 925 | end 926 | else 927 | GameSession.Observe(event, callback) 928 | end 929 | end 930 | 931 | GameSession.On = GameSession.Listen 932 | 933 | setmetatable(GameSession, { 934 | __index = function(_, key) 935 | local event = string.match(key, '^On(%w+)$') 936 | 937 | if event and GameSession.Event[event] then 938 | rawset(GameSession, key, function(callback) 939 | GameSession.Observe(event, callback) 940 | end) 941 | 942 | return rawget(GameSession, key) 943 | end 944 | end 945 | }) 946 | 947 | function GameSession.IsLoaded() 948 | return isLoaded 949 | end 950 | 951 | function GameSession.IsPaused() 952 | return isPaused 953 | end 954 | 955 | function GameSession.IsBlurred() 956 | return isBlurred 957 | end 958 | 959 | function GameSession.IsDead() 960 | return isDead 961 | end 962 | 963 | function GameSession.GetKey() 964 | return getSessionKey() 965 | end 966 | 967 | function GameSession.GetState() 968 | local currentState = {} 969 | 970 | currentState.isLoaded = GameSession.IsLoaded() 971 | currentState.isPaused = GameSession.IsPaused() 972 | currentState.isBlurred = GameSession.IsBlurred() 973 | currentState.isDead = GameSession.IsDead() 974 | 975 | for _, stateProp in ipairs(stateProps) do 976 | if stateProp.previous then 977 | currentState[stateProp.previous] = previousState[stateProp.current] 978 | end 979 | end 980 | 981 | return currentState 982 | end 983 | 984 | local function exportValue(value) 985 | if type(value) == 'userdata' then 986 | value = string.format('%q', value.value) 987 | elseif type(value) == 'string' then 988 | value = string.format('%q', value) 989 | elseif type(value) == 'table' then 990 | value = '{ ' .. table.concat(value, ', ') .. ' }' 991 | else 992 | value = tostring(value) 993 | end 994 | 995 | return value 996 | end 997 | 998 | function GameSession.ExportState(state) 999 | local export = {} 1000 | 1001 | if state.event then 1002 | table.insert(export, 'event = ' .. string.format('%q', state.event)) 1003 | end 1004 | 1005 | for _, stateProp in ipairs(stateProps) do 1006 | local value = state[stateProp.current] 1007 | 1008 | if value and (not stateProp.parent or state[stateProp.parent]) then 1009 | table.insert(export, stateProp.current .. ' = ' .. exportValue(value)) 1010 | end 1011 | end 1012 | 1013 | for _, stateProp in ipairs(stateProps) do 1014 | if stateProp.previous then 1015 | local currentValue = state[stateProp.current] 1016 | local previousValue = state[stateProp.previous] 1017 | 1018 | if previousValue and previousValue ~= currentValue then 1019 | table.insert(export, stateProp.previous .. ' = ' .. exportValue(previousValue)) 1020 | end 1021 | end 1022 | end 1023 | 1024 | return '{ ' .. table.concat(export, ', ') .. ' }' 1025 | end 1026 | 1027 | function GameSession.PrintState(state) 1028 | print('[GameSession] ' .. GameSession.ExportState(state)) 1029 | end 1030 | 1031 | function GameSession.IdentifyAs(sessionName) 1032 | setSessionKeyName(sessionName) 1033 | end 1034 | 1035 | function GameSession.StoreInDir(sessionDir) 1036 | sessionDataDir = sessionDir 1037 | 1038 | initialize(GameSession.Event.SaveData) 1039 | end 1040 | 1041 | function GameSession.Persist(sessionData, relaxedMode) 1042 | if type(sessionData) ~= 'table' then 1043 | raiseError(('Session data must be a table, received %s.'):format(type(sessionData))) 1044 | end 1045 | 1046 | sessionDataRef = sessionData 1047 | sessionDataRelaxed = relaxedMode and true or false 1048 | sessionDataTmpl = exportSessionData(sessionData) 1049 | 1050 | initialize(GameSession.Event.SaveData) 1051 | end 1052 | 1053 | function GameSession.TrySave() 1054 | if Game.GetSettingsSystem() then 1055 | dispatchEvent(GameSession.Event.Save, getSessionMetaForSaving(true)) 1056 | end 1057 | end 1058 | 1059 | function GameSession.TryLoad() 1060 | if not isPreGame() and not isEmptySessionKey() then 1061 | dispatchEvent(GameSession.Event.Load, getSessionMetaForLoading(true)) 1062 | end 1063 | end 1064 | 1065 | return GameSession -------------------------------------------------------------------------------- /mods/GameSession-KillStats/init.lua: -------------------------------------------------------------------------------- 1 | local GameSession = require('GameSession') 2 | local GameHUD = require('GameHUD') 3 | 4 | local KillStats = { 5 | totalKills = 0, 6 | totalKillsByGroup = {}, 7 | lastKillLocation = nil, 8 | lastKillTimestamp = nil, 9 | } 10 | 11 | function KillStats.IsPlayer(target) 12 | return target and target:GetEntityID().hash == Game.GetPlayer():GetEntityID().hash 13 | end 14 | 15 | function KillStats.TrackKill(target) 16 | local kill = { 17 | confirmed = false, 18 | number = 0, 19 | groups = nil, 20 | } 21 | 22 | if target.shouldDie and (KillStats.IsPlayer(target.myKiller) or target.wasJustKilledOrDefeated) then 23 | KillStats.totalKills = KillStats.totalKills + 1 24 | KillStats.lastKillLocation = Game.GetPlayer():GetWorldPosition() 25 | KillStats.lastKillTimestamp = Game.GetTimeSystem():GetGameTimeStamp() 26 | 27 | local groups = KillStats.GetTargetGroups(target) 28 | 29 | for _, group in ipairs(groups) do 30 | KillStats.totalKillsByGroup[group] = (KillStats.totalKillsByGroup[group] or 0) + 1 31 | end 32 | 33 | kill.confirmed = true 34 | kill.number = KillStats.totalKills 35 | kill.groups = groups 36 | end 37 | 38 | return kill 39 | end 40 | 41 | function KillStats.GetTargetGroups(target) 42 | local groups = {} 43 | 44 | -- Reaction Group: Civilian, Ganger, Police 45 | if target:GetStimReactionComponent() then 46 | local reactionGroup = target:GetStimReactionComponent():GetReactionPreset():ReactionGroup() 47 | 48 | if reactionGroup then 49 | table.insert(groups, reactionGroup) 50 | end 51 | end 52 | 53 | -- Character Type: Human, Android, etc. 54 | table.insert(groups, target:GetRecord():CharacterType():Type().value) 55 | 56 | -- Tags: Cyberpsycho 57 | for _, tag in ipairs(target:GetRecord():Tags()) do 58 | table.insert(groups, Game.NameToString(tag)) 59 | end 60 | 61 | -- Visual Tags: Affiliation, Role, etc. 62 | for _, tag in ipairs(target:GetRecord():VisualTags()) do 63 | table.insert(groups, Game.NameToString(tag)) 64 | end 65 | 66 | return groups 67 | end 68 | 69 | registerForEvent('onInit', function() 70 | GameHUD.Initialize() 71 | 72 | GameSession.StoreInDir('sessions') 73 | GameSession.Persist(KillStats, true) 74 | 75 | GameSession.OnLoad(function() 76 | print('[KillStats] Total Kills: ' .. KillStats.totalKills) 77 | end) 78 | 79 | GameSession.OnStart(function() 80 | GameHUD.ShowWarning('Total Kills: ' .. KillStats.totalKills, 5.0) 81 | end) 82 | 83 | Observe('NPCPuppet', 'SendAfterDeathOrDefeatEvent', function(self) 84 | local kill = KillStats.TrackKill(self) 85 | 86 | if kill.confirmed then 87 | GameHUD.ShowMessage('Kill #' .. kill.number .. ' ' .. kill.groups[1]) 88 | 89 | print('[KillStats] Kill #' .. kill.number .. ' (' .. table.concat(kill.groups, ', ') .. ')') 90 | end 91 | end) 92 | 93 | GameSession.TryLoad() 94 | end) 95 | 96 | registerForEvent('onShutdown', function() 97 | GameSession.TrySave() 98 | end) 99 | -------------------------------------------------------------------------------- /mods/GameSession-KillStats/sessions/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/psiberx/cp2077-cet-kit/f64c837e589ffffc030e79ed0123688eb3091098/mods/GameSession-KillStats/sessions/.keep -------------------------------------------------------------------------------- /mods/GameSession-Reload/GameSession.lua: -------------------------------------------------------------------------------- 1 | --[[ 2 | GameSession.lua 3 | Reactive Session Observer 4 | Persistent Session Manager 5 | 6 | Copyright (c) 2021 psiberx 7 | ]] 8 | 9 | local GameSession = { 10 | version = '1.4.5', 11 | framework = '1.19.0' 12 | } 13 | 14 | GameSession.Event = { 15 | Start = 'Start', 16 | End = 'End', 17 | Pause = 'Pause', 18 | Blur = 'Blur', 19 | Resume = 'Resume', 20 | Death = 'Death', 21 | Update = 'Update', 22 | Load = 'Load', 23 | Save = 'Save', 24 | Clean = 'Clean', 25 | LoadData = 'LoadData', 26 | SaveData = 'SaveData', 27 | } 28 | 29 | GameSession.Scope = { 30 | Session = 'Session', 31 | Pause = 'Pause', 32 | Blur = 'Blur', 33 | Death = 'Death', 34 | Saves = 'Saves', 35 | Persistence = 'Persistence', 36 | } 37 | 38 | local initialized = {} 39 | local listeners = {} 40 | 41 | local eventScopes = { 42 | [GameSession.Event.Update] = {}, 43 | [GameSession.Event.Load] = { [GameSession.Scope.Saves] = true }, 44 | [GameSession.Event.Save] = { [GameSession.Scope.Saves] = true }, 45 | [GameSession.Event.Clean] = { [GameSession.Scope.Saves] = true }, 46 | [GameSession.Event.LoadData] = { [GameSession.Scope.Saves] = true, [GameSession.Scope.Persistence] = true }, 47 | [GameSession.Event.SaveData] = { [GameSession.Scope.Saves] = true, [GameSession.Scope.Persistence] = true }, 48 | } 49 | 50 | local isLoaded = false 51 | local isPaused = true 52 | local isBlurred = false 53 | local isDead = false 54 | 55 | local sessionDataDir 56 | local sessionDataRef 57 | local sessionDataTmpl 58 | local sessionDataRelaxed = false 59 | 60 | local sessionKeyValue = 0 61 | local sessionKeyFactName = '_psxgs_session_key' 62 | 63 | -- Error Handling -- 64 | 65 | local function raiseError(msg) 66 | print('[GameSession] ' .. msg) 67 | error(msg, 2) 68 | end 69 | 70 | -- Event Dispatching -- 71 | 72 | local function addEventListener(event, callback) 73 | if not listeners[event] then 74 | listeners[event] = {} 75 | end 76 | 77 | table.insert(listeners[event], callback) 78 | end 79 | 80 | local function dispatchEvent(event, state) 81 | if listeners[event] then 82 | state.event = event 83 | 84 | for _, callback in ipairs(listeners[event]) do 85 | callback(state) 86 | end 87 | 88 | state.event = nil 89 | end 90 | end 91 | 92 | -- State Observing -- 93 | 94 | local stateProps = { 95 | { current = 'isLoaded', previous = 'wasLoaded', event = { on = GameSession.Event.Start, off = GameSession.Event.End, scope = GameSession.Scope.Session } }, 96 | { current = 'isPaused', previous = 'wasPaused', event = { on = GameSession.Event.Pause, off = GameSession.Event.Resume, scope = GameSession.Scope.Pause } }, 97 | { current = 'isBlurred', previous = 'wasBlurred', event = { on = GameSession.Event.Blur, off = GameSession.Event.Resume, scope = GameSession.Scope.Blur } }, 98 | { current = 'isDead', previous = 'wasWheel', event = { on = GameSession.Event.Death, scope = GameSession.Scope.Death } }, 99 | { current = 'timestamp' }, 100 | { current = 'timestamps' }, 101 | { current = 'sessionKey' }, 102 | } 103 | 104 | local previousState = {} 105 | 106 | local function updateLoaded(loaded) 107 | local changed = isLoaded ~= loaded 108 | 109 | isLoaded = loaded 110 | 111 | return changed 112 | end 113 | 114 | local function updatePaused(isMenuActive) 115 | isPaused = not isLoaded or isMenuActive 116 | end 117 | 118 | local function updateBlurred(isBlurActive) 119 | isBlurred = isBlurActive 120 | end 121 | 122 | local function updateDead(isPlayerDead) 123 | isDead = isPlayerDead 124 | end 125 | 126 | local function isPreGame() 127 | return GetSingleton('inkMenuScenario'):GetSystemRequestsHandler():IsPreGame() 128 | end 129 | 130 | local function refreshCurrentState() 131 | local player = Game.GetPlayer() 132 | local blackboardDefs = Game.GetAllBlackboardDefs() 133 | local blackboardUI = Game.GetBlackboardSystem():Get(blackboardDefs.UI_System) 134 | local blackboardPM = Game.GetBlackboardSystem():Get(blackboardDefs.PhotoMode) 135 | 136 | local menuActive = blackboardUI:GetBool(blackboardDefs.UI_System.IsInMenu) 137 | local blurActive = blackboardUI:GetBool(blackboardDefs.UI_System.CircularBlurEnabled) 138 | local photoModeActive = blackboardPM:GetBool(blackboardDefs.PhotoMode.IsActive) 139 | local tutorialActive = Game.GetTimeSystem():IsTimeDilationActive('UI_TutorialPopup') 140 | 141 | if not isLoaded then 142 | updateLoaded(player:IsAttached() and not isPreGame()) 143 | end 144 | 145 | updatePaused(menuActive or photoModeActive or tutorialActive) 146 | updateBlurred(blurActive) 147 | updateDead(player:IsDeadNoStatPool()) 148 | end 149 | 150 | local function determineEvents(currentState) 151 | local events = { GameSession.Event.Update } 152 | local firing = {} 153 | 154 | for _, stateProp in ipairs(stateProps) do 155 | local currentValue = currentState[stateProp.current] 156 | local previousValue = previousState[stateProp.current] 157 | 158 | if stateProp.event and (not stateProp.parent or currentState[stateProp.parent]) then 159 | local reqSatisfied = true 160 | 161 | if stateProp.event.reqs then 162 | for reqProp, reqValue in pairs(stateProp.event.reqs) do 163 | if tostring(currentState[reqProp]) ~= tostring(reqValue) then 164 | reqSatisfied = false 165 | break 166 | end 167 | end 168 | end 169 | 170 | if reqSatisfied then 171 | if stateProp.event.change and previousValue ~= nil then 172 | if tostring(currentValue) ~= tostring(previousValue) then 173 | if not firing[stateProp.event.change] then 174 | table.insert(events, stateProp.event.change) 175 | firing[stateProp.event.change] = true 176 | end 177 | end 178 | end 179 | 180 | if stateProp.event.on and currentValue and not previousValue then 181 | if not firing[stateProp.event.on] then 182 | table.insert(events, stateProp.event.on) 183 | firing[stateProp.event.on] = true 184 | end 185 | elseif stateProp.event.off and not currentValue and previousValue then 186 | if not firing[stateProp.event.off] then 187 | table.insert(events, 1, stateProp.event.off) 188 | firing[stateProp.event.off] = true 189 | end 190 | end 191 | end 192 | end 193 | end 194 | 195 | return events 196 | end 197 | 198 | local function notifyObservers() 199 | local currentState = GameSession.GetState() 200 | local stateChanged = false 201 | 202 | for _, stateProp in ipairs(stateProps) do 203 | local currentValue = currentState[stateProp.current] 204 | local previousValue = previousState[stateProp.current] 205 | 206 | if tostring(currentValue) ~= tostring(previousValue) then 207 | stateChanged = true 208 | break 209 | end 210 | end 211 | 212 | if stateChanged then 213 | local events = determineEvents(currentState) 214 | 215 | for _, event in ipairs(events) do 216 | if listeners[event] then 217 | if event ~= GameSession.Event.Update then 218 | currentState.event = event 219 | end 220 | 221 | for _, callback in ipairs(listeners[event]) do 222 | callback(currentState) 223 | end 224 | 225 | currentState.event = nil 226 | end 227 | end 228 | 229 | previousState = currentState 230 | end 231 | end 232 | 233 | local function pushCurrentState() 234 | previousState = GameSession.GetState() 235 | end 236 | 237 | -- Session Key -- 238 | 239 | local function generateSessionKey() 240 | return os.time() 241 | end 242 | 243 | local function getSessionKey() 244 | return sessionKeyValue 245 | end 246 | 247 | local function setSessionKey(sessionKey) 248 | sessionKeyValue = sessionKey 249 | end 250 | 251 | local function isEmptySessionKey(sessionKey) 252 | if not sessionKey then 253 | sessionKey = getSessionKey() 254 | end 255 | 256 | return not sessionKey or sessionKey == 0 257 | end 258 | 259 | local function readSessionKey() 260 | return Game.GetQuestsSystem():GetFactStr(sessionKeyFactName) 261 | end 262 | 263 | local function writeSessionKey(sessionKey) 264 | Game.GetQuestsSystem():SetFactStr(sessionKeyFactName, sessionKey) 265 | end 266 | 267 | local function initSessionKey() 268 | local sessionKey = readSessionKey() 269 | 270 | if isEmptySessionKey(sessionKey) then 271 | sessionKey = generateSessionKey() 272 | writeSessionKey(sessionKey) 273 | end 274 | 275 | setSessionKey(sessionKey) 276 | end 277 | 278 | local function renewSessionKey() 279 | local sessionKey = getSessionKey() 280 | local savedKey = readSessionKey() 281 | local nextKey = generateSessionKey() 282 | 283 | if sessionKey == savedKey or savedKey < nextKey - 1 then 284 | sessionKey = generateSessionKey() 285 | writeSessionKey(sessionKey) 286 | else 287 | sessionKey = savedKey 288 | end 289 | 290 | setSessionKey(sessionKey) 291 | end 292 | 293 | local function setSessionKeyName(sessionKeyName) 294 | sessionKeyFactName = sessionKeyName 295 | end 296 | 297 | -- Session Data -- 298 | 299 | local function exportSessionData(t, max, depth, result) 300 | if type(t) ~= 'table' then 301 | return '{}' 302 | end 303 | 304 | max = max or 63 305 | depth = depth or 0 306 | 307 | local indent = string.rep('\t', depth) 308 | local output = result or {} 309 | 310 | table.insert(output, '{\n') 311 | 312 | for k, v in pairs(t) do 313 | local ktype = type(k) 314 | local vtype = type(v) 315 | 316 | local kstr = '' 317 | if ktype == 'string' then 318 | kstr = string.format('[%q] = ', k) 319 | else 320 | kstr = string.format('[%s] = ', tostring(k)) 321 | end 322 | 323 | local vstr = '' 324 | if vtype == 'string' then 325 | vstr = string.format('%q', v) 326 | elseif vtype == 'table' then 327 | if depth < max then 328 | table.insert(output, string.format('\t%s%s', indent, kstr)) 329 | exportSessionData(v, max, depth + 1, output) 330 | table.insert(output, ',\n') 331 | end 332 | elseif vtype == 'userdata' then 333 | vstr = tostring(v) 334 | if vstr:find('^userdata:') or vstr:find('^sol%.') then 335 | if not sessionDataRelaxed then 336 | --vtype = vstr:match('^sol%.(.+):') 337 | if ktype == 'string' then 338 | raiseError(('Cannot store userdata in the %q field.'):format(k)) 339 | --raiseError(('Cannot store userdata of type %q in the %q field.'):format(vtype, k)) 340 | else 341 | raiseError(('Cannot store userdata in the list.')) 342 | --raiseError(('Cannot store userdata of type %q.'):format(vtype)) 343 | end 344 | else 345 | vstr = '' 346 | end 347 | end 348 | elseif vtype == 'function' or vtype == 'thread' then 349 | if not sessionDataRelaxed then 350 | if ktype == 'string' then 351 | raiseError(('Cannot store %s in the %q field.'):format(vtype, k)) 352 | else 353 | raiseError(('Cannot store %s.'):format(vtype)) 354 | end 355 | end 356 | else 357 | vstr = tostring(v) 358 | end 359 | 360 | if vstr ~= '' then 361 | table.insert(output, string.format('\t%s%s%s,\n', indent, kstr, vstr)) 362 | end 363 | end 364 | 365 | if not result and #output == 1 then 366 | return '{}' 367 | end 368 | 369 | table.insert(output, indent .. '}') 370 | 371 | if not result then 372 | return table.concat(output) 373 | end 374 | end 375 | 376 | local function importSessionData(s) 377 | local chunk = loadstring('return ' .. s, '') 378 | 379 | return chunk and chunk() or {} 380 | end 381 | 382 | -- Session File IO -- 383 | 384 | local function irpairs(tbl) 385 | local function iter(t, i) 386 | i = i - 1 387 | if i ~= 0 then 388 | return i, t[i] 389 | end 390 | end 391 | 392 | return iter, tbl, #tbl + 1 393 | end 394 | 395 | local function findSessionTimestampByKey(targetKey, isTemporary) 396 | if sessionDataDir and not isEmptySessionKey(targetKey) then 397 | local pattern = '^' .. (isTemporary and '!' or '') .. '(%d+)%.lua$' 398 | 399 | for _, sessionFile in irpairs(dir(sessionDataDir)) do 400 | if sessionFile.name:find(pattern) then 401 | local sessionReader = io.open(sessionDataDir .. '/' .. sessionFile.name, 'r') 402 | local sessionHeader = sessionReader:read('l') 403 | sessionReader:close() 404 | 405 | local sessionKeyStr = sessionHeader:match('^-- (%d+)$') 406 | if sessionKeyStr then 407 | local sessionKey = tonumber(sessionKeyStr) 408 | if sessionKey == targetKey then 409 | return tonumber((sessionFile.name:match(pattern))) 410 | end 411 | end 412 | end 413 | end 414 | end 415 | 416 | return nil 417 | end 418 | 419 | local function writeSessionFile(sessionTimestamp, sessionKey, isTemporary, sessionData) 420 | if not sessionDataDir then 421 | return 422 | end 423 | 424 | local sessionPath = sessionDataDir .. '/' .. (isTemporary and '!' or '') .. sessionTimestamp .. '.lua' 425 | local sessionFile = io.open(sessionPath, 'w') 426 | 427 | if not sessionFile then 428 | raiseError(('Cannot write session file %q.'):format(sessionPath)) 429 | end 430 | 431 | sessionFile:write('-- ') 432 | sessionFile:write(sessionKey) 433 | sessionFile:write('\n') 434 | sessionFile:write('return ') 435 | sessionFile:write(exportSessionData(sessionData)) 436 | sessionFile:close() 437 | end 438 | 439 | local function readSessionFile(sessionTimestamp, sessionKey, isTemporary) 440 | if not sessionDataDir then 441 | return nil 442 | end 443 | 444 | if not sessionTimestamp then 445 | sessionTimestamp = findSessionTimestampByKey(sessionKey, isTemporary) 446 | 447 | if not sessionTimestamp then 448 | return nil 449 | end 450 | end 451 | 452 | local sessionPath = sessionDataDir .. '/' .. (isTemporary and '!' or '') .. sessionTimestamp .. '.lua' 453 | local sessionChunk = loadfile(sessionPath) 454 | 455 | if type(sessionChunk) ~= 'function' then 456 | sessionPath = sessionDataDir .. '/' .. (sessionTimestamp + 1) .. '.lua' 457 | sessionChunk = loadfile(sessionPath) 458 | 459 | if type(sessionChunk) ~= 'function' then 460 | return nil 461 | end 462 | end 463 | 464 | return sessionChunk() 465 | end 466 | 467 | local function writeSessionFileFor(sessionMeta, sessionData) 468 | writeSessionFile(sessionMeta.timestamp, sessionMeta.sessionKey, sessionMeta.isTemporary, sessionData) 469 | end 470 | 471 | local function readSessionFileFor(sessionMeta) 472 | return readSessionFile(sessionMeta.timestamp, sessionMeta.sessionKey, sessionMeta.isTemporary) 473 | end 474 | 475 | local function removeSessionFile(sessionTimestamp, isTemporary) 476 | if not sessionDataDir then 477 | return 478 | end 479 | 480 | local sessionPath = sessionDataDir .. '/' .. (isTemporary and '!' or '') .. sessionTimestamp .. '.lua' 481 | 482 | os.remove(sessionPath) 483 | end 484 | 485 | local function cleanUpSessionFiles(sessionTimestamps) 486 | if not sessionDataDir then 487 | return 488 | end 489 | 490 | local validNames = {} 491 | 492 | for _, sessionTimestamp in ipairs(sessionTimestamps) do 493 | validNames[tostring(sessionTimestamp)] = true 494 | validNames[tostring(sessionTimestamp + 1)] = true 495 | end 496 | 497 | for _, sessionFile in pairs(dir(sessionDataDir)) do 498 | local sessionTimestamp = sessionFile.name:match('^!?(%d+)%.lua$') 499 | 500 | if sessionTimestamp and not validNames[sessionTimestamp] then 501 | os.remove(sessionDataDir .. '/' .. sessionFile.name) 502 | end 503 | end 504 | end 505 | 506 | -- Session Meta -- 507 | 508 | local function getSessionMetaForSaving(isTemporary) 509 | return { 510 | sessionKey = getSessionKey(), 511 | timestamp = os.time(), 512 | isTemporary = isTemporary, 513 | } 514 | end 515 | 516 | local function getSessionMetaForLoading(isTemporary) 517 | return { 518 | sessionKey = getSessionKey(), 519 | timestamp = findSessionTimestampByKey(getSessionKey(), isTemporary) or 0, 520 | isTemporary = isTemporary, 521 | } 522 | end 523 | 524 | local function extractSessionMetaForLoading(saveInfo) 525 | return { 526 | sessionKey = 0, -- Cannot be retrieved from save metadata 527 | timestamp = tonumber(saveInfo.timestamp), 528 | isTemporary = false, 529 | } 530 | end 531 | 532 | -- Initialization -- 533 | 534 | local function initialize(event) 535 | if not initialized.data then 536 | for _, stateProp in ipairs(stateProps) do 537 | if stateProp.event then 538 | local eventScope = stateProp.event.scope or stateProp.event.change 539 | 540 | if eventScope then 541 | for _, eventKey in ipairs({ 'change', 'on', 'off' }) do 542 | local eventName = stateProp.event[eventKey] 543 | 544 | if eventName then 545 | if not eventScopes[eventName] then 546 | eventScopes[eventName] = {} 547 | end 548 | 549 | eventScopes[eventName][eventScope] = true 550 | end 551 | end 552 | 553 | if eventScope ~= GameSession.Scope.Persistence then 554 | eventScopes[GameSession.Event.Update][eventScope] = true 555 | end 556 | end 557 | end 558 | end 559 | 560 | initialized.data = true 561 | end 562 | 563 | local required = eventScopes[event] or eventScopes[GameSession.Event.Update] 564 | 565 | -- Session State 566 | 567 | if required[GameSession.Scope.Session] and not initialized[GameSession.Scope.Session] then 568 | Observe('QuestTrackerGameController', 'OnInitialize', function() 569 | --spdlog.error(('QuestTrackerGameController::OnInitialize()')) 570 | 571 | if updateLoaded(true) then 572 | updatePaused(false) 573 | updateBlurred(false) 574 | updateDead(false) 575 | notifyObservers() 576 | end 577 | end) 578 | 579 | Observe('QuestTrackerGameController', 'OnUninitialize', function() 580 | --spdlog.error(('QuestTrackerGameController::OnUninitialize()')) 581 | 582 | if Game.GetPlayer() == nil then 583 | if updateLoaded(false) then 584 | updatePaused(true) 585 | updateBlurred(false) 586 | updateDead(false) 587 | notifyObservers() 588 | end 589 | end 590 | end) 591 | 592 | initialized[GameSession.Scope.Session] = true 593 | end 594 | 595 | -- Pause State 596 | 597 | if required[GameSession.Scope.Pause] and not initialized[GameSession.Scope.Pause] then 598 | local fastTravelActive, fastTravelStart 599 | 600 | Observe('gameuiPopupsManager', 'OnMenuUpdate', function(_, isInMenu) 601 | --spdlog.error(('gameuiPopupsManager::OnMenuUpdate(%s)'):format(tostring(isInMenu))) 602 | 603 | if not fastTravelActive then 604 | updatePaused(isInMenu) 605 | notifyObservers() 606 | end 607 | end) 608 | 609 | Observe('gameuiPhotoModeMenuController', 'OnShow', function() 610 | --spdlog.error(('PhotoModeMenuController::OnShow()')) 611 | 612 | updatePaused(true) 613 | notifyObservers() 614 | end) 615 | 616 | Observe('gameuiPhotoModeMenuController', 'OnHide', function() 617 | --spdlog.error(('PhotoModeMenuController::OnHide()')) 618 | 619 | updatePaused(false) 620 | notifyObservers() 621 | end) 622 | 623 | Observe('gameuiTutorialPopupGameController', 'PauseGame', function(_, tutorialActive) 624 | --spdlog.error(('gameuiTutorialPopupGameController::PauseGame(%s)'):format(tostring(tutorialActive))) 625 | 626 | updatePaused(tutorialActive) 627 | notifyObservers() 628 | end) 629 | 630 | Observe('FastTravelSystem', 'OnUpdateFastTravelPointRecordRequest', function(_, request) 631 | --spdlog.error(('FastTravelSystem::OnUpdateFastTravelPointRecordRequest()')) 632 | 633 | fastTravelStart = request.pointRecord 634 | end) 635 | 636 | Observe('FastTravelSystem', 'OnPerformFastTravelRequest', function(self, request) 637 | --spdlog.error(('FastTravelSystem::OnPerformFastTravelRequest()')) 638 | 639 | if self.isFastTravelEnabledOnMap then 640 | local fastTravelDestination = request.pointData and request.pointData.pointRecord or nil 641 | 642 | if tostring(fastTravelStart) ~= tostring(fastTravelDestination) then 643 | fastTravelActive = true 644 | else 645 | fastTravelStart = nil 646 | end 647 | end 648 | end) 649 | 650 | Observe('FastTravelSystem', 'OnLoadingScreenFinished', function(_, finished) 651 | --spdlog.error(('FastTravelSystem::OnLoadingScreenFinished(%s)'):format(tostring(finished))) 652 | 653 | if finished then 654 | fastTravelActive = false 655 | fastTravelStart = nil 656 | updatePaused(false) 657 | notifyObservers() 658 | end 659 | end) 660 | 661 | initialized[GameSession.Scope.Pause] = true 662 | end 663 | 664 | -- Blur State 665 | 666 | if required[GameSession.Scope.Blur] and not initialized[GameSession.Scope.Blur] then 667 | local popupControllers = { 668 | ['PhoneDialerGameController'] = { 669 | ['Show'] = true, 670 | ['Hide'] = false, 671 | }, 672 | ['RadialWheelController'] = { 673 | ['RefreshSlots'] = { initialized = true }, 674 | ['Shutdown'] = false, 675 | }, 676 | ['VehicleRadioPopupGameController'] = { 677 | ['OnInitialize'] = true, 678 | ['OnClose'] = false, 679 | }, 680 | ['VehiclesManagerPopupGameController'] = { 681 | ['OnInitialize'] = true, 682 | ['OnClose'] = false, 683 | }, 684 | } 685 | 686 | for popupController, popupEvents in pairs(popupControllers) do 687 | for popupEvent, popupState in pairs(popupEvents) do 688 | Observe(popupController, popupEvent, function(self) 689 | --spdlog.error(('%s::%s()'):format(popupController, popupEvent)) 690 | 691 | if isLoaded then 692 | if type(popupState) == 'table' then 693 | local popupActive = true 694 | for prop, value in pairs(popupState) do 695 | if self[prop] ~= value then 696 | popupActive = false 697 | break 698 | end 699 | end 700 | updateBlurred(popupActive) 701 | else 702 | updateBlurred(popupState) 703 | end 704 | 705 | notifyObservers() 706 | end 707 | end) 708 | end 709 | end 710 | 711 | Observe('PhoneMessagePopupGameController', 'SetTimeDilatation', function(_, popupActive) 712 | --spdlog.error(('PhoneMessagePopupGameController::SetTimeDilatation()')) 713 | 714 | updateBlurred(popupActive) 715 | notifyObservers() 716 | end) 717 | 718 | initialized[GameSession.Scope.Blur] = true 719 | end 720 | 721 | -- Death State 722 | 723 | if required[GameSession.Scope.Death] and not initialized[GameSession.Scope.Death] then 724 | Observe('PlayerPuppet', 'OnDeath', function() 725 | --spdlog.error(('PlayerPuppet::OnDeath()')) 726 | 727 | updateDead(true) 728 | notifyObservers() 729 | end) 730 | 731 | initialized[GameSession.Scope.Death] = true 732 | end 733 | 734 | -- Saving and Loading 735 | 736 | if required[GameSession.Scope.Saves] and not initialized[GameSession.Scope.Saves] then 737 | local sessionLoadList = {} 738 | local sessionLoadRequest = {} 739 | 740 | if not isPreGame() then 741 | initSessionKey() 742 | end 743 | 744 | ---@param self PlayerPuppet 745 | Observe('PlayerPuppet', 'OnTakeControl', function(self) 746 | --spdlog.error(('PlayerPuppet::OnTakeControl()')) 747 | 748 | if self:GetEntityID().hash ~= 1ULL then 749 | return 750 | end 751 | 752 | if not isPreGame() then 753 | -- Expand load request with session key from facts 754 | sessionLoadRequest.sessionKey = readSessionKey() 755 | 756 | -- Try to resolve timestamp from session key 757 | if not sessionLoadRequest.timestamp then 758 | sessionLoadRequest.timestamp = findSessionTimestampByKey( 759 | sessionLoadRequest.sessionKey, 760 | sessionLoadRequest.isTemporary 761 | ) 762 | end 763 | 764 | -- Dispatch load event 765 | dispatchEvent(GameSession.Event.Load, sessionLoadRequest) 766 | end 767 | 768 | -- Reset session load request 769 | sessionLoadRequest = {} 770 | end) 771 | 772 | ---@param self PlayerPuppet 773 | Observe('PlayerPuppet', 'OnGameAttached', function(self) 774 | --spdlog.error(('PlayerPuppet::OnGameAttached()')) 775 | 776 | if self:IsReplacer() then 777 | return 778 | end 779 | 780 | if not isPreGame() then 781 | -- Store new session key in facts 782 | renewSessionKey() 783 | end 784 | end) 785 | 786 | Observe('LoadListItem', 'SetMetadata', function(_, saveInfo) 787 | if saveInfo == nil then 788 | saveInfo = _ 789 | end 790 | 791 | --spdlog.error(('LoadListItem::SetMetadata()')) 792 | 793 | -- Fill the session list from saves metadata 794 | sessionLoadList[saveInfo.saveIndex] = extractSessionMetaForLoading(saveInfo) 795 | end) 796 | 797 | Observe('LoadGameMenuGameController', 'LoadSaveInGame', function(_, saveIndex) 798 | --spdlog.error(('LoadGameMenuGameController::LoadSaveInGame(%d)'):format(saveIndex)) 799 | 800 | if #sessionLoadList == 0 then 801 | return 802 | end 803 | 804 | -- Make a load request from selected save 805 | sessionLoadRequest = sessionLoadList[saveIndex] 806 | 807 | -- Collect timestamps for existing saves 808 | local existingTimestamps = {} 809 | for _, sessionMeta in pairs(sessionLoadList) do 810 | table.insert(existingTimestamps, sessionMeta.timestamp) 811 | end 812 | 813 | -- Dispatch clean event 814 | dispatchEvent(GameSession.Event.Clean, { timestamps = existingTimestamps }) 815 | end) 816 | 817 | Observe('gameuiInGameMenuGameController', 'OnSavingComplete', function(_, success) 818 | if type(success) ~= 'boolean' then 819 | success = _ 820 | end 821 | 822 | --spdlog.error(('gameuiInGameMenuGameController::OnSavingComplete(%s)'):format(tostring(success))) 823 | 824 | if success then 825 | -- Dispatch 826 | dispatchEvent(GameSession.Event.Save, getSessionMetaForSaving()) 827 | 828 | -- Store new session key in facts 829 | renewSessionKey() 830 | end 831 | end) 832 | 833 | initialized[GameSession.Scope.Saves] = true 834 | end 835 | 836 | -- Persistence 837 | 838 | if required[GameSession.Scope.Persistence] and not initialized[GameSession.Scope.Persistence] then 839 | addEventListener(GameSession.Event.Save, function(sessionMeta) 840 | local sessionData = sessionDataRef or {} 841 | 842 | dispatchEvent(GameSession.Event.SaveData, sessionData) 843 | 844 | writeSessionFileFor(sessionMeta, sessionData) 845 | end) 846 | 847 | addEventListener(GameSession.Event.Load, function(sessionMeta) 848 | local sessionData = readSessionFileFor(sessionMeta) or {} 849 | 850 | if sessionDataTmpl then 851 | local defaultData = importSessionData(sessionDataTmpl) 852 | for prop, value in pairs(defaultData) do 853 | if sessionData[prop] == nil then 854 | sessionData[prop] = value 855 | end 856 | end 857 | end 858 | 859 | dispatchEvent(GameSession.Event.LoadData, sessionData) 860 | 861 | if sessionDataRef then 862 | for prop, _ in pairs(sessionDataRef) do 863 | sessionDataRef[prop] = nil 864 | end 865 | 866 | for prop, value in pairs(sessionData) do 867 | sessionDataRef[prop] = value 868 | end 869 | end 870 | 871 | if sessionMeta.isTemporary then 872 | removeSessionFile(sessionMeta.timestamp, true) 873 | end 874 | end) 875 | 876 | addEventListener(GameSession.Event.Clean, function(sessionMeta) 877 | cleanUpSessionFiles(sessionMeta.timestamps) 878 | end) 879 | 880 | initialized[GameSession.Scope.Persistence] = true 881 | end 882 | 883 | -- Initial state 884 | 885 | if not initialized.state then 886 | refreshCurrentState() 887 | pushCurrentState() 888 | 889 | initialized.state = true 890 | end 891 | end 892 | 893 | -- Public Interface -- 894 | 895 | function GameSession.Observe(event, callback) 896 | if type(event) == 'string' then 897 | initialize(event) 898 | elseif type(event) == 'function' then 899 | callback, event = event, GameSession.Event.Update 900 | initialize(event) 901 | else 902 | if not event then 903 | initialize(GameSession.Event.Update) 904 | elseif type(event) == 'table' then 905 | for _, evt in ipairs(event) do 906 | GameSession.Observe(evt, callback) 907 | end 908 | end 909 | return 910 | end 911 | 912 | if type(callback) == 'function' then 913 | addEventListener(event, callback) 914 | end 915 | end 916 | 917 | function GameSession.Listen(event, callback) 918 | if type(event) == 'function' then 919 | initialize(GameSession.Event.Update) 920 | callback = event 921 | for _, evt in pairs(GameSession.Event) do 922 | if evt ~= GameSession.Event.Update and not eventScopes[evt][GameSession.Scope.Persistence] then 923 | GameSession.Observe(evt, callback) 924 | end 925 | end 926 | else 927 | GameSession.Observe(event, callback) 928 | end 929 | end 930 | 931 | GameSession.On = GameSession.Listen 932 | 933 | setmetatable(GameSession, { 934 | __index = function(_, key) 935 | local event = string.match(key, '^On(%w+)$') 936 | 937 | if event and GameSession.Event[event] then 938 | rawset(GameSession, key, function(callback) 939 | GameSession.Observe(event, callback) 940 | end) 941 | 942 | return rawget(GameSession, key) 943 | end 944 | end 945 | }) 946 | 947 | function GameSession.IsLoaded() 948 | return isLoaded 949 | end 950 | 951 | function GameSession.IsPaused() 952 | return isPaused 953 | end 954 | 955 | function GameSession.IsBlurred() 956 | return isBlurred 957 | end 958 | 959 | function GameSession.IsDead() 960 | return isDead 961 | end 962 | 963 | function GameSession.GetKey() 964 | return getSessionKey() 965 | end 966 | 967 | function GameSession.GetState() 968 | local currentState = {} 969 | 970 | currentState.isLoaded = GameSession.IsLoaded() 971 | currentState.isPaused = GameSession.IsPaused() 972 | currentState.isBlurred = GameSession.IsBlurred() 973 | currentState.isDead = GameSession.IsDead() 974 | 975 | for _, stateProp in ipairs(stateProps) do 976 | if stateProp.previous then 977 | currentState[stateProp.previous] = previousState[stateProp.current] 978 | end 979 | end 980 | 981 | return currentState 982 | end 983 | 984 | local function exportValue(value) 985 | if type(value) == 'userdata' then 986 | value = string.format('%q', value.value) 987 | elseif type(value) == 'string' then 988 | value = string.format('%q', value) 989 | elseif type(value) == 'table' then 990 | value = '{ ' .. table.concat(value, ', ') .. ' }' 991 | else 992 | value = tostring(value) 993 | end 994 | 995 | return value 996 | end 997 | 998 | function GameSession.ExportState(state) 999 | local export = {} 1000 | 1001 | if state.event then 1002 | table.insert(export, 'event = ' .. string.format('%q', state.event)) 1003 | end 1004 | 1005 | for _, stateProp in ipairs(stateProps) do 1006 | local value = state[stateProp.current] 1007 | 1008 | if value and (not stateProp.parent or state[stateProp.parent]) then 1009 | table.insert(export, stateProp.current .. ' = ' .. exportValue(value)) 1010 | end 1011 | end 1012 | 1013 | for _, stateProp in ipairs(stateProps) do 1014 | if stateProp.previous then 1015 | local currentValue = state[stateProp.current] 1016 | local previousValue = state[stateProp.previous] 1017 | 1018 | if previousValue and previousValue ~= currentValue then 1019 | table.insert(export, stateProp.previous .. ' = ' .. exportValue(previousValue)) 1020 | end 1021 | end 1022 | end 1023 | 1024 | return '{ ' .. table.concat(export, ', ') .. ' }' 1025 | end 1026 | 1027 | function GameSession.PrintState(state) 1028 | print('[GameSession] ' .. GameSession.ExportState(state)) 1029 | end 1030 | 1031 | function GameSession.IdentifyAs(sessionName) 1032 | setSessionKeyName(sessionName) 1033 | end 1034 | 1035 | function GameSession.StoreInDir(sessionDir) 1036 | sessionDataDir = sessionDir 1037 | 1038 | initialize(GameSession.Event.SaveData) 1039 | end 1040 | 1041 | function GameSession.Persist(sessionData, relaxedMode) 1042 | if type(sessionData) ~= 'table' then 1043 | raiseError(('Session data must be a table, received %s.'):format(type(sessionData))) 1044 | end 1045 | 1046 | sessionDataRef = sessionData 1047 | sessionDataRelaxed = relaxedMode and true or false 1048 | sessionDataTmpl = exportSessionData(sessionData) 1049 | 1050 | initialize(GameSession.Event.SaveData) 1051 | end 1052 | 1053 | function GameSession.TrySave() 1054 | if Game.GetSettingsSystem() then 1055 | dispatchEvent(GameSession.Event.Save, getSessionMetaForSaving(true)) 1056 | end 1057 | end 1058 | 1059 | function GameSession.TryLoad() 1060 | if not isPreGame() and not isEmptySessionKey() then 1061 | dispatchEvent(GameSession.Event.Load, getSessionMetaForLoading(true)) 1062 | end 1063 | end 1064 | 1065 | return GameSession -------------------------------------------------------------------------------- /mods/GameSession-Reload/init.lua: -------------------------------------------------------------------------------- 1 | local GameSession = require('GameSession') 2 | 3 | local state = { runtime = 0 } 4 | 5 | registerForEvent('onInit', function() 6 | GameSession.StoreInDir('sessions') 7 | GameSession.Persist(state) 8 | GameSession.OnLoad(function() 9 | -- This is not reset when the mod is reloaded 10 | print(('Runtime: %.2f s'):format(state.runtime)) 11 | end) 12 | GameSession.TryLoad() -- Load temp session 13 | end) 14 | 15 | registerForEvent('onUpdate', function(delta) 16 | state.runtime = state.runtime + delta -- Some test data 17 | end) 18 | 19 | registerForEvent('onShutdown', function() 20 | GameSession.TrySave() -- Save temp session 21 | end) -------------------------------------------------------------------------------- /mods/GameSession-Reload/sessions/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/psiberx/cp2077-cet-kit/f64c837e589ffffc030e79ed0123688eb3091098/mods/GameSession-Reload/sessions/.keep -------------------------------------------------------------------------------- /mods/GameSettings-Demo/GameSettings.lua: -------------------------------------------------------------------------------- 1 | --[[ 2 | GameSettings.lua 3 | Game Settings Manager 4 | 5 | Copyright (c) 2021 psiberx 6 | ]] 7 | 8 | local GameSettings = { version = '1.0.4' } 9 | 10 | local module = {} 11 | 12 | function module.parsePath(setting) 13 | return setting:match('^(/.+)/([A-Za-z0-9_]+)$') 14 | end 15 | 16 | function module.makePath(groupPath, varName) 17 | return groupPath .. '/' .. varName 18 | end 19 | 20 | function module.isBoolType(target) 21 | if type(target) == 'userdata' then 22 | target = target.value 23 | end 24 | 25 | return target == 'Bool' 26 | end 27 | 28 | function module.isNameType(target) 29 | if type(target) == 'userdata' then 30 | target = target.value 31 | end 32 | 33 | return target == 'Name' or target == 'NameList' 34 | end 35 | 36 | function module.isNumberType(target) 37 | if type(target) == 'userdata' then 38 | target = target.value 39 | end 40 | 41 | return target == 'Int' or target == 'Float' 42 | end 43 | 44 | function module.isIntType(target) 45 | if type(target) == 'userdata' then 46 | target = target.value 47 | end 48 | 49 | return target == 'Int' or target == 'IntList' 50 | end 51 | 52 | function module.isFloatType(target) 53 | if type(target) == 'userdata' then 54 | target = target.value 55 | end 56 | 57 | return target == 'Float' or target == 'FloatList' 58 | end 59 | 60 | function module.isListType(target) 61 | if type(target) == 'userdata' then 62 | target = target.value 63 | end 64 | 65 | return target == 'IntList' or target == 'FloatList' or target == 'StringList' or target == 'NameList' 66 | end 67 | 68 | function module.exportVar(var) 69 | local output = {} 70 | 71 | output.path = module.makePath(Game.NameToString(var:GetGroupPath()), Game.NameToString(var:GetName())) 72 | output.value = var:GetValue() 73 | output.type = var:GetType().value 74 | 75 | if module.isNameType(output.type) then 76 | output.value = Game.NameToString(output.value) 77 | end 78 | 79 | if module.isNumberType(output.type) then 80 | output.min = var:GetMinValue() 81 | output.max = var:GetMaxValue() 82 | output.step = var:GetStepValue() 83 | end 84 | 85 | if module.isListType(output.type) then 86 | output.index = var:GetIndex() + 1 87 | output.options = var:GetValues() 88 | 89 | if module.isNameType(output.type) then 90 | for i, option in ipairs(output.options) do 91 | output.options[i] = Game.NameToString(option) 92 | end 93 | end 94 | end 95 | 96 | return output 97 | end 98 | 99 | function module.exportVars(isPreGame, group, output) 100 | if type(group) ~= 'userdata' then 101 | group = Game.GetSettingsSystem():GetRootGroup() 102 | end 103 | 104 | if type(isPreGame) ~= 'bool' then 105 | isPreGame = GetSingleton('inkMenuScenario'):GetSystemRequestsHandler():IsPreGame() 106 | end 107 | 108 | if not output then 109 | output = {} 110 | end 111 | 112 | for _, var in ipairs(group:GetVars(isPreGame)) do 113 | table.insert(output, module.exportVar(var)) 114 | end 115 | 116 | for _, child in ipairs(group:GetGroups(isPreGame)) do 117 | module.exportVars(isPreGame, child, output) 118 | end 119 | 120 | table.sort(output, function(a, b) 121 | return a.path < b.path 122 | end) 123 | 124 | return output 125 | end 126 | 127 | function GameSettings.Has(setting) 128 | local path, name = module.parsePath(setting) 129 | 130 | return Game.GetSettingsSystem():HasVar(path, name) 131 | end 132 | 133 | function GameSettings.Var(setting) 134 | local path, name = module.parsePath(setting) 135 | 136 | local var = Game.GetSettingsSystem():GetVar(path, name) 137 | 138 | if not var then 139 | return nil 140 | end 141 | 142 | return module.exportVar(var) 143 | end 144 | 145 | function GameSettings.Get(setting) 146 | local path, name = module.parsePath(setting) 147 | 148 | local var = Game.GetSettingsSystem():GetVar(path, name) 149 | 150 | if not var then 151 | return nil 152 | end 153 | 154 | return var:GetValue() 155 | end 156 | 157 | function GameSettings.GetIndex(setting) 158 | local path, name = module.parsePath(setting) 159 | 160 | local var = Game.GetSettingsSystem():GetVar(path, name) 161 | 162 | if not var or not module.isListType(var:GetType()) then 163 | return nil 164 | end 165 | 166 | return var:GetIndex() + 1 167 | end 168 | 169 | function GameSettings.Set(setting, value) 170 | local path, name = module.parsePath(setting) 171 | local var = Game.GetSettingsSystem():GetVar(path, name) 172 | 173 | if not var then 174 | return 175 | end 176 | 177 | if module.isListType(var:GetType()) then 178 | local index = var:GetIndexFor(value) 179 | 180 | if index then 181 | var:SetIndex(index) 182 | end 183 | else 184 | var:SetValue(value) 185 | end 186 | end 187 | 188 | function GameSettings.SetIndex(setting, index) 189 | local path, name = module.parsePath(setting) 190 | 191 | local var = Game.GetSettingsSystem():GetVar(path, name) 192 | 193 | if not var or not module.isListType(var:GetType()) then 194 | return 195 | end 196 | 197 | var:SetIndex(index - 1) 198 | end 199 | 200 | function GameSettings.Toggle(setting) 201 | local path, name = module.parsePath(setting) 202 | 203 | local var = Game.GetSettingsSystem():GetVar(path, name) 204 | 205 | if not var or not module.isBoolType(var:GetType()) then 206 | return 207 | end 208 | 209 | var:Toggle() 210 | end 211 | 212 | function GameSettings.ToggleAll(settings) 213 | local state = not GameSettings.Get(settings[1]) 214 | 215 | for _, setting in ipairs(settings) do 216 | GameSettings.Set(setting, state) 217 | end 218 | end 219 | 220 | function GameSettings.ToggleGroup(path) 221 | local group = Game.GetSettingsSystem():GetGroup(path) 222 | local vars = group:GetVars(false) 223 | local state = nil 224 | 225 | for _, var in ipairs(vars) do 226 | if module.isBoolType(var:GetType()) then 227 | -- Invert the first bool option 228 | if state == nil then 229 | state = not var:GetValue() 230 | end 231 | 232 | var:SetValue(state) 233 | end 234 | end 235 | end 236 | 237 | -- set all booleans in the group to val 238 | function GameSettings.SetGroupBool(path, val) 239 | local group = Game.GetSettingsSystem():GetGroup(path) 240 | local vars = group:GetVars(false) 241 | 242 | for _, var in ipairs(vars) do 243 | if module.isBoolType(var:GetType()) then 244 | var:SetValue(val) 245 | end 246 | end 247 | end 248 | 249 | function GameSettings.Options(setting) 250 | local path, name = module.parsePath(setting) 251 | 252 | local var = Game.GetSettingsSystem():GetVar(path, name) 253 | 254 | if not var or not module.isListType(var:GetType()) then 255 | return nil 256 | end 257 | 258 | return var:GetValues(), var:GetIndex() + 1 259 | end 260 | 261 | function GameSettings.Reset(setting) 262 | local path, name = module.parsePath(setting) 263 | 264 | local var = Game.GetSettingsSystem():GetVar(path, name) 265 | 266 | if not var then 267 | return 268 | end 269 | 270 | var:RestoreDefault() 271 | end 272 | 273 | function GameSettings.NeedsConfirmation() 274 | return Game.GetSettingsSystem():NeedsConfirmation() 275 | end 276 | 277 | function GameSettings.NeedsReload() 278 | return Game.GetSettingsSystem():NeedsLoadLastCheckpoint() 279 | end 280 | 281 | function GameSettings.NeedsRestart() 282 | return Game.GetSettingsSystem():NeedsRestartToApply() 283 | end 284 | 285 | function GameSettings.Confirm() 286 | Game.GetSettingsSystem():ConfirmChanges() 287 | end 288 | 289 | function GameSettings.Reject() 290 | Game.GetSettingsSystem():RejectChanges() 291 | end 292 | 293 | function GameSettings.Save() 294 | GetSingleton('inkMenuScenario'):GetSystemRequestsHandler():RequestSaveUserSettings() 295 | end 296 | 297 | function GameSettings.ExportVars(isPreGame, group, output) 298 | return module.exportVars(isPreGame, group, output) 299 | end 300 | 301 | function GameSettings.Export(isPreGame) 302 | return module.exportVars(isPreGame) 303 | end 304 | 305 | function GameSettings.ExportTo(exportPath, isPreGame) 306 | local output = {} 307 | 308 | local vars = module.exportVars(isPreGame) 309 | 310 | for _, var in ipairs(vars) do 311 | local value = var.value 312 | local options 313 | 314 | if type(value) == 'string' then 315 | value = string.format('%q', value) 316 | end 317 | 318 | if var.options and #var.options > 1 then 319 | options = {} 320 | 321 | for i, option in ipairs(var.options) do 322 | --if type(option) == 'string' then 323 | -- option = string.format('%q', option) 324 | --end 325 | 326 | options[i] = option 327 | end 328 | 329 | options = ' -- ' .. table.concat(options, ' | ') 330 | elseif var.min then 331 | if module.isIntType(var.type) then 332 | options = (' -- %d to %d / %d'):format(var.min, var.max, var.step) 333 | else 334 | options = (' -- %.2f to %.2f / %.2f'):format(var.min, var.max, var.step) 335 | end 336 | end 337 | 338 | table.insert(output, (' ["%s"] = %s,%s'):format(var.path, value, options or '')) 339 | end 340 | 341 | table.insert(output, 1, '{') 342 | table.insert(output, '}') 343 | 344 | output = table.concat(output, '\n') 345 | 346 | if exportPath then 347 | if not exportPath:find('%.lua$') then 348 | exportPath = exportPath .. '.lua' 349 | end 350 | 351 | local exportFile = io.open(exportPath, 'w') 352 | 353 | if exportFile then 354 | exportFile:write('return ') 355 | exportFile:write(output) 356 | exportFile:close() 357 | end 358 | else 359 | return output 360 | end 361 | end 362 | 363 | function GameSettings.Import(settings) 364 | for setting, value in pairs(settings) do 365 | GameSettings.Set(setting, value) 366 | end 367 | end 368 | 369 | function GameSettings.ImportFrom(importPath) 370 | local importChunk = loadfile(importPath) 371 | 372 | if importChunk then 373 | GameSettings.Import(importChunk()) 374 | end 375 | end 376 | 377 | -- For importing the result of ExportVars directly 378 | function GameSettings.ImportVars(settings) 379 | for _, var in ipairs(settings) do 380 | GameSettings.Set(var.path, var.value) 381 | end 382 | end 383 | 384 | function GameSettings.DumpVars(isPreGame) 385 | return GameSettings.ExportTo(nil, isPreGame) 386 | end 387 | 388 | function GameSettings.PrintVars(isPreGame) 389 | print(GameSettings.DumpVars(isPreGame)) 390 | end 391 | 392 | return GameSettings -------------------------------------------------------------------------------- /mods/GameSettings-Demo/init.lua: -------------------------------------------------------------------------------- 1 | local GameSettings = require('GameSettings') 2 | 3 | registerHotkey('ExportSettings', 'Export all settings', function() 4 | GameSettings.ExportTo('settings.lua') 5 | end) 6 | 7 | registerHotkey('SwitchFOV', 'Switch FOV', function() 8 | local fov = GameSettings.Var('/graphics/basic/FieldOfView') 9 | 10 | fov.value = fov.value + fov.step 11 | 12 | if fov.value > fov.max then 13 | fov.value = fov.min 14 | end 15 | 16 | GameSettings.Set('/graphics/basic/FieldOfView', fov.value) 17 | 18 | if GameSettings.NeedsConfirmation() then 19 | GameSettings.Confirm() 20 | end 21 | 22 | print(('Current FOV: %.1f'):format(GameSettings.Get('/graphics/basic/FieldOfView'))) 23 | end) 24 | 25 | registerHotkey('SwitchResolution', 'Switch resolution', function() 26 | -- You can get available options and current selection for lists 27 | local options, current = GameSettings.Options('/video/display/Resolution') 28 | local next = current + 1 29 | 30 | if next > #options then 31 | next = 1 32 | end 33 | 34 | GameSettings.Set('/video/display/Resolution', options[next]) 35 | 36 | if GameSettings.NeedsConfirmation() then 37 | GameSettings.Confirm() 38 | end 39 | 40 | print(('Switched resolution from %s to %s'):format(options[current], options[next])) 41 | end) 42 | 43 | registerHotkey('ToggleHUD', 'Toggle HUD', function() 44 | -- Option 1: Toggle all settings in the group 45 | GameSettings.ToggleGroup('/interface/hud') 46 | 47 | -- Option 2: Toggle specific settings 48 | --GameSettings.ToggleAll({ 49 | -- '/interface/hud/action_buttons', 50 | -- '/interface/hud/activity_log', 51 | -- '/interface/hud/ammo_counter', 52 | -- '/interface/hud/healthbar', 53 | -- '/interface/hud/input_hints', 54 | -- '/interface/hud/johnny_hud', 55 | -- '/interface/hud/minimap', 56 | -- '/interface/hud/npc_healthbar', 57 | -- '/interface/hud/npc_names', 58 | -- '/interface/hud/object_markers', 59 | -- '/interface/hud/quest_tracker', 60 | -- '/interface/hud/stamina_oxygen', 61 | --}) 62 | end) 63 | 64 | registerHotkey('SwitchBlur', 'Switch blur', function() 65 | local options, current = GameSettings.Options('/graphics/basic/MotionBlur') 66 | local next = current + 1 67 | 68 | if next > #options then 69 | next = 1 70 | end 71 | 72 | GameSettings.Set('/graphics/basic/MotionBlur', options[next]) 73 | GameSettings.Save() -- Required for most graphics settings 74 | 75 | print(('Switched blur from %s to %s'):format(options[current], options[next])) 76 | end) 77 | -------------------------------------------------------------------------------- /mods/GameUI-Events/init.lua: -------------------------------------------------------------------------------- 1 | local GameUI = require('GameUI') 2 | 3 | registerForEvent('onInit', function() 4 | GameUI.Listen(function(state) 5 | GameUI.PrintState(state) 6 | end) 7 | end) 8 | -------------------------------------------------------------------------------- /mods/GameUI-Observe/init.lua: -------------------------------------------------------------------------------- 1 | local GameUI = require('GameUI') 2 | 3 | registerForEvent('onInit', function() 4 | GameUI.Observe(function(state) 5 | GameUI.PrintState(state) 6 | end) 7 | end) 8 | -------------------------------------------------------------------------------- /mods/GameUI-WhereAmI/init.lua: -------------------------------------------------------------------------------- 1 | local GameUI = require('GameUI') 2 | 3 | local WhereAmI = { 4 | visible = false, 5 | districtId = nil, 6 | districtLabels = nil, 7 | districtCaption = nil, 8 | factionLabels = nil, 9 | } 10 | 11 | function WhereAmI.Update() 12 | local preventionSystem = Game.GetScriptableSystemsContainer():Get('PreventionSystem') 13 | local districtManager = preventionSystem.districtManager 14 | 15 | if districtManager and districtManager:GetCurrentDistrict() then 16 | WhereAmI.districtId = districtManager:GetCurrentDistrict():GetDistrictID() 17 | WhereAmI.districtLabels = {} 18 | WhereAmI.factionLabels = {} 19 | 20 | local tweakDb = GetSingleton('gamedataTweakDBInterface') 21 | local districtRecord = tweakDb:GetDistrictRecord(WhereAmI.districtId) 22 | repeat 23 | local districtLabel = Game.GetLocalizedText(districtRecord:LocalizedName()) 24 | 25 | table.insert(WhereAmI.districtLabels, 1, districtLabel) 26 | 27 | for _, falctionRecord in ipairs(districtRecord:Gangs()) do 28 | local falctionLabel = Game.GetLocalizedTextByKey(falctionRecord:LocalizedName()) 29 | 30 | table.insert(WhereAmI.factionLabels, 1, falctionLabel) 31 | end 32 | 33 | districtRecord = districtRecord:ParentDistrict() 34 | until districtRecord == nil 35 | 36 | WhereAmI.districtCaption = table.concat(WhereAmI.districtLabels, ' / ') 37 | end 38 | end 39 | 40 | function WhereAmI.IsMiniMapEnabled() 41 | return Game.GetSettingsSystem():GetVar('/interface/hud', 'minimap'):GetValue() 42 | end 43 | 44 | function WhereAmI.Toggle(visible) 45 | WhereAmI.visible = visible and WhereAmI.IsMiniMapEnabled() 46 | end 47 | 48 | registerForEvent('onInit', function() 49 | WhereAmI.Update() 50 | 51 | Observe('DistrictManager', 'NotifySystem', function() 52 | WhereAmI.Update() 53 | end) 54 | 55 | GameUI.Observe(function(state) 56 | WhereAmI.Toggle(state.isDefault and not state.isJohnny) 57 | end) 58 | end) 59 | 60 | registerForEvent('onDraw', function() 61 | if WhereAmI.visible and WhereAmI.districtId then 62 | local windowWidth = 220 63 | local screenWidth, screenHeight = GetDisplayResolution() 64 | local screenRatioX, screenRatioY = screenWidth / 1920, screenHeight / 1200 65 | 66 | ImGui.SetNextWindowPos(screenWidth - windowWidth - 320 * screenRatioX, 68 * screenRatioY) 67 | ImGui.SetNextWindowSize(windowWidth, 0) 68 | 69 | ImGui.PushStyleVar(ImGuiStyleVar.WindowRounding, 8) 70 | ImGui.PushStyleVar(ImGuiStyleVar.WindowPadding, 8, 7) 71 | ImGui.PushStyleColor(ImGuiCol.WindowBg, 0xaa000000) 72 | ImGui.PushStyleColor(ImGuiCol.Border, 0x8ffefd01) 73 | 74 | ImGui.Begin('Where Am I', ImGuiWindowFlags.NoDecoration) 75 | 76 | for i, districtLabel in ipairs(WhereAmI.districtLabels) do 77 | if i == 1 then 78 | ImGui.PushStyleColor(ImGuiCol.Text, 0xfffefd01) 79 | ImGui.Text(districtLabel:upper()) 80 | else 81 | ImGui.PushStyleColor(ImGuiCol.Text, 0xff5461ff) 82 | ImGui.Text(districtLabel) 83 | end 84 | 85 | ImGui.PopStyleColor() 86 | end 87 | 88 | if #WhereAmI.factionLabels > 0 then 89 | for _, factionLabel in ipairs(WhereAmI.factionLabels) do 90 | ImGui.Text('· ' .. factionLabel) 91 | end 92 | end 93 | 94 | ImGui.End() 95 | 96 | ImGui.PopStyleColor(2) 97 | ImGui.PopStyleVar(2) 98 | end 99 | end) 100 | --------------------------------------------------------------------------------