diff options
40 files changed, 599 insertions, 285 deletions
diff --git a/build/common.targets b/build/common.targets index 8b0d1301..626eeef6 100644 --- a/build/common.targets +++ b/build/common.targets @@ -4,7 +4,7 @@ <!--set properties --> <PropertyGroup> - <Version>3.2.0</Version> + <Version>3.3.0</Version> <Product>SMAPI</Product> <AssemblySearchPaths>$(AssemblySearchPaths);{GAC}</AssemblySearchPaths> diff --git a/docs/release-notes.md b/docs/release-notes.md index f1981218..26515b61 100644 --- a/docs/release-notes.md +++ b/docs/release-notes.md @@ -1,6 +1,40 @@ ← [README](README.md) # Release notes +## 3.3 +Released 22 February 2020 for Stardew Valley 1.4.1 or later. + +* For players: + * Improved performance for mods which load many images. + * Reduced network traffic for mod broadcasts to players who can't process them. + * Fixed update-check errors for recent versions of SMAPI on Android. + * Updated draw logic to match recent game updates. + * Updated compatibility list. + * Updated SMAPI/game version map. + * Updated translations. Thanks to xCarloC (added Italian)! + +* For the Save Backup mod: + * Fixed warning on MacOS when you have no saves yet. + * Reduced log messages. + +* For modders: + * Added support for [message sending](https://stardewvalleywiki.com/Modding:Modder_Guide/APIs/Integrations#Message_sending) to mods on the current computer (in addition to remote computers). + * Added `ExtendImage` method to content API when editing files to resize textures. + * Added `helper.Input.GetState` to get the low-level state of a button. + * **[Breaking change]** Map tilesheets are no loaded from `Content` if they can't be found in `Content/Maps`. This reflects an upcoming change in the game to delete duplicate map tilesheets under `Content`. Most mods should be unaffected. + * Improved map tilesheet errors so they provide more info. + * When mods load an asset using a more general type like `content.Load<object>`, SMAPI now calls `IAssetEditor` instances with the actual asset type instead of the specified one. + * Updated dependencies (including Mono.Cecil 0.11.1 → 0.11.2). + * Fixed dialogue propagation clearing marriage dialogue. + +* For the web UI: + * Updated the JSON validator and Content Patcher schema for `.tmx` support. + * The mod compatibility page now has a sticky table header. + +* For SMAPI/tool developers: + * Improved support for four-part versions to support SMAPI on Android. + * The SMAPI log now prefixes the OS name with `Android` on Android. + ## 3.2 Released 01 February 2020 for Stardew Valley 1.4.1 or later. @@ -23,7 +57,7 @@ Released 01 February 2020 for Stardew Valley 1.4.1 or later. * Fixed Android issue where game files were backed up. * For modders: - * Added support for `.tmx` map files. + * Added support for `.tmx` map files. (Thanks to [Platonymous for the underlying library](https://github.com/Platonymous/TMXTile)!) * Added special handling for `Vector2` values in `.json` files, so they work consistently crossplatform. * Reworked the order that asset editors/loaders are called between multiple mods to support some framework mods like Content Patcher and Json Assets. Note that the order is undefined and should not be depended on. * Fixed incorrect warning about mods adding invalid schedules in some cases. The validation was unreliable, and has been removed. diff --git a/src/SMAPI.ModBuildConfig.Analyzer.Tests/SMAPI.ModBuildConfig.Analyzer.Tests.csproj b/src/SMAPI.ModBuildConfig.Analyzer.Tests/SMAPI.ModBuildConfig.Analyzer.Tests.csproj index c1d5626f..e2be66d9 100644 --- a/src/SMAPI.ModBuildConfig.Analyzer.Tests/SMAPI.ModBuildConfig.Analyzer.Tests.csproj +++ b/src/SMAPI.ModBuildConfig.Analyzer.Tests/SMAPI.ModBuildConfig.Analyzer.Tests.csproj @@ -7,7 +7,7 @@ <ItemGroup> <PackageReference Include="Microsoft.CodeAnalysis.CSharp.Workspaces" Version="2.10.0" /> - <PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.4.0" /> + <PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.5.0" /> <PackageReference Include="NUnit" Version="3.12.0" /> <PackageReference Include="NUnit3TestAdapter" Version="3.16.1"> <PrivateAssets>all</PrivateAssets> diff --git a/src/SMAPI.Mods.ConsoleCommands/manifest.json b/src/SMAPI.Mods.ConsoleCommands/manifest.json index 0d0e4901..971c591a 100644 --- a/src/SMAPI.Mods.ConsoleCommands/manifest.json +++ b/src/SMAPI.Mods.ConsoleCommands/manifest.json @@ -1,9 +1,9 @@ { "Name": "Console Commands", "Author": "SMAPI", - "Version": "3.2.0", + "Version": "3.3.0", "Description": "Adds SMAPI console commands that let you manipulate the game.", "UniqueID": "SMAPI.ConsoleCommands", "EntryDll": "ConsoleCommands.dll", - "MinimumApiVersion": "3.2.0" + "MinimumApiVersion": "3.3.0" } diff --git a/src/SMAPI.Mods.SaveBackup/ModEntry.cs b/src/SMAPI.Mods.SaveBackup/ModEntry.cs index 8b139d8f..b8d3be1c 100644 --- a/src/SMAPI.Mods.SaveBackup/ModEntry.cs +++ b/src/SMAPI.Mods.SaveBackup/ModEntry.cs @@ -66,29 +66,37 @@ namespace StardewModdingAPI.Mods.SaveBackup FileInfo targetFile = new FileInfo(Path.Combine(backupFolder.FullName, this.FileName)); DirectoryInfo fallbackDir = new DirectoryInfo(Path.Combine(backupFolder.FullName, this.BackupLabel)); if (targetFile.Exists || fallbackDir.Exists) + { + this.Monitor.Log("Already backed up today."); return; + } // copy saves to fallback directory (ignore non-save files/folders) - this.Monitor.Log($"Backing up saves to {fallbackDir.FullName}...", LogLevel.Trace); DirectoryInfo savesDir = new DirectoryInfo(Constants.SavesPath); - this.RecursiveCopy(savesDir, fallbackDir, entry => this.MatchSaveFolders(savesDir, entry), copyRoot: false); + if (!this.RecursiveCopy(savesDir, fallbackDir, entry => this.MatchSaveFolders(savesDir, entry), copyRoot: false)) + { + this.Monitor.Log("No saves found."); + return; + } // compress backup if possible - this.Monitor.Log("Compressing backup if possible...", LogLevel.Trace); if (!this.TryCompress(fallbackDir.FullName, targetFile, out Exception compressError)) { - if (Constants.TargetPlatform != GamePlatform.Android) // expected to fail on Android - this.Monitor.Log($"Couldn't compress backup, leaving it uncompressed.\n{compressError}", LogLevel.Trace); + this.Monitor.Log(Constants.TargetPlatform != GamePlatform.Android + ? $"Backed up to {fallbackDir.FullName}." // expected to fail on Android + : $"Backed up to {fallbackDir.FullName}. Couldn't compress backup:\n{compressError}" + ); } else + { + this.Monitor.Log($"Backed up to {targetFile.FullName}."); fallbackDir.Delete(recursive: true); - - this.Monitor.Log("Backup done!", LogLevel.Trace); + } } catch (Exception ex) { - this.Monitor.Log("Couldn't back up save files (see log file for details).", LogLevel.Warn); - this.Monitor.Log(ex.ToString(), LogLevel.Trace); + this.Monitor.Log("Couldn't back up saves (see log file for details).", LogLevel.Warn); + this.Monitor.Log(ex.ToString()); } } @@ -108,7 +116,7 @@ namespace StardewModdingAPI.Mods.SaveBackup { try { - this.Monitor.Log($"Deleting {entry.Name}...", LogLevel.Trace); + this.Monitor.Log($"Deleting {entry.Name}..."); if (entry is DirectoryInfo folder) folder.Delete(recursive: true); else @@ -123,7 +131,7 @@ namespace StardewModdingAPI.Mods.SaveBackup catch (Exception ex) { this.Monitor.Log("Couldn't remove old backups (see log file for details).", LogLevel.Warn); - this.Monitor.Log(ex.ToString(), LogLevel.Trace); + this.Monitor.Log(ex.ToString()); } } @@ -199,29 +207,33 @@ namespace StardewModdingAPI.Mods.SaveBackup /// <param name="copyRoot">Whether to copy the root folder itself, or <c>false</c> to only copy its contents.</param> /// <param name="filter">A filter which matches the files or directories to copy, or <c>null</c> to copy everything.</param> /// <remarks>Derived from the SMAPI installer code.</remarks> - private void RecursiveCopy(FileSystemInfo source, DirectoryInfo targetFolder, Func<FileSystemInfo, bool> filter, bool copyRoot = true) + /// <returns>Returns whether any files were copied.</returns> + private bool RecursiveCopy(FileSystemInfo source, DirectoryInfo targetFolder, Func<FileSystemInfo, bool> filter, bool copyRoot = true) { - if (!targetFolder.Exists) - targetFolder.Create(); + if (!source.Exists || filter?.Invoke(source) == false) + return false; - if (filter?.Invoke(source) == false) - return; + bool anyCopied = false; switch (source) { case FileInfo sourceFile: + targetFolder.Create(); sourceFile.CopyTo(Path.Combine(targetFolder.FullName, sourceFile.Name)); + anyCopied = true; break; case DirectoryInfo sourceDir: DirectoryInfo targetSubfolder = copyRoot ? new DirectoryInfo(Path.Combine(targetFolder.FullName, sourceDir.Name)) : targetFolder; foreach (var entry in sourceDir.EnumerateFileSystemInfos()) - this.RecursiveCopy(entry, targetSubfolder, filter); + anyCopied = this.RecursiveCopy(entry, targetSubfolder, filter) || anyCopied; break; default: throw new NotSupportedException($"Unknown filesystem info type '{source.GetType().FullName}'."); } + + return anyCopied; } /// <summary>A copy filter which matches save folders.</summary> diff --git a/src/SMAPI.Mods.SaveBackup/manifest.json b/src/SMAPI.Mods.SaveBackup/manifest.json index 74256013..4559d1b0 100644 --- a/src/SMAPI.Mods.SaveBackup/manifest.json +++ b/src/SMAPI.Mods.SaveBackup/manifest.json @@ -1,9 +1,9 @@ { "Name": "Save Backup", "Author": "SMAPI", - "Version": "3.2.0", + "Version": "3.3.0", "Description": "Automatically backs up all your saves once per day into its folder.", "UniqueID": "SMAPI.SaveBackup", "EntryDll": "SaveBackup.dll", - "MinimumApiVersion": "3.2.0" + "MinimumApiVersion": "3.3.0" } diff --git a/src/SMAPI.Toolkit/Framework/Clients/WebApi/ModEntryVersionModel.cs b/src/SMAPI.Toolkit/Framework/Clients/WebApi/ModEntryVersionModel.cs index dadb8c10..188db31d 100644 --- a/src/SMAPI.Toolkit/Framework/Clients/WebApi/ModEntryVersionModel.cs +++ b/src/SMAPI.Toolkit/Framework/Clients/WebApi/ModEntryVersionModel.cs @@ -1,3 +1,6 @@ +using Newtonsoft.Json; +using StardewModdingAPI.Toolkit.Serialization.Converters; + namespace StardewModdingAPI.Toolkit.Framework.Clients.WebApi { /// <summary>Metadata about a version.</summary> @@ -7,6 +10,7 @@ namespace StardewModdingAPI.Toolkit.Framework.Clients.WebApi ** Accessors *********/ /// <summary>The version number.</summary> + [JsonConverter(typeof(NonStandardSemanticVersionConverter))] public ISemanticVersion Version { get; set; } /// <summary>The mod page URL.</summary> diff --git a/src/SMAPI.Toolkit/SMAPI.Toolkit.csproj b/src/SMAPI.Toolkit/SMAPI.Toolkit.csproj index 16a97dbf..a7de7166 100644 --- a/src/SMAPI.Toolkit/SMAPI.Toolkit.csproj +++ b/src/SMAPI.Toolkit/SMAPI.Toolkit.csproj @@ -12,7 +12,7 @@ </PropertyGroup> <ItemGroup> - <PackageReference Include="HtmlAgilityPack" Version="1.11.18" /> + <PackageReference Include="HtmlAgilityPack" Version="1.11.20" /> <PackageReference Include="Newtonsoft.Json" Version="12.0.3" /> <PackageReference Include="Pathoschild.Http.FluentClient" Version="3.3.1" /> <PackageReference Include="System.Management" Version="4.5.0" Condition="'$(OS)' == 'Windows_NT'" /> diff --git a/src/SMAPI.Toolkit/SemanticVersion.cs b/src/SMAPI.Toolkit/SemanticVersion.cs index 5ead6dc8..86db2820 100644 --- a/src/SMAPI.Toolkit/SemanticVersion.cs +++ b/src/SMAPI.Toolkit/SemanticVersion.cs @@ -199,18 +199,19 @@ namespace StardewModdingAPI.Toolkit /// <returns>Returns whether parsing the version succeeded.</returns> public static bool TryParse(string version, out ISemanticVersion parsed) { - return SemanticVersion.TryParseNonStandard(version, out parsed) && !parsed.IsNonStandard(); + return SemanticVersion.TryParse(version, allowNonStandard: false, out parsed); } - /// <summary>Parse a version string without throwing an exception if it fails, including support for non-standard extensions like <see cref="IPlatformSpecificVersion"/>.</summary> + /// <summary>Parse a version string without throwing an exception if it fails.</summary> /// <param name="version">The version string.</param> + /// <param name="allowNonStandard">Whether to allow non-standard extensions to semantic versioning.</param> /// <param name="parsed">The parsed representation.</param> /// <returns>Returns whether parsing the version succeeded.</returns> - public static bool TryParseNonStandard(string version, out ISemanticVersion parsed) + public static bool TryParse(string version, bool allowNonStandard, out ISemanticVersion parsed) { try { - parsed = new SemanticVersion(version, true); + parsed = new SemanticVersion(version, allowNonStandard); return true; } catch diff --git a/src/SMAPI.Toolkit/Serialization/Converters/NonStandardSemanticVersionConverter.cs b/src/SMAPI.Toolkit/Serialization/Converters/NonStandardSemanticVersionConverter.cs new file mode 100644 index 00000000..6f870bcf --- /dev/null +++ b/src/SMAPI.Toolkit/Serialization/Converters/NonStandardSemanticVersionConverter.cs @@ -0,0 +1,15 @@ +namespace StardewModdingAPI.Toolkit.Serialization.Converters +{ + /// <summary>Handles deserialization of <see cref="ISemanticVersion"/>, allowing for non-standard extensions.</summary> + internal class NonStandardSemanticVersionConverter : SemanticVersionConverter + { + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + public NonStandardSemanticVersionConverter() + { + this.AllowNonStandard = true; + } + } +} diff --git a/src/SMAPI.Toolkit/Serialization/Converters/SemanticVersionConverter.cs b/src/SMAPI.Toolkit/Serialization/Converters/SemanticVersionConverter.cs index e1b9db1d..3604956b 100644 --- a/src/SMAPI.Toolkit/Serialization/Converters/SemanticVersionConverter.cs +++ b/src/SMAPI.Toolkit/Serialization/Converters/SemanticVersionConverter.cs @@ -8,6 +8,13 @@ namespace StardewModdingAPI.Toolkit.Serialization.Converters internal class SemanticVersionConverter : JsonConverter { /********* + ** Fields + *********/ + /// <summary>Whether to allow non-standard extensions to semantic versioning.</summary> + protected bool AllowNonStandard { get; set; } + + + /********* ** Accessors *********/ /// <summary>Get whether this converter can read JSON.</summary> @@ -78,7 +85,7 @@ namespace StardewModdingAPI.Toolkit.Serialization.Converters { if (string.IsNullOrWhiteSpace(str)) return null; - if (!SemanticVersion.TryParse(str, out ISemanticVersion version)) + if (!SemanticVersion.TryParse(str, allowNonStandard: this.AllowNonStandard, out ISemanticVersion version)) throw new SParseException($"Can't parse semantic version from invalid value '{str}', should be formatted like 1.2, 1.2.30, or 1.2.30-beta (path: {path})."); return version; } diff --git a/src/SMAPI.Toolkit/Utilities/EnvironmentUtility.cs b/src/SMAPI.Toolkit/Utilities/EnvironmentUtility.cs index 2a01fe4b..c45448f3 100644 --- a/src/SMAPI.Toolkit/Utilities/EnvironmentUtility.cs +++ b/src/SMAPI.Toolkit/Utilities/EnvironmentUtility.cs @@ -53,7 +53,19 @@ namespace StardewModdingAPI.Toolkit.Utilities } catch { } #endif - return (platform == Platform.Mac ? "MacOS " : "") + Environment.OSVersion; + + string name = Environment.OSVersion.ToString(); + switch (platform) + { + case Platform.Android: + name = $"Android {name}"; + break; + + case Platform.Mac: + name = $"MacOS {name}"; + break; + } + return name; } /// <summary>Get the name of the Stardew Valley executable.</summary> diff --git a/src/SMAPI.Web/Controllers/ModsApiController.cs b/src/SMAPI.Web/Controllers/ModsApiController.cs index f194b4d0..06768f03 100644 --- a/src/SMAPI.Web/Controllers/ModsApiController.cs +++ b/src/SMAPI.Web/Controllers/ModsApiController.cs @@ -41,11 +41,8 @@ namespace StardewModdingAPI.Web.Controllers /// <summary>The cache in which to store mod data.</summary> private readonly IModCacheRepository ModCache; - /// <summary>The number of minutes successful update checks should be cached before refetching them.</summary> - private readonly int SuccessCacheMinutes; - - /// <summary>The number of minutes failed update checks should be cached before refetching them.</summary> - private readonly int ErrorCacheMinutes; + /// <summary>The config settings for mod update checks.</summary> + private readonly IOptions<ModUpdateCheckConfig> Config; /// <summary>The internal mod metadata list.</summary> private readonly ModDatabase ModDatabase; @@ -58,21 +55,19 @@ namespace StardewModdingAPI.Web.Controllers /// <param name="environment">The web hosting environment.</param> /// <param name="wikiCache">The cache in which to store wiki data.</param> /// <param name="modCache">The cache in which to store mod metadata.</param> - /// <param name="configProvider">The config settings for mod update checks.</param> + /// <param name="config">The config settings for mod update checks.</param> /// <param name="chucklefish">The Chucklefish API client.</param> /// <param name="curseForge">The CurseForge API client.</param> /// <param name="github">The GitHub API client.</param> /// <param name="modDrop">The ModDrop API client.</param> /// <param name="nexus">The Nexus API client.</param> - public ModsApiController(IHostingEnvironment environment, IWikiCacheRepository wikiCache, IModCacheRepository modCache, IOptions<ModUpdateCheckConfig> configProvider, IChucklefishClient chucklefish, ICurseForgeClient curseForge, IGitHubClient github, IModDropClient modDrop, INexusClient nexus) + public ModsApiController(IHostingEnvironment environment, IWikiCacheRepository wikiCache, IModCacheRepository modCache, IOptions<ModUpdateCheckConfig> config, IChucklefishClient chucklefish, ICurseForgeClient curseForge, IGitHubClient github, IModDropClient modDrop, INexusClient nexus) { this.ModDatabase = new ModToolkit().GetModDatabase(Path.Combine(environment.WebRootPath, "SMAPI.metadata.json")); - ModUpdateCheckConfig config = configProvider.Value; this.WikiCache = wikiCache; this.ModCache = modCache; - this.SuccessCacheMinutes = config.SuccessCacheMinutes; - this.ErrorCacheMinutes = config.ErrorCacheMinutes; + this.Config = config; this.Repositories = new IModRepository[] { @@ -133,6 +128,8 @@ namespace StardewModdingAPI.Web.Controllers ModDataRecord record = this.ModDatabase.Get(search.ID); WikiModEntry wikiEntry = wikiData.FirstOrDefault(entry => entry.ID.Contains(search.ID.Trim(), StringComparer.InvariantCultureIgnoreCase)); UpdateKey[] updateKeys = this.GetUpdateKeys(search.UpdateKeys, record, wikiEntry).ToArray(); + ModOverrideConfig overrides = this.Config.Value.ModOverrides.FirstOrDefault(p => p.ID.Equals(search.ID?.Trim(), StringComparison.InvariantCultureIgnoreCase)); + bool allowNonStandardVersions = overrides?.AllowNonStandardVersions ?? false; // get latest versions ModEntryModel result = new ModEntryModel { ID = search.ID }; @@ -151,7 +148,7 @@ namespace StardewModdingAPI.Web.Controllers } // fetch data - ModInfoModel data = await this.GetInfoForUpdateKeyAsync(updateKey); + ModInfoModel data = await this.GetInfoForUpdateKeyAsync(updateKey, allowNonStandardVersions); if (data.Error != null) { errors.Add(data.Error); @@ -161,7 +158,7 @@ namespace StardewModdingAPI.Web.Controllers // handle main version if (data.Version != null) { - ISemanticVersion version = this.GetMappedVersion(data.Version, wikiEntry?.MapRemoteVersions); + ISemanticVersion version = this.GetMappedVersion(data.Version, wikiEntry?.MapRemoteVersions, allowNonStandardVersions); if (version == null) { errors.Add($"The update key '{updateKey}' matches a mod with invalid semantic version '{data.Version}'."); @@ -175,7 +172,7 @@ namespace StardewModdingAPI.Web.Controllers // handle optional version if (data.PreviewVersion != null) { - ISemanticVersion version = this.GetMappedVersion(data.PreviewVersion, wikiEntry?.MapRemoteVersions); + ISemanticVersion version = this.GetMappedVersion(data.PreviewVersion, wikiEntry?.MapRemoteVersions, allowNonStandardVersions); if (version == null) { errors.Add($"The update key '{updateKey}' matches a mod with invalid optional semantic version '{data.PreviewVersion}'."); @@ -215,16 +212,16 @@ namespace StardewModdingAPI.Web.Controllers } // special cases - if (result.ID == "Pathoschild.SMAPI") + if (overrides?.SetUrl != null) { if (main != null) - main.Url = "https://smapi.io/"; + main.Url = overrides.SetUrl; if (optional != null) - optional.Url = "https://smapi.io/"; + optional.Url = overrides.SetUrl; } // get recommended update (if any) - ISemanticVersion installedVersion = this.GetMappedVersion(search.InstalledVersion?.ToString(), wikiEntry?.MapLocalVersions); + ISemanticVersion installedVersion = this.GetMappedVersion(search.InstalledVersion?.ToString(), wikiEntry?.MapLocalVersions, allowNonStandard: allowNonStandardVersions); if (apiVersion != null && installedVersion != null) { // get newer versions @@ -283,10 +280,11 @@ namespace StardewModdingAPI.Web.Controllers /// <summary>Get the mod info for an update key.</summary> /// <param name="updateKey">The namespaced update key.</param> - private async Task<ModInfoModel> GetInfoForUpdateKeyAsync(UpdateKey updateKey) + /// <param name="allowNonStandardVersions">Whether to allow non-standard versions.</param> + private async Task<ModInfoModel> GetInfoForUpdateKeyAsync(UpdateKey updateKey, bool allowNonStandardVersions) { // get mod - if (!this.ModCache.TryGetMod(updateKey.Repository, updateKey.ID, out CachedMod mod) || this.ModCache.IsStale(mod.LastUpdated, mod.FetchStatus == RemoteModStatus.TemporaryError ? this.ErrorCacheMinutes : this.SuccessCacheMinutes)) + if (!this.ModCache.TryGetMod(updateKey.Repository, updateKey.ID, out CachedMod mod) || this.ModCache.IsStale(mod.LastUpdated, mod.FetchStatus == RemoteModStatus.TemporaryError ? this.Config.Value.ErrorCacheMinutes : this.Config.Value.SuccessCacheMinutes)) { // get site if (!this.Repositories.TryGetValue(updateKey.Repository, out IModRepository repository)) @@ -298,7 +296,7 @@ namespace StardewModdingAPI.Web.Controllers { if (result.Version == null) result.SetError(RemoteModStatus.InvalidData, $"The update key '{updateKey}' matches a mod with no version number."); - else if (!SemanticVersion.TryParse(result.Version, out _)) + else if (!SemanticVersion.TryParse(result.Version, allowNonStandardVersions, out _)) result.SetError(RemoteModStatus.InvalidData, $"The update key '{updateKey}' matches a mod with invalid semantic version '{result.Version}'."); } @@ -357,15 +355,16 @@ namespace StardewModdingAPI.Web.Controllers /// <summary>Get a semantic local version for update checks.</summary> /// <param name="version">The version to parse.</param> /// <param name="map">A map of version replacements.</param> - private ISemanticVersion GetMappedVersion(string version, IDictionary<string, string> map) + /// <param name="allowNonStandard">Whether to allow non-standard versions.</param> + private ISemanticVersion GetMappedVersion(string version, IDictionary<string, string> map, bool allowNonStandard) { // try mapped version - string rawNewVersion = this.GetRawMappedVersion(version, map); - if (SemanticVersion.TryParse(rawNewVersion, out ISemanticVersion parsedNew)) + string rawNewVersion = this.GetRawMappedVersion(version, map, allowNonStandard); + if (SemanticVersion.TryParse(rawNewVersion, allowNonStandard, out ISemanticVersion parsedNew)) return parsedNew; // return original version - return SemanticVersion.TryParse(version, out ISemanticVersion parsedOld) + return SemanticVersion.TryParse(version, allowNonStandard, out ISemanticVersion parsedOld) ? parsedOld : null; } @@ -373,7 +372,8 @@ namespace StardewModdingAPI.Web.Controllers /// <summary>Get a semantic local version for update checks.</summary> /// <param name="version">The version to map.</param> /// <param name="map">A map of version replacements.</param> - private string GetRawMappedVersion(string version, IDictionary<string, string> map) + /// <param name="allowNonStandard">Whether to allow non-standard versions.</param> + private string GetRawMappedVersion(string version, IDictionary<string, string> map, bool allowNonStandard) { if (version == null || map == null || !map.Any()) return version; @@ -383,14 +383,14 @@ namespace StardewModdingAPI.Web.Controllers return map[version]; // match parsed version - if (SemanticVersion.TryParse(version, out ISemanticVersion parsed)) + if (SemanticVersion.TryParse(version, allowNonStandard, out ISemanticVersion parsed)) { if (map.ContainsKey(parsed.ToString())) return map[parsed.ToString()]; foreach (var pair in map) { - if (SemanticVersion.TryParse(pair.Key, out ISemanticVersion target) && parsed.Equals(target) && SemanticVersion.TryParse(pair.Value, out ISemanticVersion newVersion)) + if (SemanticVersion.TryParse(pair.Key, allowNonStandard, out ISemanticVersion target) && parsed.Equals(target) && SemanticVersion.TryParse(pair.Value, allowNonStandard, out ISemanticVersion newVersion)) return newVersion.ToString(); } } diff --git a/src/SMAPI.Web/Framework/ConfigModels/ModOverrideConfig.cs b/src/SMAPI.Web/Framework/ConfigModels/ModOverrideConfig.cs new file mode 100644 index 00000000..f382d7b5 --- /dev/null +++ b/src/SMAPI.Web/Framework/ConfigModels/ModOverrideConfig.cs @@ -0,0 +1,15 @@ +namespace StardewModdingAPI.Web.Framework.ConfigModels +{ + /// <summary>Override update-check metadata for a mod.</summary> + internal class ModOverrideConfig + { + /// <summary>The unique ID from the mod's manifest.</summary> + public string ID { get; set; } + + /// <summary>Whether to allow non-standard versions.</summary> + public bool AllowNonStandardVersions { get; set; } + + /// <summary>The mod page URL to use regardless of which site has the update, or <c>null</c> to use the site URL.</summary> + public string SetUrl { get; set; } + } +} diff --git a/src/SMAPI.Web/Framework/ConfigModels/ModUpdateCheckConfig.cs b/src/SMAPI.Web/Framework/ConfigModels/ModUpdateCheckConfig.cs index 46073eb8..bd58dba0 100644 --- a/src/SMAPI.Web/Framework/ConfigModels/ModUpdateCheckConfig.cs +++ b/src/SMAPI.Web/Framework/ConfigModels/ModUpdateCheckConfig.cs @@ -11,5 +11,8 @@ namespace StardewModdingAPI.Web.Framework.ConfigModels /// <summary>The number of minutes failed update checks should be cached before refetching them.</summary> public int ErrorCacheMinutes { get; set; } + + /// <summary>Update-check metadata to override.</summary> + public ModOverrideConfig[] ModOverrides { get; set; } } } diff --git a/src/SMAPI.Web/Framework/VersionConstraint.cs b/src/SMAPI.Web/Framework/VersionConstraint.cs index 72f5ef84..f0c57c41 100644 --- a/src/SMAPI.Web/Framework/VersionConstraint.cs +++ b/src/SMAPI.Web/Framework/VersionConstraint.cs @@ -28,7 +28,7 @@ namespace StardewModdingAPI.Web.Framework return values.TryGetValue(routeKey, out object routeValue) && routeValue is string routeStr - && SemanticVersion.TryParseNonStandard(routeStr, out _); + && SemanticVersion.TryParse(routeStr, allowNonStandard: true, out _); } } } diff --git a/src/SMAPI.Web/SMAPI.Web.csproj b/src/SMAPI.Web/SMAPI.Web.csproj index 148631a9..97bea0fb 100644 --- a/src/SMAPI.Web/SMAPI.Web.csproj +++ b/src/SMAPI.Web/SMAPI.Web.csproj @@ -12,11 +12,11 @@ </ItemGroup> <ItemGroup> - <PackageReference Include="Azure.Storage.Blobs" Version="12.2.0" /> + <PackageReference Include="Azure.Storage.Blobs" Version="12.3.0" /> <PackageReference Include="Hangfire.AspNetCore" Version="1.7.9" /> <PackageReference Include="Hangfire.MemoryStorage" Version="1.6.3" /> <PackageReference Include="Hangfire.Mongo" Version="0.6.6" /> - <PackageReference Include="HtmlAgilityPack" Version="1.11.18" /> + <PackageReference Include="HtmlAgilityPack" Version="1.11.20" /> <PackageReference Include="Humanizer.Core" Version="2.7.9" /> <PackageReference Include="JetBrains.Annotations" Version="2019.1.3" /> <PackageReference Include="Markdig" Version="0.18.1" /> @@ -25,7 +25,7 @@ <PackageReference Include="Microsoft.AspNetCore.Rewrite" Version="2.2.0" /> <PackageReference Include="Microsoft.AspNetCore.StaticFiles" Version="2.2.0" /> <PackageReference Include="Mongo2Go" Version="2.2.12" /> - <PackageReference Include="MongoDB.Driver" Version="2.10.1" /> + <PackageReference Include="MongoDB.Driver" Version="2.10.2" /> <PackageReference Include="Newtonsoft.Json.Schema" Version="3.0.13" /> <PackageReference Include="Pathoschild.FluentNexus" Version="0.8.0" /> <PackageReference Include="Pathoschild.Http.FluentClient" Version="3.3.1" /> diff --git a/src/SMAPI.Web/Views/Mods/Index.cshtml b/src/SMAPI.Web/Views/Mods/Index.cshtml index 5b310d55..b1d9ae2c 100644 --- a/src/SMAPI.Web/Views/Mods/Index.cshtml +++ b/src/SMAPI.Web/Views/Mods/Index.cshtml @@ -8,11 +8,11 @@ TimeSpan staleAge = DateTimeOffset.UtcNow - Model.LastUpdated; } @section Head { - <link rel="stylesheet" href="~/Content/css/mods.css?r=20190302" /> + <link rel="stylesheet" href="~/Content/css/mods.css?r=20200218" /> <script src="https://cdn.jsdelivr.net/npm/vue@2.5.17/dist/vue.min.js" crossorigin="anonymous"></script> <script src="https://cdn.jsdelivr.net/npm/jquery@3.3.1/dist/jquery.min.js" crossorigin="anonymous"></script> <script src="https://cdn.jsdelivr.net/npm/tablesorter@2.31.0/dist/js/jquery.tablesorter.combined.min.js" crossorigin="anonymous"></script> - <script src="~/Content/js/mods.js?r=20190302"></script> + <script src="~/Content/js/mods.js?r=20200218"></script> <script> $(function() { var data = @Json.Serialize(Model.Mods, new JsonSerializerSettings { Formatting = Formatting.None }); diff --git a/src/SMAPI.Web/appsettings.json b/src/SMAPI.Web/appsettings.json index caeb381f..9cd1efc8 100644 --- a/src/SMAPI.Web/appsettings.json +++ b/src/SMAPI.Web/appsettings.json @@ -64,6 +64,17 @@ "ModUpdateCheck": { "SuccessCacheMinutes": 60, - "ErrorCacheMinutes": 5 + "ErrorCacheMinutes": 5, + "ModOverrides": [ + { + "ID": "Pathoschild.SMAPI", + "AllowNonStandardVersions": true, + "SetUrl": "https://smapi.io" + }, + { + "ID": "MartyrPher.SMAPI-Android-Installer", + "AllowNonStandardVersions": true + } + ] } } diff --git a/src/SMAPI.Web/wwwroot/Content/css/mods.css b/src/SMAPI.Web/wwwroot/Content/css/mods.css index 1c2b8056..697ba514 100644 --- a/src/SMAPI.Web/wwwroot/Content/css/mods.css +++ b/src/SMAPI.Web/wwwroot/Content/css/mods.css @@ -86,6 +86,11 @@ table.wikitable > caption { font-size: 0.9em; } +#mod-list thead tr { + position: sticky; + top: 0; +} + #mod-list th.header { background-repeat: no-repeat; background-position: center right; diff --git a/src/SMAPI.Web/wwwroot/Content/js/mods.js b/src/SMAPI.Web/wwwroot/Content/js/mods.js index 0394ac4f..35098b60 100644 --- a/src/SMAPI.Web/wwwroot/Content/js/mods.js +++ b/src/SMAPI.Web/wwwroot/Content/js/mods.js @@ -102,7 +102,7 @@ smapi.modList = function (mods, enableBeta) { app = new Vue({ el: "#app", data: data, - mounted: function() { + mounted: function () { // enable table sorting $("#mod-list").tablesorter({ cssHeader: "header", @@ -115,11 +115,7 @@ smapi.modList = function (mods, enableBeta) { $("#search-box").focus(); // jump to anchor (since table is added after page load) - if (location.hash) { - var row = $(location.hash).get(0); - if (row) - row.scrollIntoView(); - } + this.fixHashPosition(); }, methods: { /** @@ -144,6 +140,18 @@ smapi.modList = function (mods, enableBeta) { } }, + /** + * Fix the window position for the current hash. + */ + fixHashPosition: function () { + if (!location.hash) + return; + + var row = $(location.hash); + var target = row.prev().get(0) || row.get(0); + if (target) + target.scrollIntoView(); + }, /** * Get whether a mod matches the current filters. @@ -151,7 +159,7 @@ smapi.modList = function (mods, enableBeta) { * @param {string[]} searchWords The search words to match. * @returns {bool} Whether the mod matches the filters. */ - matchesFilters: function(mod, searchWords) { + matchesFilters: function (mod, searchWords) { var filters = data.filters; // check hash @@ -249,7 +257,9 @@ smapi.modList = function (mods, enableBeta) { } }); app.applyFilters(); + app.fixHashPosition(); window.addEventListener("hashchange", function () { app.applyFilters(); + app.fixHashPosition(); }); }; diff --git a/src/SMAPI.Web/wwwroot/SMAPI.metadata.json b/src/SMAPI.Web/wwwroot/SMAPI.metadata.json index 78918bac..3101fdf1 100644 --- a/src/SMAPI.Web/wwwroot/SMAPI.metadata.json +++ b/src/SMAPI.Web/wwwroot/SMAPI.metadata.json @@ -112,7 +112,7 @@ "Default | UpdateKey": "Nexus:2341" }, - "TMX Loader": { + "TMXL Map Toolkit": { "ID": "Platonymous.TMXLoader", "Default | UpdateKey": "Nexus:1820" }, @@ -129,7 +129,7 @@ "Bee House Flower Range Fix": { "ID": "kirbylink.beehousefix", "~ | Status": "Obsolete", - "~ | StatusReasonPhrase": "the bee house flower range was fixed in Stardew Valley 1.4." + "~ | StatusReasonPhrase": "the bee house flower range was fixed in Stardew Valley 1.4." }, "Colored Chests": { @@ -153,9 +153,9 @@ /********* ** Broke in SDV 1.4 *********/ - "Fix Dice": { - "ID": "ashley.fixdice", - "~1.1.2 | Status": "AssumeBroken" // crashes game on startup + "Auto Quality Patch": { + "ID": "SilentOak.AutoQualityPatch", + "~2.1.3-unofficial.7 | Status": "AssumeBroken" // runtime errors }, "Fix Dice": { diff --git a/src/SMAPI.Web/wwwroot/schemas/content-patcher.json b/src/SMAPI.Web/wwwroot/schemas/content-patcher.json index 7e00c28e..e6cd4e65 100644 --- a/src/SMAPI.Web/wwwroot/schemas/content-patcher.json +++ b/src/SMAPI.Web/wwwroot/schemas/content-patcher.json @@ -142,7 +142,7 @@ }, "FromFile": { "title": "Source file", - "description": "The relative file path in your content pack folder to load instead (like 'assets/dinosaur.png'). This can be a .json (data), .png (image), .tbin (map), or .xnb file. This field supports tokens and capitalization doesn't matter.", + "description": "The relative file path in your content pack folder to load instead (like 'assets/dinosaur.png'). This can be a .json (data), .png (image), .tbin or .tmx (map), or .xnb file. This field supports tokens and capitalization doesn't matter.", "type": "string", "allOf": [ { @@ -151,12 +151,12 @@ } }, { - "pattern": "\\.(json|png|tbin|xnb) *$" + "pattern": "\\.(json|png|tbin|tmx|xnb) *$" } ], "@errorMessages": { "allOf:indexes: 0": "Invalid value; must not contain directory climbing (like '../').", - "allOf:indexes: 1": "Invalid value; must be a file path ending with .json, .png, .tbin, or .xnb." + "allOf:indexes: 1": "Invalid value; must be a file path ending with .json, .png, .tbin, .tmx, or .xnb." } }, "FromArea": { @@ -325,7 +325,7 @@ "then": { "properties": { "FromFile": { - "description": "The relative path to the map in your content pack folder from which to copy (like assets/town.tbin). This can be a .tbin or .xnb file. This field supports tokens and capitalization doesn't matter.\nContent Patcher will handle tilesheets referenced by the FromFile map for you:\n - If a tilesheet isn't referenced by the target map, Content Patcher will add it for you (with a z_ ID prefix to avoid conflicts with hardcoded game logic). If the source map has a custom version of a tilesheet that's already referenced, it'll be added as a separate tilesheet only used by your tiles.\n - If you include the tilesheet file in your mod folder, Content Patcher will use that one automatically; otherwise it will be loaded from the game's Content/Maps folder." + "description": "The relative path to the map in your content pack folder from which to copy (like assets/town.tbin). This can be a .tbin, .tmx, or .xnb file. This field supports tokens and capitalization doesn't matter.\nContent Patcher will handle tilesheets referenced by the FromFile map for you:\n - If a tilesheet isn't referenced by the target map, Content Patcher will add it for you (with a z_ ID prefix to avoid conflicts with hardcoded game logic). If the source map has a custom version of a tilesheet that's already referenced, it'll be added as a separate tilesheet only used by your tiles.\n - If you include the tilesheet file in your mod folder, Content Patcher will use that one automatically; otherwise it will be loaded from the game's Content/Maps folder." }, "FromArea": { "description": "The part of the source map to copy. Defaults to the whole source map." diff --git a/src/SMAPI/Constants.cs b/src/SMAPI/Constants.cs index 201d3166..670dc494 100644 --- a/src/SMAPI/Constants.cs +++ b/src/SMAPI/Constants.cs @@ -20,7 +20,7 @@ namespace StardewModdingAPI ** Public ****/ /// <summary>SMAPI's current semantic version.</summary> - public static ISemanticVersion ApiVersion { get; } = new Toolkit.SemanticVersion("3.2.0"); + public static ISemanticVersion ApiVersion { get; } = new Toolkit.SemanticVersion("3.3.0"); /// <summary>The minimum supported version of Stardew Valley.</summary> public static ISemanticVersion MinimumGameVersion { get; } = new GameVersion("1.4.1"); @@ -115,26 +115,59 @@ namespace StardewModdingAPI /// <returns>Returns the compatible SMAPI version, or <c>null</c> if none was found.</returns> internal static ISemanticVersion GetCompatibleApiVersion(ISemanticVersion version) { + // This covers all officially supported public game updates. It might seem like version + // ranges would be better, but the given SMAPI versions may not be compatible with + // intermediate unlisted versions (e.g. private beta updates). + // + // Nonstandard versions are normalized by GameVersion (e.g. 1.07 => 1.0.7). switch (version.ToString()) { + case "1.4.1": + case "1.4.0": + return new SemanticVersion("3.0.1"); + case "1.3.36": - return new SemanticVersion(2, 11, 2); + return new SemanticVersion("2.11.2"); - case "1.3.32": case "1.3.33": - return new SemanticVersion(2, 10, 2); + case "1.3.32": + return new SemanticVersion("2.10.2"); case "1.3.28": - return new SemanticVersion(2, 7, 0); + return new SemanticVersion("2.7.0"); - case "1.2.30": - case "1.2.31": - case "1.2.32": case "1.2.33": - return new SemanticVersion(2, 5, 5); - } + case "1.2.32": + case "1.2.31": + case "1.2.30": + return new SemanticVersion("2.5.5"); + + case "1.2.29": + case "1.2.28": + case "1.2.27": + case "1.2.26": + return new SemanticVersion("1.13.1"); + + case "1.1.1": + case "1.1.0": + return new SemanticVersion("1.9.0"); + + case "1.0.7.1": + case "1.0.7": + case "1.0.6": + case "1.0.5.2": + case "1.0.5.1": + case "1.0.5": + case "1.0.4": + case "1.0.3": + case "1.0.2": + case "1.0.1": + case "1.0.0": + return new SemanticVersion("0.40.0"); - return null; + default: + return null; + } } /// <summary>Get metadata for mapping assemblies to the current platform.</summary> diff --git a/src/SMAPI/Framework/Content/AssetDataForImage.cs b/src/SMAPI/Framework/Content/AssetDataForImage.cs index aa615a0b..44a97136 100644 --- a/src/SMAPI/Framework/Content/AssetDataForImage.cs +++ b/src/SMAPI/Framework/Content/AssetDataForImage.cs @@ -1,6 +1,7 @@ using System; using Microsoft.Xna.Framework; using Microsoft.Xna.Framework.Graphics; +using StardewValley; namespace StardewModdingAPI.Framework.Content { @@ -102,5 +103,21 @@ namespace StardewModdingAPI.Framework.Content // patch target texture target.SetData(0, targetArea, sourceData, 0, pixelCount); } + + /// <summary>Extend the image if needed to fit the given size. Note that this is an expensive operation, creates a new texture instance, and that extending a spritesheet horizontally may cause game errors or bugs.</summary> + /// <param name="minWidth">The minimum texture width.</param> + /// <param name="minHeight">The minimum texture height.</param> + /// <returns>Whether the texture was resized.</returns> + public bool ExtendImage(int minWidth, int minHeight) + { + if (this.Data.Width >= minWidth && this.Data.Height >= minHeight) + return false; + + Texture2D original = this.Data; + Texture2D texture = new Texture2D(Game1.graphics.GraphicsDevice, Math.Max(original.Width, minWidth), Math.Max(original.Height, minHeight)); + this.ReplaceWith(texture); + this.PatchImage(original); + return true; + } } } diff --git a/src/SMAPI/Framework/ContentCoordinator.cs b/src/SMAPI/Framework/ContentCoordinator.cs index 2fd31263..0b1ccc3c 100644 --- a/src/SMAPI/Framework/ContentCoordinator.cs +++ b/src/SMAPI/Framework/ContentCoordinator.cs @@ -112,9 +112,10 @@ 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="modName">The mod display name to show in errors.</param> /// <param name="rootDirectory">The root directory to search for content (or <c>null</c> for the default).</param> /// <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) + public ModContentManager CreateModContentManager(string name, string modName, string rootDirectory, IContentManager gameContentManager) { return this.ContentManagerLock.InWriteLock(() => { @@ -123,6 +124,7 @@ namespace StardewModdingAPI.Framework gameContentManager: gameContentManager, serviceProvider: this.MainContentManager.ServiceProvider, rootDirectory: rootDirectory, + modName: modName, currentCulture: this.MainContentManager.CurrentCulture, coordinator: this, monitor: this.Monitor, diff --git a/src/SMAPI/Framework/ContentManagers/GameContentManager.cs b/src/SMAPI/Framework/ContentManagers/GameContentManager.cs index eecdda74..eaaf0e6f 100644 --- a/src/SMAPI/Framework/ContentManagers/GameContentManager.cs +++ b/src/SMAPI/Framework/ContentManagers/GameContentManager.cs @@ -2,12 +2,15 @@ using System; using System.Collections.Generic; using System.Globalization; using System.Linq; +using System.Reflection; using Microsoft.Xna.Framework.Content; +using Microsoft.Xna.Framework.Graphics; using StardewModdingAPI.Framework.Content; using StardewModdingAPI.Framework.Exceptions; using StardewModdingAPI.Framework.Reflection; using StardewModdingAPI.Framework.Utilities; using StardewValley; +using xTile; namespace StardewModdingAPI.Framework.ContentManagers { @@ -337,6 +340,20 @@ namespace StardewModdingAPI.Framework.ContentManagers { IAssetData GetNewData(object data) => new AssetDataForObject(info, data, this.AssertAndNormalizeAssetName); + // special case: if the asset was loaded with a more general type like 'object', call editors with the actual type instead. + { + Type actualType = asset.Data.GetType(); + Type actualOpenType = actualType.IsGenericType ? actualType.GetGenericTypeDefinition() : null; + + if (typeof(T) != actualType && (actualOpenType == typeof(Dictionary<,>) || actualOpenType == typeof(List<>) || actualType == typeof(Texture2D) || actualType == typeof(Map))) + { + return (IAssetData)this.GetType() + .GetMethod(nameof(this.ApplyEditors), BindingFlags.NonPublic | BindingFlags.Instance) + .MakeGenericMethod(actualType) + .Invoke(this, new object[] { info, asset }); + } + } + // edit asset foreach (var entry in this.Editors) { diff --git a/src/SMAPI/Framework/ContentManagers/ModContentManager.cs b/src/SMAPI/Framework/ContentManagers/ModContentManager.cs index 0a526fc8..7d274eb7 100644 --- a/src/SMAPI/Framework/ContentManagers/ModContentManager.cs +++ b/src/SMAPI/Framework/ContentManagers/ModContentManager.cs @@ -26,6 +26,9 @@ namespace StardewModdingAPI.Framework.ContentManagers /// <summary>Encapsulates SMAPI's JSON file parsing.</summary> private readonly JsonHelper JsonHelper; + /// <summary>The mod display name to show in errors.</summary> + private readonly string ModName; + /// <summary>The game content manager used for map tilesheets not provided by the mod.</summary> private readonly IContentManager GameContentManager; @@ -40,6 +43,7 @@ namespace StardewModdingAPI.Framework.ContentManagers /// <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="modName">The mod display name to show in errors.</param> /// <param name="rootDirectory">The root directory to search for 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> @@ -47,11 +51,12 @@ namespace StardewModdingAPI.Framework.ContentManagers /// <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, IContentManager gameContentManager, IServiceProvider serviceProvider, string rootDirectory, CultureInfo currentCulture, ContentCoordinator coordinator, IMonitor monitor, Reflector reflection, JsonHelper jsonHelper, Action<BaseContentManager> onDisposing) + public ModContentManager(string name, IContentManager gameContentManager, IServiceProvider serviceProvider, string modName, 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; + this.ModName = modName; } /// <summary>Load an asset that has been processed by the content pipeline.</summary> @@ -248,8 +253,8 @@ namespace StardewModdingAPI.Framework.ContentManagers texture.GetData(data); for (int i = 0; i < data.Length; i++) { - if (data[i].A == 0) - continue; // no need to change fully transparent pixels + if (data[i].A == byte.MinValue || data[i].A == byte.MaxValue) + continue; // no need to change fully transparent/opaque pixels data[i] = Color.FromNonPremultiplied(data[i].ToVector4()); } @@ -297,98 +302,99 @@ namespace StardewModdingAPI.Framework.ContentManagers foreach (TileSheet tilesheet in map.TileSheets) { string imageSource = tilesheet.ImageSource; + string errorPrefix = $"{this.ModName} loaded map '{relativeMapPath}' with invalid tilesheet path '{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)}"; - } - } + throw new SContentLoadException($"{errorPrefix} Tilesheet paths must be a relative path without directory climbing (../)."); // load best match try { - string key = - this.GetTilesheetAssetName(relativeMapFolder, seasonalImageSource) - ?? this.GetTilesheetAssetName(relativeMapFolder, imageSource); - if (key != null) - { - tilesheet.ImageSource = key; - continue; - } + if (!this.TryGetTilesheetAssetName(relativeMapFolder, imageSource, isOutdoors, out string assetName, out string error)) + throw new SContentLoadException($"{errorPrefix} {error}"); + + tilesheet.ImageSource = assetName; } - catch (Exception ex) + catch (Exception ex) when (!(ex is SContentLoadException)) { - throw new ContentLoadException($"The '{imageSource}' tilesheet couldn't be loaded relative to either map file or the game's content folder.", ex); + throw new SContentLoadException($"{errorPrefix} The tilesheet couldn't be loaded.", 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> + /// <param name="originalPath">The tilesheet path to load.</param> + /// <param name="willSeasonalize">Whether the game will apply seasonal logic to the tilesheet.</param> + /// <param name="assetName">The found asset name.</param> + /// <param name="error">A message indicating why the file couldn't be loaded.</param> + /// <returns>Returns whether the asset name was found.</returns> /// <remarks>See remarks on <see cref="FixCustomTilesheetPaths"/>.</remarks> - private string GetTilesheetAssetName(string modRelativeMapFolder, string imageSource) + private bool TryGetTilesheetAssetName(string modRelativeMapFolder, string originalPath, bool willSeasonalize, out string assetName, out string error) { - if (imageSource == null) - return null; + assetName = null; + error = null; + + // nothing to do + if (string.IsNullOrWhiteSpace(originalPath)) + { + assetName = originalPath; + return true; + } - // check relative to map file + // parse path + string filename = Path.GetFileName(originalPath); + bool isSeasonal = filename.StartsWith("spring_", StringComparison.CurrentCultureIgnoreCase) + || filename.StartsWith("summer_", StringComparison.CurrentCultureIgnoreCase) + || filename.StartsWith("fall_", StringComparison.CurrentCultureIgnoreCase) + || filename.StartsWith("winter_", StringComparison.CurrentCultureIgnoreCase); + string relativePath = originalPath; + if (willSeasonalize && isSeasonal) { - string localKey = Path.Combine(modRelativeMapFolder, imageSource); - FileInfo localFile = this.GetModFile(localKey); - if (localFile.Exists) - return this.GetInternalAssetKey(localKey); + string dirPath = Path.GetDirectoryName(originalPath); + relativePath = Path.Combine(dirPath, $"{Game1.currentSeason}_{filename.Substring(filename.IndexOf("_", StringComparison.CurrentCultureIgnoreCase) + 1)}"); } - // check relative to content folder + // get relative to map file { - foreach (string candidateKey in new[] { imageSource, Path.Combine("Maps", imageSource) }) + string localKey = Path.Combine(modRelativeMapFolder, relativePath); + if (this.GetModFile(localKey).Exists) + { + assetName = this.GetInternalAssetKey(localKey); + return true; + } + } + + // get from game assets + { + string contentKey = Path.Combine("Maps", relativePath); + if (contentKey.EndsWith(".png")) + contentKey = contentKey.Substring(0, contentKey.Length - 4); + + try + { + this.GameContentManager.Load<Texture2D>(contentKey, this.Language, useCache: true); // no need to bypass cache here, since we're not storing the asset + assetName = contentKey; + return true; + } + catch { - 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; - } + // 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; + error = "The tilesheet couldn't be found relative to either map file or the game's content folder."; + return false; } /// <summary>Get whether a file from the game's content folder exists.</summary> diff --git a/src/SMAPI/Framework/Input/SInputState.cs b/src/SMAPI/Framework/Input/SInputState.cs index 84cea36c..4eaa9ca6 100644 --- a/src/SMAPI/Framework/Input/SInputState.cs +++ b/src/SMAPI/Framework/Input/SInputState.cs @@ -49,7 +49,7 @@ namespace StardewModdingAPI.Framework.Input public ICursorPosition CursorPosition => this.CursorPositionImpl; /// <summary>The buttons which were pressed, held, or released.</summary> - public IDictionary<SButton, InputStatus> ActiveButtons { get; private set; } = new Dictionary<SButton, InputStatus>(); + public IDictionary<SButton, SButtonState> ActiveButtons { get; private set; } = new Dictionary<SButton, SButtonState>(); /// <summary>The buttons to suppress when the game next handles input. Each button is suppressed until it's released.</summary> public HashSet<SButton> SuppressButtons { get; } = new HashSet<SButton>(); @@ -75,7 +75,7 @@ namespace StardewModdingAPI.Framework.Input [Obsolete("This method should only be called by the game itself.")] public override void Update() { } - /// <summary>Update the current button statuses for the given tick.</summary> + /// <summary>Update the current button states for the given tick.</summary> public void TrueUpdate() { try @@ -86,7 +86,7 @@ namespace StardewModdingAPI.Framework.Input GamePadState realController = GamePad.GetState(PlayerIndex.One); KeyboardState realKeyboard = Keyboard.GetState(); MouseState realMouse = Mouse.GetState(); - var activeButtons = this.DeriveStatuses(this.ActiveButtons, realKeyboard, realMouse, realController); + var activeButtons = this.DeriveStates(this.ActiveButtons, realKeyboard, realMouse, realController); 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; @@ -102,7 +102,7 @@ namespace StardewModdingAPI.Framework.Input } // update suppressed states - this.SuppressButtons.RemoveWhere(p => !this.GetStatus(activeButtons, p).IsDown()); + this.SuppressButtons.RemoveWhere(p => !this.GetState(activeButtons, p).IsDown()); this.UpdateSuppression(); } catch (InvalidOperationException) @@ -159,7 +159,7 @@ namespace StardewModdingAPI.Framework.Input /// <param name="button">The button to check.</param> public bool IsDown(SButton button) { - return this.GetStatus(this.ActiveButtons, button).IsDown(); + return this.GetState(this.ActiveButtons, button).IsDown(); } /// <summary>Get whether any of the given buttons were pressed or held.</summary> @@ -169,6 +169,13 @@ namespace StardewModdingAPI.Framework.Input return buttons.Any(button => this.IsDown(button.ToSButton())); } + /// <summary>Get the state of a button.</summary> + /// <param name="button">The button to check.</param> + public SButtonState GetState(SButton button) + { + return this.GetState(this.ActiveButtons, button); + } + /********* ** Private methods @@ -198,7 +205,7 @@ namespace StardewModdingAPI.Framework.Input /// <param name="keyboardState">The game's keyboard state for the current tick.</param> /// <param name="mouseState">The game's mouse state for the current tick.</param> /// <param name="gamePadState">The game's controller state for the current tick.</param> - private void SuppressGivenStates(IDictionary<SButton, InputStatus> activeButtons, ref KeyboardState keyboardState, ref MouseState mouseState, ref GamePadState gamePadState) + private void SuppressGivenStates(IDictionary<SButton, SButtonState> activeButtons, ref KeyboardState keyboardState, ref MouseState mouseState, ref GamePadState gamePadState) { if (this.SuppressButtons.Count == 0) return; @@ -245,48 +252,48 @@ namespace StardewModdingAPI.Framework.Input } } - /// <summary>Get the status of all pressed or released buttons relative to their previous status.</summary> - /// <param name="previousStatuses">The previous button statuses.</param> + /// <summary>Get the state of all pressed or released buttons relative to their previous state.</summary> + /// <param name="previousStates">The previous button states.</param> /// <param name="keyboard">The keyboard state.</param> /// <param name="mouse">The mouse state.</param> /// <param name="controller">The controller state.</param> - private IDictionary<SButton, InputStatus> DeriveStatuses(IDictionary<SButton, InputStatus> previousStatuses, KeyboardState keyboard, MouseState mouse, GamePadState controller) + private IDictionary<SButton, SButtonState> DeriveStates(IDictionary<SButton, SButtonState> previousStates, KeyboardState keyboard, MouseState mouse, GamePadState controller) { - IDictionary<SButton, InputStatus> activeButtons = new Dictionary<SButton, InputStatus>(); + IDictionary<SButton, SButtonState> activeButtons = new Dictionary<SButton, SButtonState>(); // handle pressed keys SButton[] down = this.GetPressedButtons(keyboard, mouse, controller).ToArray(); foreach (SButton button in down) - activeButtons[button] = this.DeriveStatus(this.GetStatus(previousStatuses, button), isDown: true); + activeButtons[button] = this.DeriveState(this.GetState(previousStates, button), isDown: true); // handle released keys - foreach (KeyValuePair<SButton, InputStatus> prev in previousStatuses) + foreach (KeyValuePair<SButton, SButtonState> prev in previousStates) { if (prev.Value.IsDown() && !activeButtons.ContainsKey(prev.Key)) - activeButtons[prev.Key] = InputStatus.Released; + activeButtons[prev.Key] = SButtonState.Released; } return activeButtons; } - /// <summary>Get the status of a button relative to its previous status.</summary> - /// <param name="oldStatus">The previous button status.</param> + /// <summary>Get the state of a button relative to its previous state.</summary> + /// <param name="oldState">The previous button state.</param> /// <param name="isDown">Whether the button is currently down.</param> - private InputStatus DeriveStatus(InputStatus oldStatus, bool isDown) + private SButtonState DeriveState(SButtonState oldState, bool isDown) { - if (isDown && oldStatus.IsDown()) - return InputStatus.Held; + if (isDown && oldState.IsDown()) + return SButtonState.Held; if (isDown) - return InputStatus.Pressed; - return InputStatus.Released; + return SButtonState.Pressed; + return SButtonState.Released; } - /// <summary>Get the status of a button.</summary> + /// <summary>Get the state of a button.</summary> /// <param name="activeButtons">The current button states to check.</param> /// <param name="button">The button to check.</param> - private InputStatus GetStatus(IDictionary<SButton, InputStatus> activeButtons, SButton button) + private SButtonState GetState(IDictionary<SButton, SButtonState> activeButtons, SButton button) { - return activeButtons.TryGetValue(button, out InputStatus status) ? status : InputStatus.None; + return activeButtons.TryGetValue(button, out SButtonState state) ? state : SButtonState.None; } /// <summary>Get the buttons pressed in the given stats.</summary> diff --git a/src/SMAPI/Framework/ModHelpers/ContentHelper.cs b/src/SMAPI/Framework/ModHelpers/ContentHelper.cs index 043ae376..e9b70845 100644 --- a/src/SMAPI/Framework/ModHelpers/ContentHelper.cs +++ b/src/SMAPI/Framework/ModHelpers/ContentHelper.cs @@ -32,7 +32,7 @@ namespace StardewModdingAPI.Framework.ModHelpers /// <summary>The friendly mod name for use in errors.</summary> private readonly string ModName; - /// <summary>Encapsulates monitoring and logging for a given module.</summary> + /// <summary>Encapsulates monitoring and logging.</summary> private readonly IMonitor Monitor; @@ -70,9 +70,11 @@ namespace StardewModdingAPI.Framework.ModHelpers public ContentHelper(ContentCoordinator contentCore, string modFolderPath, string modID, string modName, IMonitor monitor) : base(modID) { + string managedAssetPrefix = contentCore.GetManagedAssetPrefix(modID); + this.ContentCore = contentCore; - this.GameContentManager = contentCore.CreateGameContentManager(this.ContentCore.GetManagedAssetPrefix(modID) + ".content"); - this.ModContentManager = contentCore.CreateModContentManager(this.ContentCore.GetManagedAssetPrefix(modID), modFolderPath, this.GameContentManager); + this.GameContentManager = contentCore.CreateGameContentManager(managedAssetPrefix + ".content"); + this.ModContentManager = contentCore.CreateModContentManager(managedAssetPrefix, modName, modFolderPath, this.GameContentManager); this.ModName = modName; this.Monitor = monitor; } diff --git a/src/SMAPI/Framework/ModHelpers/InputHelper.cs b/src/SMAPI/Framework/ModHelpers/InputHelper.cs index f4cd12b6..f8ff0355 100644 --- a/src/SMAPI/Framework/ModHelpers/InputHelper.cs +++ b/src/SMAPI/Framework/ModHelpers/InputHelper.cs @@ -50,5 +50,12 @@ namespace StardewModdingAPI.Framework.ModHelpers { this.InputState.SuppressButtons.Add(button); } + + /// <summary>Get the state of a button.</summary> + /// <param name="button">The button to check.</param> + public SButtonState GetState(SButton button) + { + return this.InputState.GetState(button); + } } } diff --git a/src/SMAPI/Framework/Networking/ModMessageModel.cs b/src/SMAPI/Framework/Networking/ModMessageModel.cs index 7ee39863..4f694f9c 100644 --- a/src/SMAPI/Framework/Networking/ModMessageModel.cs +++ b/src/SMAPI/Framework/Networking/ModMessageModel.cs @@ -21,7 +21,7 @@ namespace StardewModdingAPI.Framework.Networking /**** ** Destination ****/ - /// <summary>The players who should receive the message, or <c>null</c> for all players.</summary> + /// <summary>The players who should receive the message.</summary> public long[] ToPlayerIDs { get; set; } /// <summary>The mods which should receive the message, or <c>null</c> for all mods.</summary> diff --git a/src/SMAPI/Framework/SGame.cs b/src/SMAPI/Framework/SGame.cs index 4b346059..6b9c1365 100644 --- a/src/SMAPI/Framework/SGame.cs +++ b/src/SMAPI/Framework/SGame.cs @@ -635,16 +635,16 @@ namespace StardewModdingAPI.Framework foreach (var pair in inputState.ActiveButtons) { SButton button = pair.Key; - InputStatus status = pair.Value; + SButtonState status = pair.Value; - if (status == InputStatus.Pressed) + if (status == SButtonState.Pressed) { if (this.Monitor.IsVerbose) this.Monitor.Log($"Events: button {button} pressed.", LogLevel.Trace); events.ButtonPressed.Raise(new ButtonPressedEventArgs(button, cursor, inputState)); } - else if (status == InputStatus.Released) + else if (status == SButtonState.Released) { if (this.Monitor.IsVerbose) this.Monitor.Log($"Events: button {button} released.", LogLevel.Trace); @@ -893,6 +893,7 @@ namespace StardewModdingAPI.Framework { var events = this.Events; + Game1.showingHealthBar = false; if (Game1._newDayTask != null) { this.GraphicsDevice.Clear(Game1.bgColor); @@ -934,7 +935,7 @@ namespace StardewModdingAPI.Framework else { this.GraphicsDevice.Clear(Game1.bgColor); - if (Game1.activeClickableMenu != null && Game1.options.showMenuBackground && Game1.activeClickableMenu.showWithoutTransparencyIfOptionIsSet()) + if (Game1.activeClickableMenu != null && Game1.options.showMenuBackground && (Game1.activeClickableMenu.showWithoutTransparencyIfOptionIsSet() && !this.takingMapScreenshot)) { Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointClamp, (DepthStencilState)null, (RasterizerState)null); @@ -1081,6 +1082,7 @@ namespace StardewModdingAPI.Framework { byte batchOpens = 0; // used for rendering event + Microsoft.Xna.Framework.Rectangle rectangle; Viewport viewport; if (Game1.gameMode == (byte)0) { @@ -1097,21 +1099,20 @@ namespace StardewModdingAPI.Framework Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.NonPremultiplied, SamplerState.PointClamp, (DepthStencilState)null, (RasterizerState)null); if (++batchOpens == 1) events.Rendering.RaiseEmpty(); - 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); + 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) + foreach (LightSource currentLightSource in Game1.currentLightSources) { - LightSource lightSource = Game1.currentLightSources.ElementAt<LightSource>(index); - if (!Game1.isRaining && !Game1.isDarkOut() || lightSource.lightContext.Value != LightSource.LightContext.WindowLight) + if (!Game1.isRaining && !Game1.isDarkOut() || currentLightSource.lightContext.Value != LightSource.LightContext.WindowLight) { - if (lightSource.PlayerID != 0L && lightSource.PlayerID != Game1.player.UniqueMultiplayerID) + if (currentLightSource.PlayerID != 0L && currentLightSource.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)) + Farmer farmerMaybeOffline = Game1.getFarmerMaybeOffline(currentLightSource.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); + if (Utility.isOnScreen((Vector2)(NetFieldBase<Vector2, NetVector2>)currentLightSource.position, (int)((double)(float)(NetFieldBase<float, NetFloat>)currentLightSource.radius * 64.0 * 4.0))) + Game1.spriteBatch.Draw(currentLightSource.lightTexture, Game1.GlobalToLocal(Game1.viewport, (Vector2)(NetFieldBase<Vector2, NetVector2>)currentLightSource.position) / (float)(Game1.options.lightingQuality / 2), new Microsoft.Xna.Framework.Rectangle?(currentLightSource.lightTexture.Bounds), (Microsoft.Xna.Framework.Color)(NetFieldBase<Microsoft.Xna.Framework.Color, NetColor>)currentLightSource.color, 0.0f, new Vector2((float)currentLightSource.lightTexture.Bounds.Center.X, (float)currentLightSource.lightTexture.Bounds.Center.Y), (float)(NetFieldBase<float, NetFloat>)currentLightSource.radius / (float)(Game1.options.lightingQuality / 2), SpriteEffects.None, 0.9f); } } Game1.spriteBatch.End(); @@ -1134,7 +1135,7 @@ namespace StardewModdingAPI.Framework { foreach (Farmer farmerActor in Game1.currentLocation.currentEvent.farmerActors) { - if (farmerActor.IsLocalPlayer && Game1.displayFarmer || !(bool)((NetFieldBase<bool, NetBool>)farmerActor.hidden)) + if (farmerActor.IsLocalPlayer && Game1.displayFarmer || !(bool)(NetFieldBase<bool, NetBool>)farmerActor.hidden) this._farmerShadows.Add(farmerActor); } } @@ -1142,7 +1143,7 @@ namespace StardewModdingAPI.Framework { foreach (Farmer farmer in Game1.currentLocation.farmers) { - if (farmer.IsLocalPlayer && Game1.displayFarmer || !(bool)((NetFieldBase<bool, NetBool>)farmer.hidden)) + if (farmer.IsLocalPlayer && Game1.displayFarmer || !(bool)(NetFieldBase<bool, NetBool>)farmer.hidden) this._farmerShadows.Add(farmer); } } @@ -1152,26 +1153,39 @@ 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), 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); + 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), 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 { 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), 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); + 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), 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 (!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); + if (!Game1.multiplayer.isDisconnecting(farmerShadow.UniqueMultiplayerID) && !(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); + Microsoft.Xna.Framework.Color white = Microsoft.Xna.Framework.Color.White; + 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 num = 4.0 - (!farmerShadow.running && !farmerShadow.UsingTool || farmerShadow.FarmerSprite.currentAnimationIndex <= 1 ? 0.0 : (double)Math.Abs(FarmerRenderer.featureYOffsetPerFrame[farmerShadow.FarmerSprite.CurrentFrame]) * 0.5); + spriteBatch.Draw(shadowTexture, local, sourceRectangle, white, 0.0f, origin, (float)num, SpriteEffects.None, 0.0f); + } } } - Layer layer = Game1.currentLocation.Map.GetLayer("Buildings"); - layer.Draw(Game1.mapDisplayDevice, Game1.viewport, Location.Origin, false, 4); + Layer layer1 = Game1.currentLocation.Map.GetLayer("Buildings"); + layer1.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); @@ -1181,23 +1195,37 @@ namespace StardewModdingAPI.Framework { foreach (NPC character in Game1.currentLocation.characters) { - 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); + 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 { 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), 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); + 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), 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()))) - 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); + float num1 = 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); + Microsoft.Xna.Framework.Color white = Microsoft.Xna.Framework.Color.White; + 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); + double num3 = (double)num1; + spriteBatch.Draw(shadowTexture, local, sourceRectangle, white, 0.0f, origin, (float)num2, SpriteEffects.None, (float)num3); + } } } if ((Game1.eventUp || Game1.killScreen) && (!Game1.killScreen && Game1.currentLocation.currentEvent != null)) @@ -1207,7 +1235,7 @@ namespace StardewModdingAPI.Framework Game1.currentLocation.draw(Game1.spriteBatch); foreach (Vector2 key in Game1.crabPotOverlayTiles.Keys) { - Tile tile = layer.Tiles[(int)key.X, (int)key.Y]; + Tile tile = layer1.Tiles[(int)key.X, (int)key.Y]; if (tile != null) { Vector2 local = Game1.GlobalToLocal(Game1.viewport, key * 64f); @@ -1237,10 +1265,31 @@ namespace StardewModdingAPI.Framework Game1.currentLocation.drawAboveFrontLayer(Game1.spriteBatch); Game1.spriteBatch.End(); 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) + 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 && (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"))) + 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 layer2 = Game1.currentLocation.Map.GetLayer("Front"); + rectangle = Game1.player.GetBoundingBox(); + Location mapDisplayLocation1 = new Location(rectangle.Right, (int)Game1.player.Position.Y - 38); + xTile.Dimensions.Size size1 = Game1.viewport.Size; + if (layer2.PickTile(mapDisplayLocation1, size1) != null) + { + Layer layer3 = Game1.currentLocation.Map.GetLayer("Front"); + rectangle = Game1.player.GetBoundingBox(); + Location mapDisplayLocation2 = new Location(rectangle.Right, (int)Game1.player.Position.Y - 38); + xTile.Dimensions.Size size2 = Game1.viewport.Size; + if (layer3.PickTile(mapDisplayLocation2, size2).TileIndexProperties.ContainsKey("FrontAlways")) + goto label_139; + } + else + goto label_139; + } Game1.drawPlayerHeldObject(Game1.player); + } + label_139: 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) @@ -1274,23 +1323,9 @@ namespace StardewModdingAPI.Framework if (Game1.farmEvent != null) Game1.farmEvent.draw(Game1.spriteBatch); if ((double)Game1.currentLocation.LightLevel > 0.0 && Game1.timeOfDay < 2000) - { - SpriteBatch spriteBatch = Game1.spriteBatch; - Texture2D fadeToBlackRect = Game1.fadeToBlackRect; - viewport = Game1.graphics.GraphicsDevice.Viewport; - Microsoft.Xna.Framework.Rectangle bounds = viewport.Bounds; - Microsoft.Xna.Framework.Color color = Microsoft.Xna.Framework.Color.Black * Game1.currentLocation.LightLevel; - spriteBatch.Draw(fadeToBlackRect, bounds, color); - } + Game1.spriteBatch.Draw(Game1.fadeToBlackRect, Game1.graphics.GraphicsDevice.Viewport.Bounds, Microsoft.Xna.Framework.Color.Black * Game1.currentLocation.LightLevel); if (Game1.screenGlow) - { - SpriteBatch spriteBatch = Game1.spriteBatch; - Texture2D fadeToBlackRect = Game1.fadeToBlackRect; - viewport = Game1.graphics.GraphicsDevice.Viewport; - Microsoft.Xna.Framework.Rectangle bounds = viewport.Bounds; - Microsoft.Xna.Framework.Color color = Game1.screenGlowColor * Game1.screenGlowAlpha; - spriteBatch.Draw(fadeToBlackRect, bounds, color); - } + Game1.spriteBatch.Draw(Game1.fadeToBlackRect, Game1.graphics.GraphicsDevice.Viewport.Bounds, Game1.screenGlowColor * Game1.screenGlowAlpha); 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); @@ -1317,15 +1352,8 @@ namespace StardewModdingAPI.Framework { 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), 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; - Microsoft.Xna.Framework.Color color = Microsoft.Xna.Framework.Color.OrangeRed * 0.45f; - spriteBatch.Draw(staminaRect, bounds, color); - } + if (Game1.isRaining && (bool)(NetFieldBase<bool, NetBool>)Game1.currentLocation.isOutdoors && !(Game1.currentLocation is Desert)) + Game1.spriteBatch.Draw(Game1.staminaRect, Game1.graphics.GraphicsDevice.Viewport.Bounds, Microsoft.Xna.Framework.Color.OrangeRed * 0.45f); Game1.spriteBatch.End(); } Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointClamp, (DepthStencilState)null, (RasterizerState)null); @@ -1424,12 +1452,28 @@ namespace StardewModdingAPI.Framework this.drawDialogueBox(); if (Game1.progressBar && !this.takingMapScreenshot) { - 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); + 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; + Microsoft.Xna.Framework.Rectangle destinationRectangle1 = new Microsoft.Xna.Framework.Rectangle(x1, y1, dialogueWidth, 32); + Microsoft.Xna.Framework.Color lightGray = Microsoft.Xna.Framework.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); + Microsoft.Xna.Framework.Rectangle destinationRectangle2 = new Microsoft.Xna.Framework.Rectangle(x2, y2, width, 32); + Microsoft.Xna.Framework.Color dimGray = Microsoft.Xna.Framework.Color.DimGray; + spriteBatch2.Draw(staminaRect, destinationRectangle2, dimGray); } if (Game1.eventUp && Game1.currentLocation != null && Game1.currentLocation.currentEvent != null) Game1.currentLocation.currentEvent.drawAfterMap(Game1.spriteBatch); - if (Game1.isRaining && Game1.currentLocation != null && ((bool)((NetFieldBase<bool, NetBool>)Game1.currentLocation.isOutdoors) && !(Game1.currentLocation is Desert))) + if (Game1.isRaining && Game1.currentLocation != null && ((bool)(NetFieldBase<bool, NetBool>)Game1.currentLocation.isOutdoors && !(Game1.currentLocation is Desert))) { SpriteBatch spriteBatch = Game1.spriteBatch; Texture2D staminaRect = Game1.staminaRect; diff --git a/src/SMAPI/Framework/SMultiplayer.cs b/src/SMAPI/Framework/SMultiplayer.cs index e04205c8..821c343f 100644 --- a/src/SMAPI/Framework/SMultiplayer.cs +++ b/src/SMAPI/Framework/SMultiplayer.cs @@ -338,64 +338,94 @@ namespace StardewModdingAPI.Framework /// <param name="toPlayerIDs">The <see cref="Farmer.UniqueMultiplayerID" /> values for the players who should receive the message, or <c>null</c> for all players. If you don't need to broadcast to all players, specifying player IDs is recommended to reduce latency.</param> public void BroadcastModMessage<TMessage>(TMessage message, string messageType, string fromModID, string[] toModIDs, long[] toPlayerIDs) { - // validate + // validate input if (message == null) throw new ArgumentNullException(nameof(message)); if (string.IsNullOrWhiteSpace(messageType)) throw new ArgumentNullException(nameof(messageType)); if (string.IsNullOrWhiteSpace(fromModID)) throw new ArgumentNullException(nameof(fromModID)); - if (!this.Peers.Any()) + + // get target players + long curPlayerId = Game1.player.UniqueMultiplayerID; + bool sendToSelf = false; + List<MultiplayerPeer> sendToPeers = new List<MultiplayerPeer>(); + if (toPlayerIDs == null) { - this.Monitor.VerboseLog($"Ignored '{messageType}' broadcast from mod {fromModID}: not connected to any players."); - return; + sendToSelf = true; + sendToPeers.AddRange(this.Peers.Values); } - - // filter player IDs - HashSet<long> playerIDs = null; - if (toPlayerIDs != null && toPlayerIDs.Any()) + else { - playerIDs = new HashSet<long>(toPlayerIDs); - playerIDs.RemoveWhere(id => !this.Peers.ContainsKey(id)); - if (!playerIDs.Any()) + foreach (long id in toPlayerIDs.Distinct()) { - this.Monitor.VerboseLog($"Ignored '{messageType}' broadcast from mod {fromModID}: none of the specified player IDs are connected."); - return; + if (id == curPlayerId) + sendToSelf = true; + else if (this.Peers.TryGetValue(id, out MultiplayerPeer peer) && peer.HasSmapi) + sendToPeers.Add(peer); } } + // filter by mod ID + if (toModIDs != null) + { + HashSet<string> sendToMods = new HashSet<string>(toModIDs, StringComparer.InvariantCultureIgnoreCase); + if (sendToSelf && toModIDs.All(id => this.ModRegistry.Get(id) == null)) + sendToSelf = false; + + sendToPeers.RemoveAll(peer => peer.Mods.All(mod => !sendToMods.Contains(mod.ID))); + } + + // validate recipients + if (!sendToSelf && !sendToPeers.Any()) + { + this.Monitor.VerboseLog($"Ignored '{messageType}' broadcast from mod {fromModID}: none of the specified player IDs can receive this message."); + return; + } + // get data to send ModMessageModel model = new ModMessageModel( fromPlayerID: Game1.player.UniqueMultiplayerID, fromModID: fromModID, toModIDs: toModIDs, - toPlayerIDs: playerIDs?.ToArray(), + toPlayerIDs: sendToPeers.Select(p => p.PlayerID).ToArray(), type: messageType, data: JToken.FromObject(message) ); string data = JsonConvert.SerializeObject(model, Formatting.None); - // log message - if (this.LogNetworkTraffic) - this.Monitor.Log($"Broadcasting '{messageType}' message: {data}.", LogLevel.Trace); + // send self-message + if (sendToSelf) + { + if (this.LogNetworkTraffic) + this.Monitor.Log($"Broadcasting '{messageType}' message to self: {data}.", LogLevel.Trace); + + this.OnModMessageReceived(model); + } - // send message - if (Context.IsMainPlayer) + // send message to peers + if (sendToPeers.Any()) { - foreach (MultiplayerPeer peer in this.Peers.Values) + if (Context.IsMainPlayer) { - if (playerIDs == null || playerIDs.Contains(peer.PlayerID)) + foreach (MultiplayerPeer peer in sendToPeers) { - model.ToPlayerIDs = new[] { peer.PlayerID }; + if (this.LogNetworkTraffic) + this.Monitor.Log($"Broadcasting '{messageType}' message to farmhand {peer.PlayerID}: {data}.", LogLevel.Trace); + peer.SendMessage(new OutgoingMessage((byte)MessageType.ModMessage, peer.PlayerID, data)); } } - } - else if (this.HostPeer != null && this.HostPeer.HasSmapi) - this.HostPeer.SendMessage(new OutgoingMessage((byte)MessageType.ModMessage, this.HostPeer.PlayerID, data)); - else - this.Monitor.VerboseLog(" Can't send message because no valid connections were found."); + else if (this.HostPeer?.HasSmapi == true) + { + if (this.LogNetworkTraffic) + this.Monitor.Log($"Broadcasting '{messageType}' message to host {this.HostPeer.PlayerID}: {data}.", LogLevel.Trace); + this.HostPeer.SendMessage(new OutgoingMessage((byte)MessageType.ModMessage, this.HostPeer.PlayerID, data)); + } + else + this.Monitor.VerboseLog(" Can't send message because no valid connections were found."); + } } diff --git a/src/SMAPI/IAssetDataForImage.cs b/src/SMAPI/IAssetDataForImage.cs index 1109194f..27ed9267 100644 --- a/src/SMAPI/IAssetDataForImage.cs +++ b/src/SMAPI/IAssetDataForImage.cs @@ -19,5 +19,11 @@ namespace StardewModdingAPI /// <exception cref="ArgumentOutOfRangeException">The <paramref name="targetArea"/> is outside the bounds of the spritesheet.</exception> /// <exception cref="InvalidOperationException">The content being read isn't an image.</exception> void PatchImage(Texture2D source, Rectangle? sourceArea = null, Rectangle? targetArea = null, PatchMode patchMode = PatchMode.Replace); + + /// <summary>Extend the image if needed to fit the given size. Note that this is an expensive operation, creates a new texture instance, and that extending a spritesheet horizontally may cause game errors or bugs.</summary> + /// <param name="minWidth">The minimum texture width.</param> + /// <param name="minHeight">The minimum texture height.</param> + /// <returns>Whether the texture was resized.</returns> + bool ExtendImage(int minWidth, int minHeight); } } diff --git a/src/SMAPI/IInputHelper.cs b/src/SMAPI/IInputHelper.cs index 328f504b..e9768c24 100644 --- a/src/SMAPI/IInputHelper.cs +++ b/src/SMAPI/IInputHelper.cs @@ -17,5 +17,9 @@ namespace StardewModdingAPI /// <summary>Prevent the game from handling a button press. This doesn't prevent other mods from receiving the event.</summary> /// <param name="button">The button to suppress.</param> void Suppress(SButton button); + + /// <summary>Get the state of a button.</summary> + /// <param name="button">The button to check.</param> + SButtonState GetState(SButton button); } } diff --git a/src/SMAPI/Metadata/CoreAssetPropagator.cs b/src/SMAPI/Metadata/CoreAssetPropagator.cs index 7a58d52c..8d5ad3ab 100644 --- a/src/SMAPI/Metadata/CoreAssetPropagator.cs +++ b/src/SMAPI/Metadata/CoreAssetPropagator.cs @@ -886,10 +886,17 @@ namespace StardewModdingAPI.Metadata return false; // update dialogue + // Note that marriage dialogue isn't reloaded after reset, but it doesn't need to be + // propagated anyway since marriage dialogue keys can't be added/removed and the field + // doesn't store the text itself. foreach (NPC villager in villagers) { + MarriageDialogueReference[] marriageDialogue = villager.currentMarriageDialogue.ToArray(); + villager.resetSeasonalDialogue(); // doesn't only affect seasonal dialogue villager.resetCurrentDialogue(); + + villager.currentMarriageDialogue.Set(marriageDialogue); } return true; diff --git a/src/SMAPI/Framework/Input/InputStatus.cs b/src/SMAPI/SButtonState.cs index 99b0006c..2b78da27 100644 --- a/src/SMAPI/Framework/Input/InputStatus.cs +++ b/src/SMAPI/SButtonState.cs @@ -1,7 +1,7 @@ -namespace StardewModdingAPI.Framework.Input +namespace StardewModdingAPI { - /// <summary>The input status for a button during an update frame.</summary> - internal enum InputStatus + /// <summary>The input state for a button during an update frame.</summary> + public enum SButtonState { /// <summary>The button was neither pressed, held, nor released.</summary> None, @@ -16,14 +16,14 @@ namespace StardewModdingAPI.Framework.Input Released } - /// <summary>Extension methods for <see cref="InputStatus"/>.</summary> + /// <summary>Extension methods for <see cref="SButtonState"/>.</summary> internal static class InputStatusExtensions { /// <summary>Whether the button was pressed or held.</summary> - /// <param name="status">The button status.</param> - public static bool IsDown(this InputStatus status) + /// <param name="state">The button state.</param> + public static bool IsDown(this SButtonState state) { - return status == InputStatus.Held || status == InputStatus.Pressed; + return state == SButtonState.Held || state == SButtonState.Pressed; } } } diff --git a/src/SMAPI/SMAPI.csproj b/src/SMAPI/SMAPI.csproj index c5d0f247..c17f13c4 100644 --- a/src/SMAPI/SMAPI.csproj +++ b/src/SMAPI/SMAPI.csproj @@ -15,9 +15,9 @@ </PropertyGroup> <ItemGroup> - <PackageReference Include="LargeAddressAware" Version="1.0.3" /> + <PackageReference Include="LargeAddressAware" Version="1.0.4" /> <PackageReference Include="Lib.Harmony" Version="1.2.0.1" /> - <PackageReference Include="Mono.Cecil" Version="0.11.1" /> + <PackageReference Include="Mono.Cecil" Version="0.11.2" /> <PackageReference Include="Newtonsoft.Json" Version="12.0.3" /> <PackageReference Include="Platonymous.TMXTile" Version="1.0.2" /> </ItemGroup> diff --git a/src/SMAPI/i18n/it.json b/src/SMAPI/i18n/it.json new file mode 100644 index 00000000..43493018 --- /dev/null +++ b/src/SMAPI/i18n/it.json @@ -0,0 +1,3 @@ +{ + "warn.invalid-content-removed": "Contenuto non valido rimosso per prevenire un crash (Guarda la console di SMAPI per maggiori informazioni)." +} |