update for split-screen mode

This includes splitting GameRunner (the main game instance) from Game1 (now a per-screen game state), adding a PerScreen<T> utility to simplify per-screen values, adding separate per-screen input handling and events, adding new Context fields for split-screen, and logging the screen ID in split-screen mode to distinguish log entries.
This commit is contained in:
Jesse Plamondon-Willard 2020-12-20 22:34:59 -05:00
parent 50a146d1c9
commit 2e8c7e06c5
No known key found for this signature in database
GPG Key ID: CF8B1456B3E29F49
16 changed files with 646 additions and 277 deletions

View File

@ -9,7 +9,11 @@
## Upcoming release
* For players:
* Updated for Stardew Valley 1.5.
* Updated for Stardew Valley 1.5, including split-screen support.
* 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.
## 3.7.6
Released 21 November 2020 for Stardew Valley 1.4.1 or later.

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

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

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

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

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

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