UI:Main Page

From Total War Modding
Revision as of 10:55, 4 May 2022 by DrunkFlamingo (talk | contribs)
(diff) ← Older revision | Latest revision (diff) | Newer revision → (diff)

So, you’ve come to this crazy place. You want to learn about UI modding. Congrats, and good luck.

This section is going to cover a lot – I’d like to share about as much UI modding tips as I have – and because of that, it’s likely things will go back and forth. Primarily, this is a subject based in Lua, and most of the actual UI creation will take place in Lua scripts. Lua knowledge is expected. I’m not going to make this a copy of the Lua chapters, but with UI. If you don’t know what I’m talking about, it should be in the Lua chapters. If it is not, tell me, and I will add it.

There will also be assumptions that you understand what various game objects are – like buildings, effects, localisation – and there will be assumptions that you know how to do them and use them. If you do not – find out, then come back.

The final large assumption this tutorial bears in mind is that you have a goal, a specific thing you want to create. I’m going to mostly be doing lightly guiding, and I’ll be explaining one general concept at a time. We’ll start from the very basics, and will eventually rabbit-hole into a bunch of fun specific tasks – like creating custom tooltips, generating UI from data. If a chapter doesn’t cover whatever your specific needs are – I would read it. The progression is setup in mind for someone trying to go from “create a button” to “create fully custom UI panels and mess with vanilla ones as well”.

Note that this is heavily geared towards WH3, but almost all of the information here will carry over to Three Kingdoms. I’ll try to make clear any differences, if I can recall any!

And without further adieu, let’s get started.

Recommended Setup and Documentation

UI modding requires frequent testing, and as such can be very time consuming. There are, however, things you can do to make it easier.

Many of these things are other mods, such as console tools, which may or may not be available in a given Total War title.

Logging

You should always have script logging turned on when working with Lua, and the UI is no exception.

(Link the debug mods here)

Lua Consoles

Some nerds made some lua console thing you should use it (Vandy fix this).

The UI Context Viewer

Any references on this page to the context viewer refer to this tool, which is available in Warhammer 3. You can read more about it here.

Documentation

It would be difficult (and pointless) for us to reflect all of the available information about available functions and methods for the UI here. Furthermore, the details of what is and is not available will depend on the specific game you are working with.

You should be making active use of documentation in addition to the information on this page. If you need to know what arguments to give to a method, what return value to expect, or what methods are even available to call, documentation is where you will find the answers. In general, most documentation is linked to on this page, maintained by Vandy.

Warhammer 3

You can find the CA Script Documentation for Warhammer 3 hosted here. This documentation contains pages for UIComponents, CampaignUI, and all of CA's scripted utility functions.

Documentation for Component Context Objects can be found locally in the data.pack, in the /documentation/ folder. However, it is also hosted here for convienience.

UI Lua Interfaces

