Skip to content

Theme Manager (Asset Loader / Discovery) for C# Stardew Valley Mods

License

Notifications You must be signed in to change notification settings

KhloeLeclair/Stardew-ThemeManager

Repository files navigation

ThemeManager

What is ThemeManager?

ThemeManager is an asset/data loader for Stardew Valley C# mods that:

  1. Discovers available themes from three sources:
    • The current mod's assets/themes/ folder.
    • Content Packs for the current mod that have a theme.json file.
    • Other mods that have a my-mod.unique-id:theme key in their manifest, pointing to a theme json file.
  2. Selects an appropriate theme out of all available themes based on which mods are installed.
  3. Makes it easy to let users choose a theme with a config file.
  4. Makes it easy to reload themes at runtime for easier development.
  5. Passes assets through SMAPI's AssetRequested event (or IAssetLoader for pre-3.14) so that Content Patcher packs (and other mods) can modify assets.
  6. Exists as a single file that is easy to drop into a project with no other dependencies.

Also, ThemeManager is licensed under MIT-0. You can use this. You don't have to credit me. Please use it. Make life easier for people making content packs.

Quick! How do I use this?

  1. Grab the ThemeManager.cs file (and optionally ColorConverter.cs too) from this repository and drop it in your project. Everything is neat and self contained. In the future, if there's an update, just replace the file with the updated one.
  2. If you're still using SMAPI version 3.13 or lower, then you'll want to open ThemeManager.cs and uncomment this line at the start of the file:
    // #define THEME_MANAGER_PRE_314
  3. Add a ThemeManager instance to your mod, probably in ModEntry but anywhere convenient will do.
  4. Construct that ThemeManager and call its .Discover() method, which both populates the list of available themes and also selects the best one to use.
  5. Use ThemeManager.Load<T>(path) instead of Helper.ModContent.Load<T>(path) for theme-aware asset loading, as appropriate.

Optionally:

  1. Register a console command for changing the current theme. (Example Here)
  2. Register a theme picker in Generic Mod Config Menu. (Example Here)
  3. Listen to the ThemeChanged event and do anything you need to reload assets from your theme. (Example Here)
  4. Subclass BaseThemeData and add extra colors and other values for themes to override, making themes more functional. (Example Here)

The included example mod does all of these, so check its ModEntry.cs and see for yourself how simple it is.

So, what is a theme?

Themes are a mix of custom data, which is loaded from a theme.json file, as well as textures and potentially other resources loaded from assets/. Mod authors can support as much or as little extra data as they want.

What does the C# look like?

You're looking at the example project! If you don't want to check our ModEntry.cs file, here's the basics!

using Leclair.Stardew.ThemeManager;

namespace MyCoolMod {

	// All theme data types extend BaseThemeData,
	// which itself only contains a couple very
	// basic properties to do with loading / enumeration
	// and nothing to do with appearance.
	class MyThemeData : BaseThemeData {

		int PaddingTop { get; set; } = 8;

		Color? TextColor { get; set; }
	}

	// Just your everyday, average config file.
	class MyConfig {
		string Theme { get; set; } = "automatic";
	}

	// The actual Mod entry point.
	class ModEntry : Mod {
		internal MyConfig Config;

		// When declaring your ThemeManager, you provide
		// your own theme data class (or use BaseThemeData).
		internal ThemeManager<MyThemeData> ThemeManager;

		// I find it useful to set up a property for
		// quick access to the current theme.
		internal MyThemeData Theme => ThemeManager.Theme;

		public override void Entry(IModHelper helper) {
			// Init stuff
			Config = Helper.ReadConfig<MyConfig>();

			// Theme Manager
			ThemeManager = new(this, Config.Theme);
			ThemeManager.ThemeChanged += OnThemeChanged;
			ThemeManager.Discover();
		}

		private void OnThemeChanged(object sender, ThemeChangedEventArgs<MyThemeData> e) {
			// Do stuff when the user changes theme, like
			// reload textures.
		}

		private void SomewhereElse() {
			// Instead of
			Helper.ModContent.Load<Texture2D>("assets/blah.png");

			// you do
			ThemeManager.Load<Texture2D>("blah.png");

			// And also stuff like
			Color text = Theme.TextColor ?? Game1.textColor;
		}
	}

}

Well, what does a theme.json file look like?

Glad you asked. For the above, hypothetical mod:

{
	// This property is only used when loading a theme from your mod's
	// assets/themes/ folder. Otherwise, it is ignored. This is for
	// setting a default, human readable name for your theme.
	"Name": "Flower",

	// For displaying a list of themes in the Generic Mod Config Menu,
	// you can provide localized names of your theme. Please note that
	// this is optional. We're not using the i18n system because it's
	// honestly complete overkill when these are the only translated
	// strings provided by themes.
	"LocalizedNames": {
		"es": "La Flor"
	},

	// A list of unique IDs of mods that this theme is for compatibility
	// with. If a mod in this list is loaded, this theme will be selected
	// when the current theme is set to automatic.
	"For": [
		"SomeOtherRetextureMod.UniqueId"
	],

	// If you keep your assets for this theme in a different folder than
	// "assets/", you can override that here. This can otherwise be
	// left out from your theme.
	"AssetPrefix": "assets",

	// And... that's it. Everything above is optional, and there's
	// nothing else in BaseThemeData. For the earlier C# example though,
	// we also have a color. So...
	"TextColor": "#222"
}

Content Patcher Integration

When Content Patcher is loaded, ThemeManager will redirect .Load<>() requests through GameContent using its event handler for AssetRequested.

Say, for example, you request a menu texture:

var texture = ThemeManager.Load<Texture2D>("Menu.png");

Without Content Patcher, we just load the file directly and return it. Simple. Efficient. But less flexible.

When Content Patcher is present, we instead pass that call to something like:

Helper.GameContent.Load<Texture2D>("Mods/MyName.MyCoolMod/Themes/SomeoneElse.TheirThemesName/Menu.png");

To break it down, that string is combined from:

  1. The literal string Mods/
  2. Your mod's unique ID
  3. The literal string /Themes/
  4. The current theme's unique ID
  5. The literal string /
  6. The requested asset path.

Our event handler for AssetRequested is in charge of intercepting that request later on, and actually loading the base resource. The important thing, however, is that you can then in Content Patcher do something like

{
	"Format": "1.25.0",
	"Changes": [
		{
			"Action": "EditImage",
			"Target": "Mods/MyName.MyCoolMod/Themes/SomeoneElse.TheirThemesName/Menu.png",
			"FromFile": "assets/MyButton.png",
			"ToArea": { "X": 160, "Y": 80, "Width": 16, "Height" 16 }
		}
	]
}

This way, content packs don't need to replace your entire asset when they don't need to, potentially improving future compatibility.

Theme Folder Structure

As noted at the beginning, there are several ways that themes can be loaded.

Your Mod's assets/themes/ Folder

📁 Mods/
   📁 MyCoolMod/
      🗎 MyCoolMod.dll
      🗎 manifest.json
      📁 assets/
         📁 themes/
            📁 SomeTheme/
               🗎 theme.json
               📁 assets/
                  🗎 example.png

Content Packs for Your Mod

📁 Mods/
   📁 [MCM] My Cool Theme/
      🗎 manifest.json
      🗎 theme.json
      📁 assets/
         🗎 example.png

Other Mods

Including a theme in other mods is slightly more involved than just throwing down a theme.json file. How would we know that that specific file is for us? To make things explicit, we check the manifest.json of every mod for a matching theme key. For example:

{
	// The Usual Suspects
	"Name": "Some Other Cool Mod",
	"Author": "A Super Cool Person",
	"Version": "5.4.3",
	"Description": "Totally rad stuff.",
	"UniqueID": "SuperCoolPerson.OtherCoolMod",
	"MinimumApiVersion": "3.7.3",
	"ContentPackFor": {
		"UniqueID": "Pathoschild.ContentPatcher"
	},

	// Our Theme!
	"MyName.MyCoolMod:theme": "compat/MyCoolMod/theme.json"
}

ThemeManager looks for a key starting with your mod's unique ID that then ends with :theme, and tries loading the theme JSON file it points to. If it succeeds, then it adds that theme using the folder the theme JSON file is in as the root folder of the theme. So, given the above, you'd end up with something like:

📁 Mods/
   📁 SomeOtherCoolMod/
      🗎 manifest.json
      📁 compat/
         📁 MyCoolMod/
            🗎 theme.json
            📁 assets/
               🗎 example.png

About

Theme Manager (Asset Loader / Discovery) for C# Stardew Valley Mods

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published

Languages