add support for asset loaders (#255)

This commit is contained in:
Jesse Plamondon-Willard 2017-07-01 23:13:43 -04:00
parent 600ef56286
commit f95c7e8d72
7 changed files with 156 additions and 46 deletions

View File

@ -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>

View File

@ -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;

View File

@ -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;
return asset;
}
/// <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)
/// <summary>Get all registered interceptors from a list.</summary>
private IEnumerable<(IModMetadata Mod, T Interceptor)> GetInterceptors<T>(IDictionary<IModMetadata, IList<T>> entries)
{
return new AssetDataForObject(info.Locale, info.AssetName, asset, this.NormaliseAssetName);
}
/// <summary>Get all registered asset editors.</summary>
private IEnumerable<(IModMetadata Mod, IAssetEditor Editor)> GetAssetEditors()
{
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);
}
}
}

View File

@ -1,6 +1,6 @@
namespace StardewModdingAPI
{
/// <summary>Edits a loaded content asset.</summary>
/// <summary>Edits matching content assets.</summary>
public interface IAssetEditor
{
/*********

View File

@ -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);
}
}

View File

@ -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();

View File

@ -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" />