The interface (or userdata, I'm going to use interface) types that are necesssary for UI modding using Lua are: UIComponents, CampaignUI, and the cco script interface.

UIComponents

UIComponents represent pieces of the UI. They include: text; images, buttons; hud elements, and pretty much everything else you can see or click on. We typically refer to them as UIC, or just components, for shorthand.

Hierarchy

All UIC may have children, and all UIC (except one) of them has a parent. The one that doesn't have any parents is the UI Root, and can be accessed as a variable at any time using core:get_ui_root().

This setup creates a 'tree' or 'hierarchy' of components branching out from the root. If you're modding Warhammer 3, you can see this tree by opening the context viewer and pressing "Component Tree" at the top. The hierarchy typically moves from larger pieces and containers to smaller individual elements.

For instance, my Lord's unit card in the units panel is represented by a UIC named LandUnit0. This UIC It has a child named card_image_holder, which itself has lots of children, such as experience. Each component in this setup plays a different role: the first acts as a container for the other elements and displays a tooltip; the second displays the actual unit card png; and the third represents the experience icon on the unit.

We can also follow this tree backwards. LandUnit0 has a parent named units, which acts as a list and is contained by main_units_panel, which itself is contained within the units_panel, which is a child of the UI root. We refer to this chain of component names as the "path" to the component.

Following the hierarchy is the main way to get access to a UI Component. For instance, if we wanted to define a variable in our script for LandUnit0, we could do this:

local units_panel = UIComponent(core:get_ui_root():Find("units_panel"))

local main_units_panel = UIComponent(units_panel:Find("main_units_panel"))

and so forth until you got to the element you wanted, but that is extremely cubersome, so CA defines a function to do it for you: find_uicomponent. You will call this function a lot when you are UI modding.

To use it, we just provide a parent and the path to the element we want, in this case:

local LandUnit0 = find_uicomponent(core:get_ui_root(), "units_panel", "main_units_panel", "units", "LandUnit0").

On the rare occasion we need to, we can also define the parent of any element using

local units = UIComponent(LandUnit0:Parent())

Addresses and the UIComponent() function

In the above function calls, we didn't define our variables as the return values of Find() and Parent(), but rather passed those return values to UIComponent.

That is because those functions don't return the UIComponent that is a child or parent of the current one; instead, they return an address for it. There are three uses for addresses:

  1. You can pass the address to UIComponent() and it will return the UIC which the address points to.
  2. You can use the address with certain other UIC Methods that ask for one, such as Divorce and Adopt
  3. You can pass the address to tostring() and it will give you a string that can be used to refer to that specific UIC. This is useful when you want to distinguish between two UIC with the same name.

One of the most common mistakes when starting out with UI Modding is forgetting to use UIComponent to convert addresses into UIC's before using them. Make sure to pay attention to this distinction.

States

All UI Components have a state, which changes how they appear. For instance, our unit card from the previous example has active hover inactive locked queued raise reinforcement routing selected selected_hover taking_damage taking_damage_selected unknown wavering wavering_selected

That's a lot of states, but it does a good job showing what they're for: they let a single element change how it looks, which of its children are visible, what image it is displaying, and what text and tooltip it shows, and pretty much anything else.

That makes them very useful when you're trying to change the UI. For instance, if I wanted to make it impossible to disband, upgrade, or merge one of your units, I could achieve that by simply calling

local LandUnit5 = find_uicomponent(core:get_ui_root(), "units_panel", "main_units_panel", "units", "LandUnit5")

LandUnit5:SetState("inactive")

Creating Elements

UIComponents can be created through two methods: CreateComponent and CopyComponent.

Note that these commands return an address, so make sure to pass this address to UIComponent() in order to get the script object for your new UI component before trying to call methods on it.

AddScriptEventReporter (Warhammer 3)

UIComponents fire certain Lua events by default when they are interacted with. For example, most UI elements will notify the script when they are clicked using the ComponentLClickUp event.

However, there are more detailed events which are not fired by most UIComponents by default in the most recent titles. Examples of this include ComponentMouseOn and ComponentMouseOff, which help modders to detect when elements are hovered.

You can use the AddScriptEventReporter() method on any UIComponent to cause it to begin firing all events. For some elements, this is already enabled by default.

In Warhammer 2 and previous games, all events are fired by all UIComponents without needing to call this command - this is less complicated, but it also means that there is more of a performance impact for using those events.

CampaignUI

The CampaignUI object is a singleton that holds a bunch of useful functions for working with the UI. As the name suggests, it is only available in campaign. The exact details of the CampaignUI object can be found in CA's script documentation, which can be used to check what methods are available in the specific game you are working with.

Multiplayer Compatibility Using TriggerCampaignScriptEvent

One of the most important methods available to the CampaignUI object is TriggerCampaignScriptEvent. This command is important because it is necessary to implement button clicks or other UI actions which modify the game state. We will talk about it here in the context of pressing a button.

This method needs two arguments: a faction command queue index, and a string. The faction command queue index is used to identify which faction pressed the button, while the string is used to transmit any necessary information about what button was pressed and what needs to be done in response.

When called, the game will fire a "UITrigger" event, which can be listened for. This event provides a context with the methods context:faction_cqi(), which returns the CQI passed into the command, and context:trigger(), which returns the string passed into the command.

Here is a simple implementation to serve as an example:

    --listen for the click 
    core:add_listener(
        "MyComponentLClickUpListener",
        "ComponentLClickUp",
        function(context)
            return context.string == "my_button_name"
        end,
        function(context)
            out("Player clicked my Button! This only happens on one computer!")
            local character = cm:get_character_by_cqi(cm:get_campaign_ui_manager():get_char_selected_cqi())
            local trigger_string = "press_my_button:"..character:character_subtype_key()
            CampaignUI.TriggerCampaignScriptEvent(cm:get_local_faction(true):command_queue_index(), trigger_string)
        end,
        true)
    
    core:add_listener(
        "MyUITriggerListener",
        "UITrigger",
        function(context)
            return string.find(context:trigger(), "press_my_button:")
        end,
        function(context)
            local faction = cm:model():faction_for_command_queue_index(context:faction_cqi());
            local subtype_key = string.gsub(context:trigger(), "press_my_button:", "")
            out("Our button was pressed by faction "..faction:name().." with a character subtype "..subtype_key.." passed as additional information")
            out("This happens on both computers!")
        end,
        true)
    
    


In this example, we are taking two things which are local to our computer (who our currently selected character is, the fact that a button was just pressed) and transmitting them through a script event so that they are available to all the computers in the game.

Component Context Objects

The third and final type relevant to UI Modding. We call these Ccos for short. They are objects created by CA to expose game objects to the UI, so that it can be used to populate it. They also often contain commands which are used by the UI to affect the game object.

For instance, every unit in your army has a CcoCampaignUnit attached to its unit card, and each of those Cco has a method IconPath() which returns the unit card of that unit for displaying on the UI. They also contain a method Disband which is used by a button to disband the unit.

We refer to CcoCampaignUnit as the type of Cco.

The cco() Function

The cco() function takes two arguments: the first is the type of Cco we want to retrieve; the second is a string or number uniquely identifying which Cco of that type we want to retrieve.


For example, if we wanted to retrieve the CCO object corresponding to the currently selected character, we could do the following:

   local character_cqi = cm:get_campaign_ui_manager():get_char_selected_cqi()
    local characterContext = cco("CcoCampaignCharacter", cqi)

Here CcoCampaignCharacter is the type of Cco, and the CQI identifies which specific character we want.

For gameplay objects that have a CQI, such as factions, settlements, characters, and forces, the CQI can be used to retrieve their associated Cco.

In cases where you want a Cco for a game object that does not have a CQI, you will need to use another method to find the Context Id you are looking for.

Retrieving Cco Id's from UIComponents

UIComponents have a method GetContextObjectId. which can be used to retrieve the unique identifier of a particular Cco attached to that UIComponent.

This method takes a single argument: a string identifying the type of Cco to retrieve. Elements may have many different Cco's attached to them, but they will only have one of a particular type of Cco.

For instance, a unit card typically has a CcoCampaignUnit, referring to the unit it represents, as well as a CcoCampaignCharacter corresponding to the general character of the force which contains that unit.

If we wanted to define a variable as one of those contexts, we could do so as follows:

   --assume unitCard is a valid UIComponent representing a card on the units panel
    local campaignUnitContextId = unitCard:GetContextObjectId("CcoCampaignUnit")
    local campaignUnitContext = cco("CcoCampaignUnit", campaignUnitContextId)

Using Ccos

Once a CCO is defined, it has a single method which can be used: Call. This method takes a string called a "context expression".

This allows you to use Cco commands and queries from the Lua script. Continuing our example from above, we could retrieve the path to the unit card of our unit by doing:

campaignUnitContext:Call("IconPath")

or disband that same unit using

campaignUnitContext:Call("Disband")

Context expressions can also be chained together. For instance, to get the main_units_tables key associated with our unit, we could do it the long way:

   local mainUnitRecordContext =  campaignUnitContext:Call("UnitRecordContext")
    local main_unit_key = mainUnitRecordContext:Call("Key")

or we could save time by chaining them together as follows:

   local main_unit_key = campaignUnitContext:Call("UnitRecordContext.Key")

You can learn more about context expressions on their page.

Layout Files

UIComponents are defined in and created from layout files which specify the hierarchical relationships and properties of each element.

In games previous to Warhammer 3, UI Layout files are extensionless binary files found in the ui folder and its subfolders. They can be edited using a hex editor.

In Warhammer 3, Layout files are .twui.xml files. They can still be found in the UI folder, and are much easier to edit.

Binary Layout Files

Editing binary layout files is necessary for creating your own layouts in games prior to Warhammer 3. This is typically done using a hex editor.

TWUI.XML Layout Files

Each .twui.xml file has two parts: the hierarchy definition, and the components list.

Implementation Patterns

This section describes some basic patterns for creating UI mods. These are far from the only ways of doing things, but they should help you get started.

Basic Button Pattern in Lua by Drunk Flamingo

The example used here is to create a button on the character panel which costs 1000 gold and levels the character up to level 50.

The pattern has five parts:

  1. The Get or Create function.
  2. The Populate function.
  3. The Update Listeners
  4. The Click Listener
  5. The Multiplayer Safe Trigger Listener

In the case of images, text displays, or other visual elements, only the first three elements are required. The final two elements are specific to adding buttons to the UI.

Get or Create

As you might have guessed, this is a function that returns your new UI Component. The function first attempts to 'get' a component which already exists, and then creates the component if it doesn't.

Why do we do it that way? Because UIComponents are often destroyed as the player navigates the UI and causes it to refresh. That means that every time we want to reference our UIC, we also want to make sure it exists, and presumably we also want to make it exist if it doesn't. Rather than messy code that manually checks this every time, we formalize it into a single function.

The most simple get or create function is the one that CA provides: core:get_or_create_component("name", "ui/path/to/layout", parentComponent)

This command is suitable in many cases, but you may want to define your own. A simple get or create function is as follows:

   local function get_or_create_my_button()
          local ui_button_parent = find_uicomponent(core:get_ui_root(), "character_details_panel", "character_context_parent", "skill_pts_holder", "skill_pts")
          if not is_uicomponent(ui_button_parent) then
              out("Get or create button was called, but we couldn't find the parent! This function was probably called at the wrong time.")
              return
          end
          local button_name = "my_button_name"
          local existing_button = find_uicomponent(ui_button_parent, button_name)
          if is_uicomponent(existing_button) then
              return existing_button
          else
              local new_button = UIComponent(ui_button_parent:CreateComponent(button_name, "ui/templates/round_small_button"))
              new_button:SetImagePath("ui/skins/default/icon_swap.png")
              return new_button
          end
      end

Note that the only thing being done to this UI element is creating it, and setting up aspects of it which don't change: in this case, the image. As a general rule, your 'get or create' function should never include references to gameplay values or bother with setting the dynamic aspects of the element.

You can test your create function by opening the panel you want it to appear on, then calling it from the Lua console.

Populate

This function's job is to apply any dynamic aspects of your UI component based on the game state. Common examples of things that happen in the populate phase are:

  • Locking or unlocking buttons based on whether the player can afford some cost associated with pressing them.
  • Showing or hiding elements based on what region/character the player has selected.
  • Building and setting custom tooltips with information from the game.

Essentially: it should make the element look correct and display the correct information based on what the context is.

A simple populate function is as follows:

   local function populate_my_button(MyButton)
           if not is_uicomponent(MyButton) then
               out("The button passed to populate_my_button is not a valid UIC! We're probably calling these functions at the wrong time")
               return
           end
           local character = cm:get_character_by_cqi(cm:get_campaign_ui_manager():get_char_selected_cqi())
           if character:faction():treasury() < 1000 then
               MyButton:SetState("inactive")
               MyButton:SetTooltipText("Go Away!", true)
           else
               MyButton:SetState("active")
               MyButton:SetTooltipText("Click me!", true)
           end
       end

You can expand your elseif chains as needed.

To test your function, you can once again navigate to the correct panel, then call populate_my_button(get_or_create_my_button()) from the lua console.

Update Listeners

Now for the hard part - instead of using the Lua console, we want to figure out when to tell the game to call those functions for us.

The answer is usually 'right after the panel we're using opens'. In this case, we've defined a button that goes in the character panel, so lets start with that:

   core:add_listener(
           "UpdateMyButtonOnPanelOpenedCampaign",
           "PanelOpenedCampaign",
           function(context)
               return context.string == "character_details_panel"
           end,
           function (context)
               populate_my_button(get_or_create_my_button())
           end,
           true)

This is fairly simple, and in this case, it works!

Problems Writing Update Listeners

However, it won't always be that easy. Some common problems you'll run into are:

  1. The panel has old information on it when it appears, and doesn't show the correct information until it updates.
  2. The panel doesn't create all of its children instantly after opening.
  3. The panel updates shortly after opening and undoes the work you did (by deleting the button).

In all of these cases, you should try adding a callback to your listener, like so:

        cm:callback(function () populate_my_button(get_or_create_my_button()) end, 0.1)

This will delay your function call until the next tick. On some UI elements, it might be better to use:

        cm:real_callback(function () populate_my_button(get_or_create_my_button()) end, 100)

This will delay your function call for 100 miliseconds of real time, without reference to game ticks.

In some cases, you'll want to both call your function immediately and then also call it again later through a callback - this is commonly needed on panels which refresh constantly in order to avoid 'blinking' UI elements.

Add More Listeners

You will also probably want more than one update listener. While we have created our button on the character panel, it's possible for the player to change which character is selected without having to close and reopen that panel.

When we wrote our populate function, we changed the behaviour of our button based on which character was selected, so logically, when the selected character changes, we have to update our button. We'd probably want a listener that looks like this:

   core:add_listener(
          "UpdateMyButtonOnCharacterSelected",
          "CharacterSelected",
          function(context)
              return cm:get_campaign_ui_manager():is_panel_open("character_details_panel") --if the details panel isn't open, our button isn't relevant.
          end,
          function(context)
              populate_my_button(get_or_create_my_button())
          end,
          true)

For the most part, you'll just have to use your noggin to figure out how many additional update listeners you need. In this case, PanelOpenedCampaign and CharacterSelected are sufficient, but more complex mods will have dozens of update listeners. Think through your mod and playtest it to find ways that you can cause it to break.

You should now have a working component which appears where it is supposed to and populates with the correct information at the right time, all with minimal duplication of code.

Click Listener

To reiterate: if the UI component you're making isn't a button, you're done. You don't need this step or the next one. However, if you made something clickable, its time to set up that click.

We start by making a listener for the ComponentLClickUp event, which looks like this:

   core:add_listener(
         "MyButtonClicked",
         "ComponentLClickUp",
         function(context)
             return context.string == "my_button_name"
         end,
         function(context)
             my_button_clicked()
         end,
         true)

Whenever a UIComponent is clicked, this event will fire. In the conditional passed to the listener, we check that the name of the UI element which just got clicked matches up to the name of the button we created. Then, this will call my_button_clicked(), which we will define. We might try to do it like this:

       local function my_button_clicked()
             local character = cm:get_character_by_cqi(cm:get_campaign_ui_manager():get_char_selected_cqi())
             cm:treasury_mod(character:faction():name(), -1000)
             cm:add_agent_experience(cm:char_lookup_str(character), 50, true)
         end

and that would, at least at first glance, work as intended.

However, if you tested it in multiplayer, you would quickly notice a problem: ComponentLClickUp only happens on one PC, namely the one that clicked the button. If we use this code in our code, the other PCs in a multiplayer game won't be aware of the button click, and the game will desynchronise. To fix that, we need to instead package our relevant local information up and send it over to every PC in the game through a UI Trigger event. We do that using this command:

CampaignUI.TriggerCampaignScriptEvent(faction_command_queue_index, "specifying string")

This command allows you transmit a string message, associated to a particular faction, across the network for multiplayer games. The tricky part is that all additional information (in our case, the particular character we want to level) about the operation has to be packed into the string message. Our simple example of that looks like this:

       local function my_button_clicked()
             local character_cqi = cm:get_campaign_ui_manager():get_char_selected_cqi()
             local trigger_message = "my_button_pressed:"..tostring(character_cqi)
             CampaignUI.TriggerCampaignScriptEvent(cm:get_local_faction(true):command_queue_index(), trigger_message)
         end

First, we gather the information relevant to our operation. For us, that's just the character we want it to apply to. Then, we add that information to the message string with a prefix. Finally, we fire off the command, associated to the faction who pressed the button, which is our local faction.

The purpose of the prefix is to later determine whether a UITrigger event is the one we are looking for, and not one fired by someone else's button.

The takeaway? You need to be packing the necessary information into a string and sending it through TriggerCampaignScriptEvent, or your UI won't work in multiplayer games.

Multiplayer Safe Trigger Listener

Now that we've 'sent' the click, we need to recieve it. To do this, we need to write code that does the previous step in reverse - before we were packing information into a string, now we're unpacking it out of one. We'll start by assembling a listener for the UITrigger:

       core:add_listener(
             "MyButtonMPSafeTrigger",
             "UITrigger",
             function(context)
                 return string.find(context:trigger(), "my_button_pressed:")
             end,
             function(context)
                 my_mp_safe_trigger(context)
             end,
             true)

The "UITrigger" event is what we've fired when we called TriggerCampaignScriptEvent in the previous step. In our conditional function, we ask whether the trigger string contains the prefix that we used in the previous step. If it does, then this trigger event came from our button and we need to do something with it. That would look like this:

       local function my_mp_safe_trigger(context)
             local this_trigger_str = context:trigger()
             out("UI trigger recieved: "..this_trigger_str)
             local faction = cm:model():faction_for_command_queue_index(context:faction_cqi());
             local cqi_as_str = string.gsub(this_trigger_str, "my_button_pressed:", "")
             local character = cm:get_character_by_cqi(tonumber(cqi_as_str))
             if character then
                 cm:treasury_mod(faction:name(), -1000)
                 cm:add_agent_experience(cm:char_lookup_str(character), 50, true)
             end
         end

First, we grab the faction and trigger string associated to the event. We can pass the faction command queue index directly into the model interface to get our faction. Next, we want to get our character. We know that our string is formatted as:

my_button_pressed:14

where 14 is a Command Queue Index. Since this is a relatively simple format, we can get just the command queue index by removing the prefix we are using from the string. We do that by calling for a global substitution of every "my_button_pressed:" for "", or an empty string. The resulting string would now just be: "14". We can then pass that string into tonumber() and finally into get_character_by_cqi() to get our character interface.

At this point, you'll see that we have the exact same information we had in our first, MP unsafe, attempt at making our click function. So, we can now call the same commands we tried before, only this time, we are calling them on every PC in the game, not just the local PC who clicked the button. This keeps the game synchronised, and lets your UI mod work in multiplayer.

Simple enough, but this step can get harder. What if you need to transmit not just one character, but a list of characters? You might need to assemble a longer string like so:

my_button_pressed:14|18|102

and then decode it on the other side. The more information you need to transmit, the more complex your serialization and unserialization will become.

Expanding on the Pattern

As you continue to work on your mod, this basic structure should remain recognizable, but the individual parts will get more and more complicated. You will add more details to the populate function to display the information you need to display, you will write more update functions to keep that information accurate, you will cram more information into the multiplayer trigger string, and you will have to unpack that info on the other side. However, this pattern should keep that work spread out into managable chunks.