-
Notifications
You must be signed in to change notification settings - Fork 169
MCM Advanced Features
This guide covers several topics that were not yet covered in the Quickstart guide.
For details on the referenced functions, see the API Reference.
- Script Initialization
- Pages
- Options
- Custom Content
- Localization
- Key Conflict Management
- Script Versioning
Normally, when working with arrays or other variables that cannot be initialized immediately at the point of declaration, you have to put initialization code in OnInit
.
For your config menu, however, you should rather use the OnConfigInit
event we provide and avoid OnInit
.
If that's not possible and you have to use OnInit
for some reason, make sure to call parent.OnInit()
the original event handler of SKI_ConfigBase is not skipped.
string[] myArray
; WRONG
event OnInit()
myArray= new myArray[5]
; ...
endEvent
; Meh... avoid using OnInit()
event OnInit()
parent.OnInit()
myArray= new myArray[5]
; ...
endEvent
; OK
event OnConfigInit()
myArray= new myArray[5]
; ...
endEvent
The reason you should avoid OnInit()
is that config menus should be designed so that MCM (which is part of SkyUI) can be removed at any time and added again later. If that happens, the variables of your script will be reset, BUT OnInit()
won't be called again. This may leave some of variables uninitialized. OnConfigInit()
doesn't have this problem.
There are certain settings you might want to apply after each game reload, for example scripted INI changes or other modifications that persist in memory.
To do this, extend OnGameReload
of SKI_ConfigBase. Be aware that this is not part of the base API, so you'll be responsible for calling parent.OnGameReload()
. Not doing that will break the menu.
event OnGameReload()
parent.OnGameReload() ; Don't forget to call the parent!
Utility.SetINIFloat("someSetting", someValue)
endEvent
If your config menu contains a lot of options, you might want to consider diving them into several categories. Or, even if all options would fit into a single page, it might still be a good idea to separate general from advanced settings.
For this purpose config menus support multiple pages. Setting them up is straightforward.
Define your page names by setting the Pages
property of your config menu. You can either do that in the Creation Kit property editor, just like you set ModName
, or you can do it in the OnConfigInit
event of your script. An example for the scripted variant:
event OnConfigInit()
Pages = new string[2]
Pages[0] = "Page 1"
Pages[1] = "Page 2"
endEvent
Now, whenever your config menu is active, the list of pages will be shown below the mod name.
In general, setting up options for multiple pages works exactly the same as using a single page; you add them in OnPageReset
. The only difference is that you check the page
parameter when deciding which options to add:
event OnPageReset(string page)
if (page == "Page 1")
; Add page 1 options
elseIf (page == "Page 2")
; Add page 2 options
endIf
endEvent
By default, after selecting your mod from the main list, no page is active and the page
parameter is is ""
. Since we didn't handle this case in our example, the option list will be blank until the user has chosen a page. On way to fill that void is adding a custom logo of your mod . For further details on this topic, see Loading custom content.
Since each option ID is unique, for all the other events it won't matter which option a page is on. However, if you have a lot of options, you may want to spread your code over several functions.
In this scenario, it's helpful being able to check the current page even outside of OnPageReset
; you do that by accessing the CurrentPage
property:
event OnOptionSelect(int option)
if (CurrentPage == "Gameplay")
HandleGameplayOptionSelect(option)
elseIf (CurrentPage == "Immersion")
HandleImmersionOptionSelect(option)
endIf
endIf
Be aware that this makes moving around options between several pages, or renaming pages, slightly more tedious, since you have to change more than just OnPageReset
when doing so.
While the basics about options have already been covered, there are two more topics that have been left out so far: Option flags and grouped updating.
All Add*Option
functions have an optional flags
parameter. It can be used to enable certain behavior for the option. Accepted flags are
-
OPTION_FLAG_NONE
, to clear the flags; -
OPTION_FLAG_DISABLED
, to grey out and disable the option.
int flags
if (isMyOptionDisabled)
flags = OPTION_FLAG_DISABLED
else
flags = OPTION_FLAG_NONE
endIf
AddToggleOption("Toggle this", myToggleValue, flags)
To change the flags later in combination with Set*OptionValue
functions, use SetOptionFlags(optionID, flags)
.
If you use the Set*OptionValue
functions to change several options at once, Papyrus may slightly delay function execution at any point in the script. This results in asynchronous updating of the option display.
To prevent this, Set*OptionValue
and SetOptionFlags
support an optional noUpdate
parameter. Example:
SetTextOptionValue(oid1, "Value1", true) ; Don't redraw the list yet
SetTextOptionValue(oid2, "Value2", true) ; ...
SetTextOptionValue(oid3, "Value3", true) ; ...
SetTextOptionValue(oid4, "Value4") ; Refresh now
It's possible to load content from an external file into the option list area. The original option list will be hidden when that happens.
Supported source file formats are SWF for Flash movies and DDS for images. PNG and some other image formats may work as well, but they will most likely be imported in bad quality.
Here's a practical example used in SkyUI to display the animated logo:
event OnPageReset(string a_page)
; Load custom .swf for animated logo that's displayed when no page is selected yet.
if (a_page == "")
LoadCustomContent("skyui/skyui_splash.swf")
return
else
UnloadCustomContent()
endIf
; ... rest of OnPageReset
The path of the loaded file is relative to Data/Interface/.
Note that you have to call UnloadCustomContent()
manually, to remove the custom content and show the original option list again. This isn't done automatically so custom content can stay active across several pages.
LoadCustomContent
supports two optional parameters for X and Y position offset. (0,0) is the top left corner of the option list area. The dimensions of this area are 770x446, the horizontal center point is at (376,223), which is not exactly at half of the width because the right side holds the scroll bar. To calculate the offsets for a 256x256 image, have a look at this example:
X offset = 376 - (imageWidth / 2) = 376 - 128 = 258
Y offset = 223 - (imageHeight / 2) = 223 - 128 = 95
To support multiple languages in your config menu, you can utilize the UI localization capabilities provided by SKSE. This section will explain how this works exactly.
The game itself stores the translated strings in Data/Interface/Translate_LANGUAGE.txt
, where LANGUAGE
is replaced by the current game language, i.e. Translate_ENGLISH.txt
. This file contains lines of key/value pairs for the translated strings, separated by a tab stop. Each key has to start with the $
sign. Example:
...
$Back Back
$Backstabs Backstabs
$Barters Barters
...
Whenever the contents of a textfield in UI match a key in this file, it's replaced with the translated value. Even without SKSE, you could add your own translations by modifying the original translates file. The problem is that this would lead to conflicts if multiple mods want to extend this file.
That's why SKSE adds support to load translations from additional files. For each active mod in the load order, Data/Interface/Translations/modname_LANGUAGE.txt
is checked for translations, where modname
is replaced by the name of the mod data file (i.e. SkyUI_ENGLISH.txt
, if the data file is SkyUI.esp
). The translation format is the same as described above.
Important: The character encoding of the translation files must be UTF16 LE (aka UCS-2 LE) with BOM. Use a text editor like Sublime or Notepad++ to save with this encoding.
Be aware, however, that these translations only work, if the textfield contents match the translation key exactly. Given
$Hello Hello
"$Hello"
results in "Hello"
, but "$Hello World"
stays "$Hello World"
.
Loading translation files from inside BSA file is possible. Languages the game supports are CZECH, ENGLISH, FRENCH, GERMAN, ITALIAN, POLISH, RUSSIAN, SPANISH and JAPANESE. Only the file that matches your current language is loaded; there is no fallback to ENGLISH as default.
For simple words or short sentences, you should follow the convention of naming the key the same as the translated string:
$Are you sure? Are you sure?
For longer strings, rather pick a different key for practical reasons. You should always add a prefix that is unique to your mod in this case, to avoid name collisions with other mods:
$MYPREFIX_QUESTION1 Are you sure?\nAre you ABSOLUTELY sure you want to continue??????
If your mod is named MyMod.esp
, localize your page names by adding
$General General
$Advanced Advanced
$Help Help
to Data/Interface/Translations/MyMod_ENGLISH.txt
,
$General Allgemein
$Advanced Fortgeschritten
$Help Hilfe
to Data/Interface/Translations/MyMod_GERMAN.txt
etc.
Then, in the config menu, name your pages accordingly:
Pages[0] = "$General"
Pages[1] = "$Advanced"
Pages[2] = "$Help"
When using the KeyMap option type to map buttons to custom controls, conflicts may arise because these buttons are already in use. OnOptionKeyMapChange
reports these conflicts, so you can react accordingly. The conflictControl
string parameter contains the name of the conflicting control, or ""
of there was no conflict. conflictName
is the name of the mod that owns the control, or ""
if it's a part of the regular game.
In some cases, for example when defining a control that is only used in a certain context, you may choose to ignore any conflicts.
For regular game-play controls, it should be a good idea to display a confirmation dialog.
event OnOptionKeyMapChange(int option, int keyCode, string conflictControl, string conflictName)
if (option == myKeymapOID)
bool continue = true
if (conflictControl != "")
string msg
if (conflictName != "")
msg = "This key is already mapped to:\n\"" + conflictControl + "\"\n(" + conflictName + ")\n\nAre you sure you want to continue?"
else
msg = "This key is already mapped to:\n\"" + conflictControl + "\"\n\nAre you sure you want to continue?"
endIf
continue = ShowMessage(msg, true, "$Yes", "$No")
endIf
if (continue)
myKey = keyCode
SetKeymapOptionValue(_option, keyCode)
endIf
endIf
endEvent
The mechanism for conflict detection described just now relies on mods reporting their used keys via GetCustomControl
. Otherwise, only conflicts with standard controls can be detected.
Implementing GetCustomControl
is easy, just check the passed keyCode
against all keys you are using and - if matched - return a descriptive name of the control it's assigned to. Otherwise, return ""
. Example:
string function GetCustomControl(int keyCode)
if (keyCode == myKey)
return "Turn 180 degrees"
else
return ""
endIf
endFunction
As soon as you released the first version of a config menu, you'll have to account for people upgrading to a newer version from an older save. To understand why it can be a problem, have a look at [this article](http://www.creationkit.com/Save_File_Notes_(Papyrus\)).
There are several ways you could handle this:
- Force users of your mod to make a 'clean save' after each new version.
- Delete the old config menu quest and create a new one.
- Incrementally upgrade your existing config menu quest at run-time.
The 'clean save' method is tempting, because it doesn't require any action from you as the mod author. But it comes with several major drawbacks:
- If users don't follow instructions (it happens), you'll end up with undefined behavior. Usually, this means that things break.
- You'll lose any progress associated with your mod.
- There have been reported issues where 'clean save' actually results in 'broken save'. Officially, removing mods at runtime is not supported, so there may be all kinds of issues.
One scenario where 'clean saves' may still be used is for alpha/beta testing for a smaller group of users who are supposed to know what they're doing, though even there it's advised to instruct them to keep a save around that is actually clean (i.e. has never been used with your mod before).
The second method would be deleting your old config menu quest and creating a new one. This is recommended if you're doing major changes to the script and an incremental update would not be feasible. The downside is that all previous settings are reset to their default values.
Based on [the article](http://www.creationkit.com/Save_File_Notes_(Papyrus\)) that was linked earlier in this section, when making changes to a script that has already been deployed in a release, we can define a few rules:
- It's save to change functions and event handlers.
- Don't remove or rename variables or properties; only add new things.
- Once you referenced a custom type (a custom script), removing this type will break your script, even if you removed all references to the former.
If there are fundamental problems with these rules when you want to update the script, consider using the previous approach of replacing the old quest by a new one.
More things to consider:
- If you change default values of variables that have already been initialized, they'll keep their old value. You can't, for example, add new pages by just changing the original property value.
- OnConfigInit (or OnInit) will not be executed again.
To work around these issues, we already provide an infrastructure for script versioning as part of the ConfigMenu API. First implement GetVersion()
to return the current revision of your script. Don't use a variable to hold the version but return a literal number. The default implementation returns 1:
; SCRIPT VERSION
int function GetVersion()
return 1 ; Default version
endFunction
Each script keeps track of an internal version and detects when it's different from the return value of GetVersion()
. If that's the case, the OnVersionUpdate
event is triggered. The following example will illustrate things further.
Version 1 is just a regular script. GetVersion()
and OnVersionUpdate
can be omitted and left at their default value.
; INITIALIZATION
event OnConfigInit()
Pages = new string[2]
Pages[0] = "Page 1"
Pages[1] = "Page 2"
endEvent
Version 2 now adds two more pages.
; SCRIPT VERSION
int function GetVersion()
return 2
endFunction
; INITIALIZATION
event OnConfigInit()
Pages = new string[2]
Pages[0] = "Page 1"
Pages[1] = "Page 2"
endEvent
event OnVersionUpdate(int a_version)
; a_version is the new version, CurrentVersion is the old version
if (a_version >= 2 && CurrentVersion < 2)
Debug.Trace(self + ": Updating script to version 2")
Pages = new string[4]
Pages[0] = "Page 1"
Pages[1] = "Page 2"
Pages[2] = "Page 3"
Pages[3] = "Page 4"
endIf
endEvent
If this script is run on a save for the first time, it'll execute OnConfigInit
, then instantly OnVersionUpdate
to version 2. If it's run on a save that already used the first version, only OnVersionUpdate
will be executed.
Version 3 adds a couple of variables that have to be initialized.
; SCRIPT VERSION
int function GetVersion()
return 3
endFunction
; PRIVATE VARIABLES
; -- Version 3 --
int myVar = 0
string[] myArray
; INITIALIZATION
event OnConfigInit()
Pages = new string[2]
Pages[0] = "Page 1"
Pages[1] = "Page 2"
endEvent
event OnVersionUpdate(int a_version)
; a_version is the new version, CurrentVersion is the old version
if (a_version >= 2 && CurrentVersion < 2)
Debug.Trace(self + ": Updating script to version 2")
Pages = new string[4]
Pages[0] = "Page 1"
Pages[1] = "Page 2"
Pages[2] = "Page 3"
Pages[3] = "Page 4"
endIf
; a_version is the new version, CurrentVersion is the old version
if (a_version >= 3 && CurrentVersion < 3)
Debug.Trace(self + ": Updating script to version 3")
myVar = Utility.RandomInt(0,100)
myArray = new string[128]
; ...
endIf
endEvent
An alternative version 3 that just runs OnConfigInit
again when updating:
; SCRIPT VERSION
int function GetVersion()
return 3
endFunction
; PRIVATE VARIABLES
; -- Version 3 --
int myVar = 0
string[] myArray
; INITIALIZATION
event OnConfigInit()
Pages = new string[4]
Pages[0] = "Page 1"
Pages[1] = "Page 2"
Pages[2] = "Page 3"
Pages[3] = "Page 4"
myVar = Utility.RandomInt(0,100)
myArray = new string[128]
endEvent
event OnVersionUpdate(int a_version)
if (a_version > 1)
Debug.Trace(self + ": Updating script to version " + a_version)
OnConfigInit()
endIf
endEvent
Note that this is just one way of handling updates. You can always use own preferred method.