diff options
Diffstat (limited to 'src')
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)." +} |