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 +++++++++++++++------ 3 files changed, 129 insertions(+), 45 deletions(-) (limited to 'src/StardewModdingAPI/Framework') 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); } } } -- cgit