summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--docs/release-notes.md1
-rw-r--r--src/SMAPI/Events/AssetRequestedEventArgs.cs94
-rw-r--r--src/SMAPI/Events/IContentEvents.cs17
-rw-r--r--src/SMAPI/Events/IModEvents.cs3
-rw-r--r--src/SMAPI/Framework/ContentCoordinator.cs12
-rw-r--r--src/SMAPI/Framework/Events/EventManager.cs11
-rw-r--r--src/SMAPI/Framework/Events/ManagedEvent.cs10
-rw-r--r--src/SMAPI/Framework/Events/ModContentEvents.cs29
-rw-r--r--src/SMAPI/Framework/Events/ModEvents.cs4
-rw-r--r--src/SMAPI/Framework/SCore.cs41
-rw-r--r--src/SMAPI/IContentHelper.cs2
11 files changed, 219 insertions, 5 deletions
diff --git a/docs/release-notes.md b/docs/release-notes.md
index b9385e3f..2d1cdf44 100644
--- a/docs/release-notes.md
+++ b/docs/release-notes.md
@@ -11,6 +11,7 @@
* Fixed `--no-terminal` still opening a terminal window, even if nothing is logged to it (thanks to Ryhon0!).
* For mod authors:
+ * Added [`AssetRequested` content event](https://stardewvalleywiki.com/Modding:Modder_Guide/APIs/Events#Content), which will eventually replace `IAssetEditor` and `IAssetLoader`.
* Overhauled [mod-provided APIs](https://stardewvalleywiki.com/Modding:Modder_Guide/APIs/Integrations#Mod-provided_APIs) (thanks to Shockah!).
_This adds support for many previously-unsupported cases: proxied interfaces in return values or input arguments, proxied enums if their values match, generic methods, and more._
* Added `Constants.ContentPath`.
diff --git a/src/SMAPI/Events/AssetRequestedEventArgs.cs b/src/SMAPI/Events/AssetRequestedEventArgs.cs
new file mode 100644
index 00000000..b17250b0
--- /dev/null
+++ b/src/SMAPI/Events/AssetRequestedEventArgs.cs
@@ -0,0 +1,94 @@
+using System;
+using System.Collections.Generic;
+using Microsoft.Xna.Framework.Graphics;
+using StardewModdingAPI.Framework;
+using StardewModdingAPI.Framework.Content;
+using xTile;
+
+namespace StardewModdingAPI.Events
+{
+ /// <summary>Event arguments for an <see cref="IContentEvents.AssetRequested"/> event.</summary>
+ public class AssetRequestedEventArgs : EventArgs
+ {
+ /*********
+ ** Fields
+ *********/
+ /// <summary>The mod handling the event.</summary>
+ private readonly IModMetadata Mod;
+
+
+ /*********
+ ** Accessors
+ *********/
+ /// <summary>The name of the asset being requested.</summary>
+ public IAssetName Name { get; }
+
+ /// <summary>The load operations requested by the event handler.</summary>
+ internal IList<AssetLoadOperation> LoadOperations { get; } = new List<AssetLoadOperation>();
+
+ /// <summary>The edit operations requested by the event handler.</summary>
+ internal IList<AssetEditOperation> EditOperations { get; } = new List<AssetEditOperation>();
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Construct an instance.</summary>
+ /// <param name="mod">The mod handling the event.</param>
+ /// <param name="name">The name of the asset being requested.</param>
+ internal AssetRequestedEventArgs(IModMetadata mod, IAssetName name)
+ {
+ this.Mod = mod;
+ this.Name = name;
+ }
+
+ /// <summary>Provide the initial instance for the asset, instead of trying to load it from the game's <c>Content</c> folder.</summary>
+ /// <param name="load">Get the initial instance of an asset.</param>
+ /// <remarks>
+ /// Usage notes:
+ /// <list type="bullet">
+ /// <item>The asset doesn't need to exist in the game's <c>Content</c> folder. If any mod loads the asset, the game will see it as an existing asset as if it was in that folder.</item>
+ /// <item>Each asset can logically only have one initial instance. If multiple loads apply at the same time, SMAPI will raise an error and ignore all of them. If you're making changes to the existing asset instead of replacing it, you should use <see cref="Edit"/> instead to avoid those limitations and improve mod compatibility.</item>
+ /// </list>
+ /// </remarks>
+ public void LoadFrom(Func<object> load)
+ {
+ this.LoadOperations.Add(
+ new AssetLoadOperation(this.Mod, _ => load())
+ );
+ }
+
+ /// <summary>Provide the initial instance for the asset from a file in your mod folder, instead of trying to load it from the game's <c>Content</c> folder.</summary>
+ /// <typeparam name="TAsset">The expected data type. The main supported types are <see cref="Map"/>, <see cref="Texture2D"/>, dictionaries, and lists; other types may be supported by the game's content pipeline.</typeparam>
+ /// <param name="relativePath">The relative path to the file in your mod folder.</param>
+ /// <remarks>
+ /// Usage notes:
+ /// <list type="bullet">
+ /// <item>The asset doesn't need to exist in the game's <c>Content</c> folder. If any mod loads the asset, the game will see it as an existing asset as if it was in that folder.</item>
+ /// <item>Each asset can logically only have one initial instance. If multiple loads apply at the same time, SMAPI will raise an error and ignore all of them. If you're making changes to the existing asset instead of replacing it, you should use <see cref="Edit"/> instead to avoid those limitations and improve mod compatibility.</item>
+ /// </list>
+ /// </remarks>
+ public void LoadFromModFile<TAsset>(string relativePath)
+ {
+ this.LoadOperations.Add(
+ new AssetLoadOperation(this.Mod, _ => this.Mod.Mod.Helper.Content.Load<TAsset>(relativePath))
+ );
+ }
+
+ /// <summary>Edit the asset after it's loaded.</summary>
+ /// <param name="apply">Apply changes to the asset.</param>
+ /// <remarks>
+ /// Usage notes:
+ /// <list type="bullet">
+ /// <item>Editing an asset which doesn't exist has no effect. This is applied after the asset is loaded from the game's <c>Content</c> folder, or from any mod's <see cref="LoadFrom"/> or <see cref="LoadFromModFile{TAsset}"/>.</item>
+ /// <item>You can apply any number of edits to the asset. Each edit will be applied on top of the previous one (i.e. it'll see the merged asset from all previous edits as its input).</item>
+ /// </list>
+ /// </remarks>
+ public void Edit(Action<IAssetData> apply)
+ {
+ this.EditOperations.Add(
+ new AssetEditOperation(this.Mod, apply)
+ );
+ }
+ }
+}
diff --git a/src/SMAPI/Events/IContentEvents.cs b/src/SMAPI/Events/IContentEvents.cs
new file mode 100644
index 00000000..feaf9c0a
--- /dev/null
+++ b/src/SMAPI/Events/IContentEvents.cs
@@ -0,0 +1,17 @@
+using System;
+using StardewValley;
+
+namespace StardewModdingAPI.Events
+{
+ /// <summary>Events related to assets loaded from the content pipeline (including data, maps, and textures).</summary>
+ public interface IContentEvents
+ {
+ /// <summary>Raised when an asset is being requested from the content pipeline.</summary>
+ /// <remarks>
+ /// The asset isn't necessarily being loaded yet (e.g. the game may be checking if it exists). Mods can register the changes they want to apply using methods on the <paramref name="e"/> parameter. These will be applied when the asset is actually loaded.
+ ///
+ /// If the asset is requested multiple times in the same tick (e.g. once to check if it exists and once to load it), SMAPI might only raise the event once and reuse the cached result.
+ /// </remarks>
+ event EventHandler<AssetRequestedEventArgs> AssetRequested;
+ }
+}
diff --git a/src/SMAPI/Events/IModEvents.cs b/src/SMAPI/Events/IModEvents.cs
index 1f892b31..2603961b 100644
--- a/src/SMAPI/Events/IModEvents.cs
+++ b/src/SMAPI/Events/IModEvents.cs
@@ -3,6 +3,9 @@ namespace StardewModdingAPI.Events
/// <summary>Manages access to events raised by SMAPI.</summary>
public interface IModEvents
{
+ /// <summary>Events related to assets loaded from the content pipeline (including data, maps, and textures).</summary>
+ IContentEvents Content { get; }
+
/// <summary>Events related to UI and drawing to the screen.</summary>
IDisplayEvents Display { get; }
diff --git a/src/SMAPI/Framework/ContentCoordinator.cs b/src/SMAPI/Framework/ContentCoordinator.cs
index bf944e23..22ae0a18 100644
--- a/src/SMAPI/Framework/ContentCoordinator.cs
+++ b/src/SMAPI/Framework/ContentCoordinator.cs
@@ -7,6 +7,7 @@ using System.Linq;
using System.Text;
using System.Threading;
using Microsoft.Xna.Framework.Content;
+using StardewModdingAPI.Events;
using StardewModdingAPI.Framework.Content;
using StardewModdingAPI.Framework.ContentManagers;
using StardewModdingAPI.Framework.Reflection;
@@ -70,6 +71,9 @@ namespace StardewModdingAPI.Framework
/// <summary>The language enum values indexed by locale code.</summary>
private Lazy<Dictionary<string, LocalizedContentManager.LanguageCode>> LocaleCodes;
+ /// <summary>Get the load/edit operations to apply to an asset by querying registered <see cref="IContentEvents.AssetRequested"/> event handlers.</summary>
+ private readonly Func<IAssetInfo, IList<AssetOperationGroup>> RequestAssetOperations;
+
/// <summary>The cached asset load/edit operations to apply, indexed by asset name.</summary>
private readonly TickCacheDictionary<IAssetName, AssetOperationGroup[]> AssetOperationsByKey = new();
@@ -105,13 +109,15 @@ namespace StardewModdingAPI.Framework
/// <param name="jsonHelper">Encapsulates SMAPI's JSON file parsing.</param>
/// <param name="onLoadingFirstAsset">A callback to invoke the first time *any* game content manager loads an asset.</param>
/// <param name="aggressiveMemoryOptimizations">Whether to enable more aggressive memory optimizations.</param>
- public ContentCoordinator(IServiceProvider serviceProvider, string rootDirectory, CultureInfo currentCulture, IMonitor monitor, Reflector reflection, JsonHelper jsonHelper, Action onLoadingFirstAsset, bool aggressiveMemoryOptimizations)
+ /// <param name="requestAssetOperations">Get the load/edit operations to apply to an asset by querying registered <see cref="IContentEvents.AssetRequested"/> event handlers.</param>
+ public ContentCoordinator(IServiceProvider serviceProvider, string rootDirectory, CultureInfo currentCulture, IMonitor monitor, Reflector reflection, JsonHelper jsonHelper, Action onLoadingFirstAsset, bool aggressiveMemoryOptimizations, Func<IAssetInfo, IList<AssetOperationGroup>> requestAssetOperations)
{
this.AggressiveMemoryOptimizations = aggressiveMemoryOptimizations;
this.Monitor = monitor ?? throw new ArgumentNullException(nameof(monitor));
this.Reflection = reflection;
this.JsonHelper = jsonHelper;
this.OnLoadingFirstAsset = onLoadingFirstAsset;
+ this.RequestAssetOperations = requestAssetOperations;
this.FullRootDirectory = Path.Combine(Constants.GamePath, rootDirectory);
this.ContentManagers.Add(
this.MainContentManager = new GameContentManager(
@@ -560,6 +566,10 @@ namespace StardewModdingAPI.Framework
/// <param name="info">The asset info to load or edit.</param>
private IEnumerable<AssetOperationGroup> GetAssetOperationsWithoutCache<T>(IAssetInfo info)
{
+ // new content API
+ foreach (AssetOperationGroup group in this.RequestAssetOperations(info))
+ yield return group;
+
// legacy load operations
foreach (ModLinked<IAssetLoader> loader in this.Loaders)
{
diff --git a/src/SMAPI/Framework/Events/EventManager.cs b/src/SMAPI/Framework/Events/EventManager.cs
index fa4d564d..8142f00e 100644
--- a/src/SMAPI/Framework/Events/EventManager.cs
+++ b/src/SMAPI/Framework/Events/EventManager.cs
@@ -11,6 +11,13 @@ namespace StardewModdingAPI.Framework.Events
** Events
*********/
/****
+ ** Content
+ ****/
+ /// <inheritdoc cref="IContentEvents.AssetRequested" />
+ public readonly ManagedEvent<AssetRequestedEventArgs> AssetRequested;
+
+
+ /****
** Display
****/
/// <inheritdoc cref="IDisplayEvents.MenuChanged" />
@@ -189,7 +196,9 @@ namespace StardewModdingAPI.Framework.Events
return new ManagedEvent<TEventArgs>($"{typeName}.{eventName}", modRegistry, isPerformanceCritical);
}
- // init events (new)
+ // init events
+ this.AssetRequested = ManageEventOf<AssetRequestedEventArgs>(nameof(IModEvents.Content), nameof(IContentEvents.AssetRequested));
+
this.MenuChanged = ManageEventOf<MenuChangedEventArgs>(nameof(IModEvents.Display), nameof(IDisplayEvents.MenuChanged));
this.Rendering = ManageEventOf<RenderingEventArgs>(nameof(IModEvents.Display), nameof(IDisplayEvents.Rendering), isPerformanceCritical: true);
this.Rendered = ManageEventOf<RenderedEventArgs>(nameof(IModEvents.Display), nameof(IDisplayEvents.Rendered), isPerformanceCritical: true);
diff --git a/src/SMAPI/Framework/Events/ManagedEvent.cs b/src/SMAPI/Framework/Events/ManagedEvent.cs
index a200393d..154ef659 100644
--- a/src/SMAPI/Framework/Events/ManagedEvent.cs
+++ b/src/SMAPI/Framework/Events/ManagedEvent.cs
@@ -100,6 +100,14 @@ namespace StardewModdingAPI.Framework.Events
/// <param name="match">A lambda which returns true if the event should be raised for the given mod.</param>
public void Raise(TEventArgs args, Func<IModMetadata, bool> match = null)
{
+ this.Raise((_, invoke) => invoke(args), match);
+ }
+
+ /// <summary>Raise the event and notify all handlers.</summary>
+ /// <param name="invoke">Invoke an event handler. This receives the mod which registered the handler, and should invoke the callback with the event arguments to pass it.</param>
+ /// <param name="match">A lambda which returns true if the event should be raised for the given mod.</param>
+ public void Raise(Action<IModMetadata, Action<TEventArgs>> invoke, Func<IModMetadata, bool> match = null)
+ {
// skip if no handlers
if (this.Handlers.Count == 0)
return;
@@ -128,7 +136,7 @@ namespace StardewModdingAPI.Framework.Events
try
{
- handler.Handler.Invoke(null, args);
+ invoke(handler.SourceMod, args => handler.Handler.Invoke(null, args));
}
catch (Exception ex)
{
diff --git a/src/SMAPI/Framework/Events/ModContentEvents.cs b/src/SMAPI/Framework/Events/ModContentEvents.cs
new file mode 100644
index 00000000..b4d4279c
--- /dev/null
+++ b/src/SMAPI/Framework/Events/ModContentEvents.cs
@@ -0,0 +1,29 @@
+using System;
+using StardewModdingAPI.Events;
+
+namespace StardewModdingAPI.Framework.Events
+{
+ /// <inheritdoc cref="IContentEvents" />
+ internal class ModContentEvents : ModEventsBase, IContentEvents
+ {
+ /*********
+ ** Accessors
+ *********/
+ /// <inheritdoc />
+ public event EventHandler<AssetRequestedEventArgs> AssetRequested
+ {
+ add => this.EventManager.AssetRequested.Add(value, this.Mod);
+ remove => this.EventManager.AssetRequested.Remove(value);
+ }
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Construct an instance.</summary>
+ /// <param name="mod">The mod which uses this instance.</param>
+ /// <param name="eventManager">The underlying event manager.</param>
+ internal ModContentEvents(IModMetadata mod, EventManager eventManager)
+ : base(mod, eventManager) { }
+ }
+}
diff --git a/src/SMAPI/Framework/Events/ModEvents.cs b/src/SMAPI/Framework/Events/ModEvents.cs
index 0c365d42..1fb3482c 100644
--- a/src/SMAPI/Framework/Events/ModEvents.cs
+++ b/src/SMAPI/Framework/Events/ModEvents.cs
@@ -9,6 +9,9 @@ namespace StardewModdingAPI.Framework.Events
** Accessors
*********/
/// <inheritdoc />
+ public IContentEvents Content { get; }
+
+ /// <inheritdoc />
public IDisplayEvents Display { get; }
/// <inheritdoc />
@@ -38,6 +41,7 @@ namespace StardewModdingAPI.Framework.Events
/// <param name="eventManager">The underlying event manager.</param>
public ModEvents(IModMetadata mod, EventManager eventManager)
{
+ this.Content = new ModContentEvents(mod, eventManager);
this.Display = new ModDisplayEvents(mod, eventManager);
this.GameLoop = new ModGameLoopEvents(mod, eventManager);
this.Input = new ModInputEvents(mod, eventManager);
diff --git a/src/SMAPI/Framework/SCore.cs b/src/SMAPI/Framework/SCore.cs
index 342d6415..f0340cf5 100644
--- a/src/SMAPI/Framework/SCore.cs
+++ b/src/SMAPI/Framework/SCore.cs
@@ -1106,6 +1106,35 @@ namespace StardewModdingAPI.Framework
this.EventManager.DayEnding.RaiseEmpty();
}
+ /// <summary>Get the load/edit operations to apply to an asset by querying registered <see cref="IContentEvents.AssetRequested"/> event handlers.</summary>
+ /// <param name="asset">The asset info being requested.</param>
+ private IList<AssetOperationGroup> RequestAssetOperations(IAssetInfo asset)
+ {
+ List<AssetOperationGroup> operations = new();
+
+ this.EventManager.AssetRequested.Raise(
+ invoke: (mod, invoke) =>
+ {
+ AssetRequestedEventArgs args = new(mod, asset.Name);
+
+ invoke(args);
+
+ if (args.LoadOperations.Any() || args.EditOperations.Any())
+ {
+ operations.Add(
+ new AssetOperationGroup(
+ mod,
+ args.LoadOperations.Select(p => new AssetLoadOperation(mod, assetInfo => p.GetData(assetInfo))).ToArray(),
+ args.EditOperations.Select(p => new AssetEditOperation(mod, assetInfo => p.ApplyEdit(assetInfo))).ToArray()
+ )
+ );
+ }
+ }
+ );
+
+ return operations;
+ }
+
/// <summary>Raised immediately before the player returns to the title screen.</summary>
private void OnReturningToTitle()
{
@@ -1142,7 +1171,17 @@ namespace StardewModdingAPI.Framework
// Game1._temporaryContent initializing from SGame constructor
if (this.ContentCore == null)
{
- this.ContentCore = new ContentCoordinator(serviceProvider, rootDirectory, Thread.CurrentThread.CurrentUICulture, this.Monitor, this.Reflection, this.Toolkit.JsonHelper, this.InitializeBeforeFirstAssetLoaded, this.Settings.AggressiveMemoryOptimizations);
+ this.ContentCore = new ContentCoordinator(
+ serviceProvider: serviceProvider,
+ rootDirectory: rootDirectory,
+ currentCulture: Thread.CurrentThread.CurrentUICulture,
+ monitor: this.Monitor,
+ reflection: this.Reflection,
+ jsonHelper: this.Toolkit.JsonHelper,
+ onLoadingFirstAsset: this.InitializeBeforeFirstAssetLoaded,
+ aggressiveMemoryOptimizations: this.Settings.AggressiveMemoryOptimizations,
+ requestAssetOperations: this.RequestAssetOperations
+ );
if (this.ContentCore.Language != this.Translator.LocaleEnum)
this.Translator.SetLocale(this.ContentCore.GetLocale(), this.ContentCore.Language);
diff --git a/src/SMAPI/IContentHelper.cs b/src/SMAPI/IContentHelper.cs
index 2936ecfb..207b4a33 100644
--- a/src/SMAPI/IContentHelper.cs
+++ b/src/SMAPI/IContentHelper.cs
@@ -31,7 +31,7 @@ namespace StardewModdingAPI
** Public methods
*********/
/// <summary>Load content from the game folder or mod folder (if not already cached), and return it. When loading a <c>.png</c> file, this must be called outside the game's draw loop.</summary>
- /// <typeparam name="T">The expected data type. The main supported types are <see cref="Map"/>, <see cref="Texture2D"/>, and dictionaries; other types may be supported by the game's content pipeline.</typeparam>
+ /// <typeparam name="T">The expected data type. The main supported types are <see cref="Map"/>, <see cref="Texture2D"/>, dictionaries, and lists; other types may be supported by the game's content pipeline.</typeparam>
/// <param name="key">The asset key to fetch (if the <paramref name="source"/> is <see cref="ContentSource.GameContent"/>), or the local path to a content file relative to the mod folder.</param>
/// <param name="source">Where to search for a matching content asset.</param>
/// <exception cref="ArgumentException">The <paramref name="key"/> is empty or contains invalid characters.</exception>