summaryrefslogtreecommitdiff
path: root/src/SMAPI/Framework/SGame.cs
diff options
context:
space:
mode:
authorJesse Plamondon-Willard <github@jplamondonw.com>2017-10-07 23:20:36 -0400
committerJesse Plamondon-Willard <github@jplamondonw.com>2017-10-07 23:20:36 -0400
commitd0dd2f7ba729de6be749d326a2fed78988ba9d7b (patch)
treea22127da6a8900e9f29bbb847bfd5d3347f6b952 /src/SMAPI/Framework/SGame.cs
parent7889676ea24cafc945899bf25608784e3f5bc9e0 (diff)
parent5928f5f86c4493ddb6b89bce0b7d0fb73a884c09 (diff)
downloadSMAPI-d0dd2f7ba729de6be749d326a2fed78988ba9d7b.tar.gz
SMAPI-d0dd2f7ba729de6be749d326a2fed78988ba9d7b.tar.bz2
SMAPI-d0dd2f7ba729de6be749d326a2fed78988ba9d7b.zip
Merge branch 'add-mod-build-config' into develop
Diffstat (limited to 'src/SMAPI/Framework/SGame.cs')
-rw-r--r--src/SMAPI/Framework/SGame.cs1403
1 files changed, 1403 insertions, 0 deletions
diff --git a/src/SMAPI/Framework/SGame.cs b/src/SMAPI/Framework/SGame.cs
new file mode 100644
index 00000000..7287cab7
--- /dev/null
+++ b/src/SMAPI/Framework/SGame.cs
@@ -0,0 +1,1403 @@
+using System;
+using System.Collections;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.Diagnostics.CodeAnalysis;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Xna.Framework;
+using Microsoft.Xna.Framework.Graphics;
+using Microsoft.Xna.Framework.Input;
+using StardewModdingAPI.Events;
+using StardewModdingAPI.Framework.Reflection;
+using StardewModdingAPI.Framework.Utilities;
+using StardewModdingAPI.Utilities;
+using StardewValley;
+using StardewValley.BellsAndWhistles;
+using StardewValley.Locations;
+using StardewValley.Menus;
+using StardewValley.Tools;
+using xTile.Dimensions;
+using xTile.Layers;
+
+namespace StardewModdingAPI.Framework
+{
+ /// <summary>SMAPI's extension of the game's core <see cref="Game1"/>, used to inject events.</summary>
+ internal class SGame : Game1
+ {
+ /*********
+ ** Properties
+ *********/
+ /****
+ ** SMAPI state
+ ****/
+ /// <summary>Encapsulates monitoring and logging.</summary>
+ private readonly IMonitor Monitor;
+
+ /// <summary>The maximum number of consecutive attempts SMAPI should make to recover from a draw error.</summary>
+ private readonly Countdown DrawCrashTimer = new Countdown(60); // 60 ticks = roughly one second
+
+ /// <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 initialising the world before mods try to change it.</remarks>
+ private int AfterLoadTimer = 5;
+
+ /// <summary>Whether the game is returning to the menu.</summary>
+ private bool IsExitingToTitle;
+
+ /// <summary>Whether the game is saving and SMAPI has already raised <see cref="SaveEvents.BeforeSave"/>.</summary>
+ private bool IsBetweenSaveEvents;
+
+ /****
+ ** Game state
+ ****/
+ /// <summary>A record of the buttons pressed as of the previous tick.</summary>
+ private SButton[] PreviousPressedButtons = new SButton[0];
+
+ /// <summary>A record of the keyboard state (i.e. the up/down state for each button) as of the previous tick.</summary>
+ private KeyboardState PreviousKeyState;
+
+ /// <summary>A record of the controller state (i.e. the up/down state for each button) as of the previous tick.</summary>
+ private GamePadState PreviousControllerState;
+
+ /// <summary>A record of the mouse state (i.e. the cursor position, scroll amount, and the up/down state for each button) as of the previous tick.</summary>
+ private MouseState PreviousMouseState;
+
+ /// <summary>The previous mouse position on the screen adjusted for the zoom level.</summary>
+ private Point PreviousMousePosition;
+
+ /// <summary>The window size value at last check.</summary>
+ private Point PreviousWindowSize;
+
+ /// <summary>The save ID at last check.</summary>
+ private ulong PreviousSaveID;
+
+ /// <summary>A hash of <see cref="Game1.locations"/> at last check.</summary>
+ private int PreviousGameLocations;
+
+ /// <summary>A hash of the current location's <see cref="GameLocation.objects"/> at last check.</summary>
+ private int PreviousLocationObjects;
+
+ /// <summary>The player's inventory at last check.</summary>
+ private IDictionary<Item, int> PreviousItems;
+
+ /// <summary>The player's combat skill level at last check.</summary>
+ private int PreviousCombatLevel;
+
+ /// <summary>The player's farming skill level at last check.</summary>
+ private int PreviousFarmingLevel;
+
+ /// <summary>The player's fishing skill level at last check.</summary>
+ private int PreviousFishingLevel;
+
+ /// <summary>The player's foraging skill level at last check.</summary>
+ private int PreviousForagingLevel;
+
+ /// <summary>The player's mining skill level at last check.</summary>
+ private int PreviousMiningLevel;
+
+ /// <summary>The player's luck skill level at last check.</summary>
+ private int PreviousLuckLevel;
+
+ /// <summary>The player's location at last check.</summary>
+ private GameLocation PreviousGameLocation;
+
+ /// <summary>The active game menu at last check.</summary>
+ private IClickableMenu PreviousActiveMenu;
+
+ /// <summary>The mine level at last check.</summary>
+ private int PreviousMineLevel;
+
+ /// <summary>The time of day (in 24-hour military format) at last check.</summary>
+ private int PreviousTime;
+
+ /// <summary>The previous content locale.</summary>
+ private LocalizedContentManager.LanguageCode? PreviousLocale;
+
+ /// <summary>An index incremented on every tick and reset every 60th tick (0–59).</summary>
+ private int CurrentUpdateTick;
+
+ /// <summary>Whether this is the very first update tick since the game started.</summary>
+ private bool FirstUpdate;
+
+ /// <summary>The current game instance.</summary>
+ private static SGame Instance;
+
+ /****
+ ** Private wrappers
+ ****/
+ /// <summary>Simplifies access to private game code.</summary>
+ private static Reflector Reflection;
+
+ // ReSharper disable ArrangeStaticMemberQualifier, ArrangeThisQualifier, InconsistentNaming
+ /// <summary>Used to access private fields and methods.</summary>
+ private static List<float> _fpsList => SGame.Reflection.GetPrivateField<List<float>>(typeof(Game1), nameof(_fpsList)).GetValue();
+ private static Stopwatch _fpsStopwatch => SGame.Reflection.GetPrivateField<Stopwatch>(typeof(Game1), nameof(SGame._fpsStopwatch)).GetValue();
+ private static float _fps
+ {
+ set => SGame.Reflection.GetPrivateField<float>(typeof(Game1), nameof(_fps)).SetValue(value);
+ }
+ private static Task _newDayTask => SGame.Reflection.GetPrivateField<Task>(typeof(Game1), nameof(_newDayTask)).GetValue();
+ private Color bgColor => SGame.Reflection.GetPrivateField<Color>(this, nameof(bgColor)).GetValue();
+ public RenderTarget2D screenWrapper => SGame.Reflection.GetPrivateProperty<RenderTarget2D>(this, "screen").GetValue(); // deliberately renamed to avoid an infinite loop
+ public BlendState lightingBlend => SGame.Reflection.GetPrivateField<BlendState>(this, nameof(lightingBlend)).GetValue();
+ private readonly Action drawFarmBuildings = () => SGame.Reflection.GetPrivateMethod(SGame.Instance, nameof(drawFarmBuildings)).Invoke();
+ private readonly Action drawHUD = () => SGame.Reflection.GetPrivateMethod(SGame.Instance, nameof(drawHUD)).Invoke();
+ private readonly Action drawDialogueBox = () => SGame.Reflection.GetPrivateMethod(SGame.Instance, nameof(drawDialogueBox)).Invoke();
+ private readonly Action renderScreenBuffer = () => SGame.Reflection.GetPrivateMethod(SGame.Instance, nameof(renderScreenBuffer)).Invoke();
+ // ReSharper restore ArrangeStaticMemberQualifier, ArrangeThisQualifier, InconsistentNaming
+
+
+ /*********
+ ** Accessors
+ *********/
+ /// <summary>SMAPI's content manager.</summary>
+ public SContentManager SContentManager { get; }
+
+ /// <summary>Whether SMAPI should log more information about the game context.</summary>
+ public bool VerboseLogging { get; set; }
+
+
+ /*********
+ ** Protected methods
+ *********/
+ /// <summary>Construct an instance.</summary>
+ /// <param name="monitor">Encapsulates monitoring and logging.</param>
+ /// <param name="reflection">Simplifies access to private game code.</param>
+ internal SGame(IMonitor monitor, Reflector reflection)
+ {
+ // initialise
+ this.Monitor = monitor;
+ this.FirstUpdate = true;
+ SGame.Instance = this;
+ SGame.Reflection = reflection;
+
+ // set XNA option required by Stardew Valley
+ Game1.graphics.GraphicsProfile = GraphicsProfile.HiDef;
+
+ // override content manager
+ this.Monitor?.Log("Overriding content manager...", LogLevel.Trace);
+ this.SContentManager = new SContentManager(this.Content.ServiceProvider, this.Content.RootDirectory, Thread.CurrentThread.CurrentUICulture, null, this.Monitor);
+ this.Content = new ContentManagerShim(this.SContentManager, "SGame.Content");
+ Game1.content = new ContentManagerShim(this.SContentManager, "Game1.content");
+ reflection.GetPrivateField<LocalizedContentManager>(typeof(Game1), "_temporaryContent").SetValue(new ContentManagerShim(this.SContentManager, "Game1._temporaryContent")); // regenerate value with new content manager
+ }
+
+ /****
+ ** Intercepted methods & events
+ ****/
+ /// <summary>Constructor a content manager to read XNB 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>
+ protected override LocalizedContentManager CreateContentManager(IServiceProvider serviceProvider, string rootDirectory)
+ {
+ // return default if SMAPI's content manager isn't initialised yet
+ if (this.SContentManager == null)
+ {
+ this.Monitor?.Log("SMAPI's content manager isn't initialised; skipping content manager interception.", LogLevel.Trace);
+ return base.CreateContentManager(serviceProvider, rootDirectory);
+ }
+
+ // return single instance if valid
+ if (serviceProvider != this.Content.ServiceProvider)
+ throw new InvalidOperationException("SMAPI uses a single content manager internally. You can't get a new content manager with a different service provider.");
+ if (rootDirectory != this.Content.RootDirectory)
+ throw new InvalidOperationException($"SMAPI uses a single content manager internally. You can't get a new content manager with a different root directory (current is {this.Content.RootDirectory}, requested {rootDirectory}).");
+ return new ContentManagerShim(this.SContentManager, "(generated instance)");
+ }
+
+ /// <summary>The method called when the game is updating its state. This happens roughly 60 times per second.</summary>
+ /// <param name="gameTime">A snapshot of the game timing state.</param>
+ protected override void Update(GameTime gameTime)
+ {
+ try
+ {
+ /*********
+ ** Skip conditions
+ *********/
+ // SMAPI exiting, stop processing game updates
+ if (this.Monitor.IsExiting)
+ {
+ this.Monitor.Log("SMAPI shutting down: aborting update.", LogLevel.Trace);
+ return;
+ }
+
+ // While a background new-day task is in progress, the game skips its own update logic
+ // and defers to the XNA Update method. Running mod code in parallel to the background
+ // update 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 neglible and not worth the complications of bypassing Game1.Update.
+ if (SGame._newDayTask != null)
+ {
+ base.Update(gameTime);
+ return;
+ }
+
+ // 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-save
+ if (!this.IsBetweenSaveEvents)
+ {
+ this.IsBetweenSaveEvents = true;
+ this.Monitor.Log("Context: before save.", LogLevel.Trace);
+ SaveEvents.InvokeBeforeSave(this.Monitor);
+ }
+
+ // suppress non-save events
+ base.Update(gameTime);
+ return;
+ }
+ if (this.IsBetweenSaveEvents)
+ {
+ // raise after-save
+ this.IsBetweenSaveEvents = false;
+ this.Monitor.Log($"Context: after save, starting {Game1.currentSeason} {Game1.dayOfMonth} Y{Game1.year}.", LogLevel.Trace);
+ SaveEvents.InvokeAfterSave(this.Monitor);
+ TimeEvents.InvokeAfterDayStarted(this.Monitor);
+ }
+
+ /*********
+ ** Game loaded events
+ *********/
+ if (this.FirstUpdate)
+ {
+ GameEvents.InvokeInitialize(this.Monitor);
+ }
+
+ /*********
+ ** Locale changed events
+ *********/
+ if (this.PreviousLocale != LocalizedContentManager.CurrentLanguageCode)
+ {
+ var oldValue = this.PreviousLocale;
+ var newValue = LocalizedContentManager.CurrentLanguageCode;
+
+ this.Monitor.Log($"Context: locale set to {newValue}.", LogLevel.Trace);
+
+ if (oldValue != null)
+ ContentEvents.InvokeAfterLocaleChanged(this.Monitor, oldValue.ToString(), newValue.ToString());
+ this.PreviousLocale = newValue;
+ }
+
+ /*********
+ ** After load events
+ *********/
+ if (Context.IsSaveLoaded && !SaveGame.IsProcessing /*still loading save*/ && this.AfterLoadTimer >= 0)
+ {
+ if (Game1.dayOfMonth != 0) // wait until new-game intro finishes (world not fully initialised yet)
+ this.AfterLoadTimer--;
+
+ if (this.AfterLoadTimer == 0)
+ {
+ this.Monitor.Log($"Context: loaded saved game '{Constants.SaveFolderName}', starting {Game1.currentSeason} {Game1.dayOfMonth} Y{Game1.year}.", LogLevel.Trace);
+ Context.IsWorldReady = true;
+
+ SaveEvents.InvokeAfterLoad(this.Monitor);
+ TimeEvents.InvokeAfterDayStarted(this.Monitor);
+ }
+ }
+
+ /*********
+ ** Exit to title events
+ *********/
+ // before exit to title
+ if (Game1.exitToTitle)
+ this.IsExitingToTitle = true;
+
+ // after exit to title
+ if (Context.IsWorldReady && this.IsExitingToTitle && Game1.activeClickableMenu is TitleMenu)
+ {
+ this.Monitor.Log("Context: returned to title", LogLevel.Trace);
+
+ this.IsExitingToTitle = false;
+ this.CleanupAfterReturnToTitle();
+ SaveEvents.InvokeAfterReturnToTitle(this.Monitor);
+ }
+
+ /*********
+ ** Window events
+ *********/
+ // Here we depend on the game's viewport instead of listening to the Window.Resize
+ // event because we need to notify mods after the game handles the resize, so the
+ // game's metadata (like Game1.viewport) are updated. That's a bit complicated
+ // since the game adds & removes its own handler on the fly.
+ if (Game1.viewport.Width != this.PreviousWindowSize.X || Game1.viewport.Height != this.PreviousWindowSize.Y)
+ {
+ Point size = new Point(Game1.viewport.Width, Game1.viewport.Height);
+ GraphicsEvents.InvokeResize(this.Monitor);
+ this.PreviousWindowSize = size;
+ }
+
+ /*********
+ ** Input events (if window has focus)
+ *********/
+ if (Game1.game1.IsActive)
+ {
+ // get latest state
+ KeyboardState keyState;
+ GamePadState controllerState;
+ MouseState mouseState;
+ Point mousePosition;
+ try
+ {
+ keyState = Keyboard.GetState();
+ controllerState = GamePad.GetState(PlayerIndex.One);
+ mouseState = Mouse.GetState();
+ mousePosition = new Point(Game1.getMouseX(), Game1.getMouseY());
+ }
+ catch (InvalidOperationException) // GetState() may crash for some players if window doesn't have focus but game1.IsActive == true
+ {
+ keyState = this.PreviousKeyState;
+ controllerState = this.PreviousControllerState;
+ mouseState = this.PreviousMouseState;
+ mousePosition = this.PreviousMousePosition;
+ }
+
+ // analyse state
+ SButton[] currentlyPressedKeys = this.GetPressedButtons(keyState, mouseState, controllerState).ToArray();
+ SButton[] previousPressedKeys = this.PreviousPressedButtons;
+ SButton[] framePressedKeys = currentlyPressedKeys.Except(previousPressedKeys).ToArray();
+ SButton[] frameReleasedKeys = previousPressedKeys.Except(currentlyPressedKeys).ToArray();
+ bool isClick = framePressedKeys.Contains(SButton.MouseLeft) || (framePressedKeys.Contains(SButton.ControllerA) && !currentlyPressedKeys.Contains(SButton.ControllerX));
+
+ // get cursor position
+ ICursorPosition cursor;
+ {
+ // cursor position
+ Vector2 screenPixels = new Vector2(Game1.getMouseX(), Game1.getMouseY());
+ Vector2 tile = new Vector2((Game1.viewport.X + screenPixels.X) / Game1.tileSize, (Game1.viewport.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();
+ cursor = new CursorPosition(screenPixels, tile, grabTile);
+ }
+
+ // raise button pressed
+ foreach (SButton button in framePressedKeys)
+ {
+ InputEvents.InvokeButtonPressed(this.Monitor, button, cursor, isClick);
+
+ // legacy events
+ if (button.TryGetKeyboard(out Keys key))
+ {
+ if (key != Keys.None)
+ ControlEvents.InvokeKeyPressed(this.Monitor, key);
+ }
+ else if (button.TryGetController(out Buttons controllerButton))
+ {
+ if (controllerButton == Buttons.LeftTrigger || controllerButton == Buttons.RightTrigger)
+ ControlEvents.InvokeTriggerPressed(this.Monitor, controllerButton, controllerButton == Buttons.LeftTrigger ? controllerState.Triggers.Left : controllerState.Triggers.Right);
+ else
+ ControlEvents.InvokeButtonPressed(this.Monitor, controllerButton);
+ }
+ }
+
+ // raise button released
+ foreach (SButton button in frameReleasedKeys)
+ {
+ bool wasClick =
+ (button == SButton.MouseLeft && previousPressedKeys.Contains(SButton.MouseLeft)) // released left click
+ || (button == SButton.ControllerA && previousPressedKeys.Contains(SButton.ControllerA) && !previousPressedKeys.Contains(SButton.ControllerX));
+ InputEvents.InvokeButtonReleased(this.Monitor, button, cursor, wasClick);
+
+ // legacy events
+ if (button.TryGetKeyboard(out Keys key))
+ {
+ if (key != Keys.None)
+ ControlEvents.InvokeKeyReleased(this.Monitor, key);
+ }
+ else if (button.TryGetController(out Buttons controllerButton))
+ {
+ if (controllerButton == Buttons.LeftTrigger || controllerButton == Buttons.RightTrigger)
+ ControlEvents.InvokeTriggerReleased(this.Monitor, controllerButton, controllerButton == Buttons.LeftTrigger ? controllerState.Triggers.Left : controllerState.Triggers.Right);
+ else
+ ControlEvents.InvokeButtonReleased(this.Monitor, controllerButton);
+ }
+ }
+
+ // raise legacy state-changed events
+ if (keyState != this.PreviousKeyState)
+ ControlEvents.InvokeKeyboardChanged(this.Monitor, this.PreviousKeyState, keyState);
+ if (mouseState != this.PreviousMouseState)
+ ControlEvents.InvokeMouseChanged(this.Monitor, this.PreviousMouseState, mouseState, this.PreviousMousePosition, mousePosition);
+
+ // track state
+ this.PreviousMouseState = mouseState;
+ this.PreviousMousePosition = mousePosition;
+ this.PreviousKeyState = keyState;
+ this.PreviousControllerState = controllerState;
+ this.PreviousPressedButtons = currentlyPressedKeys;
+ }
+
+ /*********
+ ** Menu events
+ *********/
+ if (Game1.activeClickableMenu != this.PreviousActiveMenu)
+ {
+ IClickableMenu previousMenu = this.PreviousActiveMenu;
+ IClickableMenu newMenu = Game1.activeClickableMenu;
+
+ // log context
+ if (this.VerboseLogging)
+ {
+ if (previousMenu == null)
+ this.Monitor.Log($"Context: opened menu {newMenu?.GetType().FullName ?? "(none)"}.", LogLevel.Trace);
+ else if (newMenu == null)
+ this.Monitor.Log($"Context: closed menu {previousMenu.GetType().FullName}.", LogLevel.Trace);
+ else
+ this.Monitor.Log($"Context: changed menu from {previousMenu.GetType().FullName} to {newMenu.GetType().FullName}.", LogLevel.Trace);
+ }
+
+ // raise menu events
+ if (newMenu != null)
+ MenuEvents.InvokeMenuChanged(this.Monitor, previousMenu, newMenu);
+ else
+ MenuEvents.InvokeMenuClosed(this.Monitor, previousMenu);
+
+ // update previous menu
+ // (if the menu was changed in one of the handlers, deliberately defer detection until the next update so mods can be notified of the new menu change)
+ this.PreviousActiveMenu = newMenu;
+ }
+
+ /*********
+ ** World & player events
+ *********/
+ if (Context.IsWorldReady)
+ {
+ // raise current location changed
+ if (Game1.currentLocation != this.PreviousGameLocation)
+ {
+ if (this.VerboseLogging)
+ this.Monitor.Log($"Context: set location to {Game1.currentLocation?.Name ?? "(none)"}.", LogLevel.Trace);
+ LocationEvents.InvokeCurrentLocationChanged(this.Monitor, this.PreviousGameLocation, Game1.currentLocation);
+ }
+
+ // raise location list changed
+ if (this.GetHash(Game1.locations) != this.PreviousGameLocations)
+ LocationEvents.InvokeLocationsChanged(this.Monitor, Game1.locations);
+
+ // raise events that shouldn't be triggered on initial load
+ if (Game1.uniqueIDForThisGame == this.PreviousSaveID)
+ {
+ // raise player leveled up a skill
+ if (Game1.player.combatLevel != this.PreviousCombatLevel)
+ PlayerEvents.InvokeLeveledUp(this.Monitor, EventArgsLevelUp.LevelType.Combat, Game1.player.combatLevel);
+ if (Game1.player.farmingLevel != this.PreviousFarmingLevel)
+ PlayerEvents.InvokeLeveledUp(this.Monitor, EventArgsLevelUp.LevelType.Farming, Game1.player.farmingLevel);
+ if (Game1.player.fishingLevel != this.PreviousFishingLevel)
+ PlayerEvents.InvokeLeveledUp(this.Monitor, EventArgsLevelUp.LevelType.Fishing, Game1.player.fishingLevel);
+ if (Game1.player.foragingLevel != this.PreviousForagingLevel)
+ PlayerEvents.InvokeLeveledUp(this.Monitor, EventArgsLevelUp.LevelType.Foraging, Game1.player.foragingLevel);
+ if (Game1.player.miningLevel != this.PreviousMiningLevel)
+ PlayerEvents.InvokeLeveledUp(this.Monitor, EventArgsLevelUp.LevelType.Mining, Game1.player.miningLevel);
+ if (Game1.player.luckLevel != this.PreviousLuckLevel)
+ PlayerEvents.InvokeLeveledUp(this.Monitor, EventArgsLevelUp.LevelType.Luck, Game1.player.luckLevel);
+
+ // raise player inventory changed
+ ItemStackChange[] changedItems = this.GetInventoryChanges(Game1.player.items, this.PreviousItems).ToArray();
+ if (changedItems.Any())
+ PlayerEvents.InvokeInventoryChanged(this.Monitor, Game1.player.items, changedItems);
+
+ // raise current location's object list changed
+ if (this.GetHash(Game1.currentLocation.objects) != this.PreviousLocationObjects)
+ LocationEvents.InvokeOnNewLocationObject(this.Monitor, Game1.currentLocation.objects);
+
+ // raise time changed
+ if (Game1.timeOfDay != this.PreviousTime)
+ TimeEvents.InvokeTimeOfDayChanged(this.Monitor, this.PreviousTime, Game1.timeOfDay);
+
+ // raise mine level changed
+ if (Game1.mine != null && Game1.mine.mineLevel != this.PreviousMineLevel)
+ MineEvents.InvokeMineLevelChanged(this.Monitor, this.PreviousMineLevel, Game1.mine.mineLevel);
+ }
+
+ // update state
+ this.PreviousGameLocations = this.GetHash(Game1.locations);
+ this.PreviousGameLocation = Game1.currentLocation;
+ this.PreviousCombatLevel = Game1.player.combatLevel;
+ this.PreviousFarmingLevel = Game1.player.farmingLevel;
+ this.PreviousFishingLevel = Game1.player.fishingLevel;
+ this.PreviousForagingLevel = Game1.player.foragingLevel;
+ this.PreviousMiningLevel = Game1.player.miningLevel;
+ this.PreviousLuckLevel = Game1.player.luckLevel;
+ this.PreviousItems = Game1.player.items.Where(n => n != null).ToDictionary(n => n, n => n.Stack);
+ this.PreviousLocationObjects = this.GetHash(Game1.currentLocation.objects);
+ this.PreviousTime = Game1.timeOfDay;
+ this.PreviousMineLevel = Game1.mine?.mineLevel ?? 0;
+ this.PreviousSaveID = Game1.uniqueIDForThisGame;
+ }
+
+ /*********
+ ** Game update
+ *********/
+ try
+ {
+ base.Update(gameTime);
+ }
+ catch (Exception ex)
+ {
+ this.Monitor.Log($"An error occured in the base update loop: {ex.GetLogSummary()}", LogLevel.Error);
+ }
+
+ /*********
+ ** Update events
+ *********/
+ GameEvents.InvokeUpdateTick(this.Monitor);
+ if (this.FirstUpdate)
+ this.FirstUpdate = false;
+ if (this.CurrentUpdateTick % 2 == 0)
+ GameEvents.InvokeSecondUpdateTick(this.Monitor);
+ if (this.CurrentUpdateTick % 4 == 0)
+ GameEvents.InvokeFourthUpdateTick(this.Monitor);
+ if (this.CurrentUpdateTick % 8 == 0)
+ GameEvents.InvokeEighthUpdateTick(this.Monitor);
+ if (this.CurrentUpdateTick % 15 == 0)
+ GameEvents.InvokeQuarterSecondTick(this.Monitor);
+ if (this.CurrentUpdateTick % 30 == 0)
+ GameEvents.InvokeHalfSecondTick(this.Monitor);
+ if (this.CurrentUpdateTick % 60 == 0)
+ GameEvents.InvokeOneSecondTick(this.Monitor);
+ this.CurrentUpdateTick += 1;
+ if (this.CurrentUpdateTick >= 60)
+ this.CurrentUpdateTick = 0;
+
+ 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.Monitor.ExitGameImmediately("the game crashed when updating, and SMAPI was unable to recover the game.");
+ }
+ }
+
+ /// <summary>The method called to draw everything to the screen.</summary>
+ /// <param name="gameTime">A snapshot of the game timing state.</param>
+ protected override void Draw(GameTime gameTime)
+ {
+ Context.IsInDrawLoop = true;
+ try
+ {
+ this.DrawImpl(gameTime);
+ this.DrawCrashTimer.Reset();
+ }
+ catch (Exception ex)
+ {
+ // log error
+ this.Monitor.Log($"An error occured in the overridden draw loop: {ex.GetLogSummary()}", LogLevel.Error);
+
+ // exit if irrecoverable
+ if (!this.DrawCrashTimer.Decrement())
+ {
+ this.Monitor.ExitGameImmediately("the game crashed when drawing, and SMAPI was unable to recover the game.");
+ return;
+ }
+
+ // recover sprite batch
+ try
+ {
+ if (Game1.spriteBatch.IsOpen(SGame.Reflection))
+ {
+ this.Monitor.Log("Recovering sprite batch from error...", LogLevel.Trace);
+ Game1.spriteBatch.End();
+ }
+ }
+ catch (Exception innerEx)
+ {
+ this.Monitor.Log($"Could not recover sprite batch state: {innerEx.GetLogSummary()}", LogLevel.Error);
+ }
+ }
+ Context.IsInDrawLoop = false;
+ }
+
+ /// <summary>Replicate the game's draw logic with some changes for SMAPI.</summary>
+ /// <param name="gameTime">A snapshot of the game timing state.</param>
+ /// <remarks>This implementation is identical to <see cref="Game1.Draw"/>, except for try..catch around menu draw code, private field references replaced by wrappers, and added events.</remarks>
+ [SuppressMessage("ReSharper", "CompareOfFloatsByEqualityOperator", Justification = "copied from game code as-is")]
+ [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", "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")]
+ private void DrawImpl(GameTime gameTime)
+ {
+ if (Game1.debugMode)
+ {
+ if (SGame._fpsStopwatch.IsRunning)
+ {
+ float totalSeconds = (float)SGame._fpsStopwatch.Elapsed.TotalSeconds;
+ SGame._fpsList.Add(totalSeconds);
+ while (SGame._fpsList.Count >= 120)
+ SGame._fpsList.RemoveAt(0);
+ float num = 0.0f;
+ foreach (float fps in SGame._fpsList)
+ num += fps;
+ SGame._fps = (float)(1.0 / ((double)num / (double)SGame._fpsList.Count));
+ }
+ SGame._fpsStopwatch.Restart();
+ }
+ else
+ {
+ if (SGame._fpsStopwatch.IsRunning)
+ SGame._fpsStopwatch.Reset();
+ SGame._fps = 0.0f;
+ SGame._fpsList.Clear();
+ }
+ if (SGame._newDayTask != null)
+ {
+ this.GraphicsDevice.Clear(this.bgColor);
+ //base.Draw(gameTime);
+ }
+ else
+ {
+ if ((double)Game1.options.zoomLevel != 1.0)
+ this.GraphicsDevice.SetRenderTarget(this.screenWrapper);
+ if (this.IsSaving)
+ {
+ this.GraphicsDevice.Clear(this.bgColor);
+ IClickableMenu activeClickableMenu = Game1.activeClickableMenu;
+ if (activeClickableMenu != null)
+ {
+ Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointClamp, (DepthStencilState)null, (RasterizerState)null);
+ try
+ {
+ GraphicsEvents.InvokeOnPreRenderGuiEvent(this.Monitor);
+ activeClickableMenu.draw(Game1.spriteBatch);
+ GraphicsEvents.InvokeOnPostRenderGuiEvent(this.Monitor);
+ }
+ catch (Exception ex)
+ {
+ this.Monitor.Log($"The {activeClickableMenu.GetType().FullName} menu crashed while drawing itself during save. SMAPI will force it to exit to avoid crashing the game.\n{ex.GetLogSummary()}", LogLevel.Error);
+ activeClickableMenu.exitThisMenu();
+ }
+ Game1.spriteBatch.End();
+ }
+ //base.Draw(gameTime);
+ this.renderScreenBuffer();
+ }
+ else
+ {
+ this.GraphicsDevice.Clear(this.bgColor);
+ if (Game1.activeClickableMenu != null && Game1.options.showMenuBackground && Game1.activeClickableMenu.showWithoutTransparencyIfOptionIsSet())
+ {
+ Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointClamp, (DepthStencilState)null, (RasterizerState)null);
+ try
+ {
+ Game1.activeClickableMenu.drawBackground(Game1.spriteBatch);
+ GraphicsEvents.InvokeOnPreRenderGuiEvent(this.Monitor);
+ Game1.activeClickableMenu.draw(Game1.spriteBatch);
+ GraphicsEvents.InvokeOnPostRenderGuiEvent(this.Monitor);
+ }
+ 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);
+ Game1.activeClickableMenu.exitThisMenu();
+ }
+ Game1.spriteBatch.End();
+ if ((double)Game1.options.zoomLevel != 1.0)
+ {
+ this.GraphicsDevice.SetRenderTarget((RenderTarget2D)null);
+ this.GraphicsDevice.Clear(this.bgColor);
+ Game1.spriteBatch.Begin(SpriteSortMode.Immediate, BlendState.AlphaBlend, SamplerState.LinearClamp, DepthStencilState.Default, RasterizerState.CullNone);
+ Game1.spriteBatch.Draw((Texture2D)this.screenWrapper, Vector2.Zero, new Microsoft.Xna.Framework.Rectangle?(this.screenWrapper.Bounds), Color.White, 0.0f, Vector2.Zero, Game1.options.zoomLevel, SpriteEffects.None, 1f);
+ Game1.spriteBatch.End();
+ }
+ if (Game1.overlayMenu == null)
+ return;
+ Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointClamp, (DepthStencilState)null, (RasterizerState)null);
+ Game1.overlayMenu.draw(Game1.spriteBatch);
+ Game1.spriteBatch.End();
+ }
+ else if ((int)Game1.gameMode == 11)
+ {
+ Game1.spriteBatch.Begin(SpriteSortMode.Immediate, BlendState.AlphaBlend, SamplerState.PointClamp, (DepthStencilState)null, (RasterizerState)null);
+ 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, (int)byte.MaxValue, 0));
+ Game1.spriteBatch.DrawString(Game1.dialogueFont, Game1.parseText(Game1.errorMessage, Game1.dialogueFont, Game1.graphics.GraphicsD