Tutorial:Pooled Resource Unit Costs

From Total War Modding

Originally written by Pear

This guide and the example mod are my submission for Mod Jam #7.

Pooled Resources are cool. The problem is their application is often limited. This tutorial aims to fix that by explaining how to use Pooled Resources as costs for units in the main recruitment panel and providing an example mod for reference. The method used here can be applied to a range of other things such as RoRs, lords, agents, buildings, technologies, or anything like that.

There's currently nothing in the tables to make units cost Pooled Resources except for a few faction-specific things so we have to use Lua UI scripting instead. It works by checking when the player has clicked certain units, taking away the defined cost of those units, and creating some UI components to visualise all this. The process consists of two main parts:

  1. Making the UI: creating UICs, enabling and disabling UICs, and setting text, tooltips, and icons.
  2. UIC Triggers: accounting for UI refreshes and taking away / returning the Pooled Resource to the player in a multiplayer-friendly way.

Before we get started I'd recommend using my example mod as a reference, as well as Groove Wizard's Visual Studio development environment for your scripting, and Groove's Modding Development Tools: Lua Console to test the code in game (yes this man is a machine). This guide assumes some knowledge of RFPM and scripting.

๐Ÿง‘โ€๐ŸŽ“ Some UI Basics

If you're not familiar with UI modding then you may find this section useful. If you're too kool for skool, move along!

๐Ÿ” Finding UI Components

Recruitment cost component.png

In order to modify a part of the UI you need to find where it's located. To do this we can use the Context Viewer. A quick way to do this is by having the Context Viewer open and clicking on the screen with you mouse wheel.

For the purpose of this tutorial, try clicking the unit card you want to add a Pooled Resource to then expanding the unit card path in the Context Viewer until you find the "RecruitmentCost" UIC. Once you've found it, paste CopyFullPathToClipboard() in the expression tester box. This copies the path to that UIC.

When copied it will look like this: ":root:units_panel:main_units_panel:..." and so on.

Referencing my example mod, you can write the path to the recruitment cost UIC for the Swordsmen unit like this:

local recruitment_uic = find_uicomponent(core:get_ui_root(), "units_panel", "main_units_panel", "recruitment_docker", "recruitment_options", "recruitment_listbox", "local1", "unit_list", "listview", "list_clip", "list_box", "wh_main_emp_inf_swordsmen_recruitable", "unit_icon", "RecruitmentCost") 

It is fine as it is however it can be split into smaller, easier-to-access parts like this:

local recruitment_uic = find_uicomponent(core:get_ui_root(), "units_panel", "main_units_panel", "recruitment_docker", "recruitment_options", "recruitment_listbox", recruitment_type)
local listview_uic = find_uicomponent(recruitment_uic, "unit_list", "listview")
local unit_uic = find_uicomponent(listview_uic, "list_clip", "list_box", "wh_main_emp_inf_swordsmen_recruitable")
local recruitment_cost_uic = find_uicomponent(unit_uic, "unit_icon", "RecruitmentCost")

๐Ÿ“ท Creating / Copying Components

For our purposes we will probably only need to create copies of pre-existing UICs. To do that you need to use uicomponent:CopyComponent() which is being used here to copy the recruitment cost UIC for the Swordsmen unit and naming the new UIC "prestige cost":

UIComponent(recruitment_cost_uic:CopyComponent("prestige_cost"))

Using the paths we've previously established, the path for this new "prestige_cost" UIC we've created is therefore:

local prestige_cost_parent_uic = find_uicomponent(unit_uic, "unit_icon", "prestige_cost")

โฌ‡๏ธ Repositioning Components

This part is a bit more tedious. You will need to use the console (shift + f3) from Groove's Modding Dev Tools to test where the new "prestige_cost" UIC we've created should be repositioned. To do that you need to use uicomponent:SetDockOffset(). Using the code below you can test coordinates e.g. x = 0, y = 5 until the new UIC looks like it's in the right place:

local prestige_cost_parent_uic = find_uicomponent(unit_uic, "unit_icon", "prestige_cost")
prestige_cost_parent_uic:SetDockOffset(x, y)

If you really want to make sure it's in the right place take a screenshot and do a bit of pixel peeping in a photo editor and count the pixels between e.g. the original recruitment cost and the upkeep cost UICs and that's how many pixels your new component should be below the recruitment cost UIC.

Using my example mod, we can see that the correct coordinates for the normal recruitment panel are:

prestige_cost_parent_uic:SetDockOffset(8, 19)

this may change depending on the faction or panel e.g. RoRs or lords.

