summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorJesse Plamondon-Willard <Pathoschild@users.noreply.github.com>2020-03-22 19:52:42 -0400
committerJesse Plamondon-Willard <Pathoschild@users.noreply.github.com>2020-03-22 19:52:42 -0400
commit7ca5efbbc576f3c6c43493654b2a0ac040fd4f31 (patch)
treefae7a4e06a14ff7f8d709e2f4d5b8b92b8784a37 /src
parent5ae640dc91adff8dfb0827e2a3c3f6b54be7c612 (diff)
parent6d1494a56c5d04e7bc1ee406810a5a53dea2229a (diff)
downloadSMAPI-7ca5efbbc576f3c6c43493654b2a0ac040fd4f31.tar.gz
SMAPI-7ca5efbbc576f3c6c43493654b2a0ac040fd4f31.tar.bz2
SMAPI-7ca5efbbc576f3c6c43493654b2a0ac040fd4f31.zip
Merge branch 'develop' into stable
Diffstat (limited to 'src')
-rw-r--r--src/SMAPI.Mods.ConsoleCommands/Framework/ItemRepository.cs28
-rw-r--r--src/SMAPI.Mods.ConsoleCommands/manifest.json4
-rw-r--r--src/SMAPI.Mods.SaveBackup/manifest.json4
-rw-r--r--src/SMAPI.Toolkit/SMAPI.Toolkit.csproj2
-rw-r--r--src/SMAPI.Web/Framework/LogParsing/LogMessageBuilder.cs89
-rw-r--r--src/SMAPI.Web/Framework/LogParsing/LogParser.cs66
-rw-r--r--src/SMAPI.Web/Program.cs2
-rw-r--r--src/SMAPI.Web/SMAPI.Web.csproj12
-rw-r--r--src/SMAPI.Web/Startup.cs3
-rw-r--r--src/SMAPI.Web/Views/JsonValidator/Index.cshtml9
-rw-r--r--src/SMAPI.Web/Views/LogParser/Index.cshtml12
-rw-r--r--src/SMAPI.Web/wwwroot/Content/css/file-upload.css25
-rw-r--r--src/SMAPI.Web/wwwroot/Content/css/json-validator.css25
-rw-r--r--src/SMAPI.Web/wwwroot/Content/css/log-parser.css21
-rw-r--r--src/SMAPI.Web/wwwroot/Content/js/file-upload.js71
-rw-r--r--src/SMAPI.Web/wwwroot/Content/js/json-validator.js51
-rw-r--r--src/SMAPI.Web/wwwroot/Content/js/log-parser.js38
-rw-r--r--src/SMAPI/Constants.cs2
-rw-r--r--src/SMAPI/Events/ButtonPressedEventArgs.cs6
-rw-r--r--src/SMAPI/Events/ButtonReleasedEventArgs.cs6
-rw-r--r--src/SMAPI/Framework/ContentManagers/ModContentManager.cs7
-rw-r--r--src/SMAPI/Framework/Input/GamePadStateBuilder.cs283
-rw-r--r--src/SMAPI/Framework/Input/IInputStateBuilder.cs29
-rw-r--r--src/SMAPI/Framework/Input/KeyboardStateBuilder.cs78
-rw-r--r--src/SMAPI/Framework/Input/MouseStateBuilder.cs107
-rw-r--r--src/SMAPI/Framework/Input/SInputState.cs347
-rw-r--r--src/SMAPI/Framework/ModHelpers/InputHelper.cs4
-rw-r--r--src/SMAPI/Framework/Monitor.cs13
-rw-r--r--src/SMAPI/Framework/Rendering/SDisplayDevice.cs89
-rw-r--r--src/SMAPI/Framework/Rendering/SXnaDisplayDevice.cs121
-rw-r--r--src/SMAPI/Framework/SGame.cs25
-rw-r--r--src/SMAPI/Framework/WatcherCore.cs2
-rw-r--r--src/SMAPI/IMonitor.cs5
-rw-r--r--src/SMAPI/Patches/LoadErrorPatch.cs25
-rw-r--r--src/SMAPI/SMAPI.csproj2
-rw-r--r--src/SMAPI/i18n/hu.json3
36 files changed, 1085 insertions, 531 deletions
diff --git a/src/SMAPI.Mods.ConsoleCommands/Framework/ItemRepository.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/ItemRepository.cs
index 08dd8eed..6a17213c 100644
--- a/src/SMAPI.Mods.ConsoleCommands/Framework/ItemRepository.cs
+++ b/src/SMAPI.Mods.ConsoleCommands/Framework/ItemRepository.cs
@@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using Microsoft.Xna.Framework;
+using Microsoft.Xna.Framework.Content;
using StardewModdingAPI.Mods.ConsoleCommands.Framework.ItemData;
using StardewValley;
using StardewValley.Menus;
@@ -59,13 +60,13 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework
yield return this.TryCreate(ItemType.Flooring, id, () => new Wallpaper(id, isFloor: true) { Category = SObject.furnitureCategory });
// equipment
- foreach (int id in Game1.content.Load<Dictionary<int, string>>("Data\\Boots").Keys)
+ foreach (int id in this.TryLoad<int, string>("Data\\Boots").Keys)
yield return this.TryCreate(ItemType.Boots, id, () => new Boots(id));
- foreach (int id in Game1.content.Load<Dictionary<int, string>>("Data\\hats").Keys)
+ foreach (int id in this.TryLoad<int, string>("Data\\hats").Keys)
yield return this.TryCreate(ItemType.Hat, id, () => new Hat(id));
// weapons
- foreach (int id in Game1.content.Load<Dictionary<int, string>>("Data\\weapons").Keys)
+ foreach (int id in this.TryLoad<int, string>("Data\\weapons").Keys)
{
yield return this.TryCreate(ItemType.Weapon, id, () => (id >= 32 && id <= 34)
? (Item)new Slingshot(id)
@@ -74,7 +75,7 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework
}
// furniture
- foreach (int id in Game1.content.Load<Dictionary<int, string>>("Data\\Furniture").Keys)
+ foreach (int id in this.TryLoad<int, string>("Data\\Furniture").Keys)
{
if (id == 1466 || id == 1468)
yield return this.TryCreate(ItemType.Furniture, id, () => new TV(id, Vector2.Zero));
@@ -94,7 +95,7 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework
// secret notes
if (id == 79)
{
- foreach (int secretNoteId in Game1.content.Load<Dictionary<int, string>>("Data\\SecretNotes").Keys)
+ foreach (int secretNoteId in this.TryLoad<int, string>("Data\\SecretNotes").Keys)
{
yield return this.TryCreate(ItemType.Object, this.CustomIDOffset + secretNoteId, () =>
{
@@ -233,6 +234,23 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework
/*********
** Private methods
*********/
+ /// <summary>Try to load a data file, and return empty data if it's invalid.</summary>
+ /// <typeparam name="TKey">The asset key type.</typeparam>
+ /// <typeparam name="TValue">The asset value type.</typeparam>
+ /// <param name="assetName">The data asset name.</param>
+ private Dictionary<TKey, TValue> TryLoad<TKey, TValue>(string assetName)
+ {
+ try
+ {
+ return Game1.content.Load<Dictionary<TKey, TValue>>(assetName);
+ }
+ catch (ContentLoadException)
+ {
+ // generally due to a player incorrectly replacing a data file with an XNB mod
+ return new Dictionary<TKey, TValue>();
+ }
+ }
+
/// <summary>Create a searchable item if valid.</summary>
/// <param name="type">The item type.</param>
/// <param name="id">The unique ID (if different from the item's parent sheet index).</param>
diff --git a/src/SMAPI.Mods.ConsoleCommands/manifest.json b/src/SMAPI.Mods.ConsoleCommands/manifest.json
index 0e6805dc..dbed84eb 100644
--- a/src/SMAPI.Mods.ConsoleCommands/manifest.json
+++ b/src/SMAPI.Mods.ConsoleCommands/manifest.json
@@ -1,9 +1,9 @@
{
"Name": "Console Commands",
"Author": "SMAPI",
- "Version": "3.3.2",
+ "Version": "3.4.0",
"Description": "Adds SMAPI console commands that let you manipulate the game.",
"UniqueID": "SMAPI.ConsoleCommands",
"EntryDll": "ConsoleCommands.dll",
- "MinimumApiVersion": "3.3.2"
+ "MinimumApiVersion": "3.4.0"
}
diff --git a/src/SMAPI.Mods.SaveBackup/manifest.json b/src/SMAPI.Mods.SaveBackup/manifest.json
index 5165d2b2..dc8bc8d4 100644
--- a/src/SMAPI.Mods.SaveBackup/manifest.json
+++ b/src/SMAPI.Mods.SaveBackup/manifest.json
@@ -1,9 +1,9 @@
{
"Name": "Save Backup",
"Author": "SMAPI",
- "Version": "3.3.2",
+ "Version": "3.4.0",
"Description": "Automatically backs up all your saves once per day into its folder.",
"UniqueID": "SMAPI.SaveBackup",
"EntryDll": "SaveBackup.dll",
- "MinimumApiVersion": "3.3.2"
+ "MinimumApiVersion": "3.4.0"
}
diff --git a/src/SMAPI.Toolkit/SMAPI.Toolkit.csproj b/src/SMAPI.Toolkit/SMAPI.Toolkit.csproj
index a7de7166..edb1d612 100644
--- a/src/SMAPI.Toolkit/SMAPI.Toolkit.csproj
+++ b/src/SMAPI.Toolkit/SMAPI.Toolkit.csproj
@@ -12,7 +12,7 @@
</PropertyGroup>
<ItemGroup>
- <PackageReference Include="HtmlAgilityPack" Version="1.11.20" />
+ <PackageReference Include="HtmlAgilityPack" Version="1.11.23" />
<PackageReference Include="Newtonsoft.Json" Version="12.0.3" />
<PackageReference Include="Pathoschild.Http.FluentClient" Version="3.3.1" />
<PackageReference Include="System.Management" Version="4.5.0" Condition="'$(OS)' == 'Windows_NT'" />
diff --git a/src/SMAPI.Web/Framework/LogParsing/LogMessageBuilder.cs b/src/SMAPI.Web/Framework/LogParsing/LogMessageBuilder.cs
new file mode 100644
index 00000000..42e283a9
--- /dev/null
+++ b/src/SMAPI.Web/Framework/LogParsing/LogMessageBuilder.cs
@@ -0,0 +1,89 @@
+using System;
+using System.Text;
+using StardewModdingAPI.Web.Framework.LogParsing.Models;
+
+namespace StardewModdingAPI.Web.Framework.LogParsing
+{
+ /// <summary>Handles constructing log message instances with minimal memory allocation.</summary>
+ internal class LogMessageBuilder
+ {
+ /*********
+ ** Fields
+ *********/
+ /// <summary>The local time when the next log was posted.</summary>
+ public string Time { get; set; }
+
+ /// <summary>The log level for the next log message.</summary>
+ public LogLevel Level { get; set; }
+
+ /// <summary>The mod name for the next log message.</summary>
+ public string Mod { get; set; }
+
+ /// <summary>The text for the next log message.</summary>
+ private readonly StringBuilder Text = new StringBuilder();
+
+
+ /*********
+ ** Accessors
+ *********/
+ /// <summary>Whether the next log message has been started.</summary>
+ public bool Started { get; private set; }
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <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="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)
+ {
+ if (this.Started)
+ throw new InvalidOperationException("Can't start new message, previous log message isn't done yet.");
+
+ this.Started = true;
+
+ this.Time = time;
+ this.Level = level;
+ this.Mod = mod;
+ this.Text.Append(text);
+ }
+
+ /// <summary>Add a new line to the next log message being built.</summary>
+ /// <param name="text">The line to add.</param>
+ /// <exception cref="InvalidOperationException">A log message hasn't been started yet.</exception>
+ public void AddLine(string text)
+ {
+ if (!this.Started)
+ throw new InvalidOperationException("Can't add text, no log message started yet.");
+
+ this.Text.Append("\n");
+ this.Text.Append(text);
+ }
+
+ /// <summary>Get a log message for the accumulated values.</summary>
+ public LogMessage Build()
+ {
+ if (!this.Started)
+ return null;
+
+ return new LogMessage
+ {
+ Time = this.Time,
+ Level = this.Level,
+ Mod = this.Mod,
+ Text = this.Text.ToString()
+ };
+ }
+
+ /// <summary>Reset to start a new log message.</summary>
+ public void Clear()
+ {
+ this.Started = false;
+ this.Text.Clear();
+ }
+ }
+}
diff --git a/src/SMAPI.Web/Framework/LogParsing/LogParser.cs b/src/SMAPI.Web/Framework/LogParsing/LogParser.cs
index cc91ec51..cce80816 100644
--- a/src/SMAPI.Web/Framework/LogParsing/LogParser.cs
+++ b/src/SMAPI.Web/Framework/LogParsing/LogParser.cs
@@ -201,7 +201,7 @@ namespace StardewModdingAPI.Web.Framework.LogParsing
}
// mod path line
- else if (message.Level == LogLevel.Debug && this.ModPathPattern.IsMatch(message.Text))
+ else if (message.Level == LogLevel.Info && this.ModPathPattern.IsMatch(message.Text))
{
Match match = this.ModPathPattern.Match(message.Text);
log.ModPath = match.Groups["path"].Value;
@@ -282,43 +282,47 @@ namespace StardewModdingAPI.Web.Framework.LogParsing
/// <exception cref="LogParseException">The log text can't be parsed successfully.</exception>
private IEnumerable<LogMessage> GetMessages(string logText)
{
- LogMessage message = new LogMessage();
- using (StringReader reader = new StringReader(logText))
+ LogMessageBuilder builder = new LogMessageBuilder();
+ using StringReader reader = new StringReader(logText);
+ while (true)
{
- while (true)
- {
- // read data
- string line = reader.ReadLine();
- if (line == null)
- break;
- Match header = this.MessageHeaderPattern.Match(line);
-
- // validate
- if (message.Text == null && !header.Success)
- throw new LogParseException("Found a log message with no SMAPI metadata. Is this a SMAPI log file?");
+ // read line
+ string line = reader.ReadLine();
+ if (line == null)
+ break;
- // start or continue message
- if (header.Success)
- {
- if (message.Text != null)
- yield return message;
+ // match header
+ Match header = this.MessageHeaderPattern.Match(line);
+ bool isNewMessage = header.Success;
- message = new LogMessage
- {
- Time = header.Groups["time"].Value,
- Level = Enum.Parse<LogLevel>(header.Groups["level"].Value, ignoreCase: true),
- Mod = header.Groups["modName"].Value,
- Text = line.Substring(header.Length)
- };
+ // start/continue message
+ if (isNewMessage)
+ {
+ if (builder.Started)
+ {
+ yield return builder.Build();
+ builder.Clear();
}
- else
- message.Text += "\n" + line;
+
+ builder.Start(
+ time: header.Groups["time"].Value,
+ level: Enum.Parse<LogLevel>(header.Groups["level"].Value, ignoreCase: true),
+ mod: header.Groups["modName"].Value,
+ text: line.Substring(header.Length)
+ );
}
+ else
+ {
+ if (!builder.Started)
+ throw new LogParseException("Found a log message with no SMAPI metadata. Is this a SMAPI log file?");
- // end last message
- if (message.Text != null)
- yield return message;
+ builder.AddLine(line);
+ }
}
+
+ // end last message
+ if (builder.Started)
+ yield return builder.Build();
}
}
}
diff --git a/src/SMAPI.Web/Program.cs b/src/SMAPI.Web/Program.cs
index 5856fc98..5d13cdf3 100644
--- a/src/SMAPI.Web/Program.cs
+++ b/src/SMAPI.Web/Program.cs
@@ -16,6 +16,8 @@ namespace StardewModdingAPI.Web
// configure web server
WebHost
.CreateDefaultBuilder(args)
+ .CaptureStartupErrors(true)
+ .UseSetting("detailedErrors", "true")
.UseStartup<Startup>()
.Build()
.Run();
diff --git a/src/SMAPI.Web/SMAPI.Web.csproj b/src/SMAPI.Web/SMAPI.Web.csproj
index 97bea0fb..0a978b30 100644
--- a/src/SMAPI.Web/SMAPI.Web.csproj
+++ b/src/SMAPI.Web/SMAPI.Web.csproj
@@ -12,14 +12,14 @@
</ItemGroup>
<ItemGroup>
- <PackageReference Include="Azure.Storage.Blobs" Version="12.3.0" />
+ <PackageReference Include="Azure.Storage.Blobs" Version="12.4.0" />
<PackageReference Include="Hangfire.AspNetCore" Version="1.7.9" />
- <PackageReference Include="Hangfire.MemoryStorage" Version="1.6.3" />
- <PackageReference Include="Hangfire.Mongo" Version="0.6.6" />
- <PackageReference Include="HtmlAgilityPack" Version="1.11.20" />
+ <PackageReference Include="Hangfire.MemoryStorage" Version="1.7.0" />
+ <PackageReference Include="Hangfire.Mongo" Version="0.6.7" />
+ <PackageReference Include="HtmlAgilityPack" Version="1.11.23" />
<PackageReference Include="Humanizer.Core" Version="2.7.9" />
<PackageReference Include="JetBrains.Annotations" Version="2019.1.3" />
- <PackageReference Include="Markdig" Version="0.18.1" />
+ <PackageReference Include="Markdig" Version="0.18.3" />
<PackageReference Include="Microsoft.AspNetCore" Version="2.2.0" />
<PackageReference Include="Microsoft.AspNetCore.Mvc" Version="2.2.0" />
<PackageReference Include="Microsoft.AspNetCore.Rewrite" Version="2.2.0" />
@@ -27,7 +27,7 @@
<PackageReference Include="Mongo2Go" Version="2.2.12" />
<PackageReference Include="MongoDB.Driver" Version="2.10.2" />
<PackageReference Include="Newtonsoft.Json.Schema" Version="3.0.13" />
- <PackageReference Include="Pathoschild.FluentNexus" Version="0.8.0" />
+ <PackageReference Include="Pathoschild.FluentNexus" Version="1.0.0" />
<PackageReference Include="Pathoschild.Http.FluentClient" Version="3.3.1" />
</ItemGroup>
<ItemGroup>
diff --git a/src/SMAPI.Web/Startup.cs b/src/SMAPI.Web/Startup.cs
index 29086472..56ef9a79 100644
--- a/src/SMAPI.Web/Startup.cs
+++ b/src/SMAPI.Web/Startup.cs
@@ -192,8 +192,7 @@ namespace StardewModdingAPI.Web
public void Configure(IApplicationBuilder app, IHostingEnvironment env)
{
// basic config
- if (env.IsDevelopment())
- app.UseDeveloperExceptionPage();
+ app.UseDeveloperExceptionPage();
app
.UseCors(policy => policy
.AllowAnyHeader()
diff --git a/src/SMAPI.Web/Views/JsonValidator/Index.cshtml b/src/SMAPI.Web/Views/JsonValidator/Index.cshtml
index a00c8387..7287e00b 100644
--- a/src/SMAPI.Web/Views/JsonValidator/Index.cshtml
+++ b/src/SMAPI.Web/Views/JsonValidator/Index.cshtml
@@ -28,14 +28,16 @@
{
<meta name="robots" content="noindex" />
}
- <link rel="stylesheet" href="~/Content/css/json-validator.css?r=20191204" />
+ <link rel="stylesheet" href="~/Content/css/file-upload.css?r=202002" />
+ <link rel="stylesheet" href="~/Content/css/json-validator.css?r=202002" />
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/tmont/sunlight@1.22.0/src/themes/sunlight.default.min.css" />
<script src="https://cdn.jsdelivr.net/npm/jquery@3.3.1/dist/jquery.min.js" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/gh/tmont/sunlight@1.22.0/src/sunlight.min.js" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/gh/tmont/sunlight@1.22.0/src/plugins/sunlight-plugin.linenumbers.min.js" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/gh/tmont/sunlight@1.22.0/src/lang/sunlight.javascript.min.js" crossorigin="anonymous"></script>
- <script src="~/Content/js/json-validator.js?r=20191204"></script>
+ <script src="~/Content/js/file-upload.js?r=202002"></script>
+ <script src="~/Content/js/json-validator.js?r=202002"></script>
<script>
$(function() {
smapi.jsonValidator(@Json.Serialize(this.Url.PlainAction("Index", "JsonValidator", new { schemaName = "$schemaName", id = "$id" })), @Json.Serialize(Model.PasteID));
@@ -86,6 +88,7 @@ else if (!isEditView && Model.PasteID != null)
{
<h2>Upload a JSON file</h2>
<form action="@this.Url.PlainAction("PostAsync", "JsonValidator")" method="post">
+ <input id="inputFile" type="file" />
<ol>
<li>
Choose the JSON format:<br />
@@ -97,7 +100,7 @@ else if (!isEditView && Model.PasteID != null)
</select>
</li>
<li>
- Drag the file onto this textbox (or paste the text in):<br />
+ Drag the file onto this textbox <small>(or <a href="#" id="choose-file-link">choose a file</a>)</small>:<br />
<textarea id="input" name="Content" placeholder="paste file here">@Model.Content</textarea>
</li>
<li>
diff --git a/src/SMAPI.Web/Views/LogParser/Index.cshtml b/src/SMAPI.Web/Views/LogParser/Index.cshtml
index 87c7f918..2183992b 100644
--- a/src/SMAPI.Web/Views/LogParser/Index.cshtml
+++ b/src/SMAPI.Web/Views/LogParser/Index.cshtml
@@ -22,10 +22,13 @@
{
<meta name="robots" content="noindex" />
}
- <link rel="stylesheet" href="~/Content/css/log-parser.css?r=20191127" />
+ <link rel="stylesheet" href="~/Content/css/file-upload.css?r=202002" />
+ <link rel="stylesheet" href="~/Content/css/log-parser.css?r=202002" />
+
<script src="https://cdn.jsdelivr.net/npm/vue@2.5.17/dist/vue.min.js" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/jquery@3.3.1/dist/jquery.min.js" crossorigin="anonymous"></script>
- <script src="~/Content/js/log-parser.js?r=20190515"></script>
+ <script src="~/Content/js/file-upload.js?r=202002"></script>
+ <script src="~/Content/js/log-parser.js?r=202002"></script>
<script>
$(function() {
smapi.logParser({
@@ -135,9 +138,10 @@ else if (Model.ParsedLog?.IsValid == true)
<h2>How do I share my log?</h2>
<form action="@this.Url.PlainAction("PostAsync", "LogParser")" method="post">
+ <input id="inputFile" type="file" />
<ol>
<li>
- Drag the file onto this textbox (or paste the text in):<br />
+ Drag the file onto this textbox <small>(or <a href="#" id="choose-file-link">choose a file</a>)</small>:<br />
<textarea id="input" name="input" placeholder="paste log here"></textarea>
</li>
<li>
@@ -300,7 +304,7 @@ else if (Model.ParsedLog?.IsValid == true)
string sectionFilter = message.Section != null && !message.IsStartOfSection ? $"&& sectionsAllow('{message.Section}')" : null; // filter the message by section if applicable
<tr class="mod @levelStr @sectionStartClass"
- @if (message.IsStartOfSection) { <text> v-on:click="toggleSection('@message.Section')" </text> }
+ @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>
<td v-pre>@message.Level.ToString().ToUpper()</td>
diff --git a/src/SMAPI.Web/wwwroot/Content/css/file-upload.css b/src/SMAPI.Web/wwwroot/Content/css/file-upload.css
new file mode 100644
index 00000000..ff170691
--- /dev/null
+++ b/src/SMAPI.Web/wwwroot/Content/css/file-upload.css
@@ -0,0 +1,25 @@
+#inputFile {
+ display: none;
+}
+
+#input {
+ width: 100%;
+ height: 20em;
+ max-height: 70%;
+ margin: auto;
+ box-sizing: border-box;
+ border-radius: 5px;
+ border: 1px solid #000088;
+ outline: none;
+ box-shadow: inset 0px 0px 1px 1px rgba(0, 0, 192, .2);
+}
+
+#submit {
+ font-size: 1.5em;
+ border-radius: 5px;
+ outline: none;
+ box-shadow: inset 0 0 1px 1px rgba(0, 0, 0, .2);
+ cursor: pointer;
+ border: 1px solid #008800;
+ background-color: #cfc;
+}
diff --git a/src/SMAPI.Web/wwwroot/Content/css/json-validator.css b/src/SMAPI.Web/wwwroot/Content/css/json-validator.css
index 18195098..de0f8fed 100644
--- a/src/SMAPI.Web/wwwroot/Content/css/json-validator.css
+++ b/src/SMAPI.Web/wwwroot/Content/css/json-validator.css
@@ -90,28 +90,3 @@
.footer-tip a {
color: gray;
}
-
-/*********
-** Upload form
-*********/
-#input {
- width: 100%;
- height: 20em;
- max-height: 70%;
- margin: auto;
- box-sizing: border-box;
- border-radius: 5px;
- border: 1px solid #000088;
- outline: none;
- box-shadow: inset 0px 0px 1px 1px rgba(0, 0, 192, .2);
-}
-
-#submit {
- font-size: 1.5em;
- border-radius: 5px;
- outline: none;
- box-shadow: inset 0 0 1px 1px rgba(0, 0, 0, .2);
- cursor: pointer;
- border: 1px solid #008800;
- background-color: #cfc;
-}
diff --git a/src/SMAPI.Web/wwwroot/Content/css/log-parser.css b/src/SMAPI.Web/wwwroot/Content/css/log-parser.css
index 4d4ab326..bfbc8982 100644
--- a/src/SMAPI.Web/wwwroot/Content/css/log-parser.css
+++ b/src/SMAPI.Web/wwwroot/Content/css/log-parser.css
@@ -301,24 +301,3 @@ div[data-os] {
display: none;
}
-#input {
- width: 100%;
- height: 20em;
- max-height: 70%;
- margin: auto;
- box-sizing: border-box;
- border-radius: 5px;
- border: 1px solid #000088;
- outline: none;
- box-shadow: inset 0px 0px 1px 1px rgba(0, 0, 192, .2);
-}
-
-#submit {
- font-size: 1.5em;
- border-radius: 5px;
- outline: none;
- box-shadow: inset 0 0 1px 1px rgba(0, 0, 0, .2);
- cursor: pointer;
- border: 1px solid #008800;
- background-color: #cfc;
-}
diff --git a/src/SMAPI.Web/wwwroot/Content/js/file-upload.js b/src/SMAPI.Web/wwwroot/Content/js/file-upload.js
new file mode 100644
index 00000000..411efad3
--- /dev/null
+++ b/src/SMAPI.Web/wwwroot/Content/js/file-upload.js
@@ -0,0 +1,71 @@
+/* globals $ */
+var smapi = smapi || {};
+
+/**
+ * Implements the logic for a log/JSON file upload form.
+ *
+ * @param {object} opts The file upload form options.
+ * @param {jQuery} opts.chooseFileLink The clickable link which shows the file chooser.
+ * @param {jQuery} opts.chooseFileInput The file input element.
+ * @param {jQuery} opts.contentArea The file content area.
+ * @param {jQuery} opts.submitButton The submit button.
+ */
+smapi.fileUpload = function (opts) {
+ /**
+ * Toggle the submit button if the form has content.
+ */
+ var toggleSubmit = function () {
+ var hasText = !!opts.contentArea.val().trim();
+ opts.submitButton.prop("disabled", !hasText);
+ };
+
+ /**
+ * Paste the content of a file into the content area.
+ * @param {File} file The file whose content to paste.
+ */
+ var pasteFile = function (file) {
+ var reader = new FileReader();
+ reader.onload = $.proxy(function (file, $input, event) {
+ $input.val(event.target.result);
+ toggleSubmit();
+ }, this, file, $("#input"));
+ reader.readAsText(file);
+ };
+
+ // initialize
+ if (opts.contentArea.length) {
+ // disable submit button if it's empty
+ opts.contentArea.on("input", toggleSubmit);
+ toggleSubmit();
+
+ // drag & drop file
+ opts.contentArea.on({
+ "dragover dragenter": function (e) {
+ e.preventDefault();
+ },
+ "drop": function (e) {
+ e.preventDefault();
+
+ var transfer = e.originalEvent.dataTransfer;
+ if (transfer && transfer.files.length)
+ pasteFile(transfer.files[0]);
+ }
+ });
+
+ // choose file
+ opts.chooseFileLink.on({
+ "click": function (e) {
+ e.preventDefault();
+ opts.chooseFileInput.click();
+ }
+ });
+ opts.chooseFileInput.on({
+ "change": function (e) {
+ if (!e.target.files || !e.target.files.length)
+ return;
+
+ pasteFile(e.target.files[0]);
+ }
+ });
+ }
+};
diff --git a/src/SMAPI.Web/wwwroot/Content/js/json-validator.js b/src/SMAPI.Web/wwwroot/Content/js/json-validator.js
index 72b8192b..e9f72226 100644
--- a/src/SMAPI.Web/wwwroot/Content/js/json-validator.js
+++ b/src/SMAPI.Web/wwwroot/Content/js/json-validator.js
@@ -41,7 +41,7 @@ smapi.LineNumberRange = function (maxLines) {
* Generate a URL hash for the current line range.
* @returns {string} The generated URL hash.
*/
- self.buildHash = function() {
+ self.buildHash = function () {
if (!self.minLine)
return "";
else if (self.minLine === self.maxLine)
@@ -54,7 +54,7 @@ smapi.LineNumberRange = function (maxLines) {
* Get a list of all selected lines.
* @returns {Array<int>} The selected line numbers.
*/
- self.getLinesSelected = function() {
+ self.getLinesSelected = function () {
// format
if (!self.minLine)
return [];
@@ -97,7 +97,7 @@ smapi.jsonValidator = function (urlFormat, fileId) {
});
// fix line links
- $(".sunlight-line-number-margin a").each(function() {
+ $(".sunlight-line-number-margin a").each(function () {
var link = $(this);
var lineNumber = parseInt(link.text());
link
@@ -111,7 +111,7 @@ smapi.jsonValidator = function (urlFormat, fileId) {
/**
* Scroll the page so the selected range is visible.
*/
- var scrollToRange = function() {
+ var scrollToRange = function () {
if (!selection.minLine)
return;
@@ -123,56 +123,33 @@ smapi.jsonValidator = function (urlFormat, fileId) {
* Initialize the JSON validator page.
*/
var init = function () {
+ var input = $("#input");
+
// set initial code formatting
selection.parseFromUrlHash(location.hash);
formatCode();
scrollToRange();
// update code formatting on hash change
- $(window).on("hashchange", function() {
+ $(window).on("hashchange", function () {
selection.parseFromUrlHash(location.hash);
formatCode();
scrollToRange();
});
// change format
- $("#output #format").on("change", function() {
+ $("#output #format").on("change", function () {
var schemaName = $(this).val();
location.href = urlFormat.replace("$schemaName", schemaName).replace("$id", fileId);
});
- // upload form
- var submit = $("#submit");
- var input = $("#input");
if (input.length) {
- // disable submit if it's empty
- var toggleSubmit = function () {
- var hasText = !!input.val().trim();
- submit.prop("disabled", !hasText);
- };
- input.on("input", toggleSubmit);
- toggleSubmit();
-
- // drag & drop file
- input.on({
- 'dragover dragenter': function (e) {
- e.preventDefault();
- e.stopPropagation();
- },
- 'drop': function (e) {
- var dataTransfer = e.originalEvent.dataTransfer;
- if (dataTransfer && dataTransfer.files.length) {
- e.preventDefault();
- e.stopPropagation();
- var file = dataTransfer.files[0];
- var reader = new FileReader();
- reader.onload = $.proxy(function (file, $input, event) {
- $input.val(event.target.result);
- toggleSubmit();
- }, this, file, $("#input"));
- reader.readAsText(file);
- }
- }
+ // upload form
+ smapi.fileUpload({
+ chooseFileLink: $("#choose-file-link"),
+ chooseFileInput: $("#inputFile"),
+ contentArea: input,
+ submitButton: $("#submit")
});
}
};
diff --git a/src/SMAPI.Web/wwwroot/Content/js/log-parser.js b/src/SMAPI.Web/wwwroot/Content/js/log-parser.js
index e6c7591c..6ae1707e 100644
--- a/src/SMAPI.Web/wwwroot/Content/js/log-parser.js
+++ b/src/SMAPI.Web/wwwroot/Content/js/log-parser.js
@@ -115,12 +115,10 @@ smapi.logParser = function (data, sectionUrl) {
*********/
var input = $("#input");
if (input.length) {
- // get elements
+ // instructions per OS
var systemOptions = $("input[name='os']");
var systemInstructions = $("div[data-os]");
- var submit = $("#submit");
- // instruction OS chooser
var chooseSystem = function () {
systemInstructions.hide();
systemInstructions.filter("[data-os='" + $("input[name='os']:checked").val() + "']").show();
@@ -128,34 +126,12 @@ smapi.logParser = function (data, sectionUrl) {
systemOptions.on("click", chooseSystem);
chooseSystem();
- // disable submit if it's empty
- var toggleSubmit = function () {
- var hasText = !!input.val().trim();
- submit.prop("disabled", !hasText);
- }
- input.on("input", toggleSubmit);
- toggleSubmit();
-
- // drag & drop file
- input.on({
- 'dragover dragenter': function (e) {
- e.preventDefault();
- e.stopPropagation();
- },
- 'drop': function (e) {
- var dataTransfer = e.originalEvent.dataTransfer;
- if (dataTransfer && dataTransfer.files.length) {
- e.preventDefault();
- e.stopPropagation();
- var file = dataTransfer.files[0];
- var reader = new FileReader();
- reader.onload = $.proxy(function (file, $input, event) {
- $input.val(event.target.result);
- toggleSubmit();
- }, this, file, $("#input"));
- reader.readAsText(file);
- }
- }
+ // file upload
+ smapi.fileUpload({
+ chooseFileLink: $("#choose-file-link"),
+ chooseFileInput: $("#inputFile"),
+ contentArea: input,
+ submitButton: $("#submit")
});
}
};
diff --git a/src/SMAPI/Constants.cs b/src/SMAPI/Constants.cs
index 3242a12c..d66e9d6b 100644
--- a/src/SMAPI/Constants.cs
+++ b/src/SMAPI/Constants.cs
@@ -20,7 +20,7 @@ namespace StardewModdingAPI
** Public
****/
/// <summary>SMAPI's current semantic version.</summary>
- public static ISemanticVersion ApiVersion { get; } = new Toolkit.SemanticVersion("3.3.2");
+ public static ISemanticVersion ApiVersion { get; } = new Toolkit.SemanticVersion("3.4.0");
/// <summary>The minimum supported version of Stardew Valley.</summary>
public static ISemanticVersion MinimumGameVersion { get; } = new GameVersion("1.4.1");
diff --git a/src/SMAPI/Events/ButtonPressedEventArgs.cs b/src/SMAPI/Events/ButtonPressedEventArgs.cs
index 5d922666..1b30fd23 100644
--- a/src/SMAPI/Events/ButtonPressedEventArgs.cs
+++ b/src/SMAPI/Events/ButtonPressedEventArgs.cs
@@ -37,17 +37,17 @@ namespace StardewModdingAPI.Events
this.InputState = inputState;
}
- /// <summary>Whether a mod has indicated the key was already handled, so the game should handle it.</summary>
+ /// <summary>Get whether a mod has indicated the key was already handled, so the game shouldn't handle it.</summary>
public bool IsSuppressed()
{
return this.IsSuppressed(this.Button);
}
- /// <summary>Whether a mod has indicated the key was already handled, so the game should handle it.</summary>
+ /// <summary>Get whether a mod has indicated the key was already handled, so the game shouldn't handle it.</summary>
/// <param name="button">The button to check.</param>
public bool IsSuppressed(SButton button)
{
- return this.InputState.SuppressButtons.Contains(button);
+ return this.InputState.IsSuppressed(button);
}
/// <summary>Get whether a given button was pressed or held.</summary>
diff --git a/src/SMAPI/Events/ButtonReleasedEventArgs.cs b/src/SMAPI/Events/ButtonReleasedEventArgs.cs
index f5282230..40ec1cc1 100644
--- a/src/SMAPI/Events/ButtonReleasedEventArgs.cs
+++ b/src/SMAPI/Events/ButtonReleasedEventArgs.cs
@@ -37,17 +37,17 @@ namespace StardewModdingAPI.Events
this.InputState = inputState;
}
- /// <summary>Whether a mod has indicated the key was already handled, so the game should handle it.</summary>
+ /// <summary>Get whether a mod has indicated the key was already handled, so the game shouldn't handle it.</summary>
public bool IsSuppressed()
{
return this.IsSuppressed(this.Button);
}
- /// <summary>Whether a mod has indicated the key was already handled, so the game should handle it.</summary>
+ /// <summary>Get whether a mod has indicated the key was already handled, so the game shouldn't handle it.</summary>
/// <param name="button">The button to check.</param>
public bool IsSuppressed(SButton button)
{
- return this.InputState.SuppressButtons.Contains(button);
+ return this.InputState.IsSuppressed(button);
}
/// <summary>Get whether a given button was pressed or held.</summary>
diff --git a/src/SMAPI/Framework/ContentManagers/ModContentManager.cs b/src/SMAPI/Framework/ContentManagers/ModContentManager.cs
index 4ffe3acd..fda80a83 100644
--- a/src/SMAPI/Framework/ContentManagers/ModContentManager.cs
+++ b/src/SMAPI/Framework/ContentManagers/ModContentManager.cs
@@ -241,13 +241,6 @@ namespace StardewModdingAPI.Framework.ContentManagers
/// <remarks>Based on <a href="https://gamedev.stackexchange.com/a/26037">code by David Gouveia</a>.</remarks>
private Texture2D PremultiplyTransparency(Texture2D texture)
{
- // Textures loaded by Texture2D.FromStream are already premultiplied on Linux/Mac, even
- // though the XNA documentation explicitly says otherwise. That's a glitch in MonoGame
- // fixed in newer versions, but the game uses a bundled version that will always be
- // affected. See https://github.com/MonoGame/MonoGame/issues/4820 for more info.
- if (Constants.TargetPlatform != GamePlatform.Windows)
- return texture;
-
// premultiply pixels
Color[] data = new Color[texture.Width * texture.Height];
texture.GetData(data);
diff --git a/src/SMAPI/Framework/Input/GamePadStateBuilder.cs b/src/SMAPI/Framework/Input/GamePadStateBuilder.cs
index a20e1248..36622066 100644
--- a/src/SMAPI/Framework/Input/GamePadStateBuilder.cs
+++ b/src/SMAPI/Framework/Input/GamePadStateBuilder.cs
@@ -4,14 +4,23 @@ using Microsoft.Xna.Framework.Input;
namespace StardewModdingAPI.Framework.Input
{
- /// <summary>An abstraction for manipulating controller state.</summary>
- internal class GamePadStateBuilder
+ /// <summary>Manages controller state.</summary>
+ internal class GamePadStateBuilder : IInputStateBuilder<GamePadStateBuilder, GamePadState>
{
/*********
** Fields
*********/
+ /// <summary>The maximum direction to ignore for the left thumbstick.</summary>
+ private const float LeftThumbstickDeadZone = 0.2f;
+
+ /// <summary>The maximum direction to ignore for the right thumbstick.</summary>
+ private const float RightThumbstickDeadZone = 0.9f;
+
+ /// <summary>The underlying controller state.</summary>
+ private GamePadState? State;
+
/// <summary>The current button states.</summary>
- private readonly IDictionary<SButton, ButtonState> ButtonStates;
+ private IDictionary<SButton, ButtonState> ButtonStates;
/// <summary>The left trigger value.</summary>
private float LeftTrigger;
@@ -27,135 +36,203 @@ namespace StardewModdingAPI.Framework.Input
/*********
+ ** Accessors
+ *********/
+ /// <summary>Whether the gamepad is currently connected.</summary>
+ public bool IsConnected { get; private set; }
+
+
+ /*********
** Public methods
*********/
/// <summary>Construct an instance.</summary>
- /// <param name="state">The initial controller state.</param>
- public GamePadStateBuilder(GamePadState state)
+ /// <param name="state">The initial state, or <c>null</c> to get the latest state.</param>
+ public GamePadStateBuilder(GamePadState? state = null)
+ {
+ this.Reset(state);
+ }
+
+ /// <summary>Reset the tracked state.</summary>
+ /// <param name="state">The state from which to reset, or <c>null</c> to get the latest state.</param>
+ public GamePadStateBuilder Reset(GamePadState? state = null)
{
+ this.State = state ??= GamePad.GetState(PlayerIndex.One);
+ this.IsConnected = state.Value.IsConnected;
+
+ if (!this.IsConnected)
+ return this;
+
+ GamePadDPad pad = state.Value.DPad;
+ GamePadButtons buttons = state.Value.Buttons;
+ GamePadTriggers triggers = state.Value.Triggers;
+ GamePadThumbSticks sticks = state.Value.ThumbSticks;
this.ButtonStates = new Dictionary<SButton, ButtonState>
{
- [SButton.DPadUp] = state.DPad.Up,
- [SButton.DPadDown] = state.DPad.Down,
- [SButton.DPadLeft] = state.DPad.Left,
- [SButton.DPadRight] = state.DPad.Right,
-
- [SButton.ControllerA] = state.Buttons.A,
- [SButton.ControllerB] = state.Buttons.B,
- [SButton.ControllerX] = state.Buttons.X,
- [SButton.ControllerY] = state.Buttons.Y,
- [SButton.LeftStick] = state.Buttons.LeftStick,
- [SButton.RightStick] = state.Buttons.RightStick,
- [SButton.LeftShoulder] = state.Buttons.LeftShoulder,
- [SButton.RightShoulder] = state.Buttons.RightShoulder,
- [SButton.ControllerBack] = state.Buttons.Back,
- [SButton.ControllerStart] = state.Buttons.Start,
- [SButton.BigButton] = state.Buttons.BigButton
+ [SButton.DPadUp] = pad.Up,
+ [SButton.DPadDown] = pad.Down,
+ [SButton.DPadLeft] = pad.Left,
+ [SButton.DPadRight] = pad.Right,
+
+ [SButton.ControllerA] = buttons.A,
+ [SButton.ControllerB] = buttons.B,
+ [SButton.ControllerX] = buttons.X,
+ [SButton.ControllerY] = buttons.Y,
+ [SButton.LeftStick] = buttons.LeftStick,
+ [SButton.RightStick] = buttons.RightStick,
+ [SButton.LeftShoulder] = buttons.LeftShoulder,
+ [SButton.RightShoulder] = buttons.RightShoulder,
+ [SButton.ControllerBack] = buttons.Back,
+ [SButton.ControllerStart] = buttons.Start,
+ [SButton.BigButton] = buttons.BigButton
};
- this.LeftTrigger = state.Triggers.Left;
- this.RightTrigger = state.Triggers.Right;
- this.LeftStickPos = state.ThumbSticks.Left;
- this.RightStickPos = state.ThumbSticks.Right;
+ this.LeftTrigger = triggers.Left;
+ this.RightTrigger = triggers.Right;
+ this.LeftStickPos = sticks.Left;
+ this.RightStickPos = sticks.Right;
+
+ return this;
}
- /// <summary>Mark all matching buttons unpressed.</summary>
- /// <param name="buttons">The buttons.</param>
- public void SuppressButtons(IEnumerable<SButton> buttons)
+ /// <summary>Override the states for a set of buttons.</summary>
+ /// <param name="overrides">The button state overrides.</param>
+ public GamePadStateBuilder OverrideButtons(IDictionary<SButton, SButtonState> overrides)
{
- foreach (SButton button in buttons)
- this.SuppressButton(button);
+ if (!this.IsConnected)
+ return this;
+
+ foreach (var pair in overrides)
+ {
+ bool changed = true;
+
+ bool isDown = pair.Value.IsDown();
+ switch (pair.Key)
+ {
+ // left thumbstick
+ case SButton.LeftThumbstickUp:
+ this.LeftStickPos.Y = isDown ? 1 : 0;
+ break;
+ case SButton.LeftThumbstickDown:
+ this.LeftStickPos.Y = isDown ? 1 : 0;
+ break;
+ case SButton.LeftThumbstickLeft:
+ this.LeftStickPos.X = isDown ? 1 : 0;
+ break;
+ case SButton.LeftThumbstickRight:
+ this.LeftStickPos.X = isDown ? 1 : 0;
+ break;
+
+ // right thumbstick
+ case SButton.RightThumbstickUp:
+ this.RightStickPos.Y = isDown ? 1 : 0;
+ break;
+ case SButton.RightThumbstickDown:
+ this.RightStickPos.Y = isDown ? 1 : 0;
+ break;
+ case SButton.RightThumbstickLeft:
+ this.RightStickPos.X = isDown ? 1 : 0;
+ break;
+ case SButton.RightThumbstickRight:
+ this.RightStickPos.X = isDown ? 1 : 0;
+ break;
+
+ // triggers
+ case SButton.LeftTrigger:
+ this.LeftTrigger = isDown ? 1 : 0;
+ break;
+ case SButton.RightTrigger:
+ this.RightTrigger = isDown ? 1 : 0;
+ break;
+
+ // buttons
+ default:
+ if (this.ButtonStates.ContainsKey(pair.Key))
+ this.ButtonStates[pair.Key] = isDown ? ButtonState.Pressed : ButtonState.Released;
+ else
+ changed = false;
+ break;
+ }
+
+ if (changed)
+ this.State = null;
+ }
+
+ return this;
}
- /// <summary>Mark a button unpressed.</summary>
- /// <param name="button">The button.</param>
- public void SuppressButton(SButton button)
+ /// <summary>Get the currently pressed buttons.</summary>
+ public IEnumerable<SButton> GetPressedButtons()
{
- switch (button)
+ if (!this.IsConnected)
+ yield break;
+
+ // buttons
+ foreach (var pair in this.ButtonStates)
+ {
+ if (pair.Value == ButtonState.Pressed && pair.Key.TryGetController(out Buttons button))
+ yield return button.ToSButton();
+ }
+
+ // triggers
+ if (this.LeftTrigger > 0.2f)
+ yield return SButton.LeftTrigger;
+ if (this.RightTrigger > 0.2f)
+ yield return SButton.RightTrigger;
+
+ // left thumbstick direction
+ if (this.LeftStickPos.Y > GamePadStateBuilder.LeftThumbstickDeadZone)
+ yield return SButton.LeftThumbstickUp;
+ if (this.LeftStickPos.Y < -GamePadStateBuilder.LeftThumbstickDeadZone)
+ yield return SButton.LeftThumbstickDown;
+ if (this.LeftStickPos.X > GamePadStateBuilder.LeftThumbstickDeadZone)
+ yield return SButton.LeftThumbstickRight;
+ if (this.LeftStickPos.X < -GamePadStateBuilder.LeftThumbstickDeadZone)
+ yield return SButton.LeftThumbstickLeft;
+
+ // right thumbstick direction
+ if (this.RightStickPos.Length() > GamePadStateBuilder.RightThumbstickDeadZone)
{
- // left thumbstick
- case SButton.LeftThumbstickUp:
- if (this.LeftStickPos.Y > 0)
- this.LeftStickPos.Y = 0;
- break;
- case SButton.LeftThumbstickDown:
- if (this.LeftStickPos.Y < 0)
- this.LeftStickPos.Y = 0;
- break;
- case SButton.LeftThumbstickLeft:
- if (this.LeftStickPos.X < 0)
- this.LeftStickPos.X = 0;
- break;
- case SButton.LeftThumbstickRight:
- if (this.LeftStickPos.X > 0)
- this.LeftStickPos.X = 0;
- break;
-
- // right thumbstick
- case SButton.RightThumbstickUp:
- if (this.RightStickPos.Y > 0)
- this.RightStickPos.Y = 0;
- break;
- case SButton.RightThumbstickDown:
- if (this.RightStickPos.Y < 0)
- this.RightStickPos.Y = 0;
- break;
- case SButton.RightThumbstickLeft:
- if (this.RightStickPos.X < 0)
- this.RightStickPos.X = 0;
- break;
- case SButton.RightThumbstickRight:
- if (this.RightStickPos.X > 0)
- this.RightStickPos.X = 0;
- break;
-
- // triggers
- case SButton.LeftTrigger:
- this.LeftTrigger = 0;
- break;
- case SButton.RightTrigger:
- this.RightTrigger = 0;
- break;
-
- // buttons
- default:
- if (this.ButtonStates.ContainsKey(button))
- this.ButtonStates[button] = ButtonState.Released;
- break;
+ if (this.RightStickPos.Y > 0)
+ yield return SButton.RightThumbstickUp;
+ if (this.RightStickPos.Y < 0)
+ yield return SButton.RightThumbstickDown;
+ if (this.RightStickPos.X > 0)
+ yield return SButton.RightThumbstickRight;
+ if (this.RightStickPos.X < 0)
+ yield return SButton.RightThumbstickLeft;
}
}
- /// <summary>Construct an equivalent gamepad state.</summary>
- public GamePadState ToGamePadState()
+ /// <summary>Get the equivalent state.</summary>
+ public GamePadState GetState()
{
- return new GamePadState(
- leftThumbStick: this.LeftStickPos,
- rightThumbStick: this.RightStickPos,
- leftTrigger: this.LeftTrigger,
- rightTrigger: this.RightTrigger,
- buttons: this.GetBitmask(this.GetPressedButtons()) // MonoDevelop requires one bitmask here; don't specify multiple values
- );
+ if (this.State == null)
+ {
+ this.State = new GamePadState(
+ leftThumbStick: this.LeftStickPos,
+ rightThumbStick: this.RightStickPos,
+ leftTrigger: this.LeftTrigger,
+ rightTrigger: this.RightTrigger,
+ buttons: this.GetButtonBitmask() // MonoGame requires one bitmask here; don't specify multiple values
+ );
+ }
+
+ return this.State.Value;
}
+
/*********
** Private methods
*********/
- /// <summary>Get all pressed buttons.</summary>
- private IEnumerable<Buttons> GetPressedButtons()
+ /// <summary>Get a bitmask representing the pressed buttons.</summary>
+ private Buttons GetButtonBitmask()
{
+ Buttons flag = 0;
foreach (var pair in this.ButtonStates)
{
if (pair.Value == ButtonState.Pressed && pair.Key.TryGetController(out Buttons button))
- yield return button;
+ flag |= button;
}
- }
- /// <summary>Get a bitmask representing the given buttons.</summary>
- /// <param name="buttons">The buttons to represent.</param>
- private Buttons GetBitmask(IEnumerable<Buttons> buttons)
- {
- Buttons flag = 0;
- foreach (Buttons button in buttons)
- flag |= button;
return flag;
}
}
diff --git a/src/SMAPI/Framework/Input/IInputStateBuilder.cs b/src/SMAPI/Framework/Input/IInputStateBuilder.cs
new file mode 100644
index 00000000..193e5216
--- /dev/null
+++ b/src/SMAPI/Framework/Input/IInputStateBuilder.cs
@@ -0,0 +1,29 @@
+using System.Collections.Generic;
+
+namespace StardewModdingAPI.Framework.Input
+{
+ /// <summary>Manages input state.</summary>
+ /// <typeparam name="THandler">The handler type.</typeparam>
+ /// <typeparam name="TState">The state type.</typeparam>
+ internal interface IInputStateBuilder<out THandler, TState>
+ where TState : struct
+ where THandler : IInputStateBuilder<THandler, TState>
+ {
+ /*********
+ ** Methods
+ *********/
+ /// <summary>Reset the tracked state.</summary>
+ /// <param name="state">The state from which to reset, or <c>null</c> to get the latest state.</param>
+ THandler Reset(TState? state = null);
+
+ /// <summary>Override the states for a set of buttons.</summary>
+ /// <param name="overrides">The button state overrides.</param>
+ THandler OverrideButtons(IDictionary<SButton, SButtonState> overrides);
+
+ /// <summary>Get the currently pressed buttons.</summary>
+ IEnumerable<SButton> GetPressedButtons();
+
+ /// <summary>Get the equivalent state.</summary>
+ TState GetState();
+ }
+}
diff --git a/src/SMAPI/Framework/Input/KeyboardStateBuilder.cs b/src/SMAPI/Framework/Input/KeyboardStateBuilder.cs
new file mode 100644
index 00000000..f95a28bf
--- /dev/null
+++ b/src/SMAPI/Framework/Input/KeyboardStateBuilder.cs
@@ -0,0 +1,78 @@
+using System.Collections.Generic;
+using System.Linq;
+using Microsoft.Xna.Framework.Input;
+
+namespace StardewModdingAPI.Framework.Input
+{
+ /// <summary>Manages keyboard state.</summary>
+ internal class KeyboardStateBuilder : IInputStateBuilder<KeyboardStateBuilder, KeyboardState>
+ {
+ /*********
+ ** Fields
+ *********/
+ /// <summary>The underlying keyboard state.</summary>
+ private KeyboardState? State;
+
+ /// <summary>The pressed buttons.</summary>
+ private readonly HashSet<Keys> PressedButtons = new HashSet<Keys>();
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Construct an instance.</summary>
+ /// <param name="state">The initial state, or <c>null</c> to get the latest state.</param>
+ public KeyboardStateBuilder(KeyboardState? state = null)
+ {
+ this.Reset(state);
+ }
+
+ /// <summary>Reset the tracked state.</summary>
+ /// <param name="state">The state from which to reset, or <c>null</c> to get the latest state.</param>
+ public KeyboardStateBuilder Reset(KeyboardState? state = null)
+ {
+ this.State = state ??= Keyboard.GetState();
+
+ this.PressedButtons.Clear();
+ foreach (var button in state.Value.GetPressedKeys())
+ this.PressedButtons.Add(button);
+
+ return this;
+ }
+
+ /// <summary>Override the states for a set of buttons.</summary>
+ /// <param name="overrides">The button state overrides.</param>
+ public KeyboardStateBuilder OverrideButtons(IDictionary<SButton, SButtonState> overrides)
+ {
+ foreach (var pair in overrides)
+ {
+ if (pair.Key.TryGetKeyboard(out Keys key))
+ {
+ this.State = null;
+
+ if (pair.Value.IsDown())
+ this.PressedButtons.Add(key);
+ else
+ this.PressedButtons.Remove(key);
+ }
+ }
+
+ return this;
+ }
+
+ /// <summary>Get the currently pressed buttons.</summary>
+ public IEnumerable<SButton> GetPressedButtons()
+ {
+ foreach (Keys key in this.PressedButtons)
+ yield return key.ToSButton();
+ }
+
+ /// <summary>Get the equivalent state.</summary>
+ public KeyboardState GetState()
+ {
+ return
+ this.State
+ ?? (this.State = new KeyboardState(this.PressedButtons.ToArray())).Value;
+ }
+ }
+}
diff --git a/src/SMAPI/Framework/Input/MouseStateBuilder.cs b/src/SMAPI/Framework/Input/MouseStateBuilder.cs
new file mode 100644
index 00000000..cff3e05e
--- /dev/null
+++ b/src/SMAPI/Framework/Input/MouseStateBuilder.cs
@@ -0,0 +1,107 @@
+using System.Collections.Generic;
+using Microsoft.Xna.Framework.Input;
+
+namespace StardewModdingAPI.Framework.Input
+{
+ /// <summary>Manages mouse state.</summary>
+ internal class MouseStateBuilder : IInputStateBuilder<MouseStateBuilder, MouseState>
+ {
+ /*********
+ ** Fields
+ *********/
+ /// <summary>The underlying mouse state.</summary>
+ private MouseState? State;
+
+ /// <summary>The current button states.</summary>
+ private IDictionary<SButton, ButtonState> ButtonStates;
+
+ /// <summary>The mouse wheel scroll value.</summary>
+ private int ScrollWheelValue;
+
+
+ /*********
+ ** Accessors
+ *********/
+ /// <summary>The X cursor position.</summary>
+ public int X { get; private set; }
+
+ /// <summary>The Y cursor position.</summary>
+ public int Y { get; private set; }
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Construct an instance.</summary>
+ /// <param name="state">The initial state, or <c>null</c> to get the latest state.</param>
+ public MouseStateBuilder(MouseState? state = null)
+ {
+ this.Reset(state);
+ }
+
+ /// <summary>Reset the tracked state.</summary>
+ /// <param name="state">The state from which to reset, or <c>null</c> to get the latest state.</param>
+ public MouseStateBuilder Reset(MouseState? state = null)
+ {
+ this.State = state ??= Mouse.GetState();
+
+ this.ButtonStates = new Dictionary<SButton, ButtonState>
+ {
+ [SButton.MouseLeft] = state.Value.LeftButton,
+ [SButton.MouseMiddle] = state.Value.MiddleButton,
+ [SButton.MouseRight] = state.Value.RightButton,
+ [SButton.MouseX1] = state.Value.XButton1,
+ [SButton.MouseX2] = state.Value.XButton2
+ };
+ this.X = state.Value.X;
+ this.Y = state.Value.Y;
+ this.ScrollWheelValue = state.Value.ScrollWheelValue;
+
+ return this;
+ }
+
+ /// <summary>Override the states for a set of buttons.</summary>
+ /// <param name="overrides">The button state overrides.</param>
+ public MouseStateBuilder OverrideButtons(IDictionary<SButton, SButtonState> overrides)
+ {
+ foreach (var pair in overrides)
+ {
+ bool isDown = pair.Value.IsDown();
+ if (this.ButtonStates.ContainsKey(pair.Key))
+ this.ButtonStates[pair.Key] = isDown ? ButtonState.Pressed : ButtonState.Released;
+ }
+
+ return this;
+ }
+
+ /// <summary>Get the currently pressed buttons.</summary>
+ public IEnumerable<SButton> GetPressedButtons()
+ {
+ foreach (var pair in this.ButtonStates)
+ {
+ if (pair.Value == ButtonState.Pressed)
+ yield return pair.Key;
+ }
+ }
+
+ /// <summary>Get the equivalent state.</summary>
+ public MouseState GetState()
+ {
+ if (this.State == null)
+ {
+ this.State = new MouseState(
+ x: this.X,
+ y: this.Y,
+ scrollWheel: this.ScrollWheelValue,
+ leftButton: this.ButtonStates[SButton.MouseLeft],
+ middleButton: this.ButtonStates[SButton.MouseMiddle],
+ rightButton: this.ButtonStates[SButton.MouseRight],
+ xButton1: this.ButtonStates[SButton.MouseX1],
+ xButton2: this.ButtonStates[SButton.MouseX2]
+ );
+ }
+
+ return this.State.Value;
+ }
+ }
+}
diff --git a/src/SMAPI/Framework/Input/SInputState.cs b/src/SMAPI/Framework/Input/SInputState.cs
index 4eaa9ca6..333f5726 100644
--- a/src/SMAPI/Framework/Input/SInputState.cs
+++ b/src/SMAPI/Framework/Input/SInputState.cs
@@ -14,46 +14,40 @@ namespace StardewModdingAPI.Framework.Input
/*********
** Accessors
*********/
- /// <summary>The maximum amount of direction to ignore for the left thumbstick.</summary>
- private const float LeftThumbstickDeadZone = 0.2f;
-
/// <summary>The cursor position on the screen adjusted for the zoom level.</summary>
private CursorPosition CursorPositionImpl;
/// <summary>The player's last known tile position.</summary>
private Vector2? LastPlayerTile;
+ /// <summary>The buttons to press until the game next handles input.</summary>
+ private readonly HashSet<SButton> CustomPressedKeys = new HashSet<SButton>();
+
+ /// <summary>The buttons to consider released until the actual button is released.</summary>
+ private readonly HashSet<SButton> CustomReleasedKeys = new HashSet<SButton>();
+
+ /// <summary>Whether there are new overrides in <see cref="CustomPressedKeys"/> or <see cref="CustomReleasedKeys"/> that haven't been applied to the previous state.</summary>
+ private bool HasNewOverrides;
+
/*********
** Accessors
*********/
/// <summary>The controller state as of the last update.</summary>
- public GamePadState RealController { get; private set; }
+ public GamePadState LastController { get; private set; }
/// <summary>The keyboard state as of the last update.</summary>
- public KeyboardState RealKeyboard { get; private set; }
+ public KeyboardState LastKeyboard { get; private set; }
/// <summary>The mouse state as of the last update.</summary>
- public MouseState RealMouse { get; private set; }
+ public MouseState LastMouse { get; private set; }
- /// <summary>A derivative of <see cref="RealController"/> which suppresses the buttons in <see cref="SuppressButtons"/>.</summary>
- public GamePadState SuppressedController { get; private set; }
-
- /// <summary>A derivative of <see cref="RealKeyboard"/> which suppresses the buttons in <see cref="SuppressButtons"/>.</summary>
- public KeyboardState SuppressedKeyboard { get; private set; }
-
- /// <summary>A derivative of <see cref="RealMouse"/> which suppresses the buttons in <see cref="SuppressButtons"/>.</summary>
- public MouseState SuppressedMouse { get; private set; }
+ /// <summary>The buttons which were pressed, held, or released as of the last update.</summary>
+ public IDictionary<SButton, SButtonState> LastButtonStates { get; private set; } = new Dictionary<SButton, SButtonState>();
/// <summary>The cursor position on the screen adjusted for the zoom level.</summary>
public ICursorPosition CursorPosition => this.CursorPositionImpl;
- /// <summary>The buttons which were pressed, held, or released.</summary>
- public IDictionary<SButton, SButtonState> ActiveButtons { get; private set; } = new Dictionary<SButton, SButtonState>();
-
- /// <summary>The buttons to suppress when the game next handles input. Each button is suppressed until it's released.</summary>
- public HashSet<SButton> SuppressButtons { get; } = new HashSet<SButton>();
-
/*********
** Public methods
@@ -63,14 +57,34 @@ namespace StardewModdingAPI.Framework.Input
{
return new SInputState
{
- ActiveButtons = this.ActiveButtons,
- RealController = this.RealController,
- RealKeyboard = this.RealKeyboard,
- RealMouse = this.RealMouse,
+ LastButtonStates = this.LastButtonStates,
+ LastController = this.LastController,
+ LastKeyboard = this.LastKeyboard,
+ LastMouse = this.LastMouse,
CursorPositionImpl = this.CursorPositionImpl
};
}
+ /// <summary>Override the state for a button.</summary>
+ /// <param name="button">The button to override.</param>
+ /// <param name="setDown">Whether to mark it pressed; else mark it released.</param>
+ public void OverrideButton(SButton button, bool setDown)
+ {
+ bool changed = setDown
+ ? this.CustomPressedKeys.Add(button) | this.CustomReleasedKeys.Remove(button)
+ : this.CustomPressedKeys.Remove(button) | this.CustomReleasedKeys.Add(button);
+
+ if (changed)
+ this.HasNewOverrides = true;
+ }
+
+ /// <summary>Get whether a mod has indicated the key was already handled, so the game shouldn't handle it.</summary>
+ /// <param name="button">The button to check.</param>
+ public bool IsSuppressed(SButton button)
+ {
+ return this.CustomReleasedKeys.Contains(button);
+ }
+
/// <summary>This method is called by the game, and does nothing since SMAPI will already have updated by that point.</summary>
[Obsolete("This method should only be called by the game itself.")]
public override void Update() { }
@@ -82,28 +96,47 @@ namespace StardewModdingAPI.Framework.Input
{
float zoomMultiplier = (1f / Game1.options.zoomLevel);
- // get new states
- GamePadState realController = GamePad.GetState(PlayerIndex.One);
- KeyboardState realKeyboard = Keyboard.GetState();
- MouseState realMouse = Mouse.GetState();
- var activeButtons = this.DeriveStates(this.ActiveButtons, realKeyboard, realMouse, realController);
- Vector2 cursorAbsolutePos = new Vector2((realMouse.X * zoomMultiplier) + Game1.viewport.X, (realMouse.Y * zoomMultiplier) + Game1.viewport.Y);
+ // get real values
+ var controller = new GamePadStateBuilder();
+ var keyboard = new KeyboardStateBuilder();
+ var mouse = new MouseStateBuilder();
+ Vector2 cursorAbsolutePos = new Vector2((mouse.X * zoomMultiplier) + Game1.viewport.X, (mouse.Y * zoomMultiplier) + Game1.viewport.Y);
Vector2? playerTilePos = Context.IsPlayerFree ? Game1.player.getTileLocation() : (Vector2?)null;
+ HashSet<SButton> reallyDown = new HashSet<SButton>(this.GetPressedButtons(keyboard, mouse, controller));
+
+ // apply overrides
+ bool hasOverrides = false;
+ if (this.CustomPressedKeys.Count > 0 || this.CustomReleasedKeys.Count > 0)
+ {
+ // reset overrides that no longer apply
+ this.CustomPressedKeys.RemoveWhere(key => reallyDown.Contains(key));
+ this.CustomReleasedKeys.RemoveWhere(key => !reallyDown.Contains(key));
+
+ // apply overrides
+ if (this.ApplyOverrides(this.CustomPressedKeys, this.CustomReleasedKeys, controller, keyboard, mouse))
+ hasOverrides = true;
- // update real states
- this.ActiveButtons = activeButtons;
- this.RealController = realController;
- this.RealKeyboard = realKeyboard;
- this.RealMouse = realMouse;
+ // remove pressed keys
+ this.CustomPressedKeys.Clear();
+ }
+
+ // get button states
+ var pressedButtons = hasOverrides
+ ? new HashSet<SButton>(this.GetPressedButtons(keyboard, mouse, controller))
+ : reallyDown;
+ var activeButtons = this.DeriveStates(this.LastButtonStates, pressedButtons);
+
+ // update
+ this.HasNewOverrides = false;
+ this.LastController = controller.GetState();
+ this.LastKeyboard = keyboard.GetState();
+ this.LastMouse = mouse.GetState();
+ this.LastButtonStates = activeButtons;
if (cursorAbsolutePos != this.CursorPositionImpl?.AbsolutePixels || playerTilePos != this.LastPlayerTile)
{
this.LastPlayerTile = playerTilePos;
- this.CursorPositionImpl = this.GetCursorPosition(realMouse, cursorAbsolutePos, zoomMultiplier);
+ this.CursorPositionImpl = this.GetCursorPosition(this.LastMouse, cursorAbsolutePos, zoomMultiplier);
}
-
- // update suppressed states
- this.SuppressButtons.RemoveWhere(p => !this.GetState(activeButtons, p).IsDown());
- this.UpdateSuppression();
}
catch (InvalidOperationException)
{
@@ -111,18 +144,22 @@ namespace StardewModdingAPI.Framework.Input
}
}
- /// <summary>Apply input suppression to current input.</summary>
- public void UpdateSuppression()
+ /// <summary>Apply input overrides to the current state.</summary>
+ public void ApplyOverrides()
{
- GamePadState suppressedController = this.RealController;
- KeyboardState suppressedKeyboard = this.RealKeyboard;
- MouseState suppressedMouse = this.RealMouse;
-
- this.SuppressGivenStates(this.ActiveButtons, ref suppressedKeyboard, ref suppressedMouse, ref suppressedController);
+ if (this.HasNewOverrides)
+ {
+ var controller = new GamePadStateBuilder(this.LastController);
+ var keyboard = new KeyboardStateBuilder(this.LastKeyboard);
+ var mouse = new MouseStateBuilder(this.LastMouse);
- this.SuppressedController = suppressedController;
- this.SuppressedKeyboard = suppressedKeyboard;
- this.SuppressedMouse = suppressedMouse;
+ if (this.ApplyOverrides(pressed: this.CustomPressedKeys, released: this.CustomReleasedKeys, controller, keyboard, mouse))
+ {
+ this.LastController = controller.GetState();
+ this.LastKeyboard = keyboard.GetState();
+ this.LastMouse = mouse.GetState();
+ }
+ }
}
/// <summary>Get the gamepad state visible to the game.</summary>
@@ -130,36 +167,30 @@ namespace StardewModdingAPI.Framework.Input
public override GamePadState GetGamePadState()
{
if (Game1.options.gamepadMode == Options.GamepadModes.ForceOff)
- return base.GetGamePadState();
+ return new GamePadState();
- return this.ShouldSuppressNow()
- ? this.SuppressedController
- : this.RealController;
+ return this.LastController;
}
/// <summary>Get the keyboard state visible to the game.</summary>
[Obsolete("This method should only be called by the game itself.")]
public override KeyboardState GetKeyboardState()
{
- return this.ShouldSuppressNow()
- ? this.SuppressedKeyboard
- : this.RealKeyboard;
+ return this.LastKeyboard;
}
/// <summary>Get the keyboard state visible to the game.</summary>
[Obsolete("This method should only be called by the game itself.")]
public override MouseState GetMouseState()
{
- return this.ShouldSuppressNow()
- ? this.SuppressedMouse
- : this.RealMouse;
+ return this.LastMouse;
}
/// <summary>Get whether a given button was pressed or held.</summary>
/// <param name="button">The button to check.</param>
public bool IsDown(SButton button)
{
- return this.GetState(this.ActiveButtons, button).IsDown();
+ return this.GetState(this.LastButtonStates, button).IsDown();
}
/// <summary>Get whether any of the given buttons were pressed or held.</summary>
@@ -173,7 +204,7 @@ namespace StardewModdingAPI.Framework.Input
/// <param name="button">The button to check.</param>
public SButtonState GetState(SButton button)
{
- return this.GetState(this.ActiveButtons, button);
+ return this.GetState(this.LastButtonStates, button);
}
@@ -194,76 +225,57 @@ namespace StardewModdingAPI.Framework.Input
return new CursorPosition(absolutePixels, screenPixels, tile, grabTile);
}
- /// <summary>Whether input should be suppressed in the current context.</summary>
- private bool ShouldSuppressNow()
- {
- return Game1.chatBox == null || !Game1.chatBox.isActive();
- }
-
- /// <summary>Apply input suppression to the given input states.</summary>
- /// <param name="activeButtons">The current button states to check.</param>
- /// <param name="keyboardState">The game's keyboard state for the current tick.</param>
- /// <param name="mouseState">The game's mouse state for the current tick.</param>
- /// <param name="gamePadState">The game's controller state for the current tick.</param>
- private void SuppressGivenStates(IDictionary<SButton, SButtonState> activeButtons, ref KeyboardState keyboardState, ref MouseState mouseState, ref GamePadState gamePadState)
+ /// <summary>Apply input overrides to the given states.</summary>
+ /// <param name="pressed">The buttons to mark pressed.</param>
+ /// <param name="released">The buttons to mark released.</param>
+ /// <param name="controller">The game's controller state for the current tick.</param>
+ /// <param name="keyboard">The game's keyboard state for the current tick.</param>
+ /// <param name="mouse">The game's mouse state for the current tick.</param>
+ /// <returns>Returns whether any overrides were applied.</returns>
+ private bool ApplyOverrides(ISet<SButton> pressed, ISet<SButton> released, GamePadStateBuilder controller, KeyboardStateBuilder keyboard, MouseStateBuilder mouse)
{
- if (this.SuppressButtons.Count == 0)
- return;
-
- // gather info
- HashSet<Keys> suppressKeys = new HashSet<Keys>();
- HashSet<SButton> suppressButtons = new HashSet<SButton>();
- HashSet<SButton> suppressMouse = new HashSet<SButton>();
- foreach (SButton button in this.SuppressButtons)
+ if (pressed.Count == 0 && released.Count == 0)
+ return false;
+
+ // group keys by type
+ IDictionary<SButton, SButtonState> keyboardOverrides = new Dictionary<SButton, SButtonState>();
+ IDictionary<SButton, SButtonState> controllerOverrides = new Dictionary<SButton, SButtonState>();
+ IDictionary<SButton, SButtonState> mouseOverrides = new Dictionary<SButton, SButtonState>();
+ foreach (var button in pressed.Concat(released))
{
+ var newState = this.DeriveState(
+ oldState: this.GetState(button),
+ isDown: pressed.Contains(button)
+ );
+
if (button == SButton.MouseLeft || button == SButton.MouseMiddle || button == SButton.MouseRight || button == SButton.MouseX1 || button == SButton.MouseX2)
- suppressMouse.Add(button);
- else if (button.TryGetKeyboard(out Keys key))
- suppressKeys.Add(key);
- else if (gamePadState.IsConnected && button.TryGetController(out Buttons _))
- suppressButtons.Add(button);
+ mouseOverrides[button] = newState;
+ else if (button.TryGetKeyboard(out Keys _))
+ keyboardOverrides[button] = newState;
+ else if (controller.IsConnected && button.TryGetController(out Buttons _))
+ controllerOverrides[button] = newState;
}
- // suppress keyboard keys
- if (keyboardState.GetPressedKeys().Any() && suppressKeys.Any())
- keyboardState = new KeyboardState(keyboardState.GetPressedKeys().Except(suppressKeys).ToArray());
-
- // suppress controller keys
- if (gamePadState.IsConnected && suppressButtons.Any())
- {
- GamePadStateBuilder builder = new GamePadStateBuilder(gamePadState);
- builder.SuppressButtons(suppressButtons);
- gamePadState = builder.ToGamePadState();
- }
+ // override states
+ if (keyboardOverrides.Any())
+ keyboard.OverrideButtons(keyboardOverrides);
+ if (controller.IsConnected && controllerOverrides.Any())
+ controller.OverrideButtons(controllerOverrides);
+ if (mouseOverrides.Any())
+ mouse.OverrideButtons(mouseOverrides);
- // suppress mouse buttons
- if (suppressMouse.Any())
- {
- mouseState = new MouseState(
- x: mouseState.X,
- y: mouseState.Y,
- scrollWheel: mouseState.ScrollWheelValue,
- leftButton: suppressMouse.Contains(SButton.MouseLeft) ? ButtonState.Released : mouseState.LeftButton,
- middleButton: suppressMouse.Contains(SButton.MouseMiddle) ? ButtonState.Released : mouseState.MiddleButton,
- rightButton: suppressMouse.Contains(SButton.MouseRight) ? ButtonState.Released : mouseState.RightButton,
- xButton1: suppressMouse.Contains(SButton.MouseX1) ? ButtonState.Released : mouseState.XButton1,
- xButton2: suppressMouse.Contains(SButton.MouseX2) ? ButtonState.Released : mouseState.XButton2
- );
- }
+ return true;
}
/// <summary>Get the state of all pressed or released buttons relative to their previous state.</summary>
/// <param name="previousStates">The previous button states.</param>
- /// <param name="keyboard">The keyboard state.</param>
- /// <param name="mouse">The mouse state.</param>
- /// <param name="controller">The controller state.</param>
- private IDictionary<SButton, SButtonState> DeriveStates(IDictionary<SButton, SButtonState> previousStates, KeyboardState keyboard, MouseState mouse, GamePadState controller)
+ /// <param name="pressedButtons">The currently pressed buttons.</param>
+ private IDictionary<SButton, SButtonState> DeriveStates(IDictionary<SButton, SButtonState> previousStates, HashSet<SButton> pressedButtons)
{
IDictionary<SButton, SButtonState> activeButtons = new Dictionary<SButton, SButtonState>();
// handle pressed keys
- SButton[] down = this.GetPressedButtons(keyboard, mouse, controller).ToArray();
- foreach (SButton button in down)
+ foreach (SButton button in pressedButtons)
activeButtons[button] = this.DeriveState(this.GetState(previousStates, button), isDown: true);
// handle released keys
@@ -301,101 +313,12 @@ namespace StardewModdingAPI.Framework.Input
/// <param name="mouse">The mouse state.</param>
/// <param name="controller">The controller state.</param>
/// <remarks>Thumbstick direction logic derived from <see cref="ButtonCollection"/>.</remarks>
- private IEnumerable<SButton> GetPressedButtons(KeyboardState keyboard, MouseState mouse, GamePadState controller)
- {
- // keyboard
- foreach (Keys key in keyboard.GetPressedKeys())
- yield return key.ToSButton();
-
- // mouse
- if (mouse.LeftButton == ButtonState.Pressed)
- yield return SButton.MouseLeft;
- if (mouse.RightButton == ButtonState.Pressed)
- yield return SButton.MouseRight;
- if (mouse.MiddleButton == ButtonState.Pressed)
- yield return SButton.MouseMiddle;
- if (mouse.XButton1 == ButtonState.Pressed)
- yield return SButton.MouseX1;
- if (mouse.XButton2 == ButtonState.Pressed)
- yield return SButton.MouseX2;
-
- // controller
- if (controller.IsConnected)
- {
- // main buttons
- if (controller.Buttons.A == ButtonState.Pressed)
- yield return SButton.ControllerA;
- if (controller.Buttons.B == ButtonState.Pressed)
- yield return SButton.ControllerB;
- if (controller.Buttons.X == ButtonState.Pressed)
- yield return SButton.ControllerX;
- if (controller.Buttons.Y == ButtonState.Pressed)
- yield return SButton.ControllerY;
- if (controller.Buttons.LeftStick == ButtonState.Pressed)
- yield return SButton.LeftStick;
- if (controller.Buttons.RightStick == ButtonState.Pressed)
- yield return SButton.RightStick;
- if (controller.Buttons.Start == ButtonState.Pressed)
- yield return SButton.ControllerStart;
-
- // directional pad
- if (controller.DPad.Up == ButtonState.Pressed)
- yield return SButton.DPadUp;
- if (controller.DPad.Down == ButtonState.Pressed)
- yield return SButton.DPadDown;
- if (controller.DPad.Left == ButtonState.Pressed)
- yield return SButton.DPadLeft;
- if (controller.DPad.Right == ButtonState.Pressed)
- yield return SButton.DPadRight;
-
- // secondary buttons
- if (controller.Buttons.Back == ButtonState.Pressed)
- yield return SButton.ControllerBack;
- if (controller.Buttons.BigButton == ButtonState.Pressed)
- yield return SButton.BigButton;
-
- // shoulders
- if (controller.Buttons.LeftShoulder == ButtonState.Pressed)
- yield return SButton.LeftShoulder;
- if (controller.Buttons.RightShoulder == ButtonState.Pressed)
- yield return SButton.RightShoulder;
-
- // triggers
- if (controller.Triggers.Left > 0.2f)
- yield return SButton.LeftTrigger;
- if (controller.Triggers.Right > 0.2f)
- yield return SButton.RightTrigger;
-
- // left thumbstick direction
- if (controller.ThumbSticks.Left.Y > SInputState.LeftThumbstickDeadZone)
- yield return SButton.LeftThumbstickUp;
- if (controller.ThumbSticks.Left.Y < -SInputState.LeftThumbstickDeadZone)
- yield return SButton.LeftThumbstickDown;
- if (controller.ThumbSticks.Left.X > SInputState.LeftThumbstickDeadZone)
- yield return SButton.LeftThumbstickRight;
- if (controller.ThumbSticks.Left.X < -SInputState.LeftThumbstickDeadZone)
- yield return SButton.LeftThumbstickLeft;
-
- // right thumbstick direction
- if (this.IsRightThumbstickOutsideDeadZone(controller.ThumbSticks.Right))
- {
- if (controller.ThumbSticks.Right.Y > 0)
- yield return SButton.RightThumbstickUp;
- if (controller.ThumbSticks.Right.Y < 0)
- yield return SButton.RightThumbstickDown;
- if (controller.ThumbSticks.Right.X > 0)
- yield return SButton.RightThumbstickRight;
- if (controller.ThumbSticks.Right.X < 0)
- yield return SButton.RightThumbstickLeft;
- }
- }
- }
-
- /// <summary>Get whether the right thumbstick should be considered outside the dead zone.</summary>
- /// <param name="direction">The right thumbstick value.</param>
- private bool IsRightThumbstickOutsideDeadZone(Vector2 direction)
+ private IEnumerable<SButton> GetPressedButtons(KeyboardStateBuilder keyboard, MouseStateBuilder mouse, GamePadStateBuilder controller)
{
- return direction.Length() > 0.9f;
+ return keyboard
+ .GetPressedButtons()
+ .Concat(mouse.GetPressedButtons())
+ .Concat(controller.GetPressedButtons());
}
}
}
diff --git a/src/SMAPI/Framework/ModHelpers/InputHelper.cs b/src/SMAPI/Framework/ModHelpers/InputHelper.cs
index f8ff0355..134ba8d1 100644
--- a/src/SMAPI/Framework/ModHelpers/InputHelper.cs
+++ b/src/SMAPI/Framework/ModHelpers/InputHelper.cs
@@ -41,14 +41,14 @@ namespace StardewModdingAPI.Framework.ModHelpers
/// <param name="button">The button.</param>
public bool IsSuppressed(SButton button)
{
- return this.InputState.SuppressButtons.Contains(button);
+ return this.InputState.IsSuppressed(button);
}
/// <summary>Prevent the game from handling a button press. This doesn't prevent other mods from receiving the event.</summary>
/// <param name="button">The button to suppress.</param>
public void Suppress(SButton button)
{
- this.InputState.SuppressButtons.Add(button);
+ this.InputState.OverrideButton(button, setDown: false);
}
/// <summary>Get the state of a button.</summary>
diff --git a/src/SMAPI/Framework/Monitor.cs b/src/SMAPI/Framework/Monitor.cs
index 06cf1b46..f630c7fe 100644
--- a/src/SMAPI/Framework/Monitor.cs
+++ b/src/SMAPI/Framework/Monitor.cs
@@ -1,4 +1,5 @@
using System;
+using System.Collections.Generic;
using System.Linq;
using StardewModdingAPI.Framework.Logging;
using StardewModdingAPI.Internal.ConsoleWriting;
@@ -26,6 +27,9 @@ namespace StardewModdingAPI.Framework
/// <summary>The maximum length of the <see cref="LogLevel"/> values.</summary>
private static readonly int MaxLevelLength = (from level in Enum.GetValues(typeof(LogLevel)).Cast<LogLevel>() select level.ToString().Length).Max();
+ /// <summary>A cache of messages that should only be logged once.</summary>
+ private readonly HashSet<string> LogOnceCache = new HashSet<string>();
+
/*********
** Accessors
@@ -74,6 +78,15 @@ namespace StardewModdingAPI.Framework
this.LogImpl(this.Source, message, (ConsoleLogLevel)level);
}
+ /// <summary>Log a message for the player or developer, but only if it hasn't already been logged since the last game launch.</summary>
+ /// <param name="message">The message to log.</param>
+ /// <param name="level">The log severity level.</param>
+ public void LogOnce(string message, LogLevel level = LogLevel.Trace)
+ {
+ if (this.LogOnceCache.Add($"{message}|{level}"))
+ this.LogImpl(this.Source, message, (ConsoleLogLevel)level);
+ }
+
/// <summary>Log a message that only appears when <see cref="IMonitor.IsVerbose"/> is enabled.</summary>
/// <param name="message">The message to log.</param>
public void VerboseLog(string message)
diff --git a/src/SMAPI/Framework/Rendering/SDisplayDevice.cs b/src/SMAPI/Framework/Rendering/SDisplayDevice.cs
new file mode 100644
index 00000000..382949bf
--- /dev/null
+++ b/src/SMAPI/Framework/Rendering/SDisplayDevice.cs
@@ -0,0 +1,89 @@
+using System;
+using Microsoft.Xna.Framework;
+using Microsoft.Xna.Framework.Content;
+using Microsoft.Xna.Framework.Graphics;
+using StardewValley;
+using xTile.Dimensions;
+using xTile.Layers;
+using xTile.ObjectModel;
+using xTile.Tiles;
+
+namespace StardewModdingAPI.Framework.Rendering
+{
+ /// <summary>A map display device which overrides the draw logic to support tile rotation.</summary>
+ internal class SDisplayDevice : SXnaDisplayDevice
+ {
+ /*********
+ ** Fields
+ *********/
+ /// <summary>The origin to use when rotating tiles.</summary>
+ private readonly Vector2 RotationOrigin;
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Construct an instance.</summary>
+ /// <param name="contentManager">The content manager through which to load tiles.</param>
+ /// <param name="graphicsDevice">The graphics device with which to render tiles.</param>
+ public SDisplayDevice(ContentManager contentManager, GraphicsDevice graphicsDevice)
+ : base(contentManager, graphicsDevice)
+ {
+ this.RotationOrigin = new Vector2((Game1.tileSize * Game1.pixelZoom) / 2f);
+ }
+
+ /// <summary>Draw a tile to the screen.</summary>
+ /// <param name="tile">The tile to draw.</param>
+ /// <param name="location">The tile position to draw.</param>
+ /// <param name="layerDepth">The layer depth at which to draw.</param>
+ public override void DrawTile(Tile tile, Location location, float layerDepth)
+ {
+ // identical to XnaDisplayDevice
+ if (tile == null)
+ return;
+ xTile.Dimensions.Rectangle tileImageBounds = tile.TileSheet.GetTileImageBounds(tile.TileIndex);
+ Texture2D tileSheetTexture = this.m_tileSheetTextures[tile.TileSheet];
+ if (tileSheetTexture.IsDisposed)
+ return;
+ this.m_tilePosition.X = location.X;
+ this.m_tilePosition.Y = location.Y;
+ this.m_sourceRectangle.X = tileImageBounds.X;
+ this.m_sourceRectangle.Y = tileImageBounds.Y;
+ this.m_sourceRectangle.Width = tileImageBounds.Width;
+ this.m_sourceRectangle.Height = tileImageBounds.Height;
+
+ // get rotation and effects
+ float rotation = this.GetRotation(tile);
+ SpriteEffects effects = this.GetSpriteEffects(tile);
+ var origin = new Vector2(tileImageBounds.Width / 2f, tileImageBounds.Height / 2f);
+ this.m_tilePosition.X += origin.X * Layer.zoom;
+ this.m_tilePosition.Y += origin.X * Layer.zoom;
+
+ // apply
+ this.m_spriteBatchAlpha.Draw(tileSheetTexture, this.m_tilePosition, this.m_sourceRectangle, this.m_modulationColour, rotation, origin, Layer.zoom, effects, layerDepth);
+ }
+
+ /// <summary>Get the sprite effects to apply for a tile.</summary>
+ /// <param name="tile">The tile being drawn.</param>
+ private SpriteEffects GetSpriteEffects(Tile tile)
+ {
+ return tile.Properties.TryGetValue("@Flip", out PropertyValue propertyValue) && int.TryParse(propertyValue, out int value)
+ ? (SpriteEffects)value
+ : SpriteEffects.None;
+ }
+
+ /// <summary>Get the draw rotation to apply for a tile.</summary>
+ /// <param name="tile">The tile being drawn.</param>
+ private float GetRotation(Tile tile)
+ {
+ if (!tile.Properties.TryGetValue("@Rotation", out PropertyValue propertyValue) || !int.TryParse(propertyValue, out int value))
+ return 0;
+
+ value %= 360;
+ if (value == 0)
+ return 0;
+
+ return (float)(Math.PI / (180.0 / value));
+ }
+ }
+}
diff --git a/src/SMAPI/Framework/Rendering/SXnaDisplayDevice.cs b/src/SMAPI/Framework/Rendering/SXnaDisplayDevice.cs
new file mode 100644
index 00000000..d4f62b4f
--- /dev/null
+++ b/src/SMAPI/Framework/Rendering/SXnaDisplayDevice.cs
@@ -0,0 +1,121 @@
+using System;
+using System.Collections.Generic;
+using System.Diagnostics.CodeAnalysis;
+using Microsoft.Xna.Framework;
+using Microsoft.Xna.Framework.Content;
+using Microsoft.Xna.Framework.Graphics;
+using xTile.Dimensions;
+using xTile.Display;
+using xTile.Layers;
+using xTile.Tiles;
+using Rectangle = xTile.Dimensions.Rectangle;
+
+namespace StardewModdingAPI.Framework
+{
+ /// <summary>A map display device which reimplements the default logic.</summary>
+ /// <remarks>This is an exact copy of <see cref="XnaDisplayDevice"/>, except that private fields are protected and all methods are virtual.</remarks>
+ [SuppressMessage("ReSharper", "InconsistentNaming", Justification = "Field naming deliberately matches " + nameof(XnaDisplayDevice) + " to minimize differences.")]
+ internal class SXnaDisplayDevice : IDisplayDevice
+ {
+ /*********
+ ** Fields
+ *********/
+ protected readonly ContentManager m_contentManager;
+ protected readonly GraphicsDevice m_graphicsDevice;
+ protected SpriteBatch m_spriteBatchAlpha;
+ protected SpriteBatch m_spriteBatchAdditive;
+ protected readonly Dictionary<TileSheet, Texture2D> m_tileSheetTextures;
+ protected Vector2 m_tilePosition;
+ protected Microsoft.Xna.Framework.Rectangle m_sourceRectangle;
+ protected readonly Color m_modulationColour;
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Construct an instance.</summary>
+ /// <param name="contentManager">The content manager through which to load tiles.</param>
+ /// <param name="graphicsDevice">The graphics device with which to render tiles.</param>
+ public SXnaDisplayDevice(ContentManager contentManager, GraphicsDevice graphicsDevice)
+ {
+ this.m_contentManager = contentManager;
+ this.m_graphicsDevice = graphicsDevice;
+ this.m_spriteBatchAlpha = new SpriteBatch(graphicsDevice);
+ this.m_spriteBatchAdditive = new SpriteBatch(graphicsDevice);
+ this.m_tileSheetTextures = new Dictionary<TileSheet, Texture2D>();
+ this.m_tilePosition = new Vector2();
+ this.m_sourceRectangle = new Microsoft.Xna.Framework.Rectangle();
+ this.m_modulationColour = Color.White;
+ }
+
+ /// <summary>Load a tilesheet texture.</summary>
+ /// <param name="tileSheet">The tilesheet instance.</param>
+ public virtual void LoadTileSheet(TileSheet tileSheet)
+ {
+ Texture2D texture2D = this.m_contentManager.Load<Texture2D>(tileSheet.ImageSource);
+ this.m_tileSheetTextures[tileSheet] = texture2D;
+ }
+
+ /// <summary>Unload a tilesheet texture.</summary>
+ /// <param name="tileSheet">The tilesheet instance.</param>
+ public virtual void DisposeTileSheet(TileSheet tileSheet)
+ {
+ this.m_tileSheetTextures.Remove(tileSheet);
+ }
+
+ /// <summary>Prepare to render to the screen.</summary>
+ /// <param name="b">The sprite batch being rendered.</param>
+ public virtual void BeginScene(SpriteBatch b)
+ {
+ this.m_spriteBatchAlpha = b;
+ }
+
+ /// <summary>Set the clipping region.</summary>
+ /// <param name="clippingRegion">The clipping region.</param>
+ public virtual void SetClippingRegion(Rectangle clippingRegion)
+ {
+ int backBufferWidth = this.m_graphicsDevice.PresentationParameters.BackBufferWidth;
+ int backBufferHeight = this.m_graphicsDevice.PresentationParameters.BackBufferHeight;
+ int x = this.Clamp(clippingRegion.X, 0, backBufferWidth);
+ int y = this.Clamp(clippingRegion.Y, 0, backBufferHeight);
+ int num1 = this.Clamp(clippingRegion.X + clippingRegion.Width, 0, backBufferWidth);
+ int num2 = this.Clamp(clippingRegion.Y + clippingRegion.Height, 0, backBufferHeight);
+ int width = num1 - x;
+ int height = num2 - y;
+ this.m_graphicsDevice.Viewport = new Viewport(x, y, width, height);
+ }
+
+ /// <summary>Draw a tile to the screen.</summary>
+ /// <param name="tile">The tile to draw.</param>
+ /// <param name="location">The tile position to draw.</param>
+ /// <param name="layerDepth">The layer depth at which to draw.</param>
+ public virtual void DrawTile(Tile tile, Location location, float layerDepth)
+ {
+ if (tile == null)
+ return;
+ xTile.Dimensions.Rectangle tileImageBounds = tile.TileSheet.GetTileImageBounds(tile.TileIndex);
+ Texture2D tileSheetTexture = this.m_tileSheetTextures[tile.TileSheet];
+ if (tileSheetTexture.IsDisposed)
+ return;
+ this.m_tilePosition.X = (float)location.X;
+ this.m_tilePosition.Y = (float)location.Y;
+ this.m_sourceRectangle.X = tileImageBounds.X;
+ this.m_sourceRectangle.Y = tileImageBounds.Y;
+ this.m_sourceRectangle.Width = tileImageBounds.Width;
+ this.m_sourceRectangle.Height = tileImageBounds.Height;
+ this.m_spriteBatchAlpha.Draw(tileSheetTexture, this.m_tilePosition, new Microsoft.Xna.Framework.Rectangle?(this.m_sourceRectangle), this.m_modulationColour, 0.0f, Vector2.Zero, (float)Layer.zoom, SpriteEffects.None, layerDepth);
+ }
+
+ /// <summary>Finish drawing to the screen.</summary>
+ public virtual void EndScene() { }
+
+ /// <summary>Snap a value to the given range.</summary>
+ /// <param name="nValue">The value to normalize.</param>
+ /// <param name="nMin">The minimum value.</param>
+ /// <param name="nMax">The maximum value.</param>
+ protected int Clamp(int nValue, int nMin, int nMax)
+ {
+ return Math.Min(Math.Max(nValue, nMin), nMax);
+ }
+ }
+}
diff --git a/src/SMAPI/Framework/SGame.cs b/src/SMAPI/Framework/SGame.cs
index b2d92ce8..2a30b595 100644
--- a/src/SMAPI/Framework/SGame.cs
+++ b/src/SMAPI/Framework/SGame.cs
@@ -19,6 +19,7 @@ using StardewModdingAPI.Framework.Input;
using StardewModdingAPI.Framework.Networking;
using StardewModdingAPI.Framework.PerformanceMonitoring;
using StardewModdingAPI.Framework.Reflection;
+using StardewModdingAPI.Framework.Rendering;
using StardewModdingAPI.Framework.StateTracking.Comparers;
using StardewModdingAPI.Framework.StateTracking.Snapshots;
using StardewModdingAPI.Framework.Utilities;
@@ -193,6 +194,13 @@ namespace StardewModdingAPI.Framework
Game1.locations = new ObservableCollection<GameLocation>();
}
+ /// <summary>Load content when the game is launched.</summary>
+ protected override void LoadContent()
+ {
+ base.LoadContent();
+ Game1.mapDisplayDevice = new SDisplayDevice(Game1.content, this.GraphicsDevice);
+ }
+
/// <summary>Initialize just before the game's first update tick.</summary>
private void InitializeAfterGameStarted()
{
@@ -252,12 +260,12 @@ namespace StardewModdingAPI.Framework
// update data
LoadStage oldStage = Context.LoadStage;
Context.LoadStage = newStage;
+ this.Monitor.VerboseLog($"Context: load stage changed to {newStage}");
if (newStage == LoadStage.None)
{
this.Monitor.Log("Context: returned to title", LogLevel.Trace);
- this.Multiplayer.CleanupOnMultiplayerExit();
+ this.OnReturnedToTitle();
}
- this.Monitor.VerboseLog($"Context: load stage changed to {newStage}");
// raise events
this.Events.LoadStageChanged.Raise(new LoadStageChangedEventArgs(oldStage, newStage));
@@ -283,6 +291,15 @@ namespace StardewModdingAPI.Framework
}
}
+ /// <summary>Perform cleanup when the game returns to the title screen.</summary>
+ private void OnReturnedToTitle()
+ {
+ this.Multiplayer.CleanupOnMultiplayerExit();
+
+ if (!(Game1.mapDisplayDevice is SDisplayDevice))
+ Game1.mapDisplayDevice = new SDisplayDevice(Game1.content, this.GraphicsDevice);
+ }
+
/// <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>
@@ -636,7 +653,7 @@ namespace StardewModdingAPI.Framework
}
// raise input button events
- foreach (var pair in inputState.ActiveButtons)
+ foreach (var pair in inputState.LastButtonStates)
{
SButton button = pair.Key;
SButtonState status = pair.Value;
@@ -807,7 +824,7 @@ namespace StardewModdingAPI.Framework
events.OneSecondUpdateTicking.RaiseEmpty();
try
{
- this.Input.UpdateSuppression();
+ this.Input.ApplyOverrides(); // if mods added any new overrides since the update, process them now
SGame.TicksElapsed++;
base.Update(gameTime);
}
diff --git a/src/SMAPI/Framework/WatcherCore.cs b/src/SMAPI/Framework/WatcherCore.cs
index 32b7fdc6..c89efa44 100644
--- a/src/SMAPI/Framework/WatcherCore.cs
+++ b/src/SMAPI/Framework/WatcherCore.cs
@@ -60,7 +60,7 @@ namespace StardewModdingAPI.Framework
{
// init watchers
this.CursorWatcher = WatcherFactory.ForEquatable(() => inputState.CursorPosition);
- this.MouseWheelScrollWatcher = WatcherFactory.ForEquatable(() => inputState.RealMouse.ScrollWheelValue);
+ this.MouseWheelScrollWatcher = WatcherFactory.ForEquatable(() => inputState.LastMouse.ScrollWheelValue);
this.SaveIdWatcher = WatcherFactory.ForEquatable(() => Game1.hasLoadedGame ? Game1.uniqueIDForThisGame : 0);
this.WindowSizeWatcher = WatcherFactory.ForEquatable(() => new Point(Game1.viewport.Width, Game1.viewport.Height));
this.TimeWatcher = WatcherFactory.ForEquatable(() => Game1.timeOfDay);
diff --git a/src/SMAPI/IMonitor.cs b/src/SMAPI/IMonitor.cs
index f2d110b8..c400a211 100644
--- a/src/SMAPI/IMonitor.cs
+++ b/src/SMAPI/IMonitor.cs
@@ -18,6 +18,11 @@ namespace StardewModdingAPI
/// <param name="level">The log severity level.</param>
void Log(string message, LogLevel level = LogLevel.Trace);
+ /// <summary>Log a message for the player or developer, but only if it hasn't already been logged since the last game launch.</summary>
+ /// <param name="message">The message to log.</param>
+ /// <param name="level">The log severity level.</param>
+ void LogOnce(string message, LogLevel level = LogLevel.Trace);
+
/// <summary>Log a message that only appears when <see cref="IsVerbose"/> is enabled.</summary>
/// <param name="message">The message to log.</param>
void VerboseLog(string message);
diff --git a/src/SMAPI/Patches/LoadErrorPatch.cs b/src/SMAPI/Patches/LoadErrorPatch.cs
index c16ca7cc..77415ff2 100644
--- a/src/SMAPI/Patches/LoadErrorPatch.cs
+++ b/src/SMAPI/Patches/LoadErrorPatch.cs
@@ -67,8 +67,7 @@ namespace StardewModdingAPI.Patches
private static bool Before_SaveGame_LoadDataToLocations(List<GameLocation> gamelocations)
{
bool removedAny =
- LoadErrorPatch.RemoveInvalidLocations(gamelocations)
- | LoadErrorPatch.RemoveBrokenBuildings(gamelocations)
+ LoadErrorPatch.RemoveBrokenBuildings(gamelocations)
| LoadErrorPatch.RemoveInvalidNpcs(gamelocations);
if (removedAny)
@@ -77,28 +76,6 @@ namespace StardewModdingAPI.Patches
return true;
}
- /// <summary>Remove locations which don't exist in-game.</summary>
- /// <param name="locations">The current game locations.</param>
- private static bool RemoveInvalidLocations(List<GameLocation> locations)
- {
- bool removedAny = false;
-
- foreach (GameLocation location in locations.ToArray())
- {
- if (location is Cellar)
- continue; // missing cellars will be added by the game code
-
- if (Game1.getLocationFromName(location.name) == null)
- {
- LoadErrorPatch.Monitor.Log($"Removed invalid location '{location.Name}' to avoid a crash when loading save '{Constants.SaveFolderName}'. (Did you remove a custom location mod?)", LogLevel.Warn);
- locations.Remove(location);
- removedAny = true;
- }
- }
-
- return removedAny;
- }
-
/// <summary>Remove buildings which don't exist in the game data.</summary>
/// <param name="locations">The current game locations.</param>
private static bool RemoveBrokenBuildings(IEnumerable<GameLocation> locations)
diff --git a/src/SMAPI/SMAPI.csproj b/src/SMAPI/SMAPI.csproj
index c17f13c4..5f41387b 100644
--- a/src/SMAPI/SMAPI.csproj
+++ b/src/SMAPI/SMAPI.csproj
@@ -19,7 +19,7 @@
<PackageReference Include="Lib.Harmony" Version="1.2.0.1" />
<PackageReference Include="Mono.Cecil" Version="0.11.2" />
<PackageReference Include="Newtonsoft.Json" Version="12.0.3" />
- <PackageReference Include="Platonymous.TMXTile" Version="1.0.2" />
+ <PackageReference Include="Platonymous.TMXTile" Version="1.3.4" />
</ItemGroup>
<ItemGroup>
diff --git a/src/SMAPI/i18n/hu.json b/src/SMAPI/i18n/hu.json
new file mode 100644
index 00000000..aa0c7546
--- /dev/null
+++ b/src/SMAPI/i18n/hu.json
@@ -0,0 +1,3 @@
+{
+ "warn.invalid-content-removed": "Érvénytelen elemek kerültek eltávolításra, hogy a játék ne omoljon össze (további információk a SMAPI konzolon)."
+}