add initial content API (#257)

This commit is contained in:
Jesse Plamondon-Willard 2017-04-29 14:13:55 -04:00
parent 6b9372237c
commit 9b615fadaa
8 changed files with 222 additions and 11 deletions

View File

@ -0,0 +1,12 @@
namespace StardewModdingAPI
{
/// <summary>Specifies a source containing content that can be loaded.</summary>
public enum ContentSource
{
/// <summary>Assets in the game's content manager (i.e. XNBs in the game's content folder).</summary>
GameContent,
/// <summary>XNB files in the current mod's folder.</summary>
ModFolder
}
}

View File

@ -0,0 +1,147 @@
using System;
using System.IO;
using System.Linq;
using Microsoft.Xna.Framework.Content;
using Microsoft.Xna.Framework.Graphics;
using StardewValley;
namespace StardewModdingAPI.Framework
{
/// <summary>Provides an API for loading content assets.</summary>
internal class ContentHelper : IContentHelper
{
/*********
** Properties
*********/
/// <summary>SMAPI's underlying content manager.</summary>
private readonly SContentManager ContentManager;
/// <summary>The absolute path to the mod folder.</summary>
private readonly string ModFolderPath;
/// <summary>The path to the mod's folder, relative to the game's content folder (e.g. "../Mods/ModName").</summary>
private readonly string RelativeContentFolder;
/// <summary>The friendly mod name for use in errors.</summary>
private readonly string ModName;
/*********
** Public methods
*********/
/// <summary>Construct an instance.</summary>
/// <param name="contentManager">SMAPI's underlying content manager.</param>
/// <param name="modFolderPath">The absolute path to the mod folder.</param>
/// <param name="modName">The friendly mod name for use in errors.</param>
public ContentHelper(SContentManager contentManager, string modFolderPath, string modName)
{
this.ContentManager = contentManager;
this.ModFolderPath = modFolderPath;
this.ModName = modName;
this.RelativeContentFolder = this.GetRelativePath(contentManager.FullRootDirectory, modFolderPath);
}
/// <summary>Fetch and cache content from the game content or mod folder (if not already cached), and return it.</summary>
/// <typeparam name="T">The expected data type. The main supported types are <see cref="Texture2D"/> and dictionaries; 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 an XNB 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>
/// <exception cref="ContentLoadException">The content asset couldn't be loaded (e.g. because it doesn't exist).</exception>
public T Load<T>(string key, ContentSource source)
{
// validate
if (string.IsNullOrWhiteSpace(key))
throw new ArgumentException("The asset key or local path is empty.");
if (key.Intersect(Path.GetInvalidPathChars()).Any())
throw new ArgumentException("The asset key or local path contains invalid characters.");
// load content
switch (source)
{
case ContentSource.GameContent:
return this.LoadFromGameContent<T>(key, key, source);
case ContentSource.ModFolder:
// find content file
FileInfo file = new FileInfo(Path.Combine(this.ModFolderPath, key));
if (!file.Exists && file.Extension == "")
file = new FileInfo(Path.Combine(this.ModFolderPath, key + ".xnb"));
if (!file.Exists)
throw new ContentLoadException($"There is no file at path '{file.FullName}'.");
// get content-relative path
string contentPath = Path.Combine(this.RelativeContentFolder, key);
if (contentPath.EndsWith(".xnb"))
contentPath = contentPath.Substring(0, contentPath.Length - 4);
// load content
switch (file.Extension.ToLower())
{
case ".xnb":
return this.LoadFromGameContent<T>(contentPath, key, source);
case ".png":
// validate
if (typeof(T) != typeof(Texture2D))
throw new ContentLoadException($"Can't read file with extension '{file.Extension}' as type '{typeof(T)}'; must be type '{typeof(Texture2D)}'.");
// try cache
if (this.ContentManager.IsLoaded(contentPath))
return this.LoadFromGameContent<T>(contentPath, key, source);
// fetch & cache
using (FileStream stream = File.OpenRead(file.FullName))
{
Texture2D texture = Texture2D.FromStream(Game1.graphics.GraphicsDevice, stream);
this.ContentManager.Inject(contentPath, texture);
return (T)(object)texture;
}
default:
throw new ContentLoadException($"Unknown file extension '{file.Extension}'; must be '.xnb' or '.png'.");
}
default:
throw new NotSupportedException($"Unknown content source '{source}'.");
}
}
/*********
** Private methods
*********/
/// <summary>Load a content asset through the underlying content manager, and throw a friendly error if it fails.</summary>
/// <typeparam name="T">The expected data type.</typeparam>
/// <param name="assetKey">The content key.</param>
/// <param name="friendlyKey">The friendly content key to show in errors.</param>
/// <param name="source">The content source for use in errors.</param>
/// <exception cref="ContentLoadException">The content couldn't be loaded.</exception>
private T LoadFromGameContent<T>(string assetKey, string friendlyKey, ContentSource source)
{
try
{
return this.ContentManager.Load<T>(assetKey);
}
catch (Exception ex)
{
throw new ContentLoadException($"{this.ModName} failed loading content asset '{friendlyKey}' from {source}.", ex);
}
}
/// <summary>Get a directory path relative to a given root.</summary>
/// <param name="rootPath">The root path from which the path should be relative.</param>
/// <param name="targetPath">The target file path.</param>
private string GetRelativePath(string rootPath, string targetPath)
{
// convert to URIs
Uri from = new Uri(rootPath + "/");
Uri to = new Uri(targetPath + "/");
if (from.Scheme != to.Scheme)
throw new InvalidOperationException($"Can't get path for '{targetPath}' relative to '{rootPath}'.");
// get relative path
return Uri.UnescapeDataString(from.MakeRelativeUri(to).ToString())
.Replace(Path.DirectorySeparatorChar == '/' ? '\\' : '/', Path.DirectorySeparatorChar); // use correct separator for platform
}
}
}

View File

@ -18,9 +18,12 @@ namespace StardewModdingAPI.Framework
/*********
** Accessors
*********/
/// <summary>The mod directory path.</summary>
/// <summary>The full path to the mod's folder.</summary>
public string DirectoryPath { get; }
/// <summary>An API for loading content assets.</summary>
public IContentHelper Content { get; }
/// <summary>Simplifies access to private game code.</summary>
public IReflectionHelper Reflection { get; } = new ReflectionHelper();
@ -35,14 +38,15 @@ namespace StardewModdingAPI.Framework
** Public methods
*********/
/// <summary>Construct an instance.</summary>
/// <param name="modName">The friendly mod name.</param>
/// <param name="modDirectory">The mod directory path.</param>
/// <param name="manifest">The manifest for the associated mod.</param>
/// <param name="modDirectory">The full path to the mod's folder.</param>
/// <param name="jsonHelper">Encapsulate SMAPI's JSON parsing.</param>
/// <param name="modRegistry">Metadata about loaded mods.</param>
/// <param name="commandManager">Manages console commands.</param>
/// <param name="contentManager">The content manager which loads content assets.</param>
/// <exception cref="ArgumentNullException">An argument is null or empty.</exception>
/// <exception cref="InvalidOperationException">The <paramref name="modDirectory"/> path does not exist on disk.</exception>
public ModHelper(string modName, string modDirectory, JsonHelper jsonHelper, IModRegistry modRegistry, CommandManager commandManager)
public ModHelper(IManifest manifest, string modDirectory, JsonHelper jsonHelper, IModRegistry modRegistry, CommandManager commandManager, SContentManager contentManager)
{
// validate
if (string.IsNullOrWhiteSpace(modDirectory))
@ -55,10 +59,11 @@ namespace StardewModdingAPI.Framework
throw new InvalidOperationException("The specified mod directory does not exist.");
// initialise
this.JsonHelper = jsonHelper;
this.DirectoryPath = modDirectory;
this.JsonHelper = jsonHelper;
this.Content = new ContentHelper(contentManager, modDirectory, manifest.Name);
this.ModRegistry = modRegistry;
this.ConsoleCommands = new CommandHelper(modName, commandManager);
this.ConsoleCommands = new CommandHelper(manifest.Name, commandManager);
}
/****

View File

@ -5,6 +5,7 @@ using System.IO;
using System.Linq;
using System.Threading;
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Content;
using StardewModdingAPI.AssemblyRewriters;
using StardewModdingAPI.Events;
using StardewModdingAPI.Framework.Content;
@ -17,7 +18,7 @@ namespace StardewModdingAPI.Framework
internal class SContentManager : LocalizedContentManager
{
/*********
** Accessors
** Properties
*********/
/// <summary>The possible directory separator characters in an asset key.</summary>
private static readonly char[] PossiblePathSeparators = new[] { '/', '\\', Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar }.Distinct().ToArray();
@ -38,6 +39,13 @@ namespace StardewModdingAPI.Framework
private readonly IPrivateMethod GetKeyLocale;
/*********
** Accessors
*********/
/// <summary>The absolute path to the <see cref="ContentManager.RootDirectory"/>.</summary>
public string FullRootDirectory => Path.Combine(Constants.ExecutionPath, this.RootDirectory);
/*********
** Public methods
*********/
@ -85,7 +93,7 @@ namespace StardewModdingAPI.Framework
string cacheLocale = this.GetCacheLocale(assetName);
// skip if already loaded
if (this.IsLoaded(assetName))
if (this.IsNormalisedKeyLoaded(assetName))
return base.Load<T>(assetName);
// load data
@ -98,6 +106,25 @@ namespace StardewModdingAPI.Framework
return (T)helper.Data;
}
/// <summary>Inject an asset into the cache.</summary>
/// <typeparam name="T">The type of asset to inject.</typeparam>
/// <param name="assetName">The asset path relative to the loader root directory, not including the <c>.xnb</c> extension.</param>
/// <param name="value">The asset value.</param>
public void Inject<T>(string assetName, T value)
{
assetName = this.NormaliseAssetName(assetName);
this.Cache[assetName] = value;
}
/// <summary>Get whether the content manager has already loaded and cached the given asset.</summary>
/// <param name="assetName">The asset path relative to the loader root directory, not including the <c>.xnb</c> extension.</param>
public bool IsLoaded(string assetName)
{
assetName = this.NormaliseAssetName(assetName);
return this.IsNormalisedKeyLoaded(assetName);
}
/*********
** Private methods
@ -116,7 +143,7 @@ namespace StardewModdingAPI.Framework
/// <summary>Get whether an asset has already been loaded.</summary>
/// <param name="normalisedAssetName">The normalised asset name.</param>
private bool IsLoaded(string normalisedAssetName)
private bool IsNormalisedKeyLoaded(string normalisedAssetName)
{
return this.Cache.ContainsKey(normalisedAssetName)
|| this.Cache.ContainsKey($"{normalisedAssetName}.{this.GetKeyLocale.Invoke<string>()}"); // translated asset

View File

@ -0,0 +1,14 @@
using Microsoft.Xna.Framework.Graphics;
namespace StardewModdingAPI
{
/// <summary>Provides an API for loading content assets.</summary>
public interface IContentHelper
{
/// <summary>Fetch and cache content from the game content or mod folder (if not already cached), and return it.</summary>
/// <typeparam name="T">The expected data type. The main supported types are <see cref="Texture2D"/> and dictionaries; 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 an XNB file relative to the mod folder.</param>
/// <param name="source">Where to search for a matching content asset.</param>
T Load<T>(string key, ContentSource source);
}
}

View File

@ -6,9 +6,12 @@
/*********
** Accessors
*********/
/// <summary>The mod directory path.</summary>
/// <summary>The full path to the mod's folder.</summary>
string DirectoryPath { get; }
/// <summary>An API for loading content assets.</summary>
IContentHelper Content { get; }
/// <summary>Simplifies access to private game code.</summary>
IReflectionHelper Reflection { get; }