โœ‹ Handling the UI

For the purpose of this guide we will be keeping the vanilla treasury costs and making one new Pooled Resource cost which will go below the unit card. To make the UI components work we need functions and listeners!

IMPORTANT: Make sure you change the listener and saved value names from the examples to avoid incompatibilities.

๐Ÿ Initialising and Finalising UICs

This part of the guide requires a little knowledge of scripting as we'll be looking at two functions. In short, the two functions apply the UI knowledge we covered earlier (and a little extra) to create UICs, resize UICs, and set their docking points, text, and icons. Comments in the functions briefly explain what each part does so I won't go into too much detail here. If it does not make sense I'd suggest looking up the functions used in the documentation.

First, we need a table for the units we want to have Pooled Resource costs. It should contain the loc key for the unit's onscreen name, the unit's UIC (which is just the unit's key plus "_recruitable"), and the Pooled Resource cost. Together it should look like this:

local pr_units = {
   {unit_name_loc = "land_units_onscreen_name_wh_main_emp_inf_spearmen_1", unit_uic = "wh_main_emp_inf_spearmen_1_recruitable", prestige_cost = 18},
   {unit_name_loc = "land_units_onscreen_name_wh_main_emp_inf_swordsmen", unit_uic = "wh_main_emp_inf_swordsmen_recruitable", prestige_cost = 19}
}

This table is then used in the functions initialise_uics(recruitment_type) and finalise_uics(). The first function initialise_uics(recruitment_type) handles resizing the parents for UICs to make sure the list clips are displaying our new components and everything else appears correctly.

local function initialise_uics(recruitment_type)
   local recruitment_uic = find_uicomponent(core:get_ui_root(), "units_panel", "main_units_panel", "recruitment_docker", "recruitment_options", "recruitment_listbox", recruitment_type)
   if recruitment_uic then
       local recruitment_docker_uic = find_uicomponent(core:get_ui_root(), "units_panel", "main_units_panel", "recruitment_docker")
       local unit_list = find_uicomponent(recruitment_uic, "unit_list")
       local listview_uic = find_uicomponent(unit_list, "listview")
       local list_clip_uic = find_uicomponent(listview_uic, "list_clip")
       local list_box_uic = find_uicomponent(list_clip_uic, "list_box")
       -- gets the reference unit from the table
       local reference_unit
       for i = 1, #pr_units do
           reference_unit = pr_units[i].unit_uic
           local unit_uic = find_uicomponent(list_box_uic, reference_unit)
           if unit_uic then
               break
           elseif unit_uic == false then
               reference_unit = nil
               break
           end
       end
       if reference_unit ~= nil then
           local unit_uic = find_uicomponent(list_box_uic, reference_unit)
           local recruitment_cost_uic = find_uicomponent(unit_uic, "unit_icon", "RecruitmentCost")
           recruitment_docker_uic:SetCanResizeHeight(true)
           recruitment_docker_uic:SetCanResizeWidth(true)
           unit_list:SetCanResizeHeight(true)
           unit_list:SetCanResizeWidth(true)
           list_clip_uic:SetCanResizeHeight(true)
           list_clip_uic:SetCanResizeWidth(true)
           unit_uic:SetCanResizeHeight(true)
           unit_uic:SetCanResizeWidth(true)
           -- dimensions for resizing components
           local width_rcc, height_rcc = recruitment_cost_uic:Dimensions()
           -- resizing recruitment_docker
           local width_rdc, height_rdc = recruitment_docker_uic:Dimensions()
           recruitment_docker_uic:Resize(width_rdc, (height_rdc + (height_rcc * 2)), false) 
           -- resizing unit_list
           local width_lrc, height_lrc = unit_list:Dimensions()
           unit_list:Resize(width_lrc, (height_lrc + height_rcc), false) 
           -- resizing list_clip
           local width_lcc, height_lcc = list_clip_uic:Dimensions()
           list_clip_uic:Resize(width_lcc, (height_lcc + (height_rcc * 2) + 5), false) -- the plus 5 is to make sure there's no clipping at the bottom of the upkeep cost component
           -- handling pr cost components
           finalise_uics()
       end
   end
end

The second function finalise_uics() handles the creation of our new UICs, setting the Pooled Resource costs, text, tooltips, icons, and handling anything that gets refreshed when UICs are clicked.

local function finalise_uics()
   local recruitment_type
   for g = 1, 2 do
       -- loops for local recruitment and global recruitment
       if g == 1 then
           recruitment_type = "local1"
       elseif g == 2 then
           recruitment_type = "global"
       end
       local recruitment_uic = find_uicomponent(core:get_ui_root(), "units_panel", "main_units_panel", "recruitment_docker", "recruitment_options", "recruitment_listbox", recruitment_type)
       if recruitment_uic then
           for i = 1, #pr_units do
               local unit_name = common.get_localised_string(pr_units[i].unit_name_loc)
               local prestige_cost = pr_units[i].prestige_cost
               local listview_uic = find_uicomponent(recruitment_uic, "unit_list", "listview")
               local unit_uic = find_uicomponent(listview_uic, "list_clip", "list_box", pr_units[i].unit_uic)
               if unit_uic then
                   local recruitment_cost_uic = find_uicomponent(unit_uic, "unit_icon", "RecruitmentCost")
                   local upkeep_cost_uic = find_uicomponent(unit_uic, "unit_icon", "UpkeepCost")
                   local prestige_cost_uic_check = find_uicomponent(unit_uic, "unit_icon", "prestige_cost")
                   if prestige_cost_uic_check == false then
                       UIComponent(recruitment_cost_uic:CopyComponent("prestige_cost"))
                   end
                   -- dimensions for resizing components
                   local width_rcc, height_rcc = recruitment_cost_uic:Dimensions()
                   -- resizing unit component
                   local width_uc, height_uc = unit_uic:Dimensions()
                   unit_uic:Resize(width_uc, (height_uc + (height_rcc * 2) + 5), false)
                   -- repositioning list_view
                   listview_uic:SetDockOffset(0, -24)
                   -- repositioning cost components
                   local prestige_cost_parent_uic = find_uicomponent(unit_uic, "unit_icon", "prestige_cost")
                   local prestige_cost_uic = find_uicomponent(prestige_cost_parent_uic, "Cost")
                   prestige_cost_parent_uic:SetDockOffset(8, 19)
                   upkeep_cost_uic:SetDockOffset(8, 42)
                   local faction = cm:get_faction(cm:get_local_faction_name(true))
                   local player_prestige = faction:pooled_resource_manager():resource("emp_prestige"):value()
                   if player_prestige >= prestige_cost then
                       -- setting cost text
                       prestige_cost_uic:SetStateText(tostring(prestige_cost), "")
                   else
                       -- setting cost text
                       prestige_cost_uic:SetStateText(tostring("col:red"..prestige_cost.."/col"), "")
                       local unit_uic_tooltip = unit_uic:GetTooltipText()
                       local cannot_recruit_loc = common.get_localised_string("random_localisation_strings_string_StratHudbutton_Cannot_Recruit_Unit0")
                       local insufficient_pr_loc = common.get_localised_string("pear_insufficient_pr_prestige_tooltip")
                       local unit_uic_tooltip_gsub = unit_uic_tooltip:gsub('[%W]', )
                       local left_click_loc_gsub = (common.get_localised_string("random_localisation_strings_string_StratHud_Unit_Card_Recruit_Selection")):gsub('[%W]', )
                       if string.match(unit_uic_tooltip_gsub, left_click_loc_gsub) then
                           unit_uic:SetTooltipText(unit_name.."\n\n"..cannot_recruit_loc.."\n\n"..insufficient_pr_loc, "", true)
                       else
                           unit_uic:SetTooltipText(unit_uic_tooltip.."\n"..insufficient_pr_loc, "", true) 
                       end
                       -- disabling recruitment of unit
                       unit_uic:SetState("inactive")
                       unit_uic:SetDisabled(true)
                   end
                   -- setting cost icon
                   prestige_cost_uic:SetImagePath("ui/skins/default/prestige_bar_icon.png", 0)
                   -- setting cost tooltip
                   prestige_cost_parent_uic:SetTooltipText("Cost||This amount will be deducted from your Prestige in order to recruit this unit.", "", true) 
                   -- setting the cost modified icon to invisible as the CCO is still for recruitment cost so make it appear when intended
                   local cost_modified_icon_uic = find_uicomponent(prestige_cost_uic, "cost_modified_icon")
                   cost_modified_icon_uic:Visible(false)
               end
           end
       end
   end
end

๐ŸŽฎ UIC Triggers

In order to trigger the functions we need listeners. The first one is PanelOpenedCampaign. We can use this to tell when the main recruitment panel "units_recruitment" is open so we can start setting up our UI. This is important because the UI usually refreshes when certain UICs are clicked which causes the panel to re-open. To find the name of the panel you're adding Pooled Resource costs to you can check the log produced by Groove's Modding Dev Tools. In the log it will look something like this:

[ui] <97.6s>    Panel opened units_panel
[ui] <98.8s>    Panel opened recruitment_options
[ui] <98.8s>    Panel opened units_recruitment

For this listener to work for both local and global recruitment we need a for loop to run the set up for both types of recruitment which will use this table:

local recruitment_types = {

   {recruitment_type = "local1"},
   {recruitment_type = "global"}
} 

We also need some saved values to make sure the listener doesn't continually resize the parent UI components every time something is clicked. Together the listener looks like this:

core:add_listener(

   "EXAMPLE_PANEL_OPENED",
   "PanelOpenedCampaign",
   function(context)
       return context.string == "units_recruitment"
   end,
   function()
       for i = 1, #recruitment_types do
           local recruitment_type = recruitment_types[i].recruitment_type
           if cm:get_saved_value("EXAMPLE_INIT_1") ~= true then
               cm:set_saved_value("EXAMPLE_INIT_1", true)
               initialise_uics(recruitment_type)
           else
               finalise_uics()
               return
           end
       end
   end,
   true
)

A refresh to the UI occurs when switching to the Encamp stance. This messes up the sizing of the parent panels. To account for this we need to use ComponentLClickUp which checks when the Encamp button is pressed like this:

core:add_listener(
   "EXAMPLE_UIC_CLICKED",
   "ComponentLClickUp",
   function(context)
       return context.string == "button_MILITARY_FORCE_ACTIVE_STANCE_TYPE_SET_CAMP"
   end,
   function()
       local recruitment_type = "global"
       cm:callback(
           function()
               if cm:get_saved_value("EXAMPLE_ENCAMP_CLICKED_1") ~= true then
                   cm:set_saved_value("EXAMPLE_ENCAMP_CLICKED_1", true)
                   initialise_uics(recruitment_type)
               else
                   finalise_uics()
               end
           end, 
           0.1 -- this delay is due to a timing issue with refreshing UI
       )
   end,
   true
)
  • This listener is used again later and combined with something else.

Now we need to use PanelClosedCampaign to reset our saved values when the entire "units_panel" closes.

core:add_listener(
       "EXAMPLE_PANEL_CLOSED",
       "PanelClosedCampaign",
       function(context)
           return context.string == "units_panel"
       end,
       function()
           for i = 1, #recruitment_types do
               local recruitment_type = recruitment_types[i].recruitment_type
               cm:set_saved_value("EXAMPLE_INIT_1", false)
           end
           cm:set_saved_value("EXAMPLE_ENCAMP_CLICKED_1", false)
       end,
       true
   )

Next we need to use ComponentLClickUp and UITrigger. These listeners act as a pair โ€” ComponentLClickUp sends a message which is received by UITrigger. This is required for multiplayer compatibility.

The ComponentLClickUp listener contains 2 parts. *The first part is to account for a refresh in the UI when the army's stance is changed covered earlier. The second part is what sends the message to the UITrigger listener. Together this looks like:

    core:add_listener(
       "EXAMPLE_UIC_CLICKED",
       "ComponentLClickUp",
       true,
       function(context)
           if context.string == "button_MILITARY_FORCE_ACTIVE_STANCE_TYPE_SET_CAMP" then
               local recruitment_type = "global"
               cm:callback( 
                   function()
                       if cm:get_saved_value("pr_cost_encamp_clicked") ~= true then
                           cm:set_saved_value("pr_cost_encamp_clicked", true)
                           initialise_uics(recruitment_type)
                       else
                           out("PR UNIT COST LOG - finalise_uics() from pr_unit_cost_finalise_uics.")
                           finalise_uics()
                       end
                   end, 
                   0.1 -- this delay is due to a timing issue with refreshing UI
               )
           end       
           local unit_info_uic = find_uicomponent(core:get_ui_root(), "hud_campaign", "unit_info_panel_adopter", "unit_information_parent", "unit_info_panel_holder_parent", "unit_info_panel_holder", "unit_information", "info_parent", "info_panel", "tx_unit-type")
           if unit_info_uic then
               local unit_ui_name = unit_info_uic:GetStateText()
               for i = 1, #pr_units do
                   local unit_name = common.get_localised_string(pr_units[i].unit_name_loc)
                   if unit_ui_name == unit_name then
                       cm:set_saved_value("pr_cost_unit_clicked", unit_name)
                       CampaignUI.TriggerCampaignScriptEvent(cm:get_faction(cm:get_local_faction_name(true)):command_queue_index(), context.string .."Clicked")
                       return
                   end
               end
           end
       end,
       true
   )