diff options
author | Jesse Plamondon-Willard <Pathoschild@users.noreply.github.com> | 2019-11-24 13:49:30 -0500 |
---|---|---|
committer | Jesse Plamondon-Willard <Pathoschild@users.noreply.github.com> | 2019-11-24 13:49:30 -0500 |
commit | a3f21685049cabf2d824c8060dc0b1de47e9449e (patch) | |
tree | ad9add30e9da2a50e0ea0245f1546b7378f0d282 /src/SMAPI/Framework | |
parent | 6521df7b131924835eb797251c1e956fae0d6e13 (diff) | |
parent | 277bf082675b98b95bf6184fe3c7a45b969c7ac2 (diff) | |
download | SMAPI-a3f21685049cabf2d824c8060dc0b1de47e9449e.tar.gz SMAPI-a3f21685049cabf2d824c8060dc0b1de47e9449e.tar.bz2 SMAPI-a3f21685049cabf2d824c8060dc0b1de47e9449e.zip |
Merge branch 'develop' into stable
Diffstat (limited to 'src/SMAPI/Framework')
69 files changed, 2227 insertions, 2289 deletions
diff --git a/src/SMAPI/Framework/CommandManager.cs b/src/SMAPI/Framework/CommandManager.cs index fdaafff1..ceeb6f93 100644 --- a/src/SMAPI/Framework/CommandManager.cs +++ b/src/SMAPI/Framework/CommandManager.cs @@ -29,7 +29,7 @@ namespace StardewModdingAPI.Framework /// <exception cref="ArgumentException">There's already a command with that name.</exception> public void Add(IModMetadata mod, string name, string documentation, Action<string, string[]> callback, bool allowNullCallback = false) { - name = this.GetNormalisedName(name); + name = this.GetNormalizedName(name); // validate format if (string.IsNullOrWhiteSpace(name)) @@ -52,7 +52,7 @@ namespace StardewModdingAPI.Framework /// <returns>Returns the matching command, or <c>null</c> if not found.</returns> public Command Get(string name) { - name = this.GetNormalisedName(name); + name = this.GetNormalizedName(name); this.Commands.TryGetValue(name, out Command command); return command; } @@ -84,7 +84,7 @@ namespace StardewModdingAPI.Framework // parse input args = this.ParseArgs(input); - name = this.GetNormalisedName(args[0]); + name = this.GetNormalizedName(args[0]); args = args.Skip(1).ToArray(); // get command @@ -97,8 +97,8 @@ namespace StardewModdingAPI.Framework /// <returns>Returns whether a matching command was triggered.</returns> public bool Trigger(string name, string[] arguments) { - // get normalised name - name = this.GetNormalisedName(name); + // get normalized name + name = this.GetNormalizedName(name); if (name == null) return false; @@ -140,9 +140,9 @@ namespace StardewModdingAPI.Framework return args.Where(item => !string.IsNullOrWhiteSpace(item)).ToArray(); } - /// <summary>Get a normalised command name.</summary> + /// <summary>Get a normalized command name.</summary> /// <param name="name">The command name.</param> - private string GetNormalisedName(string name) + private string GetNormalizedName(string name) { name = name?.Trim().ToLower(); return !string.IsNullOrWhiteSpace(name) diff --git a/src/SMAPI/Framework/Content/AssetData.cs b/src/SMAPI/Framework/Content/AssetData.cs index 553404d3..cacc6078 100644 --- a/src/SMAPI/Framework/Content/AssetData.cs +++ b/src/SMAPI/Framework/Content/AssetData.cs @@ -24,13 +24,13 @@ namespace StardewModdingAPI.Framework.Content ** Public methods *********/ /// <summary>Construct an instance.</summary> - /// <param name="locale">The content's locale code, if the content is localised.</param> - /// <param name="assetName">The normalised asset name being read.</param> + /// <param name="locale">The content's locale code, if the content is localized.</param> + /// <param name="assetName">The normalized asset name being read.</param> /// <param name="data">The content data being read.</param> - /// <param name="getNormalisedPath">Normalises an asset key to match the cache key.</param> + /// <param name="getNormalizedPath">Normalizes an asset key to match the cache key.</param> /// <param name="onDataReplaced">A callback to invoke when the data is replaced (if any).</param> - public AssetData(string locale, string assetName, TValue data, Func<string, string> getNormalisedPath, Action<TValue> onDataReplaced) - : base(locale, assetName, data.GetType(), getNormalisedPath) + public AssetData(string locale, string assetName, TValue data, Func<string, string> getNormalizedPath, Action<TValue> onDataReplaced) + : base(locale, assetName, data.GetType(), getNormalizedPath) { this.Data = data; this.OnDataReplaced = onDataReplaced; diff --git a/src/SMAPI/Framework/Content/AssetDataForDictionary.cs b/src/SMAPI/Framework/Content/AssetDataForDictionary.cs index 11a2564c..26cbff5a 100644 --- a/src/SMAPI/Framework/Content/AssetDataForDictionary.cs +++ b/src/SMAPI/Framework/Content/AssetDataForDictionary.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Generic; -using System.Linq; namespace StardewModdingAPI.Framework.Content { @@ -11,44 +10,12 @@ namespace StardewModdingAPI.Framework.Content ** Public methods *********/ /// <summary>Construct an instance.</summary> - /// <param name="locale">The content's locale code, if the content is localised.</param> - /// <param name="assetName">The normalised asset name being read.</param> + /// <param name="locale">The content's locale code, if the content is localized.</param> + /// <param name="assetName">The normalized asset name being read.</param> /// <param name="data">The content data being read.</param> - /// <param name="getNormalisedPath">Normalises an asset key to match the cache key.</param> + /// <param name="getNormalizedPath">Normalizes an asset key to match the cache key.</param> /// <param name="onDataReplaced">A callback to invoke when the data is replaced (if any).</param> - public AssetDataForDictionary(string locale, string assetName, IDictionary<TKey, TValue> data, Func<string, string> getNormalisedPath, Action<IDictionary<TKey, TValue>> onDataReplaced) - : base(locale, assetName, data, getNormalisedPath, onDataReplaced) { } - -#if !SMAPI_3_0_STRICT - /// <summary>Add or replace an entry in the dictionary.</summary> - /// <param name="key">The entry key.</param> - /// <param name="value">The entry value.</param> - [Obsolete("Access " + nameof(AssetData<IDictionary<TKey, TValue>>.Data) + "field directly.")] - public void Set(TKey key, TValue value) - { - SCore.DeprecationManager.Warn($"AssetDataForDictionary.{nameof(Set)}", "2.10", DeprecationLevel.PendingRemoval); - this.Data[key] = value; - } - - /// <summary>Add or replace an entry in the dictionary.</summary> - /// <param name="key">The entry key.</param> - /// <param name="value">A callback which accepts the current value and returns the new value.</param> - [Obsolete("Access " + nameof(AssetData<IDictionary<TKey, TValue>>.Data) + "field directly.")] - public void Set(TKey key, Func<TValue, TValue> value) - { - SCore.DeprecationManager.Warn($"AssetDataForDictionary.{nameof(Set)}", "2.10", DeprecationLevel.PendingRemoval); - this.Data[key] = value(this.Data[key]); - } - - /// <summary>Dynamically replace values in the dictionary.</summary> - /// <param name="replacer">A lambda which takes the current key and value for an entry, and returns the new value.</param> - [Obsolete("Access " + nameof(AssetData<IDictionary<TKey, TValue>>.Data) + "field directly.")] - public void Set(Func<TKey, TValue, TValue> replacer) - { - SCore.DeprecationManager.Warn($"AssetDataForDictionary.{nameof(Set)}", "2.10", DeprecationLevel.PendingRemoval); - foreach (var pair in this.Data.ToArray()) - this.Data[pair.Key] = replacer(pair.Key, pair.Value); - } -#endif + public AssetDataForDictionary(string locale, string assetName, IDictionary<TKey, TValue> data, Func<string, string> getNormalizedPath, Action<IDictionary<TKey, TValue>> onDataReplaced) + : base(locale, assetName, data, getNormalizedPath, onDataReplaced) { } } } diff --git a/src/SMAPI/Framework/Content/AssetDataForImage.cs b/src/SMAPI/Framework/Content/AssetDataForImage.cs index f2d21b5e..4ae2ad68 100644 --- a/src/SMAPI/Framework/Content/AssetDataForImage.cs +++ b/src/SMAPI/Framework/Content/AssetDataForImage.cs @@ -19,13 +19,13 @@ namespace StardewModdingAPI.Framework.Content ** Public methods *********/ /// <summary>Construct an instance.</summary> - /// <param name="locale">The content's locale code, if the content is localised.</param> - /// <param name="assetName">The normalised asset name being read.</param> + /// <param name="locale">The content's locale code, if the content is localized.</param> + /// <param name="assetName">The normalized asset name being read.</param> /// <param name="data">The content data being read.</param> - /// <param name="getNormalisedPath">Normalises an asset key to match the cache key.</param> + /// <param name="getNormalizedPath">Normalizes an asset key to match the cache key.</param> /// <param name="onDataReplaced">A callback to invoke when the data is replaced (if any).</param> - public AssetDataForImage(string locale, string assetName, Texture2D data, Func<string, string> getNormalisedPath, Action<Texture2D> onDataReplaced) - : base(locale, assetName, data, getNormalisedPath, onDataReplaced) { } + public AssetDataForImage(string locale, string assetName, Texture2D data, Func<string, string> getNormalizedPath, Action<Texture2D> onDataReplaced) + : base(locale, assetName, data, getNormalizedPath, onDataReplaced) { } /// <summary>Overwrite part of the image.</summary> /// <param name="source">The image to patch into the content.</param> diff --git a/src/SMAPI/Framework/Content/AssetDataForObject.cs b/src/SMAPI/Framework/Content/AssetDataForObject.cs index 90f9e2d4..4dbc988c 100644 --- a/src/SMAPI/Framework/Content/AssetDataForObject.cs +++ b/src/SMAPI/Framework/Content/AssetDataForObject.cs @@ -11,19 +11,19 @@ namespace StardewModdingAPI.Framework.Content ** Public methods *********/ /// <summary>Construct an instance.</summary> - /// <param name="locale">The content's locale code, if the content is localised.</param> - /// <param name="assetName">The normalised asset name being read.</param> + /// <param name="locale">The content's locale code, if the content is localized.</param> + /// <param name="assetName">The normalized asset name being read.</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(string locale, string assetName, object data, Func<string, string> getNormalisedPath) - : base(locale, assetName, data, getNormalisedPath, onDataReplaced: null) { } + /// <param name="getNormalizedPath">Normalizes an asset key to match the cache key.</param> + public AssetDataForObject(string locale, string assetName, object data, Func<string, string> getNormalizedPath) + : base(locale, assetName, data, getNormalizedPath, onDataReplaced: null) { } /// <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) { } + /// <param name="getNormalizedPath">Normalizes an asset key to match the cache key.</param> + public AssetDataForObject(IAssetInfo info, object data, Func<string, string> getNormalizedPath) + : this(info.Locale, info.AssetName, data, getNormalizedPath) { } /// <summary>Get a helper to manipulate the data as a dictionary.</summary> /// <typeparam name="TKey">The expected dictionary key.</typeparam> @@ -31,14 +31,14 @@ namespace StardewModdingAPI.Framework.Content /// <exception cref="InvalidOperationException">The content being read isn't a dictionary.</exception> public IAssetDataForDictionary<TKey, TValue> AsDictionary<TKey, TValue>() { - return new AssetDataForDictionary<TKey, TValue>(this.Locale, this.AssetName, this.GetData<IDictionary<TKey, TValue>>(), this.GetNormalisedPath, this.ReplaceWith); + return new AssetDataForDictionary<TKey, TValue>(this.Locale, this.AssetName, this.GetData<IDictionary<TKey, TValue>>(), this.GetNormalizedPath, this.ReplaceWith); } /// <summary>Get a helper to manipulate the data as an image.</summary> /// <exception cref="InvalidOperationException">The content being read isn't an image.</exception> public IAssetDataForImage AsImage() { - return new AssetDataForImage(this.Locale, this.AssetName, this.GetData<Texture2D>(), this.GetNormalisedPath, this.ReplaceWith); + return new AssetDataForImage(this.Locale, this.AssetName, this.GetData<Texture2D>(), this.GetNormalizedPath, this.ReplaceWith); } /// <summary>Get the data as a given type.</summary> diff --git a/src/SMAPI/Framework/Content/AssetInfo.cs b/src/SMAPI/Framework/Content/AssetInfo.cs index e5211290..9b685e72 100644 --- a/src/SMAPI/Framework/Content/AssetInfo.cs +++ b/src/SMAPI/Framework/Content/AssetInfo.cs @@ -9,17 +9,17 @@ namespace StardewModdingAPI.Framework.Content /********* ** Fields *********/ - /// <summary>Normalises an asset key to match the cache key.</summary> - protected readonly Func<string, string> GetNormalisedPath; + /// <summary>Normalizes an asset key to match the cache key.</summary> + protected readonly Func<string, string> GetNormalizedPath; /********* ** Accessors *********/ - /// <summary>The content's locale code, if the content is localised.</summary> + /// <summary>The content's locale code, if the content is localized.</summary> public string Locale { get; } - /// <summary>The normalised asset name being read. The format may change between platforms; see <see cref="AssetNameEquals"/> to compare with a known path.</summary> + /// <summary>The normalized asset name being read. The format may change between platforms; see <see cref="AssetNameEquals"/> to compare with a known path.</summary> public string AssetName { get; } /// <summary>The content data type.</summary> @@ -30,23 +30,23 @@ namespace StardewModdingAPI.Framework.Content ** Public methods *********/ /// <summary>Construct an instance.</summary> - /// <param name="locale">The content's locale code, if the content is localised.</param> - /// <param name="assetName">The normalised asset name being read.</param> + /// <param name="locale">The content's locale code, if the content is localized.</param> + /// <param name="assetName">The normalized asset name being read.</param> /// <param name="type">The content type being read.</param> - /// <param name="getNormalisedPath">Normalises an asset key to match the cache key.</param> - public AssetInfo(string locale, string assetName, Type type, Func<string, string> getNormalisedPath) + /// <param name="getNormalizedPath">Normalizes an asset key to match the cache key.</param> + public AssetInfo(string locale, string assetName, Type type, Func<string, string> getNormalizedPath) { this.Locale = locale; this.AssetName = assetName; this.DataType = type; - this.GetNormalisedPath = getNormalisedPath; + this.GetNormalizedPath = getNormalizedPath; } - /// <summary>Get whether the asset name being loaded matches a given name after normalisation.</summary> + /// <summary>Get whether the asset name being loaded matches a given name after normalization.</summary> /// <param name="path">The expected asset path, relative to the game's content folder and without the .xnb extension or locale suffix (like 'Data\ObjectInformation').</param> public bool AssetNameEquals(string path) { - path = this.GetNormalisedPath(path); + path = this.GetNormalizedPath(path); return this.AssetName.Equals(path, StringComparison.InvariantCultureIgnoreCase); } diff --git a/src/SMAPI/Framework/Content/ContentCache.cs b/src/SMAPI/Framework/Content/ContentCache.cs index 55a96ed2..4178b663 100644 --- a/src/SMAPI/Framework/Content/ContentCache.cs +++ b/src/SMAPI/Framework/Content/ContentCache.cs @@ -10,7 +10,7 @@ using StardewValley; namespace StardewModdingAPI.Framework.Content { - /// <summary>A low-level wrapper around the content cache which handles reading, writing, and invalidating entries in the cache. This doesn't handle any higher-level logic like localisation, loading content, etc. It assumes all keys passed in are already normalised.</summary> + /// <summary>A low-level wrapper around the content cache which handles reading, writing, and invalidating entries in the cache. This doesn't handle any higher-level logic like localization, loading content, etc. It assumes all keys passed in are already normalized.</summary> internal class ContentCache { /********* @@ -19,8 +19,8 @@ namespace StardewModdingAPI.Framework.Content /// <summary>The underlying asset cache.</summary> private readonly IDictionary<string, object> Cache; - /// <summary>Applies platform-specific asset key normalisation so it's consistent with the underlying cache.</summary> - private readonly Func<string, string> NormaliseAssetNameForPlatform; + /// <summary>Applies platform-specific asset key normalization so it's consistent with the underlying cache.</summary> + private readonly Func<string, string> NormalizeAssetNameForPlatform; /********* @@ -52,14 +52,14 @@ namespace StardewModdingAPI.Framework.Content // init this.Cache = reflection.GetField<Dictionary<string, object>>(contentManager, "loadedAssets").GetValue(); - // get key normalisation logic + // get key normalization logic if (Constants.Platform == Platform.Windows) { IReflectedMethod method = reflection.GetMethod(typeof(TitleContainer), "GetCleanPath"); - this.NormaliseAssetNameForPlatform = path => method.Invoke<string>(path); + this.NormalizeAssetNameForPlatform = path => method.Invoke<string>(path); } else - this.NormaliseAssetNameForPlatform = key => key.Replace('\\', '/'); // based on MonoGame's ContentManager.Load<T> logic + this.NormalizeAssetNameForPlatform = key => key.Replace('\\', '/'); // based on MonoGame's ContentManager.Load<T> logic } /**** @@ -74,25 +74,25 @@ namespace StardewModdingAPI.Framework.Content /**** - ** Normalise + ** Normalize ****/ - /// <summary>Normalise path separators in a file path. For asset keys, see <see cref="NormaliseKey"/> instead.</summary> - /// <param name="path">The file path to normalise.</param> + /// <summary>Normalize path separators in a file path. For asset keys, see <see cref="NormalizeKey"/> instead.</summary> + /// <param name="path">The file path to normalize.</param> [Pure] - public string NormalisePathSeparators(string path) + public string NormalizePathSeparators(string path) { - return PathUtilities.NormalisePathSeparators(path); + return PathUtilities.NormalizePathSeparators(path); } - /// <summary>Normalise a cache key so it's consistent with the underlying cache.</summary> + /// <summary>Normalize a cache key so it's consistent with the underlying cache.</summary> /// <param name="key">The asset key.</param> [Pure] - public string NormaliseKey(string key) + public string NormalizeKey(string key) { - key = this.NormalisePathSeparators(key); + key = this.NormalizePathSeparators(key); return key.EndsWith(".xnb", StringComparison.InvariantCultureIgnoreCase) ? key.Substring(0, key.Length - 4) - : this.NormaliseAssetNameForPlatform(key); + : this.NormalizeAssetNameForPlatform(key); } /**** diff --git a/src/SMAPI/Framework/ContentCoordinator.cs b/src/SMAPI/Framework/ContentCoordinator.cs index ee654081..08ebe6a5 100644 --- a/src/SMAPI/Framework/ContentCoordinator.cs +++ b/src/SMAPI/Framework/ContentCoordinator.cs @@ -9,7 +9,7 @@ using StardewModdingAPI.Framework.Content; using StardewModdingAPI.Framework.ContentManagers; using StardewModdingAPI.Framework.Reflection; using StardewModdingAPI.Metadata; -using StardewModdingAPI.Toolkit.Serialisation; +using StardewModdingAPI.Toolkit.Serialization; using StardewModdingAPI.Toolkit.Utilities; using StardewValley; @@ -36,9 +36,15 @@ namespace StardewModdingAPI.Framework /// <summary>Encapsulates SMAPI's JSON file parsing.</summary> private readonly JsonHelper JsonHelper; + /// <summary>A callback to invoke the first time *any* game content manager loads an asset.</summary> + private readonly Action OnLoadingFirstAsset; + /// <summary>The loaded content managers (including the <see cref="MainContentManager"/>).</summary> private readonly IList<IContentManager> ContentManagers = new List<IContentManager>(); + /// <summary>The language code for language-agnostic mod assets.</summary> + private readonly LocalizedContentManager.LanguageCode DefaultLanguage = Constants.DefaultLanguage; + /// <summary>Whether the content coordinator has been disposed.</summary> private bool IsDisposed; @@ -68,27 +74,29 @@ namespace StardewModdingAPI.Framework /// <summary>Construct an instance.</summary> /// <param name="serviceProvider">The service provider to use to locate services.</param> /// <param name="rootDirectory">The root directory to search for content.</param> - /// <param name="currentCulture">The current culture for which to localise content.</param> + /// <param name="currentCulture">The current culture for which to localize content.</param> /// <param name="monitor">Encapsulates monitoring and logging.</param> /// <param name="reflection">Simplifies access to private code.</param> /// <param name="jsonHelper">Encapsulates SMAPI's JSON file parsing.</param> - public ContentCoordinator(IServiceProvider serviceProvider, string rootDirectory, CultureInfo currentCulture, IMonitor monitor, Reflector reflection, JsonHelper jsonHelper) + /// <param name="onLoadingFirstAsset">A callback to invoke the first time *any* game content manager loads an asset.</param> + public ContentCoordinator(IServiceProvider serviceProvider, string rootDirectory, CultureInfo currentCulture, IMonitor monitor, Reflector reflection, JsonHelper jsonHelper, Action onLoadingFirstAsset) { this.Monitor = monitor ?? throw new ArgumentNullException(nameof(monitor)); this.Reflection = reflection; this.JsonHelper = jsonHelper; + this.OnLoadingFirstAsset = onLoadingFirstAsset; this.FullRootDirectory = Path.Combine(Constants.ExecutionPath, rootDirectory); this.ContentManagers.Add( - this.MainContentManager = new GameContentManager("Game1.content", serviceProvider, rootDirectory, currentCulture, this, monitor, reflection, this.OnDisposing) + this.MainContentManager = new GameContentManager("Game1.content", serviceProvider, rootDirectory, currentCulture, this, monitor, reflection, this.OnDisposing, onLoadingFirstAsset) ); - this.CoreAssets = new CoreAssetPropagator(this.MainContentManager.AssertAndNormaliseAssetName, reflection, monitor); + this.CoreAssets = new CoreAssetPropagator(this.MainContentManager.AssertAndNormalizeAssetName, reflection, monitor); } /// <summary>Get a new content manager which handles reading files from the game content folder with support for interception.</summary> /// <param name="name">A name for the mod manager. Not guaranteed to be unique.</param> public GameContentManager CreateGameContentManager(string name) { - GameContentManager manager = new GameContentManager(name, this.MainContentManager.ServiceProvider, this.MainContentManager.RootDirectory, this.MainContentManager.CurrentCulture, this, this.Monitor, this.Reflection, this.OnDisposing); + GameContentManager manager = new GameContentManager(name, this.MainContentManager.ServiceProvider, this.MainContentManager.RootDirectory, this.MainContentManager.CurrentCulture, this, this.Monitor, this.Reflection, this.OnDisposing, this.OnLoadingFirstAsset); this.ContentManagers.Add(manager); return manager; } @@ -96,9 +104,21 @@ namespace StardewModdingAPI.Framework /// <summary>Get a new content manager which handles reading files from a SMAPI mod folder with support for unpacked files.</summary> /// <param name="name">A name for the mod manager. Not guaranteed to be unique.</param> /// <param name="rootDirectory">The root directory to search for content (or <c>null</c> for the default).</param> - public ModContentManager CreateModContentManager(string name, string rootDirectory) + /// <param name="gameContentManager">The game content manager used for map tilesheets not provided by the mod.</param> + public ModContentManager CreateModContentManager(string name, string rootDirectory, IContentManager gameContentManager) { - ModContentManager manager = new ModContentManager(name, this.MainContentManager.ServiceProvider, rootDirectory, this.MainContentManager.CurrentCulture, this, this.Monitor, this.Reflection, this.JsonHelper, this.OnDisposing); + ModContentManager manager = new ModContentManager( + name: name, + gameContentManager: gameContentManager, + serviceProvider: this.MainContentManager.ServiceProvider, + rootDirectory: rootDirectory, + currentCulture: this.MainContentManager.CurrentCulture, + coordinator: this, + monitor: this.Monitor, + reflection: this.Reflection, + jsonHelper: this.JsonHelper, + onDisposing: this.OnDisposing + ); this.ContentManagers.Add(manager); return manager; } @@ -109,6 +129,13 @@ namespace StardewModdingAPI.Framework return this.MainContentManager.GetLocale(LocalizedContentManager.CurrentLanguageCode); } + /// <summary>Perform any cleanup needed when the locale changes.</summary> + public void OnLocaleChanged() + { + foreach (IContentManager contentManager in this.ContentManagers) + contentManager.OnLocaleChanged(); + } + /// <summary>Get whether this asset is mapped to a mod folder.</summary> /// <param name="key">The asset key.</param> public bool IsManagedAssetKey(string key) @@ -148,20 +175,17 @@ namespace StardewModdingAPI.Framework /// <summary>Get a copy of an asset from a mod folder.</summary> /// <typeparam name="T">The asset type.</typeparam> - /// <param name="internalKey">The internal asset key.</param> /// <param name="contentManagerID">The unique name for the content manager which should load this asset.</param> /// <param name="relativePath">The internal SMAPI asset key.</param> - /// <param name="language">The language code for which to load content.</param> - public T LoadAndCloneManagedAsset<T>(string internalKey, string contentManagerID, string relativePath, LocalizedContentManager.LanguageCode language) + public T LoadManagedAsset<T>(string contentManagerID, string relativePath) { // get content manager - IContentManager contentManager = this.ContentManagers.FirstOrDefault(p => p.Name == contentManagerID); + IContentManager contentManager = this.ContentManagers.FirstOrDefault(p => p.IsNamespaced && p.Name == contentManagerID); if (contentManager == null) throw new InvalidOperationException($"The '{contentManagerID}' prefix isn't handled by any mod."); - // get cloned asset - T data = contentManager.Load<T>(internalKey, language); - return contentManager.CloneIfPossible(data); + // get fresh asset + return contentManager.Load<T>(relativePath, this.DefaultLanguage, useCache: false); } /// <summary>Purge assets from the cache that match one of the interceptors.</summary> @@ -226,7 +250,7 @@ namespace StardewModdingAPI.Framework string locale = this.GetLocale(); return this.InvalidateCache((assetName, type) => { - IAssetInfo info = new AssetInfo(locale, assetName, type, this.MainContentManager.AssertAndNormaliseAssetName); + IAssetInfo info = new AssetInfo(locale, assetName, type, this.MainContentManager.AssertAndNormalizeAssetName); return predicate(info); }, dispose); } @@ -246,14 +270,7 @@ namespace StardewModdingAPI.Framework } // reload core game assets - int reloaded = 0; - foreach (var pair in removedAssetNames) - { - string key = pair.Key; - Type type = pair.Value; - if (this.CoreAssets.Propagate(this.MainContentManager, key, type)) // use an intercepted content manager - reloaded++; - } + int reloaded = this.CoreAssets.Propagate(this.MainContentManager, removedAssetNames); // use an intercepted content manager // report result if (removedAssetNames.Any()) diff --git a/src/SMAPI/Framework/ContentManagers/BaseContentManager.cs b/src/SMAPI/Framework/ContentManagers/BaseContentManager.cs index 7821e454..5283340e 100644 --- a/src/SMAPI/Framework/ContentManagers/BaseContentManager.cs +++ b/src/SMAPI/Framework/ContentManagers/BaseContentManager.cs @@ -38,6 +38,9 @@ namespace StardewModdingAPI.Framework.ContentManagers /// <summary>The language enum values indexed by locale code.</summary> protected IDictionary<string, LanguageCode> LanguageCodes { get; } + /// <summary>A list of disposable assets.</summary> + private readonly List<WeakReference<IDisposable>> Disposables = new List<WeakReference<IDisposable>>(); + /********* ** Accessors @@ -51,8 +54,8 @@ namespace StardewModdingAPI.Framework.ContentManagers /// <summary>The absolute path to the <see cref="ContentManager.RootDirectory"/>.</summary> public string FullRootDirectory => Path.Combine(Constants.ExecutionPath, this.RootDirectory); - /// <summary>Whether this content manager is for a mod folder.</summary> - public bool IsModContentManager { get; } + /// <summary>Whether this content manager can be targeted by managed asset keys (e.g. to load assets from a mod folder).</summary> + public bool IsNamespaced { get; } /********* @@ -62,13 +65,13 @@ namespace StardewModdingAPI.Framework.ContentManagers /// <param name="name">A name for the mod manager. Not guaranteed to be unique.</param> /// <param name="serviceProvider">The service provider to use to locate services.</param> /// <param name="rootDirectory">The root directory to search for content.</param> - /// <param name="currentCulture">The current culture for which to localise content.</param> + /// <param name="currentCulture">The current culture for which to localize content.</param> /// <param name="coordinator">The central coordinator which manages content managers.</param> /// <param name="monitor">Encapsulates monitoring and logging.</param> /// <param name="reflection">Simplifies access to private code.</param> /// <param name="onDisposing">A callback to invoke when the content manager is being disposed.</param> - /// <param name="isModFolder">Whether this content manager is for a mod folder.</param> - protected BaseContentManager(string name, IServiceProvider serviceProvider, string rootDirectory, CultureInfo currentCulture, ContentCoordinator coordinator, IMonitor monitor, Reflector reflection, Action<BaseContentManager> onDisposing, bool isModFolder) + /// <param name="isNamespaced">Whether this content manager handles managed asset keys (e.g. to load assets from a mod folder).</param> + protected BaseContentManager(string name, IServiceProvider serviceProvider, string rootDirectory, CultureInfo currentCulture, ContentCoordinator coordinator, IMonitor monitor, Reflector reflection, Action<BaseContentManager> onDisposing, bool isNamespaced) : base(serviceProvider, rootDirectory, currentCulture) { // init @@ -77,7 +80,7 @@ namespace StardewModdingAPI.Framework.ContentManagers this.Cache = new ContentCache(this, reflection); this.Monitor = monitor ?? throw new ArgumentNullException(nameof(monitor)); this.OnDisposing = onDisposing; - this.IsModContentManager = isModFolder; + this.IsNamespaced = isNamespaced; // get asset data this.LanguageCodes = this.GetKeyLocales().ToDictionary(p => p.Value, p => p.Key, StringComparer.InvariantCultureIgnoreCase); @@ -88,69 +91,50 @@ namespace StardewModdingAPI.Framework.ContentManagers /// <param name="assetName">The asset path relative to the loader root directory, not including the <c>.xnb</c> extension.</param> public override T Load<T>(string assetName) { - return this.Load<T>(assetName, LocalizedContentManager.CurrentLanguageCode); + return this.Load<T>(assetName, this.Language, useCache: true); } - /// <summary>Load the base asset without localisation.</summary> + /// <summary>Load an asset that has been processed by the content pipeline.</summary> /// <typeparam name="T">The type of asset to load.</typeparam> /// <param name="assetName">The asset path relative to the loader root directory, not including the <c>.xnb</c> extension.</param> - public override T LoadBase<T>(string assetName) + /// <param name="language">The language code for which to load content.</param> + public override T Load<T>(string assetName, LanguageCode language) { - return this.Load<T>(assetName, LanguageCode.en); + return this.Load<T>(assetName, language, useCache: true); } - /// <summary>Inject an asset into the cache.</summary> - /// <typeparam name="T">The type of asset to inject.</typeparam> + /// <summary>Load an asset that has been processed by the content pipeline.</summary> + /// <typeparam name="T">The type of asset to load.</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.AssertAndNormaliseAssetName(assetName); - this.Cache[assetName] = value; - - } + /// <param name="language">The language code for which to load content.</param> + /// <param name="useCache">Whether to read/write the loaded asset to the asset cache.</param> + public abstract T Load<T>(string assetName, LocalizedContentManager.LanguageCode language, bool useCache); - /// <summary>Get a copy of the given asset if supported.</summary> - /// <typeparam name="T">The asset type.</typeparam> - /// <param name="asset">The asset to clone.</param> - public T CloneIfPossible<T>(T asset) + /// <summary>Load the base asset without localization.</summary> + /// <typeparam name="T">The type of asset to load.</typeparam> + /// <param name="assetName">The asset path relative to the loader root directory, not including the <c>.xnb</c> extension.</param> + [Obsolete("This method is implemented for the base game and should not be used directly. To load an asset from the underlying content manager directly, use " + nameof(BaseContentManager.RawLoad) + " instead.")] + public override T LoadBase<T>(string assetName) { - switch (asset as object) - { - case Texture2D source: - { - int[] pixels = new int[source.Width * source.Height]; - source.GetData(pixels); - - Texture2D clone = new Texture2D(source.GraphicsDevice, source.Width, source.Height); - clone.SetData(pixels); - return (T)(object)clone; - } - - case Dictionary<string, string> source: - return (T)(object)new Dictionary<string, string>(source); - - case Dictionary<int, string> source: - return (T)(object)new Dictionary<int, string>(source); - - default: - return asset; - } + return this.Load<T>(assetName, LanguageCode.en, useCache: true); } - /// <summary>Normalise path separators in a file path. For asset keys, see <see cref="AssertAndNormaliseAssetName"/> instead.</summary> - /// <param name="path">The file path to normalise.</param> + /// <summary>Perform any cleanup needed when the locale changes.</summary> + public virtual void OnLocaleChanged() { } + + /// <summary>Normalize path separators in a file path. For asset keys, see <see cref="AssertAndNormalizeAssetName"/> instead.</summary> + /// <param name="path">The file path to normalize.</param> [Pure] - public string NormalisePathSeparators(string path) + public string NormalizePathSeparators(string path) { - return this.Cache.NormalisePathSeparators(path); + return this.Cache.NormalizePathSeparators(path); } - /// <summary>Assert that the given key has a valid format and return a normalised form consistent with the underlying cache.</summary> + /// <summary>Assert that the given key has a valid format and return a normalized form consistent with the underlying cache.</summary> /// <param name="assetName">The asset key to check.</param> /// <exception cref="SContentLoadException">The asset key is empty or contains invalid characters.</exception> [SuppressMessage("ReSharper", "ParameterOnlyUsedForPreconditionCheck.Local", Justification = "Parameter is only used for assertion checks by design.")] - public string AssertAndNormaliseAssetName(string assetName) + public string AssertAndNormalizeAssetName(string assetName) { // NOTE: the game checks for ContentLoadException to handle invalid keys, so avoid // throwing other types like ArgumentException here. @@ -159,7 +143,7 @@ namespace StardewModdingAPI.Framework.ContentManagers if (assetName.Intersect(Path.GetInvalidPathChars()).Any()) throw new SContentLoadException("The asset key or local path contains invalid characters."); - return this.Cache.NormaliseKey(assetName); + return this.Cache.NormalizeKey(assetName); } /**** @@ -182,8 +166,8 @@ namespace StardewModdingAPI.Framework.ContentManagers /// <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.Cache.NormaliseKey(assetName); - return this.IsNormalisedKeyLoaded(assetName); + assetName = this.Cache.NormalizeKey(assetName); + return this.IsNormalizedKeyLoaded(assetName); } /// <summary>Get the cached asset keys.</summary> @@ -225,11 +209,28 @@ namespace StardewModdingAPI.Framework.ContentManagers /// <param name="isDisposing">Whether the content manager is being disposed (rather than finalized).</param> protected override void Dispose(bool isDisposing) { + // ignore if disposed if (this.IsDisposed) return; this.IsDisposed = true; + // dispose uncached assets + foreach (WeakReference<IDisposable> reference in this.Disposables) + { + if (reference.TryGetTarget(out IDisposable disposable)) + { + try + { + disposable.Dispose(); + } + catch { /* ignore dispose errors */ } + } + } + this.Disposables.Clear(); + + // raise event this.OnDisposing(this); + base.Dispose(isDisposing); } @@ -246,32 +247,40 @@ namespace StardewModdingAPI.Framework.ContentManagers /********* ** Private methods *********/ - /// <summary>Get the locale codes (like <c>ja-JP</c>) used in asset keys.</summary> - private IDictionary<LanguageCode, string> GetKeyLocales() + /// <summary>Load an asset file directly from the underlying content manager.</summary> + /// <typeparam name="T">The type of asset to load.</typeparam> + /// <param name="assetName">The normalized asset key.</param> + /// <param name="useCache">Whether to read/write the loaded asset to the asset cache.</param> + protected virtual T RawLoad<T>(string assetName, bool useCache) { - // create locale => code map - IDictionary<LanguageCode, string> map = new Dictionary<LanguageCode, string>(); - foreach (LanguageCode code in Enum.GetValues(typeof(LanguageCode))) - map[code] = this.GetLocale(code); - - return map; + return useCache + ? base.LoadBase<T>(assetName) + : base.ReadAsset<T>(assetName, disposable => this.Disposables.Add(new WeakReference<IDisposable>(disposable))); } - /// <summary>Get the asset name from a cache key.</summary> - /// <param name="cacheKey">The input cache key.</param> - private string GetAssetName(string cacheKey) + /// <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> + /// <param name="language">The language code for which to inject the asset.</param> + protected virtual void Inject<T>(string assetName, T value, LanguageCode language) { - this.ParseCacheKey(cacheKey, out string assetName, out string _); - return assetName; + // track asset key + if (value is Texture2D texture) + texture.Name = assetName; + + // cache asset + assetName = this.AssertAndNormalizeAssetName(assetName); + this.Cache[assetName] = value; } /// <summary>Parse a cache key into its component parts.</summary> /// <param name="cacheKey">The input cache key.</param> /// <param name="assetName">The original asset name.</param> - /// <param name="localeCode">The asset locale code (or <c>null</c> if not localised).</param> + /// <param name="localeCode">The asset locale code (or <c>null</c> if not localized).</param> protected void ParseCacheKey(string cacheKey, out string assetName, out string localeCode) { - // handle localised key + // handle localized key if (!string.IsNullOrWhiteSpace(cacheKey)) { int lastSepIndex = cacheKey.LastIndexOf(".", StringComparison.InvariantCulture); @@ -293,7 +302,26 @@ namespace StardewModdingAPI.Framework.ContentManagers } /// <summary>Get whether an asset has already been loaded.</summary> - /// <param name="normalisedAssetName">The normalised asset name.</param> - protected abstract bool IsNormalisedKeyLoaded(string normalisedAssetName); + /// <param name="normalizedAssetName">The normalized asset name.</param> + protected abstract bool IsNormalizedKeyLoaded(string normalizedAssetName); + + /// <summary>Get the locale codes (like <c>ja-JP</c>) used in asset keys.</summary> + private IDictionary<LanguageCode, string> GetKeyLocales() + { + // create locale => code map + IDictionary<LanguageCode, string> map = new Dictionary<LanguageCode, string>(); + foreach (LanguageCode code in Enum.GetValues(typeof(LanguageCode))) + map[code] = this.GetLocale(code); + + return map; + } + + /// <summary>Get the asset name from a cache key.</summary> + /// <param name="cacheKey">The input cache key.</param> + private string GetAssetName(string cacheKey) + { + this.ParseCacheKey(cacheKey, out string assetName, out string _); + return assetName; + } } } diff --git a/src/SMAPI/Framework/ContentManagers/GameContentManager.cs b/src/SMAPI/Framework/ContentManagers/GameContentManager.cs index ee940cc7..0b563555 100644 --- a/src/SMAPI/Framework/ContentManagers/GameContentManager.cs +++ b/src/SMAPI/Framework/ContentManagers/GameContentManager.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using System.Globalization; using System.Linq; +using Microsoft.Xna.Framework.Content; using StardewModdingAPI.Framework.Content; using StardewModdingAPI.Framework.Exceptions; using StardewModdingAPI.Framework.Reflection; @@ -25,8 +26,14 @@ namespace StardewModdingAPI.Framework.ContentManagers /// <summary>Interceptors which edit matching assets after they're loaded.</summary> private IDictionary<IModMetadata, IList<IAssetEditor>> Editors => this.Coordinator.Editors; - /// <summary>A lookup which indicates whether the asset is localisable (i.e. the filename contains the locale), if previously loaded.</summary> - private readonly IDictionary<string, bool> IsLocalisableLookup; + /// <summary>A lookup which indicates whether the asset is localizable (i.e. the filename contains the locale), if previously loaded.</summary> + private readonly IDictionary<string, bool> IsLocalizableLookup; + + /// <summary>Whether the next load is the first for any game content manager.</summary> + private static bool IsFirstLoad = true; + + /// <summary>A callback to invoke the first time *any* game content manager loads an asset.</summary> + private readonly Action OnLoadingFirstAsset; /********* @@ -36,37 +43,48 @@ namespace StardewModdingAPI.Framework.ContentManagers /// <param name="name">A name for the mod manager. Not guaranteed to be unique.</param> /// <param name="serviceProvider">The service provider to use to locate services.</param> /// <param name="rootDirectory">The root directory to search for content.</param> - /// <param name="currentCulture">The current culture for which to localise content.</param> + /// <param name="currentCulture">The current culture for which to localize content.</param> /// <param name="coordinator">The central coordinator which manages content managers.</param> /// <param name="monitor">Encapsulates monitoring and logging.</param> /// <param name="reflection">Simplifies access to private code.</param> /// <param name="onDisposing">A callback to invoke when the content manager is being disposed.</param> - public GameContentManager(string name, IServiceProvider serviceProvider, string rootDirectory, CultureInfo currentCulture, ContentCoordinator coordinator, IMonitor monitor, Reflector reflection, Action<BaseContentManager> onDisposing) - : base(name, serviceProvider, rootDirectory, currentCulture, coordinator, monitor, reflection, onDisposing, isModFolder: false) + /// <param name="onLoadingFirstAsset">A callback to invoke the first time *any* game content manager loads an asset.</param> + public GameContentManager(string name, IServiceProvider serviceProvider, string rootDirectory, CultureInfo currentCulture, ContentCoordinator coordinator, IMonitor monitor, Reflector reflection, Action<BaseContentManager> onDisposing, Action onLoadingFirstAsset) + : base(name, serviceProvider, rootDirectory, currentCulture, coordinator, monitor, reflection, onDisposing, isNamespaced: false) { - this.IsLocalisableLookup = reflection.GetField<IDictionary<string, bool>>(this, "_localizedAsset").GetValue(); + this.IsLocalizableLookup = reflection.GetField<IDictionary<string, bool>>(this, "_localizedAsset").GetValue(); + this.OnLoadingFirstAsset = onLoadingFirstAsset; } /// <summary>Load an asset that has been processed by the content pipeline.</summary> /// <typeparam name="T">The type of asset to load.</typeparam> /// <param name="assetName">The asset path relative to the loader root directory, not including the <c>.xnb</c> extension.</param> /// <param name="language">The language code for which to load content.</param> - public override T Load<T>(string assetName, LanguageCode language) + /// <param name="useCache">Whether to read/write the loaded asset to the asset cache.</param> + public override T Load<T>(string assetName, LocalizedContentManager.LanguageCode language, bool useCache) { - // normalise asset name - assetName = this.AssertAndNormaliseAssetName(assetName); + // raise first-load callback + if (GameContentManager.IsFirstLoad) + { + GameContentManager.IsFirstLoad = false; + this.OnLoadingFirstAsset(); + } + + // normalize asset name + assetName = this.AssertAndNormalizeAssetName(assetName); if (this.TryParseExplicitLanguageAssetKey(assetName, out string newAssetName, out LanguageCode newLanguage)) - return this.Load<T>(newAssetName, newLanguage); + return this.Load<T>(newAssetName, newLanguage, useCache); // get from cache - if (this.IsLoaded(assetName)) - return base.Load<T>(assetName, language); + if (useCache && this.IsLoaded(assetName)) + return this.RawLoad<T>(assetName, language, useCache: true); // get managed asset if (this.Coordinator.TryParseManagedAssetKey(assetName, out string contentManagerID, out string relativePath)) { - T managedAsset = this.Coordinator.LoadAndCloneManagedAsset<T>(assetName, contentManagerID, relativePath, language); - this.Inject(assetName, managedAsset); + T managedAsset = this.Coordinator.LoadManagedAsset<T>(contentManagerID, relativePath); + if (useCache) + this.Inject(assetName, managedAsset, language); return managedAsset; } @@ -76,27 +94,50 @@ namespace StardewModdingAPI.Framework.ContentManagers { this.Monitor.Log($"Broke loop while loading asset '{assetName}'.", LogLevel.Warn); this.Monitor.Log($"Bypassing mod loaders for this asset. Stack trace:\n{Environment.StackTrace}", LogLevel.Trace); - data = base.Load<T>(assetName, language); + data = this.RawLoad<T>(assetName, language, useCache); } else { data = this.AssetsBeingLoaded.Track(assetName, () => { string locale = this.GetLocale(language); - IAssetInfo info = new AssetInfo(locale, assetName, typeof(T), this.AssertAndNormaliseAssetName); + IAssetInfo info = new AssetInfo(locale, assetName, typeof(T), this.AssertAndNormalizeAssetName); IAssetData asset = this.ApplyLoader<T>(info) - ?? new AssetDataForObject(info, base.Load<T>(assetName, language), this.AssertAndNormaliseAssetName); + ?? new AssetDataForObject(info, this.RawLoad<T>(assetName, language, useCache), this.AssertAndNormalizeAssetName); asset = this.ApplyEditors<T>(info, asset); return (T)asset.Data; }); } // update cache & return data - this.Inject(assetName, data); + this.Inject(assetName, data, language); return data; } + /// <summary>Perform any cleanup needed when the locale changes.</summary> + public override void OnLocaleChanged() + { + base.OnLocaleChanged(); + + // find assets for which a translatable version was loaded + HashSet<string> removeAssetNames = new HashSet<string>(StringComparer.InvariantCultureIgnoreCase); + foreach (string key in this.IsLocalizableLookup.Where(p => p.Value).Select(p => p.Key)) + removeAssetNames.Add(this.TryParseExplicitLanguageAssetKey(key, out string assetName, out _) ? assetName : key); + + // invalidate translatable assets + string[] invalidated = this + .InvalidateCache((key, type) => + removeAssetNames.Contains(key) + || (this.TryParseExplicitLanguageAssetKey(key, out string assetName, out _) && removeAssetNames.Contains(assetName)) + ) + .Select(p => p.Item1) + .OrderBy(p => p, StringComparer.InvariantCultureIgnoreCase) + .ToArray(); + if (invalidated.Any()) + this.Monitor.Log($"Invalidated {invalidated.Length} asset names: {string.Join(", ", invalidated)} for locale change.", LogLevel.Trace); + } + /// <summary>Create a new content manager for temporary use.</summary> public override LocalizedContentManager CreateTemporary() { @@ -108,30 +149,107 @@ namespace StardewModdingAPI.Framework.ContentManagers ** Private methods *********/ /// <summary>Get whether an asset has already been loaded.</summary> - /// <param name="normalisedAssetName">The normalised asset name.</param> - protected override bool IsNormalisedKeyLoaded(string normalisedAssetName) + /// <param name="normalizedAssetName">The normalized asset name.</param> + protected override bool IsNormalizedKeyLoaded(string normalizedAssetName) { // default English - if (this.Language == LocalizedContentManager.LanguageCode.en || this.Coordinator.IsManagedAssetKey(normalisedAssetName)) - return this.Cache.ContainsKey(normalisedAssetName); + if (this.Language == LocalizedContentManager.LanguageCode.en || this.Coordinator.IsManagedAssetKey(normalizedAssetName)) + return this.Cache.ContainsKey(normalizedAssetName); // translated - string localeKey = $"{normalisedAssetName}.{this.GetLocale(this.GetCurrentLanguage())}"; - if (this.IsLocalisableLookup.TryGetValue(localeKey, out bool localisable)) + string keyWithLocale = $"{normalizedAssetName}.{this.GetLocale(this.GetCurrentLanguage())}"; + if (this.IsLocalizableLookup.TryGetValue(keyWithLocale, out bool localizable)) { - return localisable - ? this.Cache.ContainsKey(localeKey) - : this.Cache.ContainsKey(normalisedAssetName); + return localizable + ? this.Cache.ContainsKey(keyWithLocale) + : this.Cache.ContainsKey(normalizedAssetName); } // not loaded yet return false; } + /// <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> + /// <param name="language">The language code for which to inject the asset.</param> + protected override void Inject<T>(string assetName, T value, LanguageCode language) + { + // handle explicit language in asset name + { + if (this.TryParseExplicitLanguageAssetKey(assetName, out string newAssetName, out LanguageCode newLanguage)) + { + this.Inject(newAssetName, value, newLanguage); + return; + } + } + + // save to cache + // Note: even if the asset was loaded and cached right before this method was called, + // we need to fully re-inject it here for two reasons: + // 1. So we can look up an asset by its base or localized key (the game/XNA logic + // only caches by the most specific key). + // 2. Because a mod asset loader/editor may have changed the asset in a way that + // doesn't change the instance stored in the cache, e.g. using `asset.ReplaceWith`. + string keyWithLocale = $"{assetName}.{this.GetLocale(language)}"; + base.Inject(assetName, value, language); + if (this.Cache.ContainsKey(keyWithLocale)) + base.Inject(keyWithLocale, value, language); + + // track whether the injected asset is translatable for is-loaded lookups + if (this.Cache.ContainsKey(keyWithLocale)) + { + this.IsLocalizableLookup[assetName] = true; + this.IsLocalizableLookup[keyWithLocale] = true; + } + else if (this.Cache.ContainsKey(assetName)) + { + this.IsLocalizableLookup[assetName] = false; + this.IsLocalizableLookup[keyWithLocale] = false; + } + else + this.Monitor.Log($"Asset '{assetName}' could not be found in the cache immediately after injection.", LogLevel.Error); + } + + /// <summary>Load an asset file directly from the underlying content manager.</summary> + /// <typeparam name="T">The type of asset to load.</typeparam> + /// <param name="assetName">The normalized asset key.</param> + /// <param name="language">The language code for which to load content.</param> + /// <param name="useCache">Whether to read/write the loaded asset to the asset cache.</param> + /// <remarks>Derived from <see cref="LocalizedContentManager.Load{T}(string, LocalizedContentManager.LanguageCode)"/>.</remarks> + private T RawLoad<T>(string assetName, LanguageCode language, bool useCache) + { + // try translated asset + if (language != LocalizedContentManager.LanguageCode.en) + { + string translatedKey = $"{assetName}.{this.GetLocale(language)}"; + if (!this.IsLocalizableLookup.TryGetValue(translatedKey, out bool isTranslatable) || isTranslatable) + { + try + { + T obj = base.RawLoad<T>(translatedKey, useCache); + this.IsLocalizableLookup[assetName] = true; + this.IsLocalizableLookup[translatedKey] = true; + return obj; + } + catch (ContentLoadException) + { + this.IsLocalizableLookup[assetName] = false; + this.IsLocalizableLookup[translatedKey] = false; + } + } + } + + // try base asset + return base.RawLoad<T>(assetName, useCache); + } + /// <summary>Parse an asset key that contains an explicit language into its asset name and language, if applicable.</summary> /// <param name="rawAsset">The asset key to parse.</param> /// <param name="assetName">The asset name without the language code.</param> /// <param name="language">The language code removed from the asset name.</param> + /// <returns>Returns whether the asset key contains an explicit language and was successfully parsed.</returns> private bool TryParseExplicitLanguageAssetKey(string rawAsset, out string assetName, out LanguageCode language) { if (string.IsNullOrWhiteSpace(rawAsset)) @@ -188,7 +306,7 @@ namespace StardewModdingAPI.Framework.ContentManagers T data; try { - data = this.CloneIfPossible(loader.Load<T>(info)); + data = loader.Load<T>(info); this.Monitor.Log($"{mod.DisplayName} loaded asset '{info.AssetName}'.", LogLevel.Trace); } catch (Exception ex) @@ -205,7 +323,7 @@ namespace StardewModdingAPI.Framework.ContentManagers } // return matched asset - return new AssetDataForObject(info, data, this.AssertAndNormaliseAssetName); + return new AssetDataForObject(info, data, this.AssertAndNormalizeAssetName); } /// <summary>Apply any <see cref="Editors"/> to a loaded asset.</summary> @@ -214,7 +332,7 @@ namespace StardewModdingAPI.Framework.ContentManagers /// <param name="asset">The loaded asset.</param> private IAssetData ApplyEditors<T>(IAssetInfo info, IAssetData asset) { - IAssetData GetNewData(object data) => new AssetDataForObject(info, data, this.AssertAndNormaliseAssetName); + IAssetData GetNewData(object data) => new AssetDataForObject(info, data, this.AssertAndNormalizeAssetName); // edit asset foreach (var entry in this.GetInterceptors(this.Editors)) diff --git a/src/SMAPI/Framework/ContentManagers/IContentManager.cs b/src/SMAPI/Framework/ContentManagers/IContentManager.cs index 17618edd..12c01352 100644 --- a/src/SMAPI/Framework/ContentManagers/IContentManager.cs +++ b/src/SMAPI/Framework/ContentManagers/IContentManager.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; using System.Diagnostics.Contracts; using Microsoft.Xna.Framework.Content; using StardewModdingAPI.Framework.Exceptions; @@ -23,8 +22,8 @@ namespace StardewModdingAPI.Framework.ContentManagers /// <summary>The absolute path to the <see cref="ContentManager.RootDirectory"/>.</summary> string FullRootDirectory { get; } - /// <summary>Whether this content manager is for a mod folder.</summary> - bool IsModContentManager { get; } + /// <summary>Whether this content manager can be targeted by managed asset keys (e.g. to load assets from a mod folder).</summary> + bool IsNamespaced { get; } /********* @@ -33,35 +32,22 @@ namespace StardewModdingAPI.Framework.ContentManagers /// <summary>Load an asset that has been processed by the content pipeline.</summary> /// <typeparam name="T">The type of asset to load.</typeparam> /// <param name="assetName">The asset path relative to the loader root directory, not including the <c>.xnb</c> extension.</param> - T Load<T>(string assetName); - - /// <summary>Load an asset that has been processed by the content pipeline.</summary> - /// <typeparam name="T">The type of asset to load.</typeparam> - /// <param name="assetName">The asset path relative to the loader root directory, not including the <c>.xnb</c> extension.</param> /// <param name="language">The language code for which to load content.</param> - T Load<T>(string assetName, LocalizedContentManager.LanguageCode language); - - /// <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> - void Inject<T>(string assetName, T value); + /// <param name="useCache">Whether to read/write the loaded asset to the asset cache.</param> + T Load<T>(string assetName, LocalizedContentManager.LanguageCode language, bool useCache); - /// <summary>Get a copy of the given asset if supported.</summary> - /// <typeparam name="T">The asset type.</typeparam> - /// <param name="asset">The asset to clone.</param> - T CloneIfPossible<T>(T asset); + /// <summary>Perform any cleanup needed when the locale changes.</summary> + void OnLocaleChanged(); - /// <summary>Normalise path separators in a file path. For asset keys, see <see cref="AssertAndNormaliseAssetName"/> instead.</summary> - /// <param name="path">The file path to normalise.</param> + /// <summary>Normalize path separators in a file path. For asset keys, see <see cref="AssertAndNormalizeAssetName"/> instead.</summary> + /// <param name="path">The file path to normalize.</param> [Pure] - string NormalisePathSeparators(string path); + string NormalizePathSeparators(string path); - /// <summary>Assert that the given key has a valid format and return a normalised form consistent with the underlying cache.</summary> + /// <summary>Assert that the given key has a valid format and return a normalized form consistent with the underlying cache.</summary> /// <param name="assetName">The asset key to check.</param> /// <exception cref="SContentLoadException">The asset key is empty or contains invalid characters.</exception> - [SuppressMessage("ReSharper", "ParameterOnlyUsedForPreconditionCheck.Local", Justification = "Parameter is only used for assertion checks by design.")] - string AssertAndNormaliseAssetName(string assetName); + string AssertAndNormalizeAssetName(string assetName); /// <summary>Get the current content locale.</summary> string GetLocale(); diff --git a/src/SMAPI/Framework/ContentManagers/ModContentManager.cs b/src/SMAPI/Framework/ContentManagers/ModContentManager.cs index 2c50ec04..90b86179 100644 --- a/src/SMAPI/Framework/ContentManagers/ModContentManager.cs +++ b/src/SMAPI/Framework/ContentManagers/ModContentManager.cs @@ -1,12 +1,19 @@ using System; using System.Globalization; using System.IO; +using System.Linq; using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Content; using Microsoft.Xna.Framework.Graphics; using StardewModdingAPI.Framework.Exceptions; using StardewModdingAPI.Framework.Reflection; -using StardewModdingAPI.Toolkit.Serialisation; +using StardewModdingAPI.Toolkit.Serialization; +using StardewModdingAPI.Toolkit.Utilities; using StardewValley; +using xTile; +using xTile.Format; +using xTile.ObjectModel; +using xTile.Tiles; namespace StardewModdingAPI.Framework.ContentManagers { @@ -19,84 +26,89 @@ namespace StardewModdingAPI.Framework.ContentManagers /// <summary>Encapsulates SMAPI's JSON file parsing.</summary> private readonly JsonHelper JsonHelper; + /// <summary>The game content manager used for map tilesheets not provided by the mod.</summary> + private readonly IContentManager GameContentManager; + + /// <summary>The language code for language-agnostic mod assets.</summary> + private readonly LanguageCode DefaultLanguage = Constants.DefaultLanguage; + /********* ** Public methods *********/ /// <summary>Construct an instance.</summary> /// <param name="name">A name for the mod manager. Not guaranteed to be unique.</param> + /// <param name="gameContentManager">The game content manager used for map tilesheets not provided by the mod.</param> /// <param name="serviceProvider">The service provider to use to locate services.</param> /// <param name="rootDirectory">The root directory to search for content.</param> - /// <param name="currentCulture">The current culture for which to localise content.</param> + /// <param name="currentCulture">The current culture for which to localize content.</param> /// <param name="coordinator">The central coordinator which manages content managers.</param> /// <param name="monitor">Encapsulates monitoring and logging.</param> /// <param name="reflection">Simplifies access to private code.</param> /// <param name="jsonHelper">Encapsulates SMAPI's JSON file parsing.</param> /// <param name="onDisposing">A callback to invoke when the content manager is being disposed.</param> - public ModContentManager(string name, IServiceProvider serviceProvider, string rootDirectory, CultureInfo currentCulture, ContentCoordinator coordinator, IMonitor monitor, Reflector reflection, JsonHelper jsonHelper, Action<BaseContentManager> onDisposing) - : base(name, serviceProvider, rootDirectory, currentCulture, coordinator, monitor, reflection, onDisposing, isModFolder: true) + public ModContentManager(string name, IContentManager gameContentManager, IServiceProvider serviceProvider, string rootDirectory, CultureInfo currentCulture, ContentCoordinator coordinator, IMonitor monitor, Reflector reflection, JsonHelper jsonHelper, Action<BaseContentManager> onDisposing) + : base(name, serviceProvider, rootDirectory, currentCulture, coordinator, monitor, reflection, onDisposing, isNamespaced: true) { + this.GameContentManager = gameContentManager; this.JsonHelper = jsonHelper; } /// <summary>Load an asset that has been processed by the content pipeline.</summary> /// <typeparam name="T">The type of asset to load.</typeparam> /// <param name="assetName">The asset path relative to the loader root directory, not including the <c>.xnb</c> extension.</param> + public override T Load<T>(string assetName) + { + return this.Load<T>(assetName, this.DefaultLanguage, useCache: false); + } + + /// <summary>Load an asset that has been processed by the content pipeline.</summary> + /// <typeparam name="T">The type of asset to load.</typeparam> + /// <param name="assetName">The asset path relative to the loader root directory, not including the <c>.xnb</c> extension.</param> /// <param name="language">The language code for which to load content.</param> public override T Load<T>(string assetName, LanguageCode language) { - assetName = this.AssertAndNormaliseAssetName(assetName); + return this.Load<T>(assetName, language, useCache: false); + } + + /// <summary>Load an asset that has been processed by the content pipeline.</summary> + /// <typeparam name="T">The type of asset to load.</typeparam> + /// <param name="assetName">The asset path relative to the loader root directory, not including the <c>.xnb</c> extension.</param> + /// <param name="language">The language code for which to load content.</param> + /// <param name="useCache">Whether to read/write the loaded asset to the asset cache.</param> + public override T Load<T>(string assetName, LanguageCode language, bool useCache) + { + assetName = this.AssertAndNormalizeAssetName(assetName); - // get from cache - if (this.IsLoaded(assetName)) - return base.Load<T>(assetName, language); + // disable caching + // This is necessary to avoid assets being shared between content managers, which can + // cause changes to an asset through one content manager affecting the same asset in + // others (or even fresh content managers). See https://www.patreon.com/posts/27247161 + // for more background info. + if (useCache) + throw new InvalidOperationException("Mod content managers don't support asset caching."); - // get managed asset - if (this.Coordinator.TryParseManagedAssetKey(assetName, out string contentManagerID, out string relativePath)) + // disable language handling + // Mod files don't support automatic translation logic, so this should never happen. + if (language != this.DefaultLanguage) + throw new InvalidOperationException("Localized assets aren't supported by the mod content manager."); + + // resolve managed asset key { - if (contentManagerID != this.Name) + if (this.Coordinator.TryParseManagedAssetKey(assetName, out string contentManagerID, out string relativePath)) { - T data = this.Coordinator.LoadAndCloneManagedAsset<T>(assetName, contentManagerID, relativePath, language); - this.Inject(assetName, data); - return data; + if (contentManagerID != this.Name) + throw new SContentLoadException($"Can't load managed asset key '{assetName}' through content manager '{this.Name}' for a different mod."); + assetName = relativePath; } - - return this.LoadManagedAsset<T>(assetName, contentManagerID, relativePath, language); } - throw new NotSupportedException("Can't load content folder asset from a mod content manager."); - } - - /// <summary>Create a new content manager for temporary use.</summary> - public override LocalizedContentManager CreateTemporary() - { - throw new NotSupportedException("Can't create a temporary mod content manager."); - } - - - /********* - ** Private methods - *********/ - /// <summary>Get whether an asset has already been loaded.</summary> - /// <param name="normalisedAssetName">The normalised asset name.</param> - protected override bool IsNormalisedKeyLoaded(string normalisedAssetName) - { - return this.Cache.ContainsKey(normalisedAssetName); - } - - /// <summary>Load a managed mod asset.</summary> - /// <typeparam name="T">The type of asset to load.</typeparam> - /// <param name="internalKey">The internal asset key.</param> - /// <param name="contentManagerID">The unique name for the content manager which should load this asset.</param> - /// <param name="relativePath">The relative path within the mod folder.</param> - /// <param name="language">The language code for which to load content.</param> - private T LoadManagedAsset<T>(string internalKey, string contentManagerID, string relativePath, LanguageCode language) - { - SContentLoadException GetContentError(string reasonPhrase) => new SContentLoadException($"Failed loading asset '{relativePath}' from {contentManagerID}: {reasonPhrase}"); + // get local asset + SContentLoadException GetContentError(string reasonPhrase) => new SContentLoadException($"Failed loading asset '{assetName}' from {this.Name}: {reasonPhrase}"); try { // get file - FileInfo file = this.GetModFile(relativePath); + FileInfo file = this.GetModFile(assetName); if (!file.Exists) throw GetContentError("the specified path doesn't exist."); @@ -105,35 +117,54 @@ namespace StardewModdingAPI.Framework.ContentManagers { // XNB file case ".xnb": - return base.Load<T>(relativePath, language); + { + T data = this.RawLoad<T>(assetName, useCache: false); + if (data is Map map) + { + this.NormalizeTilesheetPaths(map); + this.FixCustomTilesheetPaths(map, relativeMapPath: assetName); + } + return data; + } // unpacked data case ".json": { if (!this.JsonHelper.ReadJsonFileIfExists(file.FullName, out T data)) throw GetContentError("the JSON file is invalid."); // should never happen since we check for file existence above - return data; } // unpacked image case ".png": - // validate - if (typeof(T) != typeof(Texture2D)) - throw GetContentError($"can't read file with extension '{file.Extension}' as type '{typeof(T)}'; must be type '{typeof(Texture2D)}'."); - - // fetch & cache - using (FileStream stream = File.OpenRead(file.FullName)) { - Texture2D texture = Texture2D.FromStream(Game1.graphics.GraphicsDevice, stream); - texture = this.PremultiplyTransparency(texture); - this.Inject(internalKey, texture); - return (T)(object)texture; + // validate + if (typeof(T) != typeof(Texture2D)) + throw GetContentError($"can't read file with extension '{file.Extension}' as type '{typeof(T)}'; must be type '{typeof(Texture2D)}'."); + + // fetch & cache + using (FileStream stream = File.OpenRead(file.FullName)) + { + Texture2D texture = Texture2D.FromStream(Game1.graphics.GraphicsDevice, stream); + texture = this.PremultiplyTransparency(texture); + return (T)(object)texture; + } } // unpacked map case ".tbin": - throw GetContentError($"can't read unpacked map file directly from the underlying content manager. It must be loaded through the mod's {typeof(IModHelper)}.{nameof(IModHelper.Content)} helper."); + { + // validate + if (typeof(T) != typeof(Map)) + throw GetContentError($"can't read file with extension '{file.Extension}' as type '{typeof(T)}'; must be type '{typeof(Map)}'."); + + // fetch & cache + FormatManager formatManager = FormatManager.Instance; + Map map = formatManager.LoadMap(file.FullName); + this.NormalizeTilesheetPaths(map); + this.FixCustomTilesheetPaths(map, relativeMapPath: assetName); + return (T)(object)map; + } default: throw GetContentError($"unknown file extension '{file.Extension}'; must be one of '.json', '.png', '.tbin', or '.xnb'."); @@ -143,10 +174,37 @@ namespace StardewModdingAPI.Framework.ContentManagers { if (ex.GetInnermostException() is DllNotFoundException dllEx && dllEx.Message == "libgdiplus.dylib") throw GetContentError("couldn't find libgdiplus, which is needed to load mod images. Make sure Mono is installed and you're running the game through the normal launcher."); - throw new SContentLoadException($"The content manager failed loading content asset '{relativePath}' from {contentManagerID}.", ex); + throw new SContentLoadException($"The content manager failed loading content asset '{assetName}' from {this.Name}.", ex); } } + /// <summary>Create a new content manager for temporary use.</summary> + public override LocalizedContentManager CreateTemporary() + { + throw new NotSupportedException("Can't create a temporary mod content manager."); + } + + /// <summary>Get the underlying key in the game's content cache for an asset. This does not validate whether the asset exists.</summary> + /// <param name="key">The local path to a content file relative to the mod folder.</param> + /// <exception cref="ArgumentException">The <paramref name="key"/> is empty or contains invalid characters.</exception> + public string GetInternalAssetKey(string key) + { + FileInfo file = this.GetModFile(key); + string relativePath = PathUtilities.GetRelativePath(this.RootDirectory, file.FullName); + return Path.Combine(this.Name, relativePath); + } + + + /********* + ** Private methods + *********/ + /// <summary>Get whether an asset has already been loaded.</summary> + /// <param name="normalizedAssetName">The normalized asset name.</param> + protected override bool IsNormalizedKeyLoaded(string normalizedAssetName) + { + return this.Cache.ContainsKey(normalizedAssetName); + } + /// <summary>Get a file from the mod folder.</summary> /// <param name="path">The asset path relative to the content folder.</param> private FileInfo GetModFile(string path) @@ -182,9 +240,161 @@ namespace StardewModdingAPI.Framework.ContentManagers Color[] data = new Color[texture.Width * texture.Height]; texture.GetData(data); for (int i = 0; i < data.Length; i++) + { + if (data[i].A == 0) + continue; // no need to change fully transparent pixels + data[i] = Color.FromNonPremultiplied(data[i].ToVector4()); + } + texture.SetData(data); return texture; } + + /// <summary>Normalize map tilesheet paths for the current platform.</summary> + /// <param name="map">The map whose tilesheets to fix.</param> + private void NormalizeTilesheetPaths(Map map) + { + foreach (TileSheet tilesheet in map.TileSheets) + tilesheet.ImageSource = this.NormalizePathSeparators(tilesheet.ImageSource); + } + + /// <summary>Fix custom map tilesheet paths so they can be found by the content manager.</summary> + /// <param name="map">The map whose tilesheets to fix.</param> + /// <param name="relativeMapPath">The relative map path within the mod folder.</param> + /// <exception cref="ContentLoadException">A map tilesheet couldn't be resolved.</exception> + /// <remarks> + /// The game's logic for tilesheets in <see cref="Game1.setGraphicsForSeason"/> is a bit specialized. It boils + /// down to this: + /// * If the location is indoors or the desert, or the image source contains 'path' or 'object', it's loaded + /// as-is relative to the <c>Content</c> folder. + /// * Else it's loaded from <c>Content\Maps</c> with a seasonal prefix. + /// + /// That logic doesn't work well in our case, mainly because we have no location metadata at this point. + /// Instead we use a more heuristic approach: check relative to the map file first, then relative to + /// <c>Content\Maps</c>, then <c>Content</c>. If the image source filename contains a seasonal prefix, try for a + /// seasonal variation and then an exact match. + /// + /// While that doesn't exactly match the game logic, it's close enough that it's unlikely to make a difference. + /// </remarks> + private void FixCustomTilesheetPaths(Map map, string relativeMapPath) + { + // get map info + if (!map.TileSheets.Any()) + return; + relativeMapPath = this.AssertAndNormalizeAssetName(relativeMapPath); // Mono's Path.GetDirectoryName doesn't handle Windows dir separators + string relativeMapFolder = Path.GetDirectoryName(relativeMapPath) ?? ""; // folder path containing the map, relative to the mod folder + bool isOutdoors = map.Properties.TryGetValue("Outdoors", out PropertyValue outdoorsProperty) && outdoorsProperty != null; + + // fix tilesheets + foreach (TileSheet tilesheet in map.TileSheets) + { + string imageSource = tilesheet.ImageSource; + + // validate tilesheet path + if (Path.IsPathRooted(imageSource) || PathUtilities.GetSegments(imageSource).Contains("..")) + throw new ContentLoadException($"The '{imageSource}' tilesheet couldn't be loaded. Tilesheet paths must be a relative path without directory climbing (../)."); + + // get seasonal name (if applicable) + string seasonalImageSource = null; + if (isOutdoors && Context.IsSaveLoaded && Game1.currentSeason != null) + { + string filename = Path.GetFileName(imageSource) ?? throw new InvalidOperationException($"The '{imageSource}' tilesheet couldn't be loaded: filename is unexpectedly null."); + bool hasSeasonalPrefix = + filename.StartsWith("spring_", StringComparison.CurrentCultureIgnoreCase) + || filename.StartsWith("summer_", StringComparison.CurrentCultureIgnoreCase) + || filename.StartsWith("fall_", StringComparison.CurrentCultureIgnoreCase) + || filename.StartsWith("winter_", StringComparison.CurrentCultureIgnoreCase); + if (hasSeasonalPrefix && !filename.StartsWith(Game1.currentSeason + "_")) + { + string dirPath = imageSource.Substring(0, imageSource.LastIndexOf(filename, StringComparison.CurrentCultureIgnoreCase)); + seasonalImageSource = $"{dirPath}{Game1.currentSeason}_{filename.Substring(filename.IndexOf("_", StringComparison.CurrentCultureIgnoreCase) + 1)}"; + } + } + + // load best match + try + { + string key = + this.GetTilesheetAssetName(relativeMapFolder, seasonalImageSource) + ?? this.GetTilesheetAssetName(relativeMapFolder, imageSource); + if (key != null) + { + tilesheet.ImageSource = key; + continue; + } + } + catch (Exception ex) + { + throw new ContentLoadException($"The '{imageSource}' tilesheet couldn't be loaded relative to either map file or the game's content folder.", ex); + } + + // none found + throw new ContentLoadException($"The '{imageSource}' tilesheet couldn't be loaded relative to either map file or the game's content folder."); + } + } + + /// <summary>Get the actual asset name for a tilesheet.</summary> + /// <param name="modRelativeMapFolder">The folder path containing the map, relative to the mod folder.</param> + /// <param name="imageSource">The tilesheet image source to load.</param> + /// <returns>Returns the asset name.</returns> + /// <remarks>See remarks on <see cref="FixCustomTilesheetPaths"/>.</remarks> + private string GetTilesheetAssetName(string modRelativeMapFolder, string imageSource) + { + if (imageSource == null) + return null; + + // check relative to map file + { + string localKey = Path.Combine(modRelativeMapFolder, imageSource); + FileInfo localFile = this.GetModFile(localKey); + if (localFile.Exists) + return this.GetInternalAssetKey(localKey); + } + + // check relative to content folder + { + foreach (string candidateKey in new[] { imageSource, Path.Combine("Maps", imageSource) }) + { + string contentKey = candidateKey.EndsWith(".png") + ? candidateKey.Substring(0, candidateKey.Length - 4) + : candidateKey; + + try + { + this.GameContentManager.Load<Texture2D>(contentKey, this.Language, useCache: true); // no need to bypass cache here, since we're not storing the asset + return contentKey; + } + catch + { + // ignore file-not-found errors + // TODO: while it's useful to suppress an asset-not-found error here to avoid + // confusion, this is a pretty naive approach. Even if the file doesn't exist, + // the file may have been loaded through an IAssetLoader which failed. So even + // if the content file doesn't exist, that doesn't mean the error here is a + // content-not-found error. Unfortunately XNA doesn't provide a good way to + // detect the error type. + if (this.GetContentFolderFileExists(contentKey)) + throw; + } + } + } + + // not found + return null; + } + + /// <summary>Get whether a file from the game's content folder exists.</summary> + /// <param name="key">The asset key.</param> + private bool GetContentFolderFileExists(string key) + { + // get file path + string path = Path.Combine(this.GameContentManager.FullRootDirectory, key); + if (!path.EndsWith(".xnb")) + path += ".xnb"; + + // get file + return new FileInfo(path).Exists; + } } } diff --git a/src/SMAPI/Framework/ContentPack.cs b/src/SMAPI/Framework/ContentPack.cs index e39d03a1..9c0bb9d1 100644 --- a/src/SMAPI/Framework/ContentPack.cs +++ b/src/SMAPI/Framework/ContentPack.cs @@ -2,7 +2,7 @@ using System; using System.IO; using Microsoft.Xna.Framework.Content; using Microsoft.Xna.Framework.Graphics; -using StardewModdingAPI.Toolkit.Serialisation; +using StardewModdingAPI.Toolkit.Serialization; using StardewModdingAPI.Toolkit.Utilities; using xTile; @@ -30,6 +30,9 @@ namespace StardewModdingAPI.Framework /// <summary>The content pack's manifest.</summary> public IManifest Manifest { get; } + /// <summary>Provides translations stored in the content pack's <c>i18n</c> folder. See <see cref="IModHelper.Translation"/> for more info.</summary> + public ITranslationHelper Translation { get; } + /********* ** Public methods @@ -38,26 +41,36 @@ namespace StardewModdingAPI.Framework /// <param name="directoryPath">The full path to the content pack's folder.</param> /// <param name="manifest">The content pack's manifest.</param> /// <param name="content">Provides an API for loading content assets.</param> + /// <param name="translation">Provides translations stored in the content pack's <c>i18n</c> folder.</param> /// <param name="jsonHelper">Encapsulates SMAPI's JSON file parsing.</param> - public ContentPack(string directoryPath, IManifest manifest, IContentHelper content, JsonHelper jsonHelper) + public ContentPack(string directoryPath, IManifest manifest, IContentHelper content, ITranslationHelper translation, JsonHelper jsonHelper) { this.DirectoryPath = directoryPath; this.Manifest = manifest; this.Content = content; + this.Translation = translation; this.JsonHelper = jsonHelper; } + /// <summary>Get whether a given file exists in the content pack.</summary> + /// <param name="path">The file path to check.</param> + public bool HasFile(string path) + { + this.AssertRelativePath(path, nameof(this.HasFile)); + + return File.Exists(Path.Combine(this.DirectoryPath, path)); + } + /// <summary>Read a JSON file from the content pack folder.</summary> /// <typeparam name="TModel">The model type.</typeparam> - /// <param name="path">The file path relative to the contnet directory.</param> - /// <returns>Returns the deserialised model, or <c>null</c> if the file doesn't exist or is empty.</returns> + /// <param name="path">The file path relative to the content directory.</param> + /// <returns>Returns the deserialized model, or <c>null</c> if the file doesn't exist or is empty.</returns> /// <exception cref="InvalidOperationException">The <paramref name="path"/> is not relative or contains directory climbing (../).</exception> public TModel ReadJsonFile<TModel>(string path) where TModel : class { - if (!PathUtilities.IsSafeRelativePath(path)) - throw new InvalidOperationException($"You must call {nameof(IContentPack)}.{nameof(this.ReadJsonFile)} with a relative path."); + this.AssertRelativePath(path, nameof(this.ReadJsonFile)); - path = Path.Combine(this.DirectoryPath, PathUtilities.NormalisePathSeparators(path)); + path = Path.Combine(this.DirectoryPath, PathUtilities.NormalizePathSeparators(path)); return this.JsonHelper.ReadJsonFileIfExists(path, out TModel model) ? model : null; @@ -70,10 +83,9 @@ namespace StardewModdingAPI.Framework /// <exception cref="InvalidOperationException">The <paramref name="path"/> is not relative or contains directory climbing (../).</exception> public void WriteJsonFile<TModel>(string path, TModel data) where TModel : class { - if (!PathUtilities.IsSafeRelativePath(path)) - throw new InvalidOperationException($"You must call {nameof(IContentPack)}.{nameof(this.WriteJsonFile)} with a relative path."); + this.AssertRelativePath(path, nameof(this.WriteJsonFile)); - path = Path.Combine(this.DirectoryPath, PathUtilities.NormalisePathSeparators(path)); + path = Path.Combine(this.DirectoryPath, PathUtilities.NormalizePathSeparators(path)); this.JsonHelper.WriteJsonFile(path, data); } @@ -95,5 +107,17 @@ namespace StardewModdingAPI.Framework return this.Content.GetActualAssetKey(key, ContentSource.ModFolder); } + + /********* + ** Private methods + *********/ + /// <summary>Assert that a relative path was passed it to a content pack method.</summary> + /// <param name="path">The path to check.</param> + /// <param name="methodName">The name of the method which was invoked.</param> + private void AssertRelativePath(string path, string methodName) + { + if (!PathUtilities.IsSafeRelativePath(path)) + throw new InvalidOperationException($"You must call {nameof(IContentPack)}.{methodName} with a relative path."); + } } } diff --git a/src/SMAPI/Framework/CursorPosition.cs b/src/SMAPI/Framework/CursorPosition.cs index 079917f2..2008ccce 100644 --- a/src/SMAPI/Framework/CursorPosition.cs +++ b/src/SMAPI/Framework/CursorPosition.cs @@ -8,10 +8,10 @@ namespace StardewModdingAPI.Framework /********* ** Accessors *********/ - /// <summary>The pixel position relative to the top-left corner of the in-game map.</summary> + /// <summary>The pixel position relative to the top-left corner of the in-game map, adjusted for pixel zoom.</summary> public Vector2 AbsolutePixels { get; } - /// <summary>The pixel position relative to the top-left corner of the visible screen.</summary> + /// <summary>The pixel position relative to the top-left corner of the visible screen, adjusted for pixel zoom.</summary> public Vector2 ScreenPixels { get; } /// <summary>The tile position under the cursor relative to the top-left corner of the map.</summary> @@ -25,8 +25,8 @@ namespace StardewModdingAPI.Framework ** Public methods *********/ /// <summary>Construct an instance.</summary> - /// <param name="absolutePixels">The pixel position relative to the top-left corner of the in-game map.</param> - /// <param name="screenPixels">The pixel position relative to the top-left corner of the visible screen.</param> + /// <param name="absolutePixels">The pixel position relative to the top-left corner of the in-game map, adjusted for pixel zoom.</param> + /// <param name="screenPixels">The pixel position relative to the top-left corner of the visible screen, adjusted for pixel zoom.</param> /// <param name="tile">The tile position relative to the top-left corner of the map.</param> /// <param name="grabTile">The tile position that the game considers under the cursor for purposes of clicking actions.</param> public CursorPosition(Vector2 absolutePixels, Vector2 screenPixels, Vector2 tile, Vector2 grabTile) diff --git a/src/SMAPI/Framework/DeprecationManager.cs b/src/SMAPI/Framework/DeprecationManager.cs index 984bb487..636b1979 100644 --- a/src/SMAPI/Framework/DeprecationManager.cs +++ b/src/SMAPI/Framework/DeprecationManager.cs @@ -14,11 +14,7 @@ namespace StardewModdingAPI.Framework private readonly HashSet<string> LoggedDeprecations = new HashSet<string>(StringComparer.InvariantCultureIgnoreCase); /// <summary>Encapsulates monitoring and logging for a given module.</summary> -#if !SMAPI_3_0_STRICT - private readonly Monitor Monitor; -#else private readonly IMonitor Monitor; -#endif /// <summary>Tracks the installed mods.</summary> private readonly ModRegistry ModRegistry; @@ -26,11 +22,6 @@ namespace StardewModdingAPI.Framework /// <summary>The queued deprecation warnings to display.</summary> private readonly IList<DeprecationWarning> QueuedWarnings = new List<DeprecationWarning>(); -#if !SMAPI_3_0_STRICT - /// <summary>Whether the one-time deprecation message has been shown.</summary> - private bool DeprecationHeaderShown = false; -#endif - /********* ** Public methods @@ -38,11 +29,7 @@ namespace StardewModdingAPI.Framework /// <summary>Construct an instance.</summary> /// <param name="monitor">Encapsulates monitoring and logging for a given module.</param> /// <param name="modRegistry">Tracks the installed mods.</param> -#if !SMAPI_3_0_STRICT - public DeprecationManager(Monitor monitor, ModRegistry modRegistry) -#else public DeprecationManager(IMonitor monitor, ModRegistry modRegistry) -#endif { this.Monitor = monitor; this.ModRegistry = modRegistry; @@ -81,26 +68,10 @@ namespace StardewModdingAPI.Framework /// <summary>Print any queued messages.</summary> public void PrintQueued() { -#if !SMAPI_3_0_STRICT - if (!this.DeprecationHeaderShown && this.QueuedWarnings.Any()) - { - this.Monitor.Newline(); - this.Monitor.Log("Some of your mods will break in the upcoming SMAPI 3.0. Please update your mods now, or notify the author if no update is available. See https://mods.smapi.io for links to the latest versions.", LogLevel.Warn); - this.Monitor.Newline(); - this.DeprecationHeaderShown = true; - } -#endif - foreach (DeprecationWarning warning in this.QueuedWarnings.OrderBy(p => p.ModName).ThenBy(p => p.NounPhrase)) { // build message -#if SMAPI_3_0_STRICT string message = $"{warning.ModName} uses deprecated code ({warning.NounPhrase} is deprecated since SMAPI {warning.Version})."; -#else - string message = warning.NounPhrase == "legacy events" - ? $"{warning.ModName ?? "An unknown mod"} will break in the upcoming SMAPI 3.0 (legacy events are deprecated since SMAPI {warning.Version})." - : $"{warning.ModName ?? "An unknown mod"} will break in the upcoming SMAPI 3.0 ({warning.NounPhrase} is deprecated since SMAPI {warning.Version})."; -#endif // get log level LogLevel level; diff --git a/src/SMAPI/Framework/Events/EventManager.cs b/src/SMAPI/Framework/Events/EventManager.cs index 13244601..18b00f69 100644 --- a/src/SMAPI/Framework/Events/EventManager.cs +++ b/src/SMAPI/Framework/Events/EventManager.cs @@ -1,7 +1,4 @@ using System.Diagnostics.CodeAnalysis; -#if !SMAPI_3_0_STRICT -using Microsoft.Xna.Framework.Input; -#endif using StardewModdingAPI.Events; namespace StardewModdingAPI.Framework.Events @@ -76,7 +73,7 @@ namespace StardewModdingAPI.Framework.Events /// <summary>Raised after the game finishes writing data to the save file (except the initial save creation).</summary> public readonly ManagedEvent<SavedEventArgs> Saved; - /// <summary>Raised after the player loads a save slot and the world is initialised.</summary> + /// <summary>Raised after the player loads a save slot and the world is initialized.</summary> public readonly ManagedEvent<SaveLoadedEventArgs> SaveLoaded; /// <summary>Raised after the game begins a new day, including when loading a save.</summary> @@ -155,208 +152,18 @@ namespace StardewModdingAPI.Framework.Events public readonly ManagedEvent<TerrainFeatureListChangedEventArgs> TerrainFeatureListChanged; /**** - ** Specialised + ** Specialized ****/ - /// <summary>Raised when the low-level stage in the game's loading process has changed. See notes on <see cref="ISpecialisedEvents.LoadStageChanged"/>.</summary> + /// <summary>Raised when the low-level stage in the game's loading process has changed. See notes on <see cref="ISpecializedEvents.LoadStageChanged"/>.</summary> public readonly ManagedEvent<LoadStageChangedEventArgs> LoadStageChanged; - /// <summary>Raised before the game performs its overall update tick (≈60 times per second). See notes on <see cref="ISpecialisedEvents.UnvalidatedUpdateTicking"/>.</summary> + /// <summary>Raised before the game performs its overall update tick (≈60 times per second). See notes on <see cref="ISpecializedEvents.UnvalidatedUpdateTicking"/>.</summary> public readonly ManagedEvent<UnvalidatedUpdateTickingEventArgs> UnvalidatedUpdateTicking; - /// <summary>Raised after the game performs its overall update tick (≈60 times per second). See notes on <see cref="ISpecialisedEvents.UnvalidatedUpdateTicked"/>.</summary> + /// <summary>Raised after the game performs its overall update tick (≈60 times per second). See notes on <see cref="ISpecializedEvents.UnvalidatedUpdateTicked"/>.</summary> public readonly ManagedEvent<UnvalidatedUpdateTickedEventArgs> UnvalidatedUpdateTicked; -#if !SMAPI_3_0_STRICT - /********* - ** Events (old) - *********/ - /**** - ** ContentEvents - ****/ - /// <summary>Raised after the content language changes.</summary> - public readonly ManagedEvent<EventArgsValueChanged<string>> Legacy_LocaleChanged; - - /**** - ** ControlEvents - ****/ - /// <summary>Raised when the <see cref="KeyboardState"/> changes. That happens when the player presses or releases a key.</summary> - public readonly ManagedEvent<EventArgsKeyboardStateChanged> Legacy_KeyboardChanged; - - /// <summary>Raised after the player presses a keyboard key.</summary> - public readonly ManagedEvent<EventArgsKeyPressed> Legacy_KeyPressed; - - /// <summary>Raised after the player releases a keyboard key.</summary> - public readonly ManagedEvent<EventArgsKeyPressed> Legacy_KeyReleased; - - /// <summary>Raised when the <see cref="MouseState"/> changes. That happens when the player moves the mouse, scrolls the mouse wheel, or presses/releases a button.</summary> - public readonly ManagedEvent<EventArgsMouseStateChanged> Legacy_MouseChanged; - - /// <summary>The player pressed a controller button. This event isn't raised for trigger buttons.</summary> - public readonly ManagedEvent<EventArgsControllerButtonPressed> Legacy_ControllerButtonPressed; - - /// <summary>The player released a controller button. This event isn't raised for trigger buttons.</summary> - public readonly ManagedEvent<EventArgsControllerButtonReleased> Legacy_ControllerButtonReleased; - - /// <summary>The player pressed a controller trigger button.</summary> - public readonly ManagedEvent<EventArgsControllerTriggerPressed> Legacy_ControllerTriggerPressed; - - /// <summary>The player released a controller trigger button.</summary> - public readonly ManagedEvent<EventArgsControllerTriggerReleased> Legacy_ControllerTriggerReleased; - - /**** - ** GameEvents - ****/ - /// <summary>Raised once after the game initialises and all <see cref="IMod.Entry"/> methods have been called.</summary> - public readonly ManagedEvent Legacy_FirstUpdateTick; - - /// <summary>Raised when the game updates its state (≈60 times per second).</summary> - public readonly ManagedEvent Legacy_UpdateTick; - - /// <summary>Raised every other tick (≈30 times per second).</summary> - public readonly ManagedEvent Legacy_SecondUpdateTick; - - /// <summary>Raised every fourth tick (≈15 times per second).</summary> - public readonly ManagedEvent Legacy_FourthUpdateTick; - - /// <summary>Raised every eighth tick (≈8 times per second).</summary> - public readonly ManagedEvent Legacy_EighthUpdateTick; - - /// <summary>Raised every 15th tick (≈4 times per second).</summary> - public readonly ManagedEvent Legacy_QuarterSecondTick; - - /// <summary>Raised every 30th tick (≈twice per second).</summary> - public readonly ManagedEvent Legacy_HalfSecondTick; - - /// <summary>Raised every 60th tick (≈once per second).</summary> - public readonly ManagedEvent Legacy_OneSecondTick; - - /**** - ** GraphicsEvents - ****/ - /// <summary>Raised after the game window is resized.</summary> - public readonly ManagedEvent Legacy_Resize; - - /// <summary>Raised before drawing the world to the screen.</summary> - public readonly ManagedEvent Legacy_OnPreRenderEvent; - - /// <summary>Raised after drawing the world to the screen.</summary> - public readonly ManagedEvent Legacy_OnPostRenderEvent; - - /// <summary>Raised before drawing the HUD (item toolbar, clock, etc) to the screen. The HUD is available at this point, but not necessarily visible. (For example, the event is raised even if a menu is open.)</summary> - public readonly ManagedEvent Legacy_OnPreRenderHudEvent; - - /// <summary>Raised after drawing the HUD (item toolbar, clock, etc) to the screen. The HUD is available at this point, but not necessarily visible. (For example, the event is raised even if a menu is open.)</summary> - public readonly ManagedEvent Legacy_OnPostRenderHudEvent; - - /// <summary>Raised before drawing a menu to the screen during a draw loop. This includes the game's internal menus like the title screen.</summary> - public readonly ManagedEvent Legacy_OnPreRenderGuiEvent; - - /// <summary>Raised after drawing a menu to the screen during a draw loop. This includes the game's internal menus like the title screen.</summary> - public readonly ManagedEvent Legacy_OnPostRenderGuiEvent; - - /**** - ** InputEvents - ****/ - /// <summary>Raised after the player presses a button on the keyboard, controller, or mouse.</summary> - public readonly ManagedEvent<EventArgsInput> Legacy_ButtonPressed; - - /// <summary>Raised after the player releases a keyboard key on the keyboard, controller, or mouse.</summary> - public readonly ManagedEvent<EventArgsInput> Legacy_ButtonReleased; - - /**** - ** LocationEvents - ****/ - /// <summary>Raised after a game location is added or removed.</summary> - public readonly ManagedEvent<EventArgsLocationsChanged> Legacy_LocationsChanged; - - /// <summary>Raised after buildings are added or removed in a location.</summary> - public readonly ManagedEvent<EventArgsLocationBuildingsChanged> Legacy_BuildingsChanged; - - /// <summary>Raised after objects are added or removed in a location.</summary> - public readonly ManagedEvent<EventArgsLocationObjectsChanged> Legacy_ObjectsChanged; - - /**** - ** MenuEvents - ****/ - /// <summary>Raised after a game menu is opened or replaced with another menu. This event is not invoked when a menu is closed.</summary> - public readonly ManagedEvent<EventArgsClickableMenuChanged> Legacy_MenuChanged; - - /// <summary>Raised after a game menu is closed.</summary> - public readonly ManagedEvent<EventArgsClickableMenuClosed> Legacy_MenuClosed; - - /**** - ** MultiplayerEvents - ****/ - /// <summary>Raised before the game syncs changes from other players.</summary> - public readonly ManagedEvent Legacy_BeforeMainSync; - - /// <summary>Raised after the game syncs changes from other players.</summary> - public readonly ManagedEvent Legacy_AfterMainSync; - - /// <summary>Raised before the game broadcasts changes to other players.</summary> - public readonly ManagedEvent Legacy_BeforeMainBroadcast; - - /// <summary>Raised after the game broadcasts changes to other players.</summary> - public readonly ManagedEvent Legacy_AfterMainBroadcast; - - /**** - ** MineEvents - ****/ - /// <summary>Raised after the player warps to a new level of the mine.</summary> - public readonly ManagedEvent<EventArgsMineLevelChanged> Legacy_MineLevelChanged; - - /**** - ** PlayerEvents - ****/ - /// <summary>Raised after the player's inventory changes in any way (added or removed item, sorted, etc).</summary> - public readonly ManagedEvent<EventArgsInventoryChanged> Legacy_InventoryChanged; - - /// <summary> Raised after the player levels up a skill. This happens as soon as they level up, not when the game notifies the player after their character goes to bed.</summary> - public readonly ManagedEvent<EventArgsLevelUp> Legacy_LeveledUp; - - /// <summary>Raised after the player warps to a new location.</summary> - public readonly ManagedEvent<EventArgsPlayerWarped> Legacy_PlayerWarped; - - - /**** - ** SaveEvents - ****/ - /// <summary>Raised before the game creates the save file.</summary> - public readonly ManagedEvent Legacy_BeforeCreateSave; - - /// <summary>Raised after the game finishes creating the save file.</summary> - public readonly ManagedEvent Legacy_AfterCreateSave; - - /// <summary>Raised before the game begins writes data to the save file.</summary> - public readonly ManagedEvent Legacy_BeforeSave; - - /// <summary>Raised after the game finishes writing data to the save file.</summary> - public readonly ManagedEvent Legacy_AfterSave; - - /// <summary>Raised after the player loads a save slot.</summary> - public readonly ManagedEvent Legacy_AfterLoad; - - /// <summary>Raised after the game returns to the title screen.</summary> - public readonly ManagedEvent Legacy_AfterReturnToTitle; - - /**** - ** SpecialisedEvents - ****/ - /// <summary>Raised when the game updates its state (≈60 times per second), regardless of normal SMAPI validation. This event is not thread-safe and may be invoked while game logic is running asynchronously. Changes to game state in this method may crash the game or corrupt an in-progress save. Do not use this event unless you're fully aware of the context in which your code will be run. Mods using this method will trigger a stability warning in the SMAPI console.</summary> - public readonly ManagedEvent Legacy_UnvalidatedUpdateTick; - - /**** - ** TimeEvents - ****/ - /// <summary>Raised after the game begins a new day, including when loading a save.</summary> - public readonly ManagedEvent Legacy_AfterDayStarted; - - /// <summary>Raised after the in-game clock changes.</summary> - public readonly ManagedEvent<EventArgsIntChanged> Legacy_TimeOfDayChanged; -#endif - - /********* ** Public methods *********/ @@ -365,11 +172,8 @@ namespace StardewModdingAPI.Framework.Events /// <param name="modRegistry">The mod registry with which to identify mods.</param> public EventManager(IMonitor monitor, ModRegistry modRegistry) { - // create shortcut initialisers + // create shortcut initializers ManagedEvent<TEventArgs> ManageEventOf<TEventArgs>(string typeName, string eventName) => new ManagedEvent<TEventArgs>($"{typeName}.{eventName}", monitor, modRegistry); -#if !SMAPI_3_0_STRICT - ManagedEvent ManageEvent(string typeName, string eventName) => new ManagedEvent($"{typeName}.{eventName}", monitor, modRegistry); -#endif // init events (new) this.MenuChanged = ManageEventOf<MenuChangedEventArgs>(nameof(IModEvents.Display), nameof(IDisplayEvents.MenuChanged)); @@ -419,73 +223,9 @@ namespace StardewModdingAPI.Framework.Events this.ObjectListChanged = ManageEventOf<ObjectListChangedEventArgs>(nameof(IModEvents.World), nameof(IWorldEvents.ObjectListChanged)); this.TerrainFeatureListChanged = ManageEventOf<TerrainFeatureListChangedEventArgs>(nameof(IModEvents.World), nameof(IWorldEvents.TerrainFeatureListChanged)); - this.LoadStageChanged = ManageEventOf<LoadStageChangedEventArgs>(nameof(IModEvents.Specialised), nameof(ISpecialisedEvents.LoadStageChanged)); - this.UnvalidatedUpdateTicking = ManageEventOf<UnvalidatedUpdateTickingEventArgs>(nameof(IModEvents.Specialised), nameof(ISpecialisedEvents.UnvalidatedUpdateTicking)); - this.UnvalidatedUpdateTicked = ManageEventOf<UnvalidatedUpdateTickedEventArgs>(nameof(IModEvents.Specialised), nameof(ISpecialisedEvents.UnvalidatedUpdateTicked)); - -#if !SMAPI_3_0_STRICT - // init events (old) - this.Legacy_LocaleChanged = ManageEventOf<EventArgsValueChanged<string>>(nameof(ContentEvents), nameof(ContentEvents.AfterLocaleChanged)); - - this.Legacy_ControllerButtonPressed = ManageEventOf<EventArgsControllerButtonPressed>(nameof(ControlEvents), nameof(ControlEvents.ControllerButtonPressed)); - this.Legacy_ControllerButtonReleased = ManageEventOf<EventArgsControllerButtonReleased>(nameof(ControlEvents), nameof(ControlEvents.ControllerButtonReleased)); - this.Legacy_ControllerTriggerPressed = ManageEventOf<EventArgsControllerTriggerPressed>(nameof(ControlEvents), nameof(ControlEvents.ControllerTriggerPressed)); - this.Legacy_ControllerTriggerReleased = ManageEventOf<EventArgsControllerTriggerReleased>(nameof(ControlEvents), nameof(ControlEvents.ControllerTriggerReleased)); - this.Legacy_KeyboardChanged = ManageEventOf<EventArgsKeyboardStateChanged>(nameof(ControlEvents), nameof(ControlEvents.KeyboardChanged)); - this.Legacy_KeyPressed = ManageEventOf<EventArgsKeyPressed>(nameof(ControlEvents), nameof(ControlEvents.KeyPressed)); - this.Legacy_KeyReleased = ManageEventOf<EventArgsKeyPressed>(nameof(ControlEvents), nameof(ControlEvents.KeyReleased)); - this.Legacy_MouseChanged = ManageEventOf<EventArgsMouseStateChanged>(nameof(ControlEvents), nameof(ControlEvents.MouseChanged)); - - this.Legacy_FirstUpdateTick = ManageEvent(nameof(GameEvents), nameof(GameEvents.FirstUpdateTick)); - this.Legacy_UpdateTick = ManageEvent(nameof(GameEvents), nameof(GameEvents.UpdateTick)); - this.Legacy_SecondUpdateTick = ManageEvent(nameof(GameEvents), nameof(GameEvents.SecondUpdateTick)); - this.Legacy_FourthUpdateTick = ManageEvent(nameof(GameEvents), nameof(GameEvents.FourthUpdateTick)); - this.Legacy_EighthUpdateTick = ManageEvent(nameof(GameEvents), nameof(GameEvents.EighthUpdateTick)); - this.Legacy_QuarterSecondTick = ManageEvent(nameof(GameEvents), nameof(GameEvents.QuarterSecondTick)); - this.Legacy_HalfSecondTick = ManageEvent(nameof(GameEvents), nameof(GameEvents.HalfSecondTick)); - this.Legacy_OneSecondTick = ManageEvent(nameof(GameEvents), nameof(GameEvents.OneSecondTick)); - - this.Legacy_Resize = ManageEvent(nameof(GraphicsEvents), nameof(GraphicsEvents.Resize)); - this.Legacy_OnPreRenderEvent = ManageEvent(nameof(GraphicsEvents), nameof(GraphicsEvents.OnPreRenderEvent)); - this.Legacy_OnPostRenderEvent = ManageEvent(nameof(GraphicsEvents), nameof(GraphicsEvents.OnPostRenderEvent)); - this.Legacy_OnPreRenderHudEvent = ManageEvent(nameof(GraphicsEvents), nameof(GraphicsEvents.OnPreRenderHudEvent)); - this.Legacy_OnPostRenderHudEvent = ManageEvent(nameof(GraphicsEvents), nameof(GraphicsEvents.OnPostRenderHudEvent)); - this.Legacy_OnPreRenderGuiEvent = ManageEvent(nameof(GraphicsEvents), nameof(GraphicsEvents.OnPreRenderGuiEvent)); - this.Legacy_OnPostRenderGuiEvent = ManageEvent(nameof(GraphicsEvents), nameof(GraphicsEvents.OnPostRenderGuiEvent)); - - this.Legacy_ButtonPressed = ManageEventOf<EventArgsInput>(nameof(InputEvents), nameof(InputEvents.ButtonPressed)); - this.Legacy_ButtonReleased = ManageEventOf<EventArgsInput>(nameof(InputEvents), nameof(InputEvents.ButtonReleased)); - - this.Legacy_LocationsChanged = ManageEventOf<EventArgsLocationsChanged>(nameof(LocationEvents), nameof(LocationEvents.LocationsChanged)); - this.Legacy_BuildingsChanged = ManageEventOf<EventArgsLocationBuildingsChanged>(nameof(LocationEvents), nameof(LocationEvents.BuildingsChanged)); - this.Legacy_ObjectsChanged = ManageEventOf<EventArgsLocationObjectsChanged>(nameof(LocationEvents), nameof(LocationEvents.ObjectsChanged)); - - this.Legacy_MenuChanged = ManageEventOf<EventArgsClickableMenuChanged>(nameof(MenuEvents), nameof(MenuEvents.MenuChanged)); - this.Legacy_MenuClosed = ManageEventOf<EventArgsClickableMenuClosed>(nameof(MenuEvents), nameof(MenuEvents.MenuClosed)); - - this.Legacy_BeforeMainBroadcast = ManageEvent(nameof(MultiplayerEvents), nameof(MultiplayerEvents.BeforeMainBroadcast)); - this.Legacy_AfterMainBroadcast = ManageEvent(nameof(MultiplayerEvents), nameof(MultiplayerEvents.AfterMainBroadcast)); - this.Legacy_BeforeMainSync = ManageEvent(nameof(MultiplayerEvents), nameof(MultiplayerEvents.BeforeMainSync)); - this.Legacy_AfterMainSync = ManageEvent(nameof(MultiplayerEvents), nameof(MultiplayerEvents.AfterMainSync)); - - this.Legacy_MineLevelChanged = ManageEventOf<EventArgsMineLevelChanged>(nameof(MineEvents), nameof(MineEvents.MineLevelChanged)); - - this.Legacy_InventoryChanged = ManageEventOf<EventArgsInventoryChanged>(nameof(PlayerEvents), nameof(PlayerEvents.InventoryChanged)); - this.Legacy_LeveledUp = ManageEventOf<EventArgsLevelUp>(nameof(PlayerEvents), nameof(PlayerEvents.LeveledUp)); - this.Legacy_PlayerWarped = ManageEventOf<EventArgsPlayerWarped>(nameof(PlayerEvents), nameof(PlayerEvents.Warped)); - - this.Legacy_BeforeCreateSave = ManageEvent(nameof(SaveEvents), nameof(SaveEvents.BeforeCreate)); - this.Legacy_AfterCreateSave = ManageEvent(nameof(SaveEvents), nameof(SaveEvents.AfterCreate)); - this.Legacy_BeforeSave = ManageEvent(nameof(SaveEvents), nameof(SaveEvents.BeforeSave)); - this.Legacy_AfterSave = ManageEvent(nameof(SaveEvents), nameof(SaveEvents.AfterSave)); - this.Legacy_AfterLoad = ManageEvent(nameof(SaveEvents), nameof(SaveEvents.AfterLoad)); - this.Legacy_AfterReturnToTitle = ManageEvent(nameof(SaveEvents), nameof(SaveEvents.AfterReturnToTitle)); - - this.Legacy_UnvalidatedUpdateTick = ManageEvent(nameof(SpecialisedEvents), nameof(SpecialisedEvents.UnvalidatedUpdateTick)); - - this.Legacy_AfterDayStarted = ManageEvent(nameof(TimeEvents), nameof(TimeEvents.AfterDayStarted)); - this.Legacy_TimeOfDayChanged = ManageEventOf<EventArgsIntChanged>(nameof(TimeEvents), nameof(TimeEvents.TimeOfDayChanged)); -#endif + this.LoadStageChanged = ManageEventOf<LoadStageChangedEventArgs>(nameof(IModEvents.Specialized), nameof(ISpecializedEvents.LoadStageChanged)); + this.UnvalidatedUpdateTicking = ManageEventOf<UnvalidatedUpdateTickingEventArgs>(nameof(IModEvents.Specialized), nameof(ISpecializedEvents.UnvalidatedUpdateTicking)); + this.UnvalidatedUpdateTicked = ManageEventOf<UnvalidatedUpdateTickedEventArgs>(nameof(IModEvents.Specialized), nameof(ISpecializedEvents.UnvalidatedUpdateTicked)); } } } diff --git a/src/SMAPI/Framework/Events/ManagedEvent.cs b/src/SMAPI/Framework/Events/ManagedEvent.cs index f9e7f6ec..2afe7a03 100644 --- a/src/SMAPI/Framework/Events/ManagedEvent.cs +++ b/src/SMAPI/Framework/Events/ManagedEvent.cs @@ -1,11 +1,12 @@ using System; +using System.Collections.Generic; using System.Linq; namespace StardewModdingAPI.Framework.Events { /// <summary>An event wrapper which intercepts and logs errors in handler code.</summary> /// <typeparam name="TEventArgs">The event arguments type.</typeparam> - internal class ManagedEvent<TEventArgs> : ManagedEventBase<EventHandler<TEventArgs>> + internal class ManagedEvent<TEventArgs> { /********* ** Fields @@ -13,6 +14,21 @@ namespace StardewModdingAPI.Framework.Events /// <summary>The underlying event.</summary> private event EventHandler<TEventArgs> Event; + /// <summary>A human-readable name for the event.</summary> + private readonly string EventName; + + /// <summary>Writes messages to the log.</summary> + private readonly IMonitor Monitor; + + /// <summary>The mod registry with which to identify mods.</summary> + protected readonly ModRegistry ModRegistry; + + /// <summary>The display names for the mods which added each delegate.</summary> + private readonly IDictionary<EventHandler<TEventArgs>, IModMetadata> SourceMods = new Dictionary<EventHandler<TEventArgs>, IModMetadata>(); + + /// <summary>The cached invocation list.</summary> + private EventHandler<TEventArgs>[] CachedInvocationList; + /********* ** Public methods @@ -22,7 +38,17 @@ namespace StardewModdingAPI.Framework.Events /// <param name="monitor">Writes messages to the log.</param> /// <param name="modRegistry">The mod registry with which to identify mods.</param> public ManagedEvent(string eventName, IMonitor monitor, ModRegistry modRegistry) - : base(eventName, monitor, modRegistry) { } + { + this.EventName = eventName; + this.Monitor = monitor; + this.ModRegistry = modRegistry; + } + + /// <summary>Get whether anything is listening to the event.</summary> + public bool HasListeners() + { + return this.CachedInvocationList?.Length > 0; + } /// <summary>Add an event handler.</summary> /// <param name="handler">The event handler.</param> @@ -91,71 +117,50 @@ namespace StardewModdingAPI.Framework.Events } } } - } - -#if !SMAPI_3_0_STRICT - /// <summary>An event wrapper which intercepts and logs errors in handler code.</summary> - internal class ManagedEvent : ManagedEventBase<EventHandler> - { - /********* - ** Fields - *********/ - /// <summary>The underlying event.</summary> - private event EventHandler Event; /********* - ** Public methods + ** Private methods *********/ - /// <summary>Construct an instance.</summary> - /// <param name="eventName">A human-readable name for the event.</param> - /// <param name="monitor">Writes messages to the log.</param> - /// <param name="modRegistry">The mod registry with which to identify mods.</param> - public ManagedEvent(string eventName, IMonitor monitor, ModRegistry modRegistry) - : base(eventName, monitor, modRegistry) { } - - /// <summary>Add an event handler.</summary> + /// <summary>Track an event handler.</summary> + /// <param name="mod">The mod which added the handler.</param> /// <param name="handler">The event handler.</param> - public void Add(EventHandler handler) + /// <param name="invocationList">The updated event invocation list.</param> + protected void AddTracking(IModMetadata mod, EventHandler<TEventArgs> handler, IEnumerable<EventHandler<TEventArgs>> invocationList) { - this.Add(handler, this.ModRegistry.GetFromStack()); + this.SourceMods[handler] = mod; + this.CachedInvocationList = invocationList?.ToArray() ?? new EventHandler<TEventArgs>[0]; } - /// <summary>Add an event handler.</summary> + /// <summary>Remove tracking for an event handler.</summary> /// <param name="handler">The event handler.</param> - /// <param name="mod">The mod which added the event handler.</param> - public void Add(EventHandler handler, IModMetadata mod) + /// <param name="invocationList">The updated event invocation list.</param> + protected void RemoveTracking(EventHandler<TEventArgs> handler, IEnumerable<EventHandler<TEventArgs>> invocationList) { - this.Event += handler; - this.AddTracking(mod, handler, this.Event?.GetInvocationList().Cast<EventHandler>()); + this.CachedInvocationList = invocationList?.ToArray() ?? new EventHandler<TEventArgs>[0]; + if (!this.CachedInvocationList.Contains(handler)) // don't remove if there's still a reference to the removed handler (e.g. it was added twice and removed once) + this.SourceMods.Remove(handler); } - /// <summary>Remove an event handler.</summary> + /// <summary>Get the mod which registered the given event handler, if available.</summary> /// <param name="handler">The event handler.</param> - public void Remove(EventHandler handler) + protected IModMetadata GetSourceMod(EventHandler<TEventArgs> handler) { - this.Event -= handler; - this.RemoveTracking(handler, this.Event?.GetInvocationList().Cast<EventHandler>()); + return this.SourceMods.TryGetValue(handler, out IModMetadata mod) + ? mod + : null; } - /// <summary>Raise the event and notify all handlers.</summary> - public void Raise() + /// <summary>Log an exception from an event handler.</summary> + /// <param name="handler">The event handler instance.</param> + /// <param name="ex">The exception that was raised.</param> + protected void LogError(EventHandler<TEventArgs> handler, Exception ex) { - if (this.Event == null) - return; - - foreach (EventHandler handler in this.CachedInvocationList) - { - try - { - handler.Invoke(null, EventArgs.Empty); - } - catch (Exception ex) - { - this.LogError(handler, ex); - } - } + IModMetadata mod = this.GetSourceMod(handler); + if (mod != null) + mod.LogAsMod($"This mod failed in the {this.EventName} event. Technical details: \n{ex.GetLogSummary()}", LogLevel.Error); + else + this.Monitor.Log($"A mod failed in the {this.EventName} event. Technical details: \n{ex.GetLogSummary()}", LogLevel.Error); } } -#endif } diff --git a/src/SMAPI/Framework/Events/ManagedEventBase.cs b/src/SMAPI/Framework/Events/ManagedEventBase.cs deleted file mode 100644 index c8c3516b..00000000 --- a/src/SMAPI/Framework/Events/ManagedEventBase.cs +++ /dev/null @@ -1,93 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; - -namespace StardewModdingAPI.Framework.Events -{ - /// <summary>The base implementation for an event wrapper which intercepts and logs errors in handler code.</summary> - internal abstract class ManagedEventBase<TEventHandler> - { - /********* - ** Fields - *********/ - /// <summary>A human-readable name for the event.</summary> - private readonly string EventName; - - /// <summary>Writes messages to the log.</summary> - private readonly IMonitor Monitor; - - /// <summary>The mod registry with which to identify mods.</summary> - protected readonly ModRegistry ModRegistry; - - /// <summary>The display names for the mods which added each delegate.</summary> - private readonly IDictionary<TEventHandler, IModMetadata> SourceMods = new Dictionary<TEventHandler, IModMetadata>(); - - /// <summary>The cached invocation list.</summary> - protected TEventHandler[] CachedInvocationList { get; private set; } - - - /********* - ** Public methods - *********/ - /// <summary>Get whether anything is listening to the event.</summary> - public bool HasListeners() - { - return this.CachedInvocationList?.Length > 0; - } - - /********* - ** Protected methods - *********/ - /// <summary>Construct an instance.</summary> - /// <param name="eventName">A human-readable name for the event.</param> - /// <param name="monitor">Writes messages to the log.</param> - /// <param name="modRegistry">The mod registry with which to identify mods.</param> - protected ManagedEventBase(string eventName, IMonitor monitor, ModRegistry modRegistry) - { - this.EventName = eventName; - this.Monitor = monitor; - this.ModRegistry = modRegistry; - } - - /// <summary>Track an event handler.</summary> - /// <param name="mod">The mod which added the handler.</param> - /// <param name="handler">The event handler.</param> - /// <param name="invocationList">The updated event invocation list.</param> - protected void AddTracking(IModMetadata mod, TEventHandler handler, IEnumerable<TEventHandler> invocationList) - { - this.SourceMods[handler] = mod; - this.CachedInvocationList = invocationList?.ToArray() ?? new TEventHandler[0]; - } - - /// <summary>Remove tracking for an event handler.</summary> - /// <param name="handler">The event handler.</param> - /// <param name="invocationList">The updated event invocation list.</param> - protected void RemoveTracking(TEventHandler handler, IEnumerable<TEventHandler> invocationList) - { - this.CachedInvocationList = invocationList?.ToArray() ?? new TEventHandler[0]; - if (!this.CachedInvocationList.Contains(handler)) // don't remove if there's still a reference to the removed handler (e.g. it was added twice and removed once) - this.SourceMods.Remove(handler); - } - - /// <summary>Get the mod which registered the given event handler, if available.</summary> - /// <param name="handler">The event handler.</param> - protected IModMetadata GetSourceMod(TEventHandler handler) - { - return this.SourceMods.TryGetValue(handler, out IModMetadata mod) - ? mod - : null; - } - - /// <summary>Log an exception from an event handler.</summary> - /// <param name="handler">The event handler instance.</param> - /// <param name="ex">The exception that was raised.</param> - protected void LogError(TEventHandler handler, Exception ex) - { - IModMetadata mod = this.GetSourceMod(handler); - if (mod != null) - mod.LogAsMod($"This mod failed in the {this.EventName} event. Technical details: \n{ex.GetLogSummary()}", LogLevel.Error); - else - this.Monitor.Log($"A mod failed in the {this.EventName} event. Technical details: \n{ex.GetLogSummary()}", LogLevel.Error); - } - } -} diff --git a/src/SMAPI/Framework/Events/ModEvents.cs b/src/SMAPI/Framework/Events/ModEvents.cs index 8ad3936c..1d1c92c6 100644 --- a/src/SMAPI/Framework/Events/ModEvents.cs +++ b/src/SMAPI/Framework/Events/ModEvents.cs @@ -26,8 +26,8 @@ namespace StardewModdingAPI.Framework.Events /// <summary>Events raised when something changes in the world.</summary> public IWorldEvents World { get; } - /// <summary>Events serving specialised edge cases that shouldn't be used by most mods.</summary> - public ISpecialisedEvents Specialised { get; } + /// <summary>Events serving specialized edge cases that shouldn't be used by most mods.</summary> + public ISpecializedEvents Specialized { get; } /********* @@ -44,7 +44,7 @@ namespace StardewModdingAPI.Framework.Events this.Multiplayer = new ModMultiplayerEvents(mod, eventManager); this.Player = new ModPlayerEvents(mod, eventManager); this.World = new ModWorldEvents(mod, eventManager); - this.Specialised = new ModSpecialisedEvents(mod, eventManager); + this.Specialized = new ModSpecializedEvents(mod, eventManager); } } } diff --git a/src/SMAPI/Framework/Events/ModGameLoopEvents.cs b/src/SMAPI/Framework/Events/ModGameLoopEvents.cs index 0177c22e..c15460fa 100644 --- a/src/SMAPI/Framework/Events/ModGameLoopEvents.cs +++ b/src/SMAPI/Framework/Events/ModGameLoopEvents.cs @@ -72,7 +72,7 @@ namespace StardewModdingAPI.Framework.Events remove => this.EventManager.Saved.Remove(value); } - /// <summary>Raised after the player loads a save slot and the world is initialised.</summary> + /// <summary>Raised after the player loads a save slot and the world is initialized.</summary> public event EventHandler<SaveLoadedEventArgs> SaveLoaded { add => this.EventManager.SaveLoaded.Add(value); diff --git a/src/SMAPI/Framework/Events/ModSpecialisedEvents.cs b/src/SMAPI/Framework/Events/ModSpecialisedEvents.cs index 7c3e9dee..9388bdb2 100644 --- a/src/SMAPI/Framework/Events/ModSpecialisedEvents.cs +++ b/src/SMAPI/Framework/Events/ModSpecialisedEvents.cs @@ -3,8 +3,8 @@ using StardewModdingAPI.Events; namespace StardewModdingAPI.Framework.Events { - /// <summary>Events serving specialised edge cases that shouldn't be used by most mods.</summary> - internal class ModSpecialisedEvents : ModEventsBase, ISpecialisedEvents + /// <summary>Events serving specialized edge cases that shouldn't be used by most mods.</summary> + internal class ModSpecializedEvents : ModEventsBase, ISpecializedEvents { /********* ** Accessors @@ -37,7 +37,7 @@ namespace StardewModdingAPI.Framework.Events /// <summary>Construct an instance.</summary> /// <param name="mod">The mod which uses this instance.</param> /// <param name="eventManager">The underlying event manager.</param> - internal ModSpecialisedEvents(IModMetadata mod, EventManager eventManager) + internal ModSpecializedEvents(IModMetadata mod, EventManager eventManager) : base(mod, eventManager) { } } } diff --git a/src/SMAPI/Framework/GameVersion.cs b/src/SMAPI/Framework/GameVersion.cs index 261de374..cd88895c 100644 --- a/src/SMAPI/Framework/GameVersion.cs +++ b/src/SMAPI/Framework/GameVersion.cs @@ -12,17 +12,20 @@ namespace StardewModdingAPI.Framework /// <summary>A mapping of game to semantic versions.</summary> private static readonly IDictionary<string, string> VersionMap = new Dictionary<string, string>(StringComparer.InvariantCultureIgnoreCase) { + ["1.0"] = "1.0.0", ["1.01"] = "1.0.1", ["1.02"] = "1.0.2", ["1.03"] = "1.0.3", ["1.04"] = "1.0.4", ["1.05"] = "1.0.5", ["1.051"] = "1.0.6-prerelease1", // not a very good mapping, but good enough for SMAPI's purposes. - ["1.051b"] = "1.0.6-prelease2", + ["1.051b"] = "1.0.6-prerelease2", ["1.06"] = "1.0.6", ["1.07"] = "1.0.7", ["1.07a"] = "1.0.8-prerelease1", ["1.08"] = "1.0.8", + ["1.1"] = "1.1.0", + ["1.2"] = "1.2.0", ["1.11"] = "1.1.1" }; diff --git a/src/SMAPI/Framework/IModMetadata.cs b/src/SMAPI/Framework/IModMetadata.cs index 38514959..6ee7df69 100644 --- a/src/SMAPI/Framework/IModMetadata.cs +++ b/src/SMAPI/Framework/IModMetadata.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using StardewModdingAPI.Framework.ModHelpers; using StardewModdingAPI.Framework.ModLoading; using StardewModdingAPI.Toolkit.Framework.Clients.WebApi; using StardewModdingAPI.Toolkit.Framework.ModData; @@ -15,10 +16,13 @@ namespace StardewModdingAPI.Framework /// <summary>The mod's display name.</summary> string DisplayName { get; } - /// <summary>The mod's full directory path.</summary> + /// <summary>The root path containing mods.</summary> + string RootPath { get; } + + /// <summary>The mod's full directory path within the <see cref="RootPath"/>.</summary> string DirectoryPath { get; } - /// <summary>The <see cref="DirectoryPath"/> relative to the game's Mods folder.</summary> + /// <summary>The <see cref="DirectoryPath"/> relative to the <see cref="RootPath"/>.</summary> string RelativeDirectoryPath { get; } /// <summary>Metadata about the mod from SMAPI's internal data (if any).</summary> @@ -42,6 +46,9 @@ namespace StardewModdingAPI.Framework /// <summary>The content pack instance (if loaded and <see cref="IModInfo.IsContentPack"/> is true).</summary> IContentPack ContentPack { get; } + /// <summary>The translations for this mod (if loaded).</summary> + TranslationHelper Translations { get; } + /// <summary>Writes messages to the console and log file as this mod.</summary> IMonitor Monitor { get; } @@ -67,12 +74,14 @@ namespace StardewModdingAPI.Framework /// <summary>Set the mod instance.</summary> /// <param name="mod">The mod instance to set.</param> - IModMetadata SetMod(IMod mod); + /// <param name="translations">The translations for this mod (if loaded).</param> + IModMetadata SetMod(IMod mod, TranslationHelper translations); /// <summary>Set the mod instance.</summary> /// <param name="contentPack">The contentPack instance to set.</param> /// <param name="monitor">Writes messages to the console and log file.</param> - IModMetadata SetMod(IContentPack contentPack, IMonitor monitor); + /// <param name="translations">The translations for this mod (if loaded).</param> + IModMetadata SetMod(IContentPack contentPack, IMonitor monitor, TranslationHelper translations); /// <summary>Set the mod-provided API instance.</summary> /// <param name="api">The mod-provided API.</param> @@ -102,5 +111,8 @@ namespace StardewModdingAPI.Framework /// <summary>Get whether the mod has a given warning and it hasn't been suppressed in the <see cref="DataRecord"/>.</summary> /// <param name="warning">The warning to check.</param> bool HasUnsuppressWarning(ModWarning warning); + + /// <summary>Get a relative path which includes the root folder name.</summary> + string GetRelativePathWithRoot(); } } diff --git a/src/SMAPI/Framework/Input/SInputState.cs b/src/SMAPI/Framework/Input/SInputState.cs index 96a7003a..d69e5604 100644 --- a/src/SMAPI/Framework/Input/SInputState.cs +++ b/src/SMAPI/Framework/Input/SInputState.cs @@ -80,12 +80,14 @@ namespace StardewModdingAPI.Framework.Input { try { + float zoomMultiplier = (1f / Game1.options.zoomLevel); + // get new states GamePadState realController = GamePad.GetState(PlayerIndex.One); KeyboardState realKeyboard = Keyboard.GetState(); MouseState realMouse = Mouse.GetState(); var activeButtons = this.DeriveStatuses(this.ActiveButtons, realKeyboard, realMouse, realController); - Vector2 cursorAbsolutePos = new Vector2(realMouse.X + Game1.viewport.X, realMouse.Y + Game1.viewport.Y); + Vector2 cursorAbsolutePos = new Vector2((realMouse.X * zoomMultiplier) + Game1.viewport.X, (realMouse.Y * zoomMultiplier) + Game1.viewport.Y); Vector2? playerTilePos = Context.IsPlayerFree ? Game1.player.getTileLocation() : (Vector2?)null; // update real states @@ -94,7 +96,10 @@ namespace StardewModdingAPI.Framework.Input this.RealKeyboard = realKeyboard; this.RealMouse = realMouse; if (cursorAbsolutePos != this.CursorPositionImpl?.AbsolutePixels || playerTilePos != this.LastPlayerTile) - this.CursorPositionImpl = this.GetCursorPosition(realMouse, cursorAbsolutePos); + { + this.LastPlayerTile = playerTilePos; + this.CursorPositionImpl = this.GetCursorPosition(realMouse, cursorAbsolutePos, zoomMultiplier); + } // update suppressed states this.SuppressButtons.RemoveWhere(p => !this.GetStatus(activeButtons, p).IsDown()); @@ -167,11 +172,11 @@ namespace StardewModdingAPI.Framework.Input *********/ /// <summary>Get the current cursor position.</summary> /// <param name="mouseState">The current mouse state.</param> - /// <param name="absolutePixels">The absolute pixel position relative to the map.</param> - private CursorPosition GetCursorPosition(MouseState mouseState, Vector2 absolutePixels) + /// <param name="absolutePixels">The absolute pixel position relative to the map, adjusted for pixel zoom.</param> + /// <param name="zoomMultiplier">The multiplier applied to pixel coordinates to adjust them for pixel zoom.</param> + private CursorPosition GetCursorPosition(MouseState mouseState, Vector2 absolutePixels, float zoomMultiplier) { - Vector2 rawPixels = new Vector2(mouseState.X, mouseState.Y); - Vector2 screenPixels = rawPixels * new Vector2((float)1.0 / Game1.options.zoomLevel); // derived from Game1::getMouseX + Vector2 screenPixels = new Vector2(mouseState.X * zoomMultiplier, mouseState.Y * zoomMultiplier); Vector2 tile = new Vector2((int)((Game1.viewport.X + screenPixels.X) / Game1.tileSize), (int)((Game1.viewport.Y + screenPixels.Y) / Game1.tileSize)); Vector2 grabTile = (Game1.mouseCursorTransparency > 0 && Utility.tileWithinRadiusOfPlayer((int)tile.X, (int)tile.Y, 1, Game1.player)) // derived from Game1.pressActionButton ? tile diff --git a/src/SMAPI/Framework/InternalExtensions.cs b/src/SMAPI/Framework/InternalExtensions.cs index f52bfe2b..c3155b1c 100644 --- a/src/SMAPI/Framework/InternalExtensions.cs +++ b/src/SMAPI/Framework/InternalExtensions.cs @@ -55,7 +55,7 @@ namespace StardewModdingAPI.Framework ** Exceptions ****/ /// <summary>Get a string representation of an exception suitable for writing to the error log.</summary> - /// <param name="exception">The error to summarise.</param> + /// <param name="exception">The error to summarize.</param> public static string GetLogSummary(this Exception exception) { switch (exception) diff --git a/src/SMAPI/Framework/ModHelpers/ContentHelper.cs b/src/SMAPI/Framework/ModHelpers/ContentHelper.cs index 8b86fdeb..043ae376 100644 --- a/src/SMAPI/Framework/ModHelpers/ContentHelper.cs +++ b/src/SMAPI/Framework/ModHelpers/ContentHelper.cs @@ -9,11 +9,8 @@ using Microsoft.Xna.Framework.Content; using Microsoft.Xna.Framework.Graphics; using StardewModdingAPI.Framework.ContentManagers; using StardewModdingAPI.Framework.Exceptions; -using StardewModdingAPI.Toolkit.Utilities; using StardewValley; using xTile; -using xTile.Format; -using xTile.Tiles; namespace StardewModdingAPI.Framework.ModHelpers { @@ -30,10 +27,7 @@ namespace StardewModdingAPI.Framework.ModHelpers private readonly IContentManager GameContentManager; /// <summary>A content manager for this mod which manages files from the mod's folder.</summary> - private readonly IContentManager ModContentManager; - - /// <summary>The absolute path to the mod folder.</summary> - private readonly string ModFolderPath; + private readonly ModContentManager ModContentManager; /// <summary>The friendly mod name for use in errors.</summary> private readonly string ModName; @@ -78,8 +72,7 @@ namespace StardewModdingAPI.Framework.ModHelpers { this.ContentCore = contentCore; this.GameContentManager = contentCore.CreateGameContentManager(this.ContentCore.GetManagedAssetPrefix(modID) + ".content"); - this.ModContentManager = contentCore.CreateModContentManager(this.ContentCore.GetManagedAssetPrefix(modID), rootDirectory: modFolderPath); - this.ModFolderPath = modFolderPath; + this.ModContentManager = contentCore.CreateModContentManager(this.ContentCore.GetManagedAssetPrefix(modID), modFolderPath, this.GameContentManager); this.ModName = modName; this.Monitor = monitor; } @@ -92,49 +85,19 @@ namespace StardewModdingAPI.Framework.ModHelpers /// <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 = ContentSource.ModFolder) { - SContentLoadException GetContentError(string reasonPhrase) => new SContentLoadException($"{this.ModName} failed loading content asset '{key}' from {source}: {reasonPhrase}."); - try { - this.AssertAndNormaliseAssetName(key); + this.AssertAndNormalizeAssetName(key); switch (source) { case ContentSource.GameContent: - return this.GameContentManager.Load<T>(key); + return this.GameContentManager.Load<T>(key, this.CurrentLocaleConstant, useCache: false); case ContentSource.ModFolder: - // get file - FileInfo file = this.GetModFile(key); - if (!file.Exists) - throw GetContentError($"there's no matching file at path '{file.FullName}'."); - string internalKey = this.GetInternalModAssetKey(file); - - // try cache - if (this.ModContentManager.IsLoaded(internalKey)) - return this.ModContentManager.Load<T>(internalKey); - - // fix map tilesheets - if (file.Extension.ToLower() == ".tbin") - { - // validate - if (typeof(T) != typeof(Map)) - throw GetContentError($"can't read file with extension '{file.Extension}' as type '{typeof(T)}'; must be type '{typeof(Map)}'."); - - // fetch & cache - FormatManager formatManager = FormatManager.Instance; - Map map = formatManager.LoadMap(file.FullName); - this.FixCustomTilesheetPaths(map, relativeMapPath: key); - - // inject map - this.ModContentManager.Inject(internalKey, map); - return (T)(object)map; - } - - // load through content manager - return this.ModContentManager.Load<T>(internalKey); + return this.ModContentManager.Load<T>(key, Constants.DefaultLanguage, useCache: false); default: - throw GetContentError($"unknown content source '{source}'."); + throw new SContentLoadException($"{this.ModName} failed loading content asset '{key}' from {source}: unknown content source '{source}'."); } } catch (Exception ex) when (!(ex is SContentLoadException)) @@ -143,12 +106,12 @@ namespace StardewModdingAPI.Framework.ModHelpers } } - /// <summary>Normalise an asset name so it's consistent with those generated by the game. This is mainly useful for string comparisons like <see cref="string.StartsWith(string)"/> on generated asset names, and isn't necessary when passing asset names into other content helper methods.</summary> + /// <summary>Normalize an asset name so it's consistent with those generated by the game. This is mainly useful for string comparisons like <see cref="string.StartsWith(string)"/> on generated asset names, and isn't necessary when passing asset names into other content helper methods.</summary> /// <param name="assetName">The asset key.</param> [Pure] - public string NormaliseAssetName(string assetName) + public string NormalizeAssetName(string assetName) { - return this.ModContentManager.AssertAndNormaliseAssetName(assetName); + return this.ModContentManager.AssertAndNormalizeAssetName(assetName); } /// <summary>Get the underlying key in the game's content cache for an asset. This can be used to load custom map tilesheets, but should be avoided when you can use the content API instead. This does not validate whether the asset exists.</summary> @@ -160,11 +123,10 @@ namespace StardewModdingAPI.Framework.ModHelpers switch (source) { case ContentSource.GameContent: - return this.GameContentManager.AssertAndNormaliseAssetName(key); + return this.GameContentManager.AssertAndNormalizeAssetName(key); case ContentSource.ModFolder: - FileInfo file = this.GetModFile(key); - return this.GetInternalModAssetKey(file); + return this.ModContentManager.GetInternalAssetKey(key); default: throw new NotSupportedException($"Unknown content source '{source}'."); @@ -200,6 +162,7 @@ namespace StardewModdingAPI.Framework.ModHelpers return this.ContentCore.InvalidateCache(predicate).Any(); } + /********* ** Private methods *********/ @@ -207,175 +170,11 @@ namespace StardewModdingAPI.Framework.ModHelpers /// <param name="key">The asset key to check.</param> /// <exception cref="ArgumentException">The asset key is empty or contains invalid characters.</exception> [SuppressMessage("ReSharper", "ParameterOnlyUsedForPreconditionCheck.Local", Justification = "Parameter is only used for assertion checks by design.")] - private void AssertAndNormaliseAssetName(string key) + private void AssertAndNormalizeAssetName(string key) { - this.ModContentManager.AssertAndNormaliseAssetName(key); + this.ModContentManager.AssertAndNormalizeAssetName(key); if (Path.IsPathRooted(key)) throw new ArgumentException("The asset key must not be an absolute path."); } - - /// <summary>Get the internal key in the content cache for a mod asset.</summary> - /// <param name="modFile">The asset file.</param> - private string GetInternalModAssetKey(FileInfo modFile) - { - string relativePath = PathUtilities.GetRelativePath(this.ModFolderPath, modFile.FullName); - return Path.Combine(this.ModContentManager.Name, relativePath); - } - - /// <summary>Fix custom map tilesheet paths so they can be found by the content manager.</summary> - /// <param name="map">The map whose tilesheets to fix.</param> - /// <param name="relativeMapPath">The relative map path within the mod folder.</param> - /// <exception cref="ContentLoadException">A map tilesheet couldn't be resolved.</exception> - /// <remarks> - /// The game's logic for tilesheets in <see cref="Game1.setGraphicsForSeason"/> is a bit specialised. It boils - /// down to this: - /// * If the location is indoors or the desert, or the image source contains 'path' or 'object', it's loaded - /// as-is relative to the <c>Content</c> folder. - /// * Else it's loaded from <c>Content\Maps</c> with a seasonal prefix. - /// - /// That logic doesn't work well in our case, mainly because we have no location metadata at this point. - /// Instead we use a more heuristic approach: check relative to the map file first, then relative to - /// <c>Content\Maps</c>, then <c>Content</c>. If the image source filename contains a seasonal prefix, try for a - /// seasonal variation and then an exact match. - /// - /// While that doesn't exactly match the game logic, it's close enough that it's unlikely to make a difference. - /// </remarks> - private void FixCustomTilesheetPaths(Map map, string relativeMapPath) - { - // get map info - if (!map.TileSheets.Any()) - return; - relativeMapPath = this.ModContentManager.AssertAndNormaliseAssetName(relativeMapPath); // Mono's Path.GetDirectoryName doesn't handle Windows dir separators - string relativeMapFolder = Path.GetDirectoryName(relativeMapPath) ?? ""; // folder path containing the map, relative to the mod folder - - // fix tilesheets - foreach (TileSheet tilesheet in map.TileSheets) - { - string imageSource = tilesheet.ImageSource; - - // validate tilesheet path - if (Path.IsPathRooted(imageSource) || PathUtilities.GetSegments(imageSource).Contains("..")) - throw new ContentLoadException($"The '{imageSource}' tilesheet couldn't be loaded. Tilesheet paths must be a relative path without directory climbing (../)."); - - // get seasonal name (if applicable) - string seasonalImageSource = null; - if (Context.IsSaveLoaded && Game1.currentSeason != null) - { - string filename = Path.GetFileName(imageSource) ?? throw new InvalidOperationException($"The '{imageSource}' tilesheet couldn't be loaded: filename is unexpectedly null."); - bool hasSeasonalPrefix = - filename.StartsWith("spring_", StringComparison.CurrentCultureIgnoreCase) - || filename.StartsWith("summer_", StringComparison.CurrentCultureIgnoreCase) - || filename.StartsWith("fall_", StringComparison.CurrentCultureIgnoreCase) - || filename.StartsWith("winter_", StringComparison.CurrentCultureIgnoreCase); - if (hasSeasonalPrefix && !filename.StartsWith(Game1.currentSeason + "_")) - { - string dirPath = imageSource.Substring(0, imageSource.LastIndexOf(filename, StringComparison.CurrentCultureIgnoreCase)); - seasonalImageSource = $"{dirPath}{Game1.currentSeason}_{filename.Substring(filename.IndexOf("_", StringComparison.CurrentCultureIgnoreCase) + 1)}"; - } - } - - // load best match - try - { - string key = - this.GetTilesheetAssetName(relativeMapFolder, seasonalImageSource) - ?? this.GetTilesheetAssetName(relativeMapFolder, imageSource); - if (key != null) - { - tilesheet.ImageSource = key; - continue; - } - } - catch (Exception ex) - { - throw new ContentLoadException($"The '{imageSource}' tilesheet couldn't be loaded relative to either map file or the game's content folder.", ex); - } - - // none found - throw new ContentLoadException($"The '{imageSource}' tilesheet couldn't be loaded relative to either map file or the game's content folder."); - } - } - - /// <summary>Get the actual asset name for a tilesheet.</summary> - /// <param name="modRelativeMapFolder">The folder path containing the map, relative to the mod folder.</param> - /// <param name="imageSource">The tilesheet image source to load.</param> - /// <returns>Returns the asset name.</returns> - /// <remarks>See remarks on <see cref="FixCustomTilesheetPaths"/>.</remarks> - private string GetTilesheetAssetName(string modRelativeMapFolder, string imageSource) - { - if (imageSource == null) - return null; - - // check relative to map file - { - string localKey = Path.Combine(modRelativeMapFolder, imageSource); - FileInfo localFile = this.GetModFile(localKey); - if (localFile.Exists) - return this.GetActualAssetKey(localKey); - } - - // check relative to content folder - { - foreach (string candidateKey in new[] { imageSource, Path.Combine("Maps", imageSource) }) - { - string contentKey = candidateKey.EndsWith(".png") - ? candidateKey.Substring(0, candidateKey.Length - 4) - : candidateKey; - - try - { - this.Load<Texture2D>(contentKey, ContentSource.GameContent); - return contentKey; - } - catch - { - // ignore file-not-found errors - // TODO: while it's useful to suppress an asset-not-found error here to avoid - // confusion, this is a pretty naive approach. Even if the file doesn't exist, - // the file may have been loaded through an IAssetLoader which failed. So even - // if the content file doesn't exist, that doesn't mean the error here is a - // content-not-found error. Unfortunately XNA doesn't provide a good way to - // detect the error type. - if (this.GetContentFolderFile(contentKey).Exists) - throw; - } - } - } - - // not found - return null; - } - - /// <summary>Get a file from the mod folder.</summary> - /// <param name="path">The asset path relative to the mod folder.</param> - private FileInfo GetModFile(string path) - { - // try exact match - path = Path.Combine(this.ModFolderPath, this.ModContentManager.NormalisePathSeparators(path)); - FileInfo file = new FileInfo(path); - - // try with default extension - if (!file.Exists && file.Extension.ToLower() != ".xnb") - { - FileInfo result = new FileInfo(path + ".xnb"); - if (result.Exists) - file = result; - } - - return file; - } - - /// <summary>Get a file from the game's content folder.</summary> - /// <param name="key">The asset key.</param> - private FileInfo GetContentFolderFile(string key) - { - // get file path - string path = Path.Combine(this.GameContentManager.FullRootDirectory, key); - if (!path.EndsWith(".xnb")) - path += ".xnb"; - - // get file - return new FileInfo(path); - } } } diff --git a/src/SMAPI/Framework/ModHelpers/ContentPackHelper.cs b/src/SMAPI/Framework/ModHelpers/ContentPackHelper.cs index 34f24d65..acdd82a0 100644 --- a/src/SMAPI/Framework/ModHelpers/ContentPackHelper.cs +++ b/src/SMAPI/Framework/ModHelpers/ContentPackHelper.cs @@ -1,7 +1,7 @@ using System; using System.Collections.Generic; using System.IO; -using StardewModdingAPI.Toolkit.Serialisation.Models; +using StardewModdingAPI.Toolkit.Serialization.Models; namespace StardewModdingAPI.Framework.ModHelpers { @@ -38,7 +38,7 @@ namespace StardewModdingAPI.Framework.ModHelpers return this.ContentPacks.Value; } - /// <summary>Create a temporary content pack to read files from a directory, using randomised manifest fields. This will generate fake manifest data; any <c>manifest.json</c> in the directory will be ignored. Temporary content packs will not appear in the SMAPI log and update checks will not be performed.</summary> + /// <summary>Create a temporary content pack to read files from a directory, using randomized manifest fields. This will generate fake manifest data; any <c>manifest.json</c> in the directory will be ignored. Temporary content packs will not appear in the SMAPI log and update checks will not be performed.</summary> /// <param name="directoryPath">The absolute directory path containing the content pack files.</param> public IContentPack CreateFake(string directoryPath) { diff --git a/src/SMAPI/Framework/ModHelpers/DataHelper.cs b/src/SMAPI/Framework/ModHelpers/DataHelper.cs index 3b5c1752..cc08c42b 100644 --- a/src/SMAPI/Framework/ModHelpers/DataHelper.cs +++ b/src/SMAPI/Framework/ModHelpers/DataHelper.cs @@ -1,7 +1,7 @@ using System; using System.IO; using Newtonsoft.Json; -using StardewModdingAPI.Toolkit.Serialisation; +using StardewModdingAPI.Toolkit.Serialization; using StardewModdingAPI.Toolkit.Utilities; using StardewValley; @@ -40,14 +40,14 @@ namespace StardewModdingAPI.Framework.ModHelpers /// <summary>Read data from a JSON file in the mod's folder.</summary> /// <typeparam name="TModel">The model type. This should be a plain class that has public properties for the data you want. The properties can be complex types.</typeparam> /// <param name="path">The file path relative to the mod folder.</param> - /// <returns>Returns the deserialised model, or <c>null</c> if the file doesn't exist or is empty.</returns> + /// <returns>Returns the deserialized model, or <c>null</c> if the file doesn't exist or is empty.</returns> /// <exception cref="InvalidOperationException">The <paramref name="path"/> is not relative or contains directory climbing (../).</exception> public TModel ReadJsonFile<TModel>(string path) where TModel : class { if (!PathUtilities.IsSafeRelativePath(path)) throw new InvalidOperationException($"You must call {nameof(IModHelper.Data)}.{nameof(this.ReadJsonFile)} with a relative path."); - path = Path.Combine(this.ModFolderPath, PathUtilities.NormalisePathSeparators(path)); + path = Path.Combine(this.ModFolderPath, PathUtilities.NormalizePathSeparators(path)); return this.JsonHelper.ReadJsonFileIfExists(path, out TModel data) ? data : null; @@ -63,7 +63,7 @@ namespace StardewModdingAPI.Framework.ModHelpers if (!PathUtilities.IsSafeRelativePath(path)) throw new InvalidOperationException($"You must call {nameof(IMod.Helper)}.{nameof(IModHelper.Data)}.{nameof(this.WriteJsonFile)} with a relative path (without directory climbing)."); - path = Path.Combine(this.ModFolderPath, PathUtilities.NormalisePathSeparators(path)); + path = Path.Combine(this.ModFolderPath, PathUtilities.NormalizePathSeparators(path)); this.JsonHelper.WriteJsonFile(path, data); } @@ -83,7 +83,7 @@ namespace StardewModdingAPI.Framework.ModHelpers throw new InvalidOperationException($"Can't use {nameof(IMod.Helper)}.{nameof(IModHelper.Data)}.{nameof(this.ReadSaveData)} because this isn't the main player. (Save files are stored on the main player's computer.)"); return Game1.CustomData.TryGetValue(this.GetSaveFileKey(key), out string value) - ? this.JsonHelper.Deserialise<TModel>(value) + ? this.JsonHelper.Deserialize<TModel>(value) : null; } @@ -101,7 +101,7 @@ namespace StardewModdingAPI.Framework.ModHelpers string internalKey = this.GetSaveFileKey(key); if (data != null) - Game1.CustomData[internalKey] = this.JsonHelper.Serialise(data, Formatting.None); + Game1.CustomData[internalKey] = this.JsonHelper.Serialize(data, Formatting.None); else Game1.CustomData.Remove(internalKey); } diff --git a/src/SMAPI/Framework/ModHelpers/ModHelper.cs b/src/SMAPI/Framework/ModHelpers/ModHelper.cs index 6c9838c9..25401e23 100644 --- a/src/SMAPI/Framework/ModHelpers/ModHelper.cs +++ b/src/SMAPI/Framework/ModHelpers/ModHelper.cs @@ -1,11 +1,8 @@ using System; -using System.Collections.Generic; using System.IO; using StardewModdingAPI.Events; using StardewModdingAPI.Framework.Input; -using StardewModdingAPI.Toolkit.Serialisation; -using StardewModdingAPI.Toolkit.Serialisation.Models; -using StardewModdingAPI.Toolkit.Utilities; +using StardewModdingAPI.Toolkit.Serialization; namespace StardewModdingAPI.Framework.ModHelpers { @@ -18,11 +15,6 @@ namespace StardewModdingAPI.Framework.ModHelpers /// <summary>The full path to the mod's folder.</summary> public string DirectoryPath { get; } -#if !SMAPI_3_0_STRICT - /// <summary>Encapsulates SMAPI's JSON file parsing.</summary> - private readonly JsonHelper JsonHelper; -#endif - /// <summary>Manages access to events raised by SMAPI, which let your mod react when something happens in the game.</summary> public IModEvents Events { get; } @@ -60,7 +52,6 @@ namespace StardewModdingAPI.Framework.ModHelpers /// <summary>Construct an instance.</summary> /// <param name="modID">The mod's unique ID.</param> /// <param name="modDirectory">The full path to the mod's folder.</param> - /// <param name="jsonHelper">Encapsulate SMAPI's JSON parsing.</param> /// <param name="inputState">Manages the game's input state.</param> /// <param name="events">Manages access to events raised by SMAPI.</param> /// <param name="contentHelper">An API for loading content assets.</param> @@ -73,7 +64,7 @@ namespace StardewModdingAPI.Framework.ModHelpers /// <param name="translationHelper">An API for reading translations stored in the mod's <c>i18n</c> folder.</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 modID, string modDirectory, JsonHelper jsonHelper, SInputState inputState, IModEvents events, IContentHelper contentHelper, IContentPackHelper contentPackHelper, ICommandHelper commandHelper, IDataHelper dataHelper, IModRegistry modRegistry, IReflectionHelper reflectionHelper, IMultiplayerHelper multiplayer, ITranslationHelper translationHelper) + public ModHelper(string modID, string modDirectory, SInputState inputState, IModEvents events, IContentHelper contentHelper, IContentPackHelper contentPackHelper, ICommandHelper commandHelper, IDataHelper dataHelper, IModRegistry modRegistry, IReflectionHelper reflectionHelper, IMultiplayerHelper multiplayer, ITranslationHelper translationHelper) : base(modID) { // validate directory @@ -82,7 +73,7 @@ namespace StardewModdingAPI.Framework.ModHelpers if (!Directory.Exists(modDirectory)) throw new InvalidOperationException("The specified mod directory does not exist."); - // initialise + // initialize this.DirectoryPath = modDirectory; this.Content = contentHelper ?? throw new ArgumentNullException(nameof(contentHelper)); this.ContentPacks = contentPackHelper ?? throw new ArgumentNullException(nameof(contentPackHelper)); @@ -94,9 +85,6 @@ namespace StardewModdingAPI.Framework.ModHelpers this.Multiplayer = multiplayer ?? throw new ArgumentNullException(nameof(multiplayer)); this.Translation = translationHelper ?? throw new ArgumentNullException(nameof(translationHelper)); this.Events = events; -#if !SMAPI_3_0_STRICT - this.JsonHelper = jsonHelper ?? throw new ArgumentNullException(nameof(jsonHelper)); -#endif } /**** @@ -121,63 +109,6 @@ namespace StardewModdingAPI.Framework.ModHelpers this.Data.WriteJsonFile("config.json", config); } -#if !SMAPI_3_0_STRICT - /**** - ** Generic JSON files - ****/ - /// <summary>Read a JSON file.</summary> - /// <typeparam name="TModel">The model type.</typeparam> - /// <param name="path">The file path relative to the mod directory.</param> - /// <returns>Returns the deserialised model, or <c>null</c> if the file doesn't exist or is empty.</returns> - [Obsolete("Use " + nameof(ModHelper.Data) + "." + nameof(IDataHelper.ReadJsonFile) + " instead")] - public TModel ReadJsonFile<TModel>(string path) - where TModel : class - { - path = Path.Combine(this.DirectoryPath, PathUtilities.NormalisePathSeparators(path)); - return this.JsonHelper.ReadJsonFileIfExists(path, out TModel data) - ? data - : null; - } - - /// <summary>Save to a JSON file.</summary> - /// <typeparam name="TModel">The model type.</typeparam> - /// <param name="path">The file path relative to the mod directory.</param> - /// <param name="model">The model to save.</param> - [Obsolete("Use " + nameof(ModHelper.Data) + "." + nameof(IDataHelper.WriteJsonFile) + " instead")] - public void WriteJsonFile<TModel>(string path, TModel model) - where TModel : class - { - path = Path.Combine(this.DirectoryPath, PathUtilities.NormalisePathSeparators(path)); - this.JsonHelper.WriteJsonFile(path, model); - } -#endif - - /**** - ** Content packs - ****/ -#if !SMAPI_3_0_STRICT - /// <summary>Manually create a transitional content pack to support pre-SMAPI content packs. This provides a way to access legacy content packs using the SMAPI content pack APIs, but the content pack will not be visible in the log or validated by SMAPI.</summary> - /// <param name="directoryPath">The absolute directory path containing the content pack files.</param> - /// <param name="id">The content pack's unique ID.</param> - /// <param name="name">The content pack name.</param> - /// <param name="description">The content pack description.</param> - /// <param name="author">The content pack author's name.</param> - /// <param name="version">The content pack version.</param> - [Obsolete("Use " + nameof(IModHelper) + "." + nameof(IModHelper.ContentPacks) + "." + nameof(IContentPackHelper.CreateTemporary) + " instead")] - public IContentPack CreateTransitionalContentPack(string directoryPath, string id, string name, string description, string author, ISemanticVersion version) - { - SCore.DeprecationManager.Warn($"{nameof(IModHelper)}.{nameof(IModHelper.CreateTransitionalContentPack)}", "2.5", DeprecationLevel.PendingRemoval); - return this.ContentPacks.CreateTemporary(directoryPath, id, name, description, author, version); - } - - /// <summary>Get all content packs loaded for this mod.</summary> - [Obsolete("Use " + nameof(IModHelper) + "." + nameof(IModHelper.ContentPacks) + "." + nameof(IContentPackHelper.GetOwned) + " instead")] - public IEnumerable<IContentPack> GetContentPacks() - { - return this.ContentPacks.GetOwned(); - } -#endif - /**** ** Disposal ****/ diff --git a/src/SMAPI/Framework/ModHelpers/ModRegistryHelper.cs b/src/SMAPI/Framework/ModHelpers/ModRegistryHelper.cs index 8330e078..f42cb085 100644 --- a/src/SMAPI/Framework/ModHelpers/ModRegistryHelper.cs +++ b/src/SMAPI/Framework/ModHelpers/ModRegistryHelper.cs @@ -1,5 +1,4 @@ using System.Collections.Generic; -using System.Linq; using StardewModdingAPI.Framework.Reflection; namespace StardewModdingAPI.Framework.ModHelpers @@ -63,6 +62,14 @@ namespace StardewModdingAPI.Framework.ModHelpers /// <summary>Get the API provided by a mod, or <c>null</c> if it has none. This signature requires using the <see cref="IModHelper.Reflection"/> API to access the API's properties and methods.</summary> public object GetApi(string uniqueID) { + // validate ready + if (!this.Registry.AreAllModsInitialized) + { + this.Monitor.Log("Tried to access a mod-provided API before all mods were initialized.", LogLevel.Error); + return null; + } + + // get raw API IModMetadata mod = this.Registry.Get(uniqueID); if (mod?.Api != null && this.AccessedModApis.Add(mod.Manifest.UniqueID)) this.Monitor.Log($"Accessed mod-provided API for {mod.DisplayName}.", LogLevel.Trace); @@ -74,12 +81,12 @@ namespace StardewModdingAPI.Framework.ModHelpers /// <param name="uniqueID">The mod's unique ID.</param> public TInterface GetApi<TInterface>(string uniqueID) where TInterface : class { - // validate - if (!this.Registry.AreAllModsInitialised) - { - this.Monitor.Log("Tried to access a mod-provided API before all mods were initialised.", LogLevel.Error); + // get raw API + object api = this.GetApi(uniqueID); + if (api == null) return null; - } + + // validate mapping if (!typeof(TInterface).IsInterface) { this.Monitor.Log($"Tried to map a mod-provided API to class '{typeof(TInterface).FullName}'; must be a public interface.", LogLevel.Error); @@ -91,11 +98,6 @@ namespace StardewModdingAPI.Framework.ModHelpers return null; } - // get raw API - object api = this.GetApi(uniqueID); - if (api == null) - return null; - // get API of type if (api is TInterface castApi) return castApi; diff --git a/src/SMAPI/Framework/ModHelpers/ReflectionHelper.cs b/src/SMAPI/Framework/ModHelpers/ReflectionHelper.cs index 0ce72a9e..86c327ed 100644 --- a/src/SMAPI/Framework/ModHelpers/ReflectionHelper.cs +++ b/src/SMAPI/Framework/ModHelpers/ReflectionHelper.cs @@ -5,7 +5,7 @@ using StardewModdingAPI.Framework.Reflection; namespace StardewModdingAPI.Framework.ModHelpers { /// <summary>Provides helper methods for accessing private game code.</summary> - /// <remarks>This implementation searches up the type hierarchy, and caches the reflected fields and methods with a sliding expiry (to optimise performance without unnecessary memory usage).</remarks> + /// <remarks>This implementation searches up the type hierarchy, and caches the reflected fields and methods with a sliding expiry (to optimize performance without unnecessary memory usage).</remarks> internal class ReflectionHelper : BaseHelper, IReflectionHelper { /********* diff --git a/src/SMAPI/Framework/ModHelpers/TranslationHelper.cs b/src/SMAPI/Framework/ModHelpers/TranslationHelper.cs index 3252e047..be7768e8 100644 --- a/src/SMAPI/Framework/ModHelpers/TranslationHelper.cs +++ b/src/SMAPI/Framework/ModHelpers/TranslationHelper.cs @@ -1,6 +1,4 @@ -using System; using System.Collections.Generic; -using System.Linq; using StardewValley; namespace StardewModdingAPI.Framework.ModHelpers @@ -11,24 +9,18 @@ namespace StardewModdingAPI.Framework.ModHelpers /********* ** Fields *********/ - /// <summary>The name of the relevant mod for error messages.</summary> - private readonly string ModName; - - /// <summary>The translations for each locale.</summary> - private readonly IDictionary<string, IDictionary<string, string>> All = new Dictionary<string, IDictionary<string, string>>(StringComparer.InvariantCultureIgnoreCase); - - /// <summary>The translations for the current locale, with locale fallback taken into account.</summary> - private IDictionary<string, Translation> ForLocale; + /// <summary>The underlying translation manager.</summary> + private readonly Translator Translator; /********* ** Accessors *********/ /// <summary>The current locale.</summary> - public string Locale { get; private set; } + public string Locale => this.Translator.Locale; /// <summary>The game's current language code.</summary> - public LocalizedContentManager.LanguageCode LocaleEnum { get; private set; } + public LocalizedContentManager.LanguageCode LocaleEnum => this.Translator.LocaleEnum; /********* @@ -36,31 +28,26 @@ namespace StardewModdingAPI.Framework.ModHelpers *********/ /// <summary>Construct an instance.</summary> /// <param name="modID">The unique ID of the relevant mod.</param> - /// <param name="modName">The name of the relevant mod for error messages.</param> /// <param name="locale">The initial locale.</param> /// <param name="languageCode">The game's current language code.</param> - public TranslationHelper(string modID, string modName, string locale, LocalizedContentManager.LanguageCode languageCode) + public TranslationHelper(string modID, string locale, LocalizedContentManager.LanguageCode languageCode) : base(modID) { - // save data - this.ModName = modName; - - // set locale - this.SetLocale(locale, languageCode); + this.Translator = new Translator(); + this.Translator.SetLocale(locale, languageCode); } /// <summary>Get all translations for the current locale.</summary> public IEnumerable<Translation> GetTranslations() { - return this.ForLocale.Values.ToArray(); + return this.Translator.GetTranslations(); } /// <summary>Get a translation for the current locale.</summary> /// <param name="key">The translation key.</param> public Translation Get(string key) { - this.ForLocale.TryGetValue(key, out Translation translation); - return translation ?? new Translation(this.ModName, this.Locale, key, null); + return this.Translator.Get(key); } /// <summary>Get a translation for the current locale.</summary> @@ -68,21 +55,14 @@ namespace StardewModdingAPI.Framework.ModHelpers /// <param name="tokens">An object containing token key/value pairs. This can be an anonymous object (like <c>new { value = 42, name = "Cranberries" }</c>), a dictionary, or a class instance.</param> public Translation Get(string key, object tokens) { - return this.Get(key).Tokens(tokens); + return this.Translator.Get(key, tokens); } /// <summary>Set the translations to use.</summary> /// <param name="translations">The translations to use.</param> internal TranslationHelper SetTranslations(IDictionary<string, IDictionary<string, string>> translations) { - // reset translations - this.All.Clear(); - foreach (var pair in translations) - this.All[pair.Key] = new Dictionary<string, string>(pair.Value, StringComparer.InvariantCultureIgnoreCase); - - // rebuild cache - this.SetLocale(this.Locale, this.LocaleEnum); - + this.Translator.SetTranslations(translations); return this; } @@ -91,50 +71,7 @@ namespace StardewModdingAPI.Framework.ModHelpers /// <param name="localeEnum">The game's current language code.</param> internal void SetLocale(string locale, LocalizedContentManager.LanguageCode localeEnum) { - this.Locale = locale.ToLower().Trim(); - this.LocaleEnum = localeEnum; - - this.ForLocale = new Dictionary<string, Translation>(StringComparer.InvariantCultureIgnoreCase); - foreach (string next in this.GetRelevantLocales(this.Locale)) - { - // skip if locale not defined - if (!this.All.TryGetValue(next, out IDictionary<string, string> translations)) - continue; - - // add missing translations - foreach (var pair in translations) - { - if (!this.ForLocale.ContainsKey(pair.Key)) - this.ForLocale.Add(pair.Key, new Translation(this.ModName, this.Locale, pair.Key, pair.Value)); - } - } - } - - - /********* - ** Private methods - *********/ - /// <summary>Get the locales which can provide translations for the given locale, in precedence order.</summary> - /// <param name="locale">The locale for which to find valid locales.</param> - private IEnumerable<string> GetRelevantLocales(string locale) - { - // given locale - yield return locale; - - // broader locales (like pt-BR => pt) - while (true) - { - int dashIndex = locale.LastIndexOf('-'); - if (dashIndex <= 0) - break; - - locale = locale.Substring(0, dashIndex); - yield return locale; - } - - // default - if (locale != "default") - yield return "default"; + this.Translator.SetLocale(locale, localeEnum); } } } diff --git a/src/SMAPI/Framework/ModLoading/AssemblyLoader.cs b/src/SMAPI/Framework/ModLoading/AssemblyLoader.cs index 878b3148..7670eb3a 100644 --- a/src/SMAPI/Framework/ModLoading/AssemblyLoader.cs +++ b/src/SMAPI/Framework/ModLoading/AssemblyLoader.cs @@ -6,9 +6,9 @@ using System.Reflection; using Mono.Cecil; using Mono.Cecil.Cil; using StardewModdingAPI.Framework.Exceptions; -using StardewModdingAPI.Internal; using StardewModdingAPI.Metadata; using StardewModdingAPI.Toolkit.Framework.ModData; +using StardewModdingAPI.Toolkit.Utilities; namespace StardewModdingAPI.Framework.ModLoading { @@ -105,7 +105,7 @@ namespace StardewModdingAPI.Framework.ModLoading continue; // rewrite assembly - bool changed = this.RewriteAssembly(mod, assembly.Definition, assumeCompatible, loggedMessages, logPrefix: " "); + bool changed = this.RewriteAssembly(mod, assembly.Definition, loggedMessages, logPrefix: " "); // detect broken assembly reference foreach (AssemblyNameReference reference in assembly.Definition.MainModule.AssemblyReferences) @@ -114,7 +114,7 @@ namespace StardewModdingAPI.Framework.ModLoading { this.Monitor.LogOnce(loggedMessages, $" Broken code in {assembly.File.Name}: reference to missing assembly '{reference.FullName}'."); if (!assumeCompatible) - throw new IncompatibleInstructionException($"assembly reference to {reference.FullName}", $"Found a reference to missing assembly '{reference.FullName}' while loading assembly {assembly.File.Name}."); + throw new IncompatibleInstructionException($"Found a reference to missing assembly '{reference.FullName}' while loading assembly {assembly.File.Name}."); mod.SetWarning(ModWarning.BrokenCodeLoaded); break; } @@ -143,6 +143,10 @@ namespace StardewModdingAPI.Framework.ModLoading this.AssemblyDefinitionResolver.Add(assembly.Definition); } + // throw if incompatibilities detected + if (!assumeCompatible && mod.Warnings.HasFlag(ModWarning.BrokenCodeLoaded)) + throw new IncompatibleInstructionException(); + // last assembly loaded is the root return lastAssembly; } @@ -244,12 +248,11 @@ namespace StardewModdingAPI.Framework.ModLoading /// <summary>Rewrite the types referenced by an assembly.</summary> /// <param name="mod">The mod for which the assembly is being loaded.</param> /// <param name="assembly">The assembly to rewrite.</param> - /// <param name="assumeCompatible">Assume the mod is compatible, even if incompatible code is detected.</param> /// <param name="loggedMessages">The messages that have already been logged for this mod.</param> /// <param name="logPrefix">A string to prefix to log messages.</param> /// <returns>Returns whether the assembly was modified.</returns> /// <exception cref="IncompatibleInstructionException">An incompatible CIL instruction was found while rewriting the assembly.</exception> - private bool RewriteAssembly(IModMetadata mod, AssemblyDefinition assembly, bool assumeCompatible, HashSet<string> loggedMessages, string logPrefix) + private bool RewriteAssembly(IModMetadata mod, AssemblyDefinition assembly, HashSet<string> loggedMessages, string logPrefix) { ModuleDefinition module = assembly.MainModule; string filename = $"{assembly.Name.Name}.dll"; @@ -288,7 +291,7 @@ namespace StardewModdingAPI.Framework.ModLoading foreach (IInstructionHandler handler in handlers) { InstructionHandleResult result = handler.Handle(module, method, this.AssemblyMap, platformChanged); - this.ProcessInstructionHandleResult(mod, handler, result, loggedMessages, logPrefix, assumeCompatible, filename); + this.ProcessInstructionHandleResult(mod, handler, result, loggedMessages, logPrefix, filename); if (result == InstructionHandleResult.Rewritten) anyRewritten = true; } @@ -303,7 +306,7 @@ namespace StardewModdingAPI.Framework.ModLoading { Instruction instruction = instructions[offset]; InstructionHandleResult result = handler.Handle(module, cil, instruction, this.AssemblyMap, platformChanged); - this.ProcessInstructionHandleResult(mod, handler, result, loggedMessages, logPrefix, assumeCompatible, filename); + this.ProcessInstructionHandleResult(mod, handler, result, loggedMessages, logPrefix, filename); if (result == InstructionHandleResult.Rewritten) anyRewritten = true; } @@ -314,14 +317,13 @@ namespace StardewModdingAPI.Framework.ModLoading } /// <summary>Process the result from an instruction handler.</summary> - /// <param name="mod">The mod being analysed.</param> + /// <param name="mod">The mod being analyzed.</param> /// <param name="handler">The instruction handler.</param> /// <param name="result">The result returned by the handler.</param> /// <param name="loggedMessages">The messages already logged for the current mod.</param> - /// <param name="assumeCompatible">Assume the mod is compatible, even if incompatible code is detected.</param> /// <param name="logPrefix">A string to prefix to log messages.</param> /// <param name="filename">The assembly filename for log messages.</param> - private void ProcessInstructionHandleResult(IModMetadata mod, IInstructionHandler handler, InstructionHandleResult result, HashSet<string> loggedMessages, string logPrefix, bool assumeCompatible, string filename) + private void ProcessInstructionHandleResult(IModMetadata mod, IInstructionHandler handler, InstructionHandleResult result, HashSet<string> loggedMessages, string logPrefix, string filename) { switch (result) { @@ -331,8 +333,6 @@ namespace StardewModdingAPI.Framework.ModLoading case InstructionHandleResult.NotCompatible: this.Monitor.LogOnce(loggedMessages, $"{logPrefix}Broken code in {filename}: {handler.NounPhrase}."); - if (!assumeCompatible) - throw new IncompatibleInstructionException(handler.NounPhrase, $"Found an incompatible CIL instruction ({handler.NounPhrase}) while loading assembly {filename}."); mod.SetWarning(ModWarning.BrokenCodeLoaded); break; @@ -341,9 +341,9 @@ namespace StardewModdingAPI.Framework.ModLoading mod.SetWarning(ModWarning.PatchesGame); break; - case InstructionHandleResult.DetectedSaveSerialiser: - this.Monitor.LogOnce(loggedMessages, $"{logPrefix}Detected possible save serialiser change ({handler.NounPhrase}) in assembly {filename}."); - mod.SetWarning(ModWarning.ChangesSaveSerialiser); + case InstructionHandleResult.DetectedSaveSerializer: + this.Monitor.LogOnce(loggedMessages, $"{logPrefix}Detected possible save serializer change ({handler.NounPhrase}) in assembly {filename}."); + mod.SetWarning(ModWarning.ChangesSaveSerializer); break; case InstructionHandleResult.DetectedUnvalidatedUpdateTick: @@ -370,7 +370,7 @@ namespace StardewModdingAPI.Framework.ModLoading break; default: - throw new NotSupportedException($"Unrecognised instruction handler result '{result}'."); + throw new NotSupportedException($"Unrecognized instruction handler result '{result}'."); } } diff --git a/src/SMAPI/Framework/ModLoading/Finders/ReferenceToMemberWithUnexpectedTypeFinder.cs b/src/SMAPI/Framework/ModLoading/Finders/ReferenceToMemberWithUnexpectedTypeFinder.cs index 82c4920a..459e3210 100644 --- a/src/SMAPI/Framework/ModLoading/Finders/ReferenceToMemberWithUnexpectedTypeFinder.cs +++ b/src/SMAPI/Framework/ModLoading/Finders/ReferenceToMemberWithUnexpectedTypeFinder.cs @@ -80,10 +80,7 @@ namespace StardewModdingAPI.Framework.ModLoading.Finders // compare return types MethodDefinition methodDef = methodReference.Resolve(); if (methodDef == null) - { - this.NounPhrase = $"reference to {methodReference.DeclaringType.FullName}.{methodReference.Name} (no such method)"; - return InstructionHandleResult.NotCompatible; - } + return InstructionHandleResult.None; // validated by ReferenceToMissingMemberFinder if (candidateMethods.All(method => !RewriteHelper.LooksLikeSameType(method.ReturnType, methodDef.ReturnType))) { diff --git a/src/SMAPI/Framework/ModLoading/Finders/TypeFinder.cs b/src/SMAPI/Framework/ModLoading/Finders/TypeFinder.cs index 79045241..701b15f2 100644 --- a/src/SMAPI/Framework/ModLoading/Finders/TypeFinder.cs +++ b/src/SMAPI/Framework/ModLoading/Finders/TypeFinder.cs @@ -73,7 +73,7 @@ namespace StardewModdingAPI.Framework.ModLoading.Finders ** Protected methods *********/ /// <summary>Get whether a CIL instruction matches.</summary> - /// <param name="method">The method deifnition.</param> + /// <param name="method">The method definition.</param> protected bool IsMatch(MethodDefinition method) { if (this.IsMatch(method.ReturnType)) diff --git a/src/SMAPI/Framework/ModLoading/IncompatibleInstructionException.cs b/src/SMAPI/Framework/ModLoading/IncompatibleInstructionException.cs index 17ec24b1..1f9add30 100644 --- a/src/SMAPI/Framework/ModLoading/IncompatibleInstructionException.cs +++ b/src/SMAPI/Framework/ModLoading/IncompatibleInstructionException.cs @@ -6,30 +6,15 @@ namespace StardewModdingAPI.Framework.ModLoading internal class IncompatibleInstructionException : Exception { /********* - ** Accessors - *********/ - /// <summary>A brief noun phrase which describes the incompatible instruction that was found.</summary> - public string NounPhrase { get; } - - - /********* ** Public methods *********/ /// <summary>Construct an instance.</summary> - /// <param name="nounPhrase">A brief noun phrase which describes the incompatible instruction that was found.</param> - public IncompatibleInstructionException(string nounPhrase) - : base($"Found an incompatible CIL instruction ({nounPhrase}).") - { - this.NounPhrase = nounPhrase; - } + public IncompatibleInstructionException() + : base("Found incompatible CIL instructions.") { } /// <summary>Construct an instance.</summary> - /// <param name="nounPhrase">A brief noun phrase which describes the incompatible instruction that was found.</param> /// <param name="message">A message which describes the error.</param> - public IncompatibleInstructionException(string nounPhrase, string message) - : base(message) - { - this.NounPhrase = nounPhrase; - } + public IncompatibleInstructionException(string message) + : base(message) { } } } diff --git a/src/SMAPI/Framework/ModLoading/InstructionHandleResult.cs b/src/SMAPI/Framework/ModLoading/InstructionHandleResult.cs index 6592760e..d93b603d 100644 --- a/src/SMAPI/Framework/ModLoading/InstructionHandleResult.cs +++ b/src/SMAPI/Framework/ModLoading/InstructionHandleResult.cs @@ -18,12 +18,12 @@ namespace StardewModdingAPI.Framework.ModLoading DetectedGamePatch, /// <summary>The instruction is compatible, but affects the save serializer in a way that may make saves unloadable without the mod.</summary> - DetectedSaveSerialiser, + DetectedSaveSerializer, /// <summary>The instruction is compatible, but uses the <c>dynamic</c> keyword which won't work on Linux/Mac.</summary> DetectedDynamic, - /// <summary>The instruction is compatible, but references <see cref="ISpecialisedEvents.UnvalidatedUpdateTicking"/> or <see cref="ISpecialisedEvents.UnvalidatedUpdateTicked"/> which may impact stability.</summary> + /// <summary>The instruction is compatible, but references <see cref="ISpecializedEvents.UnvalidatedUpdateTicking"/> or <see cref="ISpecializedEvents.UnvalidatedUpdateTicked"/> which may impact stability.</summary> DetectedUnvalidatedUpdateTick, /// <summary>The instruction accesses the filesystem directly.</summary> diff --git a/src/SMAPI/Framework/ModLoading/ModDependencyStatus.cs b/src/SMAPI/Framework/ModLoading/ModDependencyStatus.cs index 0774b487..dd855d2f 100644 --- a/src/SMAPI/Framework/ModLoading/ModDependencyStatus.cs +++ b/src/SMAPI/Framework/ModLoading/ModDependencyStatus.cs @@ -1,4 +1,4 @@ -namespace StardewModdingAPI.Framework.ModLoading +namespace StardewModdingAPI.Framework.ModLoading { /// <summary>The status of a given mod in the dependency-sorting algorithm.</summary> internal enum ModDependencyStatus @@ -6,7 +6,7 @@ /// <summary>The mod hasn't been visited yet.</summary> Queued, - /// <summary>The mod is currently being analysed as part of a dependency chain.</summary> + /// <summary>The mod is currently being analyzed as part of a dependency chain.</summary> Checking, /// <summary>The mod has already been sorted.</summary> diff --git a/src/SMAPI/Framework/ModLoading/ModMetadata.cs b/src/SMAPI/Framework/ModLoading/ModMetadata.cs index 4ff021b7..7f788d17 100644 --- a/src/SMAPI/Framework/ModLoading/ModMetadata.cs +++ b/src/SMAPI/Framework/ModLoading/ModMetadata.cs @@ -1,9 +1,12 @@ using System; using System.Collections.Generic; +using System.IO; using System.Linq; +using StardewModdingAPI.Framework.ModHelpers; using StardewModdingAPI.Toolkit.Framework.Clients.WebApi; using StardewModdingAPI.Toolkit.Framework.ModData; using StardewModdingAPI.Toolkit.Framework.UpdateData; +using StardewModdingAPI.Toolkit.Utilities; namespace StardewModdingAPI.Framework.ModLoading { @@ -16,10 +19,13 @@ namespace StardewModdingAPI.Framework.ModLoading /// <summary>The mod's display name.</summary> public string DisplayName { get; } - /// <summary>The mod's full directory path.</summary> + /// <summary>The root path containing mods.</summary> + public string RootPath { get; } + + /// <summary>The mod's full directory path within the <see cref="RootPath"/>.</summary> public string DirectoryPath { get; } - /// <summary>The <see cref="IModMetadata.DirectoryPath"/> relative to the game's Mods folder.</summary> + /// <summary>The <see cref="DirectoryPath"/> relative to the <see cref="RootPath"/>.</summary> public string RelativeDirectoryPath { get; } /// <summary>The mod manifest.</summary> @@ -46,6 +52,9 @@ namespace StardewModdingAPI.Framework.ModLoading /// <summary>The content pack instance (if loaded and <see cref="IsContentPack"/> is true).</summary> public IContentPack ContentPack { get; private set; } + /// <summary>The translations for this mod (if loaded).</summary> + public TranslationHelper Translations { get; private set; } + /// <summary>Writes messages to the console and log file as this mod.</summary> public IMonitor Monitor { get; private set; } @@ -64,16 +73,17 @@ namespace StardewModdingAPI.Framework.ModLoading *********/ /// <summary>Construct an instance.</summary> /// <param name="displayName">The mod's display name.</param> - /// <param name="directoryPath">The mod's full directory path.</param> - /// <param name="relativeDirectoryPath">The <paramref name="directoryPath"/> relative to the game's Mods folder.</param> + /// <param name="directoryPath">The mod's full directory path within the <paramref name="rootPath"/>.</param> + /// <param name="rootPath">The root path containing mods.</param> /// <param name="manifest">The mod manifest.</param> /// <param name="dataRecord">Metadata about the mod from SMAPI's internal data (if any).</param> /// <param name="isIgnored">Whether the mod folder should be ignored. This should be <c>true</c> if it was found within a folder whose name starts with a dot.</param> - public ModMetadata(string displayName, string directoryPath, string relativeDirectoryPath, IManifest manifest, ModDataRecordVersionedFields dataRecord, bool isIgnored) + public ModMetadata(string displayName, string directoryPath, string rootPath, IManifest manifest, ModDataRecordVersionedFields dataRecord, bool isIgnored) { this.DisplayName = displayName; this.DirectoryPath = directoryPath; - this.RelativeDirectoryPath = relativeDirectoryPath; + this.RootPath = rootPath; + this.RelativeDirectoryPath = PathUtilities.GetRelativePath(this.RootPath, this.DirectoryPath); this.Manifest = manifest; this.DataRecord = dataRecord; this.IsIgnored = isIgnored; @@ -100,26 +110,30 @@ namespace StardewModdingAPI.Framework.ModLoading /// <summary>Set the mod instance.</summary> /// <param name="mod">The mod instance to set.</param> - public IModMetadata SetMod(IMod mod) + /// <param name="translations">The translations for this mod (if loaded).</param> + public IModMetadata SetMod(IMod mod, TranslationHelper translations) { if (this.ContentPack != null) throw new InvalidOperationException("A mod can't be both an assembly mod and content pack."); this.Mod = mod; this.Monitor = mod.Monitor; + this.Translations = translations; return this; } /// <summary>Set the mod instance.</summary> /// <param name="contentPack">The contentPack instance to set.</param> /// <param name="monitor">Writes messages to the console and log file.</param> - public IModMetadata SetMod(IContentPack contentPack, IMonitor monitor) + /// <param name="translations">The translations for this mod (if loaded).</param> + public IModMetadata SetMod(IContentPack contentPack, IMonitor monitor, TranslationHelper translations) { if (this.Mod != null) throw new InvalidOperationException("A mod can't be both an assembly mod and content pack."); this.ContentPack = contentPack; this.Monitor = monitor; + this.Translations = translations; return this; } @@ -188,5 +202,12 @@ namespace StardewModdingAPI.Framework.ModLoading this.Warnings.HasFlag(warning) && (this.DataRecord?.DataRecord == null || !this.DataRecord.DataRecord.SuppressWarnings.HasFlag(warning)); } + + /// <summary>Get a relative path which includes the root folder name.</summary> + public string GetRelativePathWithRoot() + { + string rootFolderName = Path.GetFileName(this.RootPath) ?? ""; + return Path.Combine(rootFolderName, this.RelativeDirectoryPath); + } } } diff --git a/src/SMAPI/Framework/ModLoading/ModResolver.cs b/src/SMAPI/Framework/ModLoading/ModResolver.cs index 75d3849d..5ea21710 100644 --- a/src/SMAPI/Framework/ModLoading/ModResolver.cs +++ b/src/SMAPI/Framework/ModLoading/ModResolver.cs @@ -5,7 +5,7 @@ using System.Linq; using StardewModdingAPI.Toolkit; using StardewModdingAPI.Toolkit.Framework.ModData; using StardewModdingAPI.Toolkit.Framework.ModScanning; -using StardewModdingAPI.Toolkit.Serialisation.Models; +using StardewModdingAPI.Toolkit.Serialization.Models; using StardewModdingAPI.Toolkit.Utilities; namespace StardewModdingAPI.Framework.ModLoading @@ -38,13 +38,13 @@ namespace StardewModdingAPI.Framework.ModLoading } // build metadata - ModMetadataStatus status = folder.ManifestParseError == null || !folder.ShouldBeLoaded + bool shouldIgnore = folder.Type == ModType.Ignored; + ModMetadataStatus status = folder.ManifestParseError == ModParseError.None || shouldIgnore ? ModMetadataStatus.Found : ModMetadataStatus.Failed; - string relativePath = PathUtilities.GetRelativePath(rootPath, folder.Directory.FullName); - yield return new ModMetadata(folder.DisplayName, folder.Directory.FullName, relativePath, manifest, dataRecord, isIgnored: !folder.ShouldBeLoaded) - .SetStatus(status, !folder.ShouldBeLoaded ? "disabled by dot convention" : folder.ManifestParseError); + yield return new ModMetadata(folder.DisplayName, folder.Directory.FullName, rootPath, manifest, dataRecord, isIgnored: shouldIgnore) + .SetStatus(status, shouldIgnore ? "disabled by dot convention" : folder.ManifestParseErrorText); } } @@ -143,16 +143,12 @@ namespace StardewModdingAPI.Framework.ModLoading continue; } - // invalid capitalisation + // invalid capitalization string actualFilename = new DirectoryInfo(mod.DirectoryPath).GetFiles(mod.Manifest.EntryDll).FirstOrDefault()?.Name; if (actualFilename != mod.Manifest.EntryDll) { -#if SMAPI_3_0_STRICT - mod.SetStatus(ModMetadataStatus.Failed, $"its {nameof(IManifest.EntryDll)} value '{mod.Manifest.EntryDll}' doesn't match the actual file capitalisation '{actualFilename}'. The capitalisation must match for crossplatform compatibility."); + mod.SetStatus(ModMetadataStatus.Failed, $"its {nameof(IManifest.EntryDll)} value '{mod.Manifest.EntryDll}' doesn't match the actual file capitalization '{actualFilename}'. The capitalization must match for crossplatform compatibility."); continue; -#else - SCore.DeprecationManager.Warn(mod.DisplayName, $"{nameof(IManifest.EntryDll)} value with case-insensitive capitalisation", "2.11", DeprecationLevel.PendingRemoval); -#endif } } @@ -202,7 +198,14 @@ namespace StardewModdingAPI.Framework.ModLoading { if (mod.Status == ModMetadataStatus.Failed) continue; // don't replace metadata error - mod.SetStatus(ModMetadataStatus.Failed, $"you have multiple copies of this mod installed ({string.Join(", ", group.Select(p => p.RelativeDirectoryPath).OrderBy(p => p))})."); + + string folderList = string.Join(", ", + from entry in @group + let relativePath = entry.GetRelativePathWithRoot() + orderby relativePath + select $"{relativePath} ({entry.Manifest.Version})" + ); + mod.SetStatus(ModMetadataStatus.Failed, $"you have multiple copies of this mod installed. Found in folders: {folderList}."); } } } @@ -213,7 +216,7 @@ namespace StardewModdingAPI.Framework.ModLoading /// <param name="modDatabase">Handles access to SMAPI's internal mod metadata list.</param> public IEnumerable<IModMetadata> ProcessDependencies(IEnumerable<IModMetadata> mods, ModDatabase modDatabase) { - // initialise metadata + // initialize metadata mods = mods.ToArray(); var sortedMods = new Stack<IModMetadata>(); var states = mods.ToDictionary(mod => mod, mod => ModDependencyStatus.Queued); diff --git a/src/SMAPI/Framework/ModLoading/PlatformAssemblyMap.cs b/src/SMAPI/Framework/ModLoading/PlatformAssemblyMap.cs index 01460dce..d4366294 100644 --- a/src/SMAPI/Framework/ModLoading/PlatformAssemblyMap.cs +++ b/src/SMAPI/Framework/ModLoading/PlatformAssemblyMap.cs @@ -3,7 +3,7 @@ using System.Collections.Generic; using System.Linq; using System.Reflection; using Mono.Cecil; -using StardewModdingAPI.Internal; +using StardewModdingAPI.Toolkit.Utilities; namespace StardewModdingAPI.Framework.ModLoading { diff --git a/src/SMAPI/Framework/ModLoading/TypeReferenceComparer.cs b/src/SMAPI/Framework/ModLoading/TypeReferenceComparer.cs index f7497789..a4ac54e2 100644 --- a/src/SMAPI/Framework/ModLoading/TypeReferenceComparer.cs +++ b/src/SMAPI/Framework/ModLoading/TypeReferenceComparer.cs @@ -54,7 +54,7 @@ namespace StardewModdingAPI.Framework.ModLoading { bool HeuristicallyEquals(string typeNameA, string typeNameB, IDictionary<string, string> tokenMap) { - // analyse type names + // analyze type names bool hasTokensA = typeNameA.Contains("!"); bool hasTokensB = typeNameB.Contains("!"); bool isTokenA = hasTokensA && typeNameA[0] == '!'; diff --git a/src/SMAPI/Framework/ModRegistry.cs b/src/SMAPI/Framework/ModRegistry.cs index 5be33cb4..ef389337 100644 --- a/src/SMAPI/Framework/ModRegistry.cs +++ b/src/SMAPI/Framework/ModRegistry.cs @@ -21,8 +21,8 @@ namespace StardewModdingAPI.Framework /// <summary>Whether all mod assemblies have been loaded.</summary> public bool AreAllModsLoaded { get; set; } - /// <summary>Whether all mods have been initialised and their <see cref="IMod.Entry"/> method called.</summary> - public bool AreAllModsInitialised { get; set; } + /// <summary>Whether all mods have been initialized and their <see cref="IMod.Entry"/> method called.</summary> + public bool AreAllModsInitialized { get; set; } /********* @@ -62,7 +62,7 @@ namespace StardewModdingAPI.Framework /// <returns>Returns the matching mod's metadata, or <c>null</c> if not found.</returns> public IModMetadata Get(string uniqueID) { - // normalise search ID + // normalize search ID if (string.IsNullOrWhiteSpace(uniqueID)) return null; uniqueID = uniqueID.Trim(); diff --git a/src/SMAPI/Framework/Models/SConfig.cs b/src/SMAPI/Framework/Models/SConfig.cs index e2b33160..b778af5d 100644 --- a/src/SMAPI/Framework/Models/SConfig.cs +++ b/src/SMAPI/Framework/Models/SConfig.cs @@ -1,3 +1,6 @@ +using System; +using System.Collections.Generic; +using System.Linq; using StardewModdingAPI.Internal.ConsoleWriting; namespace StardewModdingAPI.Framework.Models @@ -6,6 +9,35 @@ namespace StardewModdingAPI.Framework.Models internal class SConfig { /******** + ** Fields + ********/ + /// <summary>The default config values, for fields that should be logged if different.</summary> + private static readonly IDictionary<string, object> DefaultValues = new Dictionary<string, object> + { + [nameof(CheckForUpdates)] = true, + [nameof(ParanoidWarnings)] = +#if DEBUG + true, +#else + false, +#endif + [nameof(UseBetaChannel)] = Constants.ApiVersion.IsPrerelease(), + [nameof(GitHubProjectName)] = "Pathoschild/SMAPI", + [nameof(WebApiBaseUrl)] = "https://api.smapi.io", + [nameof(VerboseLogging)] = false, + [nameof(LogNetworkTraffic)] = false, + [nameof(DumpMetadata)] = false + }; + + /// <summary>The default values for <see cref="SuppressUpdateChecks"/>, to log changes if different.</summary> + private static readonly HashSet<string> DefaultSuppressUpdateChecks = new HashSet<string>(StringComparer.InvariantCultureIgnoreCase) + { + "SMAPI.ConsoleCommands", + "SMAPI.SaveBackup" + }; + + + /******** ** Accessors ********/ /// <summary>Whether to enable development features.</summary> @@ -15,15 +47,10 @@ namespace StardewModdingAPI.Framework.Models public bool CheckForUpdates { get; set; } /// <summary>Whether to add a section to the 'mod issues' list for mods which which directly use potentially sensitive .NET APIs like file or shell access.</summary> - public bool ParanoidWarnings { get; set; } = -#if DEBUG - true; -#else - false; -#endif + public bool ParanoidWarnings { get; set; } = (bool)SConfig.DefaultValues[nameof(SConfig.ParanoidWarnings)]; /// <summary>Whether to show beta versions as valid updates.</summary> - public bool UseBetaChannel { get; set; } = Constants.ApiVersion.IsPrerelease(); + public bool UseBetaChannel { get; set; } = (bool)SConfig.DefaultValues[nameof(SConfig.UseBetaChannel)]; /// <summary>SMAPI's GitHub project name, used to perform update checks.</summary> public string GitHubProjectName { get; set; } @@ -34,13 +61,39 @@ namespace StardewModdingAPI.Framework.Models /// <summary>Whether SMAPI should log more information about the game context.</summary> public bool VerboseLogging { get; set; } + /// <summary>Whether SMAPI should log network traffic. Best combined with <see cref="VerboseLogging"/>, which includes network metadata.</summary> + public bool LogNetworkTraffic { get; set; } + /// <summary>Whether to generate a file in the mods folder with detailed metadata about the detected mods.</summary> public bool DumpMetadata { get; set; } - /// <summary>The console color scheme to use.</summary> - public MonitorColorScheme ColorScheme { get; set; } + /// <summary>The colors to use for text written to the SMAPI console.</summary> + public ColorSchemeConfig ConsoleColors { get; set; } /// <summary>The mod IDs SMAPI should ignore when performing update checks or validating update keys.</summary> public string[] SuppressUpdateChecks { get; set; } + + + /******** + ** Public methods + ********/ + /// <summary>Get the settings which have been customised by the player.</summary> + public IDictionary<string, object> GetCustomSettings() + { + IDictionary<string, object> custom = new Dictionary<string, object>(); + + foreach (var pair in SConfig.DefaultValues) + { + object value = typeof(SConfig).GetProperty(pair.Key)?.GetValue(this); + if (!pair.Value.Equals(value)) + custom[pair.Key] = value; + } + + HashSet<string> curSuppressUpdateChecks = new HashSet<string>(this.SuppressUpdateChecks ?? new string[0], StringComparer.InvariantCultureIgnoreCase); + if (SConfig.DefaultSuppressUpdateChecks.Count != curSuppressUpdateChecks.Count || SConfig.DefaultSuppressUpdateChecks.Any(p => !curSuppressUpdateChecks.Contains(p))) + custom[nameof(this.SuppressUpdateChecks)] = "[" + string.Join(", ", this.SuppressUpdateChecks ?? new string[0]) + "]"; + + return custom; + } } } diff --git a/src/SMAPI/Framework/Monitor.cs b/src/SMAPI/Framework/Monitor.cs index 617bfd85..06cf1b46 100644 --- a/src/SMAPI/Framework/Monitor.cs +++ b/src/SMAPI/Framework/Monitor.cs @@ -1,6 +1,5 @@ using System; using System.Linq; -using System.Threading; using StardewModdingAPI.Framework.Logging; using StardewModdingAPI.Internal.ConsoleWriting; @@ -27,16 +26,10 @@ namespace StardewModdingAPI.Framework /// <summary>The maximum length of the <see cref="LogLevel"/> values.</summary> private static readonly int MaxLevelLength = (from level in Enum.GetValues(typeof(LogLevel)).Cast<LogLevel>() select level.ToString().Length).Max(); - /// <summary>Propagates notification that SMAPI should exit.</summary> - private readonly CancellationTokenSource ExitTokenSource; - /********* ** Accessors *********/ - /// <summary>Whether SMAPI is aborting. Mods don't need to worry about this unless they have background tasks.</summary> - public bool IsExiting => this.ExitTokenSource.IsCancellationRequested; - /// <summary>Whether verbose logging is enabled. This enables more detailed diagnostic messages than are normally needed.</summary> public bool IsVerbose { get; } @@ -57,21 +50,19 @@ namespace StardewModdingAPI.Framework /// <param name="source">The name of the module which logs messages using this instance.</param> /// <param name="consoleInterceptor">Intercepts access to the console output.</param> /// <param name="logFile">The log file to which to write messages.</param> - /// <param name="exitTokenSource">Propagates notification that SMAPI should exit.</param> - /// <param name="colorScheme">The console color scheme to use.</param> + /// <param name="colorConfig">The colors to use for text written to the SMAPI console.</param> /// <param name="isVerbose">Whether verbose logging is enabled. This enables more detailed diagnostic messages than are normally needed.</param> - public Monitor(string source, ConsoleInterceptionManager consoleInterceptor, LogFileManager logFile, CancellationTokenSource exitTokenSource, MonitorColorScheme colorScheme, bool isVerbose) + public Monitor(string source, ConsoleInterceptionManager consoleInterceptor, LogFileManager logFile, ColorSchemeConfig colorConfig, bool isVerbose) { // validate if (string.IsNullOrWhiteSpace(source)) throw new ArgumentException("The log source cannot be empty."); - // initialise + // initialize this.Source = source; this.LogFile = logFile ?? throw new ArgumentNullException(nameof(logFile), "The log file manager cannot be null."); - this.ConsoleWriter = new ColorfulConsoleWriter(Constants.Platform, colorScheme); + this.ConsoleWriter = new ColorfulConsoleWriter(Constants.Platform, colorConfig); this.ConsoleInterceptor = consoleInterceptor; - this.ExitTokenSource = exitTokenSource; this.IsVerbose = isVerbose; } @@ -91,14 +82,6 @@ namespace StardewModdingAPI.Framework this.Log(message, LogLevel.Trace); } - /// <summary>Immediately exit the game without saving. This should only be invoked when an irrecoverable fatal error happens that risks save corruption or game-breaking bugs.</summary> - /// <param name="reason">The reason for the shutdown.</param> - public void ExitGameImmediately(string reason) - { - this.LogFatal($"{this.Source} requested an immediate game shutdown: {reason}"); - this.ExitTokenSource.Cancel(); - } - /// <summary>Write a newline to the console and log file.</summary> internal void Newline() { @@ -107,6 +90,13 @@ namespace StardewModdingAPI.Framework this.LogFile.WriteLine(""); } + /// <summary>Log a fatal error message.</summary> + /// <param name="message">The message to log.</param> + internal void LogFatal(string message) + { + this.LogImpl(this.Source, message, ConsoleLogLevel.Critical); + } + /// <summary>Log console input from the user.</summary> /// <param name="input">The user input to log.</param> internal void LogUserInput(string input) @@ -120,13 +110,6 @@ namespace StardewModdingAPI.Framework /********* ** Private methods *********/ - /// <summary>Log a fatal error message.</summary> - /// <param name="message">The message to log.</param> - private void LogFatal(string message) - { - this.LogImpl(this.Source, message, ConsoleLogLevel.Critical); - } - /// <summary>Write a message line to the log.</summary> /// <param name="source">The name of the mod logging the message.</param> /// <param name="message">The message to log.</param> diff --git a/src/SMAPI/Framework/Networking/MessageType.cs b/src/SMAPI/Framework/Networking/MessageType.cs index bd9acfa9..4e1388ca 100644 --- a/src/SMAPI/Framework/Networking/MessageType.cs +++ b/src/SMAPI/Framework/Networking/MessageType.cs @@ -2,7 +2,7 @@ using StardewValley; namespace StardewModdingAPI.Framework.Networking { - /// <summary>Network message types recognised by SMAPI and Stardew Valley.</summary> + /// <summary>Network message types recognized by SMAPI and Stardew Valley.</summary> internal enum MessageType : byte { /********* diff --git a/src/SMAPI/Framework/Networking/SGalaxyNetServer.cs b/src/SMAPI/Framework/Networking/SGalaxyNetServer.cs index bb67f70e..7dbfa767 100644 --- a/src/SMAPI/Framework/Networking/SGalaxyNetServer.cs +++ b/src/SMAPI/Framework/Networking/SGalaxyNetServer.cs @@ -58,7 +58,7 @@ namespace StardewModdingAPI.Framework.Networking { NetFarmerRoot farmer = this.Multiplayer.readFarmer(message.Reader); GalaxyID capturedPeer = new GalaxyID(peerID); - this.gameServer.checkFarmhandRequest(Convert.ToString(peerID), farmer, msg => this.sendMessage(capturedPeer, msg), () => this.peers[farmer.Value.UniqueMultiplayerID] = capturedPeer.ToUint64()); + this.gameServer.checkFarmhandRequest(Convert.ToString(peerID), this.getConnectionId(peer), farmer, msg => this.sendMessage(capturedPeer, msg), () => this.peers[farmer.Value.UniqueMultiplayerID] = capturedPeer.ToUint64()); } }); } diff --git a/src/SMAPI/Framework/Networking/SLidgrenServer.cs b/src/SMAPI/Framework/Networking/SLidgrenServer.cs index 1bce47fe..f2c61917 100644 --- a/src/SMAPI/Framework/Networking/SLidgrenServer.cs +++ b/src/SMAPI/Framework/Networking/SLidgrenServer.cs @@ -33,6 +33,10 @@ namespace StardewModdingAPI.Framework.Networking this.OnProcessingMessage = onProcessingMessage; } + + /********* + ** Protected methods + *********/ /// <summary>Parse a data message from a client.</summary> /// <param name="rawMessage">The raw network message to parse.</param> [SuppressMessage("ReSharper", "AccessToDisposedClosure", Justification = "The callback is invoked synchronously.")] @@ -55,7 +59,7 @@ namespace StardewModdingAPI.Framework.Networking else if (message.MessageType == StardewValley.Multiplayer.playerIntroduction) { NetFarmerRoot farmer = this.Multiplayer.readFarmer(message.Reader); - this.gameServer.checkFarmhandRequest("", farmer, msg => this.sendMessage(peer, msg), () => this.peers[farmer.Value.UniqueMultiplayerID] = peer); + this.gameServer.checkFarmhandRequest("", this.getConnectionId(rawMessage.SenderConnection), farmer, msg => this.sendMessage(peer, msg), () => this.peers[farmer.Value.UniqueMultiplayerID] = peer); } }); } diff --git a/src/SMAPI/Framework/Reflection/Reflector.cs b/src/SMAPI/Framework/Reflection/Reflector.cs index ed1a4381..d4904878 100644 --- a/src/SMAPI/Framework/Reflection/Reflector.cs +++ b/src/SMAPI/Framework/Reflection/Reflector.cs @@ -6,7 +6,7 @@ using System.Runtime.Caching; namespace StardewModdingAPI.Framework.Reflection { /// <summary>Provides helper methods for accessing inaccessible code.</summary> - /// <remarks>This implementation searches up the type hierarchy, and caches the reflected fields and methods with a sliding expiry (to optimise performance without unnecessary memory usage).</remarks> + /// <remarks>This implementation searches up the type hierarchy, and caches the reflected fields and methods with a sliding expiry (to optimize performance without unnecessary memory usage).</remarks> internal class Reflector { /********* diff --git a/src/SMAPI/Framework/SCore.cs b/src/SMAPI/Framework/SCore.cs index 5dd52992..afb82679 100644 --- a/src/SMAPI/Framework/SCore.cs +++ b/src/SMAPI/Framework/SCore.cs @@ -24,13 +24,12 @@ using StardewModdingAPI.Framework.ModHelpers; using StardewModdingAPI.Framework.ModLoading; using StardewModdingAPI.Framework.Patching; using StardewModdingAPI.Framework.Reflection; -using StardewModdingAPI.Framework.Serialisation; -using StardewModdingAPI.Internal; +using StardewModdingAPI.Framework.Serialization; using StardewModdingAPI.Patches; using StardewModdingAPI.Toolkit; using StardewModdingAPI.Toolkit.Framework.Clients.WebApi; using StardewModdingAPI.Toolkit.Framework.ModData; -using StardewModdingAPI.Toolkit.Serialisation; +using StardewModdingAPI.Toolkit.Serialization; using StardewModdingAPI.Toolkit.Utilities; using StardewValley; using Object = StardewValley.Object; @@ -38,7 +37,7 @@ using ThreadState = System.Threading.ThreadState; namespace StardewModdingAPI.Framework { - /// <summary>The core class which initialises and manages SMAPI.</summary> + /// <summary>The core class which initializes and manages SMAPI.</summary> internal class SCore : IDisposable { /********* @@ -56,12 +55,15 @@ namespace StardewModdingAPI.Framework /// <summary>The core logger and monitor on behalf of the game.</summary> private readonly Monitor MonitorForGame; - /// <summary>Tracks whether the game should exit immediately and any pending initialisation should be cancelled.</summary> - private readonly CancellationTokenSource CancellationTokenSource = new CancellationTokenSource(); + /// <summary>Tracks whether the game should exit immediately and any pending initialization should be cancelled.</summary> + private readonly CancellationTokenSource CancellationToken = new CancellationTokenSource(); /// <summary>Simplifies access to private game code.</summary> private readonly Reflector Reflection = new Reflector(); + /// <summary>Encapsulates access to SMAPI core translations.</summary> + private readonly Translator Translator = new Translator(); + /// <summary>The SMAPI configuration settings.</summary> private readonly SConfig Settings; @@ -72,7 +74,7 @@ namespace StardewModdingAPI.Framework private ContentCoordinator ContentCore => this.GameInstance.ContentCore; /// <summary>Tracks the installed mods.</summary> - /// <remarks>This is initialised after the game starts.</remarks> + /// <remarks>This is initialized after the game starts.</remarks> private readonly ModRegistry ModRegistry = new ModRegistry(); /// <summary>Manages SMAPI events for mods.</summary> @@ -84,15 +86,14 @@ namespace StardewModdingAPI.Framework /// <summary>Whether the program has been disposed.</summary> private bool IsDisposed; - /// <summary>Regex patterns which match console messages to suppress from the console and log.</summary> + /// <summary>Regex patterns which match console non-error messages to suppress from the console and log.</summary> private readonly Regex[] SuppressConsolePatterns = { new Regex(@"^TextBox\.Selected is now '(?:True|False)'\.$", RegexOptions.Compiled | RegexOptions.CultureInvariant), new Regex(@"^(?:FRUIT )?TREE: IsClient:(?:True|False) randomOutput: \d+$", RegexOptions.Compiled | RegexOptions.CultureInvariant), new Regex(@"^loadPreferences\(\); begin", RegexOptions.Compiled | RegexOptions.CultureInvariant), new Regex(@"^savePreferences\(\); async=", RegexOptions.Compiled | RegexOptions.CultureInvariant), - new Regex(@"^DebugOutput:\s+(?:added CLOUD|added cricket|dismount tile|Ping|playerPos)", RegexOptions.Compiled | RegexOptions.CultureInvariant), - new Regex(@"^static SerializableDictionary<.+>\(\) called\.$", RegexOptions.Compiled | RegexOptions.CultureInvariant), + new Regex(@"^DebugOutput:\s+(?:added CLOUD|added cricket|dismount tile|Ping|playerPos)", RegexOptions.Compiled | RegexOptions.CultureInvariant) }; /// <summary>Regex patterns which match console messages to show a more friendly error for.</summary> @@ -120,7 +121,7 @@ namespace StardewModdingAPI.Framework ** Accessors *********/ /// <summary>Manages deprecation warnings.</summary> - /// <remarks>This is initialised after the game starts. This is accessed directly because it's not part of the normal class model.</remarks> + /// <remarks>This is initialized after the game starts. This is accessed directly because it's not part of the normal class model.</remarks> internal static DeprecationManager DeprecationManager { get; private set; } @@ -144,7 +145,7 @@ namespace StardewModdingAPI.Framework // init basics this.Settings = JsonConvert.DeserializeObject<SConfig>(File.ReadAllText(Constants.ApiConfigPath)); this.LogFile = new LogFileManager(logPath); - this.Monitor = new Monitor("SMAPI", this.ConsoleManager, this.LogFile, this.CancellationTokenSource, this.Settings.ColorScheme, this.Settings.VerboseLogging) + this.Monitor = new Monitor("SMAPI", this.ConsoleManager, this.LogFile, this.Settings.ConsoleColors, this.Settings.VerboseLogging) { WriteToConsole = writeToConsole, ShowTraceInConsole = this.Settings.DeveloperMode, @@ -165,6 +166,13 @@ namespace StardewModdingAPI.Framework this.Monitor.Log("(Using custom --mods-path argument.)", LogLevel.Trace); this.Monitor.Log($"Log started at {DateTime.UtcNow:s} UTC", LogLevel.Trace); + // log custom settings + { + IDictionary<string, object> customSettings = this.Settings.GetCustomSettings(); + if (customSettings.Any()) + this.Monitor.Log($"Loaded with custom settings: {string.Join(", ", customSettings.OrderBy(p => p.Key).Select(p => $"{p.Key}: {p.Value}"))}", LogLevel.Trace); + } + // validate platform #if SMAPI_FOR_WINDOWS if (Constants.Platform != Platform.Windows) @@ -187,27 +195,9 @@ namespace StardewModdingAPI.Framework [HandleProcessCorruptedStateExceptions, SecurityCritical] // let try..catch handle corrupted state exceptions public void RunInteractively() { - // initialise SMAPI + // initialize SMAPI try { -#if !SMAPI_3_0_STRICT - // hook up events - ContentEvents.Init(this.EventManager); - ControlEvents.Init(this.EventManager); - GameEvents.Init(this.EventManager); - GraphicsEvents.Init(this.EventManager); - InputEvents.Init(this.EventManager); - LocationEvents.Init(this.EventManager); - MenuEvents.Init(this.EventManager); - MineEvents.Init(this.EventManager); - MultiplayerEvents.Init(this.EventManager); - PlayerEvents.Init(this.EventManager); - SaveEvents.Init(this.EventManager); - SpecialisedEvents.Init(this.EventManager); - TimeEvents.Init(this.EventManager); -#endif - - // init JSON parser JsonConverter[] converters = { new ColorConverter(), new PointConverter(), @@ -223,12 +213,29 @@ namespace StardewModdingAPI.Framework #endif AppDomain.CurrentDomain.UnhandledException += (sender, e) => this.Monitor.Log($"Critical app domain exception: {e.ExceptionObject}", LogLevel.Error); - // add more leniant assembly resolvers + // add more lenient assembly resolvers AppDomain.CurrentDomain.AssemblyResolve += (sender, e) => AssemblyLoader.ResolveAssembly(e.Name); + // hook locale event + LocalizedContentManager.OnLanguageChange += locale => this.OnLocaleChanged(); + // override game - SGame.ConstructorHack = new SGameConstructorHack(this.Monitor, this.Reflection, this.Toolkit.JsonHelper); - this.GameInstance = new SGame(this.Monitor, this.MonitorForGame, this.Reflection, this.EventManager, this.Toolkit.JsonHelper, this.ModRegistry, SCore.DeprecationManager, this.OnLocaleChanged, this.InitialiseAfterGameStart, this.Dispose); + SGame.ConstructorHack = new SGameConstructorHack(this.Monitor, this.Reflection, this.Toolkit.JsonHelper, this.InitializeBeforeFirstAssetLoaded); + this.GameInstance = new SGame( + monitor: this.Monitor, + monitorForGame: this.MonitorForGame, + reflection: this.Reflection, + translator: this.Translator, + eventManager: this.EventManager, + jsonHelper: this.Toolkit.JsonHelper, + modRegistry: this.ModRegistry, + deprecationManager: SCore.DeprecationManager, + onGameInitialized: this.InitializeAfterGameStart, + onGameExiting: this.Dispose, + cancellationToken: this.CancellationToken, + logNetworkTraffic: this.Settings.LogNetworkTraffic + ); + this.Translator.SetLocale(this.GameInstance.ContentCore.GetLocale(), this.GameInstance.ContentCore.Language); StardewValley.Program.gamePtr = this.GameInstance; // apply game patches @@ -236,13 +243,14 @@ namespace StardewModdingAPI.Framework new EventErrorPatch(this.MonitorForGame), new DialogueErrorPatch(this.MonitorForGame, this.Reflection), new ObjectErrorPatch(), - new LoadContextPatch(this.Reflection, this.GameInstance.OnLoadStageChanged) + new LoadContextPatch(this.Reflection, this.GameInstance.OnLoadStageChanged), + new LoadErrorPatch(this.Monitor, this.GameInstance.OnSaveContentRemoved) ); // add exit handler new Thread(() => { - this.CancellationTokenSource.Token.WaitHandle.WaitOne(); + this.CancellationToken.Token.WaitHandle.WaitOne(); if (this.IsGameRunning) { try @@ -262,14 +270,10 @@ namespace StardewModdingAPI.Framework // set window titles this.GameInstance.Window.Title = $"Stardew Valley {Constants.GameVersion} - running SMAPI {Constants.ApiVersion}"; Console.Title = $"SMAPI {Constants.ApiVersion} - running Stardew Valley {Constants.GameVersion}"; -#if SMAPI_3_0_STRICT - this.GameInstance.Window.Title += " [SMAPI 3.0 strict mode]"; - Console.Title += " [SMAPI 3.0 strict mode]"; -#endif } catch (Exception ex) { - this.Monitor.Log($"SMAPI failed to initialise: {ex.GetLogSummary()}", LogLevel.Error); + this.Monitor.Log($"SMAPI failed to initialize: {ex.GetLogSummary()}", LogLevel.Error); this.PressAnyKeyToExit(); return; } @@ -302,6 +306,19 @@ namespace StardewModdingAPI.Framework File.Delete(Constants.FatalCrashMarker); } + // add headers + if (this.Settings.DeveloperMode) + this.Monitor.Log($"You have SMAPI for developers, so the console will be much more verbose. You can disable developer mode by installing the non-developer version of SMAPI, or by editing {Constants.ApiConfigPath}.", LogLevel.Info); + if (!this.Settings.CheckForUpdates) + this.Monitor.Log($"You configured SMAPI to not check for updates. Running an old version of SMAPI is not recommended. You can enable update checks by reinstalling SMAPI or editing {Constants.ApiConfigPath}.", LogLevel.Warn); + if (!this.Monitor.WriteToConsole) + this.Monitor.Log("Writing to the terminal is disabled because the --no-terminal argument was received. This usually means launching the terminal failed.", LogLevel.Warn); + this.Monitor.VerboseLog("Verbose logging enabled."); + + // update window titles + this.GameInstance.Window.Title = $"Stardew Valley {Constants.GameVersion} - running SMAPI {Constants.ApiVersion}"; + Console.Title = $"SMAPI {Constants.ApiVersion} - running Stardew Valley {Constants.GameVersion}"; + // start game this.Monitor.Log("Starting game...", LogLevel.Debug); try @@ -359,7 +376,7 @@ namespace StardewModdingAPI.Framework this.IsGameRunning = false; this.ConsoleManager?.Dispose(); this.ContentCore?.Dispose(); - this.CancellationTokenSource?.Dispose(); + this.CancellationToken?.Dispose(); this.GameInstance?.Dispose(); this.LogFile?.Dispose(); @@ -371,24 +388,14 @@ namespace StardewModdingAPI.Framework /********* ** Private methods *********/ - /// <summary>Initialise SMAPI and mods after the game starts.</summary> - private void InitialiseAfterGameStart() + /// <summary>Initialize mods before the first game asset is loaded. At this point the core content managers are loaded (so mods can load their own assets), but the game is mostly uninitialized.</summary> + private void InitializeBeforeFirstAssetLoaded() { - // add headers -#if SMAPI_3_0_STRICT - this.Monitor.Log($"You're running SMAPI 3.0 strict mode, so most mods won't work correctly. If that wasn't intended, install the normal version of SMAPI from https://smapi.io instead.", LogLevel.Warn); -#endif - if (this.Settings.DeveloperMode) - this.Monitor.Log($"You have SMAPI for developers, so the console will be much more verbose. You can disable developer mode by installing the non-developer version of SMAPI, or by editing {Constants.ApiConfigPath}.", LogLevel.Info); - if (!this.Settings.CheckForUpdates) - this.Monitor.Log($"You configured SMAPI to not check for updates. Running an old version of SMAPI is not recommended. You can enable update checks by reinstalling SMAPI or editing {Constants.ApiConfigPath}.", LogLevel.Warn); - if (!this.Monitor.WriteToConsole) - this.Monitor.Log("Writing to the terminal is disabled because the --no-terminal argument was received. This usually means launching the terminal failed.", LogLevel.Warn); - this.Monitor.VerboseLog("Verbose logging enabled."); - - // validate XNB integrity - if (!this.ValidateContentIntegrity()) - this.Monitor.Log("SMAPI found problems in your game's content files which are likely to cause errors or crashes. Consider uninstalling XNB mods or reinstalling the game.", LogLevel.Error); + if (this.CancellationToken.IsCancellationRequested) + { + this.Monitor.Log("SMAPI shutting down: aborting initialization.", LogLevel.Warn); + return; + } // load mod data ModToolkit toolkit = new ModToolkit(); @@ -399,12 +406,19 @@ namespace StardewModdingAPI.Framework this.Monitor.Log("Loading mod metadata...", LogLevel.Trace); ModResolver resolver = new ModResolver(); + // log loose files + { + string[] looseFiles = new DirectoryInfo(this.ModsPath).GetFiles().Select(p => p.Name).ToArray(); + if (looseFiles.Any()) + this.Monitor.Log($" Ignored loose files: {string.Join(", ", looseFiles.OrderBy(p => p, StringComparer.InvariantCultureIgnoreCase))}", LogLevel.Trace); + } + // load manifests IModMetadata[] mods = resolver.ReadManifests(toolkit, this.ModsPath, modDatabase).ToArray(); // filter out ignored mods foreach (IModMetadata mod in mods.Where(p => p.IsIgnored)) - this.Monitor.Log($" Skipped {mod.RelativeDirectoryPath} (folder name starts with a dot).", LogLevel.Trace); + this.Monitor.Log($" Skipped {mod.GetRelativePathWithRoot()} (folder name starts with a dot).", LogLevel.Trace); mods = mods.Where(p => !p.IsIgnored).ToArray(); // load mods @@ -429,21 +443,19 @@ namespace StardewModdingAPI.Framework // check for updates this.CheckForUpdatesAsync(mods); } - if (this.Monitor.IsExiting) - { - this.Monitor.Log("SMAPI shutting down: aborting initialisation.", LogLevel.Warn); - return; - } // update window titles int modsLoaded = this.ModRegistry.GetAll().Count(); this.GameInstance.Window.Title = $"Stardew Valley {Constants.GameVersion} - running SMAPI {Constants.ApiVersion} with {modsLoaded} mods"; Console.Title = $"SMAPI {Constants.ApiVersion} - running Stardew Valley {Constants.GameVersion} with {modsLoaded} mods"; -#if SMAPI_3_0_STRICT - this.GameInstance.Window.Title += " [SMAPI 3.0 strict mode]"; - Console.Title += " [SMAPI 3.0 strict mode]"; -#endif + } + /// <summary>Initialize SMAPI and mods after the game starts.</summary> + private void InitializeAfterGameStart() + { + // validate XNB integrity + if (!this.ValidateContentIntegrity()) + this.Monitor.Log("SMAPI found problems in your game's content files which are likely to cause errors or crashes. Consider uninstalling XNB mods or reinstalling the game.", LogLevel.Error); // start SMAPI console new Thread(this.RunConsoleLoop).Start(); @@ -452,13 +464,18 @@ namespace StardewModdingAPI.Framework /// <summary>Handle the game changing locale.</summary> private void OnLocaleChanged() { + this.ContentCore.OnLocaleChanged(); + // get locale string locale = this.ContentCore.GetLocale(); LocalizedContentManager.LanguageCode languageCode = this.ContentCore.Language; + // update core translations + this.Translator.SetLocale(locale, languageCode); + // update mod translation helpers - foreach (IModMetadata mod in this.ModRegistry.GetAll(contentPacks: false)) - (mod.Mod.Helper.Translation as TranslationHelper)?.SetLocale(locale, languageCode); + foreach (IModMetadata mod in this.ModRegistry.GetAll()) + mod.Translations.SetLocale(locale, languageCode); } /// <summary>Run a loop handling console input.</summary> @@ -488,7 +505,7 @@ namespace StardewModdingAPI.Framework inputThread.Start(); // keep console thread alive while the game is running - while (this.IsGameRunning && !this.Monitor.IsExiting) + while (this.IsGameRunning && !this.CancellationToken.IsCancellationRequested) Thread.Sleep(1000 / 10); if (inputThread.ThreadState == ThreadState.Running) inputThread.Abort(); @@ -570,27 +587,19 @@ namespace StardewModdingAPI.Framework ISemanticVersion updateFound = null; try { - ModEntryModel response = client.GetModInfo(new[] { new ModSearchEntryModel("Pathoschild.SMAPI", new[] { $"GitHub:{this.Settings.GitHubProjectName}" }) }).Single().Value; - ISemanticVersion latestStable = response.Main?.Version; - ISemanticVersion latestBeta = response.Optional?.Version; + // fetch update check + ModEntryModel response = client.GetModInfo(new[] { new ModSearchEntryModel("Pathoschild.SMAPI", Constants.ApiVersion, new[] { $"GitHub:{this.Settings.GitHubProjectName}" }) }, apiVersion: Constants.ApiVersion, gameVersion: Constants.GameVersion, platform: Constants.Platform).Single().Value; + if (response.SuggestedUpdate != null) + this.Monitor.Log($"You can update SMAPI to {response.SuggestedUpdate.Version}: {Constants.HomePageUrl}", LogLevel.Alert); + else + this.Monitor.Log(" SMAPI okay.", LogLevel.Trace); - if (latestStable == null && response.Errors.Any()) + // show errors + if (response.Errors.Any()) { this.Monitor.Log("Couldn't check for a new version of SMAPI. This won't affect your game, but you may not be notified of new versions if this keeps happening.", LogLevel.Warn); this.Monitor.Log($"Error: {string.Join("\n", response.Errors)}", LogLevel.Trace); } - else if (this.IsValidUpdate(Constants.ApiVersion, latestBeta, this.Settings.UseBetaChannel)) - { - updateFound = latestBeta; - this.Monitor.Log($"You can update SMAPI to {latestBeta}: {Constants.HomePageUrl}", LogLevel.Alert); - } - else if (this.IsValidUpdate(Constants.ApiVersion, latestStable, this.Settings.UseBetaChannel)) - { - updateFound = latestStable; - this.Monitor.Log($"You can update SMAPI to {latestStable}: {Constants.HomePageUrl}", LogLevel.Alert); - } - else - this.Monitor.Log(" SMAPI okay.", LogLevel.Trace); } catch (Exception ex) { @@ -623,12 +632,12 @@ namespace StardewModdingAPI.Framework .GetUpdateKeys(validOnly: true) .Select(p => p.ToString()) .ToArray(); - searchMods.Add(new ModSearchEntryModel(mod.Manifest.UniqueID, updateKeys.ToArray())); + searchMods.Add(new ModSearchEntryModel(mod.Manifest.UniqueID, mod.Manifest.Version, updateKeys.ToArray(), isBroken: mod.Status == ModMetadataStatus.Failed)); } // fetch results this.Monitor.Log($" Checking for updates to {searchMods.Count} mods...", LogLevel.Trace); - IDictionary<string, ModEntryModel> results = client.GetModInfo(searchMods.ToArray()); + IDictionary<string, ModEntryModel> results = client.GetModInfo(searchMods.ToArray(), apiVersion: Constants.ApiVersion, gameVersion: Constants.GameVersion, platform: Constants.Platform); // extract update alerts & errors var updates = new List<Tuple<IModMetadata, ISemanticVersion, string>>(); @@ -649,20 +658,9 @@ namespace StardewModdingAPI.Framework ); } - // parse versions - bool useBetaInfo = result.HasBetaInfo && Constants.ApiVersion.IsPrerelease(); - ISemanticVersion localVersion = mod.DataRecord?.GetLocalVersionForUpdateChecks(mod.Manifest.Version) ?? mod.Manifest.Version; - ISemanticVersion latestVersion = mod.DataRecord?.GetRemoteVersionForUpdateChecks(result.Main?.Version) ?? result.Main?.Version; - ISemanticVersion optionalVersion = mod.DataRecord?.GetRemoteVersionForUpdateChecks(result.Optional?.Version) ?? result.Optional?.Version; - ISemanticVersion unofficialVersion = useBetaInfo ? result.UnofficialForBeta?.Version : result.Unofficial?.Version; - - // show update alerts - if (this.IsValidUpdate(localVersion, latestVersion, useBetaChannel: true)) - updates.Add(Tuple.Create(mod, latestVersion, result.Main?.Url)); - else if (this.IsValidUpdate(localVersion, optionalVersion, useBetaChannel: localVersion.IsPrerelease())) - updates.Add(Tuple.Create(mod, optionalVersion, result.Optional?.Url)); - else if (this.IsValidUpdate(localVersion, unofficialVersion, useBetaChannel: mod.Status == ModMetadataStatus.Failed)) - updates.Add(Tuple.Create(mod, unofficialVersion, useBetaInfo ? result.UnofficialForBeta?.Url : result.Unofficial?.Url)); + // handle update + if (result.SuggestedUpdate != null) + updates.Add(Tuple.Create(mod, result.SuggestedUpdate.Version, result.SuggestedUpdate.Url)); } // show update errors @@ -697,18 +695,6 @@ namespace StardewModdingAPI.Framework }).Start(); } - /// <summary>Get whether a given version should be offered to the user as an update.</summary> - /// <param name="currentVersion">The current semantic version.</param> - /// <param name="newVersion">The target semantic version.</param> - /// <param name="useBetaChannel">Whether the user enabled the beta channel and should be offered pre-release updates.</param> - private bool IsValidUpdate(ISemanticVersion currentVersion, ISemanticVersion newVersion, bool useBetaChannel) - { - return - newVersion != null - && newVersion.IsNewerThan(currentVersion) - && (useBetaChannel || !newVersion.IsPrerelease()); - } - /// <summary>Create a directory path if it doesn't exist.</summary> /// <param name="path">The directory path.</param> private void VerifyPath(string path) @@ -720,7 +706,7 @@ namespace StardewModdingAPI.Framework } catch (Exception ex) { - // note: this happens before this.Monitor is initialised + // note: this happens before this.Monitor is initialized Console.WriteLine($"Couldn't create a path: {path}\n\n{ex.GetLogSummary()}"); } } @@ -755,8 +741,9 @@ namespace StardewModdingAPI.Framework LogSkip(contentPack, errorPhrase, errorDetails); } } - IModMetadata[] loadedContentPacks = this.ModRegistry.GetAll(assemblyMods: false).ToArray(); - IModMetadata[] loadedMods = this.ModRegistry.GetAll(contentPacks: false).ToArray(); + IModMetadata[] loaded = this.ModRegistry.GetAll().ToArray(); + IModMetadata[] loadedContentPacks = loaded.Where(p => p.IsContentPack).ToArray(); + IModMetadata[] loadedMods = loaded.Where(p => !p.IsContentPack).ToArray(); // unlock content packs this.ModRegistry.AreAllModsLoaded = true; @@ -796,12 +783,12 @@ namespace StardewModdingAPI.Framework } // log mod warnings - this.LogModWarnings(this.ModRegistry.GetAll().ToArray(), skippedMods); + this.LogModWarnings(loaded, skippedMods); - // initialise translations - this.ReloadTranslations(loadedMods); + // initialize translations + this.ReloadTranslations(loaded); - // initialise loaded non-content-pack mods + // initialize loaded non-content-pack mods foreach (IModMetadata metadata in loadedMods) { // add interceptors @@ -850,7 +837,7 @@ namespace StardewModdingAPI.Framework } // invalidate cache entries when needed - // (These listeners are registered after Entry to avoid repeatedly reloading assets as mods initialise.) + // (These listeners are registered after Entry to avoid repeatedly reloading assets as mods initialize.) foreach (IModMetadata metadata in loadedMods) { if (metadata.Mod.Helper.Content is ContentHelper helper) @@ -884,7 +871,7 @@ namespace StardewModdingAPI.Framework } // unlock mod integrations - this.ModRegistry.AreAllModsInitialised = true; + this.ModRegistry.AreAllModsInitialized = true; } /// <summary>Load a given mod.</summary> @@ -905,13 +892,13 @@ namespace StardewModdingAPI.Framework // log entry { - string relativePath = PathUtilities.GetRelativePath(this.ModsPath, mod.DirectoryPath); + string relativePath = mod.GetRelativePathWithRoot(); if (mod.IsContentPack) - this.Monitor.Log($" {mod.DisplayName} ({relativePath}) [content pack]...", LogLevel.Trace); + this.Monitor.Log($" {mod.DisplayName} (from {relativePath}) [content pack]...", LogLevel.Trace); else if (mod.Manifest?.EntryDll != null) - this.Monitor.Log($" {mod.DisplayName} ({relativePath}{Path.DirectorySeparatorChar}{mod.Manifest.EntryDll})...", LogLevel.Trace); // don't use Path.Combine here, since EntryDLL might not be valid + this.Monitor.Log($" {mod.DisplayName} (from {relativePath}{Path.DirectorySeparatorChar}{mod.Manifest.EntryDll})...", LogLevel.Trace); // don't use Path.Combine here, since EntryDLL might not be valid else - this.Monitor.Log($" {mod.DisplayName} ({relativePath})...", LogLevel.Trace); + this.Monitor.Log($" {mod.DisplayName} (from {relativePath})...", LogLevel.Trace); } // add warning for missing update key @@ -926,16 +913,8 @@ namespace StardewModdingAPI.Framework return false; } -#if !SMAPI_3_0_STRICT - // add deprecation warning for old version format - { - if (mod.Manifest?.Version is Toolkit.SemanticVersion version && version.IsLegacyFormat) - SCore.DeprecationManager.Warn(mod.DisplayName, "non-string manifest version", "2.8", DeprecationLevel.PendingRemoval); - } -#endif - // validate dependencies - // Although dependences are validated before mods are loaded, a dependency may have failed to load. + // Although dependencies are validated before mods are loaded, a dependency may have failed to load. if (mod.Manifest.Dependencies?.Any() == true) { foreach (IManifestDependency dependency in mod.Manifest.Dependencies.Where(p => p.IsRequired)) @@ -957,8 +936,9 @@ namespace StardewModdingAPI.Framework IManifest manifest = mod.Manifest; IMonitor monitor = this.GetSecondaryMonitor(mod.DisplayName); IContentHelper contentHelper = new ContentHelper(this.ContentCore, mod.DirectoryPath, manifest.UniqueID, mod.DisplayName, monitor); - IContentPack contentPack = new ContentPack(mod.DirectoryPath, manifest, contentHelper, jsonHelper); - mod.SetMod(contentPack, monitor); + TranslationHelper translationHelper = new TranslationHelper(manifest.UniqueID, contentCore.GetLocale(), contentCore.Language); + IContentPack contentPack = new ContentPack(mod.DirectoryPath, manifest, contentHelper, translationHelper, jsonHelper); + mod.SetMod(contentPack, monitor, translationHelper); this.ModRegistry.Add(mod); errorReasonPhrase = null; @@ -998,7 +978,7 @@ namespace StardewModdingAPI.Framework return false; } - // initialise mod + // initialize mod try { // get mod instance @@ -1020,8 +1000,17 @@ namespace StardewModdingAPI.Framework // init mod helpers IMonitor monitor = this.GetSecondaryMonitor(mod.DisplayName); + TranslationHelper translationHelper = new TranslationHelper(manifest.UniqueID, contentCore.GetLocale(), contentCore.Language); IModHelper modHelper; { + IContentPack CreateFakeContentPack(string packDirPath, IManifest packManifest) + { + IMonitor packMonitor = this.GetSecondaryMonitor(packManifest.Name); + IContentHelper packContentHelper = new ContentHelper(contentCore, packDirPath, packManifest.UniqueID, packManifest.Name, packMonitor); + ITranslationHelper packTranslationHelper = new TranslationHelper(packManifest.UniqueID, contentCore.GetLocale(), contentCore.Language); + return new ContentPack(packDirPath, packManifest, packContentHelper, packTranslationHelper, this.Toolkit.JsonHelper); + } + IModEvents events = new ModEvents(mod, this.EventManager); ICommandHelper commandHelper = new CommandHelper(mod, this.GameInstance.CommandManager); IContentHelper contentHelper = new ContentHelper(contentCore, mod.DirectoryPath, manifest.UniqueID, mod.DisplayName, monitor); @@ -1030,16 +1019,8 @@ namespace StardewModdingAPI.Framework IReflectionHelper reflectionHelper = new ReflectionHelper(manifest.UniqueID, mod.DisplayName, this.Reflection); IModRegistry modRegistryHelper = new ModRegistryHelper(manifest.UniqueID, this.ModRegistry, proxyFactory, monitor); IMultiplayerHelper multiplayerHelper = new MultiplayerHelper(manifest.UniqueID, this.GameInstance.Multiplayer); - ITranslationHelper translationHelper = new TranslationHelper(manifest.UniqueID, manifest.Name, contentCore.GetLocale(), contentCore.Language); - - IContentPack CreateFakeContentPack(string packDirPath, IManifest packManifest) - { - IMonitor packMonitor = this.GetSecondaryMonitor(packManifest.Name); - IContentHelper packContentHelper = new ContentHelper(contentCore, packDirPath, packManifest.UniqueID, packManifest.Name, packMonitor); - return new ContentPack(packDirPath, packManifest, packContentHelper, this.Toolkit.JsonHelper); - } - modHelper = new ModHelper(manifest.UniqueID, mod.DirectoryPath, this.Toolkit.JsonHelper, this.GameInstance.Input, events, contentHelper, contentPackHelper, commandHelper, dataHelper, modRegistryHelper, reflectionHelper, multiplayerHelper, translationHelper); + modHelper = new ModHelper(manifest.UniqueID, mod.DirectoryPath, this.GameInstance.Input, events, contentHelper, contentPackHelper, commandHelper, dataHelper, modRegistryHelper, reflectionHelper, multiplayerHelper, translationHelper); } // init mod @@ -1048,13 +1029,13 @@ namespace StardewModdingAPI.Framework modEntry.Monitor = monitor; // track mod - mod.SetMod(modEntry); + mod.SetMod(modEntry, translationHelper); this.ModRegistry.Add(mod); return true; } catch (Exception ex) { - errorReasonPhrase = $"initialisation failed:\n{ex.GetLogSummary()}"; + errorReasonPhrase = $"initialization failed:\n{ex.GetLogSummary()}"; return false; } } @@ -1063,7 +1044,7 @@ namespace StardewModdingAPI.Framework /// <summary>Write a summary of mod warnings to the console and log.</summary> /// <param name="mods">The loaded mods.</param> /// <param name="skippedMods">The mods which were skipped, along with the friendly and developer reasons.</param> - private void LogModWarnings(IModMetadata[] mods, IDictionary<IModMetadata, Tuple<string, string>> skippedMods) + private void LogModWarnings(IEnumerable<IModMetadata> mods, IDictionary<IModMetadata, Tuple<string, string>> skippedMods) { // get mods with warnings IModMetadata[] modsWithWarnings = mods.Where(p => p.Warnings != ModWarning.None).ToArray(); @@ -1129,8 +1110,8 @@ namespace StardewModdingAPI.Framework "These mods have broken code, but you configured SMAPI to load them anyway. This may cause bugs,", "errors, or crashes in-game." ); - LogWarningGroup(ModWarning.ChangesSaveSerialiser, LogLevel.Warn, "Changed save serialiser", - "These mods change the save serialiser. They may corrupt your save files, or make them unusable if", + LogWarningGroup(ModWarning.ChangesSaveSerializer, LogLevel.Warn, "Changed save serializer", + "These mods change the save serializer. They may corrupt your save files, or make them unusable if", "you uninstall these mods." ); if (this.Settings.ParanoidWarnings) @@ -1200,64 +1181,85 @@ namespace StardewModdingAPI.Framework /// <param name="mods">The mods for which to reload translations.</param> private void ReloadTranslations(IEnumerable<IModMetadata> mods) { - JsonHelper jsonHelper = this.Toolkit.JsonHelper; + // core SMAPI translations + { + var translations = this.ReadTranslationFiles(Path.Combine(Constants.InternalFilesPath, "i18n"), out IList<string> errors); + if (errors.Any() || !translations.Any()) + { + this.Monitor.Log("SMAPI couldn't load some core translations. You may need to reinstall SMAPI.", LogLevel.Warn); + foreach (string error in errors) + this.Monitor.Log($" - {error}", LogLevel.Warn); + } + this.Translator.SetTranslations(translations); + } + + // mod translations foreach (IModMetadata metadata in mods) { - if (metadata.IsContentPack) - throw new InvalidOperationException("Can't reload translations for a content pack."); + var translations = this.ReadTranslationFiles(Path.Combine(metadata.DirectoryPath, "i18n"), out IList<string> errors); + if (errors.Any()) + { + metadata.LogAsMod("Mod couldn't load some translation files:", LogLevel.Warn); + foreach (string error in errors) + metadata.LogAsMod($" - {error}", LogLevel.Warn); + } + metadata.Translations.SetTranslations(translations); + } + } + + /// <summary>Read translations from a directory containing JSON translation files.</summary> + /// <param name="folderPath">The folder path to search.</param> + /// <param name="errors">The errors indicating why translation files couldn't be parsed, indexed by translation filename.</param> + private IDictionary<string, IDictionary<string, string>> ReadTranslationFiles(string folderPath, out IList<string> errors) + { + JsonHelper jsonHelper = this.Toolkit.JsonHelper; - // read translation files - IDictionary<string, IDictionary<string, string>> translations = new Dictionary<string, IDictionary<string, string>>(); - DirectoryInfo translationsDir = new DirectoryInfo(Path.Combine(metadata.DirectoryPath, "i18n")); - if (translationsDir.Exists) + // read translation files + var translations = new Dictionary<string, IDictionary<string, string>>(); + errors = new List<string>(); + DirectoryInfo translationsDir = new DirectoryInfo(folderPath); + if (translationsDir.Exists) + { + foreach (FileInfo file in translationsDir.EnumerateFiles("*.json")) { - foreach (FileInfo file in translationsDir.EnumerateFiles("*.json")) + string locale = Path.GetFileNameWithoutExtension(file.Name.ToLower().Trim()); + try { - string locale = Path.GetFileNameWithoutExtension(file.Name.ToLower().Trim()); - try - { - if (jsonHelper.ReadJsonFileIfExists(file.FullName, out IDictionary<string, string> data)) - translations[locale] = data; - else - metadata.LogAsMod($"Mod's i18n/{locale}.json file couldn't be parsed.", LogLevel.Warn); - } - catch (Exception ex) + if (!jsonHelper.ReadJsonFileIfExists(file.FullName, out IDictionary<string, string> data)) { - metadata.LogAsMod($"Mod's i18n/{locale}.json file couldn't be parsed: {ex.GetLogSummary()}", LogLevel.Warn); + errors.Add($"{file.Name} file couldn't be read"); // should never happen, since we're iterating files that exist + continue; } - } - } - // validate translations - foreach (string locale in translations.Keys.ToArray()) - { - // skip empty files - if (translations[locale] == null || !translations[locale].Keys.Any()) + translations[locale] = data; + } + catch (Exception ex) { - metadata.LogAsMod($"Mod's i18n/{locale}.json is empty and will be ignored.", LogLevel.Warn); - translations.Remove(locale); + errors.Add($"{file.Name} file couldn't be parsed: {ex.GetLogSummary()}"); continue; } + } + } - // handle duplicates - HashSet<string> keys = new HashSet<string>(StringComparer.InvariantCultureIgnoreCase); - HashSet<string> duplicateKeys = new HashSet<string>(StringComparer.InvariantCultureIgnoreCase); - foreach (string key in translations[locale].Keys.ToArray()) + // validate translations + foreach (string locale in translations.Keys.ToArray()) + { + // handle duplicates + HashSet<string> keys = new HashSet<string>(StringComparer.InvariantCultureIgnoreCase); + HashSet<string> duplicateKeys = new HashSet<string>(StringComparer.InvariantCultureIgnoreCase); + foreach (string key in translations[locale].Keys.ToArray()) + { + if (!keys.Add(key)) { - if (!keys.Add(key)) - { - duplicateKeys.Add(key); - translations[locale].Remove(key); - } + duplicateKeys.Add(key); + translations[locale].Remove(key); } - if (duplicateKeys.Any()) - metadata.LogAsMod($"Mod's i18n/{locale}.json has duplicate translation keys: [{string.Join(", ", duplicateKeys)}]. Keys are case-insensitive.", LogLevel.Warn); } - - // update translation - TranslationHelper translationHelper = (TranslationHelper)metadata.Mod.Helper.Translation; - translationHelper.SetTranslations(translations); + if (duplicateKeys.Any()) + errors.Add($"{locale}.json has duplicate translation keys: [{string.Join(", ", duplicateKeys)}]. Keys are case-insensitive."); } + + return translations; } /// <summary>The method called when the user submits a core SMAPI command in the console.</summary> @@ -1298,7 +1300,7 @@ namespace StardewModdingAPI.Framework break; default: - throw new NotSupportedException($"Unrecognise core SMAPI command '{name}'."); + throw new NotSupportedException($"Unrecognized core SMAPI command '{name}'."); } } @@ -1351,7 +1353,7 @@ namespace StardewModdingAPI.Framework /// <param name="name">The name of the module which will log messages with this instance.</param> private Monitor GetSecondaryMonitor(string name) { - return new Monitor(name, this.ConsoleManager, this.LogFile, this.CancellationTokenSource, this.Settings.ColorScheme, this.Settings.VerboseLogging) + return new Monitor(name, this.ConsoleManager, this.LogFile, this.Settings.ConsoleColors, this.Settings.VerboseLogging) { WriteToConsole = this.Monitor.WriteToConsole, ShowTraceInConsole = this.Settings.DeveloperMode, diff --git a/src/SMAPI/Framework/SGame.cs b/src/SMAPI/Framework/SGame.cs index 704eb6bc..47261862 100644 --- a/src/SMAPI/Framework/SGame.cs +++ b/src/SMAPI/Framework/SGame.cs @@ -9,9 +9,6 @@ using System.Threading; using System.Threading.Tasks; using Microsoft.Xna.Framework; using Microsoft.Xna.Framework.Graphics; -#if !SMAPI_3_0_STRICT -using Microsoft.Xna.Framework.Input; -#endif using Netcode; using StardewModdingAPI.Enums; using StardewModdingAPI.Events; @@ -19,19 +16,18 @@ using StardewModdingAPI.Framework.Events; using StardewModdingAPI.Framework.Input; using StardewModdingAPI.Framework.Networking; using StardewModdingAPI.Framework.Reflection; -using StardewModdingAPI.Framework.StateTracking; +using StardewModdingAPI.Framework.StateTracking.Snapshots; using StardewModdingAPI.Framework.Utilities; -using StardewModdingAPI.Toolkit.Serialisation; +using StardewModdingAPI.Toolkit.Serialization; using StardewValley; using StardewValley.BellsAndWhistles; -using StardewValley.Buildings; +using StardewValley.Events; using StardewValley.Locations; using StardewValley.Menus; -using StardewValley.TerrainFeatures; using StardewValley.Tools; using xTile.Dimensions; using xTile.Layers; -using SObject = StardewValley.Object; +using xTile.Tiles; namespace StardewModdingAPI.Framework { @@ -45,7 +41,7 @@ namespace StardewModdingAPI.Framework ** SMAPI state ****/ /// <summary>Encapsulates monitoring and logging for SMAPI.</summary> - private readonly IMonitor Monitor; + private readonly Monitor Monitor; /// <summary>Encapsulates monitoring and logging on the game's behalf.</summary> private readonly IMonitor MonitorForGame; @@ -66,20 +62,23 @@ namespace StardewModdingAPI.Framework private readonly Countdown UpdateCrashTimer = new Countdown(60); // 60 ticks = roughly one second /// <summary>The number of ticks until SMAPI should notify mods that the game has loaded.</summary> - /// <remarks>Skipping a few frames ensures the game finishes initialising the world before mods try to change it.</remarks> + /// <remarks>Skipping a few frames ensures the game finishes initializing the world before mods try to change it.</remarks> private readonly Countdown AfterLoadTimer = new Countdown(5); + /// <summary>Whether custom content was removed from the save data to avoid a crash.</summary> + private bool IsSaveContentRemoved; + /// <summary>Whether the game is saving and SMAPI has already raised <see cref="IGameLoopEvents.Saving"/>.</summary> private bool IsBetweenSaveEvents; /// <summary>Whether the game is creating the save file and SMAPI has already raised <see cref="IGameLoopEvents.SaveCreating"/>.</summary> private bool IsBetweenCreateEvents; - /// <summary>A callback to invoke after the content language changes.</summary> - private readonly Action OnLocaleChanged; + /// <summary>A callback to invoke the first time *any* game content manager loads an asset.</summary> + private readonly Action OnLoadingFirstAsset; - /// <summary>A callback to invoke after the game finishes initialising.</summary> - private readonly Action OnGameInitialised; + /// <summary>A callback to invoke after the game finishes initializing.</summary> + private readonly Action OnGameInitialized; /// <summary>A callback to invoke when the game exits.</summary> private readonly Action OnGameExiting; @@ -87,14 +86,23 @@ namespace StardewModdingAPI.Framework /// <summary>Simplifies access to private game code.</summary> private readonly Reflector Reflection; + /// <summary>Encapsulates access to SMAPI core translations.</summary> + private readonly Translator Translator; + + /// <summary>Propagates notification that SMAPI should exit.</summary> + private readonly CancellationTokenSource CancellationToken; + /**** ** Game state ****/ /// <summary>Monitors the entire game state for changes.</summary> private WatcherCore Watchers; - /// <summary>Whether post-game-startup initialisation has been performed.</summary> - private bool IsInitialised; + /// <summary>A snapshot of the current <see cref="Watchers"/> state.</summary> + private WatcherSnapshot WatcherSnapshot = new WatcherSnapshot(); + + /// <summary>Whether post-game-startup initialization has been performed.</summary> + private bool IsInitialized; /// <summary>Whether the next content manager requested by the game will be for <see cref="Game1.content"/>.</summary> private bool NextContentManagerIsMain; @@ -103,7 +111,7 @@ namespace StardewModdingAPI.Framework /********* ** Accessors *********/ - /// <summary>Static state to use while <see cref="Game1"/> is initialising, which happens before the <see cref="SGame"/> constructor runs.</summary> + /// <summary>Static state to use while <see cref="Game1"/> is initializing, which happens before the <see cref="SGame"/> constructor runs.</summary> internal static SGameConstructorHack ConstructorHack { get; set; } /// <summary>The number of update ticks which have already executed. This is similar to <see cref="Game1.ticks"/>, but incremented more consistently for every tick.</summary> @@ -133,20 +141,23 @@ namespace StardewModdingAPI.Framework /// <param name="monitor">Encapsulates monitoring and logging for SMAPI.</param> /// <param name="monitorForGame">Encapsulates monitoring and logging on the game's behalf.</param> /// <param name="reflection">Simplifies access to private game code.</param> + /// <param name="translator">Encapsulates access to arbitrary translations.</param> /// <param name="eventManager">Manages SMAPI events for mods.</param> /// <param name="jsonHelper">Encapsulates SMAPI's JSON file parsing.</param> /// <param name="modRegistry">Tracks the installed mods.</param> /// <param name="deprecationManager">Manages deprecation warnings.</param> - /// <param name="onLocaleChanged">A callback to invoke after the content language changes.</param> - /// <param name="onGameInitialised">A callback to invoke after the game finishes initialising.</param> + /// <param name="onGameInitialized">A callback to invoke after the game finishes initializing.</param> /// <param name="onGameExiting">A callback to invoke when the game exits.</param> - internal SGame(IMonitor monitor, IMonitor monitorForGame, Reflector reflection, EventManager eventManager, JsonHelper jsonHelper, ModRegistry modRegistry, DeprecationManager deprecationManager, Action onLocaleChanged, Action onGameInitialised, Action onGameExiting) + /// <param name="cancellationToken">Propagates notification that SMAPI should exit.</param> + /// <param name="logNetworkTraffic">Whether to log network traffic.</param> + internal SGame(Monitor monitor, IMonitor monitorForGame, Reflector reflection, Translator translator, EventManager eventManager, JsonHelper jsonHelper, ModRegistry modRegistry, DeprecationManager deprecationManager, Action onGameInitialized, Action onGameExiting, CancellationTokenSource cancellationToken, bool logNetworkTraffic) { + this.OnLoadingFirstAsset = SGame.ConstructorHack.OnLoadingFirstAsset; SGame.ConstructorHack = null; // check expectations if (this.ContentCore == null) - throw new InvalidOperationException($"The game didn't initialise its first content manager before SMAPI's {nameof(SGame)} constructor. This indicates an incompatible lifecycle change."); + throw new InvalidOperationException($"The game didn't initialize its first content manager before SMAPI's {nameof(SGame)} constructor. This indicates an incompatible lifecycle change."); // init XNA Game1.graphics.GraphicsProfile = GraphicsProfile.HiDef; @@ -157,20 +168,21 @@ namespace StardewModdingAPI.Framework this.Events = eventManager; this.ModRegistry = modRegistry; this.Reflection = reflection; + this.Translator = translator; this.DeprecationManager = deprecationManager; - this.OnLocaleChanged = onLocaleChanged; - this.OnGameInitialised = onGameInitialised; + this.OnGameInitialized = onGameInitialized; this.OnGameExiting = onGameExiting; Game1.input = new SInputState(); - Game1.multiplayer = new SMultiplayer(monitor, eventManager, jsonHelper, modRegistry, reflection, this.OnModMessageReceived); + Game1.multiplayer = new SMultiplayer(monitor, eventManager, jsonHelper, modRegistry, reflection, this.OnModMessageReceived, logNetworkTraffic); Game1.hooks = new SModHooks(this.OnNewDayAfterFade); + this.CancellationToken = cancellationToken; // init observables Game1.locations = new ObservableCollection<GameLocation>(); } - /// <summary>Initialise just before the game's first update tick.</summary> - private void InitialiseAfterGameStarted() + /// <summary>Initialize just before the game's first update tick.</summary> + private void InitializeAfterGameStarted() { // set initial state this.Input.TrueUpdate(); @@ -179,7 +191,7 @@ namespace StardewModdingAPI.Framework this.Watchers = new WatcherCore(this.Input); // raise callback - this.OnGameInitialised(); + this.OnGameInitialized(); } /// <summary>Perform cleanup logic when the game exits.</summary> @@ -188,7 +200,7 @@ namespace StardewModdingAPI.Framework /// <remarks>This overrides the logic in <see cref="Game1.exitEvent"/> to let SMAPI clean up before exit.</remarks> protected override void OnExiting(object sender, EventArgs args) { - Game1.multiplayer.Disconnect(); + Game1.multiplayer.Disconnect(StardewValley.Multiplayer.DisconnectType.ClosedGame); this.OnGameExiting?.Invoke(); } @@ -207,6 +219,12 @@ namespace StardewModdingAPI.Framework this.Events.ModMessageReceived.RaiseForMods(new ModMessageReceivedEventArgs(message), mod => mod != null && modIDs.Contains(mod.Manifest.UniqueID)); } + /// <summary>A callback invoked when custom content is removed from the save data to avoid a crash.</summary> + internal void OnSaveContentRemoved() + { + this.IsSaveContentRemoved = true; + } + /// <summary>A callback invoked when the game's low-level load stage changes.</summary> /// <param name="newStage">The new load stage.</param> internal void OnLoadStageChanged(LoadStage newStage) @@ -228,12 +246,7 @@ namespace StardewModdingAPI.Framework // raise events this.Events.LoadStageChanged.Raise(new LoadStageChangedEventArgs(oldStage, newStage)); if (newStage == LoadStage.None) - { this.Events.ReturnedToTitle.RaiseEmpty(); -#if !SMAPI_3_0_STRICT - this.Events.Legacy_AfterReturnToTitle.Raise(); -#endif - } } /// <summary>Constructor a content manager to read XNB files.</summary> @@ -241,16 +254,16 @@ namespace StardewModdingAPI.Framework /// <param name="rootDirectory">The root directory to search for content.</param> protected override LocalizedContentManager CreateContentManager(IServiceProvider serviceProvider, string rootDirectory) { - // Game1._temporaryContent initialising from SGame constructor - // NOTE: this method is called before the SGame constructor runs. Don't depend on anything being initialised at this point. + // Game1._temporaryContent initializing from SGame constructor + // NOTE: this method is called before the SGame constructor runs. Don't depend on anything being initialized at this point. if (this.ContentCore == null) { - this.ContentCore = new ContentCoordinator(serviceProvider, rootDirectory, Thread.CurrentThread.CurrentUICulture, SGame.ConstructorHack.Monitor, SGame.ConstructorHack.Reflection, SGame.ConstructorHack.JsonHelper); + this.ContentCore = new ContentCoordinator(serviceProvider, rootDirectory, Thread.CurrentThread.CurrentUICulture, SGame.ConstructorHack.Monitor, SGame.ConstructorHack.Reflection, SGame.ConstructorHack.JsonHelper, this.OnLoadingFirstAsset ?? SGame.ConstructorHack?.OnLoadingFirstAsset); this.NextContentManagerIsMain = true; return this.ContentCore.CreateGameContentManager("Game1._temporaryContent"); } - // Game1.content initialising from LoadContent + // Game1.content initializing from LoadContent if (this.NextContentManagerIsMain) { this.NextContentManagerIsMain = false; @@ -272,17 +285,29 @@ namespace StardewModdingAPI.Framework this.DeprecationManager.PrintQueued(); /********* - ** Special cases + ** First-tick initialization *********/ - // Perform first-tick initialisation. - if (!this.IsInitialised) + if (!this.IsInitialized) { - this.IsInitialised = true; - this.InitialiseAfterGameStarted(); + this.IsInitialized = true; + this.InitializeAfterGameStarted(); } + /********* + ** Update input + *********/ + // This should *always* run, even when suppressing mod events, since the game uses + // this too. For example, doing this after mod event suppression would prevent the + // user from doing anything on the overnight shipping screen. + SInputState inputState = this.Input; + if (this.IsActive) + inputState.TrueUpdate(); + + /********* + ** Special cases + *********/ // Abort if SMAPI is exiting. - if (this.Monitor.IsExiting) + if (this.CancellationToken.IsCancellationRequested) { this.Monitor.Log("SMAPI shutting down: aborting update.", LogLevel.Trace); return; @@ -293,7 +318,7 @@ namespace StardewModdingAPI.Framework bool saveParsed = false; if (Game1.currentLoader != null) { - this.Monitor.Log("Game loader synchronising...", LogLevel.Trace); + this.Monitor.Log("Game loader synchronizing...", LogLevel.Trace); while (Game1.currentLoader?.MoveNext() == true) { // raise load stage changed @@ -324,7 +349,7 @@ namespace StardewModdingAPI.Framework } if (Game1._newDayTask?.Status == TaskStatus.Created) { - this.Monitor.Log("New day task synchronising...", LogLevel.Trace); + this.Monitor.Log("New day task synchronizing...", LogLevel.Trace); Game1._newDayTask.RunSynchronously(); this.Monitor.Log("New day task done.", LogLevel.Trace); } @@ -337,16 +362,45 @@ namespace StardewModdingAPI.Framework // Therefore we can just run Game1.Update here without raising any SMAPI events. There's // a small chance that the task will finish after we defer but before the game checks, // which means technically events should be raised, but the effects of missing one - // update tick are neglible and not worth the complications of bypassing Game1.Update. + // update tick are negligible and not worth the complications of bypassing Game1.Update. if (Game1._newDayTask != null || Game1.gameMode == Game1.loadingMode) { events.UnvalidatedUpdateTicking.RaiseEmpty(); SGame.TicksElapsed++; base.Update(gameTime); events.UnvalidatedUpdateTicked.RaiseEmpty(); -#if !SMAPI_3_0_STRICT - events.Legacy_UnvalidatedUpdateTick.Raise(); -#endif + return; + } + + // Raise minimal events while saving. + // While the game is writing to the save file in the background, mods can unexpectedly + // fail since they don't have exclusive access to resources (e.g. collection changed + // during enumeration errors). To avoid problems, events are not invoked while a save + // is in progress. It's safe to raise SaveEvents.BeforeSave as soon as the menu is + // opened (since the save hasn't started yet), but all other events should be suppressed. + if (Context.IsSaving) + { + // raise before-create + if (!Context.IsWorldReady && !this.IsBetweenCreateEvents) + { + this.IsBetweenCreateEvents = true; + this.Monitor.Log("Context: before save creation.", LogLevel.Trace); + events.SaveCreating.RaiseEmpty(); + } + + // raise before-save + if (Context.IsWorldReady && !this.IsBetweenSaveEvents) + { + this.IsBetweenSaveEvents = true; + this.Monitor.Log("Context: before save.", LogLevel.Trace); + events.Saving.RaiseEmpty(); + } + + // suppress non-save events + events.UnvalidatedUpdateTicking.RaiseEmpty(); + SGame.TicksElapsed++; + base.Update(gameTime); + events.UnvalidatedUpdateTicked.RaiseEmpty(); return; } @@ -388,85 +442,6 @@ namespace StardewModdingAPI.Framework } /********* - ** Update input - *********/ - // This should *always* run, even when suppressing mod events, since the game uses - // this too. For example, doing this after mod event suppression would prevent the - // user from doing anything on the overnight shipping screen. -#if !SMAPI_3_0_STRICT - SInputState previousInputState = this.Input.Clone(); -#endif - SInputState inputState = this.Input; - if (this.IsActive) - inputState.TrueUpdate(); - - /********* - ** Save events + suppress events during save - *********/ - // While the game is writing to the save file in the background, mods can unexpectedly - // fail since they don't have exclusive access to resources (e.g. collection changed - // during enumeration errors). To avoid problems, events are not invoked while a save - // is in progress. It's safe to raise SaveEvents.BeforeSave as soon as the menu is - // opened (since the save hasn't started yet), but all other events should be suppressed. - if (Context.IsSaving) - { - // raise before-create - if (!Context.IsWorldReady && !this.IsBetweenCreateEvents) - { - this.IsBetweenCreateEvents = true; - this.Monitor.Log("Context: before save creation.", LogLevel.Trace); - events.SaveCreating.RaiseEmpty(); -#if !SMAPI_3_0_STRICT - events.Legacy_BeforeCreateSave.Raise(); -#endif - } - - // raise before-save - if (Context.IsWorldReady && !this.IsBetweenSaveEvents) - { - this.IsBetweenSaveEvents = true; - this.Monitor.Log("Context: before save.", LogLevel.Trace); - events.Saving.RaiseEmpty(); -#if !SMAPI_3_0_STRICT - events.Legacy_BeforeSave.Raise(); -#endif - } - - // suppress non-save events - events.UnvalidatedUpdateTicking.RaiseEmpty(); - SGame.TicksElapsed++; - base.Update(gameTime); - events.UnvalidatedUpdateTicked.RaiseEmpty(); -#if !SMAPI_3_0_STRICT - events.Legacy_UnvalidatedUpdateTick.Raise(); -#endif - return; - } - if (this.IsBetweenCreateEvents) - { - // raise after-create - this.IsBetweenCreateEvents = false; - this.Monitor.Log($"Context: after save creation, starting {Game1.currentSeason} {Game1.dayOfMonth} Y{Game1.year}.", LogLevel.Trace); - this.OnLoadStageChanged(LoadStage.CreatedSaveFile); - events.SaveCreated.RaiseEmpty(); -#if !SMAPI_3_0_STRICT - events.Legacy_AfterCreateSave.Raise(); -#endif - } - if (this.IsBetweenSaveEvents) - { - // raise after-save - this.IsBetweenSaveEvents = false; - this.Monitor.Log($"Context: after save, starting {Game1.currentSeason} {Game1.dayOfMonth} Y{Game1.year}.", LogLevel.Trace); - events.Saved.RaiseEmpty(); - events.DayStarted.RaiseEmpty(); -#if !SMAPI_3_0_STRICT - events.Legacy_AfterSave.Raise(); - events.Legacy_AfterDayStarted.Raise(); -#endif - } - - /********* ** Update context *********/ bool wasWorldReady = Context.IsWorldReady; @@ -477,231 +452,170 @@ namespace StardewModdingAPI.Framework } else if (Context.IsSaveLoaded && this.AfterLoadTimer.Current > 0 && Game1.currentLocation != null) { - if (Game1.dayOfMonth != 0) // wait until new-game intro finishes (world not fully initialised yet) + if (Game1.dayOfMonth != 0) // wait until new-game intro finishes (world not fully initialized yet) this.AfterLoadTimer.Decrement(); Context.IsWorldReady = this.AfterLoadTimer.Current == 0; } /********* ** Update watchers + ** (Watchers need to be updated, checked, and reset in one go so we can detect any changes mods make in event handlers.) *********/ this.Watchers.Update(); + this.WatcherSnapshot.Update(this.Watchers); + this.Watchers.Reset(); + WatcherSnapshot state = this.WatcherSnapshot; /********* - ** Locale changed events + ** Display in-game warnings *********/ - if (this.Watchers.LocaleWatcher.IsChanged) + // save content removed + if (this.IsSaveContentRemoved && Context.IsWorldReady) { - var was = this.Watchers.LocaleWatcher.PreviousValue; - var now = this.Watchers.LocaleWatcher.CurrentValue; - - this.Monitor.Log($"Context: locale set to {now}.", LogLevel.Trace); - - this.OnLocaleChanged(); -#if !SMAPI_3_0_STRICT - events.Legacy_LocaleChanged.Raise(new EventArgsValueChanged<string>(was.ToString(), now.ToString())); -#endif - - this.Watchers.LocaleWatcher.Reset(); + this.IsSaveContentRemoved = false; + Game1.addHUDMessage(new HUDMessage(this.Translator.Get("warn.invalid-content-removed"), HUDMessage.error_type)); } /********* - ** Load / return-to-title events + ** Pre-update events *********/ - if (wasWorldReady && !Context.IsWorldReady) - this.OnLoadStageChanged(LoadStage.None); - else if (Context.IsWorldReady && Context.LoadStage != LoadStage.Ready) { - // print context - string context = $"Context: loaded save '{Constants.SaveFolderName}', starting {Game1.currentSeason} {Game1.dayOfMonth} Y{Game1.year}, locale set to {this.ContentCore.Language}."; - if (Context.IsMultiplayer) + /********* + ** Save created/loaded events + *********/ + if (this.IsBetweenCreateEvents) { - int onlineCount = Game1.getOnlineFarmers().Count(); - context += $" {(Context.IsMainPlayer ? "Main player" : "Farmhand")} with {onlineCount} {(onlineCount == 1 ? "player" : "players")} online."; + // raise after-create + this.IsBetweenCreateEvents = false; + this.Monitor.Log($"Context: after save creation, starting {Game1.currentSeason} {Game1.dayOfMonth} Y{Game1.year}.", LogLevel.Trace); + this.OnLoadStageChanged(LoadStage.CreatedSaveFile); + events.SaveCreated.RaiseEmpty(); + } + if (this.IsBetweenSaveEvents) + { + // raise after-save + this.IsBetweenSaveEvents = false; + this.Monitor.Log($"Context: after save, starting {Game1.currentSeason} {Game1.dayOfMonth} Y{Game1.year}.", LogLevel.Trace); + events.Saved.RaiseEmpty(); + events.DayStarted.RaiseEmpty(); } - else - context += " Single-player."; - this.Monitor.Log(context, LogLevel.Trace); - - // raise events - this.OnLoadStageChanged(LoadStage.Ready); - events.SaveLoaded.RaiseEmpty(); - events.DayStarted.RaiseEmpty(); -#if !SMAPI_3_0_STRICT - events.Legacy_AfterLoad.Raise(); - events.Legacy_AfterDayStarted.Raise(); -#endif - } - - /********* - ** Window events - *********/ - // Here we depend on the game's viewport instead of listening to the Window.Resize - // event because we need to notify mods after the game handles the resize, so the - // game's metadata (like Game1.viewport) are updated. That's a bit complicated - // since the game adds & removes its own handler on the fly. - if (this.Watchers.WindowSizeWatcher.IsChanged) - { - if (this.Monitor.IsVerbose) - this.Monitor.Log($"Events: window size changed to {this.Watchers.WindowSizeWatcher.CurrentValue}.", LogLevel.Trace); - Point oldSize = this.Watchers.WindowSizeWatcher.PreviousValue; - Point newSize = this.Watchers.WindowSizeWatcher.CurrentValue; + /********* + ** Locale changed events + *********/ + if (state.Locale.IsChanged) + this.Monitor.Log($"Context: locale set to {state.Locale.New}.", LogLevel.Trace); + + /********* + ** Load / return-to-title events + *********/ + if (wasWorldReady && !Context.IsWorldReady) + this.OnLoadStageChanged(LoadStage.None); + else if (Context.IsWorldReady && Context.LoadStage != LoadStage.Ready) + { + // print context + string context = $"Context: loaded save '{Constants.SaveFolderName}', starting {Game1.currentSeason} {Game1.dayOfMonth} Y{Game1.year}, locale set to {this.ContentCore.Language}."; + if (Context.IsMultiplayer) + { + int onlineCount = Game1.getOnlineFarmers().Count(); + context += $" {(Context.IsMainPlayer ? "Main player" : "Farmhand")} with {onlineCount} {(onlineCount == 1 ? "player" : "players")} online."; + } + else + context += " Single-player."; + this.Monitor.Log(context, LogLevel.Trace); - events.WindowResized.Raise(new WindowResizedEventArgs(oldSize, newSize)); -#if !SMAPI_3_0_STRICT - events.Legacy_Resize.Raise(); -#endif - this.Watchers.WindowSizeWatcher.Reset(); - } + // raise events + this.OnLoadStageChanged(LoadStage.Ready); + events.SaveLoaded.RaiseEmpty(); + events.DayStarted.RaiseEmpty(); + } - /********* - ** Input events (if window has focus) - *********/ - if (this.IsActive) - { - // raise events - bool isChatInput = Game1.IsChatting || (Context.IsMultiplayer && Context.IsWorldReady && Game1.activeClickableMenu == null && Game1.currentMinigame == null && inputState.IsAnyDown(Game1.options.chatButton)); - if (!isChatInput) + /********* + ** Window events + *********/ + // Here we depend on the game's viewport instead of listening to the Window.Resize + // event because we need to notify mods after the game handles the resize, so the + // game's metadata (like Game1.viewport) are updated. That's a bit complicated + // since the game adds & removes its own handler on the fly. + if (state.WindowSize.IsChanged) { - ICursorPosition cursor = this.Input.CursorPosition; + if (this.Monitor.IsVerbose) + this.Monitor.Log($"Events: window size changed to {state.WindowSize.New}.", LogLevel.Trace); - // raise cursor moved event - if (this.Watchers.CursorWatcher.IsChanged) + events.WindowResized.Raise(new WindowResizedEventArgs(state.WindowSize.Old, state.WindowSize.New)); + } + + /********* + ** Input events (if window has focus) + *********/ + if (this.IsActive) + { + // raise events + bool isChatInput = Game1.IsChatting || (Context.IsMultiplayer && Context.IsWorldReady && Game1.activeClickableMenu == null && Game1.currentMinigame == null && inputState.IsAnyDown(Game1.options.chatButton)); + if (!isChatInput) { - if (events.CursorMoved.HasListeners()) - { - ICursorPosition was = this.Watchers.CursorWatcher.PreviousValue; - ICursorPosition now = this.Watchers.CursorWatcher.CurrentValue; - this.Watchers.CursorWatcher.Reset(); + ICursorPosition cursor = this.Input.CursorPosition; - events.CursorMoved.Raise(new CursorMovedEventArgs(was, now)); - } - else - this.Watchers.CursorWatcher.Reset(); - } + // raise cursor moved event + if (state.Cursor.IsChanged) + events.CursorMoved.Raise(new CursorMovedEventArgs(state.Cursor.Old, state.Cursor.New)); - // raise mouse wheel scrolled - if (this.Watchers.MouseWheelScrollWatcher.IsChanged) - { - if (events.MouseWheelScrolled.HasListeners() || this.Monitor.IsVerbose) + // raise mouse wheel scrolled + if (state.MouseWheelScroll.IsChanged) { - int was = this.Watchers.MouseWheelScrollWatcher.PreviousValue; - int now = this.Watchers.MouseWheelScrollWatcher.CurrentValue; - this.Watchers.MouseWheelScrollWatcher.Reset(); - if (this.Monitor.IsVerbose) - this.Monitor.Log($"Events: mouse wheel scrolled to {now}.", LogLevel.Trace); - events.MouseWheelScrolled.Raise(new MouseWheelScrolledEventArgs(cursor, was, now)); + this.Monitor.Log($"Events: mouse wheel scrolled to {state.MouseWheelScroll.New}.", LogLevel.Trace); + events.MouseWheelScrolled.Raise(new MouseWheelScrolledEventArgs(cursor, state.MouseWheelScroll.Old, state.MouseWheelScroll.New)); } - else - this.Watchers.MouseWheelScrollWatcher.Reset(); - } - - // raise input button events - foreach (var pair in inputState.ActiveButtons) - { - SButton button = pair.Key; - InputStatus status = pair.Value; - if (status == InputStatus.Pressed) + // raise input button events + foreach (var pair in inputState.ActiveButtons) { - if (this.Monitor.IsVerbose) - this.Monitor.Log($"Events: button {button} pressed.", LogLevel.Trace); + SButton button = pair.Key; + InputStatus status = pair.Value; - events.ButtonPressed.Raise(new ButtonPressedEventArgs(button, cursor, inputState)); - -#if !SMAPI_3_0_STRICT - // legacy events - events.Legacy_ButtonPressed.Raise(new EventArgsInput(button, cursor, inputState.SuppressButtons)); - if (button.TryGetKeyboard(out Keys key)) - { - if (key != Keys.None) - events.Legacy_KeyPressed.Raise(new EventArgsKeyPressed(key)); - } - else if (button.TryGetController(out Buttons controllerButton)) + if (status == InputStatus.Pressed) { - if (controllerButton == Buttons.LeftTrigger || controllerButton == Buttons.RightTrigger) - events.Legacy_ControllerTriggerPressed.Raise(new EventArgsControllerTriggerPressed(PlayerIndex.One, controllerButton, controllerButton == Buttons.LeftTrigger ? inputState.RealController.Triggers.Left : inputState.RealController.Triggers.Right)); - else - events.Legacy_ControllerButtonPressed.Raise(new EventArgsControllerButtonPressed(PlayerIndex.One, controllerButton)); - } -#endif - } - else if (status == InputStatus.Released) - { - if (this.Monitor.IsVerbose) - this.Monitor.Log($"Events: button {button} released.", LogLevel.Trace); - - events.ButtonReleased.Raise(new ButtonReleasedEventArgs(button, cursor, inputState)); + if (this.Monitor.IsVerbose) + this.Monitor.Log($"Events: button {button} pressed.", LogLevel.Trace); -#if !SMAPI_3_0_STRICT - // legacy events - events.Legacy_ButtonReleased.Raise(new EventArgsInput(button, cursor, inputState.SuppressButtons)); - if (button.TryGetKeyboard(out Keys key)) - { - if (key != Keys.None) - events.Legacy_KeyReleased.Raise(new EventArgsKeyPressed(key)); + events.ButtonPressed.Raise(new ButtonPressedEventArgs(button, cursor, inputState)); } - else if (button.TryGetController(out Buttons controllerButton)) + else if (status == InputStatus.Released) { - if (controllerButton == Buttons.LeftTrigger || controllerButton == Buttons.RightTrigger) - events.Legacy_ControllerTriggerReleased.Raise(new EventArgsControllerTriggerReleased(PlayerIndex.One, controllerButton, controllerButton == Buttons.LeftTrigger ? inputState.RealController.Triggers.Left : inputState.RealController.Triggers.Right)); - else - events.Legacy_ControllerButtonReleased.Raise(new EventArgsControllerButtonReleased(PlayerIndex.One, controllerButton)); + if (this.Monitor.IsVerbose) + this.Monitor.Log($"Events: button {button} released.", LogLevel.Trace); + + events.ButtonReleased.Raise(new ButtonReleasedEventArgs(button, cursor, inputState)); } -#endif } } - -#if !SMAPI_3_0_STRICT - // raise legacy state-changed events - if (inputState.RealKeyboard != previousInputState.RealKeyboard) - events.Legacy_KeyboardChanged.Raise(new EventArgsKeyboardStateChanged(previousInputState.RealKeyboard, inputState.RealKeyboard)); - if (inputState.RealMouse != previousInputState.RealMouse) - events.Legacy_MouseChanged.Raise(new EventArgsMouseStateChanged(previousInputState.RealMouse, inputState.RealMouse, new Point((int)previousInputState.CursorPosition.ScreenPixels.X, (int)previousInputState.CursorPosition.ScreenPixels.Y), new Point((int)inputState.CursorPosition.ScreenPixels.X, (int)inputState.CursorPosition.ScreenPixels.Y))); -#endif } - } - /********* - ** Menu events - *********/ - if (this.Watchers.ActiveMenuWatcher.IsChanged) - { - IClickableMenu was = this.Watchers.ActiveMenuWatcher.PreviousValue; - IClickableMenu now = this.Watchers.ActiveMenuWatcher.CurrentValue; - this.Watchers.ActiveMenuWatcher.Reset(); // reset here so a mod changing the menu will be raised as a new event afterwards - - if (this.Monitor.IsVerbose) - this.Monitor.Log($"Context: menu changed from {was?.GetType().FullName ?? "none"} to {now?.GetType().FullName ?? "none"}.", LogLevel.Trace); - - // raise menu events - events.MenuChanged.Raise(new MenuChangedEventArgs(was, now)); -#if !SMAPI_3_0_STRICT - if (now != null) - events.Legacy_MenuChanged.Raise(new EventArgsClickableMenuChanged(was, now)); - else - events.Legacy_MenuClosed.Raise(new EventArgsClickableMenuClosed(was)); -#endif - } + /********* + ** Menu events + *********/ + if (state.ActiveMenu.IsChanged) + { + if (this.Monitor.IsVerbose) + this.Monitor.Log($"Context: menu changed from {state.ActiveMenu.Old?.GetType().FullName ?? "none"} to {state.ActiveMenu.New?.GetType().FullName ?? "none"}.", LogLevel.Trace); - /********* - ** World & player events - *********/ - if (Context.IsWorldReady) - { - bool raiseWorldEvents = !this.Watchers.SaveIdWatcher.IsChanged; // don't report changes from unloaded => loaded + // raise menu events + events.MenuChanged.Raise(new MenuChangedEventArgs(state.ActiveMenu.Old, state.ActiveMenu.New)); + } - // raise location changes - if (this.Watchers.LocationsWatcher.IsChanged) + /********* + ** World & player events + *********/ + if (Context.IsWorldReady) { + bool raiseWorldEvents = !state.SaveID.IsChanged; // don't report changes from unloaded => loaded + // location list changes - if (this.Watchers.LocationsWatcher.IsLocationListChanged) + if (state.Locations.LocationList.IsChanged && (events.LocationListChanged.HasListeners() || this.Monitor.IsVerbose)) { - GameLocation[] added = this.Watchers.LocationsWatcher.Added.ToArray(); - GameLocation[] removed = this.Watchers.LocationsWatcher.Removed.ToArray(); - this.Watchers.LocationsWatcher.ResetLocationList(); + var added = state.Locations.LocationList.Added.ToArray(); + var removed = state.Locations.LocationList.Removed.ToArray(); if (this.Monitor.IsVerbose) { @@ -711,224 +625,128 @@ namespace StardewModdingAPI.Framework } events.LocationListChanged.Raise(new LocationListChangedEventArgs(added, removed)); -#if !SMAPI_3_0_STRICT - events.Legacy_LocationsChanged.Raise(new EventArgsLocationsChanged(added, removed)); -#endif } // raise location contents changed if (raiseWorldEvents) { - foreach (LocationTracker watcher in this.Watchers.LocationsWatcher.Locations) + foreach (LocationSnapshot locState in state.Locations.Locations) { + var location = locState.Location; + // buildings changed - if (watcher.BuildingsWatcher.IsChanged) - { - GameLocation location = watcher.Location; - Building[] added = watcher.BuildingsWatcher.Added.ToArray(); - Building[] removed = watcher.BuildingsWatcher.Removed.ToArray(); - watcher.BuildingsWatcher.Reset(); - - events.BuildingListChanged.Raise(new BuildingListChangedEventArgs(location, added, removed)); -#if !SMAPI_3_0_STRICT - events.Legacy_BuildingsChanged.Raise(new EventArgsLocationBuildingsChanged(location, added, removed)); -#endif - } + if (locState.Buildings.IsChanged) + events.BuildingListChanged.Raise(new BuildingListChangedEventArgs(location, locState.Buildings.Added, locState.Buildings.Removed)); // debris changed - if (watcher.DebrisWatcher.IsChanged) - { - GameLocation location = watcher.Location; - Debris[] added = watcher.DebrisWatcher.Added.ToArray(); - Debris[] removed = watcher.DebrisWatcher.Removed.ToArray(); - watcher.DebrisWatcher.Reset(); - - events.DebrisListChanged.Raise(new DebrisListChangedEventArgs(location, added, removed)); - } + if (locState.Debris.IsChanged) + events.DebrisListChanged.Raise(new DebrisListChangedEventArgs(location, locState.Debris.Added, locState.Debris.Removed)); // large terrain features changed - if (watcher.LargeTerrainFeaturesWatcher.IsChanged) - { - GameLocation location = watcher.Location; - LargeTerrainFeature[] added = watcher.LargeTerrainFeaturesWatcher.Added.ToArray(); - LargeTerrainFeature[] removed = watcher.LargeTerrainFeaturesWatcher.Removed.ToArray(); - watcher.LargeTerrainFeaturesWatcher.Reset(); - - events.LargeTerrainFeatureListChanged.Raise(new LargeTerrainFeatureListChangedEventArgs(location, added, removed)); - } + if (locState.LargeTerrainFeatures.IsChanged) + events.LargeTerrainFeatureListChanged.Raise(new LargeTerrainFeatureListChangedEventArgs(location, locState.LargeTerrainFeatures.Added, locState.LargeTerrainFeatures.Removed)); // NPCs changed - if (watcher.NpcsWatcher.IsChanged) - { - GameLocation location = watcher.Location; - NPC[] added = watcher.NpcsWatcher.Added.ToArray(); - NPC[] removed = watcher.NpcsWatcher.Removed.ToArray(); - watcher.NpcsWatcher.Reset(); - - events.NpcListChanged.Raise(new NpcListChangedEventArgs(location, added, removed)); - } + if (locState.Npcs.IsChanged) + events.NpcListChanged.Raise(new NpcListChangedEventArgs(location, locState.Npcs.Added, locState.Npcs.Removed)); // objects changed - if (watcher.ObjectsWatcher.IsChanged) - { - GameLocation location = watcher.Location; - KeyValuePair<Vector2, SObject>[] added = watcher.ObjectsWatcher.Added.ToArray(); - KeyValuePair<Vector2, SObject>[] removed = watcher.ObjectsWatcher.Removed.ToArray(); - watcher.ObjectsWatcher.Reset(); - - events.ObjectListChanged.Raise(new ObjectListChangedEventArgs(location, added, removed)); -#if !SMAPI_3_0_STRICT - events.Legacy_ObjectsChanged.Raise(new EventArgsLocationObjectsChanged(location, added, removed)); -#endif - } + if (locState.Objects.IsChanged) + events.ObjectListChanged.Raise(new ObjectListChangedEventArgs(location, locState.Objects.Added, locState.Objects.Removed)); // terrain features changed - if (watcher.TerrainFeaturesWatcher.IsChanged) - { - GameLocation location = watcher.Location; - KeyValuePair<Vector2, TerrainFeature>[] added = watcher.TerrainFeaturesWatcher.Added.ToArray(); - KeyValuePair<Vector2, TerrainFeature>[] removed = watcher.TerrainFeaturesWatcher.Removed.ToArray(); - watcher.TerrainFeaturesWatcher.Reset(); - - events.TerrainFeatureListChanged.Raise(new TerrainFeatureListChangedEventArgs(location, added, removed)); - } + if (locState.TerrainFeatures.IsChanged) + events.TerrainFeatureListChanged.Raise(new TerrainFeatureListChangedEventArgs(location, locState.TerrainFeatures.Added, locState.TerrainFeatures.Removed)); } } - else - this.Watchers.LocationsWatcher.Reset(); - } - - // raise time changed - if (raiseWorldEvents && this.Watchers.TimeWatcher.IsChanged) - { - int was = this.Watchers.TimeWatcher.PreviousValue; - int now = this.Watchers.TimeWatcher.CurrentValue; - this.Watchers.TimeWatcher.Reset(); - if (this.Monitor.IsVerbose) - this.Monitor.Log($"Events: time changed from {was} to {now}.", LogLevel.Trace); + // raise time changed + if (raiseWorldEvents && state.Time.IsChanged) + events.TimeChanged.Raise(new TimeChangedEventArgs(state.Time.Old, state.Time.New)); - events.TimeChanged.Raise(new TimeChangedEventArgs(was, now)); -#if !SMAPI_3_0_STRICT - events.Legacy_TimeOfDayChanged.Raise(new EventArgsIntChanged(was, now)); -#endif - } - else - this.Watchers.TimeWatcher.Reset(); + // raise player events + if (raiseWorldEvents) + { + PlayerSnapshot playerState = state.CurrentPlayer; + Farmer player = playerState.Player; - // raise player events - if (raiseWorldEvents) - { - PlayerTracker playerTracker = this.Watchers.CurrentPlayerTracker; + // raise current location changed + if (playerState.Location.IsChanged) + { + if (this.Monitor.IsVerbose) + this.Monitor.Log($"Context: set location to {playerState.Location.New}.", LogLevel.Trace); - // raise current location changed - if (playerTracker.TryGetNewLocation(out GameLocation newLocation)) - { - if (this.Monitor.IsVerbose) - this.Monitor.Log($"Context: set location to {newLocation.Name}.", LogLevel.Trace); + events.Warped.Raise(new WarpedEventArgs(player, playerState.Location.Old, playerState.Location.New)); + } - GameLocation oldLocation = playerTracker.LocationWatcher.PreviousValue; - events.Warped.Raise(new WarpedEventArgs(playerTracker.Player, oldLocation, newLocation)); -#if !SMAPI_3_0_STRICT - events.Legacy_PlayerWarped.Raise(new EventArgsPlayerWarped(oldLocation, newLocation)); -#endif - } + // raise player leveled up a skill + foreach (var pair in playerState.Skills) + { + if (!pair.Value.IsChanged) + continue; - // raise player leveled up a skill - foreach (KeyValuePair<SkillType, IValueWatcher<int>> pair in playerTracker.GetChangedSkills()) - { - if (this.Monitor.IsVerbose) - this.Monitor.Log($"Events: player skill '{pair.Key}' changed from {pair.Value.PreviousValue} to {pair.Value.CurrentValue}.", LogLevel.Trace); + if (this.Monitor.IsVerbose) + this.Monitor.Log($"Events: player skill '{pair.Key}' changed from {pair.Value.Old} to {pair.Value.New}.", LogLevel.Trace); - events.LevelChanged.Raise(new LevelChangedEventArgs(playerTracker.Player, pair.Key, pair.Value.PreviousValue, pair.Value.CurrentValue)); -#if !SMAPI_3_0_STRICT - events.Legacy_LeveledUp.Raise(new EventArgsLevelUp((EventArgsLevelUp.LevelType)pair.Key, pair.Value.CurrentValue)); -#endif - } + events.LevelChanged.Raise(new LevelChangedEventArgs(player, pair.Key, pair.Value.Old, pair.Value.New)); + } - // raise player inventory changed - ItemStackChange[] changedItems = playerTracker.GetInventoryChanges().ToArray(); - if (changedItems.Any()) - { - if (this.Monitor.IsVerbose) - this.Monitor.Log("Events: player inventory changed.", LogLevel.Trace); - events.InventoryChanged.Raise(new InventoryChangedEventArgs(playerTracker.Player, changedItems)); -#if !SMAPI_3_0_STRICT - events.Legacy_InventoryChanged.Raise(new EventArgsInventoryChanged(Game1.player.Items, changedItems)); -#endif + // raise player inventory changed + ItemStackChange[] changedItems = playerState.InventoryChanges.ToArray(); + if (changedItems.Any()) + { + if (this.Monitor.IsVerbose) + this.Monitor.Log("Events: player inventory changed.", LogLevel.Trace); + events.InventoryChanged.Raise(new InventoryChangedEventArgs(player, changedItems)); + } } + } - // raise mine level changed - if (playerTracker.TryGetNewMineLevel(out int mineLevel)) - { - if (this.Monitor.IsVerbose) - this.Monitor.Log($"Context: mine level changed to {mineLevel}.", LogLevel.Trace); -#if !SMAPI_3_0_STRICT - events.Legacy_MineLevelChanged.Raise(new EventArgsMineLevelChanged(playerTracker.MineLevelWatcher.PreviousValue, mineLevel)); -#endif - } + /********* + ** Game update + *********/ + // game launched + bool isFirstTick = SGame.TicksElapsed == 0; + if (isFirstTick) + { + Context.IsGameLaunched = true; + events.GameLaunched.Raise(new GameLaunchedEventArgs()); } - this.Watchers.CurrentPlayerTracker?.Reset(); - } - // update save ID watcher - this.Watchers.SaveIdWatcher.Reset(); + // preloaded + if (Context.IsSaveLoaded && Context.LoadStage != LoadStage.Loaded && Context.LoadStage != LoadStage.Ready && Game1.dayOfMonth != 0) + this.OnLoadStageChanged(LoadStage.Loaded); + } /********* - ** Game update + ** Game update tick *********/ - // game launched - bool isFirstTick = SGame.TicksElapsed == 0; - if (isFirstTick) - events.GameLaunched.Raise(new GameLaunchedEventArgs()); - - // preloaded - if (Context.IsSaveLoaded && Context.LoadStage != LoadStage.Loaded && Context.LoadStage != LoadStage.Ready) - this.OnLoadStageChanged(LoadStage.Loaded); - - // update tick - bool isOneSecond = SGame.TicksElapsed % 60 == 0; - events.UnvalidatedUpdateTicking.RaiseEmpty(); - events.UpdateTicking.RaiseEmpty(); - if (isOneSecond) - events.OneSecondUpdateTicking.RaiseEmpty(); - try { - this.Input.UpdateSuppression(); - SGame.TicksElapsed++; - base.Update(gameTime); - } - catch (Exception ex) - { - this.MonitorForGame.Log($"An error occured in the base update loop: {ex.GetLogSummary()}", LogLevel.Error); + bool isOneSecond = SGame.TicksElapsed % 60 == 0; + events.UnvalidatedUpdateTicking.RaiseEmpty(); + events.UpdateTicking.RaiseEmpty(); + if (isOneSecond) + events.OneSecondUpdateTicking.RaiseEmpty(); + try + { + this.Input.UpdateSuppression(); + SGame.TicksElapsed++; + base.Update(gameTime); + } + catch (Exception ex) + { + this.MonitorForGame.Log($"An error occured in the base update loop: {ex.GetLogSummary()}", LogLevel.Error); + } + + events.UnvalidatedUpdateTicked.RaiseEmpty(); + events.UpdateTicked.RaiseEmpty(); + if (isOneSecond) + events.OneSecondUpdateTicked.RaiseEmpty(); } - events.UnvalidatedUpdateTicked.RaiseEmpty(); - events.UpdateTicked.RaiseEmpty(); - if (isOneSecond) - events.OneSecondUpdateTicked.RaiseEmpty(); /********* ** Update events *********/ -#if !SMAPI_3_0_STRICT - events.Legacy_UnvalidatedUpdateTick.Raise(); - if (isFirstTick) - events.Legacy_FirstUpdateTick.Raise(); - events.Legacy_UpdateTick.Raise(); - if (SGame.TicksElapsed % 2 == 0) - events.Legacy_SecondUpdateTick.Raise(); - if (SGame.TicksElapsed % 4 == 0) - events.Legacy_FourthUpdateTick.Raise(); - if (SGame.TicksElapsed % 8 == 0) - events.Legacy_EighthUpdateTick.Raise(); - if (SGame.TicksElapsed % 15 == 0) - events.Legacy_QuarterSecondTick.Raise(); - if (SGame.TicksElapsed % 30 == 0) - events.Legacy_HalfSecondTick.Raise(); - if (SGame.TicksElapsed % 60 == 0) - events.Legacy_OneSecondTick.Raise(); -#endif - this.UpdateCrashTimer.Reset(); } catch (Exception ex) @@ -938,18 +756,20 @@ namespace StardewModdingAPI.Framework // exit if irrecoverable if (!this.UpdateCrashTimer.Decrement()) - this.Monitor.ExitGameImmediately("the game crashed when updating, and SMAPI was unable to recover the game."); + this.ExitGameImmediately("The game crashed when updating, and SMAPI was unable to recover the game."); } } /// <summary>The method called to draw everything to the screen.</summary> /// <param name="gameTime">A snapshot of the game timing state.</param> - protected override void Draw(GameTime gameTime) + /// <param name="target_screen">The render target, if any.</param> + [SuppressMessage("ReSharper", "InconsistentNaming", Justification = "copied from game code as-is")] + protected override void _draw(GameTime gameTime, RenderTarget2D target_screen) { Context.IsInDrawLoop = true; try { - this.DrawImpl(gameTime); + this.DrawImpl(gameTime, target_screen); this.DrawCrashTimer.Reset(); } catch (Exception ex) @@ -960,7 +780,7 @@ namespace StardewModdingAPI.Framework // exit if irrecoverable if (!this.DrawCrashTimer.Decrement()) { - this.Monitor.ExitGameImmediately("the game crashed when drawing, and SMAPI was unable to recover the game."); + this.ExitGameImmediately("The game crashed when drawing, and SMAPI was unable to recover the game."); return; } @@ -983,8 +803,10 @@ namespace StardewModdingAPI.Framework /// <summary>Replicate the game's draw logic with some changes for SMAPI.</summary> /// <param name="gameTime">A snapshot of the game timing state.</param> + /// <param name="target_screen">The render target, if any.</param> /// <remarks>This implementation is identical to <see cref="Game1.Draw"/>, except for try..catch around menu draw code, private field references replaced by wrappers, and added events.</remarks> [SuppressMessage("ReSharper", "CompareOfFloatsByEqualityOperator", Justification = "copied from game code as-is")] + [SuppressMessage("ReSharper", "InconsistentNaming", Justification = "copied from game code as-is")] [SuppressMessage("ReSharper", "LocalVariableHidesMember", Justification = "copied from game code as-is")] [SuppressMessage("ReSharper", "PossibleLossOfFraction", Justification = "copied from game code as-is")] [SuppressMessage("ReSharper", "RedundantArgumentDefaultValue", Justification = "copied from game code as-is")] @@ -993,19 +815,21 @@ namespace StardewModdingAPI.Framework [SuppressMessage("ReSharper", "RedundantTypeArgumentsOfMethod", Justification = "copied from game code as-is")] [SuppressMessage("SMAPI.CommonErrors", "AvoidNetField", Justification = "copied from game code as-is")] [SuppressMessage("SMAPI.CommonErrors", "AvoidImplicitNetFieldCast", Justification = "copied from game code as-is")] - private void DrawImpl(GameTime gameTime) + private void DrawImpl(GameTime gameTime, RenderTarget2D target_screen) { var events = this.Events; if (Game1._newDayTask != null) - this.GraphicsDevice.Clear(this.bgColor); + { + this.GraphicsDevice.Clear(Game1.bgColor); + } else { - if ((double)Game1.options.zoomLevel != 1.0) - this.GraphicsDevice.SetRenderTarget(this.screen); + if (target_screen != null) + this.GraphicsDevice.SetRenderTarget(target_screen); if (this.IsSaving) { - this.GraphicsDevice.Clear(this.bgColor); + this.GraphicsDevice.Clear(Game1.bgColor); IClickableMenu activeClickableMenu = Game1.activeClickableMenu; if (activeClickableMenu != null) { @@ -1014,14 +838,8 @@ namespace StardewModdingAPI.Framework try { events.RenderingActiveMenu.RaiseEmpty(); -#if !SMAPI_3_0_STRICT - events.Legacy_OnPreRenderGuiEvent.Raise(); -#endif activeClickableMenu.draw(Game1.spriteBatch); events.RenderedActiveMenu.RaiseEmpty(); -#if !SMAPI_3_0_STRICT - events.Legacy_OnPostRenderGuiEvent.Raise(); -#endif } catch (Exception ex) { @@ -1029,10 +847,6 @@ namespace StardewModdingAPI.Framework activeClickableMenu.exitThisMenu(); } events.Rendered.RaiseEmpty(); -#if !SMAPI_3_0_STRICT - events.Legacy_OnPostRenderEvent.Raise(); -#endif - Game1.spriteBatch.End(); } if (Game1.overlayMenu != null) @@ -1041,11 +855,11 @@ namespace StardewModdingAPI.Framework Game1.overlayMenu.draw(Game1.spriteBatch); Game1.spriteBatch.End(); } - this.renderScreenBuffer(); + this.renderScreenBuffer(target_screen); } else { - this.GraphicsDevice.Clear(this.bgColor); + this.GraphicsDevice.Clear(Game1.bgColor); if (Game1.activeClickableMenu != null && Game1.options.showMenuBackground && Game1.activeClickableMenu.showWithoutTransparencyIfOptionIsSet()) { Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointClamp, (DepthStencilState)null, (RasterizerState)null); @@ -1055,14 +869,8 @@ namespace StardewModdingAPI.Framework { Game1.activeClickableMenu.drawBackground(Game1.spriteBatch); events.RenderingActiveMenu.RaiseEmpty(); -#if !SMAPI_3_0_STRICT - events.Legacy_OnPreRenderGuiEvent.Raise(); -#endif Game1.activeClickableMenu.draw(Game1.spriteBatch); events.RenderedActiveMenu.RaiseEmpty(); -#if !SMAPI_3_0_STRICT - events.Legacy_OnPostRenderGuiEvent.Raise(); -#endif } catch (Exception ex) { @@ -1070,17 +878,14 @@ namespace StardewModdingAPI.Framework Game1.activeClickableMenu.exitThisMenu(); } events.Rendered.RaiseEmpty(); -#if !SMAPI_3_0_STRICT - events.Legacy_OnPostRenderEvent.Raise(); -#endif Game1.spriteBatch.End(); this.drawOverlays(Game1.spriteBatch); - if ((double)Game1.options.zoomLevel != 1.0) + if (target_screen != null) { this.GraphicsDevice.SetRenderTarget((RenderTarget2D)null); - this.GraphicsDevice.Clear(this.bgColor); + this.GraphicsDevice.Clear(Game1.bgColor); Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.LinearClamp, DepthStencilState.Default, RasterizerState.CullNone); - Game1.spriteBatch.Draw((Texture2D)this.screen, Vector2.Zero, new Microsoft.Xna.Framework.Rectangle?(this.screen.Bounds), Color.White, 0.0f, Vector2.Zero, Game1.options.zoomLevel, SpriteEffects.None, 1f); + Game1.spriteBatch.Draw((Texture2D)target_screen, Vector2.Zero, new Microsoft.Xna.Framework.Rectangle?(target_screen.Bounds), Microsoft.Xna.Framework.Color.White, 0.0f, Vector2.Zero, Game1.options.zoomLevel, SpriteEffects.None, 1f); Game1.spriteBatch.End(); } if (Game1.overlayMenu == null) @@ -1093,34 +898,46 @@ namespace StardewModdingAPI.Framework { Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointClamp, (DepthStencilState)null, (RasterizerState)null); events.Rendering.RaiseEmpty(); - Game1.spriteBatch.DrawString(Game1.dialogueFont, Game1.content.LoadString("Strings\\StringsFromCSFiles:Game1.cs.3685"), new Vector2(16f, 16f), Color.HotPink); - Game1.spriteBatch.DrawString(Game1.dialogueFont, Game1.content.LoadString("Strings\\StringsFromCSFiles:Game1.cs.3686"), new Vector2(16f, 32f), new Color(0, (int)byte.MaxValue, 0)); - Game1.spriteBatch.DrawString(Game1.dialogueFont, Game1.parseText(Game1.errorMessage, Game1.dialogueFont, Game1.graphics.GraphicsDevice.Viewport.Width), new Vector2(16f, 48f), Color.White); + Game1.spriteBatch.DrawString(Game1.dialogueFont, Game1.content.LoadString("Strings\\StringsFromCSFiles:Game1.cs.3685"), new Vector2(16f, 16f), Microsoft.Xna.Framework.Color.HotPink); + Game1.spriteBatch.DrawString(Game1.dialogueFont, Game1.content.LoadString("Strings\\StringsFromCSFiles:Game1.cs.3686"), new Vector2(16f, 32f), new Microsoft.Xna.Framework.Color(0, (int)byte.MaxValue, 0)); + Game1.spriteBatch.DrawString(Game1.dialogueFont, Game1.parseText(Game1.errorMessage, Game1.dialogueFont, Game1.graphics.GraphicsDevice.Viewport.Width), new Vector2(16f, 48f), Microsoft.Xna.Framework.Color.White); events.Rendered.RaiseEmpty(); -#if !SMAPI_3_0_STRICT - events.Legacy_OnPostRenderEvent.Raise(); -#endif Game1.spriteBatch.End(); } else if (Game1.currentMinigame != null) { + int batchEnds = 0; + + if (events.Rendering.HasListeners()) + { + Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointClamp, null, null); + events.Rendering.RaiseEmpty(); + Game1.spriteBatch.End(); + } Game1.currentMinigame.draw(Game1.spriteBatch); if (Game1.globalFade && !Game1.menuUp && (!Game1.nameSelectUp || Game1.messagePause)) { Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointClamp, (DepthStencilState)null, (RasterizerState)null); - Game1.spriteBatch.Draw(Game1.fadeToBlackRect, Game1.graphics.GraphicsDevice.Viewport.Bounds, Color.Black * (Game1.gameMode == (byte)0 ? 1f - Game1.fadeToBlackAlpha : Game1.fadeToBlackAlpha)); + Game1.spriteBatch.Draw(Game1.fadeToBlackRect, Game1.graphics.GraphicsDevice.Viewport.Bounds, Microsoft.Xna.Framework.Color.Black * (Game1.gameMode == (byte)0 ? 1f - Game1.fadeToBlackAlpha : Game1.fadeToBlackAlpha)); Game1.spriteBatch.End(); } this.drawOverlays(Game1.spriteBatch); -#if !SMAPI_3_0_STRICT - this.RaisePostRender(needsNewBatch: true); -#endif - if ((double)Game1.options.zoomLevel == 1.0) + if (target_screen == null) + { + if (++batchEnds == 1 && events.Rendered.HasListeners()) + { + Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointClamp, null, null); + events.Rendered.RaiseEmpty(); + Game1.spriteBatch.End(); + } return; + } this.GraphicsDevice.SetRenderTarget((RenderTarget2D)null); - this.GraphicsDevice.Clear(this.bgColor); + this.GraphicsDevice.Clear(Game1.bgColor); Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.LinearClamp, DepthStencilState.Default, RasterizerState.CullNone); - Game1.spriteBatch.Draw((Texture2D)this.screen, Vector2.Zero, new Microsoft.Xna.Framework.Rectangle?(this.screen.Bounds), Color.White, 0.0f, Vector2.Zero, Game1.options.zoomLevel, SpriteEffects.None, 1f); + Game1.spriteBatch.Draw((Texture2D)target_screen, Vector2.Zero, new Microsoft.Xna.Framework.Rectangle?(target_screen.Bounds), Microsoft.Xna.Framework.Color.White, 0.0f, Vector2.Zero, Game1.options.zoomLevel, SpriteEffects.None, 1f); + if (++batchEnds == 1) + events.Rendered.RaiseEmpty(); Game1.spriteBatch.End(); } else if (Game1.showingEndOfNightStuff) @@ -1132,14 +949,8 @@ namespace StardewModdingAPI.Framework try { events.RenderingActiveMenu.RaiseEmpty(); -#if !SMAPI_3_0_STRICT - events.Legacy_OnPreRenderGuiEvent.Raise(); -#endif Game1.activeClickableMenu.draw(Game1.spriteBatch); events.RenderedActiveMenu.RaiseEmpty(); -#if !SMAPI_3_0_STRICT - events.Legacy_OnPostRenderGuiEvent.Raise(); -#endif } catch (Exception ex) { @@ -1150,12 +961,12 @@ namespace StardewModdingAPI.Framework events.Rendered.RaiseEmpty(); Game1.spriteBatch.End(); this.drawOverlays(Game1.spriteBatch); - if ((double)Game1.options.zoomLevel == 1.0) + if (target_screen == null) return; this.GraphicsDevice.SetRenderTarget((RenderTarget2D)null); - this.GraphicsDevice.Clear(this.bgColor); + this.GraphicsDevice.Clear(Game1.bgColor); Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.LinearClamp, DepthStencilState.Default, RasterizerState.CullNone); - Game1.spriteBatch.Draw((Texture2D)this.screen, Vector2.Zero, new Microsoft.Xna.Framework.Rectangle?(this.screen.Bounds), Color.White, 0.0f, Vector2.Zero, Game1.options.zoomLevel, SpriteEffects.None, 1f); + Game1.spriteBatch.Draw((Texture2D)target_screen, Vector2.Zero, new Microsoft.Xna.Framework.Rectangle?(target_screen.Bounds), Microsoft.Xna.Framework.Color.White, 0.0f, Vector2.Zero, Game1.options.zoomLevel, SpriteEffects.None, 1f); Game1.spriteBatch.End(); } else if (Game1.gameMode == (byte)6 || Game1.gameMode == (byte)3 && Game1.currentLocation == null) @@ -1168,20 +979,20 @@ namespace StardewModdingAPI.Framework string str2 = Game1.content.LoadString("Strings\\StringsFromCSFiles:Game1.cs.3688"); string s = str2 + str1; string str3 = str2 + "... "; - int widthOfString = SpriteText.getWidthOfString(str3); + int widthOfString = SpriteText.getWidthOfString(str3, 999999); int height = 64; int x = 64; int y = Game1.graphics.GraphicsDevice.Viewport.GetTitleSafeArea().Bottom - height; - SpriteText.drawString(Game1.spriteBatch, s, x, y, 999999, widthOfString, height, 1f, 0.88f, false, 0, str3, -1); + SpriteText.drawString(Game1.spriteBatch, s, x, y, 999999, widthOfString, height, 1f, 0.88f, false, 0, str3, -1, SpriteText.ScrollTextAlignment.Left); events.Rendered.RaiseEmpty(); Game1.spriteBatch.End(); this.drawOverlays(Game1.spriteBatch); - if ((double)Game1.options.zoomLevel != 1.0) + if (target_screen != null) { this.GraphicsDevice.SetRenderTarget((RenderTarget2D)null); - this.GraphicsDevice.Clear(this.bgColor); + this.GraphicsDevice.Clear(Game1.bgColor); Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.LinearClamp, DepthStencilState.Default, RasterizerState.CullNone); - Game1.spriteBatch.Draw((Texture2D)this.screen, Vector2.Zero, new Microsoft.Xna.Framework.Rectangle?(this.screen.Bounds), Color.White, 0.0f, Vector2.Zero, Game1.options.zoomLevel, SpriteEffects.None, 1f); + Game1.spriteBatch.Draw((Texture2D)target_screen, Vector2.Zero, new Microsoft.Xna.Framework.Rectangle?(target_screen.Bounds), Microsoft.Xna.Framework.Color.White, 0.0f, Vector2.Zero, Game1.options.zoomLevel, SpriteEffects.None, 1f); Game1.spriteBatch.End(); } if (Game1.overlayMenu != null) @@ -1196,7 +1007,6 @@ namespace StardewModdingAPI.Framework { byte batchOpens = 0; // used for rendering event - Microsoft.Xna.Framework.Rectangle rectangle; Viewport viewport; if (Game1.gameMode == (byte)0) { @@ -1209,29 +1019,37 @@ namespace StardewModdingAPI.Framework if (Game1.drawLighting) { this.GraphicsDevice.SetRenderTarget(Game1.lightmap); - this.GraphicsDevice.Clear(Color.White * 0.0f); + this.GraphicsDevice.Clear(Microsoft.Xna.Framework.Color.White * 0.0f); Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.NonPremultiplied, SamplerState.PointClamp, (DepthStencilState)null, (RasterizerState)null); if (++batchOpens == 1) events.Rendering.RaiseEmpty(); - Game1.spriteBatch.Draw(Game1.staminaRect, Game1.lightmap.Bounds, Game1.currentLocation.Name.StartsWith("UndergroundMine") ? Game1.mine.getLightingColor(gameTime) : (Game1.ambientLight.Equals(Color.White) || Game1.isRaining && (bool)((NetFieldBase<bool, NetBool>)Game1.currentLocation.isOutdoors) ? Game1.outdoorLight : Game1.ambientLight)); + Microsoft.Xna.Framework.Color color = !Game1.currentLocation.Name.StartsWith("UndergroundMine") || !(Game1.currentLocation is MineShaft) ? (Game1.ambientLight.Equals(Microsoft.Xna.Framework.Color.White) || Game1.isRaining && (bool)((NetFieldBase<bool, NetBool>)Game1.currentLocation.isOutdoors) ? Game1.outdoorLight : Game1.ambientLight) : (Game1.currentLocation as MineShaft).getLightingColor(gameTime); + Game1.spriteBatch.Draw(Game1.staminaRect, Game1.lightmap.Bounds, color); for (int index = 0; index < Game1.currentLightSources.Count; ++index) { - if (Utility.isOnScreen((Vector2)((NetFieldBase<Vector2, NetVector2>)Game1.currentLightSources.ElementAt<LightSource>(index).position), (int)((double)(float)((NetFieldBase<float, NetFloat>)Game1.currentLightSources.ElementAt<LightSource>(index).radius) * 64.0 * 4.0))) - Game1.spriteBatch.Draw(Game1.currentLightSources.ElementAt<LightSource>(index).lightTexture, Game1.GlobalToLocal(Game1.viewport, (Vector2)((NetFieldBase<Vector2, NetVector2>)Game1.currentLightSources.ElementAt<LightSource>(index).position)) / (float)(Game1.options.lightingQuality / 2), new Microsoft.Xna.Framework.Rectangle?(Game1.currentLightSources.ElementAt<LightSource>(index).lightTexture.Bounds), (Color)((NetFieldBase<Color, NetColor>)Game1.currentLightSources.ElementAt<LightSource>(index).color), 0.0f, new Vector2((float)Game1.currentLightSources.ElementAt<LightSource>(index).lightTexture.Bounds.Center.X, (float)Game1.currentLightSources.ElementAt<LightSource>(index).lightTexture.Bounds.Center.Y), (float)((NetFieldBase<float, NetFloat>)Game1.currentLightSources.ElementAt<LightSource>(index).radius) / (float)(Game1.options.lightingQuality / 2), SpriteEffects.None, 0.9f); + LightSource lightSource = Game1.currentLightSources.ElementAt<LightSource>(index); + if (!Game1.isRaining && !Game1.isDarkOut() || lightSource.lightContext.Value != LightSource.LightContext.WindowLight) + { + if (lightSource.PlayerID != 0L && lightSource.PlayerID != Game1.player.UniqueMultiplayerID) + { + Farmer farmerMaybeOffline = Game1.getFarmerMaybeOffline(lightSource.PlayerID); + if (farmerMaybeOffline == null || farmerMaybeOffline.currentLocation != null && farmerMaybeOffline.currentLocation.Name != Game1.currentLocation.Name || (bool)((NetFieldBase<bool, NetBool>)farmerMaybeOffline.hidden)) + continue; + } + if (Utility.isOnScreen((Vector2)((NetFieldBase<Vector2, NetVector2>)Game1.currentLightSources.ElementAt<LightSource>(index).position), (int)((double)(float)((NetFieldBase<float, NetFloat>)Game1.currentLightSources.ElementAt<LightSource>(index).radius) * 64.0 * 4.0))) + Game1.spriteBatch.Draw(Game1.currentLightSources.ElementAt<LightSource>(index).lightTexture, Game1.GlobalToLocal(Game1.viewport, (Vector2)((NetFieldBase<Vector2, NetVector2>)Game1.currentLightSources.ElementAt<LightSource>(index).position)) / (float)(Game1.options.lightingQuality / 2), new Microsoft.Xna.Framework.Rectangle?(Game1.currentLightSources.ElementAt<LightSource>(index).lightTexture.Bounds), (Microsoft.Xna.Framework.Color)((NetFieldBase<Microsoft.Xna.Framework.Color, NetColor>)Game1.currentLightSources.ElementAt<LightSource>(index).color), 0.0f, new Vector2((float)Game1.currentLightSources.ElementAt<LightSource>(index).lightTexture.Bounds.Center.X, (float)Game1.currentLightSources.ElementAt<LightSource>(index).lightTexture.Bounds.Center.Y), (float)((NetFieldBase<float, NetFloat>)Game1.currentLightSources.ElementAt<LightSource>(index).radius) / (float)(Game1.options.lightingQuality / 2), SpriteEffects.None, 0.9f); + } } Game1.spriteBatch.End(); - this.GraphicsDevice.SetRenderTarget((double)Game1.options.zoomLevel == 1.0 ? (RenderTarget2D)null : this.screen); + this.GraphicsDevice.SetRenderTarget(target_screen); } if (Game1.bloomDay && Game1.bloom != null) Game1.bloom.BeginDraw(); - this.GraphicsDevice.Clear(this.bgColor); + this.GraphicsDevice.Clear(Game1.bgColor); Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointClamp, (DepthStencilState)null, (RasterizerState)null); if (++batchOpens == 1) events.Rendering.RaiseEmpty(); events.RenderingWorld.RaiseEmpty(); -#if !SMAPI_3_0_STRICT - events.Legacy_OnPreRenderEvent.Raise(); -#endif if (Game1.background != null) Game1.background.draw(Game1.spriteBatch); Game1.mapDisplayDevice.BeginScene(Game1.spriteBatch); @@ -1261,7 +1079,7 @@ namespace StardewModdingAPI.Framework foreach (NPC character in Game1.currentLocation.characters) { if (!(bool)((NetFieldBase<bool, NetBool>)character.swimming) && !character.HideShadow && (!character.IsInvisible && !Game1.currentLocation.shouldShadowBeDrawnAboveBuildingsLayer(character.getTileLocation()))) - Game1.spriteBatch.Draw(Game1.shadowTexture, Game1.GlobalToLocal(Game1.viewport, character.Position + new Vector2((float)(character.Sprite.SpriteWidth * 4) / 2f, (float)(character.GetBoundingBox().Height + (character.IsMonster ? 0 : 12)))), new Microsoft.Xna.Framework.Rectangle?(Game1.shadowTexture.Bounds), Color.White, 0.0f, new Vector2((float)Game1.shadowTexture.Bounds.Center.X, (float)Game1.shadowTexture.Bounds.Center.Y), (float)(4.0 + (double)character.yJumpOffset / 40.0) * (float)((NetFieldBase<float, NetFloat>)character.scale), SpriteEffects.None, Math.Max(0.0f, (float)character.getStandingY() / 10000f) - 1E-06f); + Game1.spriteBatch.Draw(Game1.shadowTexture, Game1.GlobalToLocal(Game1.viewport, character.Position + new Vector2((float)(character.Sprite.SpriteWidth * 4) / 2f, (float)(character.GetBoundingBox().Height + (character.IsMonster ? 0 : 12)))), new Microsoft.Xna.Framework.Rectangle?(Game1.shadowTexture.Bounds), Microsoft.Xna.Framework.Color.White, 0.0f, new Vector2((float)Game1.shadowTexture.Bounds.Center.X, (float)Game1.shadowTexture.Bounds.Center.Y), (float)(4.0 + (double)character.yJumpOffset / 40.0) * (float)((NetFieldBase<float, NetFloat>)character.scale), SpriteEffects.None, Math.Max(0.0f, (float)character.getStandingY() / 10000f) - 1E-06f); } } else @@ -1269,32 +1087,17 @@ namespace StardewModdingAPI.Framework foreach (NPC actor in Game1.CurrentEvent.actors) { if (!(bool)((NetFieldBase<bool, NetBool>)actor.swimming) && !actor.HideShadow && !Game1.currentLocation.shouldShadowBeDrawnAboveBuildingsLayer(actor.getTileLocation())) - Game1.spriteBatch.Draw(Game1.shadowTexture, Game1.GlobalToLocal(Game1.viewport, actor.Position + new Vector2((float)(actor.Sprite.SpriteWidth * 4) / 2f, (float)(actor.GetBoundingBox().Height + (actor.IsMonster ? 0 : (actor.Sprite.SpriteHeight <= 16 ? -4 : 12))))), new Microsoft.Xna.Framework.Rectangle?(Game1.shadowTexture.Bounds), Color.White, 0.0f, new Vector2((float)Game1.shadowTexture.Bounds.Center.X, (float)Game1.shadowTexture.Bounds.Center.Y), (float)(4.0 + (double)actor.yJumpOffset / 40.0) * (float)((NetFieldBase<float, NetFloat>)actor.scale), SpriteEffects.None, Math.Max(0.0f, (float)actor.getStandingY() / 10000f) - 1E-06f); + Game1.spriteBatch.Draw(Game1.shadowTexture, Game1.GlobalToLocal(Game1.viewport, actor.Position + new Vector2((float)(actor.Sprite.SpriteWidth * 4) / 2f, (float)(actor.GetBoundingBox().Height + (actor.IsMonster ? 0 : (actor.Sprite.SpriteHeight <= 16 ? -4 : 12))))), new Microsoft.Xna.Framework.Rectangle?(Game1.shadowTexture.Bounds), Microsoft.Xna.Framework.Color.White, 0.0f, new Vector2((float)Game1.shadowTexture.Bounds.Center.X, (float)Game1.shadowTexture.Bounds.Center.Y), (float)(4.0 + (double)actor.yJumpOffset / 40.0) * (float)((NetFieldBase<float, NetFloat>)actor.scale), SpriteEffects.None, Math.Max(0.0f, (float)actor.getStandingY() / 10000f) - 1E-06f); } } foreach (Farmer farmerShadow in this._farmerShadows) { - if (!(bool)((NetFieldBase<bool, NetBool>)farmerShadow.swimming) && !farmerShadow.isRidingHorse() && (Game1.currentLocation == null || !Game1.currentLocation.shouldShadowBeDrawnAboveBuildingsLayer(farmerShadow.getTileLocation()))) - { - SpriteBatch spriteBatch = Game1.spriteBatch; - Texture2D shadowTexture = Game1.shadowTexture; - Vector2 local = Game1.GlobalToLocal(farmerShadow.Position + new Vector2(32f, 24f)); - Microsoft.Xna.Framework.Rectangle? sourceRectangle = new Microsoft.Xna.Framework.Rectangle?(Game1.shadowTexture.Bounds); - Color white = Color.White; - double num1 = 0.0; - Microsoft.Xna.Framework.Rectangle bounds = Game1.shadowTexture.Bounds; - double x = (double)bounds.Center.X; - bounds = Game1.shadowTexture.Bounds; - double y = (double)bounds.Center.Y; - Vector2 origin = new Vector2((float)x, (float)y); - double num2 = 4.0 - (!farmerShadow.running && !farmerShadow.UsingTool || farmerShadow.FarmerSprite.currentAnimationIndex <= 1 ? 0.0 : (double)Math.Abs(FarmerRenderer.featureYOffsetPerFrame[farmerShadow.FarmerSprite.CurrentFrame]) * 0.5); - int num3 = 0; - double num4 = 0.0; - spriteBatch.Draw(shadowTexture, local, sourceRectangle, white, (float)num1, origin, (float)num2, (SpriteEffects)num3, (float)num4); - } + if (!Game1.multiplayer.isDisconnecting(farmerShadow.UniqueMultiplayerID) && !(bool)((NetFieldBase<bool, NetBool>)farmerShadow.swimming) && !farmerShadow.isRidingHorse() && (Game1.currentLocation == null || !Game1.currentLocation.shouldShadowBeDrawnAboveBuildingsLayer(farmerShadow.getTileLocation()))) + Game1.spriteBatch.Draw(Game1.shadowTexture, Game1.GlobalToLocal(farmerShadow.Position + new Vector2(32f, 24f)), new Microsoft.Xna.Framework.Rectangle?(Game1.shadowTexture.Bounds), Microsoft.Xna.Framework.Color.White, 0.0f, new Vector2((float)Game1.shadowTexture.Bounds.Center.X, (float)Game1.shadowTexture.Bounds.Center.Y), (float)(4.0 - (!farmerShadow.running && !farmerShadow.UsingTool || farmerShadow.FarmerSprite.currentAnimationIndex <= 1 ? 0.0 : (double)Math.Abs(FarmerRenderer.featureYOffsetPerFrame[farmerShadow.FarmerSprite.CurrentFrame]) * 0.5)), SpriteEffects.None, 0.0f); } } - Game1.currentLocation.Map.GetLayer("Buildings").Draw(Game1.mapDisplayDevice, Game1.viewport, Location.Origin, false, 4); + Layer layer = Game1.currentLocation.Map.GetLayer("Buildings"); + layer.Draw(Game1.mapDisplayDevice, Game1.viewport, Location.Origin, false, 4); Game1.mapDisplayDevice.EndScene(); Game1.spriteBatch.End(); Game1.spriteBatch.Begin(SpriteSortMode.FrontToBack, BlendState.AlphaBlend, SamplerState.PointClamp, (DepthStencilState)null, (RasterizerState)null); @@ -1304,8 +1107,8 @@ namespace StardewModdingAPI.Framework { foreach (NPC character in Game1.currentLocation.characters) { - if (!(bool)((NetFieldBase<bool, NetBool>)character.swimming) && !character.HideShadow && Game1.currentLocation.shouldShadowBeDrawnAboveBuildingsLayer(character.getTileLocation())) - Game1.spriteBatch.Draw(Game1.shadowTexture, Game1.GlobalToLocal(Game1.viewport, character.Position + new Vector2((float)(character.Sprite.SpriteWidth * 4) / 2f, (float)(character.GetBoundingBox().Height + (character.IsMonster ? 0 : 12)))), new Microsoft.Xna.Framework.Rectangle?(Game1.shadowTexture.Bounds), Color.White, 0.0f, new Vector2((float)Game1.shadowTexture.Bounds.Center.X, (float)Game1.shadowTexture.Bounds.Center.Y), (float)(4.0 + (double)character.yJumpOffset / 40.0) * (float)((NetFieldBase<float, NetFloat>)character.scale), SpriteEffects.None, Math.Max(0.0f, (float)character.getStandingY() / 10000f) - 1E-06f); + if (!(bool)((NetFieldBase<bool, NetBool>)character.swimming) && !character.HideShadow && (!(bool)((NetFieldBase<bool, NetBool>)character.isInvisible) && Game1.currentLocation.shouldShadowBeDrawnAboveBuildingsLayer(character.getTileLocation()))) + Game1.spriteBatch.Draw(Game1.shadowTexture, Game1.GlobalToLocal(Game1.viewport, character.Position + new Vector2((float)(character.Sprite.SpriteWidth * 4) / 2f, (float)(character.GetBoundingBox().Height + (character.IsMonster ? 0 : 12)))), new Microsoft.Xna.Framework.Rectangle?(Game1.shadowTexture.Bounds), Microsoft.Xna.Framework.Color.White, 0.0f, new Vector2((float)Game1.shadowTexture.Bounds.Center.X, (float)Game1.shadowTexture.Bounds.Center.Y), (float)(4.0 + (double)character.yJumpOffset / 40.0) * (float)((NetFieldBase<float, NetFloat>)character.scale), SpriteEffects.None, Math.Max(0.0f, (float)character.getStandingY() / 10000f) - 1E-06f); } } else @@ -1313,36 +1116,31 @@ namespace StardewModdingAPI.Framework foreach (NPC actor in Game1.CurrentEvent.actors) { if (!(bool)((NetFieldBase<bool, NetBool>)actor.swimming) && !actor.HideShadow && Game1.currentLocation.shouldShadowBeDrawnAboveBuildingsLayer(actor.getTileLocation())) - Game1.spriteBatch.Draw(Game1.shadowTexture, Game1.GlobalToLocal(Game1.viewport, actor.Position + new Vector2((float)(actor.Sprite.SpriteWidth * 4) / 2f, (float)(actor.GetBoundingBox().Height + (actor.IsMonster ? 0 : 12)))), new Microsoft.Xna.Framework.Rectangle?(Game1.shadowTexture.Bounds), Color.White, 0.0f, new Vector2((float)Game1.shadowTexture.Bounds.Center.X, (float)Game1.shadowTexture.Bounds.Center.Y), (float)(4.0 + (double)actor.yJumpOffset / 40.0) * (float)((NetFieldBase<float, NetFloat>)actor.scale), SpriteEffects.None, Math.Max(0.0f, (float)actor.getStandingY() / 10000f) - 1E-06f); + Game1.spriteBatch.Draw(Game1.shadowTexture, Game1.GlobalToLocal(Game1.viewport, actor.Position + new Vector2((float)(actor.Sprite.SpriteWidth * 4) / 2f, (float)(actor.GetBoundingBox().Height + (actor.IsMonster ? 0 : 12)))), new Microsoft.Xna.Framework.Rectangle?(Game1.shadowTexture.Bounds), Microsoft.Xna.Framework.Color.White, 0.0f, new Vector2((float)Game1.shadowTexture.Bounds.Center.X, (float)Game1.shadowTexture.Bounds.Center.Y), (float)(4.0 + (double)actor.yJumpOffset / 40.0) * (float)((NetFieldBase<float, NetFloat>)actor.scale), SpriteEffects.None, Math.Max(0.0f, (float)actor.getStandingY() / 10000f) - 1E-06f); } } foreach (Farmer farmerShadow in this._farmerShadows) { + float layerDepth = Math.Max(0.0001f, farmerShadow.getDrawLayer() + 0.00011f) - 0.0001f; if (!(bool)((NetFieldBase<bool, NetBool>)farmerShadow.swimming) && !farmerShadow.isRidingHorse() && (Game1.currentLocation != null && Game1.currentLocation.shouldShadowBeDrawnAboveBuildingsLayer(farmerShadow.getTileLocation()))) - { - SpriteBatch spriteBatch = Game1.spriteBatch; - Texture2D shadowTexture = Game1.shadowTexture; - Vector2 local = Game1.GlobalToLocal(farmerShadow.Position + new Vector2(32f, 24f)); - Microsoft.Xna.Framework.Rectangle? sourceRectangle = new Microsoft.Xna.Framework.Rectangle?(Game1.shadowTexture.Bounds); - Color white = Color.White; - double num1 = 0.0; - Microsoft.Xna.Framework.Rectangle bounds = Game1.shadowTexture.Bounds; - double x = (double)bounds.Center.X; - bounds = Game1.shadowTexture.Bounds; - double y = (double)bounds.Center.Y; - Vector2 origin = new Vector2((float)x, (float)y); - double num2 = 4.0 - (!farmerShadow.running && !farmerShadow.UsingTool || farmerShadow.FarmerSprite.currentAnimationIndex <= 1 ? 0.0 : (double)Math.Abs(FarmerRenderer.featureYOffsetPerFrame[farmerShadow.FarmerSprite.CurrentFrame]) * 0.5); - int num3 = 0; - double num4 = 0.0; - spriteBatch.Draw(shadowTexture, local, sourceRectangle, white, (float)num1, origin, (float)num2, (SpriteEffects)num3, (float)num4); - } + Game1.spriteBatch.Draw(Game1.shadowTexture, Game1.GlobalToLocal(farmerShadow.Position + new Vector2(32f, 24f)), new Microsoft.Xna.Framework.Rectangle?(Game1.shadowTexture.Bounds), Microsoft.Xna.Framework.Color.White, 0.0f, new Vector2((float)Game1.shadowTexture.Bounds.Center.X, (float)Game1.shadowTexture.Bounds.Center.Y), (float)(4.0 - (!farmerShadow.running && !farmerShadow.UsingTool || farmerShadow.FarmerSprite.currentAnimationIndex <= 1 ? 0.0 : (double)Math.Abs(FarmerRenderer.featureYOffsetPerFrame[farmerShadow.FarmerSprite.CurrentFrame]) * 0.5)), SpriteEffects.None, layerDepth); } } if ((Game1.eventUp || Game1.killScreen) && (!Game1.killScreen && Game1.currentLocation.currentEvent != null)) Game1.currentLocation.currentEvent.draw(Game1.spriteBatch); if (Game1.player.currentUpgrade != null && Game1.player.currentUpgrade.daysLeftTillUpgradeDone <= 3 && Game1.currentLocation.Name.Equals("Farm")) - Game1.spriteBatch.Draw(Game1.player.currentUpgrade.workerTexture, Game1.GlobalToLocal(Game1.viewport, Game1.player.currentUpgrade.positionOfCarpenter), new Microsoft.Xna.Framework.Rectangle?(Game1.player.currentUpgrade.getSourceRectangle()), Color.White, 0.0f, Vector2.Zero, 1f, SpriteEffects.None, (float)(((double)Game1.player.currentUpgrade.positionOfCarpenter.Y + 48.0) / 10000.0)); + Game1.spriteBatch.Draw(Game1.player.currentUpgrade.workerTexture, Game1.GlobalToLocal(Game1.viewport, Game1.player.currentUpgrade.positionOfCarpenter), new Microsoft.Xna.Framework.Rectangle?(Game1.player.currentUpgrade.getSourceRectangle()), Microsoft.Xna.Framework.Color.White, 0.0f, Vector2.Zero, 1f, SpriteEffects.None, (float)(((double)Game1.player.currentUpgrade.positionOfCarpenter.Y + 48.0) / 10000.0)); Game1.currentLocation.draw(Game1.spriteBatch); + foreach (Vector2 key in Game1.crabPotOverlayTiles.Keys) + { + Tile tile = layer.Tiles[(int)key.X, (int)key.Y]; + if (tile != null) + { + Vector2 local = Game1.GlobalToLocal(Game1.viewport, key * 64f); + Location location = new Location((int)local.X, (int)local.Y); + Game1.mapDisplayDevice.DrawTile(tile, location, (float)(((double)key.Y * 64.0 - 1.0) / 10000.0)); + } + } if (Game1.eventUp && Game1.currentLocation.currentEvent != null) { string messageToScreen = Game1.currentLocation.currentEvent.messageToScreen; @@ -1352,12 +1150,12 @@ namespace StardewModdingAPI.Framework if (Game1.currentLocation.Name.Equals("Farm")) this.drawFarmBuildings(); if (Game1.tvStation >= 0) - Game1.spriteBatch.Draw(Game1.tvStationTexture, Game1.GlobalToLocal(Game1.viewport, new Vector2(400f, 160f)), new Microsoft.Xna.Framework.Rectangle?(new Microsoft.Xna.Framework.Rectangle(Game1.tvStation * 24, 0, 24, 15)), Color.White, 0.0f, Vector2.Zero, 4f, SpriteEffects.None, 1E-08f); + Game1.spriteBatch.Draw(Game1.tvStationTexture, Game1.GlobalToLocal(Game1.viewport, new Vector2(400f, 160f)), new Microsoft.Xna.Framework.Rectangle?(new Microsoft.Xna.Framework.Rectangle(Game1.tvStation * 24, 0, 24, 15)), Microsoft.Xna.Framework.Color.White, 0.0f, Vector2.Zero, 4f, SpriteEffects.None, 1E-08f); if (Game1.panMode) { - Game1.spriteBatch.Draw(Game1.fadeToBlackRect, new Microsoft.Xna.Framework.Rectangle((int)Math.Floor((double)(Game1.getOldMouseX() + Game1.viewport.X) / 64.0) * 64 - Game1.viewport.X, (int)Math.Floor((double)(Game1.getOldMouseY() + Game1.viewport.Y) / 64.0) * 64 - Game1.viewport.Y, 64, 64), Color.Lime * 0.75f); + Game1.spriteBatch.Draw(Game1.fadeToBlackRect, new Microsoft.Xna.Framework.Rectangle((int)Math.Floor((double)(Game1.getOldMouseX() + Game1.viewport.X) / 64.0) * 64 - Game1.viewport.X, (int)Math.Floor((double)(Game1.getOldMouseY() + Game1.viewport.Y) / 64.0) * 64 - Game1.viewport.Y, 64, 64), Microsoft.Xna.Framework.Color.Lime * 0.75f); foreach (Warp warp in (NetList<Warp, NetRef<Warp>>)Game1.currentLocation.warps) - Game1.spriteBatch.Draw(Game1.fadeToBlackRect, new Microsoft.Xna.Framework.Rectangle(warp.X * 64 - Game1.viewport.X, warp.Y * 64 - Game1.viewport.Y, 64, 64), Color.Red * 0.75f); + Game1.spriteBatch.Draw(Game1.fadeToBlackRect, new Microsoft.Xna.Framework.Rectangle(warp.X * 64 - Game1.viewport.X, warp.Y * 64 - Game1.viewport.Y, 64, 64), Microsoft.Xna.Framework.Color.Red * 0.75f); } Game1.mapDisplayDevice.BeginScene(Game1.spriteBatch); Game1.currentLocation.Map.GetLayer("Front").Draw(Game1.mapDisplayDevice, Game1.viewport, Location.Origin, false, 4); @@ -1367,29 +1165,8 @@ namespace StardewModdingAPI.Framework Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointClamp, (DepthStencilState)null, (RasterizerState)null); if (Game1.displayFarmer && Game1.player.ActiveObject != null && ((bool)((NetFieldBase<bool, NetBool>)Game1.player.ActiveObject.bigCraftable) && this.checkBigCraftableBoundariesForFrontLayer()) && Game1.currentLocation.Map.GetLayer("Front").PickTile(new Location(Game1.player.getStandingX(), Game1.player.getStandingY()), Game1.viewport.Size) == null) Game1.drawPlayerHeldObject(Game1.player); - else if (Game1.displayFarmer && Game1.player.ActiveObject != null) - { - if (Game1.currentLocation.Map.GetLayer("Front").PickTile(new Location((int)Game1.player.Position.X, (int)Game1.player.Position.Y - 38), Game1.viewport.Size) == null || Game1.currentLocation.Map.GetLayer("Front").PickTile(new Location((int)Game1.player.Position.X, (int)Game1.player.Position.Y - 38), Game1.viewport.Size).TileIndexProperties.ContainsKey("FrontAlways")) - { - Layer layer1 = Game1.currentLocation.Map.GetLayer("Front"); - rectangle = Game1.player.GetBoundingBox(); - Location mapDisplayLocation1 = new Location(rectangle.Right, (int)Game1.player.Position.Y - 38); - Size size1 = Game1.viewport.Size; - if (layer1.PickTile(mapDisplayLocation1, size1) != null) - { - Layer layer2 = Game1.currentLocation.Map.GetLayer("Front"); - rectangle = Game1.player.GetBoundingBox(); - Location mapDisplayLocation2 = new Location(rectangle.Right, (int)Game1.player.Position.Y - 38); - Size size2 = Game1.viewport.Size; - if (layer2.PickTile(mapDisplayLocation2, size2).TileIndexProperties.ContainsKey("FrontAlways")) - goto label_129; - } - else - goto label_129; - } + else if (Game1.displayFarmer && Game1.player.ActiveObject != null && (Game1.currentLocation.Map.GetLayer("Front").PickTile(new Location((int)Game1.player.Position.X, (int)Game1.player.Position.Y - 38), Game1.viewport.Size) != null && !Game1.currentLocation.Map.GetLayer("Front").PickTile(new Location((int)Game1.player.Position.X, (int)Game1.player.Position.Y - 38), Game1.viewport.Size).TileIndexProperties.ContainsKey("FrontAlways") || Game1.currentLocation.Map.GetLayer("Front").PickTile(new Location(Game1.player.GetBoundingBox().Right, (int)Game1.player.Position.Y - 38), Game1.viewport.Size) != null && !Game1.currentLocation.Map.GetLayer("Front").PickTile(new Location(Game1.player.GetBoundingBox().Right, (int)Game1.player.Position.Y - 38), Game1.viewport.Size).TileIndexProperties.ContainsKey("FrontAlways"))) Game1.drawPlayerHeldObject(Game1.player); - } - label_129: if ((Game1.player.UsingTool || Game1.pickingTool) && Game1.player.CurrentTool != null && ((!Game1.player.CurrentTool.Name.Equals("Seeds") || Game1.pickingTool) && (Game1.currentLocation.Map.GetLayer("Front").PickTile(new Location(Game1.player.getStandingX(), (int)Game1.player.Position.Y - 38), Game1.viewport.Size) != null && Game1.currentLocation.Map.GetLayer("Front").PickTile(new Location(Game1.player.getStandingX(), Game1.player.getStandingY()), Game1.viewport.Size) == null))) Game1.drawTool(Game1.player); if (Game1.currentLocation.Map.GetLayer("AlwaysFront") != null) @@ -1400,7 +1177,7 @@ namespace StardewModdingAPI.Framework } if ((double)Game1.toolHold > 400.0 && Game1.player.CurrentTool.UpgradeLevel >= 1 && Game1.player.canReleaseTool) { - Color color = Color.White; + Microsoft.Xna.Framework.Color color = Microsoft.Xna.Framework.Color.White; switch ((int)((double)Game1.toolHold / 600.0) + 2) { case 1: @@ -1416,14 +1193,10 @@ namespace StardewModdingAPI.Framework color = Tool.iridiumColor; break; } - Game1.spriteBatch.Draw(Game1.littleEffect, new Microsoft.Xna.Framework.Rectangle((int)Game1.player.getLocalPosition(Game1.viewport).X - 2, (int)Game1.player.getLocalPosition(Game1.viewport).Y - (Game1.player.CurrentTool.Name.Equals("Watering Can") ? 0 : 64) - 2, (int)((double)Game1.toolHold % 600.0 * 0.0799999982118607) + 4, 12), Color.Black); + Game1.spriteBatch.Draw(Game1.littleEffect, new Microsoft.Xna.Framework.Rectangle((int)Game1.player.getLocalPosition(Game1.viewport).X - 2, (int)Game1.player.getLocalPosition(Game1.viewport).Y - (Game1.player.CurrentTool.Name.Equals("Watering Can") ? 0 : 64) - 2, (int)((double)Game1.toolHold % 600.0 * 0.0799999982118607) + 4, 12), Microsoft.Xna.Framework.Color.Black); Game1.spriteBatch.Draw(Game1.littleEffect, new Microsoft.Xna.Framework.Rectangle((int)Game1.player.getLocalPosition(Game1.viewport).X, (int)Game1.player.getLocalPosition(Game1.viewport).Y - (Game1.player.CurrentTool.Name.Equals("Watering Can") ? 0 : 64), (int)((double)Game1.toolHold % 600.0 * 0.0799999982118607), 8), color); } - if (Game1.isDebrisWeather && Game1.currentLocation.IsOutdoors && (!(bool)((NetFieldBase<bool, NetBool>)Game1.currentLocation.ignoreDebrisWeather) && !Game1.currentLocation.Name.Equals("Desert")) && Game1.viewport.X > -10) - { - foreach (WeatherDebris weatherDebris in Game1.debrisWeather) - weatherDebris.draw(Game1.spriteBatch); - } + this.drawWeather(gameTime, target_screen); if (Game1.farmEvent != null) Game1.farmEvent.draw(Game1.spriteBatch); if ((double)Game1.currentLocation.LightLevel > 0.0 && Game1.timeOfDay < 2000) @@ -1432,7 +1205,7 @@ namespace StardewModdingAPI.Framework Texture2D fadeToBlackRect = Game1.fadeToBlackRect; viewport = Game1.graphics.GraphicsDevice.Viewport; Microsoft.Xna.Framework.Rectangle bounds = viewport.Bounds; - Color color = Color.Black * Game1.currentLocation.LightLevel; + Microsoft.Xna.Framework.Color color = Microsoft.Xna.Framework.Color.Black * Game1.currentLocation.LightLevel; spriteBatch.Draw(fadeToBlackRect, bounds, color); } if (Game1.screenGlow) @@ -1441,17 +1214,12 @@ namespace StardewModdingAPI.Framework Texture2D fadeToBlackRect = Game1.fadeToBlackRect; viewport = Game1.graphics.GraphicsDevice.Viewport; Microsoft.Xna.Framework.Rectangle bounds = viewport.Bounds; - Color color = Game1.screenGlowColor * Game1.screenGlowAlpha; + Microsoft.Xna.Framework.Color color = Game1.screenGlowColor * Game1.screenGlowAlpha; spriteBatch.Draw(fadeToBlackRect, bounds, color); } Game1.currentLocation.drawAboveAlwaysFrontLayer(Game1.spriteBatch); if (Game1.player.CurrentTool != null && Game1.player.CurrentTool is FishingRod && ((Game1.player.CurrentTool as FishingRod).isTimingCast || (double)(Game1.player.CurrentTool as FishingRod).castingChosenCountdown > 0.0 || ((Game1.player.CurrentTool as FishingRod).fishCaught || (Game1.player.CurrentTool as FishingRod).showingTreasure))) Game1.player.CurrentTool.draw(Game1.spriteBatch); - if (Game1.isRaining && Game1.currentLocation.IsOutdoors && (!Game1.currentLocation.Name.Equals("Desert") && !(Game1.currentLocation is Summit)) && (!Game1.eventUp || Game1.currentLocation.isTileOnMap(new Vector2((float)(Game1.viewport.X / 64), (float)(Game1.viewport.Y / 64))))) - { - for (int index = 0; index < Game1.rainDrops.Length; ++index) - Game1.spriteBatch.Draw(Game1.rainTexture, Game1.rainDrops[index].position, new Microsoft.Xna.Framework.Rectangle?(Game1.getSourceRectForStandardTileSheet(Game1.rainTexture, Game1.rainDrops[index].frame, -1, -1)), Color.White); - } Game1.spriteBatch.End(); Game1.spriteBatch.Begin(SpriteSortMode.FrontToBack, BlendState.AlphaBlend, SamplerState.PointClamp, (DepthStencilState)null, (RasterizerState)null); if (Game1.eventUp && Game1.currentLocation.currentEvent != null) @@ -1466,7 +1234,7 @@ namespace StardewModdingAPI.Framework localPosition.Y += 32f; else if (actor.Gender == 1) localPosition.Y += 10f; - Game1.spriteBatch.Draw(Game1.emoteSpriteSheet, localPosition, new Microsoft.Xna.Framework.Rectangle?(new Microsoft.Xna.Framework.Rectangle(actor.CurrentEmoteIndex * 16 % Game1.emoteSpriteSheet.Width, actor.CurrentEmoteIndex * 16 / Game1.emoteSpriteSheet.Width * 16, 16, 16)), Color.White, 0.0f, Vector2.Zero, 4f, SpriteEffects.None, (float)actor.getStandingY() / 10000f); + Game1.spriteBatch.Draw(Game1.emoteSpriteSheet, localPosition, new Microsoft.Xna.Framework.Rectangle?(new Microsoft.Xna.Framework.Rectangle(actor.CurrentEmoteIndex * 16 % Game1.emoteSpriteSheet.Width, actor.CurrentEmoteIndex * 16 / Game1.emoteSpriteSheet.Width * 16, 16, 16)), Microsoft.Xna.Framework.Color.White, 0.0f, Vector2.Zero, 4f, SpriteEffects.None, (float)actor.getStandingY() / 10000f); } } } @@ -1474,14 +1242,14 @@ namespace StardewModdingAPI.Framework if (Game1.drawLighting) { Game1.spriteBatch.Begin(SpriteSortMode.Deferred, this.lightingBlend, SamplerState.LinearClamp, (DepthStencilState)null, (RasterizerState)null); - Game1.spriteBatch.Draw((Texture2D)Game1.lightmap, Vector2.Zero, new Microsoft.Xna.Framework.Rectangle?(Game1.lightmap.Bounds), Color.White, 0.0f, Vector2.Zero, (float)(Game1.options.lightingQuality / 2), SpriteEffects.None, 1f); + Game1.spriteBatch.Draw((Texture2D)Game1.lightmap, Vector2.Zero, new Microsoft.Xna.Framework.Rectangle?(Game1.lightmap.Bounds), Microsoft.Xna.Framework.Color.White, 0.0f, Vector2.Zero, (float)(Game1.options.lightingQuality / 2), SpriteEffects.None, 1f); if (Game1.isRaining && (bool)((NetFieldBase<bool, NetBool>)Game1.currentLocation.isOutdoors) && !(Game1.currentLocation is Desert)) { SpriteBatch spriteBatch = Game1.spriteBatch; Texture2D staminaRect = Game1.staminaRect; viewport = Game1.graphics.GraphicsDevice.Viewport; Microsoft.Xna.Framework.Rectangle bounds = viewport.Bounds; - Color color = Color.OrangeRed * 0.45f; + Microsoft.Xna.Framework.Color color = Microsoft.Xna.Framework.Color.OrangeRed * 0.45f; spriteBatch.Draw(staminaRect, bounds, color); } Game1.spriteBatch.End(); @@ -1497,18 +1265,17 @@ namespace StardewModdingAPI.Framework { int num4 = num3; viewport = Game1.graphics.GraphicsDevice.Viewport; - int width1 = viewport.Width; - if (num4 < width1) + int width = viewport.Width; + if (num4 < width) { SpriteBatch spriteBatch = Game1.spriteBatch; Texture2D staminaRect = Game1.staminaRect; int x = num3; int y = (int)num2; - int width2 = 1; viewport = Game1.graphics.GraphicsDevice.Viewport; int height = viewport.Height; - Microsoft.Xna.Framework.Rectangle destinationRectangle = new Microsoft.Xna.Framework.Rectangle(x, y, width2, height); - Color color = Color.Red * 0.5f; + Microsoft.Xna.Framework.Rectangle destinationRectangle = new Microsoft.Xna.Framework.Rectangle(x, y, 1, height); + Microsoft.Xna.Framework.Color color = Microsoft.Xna.Framework.Color.Red * 0.5f; spriteBatch.Draw(staminaRect, destinationRectangle, color); num3 += 64; } @@ -1520,8 +1287,8 @@ namespace StardewModdingAPI.Framework { double num4 = (double)num5; viewport = Game1.graphics.GraphicsDevice.Viewport; - double height1 = (double)viewport.Height; - if (num4 < height1) + double height = (double)viewport.Height; + if (num4 < height) { SpriteBatch spriteBatch = Game1.spriteBatch; Texture2D staminaRect = Game1.staminaRect; @@ -1529,9 +1296,8 @@ namespace StardewModdingAPI.Framework int y = (int)num5; viewport = Game1.graphics.GraphicsDevice.Viewport; int width = viewport.Width; - int height2 = 1; - Microsoft.Xna.Framework.Rectangle destinationRectangle = new Microsoft.Xna.Framework.Rectangle(x, y, width, height2); - Color color = Color.Red * 0.5f; + Microsoft.Xna.Framework.Rectangle destinationRectangle = new Microsoft.Xna.Framework.Rectangle(x, y, width, 1); + Microsoft.Xna.Framework.Color color = Microsoft.Xna.Framework.Color.Red * 0.5f; spriteBatch.Draw(staminaRect, destinationRectangle, color); num5 += 64f; } @@ -1539,23 +1305,40 @@ namespace StardewModdingAPI.Framework break; } } - if (Game1.currentBillboard != 0) + if (Game1.currentBillboard != 0 && !this.takingMapScreenshot) this.drawBillboard(); - if ((Game1.displayHUD || Game1.eventUp) && (Game1.currentBillboard == 0 && Game1.gameMode == (byte)3) && (!Game1.freezeControls && !Game1.panMode && !Game1.HostPaused)) + if (!Game1.eventUp && Game1.farmEvent == null && (Game1.currentBillboard == 0 && Game1.gameMode == (byte)3) && (!this.takingMapScreenshot && Game1.isOutdoorMapSmallerThanViewport())) + { + SpriteBatch spriteBatch1 = Game1.spriteBatch; + Texture2D fadeToBlackRect1 = Game1.fadeToBlackRect; + int width1 = -Math.Min(Game1.viewport.X, 4096); + viewport = Game1.graphics.GraphicsDevice.Viewport; + int height1 = viewport.Height; + Microsoft.Xna.Framework.Rectangle destinationRectangle1 = new Microsoft.Xna.Framework.Rectangle(0, 0, width1, height1); + Microsoft.Xna.Framework.Color black1 = Microsoft.Xna.Framework.Color.Black; + spriteBatch1.Draw(fadeToBlackRect1, destinationRectangle1, black1); + SpriteBatch spriteBatch2 = Game1.spriteBatch; + Texture2D fadeToBlackRect2 = Game1.fadeToBlackRect; + int x = -Game1.viewport.X + Game1.currentLocation.map.Layers[0].LayerWidth * 64; + viewport = Game1.graphics.GraphicsDevice.Viewport; + int width2 = Math.Min(4096, viewport.Width - (-Game1.viewport.X + Game1.currentLocation.map.Layers[0].LayerWidth * 64)); + viewport = Game1.graphics.GraphicsDevice.Viewport; + int height2 = viewport.Height; + Microsoft.Xna.Framework.Rectangle destinationRectangle2 = new Microsoft.Xna.Framework.Rectangle(x, 0, width2, height2); + Microsoft.Xna.Framework.Color black2 = Microsoft.Xna.Framework.Color.Black; + spriteBatch2.Draw(fadeToBlackRect2, destinationRectangle2, black2); + } + if ((Game1.displayHUD || Game1.eventUp) && (Game1.currentBillboard == 0 && Game1.gameMode == (byte)3) && (!Game1.freezeControls && !Game1.panMode && (!Game1.HostPaused && !this.takingMapScreenshot))) { events.RenderingHud.RaiseEmpty(); -#if !SMAPI_3_0_STRICT - events.Legacy_OnPreRenderHudEvent.Raise(); -#endif this.drawHUD(); events.RenderedHud.RaiseEmpty(); -#if !SMAPI_3_0_STRICT - events.Legacy_OnPostRenderHudEvent.Raise(); -#endif } - else if (Game1.activeClickableMenu == null && Game1.farmEvent == null) - Game1.spriteBatch.Draw(Game1.mouseCursors, new Vector2((float)Game1.getOldMouseX(), (float)Game1.getOldMouseY()), new Microsoft.Xna.Framework.Rectangle?(Game1.getSourceRectForStandardTileSheet(Game1.mouseCursors, 0, 16, 16)), Color.White, 0.0f, Vector2.Zero, (float)(4.0 + (double)Game1.dialogueButtonScale / 150.0), SpriteEffects.None, 1f); - if (Game1.hudMessages.Count > 0 && (!Game1.eventUp || Game1.isFestival())) + else if (Game1.activeClickableMenu == null) + { + FarmEvent farmEvent = Game1.farmEvent; + } + if (Game1.hudMessages.Count > 0 && !this.takingMapScreenshot) { for (int i = Game1.hudMessages.Count - 1; i >= 0; --i) Game1.hudMessages[i].draw(Game1.spriteBatch, i); @@ -1563,30 +1346,12 @@ namespace StardewModdingAPI.Framework } if (Game1.farmEvent != null) Game1.farmEvent.draw(Game1.spriteBatch); - if (Game1.dialogueUp && !Game1.nameSelectUp && !Game1.messagePause && (Game1.activeClickableMenu == null || !(Game1.activeClickableMenu is DialogueBox))) + if (Game1.dialogueUp && !Game1.nameSelectUp && !Game1.messagePause && ((Game1.activeClickableMenu == null || !(Game1.activeClickableMenu is DialogueBox)) && !this.takingMapScreenshot)) this.drawDialogueBox(); - if (Game1.progressBar) + if (Game1.progressBar && !this.takingMapScreenshot) { - SpriteBatch spriteBatch1 = Game1.spriteBatch; - Texture2D fadeToBlackRect = Game1.fadeToBlackRect; - int x1 = (Game1.graphics.GraphicsDevice.Viewport.GetTitleSafeArea().Width - Game1.dialogueWidth) / 2; - rectangle = Game1.graphics.GraphicsDevice.Viewport.GetTitleSafeArea(); - int y1 = rectangle.Bottom - 128; - int dialogueWidth = Game1.dialogueWidth; - int height1 = 32; - Microsoft.Xna.Framework.Rectangle destinationRectangle1 = new Microsoft.Xna.Framework.Rectangle(x1, y1, dialogueWidth, height1); - Color lightGray = Color.LightGray; - spriteBatch1.Draw(fadeToBlackRect, destinationRectangle1, lightGray); - SpriteBatch spriteBatch2 = Game1.spriteBatch; - Texture2D staminaRect = Game1.staminaRect; - int x2 = (Game1.graphics.GraphicsDevice.Viewport.GetTitleSafeArea().Width - Game1.dialogueWidth) / 2; - rectangle = Game1.graphics.GraphicsDevice.Viewport.GetTitleSafeArea(); - int y2 = rectangle.Bottom - 128; - int width = (int)((double)Game1.pauseAccumulator / (double)Game1.pauseTime * (double)Game1.dialogueWidth); - int height2 = 32; - Microsoft.Xna.Framework.Rectangle destinationRectangle2 = new Microsoft.Xna.Framework.Rectangle(x2, y2, width, height2); - Color dimGray = Color.DimGray; - spriteBatch2.Draw(staminaRect, destinationRectangle2, dimGray); + Game1.spriteBatch.Draw(Game1.fadeToBlackRect, new Microsoft.Xna.Framework.Rectangle((Game1.graphics.GraphicsDevice.Viewport.GetTitleSafeArea().Width - Game1.dialogueWidth) / 2, Game1.graphics.GraphicsDevice.Viewport.GetTitleSafeArea().Bottom - 128, Game1.dialogueWidth, 32), Microsoft.Xna.Framework.Color.LightGray); + Game1.spriteBatch.Draw(Game1.staminaRect, new Microsoft.Xna.Framework.Rectangle((Game1.graphics.GraphicsDevice.Viewport.GetTitleSafeArea().Width - Game1.dialogueWidth) / 2, Game1.graphics.GraphicsDevice.Viewport.GetTitleSafeArea().Bottom - 128, (int)((double)Game1.pauseAccumulator / (double)Game1.pauseTime * (double)Game1.dialogueWidth), 32), Microsoft.Xna.Framework.Color.DimGray); } if (Game1.eventUp && Game1.currentLocation != null && Game1.currentLocation.currentEvent != null) Game1.currentLocation.currentEvent.drawAfterMap(Game1.spriteBatch); @@ -1596,19 +1361,19 @@ namespace StardewModdingAPI.Framework Texture2D staminaRect = Game1.staminaRect; viewport = Game1.graphics.GraphicsDevice.Viewport; Microsoft.Xna.Framework.Rectangle bounds = viewport.Bounds; - Color color = Color.Blue * 0.2f; + Microsoft.Xna.Framework.Color color = Microsoft.Xna.Framework.Color.Blue * 0.2f; spriteBatch.Draw(staminaRect, bounds, color); } - if ((Game1.fadeToBlack || Game1.globalFade) && !Game1.menuUp && (!Game1.nameSelectUp || Game1.messagePause)) + if ((Game1.fadeToBlack || Game1.globalFade) && !Game1.menuUp && ((!Game1.nameSelectUp || Game1.messagePause) && !this.takingMapScreenshot)) { SpriteBatch spriteBatch = Game1.spriteBatch; Texture2D fadeToBlackRect = Game1.fadeToBlackRect; viewport = Game1.graphics.GraphicsDevice.Viewport; Microsoft.Xna.Framework.Rectangle bounds = viewport.Bounds; - Color color = Color.Black * (Game1.gameMode == (byte)0 ? 1f - Game1.fadeToBlackAlpha : Game1.fadeToBlackAlpha); + Microsoft.Xna.Framework.Color color = Microsoft.Xna.Framework.Color.Black * (Game1.gameMode == (byte)0 ? 1f - Game1.fadeToBlackAlpha : Game1.fadeToBlackAlpha); spriteBatch.Draw(fadeToBlackRect, bounds, color); } - else if ((double)Game1.flashAlpha > 0.0) + else if ((double)Game1.flashAlpha > 0.0 && !this.takingMapScreenshot) { if (Game1.options.screenFlash) { @@ -1616,15 +1381,18 @@ namespace StardewModdingAPI.Framework Texture2D fadeToBlackRect = Game1.fadeToBlackRect; viewport = Game1.graphics.GraphicsDevice.Viewport; Microsoft.Xna.Framework.Rectangle bounds = viewport.Bounds; - Color color = Color.White * Math.Min(1f, Game1.flashAlpha); + Microsoft.Xna.Framework.Color color = Microsoft.Xna.Framework.Color.White * Math.Min(1f, Game1.flashAlpha); spriteBatch.Draw(fadeToBlackRect, bounds, color); } Game1.flashAlpha -= 0.1f; } - if ((Game1.messagePause || Game1.globalFade) && Game1.dialogueUp) + if ((Game1.messagePause || Game1.globalFade) && (Game1.dialogueUp && !this.takingMapScreenshot)) this.drawDialogueBox(); - foreach (TemporaryAnimatedSprite overlayTempSprite in Game1.screenOverlayTempSprites) - overlayTempSprite.draw(Game1.spriteBatch, true, 0, 0, 1f); + if (!this.takingMapScreenshot) + { + foreach (TemporaryAnimatedSprite overlayTempSprite in Game1.screenOverlayTempSprites) + overlayTempSprite.draw(Game1.spriteBatch, true, 0, 0, 1f); + } if (Game1.debugMode) { StringBuilder debugStringBuilder = Game1._debugStringBuilder; @@ -1649,25 +1417,23 @@ namespace StardewModdingAPI.Framework debugStringBuilder.Append(","); debugStringBuilder.Append(Game1.getMouseY()); debugStringBuilder.Append(Environment.NewLine); - debugStringBuilder.Append("debugOutput: "); + debugStringBuilder.Append(" mouseWorldPosition: "); + debugStringBuilder.Append(Game1.getMouseX() + Game1.viewport.X); + debugStringBuilder.Append(","); + debugStringBuilder.Append(Game1.getMouseY() + Game1.viewport.Y); + debugStringBuilder.Append(" debugOutput: "); debugStringBuilder.Append(Game1.debugOutput); - Game1.spriteBatch.DrawString(Game1.smallFont, debugStringBuilder, new Vector2((float)this.GraphicsDevice.Viewport.GetTitleSafeArea().X, (float)(this.GraphicsDevice.Viewport.GetTitleSafeArea().Y + Game1.smallFont.LineSpacing * 8)), Color.Red, 0.0f, Vector2.Zero, 1f, SpriteEffects.None, 0.9999999f); + Game1.spriteBatch.DrawString(Game1.smallFont, debugStringBuilder, new Vector2((float)this.GraphicsDevice.Viewport.GetTitleSafeArea().X, (float)(this.GraphicsDevice.Viewport.GetTitleSafeArea().Y + Game1.smallFont.LineSpacing * 8)), Microsoft.Xna.Framework.Color.Red, 0.0f, Vector2.Zero, 1f, SpriteEffects.None, 0.9999999f); } - if (Game1.showKeyHelp) - Game1.spriteBatch.DrawString(Game1.smallFont, Game1.keyHelpString, new Vector2(64f, (float)(Game1.viewport.Height - 64 - (Game1.dialogueUp ? 192 + (Game1.isQuestion ? Game1.questionChoices.Count * 64 : 0) : 0)) - Game1.smallFont.MeasureString(Game1.keyHelpString).Y), Color.LightGray, 0.0f, Vector2.Zero, 1f, SpriteEffects.None, 0.9999999f); - if (Game1.activeClickableMenu != null) + if (Game1.showKeyHelp && !this.takingMapScreenshot) + Game1.spriteBatch.DrawString(Game1.smallFont, Game1.keyHelpString, new Vector2(64f, (float)(Game1.viewport.Height - 64 - (Game1.dialogueUp ? 192 + (Game1.isQuestion ? Game1.questionChoices.Count * 64 : 0) : 0)) - Game1.smallFont.MeasureString(Game1.keyHelpString).Y), Microsoft.Xna.Framework.Color.LightGray, 0.0f, Vector2.Zero, 1f, SpriteEffects.None, 0.9999999f); + if (Game1.activeClickableMenu != null && !this.takingMapScreenshot) { try { events.RenderingActiveMenu.RaiseEmpty(); -#if !SMAPI_3_0_STRICT - events.Legacy_OnPreRenderGuiEvent.Raise(); -#endif Game1.activeClickableMenu.draw(Game1.spriteBatch); events.RenderedActiveMenu.RaiseEmpty(); -#if !SMAPI_3_0_STRICT - events.Legacy_OnPostRenderGuiEvent.Raise(); -#endif } catch (Exception ex) { @@ -1677,41 +1443,29 @@ namespace StardewModdingAPI.Framework } else if (Game1.farmEvent != null) Game1.farmEvent.drawAboveEverything(Game1.spriteBatch); - if (Game1.HostPaused) + if (Game1.emoteMenu != null && !this.takingMapScreenshot) + Game1.emoteMenu.draw(Game1.spriteBatch); + if (Game1.HostPaused && !this.takingMapScreenshot) { string s = Game1.content.LoadString("Strings\\StringsFromCSFiles:DayTimeMoneyBox.cs.10378"); - SpriteText.drawStringWithScrollBackground(Game1.spriteBatch, s, 96, 32, "", 1f, -1); + SpriteText.drawStringWithScrollBackground(Game1.spriteBatch, s, 96, 32, "", 1f, -1, SpriteText.ScrollTextAlignment.Left); } events.Rendered.RaiseEmpty(); -#if !SMAPI_3_0_STRICT - events.Legacy_OnPostRenderEvent.Raise(); -#endif Game1.spriteBatch.End(); this.drawOverlays(Game1.spriteBatch); - this.renderScreenBuffer(); + this.renderScreenBuffer(target_screen); } } } } - /**** - ** Methods - ****/ -#if !SMAPI_3_0_STRICT - /// <summary>Raise the <see cref="GraphicsEvents.OnPostRenderEvent"/> if there are any listeners.</summary> - /// <param name="needsNewBatch">Whether to create a new sprite batch.</param> - private void RaisePostRender(bool needsNewBatch = false) + /// <summary>Immediately exit the game without saving. This should only be invoked when an irrecoverable fatal error happens that risks save corruption or game-breaking bugs.</summary> + /// <param name="message">The fatal log message.</param> + private void ExitGameImmediately(string message) { - if (this.Events.Legacy_OnPostRenderEvent.HasListeners()) - { - if (needsNewBatch) - Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointClamp, null, null); - this.Events.Legacy_OnPostRenderEvent.Raise(); - if (needsNewBatch) - Game1.spriteBatch.End(); - } + this.Monitor.LogFatal(message); + this.CancellationToken.Cancel(); } -#endif } } diff --git a/src/SMAPI/Framework/SGameConstructorHack.cs b/src/SMAPI/Framework/SGameConstructorHack.cs index 494bab99..f70dec03 100644 --- a/src/SMAPI/Framework/SGameConstructorHack.cs +++ b/src/SMAPI/Framework/SGameConstructorHack.cs @@ -1,10 +1,11 @@ +using System; using StardewModdingAPI.Framework.Reflection; -using StardewModdingAPI.Toolkit.Serialisation; +using StardewModdingAPI.Toolkit.Serialization; using StardewValley; namespace StardewModdingAPI.Framework { - /// <summary>The static state to use while <see cref="Game1"/> is initialising, which happens before the <see cref="SGame"/> constructor runs.</summary> + /// <summary>The static state to use while <see cref="Game1"/> is initializing, which happens before the <see cref="SGame"/> constructor runs.</summary> internal class SGameConstructorHack { /********* @@ -19,6 +20,9 @@ namespace StardewModdingAPI.Framework /// <summary>Encapsulates SMAPI's JSON file parsing.</summary> public JsonHelper JsonHelper { get; } + /// <summary>A callback to invoke the first time *any* game content manager loads an asset.</summary> + public Action OnLoadingFirstAsset { get; } + /********* ** Public methods @@ -27,11 +31,13 @@ namespace StardewModdingAPI.Framework /// <param name="monitor">Encapsulates monitoring and logging.</param> /// <param name="reflection">Simplifies access to private game code.</param> /// <param name="jsonHelper">Encapsulates SMAPI's JSON file parsing.</param> - public SGameConstructorHack(IMonitor monitor, Reflector reflection, JsonHelper jsonHelper) + /// <param name="onLoadingFirstAsset">A callback to invoke the first time *any* game content manager loads an asset.</param> + public SGameConstructorHack(IMonitor monitor, Reflector reflection, JsonHelper jsonHelper, Action onLoadingFirstAsset) { this.Monitor = monitor; this.Reflection = reflection; this.JsonHelper = jsonHelper; + this.OnLoadingFirstAsset = onLoadingFirstAsset; } } } diff --git a/src/SMAPI/Framework/SMultiplayer.cs b/src/SMAPI/Framework/SMultiplayer.cs index 0241ef02..e04205c8 100644 --- a/src/SMAPI/Framework/SMultiplayer.cs +++ b/src/SMAPI/Framework/SMultiplayer.cs @@ -9,7 +9,7 @@ using StardewModdingAPI.Events; using StardewModdingAPI.Framework.Events; using StardewModdingAPI.Framework.Networking; using StardewModdingAPI.Framework.Reflection; -using StardewModdingAPI.Toolkit.Serialisation; +using StardewModdingAPI.Toolkit.Serialization; using StardewValley; using StardewValley.Network; using StardewValley.SDKs; @@ -51,6 +51,9 @@ namespace StardewModdingAPI.Framework /// <summary>A callback to invoke when a mod message is received.</summary> private readonly Action<ModMessageModel> OnModMessageReceived; + /// <summary>Whether to log network traffic.</summary> + private readonly bool LogNetworkTraffic; + /********* ** Accessors @@ -72,7 +75,8 @@ namespace StardewModdingAPI.Framework /// <param name="modRegistry">Tracks the installed mods.</param> /// <param name="reflection">Simplifies access to private code.</param> /// <param name="onModMessageReceived">A callback to invoke when a mod message is received.</param> - public SMultiplayer(IMonitor monitor, EventManager eventManager, JsonHelper jsonHelper, ModRegistry modRegistry, Reflector reflection, Action<ModMessageModel> onModMessageReceived) + /// <param name="logNetworkTraffic">Whether to log network traffic.</param> + public SMultiplayer(IMonitor monitor, EventManager eventManager, JsonHelper jsonHelper, ModRegistry modRegistry, Reflector reflection, Action<ModMessageModel> onModMessageReceived, bool logNetworkTraffic) { this.Monitor = monitor; this.EventManager = eventManager; @@ -80,6 +84,7 @@ namespace StardewModdingAPI.Framework this.ModRegistry = modRegistry; this.Reflection = reflection; this.OnModMessageReceived = onModMessageReceived; + this.LogNetworkTraffic = logNetworkTraffic; } /// <summary>Perform cleanup needed when a multiplayer session ends.</summary> @@ -89,26 +94,8 @@ namespace StardewModdingAPI.Framework this.HostPeer = null; } -#if !SMAPI_3_0_STRICT - /// <summary>Handle sync messages from other players and perform other initial sync logic.</summary> - public override void UpdateEarly() - { - this.EventManager.Legacy_BeforeMainSync.Raise(); - base.UpdateEarly(); - this.EventManager.Legacy_AfterMainSync.Raise(); - } - - /// <summary>Broadcast sync messages to other players and perform other final sync logic.</summary> - public override void UpdateLate(bool forceSync = false) - { - this.EventManager.Legacy_BeforeMainBroadcast.Raise(); - base.UpdateLate(forceSync); - this.EventManager.Legacy_AfterMainBroadcast.Raise(); - } -#endif - - /// <summary>Initialise a client before the game connects to a remote server.</summary> - /// <param name="client">The client to initialise.</param> + /// <summary>Initialize a client before the game connects to a remote server.</summary> + /// <param name="client">The client to initialize.</param> public override Client InitClient(Client client) { switch (client) @@ -131,8 +118,8 @@ namespace StardewModdingAPI.Framework } } - /// <summary>Initialise a server before the game connects to an incoming player.</summary> - /// <param name="server">The server to initialise.</param> + /// <summary>Initialize a server before the game connects to an incoming player.</summary> + /// <param name="server">The server to initialize.</param> public override Server InitServer(Server server) { switch (server) @@ -161,7 +148,7 @@ namespace StardewModdingAPI.Framework /// <param name="resume">Resume sending the underlying message.</param> protected void OnClientSendingMessage(OutgoingMessage message, Action<OutgoingMessage> sendMessage, Action resume) { - if (this.Monitor.IsVerbose) + if (this.LogNetworkTraffic) this.Monitor.Log($"CLIENT SEND {(MessageType)message.MessageType} {message.FarmerID}", LogLevel.Trace); switch (message.MessageType) @@ -185,7 +172,7 @@ namespace StardewModdingAPI.Framework /// <param name="resume">Process the message using the game's default logic.</param> public void OnServerProcessingMessage(IncomingMessage message, Action<OutgoingMessage> sendMessage, Action resume) { - if (this.Monitor.IsVerbose) + if (this.LogNetworkTraffic) this.Monitor.Log($"SERVER RECV {(MessageType)message.MessageType} {message.FarmerID}", LogLevel.Trace); switch (message.MessageType) @@ -201,7 +188,8 @@ namespace StardewModdingAPI.Framework MultiplayerPeer newPeer = new MultiplayerPeer(message.FarmerID, model, sendMessage, isHost: false); if (this.Peers.ContainsKey(message.FarmerID)) { - this.Monitor.Log($"Rejected mod context from farmhand {message.FarmerID}: already received context for that player.", LogLevel.Error); + this.Monitor.Log($"Received mod context from farmhand {message.FarmerID}, but the game didn't see them disconnect. This may indicate issues with the network connection.", LogLevel.Info); + this.Peers.Remove(message.FarmerID); return; } this.AddPeer(newPeer, canBeHost: false, raiseEvent: false); @@ -264,7 +252,7 @@ namespace StardewModdingAPI.Framework /// <returns>Returns whether the message was handled.</returns> public void OnClientProcessingMessage(IncomingMessage message, Action<OutgoingMessage> sendMessage, Action resume) { - if (this.Monitor.IsVerbose) + if (this.LogNetworkTraffic) this.Monitor.Log($"CLIENT RECV {(MessageType)message.MessageType} {message.FarmerID}", LogLevel.Trace); switch (message.MessageType) @@ -388,7 +376,7 @@ namespace StardewModdingAPI.Framework string data = JsonConvert.SerializeObject(model, Formatting.None); // log message - if (this.Monitor.IsVerbose) + if (this.LogNetworkTraffic) this.Monitor.Log($"Broadcasting '{messageType}' message: {data}.", LogLevel.Trace); // send message @@ -435,7 +423,7 @@ namespace StardewModdingAPI.Framework private RemoteContextModel ReadContext(BinaryReader reader) { string data = reader.ReadString(); - RemoteContextModel model = this.JsonHelper.Deserialise<RemoteContextModel>(data); + RemoteContextModel model = this.JsonHelper.Deserialize<RemoteContextModel>(data); return model.ApiVersion != null ? model : null; // no data available for unmodded players @@ -447,10 +435,10 @@ namespace StardewModdingAPI.Framework { // parse message string json = message.Reader.ReadString(); - ModMessageModel model = this.JsonHelper.Deserialise<ModMessageModel>(json); + ModMessageModel model = this.JsonHelper.Deserialize<ModMessageModel>(json); HashSet<long> playerIDs = new HashSet<long>(model.ToPlayerIDs ?? this.GetKnownPlayerIDs()); - if (this.Monitor.IsVerbose) - this.Monitor.Log($"Received message: {json}."); + if (this.LogNetworkTraffic) + this.Monitor.Log($"Received message: {json}.", LogLevel.Trace); // notify local mods if (playerIDs.Contains(Game1.player.UniqueMultiplayerID)) @@ -466,7 +454,7 @@ namespace StardewModdingAPI.Framework { newModel.ToPlayerIDs = new[] { peer.PlayerID }; this.Monitor.VerboseLog($" Forwarding message to player {peer.PlayerID}."); - peer.SendMessage(new OutgoingMessage((byte)MessageType.ModMessage, peer.PlayerID, this.JsonHelper.Serialise(newModel, Formatting.None))); + peer.SendMessage(new OutgoingMessage((byte)MessageType.ModMessage, peer.PlayerID, this.JsonHelper.Serialize(newModel, Formatting.None))); } } } @@ -500,7 +488,7 @@ namespace StardewModdingAPI.Framework .ToArray() }; - return new object[] { this.JsonHelper.Serialise(model, Formatting.None) }; + return new object[] { this.JsonHelper.Serialize(model, Formatting.None) }; } /// <summary>Get the fields to include in a context sync message sent to other players.</summary> @@ -526,7 +514,7 @@ namespace StardewModdingAPI.Framework .ToArray() }; - return new object[] { this.JsonHelper.Serialise(model, Formatting.None) }; + return new object[] { this.JsonHelper.Serialize(model, Formatting.None) }; } } } diff --git a/src/SMAPI/Framework/Serialisation/ColorConverter.cs b/src/SMAPI/Framework/Serialization/ColorConverter.cs index c27065bf..19979981 100644 --- a/src/SMAPI/Framework/Serialisation/ColorConverter.cs +++ b/src/SMAPI/Framework/Serialization/ColorConverter.cs @@ -1,12 +1,12 @@ using System; using Microsoft.Xna.Framework; using Newtonsoft.Json.Linq; -using StardewModdingAPI.Toolkit.Serialisation; -using StardewModdingAPI.Toolkit.Serialisation.Converters; +using StardewModdingAPI.Toolkit.Serialization; +using StardewModdingAPI.Toolkit.Serialization.Converters; -namespace StardewModdingAPI.Framework.Serialisation +namespace StardewModdingAPI.Framework.Serialization { - /// <summary>Handles deserialisation of <see cref="Color"/> for crossplatform compatibility.</summary> + /// <summary>Handles deserialization of <see cref="Color"/> for crossplatform compatibility.</summary> /// <remarks> /// - Linux/Mac format: { "B": 76, "G": 51, "R": 25, "A": 102 } /// - Windows format: "26, 51, 76, 102" diff --git a/src/SMAPI/Framework/Serialisation/PointConverter.cs b/src/SMAPI/Framework/Serialization/PointConverter.cs index fbc857d2..8c2f3396 100644 --- a/src/SMAPI/Framework/Serialisation/PointConverter.cs +++ b/src/SMAPI/Framework/Serialization/PointConverter.cs @@ -1,12 +1,12 @@ using System; using Microsoft.Xna.Framework; using Newtonsoft.Json.Linq; -using StardewModdingAPI.Toolkit.Serialisation; -using StardewModdingAPI.Toolkit.Serialisation.Converters; +using StardewModdingAPI.Toolkit.Serialization; +using StardewModdingAPI.Toolkit.Serialization.Converters; -namespace StardewModdingAPI.Framework.Serialisation +namespace StardewModdingAPI.Framework.Serialization { - /// <summary>Handles deserialisation of <see cref="PointConverter"/> for crossplatform compatibility.</summary> + /// <summary>Handles deserialization of <see cref="PointConverter"/> for crossplatform compatibility.</summary> /// <remarks> /// - Linux/Mac format: { "X": 1, "Y": 2 } /// - Windows format: "1, 2" diff --git a/src/SMAPI/Framework/Serialisation/RectangleConverter.cs b/src/SMAPI/Framework/Serialization/RectangleConverter.cs index 4f55cc32..fbb2e253 100644 --- a/src/SMAPI/Framework/Serialisation/RectangleConverter.cs +++ b/src/SMAPI/Framework/Serialization/RectangleConverter.cs @@ -2,12 +2,12 @@ using System; using System.Text.RegularExpressions; using Microsoft.Xna.Framework; using Newtonsoft.Json.Linq; -using StardewModdingAPI.Toolkit.Serialisation; -using StardewModdingAPI.Toolkit.Serialisation.Converters; +using StardewModdingAPI.Toolkit.Serialization; +using StardewModdingAPI.Toolkit.Serialization.Converters; -namespace StardewModdingAPI.Framework.Serialisation +namespace StardewModdingAPI.Framework.Serialization { - /// <summary>Handles deserialisation of <see cref="Rectangle"/> for crossplatform compatibility.</summary> + /// <summary>Handles deserialization of <see cref="Rectangle"/> for crossplatform compatibility.</summary> /// <remarks> /// - Linux/Mac format: { "X": 1, "Y": 2, "Width": 3, "Height": 4 } /// - Windows format: "{X:1 Y:2 Width:3 Height:4}" diff --git a/src/SMAPI/Framework/SnapshotDiff.cs b/src/SMAPI/Framework/SnapshotDiff.cs new file mode 100644 index 00000000..5b6288ff --- /dev/null +++ b/src/SMAPI/Framework/SnapshotDiff.cs @@ -0,0 +1,43 @@ +using StardewModdingAPI.Framework.StateTracking; + +namespace StardewModdingAPI.Framework +{ + /// <summary>A snapshot of a tracked value.</summary> + /// <typeparam name="T">The tracked value type.</typeparam> + internal class SnapshotDiff<T> + { + /********* + ** Accessors + *********/ + /// <summary>Whether the value changed since the last update.</summary> + public bool IsChanged { get; private set; } + + /// <summary>The previous value.</summary> + public T Old { get; private set; } + + /// <summary>The current value.</summary> + public T New { get; private set; } + + + /********* + ** Public methods + *********/ + /// <summary>Update the snapshot.</summary> + /// <param name="isChanged">Whether the value changed since the last update.</param> + /// <param name="old">The previous value.</param> + /// <param name="now">The current value.</param> + public void Update(bool isChanged, T old, T now) + { + this.IsChanged = isChanged; + this.Old = old; + this.New = now; + } + + /// <summary>Update the snapshot.</summary> + /// <param name="watcher">The value watcher to snapshot.</param> + public void Update(IValueWatcher<T> watcher) + { + this.Update(watcher.IsChanged, watcher.PreviousValue, watcher.CurrentValue); + } + } +} diff --git a/src/SMAPI/Framework/SnapshotListDiff.cs b/src/SMAPI/Framework/SnapshotListDiff.cs new file mode 100644 index 00000000..d4d5df50 --- /dev/null +++ b/src/SMAPI/Framework/SnapshotListDiff.cs @@ -0,0 +1,58 @@ +using System.Collections.Generic; +using StardewModdingAPI.Framework.StateTracking; + +namespace StardewModdingAPI.Framework +{ + /// <summary>A snapshot of a tracked list.</summary> + /// <typeparam name="T">The tracked list value type.</typeparam> + internal class SnapshotListDiff<T> + { + /********* + ** Fields + *********/ + /// <summary>The removed values.</summary> + private readonly List<T> RemovedImpl = new List<T>(); + + /// <summary>The added values.</summary> + private readonly List<T> AddedImpl = new List<T>(); + + + /********* + ** Accessors + *********/ + /// <summary>Whether the value changed since the last update.</summary> + public bool IsChanged { get; private set; } + + /// <summary>The removed values.</summary> + public IEnumerable<T> Removed => this.RemovedImpl; + + /// <summary>The added values.</summary> + public IEnumerable<T> Added => this.AddedImpl; + + + /********* + ** Public methods + *********/ + /// <summary>Update the snapshot.</summary> + /// <param name="isChanged">Whether the value changed since the last update.</param> + /// <param name="removed">The removed values.</param> + /// <param name="added">The added values.</param> + public void Update(bool isChanged, IEnumerable<T> removed, IEnumerable<T> added) + { + this.IsChanged = isChanged; + + this.RemovedImpl.Clear(); + this.RemovedImpl.AddRange(removed); + + this.AddedImpl.Clear(); + this.AddedImpl.AddRange(added); + } + + /// <summary>Update the snapshot.</summary> + /// <param name="watcher">The value watcher to snapshot.</param> + public void Update(ICollectionWatcher<T> watcher) + { + this.Update(watcher.IsChanged, watcher.Removed, watcher.Added); + } + } +} diff --git a/src/SMAPI/Framework/StateTracking/FieldWatchers/ComparableListWatcher.cs b/src/SMAPI/Framework/StateTracking/FieldWatchers/ComparableListWatcher.cs index 6550f950..32ec8c7e 100644 --- a/src/SMAPI/Framework/StateTracking/FieldWatchers/ComparableListWatcher.cs +++ b/src/SMAPI/Framework/StateTracking/FieldWatchers/ComparableListWatcher.cs @@ -53,7 +53,7 @@ namespace StardewModdingAPI.Framework.StateTracking.FieldWatchers { this.AssertNotDisposed(); - // optimise for zero items + // optimize for zero items if (this.CurrentValues.Count == 0) { if (this.LastValues.Count > 0) diff --git a/src/SMAPI/Framework/StateTracking/FieldWatchers/ImmutableCollectionWatcher.cs b/src/SMAPI/Framework/StateTracking/FieldWatchers/ImmutableCollectionWatcher.cs new file mode 100644 index 00000000..30e6274f --- /dev/null +++ b/src/SMAPI/Framework/StateTracking/FieldWatchers/ImmutableCollectionWatcher.cs @@ -0,0 +1,37 @@ +using System.Collections.Generic; + +namespace StardewModdingAPI.Framework.StateTracking.FieldWatchers +{ + /// <summary>A collection watcher which never changes.</summary> + /// <typeparam name="TValue">The value type within the collection.</typeparam> + internal class ImmutableCollectionWatcher<TValue> : BaseDisposableWatcher, ICollectionWatcher<TValue> + { + /********* + ** Accessors + *********/ + /// <summary>A singleton collection watcher instance.</summary> + public static ImmutableCollectionWatcher<TValue> Instance { get; } = new ImmutableCollectionWatcher<TValue>(); + + /// <summary>Whether the collection changed since the last reset.</summary> + public bool IsChanged { get; } = false; + + /// <summary>The values added since the last reset.</summary> + public IEnumerable<TValue> Added { get; } = new TValue[0]; + + /// <summary>The values removed since the last reset.</summary> + public IEnumerable<TValue> Removed { get; } = new TValue[0]; + + + /********* + ** Public methods + *********/ + /// <summary>Update the current value if needed.</summary> + public void Update() { } + + /// <summary>Set the current value as the baseline.</summary> + public void Reset() { } + + /// <summary>Stop watching the field and release all references.</summary> + public override void Dispose() { } + } +} diff --git a/src/SMAPI/Framework/StateTracking/FieldWatchers/WatcherFactory.cs b/src/SMAPI/Framework/StateTracking/FieldWatchers/WatcherFactory.cs index 8301351e..314ff7f5 100644 --- a/src/SMAPI/Framework/StateTracking/FieldWatchers/WatcherFactory.cs +++ b/src/SMAPI/Framework/StateTracking/FieldWatchers/WatcherFactory.cs @@ -12,10 +12,13 @@ namespace StardewModdingAPI.Framework.StateTracking.FieldWatchers /********* ** Public methods *********/ + /**** + ** Values + ****/ /// <summary>Get a watcher which compares values using their <see cref="object.Equals(object)"/> method. This method should only be used when <see cref="ForEquatable{T}"/> won't work, since this doesn't validate whether they're comparable.</summary> /// <typeparam name="T">The value type.</typeparam> /// <param name="getValue">Get the current value.</param> - public static ComparableWatcher<T> ForGenericEquality<T>(Func<T> getValue) where T : struct + public static IValueWatcher<T> ForGenericEquality<T>(Func<T> getValue) where T : struct { return new ComparableWatcher<T>(getValue, new GenericEqualsComparer<T>()); } @@ -23,7 +26,7 @@ namespace StardewModdingAPI.Framework.StateTracking.FieldWatchers /// <summary>Get a watcher for an <see cref="IEquatable{T}"/> value.</summary> /// <typeparam name="T">The value type.</typeparam> /// <param name="getValue">Get the current value.</param> - public static ComparableWatcher<T> ForEquatable<T>(Func<T> getValue) where T : IEquatable<T> + public static IValueWatcher<T> ForEquatable<T>(Func<T> getValue) where T : IEquatable<T> { return new ComparableWatcher<T>(getValue, new EquatableComparer<T>()); } @@ -31,15 +34,27 @@ namespace StardewModdingAPI.Framework.StateTracking.FieldWatchers /// <summary>Get a watcher which detects when an object reference changes.</summary> /// <typeparam name="T">The value type.</typeparam> /// <param name="getValue">Get the current value.</param> - public static ComparableWatcher<T> ForReference<T>(Func<T> getValue) + public static IValueWatcher<T> ForReference<T>(Func<T> getValue) { return new ComparableWatcher<T>(getValue, new ObjectReferenceComparer<T>()); } + /// <summary>Get a watcher for a net collection.</summary> + /// <typeparam name="T">The value type.</typeparam> + /// <typeparam name="TSelf">The net field instance type.</typeparam> + /// <param name="field">The net collection.</param> + public static IValueWatcher<T> ForNetValue<T, TSelf>(NetFieldBase<T, TSelf> field) where TSelf : NetFieldBase<T, TSelf> + { + return new NetValueWatcher<T, TSelf>(field); + } + + /**** + ** Collections + ****/ /// <summary>Get a watcher which detects when an object reference in a collection changes.</summary> /// <typeparam name="T">The value type.</typeparam> /// <param name="collection">The observable collection.</param> - public static ComparableListWatcher<T> ForReferenceList<T>(ICollection<T> collection) + public static ICollectionWatcher<T> ForReferenceList<T>(ICollection<T> collection) { return new ComparableListWatcher<T>(collection, new ObjectReferenceComparer<T>()); } @@ -47,24 +62,22 @@ namespace StardewModdingAPI.Framework.StateTracking.FieldWatchers /// <summary>Get a watcher for an observable collection.</summary> /// <typeparam name="T">The value type.</typeparam> /// <param name="collection">The observable collection.</param> - public static ObservableCollectionWatcher<T> ForObservableCollection<T>(ObservableCollection<T> collection) + public static ICollectionWatcher<T> ForObservableCollection<T>(ObservableCollection<T> collection) { return new ObservableCollectionWatcher<T>(collection); } - /// <summary>Get a watcher for a net collection.</summary> + /// <summary>Get a watcher for a collection that never changes.</summary> /// <typeparam name="T">The value type.</typeparam> - /// <typeparam name="TSelf">The net field instance type.</typeparam> - /// <param name="field">The net collection.</param> - public static NetValueWatcher<T, TSelf> ForNetValue<T, TSelf>(NetFieldBase<T, TSelf> field) where TSelf : NetFieldBase<T, TSelf> + public static ICollectionWatcher<T> ForImmutableCollection<T>() { - return new NetValueWatcher<T, TSelf>(field); + return ImmutableCollectionWatcher<T>.Instance; } /// <summary>Get a watcher for a net collection.</summary> /// <typeparam name="T">The value type.</typeparam> /// <param name="collection">The net collection.</param> - public static NetCollectionWatcher<T> ForNetCollection<T>(NetCollection<T> collection) where T : class, INetObject<INetSerializable> + public static ICollectionWatcher<T> ForNetCollection<T>(NetCollection<T> collection) where T : class, INetObject<INetSerializable> { return new NetCollectionWatcher<T>(collection); } diff --git a/src/SMAPI/Framework/StateTracking/LocationTracker.cs b/src/SMAPI/Framework/StateTracking/LocationTracker.cs index 2249e41b..1f479e12 100644 --- a/src/SMAPI/Framework/StateTracking/LocationTracker.cs +++ b/src/SMAPI/Framework/StateTracking/LocationTracker.cs @@ -1,5 +1,4 @@ using System.Collections.Generic; -using System.Collections.ObjectModel; using System.Linq; using Microsoft.Xna.Framework; using StardewModdingAPI.Framework.StateTracking.FieldWatchers; @@ -59,9 +58,7 @@ namespace StardewModdingAPI.Framework.StateTracking this.Location = location; // init watchers - this.BuildingsWatcher = location is BuildableGameLocation buildableLocation - ? WatcherFactory.ForNetCollection(buildableLocation.buildings) - : (ICollectionWatcher<Building>)WatcherFactory.ForObservableCollection(new ObservableCollection<Building>()); + this.BuildingsWatcher = location is BuildableGameLocation buildableLocation ? WatcherFactory.ForNetCollection(buildableLocation.buildings) : WatcherFactory.ForImmutableCollection<Building>(); this.DebrisWatcher = WatcherFactory.ForNetCollection(location.debris); this.LargeTerrainFeaturesWatcher = WatcherFactory.ForNetCollection(location.largeTerrainFeatures); this.NpcsWatcher = WatcherFactory.ForNetCollection(location.characters); diff --git a/src/SMAPI/Framework/StateTracking/PlayerTracker.cs b/src/SMAPI/Framework/StateTracking/PlayerTracker.cs index abb4fa24..6302a889 100644 --- a/src/SMAPI/Framework/StateTracking/PlayerTracker.cs +++ b/src/SMAPI/Framework/StateTracking/PlayerTracker.cs @@ -5,7 +5,6 @@ using StardewModdingAPI.Enums; using StardewModdingAPI.Events; using StardewModdingAPI.Framework.StateTracking.FieldWatchers; using StardewValley; -using StardewValley.Locations; using ChangeType = StardewModdingAPI.Events.ChangeType; namespace StardewModdingAPI.Framework.StateTracking @@ -38,9 +37,6 @@ namespace StardewModdingAPI.Framework.StateTracking /// <summary>The player's current location.</summary> public IValueWatcher<GameLocation> LocationWatcher { get; } - /// <summary>The player's current mine level.</summary> - public IValueWatcher<int> MineLevelWatcher { get; } - /// <summary>Tracks changes to the player's skill levels.</summary> public IDictionary<SkillType, IValueWatcher<int>> SkillWatchers { get; } @@ -58,7 +54,6 @@ namespace StardewModdingAPI.Framework.StateTracking // init trackers this.LocationWatcher = WatcherFactory.ForReference(this.GetCurrentLocation); - this.MineLevelWatcher = WatcherFactory.ForEquatable(() => this.LastValidLocation is MineShaft mine ? mine.mineLevel : 0); this.SkillWatchers = new Dictionary<SkillType, IValueWatcher<int>> { [SkillType.Combat] = WatcherFactory.ForNetValue(player.combatLevel), @@ -70,11 +65,7 @@ namespace StardewModdingAPI.Framework.StateTracking }; // track watchers for convenience - this.Watchers.AddRange(new IWatcher[] - { - this.LocationWatcher, - this.MineLevelWatcher - }); + this.Watchers.Add(this.LocationWatcher); this.Watchers.AddRange(this.SkillWatchers.Values); } @@ -124,30 +115,6 @@ namespace StardewModdingAPI.Framework.StateTracking } } - /// <summary>Get the player skill levels which changed.</summary> - public IEnumerable<KeyValuePair<SkillType, IValueWatcher<int>>> GetChangedSkills() - { - return this.SkillWatchers.Where(p => p.Value.IsChanged); - } - - /// <summary>Get the player's new location if it changed.</summary> - /// <param name="location">The player's current location.</param> - /// <returns>Returns whether it changed.</returns> - public bool TryGetNewLocation(out GameLocation location) - { - location = this.LocationWatcher.CurrentValue; - return this.LocationWatcher.IsChanged; - } - - /// <summary>Get the player's new mine level if it changed.</summary> - /// <param name="mineLevel">The player's current mine level.</param> - /// <returns>Returns whether it changed.</returns> - public bool TryGetNewMineLevel(out int mineLevel) - { - mineLevel = this.MineLevelWatcher.CurrentValue; - return this.MineLevelWatcher.IsChanged; - } - /// <summary>Stop watching the player fields and release all references.</summary> public void Dispose() { diff --git a/src/SMAPI/Framework/StateTracking/Snapshots/LocationSnapshot.cs b/src/SMAPI/Framework/StateTracking/Snapshots/LocationSnapshot.cs new file mode 100644 index 00000000..d3029540 --- /dev/null +++ b/src/SMAPI/Framework/StateTracking/Snapshots/LocationSnapshot.cs @@ -0,0 +1,59 @@ +using System.Collections.Generic; +using Microsoft.Xna.Framework; +using StardewValley; +using StardewValley.Buildings; +using StardewValley.TerrainFeatures; + +namespace StardewModdingAPI.Framework.StateTracking.Snapshots +{ + /// <summary>A frozen snapshot of a tracked game location.</summary> + internal class LocationSnapshot + { + /********* + ** Accessors + *********/ + /// <summary>The tracked location.</summary> + public GameLocation Location { get; } + + /// <summary>Tracks added or removed buildings.</summary> + public SnapshotListDiff<Building> Buildings { get; } = new SnapshotListDiff<Building>(); + + /// <summary>Tracks added or removed debris.</summary> + public SnapshotListDiff<Debris> Debris { get; } = new SnapshotListDiff<Debris>(); + + /// <summary>Tracks added or removed large terrain features.</summary> + public SnapshotListDiff<LargeTerrainFeature> LargeTerrainFeatures { get; } = new SnapshotListDiff<LargeTerrainFeature>(); + + /// <summary>Tracks added or removed NPCs.</summary> + public SnapshotListDiff<NPC> Npcs { get; } = new SnapshotListDiff<NPC>(); + + /// <summary>Tracks added or removed objects.</summary> + public SnapshotListDiff<KeyValuePair<Vector2, Object>> Objects { get; } = new SnapshotListDiff<KeyValuePair<Vector2, Object>>(); + + /// <summary>Tracks added or removed terrain features.</summary> + public SnapshotListDiff<KeyValuePair<Vector2, TerrainFeature>> TerrainFeatures { get; } = new SnapshotListDiff<KeyValuePair<Vector2, TerrainFeature>>(); + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="location">The tracked location.</param> + public LocationSnapshot(GameLocation location) + { + this.Location = location; + } + + /// <summary>Update the tracked values.</summary> + /// <param name="watcher">The watcher to snapshot.</param> + public void Update(LocationTracker watcher) + { + this.Buildings.Update(watcher.BuildingsWatcher); + this.Debris.Update(watcher.DebrisWatcher); + this.LargeTerrainFeatures.Update(watcher.LargeTerrainFeaturesWatcher); + this.Npcs.Update(watcher.NpcsWatcher); + this.Objects.Update(watcher.ObjectsWatcher); + this.TerrainFeatures.Update(watcher.TerrainFeaturesWatcher); + } + } +} diff --git a/src/SMAPI/Framework/StateTracking/Snapshots/PlayerSnapshot.cs b/src/SMAPI/Framework/StateTracking/Snapshots/PlayerSnapshot.cs new file mode 100644 index 00000000..7bcd9f82 --- /dev/null +++ b/src/SMAPI/Framework/StateTracking/Snapshots/PlayerSnapshot.cs @@ -0,0 +1,53 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using StardewModdingAPI.Enums; +using StardewModdingAPI.Events; +using StardewValley; + +namespace StardewModdingAPI.Framework.StateTracking.Snapshots +{ + /// <summary>A frozen snapshot of a tracked player.</summary> + internal class PlayerSnapshot + { + /********* + ** Accessors + *********/ + /// <summary>The player being tracked.</summary> + public Farmer Player { get; } + + /// <summary>The player's current location.</summary> + public SnapshotDiff<GameLocation> Location { get; } = new SnapshotDiff<GameLocation>(); + + /// <summary>Tracks changes to the player's skill levels.</summary> + public IDictionary<SkillType, SnapshotDiff<int>> Skills { get; } = + Enum + .GetValues(typeof(SkillType)) + .Cast<SkillType>() + .ToDictionary(skill => skill, skill => new SnapshotDiff<int>()); + + /// <summary>Get a list of inventory changes.</summary> + public IEnumerable<ItemStackChange> InventoryChanges { get; private set; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="player">The player being tracked.</param> + public PlayerSnapshot(Farmer player) + { + this.Player = player; + } + + /// <summary>Update the tracked values.</summary> + /// <param name="watcher">The player watcher to snapshot.</param> + public void Update(PlayerTracker watcher) + { + this.Location.Update(watcher.LocationWatcher); + foreach (var pair in this.Skills) + pair.Value.Update(watcher.SkillWatchers[pair.Key]); + this.InventoryChanges = watcher.GetInventoryChanges().ToArray(); + } + } +} diff --git a/src/SMAPI/Framework/StateTracking/Snapshots/WatcherSnapshot.cs b/src/SMAPI/Framework/StateTracking/Snapshots/WatcherSnapshot.cs new file mode 100644 index 00000000..cf51e040 --- /dev/null +++ b/src/SMAPI/Framework/StateTracking/Snapshots/WatcherSnapshot.cs @@ -0,0 +1,66 @@ +using Microsoft.Xna.Framework; +using StardewValley; +using StardewValley.Menus; + +namespace StardewModdingAPI.Framework.StateTracking.Snapshots +{ + /// <summary>A frozen snapshot of the game state watchers.</summary> + internal class WatcherSnapshot + { + /********* + ** Accessors + *********/ + /// <summary>Tracks changes to the window size.</summary> + public SnapshotDiff<Point> WindowSize { get; } = new SnapshotDiff<Point>(); + + /// <summary>Tracks changes to the current player.</summary> + public PlayerSnapshot CurrentPlayer { get; private set; } + + /// <summary>Tracks changes to the time of day (in 24-hour military format).</summary> + public SnapshotDiff<int> Time { get; } = new SnapshotDiff<int>(); + + /// <summary>Tracks changes to the save ID.</summary> + public SnapshotDiff<ulong> SaveID { get; } = new SnapshotDiff<ulong>(); + + /// <summary>Tracks changes to the game's locations.</summary> + public WorldLocationsSnapshot Locations { get; } = new WorldLocationsSnapshot(); + + /// <summary>Tracks changes to <see cref="Game1.activeClickableMenu"/>.</summary> + public SnapshotDiff<IClickableMenu> ActiveMenu { get; } = new SnapshotDiff<IClickableMenu>(); + + /// <summary>Tracks changes to the cursor position.</summary> + public SnapshotDiff<ICursorPosition> Cursor { get; } = new SnapshotDiff<ICursorPosition>(); + + /// <summary>Tracks changes to the mouse wheel scroll.</summary> + public SnapshotDiff<int> MouseWheelScroll { get; } = new SnapshotDiff<int>(); + + /// <summary>Tracks changes to the content locale.</summary> + public SnapshotDiff<LocalizedContentManager.LanguageCode> Locale { get; } = new SnapshotDiff<LocalizedContentManager.LanguageCode>(); + + + /********* + ** Public methods + *********/ + /// <summary>Update the tracked values.</summary> + /// <param name="watchers">The watchers to snapshot.</param> + public void Update(WatcherCore watchers) + { + // update player instance + if (watchers.CurrentPlayerTracker == null) + this.CurrentPlayer = null; + else if (watchers.CurrentPlayerTracker.Player != this.CurrentPlayer?.Player) + this.CurrentPlayer = new PlayerSnapshot(watchers.CurrentPlayerTracker.Player); + + // update snapshots + this.WindowSize.Update(watchers.WindowSizeWatcher); + this.Locale.Update(watchers.LocaleWatcher); + this.CurrentPlayer?.Update(watchers.CurrentPlayerTracker); + this.Time.Update(watchers.TimeWatcher); + this.SaveID.Update(watchers.SaveIdWatcher); + this.Locations.Update(watchers.LocationsWatcher); + this.ActiveMenu.Update(watchers.ActiveMenuWatcher); + this.Cursor.Update(watchers.CursorWatcher); + this.MouseWheelScroll.Update(watchers.MouseWheelScrollWatcher); + } + } +} diff --git a/src/SMAPI/Framework/StateTracking/Snapshots/WorldLocationsSnapshot.cs b/src/SMAPI/Framework/StateTracking/Snapshots/WorldLocationsSnapshot.cs new file mode 100644 index 00000000..73ed2d8f --- /dev/null +++ b/src/SMAPI/Framework/StateTracking/Snapshots/WorldLocationsSnapshot.cs @@ -0,0 +1,52 @@ +using System.Collections.Generic; +using System.Linq; +using StardewModdingAPI.Framework.StateTracking.Comparers; +using StardewValley; + +namespace StardewModdingAPI.Framework.StateTracking.Snapshots +{ + /// <summary>A frozen snapshot of the tracked game locations.</summary> + internal class WorldLocationsSnapshot + { + /********* + ** Fields + *********/ + /// <summary>A map of tracked locations.</summary> + private readonly Dictionary<GameLocation, LocationSnapshot> LocationsDict = new Dictionary<GameLocation, LocationSnapshot>(new ObjectReferenceComparer<GameLocation>()); + + + /********* + ** Accessors + *********/ + /// <summary>Tracks changes to the location list.</summary> + public SnapshotListDiff<GameLocation> LocationList { get; } = new SnapshotListDiff<GameLocation>(); + + /// <summary>The tracked locations.</summary> + public IEnumerable<LocationSnapshot> Locations => this.LocationsDict.Values; + + + /********* + ** Public methods + *********/ + /// <summary>Update the tracked values.</summary> + /// <param name="watcher">The watcher to snapshot.</param> + public void Update(WorldLocationsTracker watcher) + { + // update location list + this.LocationList.Update(watcher.IsLocationListChanged, watcher.Added, watcher.Removed); + + // remove missing locations + foreach (var key in this.LocationsDict.Keys.Where(key => !watcher.HasLocationTracker(key)).ToArray()) + this.LocationsDict.Remove(key); + + // update locations + foreach (LocationTracker locationWatcher in watcher.Locations) + { + if (!this.LocationsDict.TryGetValue(locationWatcher.Location, out LocationSnapshot snapshot)) + this.LocationsDict[locationWatcher.Location] = snapshot = new LocationSnapshot(locationWatcher.Location); + + snapshot.Update(locationWatcher); + } + } + } +} diff --git a/src/SMAPI/Framework/StateTracking/WorldLocationsTracker.cs b/src/SMAPI/Framework/StateTracking/WorldLocationsTracker.cs index f09c69c1..303a4f3a 100644 --- a/src/SMAPI/Framework/StateTracking/WorldLocationsTracker.cs +++ b/src/SMAPI/Framework/StateTracking/WorldLocationsTracker.cs @@ -117,6 +117,13 @@ namespace StardewModdingAPI.Framework.StateTracking watcher.Reset(); } + /// <summary>Get whether the given location is tracked.</summary> + /// <param name="location">The location to check.</param> + public bool HasLocationTracker(GameLocation location) + { + return this.LocationDict.ContainsKey(location); + } + /// <summary>Stop watching the player fields and release all references.</summary> public void Dispose() { diff --git a/src/SMAPI/Framework/Translator.cs b/src/SMAPI/Framework/Translator.cs new file mode 100644 index 00000000..f2738633 --- /dev/null +++ b/src/SMAPI/Framework/Translator.cs @@ -0,0 +1,128 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using StardewValley; + +namespace StardewModdingAPI.Framework +{ + /// <summary>Encapsulates access to arbitrary translations. Translations are fetched with locale fallback, so missing translations are filled in from broader locales (like <c>pt-BR.json</c> < <c>pt.json</c> < <c>default.json</c>).</summary> + internal class Translator + { + /********* + ** Fields + *********/ + /// <summary>The translations for each locale.</summary> + private readonly IDictionary<string, IDictionary<string, string>> All = new Dictionary<string, IDictionary<string, string>>(StringComparer.InvariantCultureIgnoreCase); + + /// <summary>The translations for the current locale, with locale fallback taken into account.</summary> + private IDictionary<string, Translation> ForLocale; + + + /********* + ** Accessors + *********/ + /// <summary>The current locale.</summary> + public string Locale { get; private set; } + + /// <summary>The game's current language code.</summary> + public LocalizedContentManager.LanguageCode LocaleEnum { get; private set; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + public Translator() + { + this.SetLocale(string.Empty, LocalizedContentManager.LanguageCode.en); + } + + /// <summary>Set the current locale and precache translations.</summary> + /// <param name="locale">The current locale.</param> + /// <param name="localeEnum">The game's current language code.</param> + public void SetLocale(string locale, LocalizedContentManager.LanguageCode localeEnum) + { + this.Locale = locale.ToLower().Trim(); + this.LocaleEnum = localeEnum; + + this.ForLocale = new Dictionary<string, Translation>(StringComparer.InvariantCultureIgnoreCase); + foreach (string next in this.GetRelevantLocales(this.Locale)) + { + // skip if locale not defined + if (!this.All.TryGetValue(next, out IDictionary<string, string> translations)) + continue; + + // add missing translations + foreach (var pair in translations) + { + if (!this.ForLocale.ContainsKey(pair.Key)) + this.ForLocale.Add(pair.Key, new Translation(this.Locale, pair.Key, pair.Value)); + } + } + } + + /// <summary>Get all translations for the current locale.</summary> + public IEnumerable<Translation> GetTranslations() + { + return this.ForLocale.Values.ToArray(); + } + + /// <summary>Get a translation for the current locale.</summary> + /// <param name="key">The translation key.</param> + public Translation Get(string key) + { + this.ForLocale.TryGetValue(key, out Translation translation); + return translation ?? new Translation(this.Locale, key, null); + } + + /// <summary>Get a translation for the current locale.</summary> + /// <param name="key">The translation key.</param> + /// <param name="tokens">An object containing token key/value pairs. This can be an anonymous object (like <c>new { value = 42, name = "Cranberries" }</c>), a dictionary, or a class instance.</param> + public Translation Get(string key, object tokens) + { + return this.Get(key).Tokens(tokens); + } + + /// <summary>Set the translations to use.</summary> + /// <param name="translations">The translations to use.</param> + internal Translator SetTranslations(IDictionary<string, IDictionary<string, string>> translations) + { + // reset translations + this.All.Clear(); + foreach (var pair in translations) + this.All[pair.Key] = new Dictionary<string, string>(pair.Value, StringComparer.InvariantCultureIgnoreCase); + + // rebuild cache + this.SetLocale(this.Locale, this.LocaleEnum); + + return this; + } + + + /********* + ** Private methods + *********/ + /// <summary>Get the locales which can provide translations for the given locale, in precedence order.</summary> + /// <param name="locale">The locale for which to find valid locales.</param> + private IEnumerable<string> GetRelevantLocales(string locale) + { + // given locale + yield return locale; + + // broader locales (like pt-BR => pt) + while (true) + { + int dashIndex = locale.LastIndexOf('-'); + if (dashIndex <= 0) + break; + + locale = locale.Substring(0, dashIndex); + yield return locale; + } + + // default + if (locale != "default") + yield return "default"; + } + } +} |