View File

@ -596,7 +596,7 @@ namespace StardewModdingAPI
// inject data
// get helper
mod.ModManifest = manifest;
mod.Helper = new ModHelper(manifest.Name, directory.FullName, jsonHelper, this.ModRegistry, this.CommandManager);
mod.Helper = new ModHelper(manifest, directory.FullName, jsonHelper, this.ModRegistry, this.CommandManager, (SContentManager)Game1.content);
mod.Monitor = this.GetSecondaryMonitor(manifest.Name);
mod.PathOnDisk = directory.FullName;

View File

@ -114,6 +114,7 @@
<Link>Properties\GlobalAssemblyInfo.cs</Link>
</Compile>
<Compile Include="Command.cs" />
<Compile Include="ContentSource.cs" />
<Compile Include="Events\ContentEvents.cs" />
<Compile Include="Events\EventArgsValueChanged.cs" />
<Compile Include="Framework\Command.cs" />
@ -146,6 +147,7 @@
<Compile Include="Framework\AssemblyDefinitionResolver.cs" />
<Compile Include="Framework\AssemblyParseResult.cs" />
<Compile Include="Framework\CommandManager.cs" />
<Compile Include="Framework\ContentHelper.cs" />
<Compile Include="Framework\Content\ContentEventData.cs" />
<Compile Include="Framework\Content\ContentEventHelper.cs" />
<Compile Include="Framework\Content\ContentEventHelperForDictionary.cs" />
@ -166,6 +168,7 @@
<Compile Include="IContentEventHelper.cs" />
<Compile Include="IContentEventHelperForDictionary.cs" />
<Compile Include="IContentEventHelperForImage.cs" />
<Compile Include="IContentHelper.cs" />
<Compile Include="IModRegistry.cs" />
<Compile Include="Events\LocationEvents.cs" />
<Compile Include="Events\MenuEvents.cs" />