-
-
Notifications
You must be signed in to change notification settings - Fork 0
Modules
Alright, let's get started. We'll show examples for most of what you'll need. Some finer details will be made clear by your IDE, and Thorn's own modules can also serve as examples.
- Assuming you already know how to write ULTRAKILL mods and can set up a project folder. If you don't, take a look at the Ultramodding wiki
- Grab the latest release of Thorn from Thunderstore or GitHub
- From the zip package, extract
plugins/ThornClient.dllandplugins/ThornClient.xml - Reference the
.dllin your mod project. The.xmlshould sit in the same folder for documentation. - If you have a BaseUnityPlugin for your mod, declare Thorn as a dependency to make sure it (and any Module)
loads before your plugin:
[BepInDependency("com.github.end-4.thornClient")]
This is the base class for configurable entries. You subclass it, ship it in your mod's assembly and it'll be detected+loaded automatically, much like BepInEx plugins. It's not a full replacement though, since it's not a MonoBehaviour and doesn't have all those usual lifecycle methods.
As an example, here's a module that constantly drains V1's health. The code is also available at the Thorn examples repo. Copy it to your project and start messing with it!
using System;
using ThornClient.Core;
using ThornClient.Core.ConfigurableElements;
using ThornClient.Managers;
using ThornClient.System;
using UnityEngine;
namespace ThornExamples;
// We subclass a Module...
public class Bleeding : Module {
// Replace this with your own icon...
// AssetManager in this case is from ThornClient.Managers
public override Sprite Icon => AssetManager.Get<Sprite>(ClickGUI.BundleKey, "cube");
// Tags for searching in the menu.
// Use related keywords that are not already present in the module name
public override string[] Tags => ["hurt", "hp"];
// Getter for cheat reason. A non-empty string means it's cheaty. You should declare any module that alters the mechanics as cheaty.
// It's necessary to call `CheatManager.UpdateCheatiness();` when this becomes true
// - In this case, it's when the module gets enabled, which we will see below...
public override string CheatReason => IsEnabled ? "Enables non-standard gameplay" : "";
// We declare settings to allow customization of the bleeding
// Setting is from the ThornClient.Core namespace
public Setting<int> DamagePerTick;
public Setting<float> DamageTickInterval;
// This is the constructor, passing the GUID, name, and description to the base Module class.
// The GUID should be unique. it's recommended to use a yourPluginName.yourModuleName
// syntax, but as long as you are absolutely sure it's unique, it's fine.
public Bleeding() : base("thornExamples.bleeding", "Bleeding", "Makes you constantly bleed",
ModuleCategory.Gameplay) {
// Instantiate the settings. In order, the fields are:
// - GUID: The identifier that's unique within your module. This name is used in the config file.
// - Name: The user-friendly name, displayed on the config menu
// - Description: The long text describing what the setting does. This shows up when hovering the config menu entry
// - Default value
DamagePerTick = CreateSetting(
"dmgPerTick", "Damage per tick", "How much to bleed each tick", 5
);
DamageTickInterval = CreateSetting(
"dmgTickInterval", "Damage tick interval", "Duration between damage ticks", 2f
);
}
// This method runs once when the module is enabled. Here you should add any setup or event subscribing...
// If it's enabled from a previous session, this will also trigger on game launch.
protected override void OnEnable() {
CheatManager.UpdateCheatiness(); // As we discussed earlier...
Console.WriteLine("Bleeding module ENABLED");
}
// This runs once when the module is disabled. Unsubscribe events here...
protected override void OnDisable() {
Console.WriteLine("Bleeding module DISABLED");
}
// Just for convenience
private static NewMovement? nm => NewMovement.Instance;
private static StatsManager? sman => StatsManager.Instance;
// Here's the plan: we constantly poll in the update loop. If the time since last
// damage tick is greater than the interval, we damage. The below variable is to
// track that time since last damage tick
private float _cumulatedTime = 0;
// This is run every frame, similar to MonoBehaviour.Update()
public override void OnUpdate() {
// Null check
if (nm == null || sman == null) return;
// Skip damaging if the run hasn't started
if (!sman.timer) return;
// Keep track of the time since last damage tick
_cumulatedTime += Time.deltaTime;
// If it's been long enough, we damage
if (_cumulatedTime >= DamageTickInterval.Value) {
TickDamage();
_cumulatedTime %= DamageTickInterval.Value;
}
}
private void TickDamage() {
if (nm == null) return;
nm.GetHurt(DamagePerTick.Value, false, 1);
}
}For reference, you can also take a look at Thorn's own modules in the
ThornClient/Modules folder of its
repo.
A few tips to keep it nice and accessible by other classes of your mod:
- Declare
Settings as static - Have a static
Instancefield that stores a reference of your module, and assign it in the constructor withInstance = this;
An example is Crossover's configuration module,
CrossoverConfig.cs.
The above example will almost certainly be insufficient for real-world use cases, so we strongly recommend you go through this section.
(Unlike PluginConfigurator,) the data and UI are separate in Thorn.
We declare Settings and optionally attach UI hints,
and the menu will generate the config UI accordingly.
Currently, it's not possible to add your own UI, but this is planned in the future.
As seen above, you can add a setting to a Module with CreateSetting(guid, name, description, defaultValue).
While Setting<T> allows any type at compile time, but not all will work. Types that are supported by default are: (
Thorn's own data types will be elaborated below)
bool-
intandfloat string- Enum types: you can declare
enum MyEnumTypethenSetting<MyEnumType> -
Keybind(inThornClient.Core.DataTypes) -
EnemyList(inThornClient.Core.DataTypes) -
Color(inUnityEngine)
You can add other data types, but note that:
- The data type must be serializable to JSON by Newtonsoft's JSON library.
Either it has to be supported natively by the library,
or you have to annotate your custom data type with
[JsonConverter(typeof(TheJsonConverterForYourCustomDataType))] - Custom UI is not supported for now, but you can edit the module's config file (and changes will reload live)
- Contains a
Keyand aModifier, both areKeyCodes - The former is mandatory. If you wish to have it default to nothing, specify
KeyCode.None. The latter is optional. - Construction example for
Ctrl+A:var bind = new Keybind(KeyCode.A, KeyCode.LeftCtrl);
- Subscribing to bind presses:
var bindSetting = CreateSetting("settingId", "Select all", $"Keybind to switch to select all items", bind); bindSetting.OnPress += SomeAction; // Somewhere accessible private void SomeAction() { // Do stuff here }
- It's a set of
EnemyTypes. Good for filtering out which enemies get special treatment, for example always have a tracer toward Mindflayers... - Construction example:
new EnemyList([EnemyType.Filth, EnemyType.Gabriel])
You can subscribe to changes via OnChanged (parameter-less)
or OnValueChanged (has a single parameter being the new value).
Here's a little example; in reality you should subscribe in OnEnable
and unsubscribe in OnDisable.
BorderColor = CreateSetting("borderCol", "Border color", "Color for border", Color.red)
BorderColor.OnChanged += UpdateColor;
BorderColor.OnValueChanged += UpdateColorValue;
void UpdateColor() { /* Do stuff */ }
void UpdateColorValue(Color newValue) { /* Do stuff */ }A linear list of settings can become extremely hard to read. It's recommended to use headers and groups if you have many settings.
Items will appear in the order they're declared. See ConfigOrganization.cs for an example.
CreateHeader("headerId", "H1 heading", "Short description. Can be empty.");CreateHeader("headerId", "H2 heading", "", headerType: HeaderType.H2);- To create a group:
var myGroup = CreateGroup("groupId", "Group name", "Hover description");- To put settings/headers in a group, specify a group at the end of their
Create-methods:
CreateSetting("settingGuid", "Setting name", "Description", myGroup);- It's possible to nest groups:
var myNestedGroup = CreateGroup("anotherGroupId", "Nested group name", "Hover description 2", myGroup);This section provides some subtle recommendations. Feel free to skip if not interested.
Setting<float> Opacity;[hudmodule.md](hudmodule.md)
Opacity = CreateSetting("opacity", "Opacity", "How opaque the item is", 0.6);
Opacity.Hints = new InterfaceHints { Range = Tuple.Create(0f, 1f) };Setting<string> LastModVersion;
LastModVersion = CreateSetting(
"lastVersion", "Last version",
"Last version the mod was loaded", "0.0.0"
);[hudmodule.md](hudmodule.md)
// Note that this is not reactive, only assignable once.
// If some settings are ideally shown only when a certain bool option is enabled, you could put them in a group.
LastModVersion.Hints = new InterfaceHints {
Hidden = true
};There's more in the Interfacehints class, but you get the idea. Your IDE will help you figure out the rest.
To fit in with the NocturnalOS theme and to aid accessibility, it's recommended to use a simple monochrome icon.
- Use sharp lines instead of curves. For circles, draw an octagon.
- For a 96px icon, have lines of 6px-8px width
- When overlaying object B on top of object A, subtract A by a larger B to create a gap so B is readable.
- "What's the representative object of my mod/mod icon?" - Don't be greedy! Pick the one simplest item.
- Open Figma and make a simple polygon trace of that main object
- Simplify if it's not simple enough. Some ways to simplify are:
- Remove decorative background noise
- Reduce the count of items in a series (such as using 3 lines instead of 4 for a "document" icon)