From f95c7e8d72014f8008886031cebf7b12aeb7ed46 Mon Sep 17 00:00:00 2001 From: Jesse Plamondon-Willard Date: Sat, 1 Jul 2017 23:13:43 -0400 Subject: add support for asset loaders (#255) --- .../Framework/Content/AssetDataForObject.cs | 7 + src/StardewModdingAPI/Framework/ContentHelper.cs | 8 +- src/StardewModdingAPI/Framework/SContentManager.cs | 159 +++++++++++++++------ src/StardewModdingAPI/IAssetEditor.cs | 2 +- src/StardewModdingAPI/IAssetLoader.cs | 17 +++ src/StardewModdingAPI/Program.cs | 8 ++ src/StardewModdingAPI/StardewModdingAPI.csproj | 1 + 7 files changed, 156 insertions(+), 46 deletions(-) create mode 100644 src/StardewModdingAPI/IAssetLoader.cs (limited to 'src') 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 getNormalisedPath) : base(locale, assetName, data, getNormalisedPath) { } + /// Construct an instance. + /// The asset metadata. + /// The content data being read. + /// Normalises an asset key to match the cache key. + public AssetDataForObject(IAssetInfo info, object data, Func getNormalisedPath) + : this(info.Locale, info.AssetName, data, getNormalisedPath) { } + /// Get a helper to manipulate the data as a dictionary. /// The expected dictionary key. /// The expected dictionary balue. 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 /// The observable implementation of . internal ObservableCollection ObservableAssetEditors { get; } = new ObservableCollection(); - /// Editors which change content assets after they're loaded. + /// The observable implementation of . + internal ObservableCollection ObservableAssetLoaders { get; } = new ObservableCollection(); + + /// Interceptors which provide the initial versions of matching content assets. + internal IList AssetLoaders => this.ObservableAssetLoaders; + + /// Interceptors which edit matching content assets after they're loaded. internal IList 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 *********/ - /// Implementations which change assets after they're loaded. + /// Interceptors which provide the initial versions of matching assets. + internal IDictionary> Loaders { get; } = new Dictionary>(); + + /// Interceptors which edit matching assets after they're loaded. internal IDictionary> Editors { get; } = new Dictionary>(); /// The absolute path to the . @@ -126,9 +129,17 @@ namespace StardewModdingAPI.Framework return base.Load(assetName); // load asset - T asset = this.GetAssetWithInterceptors(this.GetLocale(), assetName, () => base.Load(assetName)); - this.Cache[assetName] = asset; - return asset; + T data; + { + IAssetInfo info = new AssetInfo(this.GetLocale(), assetName, typeof(T), this.NormaliseAssetName); + IAssetData asset = this.ApplyLoader(info) ?? new AssetDataForObject(info, base.Load(assetName), this.NormaliseAssetName); + asset = this.ApplyEditors(info, asset); + data = (T)asset.Data; + } + + // update cache & return data + this.Cache[assetName] = data; + return data; } /// Inject an asset into the cache. @@ -198,6 +209,7 @@ namespace StardewModdingAPI.Framework Game1.player.FarmerRenderer = new FarmerRenderer(this.Load($"Characters\\Farmer\\farmer_" + (Game1.player.isMale ? "" : "girl_") + "base")); } + /********* ** Private methods *********/ @@ -209,73 +221,132 @@ namespace StardewModdingAPI.Framework || this.Cache.ContainsKey($"{normalisedAssetName}.{this.GetKeyLocale.Invoke()}"); // translated asset } - /// Read an asset with support for asset interceptors. - /// The asset type. - /// The current content locale. - /// The normalised asset path relative to the loader root directory, not including the .xnb extension. - /// Get the asset from the underlying content manager. - private T GetAssetWithInterceptors(string locale, string normalisedKey, Func getData) + /// Load the initial asset from the registered . + /// The basic asset metadata. + /// Returns the loaded asset metadata, or null if no loader matched. + private IAssetData ApplyLoader(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(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(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); + } + /// Apply any to a loaded asset. + /// The asset type. + /// The basic asset metadata. + /// The loaded asset. + private IAssetData ApplyEditors(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(info)) + IAssetEditor editor = entry.Interceptor; + try + { + if (!editor.CanEdit(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(data); + object prevAsset = asset.Data; + try + { + editor.Edit(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; - } - - /// Get an asset edit helper. - /// The asset info. - /// The loaded asset data. - private IAssetData GetAssetData(IAssetInfo info, object asset) - { - return new AssetDataForObject(info.Locale, info.AssetName, asset, this.NormaliseAssetName); + return asset; } - /// Get all registered asset editors. - private IEnumerable<(IModMetadata Mod, IAssetEditor Editor)> GetAssetEditors() + /// Get all registered interceptors from a list. + private IEnumerable<(IModMetadata Mod, T Interceptor)> GetInterceptors(IDictionary> entries) { - foreach (var entry in this.Editors) + foreach (var entry in entries) { IModMetadata metadata = entry.Key; - IList editors = entry.Value; + IList 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 { - /// Edits a loaded content asset. + /// Edits matching content assets. 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 +{ + /// 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 instead. + public interface IAssetLoader + { + /********* + ** Public methods + *********/ + /// Get whether this instance can load the initial version of the given asset. + /// Basic metadata about the asset being loaded. + bool CanLoad(IAssetInfo asset); + + /// Load a matched asset. + /// Basic metadata about the asset being loaded. + T Load(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 @@ + -- cgit