├── .gitignore ├── gamedata ├── configs │ ├── ui │ │ ├── pda_taskboard.xml │ │ ├── textures_descr │ │ │ └── ui_pda_taskboard.xml │ │ ├── pda_taskboard_16.xml │ │ └── pda_16.xml │ └── text │ │ ├── rus │ │ ├── ui_pda_taskboard.xml │ │ └── ui_mcm_pda_taskboard.xml │ │ └── eng │ │ ├── ui_mcm_pda_taskboard.xml │ │ └── ui_pda_taskboard.xml ├── textures │ └── ui │ │ └── taskboard_icons.dds └── scripts │ ├── pda_taskboard_mcm.script │ ├── a_taskboard_utils.script │ ├── z_taskboard_overrides.script │ ├── optimized_time_events.script │ └── ui_pda_taskboard_tab.script ├── LICENSE └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | meta.ini -------------------------------------------------------------------------------- /gamedata/configs/ui/pda_taskboard.xml: -------------------------------------------------------------------------------- 1 | #include "ui\pda_taskboard_16.xml" -------------------------------------------------------------------------------- /gamedata/textures/ui/taskboard_icons.dds: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lTheon/iTheon-PDA-Taskboard/HEAD/gamedata/textures/ui/taskboard_icons.dds -------------------------------------------------------------------------------- /gamedata/configs/text/rus/ui_pda_taskboard.xml: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lTheon/iTheon-PDA-Taskboard/HEAD/gamedata/configs/text/rus/ui_pda_taskboard.xml -------------------------------------------------------------------------------- /gamedata/configs/text/rus/ui_mcm_pda_taskboard.xml: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lTheon/iTheon-PDA-Taskboard/HEAD/gamedata/configs/text/rus/ui_mcm_pda_taskboard.xml -------------------------------------------------------------------------------- /gamedata/configs/text/eng/ui_mcm_pda_taskboard.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | PDA Taskboard 6 | 7 | 8 | PDA Taskboard 9 | 10 | 11 | Scanning range 12 | 13 | 14 | Taskboard PDA shortcut 15 | 16 | 17 | Allow companions to give tasks 18 | 19 | 20 | -------------------------------------------------------------------------------- /gamedata/scripts/pda_taskboard_mcm.script: -------------------------------------------------------------------------------- 1 | -- Default config 2 | config = { 3 | scanning_range = 100, 4 | taskboard_key = DIK_keys.DIK_J, 5 | companions_give_tasks = false 6 | } 7 | 8 | op = { 9 | id = "pda_taskboard", sh = true, gr = { 10 | {id = "banner", type = "slide", size = {512, 50}, text="ui_mcm_pda_taskboard_banner", spacing = 20}, 11 | {id = "scanning_range", type = "track", val = 2, min = 50, max = 300, step = 1, def = config.scanning_range}, 12 | {id = "taskboard_key", type = "key_bind", val = 2, def = config.taskboard_key}, 13 | {id = "companions_give_tasks" , type = "check", val = 1, def = config.companions_give_tasks}, 14 | } 15 | } 16 | 17 | function on_mcm_load() 18 | return op 19 | end 20 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Rafał Gawlikowski 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 | -------------------------------------------------------------------------------- /gamedata/configs/text/eng/ui_pda_taskboard.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Accept task 5 | 6 | 7 | Task limit reached 8 | 9 | 10 | Refresh tasks 11 | 12 | 13 | Next task 14 | 15 | 16 | Taskboard 17 | 18 | 19 | 20 | Bounty Tasks 21 | 22 | 23 | Camp Assault Tasks 24 | 25 | 26 | Spy Extraction Tasks 27 | 28 | 29 | Delivery Tasks 30 | 31 | 32 | Measure Tasks 33 | 34 | 35 | Fate Tasks 36 | 37 | 38 | Dominance Tasks 39 | 40 | 41 | Fetch Tasks 42 | 43 | 44 | Scripted Tasks 45 | 46 | 47 | Recover Mutant Data 48 | 49 | 50 | Other Tasks 51 | 52 | 53 | -------------------------------------------------------------------------------- /gamedata/configs/ui/textures_descr/ui_pda_taskboard.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /gamedata/scripts/a_taskboard_utils.script: -------------------------------------------------------------------------------- 1 | -- These constants need to reflect the XML setup (where applicable) 2 | local stalker_icon_height = 64 3 | local task_icon_height = 47 4 | local row_vertical_padding = 10 5 | local row_margin_bottom = 5 6 | local row_width = 764 7 | local task_category_offset = 25 8 | 9 | function adjust_rows(pda_tab) 10 | for _, row in ipairs(pda_tab.rows) do 11 | row.stalker_info:AdjustHeightToText() 12 | row.task_details_field:AdjustHeightToText() 13 | row.task_full_description_field:AdjustHeightToText() 14 | 15 | local highest_column_height = math.max( 16 | get_stalker_info_column_height(row), 17 | get_task_details_column_height(row), 18 | get_task_full_description_column_height(row) 19 | ) 20 | 21 | local row_height = row_vertical_padding * 2 + row_margin_bottom + highest_column_height + task_category_offset 22 | local frame_height = row_vertical_padding * 2 + highest_column_height + task_category_offset 23 | 24 | row.frame:SetHeight(frame_height); 25 | row:SetWndSize(vector2():set(row_width, row_height)) 26 | end 27 | 28 | -- Force scrollable area to resize 29 | local fake = CUIWindow() 30 | pda_tab.list:AddWindow(fake) 31 | pda_tab.list:RemoveWindow(fake) 32 | pda_tab.list:SetScrollPos(pda_tab.list:GetCurrentScrollPos()) 33 | end 34 | 35 | function get_stalker_info_column_height(row) 36 | return stalker_icon_height + row.stalker_info:GetHeight() 37 | end 38 | 39 | function get_task_details_column_height(row) 40 | return task_icon_height + row.task_details_field:GetHeight() 41 | end 42 | 43 | function get_task_full_description_column_height(row) 44 | return row.task_full_description_field:GetHeight() 45 | end -------------------------------------------------------------------------------- /gamedata/scripts/z_taskboard_overrides.script: -------------------------------------------------------------------------------- 1 | tasks_info = {} 2 | function create_overrides() 3 | local original_give_talk_message2 = db.actor.give_talk_message2 4 | db.actor.give_talk_message2 = function (...) 5 | if ActorMenu.get_pda_menu():IsShown() then 6 | local _, task_title, task_details, task_icon = ... 7 | table.insert(tasks_info, { 8 | task_title = task_title, 9 | task_details = task_details, 10 | task_icon = task_icon 11 | }) 12 | else 13 | original_give_talk_message2(...) 14 | end 15 | 16 | end 17 | 18 | local original_GetTalkingNpc = mob_trade.GetTalkingNpc 19 | mob_trade.GetTalkingNpc = function (...) 20 | if ui_pda_taskboard_tab.currently_processed_npc_id then 21 | return level.object_by_id(ui_pda_taskboard_tab.currently_processed_npc_id) 22 | else 23 | return original_GetTalkingNpc(...) 24 | end 25 | end 26 | 27 | local original_is_talking = db.actor.is_talking 28 | db.actor.is_talking = function (...) 29 | if ui_pda_taskboard_tab.currently_processed_npc_id then 30 | return true 31 | else 32 | return original_is_talking(...) 33 | end 34 | end 35 | 36 | local original_get_speaker = _G.get_speaker 37 | _G.get_speaker = function (...) 38 | if ui_pda_taskboard_tab.currently_processed_npc_id then 39 | return level.object_by_id(ui_pda_taskboard_tab.currently_processed_npc_id) 40 | else 41 | return original_get_speaker(...) 42 | end 43 | end 44 | 45 | local original_set_active_subdialog = pda.set_active_subdialog 46 | pda.set_active_subdialog = function (section) 47 | if (section == "eptTaskboard") then 48 | return ui_pda_taskboard_tab.get_ui() 49 | else 50 | return original_set_active_subdialog(section) 51 | end 52 | end 53 | end 54 | 55 | function clear_tasks_info() 56 | tasks_info = {} 57 | end 58 | 59 | function push_task_info(task_info) 60 | table.insert(tasks_info, task_info) 61 | end 62 | 63 | function on_game_start() 64 | RegisterScriptCallback("on_game_load", create_overrides) 65 | end 66 | 67 | -------------------------------------------------------------------------------- /gamedata/configs/ui/pda_taskboard_16.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | ui_inGame2_pda_buttons_background 8 | 9 | 10 | 11 | ui_inGame2_pda_buttons_background 12 | 13 | 14 | 15 | ui_inGame2_pda_taskboard_accept 16 | st_pda_accept_task 17 | 18 | 19 | 20 | ui_inGame2_pda_taskboard_task_limit_reached 21 | st_pda_task_limit_reached 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | ui_inGame2_pda_taskboard_next 32 | st_pda_next_task 33 | 34 | 35 | 36 | ui_inGame2_pda_taskboard_refresh 37 | st_pda_refresh_tasks 38 | 39 | 40 | 41 | 42 | 43 | 44 | ui\ui_noise 45 | 46 | 47 | 48 | 49 | 50 | 51 | ui\ui_noise 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | -------------------------------------------------------------------------------- /gamedata/configs/ui/pda_16.xml: -------------------------------------------------------------------------------- 1 | 2 |
3 | 4 | ui_inGame2_pda_texture 5 | 6 | ui_inGame2_pda_buttons_leftside 7 | 8 | 9 | ui_inGame2_pda_buttons_rightside 10 | 11 | 19 | 20 | 21 | 22 | ui\ui_console 23 | 24 | 25 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | ui_inGame2_pda_offline_t 35 | ui_inGame2_pda_offline_e 36 | ui_inGame2_pda_offline_h 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | ui_icons_PDA_tooltips 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | ui_inGame2_pda_battery_bar_background 55 | 56 | 57 | ui_inGame2_pda_battery_bar 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 76 | 77 | 87 | 88 | 98 | 99 | 109 | 110 | 120 | 121 | 131 | 132 | 142 | 143 | 153 | 154 | 155 | 156 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # iTheon-PDA-Taskboard v1.0.5a 2 | 3 | This mod adds a new taskboard tab to the PDA where you can accept all the dynamic tasks remotely 4 | 5 | How to use it?
6 | On Taskboard tab there's a Refresh tasks button - click it once and wait for the tasks to be processed (time depends on the population density in radius, but is pretty fast).
7 | You'll get a list of tasks split into categories. Next to the task description you will see two buttons. Accept task - self-explanatory - accepting a task automatically refreshes the board, as some tasks automatically remove others from the available list (vanilla Anomaly stuff). Next task - tells PDA to prepare info about the next task in category. 8 | 9 | 10 | Possible questions:
11 | Q: Why there's no `Previous task` button?
12 | A: In short - engine limitation. It's because of problems with getting additional info about the task (middle column). To add such a button, I'd need to modify a lot of base game files and this would lead to many compatibility issues with other mods. 13 | 14 | Q: Why I can take delivery and measure tasks remotely? They give me some items
15 | A: I wanted to add a screen blackout for those tasks, but it's annoying. I think it's better without it. You can omit those tasks if it hurts your realism 16 | 17 | Q: Some task details are messed up and are showing next to the wrong task. Why?
18 | A: This bug should be eradicated by now. If you still encounter it, please report to me. EDIT: Sadly - it's not eradicated. The performance improvements achieved through refactoring caused some new issues with asynchronous events. I'll be looking into this later. For now you can be sure that task category, task giver and long description are ALWAYS right - the task details paired with them might be wrong because of async events happening in wrong order. 19 | 20 | Q: One of my tasks has no image. Why?
21 | A: Some tasks don't have image - no worries. 22 | 23 | Q: Does it work with WTF?
24 | A: Technically yes, but some people report random issues. You should report those problems either to me or to Igi. 25 | 26 | Q: Some `Search stash` or `Drug runner` tasks appear in `Bounty tasks` category. Is that a bug?
27 | A: Yes - but it's a bug in vanilla Anomaly files. These tasks need to be categorized as `Bounty tasks` because their setup function dispatches a delayed function to a wrong queue (the bounty task queue). This causes some task details to be lost if a bugged task like this is processed simultaneously with a normal bounty task. 28 | 29 | Q: Why you didn't make one full board of tasks?
30 | A: Engine limitation. I've tried doing it in several different ways, but there needs to be a delay between fetching detailed info about tasks of similar type. With no delay, specific info about the task (e. g. target name and location) will not load properly. You could accept such a task, but you wouldn't know who exactly you have to kill and where he is before you accept it. With full list - even when I've set certain delays to 10 seconds - it sometimes happened anyway. With split list, there doesn't need to be any delay. 31 | 32 | Credits:
33 | Mr. Demonized - optimized time events script
34 | https://github.com/IIJTypmaH - Russian translation
35 | https://github.com/Igigog - WTF support
36 | https://github.com/flat34 - Rostok loner guards fix 37 | 38 | Changelog: 39 | 40 | v1.0.5a 41 | - Fix string formatting CTD 42 | 43 | v1.0.5 44 | - Disable Accept Task button when task limit is reached 45 | 46 | v1.0.4 47 | - Add more exceptions to sim stalkers table. 48 | - Enable task category overrides to let modders create their own categories 49 | - Add category fallback (to 'rest') for tasks missing proper task category (missing string translation for a category) 50 | 51 | v1.0.3 52 | - Scrapped the Dominance task category, as it is dispatching messages to the same queue as Assault tasks, causing bugs with getting task details. These tasks will now appear as assault tasks. 53 | - Companions will now not give you tasks by default, but you can turn it on in MCM 54 | 55 | v1.0.2 56 | - Fix for Rostok loner guards (by flat34) 57 | 58 | v1.0.1 59 | - Fix missing string for tracking device recovery task 60 | 61 | v1.0.0 62 | - Refreshed view with custom icons
63 | - * Task category captions
64 | - * Dynamic category row heights - no more weird shit with MCM configs for row height and text trimming
65 | - * Fixed bug with double-click required for `Next task` and `Accept task` buttons to work - now one click is enough
66 | - Massive code improvements 67 | - Potential fix for messed up task details 68 | - Exclude Reefer's task from the scanning 69 | 70 | v0.1.15 71 | - Fix CTD when trying to fetch Next Task and the taskgiver has been deleted by the engine 72 | 73 | v0.1.14 74 | - Add taskboard keybinding 75 | - Change order of the tabs (place taskboard next to map) 76 | 77 | v0.1.13 78 | - Exclude Gavrilenko from the list of processed npcs 79 | 80 | v0.1.12a 81 | - Add missing xml file 82 | 83 | v0.1.12 84 | - Add a separate PDA tab for taskboard 85 | 86 | v0.1.11 87 | - Exclude Outskirts merc mechanic tools task from the list of available tasks (it's impossible to take normally and can't be finished) 88 | - Adjust the task description fetching code - now the descriptions of the fetch tasks look exactly like they do in the dialog window 89 | 90 | v0.1.10 91 | - Exclude Yar's CoC task from the list of available tasks 92 | 93 | v0.1.9 94 | - Add WTF support by Igi 95 | 96 | v0.1.8 97 | - Add options to disable text trimming and increase the task row height 98 | 99 | v0.1.7 100 | - Speculative fix for rare bug with messed up details pairing (as a side effect the task categories will now always be rendered in a sorted order) 101 | 102 | v0.1.6 103 | - Fix accepting quests that assign you a temporary companion 104 | 105 | v0.1.5 106 | - Increase maximum number of simultaneously rendered task categories 107 | - Expose normalizer table for other mods 108 | 109 | v0.1.4 110 | - Add conditional shortening of the task description and details 111 | 112 | v0.1.3 113 | - Fix weird bugs caused by refreshing board when there was a task without details on it 114 | 115 | v0.1.2a 116 | - Fix task giver override not getting cleared after processing fetch tasks 117 | 118 | v0.1.2 119 | - Add Mr. Demonized's "optimized time events" script as an integral part of the addon (required for vanilla Anomaly) 120 | 121 | v0.1.1 122 | - Fix Sidor and Forester distance evaluation
123 | - Introduce delay for refreshing the tasks
124 | - Visual tweaks
125 | - Add mcm config 126 | -------------------------------------------------------------------------------- /gamedata/scripts/optimized_time_events.script: -------------------------------------------------------------------------------- 1 | -- Optimized time events, using sorted arrays to peek only closest event to come 2 | -- Designed to fully reimplement existing timed events with all its quirks 3 | -- Written by demonized 4 | 5 | -- Original description 6 | -------------------------------------------------------------------------------------------------- 7 | -- DELAYED EVENT QUEUE 8 | ------------------------------------------------------------------------------------------------------- 9 | --[[ 10 | -- Events must have a unique id. Such as object id or another identifier unique to the occasion. 11 | -- Action id must be unique to the specific Event. This allows a single event to have many queued 12 | -- actions waiting to happen. 13 | -- 14 | -- Returning true will remove the queued action. Returning false will execute the action continuously. 15 | -- This allows for events to wait for a specific occurrence, such as triggering after a certain amount of 16 | -- time only when object is offline 17 | -- 18 | -- param 1 - Event ID as type 19 | -- param 2 - Action ID as type 20 | -- param 3 - Timer in seconds as type 21 | -- param 4 - Function to execute as type 22 | -- extra params are passed to executing function as table as param 1 23 | 24 | -- see on_game_load or state_mgr_animation.script for example uses 25 | -- This does not persists through saves! So only use for non-important things. 26 | -- For example, do not try to destroy npcs unless you do not care that it can fail before player saved then loaded. 27 | --]] 28 | 29 | local ev_queue = {} 30 | local computed_ev_queue = {} 31 | 32 | local math_floor = math.floor 33 | local math_huge = math.huge 34 | 35 | local table_insert = table.insert 36 | local table_remove = table.remove 37 | 38 | local has_alife_info = has_alife_info 39 | local time_global = time_global 40 | 41 | local pairs = pairs 42 | local unpack = unpack 43 | 44 | local tg = 0 45 | 46 | local function print_table(table, subs) 47 | 48 | local sub 49 | if subs ~= nil then 50 | sub = subs 51 | else 52 | sub = "" 53 | end 54 | for k,v in pairs(table) do 55 | if type(v) == "table" then 56 | print_table(v, sub.."["..k.."]----->") 57 | elseif type(v) == "function" then 58 | printf(sub.."%s = function",k) 59 | elseif type(v) == "userdata" then 60 | if (v.x) then 61 | printf(sub.."%s = %s",k,utils_data.vector_to_string(v)) 62 | else 63 | printf(sub.."%s = userdata", k) 64 | end 65 | elseif type(v) == "boolean" then 66 | if v == true then 67 | if(type(k)~="userdata") then 68 | printf(sub.."%s = true",k) 69 | else 70 | printf(sub.."userdata = true") 71 | end 72 | else 73 | if(type(k)~="userdata") then 74 | printf(sub.."%s = false", k) 75 | else 76 | printf(sub.."userdata = false") 77 | end 78 | end 79 | else 80 | if v ~= nil then 81 | printf(sub.."%s = %s", k,v) 82 | else 83 | printf(sub.."%s = nil", k,v) 84 | end 85 | end 86 | end 87 | 88 | end 89 | 90 | local function queue_timer_compare(a, b) 91 | return a.timer < b.timer 92 | end 93 | 94 | -- http://lua-users.org/wiki/BinaryInsert 95 | local function binary_insert(t, value, fcomp) 96 | -- Initialise compare function 97 | local fcomp = fcomp or function(a, b) return a < b end 98 | 99 | -- print_table(value) 100 | 101 | -- Initialise numbers 102 | local iStart, iEnd, iMid, iState = 1, #t, 1, 0 103 | 104 | if iEnd == 0 then 105 | t[1] = value 106 | -- printf("adding in beginning table empty") 107 | return 1 108 | end 109 | 110 | if fcomp(value, t[1]) then 111 | -- printf("adding in beginning %s of %s", 1, iEnd) 112 | table_insert(t, 1, value) 113 | return 1 114 | end 115 | 116 | if not fcomp(value, t[iEnd]) then 117 | -- printf("adding in end %s of %s", iEnd + 1, iEnd) 118 | local pos = iEnd + 1 119 | t[pos] = value 120 | return pos 121 | end 122 | 123 | -- Get insert position 124 | while iStart <= iEnd do 125 | 126 | -- calculate middle 127 | iMid = math_floor((iStart + iEnd) / 2) 128 | 129 | -- compare 130 | if fcomp(value, t[iMid]) then 131 | iEnd, iState = iMid - 1, 0 132 | else 133 | iStart, iState = iMid + 1, 1 134 | end 135 | end 136 | 137 | local pos = iMid + iState 138 | -- printf("adding in middle %s of %s", pos, iEnd) 139 | table_insert(t, pos, value) 140 | return pos 141 | end 142 | 143 | local function refresh_ev_queue() 144 | empty_table(computed_ev_queue) 145 | -- tg = time_global() 146 | 147 | local t = computed_ev_queue 148 | for ev_id,actions in pairs(ev_queue) do 149 | for act_id,act in pairs(actions) do 150 | if (act_id ~= "__size") then 151 | local d = { 152 | ev_id = ev_id, 153 | act_id = act_id, 154 | timer = act.timer, 155 | f = act.f, 156 | p = act.p 157 | } 158 | binary_insert(t, d, queue_timer_compare) 159 | end 160 | end 161 | end 162 | end 163 | 164 | local function find_ev_queue(ev_id, act_id) 165 | for i = 1, #computed_ev_queue do 166 | local t = computed_ev_queue[i] 167 | if t.ev_id == ev_id and t.act_id == act_id then 168 | return i 169 | end 170 | end 171 | end 172 | 173 | local function remove_ev_queue(ev_id, act_id) 174 | local pos = find_ev_queue(ev_id, act_id) 175 | if pos then return table_remove(computed_ev_queue, pos) end 176 | end 177 | 178 | function CreateTimeEvent(ev_id,act_id,timer,f,...) 179 | if not (ev_queue[ev_id]) then 180 | ev_queue[ev_id] = {} 181 | ev_queue[ev_id].__size = 0 182 | end 183 | 184 | if not (ev_queue[ev_id][act_id]) then 185 | local new_timer = time_global() + timer*1000 186 | ev_queue[ev_id][act_id] = {} 187 | ev_queue[ev_id][act_id].timer = new_timer 188 | ev_queue[ev_id][act_id].f = f 189 | ev_queue[ev_id][act_id].p = {...} 190 | ev_queue[ev_id].__size = ev_queue[ev_id].__size + 1 191 | 192 | local d = { 193 | ev_id = ev_id, 194 | act_id = act_id, 195 | timer = new_timer, 196 | f = f, 197 | p = {...} 198 | } 199 | binary_insert(computed_ev_queue, d, queue_timer_compare) 200 | end 201 | end 202 | 203 | function RemoveTimeEvent(ev_id,act_id) 204 | if (ev_queue[ev_id] and ev_queue[ev_id][act_id]) then 205 | ev_queue[ev_id][act_id] = nil 206 | ev_queue[ev_id].__size = ev_queue[ev_id].__size - 1 207 | remove_ev_queue(ev_id, act_id) 208 | end 209 | end 210 | 211 | function ResetTimeEvent(ev_id,act_id,timer) 212 | if (ev_queue[ev_id] and ev_queue[ev_id][act_id]) then 213 | local new_timer = time_global() + timer*1000 214 | ev_queue[ev_id][act_id].timer = new_timer 215 | 216 | local el = remove_ev_queue(ev_id, act_id) 217 | el.timer = new_timer 218 | binary_insert(computed_ev_queue, el, queue_timer_compare) 219 | end 220 | end 221 | 222 | local tg_past = 0 223 | local to_remove = {} 224 | function ProcessEventQueue(force) 225 | if has_alife_info("sleep_active") then 226 | return false 227 | end 228 | 229 | tg = time_global() 230 | -- if tg > tg_past then 231 | -- printf("tg %s", tg) 232 | -- printf("computed") 233 | -- print_table(computed_ev_queue) 234 | -- tg_past = tg + 100 235 | -- end 236 | 237 | local force_refresh 238 | for i = 1, #computed_ev_queue do 239 | local t = computed_ev_queue[i] or (function() 240 | force_refresh = true 241 | return { timer = math_huge } 242 | end)() -- Failsafe if event does not exist, refresh queue and postpone to next tick 243 | 244 | if tg < t.timer then 245 | break 246 | end 247 | 248 | if t.f(unpack(t.p)) == true then 249 | local t1 = ev_queue[t.ev_id] 250 | t1[t.act_id] = nil 251 | t1.__size = t1.__size - 1 252 | if t1.__size == 0 then 253 | ev_queue[t.ev_id] = nil 254 | end 255 | to_remove[#to_remove + 1] = { 256 | ev_id = t.ev_id, 257 | act_id = t.act_id 258 | } 259 | end 260 | end 261 | 262 | if force_refresh then 263 | refresh_ev_queue() 264 | empty_table(to_remove) 265 | elseif to_remove[1] then 266 | for i = 1, #to_remove do 267 | local t = to_remove[i] 268 | remove_ev_queue(t.ev_id, t.act_id) 269 | end 270 | empty_table(to_remove) 271 | end 272 | 273 | return false 274 | end 275 | 276 | function ProcessEventQueueState(m_data,save) 277 | if (save) then 278 | m_data.event_queue = ev_queue 279 | else 280 | ev_queue = m_data.event_queue or ev_queue 281 | end 282 | refresh_ev_queue() 283 | end 284 | 285 | _G.CreateTimeEvent = CreateTimeEvent 286 | _G.RemoveTimeEvent = RemoveTimeEvent 287 | _G.ResetTimeEvent = ResetTimeEvent 288 | _G.ProcessEventQueue = ProcessEventQueue 289 | _G.ProcessEventQueueState = ProcessEventQueueState 290 | -------------------------------------------------------------------------------- /gamedata/scripts/ui_pda_taskboard_tab.script: -------------------------------------------------------------------------------- 1 | local xml = CScriptXmlInit() 2 | xml:ParseFile("pda_taskboard.xml") 3 | 4 | local SINGLETON = nil 5 | function get_ui() 6 | SINGLETON = SINGLETON or pda_taskboard_tab() 7 | return SINGLETON 8 | end 9 | -- Constructor. 10 | class "pda_taskboard_tab" (CUIScriptWnd) 11 | function pda_taskboard_tab:__init() super() 12 | self.rows = {} 13 | self:InitControls() 14 | end 15 | 16 | -- Initialise the interface. 17 | function pda_taskboard_tab:InitControls() 18 | self:SetWndRect(Frect():set(0, 0, 1024, 768)) 19 | 20 | -- Main frame. 21 | xml:ParseFile("pda_taskboard.xml") 22 | xml:InitFrame("frame1", self) 23 | xml:InitFrame("frame2", self) 24 | 25 | -- Refresh tasks button 26 | self.refresh_tasks_btn = xml:Init3tButton("btn_refresh_tasks", self) 27 | self:Register(self.refresh_tasks_btn, "refresh_tasks_btn") 28 | self:AddCallback("refresh_tasks_btn", ui_events.BUTTON_CLICKED, refresh_tasks_factory(self), self) 29 | 30 | -- Taskboard 31 | self.list = xml:InitScrollView("list", self) 32 | self:Register(self.list, "list") 33 | end 34 | 35 | 36 | ------------------- TASKBOARD 37 | class "ui_taskboard_row" (CUIWindow) 38 | function ui_taskboard_row:__init(parent, row) super(parent, row) 39 | self.frame = xml:InitFrame("frame2", self) 40 | 41 | -- Subcomponents 42 | self.task_category = xml:InitTextWnd("task_category", self) 43 | self.icon_field = xml:InitStatic("icon_field", self) 44 | self.stalker_info = xml:InitTextWnd("stalker_info", self) 45 | self.task_icon_field = xml:InitStatic("task_icon_field", self) 46 | self.task_details_field = xml:InitTextWnd("task_details_field", self) 47 | self.task_full_description_field = xml:InitTextWnd("task_full_description_field", self) 48 | self.task_accept_btn = xml:Init3tButton("btn_accept_task", self) 49 | self.task_next_btn = xml:Init3tButton("btn_next_task", self) 50 | 51 | self.task_limit_reached_btn = xml:Init3tButton("btn_task_limit_reached", self) 52 | self.task_limit_reached_btn:Show(false); 53 | self.task_limit_reached_btn:TextControl():SetTextColor(utils_xml.get_color("red",true)); 54 | 55 | parent.rows[row] = self 56 | 57 | parent:Register(self.task_accept_btn, "task_accept_btn_" .. row) 58 | parent:AddCallback("task_accept_btn_" .. row, ui_events.BUTTON_CLICKED, accept_task_callback_factory(parent, row), self) 59 | 60 | parent:Register(self.task_next_btn, "task_next_btn_" .. row) 61 | parent:AddCallback("task_next_btn_" .. row, ui_events.BUTTON_CLICKED, next_task_in_category_callback_factory(parent, row), self) 62 | end 63 | 64 | local prepared_tasks_data = {} 65 | local current_board_state = {} 66 | function reset_taskboard(pda_tab) 67 | clear_taskboard_ui(pda_tab) 68 | local npc_list = get_nearby_npcs() 69 | trigger_generate_available_tasks(npc_list) 70 | prepared_tasks_data = get_prepared_task_data(npc_list) 71 | 72 | local sorted_keys = get_sorted_keys(prepared_tasks_data) 73 | -- Basically let XRay do its stuff with setting up logic 74 | for _, task_effect in ipairs(sorted_keys) do 75 | local task_data = prepared_tasks_data[task_effect][1] 76 | prepare_task( task_data ) 77 | end 78 | 79 | -- Delay required for task details from actor message to be properly collected 80 | CreateTimeEvent("taskboard_delay_render", "taskboard_delay_render", 0.05, function () 81 | -- Generate taskboard entries 82 | local i = 1 83 | for _, task_effect in ipairs(sorted_keys) do 84 | -- Save the order of the rows and currently viewed item from the category to be able to update a single row later 85 | current_board_state[i] = { 86 | task_effect = task_effect, 87 | current_index = 1 88 | } 89 | update_task_entry(pda_tab, i, prepared_tasks_data[task_effect][1]) 90 | i = i + 1 91 | end 92 | a_taskboard_utils.adjust_rows(pda_tab) 93 | return true 94 | end) 95 | end 96 | 97 | currently_processed_npc_id = nil -- required for fetch quest override 98 | function prepare_task(task_data) 99 | currently_processed_npc_id = task_data.npc_id 100 | local on_job_descr = task_data.task_id and task_manager.task_ini:r_string_ex(task_data.task_id,"on_job_descr") 101 | if (on_job_descr) then 102 | local cond = xr_logic.parse_condlist(db.actor,"task_manager","condlist",on_job_descr) 103 | if (cond) then 104 | xr_logic.pick_section_from_condlist(db.actor,db.actor,cond) 105 | end 106 | end 107 | 108 | local fetch = task_data.task_id and task_manager.task_ini:r_string_ex(task_data.task_id,"fetch_descr") 109 | if (fetch) then 110 | axr_task_manager.trigger_fetch_func(task_data.task_id) 111 | end 112 | task_data.task_description = get_long_task_description(task_data) 113 | 114 | currently_processed_npc_id = nil 115 | end 116 | 117 | function get_long_task_description(task_data) 118 | -- Mechanic quests are using standard "job_descr" in the dialog manager instead of the fetch one, even though they're 119 | -- technically a fetch task. This requires a small exception 120 | local is_mechanic_task = string.find(task_data.task_id, "mechanic_task") 121 | local base_desc = game.translate_string( 122 | (not is_mechanic_task and axr_task_manager.get_fetch_task_description( task_data.task_id )) or 123 | axr_task_manager.get_task_job_description( task_data.task_id ) 124 | ) 125 | return strformat( base_desc, dialogs._FETCH_TEXT or "" ) 126 | end 127 | 128 | function accept_task_callback_factory(pda_tab, row_index) 129 | return function() 130 | accept_task(pda_tab, row_index) 131 | end 132 | end 133 | 134 | function accept_task(pda_tab, row_index) 135 | local entry_info = current_board_state[row_index] 136 | local task_details = prepared_tasks_data[entry_info.task_effect][entry_info.current_index] 137 | currently_processed_npc_id = task_details.npc_id 138 | task_manager.get_task_manager():give_task(task_details.task_id, task_details.npc_id) 139 | currently_processed_npc_id = nil 140 | clear_cached_data() 141 | reset_taskboard(pda_tab) 142 | end 143 | 144 | function next_task_in_category_callback_factory(pda_tab, row_index) 145 | return function() 146 | next_task_in_category(pda_tab, row_index) 147 | end 148 | end 149 | 150 | function next_task_in_category(pda_tab, row_index) 151 | local entry_info = current_board_state[row_index] 152 | local next_task = prepared_tasks_data[entry_info.task_effect][entry_info.current_index + 1] 153 | 154 | if next_task then 155 | local stalker = level.object_by_id(next_task.npc_id) 156 | if stalker then 157 | prepare_task( next_task ) 158 | 159 | CreateTimeEvent("next_task_delay", "next_task_delay", 0.05, function () 160 | entry_info.current_index = entry_info.current_index + 1 161 | update_task_entry(pda_tab, row_index, next_task) 162 | a_taskboard_utils.adjust_rows(pda_tab) 163 | return true 164 | end) 165 | else 166 | -- In case the taskgiver has been deleted by the engine, reset the whole taskboard. Creating workaround 167 | -- for deleted stalkers generates several edge cases and adds unnecessary complexity to the code - resetting is easier. 168 | reset_taskboard(pda_tab) 169 | end 170 | end 171 | end 172 | 173 | function refresh_tasks_factory(pda_tab) 174 | local refresh_disabled = false 175 | local function temporary_disable_refresh() 176 | local delay = 2 177 | refresh_disabled = true 178 | CreateTimeEvent("reenable_refresh", "reenable_refresh", delay, function () 179 | refresh_disabled = false 180 | return true 181 | end) 182 | end 183 | return function () 184 | if (not refresh_disabled) then 185 | temporary_disable_refresh() 186 | clear_cached_data() 187 | reset_taskboard(pda_tab) 188 | end 189 | end 190 | end 191 | 192 | function clear_cached_data() 193 | z_taskboard_overrides.clear_tasks_info() 194 | prepared_tasks_data = {} 195 | current_board_state = {} 196 | end 197 | 198 | function clear_taskboard_ui(pda_tab) 199 | -- Erase all pre-existing information. 200 | pda_tab.list:Clear() 201 | pda_tab.rows = {} 202 | end 203 | 204 | local excluded_npc_sections = {"bar_duty_security_squad_leader"} 205 | function get_nearby_npcs() 206 | local radius = config.scanning_range 207 | local npc_list = {} 208 | level.iterate_nearest(db.actor:position(), radius, function (obj) 209 | local is_alive_friendly_stalker = IsStalker(obj) and obj:alive() and obj:relation(db.actor) ~= game_object.enemy 210 | local is_excluded_npc = has_value(excluded_npc_sections, obj:section()) 211 | local is_companion = obj:has_info("npcx_is_companion") 212 | 213 | if 214 | is_alive_friendly_stalker 215 | and not is_excluded_npc 216 | and (is_companion and config.companions_give_tasks or not is_companion) then 217 | table.insert(npc_list, obj) 218 | end 219 | end) 220 | 221 | -- Special case for Sidorovich and Forester. 222 | local function add_by_sid(sid) 223 | local id = story_objects.object_id_by_story_id[sid] 224 | if (id) then 225 | local npc = db.storage[id] and db.storage[id].object 226 | if (npc and db.actor:position():distance_to(npc:position()) <= radius) then 227 | table.insert(npc_list, npc) 228 | end 229 | end 230 | end 231 | add_by_sid("esc_m_trader") 232 | add_by_sid("red_tech_forester") 233 | 234 | return npc_list 235 | end 236 | 237 | local actually_is_sim = { 238 | "bar_visitors_garik_stalker_guard", -- Garik 239 | "bar_visitors_zhorik_stalker_guard2", -- Zhorik 240 | "bar_arena_guard", -- Liolik 241 | "bar_dolg_general_zoneguard_stalker", -- "Get out of here stalker" guy 242 | "mil_smart_terrain_7_7_freedom_bodyguard_stalker", -- Lukash's bodyguards 243 | "mil_smart_terrain_7_7_freedom_bodyguard2_stalker" -- Lukash's bodyguards 244 | } 245 | 246 | function trigger_generate_available_tasks(npc_list) 247 | for _,npc in pairs(npc_list) do 248 | -- Sim npcs are random roaming stalkers. Non sims are traders, mechanics, etc. 249 | local is_sim = string.find(npc:section(), "sim_") 250 | currently_processed_npc_id = npc:id() 251 | if has_value(actually_is_sim, npc:section()) then 252 | axr_task_manager.generate_available_tasks(npc, not is_sim) 253 | else 254 | axr_task_manager.generate_available_tasks(npc, is_sim) 255 | end 256 | currently_processed_npc_id = nil 257 | end 258 | end 259 | 260 | -- Tasks that should not be picked by the taskboard (e. g. ones that are not accessible without it) 261 | local excluded_tasks = { 262 | "jup_b19_freedom_yar_task_1", -- Yar's CoC task - bugged 263 | "merc_pri_a18_mech_mlr_task_1", -- Outskirts merc mechanic task for tools - bugged 264 | "agr_u_bandit_boss_task_1" -- Reefer's task (Agro underground) - it doesn't use the standard setup functions and breaks the board logic 265 | } 266 | 267 | function get_prepared_task_data(npc_list) 268 | -- Split available tasks into categories based on their init effect 269 | local result = {} 270 | for _,stalker in pairs(npc_list) do 271 | local stalker_task_list = axr_task_manager.available_tasks[stalker:id()] or {} 272 | for _, task_id in pairs(stalker_task_list) do 273 | local task_effect, has_details = get_task_effect(task_id) 274 | if not has_value(excluded_tasks, task_id) then 275 | if not result[task_effect] then 276 | result[task_effect] = {} 277 | end 278 | table.insert(result[task_effect], { 279 | npc_id = stalker:id(), 280 | task_id = task_id, 281 | has_details = has_details 282 | }) 283 | end 284 | end 285 | end 286 | return result 287 | end 288 | 289 | function get_task_effect(task_id) -- (task_id: string) => task_effect: string, has_details: boolean 290 | -- Support for mods that add more tasks and dispatch effects of their own and can't be categorized using the base Anomaly task types 291 | local task_category_override = task_manager.task_ini:r_string_ex(task_id,"task_category_override") 292 | if task_category_override then return task_category_override end 293 | 294 | 295 | local raw_task_effect = task_manager.task_ini:r_string_ex(task_id,"on_job_descr") or task_manager.task_ini:r_string_ex(task_id,"fetch_func") 296 | local task_effect = string.gmatch(raw_task_effect or "", "=(.*)%(")() or "rest" 297 | task_effect = normalize_task_effect(task_effect) 298 | 299 | -- If there's no category string defined for a given effect/category override, default to `rest` to avoid ugly strings 300 | -- Also makes it easy for modders to just dump all their tasks into `Other tasks` category by simply not defining category override in ini 301 | local has_invalid_category = string.find(game.translate_string('st_pda_caption_' .. task_effect), '_') 302 | if has_invalid_category then 303 | return "rest", not not raw_task_effect 304 | else 305 | return task_effect, not not raw_task_effect 306 | end 307 | end 308 | 309 | function update_task_entry(pda_tab, i, task_details) 310 | if (not pda_tab.rows[i]) then 311 | pda_tab.list:AddWindow(ui_taskboard_row(pda_tab, i)) 312 | end 313 | local row = pda_tab.rows[i] 314 | 315 | row.task_category:SetText(game.translate_string('st_pda_caption_' .. current_board_state[i].task_effect)) 316 | 317 | local stalker = level.object_by_id(task_details.npc_id) 318 | 319 | local stalker_comm = stalker:character_community() 320 | local stalker_icon = stalker:character_icon() 321 | local stalker_name = stalker:character_name() 322 | 323 | stalker_icon = stalker_icon and stalker_icon ~= "" and stalker_icon or "ui\\ui_noise" 324 | row.icon_field:InitTexture(stalker_icon) 325 | 326 | local stalker_info = stalker_name .. "\\n" .. 327 | game.translate_string("ui_st_community") .. ": " .. game.translate_string(stalker_comm) 328 | row.stalker_info:SetText(stalker_info) 329 | 330 | local more_task_details = get_more_task_details(i) or {} 331 | row.task_icon_field:InitTexture(more_task_details.task_icon or "ui\\ui_noise") 332 | 333 | local details = (more_task_details.task_title or "") .. 334 | "\\n" .. 335 | (string.gsub(more_task_details.task_details or "", "\\n ", "\\n")) 336 | row.task_details_field:SetText(details) 337 | 338 | local full_desc = task_details.task_description or "" 339 | row.task_full_description_field:SetText(full_desc) 340 | row.task_next_btn:Show(has_more_task_in_category(i)) 341 | manage_accept_button(stalker, row) 342 | end 343 | 344 | function manage_accept_button(stalker, row) 345 | local reached_limit = dialogs.has_tasks_by_npc(stalker) 346 | if reached_limit then 347 | row.task_limit_reached_btn:Show(true) 348 | row.task_accept_btn:Show(false) 349 | else 350 | row.task_limit_reached_btn:Show(false) 351 | row.task_accept_btn:Show(true) 352 | end 353 | end 354 | 355 | function has_more_task_in_category(i) 356 | local entry_info = current_board_state[i] 357 | local next_task = prepared_tasks_data[entry_info.task_effect][entry_info.current_index + 1] 358 | return not not next_task 359 | end 360 | 361 | function get_igi_task_details(i) 362 | if not igi_generic_task then return end 363 | if not (igi_description and igi_description.get_task_text_values) then return end 364 | 365 | local task_id = prepared_tasks_data.rest[i] and prepared_tasks_data.rest[i].task_id 366 | local CACHE = task_id and igi_generic_task.TASK_SETUP[task_id] 367 | if CACHE then 368 | local title, text, icon = igi_description.get_task_text_values(CACHE) 369 | return { 370 | task_title = title, 371 | task_details = text, 372 | task_icon = icon 373 | } 374 | end 375 | end 376 | 377 | -- This function is a pure fuckery and crime against the code, but there's literally no other way to couple the 378 | -- task data with the details received through overridden actor method. 379 | function get_more_task_details(i) 380 | local entry_info = current_board_state[i] 381 | local task_data = prepared_tasks_data[entry_info.task_effect][entry_info.current_index] 382 | if entry_info.current_index == 1 then 383 | -- If no new task has been requested for a given category, index of details will be the same as the index of row (or at least I didn't observe any 384 | -- deviations from that rule for now) 385 | if(task_data.has_details) then 386 | return z_taskboard_overrides.tasks_info[i - get_processed_tasks_with_no_info_count(i)] 387 | elseif igi_generic_task then 388 | return get_igi_task_details(entry_info.current_index) 389 | else return end 390 | else 391 | -- If new task has been requested, the details will be appended at the end of the array, so we can easily read them 392 | if(task_data.has_details) then 393 | return z_taskboard_overrides.tasks_info[#z_taskboard_overrides.tasks_info] 394 | elseif igi_generic_task then 395 | return get_igi_task_details(entry_info.current_index) 396 | else return end 397 | end 398 | end 399 | 400 | function get_processed_tasks_with_no_info_count(i) 401 | local count = 0 402 | for index, entry_info in ipairs(current_board_state) do 403 | local task_data = prepared_tasks_data[entry_info.task_effect][entry_info.current_index] 404 | if index < i and not task_data.has_details then 405 | count = count + 1 406 | end 407 | end 408 | return count 409 | end 410 | 411 | function has_value(tab, val) 412 | for index, value in ipairs(tab) do 413 | if value == val then 414 | return true 415 | end 416 | end 417 | 418 | return false 419 | end 420 | 421 | function get_sorted_keys(tab) 422 | local result = {} 423 | for key, _ in pairs(tab) do 424 | table.insert(result, key) 425 | end 426 | table.sort(result) 427 | return result 428 | end 429 | 430 | -- Some effects have different names even though they perform pretty much the same actions as the generic/simulation task effects 431 | -- Those should be pushed into the same category to prevent queued actor messages from being discarded 432 | normalizer = { 433 | setup_supplies_fetch_task_lostzone_patch = "setup_fetch_task", 434 | setup_generic_fetch_task = "setup_fetch_task", 435 | drx_sl_create_quest_stash = "setup_bounty_task", -- The actor message in base vanilla files is sent to the wrong queue here 436 | setup_dominance_task = "setup_assault_task", -- Same as above - this actually scraps the whole Dominance category and puts these tasks as assault tasks 437 | multifetch_on_job_descr = "setup_fetch_task" -- Unnecessary thing, but there's only one task in this category 438 | } 439 | function normalize_task_effect(task_effect) 440 | return normalizer[task_effect] or task_effect 441 | end 442 | 443 | local function load_defaults() 444 | local t = {} 445 | local op = pda_taskboard_mcm.op 446 | for i, v in ipairs(op.gr) do 447 | if v.def ~= nil then 448 | t[v.id] = v.def 449 | end 450 | end 451 | return t 452 | end 453 | -- Default config 454 | config = load_defaults() 455 | 456 | local function load_settings() 457 | config = load_defaults() 458 | if ui_mcm then 459 | for k, v in pairs(config) do 460 | config[k] = ui_mcm.get("pda_taskboard/" .. k) 461 | end 462 | end 463 | end 464 | 465 | function on_key_release(key) 466 | if key == config.taskboard_key then 467 | local pda_menu = ActorMenu.get_pda_menu() 468 | local pda3d = get_console_cmd(1,"g_3d_pda") 469 | if not (pda_menu:IsShown()) and db.actor:item_in_slot(8) then 470 | if (pda3d) then 471 | db.actor:activate_slot(8) 472 | else 473 | pda_menu:ShowDialog(true) 474 | end 475 | 476 | pda_menu:SetActiveSubdialog("eptTaskboard") 477 | elseif (pda_menu:IsShown()) then 478 | if (pda3d) then 479 | db.actor:activate_slot(0) 480 | else 481 | pda_menu:HideDialog() 482 | end 483 | end 484 | end 485 | end 486 | 487 | function on_game_start() 488 | RegisterScriptCallback("on_game_load", load_settings) 489 | RegisterScriptCallback("on_option_change", load_settings) 490 | RegisterScriptCallback("on_key_release",on_key_release) 491 | end 492 | --------------------------------------------------------------------------------