Merge branch 'stardew-valley-1.5' into develop

# Conflicts:
#	docs/release-notes.md
This commit is contained in:
Jesse Plamondon-Willard 2020-12-20 22:35:58 -05:00
commit 77002d3e99
No known key found for this signature in database
GPG Key ID: CF8B1456B3E29F49
27 changed files with 1099 additions and 634 deletions

View File

@ -9,9 +9,17 @@
## Upcoming release
* For players:
* Updated for Stardew Valley 1.5, including split-screen support.
* When the installer is run from within a game folder, it now installs SMAPI to that folder. That simplifies installation if you have multiple copies of the game or it can't otherwise auto-detect the game path.
* Clarified not-a-mod error when the SMAPI installer is in the `Mods` folder.
* For modders:
* Added `PerScreen<T>` utility and new `Context` fields to simplify split-screen support in mods.
* Added screen ID to log when playing in split-screen mode.
* For the Console Commands mod:
* Added `furniture` option to `world_clear`.
## 3.7.6
Released 21 November 2020 for Stardew Valley 1.4.1 or later.

View File

@ -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,17 +251,14 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.World
{
int removed = 0;
if (location is DecoratableLocation decoratableLocation)
{
foreach (Furniture furniture in decoratableLocation.furniture.ToArray())
foreach (Furniture furniture in location.furniture.ToArray())
{
if (shouldRemove(furniture))
{
decoratableLocation.furniture.Remove(furniture);
location.furniture.Remove(furniture);
removed++;
}
}
}
return removed;
}

View File

@ -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)

View File

@ -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()
};

View File

@ -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)
);

View File

@ -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; }

View File

@ -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>
}

View File

@ -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
*********/

View File

@ -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>
@ -54,10 +57,10 @@ namespace StardewModdingAPI
public static ISemanticVersion ApiVersion { get; } = new Toolkit.SemanticVersion("3.7.6");
/// <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>

View File

@ -1,5 +1,7 @@
using System.Collections.Generic;
using StardewModdingAPI.Enums;
using StardewModdingAPI.Events;
using StardewModdingAPI.Utilities;
using StardewValley;
using StardewValley.Menus;
@ -8,17 +10,50 @@ namespace StardewModdingAPI
/// <summary>Provides information about the current game state.</summary>
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);
}
}
}

View File

@ -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,23 +153,17 @@ 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);
string cachedKey = null;
bool localized =
this.Language != LocalizedContentManager.LanguageCode.en
&& !this.Coordinator.IsManagedAssetKey(normalizedAssetName)
&& this.LocalizedAssetNames.TryGetValue(normalizedAssetName, out cachedKey);
// translated
string keyWithLocale = $"{normalizedAssetName}.{this.GetLocale(this.GetCurrentLanguage())}";
if (this.IsLocalizableLookup.TryGetValue(keyWithLocale, out bool localizable))
{
return localizable
? this.Cache.ContainsKey(keyWithLocale)
return localized
? this.Cache.ContainsKey(cachedKey)
: this.Cache.ContainsKey(normalizedAssetName);
}
// not loaded yet
return false;
}
/// <summary>Add tracking data to an asset and add it to the cache.</summary>
/// <typeparam name="T">The type of asset to inject.</typeparam>
/// <param name="assetName">The asset path relative to the loader root directory, not including the <c>.xnb</c> extension.</param>
@ -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
{
T obj = base.RawLoad<T>(translatedKey, useCache);
this.IsLocalizableLookup[assetName] = true;
this.IsLocalizableLookup[translatedKey] = true;
this.LocalizedAssetNames[assetName] = translatedKey;
return obj;
}
catch (ContentLoadException)
{
this.IsLocalizableLookup[assetName] = false;
this.IsLocalizableLookup[translatedKey] = false;
}
this.LocalizedAssetNames[assetName] = assetName;
}
}

View File

@ -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,23 +323,10 @@ 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 = 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);
string contentKey = this.GetContentKeyForTilesheetImageSource(relativePath);
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
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;
}
@ -393,7 +342,6 @@ namespace StardewModdingAPI.Framework.ContentManagers
if (this.GetContentFolderFileExists(contentKey))
throw;
}
}
// not found
error = "The tilesheet couldn't be found relative to either map file or the game's content folder.";
@ -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;
}
}
}

View File

@ -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();

View File

