diff options
Diffstat (limited to 'src/StardewModdingAPI')
-rw-r--r-- | src/StardewModdingAPI/Constants.cs | 2 | ||||
-rw-r--r-- | src/StardewModdingAPI/Framework/ContentManagerShim.cs | 50 | ||||
-rw-r--r-- | src/StardewModdingAPI/Framework/ModHelpers/ContentHelper.cs | 135 | ||||
-rw-r--r-- | src/StardewModdingAPI/Framework/ModLoading/AssemblyLoader.cs | 4 | ||||
-rw-r--r-- | src/StardewModdingAPI/Framework/SContentManager.cs | 67 | ||||
-rw-r--r-- | src/StardewModdingAPI/Framework/SGame.cs | 13 | ||||
-rw-r--r-- | src/StardewModdingAPI/Program.cs | 3 | ||||
-rw-r--r-- | src/StardewModdingAPI/StardewModdingAPI.csproj | 1 | ||||
-rw-r--r-- | src/StardewModdingAPI/Utilities/SButton.cs | 28 | ||||
-rw-r--r-- | src/StardewModdingAPI/Utilities/SDate.cs | 58 |
10 files changed, 273 insertions, 88 deletions
diff --git a/src/StardewModdingAPI/Constants.cs b/src/StardewModdingAPI/Constants.cs index 8ed4f416..fea9377a 100644 --- a/src/StardewModdingAPI/Constants.cs +++ b/src/StardewModdingAPI/Constants.cs @@ -36,7 +36,7 @@ namespace StardewModdingAPI /// <summary>SMAPI's current semantic version.</summary> public static ISemanticVersion ApiVersion { get; } = #if SMAPI_1_x - new SemanticVersion(1, 15, 3); + new SemanticVersion(1, 15, 4); #else new SemanticVersion(2, 0, 0, $"alpha-{DateTime.UtcNow:yyyyMMddHHmm}"); #endif diff --git a/src/StardewModdingAPI/Framework/ContentManagerShim.cs b/src/StardewModdingAPI/Framework/ContentManagerShim.cs new file mode 100644 index 00000000..d46f23a3 --- /dev/null +++ b/src/StardewModdingAPI/Framework/ContentManagerShim.cs @@ -0,0 +1,50 @@ +using StardewValley; + +namespace StardewModdingAPI.Framework +{ + /// <summary>A minimal content manager which defers to SMAPI's main content manager.</summary> + internal class ContentManagerShim : LocalizedContentManager + { + /********* + ** Properties + *********/ + /// <summary>SMAPI's underlying content manager.</summary> + private readonly SContentManager ContentManager; + + + /********* + ** Accessors + *********/ + /// <summary>The content manager's name for logs (if any).</summary> + public string Name { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="contentManager">SMAPI's underlying content manager.</param> + /// <param name="name">The content manager's name for logs (if any).</param> + public ContentManagerShim(SContentManager contentManager, string name) + : base(contentManager.ServiceProvider, contentManager.RootDirectory, contentManager.CurrentCulture, contentManager.LanguageCodeOverride) + { + this.ContentManager = contentManager; + this.Name = name; + } + + /// <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.ContentManager.LoadFor<T>(assetName, this); + } + + /// <summary>Dispose held resources.</summary> + /// <param name="disposing">Whether the content manager is disposing (rather than finalising).</param> + protected override void Dispose(bool disposing) + { + this.ContentManager.DisposeFor(this); + } + } +} diff --git a/src/StardewModdingAPI/Framework/ModHelpers/ContentHelper.cs b/src/StardewModdingAPI/Framework/ModHelpers/ContentHelper.cs index ffa78ff6..4440ae40 100644 --- a/src/StardewModdingAPI/Framework/ModHelpers/ContentHelper.cs +++ b/src/StardewModdingAPI/Framework/ModHelpers/ContentHelper.cs @@ -214,49 +214,129 @@ namespace StardewModdingAPI.Framework.ModHelpers /// <param name="map">The map whose tilesheets to fix.</param> /// <param name="mapKey">The map asset key within the mod folder.</param> /// <exception cref="ContentLoadException">The map tilesheets could not be loaded.</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, we 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 FixLocalMapTilesheets(Map map, string mapKey) { + // check map info if (!map.TileSheets.Any()) return; - + mapKey = this.ContentManager.NormaliseAssetName(mapKey); // Mono's Path.GetDirectoryName doesn't handle Windows dir separators string relativeMapFolder = Path.GetDirectoryName(mapKey) ?? ""; // folder path containing the map, relative to the mod folder + + // fix tilesheets foreach (TileSheet tilesheet in map.TileSheets) { - // check for tilesheet relative to map + string imageSource = tilesheet.ImageSource; + + // get seasonal name (if applicable) + string seasonalImageSource = null; + if (Game1.currentSeason != null) { - string localKey = Path.Combine(relativeMapFolder, tilesheet.ImageSource); - FileInfo localFile = this.GetModFile(localKey); - if (localFile.Exists) + string filename = Path.GetFileName(imageSource); + 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 + "_")) { - try - { - this.Load<Texture2D>(localKey); - } - catch (Exception ex) - { - throw new ContentLoadException($"The local '{tilesheet.ImageSource}' tilesheet couldn't be loaded.", ex); - } - tilesheet.ImageSource = this.GetActualAssetKey(localKey); + 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.TryLoadTilesheetImageSource(relativeMapFolder, seasonalImageSource) + ?? this.TryLoadTilesheetImageSource(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>Load a tilesheet image source if the file exists.</summary> + /// <param name="relativeMapFolder">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 loaded asset key (if it was loaded successfully).</returns> + /// <remarks>See remarks on <see cref="FixLocalMapTilesheets"/>.</remarks> + private string TryLoadTilesheetImageSource(string relativeMapFolder, string imageSource) + { + if (imageSource == null) + return null; - // fallback to game content + // check relative to map file + { + string localKey = Path.Combine(relativeMapFolder, imageSource); + FileInfo localFile = this.GetModFile(localKey); + if (localFile.Exists) { - string contentKey = tilesheet.ImageSource; - if (contentKey.EndsWith(".png")) - contentKey = contentKey.Substring(0, contentKey.Length - 4); try { - this.ContentManager.Load<Texture2D>(contentKey); + this.Load<Texture2D>(localKey); } catch (Exception ex) { - throw new ContentLoadException($"The '{tilesheet.ImageSource}' tilesheet couldn't be loaded relative to either map file or the game's content folder.", ex); + throw new ContentLoadException($"The local '{imageSource}' tilesheet couldn't be loaded.", ex); + } + + return this.GetActualAssetKey(localKey); + } + } + + // check relative to content folder + { + foreach (string candidateKey in new[] { imageSource, $@"Maps\{imageSource}" }) + { + string contentKey = candidateKey.EndsWith(".png") + ? candidateKey.Substring(0, imageSource.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 a 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; } - tilesheet.ImageSource = contentKey; } } + + // not found + return null; } /// <summary>Assert that the given key has a valid format.</summary> @@ -290,6 +370,19 @@ namespace StardewModdingAPI.Framework.ModHelpers 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.ContentManager.FullRootDirectory, key); + if (!path.EndsWith(".xnb")) + path += ".xnb"; + + // get file + return new FileInfo(path); + } + /// <summary>Get the asset path which loads a mod folder through a content manager.</summary> /// <param name="localPath">The file path relative to the mod's folder.</param> /// <param name="absolutePath">The absolute file path.</param> diff --git a/src/StardewModdingAPI/Framework/ModLoading/AssemblyLoader.cs b/src/StardewModdingAPI/Framework/ModLoading/AssemblyLoader.cs index e6ec21a6..9c642bef 100644 --- a/src/StardewModdingAPI/Framework/ModLoading/AssemblyLoader.cs +++ b/src/StardewModdingAPI/Framework/ModLoading/AssemblyLoader.cs @@ -91,7 +91,7 @@ namespace StardewModdingAPI.Framework.ModLoading if (changed) { if (!oneAssembly) - this.Monitor.Log($" Loading {assembly.File.Name}.dll (rewritten in memory)...", LogLevel.Trace); + this.Monitor.Log($" Loading {assembly.File.Name} (rewritten in memory)...", LogLevel.Trace); using (MemoryStream outStream = new MemoryStream()) { assembly.Definition.Write(outStream); @@ -102,7 +102,7 @@ namespace StardewModdingAPI.Framework.ModLoading else { if (!oneAssembly) - this.Monitor.Log($" Loading {assembly.File.Name}.dll...", LogLevel.Trace); + this.Monitor.Log($" Loading {assembly.File.Name}...", LogLevel.Trace); lastAssembly = Assembly.UnsafeLoadFrom(assembly.File.FullName); } } diff --git a/src/StardewModdingAPI/Framework/SContentManager.cs b/src/StardewModdingAPI/Framework/SContentManager.cs index 25775291..9553e79f 100644 --- a/src/StardewModdingAPI/Framework/SContentManager.cs +++ b/src/StardewModdingAPI/Framework/SContentManager.cs @@ -48,6 +48,9 @@ namespace StardewModdingAPI.Framework /// <summary>The assets currently being intercepted by <see cref="IAssetLoader"/> instances. This is used to prevent infinite loops when a loader loads a new asset.</summary> private readonly ContextHash<string> AssetsBeingLoaded = new ContextHash<string>(); + /// <summary>A lookup of the content managers which loaded each asset.</summary> + private readonly IDictionary<string, HashSet<ContentManager>> AssetLoaders = new Dictionary<string, HashSet<ContentManager>>(); + /********* ** Accessors @@ -98,7 +101,6 @@ namespace StardewModdingAPI.Framework // get asset data this.CoreAssets = new CoreAssets(this.NormaliseAssetName); this.KeyLocales = this.GetKeyLocales(reflection); - } /// <summary>Normalise path separators in a file path. For asset keys, see <see cref="NormaliseAssetName"/> instead.</summary> @@ -135,11 +137,23 @@ namespace StardewModdingAPI.Framework /// <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.LoadFor<T>(assetName, this); + } + + /// <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="instance">The content manager instance for which to load the asset.</param> + public T LoadFor<T>(string assetName, ContentManager instance) + { assetName = this.NormaliseAssetName(assetName); // skip if already loaded if (this.IsNormalisedKeyLoaded(assetName)) + { + this.TrackAssetLoader(assetName, instance); return base.Load<T>(assetName); + } // load asset T data; @@ -162,6 +176,7 @@ namespace StardewModdingAPI.Framework // update cache & return data this.Cache[assetName] = data; + this.TrackAssetLoader(assetName, instance); return data; } @@ -172,8 +187,8 @@ namespace StardewModdingAPI.Framework public void Inject<T>(string assetName, T value) { assetName = this.NormaliseAssetName(assetName); - this.Cache[assetName] = value; + this.TrackAssetLoader(assetName, this); } /// <summary>Get the current content locale.</summary> @@ -229,8 +244,9 @@ namespace StardewModdingAPI.Framework /// <summary>Purge matched assets from the cache.</summary> /// <param name="predicate">Matches the asset keys to invalidate.</param> + /// <param name="dispose">Whether to dispose invalidated assets. This should only be <c>true</c> when they're being invalidated as part of a dispose, to avoid crashing the game.</param> /// <returns>Returns whether any cache entries were invalidated.</returns> - public bool InvalidateCache(Func<string, Type, bool> predicate) + public bool InvalidateCache(Func<string, Type, bool> predicate, bool dispose = false) { // find matching asset keys HashSet<string> purgeCacheKeys = new HashSet<string>(StringComparer.InvariantCultureIgnoreCase); @@ -246,9 +262,14 @@ namespace StardewModdingAPI.Framework } } - // purge from cache + // purge assets foreach (string key in purgeCacheKeys) + { + if (dispose && this.Cache[key] is IDisposable disposable) + disposable.Dispose(); this.Cache.Remove(key); + this.AssetLoaders.Remove(key); + } // reload core game assets int reloaded = 0; @@ -268,6 +289,17 @@ namespace StardewModdingAPI.Framework return false; } + /// <summary>Dispose assets for the given content manager shim.</summary> + /// <param name="shim">The content manager whose assets to dispose.</param> + internal void DisposeFor(ContentManagerShim shim) + { + this.Monitor.Log($"Content manager '{shim.Name}' disposed, disposing assets that aren't needed by any other asset loader.", LogLevel.Trace); + + foreach (var entry in this.AssetLoaders) + entry.Value.Remove(shim); + this.InvalidateCache((key, type) => !this.AssetLoaders[key].Any(), dispose: true); + } + /********* ** Private methods @@ -280,6 +312,16 @@ namespace StardewModdingAPI.Framework || this.Cache.ContainsKey($"{normalisedAssetName}.{this.GetKeyLocale.Invoke<string>()}"); // translated asset } + /// <summary>Track that a content manager loaded an asset.</summary> + /// <param name="key">The asset key that was loaded.</param> + /// <param name="manager">The content manager that loaded the asset.</param> + private void TrackAssetLoader(string key, ContentManager manager) + { + if (!this.AssetLoaders.TryGetValue(key, out HashSet<ContentManager> hash)) + hash = this.AssetLoaders[key] = new HashSet<ContentManager>(); + hash.Add(manager); + } + /// <summary>Get the locale codes (like <c>ja-JP</c>) used in asset keys.</summary> /// <param name="reflection">Simplifies access to private game code.</param> private IDictionary<string, LanguageCode> GetKeyLocales(Reflector reflection) @@ -463,23 +505,12 @@ namespace StardewModdingAPI.Framework } } - /// <summary>Dispose all game resources.</summary> + /// <summary>Dispose held resources.</summary> /// <param name="disposing">Whether the content manager is disposing (rather than finalising).</param> protected override void Dispose(bool disposing) { - if (!disposing) - return; - - // Clear cache & reload all assets. While that may seem perverse during disposal, it's - // necessary due to limitations in the way SMAPI currently intercepts content assets. - // - // The game uses multiple content managers while SMAPI needs one and only one. The game - // only disposes some of its content managers when returning to title, which means SMAPI - // can't know which assets are meant to be disposed. Here we remove current assets from - // the cache, but don't dispose them to avoid crashing any code that still references - // them. The garbage collector will eventually clean up any unused assets. - this.Monitor.Log("Content manager disposed, resetting cache.", LogLevel.Trace); - this.InvalidateCache((key, type) => true); + this.Monitor.Log("Disposing SMAPI's main content manager. It will no longer be usable after this point.", LogLevel.Trace); + base.Dispose(disposing); } } } diff --git a/src/StardewModdingAPI/Framework/SGame.cs b/src/StardewModdingAPI/Framework/SGame.cs index 997e0c8c..76c106d7 100644 --- a/src/StardewModdingAPI/Framework/SGame.cs +++ b/src/StardewModdingAPI/Framework/SGame.cs @@ -38,9 +38,6 @@ namespace StardewModdingAPI.Framework /// <summary>Encapsulates monitoring and logging.</summary> private readonly IMonitor Monitor; - /// <summary>SMAPI's content manager.</summary> - private readonly SContentManager SContentManager; - /// <summary>The maximum number of consecutive attempts SMAPI should make to recover from a draw error.</summary> private readonly Countdown DrawCrashTimer = new Countdown(60); // 60 ticks = roughly one second @@ -177,6 +174,9 @@ namespace StardewModdingAPI.Framework /********* ** Accessors *********/ + /// <summary>SMAPI's content manager.</summary> + public SContentManager SContentManager { get; } + /// <summary>Whether SMAPI should log more information about the game context.</summary> public bool VerboseLogging { get; set; } @@ -201,8 +201,9 @@ namespace StardewModdingAPI.Framework // override content manager this.Monitor?.Log("Overriding content manager...", LogLevel.Trace); this.SContentManager = new SContentManager(this.Content.ServiceProvider, this.Content.RootDirectory, Thread.CurrentThread.CurrentUICulture, null, this.Monitor); - this.Content = this.SContentManager; - Game1.content = this.SContentManager; + this.Content = new ContentManagerShim(this.SContentManager, "SGame.Content"); + Game1.content = new ContentManagerShim(this.SContentManager, "Game1.content"); + reflection.GetPrivateField<LocalizedContentManager>(typeof(Game1), "_temporaryContent").SetValue(new ContentManagerShim(this.SContentManager, "Game1._temporaryContent")); // regenerate value with new content manager } /**** @@ -225,7 +226,7 @@ namespace StardewModdingAPI.Framework throw new InvalidOperationException("SMAPI uses a single content manager internally. You can't get a new content manager with a different service provider."); if (rootDirectory != this.Content.RootDirectory) throw new InvalidOperationException($"SMAPI uses a single content manager internally. You can't get a new content manager with a different root directory (current is {this.Content.RootDirectory}, requested {rootDirectory})."); - return this.SContentManager; + return new ContentManagerShim(this.SContentManager, "(generated instance)"); } /// <summary>The method called when the game is updating its state. This happens roughly 60 times per second.</summary> diff --git a/src/StardewModdingAPI/Program.cs b/src/StardewModdingAPI/Program.cs index 108e9273..ad873598 100644 --- a/src/StardewModdingAPI/Program.cs +++ b/src/StardewModdingAPI/Program.cs @@ -53,7 +53,7 @@ namespace StardewModdingAPI private SGame GameInstance; /// <summary>The underlying content manager.</summary> - private SContentManager ContentManager => (SContentManager)this.GameInstance.Content; + private SContentManager ContentManager => this.GameInstance.SContentManager; /// <summary>The SMAPI configuration settings.</summary> /// <remarks>This is initialised after the game starts.</remarks> @@ -128,6 +128,7 @@ namespace StardewModdingAPI // init logging this.Monitor.Log($"SMAPI {Constants.ApiVersion} with Stardew Valley {Constants.GameVersion} on {this.GetFriendlyPlatformName()}", LogLevel.Info); this.Monitor.Log($"Mods go here: {Constants.ModPath}"); + this.Monitor.Log($"Log started at {DateTime.UtcNow:s} UTC", LogLevel.Trace); #if SMAPI_1_x this.Monitor.Log("Preparing SMAPI..."); #endif diff --git a/src/StardewModdingAPI/StardewModdingAPI.csproj b/src/StardewModdingAPI/StardewModdingAPI.csproj index 8c7279a1..8daf21b7 100644 --- a/src/StardewModdingAPI/StardewModdingAPI.csproj +++ b/src/StardewModdingAPI/StardewModdingAPI.csproj @@ -91,6 +91,7 @@ <Link>Properties\GlobalAssemblyInfo.cs</Link> </Compile> <Compile Include="Command.cs" /> + <Compile Include="Framework\ContentManagerShim.cs" /> <Compile Include="Framework\Exceptions\SAssemblyLoadFailedException.cs" /> <Compile Include="Framework\ModLoading\AssemblyLoadStatus.cs" /> <Compile Include="Framework\Utilities\ContextHash.cs" /> diff --git a/src/StardewModdingAPI/Utilities/SButton.cs b/src/StardewModdingAPI/Utilities/SButton.cs index c4833b0b..33058a64 100644 --- a/src/StardewModdingAPI/Utilities/SButton.cs +++ b/src/StardewModdingAPI/Utilities/SButton.cs @@ -1,5 +1,6 @@ -using System; +using System; using Microsoft.Xna.Framework.Input; +using StardewValley; namespace StardewModdingAPI.Utilities { @@ -655,5 +656,30 @@ namespace StardewModdingAPI.Utilities button = 0; return false; } + + /// <summary>Get the <see cref="InputButton"/> equivalent for the given button.</summary> + /// <param name="input">The button to convert.</param> + /// <param name="button">The Stardew Valley input button equivalent.</param> + /// <returns>Returns whether the value was converted successfully.</returns> + public static bool TryGetStardewInput(this SButton input, out InputButton button) + { + // keyboard + if (input.TryGetKeyboard(out Keys key)) + { + button = new InputButton(key); + return true; + } + + // mouse + if (input == SButton.MouseLeft || input == SButton.MouseRight) + { + button = new InputButton(mouseLeft: input == SButton.MouseLeft); + return true; + } + + // not valid + button = default(InputButton); + return false; + } } } diff --git a/src/StardewModdingAPI/Utilities/SDate.cs b/src/StardewModdingAPI/Utilities/SDate.cs index d7631598..5073259d 100644 --- a/src/StardewModdingAPI/Utilities/SDate.cs +++ b/src/StardewModdingAPI/Utilities/SDate.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Linq; using StardewValley; @@ -86,34 +86,27 @@ namespace StardewModdingAPI.Utilities /// <exception cref="ArithmeticException">The offset would result in an invalid date (like year 0).</exception> public SDate AddDays(int offset) { - // simple case - int day = this.Day + offset; - string season = this.Season; - int year = this.Year; + // get new hash code + int hashCode = this.GetHashCode() + offset; + if (hashCode < 1) + throw new ArithmeticException($"Adding {offset} days to {this} would result in a date before 01 spring Y1."); - // handle season transition - if (day > this.DaysInSeason || day < 1) - { - // get season index - int curSeasonIndex = this.GetSeasonIndex(); - - // get season offset - int seasonOffset = day / this.DaysInSeason; - if (day < 1) - seasonOffset -= 1; - - // get new date - day = this.GetWrappedIndex(day, this.DaysInSeason); - season = this.Seasons[this.GetWrappedIndex(curSeasonIndex + seasonOffset, this.Seasons.Length)]; - year += seasonOffset / this.Seasons.Length; - } + // get day + int day = hashCode % 28; + if (day == 0) + day = 28; - // validate - if (year < 1) - throw new ArithmeticException($"Adding {offset} days to {this} would result in invalid date {day:00} {season} {year}."); + // get season index + int seasonIndex = hashCode / 28; + if (seasonIndex > 0 && hashCode % 28 == 0) + seasonIndex -= 1; + seasonIndex %= 4; - // return new date - return new SDate(day, season, year); + // get year + int year = hashCode / (this.Seasons.Length * this.DaysInSeason) + 1; + + // create date + return new SDate(day, this.Seasons[seasonIndex], year); } /// <summary>Get a string representation of the date. This is mainly intended for debugging or console messages.</summary> @@ -142,7 +135,7 @@ namespace StardewModdingAPI.Utilities /// <summary>Get a hash code which uniquely identifies a date.</summary> public override int GetHashCode() { - // return the number of days since 01 spring Y1 + // return the number of days since 01 spring Y1 (inclusively) int yearIndex = this.Year - 1; return yearIndex * this.DaysInSeason * this.SeasonsInYear @@ -239,16 +232,5 @@ namespace StardewModdingAPI.Utilities throw new InvalidOperationException($"The current season '{this.Season}' wasn't recognised."); return index; } - - /// <summary>Get the real index in an array which should be treated as a two-way loop.</summary> - /// <param name="index">The index in the looped array.</param> - /// <param name="length">The number of elements in the array.</param> - private int GetWrappedIndex(int index, int length) - { - int wrapped = index % length; - if (wrapped < 0) - wrapped += length; - return wrapped; - } } } |