From 9b615fadaa3bb8fbf4fe011320aa1cc709113f3f Mon Sep 17 00:00:00 2001 From: Jesse Plamondon-Willard Date: Sat, 29 Apr 2017 14:13:55 -0400 Subject: add initial content API (#257) --- src/StardewModdingAPI/Framework/ContentHelper.cs | 147 +++++++++++++++++++++ src/StardewModdingAPI/Framework/ModHelper.cs | 17 ++- src/StardewModdingAPI/Framework/SContentManager.cs | 33 ++++- 3 files changed, 188 insertions(+), 9 deletions(-) create mode 100644 src/StardewModdingAPI/Framework/ContentHelper.cs (limited to 'src/StardewModdingAPI/Framework') diff --git a/src/StardewModdingAPI/Framework/ContentHelper.cs b/src/StardewModdingAPI/Framework/ContentHelper.cs new file mode 100644 index 00000000..0d063ef0 --- /dev/null +++ b/src/StardewModdingAPI/Framework/ContentHelper.cs @@ -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 +{ + /// Provides an API for loading content assets. + internal class ContentHelper : IContentHelper + { + /********* + ** Properties + *********/ + /// SMAPI's underlying content manager. + private readonly SContentManager ContentManager; + + /// The absolute path to the mod folder. + private readonly string ModFolderPath; + + /// The path to the mod's folder, relative to the game's content folder (e.g. "../Mods/ModName"). + private readonly string RelativeContentFolder; + + /// The friendly mod name for use in errors. + private readonly string ModName; + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// SMAPI's underlying content manager. + /// The absolute path to the mod folder. + /// The friendly mod name for use in errors. + public ContentHelper(SContentManager contentManager, string modFolderPath, string modName) + { + this.ContentManager = contentManager; + this.ModFolderPath = modFolderPath; + this.ModName = modName; + this.RelativeContentFolder = this.GetRelativePath(contentManager.FullRootDirectory, modFolderPath); + } + + /// Fetch and cache content from the game content or mod folder (if not already cached), and return it. + /// The expected data type. The main supported types are and dictionaries; other types may be supported by the game's content pipeline. + /// The asset key to fetch (if the is ), or the local path to an XNB file relative to the mod folder. + /// Where to search for a matching content asset. + /// The is empty or contains invalid characters. + /// The content asset couldn't be loaded (e.g. because it doesn't exist). + public T Load(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(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(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(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 + *********/ + /// Load a content asset through the underlying content manager, and throw a friendly error if it fails. + /// The expected data type. + /// The content key. + /// The friendly content key to show in errors. + /// The content source for use in errors. + /// The content couldn't be loaded. + private T LoadFromGameContent(string assetKey, string friendlyKey, ContentSource source) + { + try + { + return this.ContentManager.Load(assetKey); + } + catch (Exception ex) + { + throw new ContentLoadException($"{this.ModName} failed loading content asset '{friendlyKey}' from {source}.", ex); + } + } + + /// Get a directory path relative to a given root. + /// The root path from which the path should be relative. + /// The target file path. + 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 + } + } +} diff --git a/src/StardewModdingAPI/Framework/ModHelper.cs b/src/StardewModdingAPI/Framework/ModHelper.cs index 52e482f2..09297a65 100644 --- a/src/StardewModdingAPI/Framework/ModHelper.cs +++ b/src/StardewModdingAPI/Framework/ModHelper.cs @@ -18,9 +18,12 @@ namespace StardewModdingAPI.Framework /********* ** Accessors *********/ - /// The mod directory path. + /// The full path to the mod's folder. public string DirectoryPath { get; } + /// An API for loading content assets. + public IContentHelper Content { get; } + /// Simplifies access to private game code. public IReflectionHelper Reflection { get; } = new ReflectionHelper(); @@ -35,14 +38,15 @@ namespace StardewModdingAPI.Framework ** Public methods *********/ /// Construct an instance. - /// The friendly mod name. - /// The mod directory path. + /// The manifest for the associated mod. + /// The full path to the mod's folder. /// Encapsulate SMAPI's JSON parsing. /// Metadata about loaded mods. /// Manages console commands. + /// The content manager which loads content assets. /// An argument is null or empty. /// The path does not exist on disk. - 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); } /**** diff --git a/src/StardewModdingAPI/Framework/SContentManager.cs b/src/StardewModdingAPI/Framework/SContentManager.cs index ef5855b2..e363e6b4 100644 --- a/src/StardewModdingAPI/Framework/SContentManager.cs +++ b/src/StardewModdingAPI/Framework/SContentManager.cs @@ -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 *********/ /// The possible directory separator characters in an asset key. private static readonly char[] PossiblePathSeparators = new[] { '/', '\\', Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar }.Distinct().ToArray(); @@ -38,6 +39,13 @@ namespace StardewModdingAPI.Framework private readonly IPrivateMethod GetKeyLocale; + /********* + ** Accessors + *********/ + /// The absolute path to the . + 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(assetName); // load data @@ -98,6 +106,25 @@ namespace StardewModdingAPI.Framework return (T)helper.Data; } + /// Inject an asset into the cache. + /// The type of asset to inject. + /// The asset path relative to the loader root directory, not including the .xnb extension. + /// The asset value. + public void Inject(string assetName, T value) + { + assetName = this.NormaliseAssetName(assetName); + this.Cache[assetName] = value; + } + + /// Get whether the content manager has already loaded and cached the given asset. + /// The asset path relative to the loader root directory, not including the .xnb extension. + public bool IsLoaded(string assetName) + { + assetName = this.NormaliseAssetName(assetName); + return this.IsNormalisedKeyLoaded(assetName); + + } + /********* ** Private methods @@ -116,7 +143,7 @@ namespace StardewModdingAPI.Framework /// Get whether an asset has already been loaded. /// The normalised asset name. - private bool IsLoaded(string normalisedAssetName) + private bool IsNormalisedKeyLoaded(string normalisedAssetName) { return this.Cache.ContainsKey(normalisedAssetName) || this.Cache.ContainsKey($"{normalisedAssetName}.{this.GetKeyLocale.Invoke()}"); // translated asset -- cgit