@ -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
{
@ -153,6 +155,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
****/

View File

@ -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,

View File

@ -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

View File

@ -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);
}
}
}

View File

@ -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));

View File

@ -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}]";
}
}
}

View File

@ -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
*********/
@ -489,16 +454,6 @@ namespace StardewModdingAPI.Framework
this.OnGameInitialized();
}
/*********
** Update input
*********/
// This should *always* run, even when suppressing mod events, since the game uses
// this too. For example, doing this after mod event suppression would prevent the
// user from doing anything on the overnight shipping screen.
SInputState inputState = this.Input;
if (this.Game.IsActive)
inputState.TrueUpdate();
/*********
** Special cases
*********/
@ -509,98 +464,6 @@ namespace StardewModdingAPI.Framework
return;
}
// Run async tasks synchronously to avoid issues due to mod events triggering
// concurrently with game code.
bool saveParsed = false;
if (Game1.currentLoader != null)
{
this.Monitor.Log("Game loader synchronizing...");
while (Game1.currentLoader?.MoveNext() == true)
{
// raise load stage changed
switch (Game1.currentLoader.Current)
{
case 20 when (!saveParsed && SaveGame.loaded != null):
saveParsed = true;
this.OnLoadStageChanged(LoadStage.SaveParsed);
break;
case 36:
this.OnLoadStageChanged(LoadStage.SaveLoadedBasicInfo);
break;
case 50:
this.OnLoadStageChanged(LoadStage.SaveLoadedLocations);
break;
default:
if (Game1.gameMode == Game1.playingGameMode)
this.OnLoadStageChanged(LoadStage.Preloaded);
break;
}
}
Game1.currentLoader = null;
this.Monitor.Log("Game loader done.");
}
if (SGame.NewDayTask?.Status == TaskStatus.Created)
{
this.Monitor.Log("New day task synchronizing...");
SGame.NewDayTask.RunSynchronously();
this.Monitor.Log("New day task done.");
}
// While a background task is in progress, the game may make changes to the game
// state while mods are running their code. This is risky, because data changes can
// conflict (e.g. collection changed during enumeration errors) and data may change
// unexpectedly from one mod instruction to the next.
//
// Therefore we can just run Game1.Update here without raising any SMAPI events. There's
// a small chance that the task will finish after we defer but before the game checks,
// which means technically events should be raised, but the effects of missing one
// update tick are negligible and not worth the complications of bypassing Game1.Update.
if (SGame.NewDayTask != null || Game1.gameMode == Game1.loadingMode)
{
events.UnvalidatedUpdateTicking.RaiseEmpty();
SCore.TicksElapsed++;
runGameUpdate();
events.UnvalidatedUpdateTicked.RaiseEmpty();
return;
}
// Raise minimal events while saving.
// While the game is writing to the save file in the background, mods can unexpectedly
// fail since they don't have exclusive access to resources (e.g. collection changed
// during enumeration errors). To avoid problems, events are not invoked while a save
// is in progress. It's safe to raise SaveEvents.BeforeSave as soon as the menu is
// opened (since the save hasn't started yet), but all other events should be suppressed.
if (Context.IsSaving)
{
// raise before-create
if (!Context.IsWorldReady && !this.IsBetweenCreateEvents)
{
this.IsBetweenCreateEvents = true;
this.Monitor.Log("Context: before save creation.");
events.SaveCreating.RaiseEmpty();
}
// raise before-save
if (Context.IsWorldReady && !this.IsBetweenSaveEvents)
{
this.IsBetweenSaveEvents = true;
this.Monitor.Log("Context: before save.");
events.Saving.RaiseEmpty();
}
// suppress non-save events
events.UnvalidatedUpdateTicking.RaiseEmpty();
SCore.TicksElapsed++;
runGameUpdate();
events.UnvalidatedUpdateTicked.RaiseEmpty();
return;
}
/*********
** Reload assets when interceptors are added/removed
*********/
@ -671,32 +534,7 @@ namespace StardewModdingAPI.Framework
}
/*********
** Update context
*********/
bool wasWorldReady = Context.IsWorldReady;
if ((Context.IsWorldReady && !Context.IsSaveLoaded) || Game1.exitToTitle)
{
Context.IsWorldReady = false;
this.AfterLoadTimer.Reset();
}
else if (Context.IsSaveLoaded && this.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;
}
/*********
** 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
** Show in-game warnings (for main player only)
*********/
// save content removed
if (this.IsSaveContentRemoved && Context.IsWorldReady)
@ -705,6 +543,178 @@ namespace StardewModdingAPI.Framework
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 = instance.Input;
if (this.Game.IsActive)
inputState.TrueUpdate();
/*********
** Special cases
*********/
// Run async tasks synchronously to avoid issues due to mod events triggering
// concurrently with game code.
bool saveParsed = false;
if (Game1.currentLoader != null)
{
this.Monitor.Log("Game loader synchronizing...");
while (Game1.currentLoader?.MoveNext() == true)
{
// raise load stage changed
switch (Game1.currentLoader.Current)
{
case 20 when (!saveParsed && SaveGame.loaded != null):
saveParsed = true;
this.OnLoadStageChanged(LoadStage.SaveParsed);
break;
case 36:
this.OnLoadStageChanged(LoadStage.SaveLoadedBasicInfo);
break;
case 50:
this.OnLoadStageChanged(LoadStage.SaveLoadedLocations);
break;
default:
if (Game1.gameMode == Game1.playingGameMode)
this.OnLoadStageChanged(LoadStage.Preloaded);
break;
}
}
Game1.currentLoader = null;
this.Monitor.Log("Game loader done.");
}
if (instance.NewDayTask?.Status == TaskStatus.Created)
{
this.Monitor.Log("New day task synchronizing...");
instance.NewDayTask.RunSynchronously();
this.Monitor.Log("New day task done.");
}
// While a background task is in progress, the game may make changes to the game
// state while mods are running their code. This is risky, because data changes can
// conflict (e.g. collection changed during enumeration errors) and data may change
// unexpectedly from one mod instruction to the next.
//
// Therefore we can just run Game1.Update here without raising any SMAPI events. There's
// a small chance that the task will finish after we defer but before the game checks,
// which means technically events should be raised, but the effects of missing one
// update tick are negligible and not worth the complications of bypassing Game1.Update.
if (instance.NewDayTask != null || Game1.gameMode == Game1.loadingMode)
{
events.UnvalidatedUpdateTicking.RaiseEmpty();
runUpdate();
events.UnvalidatedUpdateTicked.RaiseEmpty();
return;
}
// Raise minimal events while saving.
// While the game is writing to the save file in the background, mods can unexpectedly
// fail since they don't have exclusive access to resources (e.g. collection changed
// during enumeration errors). To avoid problems, events are not invoked while a save
// is in progress. It's safe to raise SaveEvents.BeforeSave as soon as the menu is
// opened (since the save hasn't started yet), but all other events should be suppressed.
if (Context.IsSaving)
{
// raise before-create
if (!Context.IsWorldReady && !instance.IsBetweenCreateEvents)
{
instance.IsBetweenCreateEvents = true;
this.Monitor.Log("Context: before save creation.");
events.SaveCreating.RaiseEmpty();
}
// raise before-save
if (Context.IsWorldReady && !instance.IsBetweenSaveEvents)
{
instance.IsBetweenSaveEvents = true;
this.Monitor.Log("Context: before save.");
events.Saving.RaiseEmpty();
}
// suppress non-save events
events.UnvalidatedUpdateTicking.RaiseEmpty();
runUpdate();
events.UnvalidatedUpdateTicked.RaiseEmpty();
return;
}
/*********
** Update context
*********/
bool wasWorldReady = Context.IsWorldReady;
if ((Context.IsWorldReady && !Context.IsSaveLoaded) || Game1.exitToTitle)
{
Context.IsWorldReady = false;
instance.AfterLoadTimer.Reset();
}
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)
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.)
*********/
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;
}
}
}

View File

@ -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();
if (Game1.specialCurrencyDisplay != null)
{
Game1.specialCurrencyDisplay.Draw(Game1.spriteBatch);
}
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();
}
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)
{
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);
events.Rendered.RaiseEmpty();
Game1.spriteBatch.End();
}
else
{
Game1.PopUIMode();
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);
}
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)
{
Game1.spriteBatch.Draw(Game1.staminaRect, Game1.graphics.GraphicsDevice.Viewport.Bounds, Color.OrangeRed * 0.45f);
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, 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();
}
}
}

View File

@ -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;
}
}
}
}

View File

@ -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()
{

View File

@ -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);

View File

@ -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

View File

@ -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

View File

@ -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);
}
}
}
}