Writing your first addon
From ESOUI Wiki
(→Saving the indicator's position) |
(→Basic skeleton code) |
||
Line 77: | Line 77: | ||
if addonName == FooAddon.name then | if addonName == FooAddon.name then | ||
FooAddon.Initialize() | FooAddon.Initialize() | ||
+ | --unregister the event again as our addon was loaded now and we do not need it anymore to be run for each other addon that will load | ||
+ | EVENT_MANAGER:UnregisterForEvent(FooAddon.name, EVENT_ADD_ON_LOADED) | ||
end | end | ||
end | end | ||
-- Finally, we'll register our event handler function to be called when the proper event occurs. | -- Finally, we'll register our event handler function to be called when the proper event occurs. | ||
+ | -->This event EVENT_ADD_ON_LOADED will be called for EACH of the addns/libraries enabled, this is why there needs to be a check against the addon name | ||
+ | -->within your callback function! Else the very first addon loaded would run your code + all following addons too. | ||
EVENT_MANAGER:RegisterForEvent(FooAddon.name, EVENT_ADD_ON_LOADED, FooAddon.OnAddOnLoaded) | EVENT_MANAGER:RegisterForEvent(FooAddon.name, EVENT_ADD_ON_LOADED, FooAddon.OnAddOnLoaded) | ||
</source> | </source> |
Latest revision as of 11:51, 1 July 2022
So you've decided that you're interested in creating UI addons for Elder Scrolls Online. Great! Here's a walk-through to get you started.
This tutorial assumes that you already have a working knowledge of the LUA language syntax. If not, we highly suggest at least familiarizing yourself with the language here
For this example addon, we'll implement a simple indicator of whether or not the player is in combat.
Contents |
Addon file structure
If you're looking to write addons, you've probably already used some. The addons that you've installed are located in the AddOns subfolder in your user folder. Typically each addon will have its own named directory within the AddOns
directory. This keeps all of the addon's files in one tidy collection.
Within an addon's folder, there are 4 types of files:
- A
.txt
file specifies the addon metadata, indicating the name of the addon, what other files should be loaded, et cetera. -
.lua
files contain the code for the addon. -
.xml
files can contain UI definitions and templates, if desired. - Custom resources (e.g. textures) can be included as well.
For the most basic of addons, you only need two files - the metadata file and a code file:
Elder Scrolls Online\live\AddOns | +-- FooAddon\ | +-- FooAddon.txt | +-- FooAddon.lua
Manifest file (.txt)
It is customary to name both the directory and the metadata file using the addon's name to make them easy to find, so if your addon is named "Foo Addon", then the directory would be FooAddon
and the metadata file would be FooAddon.txt
to match.
## Title: Foo Addon ## APIVersion: 100025 FooAddon.lua
This Title is what appears in ESO's Add-ons panel.
The ## ApiVersion: number tag is used to specify which APIVersion ingame was used as the addon was created, and which apiversion it is compatible with. It's just comparing the numbers (you can add up to 2 APIversions into this tag, e.g. ## ApiVersion: 10033 10034) with the result of the function GetAPIVersion() ingame. If the numbers differ the addon will count as "out of date" and you need to check the addon ingame list's checkbox "Allow out of date addons" to enable it again. But this does not say if the addon is working or not. Basiscally this means you'd have to update this number after each patch so that the APIVerson matches the ingame version.
While there are many options you can specify in your addon's metadata file, the most important ones (and the ones that every addon should have set) are the Title
and APIVersion
. The title is what will be displayed in the in-game addon list, and the APIVersion needs to match the current APIVersion specified by the client or else the addon will be considered "out of date" and disabled by default (to prevent completely broken UIs when Zenimax updates the API).
After the options comes a list of other files that should be loaded for the addon. In this starter addon, all there will be is a Lua file, so that is the only file listed.
Code files (.lua)
The code for your addon is where you actually implement whatever functionality you want your addon to have. Each code file will be loaded and run when the UI is initially loaded. Your addon can have as many code files as you want - addon authors will often split up code between multiple files to help keep it organized.
Try it - create an empty addon.
Try creating the directory and the two files mentioned above. Fill in FooAddon.txt
with the items mentioned above. Leave FooAddon.lua
as an empty file for now. Once you've saved both files, if you /reloadui
in game (or press E from the addons window), your new addon should show up in the list. It won't do much of anything yet though since it has no code.
Basic skeleton code
We'll start off by creating a small amount of code (in FooAddon.lua
) designed to give our addon a little structure and help it load gracefully. Note that all of the lines beginning with --
below are comments - they don't affect the actual code.
-- First, we create a namespace for our addon by declaring a top-level table that will hold everything else. FooAddon = {} -- This isn't strictly necessary, but we'll use this string later when registering events. -- Better to define it in a single place rather than retyping the same string. FooAddon.name = "FooAddon" -- Next we create a function that will initialize our addon function FooAddon.Initialize() -- ...but we don't have anything to initialize yet. We'll come back to this. end -- Then we create an event handler function which will be called when the "addon loaded" event -- occurs. We'll use this to initialize our addon after all of its resources are fully loaded. function FooAddon.OnAddOnLoaded(event, addonName) -- The event fires each time *any* addon loads - but we only care about when our own addon loads. if addonName == FooAddon.name then FooAddon.Initialize() --unregister the event again as our addon was loaded now and we do not need it anymore to be run for each other addon that will load EVENT_MANAGER:UnregisterForEvent(FooAddon.name, EVENT_ADD_ON_LOADED) end end -- Finally, we'll register our event handler function to be called when the proper event occurs. -->This event EVENT_ADD_ON_LOADED will be called for EACH of the addns/libraries enabled, this is why there needs to be a check against the addon name -->within your callback function! Else the very first addon loaded would run your code + all following addons too. EVENT_MANAGER:RegisterForEvent(FooAddon.name, EVENT_ADD_ON_LOADED, FooAddon.OnAddOnLoaded)
This skeleton code does 3 main things:
- It sets up a table to hold all of the addon's functions and data.
- It creates an initialization function that we can expand later.
- It sets up an event handler that will call the initialization function when this addon is loaded.
Save FooAddon.lua
with the new code. If you reload the UI, it still won't visibly do anything since we haven't yet added the actual functionality - but you shouldn't get any UI errors either.
Troubleshooting
If anything in your AddOn is not working as expected at this point, it can only be due to a typo.
Check that the Addon File Structure is exactly as displayed. Any differences (capitalization, spaces...) will break things.
If debug messages added via d() do not show up, that is most likely due to LibDebugLogger redirecting them to LibDebugViewer. Either disable both, or enable LibDebugViewer's panel.
Adding functionality
Now it's time to make our addon actually do something visible. We want to create an indicator for whether or not the player is in combat. We'll start out by just displaying a message whenever the player enters and exits combat. To do this, we're going to need to handle another event.
If you look at the list of events provided by the API, you'll find EVENT_PLAYER_COMBAT_STATE
among them. This event occurs whenever the player's combat state changes. It passes along a flag which indicates the player's new combat state - true
if the player is in combat, and false
if the player is not in combat. We'll create a handler for this event.
For now, we'll use a function that's part of the API, d()
, to output a message to the chat window. d
is short for "debug" - it's intended to mostly be used by developers like you for outputting information during the development process.
Getting the initial combat state
First we'll want to add a variable to our addon's table to keep track of the player's current combat state, and initialize it when our addon loads. Change the FooAddon.Initialize
function to look like this:
function FooAddon.Initialize() FooAddon.inCombat = IsUnitInCombat("player") end
In this case, the new code creates a variable in the FooAddon
table called inCombat
, and initializes it to the current combat state of the player via a call to the API function IsUnitInCombat()
.
Handling combat state changes
Now that we have the current state, we want to detect when it changes. We'll create and register another event handler to do this.
First, let's create the event handler that will be called when combat state changes. Add it as a new function:
function FooAddon.OnPlayerCombatState(event, inCombat) -- The ~= operator is "not equal to" in Lua. if inCombat ~= FooAddon.inCombat then -- The player's state has changed. Update the stored state... FooAddon.inCombat = inCombat -- ...and then announce the change. if inCombat then d("Entering combat.") else d("Exiting combat.") end end end
Once we've created the function, we need to register it as a handler for the event. We'll do this from the Initialize
function (unlike the addon load event, which we did from the top level of the file), because we only want to start handling most events after our addon has completely loaded.
Update our initialize function so it looks like this:
function FooAddon.Initialize() FooAddon.inCombat = IsUnitInCombat("player") EVENT_MANAGER:RegisterForEvent(FooAddon.name, EVENT_PLAYER_COMBAT_STATE, FooAddon.OnPlayerCombatState) end
Now if you save the file and then /reloadui
in game, you should see messages appear in the first chat tab whenever your character enters and exits combat.
Adding a graphical component
A message in the chat window proves our addon is actually doing something, but it'd be nice to have something that's part of a more visual UI. Let's change it to a big red "Fighting!" notice that appears on-screen whenever we're in combat.
There are a couple of different ways to create controls (graphical units) in ESO. For this particular tutorial, we'll do it using XML definitions, but it's also possible to create controls through pure Lua code by making use of existing templates already defined by the game.
Creating a graphical control in XML
First, create FooAddon.xml
- this is where our control definitions will go. Then update FooAddon.txt
to list FooAddon.xml
right before FooAddon.lua
. (Files are loaded in the order listed, so we want to make sure our controls are loaded before our code might use them.)
Next, add the following content to FooAddon.xml
and save the file:
<GuiXml> <Controls> <TopLevelControl name="FooAddonIndicator"> <Dimensions x="200" y="25" /> <Anchor point="BOTTOM" relativeTo="GuiRoot" relativePoint="CENTER" offsetY="-20" /> <Controls> <Label name="$(parent)Label" width="200" height="25" font="ZoFontWinH1" inheritAlpha="true" color="FF0000" wrapMode="TRUNCATE" verticalAlignment="TOP" horizontalAlignment="CENTER" text="Fighting!"> <Anchor point="TOP" relativeTo="$(parent)" relativePoint="TOP" /> </Label> </Controls> </TopLevelControl> </Controls> </GuiXml>
You're probably wondering what exactly all of this does. Here's an explanation of each part:
- GuiXml
- This is the wrapper tag for any XML file used for constructing ESO UI elements.
- Controls
- This container holds any definitions of controls.
- TopLevelControl
- Any control which isn't a child of another control is a TopLevelControl. Typically this means the highest level of organization for a given UI element - for instance the collection of all chat windows might all be in one single TopLevelControl for the chat system.
- Dimensions
- This tag is used to specify the size of a control. (It's often possible to do this via
width
andheight
attributes instead.)
- Anchor
- Anchors define how various controls should be positioned relative to one another.
- This anchor places our overall control group a bit above the center of the screen.
- Controls
- Once again, a container for controls - though in this case, the children of the current TopLevelControl, rather than a collection of top-level controls.
- Label
- This defines a label - a piece of text that will be rendered as part of a UI element. It has various attributes used to customize how the text is displayed.
- Anchor
- This anchor makes the label line up with its parent control. Since they're the same size, any two matching points would do.
The $(parent)
placeholder is filled in by ESO when it loads the template - it automatically fills in the name of the control's parent. This can be especially useful when creating reusable templates for controls that might have different names. In this case, its usage means that the Label
will wind up having the name FooAddonIndicatorLabel
.
Adding your UI to your project
For LUA to recognize your UI xml, you need to reference it in your project.
Edit your FooAddon.txt
file as follows:
## Title: Foo Addon ## APIVersion: 101033 ## AddOnVersion: 1 ## IsLibrary: False FooAddon.lua FooAddon.xml
Manipulating controls in Lua
Now that we have a defined control, we can use Lua to manipulate it. Go back to FooAddon.lua
and change FooAddon.OnPlayerCombatState
to look like this:
function FooAddon.OnPlayerCombatState(event, inCombat) -- The ~= operator is "not equal to" in Lua. if inCombat ~= FooAddon.inCombat then -- The player's state has changed. Update the stored state... FooAddon.inCombat = inCombat -- ...and then update the control. FooAddonIndicator:SetHidden(not inCombat) end end
In this new version of the function, we're calling the :SetHidden()
method of the control to change whether or not the "Fighting!" text is displayed. We use not
to invert the value of inCombat
, since we want the text to be visible when the player is in combat, and hidden when not in combat.
Make sure you've saved all of the files, then try reloading the UI. You should see the large red text appear when you go into combat and disappear when you leave combat. (Remember that it can often take some time to leave combat after you've stopped fighting.)
Saving settings
My that big red text is kind of imposing, isn't it? Especially when it's smack-dab center in the screen. Let's make it so that the user can move the text somewhere else and it'll stay where they've moved it.
In order to do this we're going to have to be able to save certain values between UI reloads (and between game starts). We do this using what's called SavedVariables.
Adding a SavedVariable
First we need to tell ESO that our addon has something to save. Update the contents of FooAddon.txt
to look like this:
## Title: Foo Addon ## APIVersion: 101033 ## AddOnVersion: 1 ## IsLibrary: False ## SavedVariables: FooAddonSavedVariables FooAddon.xml FooAddon.lua
Adding the SavedVariables line lets ESO know that it should save the data referenced by FooAddonSavedVariables
whenever the UI is unloaded.
This object you define at ## SavedVariables: (here: FooAddonSavedVariables) needs to have a unique name! It will be populated to teh global variables of lua at the EVENT_ADD_ON_LOADED of your addon. So you can access it from the global lua table _G directly at that time (attention: It will be nil before that event and it will be set = nil at the event again! So do not access ad write data to it before your addon's EVENT_ADD_ON_LOADED) or use the
ZO_SavedVars wrapper which the game provideds to help you acessing that table and defining some depdencies like the server you play on GetWorldName(), the character ID you play with GetCurrentCharacterId() or the account you play with GetDisplaName().
Next we need to actually set up the SavedVariables object in our Lua code. While it's possible to just read and write directly to the name specified as the SavedVariables entry (it behaves like a Lua global variable), there is also a built-in module that provides management of per-character settings, versioning, and other features related to saving data. We'll use it here to show you how. Update the FooAddon.Initialize()
function to look like this:
function FooAddon.Initialize() FooAddon.inCombat = IsUnitInCombat("player") EVENT_MANAGER:RegisterForEvent(FooAddon.name, EVENT_PLAYER_COMBAT_STATE, FooAddon.OnPlayerCombatState) FooAddon.savedVariables = ZO_SavedVars:NewCharacterIdSettings("FooAddonSavedVariables", 1, nil, {}) --Instead of nil you can also use GetWorldName() to save the SV server dependent end
This creates a ZO_SavedVars
object which wraps the raw SavedVariables entry and will automatically load a unique set of settings for each character. If you want your addon's settings to be account-wide instead of per-character, you can use ZO_SavedVars:NewAccountWide()
instead.
No matter if character dependent or account dependent: You should think about saving the SVs dependent on the server you play on as you might play on 3 servers (NA, EU, PTS).
If you do not want to save the settings the same for each of them you can either user param 3 (the profile name) of the ZO_SavedVars wrapper function (NewAccountWide, or NewCharacterIdSettings),
or param 5 to specify the server name. A function provided by the game, returning the server name, is GetWorldName() -> It returns e.g. "EU Megaserver", "NA Megaserver" or "PTS"
Making the indicator movable
In order to allow the user to change where the indicator is, it's going to need to be movable. To do that, update the TopLevelControl
in FooAddon.xml
to look like this:
<TopLevelControl name="FooAddonIndicator" mouseEnabled="true" movable="true" clampedToScreen="true">
The new attributes added for the TopLevelControl
tell ESO to make it so that the user can drag the control around, as long as it stays somewhere on the screen. If you save everything and /reloadui
, you should notice that you're able to drag the text around now (when it's visible, that is - for testing, you might modify the code to make it always visible). If you /reloadui
again, however, the position will reset, because we're not actually saving it yet.
Saving the indicator's position
In order to remember where the user placed the indicator we're going to have to store its position in our SavedVariable and then load that position whenever our addon loads. Let's tackle the saving portion first. In order to save the position, we'll need to know when it changes. Update FooAddon.xml
to look like this:
<GuiXml> <Controls> <TopLevelControl name="FooAddonIndicator" mouseEnabled="true" movable="true" clampedToScreen="true"> <Dimensions x="200" y="25" /> <Anchor point="BOTTOM" relativeTo="GuiRoot" relativePoint="CENTER" offsetY="-20" /> <OnMoveStop> FooAddon.OnIndicatorMoveStop() </OnMoveStop> <Controls> <Label name="$(parent)Label" width="200" height="25" font="ZoFontWinH1" inheritAlpha="true" color="FF0000" wrapMode="TRUNCATE" verticalAlignment="TOP" horizontalAlignment="CENTER" text="Fighting!"> <Anchor point="TOP" relativeTo="$(parent)" relativePoint="TOP" /> </Label> </Controls> </TopLevelControl> </Controls> </GuiXml>
The OnMoveStop
block we've added will call another event handler on our object whenever the user finishes dragging our indicator around. Now we just need to add that handler to FooAddon.lua
so it can be called:
function FooAddon.OnIndicatorMoveStop() FooAddon.savedVariables.left = FooAddonIndicator:GetLeft() FooAddon.savedVariables.top = FooAddonIndicator:GetTop() end
This function is pretty straightforward - it just stores the position of the top-left corner of our control in our saved variables table. Of course, just saving the position isn't much use if we don't load it, so let's add a function to restore the position and update our initialization function one more time to call it:
function FooAddon.RestorePosition() local left = FooAddon.savedVariables.left local top = FooAddon.savedVariables.top FooAddonIndicator:ClearAnchors() FooAddonIndicator:SetAnchor(TOPLEFT, GuiRoot, TOPLEFT, left, top) end function FooAddon.Initialize() FooAddon.inCombat = IsUnitInCombat("player") EVENT_MANAGER:RegisterForEvent(FooAddon.name, EVENT_PLAYER_COMBAT_STATE, FooAddon.OnPlayerCombatState) FooAddon.savedVariables = ZO_SavedVars:NewCharacterIdSettings("FooAddonSavedVariables", 1, nil, {}) FooAddon.RestorePosition() end
There's a couple of new things in this added code. First, note that we're using local
variables inside a function. Most variables that are only relevant to a particular function should be made local
to avoid polluting the global namespace which is shared by all addons and functions. (This is also why we've put all of our addon's functions inside a top-level table - that way, only a single table name is global, which is unlikely to cause any conflicts since it's named after our addon.)
Second, we've introduced a few more API functions, specifically ones that operate on controls. :ClearAnchors()
tells ESO to remove any existing layout anchors defining how a given control should be positioned. We do this because we then add a new anchor which positions the control based on our saved position. :SetAnchor()
is what does that adding, and in this case we're telling the game to align the control's top-left corner relative to the top-left corner of the GuiRoot
, which is how ESO refers to the entire screen. The last two arguments are the offset between the two points of the anchor, which allows us to specify the exact position.
Save everything, /reloadui
, and you should now have an indicator that can be dragged around the screen and will stay wherever it's dragged, even across UI reloads.
Behaviour on /reloadui
Since you're doing a lot of /reloadui, you've likely noticed that the indicator is visible after each one, even if you are not in combat. The simplest way to handle this is to add hidden="true" to the TopLevelControl:
<TopLevelControl name="FooAddonIndicator" mouseEnabled="true" movable="true" clampedToScreen="true" hidden="true">
If you do this, and then are a bit of a daredevil and /reloadui in combat, you'll notice something odd: The indicator isn't visible! While it's rare for players to reloadui under combat conditions, it is still a bug, and here's two ways you can deal with it:
1. Check if the player is in combat in the Initialized() function, and set it visible/hidden as needed. Adding the line FooAddonIndicator:SetHidden(not IsUnitInCombat('player')) to your initialized() function will set it appropriately. IsUnitInCombat takes in one argument, a unitTag, and returns true if the unit is in combat, or false if they are not. You can find more information on unitTags here: http://wiki.esoui.com/UnitTag 2. Use the <OnInitialized> field in the XML:
<GuiXml> <Controls> <TopLevelControl name="FooAddonIndicator" mouseEnabled="true" movable="true" clampedToScreen="true"> <Dimensions x="200" y="25" /> <Anchor point="BOTTOM" relativeTo="GuiRoot" relativePoint="CENTER" offsetY="-20" /> <OnInitialized> -- LUA code here. This is where you'd check to see if the control needs to be hidden or not. FooAddonIndicator:SetHidden(not IsUnitInCombat('player')) </OnInitialized> <OnMoveStop> FooAddon.OnIndicatorMoveStop() </OnMoveStop> <Controls> <Label name="$(parent)Label" width="200" height="25" font="ZoFontWinH1" inheritAlpha="true" color="FF0000" wrapMode="TRUNCATE" verticalAlignment="TOP" horizontalAlignment="CENTER" text="Fighting!"> <Anchor point="TOP" relativeTo="$(parent)" relativePoint="TOP" /> </Label> </Controls> </TopLevelControl> </Controls> </GuiXml>
Further reading
You now have a basic addon which encompasses many of the various techniques used by addon developers to create all manner of addons. If you're looking for more information to help you along the way, here are a few recommendations.
- Programming in Lua is an excellent guide to Lua for those who are not all that familiar with it.
- The rest of this wiki, especially the API reference, contains a significant amount of information regarding what is available in the API for you to use in addons.
- The ESOUI Developer Discussions forum categories have more additional useful information, and also are a place that you can ask questions.
- The official ESO Addons forum is probably a place of last resort - most of those posting there are not addon authors, but if all else fails, you can always try asking there.