diff options
-rw-r--r-- | src/StardewModdingAPI/Framework/Content/AssetDataForObject.cs | 7 | ||||
-rw-r--r-- | src/StardewModdingAPI/Framework/ContentHelper.cs | 8 | ||||
-rw-r--r-- | src/StardewModdingAPI/Framework/SContentManager.cs | 159 | ||||
-rw-r--r-- | src/StardewModdingAPI/IAssetEditor.cs | 2 | ||||
-rw-r--r-- | src/StardewModdingAPI/IAssetLoader.cs | 17 | ||||
-rw-r--r-- | src/StardewModdingAPI/Program.cs | 8 | ||||
-rw-r--r-- | src/StardewModdingAPI/StardewModdingAPI.csproj | 1 |
7 files changed, 156 insertions, 46 deletions
diff --git a/src/StardewModdingAPI/Framework/Content/AssetDataForObject.cs b/src/StardewModdingAPI/Framework/Content/AssetDataForObject.cs index af2f54ae..f30003e4 100644 --- a/src/StardewModdingAPI/Framework/Content/AssetDataForObject.cs +++ b/src/StardewModdingAPI/Framework/Content/AssetDataForObject.cs @@ -18,6 +18,13 @@ namespace StardewModdingAPI.Framework.Content public AssetDataForObject(string locale, string assetName, object data, Func<string, string> getNormalisedPath) : base(locale, assetName, data, getNormalisedPath) { } + /// <summary>Construct an instance.</summary> + /// <param name="info">The asset metadata.</param> + /// <param name="data">The content data being read.</param> + /// <param name="getNormalisedPath">Normalises an asset key to match the cache key.</param> + public AssetDataForObject(IAssetInfo info, object data, Func<string, string> getNormalisedPath) + : this(info.Locale, info.AssetName, data, getNormalisedPath) { } + /// <summary>Get a helper to manipulate the data as a dictionary.</summary> /// <typeparam name="TKey">The expected dictionary key.</typeparam> /// <typeparam name="TValue">The expected dictionary balue.</typeparam> diff --git a/src/StardewModdingAPI/Framework/ContentHelper.cs b/src/StardewModdingAPI/Framework/ContentHelper.cs index b7773d6a..0c09fe94 100644 --- a/src/StardewModdingAPI/Framework/ContentHelper.cs +++ b/src/StardewModdingAPI/Framework/ContentHelper.cs @@ -40,7 +40,13 @@ namespace StardewModdingAPI.Framework /// <summary>The observable implementation of <see cref="AssetEditors"/>.</summary> internal ObservableCollection<IAssetEditor> ObservableAssetEditors { get; } = new ObservableCollection<IAssetEditor>(); - /// <summary>Editors which change content assets after they're loaded.</summary> + /// <summary>The observable implementation of <see cref="AssetLoaders"/>.</summary> + internal ObservableCollection<IAssetLoader> ObservableAssetLoaders { get; } = new ObservableCollection<IAssetLoader>(); + + /// <summary>Interceptors which provide the initial versions of matching content assets.</summary> + internal IList<IAssetLoader> AssetLoaders => this.ObservableAssetLoaders; + + /// <summary>Interceptors which edit matching content assets after they're loaded.</summary> internal IList<IAssetEditor> AssetEditors => this.ObservableAssetEditors; diff --git a/src/StardewModdingAPI/Framework/SContentManager.cs b/src/StardewModdingAPI/Framework/SContentManager.cs index 53afb729..0a8a0873 100644 --- a/src/StardewModdingAPI/Framework/SContentManager.cs +++ b/src/StardewModdingAPI/Framework/SContentManager.cs @@ -44,7 +44,10 @@ namespace StardewModdingAPI.Framework /********* ** Accessors *********/ - /// <summary>Implementations which change assets after they're loaded.</summary> + /// <summary>Interceptors which provide the initial versions of matching assets.</summary> + internal IDictionary<IModMetadata, IList<IAssetLoader>> Loaders { get; } = new Dictionary<IModMetadata, IList<IAssetLoader>>(); + + /// <summary>Interceptors which edit matching assets after they're loaded.</summary> internal IDictionary<IModMetadata, IList<IAssetEditor>> Editors { get; } = new Dictionary<IModMetadata, IList<IAssetEditor>>(); /// <summary>The absolute path to the <see cref="ContentManager.RootDirectory"/>.</summary> @@ -126,9 +129,17 @@ namespace StardewModdingAPI.Framework return base.Load<T>(assetName); // load asset - T asset = this.GetAssetWithInterceptors(this.GetLocale(), assetName, () => base.Load<T>(assetName)); - this.Cache[assetName] = asset; - return asset; + T data; + { + IAssetInfo info = new AssetInfo(this.GetLocale(), assetName, typeof(T), this.NormaliseAssetName); + IAssetData asset = this.ApplyLoader<T>(info) ?? new AssetDataForObject(info, base.Load<T>(assetName), this.NormaliseAssetName); + asset = this.ApplyEditors<T>(info, asset); + data = (T)asset.Data; + } + + // update cache & return data + this.Cache[assetName] = data; + return data; } /// <summary>Inject an asset into the cache.</summary> @@ -198,6 +209,7 @@ namespace StardewModdingAPI.Framework Game1.player.FarmerRenderer = new FarmerRenderer(this.Load<Texture2D>($"Characters\\Farmer\\farmer_" + (Game1.player.isMale ? "" : "girl_") + "base")); } + /********* ** Private methods *********/ @@ -209,73 +221,132 @@ namespace StardewModdingAPI.Framework || this.Cache.ContainsKey($"{normalisedAssetName}.{this.GetKeyLocale.Invoke<string>()}"); // translated asset } - /// <summary>Read an asset with support for asset interceptors.</summary> - /// <typeparam name="T">The asset type.</typeparam> - /// <param name="locale">The current content locale.</param> - /// <param name="normalisedKey">The normalised asset path relative to the loader root directory, not including the <c>.xnb</c> extension.</param> - /// <param name="getData">Get the asset from the underlying content manager.</param> - private T GetAssetWithInterceptors<T>(string locale, string normalisedKey, Func<T> getData) + /// <summary>Load the initial asset from the registered <see cref="Loaders"/>.</summary> + /// <param name="info">The basic asset metadata.</param> + /// <returns>Returns the loaded asset metadata, or <c>null</c> if no loader matched.</returns> + private IAssetData ApplyLoader<T>(IAssetInfo info) { - // get metadata - IAssetInfo info = new AssetInfo(locale, normalisedKey, typeof(T), this.NormaliseAssetName); + // find matching loaders + var loaders = this.GetInterceptors(this.Loaders) + .Where(entry => + { + try + { + return entry.Interceptor.CanLoad<T>(info); + } + catch (Exception ex) + { + this.Monitor.Log($"{entry.Mod.DisplayName} crashed when checking whether it could load asset '{info.AssetName}', and will be ignored. Error details:\n{ex.GetLogSummary()}", LogLevel.Error); + return false; + } + }) + .ToArray(); + + // validate loaders + if (!loaders.Any()) + return null; + if (loaders.Length > 1) + { + string[] loaderNames = loaders.Select(p => p.Mod.DisplayName).ToArray(); + this.Monitor.Log($"Multiple mods want to provide the '{info.AssetName}' asset ({string.Join(", ", loaderNames)}), but an asset can't be loaded multiple times. SMAPI will use the default asset instead; uninstall one of the mods to fix this. (Message for modders: you should usually use {typeof(IAssetEditor)} instead to avoid conflicts.)", LogLevel.Warn); + return null; + } + + // fetch asset from loader + IModMetadata mod = loaders[0].Mod; + IAssetLoader loader = loaders[0].Interceptor; + T data; + try + { + data = loader.Load<T>(info); + this.Monitor.Log($"{mod.DisplayName} loaded asset '{info.AssetName}'.", LogLevel.Trace); + } + catch (Exception ex) + { + this.Monitor.Log($"{mod.DisplayName} crashed when loading asset '{info.AssetName}'. SMAPI will use the default asset instead. Error details:\n{ex.GetLogSummary()}", LogLevel.Error); + return null; + } + + // validate asset + if (data == null) + { + this.Monitor.Log($"{mod.DisplayName} incorrectly set asset '{info.AssetName}' to a null value; ignoring override.", LogLevel.Error); + return null; + } + + // return matched asset + return new AssetDataForObject(info, data, this.NormaliseAssetName); + } + /// <summary>Apply any <see cref="Editors"/> to a loaded asset.</summary> + /// <typeparam name="T">The asset type.</typeparam> + /// <param name="info">The basic asset metadata.</param> + /// <param name="asset">The loaded asset.</param> + private IAssetData ApplyEditors<T>(IAssetInfo info, IAssetData asset) + { + IAssetData GetNewData(object data) => new AssetDataForObject(info, data, this.NormaliseAssetName); // edit asset - IAssetData data = this.GetAssetData(info, getData()); - foreach (var entry in this.GetAssetEditors()) + foreach (var entry in this.GetInterceptors(this.Editors)) { // check for match IModMetadata mod = entry.Mod; - IAssetEditor editor = entry.Editor; - if (!editor.CanEdit<T>(info)) + IAssetEditor editor = entry.Interceptor; + try + { + if (!editor.CanEdit<T>(info)) + continue; + } + catch (Exception ex) + { + this.Monitor.Log($"{entry.Mod.DisplayName} crashed when checking whether it could edit asset '{info.AssetName}', and will be ignored. Error details:\n{ex.GetLogSummary()}", LogLevel.Error); continue; + } // try edit - this.Monitor.Log($"{mod.DisplayName} intercepted {info.AssetName}.", LogLevel.Trace); - object prevAsset = data.Data; - editor.Edit<T>(data); + object prevAsset = asset.Data; + try + { + editor.Edit<T>(asset); + this.Monitor.Log($"{mod.DisplayName} intercepted {info.AssetName}.", LogLevel.Trace); + } + catch (Exception ex) + { + this.Monitor.Log($"{entry.Mod.DisplayName} crashed when editing asset '{info.AssetName}', which may cause errors in-game. Error details:\n{ex.GetLogSummary()}", LogLevel.Error); + } // validate edit - if (data.Data == null) + if (asset.Data == null) { - data = this.GetAssetData(info, prevAsset); - this.Monitor.Log($"{mod.DisplayName} incorrectly set asset '{normalisedKey}' to a null value; ignoring override.", LogLevel.Warn); + this.Monitor.Log($"{mod.DisplayName} incorrectly set asset '{info.AssetName}' to a null value; ignoring override.", LogLevel.Warn); + asset = GetNewData(prevAsset); } - else if (!(data.Data is T)) + else if (!(asset.Data is T)) { - data = this.GetAssetData(info, prevAsset); - this.Monitor.Log($"{mod.DisplayName} incorrectly set asset '{normalisedKey}' to incompatible type '{data.Data.GetType()}', expected '{typeof(T)}'; ignoring override.", LogLevel.Warn); + this.Monitor.Log($"{mod.DisplayName} incorrectly set asset '{asset.AssetName}' to incompatible type '{asset.Data.GetType()}', expected '{typeof(T)}'; ignoring override.", LogLevel.Warn); + asset = GetNewData(prevAsset); } } // return result - return (T)data.Data; - } - - /// <summary>Get an asset edit helper.</summary> - /// <param name="info">The asset info.</param> - /// <param name="asset">The loaded asset data.</param> - private IAssetData GetAssetData(IAssetInfo info, object asset) - { - return new AssetDataForObject(info.Locale, info.AssetName, asset, this.NormaliseAssetName); + return asset; } - /// <summary>Get all registered asset editors.</summary> - private IEnumerable<(IModMetadata Mod, IAssetEditor Editor)> GetAssetEditors() + /// <summary>Get all registered interceptors from a list.</summary> + private IEnumerable<(IModMetadata Mod, T Interceptor)> GetInterceptors<T>(IDictionary<IModMetadata, IList<T>> entries) { - foreach (var entry in this.Editors) + foreach (var entry in entries) { IModMetadata metadata = entry.Key; - IList<IAssetEditor> editors = entry.Value; + IList<T> interceptors = entry.Value; - // special case if mod implements interface - // ReSharper disable once SuspiciousTypeConversion.Global - if (metadata.Mod is IAssetEditor modAsEditor) - yield return (metadata, modAsEditor); + // special case if mod is an interceptor + if (metadata.Mod is T modAsInterceptor) + yield return (metadata, modAsInterceptor); // registered editors - foreach (IAssetEditor editor in editors) - yield return (metadata, editor); + foreach (T interceptor in interceptors) + yield return (metadata, interceptor); } } } diff --git a/src/StardewModdingAPI/IAssetEditor.cs b/src/StardewModdingAPI/IAssetEditor.cs index b66ec15e..d2c6f295 100644 --- a/src/StardewModdingAPI/IAssetEditor.cs +++ b/src/StardewModdingAPI/IAssetEditor.cs @@ -1,6 +1,6 @@ namespace StardewModdingAPI { - /// <summary>Edits a loaded content asset.</summary> + /// <summary>Edits matching content assets.</summary> public interface IAssetEditor { /********* diff --git a/src/StardewModdingAPI/IAssetLoader.cs b/src/StardewModdingAPI/IAssetLoader.cs new file mode 100644 index 00000000..ad97b941 --- /dev/null +++ b/src/StardewModdingAPI/IAssetLoader.cs @@ -0,0 +1,17 @@ +namespace StardewModdingAPI +{ + /// <summary>Provides the initial version for matching assets loaded by the game. SMAPI will raise an error if two mods try to load the same asset; in most cases you should use <see cref="IAssetEditor"/> instead.</summary> + public interface IAssetLoader + { + /********* + ** Public methods + *********/ + /// <summary>Get whether this instance can load the initial version of the given asset.</summary> + /// <param name="asset">Basic metadata about the asset being loaded.</param> + bool CanLoad<T>(IAssetInfo asset); + + /// <summary>Load a matched asset.</summary> + /// <param name="asset">Basic metadata about the asset being loaded.</param> + T Load<T>(IAssetInfo asset); + } +} diff --git a/src/StardewModdingAPI/Program.cs b/src/StardewModdingAPI/Program.cs index 53efe1e3..483d2bc2 100644 --- a/src/StardewModdingAPI/Program.cs +++ b/src/StardewModdingAPI/Program.cs @@ -708,7 +708,10 @@ namespace StardewModdingAPI { // add interceptors if (metadata.Mod.Helper.Content is ContentHelper helper) + { this.ContentManager.Editors[metadata] = helper.ObservableAssetEditors; + this.ContentManager.Loaders[metadata] = helper.ObservableAssetLoaders; + } // call entry method try @@ -738,6 +741,11 @@ namespace StardewModdingAPI if (e.NewItems.Count > 0) this.ContentManager.Reset(); }; + helper.ObservableAssetLoaders.CollectionChanged += (sender, e) => + { + if (e.NewItems.Count > 0) + this.ContentManager.Reset(); + }; } } this.ContentManager.Reset(); diff --git a/src/StardewModdingAPI/StardewModdingAPI.csproj b/src/StardewModdingAPI/StardewModdingAPI.csproj index 1f2bd4bb..4d65b1af 100644 --- a/src/StardewModdingAPI/StardewModdingAPI.csproj +++ b/src/StardewModdingAPI/StardewModdingAPI.csproj @@ -159,6 +159,7 @@ <Compile Include="Framework\TranslationHelper.cs" /> <Compile Include="IAssetEditor.cs" /> <Compile Include="IAssetInfo.cs" /> + <Compile Include="IAssetLoader.cs" /> <Compile Include="ICommandHelper.cs" /> <Compile Include="IAssetData.cs" /> <Compile Include="IAssetDataForDictionary.cs" /> |