Merge branch 'develop' into stable
This commit is contained in:
commit
66079f2253
|
@ -4,7 +4,7 @@
|
|||
|
||||
<!--set properties -->
|
||||
<PropertyGroup>
|
||||
<Version>3.2.0</Version>
|
||||
<Version>3.3.0</Version>
|
||||
<Product>SMAPI</Product>
|
||||
|
||||
<AssemblySearchPaths>$(AssemblySearchPaths);{GAC}</AssemblySearchPaths>
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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'" />
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -7,6 +7,13 @@ namespace StardewModdingAPI.Toolkit.Serialization.Converters
|
|||
/// <summary>Handles deserialization of <see cref="ISemanticVersion"/>.</summary>
|
||||
internal class SemanticVersionConverter : JsonConverter
|
||||
{
|
||||
/*********
|
||||
** Fields
|
||||
*********/
|
||||
/// <summary>Whether to allow non-standard extensions to semantic versioning.</summary>
|
||||
protected bool AllowNonStandard { get; set; }
|
||||
|
||||
|
||||
/*********
|
||||
** Accessors
|
||||
*********/
|
||||
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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; }
|
||||
}
|
||||
}
|
|
@ -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; }
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 _);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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" />
|
||||
|
|
|
@ -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 });
|
||||
|
|
|
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
};
|
||||
|
|
|
@ -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": {
|
||||
|
|
|
@ -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."
|
||||
|
|
|
@ -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.3.36":
|
||||
return new SemanticVersion(2, 11, 2);
|
||||
case "1.4.1":
|
||||
case "1.4.0":
|
||||
return new SemanticVersion("3.0.1");
|
||||
|
||||
case "1.3.36":
|
||||
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");
|
||||
|
||||
return null;
|
||||
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");
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Get metadata for mapping assemblies to the current platform.</summary>
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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)
|
||||
{
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw new ContentLoadException($"The '{imageSource}' tilesheet couldn't be loaded relative to either map file or the game's content folder.", ex);
|
||||
}
|
||||
if (!this.TryGetTilesheetAssetName(relativeMapFolder, imageSource, isOutdoors, out string assetName, out string error))
|
||||
throw new SContentLoadException($"{errorPrefix} {error}");
|
||||
|
||||
// none found
|
||||
throw new ContentLoadException($"The '{imageSource}' tilesheet couldn't be loaded relative to either map file or the game's content folder.");
|
||||
tilesheet.ImageSource = assetName;
|
||||
}
|
||||
catch (Exception ex) when (!(ex is SContentLoadException))
|
||||
{
|
||||
throw new SContentLoadException($"{errorPrefix} The tilesheet couldn't be loaded.", ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <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;
|
||||
|
||||
// check relative to map file
|
||||
// nothing to do
|
||||
if (string.IsNullOrWhiteSpace(originalPath))
|
||||
{
|
||||
string localKey = Path.Combine(modRelativeMapFolder, imageSource);
|
||||
FileInfo localFile = this.GetModFile(localKey);
|
||||
if (localFile.Exists)
|
||||
return this.GetInternalAssetKey(localKey);
|
||||
assetName = originalPath;
|
||||
return true;
|
||||
}
|
||||
|
||||
// check relative to content folder
|
||||
// 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)
|
||||
{
|
||||
foreach (string candidateKey in new[] { imageSource, Path.Combine("Maps", imageSource) })
|
||||
{
|
||||
string contentKey = candidateKey.EndsWith(".png")
|
||||
? candidateKey.Substring(0, candidateKey.Length - 4)
|
||||
: candidateKey;
|
||||
string dirPath = Path.GetDirectoryName(originalPath);
|
||||
relativePath = Path.Combine(dirPath, $"{Game1.currentSeason}_{filename.Substring(filename.IndexOf("_", StringComparison.CurrentCultureIgnoreCase) + 1)}");
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
// get relative to map file
|
||||
{
|
||||
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
|
||||
{
|
||||
// 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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -338,30 +338,49 @@ 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);
|
||||
}
|
||||
else
|
||||
{
|
||||
foreach (long id in toPlayerIDs.Distinct())
|
||||
{
|
||||
if (id == curPlayerId)
|
||||
sendToSelf = true;
|
||||
else if (this.Peers.TryGetValue(id, out MultiplayerPeer peer) && peer.HasSmapi)
|
||||
sendToPeers.Add(peer);
|
||||
}
|
||||
}
|
||||
|
||||
// filter player IDs
|
||||
HashSet<long> playerIDs = null;
|
||||
if (toPlayerIDs != null && toPlayerIDs.Any())
|
||||
// filter by mod ID
|
||||
if (toModIDs != null)
|
||||
{
|
||||
playerIDs = new HashSet<long>(toPlayerIDs);
|
||||
playerIDs.RemoveWhere(id => !this.Peers.ContainsKey(id));
|
||||
if (!playerIDs.Any())
|
||||
{
|
||||
this.Monitor.VerboseLog($"Ignored '{messageType}' broadcast from mod {fromModID}: none of the specified player IDs are connected.");
|
||||
return;
|
||||
}
|
||||
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
|
||||
|
@ -369,33 +388,44 @@ namespace StardewModdingAPI.Framework
|
|||
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 message
|
||||
if (Context.IsMainPlayer)
|
||||
// send self-message
|
||||
if (sendToSelf)
|
||||
{
|
||||
foreach (MultiplayerPeer peer in this.Peers.Values)
|
||||
if (this.LogNetworkTraffic)
|
||||
this.Monitor.Log($"Broadcasting '{messageType}' message to self: {data}.", LogLevel.Trace);
|
||||
|
||||
this.OnModMessageReceived(model);
|
||||
}
|
||||
|
||||
// send message to peers
|
||||
if (sendToPeers.Any())
|
||||
{
|
||||
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.");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
|
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"warn.invalid-content-removed": "Contenuto non valido rimosso per prevenire un crash (Guarda la console di SMAPI per maggiori informazioni)."
|
||||
}
|
Loading…
Reference in New Issue