summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/SMAPI.Installer/Framework/InstallerContext.cs103
-rw-r--r--src/SMAPI.Installer/InteractiveInstaller.cs124
-rw-r--r--src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/ClearCommand.cs22
-rw-r--r--src/SMAPI.Mods.ConsoleCommands/Framework/ItemRepository.cs7
-rw-r--r--src/SMAPI.Mods.ConsoleCommands/manifest.json4
-rw-r--r--src/SMAPI.Mods.SaveBackup/manifest.json4
-rw-r--r--src/SMAPI.Toolkit/Framework/GameScanning/GameScanner.cs48
-rw-r--r--src/SMAPI.Toolkit/Framework/ModScanning/ModScanner.cs10
-rw-r--r--src/SMAPI.Web/Framework/LogParsing/LogMessageBuilder.cs8
-rw-r--r--src/SMAPI.Web/Framework/LogParsing/LogParser.cs4
-rw-r--r--src/SMAPI.Web/Framework/LogParsing/Models/LogMessage.cs3
-rw-r--r--src/SMAPI.Web/Views/LogParser/Index.cshtml11
-rw-r--r--src/SMAPI.Web/wwwroot/SMAPI.metadata.json88
-rw-r--r--src/SMAPI.Web/wwwroot/schemas/content-patcher.json37
-rw-r--r--src/SMAPI/Constants.cs27
-rw-r--r--src/SMAPI/Context.cs79
-rw-r--r--src/SMAPI/Framework/ContentManagers/GameContentManager.cs76
-rw-r--r--src/SMAPI/Framework/ContentManagers/ModContentManager.cs130
-rw-r--r--src/SMAPI/Framework/Input/SInputState.cs17
-rw-r--r--src/SMAPI/Framework/InternalExtensions.cs18
-rw-r--r--src/SMAPI/Framework/Logging/LogManager.cs9
-rw-r--r--src/SMAPI/Framework/ModHelpers/DataHelper.cs8
-rw-r--r--src/SMAPI/Framework/ModHelpers/InputHelper.cs21
-rw-r--r--src/SMAPI/Framework/ModHelpers/ModHelper.cs6
-rw-r--r--src/SMAPI/Framework/Monitor.cs11
-rw-r--r--src/SMAPI/Framework/SCore.cs358
-rw-r--r--src/SMAPI/Framework/SGame.cs431
-rw-r--r--src/SMAPI/Framework/SGameRunner.cs156
-rw-r--r--src/SMAPI/Metadata/CoreAssetPropagator.cs104
-rw-r--r--src/SMAPI/Metadata/InstructionMetadata.cs6
-rw-r--r--src/SMAPI/Patches/DialogueErrorPatch.cs4
-rw-r--r--src/SMAPI/Patches/ScheduleErrorPatch.cs2
-rw-r--r--src/SMAPI/Utilities/PerScreen.cs79
33 files changed, 1289 insertions, 726 deletions
diff --git a/src/SMAPI.Installer/Framework/InstallerContext.cs b/src/SMAPI.Installer/Framework/InstallerContext.cs
new file mode 100644
index 00000000..7531eaee
--- /dev/null
+++ b/src/SMAPI.Installer/Framework/InstallerContext.cs
@@ -0,0 +1,103 @@
+using System;
+using System.IO;
+using Microsoft.Win32;
+using StardewModdingAPI.Toolkit;
+using StardewModdingAPI.Toolkit.Framework.GameScanning;
+using StardewModdingAPI.Toolkit.Utilities;
+
+namespace StardewModdingAPI.Installer.Framework
+{
+ /// <summary>The installer context.</summary>
+ internal class InstallerContext
+ {
+ /*********
+ ** Fields
+ *********/
+ /// <summary>The <see cref="Environment.OSVersion"/> value that represents Windows 7.</summary>
+ private readonly Version Windows7Version = new Version(6, 1);
+
+ /// <summary>The underlying toolkit game scanner.</summary>
+ private readonly GameScanner GameScanner = new GameScanner();
+
+
+ /*********
+ ** Accessors
+ *********/
+ /// <summary>The current OS.</summary>
+ public Platform Platform { get; }
+
+ /// <summary>The human-readable OS name and version.</summary>
+ public string PlatformName { get; }
+
+ /// <summary>The name of the Stardew Valley executable.</summary>
+ public string ExecutableName { get; }
+
+ /// <summary>Whether the installer is running on Windows.</summary>
+ public bool IsWindows => this.Platform == Platform.Windows;
+
+ /// <summary>Whether the installer is running on a Unix OS (including Linux or MacOS).</summary>
+ public bool IsUnix => !this.IsWindows;
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Construct an instance.</summary>
+ public InstallerContext()
+ {
+ this.Platform = EnvironmentUtility.DetectPlatform();
+ this.PlatformName = EnvironmentUtility.GetFriendlyPlatformName(this.Platform);
+ this.ExecutableName = EnvironmentUtility.GetExecutableName(this.Platform);
+ }
+
+ /// <summary>Get the installer's version number.</summary>
+ public ISemanticVersion GetInstallerVersion()
+ {
+ var raw = this.GetType().Assembly.GetName().Version;
+ return new SemanticVersion(raw);
+ }
+
+ /// <summary>Get whether the current system has .NET Framework 4.5 or later installed. This only applies on Windows.</summary>
+ /// <exception cref="NotSupportedException">The current platform is not Windows.</exception>
+ public bool HasNetFramework45()
+ {
+ switch (this.Platform)
+ {
+ case Platform.Windows:
+ using (RegistryKey versionKey = RegistryKey.OpenBaseKey(RegistryHive.LocalMachine, RegistryView.Registry32).OpenSubKey(@"SOFTWARE\Microsoft\NET Framework Setup\NDP\v4\Full"))
+ return versionKey?.GetValue("Release") != null; // .NET Framework 4.5+
+
+ default:
+ throw new NotSupportedException("The installed .NET Framework version can only be checked on Windows.");
+ }
+ }
+
+ /// <summary>Get whether the current system has XNA Framework installed. This only applies on Windows.</summary>
+ /// <exception cref="NotSupportedException">The current platform is not Windows.</exception>
+ public bool HasXna()
+ {
+ switch (this.Platform)
+ {
+ case Platform.Windows:
+ using (RegistryKey key = RegistryKey.OpenBaseKey(RegistryHive.LocalMachine, RegistryView.Registry32).OpenSubKey(@"SOFTWARE\Microsoft\XNA\Framework"))
+ return key != null; // XNA Framework 4.0+
+
+ default:
+ throw new NotSupportedException("The installed XNA Framework version can only be checked on Windows.");
+ }
+ }
+
+ /// <summary>Whether the current OS supports newer versions of .NET Framework.</summary>
+ public bool CanInstallLatestNetFramework()
+ {
+ return Environment.OSVersion.Version >= this.Windows7Version; // Windows 7+
+ }
+
+ /// <summary>Get whether a folder seems to contain the game files.</summary>
+ /// <param name="dir">The folder to check.</param>
+ public bool LooksLikeGameFolder(DirectoryInfo dir)
+ {
+ return this.GameScanner.LooksLikeGameFolder(dir);
+ }
+ }
+}
diff --git a/src/SMAPI.Installer/InteractiveInstaller.cs b/src/SMAPI.Installer/InteractiveInstaller.cs
index d0ef0b8d..035ec382 100644
--- a/src/SMAPI.Installer/InteractiveInstaller.cs
+++ b/src/SMAPI.Installer/InteractiveInstaller.cs
@@ -3,7 +3,7 @@ using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
-using Microsoft.Win32;
+using System.Reflection;
using StardewModdingApi.Installer.Enums;
using StardewModdingAPI.Installer.Framework;
using StardewModdingAPI.Internal.ConsoleWriting;
@@ -25,12 +25,8 @@ namespace StardewModdingApi.Installer
/// <summary>The absolute path to the directory containing the files to copy into the game folder.</summary>
private readonly string BundlePath;
- /// <summary>The <see cref="Environment.OSVersion"/> value that represents Windows 7.</summary>
- private readonly Version Windows7Version = new Version(6, 1);
-
/// <summary>The mod IDs which the installer should allow as bundled mods.</summary>
- private readonly string[] BundledModIDs = new[]
- {
+ private readonly string[] BundledModIDs = {
"SMAPI.SaveBackup",
"SMAPI.ConsoleCommands"
};
@@ -129,22 +125,22 @@ namespace StardewModdingApi.Installer
** Get basic info & set window title
****/
ModToolkit toolkit = new ModToolkit();
- Platform platform = EnvironmentUtility.DetectPlatform();
- Console.Title = $"SMAPI {this.GetDisplayVersion(this.GetType().Assembly.GetName().Version)} installer on {platform} {EnvironmentUtility.GetFriendlyPlatformName(platform)}";
+ var context = new InstallerContext();
+ Console.Title = $"SMAPI {context.GetInstallerVersion()} installer on {context.Platform} {context.PlatformName}";
Console.WriteLine();
/****
** Check if correct installer
****/
#if SMAPI_FOR_WINDOWS
- if (platform == Platform.Linux || platform == Platform.Mac)
+ if (context.IsUnix)
{
- this.PrintError($"This is the installer for Windows. Run the 'install on {platform}.{(platform == Platform.Linux ? "sh" : "command")}' file instead.");
+ this.PrintError($"This is the installer for Windows. Run the 'install on {context.Platform}.{(context.Platform == Platform.Mac ? "command" : "sh")}' file instead.");
Console.ReadLine();
return;
}
#else
- if (platform == Platform.Windows)
+ if (context.IsWindows)
{
this.PrintError($"This is the installer for Linux/Mac. Run the 'install on Windows.exe' file instead.");
Console.ReadLine();
@@ -155,20 +151,20 @@ namespace StardewModdingApi.Installer
/****
** Check Windows dependencies
****/
- if (platform == Platform.Windows)
+ if (context.IsWindows)
{
// .NET Framework 4.5+
- if (!this.HasNetFramework45(platform))
+ if (!context.HasNetFramework45())
{
- this.PrintError(Environment.OSVersion.Version >= this.Windows7Version
- ? "Please install the latest version of .NET Framework before installing SMAPI." // Windows 7+
- : "Please install .NET Framework 4.5 before installing SMAPI." // Windows Vista or earlier
+ this.PrintError(context.CanInstallLatestNetFramework()
+ ? "Please install the latest version of .NET Framework before installing SMAPI."
+ : "Please install .NET Framework 4.5 before installing SMAPI."
);
this.PrintError("See the download page at https://www.microsoft.com/net/download/framework for details.");
Console.ReadLine();
return;
}
- if (!this.HasXna(platform))
+ if (!context.HasXna())
{
this.PrintError("You don't seem to have XNA Framework installed. Please run the game at least once before installing SMAPI, so it can perform its first-time setup.");
Console.ReadLine();
@@ -202,7 +198,7 @@ namespace StardewModdingApi.Installer
** Step 2: choose a theme (can't auto-detect on Linux/Mac)
*********/
MonitorColorScheme scheme = MonitorColorScheme.AutoDetect;
- if (platform == Platform.Linux || platform == Platform.Mac)
+ if (context.IsUnix)
{
/****
** print header
@@ -215,8 +211,8 @@ namespace StardewModdingApi.Installer
** show theme selector
****/
// get theme writers
- var lightBackgroundWriter = new ColorfulConsoleWriter(platform, ColorfulConsoleWriter.GetDefaultColorSchemeConfig(MonitorColorScheme.LightBackground));
- var darkBackgroundWriter = new ColorfulConsoleWriter(platform, ColorfulConsoleWriter.GetDefaultColorSchemeConfig(MonitorColorScheme.DarkBackground));
+ var lightBackgroundWriter = new ColorfulConsoleWriter(context.Platform, ColorfulConsoleWriter.GetDefaultColorSchemeConfig(MonitorColorScheme.LightBackground));
+ var darkBackgroundWriter = new ColorfulConsoleWriter(context.Platform, ColorfulConsoleWriter.GetDefaultColorSchemeConfig(MonitorColorScheme.DarkBackground));
// print question
this.PrintPlain("Which text looks more readable?");
@@ -264,7 +260,7 @@ namespace StardewModdingApi.Installer
****/
// get game path
this.PrintInfo("Where is your game folder?");
- DirectoryInfo installDir = this.InteractivelyGetInstallPath(platform, toolkit, gamePathArg);
+ DirectoryInfo installDir = this.InteractivelyGetInstallPath(toolkit, context, gamePathArg);
if (installDir == null)
{
this.PrintError("Failed finding your game path.");
@@ -274,7 +270,7 @@ namespace StardewModdingApi.Installer
// get folders
DirectoryInfo bundleDir = new DirectoryInfo(this.BundlePath);
- paths = new InstallerPaths(bundleDir, installDir, EnvironmentUtility.GetExecutableName(platform));
+ paths = new InstallerPaths(bundleDir, installDir, context.ExecutableName);
}
Console.Clear();
@@ -359,7 +355,7 @@ namespace StardewModdingApi.Installer
** Always uninstall old files
****/
// restore game launcher
- if (platform.IsMono() && File.Exists(paths.UnixBackupLauncherPath))
+ if (context.IsUnix && File.Exists(paths.UnixBackupLauncherPath))
{
this.PrintDebug("Removing SMAPI launcher...");
this.InteractivelyDelete(paths.UnixLauncherPath);
@@ -406,7 +402,7 @@ namespace StardewModdingApi.Installer
}
// replace mod launcher (if possible)
- if (platform.IsMono())
+ if (context.IsUnix)
{
this.PrintDebug("Safely replacing game launcher...");
@@ -504,7 +500,7 @@ namespace StardewModdingApi.Installer
/*********
** Step 7: final instructions
*********/
- if (platform == Platform.Windows)
+ if (context.IsWindows)
{
if (action == ScriptAction.Install)
{
@@ -531,16 +527,6 @@ namespace StardewModdingApi.Installer
/*********
** Private methods
*********/
- /// <summary>Get the display text for an assembly version.</summary>
- /// <param name="version">The assembly version.</param>
- private string GetDisplayVersion(Version version)
- {
- string str = $"{version.Major}.{version.Minor}";
- if (version.Build != 0)
- str += $".{version.Build}";
- return str;
- }
-
/// <summary>Get the display text for a color scheme.</summary>
/// <param name="scheme">The color scheme.</param>
private string GetDisplayText(MonitorColorScheme scheme)
@@ -582,38 +568,6 @@ namespace StardewModdingApi.Installer
/// <param name="text">The text to print.</param>
private void PrintSuccess(string text) => this.ConsoleWriter.WriteLine(text, ConsoleLogLevel.Success);
- /// <summary>Get whether the current system has .NET Framework 4.5 or later installed. This only applies on Windows.</summary>
- /// <param name="platform">The current platform.</param>
- /// <exception cref="NotSupportedException">The current platform is not Windows.</exception>
- private bool HasNetFramework45(Platform platform)
- {
- switch (platform)
- {
- case Platform.Windows:
- using (RegistryKey versionKey = RegistryKey.OpenBaseKey(RegistryHive.LocalMachine, RegistryView.Registry32).OpenSubKey(@"SOFTWARE\Microsoft\NET Framework Setup\NDP\v4\Full"))
- return versionKey?.GetValue("Release") != null; // .NET Framework 4.5+
-
- default:
- throw new NotSupportedException("The installed .NET Framework version can only be checked on Windows.");
- }
- }
-
- /// <summary>Get whether the current system has XNA Framework installed. This only applies on Windows.</summary>
- /// <param name="platform">The current platform.</param>
- /// <exception cref="NotSupportedException">The current platform is not Windows.</exception>
- private bool HasXna(Platform platform)
- {
- switch (platform)
- {
- case Platform.Windows:
- using (RegistryKey key = RegistryKey.OpenBaseKey(RegistryHive.LocalMachine, RegistryView.Registry32).OpenSubKey(@"SOFTWARE\Microsoft\XNA\Framework"))
- return key != null; // XNA Framework 4.0+
-
- default:
- throw new NotSupportedException("The installed XNA Framework version can only be checked on Windows.");
- }
- }
-
/// <summary>Interactively delete a file or folder path, and block until deletion completes.</summary>
/// <param name="path">The file or folder path.</param>
private void InteractivelyDelete(string path)
@@ -687,15 +641,12 @@ namespace StardewModdingApi.Installer
}
/// <summary>Interactively locate the game install path to update.</summary>
- /// <param name="platform">The current platform.</param>
/// <param name="toolkit">The mod toolkit.</param>
+ /// <param name="context">The installer context.</param>
/// <param name="specifiedPath">The path specified as a command-line argument (if any), which should override automatic path detection.</param>
- private DirectoryInfo InteractivelyGetInstallPath(Platform platform, ModToolkit toolkit, string specifiedPath)
+ private DirectoryInfo InteractivelyGetInstallPath(ModToolkit toolkit, InstallerContext context, string specifiedPath)
{
- // get executable name
- string executableFilename = EnvironmentUtility.GetExecutableName(platform);
-
- // validate specified path
+ // use specified path
if (specifiedPath != null)
{
var dir = new DirectoryInfo(specifiedPath);
@@ -704,7 +655,7 @@ namespace StardewModdingApi.Installer
this.PrintError($"You specified --game-path \"{specifiedPath}\", but that folder doesn't exist.");
return null;
}
- if (!dir.EnumerateFiles(executableFilename).Any())
+ if (!context.LooksLikeGameFolder(dir))
{
this.PrintError($"You specified --game-path \"{specifiedPath}\", but that folder doesn't contain the Stardew Valley executable.");
return null;
@@ -712,7 +663,19 @@ namespace StardewModdingApi.Installer
return dir;
}
- // get installed paths
+ // use game folder which contains the installer, if any
+ {
+ DirectoryInfo curPath = new FileInfo(Assembly.GetExecutingAssembly().Location).Directory;
+ while (curPath?.Parent != null) // must be in a folder (not at the root)
+ {
+ if (context.LooksLikeGameFolder(curPath))
+ return curPath;
+
+ curPath = curPath.Parent;
+ }
+ }
+
+ // use an installed path
DirectoryInfo[] defaultPaths = toolkit.GetGameFolders().ToArray();
if (defaultPaths.Any())
{
@@ -738,7 +701,7 @@ namespace StardewModdingApi.Installer
while (true)
{
// get path from user
- this.PrintInfo($"Type the file path to the game directory (the one containing '{executableFilename}'), then press enter.");
+ this.PrintInfo($"Type the file path to the game directory (the one containing '{context.ExecutableName}'), then press enter.");
string path = Console.ReadLine()?.Trim();
if (string.IsNullOrWhiteSpace(path))
{
@@ -747,10 +710,9 @@ namespace StardewModdingApi.Installer
}
// normalize path
- if (platform == Platform.Windows)
- path = path.Replace("\"", ""); // in Windows, quotes are used to escape spaces and aren't part of the file path
- if (platform == Platform.Linux || platform == Platform.Mac)
- path = path.Replace("\\ ", " "); // in Linux/Mac, spaces in paths may be escaped if copied from the command line
+ path = context.IsWindows
+ ? path.Replace("\"", "") // in Windows, quotes are used to escape spaces and aren't part of the file path
+ : path.Replace("\\ ", " "); // in Linux/Mac, spaces in paths may be escaped if copied from the command line
if (path.StartsWith("~/"))
{
string home = Environment.GetEnvironmentVariable("HOME") ?? Environment.GetEnvironmentVariable("USERPROFILE");
@@ -768,7 +730,7 @@ namespace StardewModdingApi.Installer
this.PrintInfo(" That directory doesn't seem to exist.");
continue;
}
- if (!directory.EnumerateFiles(executableFilename).Any())
+ if (!context.LooksLikeGameFolder(directory))
{
this.PrintInfo(" That directory doesn't contain a Stardew Valley executable.");
continue;
diff --git a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/ClearCommand.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/ClearCommand.cs
index 1190a4ab..29052be3 100644
--- a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/ClearCommand.cs
+++ b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/ClearCommand.cs
@@ -16,7 +16,7 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.World
** Fields
*********/
/// <summary>The valid types that can be cleared.</summary>
- private readonly string[] ValidTypes = { "crops", "debris", "fruit-trees", "grass", "trees", "everything" };
+ private readonly string[] ValidTypes = { "crops", "debris", "fruit-trees", "furniture", "grass", "trees", "everything" };
/// <summary>The resource clump IDs to consider debris.</summary>
private readonly int[] DebrisClumps = { ResourceClump.stumpIndex, ResourceClump.hollowLogIndex, ResourceClump.meteoriteIndex, ResourceClump.boulderIndex };
@@ -32,7 +32,7 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.World
description: "Clears in-game entities in a given location.\n\n"
+ "Usage: world_clear <location> <object type>\n"
+ "- location: the location name for which to clear objects (like Farm), or 'current' for the current location.\n"
- + " - object type: the type of object clear. You can specify 'crops', 'debris' (stones/twigs/weeds and dead crops), 'grass', and 'trees' / 'fruit-trees'. You can also specify 'everything', which includes things not removed by the other types (like furniture or resource clumps)."
+ + " - object type: the type of object clear. You can specify 'crops', 'debris' (stones/twigs/weeds and dead crops), 'furniture', 'grass', and 'trees' / 'fruit-trees'. You can also specify 'everything', which includes things not removed by the other types (like resource clumps)."
)
{ }
@@ -113,6 +113,13 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.World
break;
}
+ case "furniture":
+ {
+ int removed = this.RemoveFurniture(location, furniture => true);
+ monitor.Log($"Done! Removed {removed} entities from {location.Name}.", LogLevel.Info);
+ break;
+ }
+
case "grass":
{
int removed = this.RemoveTerrainFeatures(location, feature => feature is Grass);
@@ -244,15 +251,12 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.World
{
int removed = 0;
- if (location is DecoratableLocation decoratableLocation)
+ foreach (Furniture furniture in location.furniture.ToArray())
{
- foreach (Furniture furniture in decoratableLocation.furniture.ToArray())
+ if (shouldRemove(furniture))
{
- if (shouldRemove(furniture))
- {
- decoratableLocation.furniture.Remove(furniture);
- removed++;
- }
+ location.furniture.Remove(furniture);
+ removed++;
}
}
diff --git a/src/SMAPI.Mods.ConsoleCommands/Framework/ItemRepository.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/ItemRepository.cs
index d1dd758b..34149209 100644
--- a/src/SMAPI.Mods.ConsoleCommands/Framework/ItemRepository.cs
+++ b/src/SMAPI.Mods.ConsoleCommands/Framework/ItemRepository.cs
@@ -107,12 +107,7 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework
// furniture
foreach (int id in this.TryLoad<int, string>("Data\\Furniture").Keys)
- {
- if (id == 1466 || id == 1468 || id == 1680)
- yield return this.TryCreate(ItemType.Furniture, id, p => new TV(p.ID, Vector2.Zero));
- else
- yield return this.TryCreate(ItemType.Furniture, id, p => new Furniture(p.ID, Vector2.Zero));
- }
+ yield return this.TryCreate(ItemType.Furniture, id, p => Furniture.GetFurnitureInstance(p.ID));
// craftables
foreach (int id in Game1.bigCraftablesInformation.Keys)
diff --git a/src/SMAPI.Mods.ConsoleCommands/manifest.json b/src/SMAPI.Mods.ConsoleCommands/manifest.json
index ddc55a73..a1a137a1 100644
--- a/src/SMAPI.Mods.ConsoleCommands/manifest.json
+++ b/src/SMAPI.Mods.ConsoleCommands/manifest.json
@@ -1,9 +1,9 @@
{
"Name": "Console Commands",
"Author": "SMAPI",
- "Version": "3.7.6",
+ "Version": "3.8.0",
"Description": "Adds SMAPI console commands that let you manipulate the game.",
"UniqueID": "SMAPI.ConsoleCommands",
"EntryDll": "ConsoleCommands.dll",
- "MinimumApiVersion": "3.7.6"
+ "MinimumApiVersion": "3.8.0"
}
diff --git a/src/SMAPI.Mods.SaveBackup/manifest.json b/src/SMAPI.Mods.SaveBackup/manifest.json
index 0fe98909..96822f4a 100644
--- a/src/SMAPI.Mods.SaveBackup/manifest.json
+++ b/src/SMAPI.Mods.SaveBackup/manifest.json
@@ -1,9 +1,9 @@
{
"Name": "Save Backup",
"Author": "SMAPI",
- "Version": "3.7.6",
+ "Version": "3.8.0",
"Description": "Automatically backs up all your saves once per day into its folder.",
"UniqueID": "SMAPI.SaveBackup",
"EntryDll": "SaveBackup.dll",
- "MinimumApiVersion": "3.7.6"
+ "MinimumApiVersion": "3.8.0"
}
diff --git a/src/SMAPI.Toolkit/Framework/GameScanning/GameScanner.cs b/src/SMAPI.Toolkit/Framework/GameScanning/GameScanner.cs
index 825988a5..d4c82180 100644
--- a/src/SMAPI.Toolkit/Framework/GameScanning/GameScanner.cs
+++ b/src/SMAPI.Toolkit/Framework/GameScanning/GameScanner.cs
@@ -15,20 +15,33 @@ namespace StardewModdingAPI.Toolkit.Framework.GameScanning
public class GameScanner
{
/*********
+ ** Fields
+ *********/
+ /// <summary>The current OS.</summary>
+ private readonly Platform Platform;
+
+ /// <summary>The name of the Stardew Valley executable.</summary>
+ private readonly string ExecutableName;
+
+
+ /*********
** Public methods
*********/
+ /// <summary>Construct an instance.</summary>
+ public GameScanner()
+ {
+ this.Platform = EnvironmentUtility.DetectPlatform();
+ this.ExecutableName = EnvironmentUtility.GetExecutableName(this.Platform);
+ }
+
/// <summary>Find all valid Stardew Valley install folders.</summary>
/// <remarks>This checks default game locations, and on Windows checks the Windows registry for GOG/Steam install data. A folder is considered 'valid' if it contains the Stardew Valley executable for the current OS.</remarks>
public IEnumerable<DirectoryInfo> Scan()
{
- // get OS info
- Platform platform = EnvironmentUtility.DetectPlatform();
- string executableFilename = EnvironmentUtility.GetExecutableName(platform);
-
// get install paths
IEnumerable<string> paths = this
- .GetCustomInstallPaths(platform)
- .Concat(this.GetDefaultInstallPaths(platform))
+ .GetCustomInstallPaths()
+ .Concat(this.GetDefaultInstallPaths())
.Select(PathUtilities.NormalizePath)
.Distinct(StringComparer.OrdinalIgnoreCase);
@@ -36,21 +49,27 @@ namespace StardewModdingAPI.Toolkit.Framework.GameScanning
foreach (string path in paths)
{
DirectoryInfo folder = new DirectoryInfo(path);
- if (folder.Exists && folder.EnumerateFiles(executableFilename).Any())
+ if (this.LooksLikeGameFolder(folder))
yield return folder;
}
}
+ /// <summary>Get whether a folder seems to contain the game.</summary>
+ /// <param name="dir">The folder to check.</param>
+ public bool LooksLikeGameFolder(DirectoryInfo dir)
+ {
+ return dir.Exists && dir.EnumerateFiles(this.ExecutableName).Any();
+ }
+
/*********
** Private methods
*********/
/// <summary>The default file paths where Stardew Valley can be installed.</summary>
- /// <param name="platform">The target platform.</param>
- /// <remarks>Derived from the crossplatform mod config: https://github.com/Pathoschild/Stardew.ModBuildConfig. </remarks>
- private IEnumerable<string> GetDefaultInstallPaths(Platform platform)
+ /// <remarks>Derived from the <a href="https://github.com/Pathoschild/Stardew.ModBuildConfig">crossplatform mod config</a>.</remarks>
+ private IEnumerable<string> GetDefaultInstallPaths()
{
- switch (platform)
+ switch (this.Platform)
{
case Platform.Linux:
case Platform.Mac:
@@ -102,16 +121,15 @@ namespace StardewModdingAPI.Toolkit.Framework.GameScanning
break;
default:
- throw new InvalidOperationException($"Unknown platform '{platform}'.");
+ throw new InvalidOperationException($"Unknown platform '{this.Platform}'.");
}
}
/// <summary>Get the custom install path from the <c>stardewvalley.targets</c> file in the home directory, if any.</summary>
- /// <param name="platform">The target platform.</param>
- private IEnumerable<string> GetCustomInstallPaths(Platform platform)
+ private IEnumerable<string> GetCustomInstallPaths()
{
// get home path
- string homePath = Environment.GetEnvironmentVariable(platform == Platform.Windows ? "USERPROFILE" : "HOME");
+ string homePath = Environment.GetEnvironmentVariable(this.Platform == Platform.Windows ? "USERPROFILE" : "HOME");
if (string.IsNullOrWhiteSpace(homePath))
yield break;
diff --git a/src/SMAPI.Toolkit/Framework/ModScanning/ModScanner.cs b/src/SMAPI.Toolkit/Framework/ModScanning/ModScanner.cs
index 6d6b6417..5eacee9e 100644
--- a/src/SMAPI.Toolkit/Framework/ModScanning/ModScanner.cs
+++ b/src/SMAPI.Toolkit/Framework/ModScanning/ModScanner.cs
@@ -112,10 +112,20 @@ namespace StardewModdingAPI.Toolkit.Framework.ModScanning
if (manifestFile == null)
{
FileInfo[] files = this.RecursivelyGetRelevantFiles(searchFolder).ToArray();
+
+ // empty folder
if (!files.Any())
return new ModFolder(root, searchFolder, ModType.Invalid, null, ModParseError.EmptyFolder, "it's an empty folder.");
+
+ // XNB mod
if (files.All(this.IsPotentialXnbFile))
return new ModFolder(root, searchFolder, ModType.Xnb, null, ModParseError.XnbMod, "it's not a SMAPI mod (see https://smapi.io/xnb for info).");
+
+ // SMAPI installer
+ if (files.Any(p => p.Name == "install on Linux.sh" || p.Name == "install on Mac.command" || p.Name == "install on Windows.bat"))
+ return new ModFolder(root, searchFolder, ModType.Invalid, null, ModParseError.ManifestMissing, "the SMAPI installer isn't a mod (you can delete this folder after running the installer file).");
+
+ // not a mod?
return new ModFolder(root, searchFolder, ModType.Invalid, null, ModParseError.ManifestMissing, "it contains files, but none of them are manifest.json.");
}
diff --git a/src/SMAPI.Web/Framework/LogParsing/LogMessageBuilder.cs b/src/SMAPI.Web/Framework/LogParsing/LogMessageBuilder.cs
index 42e283a9..992876ef 100644
--- a/src/SMAPI.Web/Framework/LogParsing/LogMessageBuilder.cs
+++ b/src/SMAPI.Web/Framework/LogParsing/LogMessageBuilder.cs
@@ -16,6 +16,9 @@ namespace StardewModdingAPI.Web.Framework.LogParsing
/// <summary>The log level for the next log message.</summary>
public LogLevel Level { get; set; }
+ /// <summary>The screen ID in split-screen mode.</summary>
+ public int ScreenId { get; set; }
+
/// <summary>The mod name for the next log message.</summary>
public string Mod { get; set; }
@@ -36,10 +39,11 @@ namespace StardewModdingAPI.Web.Framework.LogParsing
/// <summary>Start accumulating values for a new log message.</summary>
/// <param name="time">The local time when the log was posted.</param>
/// <param name="level">The log level.</param>
+ /// <param name="screenId">The screen ID in split-screen mode.</param>
/// <param name="mod">The mod name.</param>
/// <param name="text">The initial log text.</param>
/// <exception cref="InvalidOperationException">A log message is already started; call <see cref="Clear"/> before starting a new message.</exception>
- public void Start(string time, LogLevel level, string mod, string text)
+ public void Start(string time, LogLevel level, int screenId, string mod, string text)
{
if (this.Started)
throw new InvalidOperationException("Can't start new message, previous log message isn't done yet.");
@@ -48,6 +52,7 @@ namespace StardewModdingAPI.Web.Framework.LogParsing
this.Time = time;
this.Level = level;
+ this.ScreenId = screenId;
this.Mod = mod;
this.Text.Append(text);
}
@@ -74,6 +79,7 @@ namespace StardewModdingAPI.Web.Framework.LogParsing
{
Time = this.Time,
Level = this.Level,
+ ScreenId = this.ScreenId,
Mod = this.Mod,
Text = this.Text.ToString()
};
diff --git a/src/SMAPI.Web/Framework/LogParsing/LogParser.cs b/src/SMAPI.Web/Framework/LogParsing/LogParser.cs
index 227dcd89..f69d4b6f 100644
--- a/src/SMAPI.Web/Framework/LogParsing/LogParser.cs
+++ b/src/SMAPI.Web/Framework/LogParsing/LogParser.cs
@@ -14,7 +14,7 @@ namespace StardewModdingAPI.Web.Framework.LogParsing
** Fields
*********/
/// <summary>A regex pattern matching the start of a SMAPI message.</summary>
- private readonly Regex MessageHeaderPattern = new Regex(@"^\[(?<time>\d\d[:\.]\d\d[:\.]\d\d) (?<level>[a-z]+) +(?<modName>[^\]]+)\] ", RegexOptions.Compiled | RegexOptions.IgnoreCase);
+ private readonly Regex MessageHeaderPattern = new Regex(@"^\[(?<time>\d\d[:\.]\d\d[:\.]\d\d) (?<level>[a-z]+)(?: +screen_(?<screen>\d+))? +(?<modName>[^\]]+)\] ", RegexOptions.Compiled | RegexOptions.IgnoreCase);
/// <summary>A regex pattern matching SMAPI's initial platform info message.</summary>
private readonly Regex InfoLinePattern = new Regex(@"^SMAPI (?<apiVersion>.+) with Stardew Valley (?<gameVersion>.+) on (?<os>.+)", RegexOptions.Compiled | RegexOptions.IgnoreCase);
@@ -304,9 +304,11 @@ namespace StardewModdingAPI.Web.Framework.LogParsing
builder.Clear();
}
+ var screenGroup = header.Groups["screen"];
builder.Start(
time: header.Groups["time"].Value,
level: Enum.Parse<LogLevel>(header.Groups["level"].Value, ignoreCase: true),
+ screenId: screenGroup.Success ? int.Parse(screenGroup.Value) : 0, // main player is always screen ID 0
mod: header.Groups["modName"].Value,
text: line.Substring(header.Length)
);
diff --git a/src/SMAPI.Web/Framework/LogParsing/Models/LogMessage.cs b/src/SMAPI.Web/Framework/LogParsing/Models/LogMessage.cs
index f7c99d02..1e08be78 100644
--- a/src/SMAPI.Web/Framework/LogParsing/Models/LogMessage.cs
+++ b/src/SMAPI.Web/Framework/LogParsing/Models/LogMessage.cs
@@ -12,6 +12,9 @@ namespace StardewModdingAPI.Web.Framework.LogParsing.Models
/// <summary>The log level.</summary>
public LogLevel Level { get; set; }
+ /// <summary>The screen ID in split-screen mode.</summary>
+ public int ScreenId { get; set; }
+
/// <summary>The mod name.</summary>
public string Mod { get; set; }
diff --git a/src/SMAPI.Web/Views/LogParser/Index.cshtml b/src/SMAPI.Web/Views/LogParser/Index.cshtml
index d4ff4f10..fd472673 100644
--- a/src/SMAPI.Web/Views/LogParser/Index.cshtml
+++ b/src/SMAPI.Web/Views/LogParser/Index.cshtml
@@ -13,6 +13,8 @@
.ToDictionary(level => level.ToString().ToLower(), level => level != LogLevel.Trace);
string curPageUrl = this.Url.PlainAction("Index", "LogParser", new { id = Model.PasteID }, absoluteUrl: true);
+
+ ISet<int> screenIds = new HashSet<int>(Model.ParsedLog?.Messages?.Select(p => p.ScreenId) ?? new int[0]);
}
@section Head {
@@ -35,7 +37,8 @@
showMods: @this.ForJson(Model.ParsedLog?.Mods?.Select(p => Model.GetSlug(p.Name)).Distinct().ToDictionary(slug => slug, slug => true)),
showSections: @this.ForJson(Enum.GetNames(typeof(LogSection)).ToDictionary(section => section, section => false)),
showLevels: @this.ForJson(defaultFilters),
- enableFilters: @this.ForJson(!Model.ShowRaw)
+ enableFilters: @this.ForJson(!Model.ShowRaw),
+ screenIds: @this.ForJson(screenIds)
}, '@this.Url.PlainAction("Index", "LogParser", values: null)');
});
</script>
@@ -305,6 +308,10 @@ else if (Model.ParsedLog?.IsValid == true)
@if (message.IsStartOfSection) { <text> v-on:click="toggleSection('@message.Section')" </text> }
v-show="filtersAllow('@Model.GetSlug(message.Mod)', '@levelStr') @sectionFilter">
<td v-pre>@message.Time</td>
+ @if (screenIds.Count > 1)
+ {
+ <td v-pre>screen_@message.ScreenId</td>
+ }
<td v-pre>@message.Level.ToString().ToUpper()</td>
<td v-pre data-title="@message.Mod">@message.Mod</td>
<td>
@@ -325,7 +332,7 @@ else if (Model.ParsedLog?.IsValid == true)
if (message.Repeated > 0)
{
<tr class="@levelStr mod mod-repeat" v-show="filtersAllow('@Model.GetSlug(message.Mod)', '@levelStr') @sectionFilter">
- <td colspan="3"></td>
+ <td colspan="4"></td>
<td v-pre><i>repeats [@message.Repeated] times.</i></td>
</tr>
}
diff --git a/src/SMAPI.Web/wwwroot/SMAPI.metadata.json b/src/SMAPI.Web/wwwroot/SMAPI.metadata.json
index 179ef42a..2a81e12a 100644
--- a/src/SMAPI.Web/wwwroot/SMAPI.metadata.json
+++ b/src/SMAPI.Web/wwwroot/SMAPI.metadata.json
@@ -54,15 +54,15 @@
"Default | UpdateKey": "Nexus:2270"
},
- //"Content Patcher": {
- // "ID": "Pathoschild.ContentPatcher",
- // "Default | UpdateKey": "Nexus:1915"
- //},
+ "Content Patcher": {
+ "ID": "Pathoschild.ContentPatcher",
+ "Default | UpdateKey": "Nexus:1915"
+ },
- //"Custom Farming Redux": {
- // "ID": "Platonymous.CustomFarming",
- // "Default | UpdateKey": "Nexus:991"
- //},
+ "Custom Farming Redux": {
+ "ID": "Platonymous.CustomFarming",
+ "Default | UpdateKey": "Nexus:991"
+ },
"Custom Shirts": {
"ID": "Platonymous.CustomShirts",
@@ -150,6 +150,51 @@
"~ | StatusReasonPhrase": "debug mode was removed in SMAPI 1.0."
},
+ "Split Screen": {
+ "ID": "Ilyaki.SplitScreen",
+ "~ | Status": "Obsolete",
+ "~ | StatusReasonPhrase": "split-screen mode was added in Stardew Valley 1.5"
+ },
+
+ /*********
+ ** Broke in SDV 1.5
+ *********/
+ "Audio Devices": {
+ "ID": "maxvollmer.audiodevices",
+ "~2.0.0 | Status": "AssumeBroken" // causes crash to desktop when starting the game
+ },
+
+ "Custom Localization": {
+ "ID": "ZaneYork.CustomLocalization",
+ "FormerIDs": "SMAPI.CustomLocalization", // changed in 1.0.1
+ "~1.1 | Status": "AssumeBroken" // reflection error for _localizedAssets field
+ },
+
+ "Mod Settings Tab": {
+ "ID": "GilarF.ModSettingsTab",
+ "~0.2.1 | Status": "AssumeBroken" // fails extending title menu
+ },
+
+ "More Grass": {
+ "ID": "EpicBellyFlop45.MoreGrass",
+ "~1.0.8 | Status": "AssumeBroken" // crashes save load
+ },
+
+ "Movement Speed": {
+ "ID": "bcmpinc.MovementSpeed",
+ "~3.0.0 | Status": "AssumeBroken" // transpiler errors
+ },
+
+ "Tree Spread": {
+ "ID": "bcmpinc.TreeSpread",
+ "~3.0.0 | Status": "AssumeBroken" // transpiler errors
+ },
+
+ "TreeTransplant": {
+ "ID": "TreeTransplant",
+ "~1.0.9 | Status": "AssumeBroken" // causes AccessViolationException which prevents game launch
+ },
+
/*********
** Broke in SDV 1.4
*********/
@@ -221,12 +266,6 @@
"~2.3.1-unofficial.7-pathoschild | Status": "AssumeBroken"
},
- "Content Patcher": {
- "ID": "Pathoschild.ContentPatcher",
- "Default | UpdateKey": "Nexus:1915",
- "~1.6.4 | Status": "AssumeBroken"
- },
-
"Current Location (Vrakyas)": {
"ID": "Vrakyas.CurrentLocation",
"~1.5.4 | Status": "AssumeBroken"
@@ -237,12 +276,6 @@
"~1.8 | Status": "AssumeBroken"
},
- "Custom Farming Redux": {
- "ID": "Platonymous.CustomFarming",
- "Default | UpdateKey": "Nexus:991",
- "~2.10.10 | Status": "AssumeBroken" // possibly due to PyTK
- },
-
"Decrafting Mod": {
"ID": "MSCFC.DecraftingMod",
"~1.0 | Status": "AssumeBroken" // NRE in ModEntry
@@ -408,11 +441,6 @@
"~1.0.1 | Status": "AssumeBroken" // broke in SDV 1.3
},
- "Movement Speed": {
- "ID": "bcmpinc.MovementSpeed",
- "~0.6 | Status": "AssumeBroken" // breaks newer versions of bcmpinc mods (per bcmpinc's request)
- },
-
"No Added Flying Mine Monsters": {
"ID": "Drynwynn.NoAddedFlyingMineMonsters",
"~1.1 | Status": "AssumeBroken" // runtime errors with Harmony 1.2.0.1 in SMAPI 2.8+
@@ -429,11 +457,6 @@
"1.3-beta | Status": "AssumeBroken" // doesn't work in multiplayer, no longer maintained
},
- "Split Screen": {
- "ID": "Ilyaki.SplitScreen",
- "~3.0.1 | Status": "AssumeBroken" // broke in SMAPI 2.6-beta.16 due to reflection into SMAPI internals
- },
-
"Stardew Hack": {
"ID": "bcmpinc.StardewHack",
"~0.6 | Status": "AssumeBroken" // breaks newer versions of bcmpinc mods (per bcmpinc's request)
@@ -455,11 +478,6 @@
"~0.6 | Status": "AssumeBroken" // breaks newer versions of bcmpinc mods (per bcmpinc's request)
},
- "Tree Spread": {
- "ID": "bcmpinc.TreeSpread",
- "~0.6 | Status": "AssumeBroken" // breaks newer versions of bcmpinc mods (per bcmpinc's request)
- },
-
/*********
** Broke circa SDV 1.2
*********/
diff --git a/src/SMAPI.Web/wwwroot/schemas/content-patcher.json b/src/SMAPI.Web/wwwroot/schemas/content-patcher.json
index e52cd757..92149f4d 100644
--- a/src/SMAPI.Web/wwwroot/schemas/content-patcher.json
+++ b/src/SMAPI.Web/wwwroot/schemas/content-patcher.json
@@ -11,9 +11,9 @@
"title": "Format version",
"description": "The format version. You should always use the latest version to enable the latest features and avoid obsolete behavior.",
"type": "string",
- "const": "1.18.0",
+ "const": "1.19.0",
"@errorMessages": {
- "const": "Incorrect value '@value'. This should be set to the latest format version, currently '1.18.0'."
+ "const": "Incorrect value '@value'. This should be set to the latest format version, currently '1.19.0'."
}
},
"ConfigSchema": {
@@ -147,13 +147,16 @@
},
"Update": {
"title": "Update",
- "description": "When the patch should update if it changed. The possible values are 'OnDayStart' and 'OnLocationChange' (defaults to OnDayStart).",
+ "description": "When the patch should update if it changed. The possible values are 'OnDayStart', 'OnLocationChange', or 'OnTimeChange' (defaults to OnDayStart).",
"type": "string",
- "enum": [ "OnDayStart", "OnLocationChange" ]
+ "pattern": "^ *((OnDayStart|OnLocationChange|OnTimeChange), *)*(OnDayStart|OnLocationChange|OnTimeChange) *$",
+ "@errorMessages": {
+ "pattern": "Invalid value; must be 'OnDayStart', 'OnLocationChange', 'OnTimeChange', or a comma-delimited combination of those values."
+ }
},
"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 or .tmx (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'), or multiple comma-delimited values. 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": [
{
@@ -180,13 +183,6 @@
"description": "The part of the target image to replace. Defaults to the FromArea size starting from the top-left corner.",
"$ref": "#/definitions/Rectangle"
},
- "PatchMode": {
- "title": "Patch mode",
- "description": "How to apply FromArea to ToArea. Defaults to Replace.",
- "type": "string",
- "enum": [ "Replace", "Overlay" ],
- "default": "Replace"
- },
"Fields": {
"title": "Fields",
"description": "The individual fields you want to change for existing entries. This field supports tokens in field keys and values. The key for each field is the field index (starting at zero) for a slash-delimited string, or the field name for an object.",
@@ -359,6 +355,15 @@
}
},
"then": {
+ "properties": {
+ "PatchMode": {
+ "title": "Patch mode",
+ "description": "How to apply FromArea to ToArea. Defaults to Replace.",
+ "type": "string",
+ "enum": [ "Replace", "Overlay" ],
+ "default": "Replace"
+ }
+ },
"required": [ "FromFile", "Target" ],
"propertyNames": {
"enum": [
@@ -417,6 +422,13 @@
},
"ToArea": {
"description": "The part of the target map to replace."
+ },
+ "PatchMode": {
+ "title": "Patch mode",
+ "description": "How to apply FromArea to ToArea. Defaults to ReplaceByLayer.",
+ "type": "string",
+ "enum": [ "Overlay", "Replace", "ReplaceByLayer" ],
+ "default": "ReplaceByLayer"
}
},
"propertyNames": {
@@ -432,6 +444,7 @@
"FromArea",
"MapProperties",
"MapTiles",
+ "PatchMode",
"TextOperations",
"ToArea"
]
diff --git a/src/SMAPI/Constants.cs b/src/SMAPI/Constants.cs
index 88f79811..98d0277b 100644
--- a/src/SMAPI/Constants.cs
+++ b/src/SMAPI/Constants.cs
@@ -39,6 +39,9 @@ namespace StardewModdingAPI
/// <summary>The game's assembly name.</summary>
internal static string GameAssemblyName => EarlyConstants.Platform == GamePlatform.Windows ? "Stardew Valley" : "StardewValley";
+
+ /// <summary>The <see cref="Context.ScreenId"/> value which should appear in the SMAPI log, if any.</summary>
+ internal static int? LogScreenId { get; set; }
}
/// <summary>Contains SMAPI's constants and assumptions.</summary>
@@ -51,13 +54,13 @@ namespace StardewModdingAPI
** Public
****/
/// <summary>SMAPI's current semantic version.</summary>
- public static ISemanticVersion ApiVersion { get; } = new Toolkit.SemanticVersion("3.7.6");
+ public static ISemanticVersion ApiVersion { get; } = new Toolkit.SemanticVersion("3.8.0");
/// <summary>The minimum supported version of Stardew Valley.</summary>
- public static ISemanticVersion MinimumGameVersion { get; } = new GameVersion("1.4.1");
+ public static ISemanticVersion MinimumGameVersion { get; } = new GameVersion("1.5.0");
/// <summary>The maximum supported version of Stardew Valley.</summary>
- public static ISemanticVersion MaximumGameVersion { get; } = new GameVersion("1.4.5");
+ public static ISemanticVersion MaximumGameVersion { get; } = null;
/// <summary>The target game platform.</summary>
public static GamePlatform TargetPlatform { get; } = EarlyConstants.Platform;
@@ -272,21 +275,13 @@ namespace StardewModdingAPI
return null;
// get basic info
- string playerName;
- ulong saveID;
- if (Context.LoadStage == LoadStage.SaveParsed)
- {
- playerName = SaveGame.loaded.player.Name;
- saveID = SaveGame.loaded.uniqueIDForThisGame;
- }
- else
- {
- playerName = Game1.player.Name;
- saveID = Game1.uniqueIDForThisGame;
- }
+ string saveName = Game1.GetSaveGameName(set_value: false);
+ ulong saveID = Context.LoadStage == LoadStage.SaveParsed
+ ? SaveGame.loaded.uniqueIDForThisGame
+ : Game1.uniqueIDForThisGame;
// build folder name
- return $"{new string(playerName.Where(char.IsLetterOrDigit).ToArray())}_{saveID}";
+ return $"{new string(saveName.Where(char.IsLetterOrDigit).ToArray())}_{saveID}";
}
/// <summary>Get the path to the current save folder, if any.</summary>
diff --git a/src/SMAPI/Context.cs b/src/SMAPI/Context.cs
index a7238b32..b1b33cd6 100644
--- a/src/SMAPI/Context.cs
+++ b/src/SMAPI/Context.cs
@@ -1,5 +1,7 @@
+using System.Collections.Generic;
using StardewModdingAPI.Enums;
using StardewModdingAPI.Events;
+using StardewModdingAPI.Utilities;
using StardewValley;
using StardewValley.Menus;
@@ -9,16 +11,49 @@ namespace StardewModdingAPI
public static class Context
{
/*********
+ ** Fields
+ *********/
+ /// <summary>Whether the player has loaded a save and the world has finished initializing.</summary>
+ private static readonly PerScreen<bool> IsWorldReadyForScreen = new PerScreen<bool>();
+
+ /// <summary>The current stage in the game's loading process.</summary>
+ private static readonly PerScreen<LoadStage> LoadStageForScreen = new PerScreen<LoadStage>();
+
+ /// <summary>Whether a player save has been loaded.</summary>
+ internal static bool IsSaveLoaded => Game1.hasLoadedGame && !(Game1.activeClickableMenu is TitleMenu);
+
+ /// <summary>Whether the game is currently writing to the save file.</summary>
+ internal static bool IsSaving => Game1.activeClickableMenu is SaveGameMenu || Game1.activeClickableMenu is ShippingMenu; // saving is performed by SaveGameMenu, but it's wrapped by ShippingMenu on days when the player shipping something
+
+ /// <summary>The active split-screen instance IDs.</summary>
+ internal static readonly ISet<int> ActiveScreenIds = new HashSet<int>();
+
+ /// <summary>The last screen ID that was removed from the game, used to synchronize <see cref="PerScreen{T}"/>.</summary>
+ internal static int LastRemovedScreenId = -1;
+
+ /// <summary>The current stage in the game's loading process.</summary>
+ internal static LoadStage LoadStage
+ {
+ get => Context.LoadStageForScreen.Value;
+ set => Context.LoadStageForScreen.Value = value;
+ }
+
+
+ /*********
** Accessors
*********/
/****
- ** Public
+ ** Game/player state
****/
/// <summary>Whether the game has performed core initialization. This becomes true right before the first update tick.</summary>
public static bool IsGameLaunched { get; internal set; }
/// <summary>Whether the player has loaded a save and the world has finished initializing.</summary>
- public static bool IsWorldReady { get; internal set; }
+ public static bool IsWorldReady
+ {
+ get => Context.IsWorldReadyForScreen.Value;
+ set => Context.IsWorldReadyForScreen.Value = value;
+ }
/// <summary>Whether <see cref="IsWorldReady"/> is true and the player is free to act in the world (no menu is displayed, no cutscene is in progress, etc).</summary>
public static bool IsPlayerFree => Context.IsWorldReady && Game1.currentLocation != null && Game1.activeClickableMenu == null && !Game1.dialogueUp && (!Game1.eventUp || Game1.isFestival());
@@ -29,22 +64,36 @@ namespace StardewModdingAPI
/// <summary>Whether the game is currently running the draw loop. This isn't relevant to most mods, since you should use <see cref="IDisplayEvents"/> events to draw to the screen.</summary>
public static bool IsInDrawLoop { get; internal set; }
- /// <summary>Whether <see cref="IsWorldReady"/> and the player loaded the save in multiplayer mode (regardless of whether any other players are connected).</summary>
- public static bool IsMultiplayer => Context.IsWorldReady && Game1.multiplayerMode != Game1.singlePlayer;
-
- /// <summary>Whether <see cref="IsWorldReady"/> and the current player is the main player. This is always true in single-player, and true when hosting in multiplayer.</summary>
- public static bool IsMainPlayer => Context.IsWorldReady && Game1.IsMasterGame;
-
/****
- ** Internal
+ ** Multiplayer
****/
- /// <summary>Whether a player save has been loaded.</summary>
- internal static bool IsSaveLoaded => Game1.hasLoadedGame && !(Game1.activeClickableMenu is TitleMenu);
+ /// <summary>The unique ID of the current screen in split-screen mode. A screen is always assigned a new ID when it's opened (so a player who quits and rejoins has a new screen ID).</summary>
+ public static int ScreenId => Game1.game1?.instanceId ?? 0;
- /// <summary>Whether the game is currently writing to the save file.</summary>
- internal static bool IsSaving => Game1.activeClickableMenu is SaveGameMenu || Game1.activeClickableMenu is ShippingMenu; // saving is performed by SaveGameMenu, but it's wrapped by ShippingMenu on days when the player shipping something
+ /// <summary>Whether the game is running in multiplayer or split-screen mode (regardless of whether any other players are connected). See <see cref="IsSplitScreen"/> and <see cref="HasRemotePlayers"/> for more specific checks.</summary>
+ public static bool IsMultiplayer => Context.IsSplitScreen || (Context.IsWorldReady && Game1.multiplayerMode != Game1.singlePlayer);
- /// <summary>The current stage in the game's loading process.</summary>
- internal static LoadStage LoadStage { get; set; }
+ /// <summary>Whether this player is running on the main player's computer. This is true for both the main player and split-screen players.</summary>
+ public static bool IsOnHostComputer => Context.IsMainPlayer || Context.IsSplitScreen;
+
+ /// <summary>Whether the current player is playing in a split-screen. This is only applicable when <see cref="IsOnHostComputer"/> is true, since split-screen players on another computer are just regular remote players.</summary>
+ public static bool IsSplitScreen => LocalMultiplayer.IsLocalMultiplayer();
+
+ /// <summary>Whether there are players connected over the network.</summary>
+ public static bool HasRemotePlayers => Context.IsMultiplayer && !Game1.hasLocalClientsOnly;
+
+ /// <summary>Whether the current player is the main player. This is always true in single-player, and true when hosting in multiplayer.</summary>
+ public static bool IsMainPlayer => Game1.IsMasterGame && !(TitleMenu.subMenu is FarmhandMenu);
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Get whether a screen ID is still active.</summary>
+ /// <param name="id">The screen ID.</param>
+ public static bool HasScreenId(int id)
+ {
+ return Context.ActiveScreenIds.Contains(id);
+ }
}
}
diff --git a/src/SMAPI/Framework/ContentManagers/GameContentManager.cs b/src/SMAPI/Framework/ContentManagers/GameContentManager.cs
index f20580e1..83a63986 100644
--- a/src/SMAPI/Framework/ContentManagers/GameContentManager.cs
+++ b/src/SMAPI/Framework/ContentManagers/GameContentManager.cs
@@ -29,8 +29,8 @@ namespace StardewModdingAPI.Framework.ContentManagers
/// <summary>Interceptors which edit matching assets after they're loaded.</summary>
private IList<ModLinked<IAssetEditor>> Editors => this.Coordinator.Editors;
- /// <summary>A lookup which indicates whether the asset is localizable (i.e. the filename contains the locale), if previously loaded.</summary>
- private readonly IDictionary<string, bool> IsLocalizableLookup;
+ /// <summary>Maps asset names to their localized form, like <c>LooseSprites\Billboard => LooseSprites\Billboard.fr-FR</c> (localized) or <c>Maps\AnimalShop => Maps\AnimalShop</c> (not localized).</summary>
+ private IDictionary<string, string> LocalizedAssetNames => LocalizedContentManager.localizedAssetNames;
/// <summary>Whether the next load is the first for any game content manager.</summary>
private static bool IsFirstLoad = true;
@@ -55,7 +55,6 @@ namespace StardewModdingAPI.Framework.ContentManagers
public GameContentManager(string name, IServiceProvider serviceProvider, string rootDirectory, CultureInfo currentCulture, ContentCoordinator coordinator, IMonitor monitor, Reflector reflection, Action<BaseContentManager> onDisposing, Action onLoadingFirstAsset)
: base(name, serviceProvider, rootDirectory, currentCulture, coordinator, monitor, reflection, onDisposing, isNamespaced: false)
{
- this.IsLocalizableLookup = reflection.GetField<IDictionary<string, bool>>(this, "_localizedAsset").GetValue();
this.OnLoadingFirstAsset = onLoadingFirstAsset;
}
@@ -124,7 +123,7 @@ namespace StardewModdingAPI.Framework.ContentManagers
// find assets for which a translatable version was loaded
HashSet<string> removeAssetNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
- foreach (string key in this.IsLocalizableLookup.Where(p => p.Value).Select(p => p.Key))
+ foreach (string key in this.LocalizedAssetNames.Where(p => p.Key != p.Value).Select(p => p.Key))
removeAssetNames.Add(this.TryParseExplicitLanguageAssetKey(key, out string assetName, out _) ? assetName : key);
// invalidate translatable assets
@@ -154,21 +153,15 @@ namespace StardewModdingAPI.Framework.ContentManagers
/// <param name="normalizedAssetName">The normalized asset name.</param>
protected override bool IsNormalizedKeyLoaded(string normalizedAssetName)
{
- // default English
- if (this.Language == LocalizedContentManager.LanguageCode.en || this.Coordinator.IsManagedAssetKey(normalizedAssetName))
- return this.Cache.ContainsKey(normalizedAssetName);
-
- // translated
- string keyWithLocale = $"{normalizedAssetName}.{this.GetLocale(this.GetCurrentLanguage())}";
- if (this.IsLocalizableLookup.TryGetValue(keyWithLocale, out bool localizable))
- {
- return localizable
- ? this.Cache.ContainsKey(keyWithLocale)
- : this.Cache.ContainsKey(normalizedAssetName);
- }
-
- // not loaded yet
- return false;
+ string cachedKey = null;
+ bool localized =
+ this.Language != LocalizedContentManager.LanguageCode.en
+ && !this.Coordinator.IsManagedAssetKey(normalizedAssetName)
+ && this.LocalizedAssetNames.TryGetValue(normalizedAssetName, out cachedKey);
+
+ return localized
+ ? this.Cache.ContainsKey(cachedKey)
+ : this.Cache.ContainsKey(normalizedAssetName);
}
/// <summary>Add tracking data to an asset and add it to the cache.</summary>
@@ -197,22 +190,16 @@ namespace StardewModdingAPI.Framework.ContentManagers
// doesn't change the instance stored in the cache, e.g. using `asset.ReplaceWith`.
if (useCache)
{
- string keyWithLocale = $"{assetName}.{this.GetLocale(language)}";
+ string translatedKey = $"{assetName}.{this.GetLocale(language)}";
base.TrackAsset(assetName, value, language, useCache: true);
- if (this.Cache.ContainsKey(keyWithLocale))
- base.TrackAsset(keyWithLocale, value, language, useCache: true);
+ if (this.Cache.ContainsKey(translatedKey))
+ base.TrackAsset(translatedKey, value, language, useCache: true);
// track whether the injected asset is translatable for is-loaded lookups
- if (this.Cache.ContainsKey(keyWithLocale))
- {
- this.IsLocalizableLookup[assetName] = true;
- this.IsLocalizableLookup[keyWithLocale] = true;
- }
+ if (this.Cache.ContainsKey(translatedKey))
+ this.LocalizedAssetNames[assetName] = translatedKey;
else if (this.Cache.ContainsKey(assetName))
- {
- this.IsLocalizableLookup[assetName] = false;
- this.IsLocalizableLookup[keyWithLocale] = false;
- }
+ this.LocalizedAssetNames[assetName] = assetName;
else
this.Monitor.Log($"Asset '{assetName}' could not be found in the cache immediately after injection.", LogLevel.Error);
}
@@ -226,24 +213,23 @@ namespace StardewModdingAPI.Framework.ContentManagers
/// <remarks>Derived from <see cref="LocalizedContentManager.Load{T}(string, LocalizedContentManager.LanguageCode)"/>.</remarks>
private T RawLoad<T>(string assetName, LanguageCode language, bool useCache)
{
- // try translated asset
+ // use cached key
+ if (this.LocalizedAssetNames.TryGetValue(assetName, out string cachedKey))
+ return base.RawLoad<T>(cachedKey, useCache);
+
+ // try translated key
if (language != LocalizedContentManager.LanguageCode.en)
{
string translatedKey = $"{assetName}.{this.GetLocale(language)}";
- if (!this.IsLocalizableLookup.TryGetValue(translatedKey, out bool isTranslatable) || isTranslatable)
+ try
{
- try
- {
- T obj = base.RawLoad<T>(translatedKey, useCache);
- this.IsLocalizableLookup[assetName] = true;
- this.IsLocalizableLookup[translatedKey] = true;
- return obj;
- }
- catch (ContentLoadException)
- {
- this.IsLocalizableLookup[assetName] = false;
- this.IsLocalizableLookup[translatedKey] = false;
- }
+ T obj = base.RawLoad<T>(translatedKey, useCache);
+ this.LocalizedAssetNames[assetName] = translatedKey;
+ return obj;
+ }
+ catch (ContentLoadException)
+ {
+ this.LocalizedAssetNames[assetName] = assetName;
}
}
diff --git a/src/SMAPI/Framework/ContentManagers/ModContentManager.cs b/src/SMAPI/Framework/ContentManagers/ModContentManager.cs
index 12d672cf..127705ea 100644
--- a/src/SMAPI/Framework/ContentManagers/ModContentManager.cs
+++ b/src/SMAPI/Framework/ContentManagers/ModContentManager.cs
@@ -12,7 +12,6 @@ using StardewModdingAPI.Toolkit.Utilities;
using StardewValley;
using xTile;
using xTile.Format;
-using xTile.ObjectModel;
using xTile.Tiles;
namespace StardewModdingAPI.Framework.ContentManagers
@@ -127,8 +126,8 @@ namespace StardewModdingAPI.Framework.ContentManagers
asset = this.RawLoad<T>(assetName, useCache: false);
if (asset is Map map)
{
- this.NormalizeTilesheetPaths(map);
- this.FixCustomTilesheetPaths(map, relativeMapPath: assetName);
+ map.assetPath = assetName;
+ this.FixTilesheetPaths(map, relativeMapPath: assetName);
}
}
break;
@@ -168,8 +167,8 @@ namespace StardewModdingAPI.Framework.ContentManagers
// fetch & cache
FormatManager formatManager = FormatManager.Instance;
Map map = formatManager.LoadMap(file.FullName);
- this.NormalizeTilesheetPaths(map);
- this.FixCustomTilesheetPaths(map, relativeMapPath: assetName);
+ map.assetPath = assetName;
+ this.FixTilesheetPaths(map, relativeMapPath: assetName);
asset = (T)(object)map;
}
break;
@@ -257,44 +256,21 @@ namespace StardewModdingAPI.Framework.ContentManagers
return texture;
}
- /// <summary>Normalize map tilesheet paths for the current platform.</summary>
- /// <param name="map">The map whose tilesheets to fix.</param>
- private void NormalizeTilesheetPaths(Map map)
- {
- foreach (TileSheet tilesheet in map.TileSheets)
- tilesheet.ImageSource = this.NormalizePathSeparators(tilesheet.ImageSource);
- }
-
/// <summary>Fix custom map tilesheet paths so they can be found by the content manager.</summary>
/// <param name="map">The map whose tilesheets to fix.</param>
/// <param name="relativeMapPath">The relative map path within the mod folder.</param>
/// <exception cref="ContentLoadException">A map tilesheet couldn't be resolved.</exception>
- /// <remarks>
- /// The game's logic for tilesheets in <see cref="Game1.setGraphicsForSeason"/> is a bit specialized. It boils
- /// down to this:
- /// * If the location is indoors or the desert, or the image source contains 'path' or 'object', it's loaded
- /// as-is relative to the <c>Content</c> folder.
- /// * Else it's loaded from <c>Content\Maps</c> with a seasonal prefix.
- ///
- /// That logic doesn't work well in our case, mainly because we have no location metadata at this point.
- /// Instead we use a more heuristic approach: check relative to the map file first, then relative to
- /// <c>Content\Maps</c>, then <c>Content</c>. If the image source filename contains a seasonal prefix, try for a
- /// seasonal variation and then an exact match.
- ///
- /// While that doesn't exactly match the game logic, it's close enough that it's unlikely to make a difference.
- /// </remarks>
- private void FixCustomTilesheetPaths(Map map, string relativeMapPath)
+ private void FixTilesheetPaths(Map map, string relativeMapPath)
{
// get map info
- if (!map.TileSheets.Any())
- return;
relativeMapPath = this.AssertAndNormalizeAssetName(relativeMapPath); // Mono's Path.GetDirectoryName doesn't handle Windows dir separators
string relativeMapFolder = Path.GetDirectoryName(relativeMapPath) ?? ""; // folder path containing the map, relative to the mod folder
- bool isOutdoors = map.Properties.TryGetValue("Outdoors", out PropertyValue outdoorsProperty) && outdoorsProperty != null;
// fix tilesheets
foreach (TileSheet tilesheet in map.TileSheets)
{
+ tilesheet.ImageSource = this.NormalizePathSeparators(tilesheet.ImageSource);
+
string imageSource = tilesheet.ImageSource;
string errorPrefix = $"{this.ModName} loaded map '{relativeMapPath}' with invalid tilesheet path '{imageSource}'.";
@@ -305,7 +281,7 @@ namespace StardewModdingAPI.Framework.ContentManagers
// load best match
try
{
- if (!this.TryGetTilesheetAssetName(relativeMapFolder, imageSource, isOutdoors, out string assetName, out string error))
+ if (!this.TryGetTilesheetAssetName(relativeMapFolder, imageSource, out string assetName, out string error))
throw new SContentLoadException($"{errorPrefix} {error}");
tilesheet.ImageSource = assetName;
@@ -319,37 +295,23 @@ namespace StardewModdingAPI.Framework.ContentManagers
/// <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="originalPath">The tilesheet path to load.</param>
- /// <param name="willSeasonalize">Whether the game will apply seasonal logic to the tilesheet.</param>
+ /// <param name="relativePath">The tilesheet path to load.</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 bool TryGetTilesheetAssetName(string modRelativeMapFolder, string originalPath, bool willSeasonalize, out string assetName, out string error)
+ /// <remarks>See remarks on <see cref="FixTilesheetPaths"/>.</remarks>
+ private bool TryGetTilesheetAssetName(string modRelativeMapFolder, string relativePath, out string assetName, out string error)
{
assetName = null;
error = null;
// nothing to do
- if (string.IsNullOrWhiteSpace(originalPath))
+ if (string.IsNullOrWhiteSpace(relativePath))
{
- assetName = originalPath;
+ assetName = relativePath;
return true;
}
- // parse path
- string filename = Path.GetFileName(originalPath);
- bool isSeasonal = filename.StartsWith("spring_", StringComparison.CurrentCultureIgnoreCase)
- || filename.StartsWith("summer_", StringComparison.CurrentCultureIgnoreCase)
- || filename.StartsWith("fall_", StringComparison.CurrentCultureIgnoreCase)
- || filename.StartsWith("winter_", StringComparison.CurrentCultureIgnoreCase);
- string relativePath = originalPath;
- if (willSeasonalize && isSeasonal)
- {
- string dirPath = Path.GetDirectoryName(originalPath);
- relativePath = Path.Combine(dirPath, $"{Game1.currentSeason}_{filename.Substring(filename.IndexOf("_", StringComparison.CurrentCultureIgnoreCase) + 1)}");
- }
-
// get relative to map file
{
string localKey = Path.Combine(modRelativeMapFolder, relativePath);
@@ -361,38 +323,24 @@ namespace StardewModdingAPI.Framework.ContentManagers
}
// get from game assets
- // Map tilesheet keys shouldn't include the "Maps/" prefix (the game will add it automatically) or ".png" extension.
+ string contentKey = this.GetContentKeyForTilesheetImageSource(relativePath);
+ try
{
- string contentKey = relativePath;
- foreach (char separator in PathUtilities.PossiblePathSeparators)
- {
- if (contentKey.StartsWith($"Maps{separator}"))
- {
- contentKey = contentKey.Substring(5);
- break;
- }
- }
- if (contentKey.EndsWith(".png", StringComparison.OrdinalIgnoreCase))
- contentKey = contentKey.Substring(0, contentKey.Length - 4);
-
- try
- {
- this.GameContentManager.Load<Texture2D>(Path.Combine("Maps", 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;
- }
+ 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
@@ -412,5 +360,23 @@ namespace StardewModdingAPI.Framework.ContentManagers
// get file
return new FileInfo(path).Exists;
}
+
+ /// <summary>Get the asset key for a tilesheet in the game's <c>Maps</c> content folder.</summary>
+ /// <param name="relativePath">The tilesheet image source.</param>
+ private string GetContentKeyForTilesheetImageSource(string relativePath)
+ {
+ string key = relativePath;
+ string topFolder = PathUtilities.GetSegments(key, limit: 2)[0];
+
+ // convert image source relative to map file into asset key
+ if (!topFolder.Equals("Maps", StringComparison.OrdinalIgnoreCase))
+ key = Path.Combine("Maps", key);
+
+ // remove file extension from unpacked file
+ if (key.EndsWith(".png", StringComparison.OrdinalIgnoreCase))
+ key = key.Substring(0, key.Length - 4);
+
+ return key;
+ }
}
}
diff --git a/src/SMAPI/Framework/Input/SInputState.cs b/src/SMAPI/Framework/Input/SInputState.cs
index f618608a..23670202 100644
--- a/src/SMAPI/Framework/Input/SInputState.cs
+++ b/src/SMAPI/Framework/Input/SInputState.cs
@@ -65,13 +65,16 @@ namespace StardewModdingAPI.Framework.Input
// update SMAPI extended data
try
{
- float zoomMultiplier = (1f / Game1.options.zoomLevel);
+ float scale = Game1.options.uiScale;
// get real values
var controller = new GamePadStateBuilder(base.GetGamePadState());
var keyboard = new KeyboardStateBuilder(base.GetKeyboardState());
var mouse = new MouseStateBuilder(base.GetMouseState());
- Vector2 cursorAbsolutePos = new Vector2((mouse.X * zoomMultiplier) + Game1.viewport.X, (mouse.Y * zoomMultiplier) + Game1.viewport.Y);
+ Vector2 cursorAbsolutePos = new Vector2(
+ x: (mouse.X / scale) + Game1.uiViewport.X,
+ y: (mouse.Y / scale) + Game1.uiViewport.Y
+ );
Vector2? playerTilePos = Context.IsPlayerFree ? Game1.player.getTileLocation() : (Vector2?)null;
HashSet<SButton> reallyDown = new HashSet<SButton>(this.GetPressedButtons(keyboard, mouse, controller));
@@ -106,7 +109,7 @@ namespace StardewModdingAPI.Framework.Input
if (cursorAbsolutePos != this.CursorPositionImpl?.AbsolutePixels || playerTilePos != this.LastPlayerTile)
{
this.LastPlayerTile = playerTilePos;
- this.CursorPositionImpl = this.GetCursorPosition(this.MouseState, cursorAbsolutePos, zoomMultiplier);
+ this.CursorPositionImpl = this.GetCursorPosition(this.MouseState, cursorAbsolutePos, scale);
}
}
catch (InvalidOperationException)
@@ -199,11 +202,11 @@ namespace StardewModdingAPI.Framework.Input
/// <summary>Get the current cursor position.</summary>
/// <param name="mouseState">The current mouse state.</param>
/// <param name="absolutePixels">The absolute pixel position relative to the map, adjusted for pixel zoom.</param>
- /// <param name="zoomMultiplier">The multiplier applied to pixel coordinates to adjust them for pixel zoom.</param>
- private CursorPosition GetCursorPosition(MouseState mouseState, Vector2 absolutePixels, float zoomMultiplier)
+ /// <param name="scale">The UI scale applied to pixel coordinates.</param>
+ private CursorPosition GetCursorPosition(MouseState mouseState, Vector2 absolutePixels, float scale)
{
- Vector2 screenPixels = new Vector2(mouseState.X * zoomMultiplier, mouseState.Y * zoomMultiplier);
- Vector2 tile = new Vector2((int)((Game1.viewport.X + screenPixels.X) / Game1.tileSize), (int)((Game1.viewport.Y + screenPixels.Y) / Game1.tileSize));
+ Vector2 screenPixels = new Vector2(mouseState.X / scale, mouseState.Y / scale);
+ Vector2 tile = new Vector2((int)((Game1.uiViewport.X + screenPixels.X) / Game1.tileSize), (int)((Game1.uiViewport.Y + screenPixels.Y) / Game1.tileSize));
Vector2 grabTile = (Game1.mouseCursorTransparency > 0 && Utility.tileWithinRadiusOfPlayer((int)tile.X, (int)tile.Y, 1, Game1.player)) // derived from Game1.pressActionButton
? tile
: Game1.player.GetGrabTile();
diff --git a/src/SMAPI/Framework/InternalExtensions.cs b/src/SMAPI/Framework/InternalExtensions.cs
index b6704f26..ba1879da 100644
--- a/src/SMAPI/Framework/InternalExtensions.cs
+++ b/src/SMAPI/Framework/InternalExtensions.cs
@@ -1,11 +1,13 @@
using System;
using System.Collections.Generic;
+using System.Linq;
using System.Reflection;
using System.Threading;
using Microsoft.Xna.Framework.Graphics;
using StardewModdingAPI.Framework.Events;
using StardewModdingAPI.Framework.Reflection;
using StardewValley;
+using StardewValley.Menus;
namespace StardewModdingAPI.Framework
{
@@ -154,6 +156,22 @@ namespace StardewModdingAPI.Framework
}
/****
+ ** IActiveClickableMenu
+ ****/
+ /// <summary>Get a string representation of the menu chain to the given menu (including the specified menu), in parent to child order.</summary>
+ /// <param name="menu">The menu whose chain to get.</param>
+ public static string GetMenuChainLabel(this IClickableMenu menu)
+ {
+ static IEnumerable<IClickableMenu> GetAncestors(IClickableMenu menu)
+ {
+ for (; menu != null; menu = menu.GetParentMenu())
+ yield return menu;
+ }
+
+ return string.Join(" > ", GetAncestors(menu).Reverse().Select(p => p.GetType().FullName));
+ }
+
+ /****
** Sprite batch
****/
/// <summary>Get whether the sprite batch is between a begin and end pair.</summary>
diff --git a/src/SMAPI/Framework/Logging/LogManager.cs b/src/SMAPI/Framework/Logging/LogManager.cs
index 1e484709..ee013a85 100644
--- a/src/SMAPI/Framework/Logging/LogManager.cs
+++ b/src/SMAPI/Framework/Logging/LogManager.cs
@@ -32,10 +32,10 @@ namespace StardewModdingAPI.Framework.Logging
private readonly Regex[] SuppressConsolePatterns =
{
new Regex(@"^TextBox\.Selected is now '(?:True|False)'\.$", RegexOptions.Compiled | RegexOptions.CultureInvariant),
- new Regex(@"^(?:FRUIT )?TREE: IsClient:(?:True|False) randomOutput: \d+$", RegexOptions.Compiled | RegexOptions.CultureInvariant),
new Regex(@"^loadPreferences\(\); begin", RegexOptions.Compiled | RegexOptions.CultureInvariant),
new Regex(@"^savePreferences\(\); async=", RegexOptions.Compiled | RegexOptions.CultureInvariant),
- new Regex(@"^DebugOutput:\s+(?:added CLOUD|added cricket|dismount tile|Ping|playerPos)", RegexOptions.Compiled | RegexOptions.CultureInvariant)
+ new Regex(@"^DebugOutput:\s+(?:added cricket|dismount tile|Ping|playerPos)", RegexOptions.Compiled | RegexOptions.CultureInvariant),
+ new Regex(@"^Ignoring keys: ", RegexOptions.Compiled | RegexOptions.CultureInvariant)
};
/// <summary>Regex patterns which match console messages to show a more friendly error for.</summary>
@@ -84,10 +84,11 @@ namespace StardewModdingAPI.Framework.Logging
/// <param name="writeToConsole">Whether to output log messages to the console.</param>
/// <param name="isVerbose">Whether verbose logging is enabled. This enables more detailed diagnostic messages than are normally needed.</param>
/// <param name="isDeveloperMode">Whether to enable full console output for developers.</param>
- public LogManager(string logPath, ColorSchemeConfig colorConfig, bool writeToConsole, bool isVerbose, bool isDeveloperMode)
+ /// <param name="getScreenIdForLog">Get the screen ID that should be logged to distinguish between players in split-screen mode, if any.</param>
+ public LogManager(string logPath, ColorSchemeConfig colorConfig, bool writeToConsole, bool isVerbose, bool isDeveloperMode, Func<int?> getScreenIdForLog)
{
// init construction logic
- this.GetMonitorImpl = name => new Monitor(name, this.IgnoreChar, this.LogFile, colorConfig, isVerbose)
+ this.GetMonitorImpl = name => new Monitor(name, this.IgnoreChar, this.LogFile, colorConfig, isVerbose, getScreenIdForLog)
{
WriteToConsole = writeToConsole,
ShowTraceInConsole = isDeveloperMode,
diff --git a/src/SMAPI/Framework/ModHelpers/DataHelper.cs b/src/SMAPI/Framework/ModHelpers/DataHelper.cs
index 41612387..0fe3209f 100644
--- a/src/SMAPI/Framework/ModHelpers/DataHelper.cs
+++ b/src/SMAPI/Framework/ModHelpers/DataHelper.cs
@@ -69,8 +69,8 @@ namespace StardewModdingAPI.Framework.ModHelpers
{
if (Context.LoadStage == LoadStage.None)
throw new InvalidOperationException($"Can't use {nameof(IMod.Helper)}.{nameof(IModHelper.Data)}.{nameof(this.ReadSaveData)} when a save file isn't loaded.");
- if (!Game1.IsMasterGame)
- throw new InvalidOperationException($"Can't use {nameof(IMod.Helper)}.{nameof(IModHelper.Data)}.{nameof(this.ReadSaveData)} because this isn't the main player. (Save files are stored on the main player's computer.)");
+ if (!Context.IsOnHostComputer)
+ throw new InvalidOperationException($"Can't use {nameof(IMod.Helper)}.{nameof(IModHelper.Data)}.{nameof(this.ReadSaveData)} when connected to a remote host. (Save files are stored on the main player's computer.)");
string internalKey = this.GetSaveFileKey(key);
@@ -87,8 +87,8 @@ namespace StardewModdingAPI.Framework.ModHelpers
{
if (Context.LoadStage == LoadStage.None)
throw new InvalidOperationException($"Can't use {nameof(IMod.Helper)}.{nameof(IModHelper.Data)}.{nameof(this.WriteSaveData)} when a save file isn't loaded.");
- if (!Game1.IsMasterGame)
- throw new InvalidOperationException($"Can't use {nameof(IMod.Helper)}.{nameof(IModHelper.Data)}.{nameof(this.WriteSaveData)} because this isn't the main player. (Save files are stored on the main player's computer.)");
+ if (!Context.IsOnHostComputer)
+ throw new InvalidOperationException($"Can't use {nameof(IMod.Helper)}.{nameof(IModHelper.Data)}.{nameof(this.WriteSaveData)} when connected to a remote host. (Save files are stored on the main player's computer.)");
string internalKey = this.GetSaveFileKey(key);
string data = model != null
diff --git a/src/SMAPI/Framework/ModHelpers/InputHelper.cs b/src/SMAPI/Framework/ModHelpers/InputHelper.cs
index 09ce3c65..e1317544 100644
--- a/src/SMAPI/Framework/ModHelpers/InputHelper.cs
+++ b/src/SMAPI/Framework/ModHelpers/InputHelper.cs
@@ -1,3 +1,4 @@
+using System;
using StardewModdingAPI.Framework.Input;
namespace StardewModdingAPI.Framework.ModHelpers
@@ -8,8 +9,8 @@ namespace StardewModdingAPI.Framework.ModHelpers
/*********
** Accessors
*********/
- /// <summary>Manages the game's input state.</summary>
- private readonly SInputState InputState;
+ /// <summary>Manages the game's input state for the current player instance. That may not be the main player in split-screen mode.</summary>
+ private readonly Func<SInputState> CurrentInputState;
/*********
@@ -17,41 +18,41 @@ namespace StardewModdingAPI.Framework.ModHelpers
*********/
/// <summary>Construct an instance.</summary>
/// <param name="modID">The unique ID of the relevant mod.</param>
- /// <param name="inputState">Manages the game's input state.</param>
- public InputHelper(string modID, SInputState inputState)
+ /// <param name="currentInputState">Manages the game's input state for the current player instance. That may not be the main player in split-screen mode.</param>
+ public InputHelper(string modID, Func<SInputState> currentInputState)
: base(modID)
{
- this.InputState = inputState;
+ this.CurrentInputState = currentInputState;
}
/// <inheritdoc />
public ICursorPosition GetCursorPosition()
{
- return this.InputState.CursorPosition;
+ return this.CurrentInputState().CursorPosition;
}
/// <inheritdoc />
public bool IsDown(SButton button)
{
- return this.InputState.IsDown(button);
+ return this.CurrentInputState().IsDown(button);
}
/// <inheritdoc />
public bool IsSuppressed(SButton button)
{
- return this.InputState.IsSuppressed(button);
+ return this.CurrentInputState().IsSuppressed(button);
}
/// <inheritdoc />
public void Suppress(SButton button)
{
- this.InputState.OverrideButton(button, setDown: false);
+ this.CurrentInputState().OverrideButton(button, setDown: false);
}
/// <inheritdoc />
public SButtonState GetState(SButton button)
{
- return this.InputState.GetState(button);
+ return this.CurrentInputState().GetState(button);
}
}
}
diff --git a/src/SMAPI/Framework/ModHelpers/ModHelper.cs b/src/SMAPI/Framework/ModHelpers/ModHelper.cs
index d9fc8621..058bff83 100644
--- a/src/SMAPI/Framework/ModHelpers/ModHelper.cs
+++ b/src/SMAPI/Framework/ModHelpers/ModHelper.cs
@@ -51,7 +51,7 @@ namespace StardewModdingAPI.Framework.ModHelpers
/// <summary>Construct an instance.</summary>
/// <param name="modID">The mod's unique ID.</param>
/// <param name="modDirectory">The full path to the mod's folder.</param>
- /// <param name="inputState">Manages the game's input state.</param>
+ /// <param name="currentInputState">Manages the game's input state for the current player instance. That may not be the main player in split-screen mode.</param>
/// <param name="events">Manages access to events raised by SMAPI.</param>
/// <param name="contentHelper">An API for loading content assets.</param>
/// <param name="contentPackHelper">An API for managing content packs.</param>
@@ -63,7 +63,7 @@ namespace StardewModdingAPI.Framework.ModHelpers
/// <param name="translationHelper">An API for reading translations stored in the mod's <c>i18n</c> folder.</param>
/// <exception cref="ArgumentNullException">An argument is null or empty.</exception>
/// <exception cref="InvalidOperationException">The <paramref name="modDirectory"/> path does not exist on disk.</exception>
- public ModHelper(string modID, string modDirectory, SInputState inputState, IModEvents events, IContentHelper contentHelper, IContentPackHelper contentPackHelper, ICommandHelper commandHelper, IDataHelper dataHelper, IModRegistry modRegistry, IReflectionHelper reflectionHelper, IMultiplayerHelper multiplayer, ITranslationHelper translationHelper)
+ public ModHelper(string modID, string modDirectory, Func<SInputState> currentInputState, IModEvents events, IContentHelper contentHelper, IContentPackHelper contentPackHelper, ICommandHelper commandHelper, IDataHelper dataHelper, IModRegistry modRegistry, IReflectionHelper reflectionHelper, IMultiplayerHelper multiplayer, ITranslationHelper translationHelper)
: base(modID)
{
// validate directory
@@ -77,7 +77,7 @@ namespace StardewModdingAPI.Framework.ModHelpers
this.Content = contentHelper ?? throw new ArgumentNullException(nameof(contentHelper));
this.ContentPacks = contentPackHelper ?? throw new ArgumentNullException(nameof(contentPackHelper));
this.Data = dataHelper ?? throw new ArgumentNullException(nameof(dataHelper));
- this.Input = new InputHelper(modID, inputState);
+ this.Input = new InputHelper(modID, currentInputState);
this.ModRegistry = modRegistry ?? throw new ArgumentNullException(nameof(modRegistry));
this.ConsoleCommands = commandHelper ?? throw new ArgumentNullException(nameof(commandHelper));
this.Reflection = reflectionHelper ?? throw new ArgumentNullException(nameof(reflectionHelper));
diff --git a/src/SMAPI/Framework/Monitor.cs b/src/SMAPI/Framework/Monitor.cs
index 533420a5..04e67d68 100644
--- a/src/SMAPI/Framework/Monitor.cs
+++ b/src/SMAPI/Framework/Monitor.cs
@@ -30,6 +30,9 @@ namespace StardewModdingAPI.Framework
/// <summary>A cache of messages that should only be logged once.</summary>
private readonly HashSet<string> LogOnceCache = new HashSet<string>();
+ /// <summary>Get the screen ID that should be logged to distinguish between players in split-screen mode, if any.</summary>
+ private readonly Func<int?> GetScreenIdForLog;
+
/*********
** Accessors
@@ -56,7 +59,8 @@ namespace StardewModdingAPI.Framework
/// <param name="logFile">The log file to which to write messages.</param>
/// <param name="colorConfig">The colors to use for text written to the SMAPI console.</param>
/// <param name="isVerbose">Whether verbose logging is enabled. This enables more detailed diagnostic messages than are normally needed.</param>
- public Monitor(string source, char ignoreChar, LogFileManager logFile, ColorSchemeConfig colorConfig, bool isVerbose)
+ /// <param name="getScreenIdForLog">Get the screen ID that should be logged to distinguish between players in split-screen mode, if any.</param>
+ public Monitor(string source, char ignoreChar, LogFileManager logFile, ColorSchemeConfig colorConfig, bool isVerbose, Func<int?> getScreenIdForLog)
{
// validate
if (string.IsNullOrWhiteSpace(source))
@@ -68,6 +72,7 @@ namespace StardewModdingAPI.Framework
this.ConsoleWriter = new ColorfulConsoleWriter(Constants.Platform, colorConfig);
this.IgnoreChar = ignoreChar;
this.IsVerbose = isVerbose;
+ this.GetScreenIdForLog = getScreenIdForLog;
}
/// <inheritdoc />
@@ -143,7 +148,9 @@ namespace StardewModdingAPI.Framework
private string GenerateMessagePrefix(string source, ConsoleLogLevel level)
{
string levelStr = level.ToString().ToUpper().PadRight(Monitor.MaxLevelLength);
- return $"[{DateTime.Now:HH:mm:ss} {levelStr} {source}]";
+ int? playerIndex = this.GetScreenIdForLog();
+
+ return $"[{DateTime.Now:HH:mm:ss} {levelStr}{(playerIndex != null ? $" screen_{playerIndex}" : "")} {source}]";
}
}
}
diff --git a/src/SMAPI/Framework/SCore.cs b/src/SMAPI/Framework/SCore.cs
index 1b4c32bb..a7f8fbed 100644
--- a/src/SMAPI/Framework/SCore.cs
+++ b/src/SMAPI/Framework/SCore.cs
@@ -85,17 +85,14 @@ namespace StardewModdingAPI.Framework
private readonly CommandManager CommandManager = new CommandManager();
/// <summary>The underlying game instance.</summary>
- private SGame Game;
-
- /// <summary>Manages input visible to the game.</summary>
- private SInputState Input => SGame.Input;
-
- /// <summary>The game's core multiplayer utility.</summary>
- private SMultiplayer Multiplayer => SGame.Multiplayer;
+ private SGameRunner Game;
/// <summary>SMAPI's content manager.</summary>
private ContentCoordinator ContentCore;
+ /// <summary>The game's core multiplayer utility for the main player.</summary>
+ private SMultiplayer Multiplayer;
+
/// <summary>Tracks the installed mods.</summary>
/// <remarks>This is initialized after the game starts.</remarks>
private readonly ModRegistry ModRegistry = new ModRegistry();
@@ -103,11 +100,6 @@ namespace StardewModdingAPI.Framework
/// <summary>Manages SMAPI events for mods.</summary>
private readonly EventManager EventManager;
- /// <summary>Monitors the entire game state for changes.</summary>
- private WatcherCore Watchers;
-
- /// <summary>A snapshot of the current <see cref="Watchers"/> state.</summary>
- private readonly WatcherSnapshot WatcherSnapshot = new WatcherSnapshot();
/****
** State
@@ -127,25 +119,15 @@ namespace StardewModdingAPI.Framework
/// <summary>Whether post-game-startup initialization has been performed.</summary>
private bool IsInitialized;
+ /// <summary>Whether the player just returned to the title screen.</summary>
+ public bool JustReturnedToTitle { get; set; }
+
/// <summary>The maximum number of consecutive attempts SMAPI should make to recover from an update error.</summary>
private readonly Countdown UpdateCrashTimer = new Countdown(60); // 60 ticks = roughly one second
- /// <summary>The number of ticks until SMAPI should notify mods that the game has loaded.</summary>
- /// <remarks>Skipping a few frames ensures the game finishes initializing the world before mods try to change it.</remarks>
- private readonly Countdown AfterLoadTimer = new Countdown(5);
-
/// <summary>Whether custom content was removed from the save data to avoid a crash.</summary>
private bool IsSaveContentRemoved;
- /// <summary>Whether the game is saving and SMAPI has already raised <see cref="IGameLoopEvents.Saving"/>.</summary>
- private bool IsBetweenSaveEvents;
-
- /// <summary>Whether the game is creating the save file and SMAPI has already raised <see cref="IGameLoopEvents.SaveCreating"/>.</summary>
- private bool IsBetweenCreateEvents;
-
- /// <summary>Whether the player just returned to the title screen.</summary>
- private bool JustReturnedToTitle;
-
/// <summary>Asset interceptors added or removed since the last tick.</summary>
private readonly List<AssetInterceptorChange> ReloadAssetInterceptorsQueue = new List<AssetInterceptorChange>();
@@ -191,7 +173,7 @@ namespace StardewModdingAPI.Framework
if (File.Exists(Constants.ApiUserConfigPath))
JsonConvert.PopulateObject(File.ReadAllText(Constants.ApiUserConfigPath), this.Settings);
- this.LogManager = new LogManager(logPath: logPath, colorConfig: this.Settings.ConsoleColors, writeToConsole: writeToConsole, isVerbose: this.Settings.VerboseLogging, isDeveloperMode: this.Settings.DeveloperMode);
+ this.LogManager = new LogManager(logPath: logPath, colorConfig: this.Settings.ConsoleColors, writeToConsole: writeToConsole, isVerbose: this.Settings.VerboseLogging, isDeveloperMode: this.Settings.DeveloperMode, getScreenIdForLog: this.GetScreenIdForLog);
SCore.PerformanceMonitor = new PerformanceMonitor(this.Monitor);
this.EventManager = new EventManager(this.ModRegistry, SCore.PerformanceMonitor);
@@ -250,22 +232,22 @@ namespace StardewModdingAPI.Framework
LocalizedContentManager.OnLanguageChange += locale => this.OnLocaleChanged();
// override game
- var multiplayer = new SMultiplayer(this.Monitor, this.EventManager, this.Toolkit.JsonHelper, this.ModRegistry, this.Reflection, this.OnModMessageReceived, this.Settings.LogNetworkTraffic);
- var modHooks = new SModHooks(this.OnNewDayAfterFade);
+ this.Multiplayer = new SMultiplayer(this.Monitor, this.EventManager, this.Toolkit.JsonHelper, this.ModRegistry, this.Reflection, this.OnModMessageReceived, this.Settings.LogNetworkTraffic);
SGame.CreateContentManagerImpl = this.CreateContentManager; // must be static since the game accesses it before the SGame constructor is called
- this.Game = new SGame(
+ this.Game = new SGameRunner(
monitor: this.Monitor,
reflection: this.Reflection,
eventManager: this.EventManager,
- modHooks: modHooks,
- multiplayer: multiplayer,
+ modHooks: new SModHooks(this.OnNewDayAfterFade),
+ multiplayer: this.Multiplayer,
exitGameImmediately: this.ExitGameImmediately,
onGameContentLoaded: this.OnGameContentLoaded,
onGameUpdating: this.OnGameUpdating,
+ onPlayerInstanceUpdating: this.OnPlayerInstanceUpdating,
onGameExiting: this.OnGameExiting
);
- StardewValley.Program.gamePtr = this.Game;
+ StardewValley.GameRunner.instance = this.Game;
// apply game patches
new GamePatcher(this.Monitor).Apply(
@@ -422,12 +404,6 @@ namespace StardewModdingAPI.Framework
/// <summary>Raised after the game finishes initializing.</summary>
private void OnGameInitialized()
{
- // set initial state
- this.Input.TrueUpdate();
-
- // init watchers
- this.Watchers = new WatcherCore(this.Input, this.Game.GetObservableLocations());
-
// validate XNB integrity
if (!this.ValidateContentIntegrity())
this.Monitor.Log("SMAPI found problems in your game's content files which are likely to cause errors or crashes. Consider uninstalling XNB mods or reinstalling the game.", LogLevel.Error);
@@ -460,8 +436,6 @@ namespace StardewModdingAPI.Framework
/// <param name="runGameUpdate">Invoke the game's update logic.</param>
private void OnGameUpdating(GameTime gameTime, Action runGameUpdate)
{
- var events = this.EventManager;
-
try
{
/*********
@@ -471,15 +445,6 @@ namespace StardewModdingAPI.Framework
SCore.DeprecationManager.PrintQueued();
SCore.PerformanceMonitor.PrintQueuedAlerts();
- // reapply overrides
- if (this.JustReturnedToTitle)
- {
- if (!(Game1.mapDisplayDevice is SDisplayDevice))
- Game1.mapDisplayDevice = this.GetMapDisplayDevice();
-
- this.JustReturnedToTitle = false;
- }
-
/*********
** First-tick initialization
*********/
@@ -490,25 +455,151 @@ namespace StardewModdingAPI.Framework
}
/*********
+ ** Special cases
+ *********/
+ // Abort if SMAPI is exiting.
+ if (this.CancellationToken.IsCancellationRequested)
+ {
+ this.Monitor.Log("SMAPI shutting down: aborting update.");
+ return;
+ }
+
+ /*********
+ ** Reload assets when interceptors are added/removed
+ *********/
+ if (this.ReloadAssetInterceptorsQueue.Any())
+ {
+ // get unique interceptors
+ AssetInterceptorChange[] interceptors = this.ReloadAssetInterceptorsQueue
+ .GroupBy(p => p.Instance, new ObjectReferenceComparer<object>())
+ .Select(p => p.First())
+ .ToArray();
+ this.ReloadAssetInterceptorsQueue.Clear();
+
+ // log summary
+ this.Monitor.Log("Invalidating cached assets for new editors & loaders...");
+ this.Monitor.Log(
+ " changed: "
+ + string.Join(", ",
+ interceptors
+ .GroupBy(p => p.Mod)
+ .OrderBy(p => p.Key.DisplayName)
+ .Select(modGroup =>
+ $"{modGroup.Key.DisplayName} ("
+ + string.Join(", ", modGroup.GroupBy(p => p.WasAdded).ToDictionary(p => p.Key, p => p.Count()).Select(p => $"{(p.Key ? "added" : "removed")} {p.Value}"))
+ + ")"
+ )
+ )
+ );
+
+ // reload affected assets
+ this.ContentCore.InvalidateCache(asset => interceptors.Any(p => p.CanIntercept(asset)));
+ }
+
+ /*********
+ ** Execute commands
+ *********/
+ while (this.CommandQueue.TryDequeue(out string rawInput))
+ {
+ // parse command
+ string name;
+ string[] args;
+ Command command;
+ try
+ {
+ if (!this.CommandManager.TryParse(rawInput, out name, out args, out command))
+ {
+ this.Monitor.Log("Unknown command; type 'help' for a list of available commands.", LogLevel.Error);
+ continue;
+ }
+ }
+ catch (Exception ex)
+ {
+ this.Monitor.Log($"Failed parsing that command:\n{ex.GetLogSummary()}", LogLevel.Error);
+ continue;
+ }
+
+ // execute command
+ try
+ {
+ command.Callback.Invoke(name, args);
+ }
+ catch (Exception ex)
+ {
+ if (command.Mod != null)
+ command.Mod.LogAsMod($"Mod failed handling that command:\n{ex.GetLogSummary()}", LogLevel.Error);
+ else
+ this.Monitor.Log($"Failed handling that command:\n{ex.GetLogSummary()}", LogLevel.Error);
+ }
+ }
+
+ /*********
+ ** Show in-game warnings (for main player only)
+ *********/
+ // save content removed
+ if (this.IsSaveContentRemoved && Context.IsWorldReady)
+ {
+ this.IsSaveContentRemoved = false;
+ Game1.addHUDMessage(new HUDMessage(this.Translator.Get("warn.invalid-content-removed"), HUDMessage.error_type));
+ }
+
+ /*********
+ ** Run game update
+ *********/
+ runGameUpdate();
+
+ /*********
+ ** Reset crash timer
+ *********/
+ this.UpdateCrashTimer.Reset();
+ }
+ catch (Exception ex)
+ {
+ // log error
+ this.Monitor.Log($"An error occured in the overridden update loop: {ex.GetLogSummary()}", LogLevel.Error);
+
+ // exit if irrecoverable
+ if (!this.UpdateCrashTimer.Decrement())
+ this.ExitGameImmediately("The game crashed when updating, and SMAPI was unable to recover the game.");
+ }
+ finally
+ {
+ SCore.TicksElapsed++;
+ }
+ }
+
+ /// <summary>Raised when the game instance for a local player is updating (once per <see cref="OnGameUpdating"/> per player).</summary>
+ /// <param name="instance">The game instance being updated.</param>
+ /// <param name="gameTime">A snapshot of the game timing state.</param>
+ /// <param name="runUpdate">Invoke the game's update logic.</param>
+ private void OnPlayerInstanceUpdating(SGame instance, GameTime gameTime, Action runUpdate)
+ {
+ var events = this.EventManager;
+
+ try
+ {
+ // reapply overrides
+ if (this.JustReturnedToTitle)
+ {
+ if (!(Game1.mapDisplayDevice is SDisplayDevice))
+ Game1.mapDisplayDevice = this.GetMapDisplayDevice();
+
+ this.JustReturnedToTitle = false;
+ }
+
+ /*********
** Update input
*********/
// This should *always* run, even when suppressing mod events, since the game uses
// this too. For example, doing this after mod event suppression would prevent the
// user from doing anything on the overnight shipping screen.
- SInputState inputState = this.Input;
+ SInputState inputState = instance.Input;
if (this.Game.IsActive)
inputState.TrueUpdate();
/*********
** Special cases
*********/
- // Abort if SMAPI is exiting.
- if (this.CancellationToken.IsCancellationRequested)
- {
- this.Monitor.Log("SMAPI shutting down: aborting update.");
- return;
- }
-
// Run async tasks synchronously to avoid issues due to mod events triggering
// concurrently with game code.
bool saveParsed = false;
@@ -544,10 +635,10 @@ namespace StardewModdingAPI.Framework
this.Monitor.Log("Game loader done.");
}
- if (SGame.NewDayTask?.Status == TaskStatus.Created)
+ if (instance.NewDayTask?.Status == TaskStatus.Created)
{
this.Monitor.Log("New day task synchronizing...");
- SGame.NewDayTask.RunSynchronously();
+ instance.NewDayTask.RunSynchronously();
this.Monitor.Log("New day task done.");
}
@@ -560,11 +651,10 @@ namespace StardewModdingAPI.Framework
// a small chance that the task will finish after we defer but before the game checks,
// which means technically events should be raised, but the effects of missing one
// update tick are negligible and not worth the complications of bypassing Game1.Update.
- if (SGame.NewDayTask != null || Game1.gameMode == Game1.loadingMode)
+ if (instance.NewDayTask != null || Game1.gameMode == Game1.loadingMode)
{
events.UnvalidatedUpdateTicking.RaiseEmpty();
- SCore.TicksElapsed++;
- runGameUpdate();
+ runUpdate();
events.UnvalidatedUpdateTicked.RaiseEmpty();
return;
}
@@ -578,132 +668,52 @@ namespace StardewModdingAPI.Framework
if (Context.IsSaving)
{
// raise before-create
- if (!Context.IsWorldReady && !this.IsBetweenCreateEvents)
+ if (!Context.IsWorldReady && !instance.IsBetweenCreateEvents)
{
- this.IsBetweenCreateEvents = true;
+ instance.IsBetweenCreateEvents = true;
this.Monitor.Log("Context: before save creation.");
events.SaveCreating.RaiseEmpty();
}
// raise before-save
- if (Context.IsWorldReady && !this.IsBetweenSaveEvents)
+ if (Context.IsWorldReady && !instance.IsBetweenSaveEvents)
{
- this.IsBetweenSaveEvents = true;
+ instance.IsBetweenSaveEvents = true;
this.Monitor.Log("Context: before save.");
events.Saving.RaiseEmpty();
}
// suppress non-save events
events.UnvalidatedUpdateTicking.RaiseEmpty();
- SCore.TicksElapsed++;
- runGameUpdate();
+ runUpdate();
events.UnvalidatedUpdateTicked.RaiseEmpty();
return;
}
/*********
- ** Reload assets when interceptors are added/removed
- *********/
- if (this.ReloadAssetInterceptorsQueue.Any())
- {
- // get unique interceptors
- AssetInterceptorChange[] interceptors = this.ReloadAssetInterceptorsQueue
- .GroupBy(p => p.Instance, new ObjectReferenceComparer<object>())
- .Select(p => p.First())
- .ToArray();
- this.ReloadAssetInterceptorsQueue.Clear();
-
- // log summary
- this.Monitor.Log("Invalidating cached assets for new editors & loaders...");
- this.Monitor.Log(
- " changed: "
- + string.Join(", ",
- interceptors
- .GroupBy(p => p.Mod)
- .OrderBy(p => p.Key.DisplayName)
- .Select(modGroup =>
- $"{modGroup.Key.DisplayName} ("
- + string.Join(", ", modGroup.GroupBy(p => p.WasAdded).ToDictionary(p => p.Key, p => p.Count()).Select(p => $"{(p.Key ? "added" : "removed")} {p.Value}"))
- + ")"
- )
- )
- );
-
- // reload affected assets
- this.ContentCore.InvalidateCache(asset => interceptors.Any(p => p.CanIntercept(asset)));
- }
-
- /*********
- ** Execute commands
- *********/
- while (this.CommandQueue.TryDequeue(out string rawInput))
- {
- // parse command
- string name;
- string[] args;
- Command command;
- try
- {
- if (!this.CommandManager.TryParse(rawInput, out name, out args, out command))
- {
- this.Monitor.Log("Unknown command; type 'help' for a list of available commands.", LogLevel.Error);
- continue;
- }
- }
- catch (Exception ex)
- {
- this.Monitor.Log($"Failed parsing that command:\n{ex.GetLogSummary()}", LogLevel.Error);
- continue;
- }
-
- // execute command
- try
- {
- command.Callback.Invoke(name, args);
- }
- catch (Exception ex)
- {
- if (command.Mod != null)
- command.Mod.LogAsMod($"Mod failed handling that command:\n{ex.GetLogSummary()}", LogLevel.Error);
- else
- this.Monitor.Log($"Failed handling that command:\n{ex.GetLogSummary()}", LogLevel.Error);
- }
- }
-
- /*********
** Update context
*********/
bool wasWorldReady = Context.IsWorldReady;
if ((Context.IsWorldReady && !Context.IsSaveLoaded) || Game1.exitToTitle)
{
Context.IsWorldReady = false;
- this.AfterLoadTimer.Reset();
+ instance.AfterLoadTimer.Reset();
}
- else if (Context.IsSaveLoaded && this.AfterLoadTimer.Current > 0 && Game1.currentLocation != null)
+ else if (Context.IsSaveLoaded && instance.AfterLoadTimer.Current > 0 && Game1.currentLocation != null)
{
if (Game1.dayOfMonth != 0) // wait until new-game intro finishes (world not fully initialized yet)
- this.AfterLoadTimer.Decrement();
- Context.IsWorldReady = this.AfterLoadTimer.Current == 0;
+ instance.AfterLoadTimer.Decrement();
+ Context.IsWorldReady = instance.AfterLoadTimer.Current == 0;
}
/*********
** Update watchers
** (Watchers need to be updated, checked, and reset in one go so we can detect any changes mods make in event handlers.)
*********/
- this.Watchers.Update();
- this.WatcherSnapshot.Update(this.Watchers);
- this.Watchers.Reset();
- WatcherSnapshot state = this.WatcherSnapshot;
-
- /*********
- ** Display in-game warnings
- *********/
- // save content removed
- if (this.IsSaveContentRemoved && Context.IsWorldReady)
- {
- this.IsSaveContentRemoved = false;
- Game1.addHUDMessage(new HUDMessage(this.Translator.Get("warn.invalid-content-removed"), HUDMessage.error_type));
- }
+ instance.Watchers.Update();
+ instance.WatcherSnapshot.Update(instance.Watchers);
+ instance.Watchers.Reset();
+ WatcherSnapshot state = instance.WatcherSnapshot;
/*********
** Pre-update events
@@ -712,19 +722,19 @@ namespace StardewModdingAPI.Framework
/*********
** Save created/loaded events
*********/
- if (this.IsBetweenCreateEvents)
+ if (instance.IsBetweenCreateEvents)
{
// raise after-create
- this.IsBetweenCreateEvents = false;
+ instance.IsBetweenCreateEvents = false;
this.Monitor.Log($"Context: after save creation, starting {Game1.currentSeason} {Game1.dayOfMonth} Y{Game1.year}.");
this.OnLoadStageChanged(LoadStage.CreatedSaveFile);
events.SaveCreated.RaiseEmpty();
}
- if (this.IsBetweenSaveEvents)
+ if (instance.IsBetweenSaveEvents)
{
// raise after-save
- this.IsBetweenSaveEvents = false;
+ instance.IsBetweenSaveEvents = false;
this.Monitor.Log($"Context: after save, starting {Game1.currentSeason} {Game1.dayOfMonth} Y{Game1.year}.");
events.Saved.RaiseEmpty();
events.DayStarted.RaiseEmpty();
@@ -785,7 +795,7 @@ namespace StardewModdingAPI.Framework
bool isChatInput = Game1.IsChatting || (Context.IsMultiplayer && Context.IsWorldReady && Game1.activeClickableMenu == null && Game1.currentMinigame == null && inputState.IsAnyDown(Game1.options.chatButton));
if (!isChatInput)
{
- ICursorPosition cursor = this.Input.CursorPosition;
+ ICursorPosition cursor = instance.Input.CursorPosition;
// raise cursor moved event
if (state.Cursor.IsChanged)
@@ -950,9 +960,8 @@ namespace StardewModdingAPI.Framework
/*********
** Game update
*********/
- // game launched
- bool isFirstTick = SCore.TicksElapsed == 0;
- if (isFirstTick)
+ // game launched (not raised for secondary players in split-screen mode)
+ if (instance.IsFirstTick && !Context.IsGameLaunched)
{
Context.IsGameLaunched = true;
events.GameLaunched.Raise(new GameLaunchedEventArgs());
@@ -974,9 +983,8 @@ namespace StardewModdingAPI.Framework
events.OneSecondUpdateTicking.RaiseEmpty();
try
{
- this.Input.ApplyOverrides(); // if mods added any new overrides since the update, process them now
- SCore.TicksElapsed++;
- runGameUpdate();
+ instance.Input.ApplyOverrides(); // if mods added any new overrides since the update, process them now
+ runUpdate();
}
catch (Exception ex)
{
@@ -1113,6 +1121,13 @@ namespace StardewModdingAPI.Framework
return this.ContentCore.CreateGameContentManager("(generated)");
}
+ /// <summary>Get the current game instance. This may not be the main player if playing in split-screen.</summary>
+ private SGame GetCurrentGameInstance()
+ {
+ return Game1.game1 as SGame
+ ?? throw new InvalidOperationException("The current game instance wasn't created by SMAPI.");
+ }
+
/// <summary>Look for common issues with the game's XNB content, and log warnings if anything looks broken or outdated.</summary>
/// <returns>Returns whether all integrity checks passed.</returns>
private bool ValidateContentIntegrity()
@@ -1601,7 +1616,7 @@ namespace StardewModdingAPI.Framework
IModRegistry modRegistryHelper = new ModRegistryHelper(manifest.UniqueID, this.ModRegistry, proxyFactory, monitor);
IMultiplayerHelper multiplayerHelper = new MultiplayerHelper(manifest.UniqueID, this.Multiplayer);
- modHelper = new ModHelper(manifest.UniqueID, mod.DirectoryPath, this.Input, events, contentHelper, contentPackHelper, commandHelper, dataHelper, modRegistryHelper, reflectionHelper, multiplayerHelper, translationHelper);
+ modHelper = new ModHelper(manifest.UniqueID, mod.DirectoryPath, () => this.GetCurrentGameInstance().Input, events, contentHelper, contentPackHelper, commandHelper, dataHelper, modRegistryHelper, reflectionHelper, multiplayerHelper, translationHelper);
}
// init mod
@@ -1813,5 +1828,14 @@ namespace StardewModdingAPI.Framework
this.Monitor.LogFatal(message);
this.CancellationToken.Cancel();
}
+
+ /// <summary>Get the screen ID that should be logged to distinguish between players in split-screen mode, if any.</summary>
+ private int? GetScreenIdForLog()
+ {
+ if (Context.ScreenId != 0 || (Context.IsWorldReady && Context.IsSplitScreen))
+ return Context.ScreenId;
+
+ return null;
+ }
}
}
diff --git a/src/SMAPI/Framework/SGame.cs b/src/SMAPI/Framework/SGame.cs
index 1c769c3f..f69c5f08 100644
--- a/src/SMAPI/Framework/SGame.cs
+++ b/src/SMAPI/Framework/SGame.cs
@@ -5,9 +5,11 @@ using System.Text;
using System.Threading.Tasks;
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
+using StardewModdingAPI.Events;
using StardewModdingAPI.Framework.Events;
using StardewModdingAPI.Framework.Input;
using StardewModdingAPI.Framework.Reflection;
+using StardewModdingAPI.Framework.StateTracking.Snapshots;
using StardewModdingAPI.Framework.Utilities;
using StardewValley;
using StardewValley.BellsAndWhistles;
@@ -41,30 +43,47 @@ namespace StardewModdingAPI.Framework
/// <summary>Immediately exit the game without saving. This should only be invoked when an irrecoverable fatal error happens that risks save corruption or game-breaking bugs.</summary>
private readonly Action<string> ExitGameImmediately;
- /// <summary>Raised after the game finishes loading its initial content.</summary>
- private readonly Action OnGameContentLoaded;
+ /// <summary>The initial override for <see cref="Input"/>. This value is null after initialization.</summary>
+ private SInputState InitialInput;
- /// <summary>Raised when the game is updating its state (roughly 60 times per second).</summary>
- private readonly Action<GameTime, Action> OnGameUpdating;
+ /// <summary>The initial override for <see cref="Multiplayer"/>. This value is null after initialization.</summary>
+ private SMultiplayer InitialMultiplayer;
- /// <summary>Raised before the game exits.</summary>
- private readonly Action OnGameExiting;
+ /// <summary>Raised when the instance is updating its state (roughly 60 times per second).</summary>
+ private readonly Action<SGame, GameTime, Action> OnUpdating;
/*********
** Accessors
*********/
/// <summary>Manages input visible to the game.</summary>
- public static SInputState Input => (SInputState)Game1.input;
-
- /// <summary>The game's core multiplayer utility.</summary>
- public static SMultiplayer Multiplayer => (SMultiplayer)Game1.multiplayer;
+ public SInputState Input => (SInputState)Game1.input;
/// <summary>The game background task which initializes a new day.</summary>
- public static Task NewDayTask => Game1._newDayTask;
+ public Task NewDayTask => Game1._newDayTask;
+
+ /// <summary>Monitors the entire game state for changes.</summary>
+ public WatcherCore Watchers { get; private set; }
+
+ /// <summary>A snapshot of the current <see cref="Watchers"/> state.</summary>
+ public WatcherSnapshot WatcherSnapshot { get; } = new WatcherSnapshot();
+
+ /// <summary>Whether the current update tick is the first one for this instance.</summary>
+ public bool IsFirstTick = true;
+
+ /// <summary>The number of ticks until SMAPI should notify mods that the game has loaded.</summary>
+ /// <remarks>Skipping a few frames ensures the game finishes initializing the world before mods try to change it.</remarks>
+ public Countdown AfterLoadTimer { get; } = new Countdown(5);
+
+ /// <summary>Whether the game is saving and SMAPI has already raised <see cref="IGameLoopEvents.Saving"/>.</summary>
+ public bool IsBetweenSaveEvents { get; set; }
+
+ /// <summary>Whether the game is creating the save file and SMAPI has already raised <see cref="IGameLoopEvents.SaveCreating"/>.</summary>
+ public bool IsBetweenCreateEvents { get; set; }
/// <summary>Construct a content manager to read game content files.</summary>
/// <remarks>This must be static because the game accesses it before the <see cref="SGame"/> constructor is called.</remarks>
+ [NonInstancedStatic]
public static Func<IServiceProvider, string, LocalizedContentManager> CreateContentManagerImpl;
@@ -72,63 +91,40 @@ namespace StardewModdingAPI.Framework
** Public methods
*********/
/// <summary>Construct an instance.</summary>
+ /// <param name="playerIndex">The player index.</param>
+ /// <param name="instanceIndex">The instance index.</param>
/// <param name="monitor">Encapsulates monitoring and logging for SMAPI.</param>
/// <param name="reflection">Simplifies access to private game code.</param>
/// <param name="eventManager">Manages SMAPI events for mods.</param>
+ /// <param name="input">Manages the game's input state.</param>
/// <param name="modHooks">Handles mod hooks provided by the game.</param>
/// <param name="multiplayer">The core multiplayer logic.</param>
/// <param name="exitGameImmediately">Immediately exit the game without saving. This should only be invoked when an irrecoverable fatal error happens that risks save corruption or game-breaking bugs.</param>
- /// <param name="onGameContentLoaded">Raised after the game finishes loading its initial content.</param>
- /// <param name="onGameUpdating">Raised when the game is updating its state (roughly 60 times per second).</param>
- /// <param name="onGameExiting">Raised before the game exits.</param>
- public SGame(Monitor monitor, Reflector reflection, EventManager eventManager, SModHooks modHooks, SMultiplayer multiplayer, Action<string> exitGameImmediately, Action onGameContentLoaded, Action<GameTime, Action> onGameUpdating, Action onGameExiting)
+ /// <param name="onUpdating">Raised when the instance is updating its state (roughly 60 times per second).</param>
+ public SGame(PlayerIndex playerIndex, int instanceIndex, Monitor monitor, Reflector reflection, EventManager eventManager, SInputState input, SModHooks modHooks, SMultiplayer multiplayer, Action<string> exitGameImmediately, Action<SGame, GameTime, Action> onUpdating)
+ : base(playerIndex, instanceIndex)
{
// init XNA
Game1.graphics.GraphicsProfile = GraphicsProfile.HiDef;
// hook into game
- Game1.input = new SInputState();
- Game1.multiplayer = multiplayer;
+ Game1.input = this.InitialInput = input;
+ Game1.multiplayer = this.InitialMultiplayer = multiplayer;
Game1.hooks = modHooks;
- Game1.locations = new ObservableCollection<GameLocation>();
+ this._locations = new ObservableCollection<GameLocation>();
// init SMAPI
this.Monitor = monitor;
this.Events = eventManager;
this.Reflection = reflection;
this.ExitGameImmediately = exitGameImmediately;
- this.OnGameContentLoaded = onGameContentLoaded;
- this.OnGameUpdating = onGameUpdating;
- this.OnGameExiting = onGameExiting;
- }
-
- /// <summary>Get the observable location list.</summary>
- public ObservableCollection<GameLocation> GetObservableLocations()
- {
- return (ObservableCollection<GameLocation>)Game1.locations;
+ this.OnUpdating = onUpdating;
}
/*********
** Protected methods
*********/
- /// <summary>Load content when the game is launched.</summary>
- protected override void LoadContent()
- {
- base.LoadContent();
-
- this.OnGameContentLoaded();
- }
-
- /// <summary>Perform cleanup logic when the game exits.</summary>
- /// <param name="sender">The event sender.</param>
- /// <param name="args">The event args.</param>
- /// <remarks>This overrides the logic in <see cref="Game1.exitEvent"/> to let SMAPI clean up before exit.</remarks>
- protected override void OnExiting(object sender, EventArgs args)
- {
- this.OnGameExiting();
- }
-
/// <summary>Construct a content manager to read game content files.</summary>
/// <param name="serviceProvider">The service provider to use to locate services.</param>
/// <param name="rootDirectory">The root directory to search for content.</param>
@@ -140,11 +136,42 @@ namespace StardewModdingAPI.Framework
return SGame.CreateContentManagerImpl(serviceProvider, rootDirectory);
}
- /// <summary>The method called when the game is updating its state (roughly 60 times per second).</summary>
+ /// <summary>Initialize the instance when the game starts.</summary>
+ protected override void Initialize()
+ {
+ base.Initialize();
+
+ // The game resets public static fields after the class is constructed (see
+ // GameRunner.SetInstanceDefaults), so SMAPI needs to re-override them here.
+ Game1.input = this.InitialInput;
+ Game1.multiplayer = this.InitialMultiplayer;
+
+ // The Initial* fields should no longer be used after this point, since mods may
+ // further override them after initialization.
+ this.InitialInput = null;
+ this.InitialMultiplayer = null;
+ }
+
+ /// <summary>The method called when the instance is updating its state (roughly 60 times per second).</summary>
/// <param name="gameTime">A snapshot of the game timing state.</param>
protected override void Update(GameTime gameTime)
{
- this.OnGameUpdating(gameTime, () => base.Update(gameTime));
+ // set initial state
+ if (this.IsFirstTick)
+ {
+ this.Input.TrueUpdate();
+ this.Watchers = new WatcherCore(this.Input, (ObservableCollection<GameLocation>)this._locations);
+ }
+
+ // update
+ try
+ {
+ this.OnUpdating(this, gameTime, () => base.Update(gameTime));
+ }
+ finally
+ {
+ this.IsFirstTick = false;
+ }
}
/// <summary>The method called to draw everything to the screen.</summary>
@@ -171,7 +198,7 @@ namespace StardewModdingAPI.Framework
return;
}
- // recover sprite batch
+ // recover draw state
try
{
if (Game1.spriteBatch.IsOpen(this.Reflection))
@@ -179,10 +206,14 @@ namespace StardewModdingAPI.Framework
this.Monitor.Log("Recovering sprite batch from error...");
Game1.spriteBatch.End();
}
+
+ Game1.uiMode = false;
+ Game1.uiModeCount = 0;
+ Game1.nonUIRenderTarget = null;
}
catch (Exception innerEx)
{
- this.Monitor.Log($"Could not recover sprite batch state: {innerEx.GetLogSummary()}", LogLevel.Error);
+ this.Monitor.Log($"Could not recover game draw state: {innerEx.GetLogSummary()}", LogLevel.Error);
}
}
Context.IsInDrawLoop = false;
@@ -197,9 +228,11 @@ namespace StardewModdingAPI.Framework
[SuppressMessage("ReSharper", "LocalVariableHidesMember", Justification = "copied from game code as-is")]
[SuppressMessage("ReSharper", "PossibleLossOfFraction", Justification = "copied from game code as-is")]
[SuppressMessage("ReSharper", "RedundantArgumentDefaultValue", Justification = "copied from game code as-is")]
+ [SuppressMessage("ReSharper", "RedundantBaseQualifier", Justification = "copied from game code as-is")]
[SuppressMessage("ReSharper", "RedundantCast", Justification = "copied from game code as-is")]
[SuppressMessage("ReSharper", "RedundantExplicitNullableCreation", Justification = "copied from game code as-is")]
[SuppressMessage("ReSharper", "RedundantTypeArgumentsOfMethod", Justification = "copied from game code as-is")]
+ [SuppressMessage("ReSharper", "IdentifierTypo", Justification = "copied from game code as-is")]
[SuppressMessage("SMAPI.CommonErrors", "AvoidNetField", Justification = "copied from game code as-is")]
[SuppressMessage("SMAPI.CommonErrors", "AvoidImplicitNetFieldCast", Justification = "copied from game code as-is")]
private void DrawImpl(GameTime gameTime, RenderTarget2D target_screen)
@@ -207,7 +240,7 @@ namespace StardewModdingAPI.Framework
var events = this.Events;
Game1.showingHealthBar = false;
- if (Game1._newDayTask != null)
+ if (Game1._newDayTask != null || this.isLocalMultiplayerNewDayActive)
{
base.GraphicsDevice.Clear(Game1.bgColor);
return;
@@ -219,6 +252,7 @@ namespace StardewModdingAPI.Framework
if (this.IsSaving)
{
base.GraphicsDevice.Clear(Game1.bgColor);
+ Game1.PushUIMode();
IClickableMenu menu = Game1.activeClickableMenu;
if (menu != null)
{
@@ -244,53 +278,49 @@ namespace StardewModdingAPI.Framework
Game1.overlayMenu.draw(Game1.spriteBatch);
Game1.spriteBatch.End();
}
- this.renderScreenBuffer(target_screen);
+ Game1.PopUIMode();
return;
}
base.GraphicsDevice.Clear(Game1.bgColor);
if (Game1.activeClickableMenu != null && Game1.options.showMenuBackground && Game1.activeClickableMenu.showWithoutTransparencyIfOptionIsSet() && !this.takingMapScreenshot)
{
+ Game1.PushUIMode();
Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointClamp, null, null);
events.Rendering.RaiseEmpty();
+ IClickableMenu curMenu = null;
try
{
Game1.activeClickableMenu.drawBackground(Game1.spriteBatch);
events.RenderingActiveMenu.RaiseEmpty();
- Game1.activeClickableMenu.draw(Game1.spriteBatch);
+ for (curMenu = Game1.activeClickableMenu; curMenu != null; curMenu = curMenu.GetChildMenu())
+ {
+ curMenu.draw(Game1.spriteBatch);
+ }
events.RenderedActiveMenu.RaiseEmpty();
}
catch (Exception ex)
{
- this.Monitor.Log($"The {Game1.activeClickableMenu.GetType().FullName} menu crashed while drawing itself. SMAPI will force it to exit to avoid crashing the game.\n{ex.GetLogSummary()}", LogLevel.Error);
+ this.Monitor.Log($"The {curMenu.GetMenuChainLabel()} menu crashed while drawing itself. SMAPI will force it to exit to avoid crashing the game.\n{ex.GetLogSummary()}", LogLevel.Error);
Game1.activeClickableMenu.exitThisMenu();
}
events.Rendered.RaiseEmpty();
- Game1.spriteBatch.End();
- this.drawOverlays(Game1.spriteBatch);
- if (target_screen != null)
+ if (Game1.specialCurrencyDisplay != null)
{
- base.GraphicsDevice.SetRenderTarget(null);
- base.GraphicsDevice.Clear(Game1.bgColor);
- Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.LinearClamp, DepthStencilState.Default, RasterizerState.CullNone);
- Game1.spriteBatch.Draw(target_screen, Vector2.Zero, target_screen.Bounds, Color.White, 0f, Vector2.Zero, Game1.options.zoomLevel, SpriteEffects.None, 1f);
- Game1.spriteBatch.End();
- }
- if (Game1.overlayMenu != null)
- {
- Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointClamp, null, null);
- Game1.overlayMenu.draw(Game1.spriteBatch);
- Game1.spriteBatch.End();
+ Game1.specialCurrencyDisplay.Draw(Game1.spriteBatch);
}
+ Game1.spriteBatch.End();
+ this.drawOverlays(Game1.spriteBatch);
+ Game1.PopUIMode();
return;
}
if (Game1.gameMode == 11)
{
Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointClamp, null, null);
events.Rendering.RaiseEmpty();
- Game1.spriteBatch.DrawString(Game1.dialogueFont, Game1.content.LoadString("Strings\\StringsFromCSFiles:Game1.cs.3685"), new Vector2(16f, 16f), Color.HotPink);
- Game1.spriteBatch.DrawString(Game1.dialogueFont, Game1.content.LoadString("Strings\\StringsFromCSFiles:Game1.cs.3686"), new Vector2(16f, 32f), new Color(0, 255, 0));
- Game1.spriteBatch.DrawString(Game1.dialogueFont, Game1.parseText(Game1.errorMessage, Game1.dialogueFont, Game1.graphics.GraphicsDevice.Viewport.Width), new Vector2(16f, 48f), Color.White);
+ Game1.spriteBatch.DrawString(Game1.dialogueFont, Game1.content.LoadString("Strings\\StringsFromCSFiles:Game1.cs.3685"), new Vector2(16f, 16f), Microsoft.Xna.Framework.Color.HotPink);
+ Game1.spriteBatch.DrawString(Game1.dialogueFont, Game1.content.LoadString("Strings\\StringsFromCSFiles:Game1.cs.3686"), new Vector2(16f, 32f), new Microsoft.Xna.Framework.Color(0, 255, 0));
+ Game1.spriteBatch.DrawString(Game1.dialogueFont, Game1.parseText(Game1.errorMessage, Game1.dialogueFont, Game1.graphics.GraphicsDevice.Viewport.Width), new Vector2(16f, 48f), Microsoft.Xna.Framework.Color.White);
events.Rendered.RaiseEmpty();
Game1.spriteBatch.End();
return;
@@ -307,64 +337,57 @@ namespace StardewModdingAPI.Framework
Game1.currentMinigame.draw(Game1.spriteBatch);
if (Game1.globalFade && !Game1.menuUp && (!Game1.nameSelectUp || Game1.messagePause))
{
+ Game1.PushUIMode();
Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointClamp, null, null);
- Game1.spriteBatch.Draw(Game1.fadeToBlackRect, Game1.graphics.GraphicsDevice.Viewport.Bounds, Color.Black * ((Game1.gameMode == 0) ? (1f - Game1.fadeToBlackAlpha) : Game1.fadeToBlackAlpha));
+ Game1.spriteBatch.Draw(Game1.fadeToBlackRect, Game1.graphics.GraphicsDevice.Viewport.Bounds, Microsoft.Xna.Framework.Color.Black * ((Game1.gameMode == 0) ? (1f - Game1.fadeToBlackAlpha) : Game1.fadeToBlackAlpha));
Game1.spriteBatch.End();
+ Game1.PopUIMode();
}
+ Game1.PushUIMode();
this.drawOverlays(Game1.spriteBatch);
- if (target_screen != null)
+ Game1.PopUIMode();
+ if (events.Rendered.HasListeners())
{
- base.GraphicsDevice.SetRenderTarget(null);
- base.GraphicsDevice.Clear(Game1.bgColor);
- Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.LinearClamp, DepthStencilState.Default, RasterizerState.CullNone);
- Game1.spriteBatch.Draw(target_screen, Vector2.Zero, target_screen.Bounds, Color.White, 0f, Vector2.Zero, Game1.options.zoomLevel, SpriteEffects.None, 1f);
+ Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointClamp, null, null);
events.Rendered.RaiseEmpty();
Game1.spriteBatch.End();
}
- else
- {
- if (events.Rendered.HasListeners())
- {
- Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointClamp, null, null);
- events.Rendered.RaiseEmpty();
- Game1.spriteBatch.End();
- }
- }
+
+ base.GraphicsDevice.SetRenderTarget(target_screen);
return;
}
if (Game1.showingEndOfNightStuff)
{
+ Game1.PushUIMode();
Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointClamp, null, null);
events.Rendering.RaiseEmpty();
if (Game1.activeClickableMenu != null)
{
+ IClickableMenu curMenu = null;
try
{
events.RenderingActiveMenu.RaiseEmpty();
- Game1.activeClickableMenu.draw(Game1.spriteBatch);
+ for (curMenu = Game1.activeClickableMenu; curMenu != null; curMenu = curMenu.GetChildMenu())
+ {
+ curMenu.draw(Game1.spriteBatch);
+ }
events.RenderedActiveMenu.RaiseEmpty();
}
catch (Exception ex)
{
- this.Monitor.Log($"The {Game1.activeClickableMenu.GetType().FullName} menu crashed while drawing itself during end-of-night-stuff. SMAPI will force it to exit to avoid crashing the game.\n{ex.GetLogSummary()}", LogLevel.Error);
+ this.Monitor.Log($"The {curMenu.GetMenuChainLabel()} menu crashed while drawing itself. SMAPI will force it to exit to avoid crashing the game.\n{ex.GetLogSummary()}", LogLevel.Error);
Game1.activeClickableMenu.exitThisMenu();
}
}
- events.Rendered.RaiseEmpty();
Game1.spriteBatch.End();
this.drawOverlays(Game1.spriteBatch);
- if (target_screen != null)
- {
- base.GraphicsDevice.SetRenderTarget(null);
- base.GraphicsDevice.Clear(Game1.bgColor);
- Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.LinearClamp, DepthStencilState.Default, RasterizerState.CullNone);
- Game1.spriteBatch.Draw(target_screen, Vector2.Zero, target_screen.Bounds, Color.White, 0f, Vector2.Zero, Game1.options.zoomLevel, SpriteEffects.None, 1f);
- Game1.spriteBatch.End();
- }
+ Game1.PopUIMode();
return;
}
if (Game1.gameMode == 6 || (Game1.gameMode == 3 && Game1.currentLocation == null))
{
+ Game1.PushUIMode();
+ base.GraphicsDevice.Clear(Game1.bgColor);
Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointClamp, null, null);
events.Rendering.RaiseEmpty();
string addOn = "";
@@ -383,23 +406,10 @@ namespace StardewModdingAPI.Framework
events.Rendered.RaiseEmpty();
Game1.spriteBatch.End();
this.drawOverlays(Game1.spriteBatch);
- if (target_screen != null)
- {
- base.GraphicsDevice.SetRenderTarget(null);
- base.GraphicsDevice.Clear(Game1.bgColor);
- Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.LinearClamp, DepthStencilState.Default, RasterizerState.CullNone);
- Game1.spriteBatch.Draw(target_screen, Vector2.Zero, target_screen.Bounds, Color.White, 0f, Vector2.Zero, Game1.options.zoomLevel, SpriteEffects.None, 1f);
- Game1.spriteBatch.End();
- }
- if (Game1.overlayMenu != null)
- {
- Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointClamp, null, null);
- Game1.overlayMenu.draw(Game1.spriteBatch);
- Game1.spriteBatch.End();
- }
- //base.Draw(gameTime);
+ Game1.PopUIMode();
return;
}
+
byte batchOpens = 0; // used for rendering event
if (Game1.gameMode == 0)
{
@@ -409,18 +419,43 @@ namespace StardewModdingAPI.Framework
}
else
{
+ if (Game1.gameMode == 3 && Game1.dayOfMonth == 0 && Game1.newDay)
+ {
+ //base.Draw(gameTime);
+ return;
+ }
if (Game1.drawLighting)
{
base.GraphicsDevice.SetRenderTarget(Game1.lightmap);
- base.GraphicsDevice.Clear(Color.White * 0f);
- Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.NonPremultiplied, SamplerState.PointClamp, null, null);
+ base.GraphicsDevice.Clear(Microsoft.Xna.Framework.Color.White * 0f);
+ Matrix lighting_matrix = Matrix.Identity;
+ if (this.useUnscaledLighting)
+ {
+ lighting_matrix = Matrix.CreateScale(Game1.options.zoomLevel);
+ }
+ Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.NonPremultiplied, SamplerState.PointClamp, null, null, null, lighting_matrix);
if (++batchOpens == 1)
events.Rendering.RaiseEmpty();
- Color lighting = (Game1.currentLocation.Name.StartsWith("UndergroundMine") && Game1.currentLocation is MineShaft) ? (Game1.currentLocation as MineShaft).getLightingColor(gameTime) : ((Game1.ambientLight.Equals(Color.White) || (Game1.isRaining && (bool)Game1.currentLocation.isOutdoors)) ? Game1.outdoorLight : Game1.ambientLight);
+ Microsoft.Xna.Framework.Color lighting = (Game1.currentLocation.Name.StartsWith("UndergroundMine") && Game1.currentLocation is MineShaft) ? (Game1.currentLocation as MineShaft).getLightingColor(gameTime) : ((Game1.ambientLight.Equals(Microsoft.Xna.Framework.Color.White) || (Game1.IsRainingHere() && (bool)Game1.currentLocation.isOutdoors)) ? Game1.outdoorLight : Game1.ambientLight);
+ float light_multiplier = 1f;
+ if (Game1.player.hasBuff(26))
+ {
+ if (lighting == Microsoft.Xna.Framework.Color.White)
+ {
+ lighting = new Microsoft.Xna.Framework.Color(0.75f, 0.75f, 0.75f);
+ }
+ else
+ {
+ lighting.R = (byte)Utility.Lerp((int)lighting.R, 255f, 0.5f);
+ lighting.G = (byte)Utility.Lerp((int)lighting.G, 255f, 0.5f);
+ lighting.B = (byte)Utility.Lerp((int)lighting.B, 255f, 0.5f);
+ }
+ light_multiplier = 0.33f;
+ }
Game1.spriteBatch.Draw(Game1.staminaRect, Game1.lightmap.Bounds, lighting);
foreach (LightSource lightSource in Game1.currentLightSources)
{
- if ((Game1.isRaining || Game1.isDarkOut()) && lightSource.lightContext.Value == LightSource.LightContext.WindowLight)
+ if ((Game1.IsRainingHere() || Game1.isDarkOut()) && lightSource.lightContext.Value == LightSource.LightContext.WindowLight)
{
continue;
}
@@ -434,7 +469,7 @@ namespace StardewModdingAPI.Framework
}
if (Utility.isOnScreen(lightSource.position, (int)((float)lightSource.radius * 64f * 4f)))
{
- Game1.spriteBatch.Draw(lightSource.lightTexture, Game1.GlobalToLocal(Game1.viewport, lightSource.position) / (Game1.options.lightingQuality / 2), lightSource.lightTexture.Bounds, lightSource.color, 0f, new Vector2(lightSource.lightTexture.Bounds.Center.X, lightSource.lightTexture.Bounds.Center.Y), (float)lightSource.radius / (float)(Game1.options.lightingQuality / 2), SpriteEffects.None, 0.9f);
+ Game1.spriteBatch.Draw(lightSource.lightTexture, Game1.GlobalToLocal(Game1.viewport, lightSource.position) / (Game1.options.lightingQuality / 2), lightSource.lightTexture.Bounds, lightSource.color.Value * light_multiplier, 0f, new Vector2(lightSource.lightTexture.Bounds.Width / 2, lightSource.lightTexture.Bounds.Height / 2), (float)lightSource.radius / (float)(Game1.options.lightingQuality / 2), SpriteEffects.None, 0.9f);
}
}
Game1.spriteBatch.End();
@@ -453,9 +488,15 @@ namespace StardewModdingAPI.Framework
{
Game1.background.draw(Game1.spriteBatch);
}
+ Game1.currentLocation.drawBackground(Game1.spriteBatch);
Game1.mapDisplayDevice.BeginScene(Game1.spriteBatch);
Game1.currentLocation.Map.GetLayer("Back").Draw(Game1.mapDisplayDevice, Game1.viewport, Location.Origin, wrapAround: false, 4);
Game1.currentLocation.drawWater(Game1.spriteBatch);
+ Game1.spriteBatch.End();
+ Game1.spriteBatch.Begin(SpriteSortMode.FrontToBack, BlendState.AlphaBlend, SamplerState.PointClamp, null, null);
+ Game1.currentLocation.drawFloorDecorations(Game1.spriteBatch);
+ Game1.spriteBatch.End();
+ Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointClamp, null, null);
this._farmerShadows.Clear();
if (Game1.currentLocation.currentEvent != null && !Game1.currentLocation.currentEvent.isFestival && Game1.currentLocation.currentEvent.farmerActors.Count > 0)
{
@@ -483,27 +524,27 @@ namespace StardewModdingAPI.Framework
{
foreach (NPC k in Game1.currentLocation.characters)
{
- if (!k.swimming && !k.HideShadow && !k.IsInvisible && !Game1.currentLocation.shouldShadowBeDrawnAboveBuildingsLayer(k.getTileLocation()))
+ if (!k.swimming && !k.HideShadow && !k.IsInvisible && !this.checkCharacterTilesForShadowDrawFlag(k))
{
- Game1.spriteBatch.Draw(Game1.shadowTexture, Game1.GlobalToLocal(Game1.viewport, k.Position + new Vector2((float)(k.Sprite.SpriteWidth * 4) / 2f, k.GetBoundingBox().Height + ((!k.IsMonster) ? 12 : 0))), Game1.shadowTexture.Bounds, Color.White, 0f, new Vector2(Game1.shadowTexture.Bounds.Center.X, Game1.shadowTexture.Bounds.Center.Y), (4f + (float)k.yJumpOffset / 40f) * (float)k.scale, SpriteEffects.None, Math.Max(0f, (float)k.getStandingY() / 10000f) - 1E-06f);
+ Game1.spriteBatch.Draw(Game1.shadowTexture, Game1.GlobalToLocal(Game1.viewport, k.GetShadowOffset() + k.Position + new Vector2((float)(k.GetSpriteWidthForPositioning() * 4) / 2f, k.GetBoundingBox().Height + ((!k.IsMonster) ? 12 : 0))), Game1.shadowTexture.Bounds, Microsoft.Xna.Framework.Color.White, 0f, new Vector2(Game1.shadowTexture.Bounds.Center.X, Game1.shadowTexture.Bounds.Center.Y), Math.Max(0f, (4f + (float)k.yJumpOffset / 40f) * (float)k.scale), SpriteEffects.None, Math.Max(0f, (float)k.getStandingY() / 10000f) - 1E-06f);
}
}
}
else
{
- foreach (NPC l in Game1.CurrentEvent.actors)
+ foreach (NPC m in Game1.CurrentEvent.actors)
{
- if (!l.swimming && !l.HideShadow && !Game1.currentLocation.shouldShadowBeDrawnAboveBuildingsLayer(l.getTileLocation()))
+ if ((Game1.CurrentEvent == null || !Game1.CurrentEvent.ShouldHideCharacter(m)) && !m.swimming && !m.HideShadow && !this.checkCharacterTilesForShadowDrawFlag(m))
{
- Game1.spriteBatch.Draw(Game1.shadowTexture, Game1.GlobalToLocal(Game1.viewport, l.Position + new Vector2((float)(l.Sprite.SpriteWidth * 4) / 2f, l.GetBoundingBox().Height + ((!l.IsMonster) ? ((l.Sprite.SpriteHeight <= 16) ? (-4) : 12) : 0))), Game1.shadowTexture.Bounds, Color.White, 0f, new Vector2(Game1.shadowTexture.Bounds.Center.X, Game1.shadowTexture.Bounds.Center.Y), (4f + (float)l.yJumpOffset / 40f) * (float)l.scale, SpriteEffects.None, Math.Max(0f, (float)l.getStandingY() / 10000f) - 1E-06f);
+ Game1.spriteBatch.Draw(Game1.shadowTexture, Game1.GlobalToLocal(Game1.viewport, m.GetShadowOffset() + m.Position + new Vector2((float)(m.GetSpriteWidthForPositioning() * 4) / 2f, m.GetBoundingBox().Height + ((!m.IsMonster) ? ((m.Sprite.SpriteHeight <= 16) ? (-4) : 12) : 0))), Game1.shadowTexture.Bounds, Microsoft.Xna.Framework.Color.White, 0f, new Vector2(Game1.shadowTexture.Bounds.Center.X, Game1.shadowTexture.Bounds.Center.Y), Math.Max(0f, 4f + (float)m.yJumpOffset / 40f) * (float)m.scale, SpriteEffects.None, Math.Max(0f, (float)m.getStandingY() / 10000f) - 1E-06f);
}
}
}
foreach (Farmer f3 in this._farmerShadows)
{
- if (!Game1.multiplayer.isDisconnecting(f3.UniqueMultiplayerID) && !f3.swimming && !f3.isRidingHorse() && (Game1.currentLocation == null || !Game1.currentLocation.shouldShadowBeDrawnAboveBuildingsLayer(f3.getTileLocation())))
+ if (!Game1.multiplayer.isDisconnecting(f3.UniqueMultiplayerID) && !f3.swimming && !f3.isRidingHorse() && !f3.IsSitting() && (Game1.currentLocation == null || !this.checkCharacterTilesForShadowDrawFlag(f3)))
{
- Game1.spriteBatch.Draw(Game1.shadowTexture, Game1.GlobalToLocal(f3.Position + new Vector2(32f, 24f)), Game1.shadowTexture.Bounds, Color.White, 0f, new Vector2(Game1.shadowTexture.Bounds.Center.X, Game1.shadowTexture.Bounds.Center.Y), 4f - (((f3.running || f3.UsingTool) && f3.FarmerSprite.currentAnimationIndex > 1) ? ((float)Math.Abs(FarmerRenderer.featureYOffsetPerFrame[f3.FarmerSprite.CurrentFrame]) * 0.5f) : 0f), SpriteEffects.None, 0f);
+ Game1.spriteBatch.Draw(Game1.shadowTexture, Game1.GlobalToLocal(f3.GetShadowOffset() + f3.Position + new Vector2(32f, 24f)), Game1.shadowTexture.Bounds, Microsoft.Xna.Framework.Color.White, 0f, new Vector2(Game1.shadowTexture.Bounds.Center.X, Game1.shadowTexture.Bounds.Center.Y), 4f - (((f3.running || f3.UsingTool) && f3.FarmerSprite.currentAnimationIndex > 1) ? ((float)Math.Abs(FarmerRenderer.featureYOffsetPerFrame[f3.FarmerSprite.CurrentFrame]) * 0.5f) : 0f), SpriteEffects.None, 0f);
}
}
}
@@ -518,9 +559,9 @@ namespace StardewModdingAPI.Framework
{
foreach (NPC n in Game1.currentLocation.characters)
{
- if (!n.swimming && !n.HideShadow && !n.isInvisible && Game1.currentLocation.shouldShadowBeDrawnAboveBuildingsLayer(n.getTileLocation()))
+ if (!n.swimming && !n.HideShadow && !n.isInvisible && this.checkCharacterTilesForShadowDrawFlag(n))
{
- Game1.spriteBatch.Draw(Game1.shadowTexture, Game1.GlobalToLocal(Game1.viewport, n.Position + new Vector2((float)(n.Sprite.SpriteWidth * 4) / 2f, n.GetBoundingBox().Height + ((!n.IsMonster) ? 12 : 0))), Game1.shadowTexture.Bounds, Color.White, 0f, new Vector2(Game1.shadowTexture.Bounds.Center.X, Game1.shadowTexture.Bounds.Center.Y), (4f + (float)n.yJumpOffset / 40f) * (float)n.scale, SpriteEffects.None, Math.Max(0f, (float)n.getStandingY() / 10000f) - 1E-06f);
+ Game1.spriteBatch.Draw(Game1.shadowTexture, Game1.GlobalToLocal(Game1.viewport, n.GetShadowOffset() + n.Position + new Vector2((float)(n.GetSpriteWidthForPositioning() * 4) / 2f, n.GetBoundingBox().Height + ((!n.IsMonster) ? 12 : 0))), Game1.shadowTexture.Bounds, Microsoft.Xna.Framework.Color.White, 0f, new Vector2(Game1.shadowTexture.Bounds.Center.X, Game1.shadowTexture.Bounds.Center.Y), Math.Max(0f, (4f + (float)n.yJumpOffset / 40f) * (float)n.scale), SpriteEffects.None, Math.Max(0f, (float)n.getStandingY() / 10000f) - 1E-06f);
}
}
}
@@ -528,18 +569,18 @@ namespace StardewModdingAPI.Framework
{
foreach (NPC n2 in Game1.CurrentEvent.actors)
{
- if (!n2.swimming && !n2.HideShadow && Game1.currentLocation.shouldShadowBeDrawnAboveBuildingsLayer(n2.getTileLocation()))
+ if ((Game1.CurrentEvent == null || !Game1.CurrentEvent.ShouldHideCharacter(n2)) && !n2.swimming && !n2.HideShadow && this.checkCharacterTilesForShadowDrawFlag(n2))
{
- Game1.spriteBatch.Draw(Game1.shadowTexture, Game1.GlobalToLocal(Game1.viewport, n2.Position + new Vector2((float)(n2.Sprite.SpriteWidth * 4) / 2f, n2.GetBoundingBox().Height + ((!n2.IsMonster) ? 12 : 0))), Game1.shadowTexture.Bounds, Color.White, 0f, new Vector2(Game1.shadowTexture.Bounds.Center.X, Game1.shadowTexture.Bounds.Center.Y), (4f + (float)n2.yJumpOffset / 40f) * (float)n2.scale, SpriteEffects.None, Math.Max(0f, (float)n2.getStandingY() / 10000f) - 1E-06f);
+ Game1.spriteBatch.Draw(Game1.shadowTexture, Game1.GlobalToLocal(Game1.viewport, n2.GetShadowOffset() + n2.Position + new Vector2((float)(n2.GetSpriteWidthForPositioning() * 4) / 2f, n2.GetBoundingBox().Height + ((!n2.IsMonster) ? 12 : 0))), Game1.shadowTexture.Bounds, Microsoft.Xna.Framework.Color.White, 0f, new Vector2(Game1.shadowTexture.Bounds.Center.X, Game1.shadowTexture.Bounds.Center.Y), Math.Max(0f, (4f + (float)n2.yJumpOffset / 40f) * (float)n2.scale), SpriteEffects.None, Math.Max(0f, (float)n2.getStandingY() / 10000f) - 1E-06f);
}
}
}
foreach (Farmer f4 in this._farmerShadows)
{
float draw_layer = Math.Max(0.0001f, f4.getDrawLayer() + 0.00011f) - 0.0001f;
- if (!f4.swimming && !f4.isRidingHorse() && Game1.currentLocation != null && Game1.currentLocation.shouldShadowBeDrawnAboveBuildingsLayer(f4.getTileLocation()))
+ if (!f4.swimming && !f4.isRidingHorse() && !f4.IsSitting() && Game1.currentLocation != null && this.checkCharacterTilesForShadowDrawFlag(f4))
{
- Game1.spriteBatch.Draw(Game1.shadowTexture, Game1.GlobalToLocal(f4.Position + new Vector2(32f, 24f)), Game1.shadowTexture.Bounds, Color.White, 0f, new Vector2(Game1.shadowTexture.Bounds.Center.X, Game1.shadowTexture.Bounds.Center.Y), 4f - (((f4.running || f4.UsingTool) && f4.FarmerSprite.currentAnimationIndex > 1) ? ((float)Math.Abs(FarmerRenderer.featureYOffsetPerFrame[f4.FarmerSprite.CurrentFrame]) * 0.5f) : 0f), SpriteEffects.None, draw_layer);
+ Game1.spriteBatch.Draw(Game1.shadowTexture, Game1.GlobalToLocal(f4.GetShadowOffset() + f4.Position + new Vector2(32f, 24f)), Game1.shadowTexture.Bounds, Microsoft.Xna.Framework.Color.White, 0f, new Vector2(Game1.shadowTexture.Bounds.Center.X, Game1.shadowTexture.Bounds.Center.Y), 4f - (((f4.running || f4.UsingTool) && f4.FarmerSprite.currentAnimationIndex > 1) ? ((float)Math.Abs(FarmerRenderer.featureYOffsetPerFrame[f4.FarmerSprite.CurrentFrame]) * 0.5f) : 0f), SpriteEffects.None, draw_layer);
}
}
}
@@ -549,7 +590,7 @@ namespace StardewModdingAPI.Framework
}
if (Game1.player.currentUpgrade != null && Game1.player.currentUpgrade.daysLeftTillUpgradeDone <= 3 && Game1.currentLocation.Name.Equals("Farm"))
{
- Game1.spriteBatch.Draw(Game1.player.currentUpgrade.workerTexture, Game1.GlobalToLocal(Game1.viewport, Game1.player.currentUpgrade.positionOfCarpenter), Game1.player.currentUpgrade.getSourceRectangle(), Color.White, 0f, Vector2.Zero, 1f, SpriteEffects.None, (Game1.player.currentUpgrade.positionOfCarpenter.Y + 48f) / 10000f);
+ Game1.spriteBatch.Draw(Game1.player.currentUpgrade.workerTexture, Game1.GlobalToLocal(Game1.viewport, Game1.player.currentUpgrade.positionOfCarpenter), Game1.player.currentUpgrade.getSourceRectangle(), Microsoft.Xna.Framework.Color.White, 0f, Vector2.Zero, 1f, SpriteEffects.None, (Game1.player.currentUpgrade.positionOfCarpenter.Y + 48f) / 10000f);
}
Game1.currentLocation.draw(Game1.spriteBatch);
foreach (Vector2 tile_position in Game1.crabPotOverlayTiles.Keys)
@@ -576,14 +617,14 @@ namespace StardewModdingAPI.Framework
}
if (Game1.tvStation >= 0)
{
- Game1.spriteBatch.Draw(Game1.tvStationTexture, Game1.GlobalToLocal(Game1.viewport, new Vector2(400f, 160f)), new Microsoft.Xna.Framework.Rectangle(Game1.tvStation * 24, 0, 24, 15), Color.White, 0f, Vector2.Zero, 4f, SpriteEffects.None, 1E-08f);
+ Game1.spriteBatch.Draw(Game1.tvStationTexture, Game1.GlobalToLocal(Game1.viewport, new Vector2(400f, 160f)), new Microsoft.Xna.Framework.Rectangle(Game1.tvStation * 24, 0, 24, 15), Microsoft.Xna.Framework.Color.White, 0f, Vector2.Zero, 4f, SpriteEffects.None, 1E-08f);
}
if (Game1.panMode)
{
- Game1.spriteBatch.Draw(Game1.fadeToBlackRect, new Microsoft.Xna.Framework.Rectangle((int)Math.Floor((double)(Game1.getOldMouseX() + Game1.viewport.X) / 64.0) * 64 - Game1.viewport.X, (int)Math.Floor((double)(Game1.getOldMouseY() + Game1.viewport.Y) / 64.0) * 64 - Game1.viewport.Y, 64, 64), Color.Lime * 0.75f);
+ Game1.spriteBatch.Draw(Game1.fadeToBlackRect, new Microsoft.Xna.Framework.Rectangle((int)Math.Floor((double)(Game1.getOldMouseX() + Game1.viewport.X) / 64.0) * 64 - Game1.viewport.X, (int)Math.Floor((double)(Game1.getOldMouseY() + Game1.viewport.Y) / 64.0) * 64 - Game1.viewport.Y, 64, 64), Microsoft.Xna.Framework.Color.Lime * 0.75f);
foreach (Warp w in Game1.currentLocation.warps)
{
- Game1.spriteBatch.Draw(Game1.fadeToBlackRect, new Microsoft.Xna.Framework.Rectangle(w.X * 64 - Game1.viewport.X, w.Y * 64 - Game1.viewport.Y, 64, 64), Color.Red * 0.75f);
+ Game1.spriteBatch.Draw(Game1.fadeToBlackRect, new Microsoft.Xna.Framework.Rectangle(w.X * 64 - Game1.viewport.X, w.Y * 64 - Game1.viewport.Y, 64, 64), Microsoft.Xna.Framework.Color.Red * 0.75f);
}
}
Game1.mapDisplayDevice.BeginScene(Game1.spriteBatch);
@@ -592,18 +633,6 @@ namespace StardewModdingAPI.Framework
Game1.currentLocation.drawAboveFrontLayer(Game1.spriteBatch);
Game1.spriteBatch.End();
Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointClamp, null, null);
- if (Game1.displayFarmer && Game1.player.ActiveObject != null && (bool)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"))))
- {
- Game1.drawPlayerHeldObject(Game1.player);
- }
- 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)
{
Game1.mapDisplayDevice.BeginScene(Game1.spriteBatch);
@@ -612,7 +641,7 @@ namespace StardewModdingAPI.Framework
}
if (Game1.toolHold > 400f && Game1.player.CurrentTool.UpgradeLevel >= 1 && Game1.player.canReleaseTool)
{
- Color barColor = Color.White;
+ Microsoft.Xna.Framework.Color barColor = Microsoft.Xna.Framework.Color.White;
switch ((int)(Game1.toolHold / 600f) + 2)
{
case 1:
@@ -628,17 +657,20 @@ namespace StardewModdingAPI.Framework
barColor = Tool.iridiumColor;
break;
}
- Game1.spriteBatch.Draw(Game1.littleEffect, new Microsoft.Xna.Framework.Rectangle((int)Game1.player.getLocalPosition(Game1.viewport).X - 2, (int)Game1.player.getLocalPosition(Game1.viewport).Y - ((!Game1.player.CurrentTool.Name.Equals("Watering Can")) ? 64 : 0) - 2, (int)(Game1.toolHold % 600f * 0.08f) + 4, 12), Color.Black);
+ Game1.spriteBatch.Draw(Game1.littleEffect, new Microsoft.Xna.Framework.Rectangle((int)Game1.player.getLocalPosition(Game1.viewport).X - 2, (int)Game1.player.getLocalPosition(Game1.viewport).Y - ((!Game1.player.CurrentTool.Name.Equals("Watering Can")) ? 64 : 0) - 2, (int)(Game1.toolHold % 600f * 0.08f) + 4, 12), Microsoft.Xna.Framework.Color.Black);
Game1.spriteBatch.Draw(Game1.littleEffect, new Microsoft.Xna.Framework.Rectangle((int)Game1.player.getLocalPosition(Game1.viewport).X, (int)Game1.player.getLocalPosition(Game1.viewport).Y - ((!Game1.player.CurrentTool.Name.Equals("Watering Can")) ? 64 : 0), (int)(Game1.toolHold % 600f * 0.08f), 8), barColor);
}
- this.drawWeather(gameTime, target_screen);
+ if (!Game1.IsFakedBlackScreen())
+ {
+ this.drawWeather(gameTime, target_screen);
+ }
if (Game1.farmEvent != null)
{
Game1.farmEvent.draw(Game1.spriteBatch);
}
if (Game1.currentLocation.LightLevel > 0f && Game1.timeOfDay < 2000)
{
- Game1.spriteBatch.Draw(Game1.fadeToBlackRect, Game1.graphics.GraphicsDevice.Viewport.Bounds, Color.Black * Game1.currentLocation.LightLevel);
+ Game1.spriteBatch.Draw(Game1.fadeToBlackRect, Game1.graphics.GraphicsDevice.Viewport.Bounds, Microsoft.Xna.Framework.Color.Black * Game1.currentLocation.LightLevel);
}
if (Game1.screenGlow)
{
@@ -653,32 +685,44 @@ namespace StardewModdingAPI.Framework
Game1.spriteBatch.Begin(SpriteSortMode.FrontToBack, BlendState.AlphaBlend, SamplerState.PointClamp, null, null);
if (Game1.eventUp && Game1.currentLocation.currentEvent != null)
{
- foreach (NPC m in Game1.currentLocation.currentEvent.actors)
+ foreach (NPC l in Game1.currentLocation.currentEvent.actors)
{
- if (m.isEmoting)
+ if (l.isEmoting)
{
- Vector2 emotePosition = m.getLocalPosition(Game1.viewport);
+ Vector2 emotePosition = l.getLocalPosition(Game1.viewport);
+ if (l.NeedsBirdieEmoteHack())
+ {
+ emotePosition.X += 64f;
+ }
emotePosition.Y -= 140f;
- if (m.Age == 2)
+ if (l.Age == 2)
{
emotePosition.Y += 32f;
}
- else if (m.Gender == 1)
+ else if (l.Gender == 1)
{
emotePosition.Y += 10f;
}
- Game1.spriteBatch.Draw(Game1.emoteSpriteSheet, emotePosition, new Microsoft.Xna.Framework.Rectangle(m.CurrentEmoteIndex * 16 % Game1.emoteSpriteSheet.Width, m.CurrentEmoteIndex * 16 / Game1.emoteSpriteSheet.Width * 16, 16, 16), Color.White, 0f, Vector2.Zero, 4f, SpriteEffects.None, (float)m.getStandingY() / 10000f);
+ Game1.spriteBatch.Draw(Game1.emoteSpriteSheet, emotePosition, new Microsoft.Xna.Framework.Rectangle(l.CurrentEmoteIndex * 16 % Game1.emoteSpriteSheet.Width, l.CurrentEmoteIndex * 16 / Game1.emoteSpriteSheet.Width * 16, 16, 16), Microsoft.Xna.Framework.Color.White, 0f, Vector2.Zero, 4f, SpriteEffects.None, (float)l.getStandingY() / 10000f);
}
}
}
Game1.spriteBatch.End();
- if (Game1.drawLighting)
+ if (Game1.drawLighting && !Game1.IsFakedBlackScreen())
{
Game1.spriteBatch.Begin(SpriteSortMode.Deferred, this.lightingBlend, SamplerState.LinearClamp, null, null);
- Game1.spriteBatch.Draw(Game1.lightmap, Vector2.Zero, Game1.lightmap.Bounds, Color.White, 0f, Vector2.Zero, Game1.options.lightingQuality / 2, SpriteEffects.None, 1f);
- if (Game1.isRaining && (bool)Game1.currentLocation.isOutdoors && !(Game1.currentLocation is Desert))
+ Viewport vp = base.GraphicsDevice.Viewport;
+ vp.Bounds = (target_screen?.Bounds ?? base.GraphicsDevice.PresentationParameters.Bounds);
+ base.GraphicsDevice.Viewport = vp;
+ float render_zoom = Game1.options.lightingQuality / 2;
+ if (this.useUnscaledLighting)
+ {
+ render_zoom /= Game1.options.zoomLevel;
+ }
+ Game1.spriteBatch.Draw(Game1.lightmap, Vector2.Zero, Game1.lightmap.Bounds, Microsoft.Xna.Framework.Color.White, 0f, Vector2.Zero, render_zoom, SpriteEffects.None, 1f);
+ if (Game1.IsRainingHere() && (bool)Game1.currentLocation.isOutdoors && !(Game1.currentLocation is Desert))
{
- Game1.spriteBatch.Draw(Game1.staminaRect, Game1.graphics.GraphicsDevice.Viewport.Bounds, Color.OrangeRed * 0.45f);
+ Game1.spriteBatch.Draw(Game1.staminaRect, vp.Bounds, Microsoft.Xna.Framework.Color.OrangeRed * 0.45f);
}
Game1.spriteBatch.End();
}
@@ -690,11 +734,11 @@ namespace StardewModdingAPI.Framework
float startingY = -Game1.viewport.Y % 64;
for (int x = startingX; x < Game1.graphics.GraphicsDevice.Viewport.Width; x += 64)
{
- Game1.spriteBatch.Draw(Game1.staminaRect, new Microsoft.Xna.Framework.Rectangle(x, (int)startingY, 1, Game1.graphics.GraphicsDevice.Viewport.Height), Color.Red * 0.5f);
+ Game1.spriteBatch.Draw(Game1.staminaRect, new Microsoft.Xna.Framework.Rectangle(x, (int)startingY, 1, Game1.graphics.GraphicsDevice.Viewport.Height), Microsoft.Xna.Framework.Color.Red * 0.5f);
}
for (float y = startingY; y < (float)Game1.graphics.GraphicsDevice.Viewport.Height; y += 64f)
{
- Game1.spriteBatch.Draw(Game1.staminaRect, new Microsoft.Xna.Framework.Rectangle(startingX, (int)y, Game1.graphics.GraphicsDevice.Viewport.Width, 1), Color.Red * 0.5f);
+ Game1.spriteBatch.Draw(Game1.staminaRect, new Microsoft.Xna.Framework.Rectangle(startingX, (int)y, Game1.graphics.GraphicsDevice.Viewport.Width, 1), Microsoft.Xna.Framework.Color.Red * 0.5f);
}
}
if (Game1.currentBillboard != 0 && !this.takingMapScreenshot)
@@ -703,9 +747,14 @@ namespace StardewModdingAPI.Framework
}
if (!Game1.eventUp && Game1.farmEvent == null && Game1.currentBillboard == 0 && Game1.gameMode == 3 && !this.takingMapScreenshot && Game1.isOutdoorMapSmallerThanViewport())
{
- Game1.spriteBatch.Draw(Game1.fadeToBlackRect, new Microsoft.Xna.Framework.Rectangle(0, 0, -Math.Min(Game1.viewport.X, 4096), Game1.graphics.GraphicsDevice.Viewport.Height), Color.Black);
- Game1.spriteBatch.Draw(Game1.fadeToBlackRect, new Microsoft.Xna.Framework.Rectangle(-Game1.viewport.X + Game1.currentLocation.map.Layers[0].LayerWidth * 64, 0, Math.Min(4096, Game1.graphics.GraphicsDevice.Viewport.Width - (-Game1.viewport.X + Game1.currentLocation.map.Layers[0].LayerWidth * 64)), Game1.graphics.GraphicsDevice.Viewport.Height), Color.Black);
+ Game1.spriteBatch.Draw(Game1.fadeToBlackRect, new Microsoft.Xna.Framework.Rectangle(0, 0, -Math.Min(Game1.viewport.X, 4096), Game1.graphics.GraphicsDevice.Viewport.Height), Microsoft.Xna.Framework.Color.Black);
+ Game1.spriteBatch.Draw(Game1.fadeToBlackRect, new Microsoft.Xna.Framework.Rectangle(-Game1.viewport.X + Game1.currentLocation.map.Layers[0].LayerWidth * 64, 0, Math.Min(4096, Game1.graphics.GraphicsDevice.Viewport.Width - (-Game1.viewport.X + Game1.currentLocation.map.Layers[0].LayerWidth * 64)), Game1.graphics.GraphicsDevice.Viewport.Height), Microsoft.Xna.Framework.Color.Black);
+ Game1.spriteBatch.Draw(Game1.fadeToBlackRect, new Microsoft.Xna.Framework.Rectangle(0, 0, Game1.graphics.GraphicsDevice.Viewport.Width, -Math.Min(Game1.viewport.Y, 4096)), Microsoft.Xna.Framework.Color.Black);
+ Game1.spriteBatch.Draw(Game1.fadeToBlackRect, new Microsoft.Xna.Framework.Rectangle(0, -Game1.viewport.Y + Game1.currentLocation.map.Layers[0].LayerHeight * 64, Game1.graphics.GraphicsDevice.Viewport.Width, Math.Min(4096, Game1.graphics.GraphicsDevice.Viewport.Height - (-Game1.viewport.Y + Game1.currentLocation.map.Layers[0].LayerHeight * 64))), Microsoft.Xna.Framework.Color.Black);
}
+ Game1.spriteBatch.End();
+ Game1.PushUIMode();
+ Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointClamp, null, null);
if ((Game1.displayHUD || Game1.eventUp) && Game1.currentBillboard == 0 && Game1.gameMode == 3 && !Game1.freezeControls && !Game1.panMode && !Game1.HostPaused && !this.takingMapScreenshot)
{
events.RenderingHud.RaiseEmpty();
@@ -723,37 +772,52 @@ namespace StardewModdingAPI.Framework
Game1.hudMessages[j].draw(Game1.spriteBatch, j);
}
}
+ Game1.spriteBatch.End();
+ Game1.PopUIMode();
+ Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointClamp, null, null);
}
if (Game1.farmEvent != null)
{
Game1.farmEvent.draw(Game1.spriteBatch);
+ Game1.spriteBatch.End();
+ Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointClamp, null, null);
}
+ Game1.PushUIMode();
if (Game1.dialogueUp && !Game1.nameSelectUp && !Game1.messagePause && (Game1.activeClickableMenu == null || !(Game1.activeClickableMenu is DialogueBox)) && !this.takingMapScreenshot)
{
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), 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)(Game1.pauseAccumulator / Game1.pauseTime * (float)Game1.dialogueWidth), 32), Color.DimGray);
+ Game1.spriteBatch.Draw(Game1.fadeToBlackRect, new Microsoft.Xna.Framework.Rectangle((Game1.graphics.GraphicsDevice.Viewport.GetTitleSafeArea().Width - Game1.dialogueWidth) / 2, Game1.graphics.GraphicsDevice.Viewport.GetTitleSafeArea().Bottom - 128, Game1.dialogueWidth, 32), Microsoft.Xna.Framework.Color.LightGray);
+ Game1.spriteBatch.Draw(Game1.staminaRect, new Microsoft.Xna.Framework.Rectangle((Game1.graphics.GraphicsDevice.Viewport.GetTitleSafeArea().Width - Game1.dialogueWidth) / 2, Game1.graphics.GraphicsDevice.Viewport.GetTitleSafeArea().Bottom - 128, (int)(Game1.pauseAccumulator / Game1.pauseTime * (float)Game1.dialogueWidth), 32), Microsoft.Xna.Framework.Color.DimGray);
}
+ Game1.spriteBatch.End();
+ Game1.PopUIMode();
+ Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointClamp, null, null);
if (Game1.eventUp && Game1.currentLocation != null && Game1.currentLocation.currentEvent != null)
{
Game1.currentLocation.currentEvent.drawAfterMap(Game1.spriteBatch);
}
- if (Game1.isRaining && Game1.currentLocation != null && (bool)Game1.currentLocation.isOutdoors && !(Game1.currentLocation is Desert))
+ if (!Game1.IsFakedBlackScreen() && Game1.IsRainingHere() && Game1.currentLocation != null && (bool)Game1.currentLocation.isOutdoors && !(Game1.currentLocation is Desert))
{
- Game1.spriteBatch.Draw(Game1.staminaRect, Game1.graphics.GraphicsDevice.Viewport.Bounds, Color.Blue * 0.2f);
+ Game1.spriteBatch.Draw(Game1.staminaRect, Game1.graphics.GraphicsDevice.Viewport.Bounds, Microsoft.Xna.Framework.Color.Blue * 0.2f);
}
if ((Game1.fadeToBlack || Game1.globalFade) && !Game1.menuUp && (!Game1.nameSelectUp || Game1.messagePause) && !this.takingMapScreenshot)
{
- Game1.spriteBatch.Draw(Game1.fadeToBlackRect, Game1.graphics.GraphicsDevice.Viewport.Bounds, Color.Black * ((Game1.gameMode == 0) ? (1f - Game1.fadeToBlackAlpha) : Game1.fadeToBlackAlpha));
+ Game1.spriteBatch.End();
+ Game1.PushUIMode();
+ Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointClamp, null, null);
+ Game1.spriteBatch.Draw(Game1.fadeToBlackRect, Game1.graphics.GraphicsDevice.Viewport.Bounds, Microsoft.Xna.Framework.Color.Black * ((Game1.gameMode == 0) ? (1f - Game1.fadeToBlackAlpha) : Game1.fadeToBlackAlpha));
+ Game1.spriteBatch.End();
+ Game1.PopUIMode();
+ Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointClamp, null, null);
}
else if (Game1.flashAlpha > 0f && !this.takingMapScreenshot)
{
if (Game1.options.screenFlash)
{
- Game1.spriteBatch.Draw(Game1.fadeToBlackRect, Game1.graphics.GraphicsDevice.Viewport.Bounds, Color.White * Math.Min(1f, Game1.flashAlpha));
+ Game1.spriteBatch.Draw(Game1.fadeToBlackRect, Game1.graphics.GraphicsDevice.Viewport.Bounds, Microsoft.Xna.Framework.Color.White * Math.Min(1f, Game1.flashAlpha));
}
Game1.flashAlpha -= 0.1f;
}
@@ -767,6 +831,16 @@ namespace StardewModdingAPI.Framework
{
screenOverlayTempSprite.draw(Game1.spriteBatch, localPosition: true);
}
+ Game1.spriteBatch.End();
+ Game1.PushUIMode();
+ Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointClamp, null, null);
+ foreach (TemporaryAnimatedSprite uiOverlayTempSprite in Game1.uiOverlayTempSprites)
+ {
+ uiOverlayTempSprite.draw(Game1.spriteBatch, localPosition: true);
+ }
+ Game1.spriteBatch.End();
+ Game1.PopUIMode();
+ Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointClamp, null, null);
}
if (Game1.debugMode)
{
@@ -798,23 +872,30 @@ namespace StardewModdingAPI.Framework
sb.Append(Game1.getMouseY() + Game1.viewport.Y);
sb.Append(" debugOutput: ");
sb.Append(Game1.debugOutput);
- Game1.spriteBatch.DrawString(Game1.smallFont, sb, new Vector2(base.GraphicsDevice.Viewport.GetTitleSafeArea().X, base.GraphicsDevice.Viewport.GetTitleSafeArea().Y + Game1.smallFont.LineSpacing * 8), Color.Red, 0f, Vector2.Zero, 1f, SpriteEffects.None, 0.9999999f);
+ Game1.spriteBatch.DrawString(Game1.smallFont, sb, new Vector2(base.GraphicsDevice.Viewport.GetTitleSafeArea().X, base.GraphicsDevice.Viewport.GetTitleSafeArea().Y + Game1.smallFont.LineSpacing * 8), Microsoft.Xna.Framework.Color.Red, 0f, Vector2.Zero, 1f, SpriteEffects.None, 0.9999999f);
}
+ Game1.spriteBatch.End();
+ Game1.PushUIMode();
+ Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointClamp, null, null);
if (Game1.showKeyHelp && !this.takingMapScreenshot)
{
- Game1.spriteBatch.DrawString(Game1.smallFont, Game1.keyHelpString, new Vector2(64f, (float)(Game1.viewport.Height - 64 - (Game1.dialogueUp ? (192 + (Game1.isQuestion ? (Game1.questionChoices.Count * 64) : 0)) : 0)) - Game1.smallFont.MeasureString(Game1.keyHelpString).Y), Color.LightGray, 0f, Vector2.Zero, 1f, SpriteEffects.None, 0.9999999f);
+ Game1.spriteBatch.DrawString(Game1.smallFont, Game1.keyHelpString, new Vector2(64f, (float)(Game1.viewport.Height - 64 - (Game1.dialogueUp ? (192 + (Game1.isQuestion ? (Game1.questionChoices.Count * 64) : 0)) : 0)) - Game1.smallFont.MeasureString(Game1.keyHelpString).Y), Microsoft.Xna.Framework.Color.LightGray, 0f, Vector2.Zero, 1f, SpriteEffects.None, 0.9999999f);
}
if (Game1.activeClickableMenu != null && !this.takingMapScreenshot)
{
+ IClickableMenu curMenu = null;
try
{
events.RenderingActiveMenu.RaiseEmpty();
- Game1.activeClickableMenu.draw(Game1.spriteBatch);
+ for (curMenu = Game1.activeClickableMenu; curMenu != null; curMenu = curMenu.GetChildMenu())
+ {
+ curMenu.draw(Game1.spriteBatch);
+ }
events.RenderedActiveMenu.RaiseEmpty();
}
catch (Exception ex)
{
- this.Monitor.Log($"The {Game1.activeClickableMenu.GetType().FullName} menu crashed while drawing itself. SMAPI will force it to exit to avoid crashing the game.\n{ex.GetLogSummary()}", LogLevel.Error);
+ this.Monitor.Log($"The {curMenu.GetMenuChainLabel()} menu crashed while drawing itself. SMAPI will force it to exit to avoid crashing the game.\n{ex.GetLogSummary()}", LogLevel.Error);
Game1.activeClickableMenu.exitThisMenu();
}
}
@@ -822,6 +903,10 @@ namespace StardewModdingAPI.Framework
{
Game1.farmEvent.drawAboveEverything(Game1.spriteBatch);
}
+ if (Game1.specialCurrencyDisplay != null)
+ {
+ Game1.specialCurrencyDisplay.Draw(Game1.spriteBatch);
+ }
if (Game1.emoteMenu != null && !this.takingMapScreenshot)
{
Game1.emoteMenu.draw(Game1.spriteBatch);
@@ -834,7 +919,7 @@ namespace StardewModdingAPI.Framework
events.Rendered.RaiseEmpty();
Game1.spriteBatch.End();
this.drawOverlays(Game1.spriteBatch);
- this.renderScreenBuffer(target_screen);
+ Game1.PopUIMode();
}
}
}
diff --git a/src/SMAPI/Framework/SGameRunner.cs b/src/SMAPI/Framework/SGameRunner.cs
new file mode 100644
index 00000000..ae06f513
--- /dev/null
+++ b/src/SMAPI/Framework/SGameRunner.cs
@@ -0,0 +1,156 @@
+using System;
+using System.Collections.Generic;
+using Microsoft.Xna.Framework;
+using Microsoft.Xna.Framework.Graphics;
+using StardewModdingAPI.Framework.Events;
+using StardewModdingAPI.Framework.Input;
+using StardewModdingAPI.Framework.Reflection;
+using StardewValley;
+
+namespace StardewModdingAPI.Framework
+{
+ /// <summary>SMAPI's extension of the game's core <see cref="GameRunner"/>, used to inject SMAPI components.</summary>
+ internal class SGameRunner : GameRunner
+ {
+ /*********
+ ** Fields
+ *********/
+ /// <summary>Encapsulates monitoring and logging for SMAPI.</summary>
+ private readonly Monitor Monitor;
+
+ /// <summary>Manages SMAPI events for mods.</summary>
+ private readonly EventManager Events;
+
+ /// <summary>Simplifies access to private game code.</summary>
+ private readonly Reflector Reflection;
+
+ /// <summary>Immediately exit the game without saving. This should only be invoked when an irrecoverable fatal error happens that risks save corruption or game-breaking bugs.</summary>
+ private readonly Action<string> ExitGameImmediately;
+
+ /// <summary>The core SMAPI mod hooks.</summary>
+ private readonly SModHooks ModHooks;
+
+ /// <summary>The core multiplayer logic.</summary>
+ private readonly SMultiplayer Multiplayer;
+
+ /// <summary>Raised after the game finishes loading its initial content.</summary>
+ private readonly Action OnGameContentLoaded;
+
+ /// <summary>Raised when XNA is updating (roughly 60 times per second).</summary>
+ private readonly Action<GameTime, Action> OnGameUpdating;
+
+ /// <summary>Raised when the game instance for a local split-screen player is updating (once per <see cref="OnGameUpdating"/> per player).</summary>
+ private readonly Action<SGame, GameTime, Action> OnPlayerInstanceUpdating;
+
+ /// <summary>Raised before the game exits.</summary>
+ private readonly Action OnGameExiting;
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Construct an instance.</summary>
+ /// <param name="monitor">Encapsulates monitoring and logging for SMAPI.</param>
+ /// <param name="reflection">Simplifies access to private game code.</param>
+ /// <param name="eventManager">Manages SMAPI events for mods.</param>
+ /// <param name="modHooks">Handles mod hooks provided by the game.</param>
+ /// <param name="multiplayer">The core multiplayer logic.</param>
+ /// <param name="exitGameImmediately">Immediately exit the game without saving. This should only be invoked when an irrecoverable fatal error happens that risks save corruption or game-breaking bugs.</param>
+ /// <param name="onGameContentLoaded">Raised after the game finishes loading its initial content.</param>
+ /// <param name="onGameUpdating">Raised when XNA is updating its state (roughly 60 times per second).</param>
+ /// <param name="onPlayerInstanceUpdating">Raised when the game instance for a local split-screen player is updating (once per <see cref="OnGameUpdating"/> per player).</param>
+ /// <param name="onGameExiting">Raised before the game exits.</param>
+ public SGameRunner(Monitor monitor, Reflector reflection, EventManager eventManager, SModHooks modHooks, SMultiplayer multiplayer, Action<string> exitGameImmediately, Action onGameContentLoaded, Action<GameTime, Action> onGameUpdating, Action<SGame, GameTime, Action> onPlayerInstanceUpdating, Action onGameExiting)
+ {
+ // init XNA
+ Game1.graphics.GraphicsProfile = GraphicsProfile.HiDef;
+
+ // hook into game
+ this.ModHooks = modHooks;
+
+ // init SMAPI
+ this.Monitor = monitor;
+ this.Events = eventManager;
+ this.Reflection = reflection;
+ this.Multiplayer = multiplayer;
+ this.ExitGameImmediately = exitGameImmediately;
+ this.OnGameContentLoaded = onGameContentLoaded;
+ this.OnGameUpdating = onGameUpdating;
+ this.OnPlayerInstanceUpdating = onPlayerInstanceUpdating;
+ this.OnGameExiting = onGameExiting;
+ }
+
+ /// <summary>Create a game instance for a local player.</summary>
+ /// <param name="playerIndex">The player index.</param>
+ /// <param name="instanceIndex">The instance index.</param>
+ public override Game1 CreateGameInstance(PlayerIndex playerIndex = PlayerIndex.One, int instanceIndex = 0)
+ {
+ SInputState inputState = new SInputState();
+ return new SGame(playerIndex, instanceIndex, this.Monitor, this.Reflection, this.Events, inputState, this.ModHooks, this.Multiplayer, this.ExitGameImmediately, this.OnPlayerInstanceUpdating);
+ }
+
+ /// <inheritdoc />
+ public override void AddGameInstance(PlayerIndex playerIndex)
+ {
+ base.AddGameInstance(playerIndex);
+
+ EarlyConstants.LogScreenId = Context.ScreenId;
+ this.UpdateForSplitScreenChanges();
+ }
+
+ /// <inheritdoc />
+ public override void RemoveGameInstance(Game1 instance)
+ {
+ base.RemoveGameInstance(instance);
+
+ if (this.gameInstances.Count <= 1)
+ EarlyConstants.LogScreenId = null;
+ this.UpdateForSplitScreenChanges();
+ }
+
+
+ /*********
+ ** Protected methods
+ *********/
+ /// <summary>Load content when the game is launched.</summary>
+ protected override void LoadContent()
+ {
+ base.LoadContent();
+
+ this.OnGameContentLoaded();
+ }
+
+ /// <summary>Perform cleanup logic when the game exits.</summary>
+ /// <param name="sender">The event sender.</param>
+ /// <param name="args">The event args.</param>
+ /// <remarks>This overrides the logic in <see cref="Game1.exitEvent"/> to let SMAPI clean up before exit.</remarks>
+ protected override void OnExiting(object sender, EventArgs args)
+ {
+ this.OnGameExiting();
+ }
+
+ /// <summary>The method called when the game is updating its state (roughly 60 times per second).</summary>
+ /// <param name="gameTime">A snapshot of the game timing state.</param>
+ protected override void Update(GameTime gameTime)
+ {
+ this.OnGameUpdating(gameTime, () => base.Update(gameTime));
+ }
+
+ private void UpdateForSplitScreenChanges()
+ {
+ HashSet<int> oldScreenIds = new HashSet<int>(Context.ActiveScreenIds);
+
+ // track active screens
+ Context.ActiveScreenIds.Clear();
+ foreach (var screen in this.gameInstances)
+ Context.ActiveScreenIds.Add(screen.instanceId);
+
+ // remember last removed screen
+ foreach (int id in oldScreenIds)
+ {
+ if (!Context.ActiveScreenIds.Contains(id))
+ Context.LastRemovedScreenId = id;
+ }
+ }
+ }
+}
diff --git a/src/SMAPI/Metadata/CoreAssetPropagator.cs b/src/SMAPI/Metadata/CoreAssetPropagator.cs
index 307bb11b..b82dedfd 100644
--- a/src/SMAPI/Metadata/CoreAssetPropagator.cs
+++ b/src/SMAPI/Metadata/CoreAssetPropagator.cs
@@ -138,12 +138,20 @@ namespace StardewModdingAPI.Metadata
{
if (!string.IsNullOrWhiteSpace(location.mapPath.Value) && this.NormalizeAssetNameIgnoringEmpty(location.mapPath.Value) == key)
{
- // reset town caches
- if (location is Town town)
+ // reset patch caches
+ switch (location)
{
- this.Reflection.GetField<bool>(town, "ccRefurbished").SetValue(false);
- this.Reflection.GetField<bool>(town, "isShowingDestroyedJoja").SetValue(false);
- this.Reflection.GetField<bool>(town, "isShowingUpgradedPamHouse").SetValue(false);
+ case Town _:
+ this.Reflection.GetField<bool>(location, "ccRefurbished").SetValue(false);
+ this.Reflection.GetField<bool>(location, "isShowingDestroyedJoja").SetValue(false);
+ this.Reflection.GetField<bool>(location, "isShowingUpgradedPamHouse").SetValue(false);
+ break;
+
+ case Beach _:
+ case BeachNightMarket _:
+ case Forest _:
+ this.Reflection.GetField<bool>(location, "hasShownCCUpgrade").SetValue(false);
+ break;
}
// general updates
@@ -271,6 +279,9 @@ namespace StardewModdingAPI.Metadata
case "data\\farmanimals": // FarmAnimal constructor
return this.ReloadFarmAnimalData();
+ case "data\\hairdata": // Farmer.GetHairStyleMetadataFile
+ return this.ReloadHairData();
+
case "data\\moviesreactions": // MovieTheater.GetMovieReactions
this.Reflection
.GetField<List<MovieCharacterReaction>>(typeof(MovieTheater), "_genericReactions")
@@ -388,13 +399,19 @@ namespace StardewModdingAPI.Metadata
Game1.shadowTexture = content.Load<Texture2D>(key);
return true;
+ case "loosesprites\\suspensionbridge": // SuspensionBridge constructor
+ return this.ReloadSuspensionBridges(content, key);
+
/****
** Content\TileSheets
****/
- case "tilesheets\\critters": // Critter constructor
- this.ReloadCritterTextures(content, key);
+ case "tilesheets\\chairtiles": // Game1.LoadContent
+ MapSeat.mapChairTexture = content.Load<Texture2D>(key);
return true;
+ case "tilesheets\\critters": // Critter constructor
+ return this.ReloadCritterTextures(content, key) > 0;
+
case "tilesheets\\crops": // Game1.LoadContent
Game1.cropSpriteSheet = content.Load<Texture2D>(key);
return true;
@@ -411,6 +428,10 @@ namespace StardewModdingAPI.Metadata
Furniture.furnitureTexture = content.Load<Texture2D>(key);
return true;
+ case "tilesheets\\furniturefront": // Game1.LoadContent
+ Furniture.furnitureFrontTexture = content.Load<Texture2D>(key);
+ return true;
+
case "tilesheets\\projectiles": // Game1.LoadContent
Projectile.projectileSheet = content.Load<Texture2D>(key);
return true;
@@ -612,7 +633,7 @@ namespace StardewModdingAPI.Metadata
// update sprites
Texture2D texture = content.Load<Texture2D>(key);
foreach (TAnimal animal in animals)
- this.SetSpriteTexture(animal.Sprite, texture);
+ animal.Sprite.spriteTexture = texture;
return true;
}
@@ -642,7 +663,7 @@ namespace StardewModdingAPI.Metadata
// reload asset
if (expectedKey == key)
- this.SetSpriteTexture(animal.Sprite, texture.Value);
+ animal.Sprite.spriteTexture = texture.Value;
}
return texture.IsValueCreated;
}
@@ -682,9 +703,8 @@ namespace StardewModdingAPI.Metadata
Critter[] critters =
(
from location in this.GetLocations()
- let locCritters = this.Reflection.GetField<List<Critter>>(location, "critters").GetValue()
- where locCritters != null
- from Critter critter in locCritters
+ where location.critters != null
+ from Critter critter in location.critters
where this.NormalizeAssetNameIgnoringEmpty(critter.sprite?.Texture?.Name) == key
select critter
)
@@ -695,7 +715,7 @@ namespace StardewModdingAPI.Metadata
// update sprites
Texture2D texture = content.Load<Texture2D>(key);
foreach (var entry in critters)
- this.SetSpriteTexture(entry.sprite, texture);
+ entry.sprite.spriteTexture = texture;
return critters.Length;
}
@@ -752,10 +772,7 @@ namespace StardewModdingAPI.Metadata
(
from location in this.GetLocations()
from grass in location.terrainFeatures.Values.OfType<Grass>()
- let textureName = this.NormalizeAssetNameIgnoringEmpty(
- this.Reflection.GetMethod(grass, "textureName").Invoke<string>()
- )
- where textureName == key
+ where this.NormalizeAssetNameIgnoringEmpty(grass.textureName()) == key
select grass
)
.ToArray();
@@ -764,13 +781,28 @@ namespace StardewModdingAPI.Metadata
{
Lazy<Texture2D> texture = new Lazy<Texture2D>(() => content.Load<Texture2D>(key));
foreach (Grass grass in grasses)
- this.Reflection.GetField<Lazy<Texture2D>>(grass, "texture").SetValue(texture);
+ grass.texture = texture;
return true;
}
return false;
}
+ /// <summary>Reload hair style metadata.</summary>
+ /// <returns>Returns whether any assets were reloaded.</returns>
+ /// <remarks>Derived from the <see cref="Farmer.GetHairStyleMetadataFile"/> and <see cref="Farmer.GetHairStyleMetadata"/>.</remarks>
+ private bool ReloadHairData()
+ {
+ if (Farmer.hairStyleMetadataFile == null)
+ return false;
+
+ Farmer.hairStyleMetadataFile = null;
+ Farmer.allHairStyleIndices = null;
+ Farmer.hairStyleMetadata.Clear();
+
+ return true;
+ }
+
/// <summary>Reload the disposition data for matching NPCs.</summary>
/// <param name="content">The content manager through which to reload the asset.</param>
/// <param name="key">The asset key to reload.</param>
@@ -813,7 +845,7 @@ namespace StardewModdingAPI.Metadata
// update sprite
foreach (var target in characters)
{
- this.SetSpriteTexture(target.Npc.Sprite, content.Load<Texture2D>(target.Key));
+ target.Npc.Sprite.spriteTexture = content.Load<Texture2D>(target.Key);
propagated[target.Key] = true;
}
}
@@ -877,6 +909,29 @@ namespace StardewModdingAPI.Metadata
return players.Any();
}
+ /// <summary>Reload suspension bridge textures.</summary>
+ /// <param name="content">The content manager through which to reload the asset.</param>
+ /// <param name="key">The asset key to reload.</param>
+ /// <returns>Returns whether any textures were reloaded.</returns>
+ private bool ReloadSuspensionBridges(LocalizedContentManager content, string key)
+ {
+ Lazy<Texture2D> texture = new Lazy<Texture2D>(() => content.Load<Texture2D>(key));
+
+ foreach (GameLocation location in this.GetLocations(buildingInteriors: false))
+ {
+ // get suspension bridges field
+ var field = this.Reflection.GetField<IEnumerable<SuspensionBridge>>(location, nameof(IslandNorth.suspensionBridges), required: false);
+ if (field == null || !typeof(IEnumerable<SuspensionBridge>).IsAssignableFrom(field.FieldInfo.FieldType))
+ continue;
+
+ // update textures
+ foreach (SuspensionBridge bridge in field.GetValue())
+ this.Reflection.GetField<Texture2D>(bridge, "_texture").SetValue(texture.Value);
+ }
+
+ return texture.IsValueCreated;
+ }
+
/// <summary>Reload tree textures.</summary>
/// <param name="content">The content manager through which to reload the asset.</param>
/// <param name="key">The asset key to reload.</param>
@@ -958,7 +1013,8 @@ namespace StardewModdingAPI.Metadata
int lastScheduleTime = villager.Schedule.Keys.Where(p => p <= Game1.timeOfDay).OrderByDescending(p => p).FirstOrDefault();
if (lastScheduleTime != 0)
{
- villager.scheduleTimeToTry = NPC.NO_TRY; // use time that's passed in to checkSchedule
+ villager.queuedSchedulePaths.Clear();
+ villager.lastAttemptedSchedule = 0;
villager.checkSchedule(lastScheduleTime);
}
}
@@ -969,14 +1025,6 @@ namespace StardewModdingAPI.Metadata
/****
** Helpers
****/
- /// <summary>Reload the texture for an animated sprite.</summary>
- /// <param name="sprite">The animated sprite to update.</param>
- /// <param name="texture">The texture to set.</param>
- private void SetSpriteTexture(AnimatedSprite sprite, Texture2D texture)
- {
- this.Reflection.GetField<Texture2D>(sprite, "spriteTexture").SetValue(texture);
- }
-
/// <summary>Get all NPCs in the game (excluding farm animals).</summary>
private IEnumerable<NPC> GetCharacters()
{
diff --git a/src/SMAPI/Metadata/InstructionMetadata.cs b/src/SMAPI/Metadata/InstructionMetadata.cs
index 09a199f9..2c1e14ce 100644
--- a/src/SMAPI/Metadata/InstructionMetadata.cs
+++ b/src/SMAPI/Metadata/InstructionMetadata.cs
@@ -6,6 +6,7 @@ using StardewModdingAPI.Framework.ModLoading.Finders;
using StardewModdingAPI.Framework.ModLoading.RewriteFacades;
using StardewModdingAPI.Framework.ModLoading.Rewriters;
using StardewValley;
+using StardewValley.Locations;
namespace StardewModdingAPI.Metadata
{
@@ -35,6 +36,11 @@ namespace StardewModdingAPI.Metadata
if (platformChanged)
yield return new MethodParentRewriter(typeof(SpriteBatch), typeof(SpriteBatchFacade));
+ // rewrite for Stardew Valley 1.5
+ yield return new FieldReplaceRewriter(typeof(DecoratableLocation), "furniture", typeof(GameLocation), nameof(GameLocation.furniture));
+ yield return new FieldReplaceRewriter(typeof(Farm), "resourceClumps", typeof(GameLocation), nameof(GameLocation.resourceClumps));
+ yield return new FieldReplaceRewriter(typeof(MineShaft), "resourceClumps", typeof(GameLocation), nameof(GameLocation.resourceClumps));
+
// heuristic rewrites
yield return new HeuristicFieldRewriter(this.ValidateReferencesToAssemblies);
yield return new HeuristicMethodRewriter(this.ValidateReferencesToAssemblies);
diff --git a/src/SMAPI/Patches/DialogueErrorPatch.cs b/src/SMAPI/Patches/DialogueErrorPatch.cs
index 42494390..215df561 100644
--- a/src/SMAPI/Patches/DialogueErrorPatch.cs
+++ b/src/SMAPI/Patches/DialogueErrorPatch.cs
@@ -135,11 +135,9 @@ namespace StardewModdingAPI.Patches
IReflectedMethod translateArraysOfStrings = DialogueErrorPatch.Reflection.GetMethod(typeof(Dialogue), "TranslateArraysOfStrings");
IReflectedMethod parseDialogueString = DialogueErrorPatch.Reflection.GetMethod(__instance, "parseDialogueString");
IReflectedMethod checkForSpecialDialogueAttributes = DialogueErrorPatch.Reflection.GetMethod(__instance, "checkForSpecialDialogueAttributes");
- IReflectedField<List<string>> dialogues = DialogueErrorPatch.Reflection.GetField<List<string>>(__instance, "dialogues");
// replicate base constructor
- if (dialogues.GetValue() == null)
- dialogues.SetValue(new List<string>());
+ __instance.dialogues ??= new List<string>();
// duplicate code with try..catch
try
diff --git a/src/SMAPI/Patches/ScheduleErrorPatch.cs b/src/SMAPI/Patches/ScheduleErrorPatch.cs
index 17db07a6..1d58a292 100644
--- a/src/SMAPI/Patches/ScheduleErrorPatch.cs
+++ b/src/SMAPI/Patches/ScheduleErrorPatch.cs
@@ -51,7 +51,7 @@ namespace StardewModdingAPI.Patches
#endif
{
harmony.Patch(
- original: AccessTools.Method(typeof(NPC), "parseMasterSchedule"),
+ original: AccessTools.Method(typeof(NPC), nameof(NPC.parseMasterSchedule)),
#if HARMONY_2
finalizer: new HarmonyMethod(this.GetType(), nameof(ScheduleErrorPatch.Finalize_NPC_parseMasterSchedule))
#else
diff --git a/src/SMAPI/Utilities/PerScreen.cs b/src/SMAPI/Utilities/PerScreen.cs
new file mode 100644
index 00000000..55dae0d8
--- /dev/null
+++ b/src/SMAPI/Utilities/PerScreen.cs
@@ -0,0 +1,79 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+
+namespace StardewModdingAPI.Utilities
+{
+ /// <summary>Manages a separate value for each player in split-screen mode. This can safely be used in non-split-screen mode too, it'll just have a single state in that case.</summary>
+ /// <typeparam name="T">The state class.</typeparam>
+ public class PerScreen<T>
+ {
+ /*********
+ ** Fields
+ *********/
+ /// <summary>Create the initial value for a player.</summary>
+ private readonly Func<T> CreateNewState;
+
+ /// <summary>The tracked values for each player.</summary>
+ private readonly IDictionary<int, T> States = new Dictionary<int, T>();
+
+ /// <summary>The last <see cref="Context.LastRemovedScreenId"/> value for which this instance was updated.</summary>
+ private int LastRemovedScreenId;
+
+
+ /*********
+ ** Accessors
+ *********/
+ /// <summary>The value for the current player.</summary>
+ /// <remarks>The value is initialized the first time it's requested for that player, unless it's set manually first.</remarks>
+ public T Value
+ {
+ get
+ {
+ this.RemoveDeadPlayers();
+ return this.States.TryGetValue(Context.ScreenId, out T state)
+ ? state
+ : this.States[Context.ScreenId] = this.CreateNewState();
+ }
+ set
+ {
+ this.RemoveDeadPlayers();
+ this.States[Context.ScreenId] = value;
+ }
+ }
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Construct an instance.</summary>
+ public PerScreen()
+ : this(null) { }
+
+ /// <summary>Construct an instance.</summary>
+ /// <param name="createNewState">Create the initial state for a player screen.</param>
+ public PerScreen(Func<T> createNewState)
+ {
+ this.CreateNewState = createNewState ?? (() => default);
+ }
+
+
+ /*********
+ ** Private methods
+ *********/
+ /// <summary>Remove players who are no longer have a split-screen index.</summary>
+ /// <returns>Returns whether any players were removed.</returns>
+ private void RemoveDeadPlayers()
+ {
+ if (this.LastRemovedScreenId == Context.LastRemovedScreenId)
+ return;
+
+ this.LastRemovedScreenId = Context.LastRemovedScreenId;
+ foreach (int id in this.States.Keys.ToArray())
+ {
+ if (!Context.HasScreenId(id))
+ this.States.Remove(id);
+ }
+ }
+ }
+}