diff --git a/Mods/AutoFish/AutoFish.csproj b/Mods/AutoFish/AutoFish.csproj new file mode 100644 index 00000000..bc33fade --- /dev/null +++ b/Mods/AutoFish/AutoFish.csproj @@ -0,0 +1,162 @@ + + + + + Debug + AnyCPU + {8B08A816-6125-4277-A9EE-CA6AF9E279FC} + Library + Properties + AutoFish + AutoFish + v4.5.2 + 512 + true + + + true + full + false + bin\Debug\ + DEBUG;TRACE + prompt + 4 + + + pdbonly + true + bin\Release\ + TRACE + prompt + 4 + + + + ..\assemblies\Mod.dll + + + ..\assemblies\StardewValley.dll + + + False + ..\assemblies\BmFont.dll + + + False + ..\assemblies\Google.Android.Vending.Expansion.Downloader.dll + + + False + ..\assemblies\Google.Android.Vending.Expansion.ZipFile.dll + + + False + ..\assemblies\Google.Android.Vending.Licensing.dll + + + False + ..\assemblies\Java.Interop.dll + + + False + ..\assemblies\Microsoft.AppCenter.dll + + + False + ..\assemblies\Microsoft.AppCenter.Analytics.dll + + + False + ..\assemblies\Microsoft.AppCenter.Analytics.Android.Bindings.dll + + + False + ..\assemblies\Microsoft.AppCenter.Android.Bindings.dll + + + False + ..\assemblies\Microsoft.AppCenter.Crashes.dll + + + False + ..\assemblies\Microsoft.AppCenter.Crashes.Android.Bindings.dll + + + False + ..\assemblies\Mono.Android.dll + + + False + ..\assemblies\Mono.Security.dll + + + False + ..\assemblies\MonoGame.Framework.dll + + + ..\assemblies\mscorlib.dll + + + ..\assemblies\System.dll + + + ..\assemblies\System.Xml + + + ..\assemblies\System.Net.Http + + + ..\assemblies\System.Runtime.Serialization + + + False + ..\assemblies\Xamarin.Android.Arch.Core.Common.dll + + + False + ..\assemblies\Xamarin.Android.Arch.Lifecycle.Common.dll + + + False + ..\assemblies\Xamarin.Android.Arch.Lifecycle.Runtime.dll + + + False + ..\assemblies\Xamarin.Android.Support.Annotations.dll + + + False + ..\assemblies\Xamarin.Android.Support.Compat.dll + + + False + ..\assemblies\Xamarin.Android.Support.Core.UI.dll + + + False + ..\assemblies\Xamarin.Android.Support.Core.Utils.dll + + + False + ..\assemblies\Xamarin.Android.Support.Fragment.dll + + + False + ..\assemblies\Xamarin.Android.Support.Media.Compat.dll + + + False + ..\assemblies\Xamarin.Android.Support.v4.dll + + + False + ..\assemblies\xTile.dll + + + + + + + + + \ No newline at end of file diff --git a/Mods/AutoFish/AutoFish/ModConfig.cs b/Mods/AutoFish/AutoFish/ModConfig.cs new file mode 100644 index 00000000..3604f5cd --- /dev/null +++ b/Mods/AutoFish/AutoFish/ModConfig.cs @@ -0,0 +1,16 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace AutoFish +{ + class ModConfig + { + public bool maxCastPower { get; set; } = true; + public bool autoHit { get; set; } = true; + public bool fastBite { get; set; } = false; + public bool catchTreasure { get; set; } = true; + } +} diff --git a/Mods/AutoFish/AutoFish/ModEntry.cs b/Mods/AutoFish/AutoFish/ModEntry.cs new file mode 100644 index 00000000..0d33b57e --- /dev/null +++ b/Mods/AutoFish/AutoFish/ModEntry.cs @@ -0,0 +1,138 @@ +using SMDroid.Options; +using StardewModdingAPI; +using StardewModdingAPI.Events; +using StardewValley; +using StardewValley.Menus; +using StardewValley.Tools; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace AutoFish +{ + public class ModEntry : Mod + { + private ModConfig Config; + private bool catching = false; + + public override void Entry(IModHelper helper) + { + this.Config = this.Helper.ReadConfig(); + helper.Events.GameLoop.UpdateTicked += this.UpdateTick; + } + public override List GetConfigMenuItems() + { + List options = new List(); + ModOptionsCheckbox _optionsCheckboxAutoHit = new ModOptionsCheckbox("自动起钩", 0x8765, delegate (bool value) { + this.Config.autoHit = value; + this.Helper.WriteConfig(this.Config); + }, -1, -1); + _optionsCheckboxAutoHit.isChecked = this.Config.autoHit; + options.Add(_optionsCheckboxAutoHit); + ModOptionsCheckbox _optionsCheckboxMaxCastPower = new ModOptionsCheckbox("最大抛竿", 0x8765, delegate (bool value) { + this.Config.maxCastPower = value; + this.Helper.WriteConfig(this.Config); + }, -1, -1); + _optionsCheckboxMaxCastPower.isChecked = this.Config.maxCastPower; + options.Add(_optionsCheckboxMaxCastPower); + ModOptionsCheckbox _optionsCheckboxFastBite = new ModOptionsCheckbox("快速咬钩", 0x8765, delegate (bool value) { + this.Config.fastBite = value; + this.Helper.WriteConfig(this.Config); + }, -1, -1); + _optionsCheckboxFastBite.isChecked = this.Config.fastBite; + options.Add(_optionsCheckboxFastBite); + ModOptionsCheckbox _optionsCheckboxCatchTreasure = new ModOptionsCheckbox("钓取宝箱", 0x8765, delegate (bool value) { + this.Config.catchTreasure = value; + this.Helper.WriteConfig(this.Config); + }, -1, -1); + _optionsCheckboxCatchTreasure.isChecked = this.Config.catchTreasure; + options.Add(_optionsCheckboxCatchTreasure); + return options; + } + + + private void UpdateTick(object sender, EventArgs e) + { + if (Game1.player == null) + return; + + if (Game1.player.CurrentTool is FishingRod) + { + FishingRod currentTool = Game1.player.CurrentTool as FishingRod; + if (this.Config.fastBite && currentTool.timeUntilFishingBite > 0) + currentTool.timeUntilFishingBite /= 2; // 快速咬钩 + + if (this.Config.autoHit && currentTool.isNibbling && !currentTool.isReeling && !currentTool.hit && !currentTool.pullingOutOfWater && !currentTool.fishCaught) + currentTool.DoFunction(Game1.player.currentLocation, 1, 1, 1, Game1.player); // 自动咬钩 + + if (this.Config.maxCastPower) + currentTool.castingPower = 1; + } + + if (Game1.activeClickableMenu is BobberBar) // 自动小游戏 + { + BobberBar bar = Game1.activeClickableMenu as BobberBar; + float barPos = this.Helper.Reflection.GetField(bar, "bobberBarPos").GetValue(); + float barHeight = this.Helper.Reflection.GetField(bar, "bobberBarHeight").GetValue(); + float fishPos = this.Helper.Reflection.GetField(bar, "bobberPosition").GetValue(); + float treasurePos = this.Helper.Reflection.GetField(bar, "treasurePosition").GetValue(); + float distanceFromCatching = this.Helper.Reflection.GetField(bar, "distanceFromCatching").GetValue(); + + bool treasureCaught = this.Helper.Reflection.GetField(bar, "treasureCaught").GetValue(); + bool hasTreasure = this.Helper.Reflection.GetField(bar, "treasure").GetValue(); + float treasureScale = this.Helper.Reflection.GetField(bar, "treasureScale").GetValue(); + float bobberBarSpeed = this.Helper.Reflection.GetField(bar, "bobberBarSpeed").GetValue(); + float barPosMax = 568 - barHeight; + + float min = barPos + barHeight / 4, + max = barPos + barHeight / 1.5f; + + if (this.Config.catchTreasure && hasTreasure && !treasureCaught && (distanceFromCatching > 0.75 || this.catching)) + { + this.catching = true; + fishPos = treasurePos; + } + if (this.catching && distanceFromCatching < 0.15) + { + this.catching = false; + fishPos = this.Helper.Reflection.GetField(bar, "bobberPosition").GetValue(); + } + + if (fishPos < min) + { + bobberBarSpeed -= 0.35f + (min - fishPos) / 20; + this.Helper.Reflection.GetField(bar, "bobberBarSpeed").SetValue(bobberBarSpeed); + } else if (fishPos > max) + { + bobberBarSpeed += 0.35f + (fishPos - max) / 20; + this.Helper.Reflection.GetField(bar, "bobberBarSpeed").SetValue(bobberBarSpeed); + } else + { + float target = 0.1f; + if (bobberBarSpeed > target) + { + bobberBarSpeed -= 0.1f + (bobberBarSpeed - target) / 25; + if (barPos + bobberBarSpeed > barPosMax) + bobberBarSpeed /= 2; // 减小触底反弹 + if (bobberBarSpeed < target) + bobberBarSpeed = target; + } else + { + bobberBarSpeed += 0.1f + (target - bobberBarSpeed) / 25; + if (barPos + bobberBarSpeed < 0) + bobberBarSpeed /= 2; // 减小触顶反弹 + if (bobberBarSpeed > target) + bobberBarSpeed = target; + } + this.Helper.Reflection.GetField(bar, "bobberBarSpeed").SetValue(bobberBarSpeed); + } + } + else + { + this.catching = false; + } + } + } +} diff --git a/Mods/AutoFish/Properties/AssemblyInfo.cs b/Mods/AutoFish/Properties/AssemblyInfo.cs new file mode 100644 index 00000000..322b9e55 --- /dev/null +++ b/Mods/AutoFish/Properties/AssemblyInfo.cs @@ -0,0 +1,36 @@ +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; + +// 有关程序集的一般信息由以下 +// 控制。更改这些特性值可修改 +// 与程序集关联的信息。 +[assembly: AssemblyTitle("AutoFish")] +[assembly: AssemblyDescription("")] +[assembly: AssemblyConfiguration("")] +[assembly: AssemblyCompany("")] +[assembly: AssemblyProduct("AutoFish")] +[assembly: AssemblyCopyright("Copyright © 2019")] +[assembly: AssemblyTrademark("")] +[assembly: AssemblyCulture("")] + +// 将 ComVisible 设置为 false 会使此程序集中的类型 +//对 COM 组件不可见。如果需要从 COM 访问此程序集中的类型 +//请将此类型的 ComVisible 特性设置为 true。 +[assembly: ComVisible(false)] + +// 如果此项目向 COM 公开,则下列 GUID 用于类型库的 ID +[assembly: Guid("8b08a816-6125-4277-a9ee-ca6af9e279fc")] + +// 程序集的版本信息由下列四个值组成: +// +// 主版本 +// 次版本 +// 生成号 +// 修订号 +// +// 可以指定所有值,也可以使用以下所示的 "*" 预置版本号和修订号 +//通过使用 "*",如下所示: +// [assembly: AssemblyVersion("1.0.*")] +[assembly: AssemblyVersion("1.0.0.0")] +[assembly: AssemblyFileVersion("1.0.0.0")] diff --git a/Mods/AutoSpeed/AutoSpeed.csproj b/Mods/AutoSpeed/AutoSpeed.csproj new file mode 100644 index 00000000..d29f361c --- /dev/null +++ b/Mods/AutoSpeed/AutoSpeed.csproj @@ -0,0 +1,175 @@ + + + + + Debug + AnyCPU + {5B089EEE-F22C-4753-B90D-16D4CD3F5D61} + Library + Properties + AutoSpeed + AutoSpeed + v4.6.1 + 512 + true + + + true + full + false + bin\Debug\ + DEBUG;TRACE + prompt + 4 + + + pdbonly + true + bin\Release\ + TRACE + prompt + 4 + + + + ..\assemblies\Mod.dll + + + ..\assemblies\StardewValley.dll + + + False + ..\assemblies\BmFont.dll + + + False + ..\assemblies\Google.Android.Vending.Expansion.Downloader.dll + + + False + ..\assemblies\Google.Android.Vending.Expansion.ZipFile.dll + + + False + ..\assemblies\Google.Android.Vending.Licensing.dll + + + False + ..\assemblies\Java.Interop.dll + + + False + ..\assemblies\Microsoft.AppCenter.dll + + + False + ..\assemblies\Microsoft.AppCenter.Analytics.dll + + + False + ..\assemblies\Microsoft.AppCenter.Analytics.Android.Bindings.dll + + + False + ..\assemblies\Microsoft.AppCenter.Android.Bindings.dll + + + False + ..\assemblies\Microsoft.AppCenter.Crashes.dll + + + False + ..\assemblies\Microsoft.AppCenter.Crashes.Android.Bindings.dll + + + False + ..\assemblies\Mono.Android.dll + + + False + ..\assemblies\Mono.Security.dll + + + False + ..\assemblies\MonoGame.Framework.dll + + + ..\assemblies\mscorlib.dll + + + ..\assemblies\System.dll + + + ..\assemblies\System.Xml + + + ..\assemblies\System.Net.Http + + + ..\assemblies\System.Runtime.Serialization + + + False + ..\assemblies\Xamarin.Android.Arch.Core.Common.dll + + + False + ..\assemblies\Xamarin.Android.Arch.Lifecycle.Common.dll + + + False + ..\assemblies\Xamarin.Android.Arch.Lifecycle.Runtime.dll + + + False + ..\assemblies\Xamarin.Android.Support.Annotations.dll + + + False + ..\assemblies\Xamarin.Android.Support.Compat.dll + + + False + ..\assemblies\Xamarin.Android.Support.Core.UI.dll + + + False + ..\assemblies\Xamarin.Android.Support.Core.Utils.dll + + + False + ..\assemblies\Xamarin.Android.Support.Fragment.dll + + + False + ..\assemblies\Xamarin.Android.Support.Media.Compat.dll + + + False + ..\assemblies\Xamarin.Android.Support.v4.dll + + + False + ..\assemblies\xTile.dll + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Mods/AutoSpeed/AutoSpeed/AutoSpeed.cs b/Mods/AutoSpeed/AutoSpeed/AutoSpeed.cs new file mode 100644 index 00000000..55ee33c6 --- /dev/null +++ b/Mods/AutoSpeed/AutoSpeed/AutoSpeed.cs @@ -0,0 +1,58 @@ +using System.Collections.Generic; +using Omegasis.AutoSpeed.Framework; +using SMDroid.Options; +using StardewModdingAPI; +using StardewModdingAPI.Events; +using StardewValley; +using StardewValley.Menus; + +namespace Omegasis.AutoSpeed +{ + /// The mod entry point. + public class AutoSpeed : Mod + { + /********* + ** Fields + *********/ + /// The mod configuration. + private ModConfig Config; + + + /********* + ** Public methods + *********/ + /// The mod entry point, called after the mod is first loaded. + /// Provides simplified APIs for writing mods. + public override void Entry(IModHelper helper) + { + helper.Events.GameLoop.UpdateTicked += this.OnUpdateTicked; + this.Config = helper.ReadConfig(); + } + + public override List GetConfigMenuItems() + { + List options = new List(); + ModOptionsSlider _optionsSliderSpeed = new ModOptionsSlider("移动加速", 0x8765, delegate (int value) { + this.Config.Speed = value; + this.Helper.WriteConfig(this.Config); + }, -1, -1); + _optionsSliderSpeed.sliderMinValue = 0; + _optionsSliderSpeed.sliderMaxValue = 10; + _optionsSliderSpeed.value = this.Config.Speed; + options.Add(_optionsSliderSpeed); + return options; + } + + /********* + ** Private methods + *********/ + /// Raised after the game state is updated (≈60 times per second). + /// The event sender. + /// The event arguments. + private void OnUpdateTicked(object sender, UpdateTickedEventArgs e) + { + if (Context.IsPlayerFree) + Game1.player.addedSpeed = this.Config.Speed; + } + } +} diff --git a/Mods/AutoSpeed/AutoSpeed/Framework/ModConfig.cs b/Mods/AutoSpeed/AutoSpeed/Framework/ModConfig.cs new file mode 100644 index 00000000..2e6dc10f --- /dev/null +++ b/Mods/AutoSpeed/AutoSpeed/Framework/ModConfig.cs @@ -0,0 +1,9 @@ +namespace Omegasis.AutoSpeed.Framework +{ + /// The mod configuration. + internal class ModConfig + { + /// The added speed. + public int Speed { get; set; } = 5; + } +} diff --git a/Mods/AutoSpeed/AutoSpeed/ModConfig.cs b/Mods/AutoSpeed/AutoSpeed/ModConfig.cs new file mode 100644 index 00000000..b1f5b73b --- /dev/null +++ b/Mods/AutoSpeed/AutoSpeed/ModConfig.cs @@ -0,0 +1,14 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace AutoSpeed +{ + class ModConfig + { + /// The added speed. + public int Speed { get; set; } = 5; + } +} diff --git a/Mods/AutoSpeed/AutoSpeed/ModEntry.cs b/Mods/AutoSpeed/AutoSpeed/ModEntry.cs new file mode 100644 index 00000000..818ba5ea --- /dev/null +++ b/Mods/AutoSpeed/AutoSpeed/ModEntry.cs @@ -0,0 +1,58 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +using StardewModdingAPI; +using StardewModdingAPI.Events; +using StardewValley; +using StardewValley.Menus; +using SMDroid.Options; + +namespace AutoSpeed +{ + /// The mod entry point. + class ModEntry : StardewModdingAPI.Mod + { + /// The mod configuration. + private ModConfig Config; + + /********* + ** Public methods + *********/ + /// The mod entry point, called after the mod is first loaded. + /// Provides simplified APIs for writing mods. + public override void Entry(IModHelper helper) + { + this.Config = helper.ReadConfig(); + helper.Events.GameLoop.UpdateTicked += this.OnUpdateTicked; + } + + public override List GetConfigMenuItems() + { + List options = new List(); + ModOptionsSlider _optionsSliderSpeed = new ModOptionsSlider("移动加速", 0x8765, delegate (int value) { + Config.Speed = value; + Helper.WriteConfig(Config); + }, -1, -1); + _optionsSliderSpeed.sliderMinValue = 0; + _optionsSliderSpeed.sliderMaxValue = 10; + _optionsSliderSpeed.value = Config.Speed; + options.Add(_optionsSliderSpeed); + return options; + } + + /********* + ** Private methods + *********/ + /// Raised after the game state is updated (≈60 times per second). + /// The event sender. + /// The event arguments. + private void OnUpdateTicked(object sender, UpdateTickedEventArgs e) + { + if (Context.IsPlayerFree) + Game1.player.addedSpeed = Config.Speed; + } + } +} \ No newline at end of file diff --git a/Mods/AutoSpeed/AutoSpeed/README.md b/Mods/AutoSpeed/AutoSpeed/README.md new file mode 100644 index 00000000..50f2e105 --- /dev/null +++ b/Mods/AutoSpeed/AutoSpeed/README.md @@ -0,0 +1,31 @@ +**Auto Speed** is a [Stardew Valley](http://stardewvalley.net/) mod which lets you move faster +without the need to enter commands in the console. + +Compatible with Stardew Valley 1.2+ on Linux, Mac, and Windows. + +## Installation +1. [Install the latest version of SMAPI](https://github.com/Pathoschild/SMAPI/releases). +2. Install [this mod from Nexus mods](http://www.nexusmods.com/stardewvalley/mods/443). +3. Run the game using SMAPI. + +## Usage +Launch the game with the mod installed to generate the config file, then edit the `config.json` to +set the speed you want (higher values are faster). + +## Versions +1.0 +* Initial release. + +1.1: +* Updated to Stardew Valley 1.1 and SMAPI 0.40 1.1-3. + +1.3: +* Updated to Stardew Valley 1.2 and SMAPI 1.12. + +1.4: +* Switched to standard JSON config file. +* Fixed config defaulting to normal speed. +* Internal refactoring. + +1.4.1: +* Enabled update checks in SMAPI 2.0+. diff --git a/Mods/AutoSpeed/AutoSpeed/manifest.json b/Mods/AutoSpeed/AutoSpeed/manifest.json new file mode 100644 index 00000000..b5c3f9e3 --- /dev/null +++ b/Mods/AutoSpeed/AutoSpeed/manifest.json @@ -0,0 +1,10 @@ +{ + "Name": "Auto Speed", + "Author": "Alpha_Omegasis", + "Version": "1.8.0", + "Description": "Got to go fast!", + "UniqueID": "Omegasis.AutoSpeed", + "EntryDll": "AutoSpeed.dll", + "MinimumApiVersion": "2.10.1", + "UpdateKeys": [ "Nexus:443" ] +} diff --git a/Mods/AutoSpeed/Properties/AssemblyInfo.cs b/Mods/AutoSpeed/Properties/AssemblyInfo.cs new file mode 100644 index 00000000..1907a05d --- /dev/null +++ b/Mods/AutoSpeed/Properties/AssemblyInfo.cs @@ -0,0 +1,36 @@ +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; + +// 有关程序集的一般信息由以下 +// 控制。更改这些特性值可修改 +// 与程序集关联的信息。 +[assembly: AssemblyTitle("AutoSpeed")] +[assembly: AssemblyDescription("")] +[assembly: AssemblyConfiguration("")] +[assembly: AssemblyCompany("")] +[assembly: AssemblyProduct("AutoSpeed")] +[assembly: AssemblyCopyright("Copyright © 2019")] +[assembly: AssemblyTrademark("")] +[assembly: AssemblyCulture("")] + +// 将 ComVisible 设置为 false 会使此程序集中的类型 +//对 COM 组件不可见。如果需要从 COM 访问此程序集中的类型 +//请将此类型的 ComVisible 特性设置为 true。 +[assembly: ComVisible(false)] + +// 如果此项目向 COM 公开,则下列 GUID 用于类型库的 ID +[assembly: Guid("5b089eee-f22c-4753-b90d-16d4cd3f5d61")] + +// 程序集的版本信息由下列四个值组成: +// +// 主版本 +// 次版本 +// 生成号 +// 修订号 +// +// 可以指定所有值,也可以使用以下所示的 "*" 预置版本号和修订号 +//通过使用 "*",如下所示: +// [assembly: AssemblyVersion("1.0.*")] +[assembly: AssemblyVersion("1.0.0.0")] +[assembly: AssemblyFileVersion("1.0.0.0")] diff --git a/Mods/Automate/Automate.csproj b/Mods/Automate/Automate.csproj new file mode 100644 index 00000000..295083d6 --- /dev/null +++ b/Mods/Automate/Automate.csproj @@ -0,0 +1,277 @@ + + + + + Debug + AnyCPU + {5EF944E3-D54B-4936-B507-A40C17B17B8E} + Library + Properties + Automate + Automate + v4.5.2 + 512 + true + + + true + full + false + bin\Debug\ + DEBUG;TRACE + prompt + 4 + 7.2 + + + pdbonly + true + bin\Release\ + TRACE + prompt + 4 + 7.2 + + + + ..\assemblies\Mod.dll + + + ..\assemblies\StardewValley.dll + + + False + ..\assemblies\BmFont.dll + + + False + ..\assemblies\Google.Android.Vending.Expansion.Downloader.dll + + + False + ..\assemblies\Google.Android.Vending.Expansion.ZipFile.dll + + + False + ..\assemblies\Google.Android.Vending.Licensing.dll + + + False + ..\assemblies\Java.Interop.dll + + + False + ..\assemblies\Microsoft.AppCenter.dll + + + False + ..\assemblies\Microsoft.AppCenter.Analytics.dll + + + False + ..\assemblies\Microsoft.AppCenter.Analytics.Android.Bindings.dll + + + False + ..\assemblies\Microsoft.AppCenter.Android.Bindings.dll + + + False + ..\assemblies\Microsoft.AppCenter.Crashes.dll + + + False + ..\assemblies\Microsoft.AppCenter.Crashes.Android.Bindings.dll + + + False + ..\assemblies\Mono.Android.dll + + + False + ..\assemblies\Mono.Security.dll + + + False + ..\assemblies\MonoGame.Framework.dll + + + ..\assemblies\mscorlib.dll + + + ..\assemblies\System.dll + + + ..\assemblies\System.Xml + + + ..\assemblies\System.Net.Http + + + ..\assemblies\System.Runtime.Serialization + + + False + ..\assemblies\Xamarin.Android.Arch.Core.Common.dll + + + False + ..\assemblies\Xamarin.Android.Arch.Lifecycle.Common.dll + + + False + ..\assemblies\Xamarin.Android.Arch.Lifecycle.Runtime.dll + + + False + ..\assemblies\Xamarin.Android.Support.Annotations.dll + + + False + ..\assemblies\Xamarin.Android.Support.Compat.dll + + + False + ..\assemblies\Xamarin.Android.Support.Core.UI.dll + + + False + ..\assemblies\Xamarin.Android.Support.Core.Utils.dll + + + False + ..\assemblies\Xamarin.Android.Support.Fragment.dll + + + False + ..\assemblies\Xamarin.Android.Support.Media.Compat.dll + + + False + ..\assemblies\Xamarin.Android.Support.v4.dll + + + False + ..\assemblies\xTile.dll + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Mods/Automate/Automate/Framework/AutomateAPI.cs b/Mods/Automate/Automate/Framework/AutomateAPI.cs new file mode 100644 index 00000000..0508d249 --- /dev/null +++ b/Mods/Automate/Automate/Framework/AutomateAPI.cs @@ -0,0 +1,98 @@ +using System.Collections.Generic; +using System.Linq; +using Microsoft.Xna.Framework; +using Pathoschild.Stardew.Common; +using StardewModdingAPI; +using StardewValley; + +namespace Pathoschild.Stardew.Automate.Framework +{ + /// The API which lets other mods interact with Automate. + public class AutomateAPI : IAutomateAPI + { + /********* + ** Fields + *********/ + /// Encapsulates monitoring and logging. + private readonly IMonitor Monitor; + + /// Constructs machine groups. + private readonly MachineGroupFactory MachineGroupFactory; + + /// The active machine groups recognised by Automate. + private readonly IDictionary ActiveMachineGroups; + + /// The disabled machine groups recognised by Automate (e.g. machines not connected to a chest). + private readonly IDictionary DisabledMachineGroups; + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// Encapsulates monitoring and logging. + /// Constructs machine groups. + /// The active machine groups recognised by Automate. + /// The disabled machine groups recognised by Automate (e.g. machines not connected to a chest). + internal AutomateAPI(IMonitor monitor, MachineGroupFactory machineGroupFactory, IDictionary activeMachineGroups, IDictionary disabledMachineGroups) + { + this.Monitor = monitor; + this.MachineGroupFactory = machineGroupFactory; + this.ActiveMachineGroups = activeMachineGroups; + this.DisabledMachineGroups = disabledMachineGroups; + } + + /// Add an automation factory. + /// An automation factory which construct machines, containers, and connectors. + public void AddFactory(IAutomationFactory factory) + { + this.Monitor.Log($"Adding automation factory: {factory.GetType().AssemblyQualifiedName}", LogLevel.Trace); + this.MachineGroupFactory.Add(factory); + } + + /// Get the status of machines in a tile area. This is a specialised API for Data Layers and similar mods. + /// The location for which to display data. + /// The tile area for which to display data. + public IDictionary GetMachineStates(GameLocation location, Rectangle tileArea) + { + IDictionary data = new Dictionary(); + foreach (IMachine machine in this.GetMachineGroups(location).SelectMany(group => group.Machines)) + { + if (machine.TileArea.Intersects(tileArea)) + { + int state = (int)machine.GetState(); + foreach (Vector2 tile in machine.TileArea.GetTiles()) + { + if (tileArea.Contains((int)tile.X, (int)tile.Y)) + data[tile] = state; + } + } + } + + return data; + } + + + /********* + ** Private methods + *********/ + /// Get all machines in a location. + /// The location whose maches to fetch. + private IEnumerable GetMachineGroups(GameLocation location) + { + // active groups + if (this.ActiveMachineGroups.TryGetValue(location, out MachineGroup[] activeGroups)) + { + foreach (MachineGroup machineGroup in activeGroups) + yield return machineGroup; + } + + // disabled groups + if (this.DisabledMachineGroups.TryGetValue(location, out MachineGroup[] disabledGroups)) + { + foreach (MachineGroup machineGroup in disabledGroups) + yield return machineGroup; + } + } + } +} diff --git a/Mods/Automate/Automate/Framework/AutomationFactory.cs b/Mods/Automate/Automate/Framework/AutomationFactory.cs new file mode 100644 index 00000000..613bb4b1 --- /dev/null +++ b/Mods/Automate/Automate/Framework/AutomationFactory.cs @@ -0,0 +1,247 @@ +using System.Collections.Generic; +using System.Linq; +using Microsoft.Xna.Framework; +using Pathoschild.Stardew.Automate.Framework.Machines.Buildings; +using Pathoschild.Stardew.Automate.Framework.Machines.Objects; +using Pathoschild.Stardew.Automate.Framework.Machines.TerrainFeatures; +using Pathoschild.Stardew.Automate.Framework.Machines.Tiles; +using Pathoschild.Stardew.Automate.Framework.Models; +using Pathoschild.Stardew.Automate.Framework.Storage; +using StardewModdingAPI; +using StardewValley; +using StardewValley.Buildings; +using StardewValley.Locations; +using StardewValley.Objects; +using StardewValley.TerrainFeatures; +using SObject = StardewValley.Object; + +namespace Pathoschild.Stardew.Automate.Framework +{ + /// Constructs machines, containers, or connectors which can be added to a machine group. + internal class AutomationFactory : IAutomationFactory + { + /********* + ** Fields + *********/ + /// Encapsulates monitoring and logging. + private readonly IMonitor Monitor; + + /// Simplifies access to private game code. + private readonly IReflectionHelper Reflection; + + /// The object IDs through which machines can connect, but which have no other automation properties. + private readonly IDictionary> Connectors; + + /// Whether to treat the shipping bin as a machine that can be automated. + private readonly bool AutomateShippingBin; + + /// The tile area on the farm matching the shipping bin. + private readonly Rectangle ShippingBinArea = new Rectangle(71, 14, 2, 1); + + /// Whether the Better Junimos mod is installed. + private readonly bool HasBetterJunimos; + + /// Whether the Deluxe Auto-Grabber mod is installed. + private readonly bool HasDeluxeAutoGrabber; + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The objects through which machines can connect, but which have no other automation properties. + /// Whether to treat the shipping bin as a machine that can be automated. + /// Encapsulates monitoring and logging. + /// Simplifies access to private game code. + /// Whether the Better Junimos mod is installed. + /// Whether the Deluxe Auto-Grabber mod is installed. + public AutomationFactory(ModConfigObject[] connectors, bool automateShippingBin, IMonitor monitor, IReflectionHelper reflection, bool hasBetterJunimos, bool hasDeluxeAutoGrabber) + { + this.Connectors = connectors + .GroupBy(connector => connector.Type) + .ToDictionary(group => group.Key, group => new HashSet(group.Select(p => p.ID))); + this.AutomateShippingBin = automateShippingBin; + this.Monitor = monitor; + this.Reflection = reflection; + this.HasBetterJunimos = hasBetterJunimos; + this.HasDeluxeAutoGrabber = hasDeluxeAutoGrabber; + } + + /// Get a machine, container, or connector instance for a given object. + /// The in-game object. + /// The location to check. + /// The tile position to check. + /// Returns an instance or null. + public IAutomatable GetFor(SObject obj, GameLocation location, in Vector2 tile) + { + // chest container + if (obj is Chest chest) + return new ChestContainer(chest, location, tile); + + // machine + if (obj.ParentSheetIndex == 165) + return new AutoGrabberMachine(obj, location, tile, ignoreSeedOutput: this.HasDeluxeAutoGrabber); + if (obj.name == "Bee House") + return new BeeHouseMachine(obj, location, tile); + if (obj is Cask cask) + return new CaskMachine(cask, location, tile); + if (obj.name == "Charcoal Kiln") + return new CharcoalKilnMachine(obj, location, tile); + if (obj.name == "Cheese Press") + return new CheesePressMachine(obj, location, tile); + if (obj is CrabPot pot) + return new CrabPotMachine(pot, location, tile, this.Monitor, this.Reflection); + if (obj.Name == "Crystalarium") + return new CrystalariumMachine(obj, location, tile, this.Reflection); + if (obj.name == "Feed Hopper") + return new FeedHopperMachine(location, tile); + if (obj.Name == "Furnace") + return new FurnaceMachine(obj, location, tile); + if (obj.name == "Incubator") + return new CoopIncubatorMachine(obj, location, tile); + if (obj.Name == "Keg") + return new KegMachine(obj, location, tile); + if (obj.name == "Lightning Rod") + return new LightningRodMachine(obj, location, tile); + if (obj.name == "Loom") + return new LoomMachine(obj, location, tile); + if (obj.name == "Mayonnaise Machine") + return new MayonnaiseMachine(obj, location, tile); + if (obj.Name == "Mushroom Box") + return new MushroomBoxMachine(obj, location, tile); + if (obj.name == "Oil Maker") + return new OilMakerMachine(obj, location, tile); + if (obj.name == "Preserves Jar") + return new PreservesJarMachine(obj, location, tile); + if (obj.name == "Recycling Machine") + return new RecyclingMachine(obj, location, tile); + if (obj.name == "Seed Maker") + return new SeedMakerMachine(obj, location, tile); + if (obj.name == "Slime Egg-Press") + return new SlimeEggPressMachine(obj, location, tile); + if (obj.name == "Slime Incubator") + return new SlimeIncubatorMachine(obj, location, tile); + if (obj.name == "Soda Machine") + return new SodaMachine(obj, location, tile); + if (obj.name == "Statue Of Endless Fortune") + return new StatueOfEndlessFortuneMachine(obj, location, tile); + if (obj.name == "Statue Of Perfection") + return new StatueOfPerfectionMachine(obj, location, tile); + if (obj.name == "Tapper") + { + if (location.terrainFeatures.TryGetValue(tile, out TerrainFeature terrainFeature) && terrainFeature is Tree tree) + return new TapperMachine(obj, location, tile, tree.treeType.Value); + } + if (obj.name == "Worm Bin") + return new WormBinMachine(obj, location, tile); + + // connector + if (this.IsConnector(obj.bigCraftable.Value ? ObjectType.BigCraftable : ObjectType.Object, this.GetItemID(obj))) + return new Connector(location, tile); + + return null; + } + + /// Get a machine, container, or connector instance for a given terrain feature. + /// The terrain feature. + /// The location to check. + /// The tile position to check. + /// Returns an instance or null. + public IAutomatable GetFor(TerrainFeature feature, GameLocation location, in Vector2 tile) + { + // machine + if (feature is FruitTree fruitTree) + return new FruitTreeMachine(fruitTree, location, tile); + + // connector + if (feature is Flooring floor && this.IsConnector(ObjectType.Floor, floor.whichFloor.Value)) + return new Connector(location, tile); + + return null; + } + + /// Get a machine, container, or connector instance for a given building. + /// The building. + /// The location to check. + /// The tile position to check. + /// Returns an instance or null. + public IAutomatable GetFor(Building building, BuildableGameLocation location, in Vector2 tile) + { + // machine + if (building is JunimoHut hut) + return new JunimoHutMachine(hut, location, ignoreSeedOutput: this.HasBetterJunimos); + if (building is Mill mill) + return new MillMachine(mill, location); + if (this.AutomateShippingBin && building is ShippingBin bin) + return new ShippingBinMachine(bin, location, Game1.getFarm()); + if (building.buildingType.Value == "Silo") + return new FeedHopperMachine(building, location); + return null; + } + + /// Get a machine, container, or connector instance for a given tile position. + /// The location to check. + /// The tile position to check. + /// Returns an instance or null. + /// Shipping bin logic from , garbage can logic from . + public IAutomatable GetForTile(GameLocation location, in Vector2 tile) + { + // shipping bin + if (this.AutomateShippingBin && location is Farm farm && (int)tile.X == this.ShippingBinArea.X && (int)tile.Y == this.ShippingBinArea.Y) + { + return new ShippingBinMachine(farm, this.ShippingBinArea); + } + + // garbage can + if (location is Town town) + { + string action = town.doesTileHaveProperty((int)tile.X, (int)tile.Y, "Action", "Buildings"); + if (!string.IsNullOrWhiteSpace(action) && action.StartsWith("Garbage ") && int.TryParse(action.Split(' ')[1], out int trashCanIndex)) + return new TrashCanMachine(town, tile, trashCanIndex, this.Reflection); + } + + return null; + } + + + /********* + ** Private methods + *********/ + /// Get whether a given object should be treated as a connector. + /// The object type. + /// The object iD. + private bool IsConnector(ObjectType type, int id) + { + return + this.Connectors.Count != 0 + && this.Connectors.TryGetValue(type, out HashSet ids) + && ids.Contains(id); + } + + /// Get the object ID for a given object. + /// The object instance. + private int GetItemID(SObject obj) + { + // get object ID from fence ID + if (obj is Fence fence) + { + if (fence.isGate.Value) + return 325; + switch (fence.whichType.Value) + { + case Fence.wood: + return 322; + case Fence.stone: + return 323; + case Fence.steel: + return 324; + case Fence.gold: + return 298; + } + } + + // else obj ID + return obj.ParentSheetIndex; + } + } +} diff --git a/Mods/Automate/Automate/Framework/BaseMachine.cs b/Mods/Automate/Automate/Framework/BaseMachine.cs new file mode 100644 index 00000000..485b4ade --- /dev/null +++ b/Mods/Automate/Automate/Framework/BaseMachine.cs @@ -0,0 +1,90 @@ +using Microsoft.Xna.Framework; +using StardewValley; +using StardewValley.Buildings; + +namespace Pathoschild.Stardew.Automate.Framework +{ + /// The base implementation for a machine. + internal abstract class BaseMachine : IMachine + { + /********* + ** Accessors + *********/ + /// A unique ID for the machine type. + /// This value should be identical for two machines if they have the exact same behavior and input logic. For example, if one machine in a group can't process input due to missing items, Automate will skip any other empty machines of that type in the same group since it assumes they need the same inputs. + public string MachineTypeID { get; protected set; } + + /// The location which contains the machine. + public GameLocation Location { get; } + + /// The tile area covered by the machine. + public Rectangle TileArea { get; } + + + /********* + ** Public methods + *********/ + /// Get the machine's processing state. + public abstract MachineState GetState(); + + /// Get the output item. + public abstract ITrackedStack GetOutput(); + + /// Provide input to the machine. + /// The available items. + /// Returns whether the machine started processing an item. + public abstract bool SetInput(IStorage input); + + + /********* + ** Protected methods + *********/ + /// Construct an instance. + /// The machine's in-game location. + /// The tile area covered by the machine. + protected BaseMachine(GameLocation location, in Rectangle tileArea) + { + this.MachineTypeID = this.GetType().FullName; + this.Location = location; + this.TileArea = tileArea; + } + + /// Get the tile area for a building. + /// The building. + protected static Rectangle GetTileAreaFor(Building building) + { + return new Rectangle(building.tileX.Value, building.tileY.Value, building.tilesWide.Value, building.tilesHigh.Value); + } + + /// Get the tile area for a placed object. + /// The tile position. + protected static Rectangle GetTileAreaFor(in Vector2 tile) + { + return new Rectangle((int)tile.X, (int)tile.Y, 1, 1); + } + } + + /// The base implementation for a machine. + internal abstract class BaseMachine : BaseMachine + { + /********* + ** Fields + *********/ + /// The underlying entity automated by this machine. This is only stored for the machine instance, and can be null if not applicable. + protected TMachine Machine { get; } + + + /********* + ** Protected methods + *********/ + /// Construct an instance. + /// The underlying entity automated by this machine. This is only stored for the machine instance, and can be null if not applicable. + /// The machine's in-game location. + /// The tile area covered by the machine. + protected BaseMachine(TMachine machine, GameLocation location, in Rectangle tileArea) + : base(location, tileArea) + { + this.Machine = machine; + } + } +} diff --git a/Mods/Automate/Automate/Framework/Connector.cs b/Mods/Automate/Automate/Framework/Connector.cs new file mode 100644 index 00000000..d92b51ab --- /dev/null +++ b/Mods/Automate/Automate/Framework/Connector.cs @@ -0,0 +1,37 @@ +using Microsoft.Xna.Framework; +using StardewValley; + +namespace Pathoschild.Stardew.Automate.Framework +{ + /// An entity which connects machines and chests in a machine group, but otherwise has no logic of its own. + internal class Connector : IAutomatable + { + /********* + ** Accessors + *********/ + /// The location which contains the machine. + public GameLocation Location { get; } + + /// The tile area covered by the machine. + public Rectangle TileArea { get; } + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The location which contains the machine. + /// The tile area covered by the machine. + public Connector(GameLocation location, Rectangle tileArea) + { + this.Location = location; + this.TileArea = tileArea; + } + + /// Construct an instance. + /// The location which contains the machine. + /// The tile covered by the machine. + public Connector(GameLocation location, Vector2 tile) + : this(location, new Rectangle((int)tile.X, (int)tile.Y, 1, 1)) { } + } +} diff --git a/Mods/Automate/Automate/Framework/Consumable.cs b/Mods/Automate/Automate/Framework/Consumable.cs new file mode 100644 index 00000000..d2135e57 --- /dev/null +++ b/Mods/Automate/Automate/Framework/Consumable.cs @@ -0,0 +1,50 @@ +using StardewValley; + +namespace Pathoschild.Stardew.Automate.Framework +{ + /// An ingredient stack (or stacks) which can be consumed by a machine. + internal class Consumable : IConsumable + { + /********* + ** Accessors + *********/ + /// The items available to consumable. + public ITrackedStack Consumables { get; } + + /// A sample item for comparison. + /// This should not be a reference to the original stack. + public Item Sample => this.Consumables.Sample; + + /// The number of items needed for the recipe. + public int CountNeeded { get; } + + /// Whether the consumables needed for this requirement are ready. + public bool IsMet { get; } + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The matching items available to consume. + /// The number of items needed for the recipe. + public Consumable(ITrackedStack consumables, int countNeeded) + { + this.Consumables = consumables; + this.CountNeeded = countNeeded; + this.IsMet = consumables.Count >= countNeeded; + } + + /// Remove the needed number of this item from the stack. + public void Reduce() + { + this.Consumables.Reduce(this.CountNeeded); + } + + /// Remove the needed number of this item from the stack and return a new stack matching the count. + public Item Take() + { + return this.Consumables.Take(this.CountNeeded); + } + } +} diff --git a/Mods/Automate/Automate/Framework/GenericObjectMachine.cs b/Mods/Automate/Automate/Framework/GenericObjectMachine.cs new file mode 100644 index 00000000..0992709f --- /dev/null +++ b/Mods/Automate/Automate/Framework/GenericObjectMachine.cs @@ -0,0 +1,63 @@ +using Microsoft.Xna.Framework; +using StardewValley; +using SObject = StardewValley.Object; + +namespace Pathoschild.Stardew.Automate.Framework +{ + /// A generic machine instance. + internal abstract class GenericObjectMachine : BaseMachine where TMachine : SObject + { + /********* + ** Public methods + *********/ + /// Get the machine's processing state. + public override MachineState GetState() + { + if (this.Machine.heldObject.Value == null) + return MachineState.Empty; + + return this.Machine.readyForHarvest.Value + ? MachineState.Done + : MachineState.Processing; + } + + /// Get the output item. + public override ITrackedStack GetOutput() + { + return new TrackedItem(this.Machine.heldObject.Value, onEmpty: this.GenericReset); + } + + + /********* + ** Protected methods + *********/ + /// Construct an instance. + /// The underlying machine. + /// The in-game location. + /// The tile covered by the machine. + protected GenericObjectMachine(TMachine machine, GameLocation location, Vector2 tile) + : base(machine, location, BaseMachine.GetTileAreaFor(tile)) { } + + /// Reset the machine so it's ready to accept a new input. + /// The output item that was taken. + protected void GenericReset(Item item) + { + this.Machine.heldObject.Value = null; + this.Machine.readyForHarvest.Value = false; + } + + /// Generic logic to pull items from storage based on the given recipes. + /// The available items. + /// The recipes to match. + protected bool GenericPullRecipe(IStorage storage, IRecipe[] recipes) + { + if (storage.TryGetIngredient(recipes, out IConsumable consumable, out IRecipe recipe)) + { + this.Machine.heldObject.Value = recipe.Output(consumable.Take()); + this.Machine.MinutesUntilReady = recipe.Minutes; + return true; + } + return false; + } + } +} diff --git a/Mods/Automate/Automate/Framework/MachineGroup.cs b/Mods/Automate/Automate/Framework/MachineGroup.cs new file mode 100644 index 00000000..2267974c --- /dev/null +++ b/Mods/Automate/Automate/Framework/MachineGroup.cs @@ -0,0 +1,91 @@ +using System.Collections.Generic; +using System.Linq; +using Microsoft.Xna.Framework; +using StardewValley; + +namespace Pathoschild.Stardew.Automate.Framework +{ + /// A collection of machines and storage which work as one unit. + internal class MachineGroup + { + /********* + ** Accessors + *********/ + /// The location containing the group. + public GameLocation Location { get; } + + /// The machines in the group. + public IMachine[] Machines { get; } + + /// The containers in the group. + public IContainer[] Containers { get; } + + /// The storage manager for the group. + public IStorage StorageManager { get; } + + /// The tiles comprising the group. + public Vector2[] Tiles { get; } + + /// Whether the group has the minimum requirements to enable internal automation (i.e., at least one chest and one machine). + public bool HasInternalAutomation => this.Machines.Length > 0 && this.Containers.Length > 0; + + + /********* + ** Public methods + *********/ + /// Create an instance. + /// The location containing the group. + /// The machines in the group. + /// The containers in the group. + /// The tiles comprising the group. + public MachineGroup(GameLocation location, IMachine[] machines, IContainer[] containers, Vector2[] tiles) + { + this.Location = location; + this.Machines = machines; + this.Containers = containers; + this.Tiles = tiles; + this.StorageManager = new StorageManager(containers); + } + + /// Automate the machines inside the group. + public void Automate() + { + // get machines ready for input/output + IList outputReady = new List(); + IList inputReady = new List(); + foreach (IMachine machine in this.Machines) + { + switch (machine.GetState()) + { + case MachineState.Done: + outputReady.Add(machine); + break; + + case MachineState.Empty: + inputReady.Add(machine); + break; + } + } + if (!outputReady.Any() && !inputReady.Any()) + return; + + // process output + foreach (IMachine machine in outputReady) + { + if (this.StorageManager.TryPush(machine.GetOutput()) && machine.GetState() == MachineState.Empty) + inputReady.Add(machine); + } + + // process input + HashSet ignoreMachines = new HashSet(); + foreach (IMachine machine in inputReady) + { + if (ignoreMachines.Contains(machine.MachineTypeID)) + continue; + + if (!machine.SetInput(this.StorageManager)) + ignoreMachines.Add(machine.MachineTypeID); // if the machine can't process available input, no need to ask every instance of its type + } + } + } +} diff --git a/Mods/Automate/Automate/Framework/MachineGroupBuilder.cs b/Mods/Automate/Automate/Framework/MachineGroupBuilder.cs new file mode 100644 index 00000000..6c62c42e --- /dev/null +++ b/Mods/Automate/Automate/Framework/MachineGroupBuilder.cs @@ -0,0 +1,90 @@ +using System.Collections.Generic; +using System.Linq; +using Microsoft.Xna.Framework; +using Pathoschild.Stardew.Common; +using StardewValley; + +namespace Pathoschild.Stardew.Automate.Framework +{ + /// Handles logic for building a . + internal class MachineGroupBuilder + { + /********* + ** Fields + *********/ + /// The location containing the group. + private readonly GameLocation Location; + + /// The machines in the group. + private readonly HashSet Machines = new HashSet(); + + /// The containers in the group. + private readonly HashSet Containers = new HashSet(); + + /// The tiles comprising the group. + private readonly HashSet Tiles = new HashSet(); + + + /********* + ** Accessors + *********/ + /// The tile areas added to the machine group since the queue was last cleared. + internal IList NewTileAreas { get; } = new List(); + + + /********* + ** Public methods + *********/ + /// Create an instance. + /// The location containing the group. + public MachineGroupBuilder(GameLocation location) + { + this.Location = location; + } + + /// Add a machine to the group. + /// The machine to add. + public void Add(IMachine machine) + { + this.Machines.Add(machine); + this.Add(machine.TileArea); + } + + /// Add a container to the group. + /// The container to add. + public void Add(IContainer container) + { + this.Containers.Add(container); + this.Add(container.TileArea); + } + + /// Add connector tiles to the group. + /// The tile area to add. + public void Add(Rectangle tileArea) + { + foreach (Vector2 tile in tileArea.GetTiles()) + this.Tiles.Add(tile); + this.NewTileAreas.Add(tileArea); + } + + /// Get whether any tiles were added to the builder. + public bool HasTiles() + { + return this.Tiles.Count > 0; + } + + /// Create a group from the saved data. + public MachineGroup Build() + { + return new MachineGroup(this.Location, this.Machines.ToArray(), this.Containers.ToArray(), this.Tiles.ToArray()); + } + + /// Clear the saved data. + public void Reset() + { + this.Machines.Clear(); + this.Containers.Clear(); + this.Tiles.Clear(); + } + } +} diff --git a/Mods/Automate/Automate/Framework/MachineGroupFactory.cs b/Mods/Automate/Automate/Framework/MachineGroupFactory.cs new file mode 100644 index 00000000..f8f8f493 --- /dev/null +++ b/Mods/Automate/Automate/Framework/MachineGroupFactory.cs @@ -0,0 +1,176 @@ +using System.Collections.Generic; +using System.Linq; +using Microsoft.Xna.Framework; +using Pathoschild.Stardew.Automate.Framework.Storage; +using Pathoschild.Stardew.Common; +using StardewValley; +using StardewValley.Buildings; +using StardewValley.Locations; +using StardewValley.TerrainFeatures; +using SObject = StardewValley.Object; + +namespace Pathoschild.Stardew.Automate.Framework +{ + /// Constructs machine groups. + internal class MachineGroupFactory + { + /********* + ** Fields + *********/ + /// The automation factories which construct machines, containers, and connectors. + private readonly IList AutomationFactories = new List(); + + + /********* + ** Public methods + *********/ + /// Add an automation factory. + /// An automation factory which construct machines, containers, and connectors. + public void Add(IAutomationFactory factory) + { + this.AutomationFactories.Add(factory); + } + + /// Get all machine groups in a location. + /// The location to search. + public IEnumerable GetMachineGroups(GameLocation location) + { + MachineGroupBuilder builder = new MachineGroupBuilder(location); + ISet visited = new HashSet(); + foreach (Vector2 tile in location.GetTiles()) + { + this.FloodFillGroup(builder, location, tile, visited); + if (builder.HasTiles()) + { + yield return builder.Build(); + builder.Reset(); + } + } + } + + + /********* + ** Private methods + *********/ + /// Extend the given machine group to include all machines and containers connected to the given tile, if any. + /// The machine group to extend. + /// The location to search. + /// The first tile to check. + /// A lookup of visited tiles. + private void FloodFillGroup(MachineGroupBuilder machineGroup, GameLocation location, in Vector2 origin, ISet visited) + { + // skip if already visited + if (visited.Contains(origin)) + return; + + // flood-fill connected machines & containers + Queue queue = new Queue(); + queue.Enqueue(origin); + while (queue.Any()) + { + // get tile + Vector2 tile = queue.Dequeue(); + if (!visited.Add(tile)) + continue; + + // add machines, containers, or connectors which covers this tile + if (this.TryAddEntity(machineGroup, location, tile)) + { + foreach (Rectangle tileArea in machineGroup.NewTileAreas) + { + // mark visited + foreach (Vector2 cur in tileArea.GetTiles()) + visited.Add(cur); + + // connect entities on surrounding tiles + foreach (Vector2 next in tileArea.GetSurroundingTiles()) + { + if (!visited.Contains(next)) + queue.Enqueue(next); + } + } + machineGroup.NewTileAreas.Clear(); + } + } + } + + /// Add any machine, container, or connector on the given tile to the machine group. + /// The machine group to extend. + /// The location to search. + /// The tile to search. + private bool TryAddEntity(MachineGroupBuilder group, GameLocation location, in Vector2 tile) + { + switch (this.GetEntity(location, tile)) + { + case IMachine machine: + group.Add(machine); + return true; + + case IContainer container: + if (!container.ShouldIgnore()) + { + group.Add(container); + return true; + } + return false; + + case IAutomatable connector: + group.Add(connector.TileArea); + return true; + + default: + return false; + } + } + + /// Get a machine, container, or connector from the given tile, if any. + /// The location to search. + /// The tile to search. + private IAutomatable GetEntity(GameLocation location, Vector2 tile) + { + foreach (IAutomationFactory factory in this.AutomationFactories) + { + // from object + if (location.objects.TryGetValue(tile, out SObject obj)) + { + IAutomatable entity = factory.GetFor(obj, location, tile); + if (entity != null) + return entity; + } + + // from terrain feature + if (location.terrainFeatures.TryGetValue(tile, out TerrainFeature feature)) + { + IAutomatable entity = factory.GetFor(feature, location, tile); + if (entity != null) + return entity; + } + + // building machine + if (location is BuildableGameLocation buildableLocation) + { + foreach (Building building in buildableLocation.buildings) + { + Rectangle tileArea = new Rectangle(building.tileX.Value, building.tileY.Value, building.tilesWide.Value, building.tilesHigh.Value); + if (tileArea.Contains((int)tile.X, (int)tile.Y)) + { + IAutomatable entity = factory.GetFor(building, buildableLocation, tile); + if (entity != null) + return entity; + } + } + } + + // from tile position + { + IAutomatable entity = factory.GetForTile(location, tile); + if (entity != null) + return entity; + } + } + + // none found + return null; + } + } +} diff --git a/Mods/Automate/Automate/Framework/Machines/Buildings/JunimoHutMachine.cs b/Mods/Automate/Automate/Framework/Machines/Buildings/JunimoHutMachine.cs new file mode 100644 index 00000000..9dbc845f --- /dev/null +++ b/Mods/Automate/Automate/Framework/Machines/Buildings/JunimoHutMachine.cs @@ -0,0 +1,88 @@ +using System.Collections.Generic; +using System.Linq; +using StardewValley; +using StardewValley.Buildings; +using StardewValley.Objects; +using SObject = StardewValley.Object; + +namespace Pathoschild.Stardew.Automate.Framework.Machines.Buildings +{ + /// A Junimo hut machine that accepts input and provides output. + internal class JunimoHutMachine : BaseMachine + { + /********* + ** Fields + *********/ + /// Whether seeds should be ignored when selecting output. + private readonly bool IgnoreSeedOutput; + + /// The Junimo hut's output chest. + private Chest Output => this.Machine.output.Value; + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The underlying Junimo hut. + /// The location which contains the machine. + /// Whether seeds should be ignored when selecting output. + public JunimoHutMachine(JunimoHut hut, GameLocation location, bool ignoreSeedOutput) + : base(hut, location, BaseMachine.GetTileAreaFor(hut)) + { + this.IgnoreSeedOutput = ignoreSeedOutput; + } + + /// Get the machine's processing state. + public override MachineState GetState() + { + if (this.Output.items.Any(item => item != null)) + return MachineState.Done; + return MachineState.Processing; + } + + /// Get the machine output. + public override ITrackedStack GetOutput() + { + IList inventory = this.Output.items; + return new TrackedItem(inventory.FirstOrDefault(item => item != null), onEmpty: this.OnOutputTaken); + } + + /// Provide input to the machine. + /// The available items. + /// Returns whether the machine started processing an item. + public override bool SetInput(IStorage input) + { + return false; // no input + } + + + /********* + ** Private methods + *********/ + /// Remove an output item once it's been taken. + /// The removed item. + private void OnOutputTaken(Item item) + { + this.Output.clearNulls(); + this.Output.items.Remove(item); + } + + /// Get the next output item. + private Item GetNextOutput() + { + foreach (Item item in this.Output.items) + { + if (item == null) + continue; + + if (this.IgnoreSeedOutput && (item as SObject)?.Category == SObject.SeedsCategory) + continue; + + return item; + } + + return null; + } + } +} diff --git a/Mods/Automate/Automate/Framework/Machines/Buildings/MillMachine.cs b/Mods/Automate/Automate/Framework/Machines/Buildings/MillMachine.cs new file mode 100644 index 00000000..59936786 --- /dev/null +++ b/Mods/Automate/Automate/Framework/Machines/Buildings/MillMachine.cs @@ -0,0 +1,173 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using StardewValley; +using StardewValley.Buildings; +using StardewValley.Objects; +using SObject = StardewValley.Object; + +namespace Pathoschild.Stardew.Automate.Framework.Machines.Buildings +{ + /// A mill machine that accepts input and provides output. + internal class MillMachine : BaseMachine + { + /********* + ** Fields + *********/ + /// The mill's input chest. + private Chest Input => this.Machine.input.Value; + + /// The mill's output chest. + private Chest Output => this.Machine.output.Value; + + /// The maximum input stack size to allow per item ID, if different from . + private readonly IDictionary MaxInputStackSize; + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The underlying mill. + /// The location which contains the machine. + public MillMachine(Mill mill, GameLocation location) + : base(mill, location, BaseMachine.GetTileAreaFor(mill)) + { + this.MaxInputStackSize = new Dictionary + { + [284] = new SObject(284, 1).maximumStackSize() / 3 // beet => 3 sugar (reduce stack to avoid overfilling output) + }; + } + + /// Get the machine's processing state. + public override MachineState GetState() + { + if (this.Output.items.Any(item => item != null)) + return MachineState.Done; + return this.InputFull() + ? MachineState.Processing + : MachineState.Empty; // 'empty' insofar as it will accept more input, not necessarily empty + } + + /// Get the output item. + public override ITrackedStack GetOutput() + { + IList inventory = this.Output.items; + return new TrackedItem(inventory.FirstOrDefault(item => item != null), onEmpty: this.OnOutputTaken); + } + + /// Provide input to the machine. + /// The available items. + /// Returns whether the machine started processing an item. + public override bool SetInput(IStorage input) + { + if (this.InputFull()) + return false; + + // fill input with wheat (262) and beets (284) + bool anyPulled = false; + foreach (ITrackedStack stack in input.GetItems().Where(i => i.Sample.ParentSheetIndex == 262 || i.Sample.ParentSheetIndex == 284)) + { + // add item + bool anyAdded = this.TryAddInput(stack); + if (!anyAdded) + continue; + anyPulled = true; + + // stop if full + if (this.InputFull()) + return true; + } + + return anyPulled; + } + + + /********* + ** Private methods + *********/ + /// Try to add an item to the input queue, and adjust its stack size accordingly. + /// The item stack to add. + /// Returns whether any items were taken from the stack. + private bool TryAddInput(ITrackedStack item) + { + // nothing to add + if (item.Count <= 0) + return false; + + // clean up input bin + this.Input.clearNulls(); + + // try adding to input + int originalSize = item.Count; + IList slots = this.Input.items; + int maxStackSize = this.GetMaxInputStackSize(item.Sample); + for (int i = 0; i < Chest.capacity; i++) + { + // done + if (item.Count <= 0) + break; + + // add to existing slot + if (slots.Count > i) + { + Item slot = slots[i]; + if (item.Sample.canStackWith(slot) && slot.Stack < maxStackSize) + { + int maxToAdd = Math.Min(item.Count, maxStackSize - slot.Stack); // the most items we can add to the stack (in theory) + int actualAdded = maxToAdd - slot.addToStack(maxToAdd); // how many items were actually added to the stack + item.Reduce(actualAdded); + } + continue; + } + + // add to new slot + slots.Add(item.Take(Math.Min(item.Count, maxStackSize))); + } + + return item.Count < originalSize; + } + + /// Get whether the mill's input bin is full. + private bool InputFull() + { + var slots = this.Input.items; + + // free slots + if (slots.Count < Chest.capacity) + return false; + + // free space in stacks + foreach (Item slot in slots) + { + if (slot == null || slot.Stack < this.GetMaxInputStackSize(slot)) + return false; + } + return true; + } + + + /********* + ** Private methods + *********/ + /// Remove an output item once it's been taken. + /// The removed item. + private void OnOutputTaken(Item item) + { + this.Output.clearNulls(); + this.Output.items.Remove(item); + } + + /// Get the maximum input stack size to allow for an item. + /// The input item to check. + private int GetMaxInputStackSize(Item item) + { + if (item == null) + return 0; + + return this.MaxInputStackSize.TryGetValue(item.ParentSheetIndex, out int max) + ? max + : item.maximumStackSize(); + } + } +} diff --git a/Mods/Automate/Automate/Framework/Machines/Buildings/ShippingBinMachine.cs b/Mods/Automate/Automate/Framework/Machines/Buildings/ShippingBinMachine.cs new file mode 100644 index 00000000..e31eccd9 --- /dev/null +++ b/Mods/Automate/Automate/Framework/Machines/Buildings/ShippingBinMachine.cs @@ -0,0 +1,70 @@ +using System.Linq; +using Microsoft.Xna.Framework; +using StardewValley; +using StardewValley.Buildings; +using SObject = StardewValley.Object; + +namespace Pathoschild.Stardew.Automate.Framework.Machines.Buildings +{ + /// A shipping bin that accepts input and provides output. + internal class ShippingBinMachine : BaseMachine + { + /********* + ** Fields + *********/ + /// The farm to automate. + private readonly Farm Farm; + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The farm containing the shipping bin. + /// The tile area covered by the machine. + public ShippingBinMachine(Farm farm, Rectangle tileArea) + : base(farm, tileArea) + { + this.Farm = farm; + } + + /// Construct an instance. + /// The constructed shipping bin. + /// The location which contains the machine. + /// The farm which has the shipping bin data. + public ShippingBinMachine(ShippingBin bin, GameLocation location, Farm farm) + : base(location, BaseMachine.GetTileAreaFor(bin)) + { + this.Farm = farm; + } + + /// Get the machine's processing state. + public override MachineState GetState() + { + return MachineState.Empty; // always accepts items + } + + /// Get the output item. + public override ITrackedStack GetOutput() + { + return null; // no output + } + + /// Provide input to the machine. + /// The available items. + /// Returns whether the machine started processing an item. + public override bool SetInput(IStorage input) + { + ITrackedStack tracker = input.GetItems().Where(p => p.Sample is SObject obj && obj.canBeShipped()).Take(1).FirstOrDefault(); + if (tracker != null) + { + SObject item = (SObject)tracker.Take(tracker.Count); + this.Farm.shippingBin.Add(item); + this.Farm.lastItemShipped = item; + this.Farm.showShipment(item, false); + return true; + } + return false; + } + } +} diff --git a/Mods/Automate/Automate/Framework/Machines/Objects/AutoGrabberMachine.cs b/Mods/Automate/Automate/Framework/Machines/Objects/AutoGrabberMachine.cs new file mode 100644 index 00000000..623d11f6 --- /dev/null +++ b/Mods/Automate/Automate/Framework/Machines/Objects/AutoGrabberMachine.cs @@ -0,0 +1,92 @@ +using Microsoft.Xna.Framework; +using StardewValley; +using StardewValley.Objects; +using SObject = StardewValley.Object; + +namespace Pathoschild.Stardew.Automate.Framework.Machines.Objects +{ + /// An auto-grabber that provides output. + /// See the game's default logic in and . + internal class AutoGrabberMachine : GenericObjectMachine + { + /********* + ** Fields + *********/ + /// Whether seeds should be ignored when selecting output. + private readonly bool IgnoreSeedOutput; + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The underlying machine. + /// The in-game location. + /// The tile covered by the machine. + /// Whether seeds should be ignored when selecting output. + public AutoGrabberMachine(SObject machine, GameLocation location, Vector2 tile, bool ignoreSeedOutput) + : base(machine, location, tile) + { + this.IgnoreSeedOutput = ignoreSeedOutput; + } + + /// Get the machine's processing state. + public override MachineState GetState() + { + return this.Machine.heldObject.Value is Chest output && this.GetNextOutput() != null + ? MachineState.Done + : MachineState.Processing; + } + + /// Get the output item. + public override ITrackedStack GetOutput() + { + Item next = this.GetNextOutput(); + return new TrackedItem(next, onEmpty: this.OnOutputTaken); + } + + /// Provide input to the machine. + /// The available items. + /// Returns whether the machine started processing an item. + public override bool SetInput(IStorage input) + { + return false; + } + + + /********* + ** Private methods + *********/ + /// Get the output chest. + private Chest GetOutputChest() + { + return (Chest)this.Machine.heldObject.Value; + } + + /// Remove an output item once it's been taken. + /// The removed item. + private void OnOutputTaken(Item item) + { + Chest output = this.GetOutputChest(); + output.clearNulls(); + output.items.Remove(item); + } + + /// Get the next output item. + private Item GetNextOutput() + { + foreach (Item item in this.GetOutputChest().items) + { + if (item == null) + continue; + + if (this.IgnoreSeedOutput && (item as SObject)?.Category == SObject.SeedsCategory) + continue; + + return item; + } + + return null; + } + } +} diff --git a/Mods/Automate/Automate/Framework/Machines/Objects/BeeHouseMachine.cs b/Mods/Automate/Automate/Framework/Machines/Objects/BeeHouseMachine.cs new file mode 100644 index 00000000..0a187d0c --- /dev/null +++ b/Mods/Automate/Automate/Framework/Machines/Objects/BeeHouseMachine.cs @@ -0,0 +1,103 @@ +using System; +using System.Collections.Generic; +using Microsoft.Xna.Framework; +using StardewValley; +using SObject = StardewValley.Object; + +namespace Pathoschild.Stardew.Automate.Framework.Machines.Objects +{ + /// A bee house that accepts input and provides output. + /// See the game's machine logic in , , and . + internal class BeeHouseMachine : GenericObjectMachine + { + /********* + ** Fields + *********/ + /// The honey types produced by this beehouse indexed by input ID. + private readonly IDictionary HoneyTypes = new Dictionary + { + [376] = SObject.HoneyType.Poppy, + [591] = SObject.HoneyType.Tulip, + [593] = SObject.HoneyType.SummerSpangle, + [595] = SObject.HoneyType.FairyRose, + [597] = SObject.HoneyType.BlueJazz + }; + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The underlying machine. + /// The location containing the machine. + /// The tile covered by the machine. + public BeeHouseMachine(SObject machine, GameLocation location, Vector2 tile) + : base(machine, location, tile) { } + + /// Get the machine's processing state. + public override MachineState GetState() + { + return Game1.currentSeason == "winter" + ? MachineState.Disabled + : base.GetState(); + } + + /// Get the output item. + public override ITrackedStack GetOutput() + { + // get raw output + SObject output = this.Machine.heldObject.Value; + if (output == null) + return null; + + // get flower data + SObject.HoneyType type = SObject.HoneyType.Wild; + string prefix = type.ToString(); + int addedPrice = 0; + Crop flower = Utility.findCloseFlower(this.Location, this.Machine.TileLocation); + if (flower != null) + { + string[] flowerData = Game1.objectInformation[flower.indexOfHarvest.Value].Split('/'); + prefix = flowerData[0]; + addedPrice = Convert.ToInt32(flowerData[1]) * 2; + if (!this.HoneyTypes.TryGetValue(flower.indexOfHarvest.Value, out type)) + type = SObject.HoneyType.Wild; + } + + // build object + SObject result = new SObject(output.ParentSheetIndex, output.Stack) + { + name = $"{prefix} Honey", + Price = output.Price + addedPrice + }; + result.honeyType.Value = type; + + // yield + return new TrackedItem(result, onEmpty: this.Reset); + } + + /// Provide input to the machine. + /// The available items. + /// Returns whether the machine started processing an item. + public override bool SetInput(IStorage input) + { + return false; // no input needed + } + + + /********* + ** Private methods + *********/ + /// Reset the machine so it's ready to accept a new input. + /// The output item that was taken. + private void Reset(Item item) + { + SObject machine = this.Machine; + + machine.heldObject.Value = new SObject(Vector2.Zero, 340, null, false, true, false, false); + machine.MinutesUntilReady = 2400 - Game1.timeOfDay + 4320; + machine.readyForHarvest.Value = false; + machine.showNextIndex.Value = false; + } + } +} diff --git a/Mods/Automate/Automate/Framework/Machines/Objects/CaskMachine.cs b/Mods/Automate/Automate/Framework/Machines/Objects/CaskMachine.cs new file mode 100644 index 00000000..654c0f04 --- /dev/null +++ b/Mods/Automate/Automate/Framework/Machines/Objects/CaskMachine.cs @@ -0,0 +1,99 @@ +using System.Collections.Generic; +using Microsoft.Xna.Framework; +using StardewValley; +using StardewValley.Objects; +using SObject = StardewValley.Object; + +namespace Pathoschild.Stardew.Automate.Framework.Machines.Objects +{ + /// A cask that accepts input and provides output. + internal class CaskMachine : GenericObjectMachine + { + /********* + ** Fields + *********/ + /// The items which can be aged in a cask with their aging rates. + private readonly IDictionary AgingRates = new Dictionary + { + [424] = 4, // cheese + [426] = 4, // goat cheese + [459] = 2, // mead + [303] = 1.66f, // pale ale + [346] = 2, // beer + [348] = 1 // wine + }; + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The underlying machine. + /// The location containing the machine. + /// The tile covered by the machine. + public CaskMachine(Cask machine, GameLocation location, Vector2 tile) + : base(machine, location, tile) { } + + /// Get the machine's processing state. + public override MachineState GetState() + { + SObject heldObject = this.Machine.heldObject.Value; + if (heldObject == null) + return MachineState.Empty; + + return heldObject.Quality >= 4 + ? MachineState.Done + : MachineState.Processing; + } + + /// Get the output item. + public override ITrackedStack GetOutput() + { + Cask cask = this.Machine; + SObject heldObject = this.Machine.heldObject.Value; + return new TrackedItem(heldObject.getOne(), item => + { + cask.heldObject.Value = null; + cask.MinutesUntilReady = 0; + cask.readyForHarvest.Value = false; + cask.agingRate.Value = 0; + cask.daysToMature.Value = 0; + }); + } + + /// Provide input to the machine. + /// The available items. + /// Returns whether the machine started processing an item. + public override bool SetInput(IStorage input) + { + Cask cask = this.Machine; + + if (input.TryGetIngredient(match => (match.Sample as SObject)?.Quality < 4 && this.AgingRates.ContainsKey(match.Sample.ParentSheetIndex), 1, out IConsumable consumable)) + { + SObject ingredient = (SObject)consumable.Take(); + + cask.heldObject.Value = ingredient; + cask.agingRate.Value = this.AgingRates[ingredient.ParentSheetIndex]; + cask.daysToMature.Value = 56; + cask.MinutesUntilReady = 999999; + switch (ingredient.Quality) + { + case SObject.medQuality: + cask.daysToMature.Value = 42; + break; + case SObject.highQuality: + cask.daysToMature.Value = 28; + break; + case SObject.bestQuality: + cask.daysToMature.Value = 0; + cask.MinutesUntilReady = 1; + break; + } + + return true; + } + + return false; + } + } +} diff --git a/Mods/Automate/Automate/Framework/Machines/Objects/CharcoalKilnMachine.cs b/Mods/Automate/Automate/Framework/Machines/Objects/CharcoalKilnMachine.cs new file mode 100644 index 00000000..d091cd89 --- /dev/null +++ b/Mods/Automate/Automate/Framework/Machines/Objects/CharcoalKilnMachine.cs @@ -0,0 +1,49 @@ +using Microsoft.Xna.Framework; +using StardewValley; +using SObject = StardewValley.Object; + +namespace Pathoschild.Stardew.Automate.Framework.Machines.Objects +{ + /// A charcoal kiln that accepts input and provides output. + internal class CharcoalKilnMachine : GenericObjectMachine + { + /********* + ** Fields + *********/ + /// The recipes to process. + private readonly IRecipe[] Recipes = + { + // wood => coal + new Recipe( + input: 388, + inputCount: 10, + output: input => new SObject(382, 1), + minutes: 30 + ) + }; + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The underlying machine. + /// The location containing the machine. + /// The tile covered by the machine. + public CharcoalKilnMachine(SObject machine, GameLocation location, Vector2 tile) + : base(machine, location, tile) { } + + /// Provide input to the machine. + /// The available items. + /// Returns whether the machine started processing an item. + public override bool SetInput(IStorage input) + { + if (this.GenericPullRecipe(input, this.Recipes)) + { + this.Machine.showNextIndex.Value = true; + return true; + } + return false; + } + } +} diff --git a/Mods/Automate/Automate/Framework/Machines/Objects/CheesePressMachine.cs b/Mods/Automate/Automate/Framework/Machines/Objects/CheesePressMachine.cs new file mode 100644 index 00000000..1a31be93 --- /dev/null +++ b/Mods/Automate/Automate/Framework/Machines/Objects/CheesePressMachine.cs @@ -0,0 +1,75 @@ +using Microsoft.Xna.Framework; +using StardewValley; +using SObject = StardewValley.Object; + +namespace Pathoschild.Stardew.Automate.Framework.Machines.Objects +{ + /// A cheese press that accepts input and provides output. + internal class CheesePressMachine : GenericObjectMachine + { + /********* + ** Fields + *********/ + /// The recipes processed by this machine (input => output). + private readonly IRecipe[] Recipes = + { + // goat milk => goat cheese + new Recipe( + input: 436, + inputCount: 1, + output: input => new SObject(Vector2.Zero, 426, null, false, true, false, false), + minutes: 200 + ), + + // large goat milk => gold-quality goat cheese + new Recipe( + input: 438, + inputCount: 1, + output: input => new SObject(Vector2.Zero, 426, null, false, true, false, false) { Quality = SObject.highQuality }, + minutes: 200 + ), + + // milk => cheese + new Recipe( + input: 184, + inputCount: 1, + output: input => new SObject(Vector2.Zero, 424, null, false, true, false, false), + minutes: 200 + ), + + // large milk => gold-quality cheese + new Recipe( + input: 186, + inputCount: 1, + output: input => new SObject(Vector2.Zero, 424, "Cheese (=)", false, true, false, false) { Quality = SObject.highQuality }, + minutes: 200 + ) + }; + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The underlying machine. + /// The location containing the machine. + /// The tile covered by the machine. + public CheesePressMachine(SObject machine, GameLocation location, Vector2 tile) + : base(machine, location, tile) { } + + /// Provide input to the machine. + /// The available items. + /// Returns whether the machine started processing an item. + public override bool SetInput(IStorage input) + { + if (input.TryGetIngredient(this.Recipes, out IConsumable consumable, out IRecipe recipe)) + { + this.Machine.heldObject.Value = recipe.Output(consumable.Take()); + this.Machine.MinutesUntilReady = recipe.Minutes; + return true; + } + + return false; + } + } +} diff --git a/Mods/Automate/Automate/Framework/Machines/Objects/CoopIncubatorMachine.cs b/Mods/Automate/Automate/Framework/Machines/Objects/CoopIncubatorMachine.cs new file mode 100644 index 00000000..43b3ed33 --- /dev/null +++ b/Mods/Automate/Automate/Framework/Machines/Objects/CoopIncubatorMachine.cs @@ -0,0 +1,80 @@ +using Microsoft.Xna.Framework; +using StardewValley; +using SObject = StardewValley.Object; + +namespace Pathoschild.Stardew.Automate.Framework.Machines.Objects +{ + /// A coop incubator that accepts eggs and spawns chickens. + internal class CoopIncubatorMachine : GenericObjectMachine + { + /********* + ** Fields + *********/ + /// The recipes to process. + private readonly IRecipe[] Recipes; + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The underlying machine. + /// The location containing the machine. + /// The tile covered by the machine. + public CoopIncubatorMachine(SObject machine, GameLocation location, Vector2 tile) + : base(machine, location, tile) + { + int minutesUntilReady = Game1.player.professions.Contains(2) ? 9000 : 18000; + this.Recipes = new IRecipe[] + { + // egg => chicken + new Recipe( + input: -5, + inputCount: 1, + output: item => new SObject(item.ParentSheetIndex, 1), + minutes: minutesUntilReady / 2 + ), + + // dinosaur egg => dinosaur + new Recipe( + input: 107, + inputCount: 1, + output: item => new SObject(107, 1), + minutes: minutesUntilReady + ) + }; + } + + /// Get the machine's processing state. + /// The coop incubator never produces an object output so it is never done. + public override MachineState GetState() + { + return this.Machine.heldObject.Value != null + ? MachineState.Processing + : MachineState.Empty; + } + + /// Get the output item. + /// The coop incubator never produces an object output. + public override ITrackedStack GetOutput() + { + return null; + } + + /// Provide input to the machine. + /// The available items. + /// Returns whether the machine started processing an item. + public override bool SetInput(IStorage input) + { + bool started = this.GenericPullRecipe(input, this.Recipes); + if (started) + { + int eggID = this.Machine.heldObject.Value.ParentSheetIndex; + this.Machine.ParentSheetIndex = eggID == 180 || eggID == 182 || eggID == 305 + ? this.Machine.ParentSheetIndex + 2 + : this.Machine.ParentSheetIndex + 1; + } + return started; + } + } +} diff --git a/Mods/Automate/Automate/Framework/Machines/Objects/CrabPotMachine.cs b/Mods/Automate/Automate/Framework/Machines/Objects/CrabPotMachine.cs new file mode 100644 index 00000000..87286cc9 --- /dev/null +++ b/Mods/Automate/Automate/Framework/Machines/Objects/CrabPotMachine.cs @@ -0,0 +1,131 @@ +using System; +using System.Collections.Generic; +using Microsoft.Xna.Framework; +using StardewModdingAPI; +using StardewValley; +using StardewValley.Objects; +using SFarmer = StardewValley.Farmer; +using SObject = StardewValley.Object; + +namespace Pathoschild.Stardew.Automate.Framework.Machines.Objects +{ + /// A crab pot that accepts input and provides output. + /// See the game's machine logic in and . + internal class CrabPotMachine : GenericObjectMachine + { + /********* + ** Fields + *********/ + /// Simplifies access to private game code. + private readonly IReflectionHelper Reflection; + + /// Encapsulates monitoring and logging. + private readonly IMonitor Monitor; + + /// The fish IDs for which any crabpot has logged an 'invalid fish data' error. + private static readonly ISet LoggedInvalidDataErrors = new HashSet(); + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The underlying machine. + /// The location containing the machine. + /// Simplifies access to private game code. + /// Encapsulates monitoring and logging. + /// The tile covered by the machine. + public CrabPotMachine(CrabPot machine, GameLocation location, Vector2 tile, IMonitor monitor, IReflectionHelper reflection) + : base(machine, location, tile) + { + this.Monitor = monitor; + this.Reflection = reflection; + } + + /// Get the machine's processing state. + public override MachineState GetState() + { + if (this.Machine.heldObject.Value == null) + { + bool hasBait = this.Machine.bait.Value != null || Game1.player.professions.Contains(11); // no bait needed if luremaster + return hasBait + ? MachineState.Processing + : MachineState.Empty; + } + return this.Machine.readyForHarvest.Value + ? MachineState.Done + : MachineState.Processing; + } + + /// Get the output item. + public override ITrackedStack GetOutput() + { + return new TrackedItem(this.Machine.heldObject.Value, onEmpty: this.Reset); + } + + /// Provide input to the machine. + /// The available items. + /// Returns whether the machine started processing an item. + public override bool SetInput(IStorage input) + { + // get bait + if (input.TryGetIngredient(SObject.baitCategory, 1, out IConsumable bait)) + { + this.Machine.bait.Value = (SObject)bait.Take(); + this.Reflection.GetField(this.Machine, "lidFlapping").SetValue(true); + this.Reflection.GetField(this.Machine, "lidFlapTimer").SetValue(60); + return true; + } + + return false; + } + + + /********* + ** Private methods + *********/ + /// Reset the machine so it's ready to accept a new input. + /// The output item that was taken. + /// XP and achievement logic based on . + private void Reset(Item item) + { + CrabPot pot = this.Machine; + + // add fishing XP + Game1.player.gainExperience(SFarmer.fishingSkill, 5); + + // mark fish caught for achievements and stats + IDictionary fishData = Game1.content.Load>("Data\\Fish"); + if (fishData.TryGetValue(item.ParentSheetIndex, out string fishRow)) + { + int size = 0; + try + { + string[] fields = fishRow.Split('/'); + int lowerSize = fields.Length > 5 ? Convert.ToInt32(fields[5]) : 1; + int upperSize = fields.Length > 5 ? Convert.ToInt32(fields[6]) : 10; + size = Game1.random.Next(lowerSize, upperSize + 1); + } + catch (Exception ex) + { + // The fish length stats don't affect anything, so it's not worth notifying the + // user; just log one trace message per affected fish for troubleshooting. + if (CrabPotMachine.LoggedInvalidDataErrors.Add(item.ParentSheetIndex)) + this.Monitor.Log($"The game's fish data has an invalid entry (#{item.ParentSheetIndex}: {fishData[item.ParentSheetIndex]}). Automated crabpots won't track fish length stats for that fish.\n{ex}", LogLevel.Trace); + } + + Game1.player.caughtFish(item.ParentSheetIndex, size); + } + + // reset pot + pot.readyForHarvest.Value = false; + pot.heldObject.Value = null; + pot.tileIndexToShow = 710; + pot.bait.Value = null; + this.Reflection.GetField(pot, "lidFlapping").SetValue(true); + this.Reflection.GetField(pot, "lidFlapTimer").SetValue(60f); + this.Reflection.GetField(pot, "shake").SetValue(Vector2.Zero); + this.Reflection.GetField(pot, "shakeTimer").SetValue(0f); + } + } +} diff --git a/Mods/Automate/Automate/Framework/Machines/Objects/CrystalariumMachine.cs b/Mods/Automate/Automate/Framework/Machines/Objects/CrystalariumMachine.cs new file mode 100644 index 00000000..00e68432 --- /dev/null +++ b/Mods/Automate/Automate/Framework/Machines/Objects/CrystalariumMachine.cs @@ -0,0 +1,63 @@ +using Microsoft.Xna.Framework; +using StardewModdingAPI; +using StardewValley; +using SObject = StardewValley.Object; + +namespace Pathoschild.Stardew.Automate.Framework.Machines.Objects +{ + /// A crystalarium that accepts input and provides output. + internal class CrystalariumMachine : GenericObjectMachine + { + /********* + ** Fields + *********/ + /// Simplifies access to private game code. + private readonly IReflectionHelper Reflection; + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The underlying machine. + /// The location containing the machine. + /// Simplifies access to private game code. + /// The tile covered by the machine. + public CrystalariumMachine(SObject machine, GameLocation location, Vector2 tile, IReflectionHelper reflection) + : base(machine, location, tile) + { + this.Reflection = reflection; + } + + /// Get the machine's processing state. + public override MachineState GetState() + { + if (this.Machine.heldObject.Value == null) + return MachineState.Disabled; + + return this.Machine.readyForHarvest.Value + ? MachineState.Done + : MachineState.Processing; + } + + /// Get the output item. + public override ITrackedStack GetOutput() + { + SObject machine = this.Machine; + SObject heldObject = machine.heldObject.Value; + return new TrackedItem(heldObject.getOne(), item => + { + machine.MinutesUntilReady = this.Reflection.GetMethod(machine, "getMinutesForCrystalarium").Invoke(heldObject.ParentSheetIndex); + machine.readyForHarvest.Value = false; + }); + } + + /// Provide input to the machine. + /// The available items. + /// Returns whether the machine started processing an item. + public override bool SetInput(IStorage input) + { + return false; // started manually + } + } +} diff --git a/Mods/Automate/Automate/Framework/Machines/Objects/FeedHopperMachine.cs b/Mods/Automate/Automate/Framework/Machines/Objects/FeedHopperMachine.cs new file mode 100644 index 00000000..5158f55e --- /dev/null +++ b/Mods/Automate/Automate/Framework/Machines/Objects/FeedHopperMachine.cs @@ -0,0 +1,93 @@ +using System; +using System.Linq; +using Microsoft.Xna.Framework; +using StardewValley; +using StardewValley.Buildings; +using SObject = StardewValley.Object; + +namespace Pathoschild.Stardew.Automate.Framework.Machines.Objects +{ + /// A hay hopper that accepts input and provides output. + internal class FeedHopperMachine : BaseMachine + { + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The location containing the machine. + /// The tile covered by the machine. + public FeedHopperMachine(GameLocation location, Vector2 tile) + : base(location, BaseMachine.GetTileAreaFor(tile)) { } + + /// Construct an instance. + /// The silo to automate. + /// The location containing the machine. + public FeedHopperMachine(Building silo, GameLocation location) + : base(location, BaseMachine.GetTileAreaFor(silo)) { } + + /// Get the machine's processing state. + public override MachineState GetState() + { + Farm farm = Game1.getFarm(); + return this.GetFreeSpace(farm) > 0 + ? MachineState.Empty // 'empty' insofar as it will accept more input, not necessarily empty + : MachineState.Disabled; + } + + /// Get the output item. + public override ITrackedStack GetOutput() + { + return null; + } + + /// Reset the machine so it's ready to accept a new input. + /// Whether the current output was taken. + public void Reset(bool outputTaken) + { + // not applicable + } + + /// Provide input to the machine. + /// The available items. + /// Returns whether the machine started processing an item. + public override bool SetInput(IStorage input) + { + Farm farm = Game1.getFarm(); + + // skip if full + if (this.GetFreeSpace(farm) <= 0) + return false; + + // try to add hay (178) until full + bool anyPulled = false; + foreach (ITrackedStack stack in input.GetItems().Where(p => p.Sample.ParentSheetIndex == 178)) + { + // get free space + int space = this.GetFreeSpace(farm); + if (space <= 0) + return anyPulled; + + // pull hay + int maxToAdd = Math.Min(stack.Count, space); + int added = maxToAdd - farm.tryToAddHay(maxToAdd); + stack.Reduce(added); + if (added > 0) + anyPulled = true; + } + + return anyPulled; + } + + + /********* + ** Private methods + *********/ + /// Get the amount of hay the hopper can still accept before it's full. + /// The farm to check. + /// Derived from . + private int GetFreeSpace(Farm farm) + { + return Utility.numSilos() * 240 - farm.piecesOfHay.Value; + } + } +} diff --git a/Mods/Automate/Automate/Framework/Machines/Objects/FurnaceMachine.cs b/Mods/Automate/Automate/Framework/Machines/Objects/FurnaceMachine.cs new file mode 100644 index 00000000..8caf4d7d --- /dev/null +++ b/Mods/Automate/Automate/Framework/Machines/Objects/FurnaceMachine.cs @@ -0,0 +1,92 @@ +using Microsoft.Xna.Framework; +using StardewValley; +using SObject = StardewValley.Object; + +namespace Pathoschild.Stardew.Automate.Framework.Machines.Objects +{ + /// A furnace that accepts input and provides output. + internal class FurnaceMachine : GenericObjectMachine + { + /********* + ** Fields + *********/ + /// The recipes to process. + /// Derived from . + private readonly IRecipe[] Recipes = + { + // copper => copper bar + new Recipe( + input: SObject.copper, + inputCount: 5, + output: input => new SObject(Vector2.Zero, SObject.copperBar, 1), + minutes: 30 + ), + + // iron => iron bar + new Recipe( + input: SObject.iron, + inputCount: 5, + output: input => new SObject(Vector2.Zero, SObject.ironBar, 1), + minutes: 120 + ), + + // gold => gold bar + new Recipe( + input: SObject.gold, + inputCount: 5, + output: input => new SObject(Vector2.Zero, SObject.goldBar, 1), + minutes: 300 + ), + + // iridium => iridium bar + new Recipe( + input: SObject.iridium, + inputCount: 5, + output: input => new SObject(Vector2.Zero, SObject.iridiumBar, 1), + minutes: 480 + ), + + // quartz => refined quartz + new Recipe( + input: SObject.quartzIndex, + inputCount: 1, + output: input => new SObject(Vector2.Zero, 338, 1), + minutes: 90 + ), + + // refined quartz => refined quartz + new Recipe( + input: 82, + inputCount: 1, + output: input => new SObject(Vector2.Zero, 338, 3), + minutes: 90 + ) + }; + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The underlying machine. + /// The location containing the machine. + /// The tile covered by the machine. + public FurnaceMachine(SObject machine, GameLocation location, Vector2 tile) + : base(machine, location, tile) { } + + /// Provide input to the machine. + /// The available items. + /// Returns whether the machine started processing an item. + public override bool SetInput(IStorage input) + { + if (input.TryGetIngredient(SObject.coal, 1, out IConsumable coal) && this.GenericPullRecipe(input, this.Recipes)) + { + coal.Reduce(); + this.Machine.initializeLightSource(this.Machine.TileLocation); + this.Machine.showNextIndex.Value = true; + return true; + } + return false; + } + } +} diff --git a/Mods/Automate/Automate/Framework/Machines/Objects/KegMachine.cs b/Mods/Automate/Automate/Framework/Machines/Objects/KegMachine.cs new file mode 100644 index 00000000..efc8adfc --- /dev/null +++ b/Mods/Automate/Automate/Framework/Machines/Objects/KegMachine.cs @@ -0,0 +1,103 @@ +using Microsoft.Xna.Framework; +using StardewValley; +using SObject = StardewValley.Object; + +namespace Pathoschild.Stardew.Automate.Framework.Machines.Objects +{ + /// A keg that accepts input and provides output. + /// See the game's machine logic in and . + internal class KegMachine : GenericObjectMachine + { + /********* + ** Fields + *********/ + /// The recipes to process. + private readonly IRecipe[] Recipes = + { + // honey => mead + new Recipe( + input: 340, + inputCount: 1, + output: input => new SObject(Vector2.Zero, 459, "Mead", false, true, false, false) { name = "Mead" }, + minutes: 600 + ), + + // coffee bean => coffee + new Recipe( + input: 433, + inputCount: 5, + output: input => new SObject(Vector2.Zero, 395, "Coffee", false, true, false, false) { name = "Coffee" }, + minutes: 120 + ), + + // wheat => beer + new Recipe( + input: 262, + inputCount: 1, + output: input => new SObject(Vector2.Zero, 346, "Beer", false, true, false, false) { name = "Beer" }, + minutes: 1750 + ), + + // hops => pale ale + new Recipe( + input: 304, + inputCount: 1, + output: input => new SObject(Vector2.Zero, 303, "Pale Ale", false, true, false, false) { name = "Pale Ale" }, + minutes: 2250 + ), + + // fruit => wine + new Recipe( + input: SObject.FruitsCategory, + inputCount: 1, + output: input => + { + SObject wine = new SObject(Vector2.Zero, 348, input.Name + " Wine", false, true, false, false) + { + name = input.Name + " Wine", + Price = ((SObject)input).Price * 3 + }; + wine.preserve.Value = SObject.PreserveType.Wine; + wine.preservedParentSheetIndex.Value = input.ParentSheetIndex; + return wine; + }, + minutes: 10000 + ), + new Recipe( + input: SObject.VegetableCategory, + inputCount: 1, + output: input => + { + SObject juice = new SObject(Vector2.Zero, 350, input.Name + " Juice", false, true, false, false) + { + name = input.Name + " Juice", + Price = (int)(((SObject)input).Price * 2.25) + }; + juice.preserve.Value = SObject.PreserveType.Juice; + juice.preservedParentSheetIndex.Value = input.ParentSheetIndex; + return juice; + }, + minutes: 6000 + ) + }; + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The underlying machine. + /// The location containing the machine. + /// The tile covered by the machine. + public KegMachine(SObject machine, GameLocation location, Vector2 tile) + : base(machine, location, tile) { } + + /// Provide input to the machine. + /// The available items. + /// Returns whether the machine started processing an item. + public override bool SetInput(IStorage input) + { + return this.GenericPullRecipe(input, this.Recipes); + } + } +} diff --git a/Mods/Automate/Automate/Framework/Machines/Objects/LightningRodMachine.cs b/Mods/Automate/Automate/Framework/Machines/Objects/LightningRodMachine.cs new file mode 100644 index 00000000..3f643ef4 --- /dev/null +++ b/Mods/Automate/Automate/Framework/Machines/Objects/LightningRodMachine.cs @@ -0,0 +1,35 @@ +using Microsoft.Xna.Framework; +using StardewValley; +using SObject = StardewValley.Object; + +namespace Pathoschild.Stardew.Automate.Framework.Machines.Objects +{ + /// A lightning rod that accepts input and provides output. + internal class LightningRodMachine : GenericObjectMachine + { + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The underlying machine. + /// The location containing the machine. + /// The tile covered by the machine. + public LightningRodMachine(SObject machine, GameLocation location, Vector2 tile) + : base(machine, location, tile) { } + + /// Get the output item. + public override ITrackedStack GetOutput() + { + SObject heldObject = this.Machine.heldObject.Value; + return new TrackedItem(heldObject.getOne(), onEmpty: this.GenericReset); + } + + /// Provide input to the machine. + /// The available items. + /// Returns whether the machine started processing an item. + public override bool SetInput(IStorage input) + { + return false; // no input + } + } +} diff --git a/Mods/Automate/Automate/Framework/Machines/Objects/LoomMachine.cs b/Mods/Automate/Automate/Framework/Machines/Objects/LoomMachine.cs new file mode 100644 index 00000000..4f6e6d09 --- /dev/null +++ b/Mods/Automate/Automate/Framework/Machines/Objects/LoomMachine.cs @@ -0,0 +1,56 @@ +using Microsoft.Xna.Framework; +using StardewValley; +using SObject = StardewValley.Object; + +namespace Pathoschild.Stardew.Automate.Framework.Machines.Objects +{ + /// A loom that accepts input and provides output. + internal class LoomMachine : GenericObjectMachine + { + /********* + ** Fields + *********/ + /// The recipes to process. + private readonly IRecipe[] Recipes = + { + // wool => cloth + new Recipe( + input: 440, + inputCount: 1, + output: item => new SObject(Vector2.Zero, 428, null, false, true, false, false), + minutes: 240 + ) + }; + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The underlying machine. + /// The location containing the machine. + /// The tile covered by the machine. + public LoomMachine(SObject machine, GameLocation location, Vector2 tile) + : base(machine, location, tile) { } + + /// Get the output item. + public override ITrackedStack GetOutput() + { + SObject machine = this.Machine; + return new TrackedItem(machine.heldObject.Value, item => + { + machine.heldObject.Value = null; + machine.readyForHarvest.Value = false; + machine.showNextIndex.Value = false; + }); + } + + /// Provide input to the machine. + /// The available items. + /// Returns whether the machine started processing an item. + public override bool SetInput(IStorage input) + { + return this.GenericPullRecipe(input, this.Recipes); + } + } +} diff --git a/Mods/Automate/Automate/Framework/Machines/Objects/MayonnaiseMachine.cs b/Mods/Automate/Automate/Framework/Machines/Objects/MayonnaiseMachine.cs new file mode 100644 index 00000000..70378440 --- /dev/null +++ b/Mods/Automate/Automate/Framework/Machines/Objects/MayonnaiseMachine.cs @@ -0,0 +1,86 @@ +using Microsoft.Xna.Framework; +using StardewValley; +using SObject = StardewValley.Object; + +namespace Pathoschild.Stardew.Automate.Framework.Machines.Objects +{ + /// A mayonnaise that accepts input and provides output. + internal class MayonnaiseMachine : GenericObjectMachine + { + /********* + ** Fields + *********/ + /// The recipes to process. + private readonly IRecipe[] Recipes = + { + // void egg => void mayonnaise + new Recipe( + input: 305, + inputCount: 1, + output: input => new SObject(Vector2.Zero, 308, null, false, true, false, false), + minutes: 180 + ), + + // duck egg => duck mayonnaise + new Recipe( + input: 442, + inputCount: 1, + output: input => new SObject(Vector2.Zero, 307, null, false, true, false, false), + minutes: 180 + ), + + // white/brown egg => normal mayonnaise + new Recipe( + input: 176, + inputCount: 1, + output: input => new SObject(Vector2.Zero, 306, null, false, true, false, false), + minutes: 180 + ), + new Recipe( + input: 180, + inputCount: 1, + output: input => new SObject(Vector2.Zero, 306, null, false, true, false, false), + minutes: 180 + ), + + // dinosaur or large white/brown egg => gold-quality mayonnaise + new Recipe( + input: 107, + inputCount: 1, + output: input => new SObject(Vector2.Zero, 306, null, false, true, false, false) { Quality = SObject.highQuality }, + minutes: 180 + ), + new Recipe( + input: 174, + inputCount: 1, + output: input => new SObject(Vector2.Zero, 306, null, false, true, false, false) { Quality = SObject.highQuality }, + minutes: 180 + ), + new Recipe( + input: 182, + inputCount: 1, + output: input => new SObject(Vector2.Zero, 306, null, false, true, false, false) { Quality = SObject.highQuality }, + minutes: 180 + ) + }; + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The underlying machine. + /// The location containing the machine. + /// The tile covered by the machine. + public MayonnaiseMachine(SObject machine, GameLocation location, Vector2 tile) + : base(machine, location, tile) { } + + /// Provide input to the machine. + /// The available items. + /// Returns whether the machine started processing an item. + public override bool SetInput(IStorage input) + { + return this.GenericPullRecipe(input, this.Recipes); + } + } +} diff --git a/Mods/Automate/Automate/Framework/Machines/Objects/MushroomBoxMachine.cs b/Mods/Automate/Automate/Framework/Machines/Objects/MushroomBoxMachine.cs new file mode 100644 index 00000000..6f2adb2f --- /dev/null +++ b/Mods/Automate/Automate/Framework/Machines/Objects/MushroomBoxMachine.cs @@ -0,0 +1,54 @@ +using Microsoft.Xna.Framework; +using StardewValley; +using SObject = StardewValley.Object; + +namespace Pathoschild.Stardew.Automate.Framework.Machines.Objects +{ + /// A mushroom box that accepts input and provides output. + internal class MushroomBoxMachine : GenericObjectMachine + { + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The underlying machine. + /// The location containing the machine. + /// The tile covered by the machine. + public MushroomBoxMachine(SObject machine, GameLocation location, Vector2 tile) + : base(machine, location, tile) { } + + /// Get the machine's processing state. + public override MachineState GetState() + { + return this.Machine.heldObject.Value != null && this.Machine.readyForHarvest.Value + ? MachineState.Done + : MachineState.Processing; + } + + /// Get the output item. + public override ITrackedStack GetOutput() + { + return new TrackedItem(this.Machine.heldObject.Value, onEmpty: this.Reset); + } + + /// Provide input to the machine. + /// The available items. + /// Returns whether the machine started processing an item. + public override bool SetInput(IStorage input) + { + return false; // no input needed + } + + + /********* + ** Private methods + *********/ + /// Reset the machine so it's ready to accept a new input. + /// The output item that was taken. + private void Reset(Item item) + { + this.GenericReset(item); + this.Machine.showNextIndex.Value = false; + } + } +} diff --git a/Mods/Automate/Automate/Framework/Machines/Objects/OilMakerMachine.cs b/Mods/Automate/Automate/Framework/Machines/Objects/OilMakerMachine.cs new file mode 100644 index 00000000..3cd95f13 --- /dev/null +++ b/Mods/Automate/Automate/Framework/Machines/Objects/OilMakerMachine.cs @@ -0,0 +1,68 @@ +using Microsoft.Xna.Framework; +using StardewValley; +using SObject = StardewValley.Object; + +namespace Pathoschild.Stardew.Automate.Framework.Machines.Objects +{ + /// An oil maker that accepts input and provides output. + internal class OilMakerMachine : GenericObjectMachine + { + /********* + ** Fields + *********/ + /// The recipes to process. + private readonly IRecipe[] Recipes = + { + // truffle => truffle oil + new Recipe( + input: 430, + inputCount: 1, + output: input => new SObject(Vector2.Zero, 432, null, false, true, false, false), + minutes: 360 + ), + + // sunflower seed => oil + new Recipe( + input: 431, + inputCount: 1, + output: input => new SObject(247, 1), + minutes: 3200 + ), + + // corn => oil + new Recipe( + input: 270, + inputCount: 1, + output: input => new SObject(Vector2.Zero, 247, null, false, true, false, false), + minutes: 1000 + ), + + // sunflower => oil + new Recipe( + input: 421, + inputCount: 1, + output: input => new SObject(Vector2.Zero, 247, null, false, true, false, false), + minutes: 60 + ) + }; + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The underlying machine. + /// The location containing the machine. + /// The tile covered by the machine. + public OilMakerMachine(SObject machine, GameLocation location, Vector2 tile) + : base(machine, location, tile) { } + + /// Provide input to the machine. + /// The available items. + /// Returns whether the machine started processing an item. + public override bool SetInput(IStorage input) + { + return this.GenericPullRecipe(input, this.Recipes); + } + } +} diff --git a/Mods/Automate/Automate/Framework/Machines/Objects/PreservesJarMachine.cs b/Mods/Automate/Automate/Framework/Machines/Objects/PreservesJarMachine.cs new file mode 100644 index 00000000..cc0b08b3 --- /dev/null +++ b/Mods/Automate/Automate/Framework/Machines/Objects/PreservesJarMachine.cs @@ -0,0 +1,74 @@ +using Microsoft.Xna.Framework; +using StardewValley; +using SObject = StardewValley.Object; + +namespace Pathoschild.Stardew.Automate.Framework.Machines.Objects +{ + /// A preserves jar that accepts input and provides output. + /// See the game's machine logic in and . + internal class PreservesJarMachine : GenericObjectMachine + { + /********* + ** Fields + *********/ + /// The recipes to process. + private readonly IRecipe[] Recipes = + { + // fruit => jelly + new Recipe( + input: SObject.FruitsCategory, + inputCount: 1, + output: input => + { + SObject jelly = new SObject(Vector2.Zero, 344, input.Name + " Jelly", false, true, false, false) + { + Price = 50 + ((SObject) input).Price * 2, + name = input.Name + " Jelly" + }; + jelly.preserve.Value = SObject.PreserveType.Jelly; + jelly.preservedParentSheetIndex.Value = input.ParentSheetIndex; + return jelly; + }, + minutes: 4000 + ), + + // vegetable => pickled vegetable + new Recipe( + input: SObject.VegetableCategory, + inputCount: 1, + output: input => + { + SObject item = new SObject(Vector2.Zero, 342, "Pickled " + input.Name, false, true, false, false) + { + Price = 50 + ((SObject) input).Price * 2, + name = "Pickled " + input.Name + }; + item.preserve.Value = SObject.PreserveType.Pickle; + item.preservedParentSheetIndex.Value = input.ParentSheetIndex; + return item; + + }, + minutes: 4000 + ) + }; + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The underlying machine. + /// The location containing the machine. + /// The tile covered by the machine. + public PreservesJarMachine(SObject machine, GameLocation location, Vector2 tile) + : base(machine, location, tile) { } + + /// Provide input to the machine. + /// The available items. + /// Returns whether the machine started processing an item. + public override bool SetInput(IStorage input) + { + return this.GenericPullRecipe(input, this.Recipes); + } + } +} diff --git a/Mods/Automate/Automate/Framework/Machines/Objects/RecyclingMachine.cs b/Mods/Automate/Automate/Framework/Machines/Objects/RecyclingMachine.cs new file mode 100644 index 00000000..a436db13 --- /dev/null +++ b/Mods/Automate/Automate/Framework/Machines/Objects/RecyclingMachine.cs @@ -0,0 +1,86 @@ +using System; +using Microsoft.Xna.Framework; +using StardewValley; +using SObject = StardewValley.Object; + +namespace Pathoschild.Stardew.Automate.Framework.Machines.Objects +{ + /// A recycling maching that accepts input and provides output. + /// This differs slightly from the game implementation in that it uses a more random RNG, due to a C# limitation which prevents us from accessing machine info from the cached recipe output functions for use in the RNG seed. + internal class RecyclingMachine : GenericObjectMachine + { + /********* + ** Fields + *********/ + /// The RNG to use for randomising output. + private static readonly Random Random = new Random(); + + /// The recipes to process. + private readonly IRecipe[] Recipes = + { + // trash => coal/iron ore/stone + new Recipe( + input: 168, + inputCount: 1, + output: input => new SObject(RecyclingMachine.Random.NextDouble() < 0.3 ? 382 : (RecyclingMachine.Random.NextDouble() < 0.3 ? 380 : 390), RecyclingMachine.Random.Next(1, 4)), + minutes: 60 + ), + + // driftwood => coal/wood + new Recipe( + input: 169, + inputCount: 1, + output: input => new SObject(RecyclingMachine.Random.NextDouble() < 0.25 ? 382 : 388, RecyclingMachine.Random.Next(1, 4)), + minutes: 60 + ), + + // broken glasses or broken CD => refined quartz + new Recipe( + input: 170, + inputCount: 1, + output: input => new SObject(338, 1), + minutes: 60 + ), + new Recipe( + input: 171, + inputCount: 1, + output: input => new SObject(338, 1), + minutes: 60 + ), + + // soggy newspaper => cloth/torch + new Recipe( + input: 172, + inputCount: 1, + output: input => RecyclingMachine.Random.NextDouble() < 0.1 ? new SObject(428, 1) : new Torch(Vector2.Zero, 3), + minutes: 60 + ) + }; + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The underlying machine. + /// The location containing the machine. + /// The tile covered by the machine. + public RecyclingMachine(SObject machine, GameLocation location, Vector2 tile) + : base(machine, location, tile) { } + + /// Provide input to the machine. + /// The available items. + /// Returns whether the machine started processing an item. + public override bool SetInput(IStorage input) + { + //Random random = new Random((int)Game1.uniqueIDForThisGame / 2 + (int)Game1.stats.DaysPlayed + Game1.timeOfDay + (int)machine.tileLocation.X * 200 + (int)machine.tileLocation.Y); + + if (this.GenericPullRecipe(input, this.Recipes)) + { + Game1.stats.PiecesOfTrashRecycled += 1; + return true; + } + return false; + } + } +} diff --git a/Mods/Automate/Automate/Framework/Machines/Objects/SeedMakerMachine.cs b/Mods/Automate/Automate/Framework/Machines/Objects/SeedMakerMachine.cs new file mode 100644 index 00000000..093408e4 --- /dev/null +++ b/Mods/Automate/Automate/Framework/Machines/Objects/SeedMakerMachine.cs @@ -0,0 +1,85 @@ +using System; +using System.Collections.Generic; +using Microsoft.Xna.Framework; +using StardewValley; +using SObject = StardewValley.Object; + +namespace Pathoschild.Stardew.Automate.Framework.Machines.Objects +{ + /// A seed maker that accepts input and provides output. + internal class SeedMakerMachine : GenericObjectMachine + { + /********* + ** Fields + *********/ + /// A crop ID => seed ID lookup. + private static readonly IDictionary CropSeedIDs = SeedMakerMachine.GetCropSeedIDs(); + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The underlying machine. + /// The location containing the machine. + /// The tile covered by the machine. + public SeedMakerMachine(SObject machine, GameLocation location, Vector2 tile) + : base(machine, location, tile) { } + + /// Provide input to the machine. + /// The available items. + /// Returns whether the machine started processing an item. + public override bool SetInput(IStorage input) + { + SObject machine = this.Machine; + + // crop => seeds + if (input.TryGetIngredient(this.IsValidCrop, 1, out IConsumable crop)) + { + crop.Reduce(); + int seedID = SeedMakerMachine.CropSeedIDs[crop.Sample.ParentSheetIndex]; + + Random random = new Random((int)Game1.stats.DaysPlayed + (int)Game1.uniqueIDForThisGame / 2 + (int)machine.TileLocation.X + (int)machine.TileLocation.Y * 77 + Game1.timeOfDay); + machine.heldObject.Value = new SObject(seedID, random.Next(1, 4)); + if (random.NextDouble() < 0.005) + machine.heldObject.Value = new SObject(499, 1); + else if (random.NextDouble() < 0.02) + machine.heldObject.Value = new SObject(770, random.Next(1, 5)); + machine.MinutesUntilReady = 20; + return true; + } + + return false; + } + + + /********* + ** Public methods + *********/ + /// Get whether a given item is a crop compatible with the seed marker. + /// The item to check. + public bool IsValidCrop(ITrackedStack item) + { + return + item.Sample.ParentSheetIndex != 433 // seed maker doesn't allow coffee beans + && SeedMakerMachine.CropSeedIDs.ContainsKey(item.Sample.ParentSheetIndex); + } + + /// Get a crop ID => seed ID lookup. + public static IDictionary GetCropSeedIDs() + { + IDictionary lookup = new Dictionary(); + + IDictionary cropData = Game1.content.Load>("Data\\Crops"); + foreach (KeyValuePair entry in cropData) + { + int dataCropID = Convert.ToInt32(entry.Value.Split('/')[3]); + int dataSeedID = entry.Key; + if (!lookup.ContainsKey(dataCropID)) // if multiple crops have the same seed, use the earliest one (which is more likely the vanilla seed) + lookup[dataCropID] = dataSeedID; + } + + return lookup; + } + } +} diff --git a/Mods/Automate/Automate/Framework/Machines/Objects/SlimeEggPressMachine.cs b/Mods/Automate/Automate/Framework/Machines/Objects/SlimeEggPressMachine.cs new file mode 100644 index 00000000..2ce84cee --- /dev/null +++ b/Mods/Automate/Automate/Framework/Machines/Objects/SlimeEggPressMachine.cs @@ -0,0 +1,50 @@ +using Microsoft.Xna.Framework; +using StardewValley; +using SObject = StardewValley.Object; + +namespace Pathoschild.Stardew.Automate.Framework.Machines.Objects +{ + /// A slime egg-press that accepts input and provides output. + internal class SlimeEggPressMachine : GenericObjectMachine + { + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The underlying machine. + /// The location containing the machine. + /// The tile covered by the machine. + public SlimeEggPressMachine(SObject machine, GameLocation location, Vector2 tile) + : base(machine, location, tile) { } + + /// Get the output item. + public override ITrackedStack GetOutput() + { + SObject heldObject = this.Machine.heldObject.Value; + return new TrackedItem(heldObject.getOne(), this.GenericReset); + } + + /// Provide input to the machine. + /// The available items. + /// Returns whether the machine started processing an item. + public override bool SetInput(IStorage input) + { + // slime => slime egg + if (input.TryConsume(766, 100)) + { + int parentSheetIndex = 680; + if (Game1.random.NextDouble() < 0.05) + parentSheetIndex = 439; + else if (Game1.random.NextDouble() < 0.1) + parentSheetIndex = 437; + else if (Game1.random.NextDouble() < 0.25) + parentSheetIndex = 413; + this.Machine.heldObject.Value = new SObject(parentSheetIndex, 1); + this.Machine.MinutesUntilReady = 1200; + return true; + } + + return false; + } + } +} diff --git a/Mods/Automate/Automate/Framework/Machines/Objects/SlimeIncubatorMachine.cs b/Mods/Automate/Automate/Framework/Machines/Objects/SlimeIncubatorMachine.cs new file mode 100644 index 00000000..6b814c43 --- /dev/null +++ b/Mods/Automate/Automate/Framework/Machines/Objects/SlimeIncubatorMachine.cs @@ -0,0 +1,90 @@ +using Microsoft.Xna.Framework; +using StardewValley; +using SObject = StardewValley.Object; + +namespace Pathoschild.Stardew.Automate.Framework.Machines.Objects +{ + /// A slime incubator that accepts slime eggs and spawns slime monsters. + internal class SlimeIncubatorMachine : GenericObjectMachine + { + /********* + ** Fields + *********/ + /// The recipes to process. + private readonly IRecipe[] Recipes; + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The underlying machine. + /// The location containing the machine. + /// The tile covered by the machine. + public SlimeIncubatorMachine(SObject machine, GameLocation location, Vector2 tile) + : base(machine, location, tile) + { + int minutesUntilReady = Game1.player.professions.Contains(2) ? 2000 : 4000; + this.Recipes = new IRecipe[] { + // blue slime egg => object with parentSheetIndex of blue slime egg + new Recipe( + input: 413, + inputCount: 1, + output: input => new SObject(413,1), + minutes: minutesUntilReady + ), + + // red slime egg => object with parentSheetIndex of red slime egg + new Recipe( + input: 437, + inputCount: 1, + output: input => new SObject(437,1), + minutes: minutesUntilReady + ), + + // purple slime egg => object with parentSheetIndex of purple slime egg + new Recipe( + input: 439, + inputCount: 1, + output: input => new SObject(439,1), + minutes: minutesUntilReady + ), + + // green slime egg => object with parentSheetIndex of green slime egg + new Recipe( + input: 680, + inputCount: 1, + output: input => new SObject(680,1), + minutes: minutesUntilReady + ) + }; + } + + /// Get the machine's processing state. + /// The slime incubator does not produce an output object, so it is never done. + public override MachineState GetState() + { + return this.Machine.heldObject.Value != null + ? MachineState.Processing + : MachineState.Empty; + } + + /// Get the output item. + /// The slime incubator does not produce an output object. + public override ITrackedStack GetOutput() + { + return null; + } + + /// Provide input to the machine. + /// The available items. + /// Returns whether the machine started processing an item. + public override bool SetInput(IStorage input) + { + bool started = this.GenericPullRecipe(input, this.Recipes); + if (started) + this.Machine.ParentSheetIndex = 157; + return started; + } + } +} diff --git a/Mods/Automate/Automate/Framework/Machines/Objects/SodaMachine.cs b/Mods/Automate/Automate/Framework/Machines/Objects/SodaMachine.cs new file mode 100644 index 00000000..054c3516 --- /dev/null +++ b/Mods/Automate/Automate/Framework/Machines/Objects/SodaMachine.cs @@ -0,0 +1,36 @@ +using Microsoft.Xna.Framework; +using StardewValley; +using SObject = StardewValley.Object; + +namespace Pathoschild.Stardew.Automate.Framework.Machines.Objects +{ + /// A soda machine that accepts input and provides output. + internal class SodaMachine : GenericObjectMachine + { + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The underlying machine. + /// The location containing the machine. + /// The tile covered by the machine. + public SodaMachine(SObject machine, GameLocation location, Vector2 tile) + : base(machine, location, tile) { } + + /// Get the machine's processing state. + public override MachineState GetState() + { + return this.Machine.heldObject.Value != null + ? MachineState.Done + : MachineState.Processing; + } + + /// Provide input to the machine. + /// The available items. + /// Returns whether the machine started processing an item. + public override bool SetInput(IStorage input) + { + return false; // no input + } + } +} diff --git a/Mods/Automate/Automate/Framework/Machines/Objects/StatueOfEndlessFortuneMachine.cs b/Mods/Automate/Automate/Framework/Machines/Objects/StatueOfEndlessFortuneMachine.cs new file mode 100644 index 00000000..0cbc6e77 --- /dev/null +++ b/Mods/Automate/Automate/Framework/Machines/Objects/StatueOfEndlessFortuneMachine.cs @@ -0,0 +1,36 @@ +using Microsoft.Xna.Framework; +using StardewValley; +using SObject = StardewValley.Object; + +namespace Pathoschild.Stardew.Automate.Framework.Machines.Objects +{ + /// A statue of endless fortune that accepts input and provides output. + internal class StatueOfEndlessFortuneMachine : GenericObjectMachine + { + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The underlying machine. + /// The location containing the machine. + /// The tile covered by the machine. + public StatueOfEndlessFortuneMachine(SObject machine, GameLocation location, Vector2 tile) + : base(machine, location, tile) { } + + /// Get the machine's processing state. + public override MachineState GetState() + { + return this.Machine.heldObject.Value != null + ? MachineState.Done + : MachineState.Processing; + } + + /// Provide input to the machine. + /// The available items. + /// Returns whether the machine started processing an item. + public override bool SetInput(IStorage input) + { + return false; // no input + } + } +} diff --git a/Mods/Automate/Automate/Framework/Machines/Objects/StatueOfPerfectionMachine.cs b/Mods/Automate/Automate/Framework/Machines/Objects/StatueOfPerfectionMachine.cs new file mode 100644 index 00000000..8a48511c --- /dev/null +++ b/Mods/Automate/Automate/Framework/Machines/Objects/StatueOfPerfectionMachine.cs @@ -0,0 +1,36 @@ +using Microsoft.Xna.Framework; +using StardewValley; +using SObject = StardewValley.Object; + +namespace Pathoschild.Stardew.Automate.Framework.Machines.Objects +{ + /// A statue of perfection that accepts input and provides output. + internal class StatueOfPerfectionMachine : GenericObjectMachine + { + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The underlying machine. + /// The location containing the machine. + /// The tile covered by the machine. + public StatueOfPerfectionMachine(SObject machine, GameLocation location, Vector2 tile) + : base(machine, location, tile) { } + + /// Get the machine's processing state. + public override MachineState GetState() + { + return this.Machine.heldObject.Value != null + ? MachineState.Done + : MachineState.Processing; + } + + /// Provide input to the machine. + /// The available items. + /// Returns whether the machine started processing an item. + public override bool SetInput(IStorage input) + { + return false; // no input + } + } +} diff --git a/Mods/Automate/Automate/Framework/Machines/Objects/TapperMachine.cs b/Mods/Automate/Automate/Framework/Machines/Objects/TapperMachine.cs new file mode 100644 index 00000000..dce6ed8d --- /dev/null +++ b/Mods/Automate/Automate/Framework/Machines/Objects/TapperMachine.cs @@ -0,0 +1,84 @@ +using Microsoft.Xna.Framework; +using StardewValley; +using SObject = StardewValley.Object; + +namespace Pathoschild.Stardew.Automate.Framework.Machines.Objects +{ + /// A tapper that accepts input and provides output. + internal class TapperMachine : GenericObjectMachine + { + /********* + ** Fields + *********/ + /// The tree type. + private readonly int TreeType; + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The underlying machine. + /// The location to search. + /// The tile covered by the machine. + /// The tree type being tapped. + public TapperMachine(SObject machine, GameLocation location, Vector2 tile, int treeType) + : base(machine, location, tile) + { + this.TreeType = treeType; + } + + /// Get the output item. + public override ITrackedStack GetOutput() + { + SObject heldObject = this.Machine.heldObject.Value; + return new TrackedItem(heldObject.getOne(), onEmpty: this.Reset); + } + + /// Provide input to the machine. + /// The available items. + /// Returns whether the machine started processing an item. + public override bool SetInput(IStorage input) + { + return false; // no input + } + + + /********* + ** Private methods + *********/ + /// Reset the machine so it's ready to accept a new input. + /// The output item that was taken. + private void Reset(Item item) + { + SObject tapper = this.Machine; + + switch (this.TreeType) + { + case 1: + tapper.heldObject.Value = new SObject(725, 1); + tapper.MinutesUntilReady = 13000 - Game1.timeOfDay; + break; + case 2: + tapper.heldObject.Value = new SObject(724, 1); + tapper.MinutesUntilReady = 16000 - Game1.timeOfDay; + break; + case 3: + tapper.heldObject.Value = new SObject(726, 1); + tapper.MinutesUntilReady = 10000 - Game1.timeOfDay; + break; + case 7: + tapper.heldObject.Value = new SObject(420, 1); + tapper.MinutesUntilReady = 3000 - Game1.timeOfDay; + if (!Game1.currentSeason.Equals("fall")) + { + tapper.heldObject.Value = new SObject(404, 1); + tapper.MinutesUntilReady = 6000 - Game1.timeOfDay; + } + break; + } + tapper.heldObject.Value = (SObject)tapper.heldObject.Value.getOne(); + tapper.readyForHarvest.Value = false; + } + } +} diff --git a/Mods/Automate/Automate/Framework/Machines/Objects/WormBinMachine.cs b/Mods/Automate/Automate/Framework/Machines/Objects/WormBinMachine.cs new file mode 100644 index 00000000..172156ac --- /dev/null +++ b/Mods/Automate/Automate/Framework/Machines/Objects/WormBinMachine.cs @@ -0,0 +1,42 @@ +using Microsoft.Xna.Framework; +using StardewValley; +using SObject = StardewValley.Object; + +namespace Pathoschild.Stardew.Automate.Framework.Machines.Objects +{ + /// A tapper that accepts input and provides output. + /// See the game's machine logic in and . + internal class WormBinMachine : GenericObjectMachine + { + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The underlying machine. + /// The location containing the machine. + /// The tile covered by the machine. + public WormBinMachine(SObject machine, GameLocation location, Vector2 tile) + : base(machine, location, tile) { } + + /// Get the output item. + public override ITrackedStack GetOutput() + { + SObject bin = this.Machine; + return new TrackedItem(bin.heldObject.Value, item => + { + bin.heldObject.Value = new SObject(685, Game1.random.Next(2, 6)); + bin.MinutesUntilReady = 2600 - Game1.timeOfDay; + bin.readyForHarvest.Value = false; + bin.showNextIndex.Value = false; + }); + } + + /// Provide input to the machine. + /// The available items. + /// Returns whether the machine started processing an item. + public override bool SetInput(IStorage input) + { + return false; // no input + } + } +} diff --git a/Mods/Automate/Automate/Framework/Machines/TerrainFeatures/FruitTreeMachine.cs b/Mods/Automate/Automate/Framework/Machines/TerrainFeatures/FruitTreeMachine.cs new file mode 100644 index 00000000..301773c9 --- /dev/null +++ b/Mods/Automate/Automate/Framework/Machines/TerrainFeatures/FruitTreeMachine.cs @@ -0,0 +1,72 @@ +using Microsoft.Xna.Framework; +using StardewValley; +using StardewValley.TerrainFeatures; +using SObject = StardewValley.Object; + +namespace Pathoschild.Stardew.Automate.Framework.Machines.TerrainFeatures +{ + /// A fruit tree machine that accepts input and provides output. + /// Derived from . + internal class FruitTreeMachine : BaseMachine + { + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The underlying fruit tree. + /// The machine's in-game location. + /// The tree's tile position. + public FruitTreeMachine(FruitTree tree, GameLocation location, Vector2 tile) + : base(tree, location, BaseMachine.GetTileAreaFor(tile)) { } + + /// Get the machine's processing state. + public override MachineState GetState() + { + if (this.Machine.growthStage.Value < FruitTree.treeStage) + return MachineState.Disabled; + + return this.Machine.fruitsOnTree.Value > 0 + ? MachineState.Done + : MachineState.Processing; + } + + /// Get the output item. + public override ITrackedStack GetOutput() + { + FruitTree tree = this.Machine; + + // if struck by lightning => coal + if (tree.struckByLightningCountdown.Value > 0) + return new TrackedItem(new SObject(382, tree.fruitsOnTree.Value), onReduced: this.OnOutputReduced); + + // else => fruit + int quality = SObject.lowQuality; + if (tree.daysUntilMature.Value <= -112) + quality = SObject.medQuality; + if (tree.daysUntilMature.Value <= -224) + quality = SObject.highQuality; + if (tree.daysUntilMature.Value <= -336) + quality = SObject.bestQuality; + return new TrackedItem(new SObject(tree.indexOfFruit.Value, tree.fruitsOnTree.Value, quality: quality), onReduced: this.OnOutputReduced); + } + + /// Provide input to the machine. + /// The available items. + /// Returns whether the machine started processing an item. + public override bool SetInput(IStorage input) + { + return false; // no input + } + + + /********* + ** Private methods + *********/ + /// Reset the machine so it's ready to accept a new input. + /// The output item that was taken. + private void OnOutputReduced(Item item) + { + this.Machine.fruitsOnTree.Value = item.Stack; + } + } +} diff --git a/Mods/Automate/Automate/Framework/Machines/Tiles/TrashCanMachine.cs b/Mods/Automate/Automate/Framework/Machines/Tiles/TrashCanMachine.cs new file mode 100644 index 00000000..0a8397dc --- /dev/null +++ b/Mods/Automate/Automate/Framework/Machines/Tiles/TrashCanMachine.cs @@ -0,0 +1,149 @@ +using System; +using System.Collections.Generic; +using Microsoft.Xna.Framework; +using StardewModdingAPI; +using StardewValley; +using StardewValley.Locations; + +namespace Pathoschild.Stardew.Automate.Framework.Machines.Tiles +{ + /// A trash can that accepts input and provides output. + internal class TrashCanMachine : BaseMachine + { + /********* + ** Fields + *********/ + /// The machine's position in its location. + private readonly Vector2 Tile; + + /// The game's list of trash cans the player has already checked. + private readonly IList TrashCansChecked; + + /// The trash can index (or -1 if not a valid trash can). + private readonly int TrashCanIndex = -1; + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The town to search. + /// The machine's position in its location. + /// The trash can index. + /// Simplifies access to private game code. + public TrashCanMachine(Town town, Vector2 tile, int trashCanIndex, IReflectionHelper reflection) + : base(town, BaseMachine.GetTileAreaFor(tile)) + { + this.Tile = tile; + this.TrashCansChecked = reflection.GetField>(town, "garbageChecked").GetValue(); + if (trashCanIndex >= 0 && trashCanIndex < this.TrashCansChecked.Count) + this.TrashCanIndex = trashCanIndex; + } + + /// Get the machine's processing state. + public override MachineState GetState() + { + if (this.TrashCanIndex == -1) + return MachineState.Disabled; + if (this.TrashCansChecked[this.TrashCanIndex]) + return MachineState.Processing; + return MachineState.Done; + } + + /// Get the output item. + public override ITrackedStack GetOutput() + { + // get trash + int? itemID = this.GetRandomTrash(this.TrashCanIndex); + if (itemID.HasValue) + return new TrackedItem(new StardewValley.Object(itemID.Value, 1), onEmpty: this.MarkChecked); + + // if nothing is returned, mark trash can checked + this.MarkChecked(null); + return null; + } + + /// Provide input to the machine. + /// The available items. + /// Returns whether the machine started processing an item. + public override bool SetInput(IStorage input) + { + return false; // no input + } + + + /********* + ** Private methods + *********/ + /// Reset the machine so it starts processing the next item. + /// The output item that was taken. + private void MarkChecked(Item item) + { + this.TrashCansChecked[this.TrashCanIndex] = true; + } + + /// Get a random trash item ID. + /// The trash can index. + /// Duplicated from . + private int? GetRandomTrash(int index) + { + Random random = new Random((int)Game1.uniqueIDForThisGame / 2 + (int)Game1.stats.DaysPlayed + 777 + index); + if (random.NextDouble() < 0.2 + Game1.dailyLuck) + { + int parentSheetIndex = 168; + switch (random.Next(10)) + { + case 0: + parentSheetIndex = 168; + break; + case 1: + parentSheetIndex = 167; + break; + case 2: + parentSheetIndex = 170; + break; + case 3: + parentSheetIndex = 171; + break; + case 4: + parentSheetIndex = 172; + break; + case 5: + parentSheetIndex = 216; + break; + case 6: + parentSheetIndex = Utility.getRandomItemFromSeason(Game1.currentSeason, ((int)this.Tile.X) * 653 + ((int)this.Tile.Y) * 777, false); + break; + case 7: + parentSheetIndex = 403; + break; + case 8: + parentSheetIndex = 309 + random.Next(3); + break; + case 9: + parentSheetIndex = 153; + break; + } + if (index == 3 && random.NextDouble() < 0.2 + Game1.dailyLuck) + { + parentSheetIndex = 535; + if (random.NextDouble() < 0.05) + parentSheetIndex = 749; + } + if (index == 4 && random.NextDouble() < 0.2 + Game1.dailyLuck) + { + parentSheetIndex = 378 + random.Next(3) * 2; + random.Next(1, 5); + } + if (index == 5 && random.NextDouble() < 0.2 + Game1.dailyLuck && Game1.dishOfTheDay != null) + parentSheetIndex = Game1.dishOfTheDay.ParentSheetIndex != 217 ? Game1.dishOfTheDay.ParentSheetIndex : 216; + if (index == 6 && random.NextDouble() < 0.2 + Game1.dailyLuck) + parentSheetIndex = 223; + + return parentSheetIndex; + } + + return null; + } + } +} diff --git a/Mods/Automate/Automate/Framework/Models/ModConfig.cs b/Mods/Automate/Automate/Framework/Models/ModConfig.cs new file mode 100644 index 00000000..1327f11c --- /dev/null +++ b/Mods/Automate/Automate/Framework/Models/ModConfig.cs @@ -0,0 +1,37 @@ +using Newtonsoft.Json; +using Pathoschild.Stardew.Common; +using StardewModdingAPI; + +namespace Pathoschild.Stardew.Automate.Framework.Models +{ + /// The raw mod configuration. + internal class ModConfig + { + /********* + ** Accessors + *********/ + /// Whether to treat the shipping bin as a machine that can be automated. + public bool AutomateShippingBin { get; set; } = true; + + /// The number of ticks between each automation process (60 = once per second). + public int AutomationInterval { get; set; } = 60; + + /// The control bindings. + public ModConfigControls Controls { get; set; } = new ModConfigControls(); + + /// The in-game objects through which machines can connect. + public ModConfigObject[] Connectors { get; set; } = new ModConfigObject[0]; + + + /********* + ** Nested models + *********/ + /// A set of control bindings. + internal class ModConfigControls + { + /// The button which toggles the automation overlay. + [JsonConverter(typeof(StringEnumArrayConverter))] + public SButton[] ToggleOverlay { get; set; } = { SButton.U }; + } + } +} diff --git a/Mods/Automate/Automate/Framework/Models/ModConfigObject.cs b/Mods/Automate/Automate/Framework/Models/ModConfigObject.cs new file mode 100644 index 00000000..f61990a6 --- /dev/null +++ b/Mods/Automate/Automate/Framework/Models/ModConfigObject.cs @@ -0,0 +1,16 @@ +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; + +namespace Pathoschild.Stardew.Automate.Framework.Models +{ + /// An object identifier. + internal class ModConfigObject + { + /// The object type. + [JsonConverter(typeof(StringEnumConverter))] + public ObjectType Type { get; set; } + + /// The object ID. + public int ID { get; set; } + } +} diff --git a/Mods/Automate/Automate/Framework/Models/ObjectType.cs b/Mods/Automate/Automate/Framework/Models/ObjectType.cs new file mode 100644 index 00000000..746a2e76 --- /dev/null +++ b/Mods/Automate/Automate/Framework/Models/ObjectType.cs @@ -0,0 +1,15 @@ +namespace Pathoschild.Stardew.Automate.Framework.Models +{ + /// The type of an in-game object for the mod's purposes. + internal enum ObjectType + { + /// A flooring object. + Floor, + + /// A bigcraftable object. + BigCraftable, + + /// A map object. + Object + } +} diff --git a/Mods/Automate/Automate/Framework/OverlayMenu.cs b/Mods/Automate/Automate/Framework/OverlayMenu.cs new file mode 100644 index 00000000..41b04858 --- /dev/null +++ b/Mods/Automate/Automate/Framework/OverlayMenu.cs @@ -0,0 +1,126 @@ +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Graphics; +using Pathoschild.Stardew.Common; +using Pathoschild.Stardew.Common.UI; +using StardewModdingAPI; +using StardewModdingAPI.Events; +using StardewValley; + +namespace Pathoschild.Stardew.Automate.Framework +{ + /// The overlay which highlights automatable machines. + internal class OverlayMenu : BaseOverlay + { + /********* + ** Fields + *********/ + /// The padding to apply to tile backgrounds to make the grid visible. + private readonly int TileGap = 1; + + /// A machine group lookup by tile coordinate. + private readonly IDictionary GroupTiles; + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The SMAPI events available for mods. + /// An API for checking and changing input state. + /// The machine groups to display. + public OverlayMenu(IModEvents events, IInputHelper inputHelper, IEnumerable machineGroups) + : base(events, inputHelper) + { + // init machine groups + machineGroups = machineGroups.ToArray(); + this.GroupTiles = + ( + from machineGroup in machineGroups + from tile in machineGroup.Tiles + select new { tile, machineGroup } + ) + .ToDictionary(p => p.tile, p => p.machineGroup); + } + + + /********* + ** Protected + *********/ + /// Draw the overlay to the screen. + /// The sprite batch being drawn. + [SuppressMessage("ReSharper", "PossibleLossOfFraction", Justification = "Deliberate discarded for conversion to tile coordinates.")] + protected override void Draw(SpriteBatch spriteBatch) + { + if (!Context.IsPlayerFree) + return; + + // draw each tile + foreach (Vector2 tile in TileHelper.GetVisibleTiles()) + { + // get tile's screen coordinates + float screenX = tile.X * Game1.tileSize - Game1.viewport.X; + float screenY = tile.Y * Game1.tileSize - Game1.viewport.Y; + int tileSize = Game1.tileSize; + + // get machine group + this.GroupTiles.TryGetValue(tile, out MachineGroup group); + bool isGrouped = group != null; + bool isActive = isGrouped && group.HasInternalAutomation; + + // draw background + { + Color color = Color.Black * 0.5f; + if (isActive) + color = Color.Green * 0.2f; + else if (isGrouped) + color = Color.Red * 0.2f; + + spriteBatch.DrawLine(screenX + this.TileGap, screenY + this.TileGap, new Vector2(tileSize - this.TileGap * 2, tileSize - this.TileGap * 2), color); + } + + // draw group edge borders + if (group != null) + this.DrawEdgeBorders(spriteBatch, group, tile, group.HasInternalAutomation ? Color.Green : Color.Red); + } + + // draw cursor + this.DrawCursor(); + } + + + /********* + ** Private methods + *********/ + /// Draw borders for each unconnected edge of a tile. + /// The sprite batch being drawn. + /// The machine group. + /// The group tile. + /// The border color. + private void DrawEdgeBorders(SpriteBatch spriteBatch, MachineGroup group, Vector2 tile, Color color) + { + int borderSize = 3; + float screenX = tile.X * Game1.tileSize - Game1.viewport.X; + float screenY = tile.Y * Game1.tileSize - Game1.viewport.Y; + float tileSize = Game1.tileSize; + + // top + if (!group.Tiles.Contains(new Vector2(tile.X, tile.Y - 1))) + spriteBatch.DrawLine(screenX, screenY, new Vector2(tileSize, borderSize), color); // top + + // bottom + if (!group.Tiles.Contains(new Vector2(tile.X, tile.Y + 1))) + spriteBatch.DrawLine(screenX, screenY + tileSize, new Vector2(tileSize, borderSize), color); // bottom + + // left + if (!group.Tiles.Contains(new Vector2(tile.X - 1, tile.Y))) + spriteBatch.DrawLine(screenX, screenY, new Vector2(borderSize, tileSize), color); // left + + // right + if (!group.Tiles.Contains(new Vector2(tile.X + 1, tile.Y))) + spriteBatch.DrawLine(screenX + tileSize, screenY, new Vector2(borderSize, tileSize), color); // right + } + } +} diff --git a/Mods/Automate/Automate/Framework/Recipe.cs b/Mods/Automate/Automate/Framework/Recipe.cs new file mode 100644 index 00000000..9f2ac69f --- /dev/null +++ b/Mods/Automate/Automate/Framework/Recipe.cs @@ -0,0 +1,49 @@ +using System; +using StardewValley; +using SObject = StardewValley.Object; + +namespace Pathoschild.Stardew.Automate.Framework +{ + /// Describes a generic recipe based on item input and output. + internal class Recipe : IRecipe + { + /********* + ** Accessors + *********/ + /// The input item or category ID. + public int InputID { get; } + + /// The number of inputs needed. + public int InputCount { get; } + + /// The output to generate (given an input). + public Func Output { get; } + + /// The time needed to prepare an output. + public int Minutes { get; } + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The input item or category ID. + /// The number of inputs needed. + /// The output to generate (given an input). + /// The time needed to prepare an output. + public Recipe(int input, int inputCount, Func output, int minutes) + { + this.InputID = input; + this.InputCount = inputCount; + this.Output = output; + this.Minutes = minutes; + } + + /// Get whether the recipe can accept a given item as input (regardless of stack size). + /// The item to check. + public bool AcceptsInput(ITrackedStack stack) + { + return stack.Sample.ParentSheetIndex == this.InputID || stack.Sample.Category == this.InputID; + } + } +} diff --git a/Mods/Automate/Automate/Framework/Storage/ChestContainer.cs b/Mods/Automate/Automate/Framework/Storage/ChestContainer.cs new file mode 100644 index 00000000..aaa5eda7 --- /dev/null +++ b/Mods/Automate/Automate/Framework/Storage/ChestContainer.cs @@ -0,0 +1,146 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using Microsoft.Xna.Framework; +using StardewValley; +using StardewValley.Objects; + +namespace Pathoschild.Stardew.Automate.Framework.Storage +{ + /// A in-game chest which can provide or store items. + internal class ChestContainer : IContainer + { + /********* + ** Fields + *********/ + /// The underlying chest. + private readonly Chest Chest; + + + /********* + ** Accessors + *********/ + /// The container name (if any). + public string Name => this.Chest.Name; + + /// The location which contains the container. + public GameLocation Location { get; } + + /// The tile area covered by the container. + public Rectangle TileArea { get; } + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The underlying chest. + /// The location which contains the container. + /// The tile area covered by the container. + public ChestContainer(Chest chest, GameLocation location, Vector2 tile) + { + this.Chest = chest; + this.Location = location; + this.TileArea = new Rectangle((int)tile.X, (int)tile.Y, 1, 1); + } + + /// Store an item stack. + /// The item stack to store. + /// If the storage can't hold the entire stack, it should reduce the tracked stack accordingly. + public void Store(ITrackedStack stack) + { + if (stack.Count <= 0) + return; + + IList inventory = this.Chest.items; + + // try stack into existing slot + foreach (Item slot in inventory) + { + if (slot != null && stack.Sample.canStackWith(slot)) + { + int added = stack.Count - slot.addToStack(stack.Count); + stack.Reduce(added); + if (stack.Count <= 0) + return; + } + } + + // try add to empty slot + for (int i = 0; i < Chest.capacity && i < inventory.Count; i++) + { + if (inventory[i] == null) + { + inventory[i] = stack.Take(stack.Count); + return; + } + + } + + // try add new slot + if (inventory.Count < Chest.capacity) + inventory.Add(stack.Take(stack.Count)); + } + + /// Find items in the pipe matching a predicate. + /// Matches items that should be returned. + /// The number of items to find. + /// If the pipe has no matching item, returns null. Otherwise returns a tracked item stack, which may have less items than requested if no more were found. + public ITrackedStack Get(Func predicate, int count) + { + ITrackedStack[] stacks = this.GetImpl(predicate, count).ToArray(); + if (!stacks.Any()) + return null; + return new TrackedItemCollection(stacks); + } + + /// Returns an enumerator that iterates through the collection. + /// An enumerator that can be used to iterate through the collection. + public IEnumerator GetEnumerator() + { + foreach (Item item in this.Chest.items.ToArray()) + { + if (item != null) + yield return this.GetTrackedItem(item); + } + } + + /// Returns an enumerator that iterates through a collection. + /// An object that can be used to iterate through the collection. + IEnumerator IEnumerable.GetEnumerator() + { + return this.GetEnumerator(); + } + + + /********* + ** Private methods + *********/ + /// Find items in the pipe matching a predicate. + /// Matches items that should be returned. + /// The number of items to find. + /// If there aren't enough items in the pipe, it should return those it has. + private IEnumerable GetImpl(Func predicate, int count) + { + int countFound = 0; + foreach (Item item in this.Chest.items) + { + if (item != null && predicate(item)) + { + countFound += item.Stack; + yield return this.GetTrackedItem(item); + if (countFound >= count) + yield break; + } + } + } + + /// Get a tracked item sync'd with the chest inventory. + /// The item to track. + private ITrackedStack GetTrackedItem(Item item) + { + return new TrackedItem(item, onEmpty: i => this.Chest.items.Remove(i)); + } + } +} diff --git a/Mods/Automate/Automate/Framework/Storage/ContainerExtensions.cs b/Mods/Automate/Automate/Framework/Storage/ContainerExtensions.cs new file mode 100644 index 00000000..ce9fe3b6 --- /dev/null +++ b/Mods/Automate/Automate/Framework/Storage/ContainerExtensions.cs @@ -0,0 +1,47 @@ +using System; + +namespace Pathoschild.Stardew.Automate.Framework.Storage +{ + /// Provides extensions for instances. + internal static class ContainerExtensions + { + /********* + ** Public methods + *********/ + /// Get whether the container name contains a given tag. + /// The container instance. + /// The tag to check, excluding the '|' delimiters. + public static bool HasTag(this IContainer container, string tag) + { + return container.Name?.IndexOf($"|{tag}|", StringComparison.InvariantCultureIgnoreCase) >= 0; + } + + /// Get whether this container should be preferred for output when possible. + /// The container instance. + public static bool ShouldIgnore(this IContainer container) + { + return container.HasTag("automate:ignore"); + } + + /// Get whether input is enabled for this container. + /// The container instance. + public static bool AllowsInput(this IContainer container) + { + return !container.ShouldIgnore() && !container.HasTag("automate:noinput"); + } + + /// Get whether output is enabled for this container. + /// The container instance. + public static bool AllowsOutput(this IContainer container) + { + return !container.ShouldIgnore() && !container.HasTag("automate:nooutput"); + } + + /// Get whether this container should be preferred for output when possible. + /// The container instance. + public static bool PreferForOutput(this IContainer container) + { + return container.HasTag("automate:output"); + } + } +} diff --git a/Mods/Automate/Automate/Framework/StorageManager.cs b/Mods/Automate/Automate/Framework/StorageManager.cs new file mode 100644 index 00000000..59fa3250 --- /dev/null +++ b/Mods/Automate/Automate/Framework/StorageManager.cs @@ -0,0 +1,206 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Pathoschild.Stardew.Automate.Framework.Storage; +using StardewValley; +using SObject = StardewValley.Object; + +namespace Pathoschild.Stardew.Automate.Framework +{ + /// Manages access to items in the underlying containers. + internal class StorageManager : IStorage + { + /********* + ** Fields + *********/ + /// The storage containers. + private readonly IContainer[] Containers; + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The storage containers. + public StorageManager(IEnumerable containers) + { + this.Containers = containers.ToArray(); + } + + /**** + ** GetItems + ****/ + /// Get all items from the given pipes. + public IEnumerable GetItems() + { + foreach (IContainer container in this.Containers) + { + if (!container.AllowsOutput()) + continue; + + foreach (ITrackedStack item in container) + yield return item; + } + } + + /**** + ** TryGetIngredient + ****/ + /// Get an ingredient needed for a recipe. + /// Returns whether an item should be matched. + /// The number of items to find. + /// The matching consumables. + /// Returns whether the requirement is met. + public bool TryGetIngredient(Func predicate, int count, out IConsumable consumable) + { + int countMissing = count; + ITrackedStack[] consumables = this.GetItems().Where(predicate) + .TakeWhile(chestItem => + { + if (countMissing <= 0) + return false; + + countMissing -= chestItem.Count; + return true; + }) + .ToArray(); + + consumable = new Consumable(new TrackedItemCollection(consumables), count); + return consumable.IsMet; + } + + /// Get an ingredient needed for a recipe. + /// The item or category ID. + /// The number of items to find. + /// The matching consumables. + /// Returns whether the requirement is met. + public bool TryGetIngredient(int id, int count, out IConsumable consumable) + { + return this.TryGetIngredient(item => item.Sample.ParentSheetIndex == id || item.Sample.Category == id, count, out consumable); + } + + /// Get an ingredient needed for a recipe. + /// The items to match. + /// The matching consumables. + /// The matched requisition. + /// Returns whether the requirement is met. + public bool TryGetIngredient(IRecipe[] recipes, out IConsumable consumable, out IRecipe recipe) + { + IDictionary> accumulator = recipes.ToDictionary(req => req, req => new List()); + + foreach (ITrackedStack stack in this.GetItems()) + { + foreach (var entry in accumulator) + { + recipe = entry.Key; + List found = entry.Value; + + if (recipe.AcceptsInput(stack)) + { + found.Add(stack); + if (found.Sum(p => p.Count) >= recipe.InputCount) + { + consumable = new Consumable(new TrackedItemCollection(found), entry.Key.InputCount); + return true; + } + } + } + } + + consumable = null; + recipe = null; + return false; + } + + /**** + ** TryConsume + ****/ + /// Consume an ingredient needed for a recipe. + /// Returns whether an item should be matched. + /// The number of items to find. + /// Returns whether the item was consumed. + public bool TryConsume(Func predicate, int count) + { + if (this.TryGetIngredient(predicate, count, out IConsumable requirement)) + { + requirement.Reduce(); + return true; + } + return false; + } + + /// Consume an ingredient needed for a recipe. + /// The item ID. + /// The number of items to find. + /// Returns whether the item was consumed. + public bool TryConsume(int itemID, int count) + { + return this.TryConsume(item => item.Sample.ParentSheetIndex == itemID, count); + } + + /**** + ** TryPush + ****/ + /// Add the given item stack to the pipes if there's space. + /// The item stack to push. + public bool TryPush(ITrackedStack item) + { + if (item == null || item.Count <= 0) + return false; + + int originalCount = item.Count; + + var preferOutputContainers = this.Containers.Where(p => p.AllowsInput() && p.PreferForOutput()); + var otherContainers = this.Containers.Where(p => p.AllowsInput() && !p.PreferForOutput()); + + // push into 'output' chests + foreach (IContainer container in preferOutputContainers) + { + container.Store(item); + if (item.Count <= 0) + return true; + } + + // push into chests that already have this item + string itemKey = this.GetItemKey(item.Sample); + foreach (IContainer container in otherContainers) + { + if (container.All(p => this.GetItemKey(p.Sample) != itemKey)) + continue; + + container.Store(item); + if (item.Count <= 0) + return true; + } + + // push into first available chest + if (item.Count >= 0) + { + foreach (IContainer container in otherContainers) + { + container.Store(item); + if (item.Count <= 0) + return true; + } + } + + return item.Count < originalCount; + } + + + /********* + ** Private methods + *********/ + /// Get a key which uniquely identifies an item type. + /// The item to identify. + private string GetItemKey(Item item) + { + string key = item.GetType().FullName; + if (item is SObject obj) + key += "_craftable:" + obj.bigCraftable.Value; + key += "_id:" + item.ParentSheetIndex; + + return key; + } + } +} diff --git a/Mods/Automate/Automate/IAutomatable.cs b/Mods/Automate/Automate/IAutomatable.cs new file mode 100644 index 00000000..a0ec02f1 --- /dev/null +++ b/Mods/Automate/Automate/IAutomatable.cs @@ -0,0 +1,18 @@ +using Microsoft.Xna.Framework; +using StardewValley; + +namespace Pathoschild.Stardew.Automate +{ + /// An automatable entity, which can implement a more specific type like or . If it doesn't implement a more specific type, it's treated as a connector with no additional logic. + public interface IAutomatable + { + /********* + ** Accessors + *********/ + /// The location which contains the machine. + GameLocation Location { get; } + + /// The tile area covered by the machine. + Rectangle TileArea { get; } + } +} diff --git a/Mods/Automate/Automate/IAutomateAPI.cs b/Mods/Automate/Automate/IAutomateAPI.cs new file mode 100644 index 00000000..4a63532f --- /dev/null +++ b/Mods/Automate/Automate/IAutomateAPI.cs @@ -0,0 +1,19 @@ +using System.Collections.Generic; +using Microsoft.Xna.Framework; +using StardewValley; + +namespace Pathoschild.Stardew.Automate +{ + /// The API which lets other mods interact with Automate. + public interface IAutomateAPI + { + /// Add an automation factory. + /// An automation factory which construct machines, containers, and connectors. + void AddFactory(IAutomationFactory factory); + + /// Get the status of machines in a tile area. This is a specialised API for Data Layers and similar mods. + /// The location for which to display data. + /// The tile area for which to display data. + IDictionary GetMachineStates(GameLocation location, Rectangle tileArea); + } +} diff --git a/Mods/Automate/Automate/IAutomationFactory.cs b/Mods/Automate/Automate/IAutomationFactory.cs new file mode 100644 index 00000000..c1adb0c0 --- /dev/null +++ b/Mods/Automate/Automate/IAutomationFactory.cs @@ -0,0 +1,43 @@ +using Microsoft.Xna.Framework; +using StardewValley; +using StardewValley.Buildings; +using StardewValley.Locations; +using StardewValley.TerrainFeatures; +using SObject = StardewValley.Object; + +namespace Pathoschild.Stardew.Automate +{ + /// Constructs machines, containers, or connectors which can be added to a machine group. + public interface IAutomationFactory + { + /********* + ** Accessors + *********/ + /// Get a machine, container, or connector instance for a given object. + /// The in-game object. + /// The location to check. + /// The tile position to check. + /// Returns an instance or null. + IAutomatable GetFor(SObject obj, GameLocation location, in Vector2 tile); + + /// Get a machine, container, or connector instance for a given terrain feature. + /// The terrain feature. + /// The location to check. + /// The tile position to check. + /// Returns an instance or null. + IAutomatable GetFor(TerrainFeature feature, GameLocation location, in Vector2 tile); + + /// Get a machine, container, or connector instance for a given building. + /// The building. + /// The location to check. + /// The tile position to check. + /// Returns an instance or null. + IAutomatable GetFor(Building building, BuildableGameLocation location, in Vector2 tile); + + /// Get a machine, container, or connector instance for a given tile position. + /// The location to check. + /// The tile position to check. + /// Returns an instance or null. + IAutomatable GetForTile(GameLocation location, in Vector2 tile); + } +} diff --git a/Mods/Automate/Automate/IConsumable.cs b/Mods/Automate/Automate/IConsumable.cs new file mode 100644 index 00000000..260a1eda --- /dev/null +++ b/Mods/Automate/Automate/IConsumable.cs @@ -0,0 +1,34 @@ +using StardewValley; + +namespace Pathoschild.Stardew.Automate +{ + /// An ingredient stack (or stacks) which can be consumed by a machine. + public interface IConsumable + { + /********* + ** Accessors + *********/ + /// The items available to consumable. + ITrackedStack Consumables { get; } + + /// A sample item for comparison. + /// This should not be a reference to the original stack. + Item Sample { get; } + + /// The number of items needed for the recipe. + int CountNeeded { get; } + + /// Whether the consumables needed for this requirement are ready. + bool IsMet { get; } + + + /********* + ** Public methods + *********/ + /// Remove the needed number of this item from the stack. + void Reduce(); + + /// Remove the needed number of this item from the stack and return a new stack matching the count. + Item Take(); + } +} diff --git a/Mods/Automate/Automate/IContainer.cs b/Mods/Automate/Automate/IContainer.cs new file mode 100644 index 00000000..42197319 --- /dev/null +++ b/Mods/Automate/Automate/IContainer.cs @@ -0,0 +1,31 @@ +using System; +using System.Collections.Generic; +using StardewValley; + +namespace Pathoschild.Stardew.Automate +{ + /// Provides and stores items for machines. + public interface IContainer : IAutomatable, IEnumerable + { + /********* + ** Accessors + *********/ + /// The container name (if any). + string Name { get; } + + + /********* + ** Public methods + *********/ + /// Find items in the pipe matching a predicate. + /// Matches items that should be returned. + /// The number of items to find. + /// If the pipe has no matching item, returns null. Otherwise returns a tracked item stack, which may have less items than requested if no more were found. + ITrackedStack Get(Func predicate, int count); + + /// Store an item stack. + /// The item stack to store. + /// If the storage can't hold the entire stack, it should reduce the tracked stack accordingly. + void Store(ITrackedStack stack); + } +} diff --git a/Mods/Automate/Automate/IMachine.cs b/Mods/Automate/Automate/IMachine.cs new file mode 100644 index 00000000..fcc20fbb --- /dev/null +++ b/Mods/Automate/Automate/IMachine.cs @@ -0,0 +1,28 @@ +namespace Pathoschild.Stardew.Automate +{ + /// A machine that accepts input and provides output. + public interface IMachine : IAutomatable + { + /********* + ** Accessors + *********/ + /// A unique ID for the machine type. + /// This value should be identical for two machines if they have the exact same behavior and input logic. For example, if one machine in a group can't process input due to missing items, Automate will skip any other empty machines of that type in the same group since it assumes they need the same inputs. + string MachineTypeID { get; } + + + /********* + ** Public methods + *********/ + /// Get the machine's processing state. + MachineState GetState(); + + /// Get the output item. + ITrackedStack GetOutput(); + + /// Provide input to the machine. + /// The available items. + /// Returns whether the machine started processing an item. + bool SetInput(IStorage input); + } +} diff --git a/Mods/Automate/Automate/IRecipe.cs b/Mods/Automate/Automate/IRecipe.cs new file mode 100644 index 00000000..806ecbdb --- /dev/null +++ b/Mods/Automate/Automate/IRecipe.cs @@ -0,0 +1,33 @@ +using System; +using StardewValley; +using Object = StardewValley.Object; + +namespace Pathoschild.Stardew.Automate +{ + /// Describes a generic recipe based on item input and output. + public interface IRecipe + { + /********* + ** Accessors + *********/ + /// The input item or category ID. + int InputID { get; } + + /// The number of inputs needed. + int InputCount { get; } + + /// The output to generate (given an input). + Func Output { get; } + + /// The time needed to prepare an output. + int Minutes { get; } + + + /********* + ** Methods + *********/ + /// Get whether the recipe can accept a given item as input (regardless of stack size). + /// The item to check. + bool AcceptsInput(ITrackedStack stack); + } +} diff --git a/Mods/Automate/Automate/IStorage.cs b/Mods/Automate/Automate/IStorage.cs new file mode 100644 index 00000000..f8df55eb --- /dev/null +++ b/Mods/Automate/Automate/IStorage.cs @@ -0,0 +1,62 @@ +using System; +using System.Collections.Generic; +using Pathoschild.Stardew.Automate.Framework; + +namespace Pathoschild.Stardew.Automate +{ + /// Manages access to items in the underlying containers. + public interface IStorage + { + /********* + ** Public methods + *********/ + /// Get all items from the given pipes. + IEnumerable GetItems(); + + /**** + ** TryGetIngredient + ****/ + /// Get an ingredient needed for a recipe. + /// Returns whether an item should be matched. + /// The number of items to find. + /// The matching consumables. + /// Returns whether the requirement is met. + bool TryGetIngredient(Func predicate, int count, out IConsumable consumable); + + /// Get an ingredient needed for a recipe. + /// The item or category ID. + /// The number of items to find. + /// The matching consumables. + /// Returns whether the requirement is met. + bool TryGetIngredient(int id, int count, out IConsumable consumable); + + /// Get an ingredient needed for a recipe. + /// The items to match. + /// The matching consumables. + /// The matched requisition. + /// Returns whether the requirement is met. + bool TryGetIngredient(IRecipe[] recipes, out IConsumable consumable, out IRecipe recipe); + + /**** + ** TryConsume + ****/ + /// Consume an ingredient needed for a recipe. + /// Returns whether an item should be matched. + /// The number of items to find. + /// Returns whether the item was consumed. + bool TryConsume(Func predicate, int count); + + /// Consume an ingredient needed for a recipe. + /// The item ID. + /// The number of items to find. + /// Returns whether the item was consumed. + bool TryConsume(int itemID, int count); + + /**** + ** TryPush + ****/ + /// Add the given item stack to the pipes if there's space. + /// The item stack to push. + bool TryPush(ITrackedStack item); + } +} diff --git a/Mods/Automate/Automate/ITrackedStack.cs b/Mods/Automate/Automate/ITrackedStack.cs new file mode 100644 index 00000000..aaa73841 --- /dev/null +++ b/Mods/Automate/Automate/ITrackedStack.cs @@ -0,0 +1,30 @@ +using StardewValley; + +namespace Pathoschild.Stardew.Automate +{ + /// An item stack in an input pipe which can be reduced or taken. + public interface ITrackedStack + { + /********* + ** Accessors + *********/ + /// A sample item for comparison. + /// This should be equivalent to the underlying item (except in stack size), but *not* a reference to it. + Item Sample { get; } + + /// The number of items in the stack. + int Count { get; } + + + /********* + ** Public methods + *********/ + /// Remove the specified number of this item from the stack. + /// The number to consume. + void Reduce(int count); + + /// Remove the specified number of this item from the stack and return a new stack matching the count. + /// The number to get. + Item Take(int count); + } +} diff --git a/Mods/Automate/Automate/MachineState.cs b/Mods/Automate/Automate/MachineState.cs new file mode 100644 index 00000000..b7275df5 --- /dev/null +++ b/Mods/Automate/Automate/MachineState.cs @@ -0,0 +1,18 @@ +namespace Pathoschild.Stardew.Automate +{ + /// A machine processing state. + public enum MachineState + { + /// The machine is not currently enabled (e.g. out of season or needs to be started manually). + Disabled, + + /// The machine has no input. + Empty, + + /// The machine is processing an input. + Processing, + + /// The machine finished processing an input and has an output item ready. + Done + } +} diff --git a/Mods/Automate/Automate/ModEntry.cs b/Mods/Automate/Automate/ModEntry.cs new file mode 100644 index 00000000..d394c482 --- /dev/null +++ b/Mods/Automate/Automate/ModEntry.cs @@ -0,0 +1,285 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Pathoschild.Stardew.Automate.Framework; +using Pathoschild.Stardew.Automate.Framework.Models; +using Pathoschild.Stardew.Common; +using Pathoschild.Stardew.Common.Utilities; +using StardewModdingAPI; +using StardewModdingAPI.Events; +using StardewValley; + +namespace Pathoschild.Stardew.Automate +{ + /// The mod entry point. + internal class ModEntry : Mod + { + /********* + ** Fields + *********/ + /// The mod configuration. + private ModConfig Config; + + /// Constructs machine groups. + private MachineGroupFactory Factory; + + /// Whether to enable automation for the current save. + private bool EnableAutomation => Context.IsMainPlayer; + + /// The machines to process. + private readonly IDictionary ActiveMachineGroups = new Dictionary(new ObjectReferenceComparer()); + + /// The disabled machine groups (e.g. machines not connected to a chest). + private readonly IDictionary DisabledMachineGroups = new Dictionary(new ObjectReferenceComparer()); + + /// The locations that should be reloaded on the next update tick. + private readonly HashSet ReloadQueue = new HashSet(new ObjectReferenceComparer()); + + /// The number of ticks until the next automation cycle. + private int AutomateCountdown; + + /// The current overlay being displayed, if any. + private OverlayMenu CurrentOverlay; + + + /********* + ** Public methods + *********/ + /// The mod entry point, called after the mod is first loaded. + /// Provides methods for interacting with the mod directory, such as read/writing a config file or custom JSON files. + public override void Entry(IModHelper helper) + { + // toggle mod compatibility + bool hasBetterJunimos = helper.ModRegistry.IsLoaded("hawkfalcon.BetterJunimos"); + bool hasDeluxeAutoGrabber = helper.ModRegistry.IsLoaded("stokastic.DeluxeGrabber"); + + // init + this.Config = helper.ReadConfig(); + this.Factory = new MachineGroupFactory(); + this.Factory.Add(new AutomationFactory(this.Config.Connectors, this.Config.AutomateShippingBin, this.Monitor, helper.Reflection, hasBetterJunimos, hasDeluxeAutoGrabber)); + + // hook events + helper.Events.GameLoop.SaveLoaded += this.OnSaveLoaded; + helper.Events.Player.Warped += this.OnWarped; + helper.Events.World.LocationListChanged += this.World_LocationListChanged; + helper.Events.World.ObjectListChanged += this.World_ObjectListChanged; + helper.Events.GameLoop.UpdateTicked += this.OnUpdateTicked; + helper.Events.Input.ButtonPressed += this.OnButtonPressed; + + if (this.Config.Connectors.Any(p => p.Type == ObjectType.Floor)) + helper.Events.World.TerrainFeatureListChanged += this.World_TerrainFeatureListChanged; + + // log info + this.Monitor.VerboseLog($"Initialised with automation every {this.Config.AutomationInterval} ticks."); + } + + /// Get an API that other mods can access. This is always called after . + public override object GetApi() + { + return new AutomateAPI(this.Monitor, this.Factory, this.ActiveMachineGroups, this.DisabledMachineGroups); + } + + + /********* + ** Private methods + *********/ + /**** + ** Event handlers + ****/ + /// The method invoked when the player loads a save. + /// The event sender. + /// The event arguments. + private void OnSaveLoaded(object sender, SaveLoadedEventArgs e) + { + // disable if secondary player + if (!this.EnableAutomation) + this.Monitor.Log("Disabled automation (only the main player can automate machines in multiplayer mode).", LogLevel.Warn); + + // reset + this.ActiveMachineGroups.Clear(); + this.DisabledMachineGroups.Clear(); + this.AutomateCountdown = this.Config.AutomationInterval; + this.DisableOverlay(); + foreach (GameLocation location in CommonHelper.GetLocations()) + this.ReloadQueue.Add(location); + } + + /// The method invoked when the player warps to a new location. + /// The event sender. + /// The event arguments. + private void OnWarped(object sender, WarpedEventArgs e) + { + if (e.IsLocalPlayer) + this.ResetOverlayIfShown(); + } + + /// The method invoked when a location is added or removed. + /// The event sender. + /// The event arguments. + private void World_LocationListChanged(object sender, LocationListChangedEventArgs e) + { + if (!this.EnableAutomation) + return; + + this.Monitor.VerboseLog("Location list changed, reloading all machines."); + + try + { + this.ActiveMachineGroups.Clear(); + this.DisabledMachineGroups.Clear(); + foreach (GameLocation location in CommonHelper.GetLocations()) + this.ReloadQueue.Add(location); + } + catch (Exception ex) + { + this.HandleError(ex, "updating locations"); + } + } + + /// The method invoked when an object is added or removed to a location. + /// The event sender. + /// The event arguments. + private void World_ObjectListChanged(object sender, ObjectListChangedEventArgs e) + { + if (!this.EnableAutomation) + return; + + this.Monitor.VerboseLog($"Object list changed in {e.Location.Name}, reloading machines in current location."); + this.ReloadQueue.Add(e.Location); + } + + /// The method invoked when a terrain feature is added or removed to a location. + /// The event sender. + /// The event arguments. + private void World_TerrainFeatureListChanged(object sender, TerrainFeatureListChangedEventArgs e) + { + if (!this.EnableAutomation) + return; + + this.Monitor.VerboseLog($"Terrain feature list changed in {e.Location.Name}, reloading machines in current location."); + this.ReloadQueue.Add(e.Location); + } + + /// The method invoked when the in-game clock time changes. + /// The event sender. + /// The event arguments. + private void OnUpdateTicked(object sender, UpdateTickedEventArgs e) + { + if (!Context.IsWorldReady || !this.EnableAutomation) + return; + + try + { + // handle delay + this.AutomateCountdown--; + if (this.AutomateCountdown > 0) + return; + this.AutomateCountdown = this.Config.AutomationInterval; + + // reload machines if needed + if (this.ReloadQueue.Any()) + { + foreach (GameLocation location in this.ReloadQueue) + this.ReloadMachinesIn(location); + this.ReloadQueue.Clear(); + + this.ResetOverlayIfShown(); + } + + // process machines + foreach (MachineGroup group in this.GetActiveMachineGroups()) + group.Automate(); + } + catch (Exception ex) + { + this.HandleError(ex, "processing machines"); + } + } + + /// The method invoked when the player presses a button. + /// The event sender. + /// The event arguments. + private void OnButtonPressed(object sender, ButtonPressedEventArgs e) + { + try + { + // toggle overlay + if (Context.IsPlayerFree && this.Config.Controls.ToggleOverlay.Contains(e.Button)) + { + if (this.CurrentOverlay != null) + this.DisableOverlay(); + else + this.EnableOverlay(); + } + } + catch (Exception ex) + { + this.HandleError(ex, "handling key input"); + } + } + + /**** + ** Methods + ****/ + /// Get the active machine groups in every location. + private IEnumerable GetActiveMachineGroups() + { + foreach (KeyValuePair group in this.ActiveMachineGroups) + { + foreach (MachineGroup machineGroup in group.Value) + yield return machineGroup; + } + } + + /// Reload the machines in a given location. + /// The location whose machines to reload. + private void ReloadMachinesIn(GameLocation location) + { + this.Monitor.VerboseLog($"Reloading machines in {location.Name}..."); + + // get machine groups + MachineGroup[] machineGroups = this.Factory.GetMachineGroups(location).ToArray(); + this.ActiveMachineGroups[location] = machineGroups.Where(p => p.HasInternalAutomation).ToArray(); + this.DisabledMachineGroups[location] = machineGroups.Where(p => !p.HasInternalAutomation).ToArray(); + + // remove unneeded entries + if (!this.ActiveMachineGroups[location].Any()) + this.ActiveMachineGroups.Remove(location); + if (!this.DisabledMachineGroups[location].Any()) + this.DisabledMachineGroups.Remove(location); + } + + /// Log an error and warn the user. + /// The exception to handle. + /// The verb describing where the error occurred (e.g. "looking that up"). + private void HandleError(Exception ex, string verb) + { + this.Monitor.Log($"Something went wrong {verb}:\n{ex}", LogLevel.Error); + CommonHelper.ShowErrorMessage($"Huh. Something went wrong {verb}. The error log has the technical details."); + } + + /// Disable the overlay, if shown. + private void DisableOverlay() + { + this.CurrentOverlay?.Dispose(); + this.CurrentOverlay = null; + } + + /// Enable the overlay. + private void EnableOverlay() + { + if (this.CurrentOverlay == null) + this.CurrentOverlay = new OverlayMenu(this.Helper.Events, this.Helper.Input, this.Factory.GetMachineGroups(Game1.currentLocation)); + } + + /// Reset the overlay if it's being shown. + private void ResetOverlayIfShown() + { + if (this.CurrentOverlay != null) + { + this.DisableOverlay(); + this.EnableOverlay(); + } + } + } +} diff --git a/Mods/Automate/Automate/TrackedItem.cs b/Mods/Automate/Automate/TrackedItem.cs new file mode 100644 index 00000000..b0da39bc --- /dev/null +++ b/Mods/Automate/Automate/TrackedItem.cs @@ -0,0 +1,102 @@ +using System; +using StardewValley; + +namespace Pathoschild.Stardew.Automate +{ + /// An item stack which notifies callbacks when it's reduced. + public class TrackedItem : ITrackedStack + { + /********* + ** Fields + *********/ + /// The item stack. + private readonly Item Item; + + /// The callback invoked when the stack size is reduced (including reduced to zero). + protected readonly Action OnReduced; + + /// The callback invoked when the stack is empty. + protected readonly Action OnEmpty; + + /// The last stack size handlers were notified of. + private int LastStackSize; + + + /********* + ** Accessors + *********/ + /// A sample item for comparison. + /// This should be equivalent to the underlying item (except in stack size), but *not* a reference to it. + public Item Sample { get; } + + /// The number of items in the stack. + public int Count => this.Item.Stack; + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The item stack. + /// The callback invoked when the stack size is reduced (including reduced to zero). + /// The callback invoked when the stack is empty. + public TrackedItem(Item item, Action onReduced = null, Action onEmpty = null) + { + this.Item = item ?? throw new InvalidOperationException("Can't track a null item stack."); + this.Sample = this.GetNewStack(item); + this.LastStackSize = item.Stack; + this.OnReduced = onReduced; + this.OnEmpty = onEmpty; + } + + /// Remove the specified number of this item from the stack. + /// The number to consume. + public void Reduce(int count) + { + this.Item.Stack -= Math.Max(0, count); + this.Delegate(); + } + + /// Remove the specified number of this item from the stack and return a new stack matching the count. + /// The number to get. + public Item Take(int count) + { + if (count <= 0) + return null; + + this.Reduce(count); + return this.GetNewStack(this.Item, count); + } + + + /********* + ** Private methods + *********/ + /// Notify handlers. + private void Delegate() + { + // skip if not reduced + if (this.Item.Stack >= this.LastStackSize) + return; + this.LastStackSize = this.Item.Stack; + + // notify handlers + this.OnReduced?.Invoke(this.Item); + if (this.Item.Stack <= 0) + this.OnEmpty?.Invoke(this.Item); + } + + /// Create a new stack of the given item. + /// The item stack to clone. + /// The new stack size. + private Item GetNewStack(Item original, int stackSize = 1) + { + if (original == null) + return null; + + Item stack = original.getOne(); + stack.Stack = stackSize; + return stack; + } + } +} diff --git a/Mods/Automate/Automate/TrackedItemCollection.cs b/Mods/Automate/Automate/TrackedItemCollection.cs new file mode 100644 index 00000000..969366c2 --- /dev/null +++ b/Mods/Automate/Automate/TrackedItemCollection.cs @@ -0,0 +1,84 @@ +using System.Collections.Generic; +using System.Linq; +using StardewValley; + +namespace Pathoschild.Stardew.Automate +{ + /// An item stack which wraps an underlying collection of stacks. + public class TrackedItemCollection : ITrackedStack + { + /********* + ** Fields + *********/ + /// The underlying item stacks. + private readonly ITrackedStack[] Stacks; + + + /********* + ** Accessors + *********/ + /// A sample item for comparison. + /// This should be equivalent to the underlying item (except in stack size), but *not* a reference to it. + public Item Sample { get; } + + /// The number of items in the stack. + public int Count => this.Stacks.Sum(p => p.Count); + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The underlying item stacks. + public TrackedItemCollection(IEnumerable stacks) + { + this.Stacks = stacks.ToArray(); + this.Sample = this.Stacks.FirstOrDefault()?.Sample; + } + + /// Remove the specified number of this item from the stack. + /// The number to consume. + public void Reduce(int count) + { + if (count <= 0 || !this.Stacks.Any()) + return; + + // reduce + int left = count; + foreach (ITrackedStack stack in this.Stacks) + { + // skip, stack empty + if (stack.Count <= 0) + continue; + + // take entire stack + if (stack.Count < left) + { + left -= stack.Count; + stack.Reduce(stack.Count); + continue; + } + + // take remaining items + stack.Reduce(left); + break; + } + } + + /// Remove the specified number of this item from the stack and return a new stack matching the count. + /// The number to get. + public Item Take(int count) + { + if (count <= 0 || !this.Stacks.Any()) + return null; + + // reduce + this.Reduce(count); + + // create new stack + Item item = this.Sample.getOne(); + item.Stack = count; + return item; + } + } +} diff --git a/Mods/Automate/Automate/screenshots/chests-anywhere-config.png b/Mods/Automate/Automate/screenshots/chests-anywhere-config.png new file mode 100644 index 00000000..1777e755 Binary files /dev/null and b/Mods/Automate/Automate/screenshots/chests-anywhere-config.png differ diff --git a/Mods/Automate/Automate/screenshots/connectors.png b/Mods/Automate/Automate/screenshots/connectors.png new file mode 100644 index 00000000..846ea60c Binary files /dev/null and b/Mods/Automate/Automate/screenshots/connectors.png differ diff --git a/Mods/Automate/Automate/screenshots/crab-pot-factory.png b/Mods/Automate/Automate/screenshots/crab-pot-factory.png new file mode 100644 index 00000000..97af06c4 Binary files /dev/null and b/Mods/Automate/Automate/screenshots/crab-pot-factory.png differ diff --git a/Mods/Automate/Automate/screenshots/example-overlay.png b/Mods/Automate/Automate/screenshots/example-overlay.png new file mode 100644 index 00000000..5768cb4e Binary files /dev/null and b/Mods/Automate/Automate/screenshots/example-overlay.png differ diff --git a/Mods/Automate/Automate/screenshots/extensibility-machine-groups.png b/Mods/Automate/Automate/screenshots/extensibility-machine-groups.png new file mode 100644 index 00000000..63e8ac2b Binary files /dev/null and b/Mods/Automate/Automate/screenshots/extensibility-machine-groups.png differ diff --git a/Mods/Automate/Automate/screenshots/iridium-bar-factory.png b/Mods/Automate/Automate/screenshots/iridium-bar-factory.png new file mode 100644 index 00000000..eb05b434 Binary files /dev/null and b/Mods/Automate/Automate/screenshots/iridium-bar-factory.png differ diff --git a/Mods/Automate/Automate/screenshots/iridium-cheese-factory.png b/Mods/Automate/Automate/screenshots/iridium-cheese-factory.png new file mode 100644 index 00000000..88c31da9 Binary files /dev/null and b/Mods/Automate/Automate/screenshots/iridium-cheese-factory.png differ diff --git a/Mods/Automate/Automate/screenshots/iridium-mead-factory.png b/Mods/Automate/Automate/screenshots/iridium-mead-factory.png new file mode 100644 index 00000000..44d3bd52 Binary files /dev/null and b/Mods/Automate/Automate/screenshots/iridium-mead-factory.png differ diff --git a/Mods/Automate/Automate/screenshots/refined-quartz-factory.png b/Mods/Automate/Automate/screenshots/refined-quartz-factory.png new file mode 100644 index 00000000..d6988480 Binary files /dev/null and b/Mods/Automate/Automate/screenshots/refined-quartz-factory.png differ diff --git a/Mods/Automate/Common/CommonHelper.cs b/Mods/Automate/Common/CommonHelper.cs new file mode 100644 index 00000000..720736e4 --- /dev/null +++ b/Mods/Automate/Common/CommonHelper.cs @@ -0,0 +1,345 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Graphics; +using Pathoschild.Stardew.Common.UI; +using StardewModdingAPI; +using StardewValley; +using StardewValley.Locations; +using StardewValley.Menus; + +namespace Pathoschild.Stardew.Common +{ + /// Provides common utility methods for interacting with the game code shared by my various mods. + internal static class CommonHelper + { + /********* + ** Fields + *********/ + /// A blank pixel which can be colorised and stretched to draw geometric shapes. + private static readonly Lazy LazyPixel = new Lazy(() => + { + Texture2D pixel = new Texture2D(Game1.graphics.GraphicsDevice, 1, 1); + pixel.SetData(new[] { Color.White }); + return pixel; + }); + + + /********* + ** Accessors + *********/ + /// A blank pixel which can be colorised and stretched to draw geometric shapes. + public static Texture2D Pixel => CommonHelper.LazyPixel.Value; + + /// The width of the horizontal and vertical scroll edges (between the origin position and start of content padding). + public static readonly Vector2 ScrollEdgeSize = new Vector2(CommonSprites.Scroll.TopLeft.Width * Game1.pixelZoom, CommonSprites.Scroll.TopLeft.Height * Game1.pixelZoom); + + + /********* + ** Public methods + *********/ + /**** + ** Game + ****/ + /// Get all game locations. + public static IEnumerable GetLocations() + { + return Game1.locations + .Concat( + from location in Game1.locations.OfType() + from building in location.buildings + where building.indoors.Value != null + select building.indoors.Value + ); + } + + /**** + ** Fonts + ****/ + /// Get the dimensions of a space character. + /// The font to measure. + public static float GetSpaceWidth(SpriteFont font) + { + return font.MeasureString("A B").X - font.MeasureString("AB").X; + } + + /**** + ** UI + ****/ + /// Draw a pretty hover box for the given text. + /// The sprite batch being drawn. + /// The text to display. + /// The position at which to draw the text. + /// The maximum width to display. + public static Vector2 DrawHoverBox(SpriteBatch spriteBatch, string label, in Vector2 position, float wrapWidth) + { + const int paddingSize = 27; + const int gutterSize = 20; + + Vector2 labelSize = spriteBatch.DrawTextBlock(Game1.smallFont, label, position + new Vector2(gutterSize), wrapWidth); // draw text to get wrapped text dimensions + IClickableMenu.drawTextureBox(spriteBatch, Game1.menuTexture, new Rectangle(0, 256, 60, 60), (int)position.X, (int)position.Y, (int)labelSize.X + paddingSize + gutterSize, (int)labelSize.Y + paddingSize, Color.White); + spriteBatch.DrawTextBlock(Game1.smallFont, label, position + new Vector2(gutterSize), wrapWidth); // draw again over texture box + + return labelSize + new Vector2(paddingSize); + } + + /// Draw a button background. + /// The sprite batch to which to draw. + /// The top-left pixel coordinate at which to draw the button. + /// The button content's pixel size. + /// The pixel position at which the content begins. + /// The button's outer bounds. + /// The padding between the content and border. + public static void DrawButton(SpriteBatch spriteBatch, in Vector2 position, in Vector2 contentSize, out Vector2 contentPos, out Rectangle bounds, int padding = 0) + { + CommonHelper.DrawContentBox( + spriteBatch: spriteBatch, + texture: CommonSprites.Button.Sheet, + background: CommonSprites.Button.Background, + top: CommonSprites.Button.Top, + right: CommonSprites.Button.Right, + bottom: CommonSprites.Button.Bottom, + left: CommonSprites.Button.Left, + topLeft: CommonSprites.Button.TopLeft, + topRight: CommonSprites.Button.TopRight, + bottomRight: CommonSprites.Button.BottomRight, + bottomLeft: CommonSprites.Button.BottomLeft, + position: position, + contentSize: contentSize, + contentPos: out contentPos, + bounds: out bounds, + padding: padding + ); + } + + /// Draw a scroll background. + /// The sprite batch to which to draw. + /// The top-left pixel coordinate at which to draw the scroll. + /// The scroll content's pixel size. + /// The pixel position at which the content begins. + /// The scroll's outer bounds. + /// The padding between the content and border. + public static void DrawScroll(SpriteBatch spriteBatch, in Vector2 position, in Vector2 contentSize, out Vector2 contentPos, out Rectangle bounds, int padding = 5) + { + CommonHelper.DrawContentBox( + spriteBatch: spriteBatch, + texture: CommonSprites.Scroll.Sheet, + background: in CommonSprites.Scroll.Background, + top: CommonSprites.Scroll.Top, + right: CommonSprites.Scroll.Right, + bottom: CommonSprites.Scroll.Bottom, + left: CommonSprites.Scroll.Left, + topLeft: CommonSprites.Scroll.TopLeft, + topRight: CommonSprites.Scroll.TopRight, + bottomRight: CommonSprites.Scroll.BottomRight, + bottomLeft: CommonSprites.Scroll.BottomLeft, + position: position, + contentSize: contentSize, + contentPos: out contentPos, + bounds: out bounds, + padding: padding + ); + } + + /// Draw a generic content box like a scroll or button. + /// The sprite batch to which to draw. + /// The texture to draw. + /// The source rectangle for the background. + /// The source rectangle for the top border. + /// The source rectangle for the right border. + /// The source rectangle for the bottom border. + /// The source rectangle for the left border. + /// The source rectangle for the top-left corner. + /// The source rectangle for the top-right corner. + /// The source rectangle for the bottom-right corner. + /// The source rectangle for the bottom-left corner. + /// The top-left pixel coordinate at which to draw the button. + /// The button content's pixel size. + /// The pixel position at which the content begins. + /// The box's outer bounds. + /// The padding between the content and border. + public static void DrawContentBox(SpriteBatch spriteBatch, Texture2D texture, in Rectangle background, in Rectangle top, in Rectangle right, in Rectangle bottom, in Rectangle left, in Rectangle topLeft, in Rectangle topRight, in Rectangle bottomRight, in Rectangle bottomLeft, in Vector2 position, in Vector2 contentSize, out Vector2 contentPos, out Rectangle bounds, int padding) + { + int cornerWidth = topLeft.Width * Game1.pixelZoom; + int cornerHeight = topLeft.Height * Game1.pixelZoom; + int innerWidth = (int)(contentSize.X + padding * 2); + int innerHeight = (int)(contentSize.Y + padding * 2); + int outerWidth = innerWidth + cornerWidth * 2; + int outerHeight = innerHeight + cornerHeight * 2; + int x = (int)position.X; + int y = (int)position.Y; + + // draw scroll background + spriteBatch.Draw(texture, new Rectangle(x + cornerWidth, y + cornerHeight, innerWidth, innerHeight), background, Color.White); + + // draw borders + spriteBatch.Draw(texture, new Rectangle(x + cornerWidth, y, innerWidth, cornerHeight), top, Color.White); + spriteBatch.Draw(texture, new Rectangle(x + cornerWidth, y + cornerHeight + innerHeight, innerWidth, cornerHeight), bottom, Color.White); + spriteBatch.Draw(texture, new Rectangle(x, y + cornerHeight, cornerWidth, innerHeight), left, Color.White); + spriteBatch.Draw(texture, new Rectangle(x + cornerWidth + innerWidth, y + cornerHeight, cornerWidth, innerHeight), right, Color.White); + + // draw corners + spriteBatch.Draw(texture, new Rectangle(x, y, cornerWidth, cornerHeight), topLeft, Color.White); + spriteBatch.Draw(texture, new Rectangle(x, y + cornerHeight + innerHeight, cornerWidth, cornerHeight), bottomLeft, Color.White); + spriteBatch.Draw(texture, new Rectangle(x + cornerWidth + innerWidth, y, cornerWidth, cornerHeight), topRight, Color.White); + spriteBatch.Draw(texture, new Rectangle(x + cornerWidth + innerWidth, y + cornerHeight + innerHeight, cornerWidth, cornerHeight), bottomRight, Color.White); + + // set out params + contentPos = new Vector2(x + cornerWidth + padding, y + cornerHeight + padding); + bounds = new Rectangle(x, y, outerWidth, outerHeight); + } + + /// Show an informational message to the player. + /// The message to show. + /// The number of milliseconds during which to keep the message on the screen before it fades (or null for the default time). + public static void ShowInfoMessage(string message, int? duration = null) + { + Game1.addHUDMessage(new HUDMessage(message, 3) { noIcon = true, timeLeft = duration ?? HUDMessage.defaultTime }); + } + + /// Show an error message to the player. + /// The message to show. + public static void ShowErrorMessage(string message) + { + Game1.addHUDMessage(new HUDMessage(message, 3)); + } + + /**** + ** Drawing + ****/ + /// Draw a sprite to the screen. + /// The sprite batch. + /// The X-position at which to start the line. + /// The X-position at which to start the line. + /// The line dimensions. + /// The color to tint the sprite. + public static void DrawLine(this SpriteBatch batch, float x, float y, in Vector2 size, in Color? color = null) + { + batch.Draw(CommonHelper.Pixel, new Rectangle((int)x, (int)y, (int)size.X, (int)size.Y), color ?? Color.White); + } + + /// Draw a block of text to the screen with the specified wrap width. + /// The sprite batch. + /// The sprite font. + /// The block of text to write. + /// The position at which to draw the text. + /// The width at which to wrap the text. + /// The text color. + /// Whether to draw bold text. + /// The font scale. + /// Returns the text dimensions. + public static Vector2 DrawTextBlock(this SpriteBatch batch, SpriteFont font, string text, in Vector2 position, float wrapWidth, in Color? color = null, bool bold = false, float scale = 1) + { + if (text == null) + return new Vector2(0, 0); + + // get word list + List words = new List(); + foreach (string word in text.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries)) + { + // split on newlines + string wordPart = word; + int newlineIndex; + while ((newlineIndex = wordPart.IndexOf(Environment.NewLine, StringComparison.InvariantCulture)) >= 0) + { + if (newlineIndex == 0) + { + words.Add(Environment.NewLine); + wordPart = wordPart.Substring(Environment.NewLine.Length); + } + else if (newlineIndex > 0) + { + words.Add(wordPart.Substring(0, newlineIndex)); + words.Add(Environment.NewLine); + wordPart = wordPart.Substring(newlineIndex + Environment.NewLine.Length); + } + } + + // add remaining word (after newline split) + if (wordPart.Length > 0) + words.Add(wordPart); + } + + // track draw values + float xOffset = 0; + float yOffset = 0; + float lineHeight = font.MeasureString("ABC").Y * scale; + float spaceWidth = CommonHelper.GetSpaceWidth(font) * scale; + float blockWidth = 0; + float blockHeight = lineHeight; + foreach (string word in words) + { + // check wrap width + float wordWidth = font.MeasureString(word).X * scale; + if (word == Environment.NewLine || ((wordWidth + xOffset) > wrapWidth && (int)xOffset != 0)) + { + xOffset = 0; + yOffset += lineHeight; + blockHeight += lineHeight; + } + if (word == Environment.NewLine) + continue; + + // draw text + Vector2 wordPosition = new Vector2(position.X + xOffset, position.Y + yOffset); + if (bold) + Utility.drawBoldText(batch, word, font, wordPosition, color ?? Color.Black, scale); + else + batch.DrawString(font, word, wordPosition, color ?? Color.Black, 0, Vector2.Zero, scale, SpriteEffects.None, 1); + + // update draw values + if (xOffset + wordWidth > blockWidth) + blockWidth = xOffset + wordWidth; + xOffset += wordWidth + spaceWidth; + } + + // return text position & dimensions + return new Vector2(blockWidth, blockHeight); + } + + /**** + ** Error handling + ****/ + /// Intercept errors thrown by the action. + /// Encapsulates monitoring and logging. + /// The verb describing where the error occurred (e.g. "looking that up"). This is displayed on the screen, so it should be simple and avoid characters that might not be available in the sprite font. + /// The action to invoke. + /// A callback invoked if an error is intercepted. + public static void InterceptErrors(this IMonitor monitor, string verb, Action action, Action onError = null) + { + monitor.InterceptErrors(verb, null, action, onError); + } + + /// Intercept errors thrown by the action. + /// Encapsulates monitoring and logging. + /// The verb describing where the error occurred (e.g. "looking that up"). This is displayed on the screen, so it should be simple and avoid characters that might not be available in the sprite font. + /// A more detailed form of if applicable. This is displayed in the log, so it can be more technical and isn't constrained by the sprite font. + /// The action to invoke. + /// A callback invoked if an error is intercepted. + public static void InterceptErrors(this IMonitor monitor, string verb, string detailedVerb, Action action, Action onError = null) + { + try + { + action(); + } + catch (Exception ex) + { + monitor.InterceptError(ex, verb, detailedVerb); + onError?.Invoke(ex); + } + } + + /// Log an error and warn the user. + /// Encapsulates monitoring and logging. + /// The exception to handle. + /// The verb describing where the error occurred (e.g. "looking that up"). This is displayed on the screen, so it should be simple and avoid characters that might not be available in the sprite font. + /// A more detailed form of if applicable. This is displayed in the log, so it can be more technical and isn't constrained by the sprite font. + public static void InterceptError(this IMonitor monitor, Exception ex, string verb, string detailedVerb = null) + { + detailedVerb = detailedVerb ?? verb; + monitor.Log($"Something went wrong {detailedVerb}:\n{ex}", LogLevel.Error); + CommonHelper.ShowErrorMessage($"Huh. Something went wrong {verb}. The error log has the technical details."); + } + } +} diff --git a/Mods/Automate/Common/DataParsers/CropDataParser.cs b/Mods/Automate/Common/DataParsers/CropDataParser.cs new file mode 100644 index 00000000..a84f4226 --- /dev/null +++ b/Mods/Automate/Common/DataParsers/CropDataParser.cs @@ -0,0 +1,93 @@ +using System; +using System.Linq; +using StardewModdingAPI.Utilities; +using StardewValley; +using SObject = StardewValley.Object; + +namespace Pathoschild.Stardew.Common.DataParsers +{ + /// Analyses crop data for a tile. + internal class CropDataParser + { + /********* + ** Accessors + *********/ + /// The crop. + public Crop Crop { get; } + + /// The seasons in which the crop grows. + public string[] Seasons { get; } + + /// The phase index in when the crop can be harvested. + public int HarvestablePhase { get; } + + /// The number of days needed between planting and first harvest. + public int DaysToFirstHarvest { get; } + + /// The number of days needed between harvests, after the first harvest. + public int DaysToSubsequentHarvest { get; } + + /// Whether the crop can be harvested multiple times. + public bool HasMultipleHarvests { get; } + + /// Whether the crop is ready to harvest now. + public bool CanHarvestNow { get; } + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The crop. + public CropDataParser(Crop crop) + { + this.Crop = crop; + if (crop != null) + { + this.Seasons = crop.seasonsToGrowIn.ToArray(); + this.HasMultipleHarvests = crop.regrowAfterHarvest.Value == -1; + this.HarvestablePhase = crop.phaseDays.Count - 1; + this.CanHarvestNow = (crop.currentPhase.Value >= this.HarvestablePhase) && (!crop.fullyGrown.Value || crop.dayOfCurrentPhase.Value <= 0); + this.DaysToFirstHarvest = crop.phaseDays.Take(crop.phaseDays.Count - 1).Sum(); // ignore harvestable phase + this.DaysToSubsequentHarvest = crop.regrowAfterHarvest.Value; + } + } + + /// Get the date when the crop will next be ready to harvest. + public SDate GetNextHarvest() + { + // get crop + Crop crop = this.Crop; + if (crop == null) + throw new InvalidOperationException("Can't get the harvest date because there's no crop."); + + // ready now + if (this.CanHarvestNow) + return SDate.Now(); + + // growing: days until next harvest + if (!crop.fullyGrown.Value) + { + int daysUntilLastPhase = this.DaysToFirstHarvest - this.Crop.dayOfCurrentPhase.Value - crop.phaseDays.Take(crop.currentPhase.Value).Sum(); + return SDate.Now().AddDays(daysUntilLastPhase); + } + + // regrowable crop harvested today + if (crop.dayOfCurrentPhase.Value >= crop.regrowAfterHarvest.Value) + return SDate.Now().AddDays(crop.regrowAfterHarvest.Value); + + // regrowable crop + // dayOfCurrentPhase decreases to 0 when fully grown, where <=0 is harvestable + return SDate.Now().AddDays(crop.dayOfCurrentPhase.Value); + } + + /// Get a sample item acquired by harvesting the crop. + public Item GetSampleDrop() + { + if (this.Crop == null) + throw new InvalidOperationException("Can't get a sample drop because there's no crop."); + + return new SObject(this.Crop.indexOfHarvest.Value, 1); + } + } +} diff --git a/Mods/Automate/Common/Integrations/Automate/AutomateIntegration.cs b/Mods/Automate/Common/Integrations/Automate/AutomateIntegration.cs new file mode 100644 index 00000000..9ee33a2d --- /dev/null +++ b/Mods/Automate/Common/Integrations/Automate/AutomateIntegration.cs @@ -0,0 +1,44 @@ +using System.Collections.Generic; +using Microsoft.Xna.Framework; +using StardewModdingAPI; +using StardewValley; + +namespace Pathoschild.Stardew.Common.Integrations.Automate +{ + /// Handles the logic for integrating with the Automate mod. + internal class AutomateIntegration : BaseIntegration + { + /********* + ** Fields + *********/ + /// The mod's public API. + private readonly IAutomateApi ModApi; + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// An API for fetching metadata about loaded mods. + /// Encapsulates monitoring and logging. + public AutomateIntegration(IModRegistry modRegistry, IMonitor monitor) + : base("Automate", "Pathoschild.Automate", "1.11.0", modRegistry, monitor) + { + if (!this.IsLoaded) + return; + + // get mod API + this.ModApi = this.GetValidatedApi(); + this.IsLoaded = this.ModApi != null; + } + + /// Get the status of machines in a tile area. This is a specialised API for Data Layers and similar mods. + /// The location for which to display data. + /// The tile area for which to display data. + public IDictionary GetMachineStates(GameLocation location, Rectangle tileArea) + { + this.AssertLoaded(); + return this.ModApi.GetMachineStates(location, tileArea); + } + } +} diff --git a/Mods/Automate/Common/Integrations/Automate/IAutomateApi.cs b/Mods/Automate/Common/Integrations/Automate/IAutomateApi.cs new file mode 100644 index 00000000..15801325 --- /dev/null +++ b/Mods/Automate/Common/Integrations/Automate/IAutomateApi.cs @@ -0,0 +1,15 @@ +using System.Collections.Generic; +using Microsoft.Xna.Framework; +using StardewValley; + +namespace Pathoschild.Stardew.Common.Integrations.Automate +{ + /// The API provided by the Automate mod. + public interface IAutomateApi + { + /// Get the status of machines in a tile area. This is a specialised API for Data Layers and similar mods. + /// The location for which to display data. + /// The tile area for which to display data. + IDictionary GetMachineStates(GameLocation location, Rectangle tileArea); + } +} diff --git a/Mods/Automate/Common/Integrations/BaseIntegration.cs b/Mods/Automate/Common/Integrations/BaseIntegration.cs new file mode 100644 index 00000000..13898dbc --- /dev/null +++ b/Mods/Automate/Common/Integrations/BaseIntegration.cs @@ -0,0 +1,82 @@ +using System; +using StardewModdingAPI; + +namespace Pathoschild.Stardew.Common.Integrations +{ + /// The base implementation for a mod integration. + internal abstract class BaseIntegration : IModIntegration + { + /********* + ** Fields + *********/ + /// The mod's unique ID. + protected string ModID { get; } + + /// An API for fetching metadata about loaded mods. + protected IModRegistry ModRegistry { get; } + + /// Encapsulates monitoring and logging. + protected IMonitor Monitor { get; } + + + /********* + ** Accessors + *********/ + /// A human-readable name for the mod. + public string Label { get; } + + /// Whether the mod is available. + public bool IsLoaded { get; protected set; } + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// A human-readable name for the mod. + /// The mod's unique ID. + /// The minimum version of the mod that's supported. + /// An API for fetching metadata about loaded mods. + /// Encapsulates monitoring and logging. + protected BaseIntegration(string label, string modID, string minVersion, IModRegistry modRegistry, IMonitor monitor) + { + // init + this.Label = label; + this.ModID = modID; + this.ModRegistry = modRegistry; + this.Monitor = monitor; + + // validate mod + IManifest manifest = modRegistry.Get(this.ModID)?.Manifest; + if (manifest == null) + return; + if (manifest.Version.IsOlderThan(minVersion)) + { + monitor.Log($"Detected {label} {manifest.Version}, but need {minVersion} or later. Disabled integration with this mod.", LogLevel.Warn); + return; + } + this.IsLoaded = true; + } + + /// Get an API for the mod, and show a message if it can't be loaded. + /// The API type. + protected TInterface GetValidatedApi() where TInterface : class + { + TInterface api = this.ModRegistry.GetApi(this.ModID); + if (api == null) + { + this.Monitor.Log($"Detected {this.Label}, but couldn't fetch its API. Disabled integration with this mod.", LogLevel.Warn); + return null; + } + return api; + } + + /// Assert that the integration is loaded. + /// The integration isn't loaded. + protected void AssertLoaded() + { + if (!this.IsLoaded) + throw new InvalidOperationException($"The {this.Label} integration isn't loaded."); + } + } +} diff --git a/Mods/Automate/Common/Integrations/BetterJunimos/BetterJunimosIntegration.cs b/Mods/Automate/Common/Integrations/BetterJunimos/BetterJunimosIntegration.cs new file mode 100644 index 00000000..6c649fca --- /dev/null +++ b/Mods/Automate/Common/Integrations/BetterJunimos/BetterJunimosIntegration.cs @@ -0,0 +1,40 @@ +using StardewModdingAPI; + +namespace Pathoschild.Stardew.Common.Integrations.BetterJunimos +{ + /// Handles the logic for integrating with the Better Junimos mod. + internal class BetterJunimosIntegration : BaseIntegration + { + /********* + ** Fields + *********/ + /// The mod's public API. + private readonly IBetterJunimosApi ModApi; + + + /********* + ** Accessors + *********/ + /// The Junimo Hut coverage radius. + public int MaxRadius { get; } + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// An API for fetching metadata about loaded mods. + /// Encapsulates monitoring and logging. + public BetterJunimosIntegration(IModRegistry modRegistry, IMonitor monitor) + : base("Better Junimos", "hawkfalcon.BetterJunimos", "0.5.0", modRegistry, monitor) + { + if (!this.IsLoaded) + return; + + // get mod API + this.ModApi = this.GetValidatedApi(); + this.IsLoaded = this.ModApi != null; + this.MaxRadius = this.ModApi?.GetJunimoHutMaxRadius() ?? 0; + } + } +} diff --git a/Mods/Automate/Common/Integrations/BetterJunimos/IBetterJunimosApi.cs b/Mods/Automate/Common/Integrations/BetterJunimos/IBetterJunimosApi.cs new file mode 100644 index 00000000..6081e89b --- /dev/null +++ b/Mods/Automate/Common/Integrations/BetterJunimos/IBetterJunimosApi.cs @@ -0,0 +1,9 @@ +namespace Pathoschild.Stardew.Common.Integrations.BetterJunimos +{ + /// The API provided by the Better Junimos mod. + public interface IBetterJunimosApi + { + /// Get the maximum radius for Junimo Huts. + int GetJunimoHutMaxRadius(); + } +} diff --git a/Mods/Automate/Common/Integrations/BetterSprinklers/BetterSprinklersIntegration.cs b/Mods/Automate/Common/Integrations/BetterSprinklers/BetterSprinklersIntegration.cs new file mode 100644 index 00000000..f7f48248 --- /dev/null +++ b/Mods/Automate/Common/Integrations/BetterSprinklers/BetterSprinklersIntegration.cs @@ -0,0 +1,49 @@ +using System.Collections.Generic; +using Microsoft.Xna.Framework; +using StardewModdingAPI; + +namespace Pathoschild.Stardew.Common.Integrations.BetterSprinklers +{ + /// Handles the logic for integrating with the Better Sprinklers mod. + internal class BetterSprinklersIntegration : BaseIntegration + { + /********* + ** Fields + *********/ + /// The mod's public API. + private readonly IBetterSprinklersApi ModApi; + + + /********* + ** Accessors + *********/ + /// The maximum possible sprinkler radius. + public int MaxRadius { get; } + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// An API for fetching metadata about loaded mods. + /// Encapsulates monitoring and logging. + public BetterSprinklersIntegration(IModRegistry modRegistry, IMonitor monitor) + : base("Better Sprinklers", "Speeder.BetterSprinklers", "2.3.1-unofficial.6-pathoschild", modRegistry, monitor) + { + if (!this.IsLoaded) + return; + + // get mod API + this.ModApi = this.GetValidatedApi(); + this.IsLoaded = this.ModApi != null; + this.MaxRadius = this.ModApi?.GetMaxGridSize() ?? 0; + } + + /// Get the configured Sprinkler tiles relative to (0, 0). + public IDictionary GetSprinklerTiles() + { + this.AssertLoaded(); + return this.ModApi.GetSprinklerCoverage(); + } + } +} diff --git a/Mods/Automate/Common/Integrations/BetterSprinklers/IBetterSprinklersApi.cs b/Mods/Automate/Common/Integrations/BetterSprinklers/IBetterSprinklersApi.cs new file mode 100644 index 00000000..c213f02e --- /dev/null +++ b/Mods/Automate/Common/Integrations/BetterSprinklers/IBetterSprinklersApi.cs @@ -0,0 +1,15 @@ +using System.Collections.Generic; +using Microsoft.Xna.Framework; + +namespace Pathoschild.Stardew.Common.Integrations.BetterSprinklers +{ + /// The API provided by the Better Sprinklers mod. + public interface IBetterSprinklersApi + { + /// Get the maximum supported coverage width or height. + int GetMaxGridSize(); + + /// Get the relative tile coverage by supported sprinkler ID. + IDictionary GetSprinklerCoverage(); + } +} diff --git a/Mods/Automate/Common/Integrations/Cobalt/CobaltIntegration.cs b/Mods/Automate/Common/Integrations/Cobalt/CobaltIntegration.cs new file mode 100644 index 00000000..4cb7c36d --- /dev/null +++ b/Mods/Automate/Common/Integrations/Cobalt/CobaltIntegration.cs @@ -0,0 +1,48 @@ +using System.Collections.Generic; +using Microsoft.Xna.Framework; +using StardewModdingAPI; + +namespace Pathoschild.Stardew.Common.Integrations.Cobalt +{ + /// Handles the logic for integrating with the Cobalt mod. + internal class CobaltIntegration : BaseIntegration + { + /********* + ** Fields + *********/ + /// The mod's public API. + private readonly ICobaltApi ModApi; + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// An API for fetching metadata about loaded mods. + /// Encapsulates monitoring and logging. + public CobaltIntegration(IModRegistry modRegistry, IMonitor monitor) + : base("Cobalt", "spacechase0.Cobalt", "1.1", modRegistry, monitor) + { + if (!this.IsLoaded) + return; + + // get mod API + this.ModApi = this.GetValidatedApi(); + this.IsLoaded = this.ModApi != null; + } + + /// Get the cobalt sprinkler's object ID. + public int GetSprinklerId() + { + this.AssertLoaded(); + return this.ModApi.GetSprinklerId(); + } + + /// Get the configured Sprinkler tiles relative to (0, 0). + public IEnumerable GetSprinklerTiles() + { + this.AssertLoaded(); + return this.ModApi.GetSprinklerCoverage(Vector2.Zero); + } + } +} diff --git a/Mods/Automate/Common/Integrations/Cobalt/ICobaltApi.cs b/Mods/Automate/Common/Integrations/Cobalt/ICobaltApi.cs new file mode 100644 index 00000000..4952043f --- /dev/null +++ b/Mods/Automate/Common/Integrations/Cobalt/ICobaltApi.cs @@ -0,0 +1,19 @@ +using System.Collections.Generic; +using Microsoft.Xna.Framework; + +namespace Pathoschild.Stardew.Common.Integrations.Cobalt +{ + /// The API provided by the Cobalt mod. + public interface ICobaltApi + { + /********* + ** Public methods + *********/ + /// Get the cobalt sprinkler's object ID. + int GetSprinklerId(); + + /// Get the cobalt sprinkler coverage. + /// The tile position containing the sprinkler. + IEnumerable GetSprinklerCoverage(Vector2 origin); + } +} diff --git a/Mods/Automate/Common/Integrations/CustomFarmingRedux/CustomFarmingReduxIntegration.cs b/Mods/Automate/Common/Integrations/CustomFarmingRedux/CustomFarmingReduxIntegration.cs new file mode 100644 index 00000000..277c95c6 --- /dev/null +++ b/Mods/Automate/Common/Integrations/CustomFarmingRedux/CustomFarmingReduxIntegration.cs @@ -0,0 +1,49 @@ +using System; +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Graphics; +using StardewModdingAPI; +using StardewValley; +using SObject = StardewValley.Object; + +namespace Pathoschild.Stardew.Common.Integrations.CustomFarmingRedux +{ + /// Handles the logic for integrating with the Custom Farming Redux mod. + internal class CustomFarmingReduxIntegration : BaseIntegration + { + /********* + ** Fields + *********/ + /// The mod's public API. + private readonly ICustomFarmingApi ModApi; + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// An API for fetching metadata about loaded mods. + /// Encapsulates monitoring and logging. + public CustomFarmingReduxIntegration(IModRegistry modRegistry, IMonitor monitor) + : base("Custom Farming Redux", "Platonymous.CustomFarming", "2.8.5", modRegistry, monitor) + { + if (!this.IsLoaded) + return; + + // get mod API + this.ModApi = this.GetValidatedApi(); + this.IsLoaded = this.ModApi != null; + } + + /// Get the sprite info for a custom object, or null if the object isn't custom. + /// The custom object. + public SpriteInfo GetSprite(SObject obj) + { + this.AssertLoaded(); + + Tuple data = this.ModApi.getRealItemAndTexture(obj); + return data != null + ? new SpriteInfo(data.Item2, data.Item3) + : null; + } + } +} diff --git a/Mods/Automate/Common/Integrations/CustomFarmingRedux/ICustomFarmingApi.cs b/Mods/Automate/Common/Integrations/CustomFarmingRedux/ICustomFarmingApi.cs new file mode 100644 index 00000000..14b80ffb --- /dev/null +++ b/Mods/Automate/Common/Integrations/CustomFarmingRedux/ICustomFarmingApi.cs @@ -0,0 +1,20 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Graphics; +using StardewValley; + +namespace Pathoschild.Stardew.Common.Integrations.CustomFarmingRedux +{ + /// The API provided by the Custom Farming Redux mod. + [SuppressMessage("ReSharper", "InconsistentNaming", Justification = "The naming convention is defined by the Custom Farming Redux mod.")] + public interface ICustomFarmingApi + { + /********* + ** Public methods + *********/ + /// Get metadata for a custom machine and draw metadata for an object. + /// The item that would be replaced by the custom item. + Tuple getRealItemAndTexture(StardewValley.Object dummy); + } +} diff --git a/Mods/Automate/Common/Integrations/FarmExpansion/FarmExpansionIntegration.cs b/Mods/Automate/Common/Integrations/FarmExpansion/FarmExpansionIntegration.cs new file mode 100644 index 00000000..a41135e5 --- /dev/null +++ b/Mods/Automate/Common/Integrations/FarmExpansion/FarmExpansionIntegration.cs @@ -0,0 +1,49 @@ +using StardewModdingAPI; +using StardewValley; + +namespace Pathoschild.Stardew.Common.Integrations.FarmExpansion +{ + /// Handles the logic for integrating with the Farm Expansion mod. + internal class FarmExpansionIntegration : BaseIntegration + { + /********* + ** Fields + *********/ + /// The mod's public API. + private readonly IFarmExpansionApi ModApi; + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// An API for fetching metadata about loaded mods. + /// Encapsulates monitoring and logging. + public FarmExpansionIntegration(IModRegistry modRegistry, IMonitor monitor) + : base("Farm Expansion", "Advize.FarmExpansion", "3.3", modRegistry, monitor) + { + if (!this.IsLoaded) + return; + + // get mod API + this.ModApi = this.GetValidatedApi(); + this.IsLoaded = this.ModApi != null; + } + + /// Add a blueprint to all future carpenter menus for the farm area. + /// The blueprint to add. + public void AddFarmBluePrint(BluePrint blueprint) + { + this.AssertLoaded(); + this.ModApi.AddFarmBluePrint(blueprint); + } + + /// Add a blueprint to all future carpenter menus for the expansion area. + /// The blueprint to add. + public void AddExpansionBluePrint(BluePrint blueprint) + { + this.AssertLoaded(); + this.ModApi.AddExpansionBluePrint(blueprint); + } + } +} diff --git a/Mods/Automate/Common/Integrations/FarmExpansion/IFarmExpansionApi.cs b/Mods/Automate/Common/Integrations/FarmExpansion/IFarmExpansionApi.cs new file mode 100644 index 00000000..2c4d92a1 --- /dev/null +++ b/Mods/Automate/Common/Integrations/FarmExpansion/IFarmExpansionApi.cs @@ -0,0 +1,16 @@ +using StardewValley; + +namespace Pathoschild.Stardew.Common.Integrations.FarmExpansion +{ + /// The API provided by the Farm Expansion mod. + public interface IFarmExpansionApi + { + /// Add a blueprint to all future carpenter menus for the farm area. + /// The blueprint to add. + void AddFarmBluePrint(BluePrint blueprint); + + /// Add a blueprint to all future carpenter menus for the expansion area. + /// The blueprint to add. + void AddExpansionBluePrint(BluePrint blueprint); + } +} diff --git a/Mods/Automate/Common/Integrations/IModIntegration.cs b/Mods/Automate/Common/Integrations/IModIntegration.cs new file mode 100644 index 00000000..17327ed8 --- /dev/null +++ b/Mods/Automate/Common/Integrations/IModIntegration.cs @@ -0,0 +1,15 @@ +namespace Pathoschild.Stardew.Common.Integrations +{ + /// Handles integration with a given mod. + internal interface IModIntegration + { + /********* + ** Accessors + *********/ + /// A human-readable name for the mod. + string Label { get; } + + /// Whether the mod is available. + bool IsLoaded { get; } + } +} diff --git a/Mods/Automate/Common/Integrations/LineSprinklers/ILineSprinklersApi.cs b/Mods/Automate/Common/Integrations/LineSprinklers/ILineSprinklersApi.cs new file mode 100644 index 00000000..a945c8c3 --- /dev/null +++ b/Mods/Automate/Common/Integrations/LineSprinklers/ILineSprinklersApi.cs @@ -0,0 +1,15 @@ +using System.Collections.Generic; +using Microsoft.Xna.Framework; + +namespace Pathoschild.Stardew.Common.Integrations.LineSprinklers +{ + /// The API provided by the Line Sprinklers mod. + public interface ILineSprinklersApi + { + /// Get the maximum supported coverage width or height. + int GetMaxGridSize(); + + /// Get the relative tile coverage by supported sprinkler ID. + IDictionary GetSprinklerCoverage(); + } +} diff --git a/Mods/Automate/Common/Integrations/LineSprinklers/LineSprinklersIntegration.cs b/Mods/Automate/Common/Integrations/LineSprinklers/LineSprinklersIntegration.cs new file mode 100644 index 00000000..d5aa4fce --- /dev/null +++ b/Mods/Automate/Common/Integrations/LineSprinklers/LineSprinklersIntegration.cs @@ -0,0 +1,49 @@ +using System.Collections.Generic; +using Microsoft.Xna.Framework; +using StardewModdingAPI; + +namespace Pathoschild.Stardew.Common.Integrations.LineSprinklers +{ + /// Handles the logic for integrating with the Line Sprinklers mod. + internal class LineSprinklersIntegration : BaseIntegration + { + /********* + ** Fields + *********/ + /// The mod's public API. + private readonly ILineSprinklersApi ModApi; + + + /********* + ** Accessors + *********/ + /// The maximum possible sprinkler radius. + public int MaxRadius { get; } + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// An API for fetching metadata about loaded mods. + /// Encapsulates monitoring and logging. + public LineSprinklersIntegration(IModRegistry modRegistry, IMonitor monitor) + : base("Line Sprinklers", "hootless.LineSprinklers", "1.1.0", modRegistry, monitor) + { + if (!this.IsLoaded) + return; + + // get mod API + this.ModApi = this.GetValidatedApi(); + this.IsLoaded = this.ModApi != null; + this.MaxRadius = this.ModApi?.GetMaxGridSize() ?? 0; + } + + /// Get the configured Sprinkler tiles relative to (0, 0). + public IDictionary GetSprinklerTiles() + { + this.AssertLoaded(); + return this.ModApi.GetSprinklerCoverage(); + } + } +} diff --git a/Mods/Automate/Common/Integrations/PelicanFiber/PelicanFiberIntegration.cs b/Mods/Automate/Common/Integrations/PelicanFiber/PelicanFiberIntegration.cs new file mode 100644 index 00000000..f90cfb74 --- /dev/null +++ b/Mods/Automate/Common/Integrations/PelicanFiber/PelicanFiberIntegration.cs @@ -0,0 +1,49 @@ +using StardewModdingAPI; +using StardewValley; + +namespace Pathoschild.Stardew.Common.Integrations.PelicanFiber +{ + /// Handles the logic for integrating with the Pelican Fiber mod. + internal class PelicanFiberIntegration : BaseIntegration + { + /********* + ** Fields + *********/ + /// The full type name of the Pelican Fiber mod's build menu. + private readonly string MenuTypeName = "PelicanFiber.Framework.ConstructionMenu"; + + /// An API for accessing private code. + private readonly IReflectionHelper Reflection; + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// An API for fetching metadata about loaded mods. + /// An API for accessing private code. + /// Encapsulates monitoring and logging. + public PelicanFiberIntegration(IModRegistry modRegistry, IReflectionHelper reflection, IMonitor monitor) + : base("Pelican Fiber", "jwdred.PelicanFiber", "3.0.2", modRegistry, monitor) + { + this.Reflection = reflection; + } + + /// Get whether the Pelican Fiber build menu is open. + public bool IsBuildMenuOpen() + { + this.AssertLoaded(); + return Game1.activeClickableMenu?.GetType().FullName == this.MenuTypeName; + } + + /// Get the selected blueprint from the Pelican Fiber build menu, if it's open. + public BluePrint GetBuildMenuBlueprint() + { + this.AssertLoaded(); + if (!this.IsBuildMenuOpen()) + return null; + + return this.Reflection.GetProperty(Game1.activeClickableMenu, "CurrentBlueprint").GetValue(); + } + } +} diff --git a/Mods/Automate/Common/Integrations/PrismaticTools/IPrismaticToolsApi.cs b/Mods/Automate/Common/Integrations/PrismaticTools/IPrismaticToolsApi.cs new file mode 100644 index 00000000..b2a61ed3 --- /dev/null +++ b/Mods/Automate/Common/Integrations/PrismaticTools/IPrismaticToolsApi.cs @@ -0,0 +1,19 @@ +using System.Collections.Generic; +using Microsoft.Xna.Framework; + +namespace Pathoschild.Stardew.Common.Integrations.PrismaticTools +{ + /// The API provided by the Prismatic Tools mod. + public interface IPrismaticToolsApi + { + /// Whether prismatic sprinklers also act as scarecrows. + bool ArePrismaticSprinklersScarecrows { get; } + + /// The prismatic sprinkler object ID. + int SprinklerIndex { get; } + + /// Get the relative tile coverage for a prismatic sprinkler. + /// The sprinkler tile. + IEnumerable GetSprinklerCoverage(Vector2 origin); + } +} diff --git a/Mods/Automate/Common/Integrations/PrismaticTools/PrismaticToolsIntegration.cs b/Mods/Automate/Common/Integrations/PrismaticTools/PrismaticToolsIntegration.cs new file mode 100644 index 00000000..b35e6f35 --- /dev/null +++ b/Mods/Automate/Common/Integrations/PrismaticTools/PrismaticToolsIntegration.cs @@ -0,0 +1,55 @@ +using System.Collections.Generic; +using Microsoft.Xna.Framework; +using StardewModdingAPI; + +namespace Pathoschild.Stardew.Common.Integrations.PrismaticTools +{ + /// Handles the logic for integrating with the Prismatic Tools mod. + internal class PrismaticToolsIntegration : BaseIntegration + { + /********* + ** Fields + *********/ + /// The mod's public API. + private readonly IPrismaticToolsApi ModApi; + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// An API for fetching metadata about loaded mods. + /// Encapsulates monitoring and logging. + public PrismaticToolsIntegration(IModRegistry modRegistry, IMonitor monitor) + : base("Prismatic Tools", "stokastic.PrismaticTools", "1.3.0", modRegistry, monitor) + { + if (!this.IsLoaded) + return; + + // get mod API + this.ModApi = this.GetValidatedApi(); + this.IsLoaded = this.ModApi != null; + } + + /// Get whether prismatic sprinklers also act as scarecrows. + public bool ArePrismaticSprinklersScarecrows() + { + this.AssertLoaded(); + return this.ModApi.ArePrismaticSprinklersScarecrows; + } + + /// Get the prismatic sprinkler object ID. + public int GetSprinklerID() + { + this.AssertLoaded(); + return this.ModApi.SprinklerIndex; + } + + /// Get the relative tile coverage for a prismatic sprinkler. + public IEnumerable GetSprinklerCoverage() + { + this.AssertLoaded(); + return this.ModApi.GetSprinklerCoverage(Vector2.Zero); + } + } +} diff --git a/Mods/Automate/Common/Integrations/SimpleSprinkler/ISimplerSprinklerApi.cs b/Mods/Automate/Common/Integrations/SimpleSprinkler/ISimplerSprinklerApi.cs new file mode 100644 index 00000000..68d8e05a --- /dev/null +++ b/Mods/Automate/Common/Integrations/SimpleSprinkler/ISimplerSprinklerApi.cs @@ -0,0 +1,12 @@ +using System.Collections.Generic; +using Microsoft.Xna.Framework; + +namespace Pathoschild.Stardew.Common.Integrations.SimpleSprinkler +{ + /// The API provided by the Simple Sprinkler mod. + public interface ISimplerSprinklerApi + { + /// Get the relative tile coverage for supported sprinkler IDs (additive to the game's default coverage). + IDictionary GetNewSprinklerCoverage(); + } +} diff --git a/Mods/Automate/Common/Integrations/SimpleSprinkler/SimpleSprinklerIntegration.cs b/Mods/Automate/Common/Integrations/SimpleSprinkler/SimpleSprinklerIntegration.cs new file mode 100644 index 00000000..ef21dd31 --- /dev/null +++ b/Mods/Automate/Common/Integrations/SimpleSprinkler/SimpleSprinklerIntegration.cs @@ -0,0 +1,41 @@ +using System.Collections.Generic; +using Microsoft.Xna.Framework; +using StardewModdingAPI; + +namespace Pathoschild.Stardew.Common.Integrations.SimpleSprinkler +{ + /// Handles the logic for integrating with the Simple Sprinkler mod. + internal class SimpleSprinklerIntegration : BaseIntegration + { + /********* + ** Fields + *********/ + /// The mod's public API. + private readonly ISimplerSprinklerApi ModApi; + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// An API for fetching metadata about loaded mods. + /// Encapsulates monitoring and logging. + public SimpleSprinklerIntegration(IModRegistry modRegistry, IMonitor monitor) + : base("Simple Sprinklers", "tZed.SimpleSprinkler", "1.6.0", modRegistry, monitor) + { + if (!this.IsLoaded) + return; + + // get mod API + this.ModApi = this.GetValidatedApi(); + this.IsLoaded = this.ModApi != null; + } + + /// Get the Sprinkler tiles relative to (0, 0), additive to the game's default sprinkler coverage. + public IDictionary GetNewSprinklerTiles() + { + this.AssertLoaded(); + return this.ModApi.GetNewSprinklerCoverage(); + } + } +} diff --git a/Mods/Automate/Common/PathUtilities.cs b/Mods/Automate/Common/PathUtilities.cs new file mode 100644 index 00000000..40b174f0 --- /dev/null +++ b/Mods/Automate/Common/PathUtilities.cs @@ -0,0 +1,86 @@ +using System; +using System.Diagnostics.Contracts; +using System.IO; +using System.Linq; +using System.Text.RegularExpressions; + +namespace Pathoschild.Stardew.Common +{ + /// Provides utilities for normalising file paths. + /// This class is duplicated from StardewModdingAPI.Toolkit.Utilities. + internal static class PathUtilities + { + /********* + ** Fields + *********/ + /// The possible directory separator characters in a file path. + private static readonly char[] PossiblePathSeparators = new[] { '/', '\\', Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar }.Distinct().ToArray(); + + /// The preferred directory separator chaeacter in an asset key. + private static readonly string PreferredPathSeparator = Path.DirectorySeparatorChar.ToString(); + + + /********* + ** Public methods + *********/ + /// Get the segments from a path (e.g. /usr/bin/boop => usr, bin, and boop). + /// The path to split. + /// The number of segments to match. Any additional segments will be merged into the last returned part. + public static string[] GetSegments(string path, int? limit = null) + { + return limit.HasValue + ? path.Split(PathUtilities.PossiblePathSeparators, limit.Value, StringSplitOptions.RemoveEmptyEntries) + : path.Split(PathUtilities.PossiblePathSeparators, StringSplitOptions.RemoveEmptyEntries); + } + + /// Normalise path separators in a file path. + /// The file path to normalise. + [Pure] + public static string NormalisePathSeparators(string path) + { + string[] parts = PathUtilities.GetSegments(path); + string normalised = string.Join(PathUtilities.PreferredPathSeparator, parts); + if (path.StartsWith(PathUtilities.PreferredPathSeparator)) + normalised = PathUtilities.PreferredPathSeparator + normalised; // keep root slash + return normalised; + } + + /// Get a directory or file path relative to a given source path. + /// The source folder path. + /// The target folder or file path. + [Pure] + public static string GetRelativePath(string sourceDir, string targetPath) + { + // convert to URIs + Uri from = new Uri(sourceDir.TrimEnd(PathUtilities.PossiblePathSeparators) + "/"); + Uri to = new Uri(targetPath.TrimEnd(PathUtilities.PossiblePathSeparators) + "/"); + if (from.Scheme != to.Scheme) + throw new InvalidOperationException($"Can't get path for '{targetPath}' relative to '{sourceDir}'."); + + // get relative path + string relative = PathUtilities.NormalisePathSeparators(Uri.UnescapeDataString(from.MakeRelativeUri(to).ToString())); + if (relative == "") + relative = "./"; + return relative; + } + + /// Get whether a path is relative and doesn't try to climb out of its containing folder (e.g. doesn't contain ../). + /// The path to check. + public static bool IsSafeRelativePath(string path) + { + if (string.IsNullOrWhiteSpace(path)) + return true; + + return + !Path.IsPathRooted(path) + && PathUtilities.GetSegments(path).All(segment => segment.Trim() != ".."); + } + + /// Get whether a string is a valid 'slug', containing only basic characters that are safe in all contexts (e.g. filenames, URLs, etc). + /// The string to check. + public static bool IsSlug(string str) + { + return !Regex.IsMatch(str, "[^a-z0-9_.-]", RegexOptions.IgnoreCase); + } + } +} diff --git a/Mods/Automate/Common/SpriteInfo.cs b/Mods/Automate/Common/SpriteInfo.cs new file mode 100644 index 00000000..b7c3be5e --- /dev/null +++ b/Mods/Automate/Common/SpriteInfo.cs @@ -0,0 +1,31 @@ +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Graphics; + +namespace Pathoschild.Stardew.Common +{ + /// Represents a single sprite in a spritesheet. + internal class SpriteInfo + { + /********* + ** Accessors + *********/ + /// The spritesheet texture. + public Texture2D Spritesheet { get; } + + /// The area in the spritesheet containing the sprite. + public Rectangle SourceRectangle { get; } + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The spritesheet texture. + /// The area in the spritesheet containing the sprite. + public SpriteInfo(Texture2D spritesheet, Rectangle sourceRectangle) + { + this.Spritesheet = spritesheet; + this.SourceRectangle = sourceRectangle; + } + } +} diff --git a/Mods/Automate/Common/StringEnumArrayConverter.cs b/Mods/Automate/Common/StringEnumArrayConverter.cs new file mode 100644 index 00000000..29e78167 --- /dev/null +++ b/Mods/Automate/Common/StringEnumArrayConverter.cs @@ -0,0 +1,153 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; +using Newtonsoft.Json.Linq; + +namespace Pathoschild.Stardew.Common +{ + /// A variant of which represents arrays in JSON as a comma-delimited string. + internal class StringEnumArrayConverter : StringEnumConverter + { + /********* + ** Fields + *********/ + /// Whether to return null values for missing data instead of an empty array. + public bool AllowNull { get; set; } + + + /********* + ** Public methods + *********/ + /// Get whether this instance can convert the specified object type. + /// The object type. + public override bool CanConvert(Type type) + { + if (!type.IsArray) + return false; + + Type elementType = this.GetElementType(type); + return elementType != null && base.CanConvert(elementType); + } + + /// Read a JSON representation. + /// The JSON reader from which to read. + /// The value type. + /// The raw value of the object being read. + /// The calling serializer. + public override object ReadJson(JsonReader reader, Type valueType, object rawValue, JsonSerializer serializer) + { + // get element type + Type elementType = this.GetElementType(valueType); + if (elementType == null) + throw new InvalidOperationException("Couldn't extract enum array element type."); // should never happen since we validate in CanConvert + + // parse + switch (reader.TokenType) + { + case JsonToken.Null: + return this.GetNullOrEmptyArray(elementType); + + case JsonToken.StartArray: + { + string[] elements = JArray.Load(reader).Values().ToArray(); + object[] parsed = elements.Select(raw => this.ParseOne(raw, elementType)).ToArray(); + return this.Cast(parsed, elementType); + } + + case JsonToken.String: + { + string value = (string)JToken.Load(reader); + + if (string.IsNullOrWhiteSpace(value)) + return this.GetNullOrEmptyArray(elementType); + + object[] parsed = this.ParseMany(value, elementType).ToArray(); + return this.Cast(parsed, elementType); + } + + default: + return base.ReadJson(reader, valueType, rawValue, serializer); + } + } + + /// Write a JSON representation. + /// The JSON writer to which to write. + /// The value. + /// The calling serializer. + public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer) + { + if (value == null) + writer.WriteNull(); + else if (value is IEnumerable list) + { + string[] array = (from object element in list where element != null select element.ToString()).ToArray(); + writer.WriteValue(string.Join(", ", array)); + } + else + base.WriteJson(writer, value, serializer); + } + + + /********* + ** Private methods + *********/ + /// Get the underlying array element type (bypassing if necessary). + /// The array type. + private Type GetElementType(Type type) + { + if (!type.IsArray) + return null; + + type = type.GetElementType(); + if (type == null) + return null; + + type = Nullable.GetUnderlyingType(type) ?? type; + + return type; + } + + /// Parse a string into individual values. + /// The input string. + /// The enum type. + private IEnumerable ParseMany(string input, Type elementType) + { + string[] values = input.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries); + foreach (string value in values) + yield return this.ParseOne(value, elementType); + } + + /// Parse a string into one value. + /// The input string. + /// The enum type. + private object ParseOne(string input, Type elementType) + { + return Enum.Parse(elementType, input, ignoreCase: true); + } + + /// Get null or an empty array, depending on the value of . + /// The enum type. + private Array GetNullOrEmptyArray(Type elementType) + { + return this.AllowNull + ? null + : Array.CreateInstance(elementType, 0); + } + + /// Create an array of elements with the given type. + /// The array elements. + /// The array element type. + private Array Cast(object[] elements, Type elementType) + { + if (elements == null) + return null; + + Array result = Array.CreateInstance(elementType, elements.Length); + Array.Copy(elements, result, result.Length); + return result; + } + } +} diff --git a/Mods/Automate/Common/TileHelper.cs b/Mods/Automate/Common/TileHelper.cs new file mode 100644 index 00000000..c96aeb92 --- /dev/null +++ b/Mods/Automate/Common/TileHelper.cs @@ -0,0 +1,126 @@ +using System.Collections.Generic; +using System.Linq; +using Microsoft.Xna.Framework; +using StardewValley; +using xTile.Layers; + +namespace Pathoschild.Stardew.Common +{ + /// Provides extension methods for working with tiles. + internal static class TileHelper + { + /********* + ** Public methods + *********/ + /**** + ** Location + ****/ + /// Get the tile coordinates in the game location. + /// The game location to search. + public static IEnumerable GetTiles(this GameLocation location) + { + if (location?.Map?.Layers == null) + return Enumerable.Empty(); + + Layer layer = location.Map.Layers[0]; + return TileHelper.GetTiles(0, 0, layer.LayerWidth, layer.LayerHeight); + } + + /**** + ** Rectangle + ****/ + /// Get the tile coordinates in the tile area. + /// The tile area to search. + public static IEnumerable GetTiles(this Rectangle area) + { + return TileHelper.GetTiles(area.X, area.Y, area.Width, area.Height); + } + + /// Expand a rectangle equally in all directions. + /// The rectangle to expand. + /// The number of tiles to add in each direction. + public static Rectangle Expand(this Rectangle area, int distance) + { + return new Rectangle(area.X - distance, area.Y - distance, area.Width + distance * 2, area.Height + distance * 2); + } + + /**** + ** Tiles + ****/ + /// Get the eight tiles surrounding the given tile. + /// The center tile. + public static IEnumerable GetSurroundingTiles(this Vector2 tile) + { + return Utility.getSurroundingTileLocationsArray(tile); + } + + /// Get the tiles surrounding the given tile area. + /// The center tile area. + public static IEnumerable GetSurroundingTiles(this Rectangle area) + { + for (int x = area.X - 1; x <= area.X + area.Width; x++) + { + for (int y = area.Y - 1; y <= area.Y + area.Height; y++) + { + if (!area.Contains(x, y)) + yield return new Vector2(x, y); + } + } + } + + /// Get the four tiles adjacent to the given tile. + /// The center tile. + public static IEnumerable GetAdjacentTiles(this Vector2 tile) + { + return Utility.getAdjacentTileLocationsArray(tile); + } + + /// Get a rectangular grid of tiles. + /// The X coordinate of the top-left tile. + /// The Y coordinate of the top-left tile. + /// The grid width. + /// The grid height. + public static IEnumerable GetTiles(int x, int y, int width, int height) + { + for (int curX = x, maxX = x + width - 1; curX <= maxX; curX++) + { + for (int curY = y, maxY = y + height - 1; curY <= maxY; curY++) + yield return new Vector2(curX, curY); + } + } + + /// Get all tiles which are on-screen. + public static IEnumerable GetVisibleTiles() + { + return TileHelper.GetVisibleArea().GetTiles(); + } + + /// Get the tile area visible on-screen. + public static Rectangle GetVisibleArea() + { + return new Rectangle( + x: Game1.viewport.X / Game1.tileSize, + y: Game1.viewport.Y / Game1.tileSize, + width: (int)(Game1.viewport.Width / (decimal)Game1.tileSize) + 2, // extend off-screen slightly to avoid edges popping in + height: (int)(Game1.viewport.Height / (decimal)Game1.tileSize) + 2 + ); + } + + /**** + ** Cursor + ****/ + /// Get the tile under the player's cursor (not restricted to the player's grab tile range). + public static Vector2 GetTileFromCursor() + { + return TileHelper.GetTileFromScreenPosition(Game1.getMouseX(), Game1.getMouseY()); + } + + /// Get the tile at the pixel coordinate relative to the top-left corner of the screen. + /// The pixel X coordinate. + /// The pixel Y coordinate. + public static Vector2 GetTileFromScreenPosition(float x, float y) + { + return new Vector2((int)((Game1.viewport.X + x) / Game1.tileSize), (int)((Game1.viewport.Y + y) / Game1.tileSize)); + } + } +} diff --git a/Mods/Automate/Common/UI/BaseOverlay.cs b/Mods/Automate/Common/UI/BaseOverlay.cs new file mode 100644 index 00000000..4b515ec5 --- /dev/null +++ b/Mods/Automate/Common/UI/BaseOverlay.cs @@ -0,0 +1,214 @@ +using System; +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Graphics; +using Microsoft.Xna.Framework.Input; +using StardewModdingAPI; +using StardewModdingAPI.Events; +using StardewValley; +using Rectangle = xTile.Dimensions.Rectangle; + +namespace Pathoschild.Stardew.Common.UI +{ + /// An interface which supports user interaction and overlays the active menu (if any). + internal abstract class BaseOverlay : IDisposable + { + /********* + ** Fields + *********/ + /// The SMAPI events available for mods. + private readonly IModEvents Events; + + /// An API for checking and changing input state. + protected readonly IInputHelper InputHelper; + + /// The last viewport bounds. + private Rectangle LastViewport; + + /// Indicates whether to keep the overlay active. If null, the overlay is kept until explicitly disposed. + private readonly Func KeepAliveCheck; + + + /********* + ** Public methods + *********/ + /// Release all resources. + public virtual void Dispose() + { + this.Events.Display.Rendered -= this.OnRendered; + this.Events.GameLoop.UpdateTicked -= this.OnUpdateTicked; + this.Events.Input.ButtonPressed -= this.OnButtonPressed; + this.Events.Input.CursorMoved -= this.OnCursorMoved; + this.Events.Input.MouseWheelScrolled -= this.OnMouseWheelScrolled; + } + + + /********* + ** Protected methods + *********/ + /**** + ** Implementation + ****/ + /// Construct an instance. + /// The SMAPI events available for mods. + /// An API for checking and changing input state. + /// Indicates whether to keep the overlay active. If null, the overlay is kept until explicitly disposed. + protected BaseOverlay(IModEvents events, IInputHelper inputHelper, Func keepAlive = null) + { + this.Events = events; + this.InputHelper = inputHelper; + this.KeepAliveCheck = keepAlive; + this.LastViewport = new Rectangle(Game1.viewport.X, Game1.viewport.Y, Game1.viewport.Width, Game1.viewport.Height); + + events.Display.Rendered += this.OnRendered; + events.GameLoop.UpdateTicked += this.OnUpdateTicked; + events.Input.ButtonPressed += this.OnButtonPressed; + events.Input.CursorMoved += this.OnCursorMoved; + events.Input.MouseWheelScrolled += this.OnMouseWheelScrolled; + } + + /// Draw the overlay to the screen. + /// The sprite batch being drawn. + protected virtual void Draw(SpriteBatch batch) { } + + /// The method invoked when the player left-clicks. + /// The X-position of the cursor. + /// The Y-position of the cursor. + /// Whether the event has been handled and shouldn't be propagated further. + protected virtual bool ReceiveLeftClick(int x, int y) + { + return false; + } + + /// The method invoked when the player presses a button. + /// The button that was pressed. + /// Whether the event has been handled and shouldn't be propagated further. + protected virtual bool ReceiveButtonPress(SButton input) + { + return false; + } + + /// The method invoked when the player uses the mouse scroll wheel. + /// The scroll amount. + /// Whether the event has been handled and shouldn't be propagated further. + protected virtual bool ReceiveScrollWheelAction(int amount) + { + return false; + } + + /// The method invoked when the cursor is hovered. + /// The cursor's X position. + /// The cursor's Y position. + /// Whether the event has been handled and shouldn't be propagated further. + protected virtual bool ReceiveCursorHover(int x, int y) + { + return false; + } + + /// The method invoked when the player resizes the game windoww. + /// The previous game window bounds. + /// The new game window bounds. + protected virtual void ReceiveGameWindowResized(Rectangle oldBounds, Rectangle newBounds) { } + + /// Draw the mouse cursor. + /// Derived from . + protected void DrawCursor() + { + if (Game1.options.hardwareCursor) + return; + Game1.spriteBatch.Draw(Game1.mouseCursors, new Vector2(Game1.getMouseX(), Game1.getMouseY()), Game1.getSourceRectForStandardTileSheet(Game1.mouseCursors, Game1.options.SnappyMenus ? 44 : 0, 16, 16), Color.White * Game1.mouseCursorTransparency, 0.0f, Vector2.Zero, Game1.pixelZoom + Game1.dialogueButtonScale / 150f, SpriteEffects.None, 1f); + } + + /**** + ** Event listeners + ****/ + /// The method called when the game finishes drawing components to the screen. + /// The source of the event. + /// The event arguments. + private void OnRendered(object sender, RenderedEventArgs e) + { + this.Draw(Game1.spriteBatch); + } + + /// The method called once per event tick. + /// The source of the event. + /// The event arguments. + private void OnUpdateTicked(object sender, UpdateTickedEventArgs e) + { + // detect end of life + if (this.KeepAliveCheck != null && !this.KeepAliveCheck()) + { + this.Dispose(); + return; + } + + // trigger window resize event + Rectangle newViewport = Game1.viewport; + if (this.LastViewport.Width != newViewport.Width || this.LastViewport.Height != newViewport.Height) + { + newViewport = new Rectangle(newViewport.X, newViewport.Y, newViewport.Width, newViewport.Height); + this.ReceiveGameWindowResized(this.LastViewport, newViewport); + this.LastViewport = newViewport; + } + } + + /// The method invoked when the player presses a key. + /// The source of the event. + /// The event arguments. + private void OnButtonPressed(object sender, ButtonPressedEventArgs e) + { + bool handled = e.Button == SButton.MouseLeft || e.Button.IsUseToolButton() + ? this.ReceiveLeftClick(Game1.getMouseX(), Game1.getMouseY()) + : this.ReceiveButtonPress(e.Button); + + if (handled) + this.InputHelper.Suppress(e.Button); + } + + /// The method invoked when the mouse wheel is scrolled. + /// The source of the event. + /// The event arguments. + private void OnMouseWheelScrolled(object sender, MouseWheelScrolledEventArgs e) + { + bool scrollHandled = this.ReceiveScrollWheelAction(e.Delta); + if (scrollHandled) + { + MouseState cur = Game1.oldMouseState; + Game1.oldMouseState = new MouseState( + x: cur.X, + y: cur.Y, + scrollWheel: e.NewValue, + leftButton: cur.LeftButton, + middleButton: cur.MiddleButton, + rightButton: cur.RightButton, + xButton1: cur.XButton1, + xButton2: cur.XButton2 + ); + } + } + + /// The method invoked when the in-game cursor is moved. + /// The source of the event. + /// The event arguments. + private void OnCursorMoved(object sender, CursorMovedEventArgs e) + { + int x = (int)e.NewPosition.ScreenPixels.X; + int y = (int)e.NewPosition.ScreenPixels.Y; + + bool hoverHandled = this.ReceiveCursorHover(x, y); + if (hoverHandled) + { + MouseState cur = Game1.oldMouseState; + Game1.oldMouseState = new MouseState( + x: x, + y: y, + scrollWheel: cur.ScrollWheelValue, + leftButton: cur.LeftButton, + middleButton: cur.MiddleButton, + rightButton: cur.RightButton, + xButton1: cur.XButton1, + xButton2: cur.XButton2 + ); + } + } + } +} diff --git a/Mods/Automate/Common/UI/CommonSprites.cs b/Mods/Automate/Common/UI/CommonSprites.cs new file mode 100644 index 00000000..3da68991 --- /dev/null +++ b/Mods/Automate/Common/UI/CommonSprites.cs @@ -0,0 +1,79 @@ +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Graphics; +using StardewValley; + +namespace Pathoschild.Stardew.Common.UI +{ + /// Simplifies access to the game's sprite sheets. + /// Each sprite is represented by a rectangle, which specifies the coordinates and dimensions of the image in the sprite sheet. + internal static class CommonSprites + { + /// Sprites used to draw a button. + public static class Button + { + /// The sprite sheet containing the icon sprites. + public static Texture2D Sheet => Game1.mouseCursors; + + /// The legend background. + public static readonly Rectangle Background = new Rectangle(297, 364, 1, 1); + + /// The top border. + public static readonly Rectangle Top = new Rectangle(279, 284, 1, 4); + + /// The bottom border. + public static readonly Rectangle Bottom = new Rectangle(279, 296, 1, 4); + + /// The left border. + public static readonly Rectangle Left = new Rectangle(274, 289, 4, 1); + + /// The right border. + public static readonly Rectangle Right = new Rectangle(286, 289, 4, 1); + + /// The top-left corner. + public static readonly Rectangle TopLeft = new Rectangle(274, 284, 4, 4); + + /// The top-right corner. + public static readonly Rectangle TopRight = new Rectangle(286, 284, 4, 4); + + /// The bottom-left corner. + public static readonly Rectangle BottomLeft = new Rectangle(274, 296, 4, 4); + + /// The bottom-right corner. + public static readonly Rectangle BottomRight = new Rectangle(286, 296, 4, 4); + } + + /// Sprites used to draw a scroll. + public static class Scroll + { + /// The sprite sheet containing the icon sprites. + public static Texture2D Sheet => Game1.mouseCursors; + + /// The legend background. + public static readonly Rectangle Background = new Rectangle(334, 321, 1, 1); + + /// The top border. + public static readonly Rectangle Top = new Rectangle(331, 318, 1, 2); + + /// The bottom border. + public static readonly Rectangle Bottom = new Rectangle(327, 334, 1, 2); + + /// The left border. + public static readonly Rectangle Left = new Rectangle(325, 320, 6, 1); + + /// The right border. + public static readonly Rectangle Right = new Rectangle(344, 320, 6, 1); + + /// The top-left corner. + public static readonly Rectangle TopLeft = new Rectangle(325, 318, 6, 2); + + /// The top-right corner. + public static readonly Rectangle TopRight = new Rectangle(344, 318, 6, 2); + + /// The bottom-left corner. + public static readonly Rectangle BottomLeft = new Rectangle(325, 334, 6, 2); + + /// The bottom-right corner. + public static readonly Rectangle BottomRight = new Rectangle(344, 334, 6, 2); + } + } +} diff --git a/Mods/Automate/Common/Utilities/ConstraintSet.cs b/Mods/Automate/Common/Utilities/ConstraintSet.cs new file mode 100644 index 00000000..98cf678e --- /dev/null +++ b/Mods/Automate/Common/Utilities/ConstraintSet.cs @@ -0,0 +1,141 @@ +using System.Collections.Generic; + +namespace Pathoschild.Stardew.Common.Utilities +{ + /// A logical collection of values defined by restriction and exclusion values which may be infinite. + /// + /// + /// Unlike a typical collection, a constraint set doesn't necessarily track the values it contains. For + /// example, a constraint set of values with one exclusion only stores one number but + /// logically contains elements. + /// + /// + /// + /// A constraint set is defined by two inner sets: contains values which are + /// explicitly not part of the set, and contains values which are explicitly + /// part of the set. Crucially, an empty means an unbounded set (i.e. it + /// contains all possible values). If a value is part of both and + /// , the exclusion takes priority. + /// + /// + internal class ConstraintSet + { + /********* + ** Accessors + *********/ + /// The specific values to contain (or empty to match any value). + public HashSet RestrictToValues { get; } + + /// The specific values to exclude. + public HashSet ExcludeValues { get; } + + /// Whether the constraint set matches a finite set of values. + public bool IsBounded => this.RestrictToValues.Count != 0; + + /// Get whether the constraint set logically matches an infinite set of values. + public bool IsInfinite => !this.IsBounded; + + /// Whether there are any constraints placed on the set of values. + public bool IsConstrained => this.RestrictToValues.Count != 0 || this.ExcludeValues.Count != 0; + + + /********* + ** Public methods + *********/ + /// Construct an instance. + public ConstraintSet() + : this(EqualityComparer.Default) { } + + /// Construct an instance. + /// The equality comparer to use when comparing values in the set, or to use the default implementation for the set type. + public ConstraintSet(IEqualityComparer comparer) + { + this.RestrictToValues = new HashSet(comparer); + this.ExcludeValues = new HashSet(comparer); + } + + /// Bound the constraint set by adding the given value to the set of allowed values. If the constraint set is unbounded, this makes it bounded. + /// The value. + /// Returns true if the value was added; else false if it was already present. + public bool AddBound(T value) + { + return this.RestrictToValues.Add(value); + } + + /// Bound the constraint set by adding the given values to the set of allowed values. If the constraint set is unbounded, this makes it bounded. + /// The values. + /// Returns true if any value was added; else false if all values were already present. + public bool AddBound(IEnumerable values) + { + bool anyAdded = false; + foreach (T value in values) + { + if (this.RestrictToValues.Add(value)) + anyAdded = true; + } + return anyAdded; + } + + /// Add values to exclude. + /// The value to exclude. + /// Returns true if the value was added; else false if it was already present. + public bool Exclude(T value) + { + return this.ExcludeValues.Add(value); + } + + /// Add values to exclude. + /// The values to exclude. + /// Returns true if any value was added; else false if all values were already present. + public bool Exclude(IEnumerable values) + { + bool anyAdded = false; + foreach (T value in values) + { + if (this.ExcludeValues.Add(value)) + anyAdded = true; + } + return anyAdded; + } + + /// Get whether this constraint allows some values that would be allowed by another. + /// The other + public bool Intersects(ConstraintSet other) + { + // If both sets are unbounded, they're guaranteed to intersect since exclude can't be unbounded. + if (this.IsInfinite && other.IsInfinite) + return true; + + // if either set is bounded, they can only intersect in the included subset. + if (this.IsBounded) + { + foreach (T value in this.RestrictToValues) + { + if (this.Allows(value) && other.Allows(value)) + return true; + } + } + if (other.IsBounded) + { + foreach (T value in other.RestrictToValues) + { + if (other.Allows(value) && this.Allows(value)) + return true; + } + } + + // else no intersection + return false; + } + + /// Get whether the constraints allow the given value. + /// The value to match. + public bool Allows(T value) + { + if (this.ExcludeValues.Contains(value)) + return false; + + return this.IsInfinite || this.RestrictToValues.Contains(value); + } + } +} diff --git a/Mods/Automate/Common/Utilities/InvariantDictionary.cs b/Mods/Automate/Common/Utilities/InvariantDictionary.cs new file mode 100644 index 00000000..4bad98e7 --- /dev/null +++ b/Mods/Automate/Common/Utilities/InvariantDictionary.cs @@ -0,0 +1,30 @@ +using System; +using System.Collections.Generic; + +namespace Pathoschild.Stardew.Common.Utilities +{ + /// An implementation of whose keys are guaranteed to use . + internal class InvariantDictionary : Dictionary + { + /********* + ** Public methods + *********/ + /// Construct an instance. + public InvariantDictionary() + : base(StringComparer.InvariantCultureIgnoreCase) { } + + /// Construct an instance. + /// The values to add. + public InvariantDictionary(IDictionary values) + : base(values, StringComparer.InvariantCultureIgnoreCase) { } + + /// Construct an instance. + /// The values to add. + public InvariantDictionary(IEnumerable> values) + : base(StringComparer.InvariantCultureIgnoreCase) + { + foreach (var entry in values) + this.Add(entry.Key, entry.Value); + } + } +} diff --git a/Mods/Automate/Common/Utilities/InvariantHashSet.cs b/Mods/Automate/Common/Utilities/InvariantHashSet.cs new file mode 100644 index 00000000..6f0530d8 --- /dev/null +++ b/Mods/Automate/Common/Utilities/InvariantHashSet.cs @@ -0,0 +1,32 @@ +using System; +using System.Collections.Generic; + +namespace Pathoschild.Stardew.Common.Utilities +{ + /// An implementation of for strings which always uses . + internal class InvariantHashSet : HashSet + { + /********* + ** Public methods + *********/ + /// Construct an instance. + public InvariantHashSet() + : base(StringComparer.InvariantCultureIgnoreCase) { } + + /// Construct an instance. + /// The values to add. + public InvariantHashSet(IEnumerable values) + : base(values, StringComparer.InvariantCultureIgnoreCase) { } + + /// Construct an instance. + /// The single value to add. + public InvariantHashSet(string value) + : base(new[] { value }, StringComparer.InvariantCultureIgnoreCase) { } + + /// Get a hashset for boolean true/false. + public static InvariantHashSet Boolean() + { + return new InvariantHashSet(new[] { "true", "false" }); + } + } +} diff --git a/Mods/Automate/Common/Utilities/ObjectReferenceComparer.cs b/Mods/Automate/Common/Utilities/ObjectReferenceComparer.cs new file mode 100644 index 00000000..020ebfad --- /dev/null +++ b/Mods/Automate/Common/Utilities/ObjectReferenceComparer.cs @@ -0,0 +1,29 @@ +using System.Collections.Generic; +using System.Runtime.CompilerServices; + +namespace Pathoschild.Stardew.Common.Utilities +{ + /// A comparer which considers two references equal if they point to the same instance. + /// The value type. + internal class ObjectReferenceComparer : IEqualityComparer + { + /********* + ** Public methods + *********/ + /// Determines whether the specified objects are equal. + /// true if the specified objects are equal; otherwise, false. + /// The first object to compare. + /// The second object to compare. + public bool Equals(T x, T y) + { + return object.ReferenceEquals(x, y); + } + + /// Get a hash code for the specified object. + /// The value. + public int GetHashCode(T obj) + { + return RuntimeHelpers.GetHashCode(obj); + } + } +} diff --git a/Mods/Automate/Properties/AssemblyInfo.cs b/Mods/Automate/Properties/AssemblyInfo.cs new file mode 100644 index 00000000..989fb802 --- /dev/null +++ b/Mods/Automate/Properties/AssemblyInfo.cs @@ -0,0 +1,36 @@ +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; + +// 有关程序集的一般信息由以下 +// 控制。更改这些特性值可修改 +// 与程序集关联的信息。 +[assembly: AssemblyTitle("Automate")] +[assembly: AssemblyDescription("")] +[assembly: AssemblyConfiguration("")] +[assembly: AssemblyCompany("")] +[assembly: AssemblyProduct("Automate")] +[assembly: AssemblyCopyright("Copyright © 2019")] +[assembly: AssemblyTrademark("")] +[assembly: AssemblyCulture("")] + +// 将 ComVisible 设置为 false 会使此程序集中的类型 +//对 COM 组件不可见。如果需要从 COM 访问此程序集中的类型 +//请将此类型的 ComVisible 特性设置为 true。 +[assembly: ComVisible(false)] + +// 如果此项目向 COM 公开,则下列 GUID 用于类型库的 ID +[assembly: Guid("5ef944e3-d54b-4936-b507-a40c17b17b8e")] + +// 程序集的版本信息由下列四个值组成: +// +// 主版本 +// 次版本 +// 生成号 +// 修订号 +// +// 可以指定所有值,也可以使用以下所示的 "*" 预置版本号和修订号 +//通过使用 "*",如下所示: +// [assembly: AssemblyVersion("1.0.*")] +[assembly: AssemblyVersion("1.0.0.0")] +[assembly: AssemblyFileVersion("1.0.0.0")] diff --git a/Mods/ConvenientChests/CategorizeChests/CategorizeChestsModule.cs b/Mods/ConvenientChests/CategorizeChests/CategorizeChestsModule.cs new file mode 100644 index 00000000..71881bb3 --- /dev/null +++ b/Mods/ConvenientChests/CategorizeChests/CategorizeChestsModule.cs @@ -0,0 +1,110 @@ +using System; +using System.IO; +using ConvenientChests.CategorizeChests.Framework; +using ConvenientChests.CategorizeChests.Framework.Persistence; +using ConvenientChests.CategorizeChests.Interface; +using ConvenientChests.CategorizeChests.Interface.Widgets; +using StardewModdingAPI; +using StardewModdingAPI.Events; +using StardewValley; +using StardewValley.Menus; +using StardewValley.Objects; + +namespace ConvenientChests.CategorizeChests { + public class CategorizeChestsModule : Module { + internal IItemDataManager ItemDataManager { get; } = new ItemDataManager(); + internal IChestDataManager ChestDataManager { get; } = new ChestDataManager(); + internal ChestFinder ChestFinder { get; } = new ChestFinder(); + + protected string SavePath => Path.Combine("savedata", $"{Constants.SaveFolderName}.json"); + protected string AbsoluteSavePath => Path.Combine(ModEntry.Helper.DirectoryPath, SavePath); + private SaveManager SaveManager { get; set; } + + + private WidgetHost WidgetHost { get; set; } + + internal bool ChestAcceptsItem(Chest chest, Item item) => item != null && ChestAcceptsItem(chest, ItemDataManager.GetItemKey(item)); + internal bool ChestAcceptsItem(Chest chest, ItemKey itemKey) => ChestDataManager.GetChestData(chest).Accepts(itemKey); + + public CategorizeChestsModule(ModEntry modEntry) : base(modEntry) { } + + public override void Activate() { + IsActive = true; + + // Menu Events + this.Events.Display.MenuChanged += OnMenuChanged; + + if (Context.IsMultiplayer && !Context.IsMainPlayer) { + ModEntry.Log("Due to limitations in the network code, CHEST CATEGORIES CAN NOT BE SAVED as farmhand, sorry :(", LogLevel.Warn); + return; + } + + // Save Events + SaveManager = new SaveManager(this); + this.Events.GameLoop.Saving += OnSaving; + OnGameLoaded(); + } + + public override void Deactivate() { + IsActive = false; + + // Menu Events + this.Events.Display.MenuChanged -= OnMenuChanged; + + // Save Events + this.Events.GameLoop.Saving -= OnSaving; + } + + /// Raised before the game begins writes data to the save file (except the initial save creation). + /// The event sender. + /// The event data. + private void OnSaving(object sender, SavingEventArgs e) { + try { + SaveManager.Save(SavePath); + } + catch (Exception ex) { + Monitor.Log($"Error saving chest data to {SavePath}", LogLevel.Error); + Monitor.Log(ex.ToString()); + } + } + + private void OnGameLoaded() { + try { + if (File.Exists(AbsoluteSavePath)) + SaveManager.Load(SavePath); + } + catch (Exception ex) { + Monitor.Log($"Error loading chest data from {SavePath}", LogLevel.Error); + Monitor.Log(ex.ToString()); + } + } + + /// Raised after a game menu is opened, closed, or replaced. + /// The event sender. + /// The event data. + private void OnMenuChanged(object sender, MenuChangedEventArgs e) { + if (e.NewMenu == e.OldMenu) + return; + + if (e.OldMenu is ItemGrabMenu) + ClearMenu(); + + if (e.NewMenu is ItemGrabMenu itemGrabMenu) + CreateMenu(itemGrabMenu); + } + + private void CreateMenu(ItemGrabMenu itemGrabMenu) { + if (!(itemGrabMenu.behaviorOnItemGrab?.Target is Chest chest)) + return; + + WidgetHost = new WidgetHost(this.Events, this.ModEntry.Helper.Input); + var overlay = new ChestOverlay(this, chest, itemGrabMenu, WidgetHost.TooltipManager); + WidgetHost.RootWidget.AddChild(overlay); + } + + private void ClearMenu() { + WidgetHost?.Dispose(); + WidgetHost = null; + } + } +} \ No newline at end of file diff --git a/Mods/ConvenientChests/CategorizeChests/Framework/ChestData.cs b/Mods/ConvenientChests/CategorizeChests/Framework/ChestData.cs new file mode 100644 index 00000000..00578b9a --- /dev/null +++ b/Mods/ConvenientChests/CategorizeChests/Framework/ChestData.cs @@ -0,0 +1,52 @@ +using System.Collections.Generic; +using StardewValley.Objects; + +namespace ConvenientChests.CategorizeChests.Framework +{ + /// + /// The extra data associated with a chest object, such as the list of + /// items it should accept. + /// + class ChestData + { + public Chest Chest { get; } + public HashSet AcceptedItemKinds { get; set; } = new HashSet(); + + public ChestData(Chest chest) => Chest = chest; + + /// + /// Set this chest to accept the specified kind of item. + /// + public void AddAccepted(ItemKey itemKey) + { + if (!AcceptedItemKinds.Contains(itemKey)) + AcceptedItemKinds.Add(itemKey); + } + + /// + /// Set this chest to not accept the specified kind of item. + /// + public void AddRejected(ItemKey itemKey) + { + if (AcceptedItemKinds.Contains(itemKey)) + AcceptedItemKinds.Remove(itemKey); + } + + /// + /// Toggle whether this chest accepts the specified kind of item. + /// + public void Toggle(ItemKey itemKey) + { + if (Accepts(itemKey)) + AddRejected(itemKey); + + else + AddAccepted(itemKey); + } + + /// + /// Return whether this chest accepts the given kind of item. + /// + public bool Accepts(ItemKey itemKey) => AcceptedItemKinds.Contains(itemKey); + } +} \ No newline at end of file diff --git a/Mods/ConvenientChests/CategorizeChests/Framework/ChestDataManager.cs b/Mods/ConvenientChests/CategorizeChests/Framework/ChestDataManager.cs new file mode 100644 index 00000000..a3fd06f4 --- /dev/null +++ b/Mods/ConvenientChests/CategorizeChests/Framework/ChestDataManager.cs @@ -0,0 +1,12 @@ +using System.Runtime.CompilerServices; +using StardewValley.Objects; + +namespace ConvenientChests.CategorizeChests.Framework +{ + class ChestDataManager : IChestDataManager + { + private readonly ConditionalWeakTable _table = new ConditionalWeakTable(); + + public ChestData GetChestData(Chest chest) => _table.GetValue(chest, c => new ChestData(c)); + } +} \ No newline at end of file diff --git a/Mods/ConvenientChests/CategorizeChests/Framework/ChestExtension.cs b/Mods/ConvenientChests/CategorizeChests/Framework/ChestExtension.cs new file mode 100644 index 00000000..698c40e5 --- /dev/null +++ b/Mods/ConvenientChests/CategorizeChests/Framework/ChestExtension.cs @@ -0,0 +1,86 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using StardewModdingAPI; +using StardewValley; +using StardewValley.Locations; +using StardewValley.Objects; + +namespace ConvenientChests.CategorizeChests.Framework { + internal static class ChestExtension { + public static Chest GetFridge(Farmer player) { + if (Game1.player.IsMainPlayer) + return StardewValley.Utility.getHomeOfFarmer(player).fridge.Value; + + if (!(Game1.currentLocation is FarmHouse f)) + // Can't access other locations + return null; + + if (f.owner != player) + ModEntry.Log($"Could not get fridge for player '{player.Name}' (wrong house)"); + + return f.fridge.Value; + } + + public static Chest GetLocalFridge(Farmer player) { + if (Game1.currentLocation is FarmHouse f) + return f.fridge.Value; + + if (Game1.player.IsMainPlayer) + return StardewValley.Utility.getHomeOfFarmer(player).fridge.Value; + + throw new Exception("Cooking from the outside as farmhand?"); + } + + public static bool ContainsItem(this Chest chest, Item i) => chest.items.Any(i.canStackWith); + + /// + /// Attempt to move as much as possible of the player's inventory into the given chest + /// + /// The chest to put the items in. + /// + /// Items to put in + /// List of Items that were successfully moved into the chest + public static IEnumerable DumpItemsToChest(this Chest chest, IList sourceInventory, IEnumerable items) { + var changedItems = items.Where(item => item != null) + .Where(item => TryMoveItemToChest(chest, sourceInventory, item)) + .ToList(); + + return changedItems; + } + + /// + /// Attempt to move as much as possible of the given item stack into the chest. + /// + /// The chest to put the items in. + /// + /// The items to put in the chest. + /// True if at least some of the stack was moved into the chest. + public static bool TryMoveItemToChest(this Chest chest, IList sourceInventory, Item item) { + var remainder = chest.addItem(item); + + // nothing remains -> remove item + if (remainder == null) { + var index = sourceInventory.IndexOf(item); + sourceInventory[index] = null; + return true; + } + + // nothing changed + if (remainder.Stack == item.Stack) + return false; + + // update stack count + item.Stack = remainder.Stack; + return true; + } + + /// + /// Check whether the given chest has any completely empty slots. + /// + /// Whether at least one slot is empty. + /// The chest to check. + public static bool HasEmptySlots(this Chest chest) + => chest.items.Count < Chest.capacity || chest.items.Any(i => i == null); + } +} \ No newline at end of file diff --git a/Mods/ConvenientChests/CategorizeChests/Framework/ChestFinder.cs b/Mods/ConvenientChests/CategorizeChests/Framework/ChestFinder.cs new file mode 100644 index 00000000..bf77a936 --- /dev/null +++ b/Mods/ConvenientChests/CategorizeChests/Framework/ChestFinder.cs @@ -0,0 +1,53 @@ +using System.Linq; +using ConvenientChests.CategorizeChests.Framework.Persistence; +using StardewValley; +using StardewValley.Locations; +using StardewValley.Objects; + +namespace ConvenientChests.CategorizeChests.Framework +{ + class ChestFinder : IChestFinder + { + public Chest GetChestByAddress(ChestAddress address) + { + if (address.LocationType == ChestLocationType.Refrigerator) + { + var house = (FarmHouse) Game1.locations.SingleOrDefault(l => l is FarmHouse f && address.LocationName == (f.uniqueName?.Value ?? f.Name)); + + if (house == null) + throw new InvalidSaveDataException($"Save data contains refrigerator data in {address.LocationName} but location does not exist"); + + if (house.upgradeLevel < 1) + throw new InvalidSaveDataException($"Save data contains refrigerator data in {address.LocationName} but refrigerator does not exist"); + + return house.fridge.Value; + } + + var location = GetLocationFromAddress(address); + if (location.objects.ContainsKey(address.Tile) && location.objects[address.Tile] is Chest chest) + return chest; + + throw new InvalidSaveDataException($"Can't find chest in {location.Name} at {address.Tile}"); + } + + private GameLocation GetLocationFromAddress(ChestAddress address) + { + var location = Game1.locations.FirstOrDefault(l => l.Name == address.LocationName); + + if (location == null) + throw new InvalidSaveDataException($"Can't find location named {address.LocationName}"); + + if (address.LocationType != ChestLocationType.Building) + return location; + + if (!(location is BuildableGameLocation buildableLocation)) + throw new InvalidSaveDataException($"Can't find any buildings in location named {location.Name}"); + + var building = buildableLocation.buildings.SingleOrDefault(b => b.nameOfIndoors == address.BuildingName); + if (building == null) + throw new InvalidSaveDataException($"Save data contains building data in {address.BuildingName} but building does not exist"); + + return building.indoors.Value; + } + } +} \ No newline at end of file diff --git a/Mods/ConvenientChests/CategorizeChests/Framework/DiscoveredItem.cs b/Mods/ConvenientChests/CategorizeChests/Framework/DiscoveredItem.cs new file mode 100644 index 00000000..8981db0b --- /dev/null +++ b/Mods/ConvenientChests/CategorizeChests/Framework/DiscoveredItem.cs @@ -0,0 +1,16 @@ +using StardewValley; + +namespace ConvenientChests.CategorizeChests.Framework +{ + class DiscoveredItem + { + public readonly ItemKey ItemKey; + public readonly Item Item; + + public DiscoveredItem(ItemType type, int index, Item item) + { + ItemKey = new ItemKey(type, index); + Item = item; + } + } +} \ No newline at end of file diff --git a/Mods/ConvenientChests/CategorizeChests/Framework/IChestDataManager.cs b/Mods/ConvenientChests/CategorizeChests/Framework/IChestDataManager.cs new file mode 100644 index 00000000..6e430220 --- /dev/null +++ b/Mods/ConvenientChests/CategorizeChests/Framework/IChestDataManager.cs @@ -0,0 +1,13 @@ +using StardewValley.Objects; + +namespace ConvenientChests.CategorizeChests.Framework +{ + /// + /// An interface for retrieving the mod-specific data associated with a + /// given Stardew Valley chest object. + /// + internal interface IChestDataManager + { + ChestData GetChestData(Chest chest); + } +} \ No newline at end of file diff --git a/Mods/ConvenientChests/CategorizeChests/Framework/IChestFiller.cs b/Mods/ConvenientChests/CategorizeChests/Framework/IChestFiller.cs new file mode 100644 index 00000000..301947e4 --- /dev/null +++ b/Mods/ConvenientChests/CategorizeChests/Framework/IChestFiller.cs @@ -0,0 +1,13 @@ +using StardewValley.Objects; + +namespace ConvenientChests.CategorizeChests.Framework +{ + /// + /// A tool for moving items in bulk from the player's inventory + /// into a given chest according to that chest's settings. + /// + public interface IChestFiller + { + void DumpItemsToChest(Chest chest); + } +} \ No newline at end of file diff --git a/Mods/ConvenientChests/CategorizeChests/Framework/IChestFinder.cs b/Mods/ConvenientChests/CategorizeChests/Framework/IChestFinder.cs new file mode 100644 index 00000000..eab1e1a4 --- /dev/null +++ b/Mods/ConvenientChests/CategorizeChests/Framework/IChestFinder.cs @@ -0,0 +1,13 @@ +using ConvenientChests.CategorizeChests.Framework.Persistence; +using StardewValley.Objects; + +namespace ConvenientChests.CategorizeChests.Framework +{ + /// + /// A helper for finding the chest object corresponding to a given chest address. + /// + interface IChestFinder + { + Chest GetChestByAddress(ChestAddress address); + } +} \ No newline at end of file diff --git a/Mods/ConvenientChests/CategorizeChests/Framework/IItemDataManager.cs b/Mods/ConvenientChests/CategorizeChests/Framework/IItemDataManager.cs new file mode 100644 index 00000000..f60fa136 --- /dev/null +++ b/Mods/ConvenientChests/CategorizeChests/Framework/IItemDataManager.cs @@ -0,0 +1,17 @@ +using System.Collections.Generic; +using System.Linq; +using StardewValley; + +namespace ConvenientChests.CategorizeChests.Framework { + /// + /// A repository of item data that maps item keys to representative items + /// and vice versa. + /// + internal interface IItemDataManager { + Dictionary> Categories { get; } + Dictionary Prototypes { get; } + + Item GetItem(ItemKey itemKey); + ItemKey GetItemKey(Item item); + } +} \ No newline at end of file diff --git a/Mods/ConvenientChests/CategorizeChests/Framework/ItemBlacklist.cs b/Mods/ConvenientChests/CategorizeChests/Framework/ItemBlacklist.cs new file mode 100644 index 00000000..dcf12be8 --- /dev/null +++ b/Mods/ConvenientChests/CategorizeChests/Framework/ItemBlacklist.cs @@ -0,0 +1,97 @@ +using System.Collections.Generic; + +namespace ConvenientChests.CategorizeChests.Framework +{ + /// + /// Maintains the list of items that should be excluded from the available + /// items to use for categorization, e.g. unobtainable items and bug items. + /// + static class ItemBlacklist + { + /// + /// Check whether a given item key is blacklisted. + /// + /// Whether the key is blacklisted. + /// Item key to check. + public static bool Includes(ItemKey itemKey) => + itemKey.ItemType == ItemType.BigCraftable ||itemKey.ItemType == ItemType.Furniture || BlacklistedItemKeys.Contains(itemKey); + + private static readonly HashSet BlacklistedItemKeys = new HashSet { + // stones + new ItemKey(ItemType.Object, 2), + new ItemKey(ItemType.Object, 4), + new ItemKey(ItemType.Object, 75), + new ItemKey(ItemType.Object, 76), + new ItemKey(ItemType.Object, 77), + new ItemKey(ItemType.Object, 290), + new ItemKey(ItemType.Object, 343), + new ItemKey(ItemType.Object, 450), + new ItemKey(ItemType.Object, 668), + new ItemKey(ItemType.Object, 670), + new ItemKey(ItemType.Object, 751), + new ItemKey(ItemType.Object, 760), + new ItemKey(ItemType.Object, 762), + new ItemKey(ItemType.Object, 764), + new ItemKey(ItemType.Object, 765), + + // weeds + new ItemKey(ItemType.Object, 0), + new ItemKey(ItemType.Object, 313), + new ItemKey(ItemType.Object, 314), + new ItemKey(ItemType.Object, 315), + new ItemKey(ItemType.Object, 316), + new ItemKey(ItemType.Object, 317), + new ItemKey(ItemType.Object, 318), + new ItemKey(ItemType.Object, 319), + new ItemKey(ItemType.Object, 320), + new ItemKey(ItemType.Object, 321), + new ItemKey(ItemType.Object, 452), + new ItemKey(ItemType.Object, 674), + new ItemKey(ItemType.Object, 675), + new ItemKey(ItemType.Object, 676), + new ItemKey(ItemType.Object, 677), + new ItemKey(ItemType.Object, 678), + new ItemKey(ItemType.Object, 679), + new ItemKey(ItemType.Object, 750), + new ItemKey(ItemType.Object, 784), + new ItemKey(ItemType.Object, 785), + new ItemKey(ItemType.Object, 786), + new ItemKey(ItemType.Object, 792), + new ItemKey(ItemType.Object, 793), + new ItemKey(ItemType.Object, 794), + + // twigs + new ItemKey(ItemType.Object, 294), + new ItemKey(ItemType.Object, 295), + + new ItemKey(ItemType.Object, 30), // Lumber + new ItemKey(ItemType.Object, 94), // Spirit Torch + new ItemKey(ItemType.Object, 102), // Lost Book + new ItemKey(ItemType.Object, 449), // Stone Base + new ItemKey(ItemType.Object, 461), // Decorative Pot + new ItemKey(ItemType.Object, 590), // Artifact Spot + new ItemKey(ItemType.Object, 788), // Lost Axe + new ItemKey(ItemType.Object, 789), // Lucky Purple Shorts + new ItemKey(ItemType.Object, 790), // Berry Basket + + new ItemKey(ItemType.Weapon, 25), // Alex's Bat + new ItemKey(ItemType.Weapon, 30), // Sam's Old Guitar + new ItemKey(ItemType.Weapon, 35), // Elliott's Pencil + new ItemKey(ItemType.Weapon, 36), // Maru's Wrench + new ItemKey(ItemType.Weapon, 37), // Harvey's Mallet + new ItemKey(ItemType.Weapon, 38), // Penny's Fryer + new ItemKey(ItemType.Weapon, 39), // Leah's Whittler + new ItemKey(ItemType.Weapon, 40), // Abby's Planchette + new ItemKey(ItemType.Weapon, 41), // Seb's Lost Mace + new ItemKey(ItemType.Weapon, 42), // Haley's Iron + new ItemKey(ItemType.Weapon, 20), // Elf Blade + new ItemKey(ItemType.Weapon, 34), // Galaxy Slingshot + new ItemKey(ItemType.Weapon, 46), // Kudgel + new ItemKey(ItemType.Weapon, 49), // Rapier + new ItemKey(ItemType.Weapon, 19), // Shadow Dagger + new ItemKey(ItemType.Weapon, 48), // Yeti Tooth + + new ItemKey(ItemType.Boots, 515), // Cowboy Boots + }; + } +} diff --git a/Mods/ConvenientChests/CategorizeChests/Framework/ItemDataManager.cs b/Mods/ConvenientChests/CategorizeChests/Framework/ItemDataManager.cs new file mode 100644 index 00000000..0480688e --- /dev/null +++ b/Mods/ConvenientChests/CategorizeChests/Framework/ItemDataManager.cs @@ -0,0 +1,162 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using StardewModdingAPI; +using StardewValley; +using StardewValley.Objects; +using StardewValley.Tools; +using StardewObject = StardewValley.Object; + +namespace ConvenientChests.CategorizeChests.Framework { + internal class ItemDataManager : IItemDataManager { + /// + /// A mapping of category names to the item keys belonging to that category. + /// + public Dictionary> Categories { get; } + + /// + /// A mapping of item keys to a representative instance of the item they correspond to. + /// + public Dictionary Prototypes { get; } = new Dictionary(); + + public ItemDataManager() { + // Load standard items + foreach (var item in DiscoverItems()) { + var key = CreateItemKey(item); + if (ItemBlacklist.Includes(key)) + continue; + + if (Prototypes.ContainsKey(key)) + continue; + + Prototypes.Add(key, item); + } + + // Create Categories + Categories = Prototypes.Keys + .GroupBy(GetCategoryName) + .ToDictionary( + g => g.Key, + g => (IList) g.ToList() + ); + } + + public ItemKey GetItemKey(Item item) { + if (item == null) + throw new Exception(); + + var key = CreateItemKey(item); + if (Prototypes.ContainsKey(key)) + return key; + + // Add to prototypes + Prototypes.Add(key, item); + + var category = GetCategoryName(key); + ModEntry.Log($"Added prototype for '{item.DisplayName}' ({key}) to category '{category}'", LogLevel.Debug); + + // Add to categories, if not blacklisted + if (ItemBlacklist.Includes(key)) + return key; + + if (!Categories.ContainsKey(category)) + Categories.Add(category, new List()); + + if (!Categories[category].Contains(key)) + Categories[category].Add(key); + + + return key; + } + + protected ItemKey CreateItemKey(Item item) { + switch (item) { + // Tool family overrides + case Axe _: + return ToolFactory.getToolFromDescription(ToolFactory.axe, 0).ToItemKey(); + case Pickaxe _: + return ToolFactory.getToolFromDescription(ToolFactory.pickAxe, 0).ToItemKey(); + case Hoe _: + return ToolFactory.getToolFromDescription(ToolFactory.hoe, 0).ToItemKey(); + case WateringCan _: + return ToolFactory.getToolFromDescription(ToolFactory.wateringCan, 0).ToItemKey(); + case FishingRod _: + return ToolFactory.getToolFromDescription(ToolFactory.fishingRod, 0).ToItemKey(); + + default: + return item.ToItemKey(); + } + } + + public Item GetItem(ItemKey itemKey) => Prototypes.ContainsKey(itemKey) + ? Prototypes[itemKey] + : itemKey.GetOne(); + + /// + /// Generate every item known to man, or at least those we're interested + /// in using for categorization. + /// + /// + /// + /// Substantially based on code from Pathoschild's LookupAnything mod. + /// + /// + /// A collection of all of the item entries. + private IEnumerable DiscoverItems() { + // upgradable tools + yield return ToolFactory.getToolFromDescription(ToolFactory.axe, Tool.stone); + yield return ToolFactory.getToolFromDescription(ToolFactory.hoe, Tool.stone); + yield return ToolFactory.getToolFromDescription(ToolFactory.pickAxe, Tool.stone); + yield return ToolFactory.getToolFromDescription(ToolFactory.wateringCan, Tool.stone); + yield return ToolFactory.getToolFromDescription(ToolFactory.fishingRod, Tool.stone); + + // other tools + yield return new MilkPail(); + yield return new Shears(); + yield return new Pan(); + yield return new MagnifyingGlass(); + yield return new Wand(); + + // equipment + foreach (int id in Game1.content.Load>("Data\\Boots").Keys) + yield return new Boots(id); + + foreach (int id in Game1.content.Load>("Data\\hats").Keys) + yield return new Hat(id); + + for (int id = Ring.ringLowerIndexRange; id <= Ring.ringUpperIndexRange; id++) + yield return new Ring(id); + + // weapons + foreach (var item in ItemHelper.GetWeapons()) + yield return item; + + // objects + foreach (int id in Game1.objectInformation.Keys) { + if (id >= Ring.ringLowerIndexRange && id <= Ring.ringUpperIndexRange) + continue; // handled separated + + yield return new StardewObject(id, 1); + } + } + + + /// + /// Decide what category name the given item key should belong to. + /// + /// The chosen category name. + /// The item key to categorize. + public string GetCategoryName(ItemKey itemKey) { + // move scythe to tools + if (itemKey.ItemType == ItemType.Weapon && itemKey.ObjectIndex == MeleeWeapon.scythe) + return "Tool"; + + if (itemKey.ItemType != ItemType.Object) + return itemKey.ItemType.ToString(); + + + var categoryName = GetItem(itemKey).getCategoryName(); + return string.IsNullOrEmpty(categoryName) ? "Miscellaneous" : categoryName; + } + } +} \ No newline at end of file diff --git a/Mods/ConvenientChests/CategorizeChests/Framework/ItemKey.cs b/Mods/ConvenientChests/CategorizeChests/Framework/ItemKey.cs new file mode 100644 index 00000000..717b4a09 --- /dev/null +++ b/Mods/ConvenientChests/CategorizeChests/Framework/ItemKey.cs @@ -0,0 +1,77 @@ +using System; +using Microsoft.Xna.Framework; +using StardewValley; +using StardewValley.Objects; +using StardewValley.Tools; +using Object = StardewValley.Object; + +namespace ConvenientChests.CategorizeChests.Framework { + internal struct ItemKey { + public ItemType ItemType { get; } + public int ObjectIndex { get; } + + public ItemKey(ItemType itemType, int parentSheetIndex) { + ItemType = itemType; + ObjectIndex = parentSheetIndex; + } + + public override int GetHashCode() => (int) ItemType * 10000 + ObjectIndex; + + public override string ToString() => $"{ItemType}:{ObjectIndex}"; + + public override bool Equals(object obj) => obj is ItemKey itemKey && + itemKey.ItemType == ItemType && + itemKey.ObjectIndex == ObjectIndex; + + public Item GetOne() { + switch (ItemType) { + case ItemType.Boots: + return new Boots(ObjectIndex); + + case ItemType.Furniture: + return new Furniture(ObjectIndex, Vector2.Zero); + + case ItemType.Hat: + return new Hat(ObjectIndex); + + case ItemType.Fish: + case ItemType.Object: + case ItemType.BigCraftable: + return new Object(ObjectIndex, 1); + + case ItemType.Ring: + return new Ring(ObjectIndex); + + case ItemType.Tool: + return ToolFactory.getToolFromDescription((byte) ObjectIndex, Tool.stone); + + case ItemType.Wallpaper: + return new Wallpaper(ObjectIndex); + + case ItemType.Flooring: + return new Wallpaper(ObjectIndex, true); + + case ItemType.Weapon: + return new MeleeWeapon(ObjectIndex); + + case ItemType.Gate: + return new Fence(Vector2.Zero, ObjectIndex, true); + + default: + throw new ArgumentOutOfRangeException(); + } + } + + public string GetCategory() { + // move scythe to tools + if (ItemType == ItemType.Weapon && ObjectIndex == MeleeWeapon.scythe) + return Game1.content.LoadString("Strings\\StringsFromCSFiles:Tool.cs.14307"); + + if (ItemType != ItemType.Object) + return ItemType.ToString(); + + var categoryName = GetOne().getCategoryName(); + return string.IsNullOrEmpty(categoryName) ? "Miscellaneous" : categoryName; + } + } +} \ No newline at end of file diff --git a/Mods/ConvenientChests/CategorizeChests/Framework/ItemNotImplementedException.cs b/Mods/ConvenientChests/CategorizeChests/Framework/ItemNotImplementedException.cs new file mode 100644 index 00000000..ec85664c --- /dev/null +++ b/Mods/ConvenientChests/CategorizeChests/Framework/ItemNotImplementedException.cs @@ -0,0 +1,17 @@ +using System; +using StardewValley; + +namespace ConvenientChests.CategorizeChests.Framework +{ + /// + /// An exception to be raised when some code attempts to perform an + /// operation on an item that's not recognized by the item repository. + /// + class ItemNotImplementedException : Exception + { + public ItemNotImplementedException(Item item) + : base($"Chest categorization for item named {item.Name} is not implemented") + { + } + } +} \ No newline at end of file diff --git a/Mods/ConvenientChests/CategorizeChests/Framework/ItemType.cs b/Mods/ConvenientChests/CategorizeChests/Framework/ItemType.cs new file mode 100644 index 00000000..a07804ef --- /dev/null +++ b/Mods/ConvenientChests/CategorizeChests/Framework/ItemType.cs @@ -0,0 +1,18 @@ +namespace ConvenientChests.CategorizeChests.Framework +{ + internal enum ItemType + { + BigCraftable, + Boots, + Fish, + Flooring, + Furniture, + Hat, + Object, + Ring, + Tool, + Wallpaper, + Weapon, + Gate + } +} \ No newline at end of file diff --git a/Mods/ConvenientChests/CategorizeChests/Framework/Persistence/ChestAddress.cs b/Mods/ConvenientChests/CategorizeChests/Framework/Persistence/ChestAddress.cs new file mode 100644 index 00000000..13703a3f --- /dev/null +++ b/Mods/ConvenientChests/CategorizeChests/Framework/Persistence/ChestAddress.cs @@ -0,0 +1,40 @@ +using Microsoft.Xna.Framework; + +namespace ConvenientChests.CategorizeChests.Framework.Persistence +{ + /// + /// A key that uniquely identifies a spot in the world where a chest exists. + /// + class ChestAddress + { + public ChestLocationType LocationType { get; set; } + + /// + /// The name of the GameLocation where the chest is. + /// + public string LocationName { get; set; } + + /// + /// The name of the building the chest is in, if the location is a + /// buildable location. + /// + public string BuildingName { get; set; } + + /// + /// The tile the chest is found on. + /// + public Vector2 Tile { get; set; } + + public ChestAddress() + { + } + + public ChestAddress(string locationName, Vector2 tile, ChestLocationType locationType = ChestLocationType.Normal, string buildingName = "") + { + LocationName = locationName; + Tile = tile; + LocationType = locationType; + BuildingName = buildingName; + } + } +} \ No newline at end of file diff --git a/Mods/ConvenientChests/CategorizeChests/Framework/Persistence/ChestEntry.cs b/Mods/ConvenientChests/CategorizeChests/Framework/Persistence/ChestEntry.cs new file mode 100644 index 00000000..19a7a28b --- /dev/null +++ b/Mods/ConvenientChests/CategorizeChests/Framework/Persistence/ChestEntry.cs @@ -0,0 +1,43 @@ +using System.Collections.Generic; +using System.Linq; + +namespace ConvenientChests.CategorizeChests.Framework.Persistence +{ + /// + /// A piece of saved data describing the location of a chest and what items + /// the chest at that location has been assigned to. + /// + class ChestEntry + { + /// + /// The chest's location in the world. + /// + public ChestAddress Address; + + /// + /// The set of item keys that were configured to be accepted + /// by the chest at . + /// + public Dictionary AcceptedItems; + + + public ChestEntry() + { + } + + public ChestEntry(ChestData data, ChestAddress address) + { + Address = address; + AcceptedItems = data.AcceptedItemKinds + .GroupBy(i => i.ItemType) + .ToDictionary( + g => g.Key, + g => string.Join(",", g.Select(i => i.ObjectIndex)) + ); + } + + + public HashSet GetItemSet() => + new HashSet(AcceptedItems.SelectMany(e => e.Value.Split(',').Select(i => new ItemKey(e.Key, int.Parse(i))))); + } +} \ No newline at end of file diff --git a/Mods/ConvenientChests/CategorizeChests/Framework/Persistence/ChestLocationType.cs b/Mods/ConvenientChests/CategorizeChests/Framework/Persistence/ChestLocationType.cs new file mode 100644 index 00000000..9c664361 --- /dev/null +++ b/Mods/ConvenientChests/CategorizeChests/Framework/Persistence/ChestLocationType.cs @@ -0,0 +1,9 @@ +namespace ConvenientChests.CategorizeChests.Framework.Persistence +{ + enum ChestLocationType + { + Normal, + Building, + Refrigerator + } +} \ No newline at end of file diff --git a/Mods/ConvenientChests/CategorizeChests/Framework/Persistence/ISaveManager.cs b/Mods/ConvenientChests/CategorizeChests/Framework/Persistence/ISaveManager.cs new file mode 100644 index 00000000..1e5b9a8c --- /dev/null +++ b/Mods/ConvenientChests/CategorizeChests/Framework/Persistence/ISaveManager.cs @@ -0,0 +1,8 @@ +namespace ConvenientChests.CategorizeChests.Framework.Persistence +{ + interface ISaveManager + { + void Save(string relativePath); + void Load(string relativePath); + } +} \ No newline at end of file diff --git a/Mods/ConvenientChests/CategorizeChests/Framework/Persistence/InvalidSaveDataException.cs b/Mods/ConvenientChests/CategorizeChests/Framework/Persistence/InvalidSaveDataException.cs new file mode 100644 index 00000000..5dcfbf8b --- /dev/null +++ b/Mods/ConvenientChests/CategorizeChests/Framework/Persistence/InvalidSaveDataException.cs @@ -0,0 +1,14 @@ +using System; + +namespace ConvenientChests.CategorizeChests.Framework.Persistence +{ + /// + /// An exception to be raised when save data is malformed or fails to + /// correspond to the state of the game world. + /// + class InvalidSaveDataException : Exception + { + public InvalidSaveDataException(string message) : base(message) {} + public InvalidSaveDataException(string message, Exception inner) : base(message, inner) {} + } +} \ No newline at end of file diff --git a/Mods/ConvenientChests/CategorizeChests/Framework/Persistence/SaveData.cs b/Mods/ConvenientChests/CategorizeChests/Framework/Persistence/SaveData.cs new file mode 100644 index 00000000..c5772d2c --- /dev/null +++ b/Mods/ConvenientChests/CategorizeChests/Framework/Persistence/SaveData.cs @@ -0,0 +1,12 @@ +using System.Collections.Generic; + +namespace ConvenientChests.CategorizeChests.Framework.Persistence +{ + class SaveData + { + /// + /// A list of chest addresses and the chest data associated with them. + /// + public IEnumerable ChestEntries; + } +} \ No newline at end of file diff --git a/Mods/ConvenientChests/CategorizeChests/Framework/Persistence/SaveManager.cs b/Mods/ConvenientChests/CategorizeChests/Framework/Persistence/SaveManager.cs new file mode 100644 index 00000000..33e9c7ab --- /dev/null +++ b/Mods/ConvenientChests/CategorizeChests/Framework/Persistence/SaveManager.cs @@ -0,0 +1,46 @@ +using System.Linq; +using StardewModdingAPI; + +namespace ConvenientChests.CategorizeChests.Framework.Persistence { + /// + /// The class responsible for saving and loading the mod state. + /// + class SaveManager : ISaveManager { + private readonly CategorizeChestsModule Module; + + public SaveManager(CategorizeChestsModule module) { + Module = module; + } + + /// + /// Generate save data and write it to the given file path. + /// + /// The path of the save file relative to the mod folder. + public void Save(string relativePath) { + var saver = new Saver(Module.ChestDataManager); + Module.ModEntry.Helper.Data.WriteJsonFile(relativePath, saver.GetSerializableData()); + } + + /// + /// Load save data from the given file path. + /// + /// The path of the save file relative to the mod folder. + public void Load(string relativePath) { + var model = Module.ModEntry.Helper.Data.ReadJsonFile(relativePath) ?? new SaveData(); + + foreach (var entry in model.ChestEntries) { + try { + var chest = Module.ChestFinder.GetChestByAddress(entry.Address); + var chestData = Module.ChestDataManager.GetChestData(chest); + + chestData.AcceptedItemKinds = entry.GetItemSet(); + foreach (var key in chestData.AcceptedItemKinds.Where(k => !Module.ItemDataManager.Prototypes.ContainsKey(k))) + Module.ItemDataManager.Prototypes.Add(key, key.GetOne()); + } + catch (InvalidSaveDataException e) { + Module.Monitor.Log(e.Message, LogLevel.Warn); + } + } + } + } +} \ No newline at end of file diff --git a/Mods/ConvenientChests/CategorizeChests/Framework/Saver.cs b/Mods/ConvenientChests/CategorizeChests/Framework/Saver.cs new file mode 100644 index 00000000..e9282f02 --- /dev/null +++ b/Mods/ConvenientChests/CategorizeChests/Framework/Saver.cs @@ -0,0 +1,81 @@ +using System.Collections.Generic; +using System.Linq; +using ConvenientChests.CategorizeChests.Framework.Persistence; +using Microsoft.Xna.Framework; +using StardewModdingAPI; +using StardewValley; +using StardewValley.Locations; +using StardewValley.Objects; + +namespace ConvenientChests.CategorizeChests.Framework +{ + /// + /// The class responsible for producing data to be saved. + /// + class Saver + { + private readonly IChestDataManager ChestDataManager; + + public Saver(IChestDataManager chestDataManager) + { + ChestDataManager = chestDataManager; + } + + /// + /// Build save data for the current game state. + /// + public SaveData GetSerializableData() + { + return new SaveData + { + ChestEntries = BuildChestEntries() + }; + } + + private IEnumerable BuildChestEntries() + { + foreach (var location in Game1.locations) + { + // chests + foreach (var pair in GetLocationChests(location)) + yield return new ChestEntry( + ChestDataManager.GetChestData(pair.Value), + new ChestAddress(location.Name, pair.Key) + ); + + switch (location) + { + // buildings + case BuildableGameLocation buildableLocation: + foreach (var building in buildableLocation.buildings.Where(b => b.indoors.Value != null)) + foreach (var pair in GetLocationChests(building.indoors.Value)) + yield return new ChestEntry( + ChestDataManager.GetChestData(pair.Value), + new ChestAddress(location.Name, pair.Key, ChestLocationType.Building, building.nameOfIndoors) + ); + break; + + // fridges + case FarmHouse farmHouse when farmHouse.upgradeLevel >= 1: + yield return new ChestEntry( + ChestDataManager.GetChestData(farmHouse.fridge.Value), + new ChestAddress {LocationName = farmHouse.uniqueName?.Value ?? farmHouse.Name, LocationType = ChestLocationType.Refrigerator} + ); + break; + } + } + } + + /// + /// Retrieve a collection of the chest objects present in the given + /// location, keyed by their tile location. + /// + private static IDictionary GetLocationChests(GameLocation location) => + location.Objects.Pairs + .Where(pair => pair.Value is Chest c && c.playerChest.Value) + .ToDictionary( + pair => pair.Key, + pair => (Chest) pair.Value + ); + } +} \ No newline at end of file diff --git a/Mods/ConvenientChests/CategorizeChests/Interface/ITooltipManager.cs b/Mods/ConvenientChests/CategorizeChests/Interface/ITooltipManager.cs new file mode 100644 index 00000000..ccacd36d --- /dev/null +++ b/Mods/ConvenientChests/CategorizeChests/Interface/ITooltipManager.cs @@ -0,0 +1,11 @@ +using ConvenientChests.CategorizeChests.Interface.Widgets; +using Microsoft.Xna.Framework.Graphics; + +namespace ConvenientChests.CategorizeChests.Interface +{ + interface ITooltipManager + { + void ShowTooltipThisFrame(Widget tooltip); + void Draw(SpriteBatch batch); + } +} \ No newline at end of file diff --git a/Mods/ConvenientChests/CategorizeChests/Interface/InterfaceHost.cs b/Mods/ConvenientChests/CategorizeChests/Interface/InterfaceHost.cs new file mode 100644 index 00000000..6d960868 --- /dev/null +++ b/Mods/ConvenientChests/CategorizeChests/Interface/InterfaceHost.cs @@ -0,0 +1,216 @@ +// This file is substantially taken from the BaseOverlay class included in Pathoschild's ChestsAnywhere mod. + +using System; +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Graphics; +using Microsoft.Xna.Framework.Input; +using StardewModdingAPI; +using StardewModdingAPI.Events; +using StardewValley; +using Rectangle = xTile.Dimensions.Rectangle; + +namespace ConvenientChests.CategorizeChests.Interface +{ + /// An interface which supports user interaction and overlays the active menu (if any). + public abstract class InterfaceHost : IDisposable + { + /********* + ** Fields + *********/ + /// The SMAPI events available for mods. + private readonly IModEvents Events; + + /// An API for checking and changing input state. + protected readonly IInputHelper InputHelper; + + /// The last viewport bounds. + private Rectangle LastViewport; + + /// Indicates whether to keep the overlay active. If null, the overlay is kept until explicitly disposed. + private readonly Func KeepAliveCheck; + + + /********* + ** Public methods + *********/ + /// Release all resources. + public virtual void Dispose() + { + this.Events.Display.Rendered -= this.OnRendered; + this.Events.GameLoop.UpdateTicked -= this.OnUpdateTicked; + this.Events.Input.ButtonPressed -= this.OnButtonPressed; + this.Events.Input.CursorMoved -= this.OnCursorMoved; + this.Events.Input.MouseWheelScrolled -= this.OnMouseWheelScrolled; + } + + + /********* + ** Protected methods + *********/ + /**** + ** Implementation + ****/ + /// Construct an instance. + /// The SMAPI events available for mods. + /// An API for checking and changing input state. + /// Indicates whether to keep the overlay active. If null, the overlay is kept until explicitly disposed. + protected InterfaceHost(IModEvents events, IInputHelper inputHelper, Func keepAlive = null) + { + this.Events = events; + this.InputHelper = inputHelper; + this.KeepAliveCheck = keepAlive; + this.LastViewport = new Rectangle(Game1.viewport.X, Game1.viewport.Y, Game1.viewport.Width, Game1.viewport.Height); + + events.Display.Rendered += this.OnRendered; + events.GameLoop.UpdateTicked += this.OnUpdateTicked; + events.Input.ButtonPressed += this.OnButtonPressed; + events.Input.CursorMoved += this.OnCursorMoved; + events.Input.MouseWheelScrolled += this.OnMouseWheelScrolled; + } + + /// Draw the overlay to the screen. + /// The sprite batch being drawn. + protected virtual void Draw(SpriteBatch batch) { } + + /// The method invoked when the player left-clicks. + /// The X-position of the cursor. + /// The Y-position of the cursor. + /// Whether the event has been handled and shouldn't be propagated further. + protected virtual bool ReceiveLeftClick(int x, int y) + { + return false; + } + + /// The method invoked when the player presses a button. + /// The button that was pressed. + /// Whether the event has been handled and shouldn't be propagated further. + protected virtual bool ReceiveButtonPress(SButton input) + { + return false; + } + + /// The method invoked when the player uses the mouse scroll wheel. + /// The scroll amount. + /// Whether the event has been handled and shouldn't be propagated further. + protected virtual bool ReceiveScrollWheelAction(int amount) + { + return false; + } + + /// The method invoked when the cursor is hovered. + /// The cursor's X position. + /// The cursor's Y position. + /// Whether the event has been handled and shouldn't be propagated further. + protected virtual bool ReceiveCursorHover(int x, int y) + { + return false; + } + + /// The method invoked when the player resizes the game windoww. + /// The previous game window bounds. + /// The new game window bounds. + protected virtual void ReceiveGameWindowResized(Rectangle oldBounds, Rectangle newBounds) { } + + /// Draw the mouse cursor. + /// Derived from . + protected void DrawCursor() + { + if (Game1.options.hardwareCursor) + return; + Game1.spriteBatch.Draw(Game1.mouseCursors, new Vector2(Game1.getMouseX(), Game1.getMouseY()), Game1.getSourceRectForStandardTileSheet(Game1.mouseCursors, Game1.options.SnappyMenus ? 44 : 0, 16, 16), Color.White * Game1.mouseCursorTransparency, 0.0f, Vector2.Zero, Game1.pixelZoom + Game1.dialogueButtonScale / 150f, SpriteEffects.None, 1f); + } + + /**** + ** Event listeners + ****/ + /// The method called when the game finishes drawing components to the screen. + /// The source of the event. + /// The event arguments. + private void OnRendered(object sender, RenderedEventArgs e) + { + this.Draw(Game1.spriteBatch); + } + + /// The method called once per event tick. + /// The source of the event. + /// The event arguments. + private void OnUpdateTicked(object sender, UpdateTickedEventArgs e) + { + // detect end of life + if (this.KeepAliveCheck != null && !this.KeepAliveCheck()) + { + this.Dispose(); + return; + } + + // trigger window resize event + Rectangle newViewport = Game1.viewport; + if (this.LastViewport.Width != newViewport.Width || this.LastViewport.Height != newViewport.Height) + { + newViewport = new Rectangle(newViewport.X, newViewport.Y, newViewport.Width, newViewport.Height); + this.ReceiveGameWindowResized(this.LastViewport, newViewport); + this.LastViewport = newViewport; + } + } + + /// The method invoked when the player presses a key. + /// The source of the event. + /// The event arguments. + private void OnButtonPressed(object sender, ButtonPressedEventArgs e) + { + bool handled = e.Button == SButton.MouseLeft || e.Button.IsUseToolButton() + ? this.ReceiveLeftClick(Game1.getMouseX(), Game1.getMouseY()) + : this.ReceiveButtonPress(e.Button); + + if (handled) + this.InputHelper.Suppress(e.Button); + } + + /// The method invoked when the mouse wheel is scrolled. + /// The source of the event. + /// The event arguments. + private void OnMouseWheelScrolled(object sender, MouseWheelScrolledEventArgs e) + { + bool scrollHandled = this.ReceiveScrollWheelAction(e.Delta); + if (scrollHandled) + { + MouseState cur = Game1.oldMouseState; + Game1.oldMouseState = new MouseState( + x: cur.X, + y: cur.Y, + scrollWheel: e.NewValue, + leftButton: cur.LeftButton, + middleButton: cur.MiddleButton, + rightButton: cur.RightButton, + xButton1: cur.XButton1, + xButton2: cur.XButton2 + ); + } + } + + /// The method invoked when the in-game cursor is moved. + /// The source of the event. + /// The event arguments. + private void OnCursorMoved(object sender, CursorMovedEventArgs e) + { + int x = (int)e.NewPosition.ScreenPixels.X; + int y = (int)e.NewPosition.ScreenPixels.Y; + + bool hoverHandled = this.ReceiveCursorHover(x, y); + if (hoverHandled) + { + MouseState cur = Game1.oldMouseState; + Game1.oldMouseState = new MouseState( + x: x, + y: y, + scrollWheel: cur.ScrollWheelValue, + leftButton: cur.LeftButton, + middleButton: cur.MiddleButton, + rightButton: cur.RightButton, + xButton1: cur.XButton1, + xButton2: cur.XButton2 + ); + } + } + } +} diff --git a/Mods/ConvenientChests/CategorizeChests/Interface/NineSlice.cs b/Mods/ConvenientChests/CategorizeChests/Interface/NineSlice.cs new file mode 100644 index 00000000..1a0816a4 --- /dev/null +++ b/Mods/ConvenientChests/CategorizeChests/Interface/NineSlice.cs @@ -0,0 +1,77 @@ +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Graphics; + +namespace ConvenientChests.CategorizeChests.Interface +{ + class NineSlice + { + public TextureRegion Center; + public TextureRegion Top; + public TextureRegion TopRight; + public TextureRegion Right; + public TextureRegion BottomRight; + public TextureRegion Bottom; + public TextureRegion BottomLeft; + public TextureRegion Left; + public TextureRegion TopLeft; + + public int RightBorderThickness => Right.Width; + public int LeftBorderThickness => Left.Width; + public int TopBorderThickness => Top.Height; + public int BottomBorderThickness => Bottom.Height; + + public void Draw(SpriteBatch batch, Rectangle bounds) + { + // draw background + batch.Draw(Center, + bounds.X + Left.Width, + bounds.Y + Top.Height, + bounds.Width - Left.Width - Right.Width, + bounds.Height - Top.Height - Bottom.Height); + + // draw borders + batch.Draw(Top, + bounds.X + TopLeft.Width, + bounds.Y, + bounds.Width - TopLeft.Width - TopRight.Width, + Top.Height); + batch.Draw(Left, + bounds.X, + bounds.Y + TopLeft.Height, + Left.Width, + bounds.Height - TopLeft.Height - BottomLeft.Height); + batch.Draw(Right, + bounds.X + bounds.Width - Right.Width, + bounds.Y + TopRight.Height, + Right.Width, + bounds.Height - TopRight.Height - BottomRight.Height); + batch.Draw(Bottom, + bounds.X + BottomLeft.Width, + bounds.Y + bounds.Height - Bottom.Height, + bounds.Width - BottomLeft.Width - BottomRight.Width, + Bottom.Height); + + // draw border joints + batch.Draw(TopLeft, + bounds.X, + bounds.Y, + TopLeft.Width, + TopLeft.Height); + batch.Draw(TopRight, + bounds.X + bounds.Width - TopRight.Width, + bounds.Y, + TopRight.Width, + TopRight.Height); + batch.Draw(BottomLeft, + bounds.X, + bounds.Y + bounds.Height - BottomLeft.Height, + BottomLeft.Width, + BottomLeft.Height); + batch.Draw(BottomRight, + bounds.X + bounds.Width - BottomRight.Width, + bounds.Y + bounds.Height - BottomRight.Height, + BottomRight.Width, + BottomRight.Height); + } + } +} \ No newline at end of file diff --git a/Mods/ConvenientChests/CategorizeChests/Interface/Sprites.cs b/Mods/ConvenientChests/CategorizeChests/Interface/Sprites.cs new file mode 100644 index 00000000..5b2baa52 --- /dev/null +++ b/Mods/ConvenientChests/CategorizeChests/Interface/Sprites.cs @@ -0,0 +1,77 @@ +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Graphics; +using StardewValley; + +namespace ConvenientChests.CategorizeChests.Interface +{ + static class Sprites + { + public static readonly NineSlice TabBackground = new NineSlice + { + TopLeft = new TextureRegion(Game1.mouseCursors, new Rectangle(0, 384, 5, 5), zoom: true), + TopRight = new TextureRegion(Game1.mouseCursors, new Rectangle(11, 384, 5, 5), zoom: true), + BottomLeft = new TextureRegion(Game1.mouseCursors, new Rectangle(0, 395, 5, 5), zoom: true), + BottomRight = new TextureRegion(Game1.mouseCursors, new Rectangle(11, 395, 5, 5), zoom: true), + Top = new TextureRegion(Game1.mouseCursors, new Rectangle(4, 384, 1, 3), zoom: true), + Left = new TextureRegion(Game1.mouseCursors, new Rectangle(0, 388, 3, 1), zoom: true), + Right = new TextureRegion(Game1.mouseCursors, new Rectangle(13, 388, 3, 1), zoom: true), + Bottom = new TextureRegion(Game1.mouseCursors, new Rectangle(4, 397, 1, 3), zoom: true), + Center = new TextureRegion(Game1.mouseCursors, new Rectangle(5, 387, 1, 1), zoom: true), + }; + + public static readonly NineSlice MenuBackground = new NineSlice + { + TopLeft = new TextureRegion(Game1.menuTexture, new Rectangle(12, 12, 24, 24)), + TopRight = new TextureRegion(Game1.menuTexture, new Rectangle(220, 12, 24, 24)), + BottomLeft = new TextureRegion(Game1.menuTexture, new Rectangle(12, 220, 24, 24)), + BottomRight = new TextureRegion(Game1.menuTexture, new Rectangle(220, 220, 24, 24)), + Top = new TextureRegion(Game1.menuTexture, new Rectangle(40, 12, 1, 24)), + Left = new TextureRegion(Game1.menuTexture, new Rectangle(12, 36, 24, 1)), + Right = new TextureRegion(Game1.menuTexture, new Rectangle(220, 40, 24, 1)), + Bottom = new TextureRegion(Game1.menuTexture, new Rectangle(36, 220, 1, 24)), + Center = new TextureRegion(Game1.menuTexture, new Rectangle(64, 128, 64, 64)), + }; + + public static readonly NineSlice TooltipBackground = new NineSlice + { + TopLeft = new TextureRegion(Game1.mouseCursors, new Rectangle(293, 360, 4, 4), zoom: true), + Left = new TextureRegion(Game1.mouseCursors, new Rectangle(293, 364, 4, 16), zoom: true), + BottomLeft = new TextureRegion(Game1.mouseCursors, new Rectangle(293, 380, 4, 4), zoom: true), + Bottom = new TextureRegion(Game1.mouseCursors, new Rectangle(297, 380, 16, 4), zoom: true), + BottomRight = new TextureRegion(Game1.mouseCursors, new Rectangle(313, 380, 4, 4), zoom: true), + Right = new TextureRegion(Game1.mouseCursors, new Rectangle(313, 364, 4, 16), zoom: true), + TopRight = new TextureRegion(Game1.mouseCursors, new Rectangle(313, 360, 4, 4), zoom: true), + Top = new TextureRegion(Game1.mouseCursors, new Rectangle(297, 360, 16, 4), zoom: true), + Center = new TextureRegion(Game1.mouseCursors, new Rectangle(297, 364, 16, 16), zoom: true), + }; + + public static readonly NineSlice LeftProtrudingTab = new NineSlice + { + TopLeft = new TextureRegion(Game1.mouseCursors, new Rectangle(656, 64, 5, 5), zoom: true), + TopRight = new TextureRegion(Game1.mouseCursors, new Rectangle(670, 64, 2, 5), zoom: true), + BottomLeft = new TextureRegion(Game1.mouseCursors, new Rectangle(656, 75, 5, 5), zoom: true), + BottomRight = new TextureRegion(Game1.mouseCursors, new Rectangle(670, 75, 2, 5), zoom: true), + Top = new TextureRegion(Game1.mouseCursors, new Rectangle(661, 64, 1, 4), zoom: true), + Left = new TextureRegion(Game1.mouseCursors, new Rectangle(656, 69, 5, 1), zoom: true), + Right = new TextureRegion(Game1.mouseCursors, new Rectangle(670, 68, 2, 1), zoom: true), + Bottom = new TextureRegion(Game1.mouseCursors, new Rectangle(661, 76, 1, 4), zoom: true), + Center = new TextureRegion(Game1.mouseCursors, new Rectangle(661, 68, 1, 1), zoom: true), + }; + + public static readonly TextureRegion LeftArrow = new TextureRegion(Game1.mouseCursors, new Rectangle(8, 268, 44, 40)); + public static readonly TextureRegion RightArrow = new TextureRegion(Game1.mouseCursors, new Rectangle(12, 204, 44, 40)); + public static readonly TextureRegion EmptyCheckbox = new TextureRegion(Game1.mouseCursors, new Rectangle(227, 425, 9, 9), zoom: true); + public static readonly TextureRegion FilledCheckbox = new TextureRegion(Game1.mouseCursors, new Rectangle(236, 425, 9, 9), zoom: true); + public static readonly TextureRegion ExitButton = new TextureRegion(Game1.mouseCursors, new Rectangle(337, 494, 12, 12), zoom: true); + + public static void Draw(this SpriteBatch batch, Texture2D sheet, Rectangle sprite, int x, int y, int width, int height, Color? color = null) + { + batch.Draw(sheet, new Rectangle(x, y, width, height), sprite, color ?? Color.White); + } + + public static void Draw(this SpriteBatch batch, TextureRegion textureRegion, int x, int y, int width, int height, Color? color = null) + { + batch.Draw(textureRegion.Texture, textureRegion.Region, x, y, width, height, color); + } + } +} \ No newline at end of file diff --git a/Mods/ConvenientChests/CategorizeChests/Interface/TextureRegion.cs b/Mods/ConvenientChests/CategorizeChests/Interface/TextureRegion.cs new file mode 100644 index 00000000..11805173 --- /dev/null +++ b/Mods/ConvenientChests/CategorizeChests/Interface/TextureRegion.cs @@ -0,0 +1,28 @@ +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Graphics; +using StardewValley; + +namespace ConvenientChests.CategorizeChests.Interface +{ + class TextureRegion + { + public readonly Texture2D Texture; + public readonly Rectangle Region; + public readonly bool Zoom; + + public TextureRegion(Texture2D texture, Rectangle region) + : this(texture, region, zoom: false) + { + } + + public TextureRegion(Texture2D texture, Rectangle region, bool zoom) + { + Texture = texture; + Region = region; + Zoom = zoom; + } + + public int Width => Region.Width * (Zoom ? Game1.pixelZoom : 1); + public int Height => Region.Height * (Zoom ? Game1.pixelZoom : 1); + } +} \ No newline at end of file diff --git a/Mods/ConvenientChests/CategorizeChests/Interface/TooltipManager.cs b/Mods/ConvenientChests/CategorizeChests/Interface/TooltipManager.cs new file mode 100644 index 00000000..6399fae5 --- /dev/null +++ b/Mods/ConvenientChests/CategorizeChests/Interface/TooltipManager.cs @@ -0,0 +1,34 @@ +using ConvenientChests.CategorizeChests.Interface.Widgets; +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Graphics; +using StardewValley; + +namespace ConvenientChests.CategorizeChests.Interface +{ + class TooltipManager : ITooltipManager + { + private Widget Tooltip; + + public void ShowTooltipThisFrame(Widget tooltip) + { + Tooltip = tooltip; + } + + public void Draw(SpriteBatch batch) + { + if (Tooltip != null) + { + var mousePosition = Game1.getMousePosition(); + + Tooltip.Position = new Point( + mousePosition.X + 8 * Game1.pixelZoom, + mousePosition.Y + 8 * Game1.pixelZoom + ); + + Tooltip.Draw(batch); + + Tooltip = null; + } + } + } +} \ No newline at end of file diff --git a/Mods/ConvenientChests/CategorizeChests/Interface/WidgetHost.cs b/Mods/ConvenientChests/CategorizeChests/Interface/WidgetHost.cs new file mode 100644 index 00000000..387bf063 --- /dev/null +++ b/Mods/ConvenientChests/CategorizeChests/Interface/WidgetHost.cs @@ -0,0 +1,40 @@ +using ConvenientChests.CategorizeChests.Interface.Widgets; +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Graphics; +using StardewModdingAPI; +using StardewModdingAPI.Events; +using StardewValley; + +namespace ConvenientChests.CategorizeChests.Interface +{ + class WidgetHost : InterfaceHost + { + public readonly Widget RootWidget; + public readonly ITooltipManager TooltipManager; + + public WidgetHost(IModEvents events, IInputHelper input) + : base(events, input) + { + RootWidget = new Widget() {Width = Game1.viewport.Width, Height = Game1.viewport.Height}; + TooltipManager = new TooltipManager(); + } + + protected override void Draw(SpriteBatch batch) + { + RootWidget.Draw(batch); + DrawCursor(); + TooltipManager.Draw(batch); + } + + protected override bool ReceiveButtonPress(SButton input) => RootWidget.ReceiveButtonPress(input); + protected override bool ReceiveLeftClick(int x, int y) => RootWidget.ReceiveLeftClick(new Point(x, y)); + protected override bool ReceiveCursorHover(int x, int y) => RootWidget.ReceiveCursorHover(new Point(x, y)); + protected override bool ReceiveScrollWheelAction(int amount) => RootWidget.ReceiveScrollWheelAction(amount); + + + public override void Dispose() { + base.Dispose(); + RootWidget?.Dispose(); + } + } +} \ No newline at end of file diff --git a/Mods/ConvenientChests/CategorizeChests/Interface/Widgets/Background.cs b/Mods/ConvenientChests/CategorizeChests/Interface/Widgets/Background.cs new file mode 100644 index 00000000..0c2d31e7 --- /dev/null +++ b/Mods/ConvenientChests/CategorizeChests/Interface/Widgets/Background.cs @@ -0,0 +1,30 @@ +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Graphics; + +namespace ConvenientChests.CategorizeChests.Interface.Widgets +{ + /// + /// A resizable nine-slice background. + /// + class Background : Widget + { + public readonly NineSlice Graphic; + + public Background(NineSlice nineSlice) + { + Graphic = nineSlice; + } + + public Background(NineSlice nineSlice, int width, int height) + { + Graphic = nineSlice; + Width = width; + Height = height; + } + + public override void Draw(SpriteBatch batch) + { + Graphic.Draw(batch, new Rectangle(GlobalPosition.X, GlobalPosition.Y, Width, Height)); + } + } +} \ No newline at end of file diff --git a/Mods/ConvenientChests/CategorizeChests/Interface/Widgets/Button.cs b/Mods/ConvenientChests/CategorizeChests/Interface/Widgets/Button.cs new file mode 100644 index 00000000..8c8c14d5 --- /dev/null +++ b/Mods/ConvenientChests/CategorizeChests/Interface/Widgets/Button.cs @@ -0,0 +1,19 @@ +using System; +using Microsoft.Xna.Framework; + +namespace ConvenientChests.CategorizeChests.Interface.Widgets +{ + /// + /// A simple clickable widget. + /// + public abstract class Button : Widget + { + public event Action OnPress; + + public override bool ReceiveLeftClick(Point point) + { + OnPress?.Invoke(); + return true; + } + } +} \ No newline at end of file diff --git a/Mods/ConvenientChests/CategorizeChests/Interface/Widgets/CategoryMenu.cs b/Mods/ConvenientChests/CategorizeChests/Interface/Widgets/CategoryMenu.cs new file mode 100644 index 00000000..60fb34c1 --- /dev/null +++ b/Mods/ConvenientChests/CategorizeChests/Interface/Widgets/CategoryMenu.cs @@ -0,0 +1,171 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using ConvenientChests.CategorizeChests.Framework; +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Graphics; +using StardewValley; + +namespace ConvenientChests.CategorizeChests.Interface.Widgets { + class CategoryMenu : Widget { + // Styling settings + private const int MaxItemColumns = 12; + private static int Padding => 2 * Game1.pixelZoom; + private static SpriteFont HeaderFont => Game1.dialogueFont; + + // Elements + private Widget Body { get; set; } + private Widget TopRow { get; set; } + private LabeledCheckbox SelectAllButton { get; set; } + private SpriteButton CloseButton { get; set; } + private Background Background { get; set; } + private Label CategoryLabel { get; set; } + private SpriteButton PrevButton { get; set; } + private SpriteButton NextButton { get; set; } + private WrapBag ToggleBag { get; set; } + private IEnumerable ItemToggles => ToggleBag.Children.OfType(); + + private IItemDataManager ItemDataManager { get; } + private ITooltipManager TooltipManager { get; } + private ChestData ChestData { get; } + private int Index { get; set; } + private List Categories { get; } + private string ActiveCategory => Categories[Index]; + + public event Action OnClose; + + public CategoryMenu(ChestData chestData, IItemDataManager itemDataManager, ITooltipManager tooltipManager) { + ItemDataManager = itemDataManager; + TooltipManager = tooltipManager; + ChestData = chestData; + + Categories = itemDataManager.Categories.Keys.ToList(); + Categories.Sort(); + + BuildWidgets(); + + SetCategory(Index); + } + + private void BuildWidgets() { + Background = AddChild(new Background(Sprites.MenuBackground)); + Body = AddChild(new Widget()); + TopRow = Body.AddChild(new Widget()); + ToggleBag = Body.AddChild(new WrapBag(MaxItemColumns * Game1.tileSize)); + + NextButton = TopRow.AddChild(new SpriteButton(Sprites.RightArrow)); + PrevButton = TopRow.AddChild(new SpriteButton(Sprites.LeftArrow)); + NextButton.OnPress += () => CycleCategory(1); + PrevButton.OnPress += () => CycleCategory(-1); + + SelectAllButton = TopRow.AddChild(new LabeledCheckbox("All")); + SelectAllButton.OnChange += OnToggleSelectAll; + + CloseButton = AddChild(new SpriteButton(Sprites.ExitButton)); + CloseButton.OnPress += () => OnClose?.Invoke(); + + CategoryLabel = TopRow.AddChild(new Label("", Color.Black, HeaderFont)); + } + + private void PositionElements() { + Body.Position = new Point(Background.Graphic.LeftBorderThickness, Background.Graphic.RightBorderThickness); + + // Figure out width + Body.Width = ToggleBag.Width; + TopRow.Width = Body.Width; + Width = Body.Width + Background.Graphic.LeftBorderThickness + Background.Graphic.RightBorderThickness + Padding * 2; + + // Build the top row + var longestCat = Categories.OrderByDescending(s => s.Length).First(); + var headerWidth = (int) HeaderFont.MeasureString(longestCat).X; + NextButton.X = TopRow.Width / 2 + headerWidth / 2; + PrevButton.X = TopRow.Width / 2 - PrevButton.Width - headerWidth / 2; + + SelectAllButton.X = Padding; + + CategoryLabel.CenterHorizontally(); + + TopRow.Height = TopRow.Children.Max(c => c.Height); + + foreach (var child in TopRow.Children) + child.Y = TopRow.Height / 2 - child.Height / 2; + + // Figure out height and vertical positioning + ToggleBag.Y = TopRow.Y + TopRow.Height + Padding; + Body.Height = ToggleBag.Y + ToggleBag.Height; + Height = (Body.Height + Background.Graphic.TopBorderThickness + Background.Graphic.BottomBorderThickness + Padding * 2); + + Background.Width = Width; + Background.Height = Height; + + CloseButton.Position = new Point(Width - CloseButton.Width, 0); + } + + private void OnToggleSelectAll(bool on) { + if (on) + SelectAll(); + else + SelectNone(); + } + + private void SelectAll() { + foreach (var toggle in ItemToggles) { + if (!toggle.Active) + toggle.Toggle(); + } + } + + private void SelectNone() { + foreach (var toggle in ItemToggles) { + if (toggle.Active) + toggle.Toggle(); + } + } + + private void CycleCategory(int offset) { + SetCategory(Utility.Mod(Index + offset, Categories.Count)); + } + + private void SetCategory(int index) { + Index = index; + CategoryLabel.Text = ActiveCategory; + + RecreateItemToggles(); + + SelectAllButton.Checked = AreAllSelected(); + + PositionElements(); + } + + private void RecreateItemToggles() { + ToggleBag.RemoveChildren(); + + var itemKeys = ItemDataManager.Categories[ActiveCategory]; + + foreach (var itemKey in itemKeys) { + var item = ItemDataManager.GetItem(itemKey); + var toggle = ToggleBag.AddChild(new ItemToggle(TooltipManager, item, ChestData.Accepts(itemKey))); + toggle.OnToggle += () => ToggleItem(itemKey); + } + } + + private void ToggleItem(ItemKey itemKey) { + ChestData.Toggle(itemKey); + SelectAllButton.Checked = AreAllSelected(); + } + + private bool AreAllSelected() { + return ItemToggles.Count(t => !t.Active) == 0; + } + + public override bool ReceiveLeftClick(Point point) { + PropagateLeftClick(point); + return true; + } + + public override bool ReceiveScrollWheelAction(int amount) { + CycleCategory(amount > 1 ? -1 : 1); + return true; + } + } +} \ No newline at end of file diff --git a/Mods/ConvenientChests/CategorizeChests/Interface/Widgets/ChestOverlay.cs b/Mods/ConvenientChests/CategorizeChests/Interface/Widgets/ChestOverlay.cs new file mode 100644 index 00000000..1223a0fe --- /dev/null +++ b/Mods/ConvenientChests/CategorizeChests/Interface/Widgets/ChestOverlay.cs @@ -0,0 +1,127 @@ +using System; +using ConvenientChests.StackToNearbyChests; +using Microsoft.Xna.Framework; +using StardewModdingAPI; +using StardewValley; +using StardewValley.Menus; +using StardewValley.Objects; + +namespace ConvenientChests.CategorizeChests.Interface.Widgets { + internal class ChestOverlay : Widget { + private ItemGrabMenu ItemGrabMenu { get; } + private CategorizeChestsModule Module { get; } + private Chest Chest { get; } + private ITooltipManager TooltipManager { get; } + + private readonly InventoryMenu InventoryMenu; + private readonly InventoryMenu.highlightThisItem DefaultChestHighlighter; + private readonly InventoryMenu.highlightThisItem DefaultInventoryHighlighter; + + private TextButton OpenButton { get; set; } + private TextButton StashButton { get; set; } + private CategoryMenu CategoryMenu { get; set; } + + public ChestOverlay(CategorizeChestsModule module, Chest chest, ItemGrabMenu menu, ITooltipManager tooltipManager) { + Module = module; + Chest = chest; + ItemGrabMenu = menu; + InventoryMenu = menu.ItemsToGrabMenu; + TooltipManager = tooltipManager; + + DefaultChestHighlighter = ItemGrabMenu.inventory.highlightMethod; + DefaultInventoryHighlighter = InventoryMenu.highlightMethod; + + AddButtons(); + } + + protected override void OnParent(Widget parent) { + base.OnParent(parent); + + if (parent == null) return; + Width = parent.Width; + Height = parent.Height; + } + + private void AddButtons() { + OpenButton = new TextButton("Categorize", Sprites.LeftProtrudingTab); + OpenButton.OnPress += ToggleMenu; + AddChild(OpenButton); + + StashButton = new TextButton(ChooseStashButtonLabel(), Sprites.LeftProtrudingTab); + StashButton.OnPress += StashItems; + AddChild(StashButton); + + PositionButtons(); + } + + private void PositionButtons() { + StashButton.Width = OpenButton.Width = Math.Max(StashButton.Width, OpenButton.Width); + + OpenButton.Position = new Point( + ItemGrabMenu.xPositionOnScreen + ItemGrabMenu.width / 2 - OpenButton.Width - 112 * Game1.pixelZoom, + ItemGrabMenu.yPositionOnScreen + 22 * Game1.pixelZoom + ); + + StashButton.Position = new Point( + OpenButton.Position.X + OpenButton.Width - StashButton.Width, + OpenButton.Position.Y + OpenButton.Height - 0 + ); + } + + private string ChooseStashButtonLabel() { + return Module.Config.StashKey == SButton.None + ? "Stash" + : $"Stash ({Module.Config.StashKey})"; + } + + private void ToggleMenu() { + if (CategoryMenu == null) + OpenCategoryMenu(); + + else + CloseCategoryMenu(); + } + + private void OpenCategoryMenu() { + var chestData = Module.ChestDataManager.GetChestData(Chest); + CategoryMenu = new CategoryMenu(chestData, Module.ItemDataManager, TooltipManager); + CategoryMenu.Position = new Point( + ItemGrabMenu.xPositionOnScreen + ItemGrabMenu.width / 2 - CategoryMenu.Width / 2 - 6 * Game1.pixelZoom, + ItemGrabMenu.yPositionOnScreen - 10 * Game1.pixelZoom + ); + CategoryMenu.OnClose += CloseCategoryMenu; + AddChild(CategoryMenu); + + SetItemsClickable(false); + } + + private void CloseCategoryMenu() { + RemoveChild(CategoryMenu); + CategoryMenu = null; + + SetItemsClickable(true); + } + + private void StashItems() => StackLogic.StashToChest(Chest, ModEntry.StashNearby.AcceptingFunction); + + public override bool ReceiveLeftClick(Point point) { + var hit = PropagateLeftClick(point); + if (!hit && CategoryMenu != null) + // Are they clicking outside the menu to try to exit it? + CloseCategoryMenu(); + + return hit; + } + + private void SetItemsClickable(bool clickable) { + if (clickable) { + ItemGrabMenu.inventory.highlightMethod = DefaultChestHighlighter; + InventoryMenu.highlightMethod = DefaultInventoryHighlighter; + } + else { + ItemGrabMenu.inventory.highlightMethod = i => false; + InventoryMenu.highlightMethod = i => false; + } + } + } +} \ No newline at end of file diff --git a/Mods/ConvenientChests/CategorizeChests/Interface/Widgets/ItemToggle.cs b/Mods/ConvenientChests/CategorizeChests/Interface/Widgets/ItemToggle.cs new file mode 100644 index 00000000..a492c5bc --- /dev/null +++ b/Mods/ConvenientChests/CategorizeChests/Interface/Widgets/ItemToggle.cs @@ -0,0 +1,48 @@ +using System; +using ConvenientChests.CategorizeChests.Framework; +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Graphics; +using StardewValley; + +namespace ConvenientChests.CategorizeChests.Interface.Widgets { + /// + /// A toggle button corresponding to a given kind of item, appearing as + /// the icon for that item with an appropriate tooltip. + /// + class ItemToggle : Widget { + public Item Item { get; } + private ITooltipManager TooltipManager { get; } + private Widget Tooltip { get; } + public bool Active; + + public event Action OnToggle; + + public ItemToggle(ITooltipManager tooltipManager, Item item, bool active) { + TooltipManager = tooltipManager; + Item = item; + Active = active; + Tooltip = new ItemTooltip(item.DisplayName); + Width = Game1.tileSize; + Height = Game1.tileSize; + } + + public override void Draw(SpriteBatch batch) { + var alpha = Active ? 1.0f : 0.25f; + + Item.drawInMenu(batch, new Vector2(GlobalPosition.X, GlobalPosition.Y), 1, alpha, 1, false); + + if (GlobalBounds.Contains(Game1.getMousePosition())) + TooltipManager.ShowTooltipThisFrame(Tooltip); + } + + public void Toggle() { + Active = !Active; + OnToggle?.Invoke(); + } + + public override bool ReceiveLeftClick(Point point) { + Toggle(); + return true; + } + } +} \ No newline at end of file diff --git a/Mods/ConvenientChests/CategorizeChests/Interface/Widgets/ItemTooltip.cs b/Mods/ConvenientChests/CategorizeChests/Interface/Widgets/ItemTooltip.cs new file mode 100644 index 00000000..16377bcc --- /dev/null +++ b/Mods/ConvenientChests/CategorizeChests/Interface/Widgets/ItemTooltip.cs @@ -0,0 +1,26 @@ +using Microsoft.Xna.Framework; + +namespace ConvenientChests.CategorizeChests.Interface.Widgets +{ + /// + /// A tooltip showing information about a particular item. + /// + class ItemTooltip : Widget + { + public ItemTooltip(string name) + { + var background = AddChild(new Background(Sprites.TooltipBackground)); + var label = AddChild(new Label(name, Color.Black)); + + Width = background.Width = label.Width + background.Graphic.LeftBorderThickness + + background.Graphic.RightBorderThickness; + Height = background.Height = label.Height + background.Graphic.TopBorderThickness + + background.Graphic.BottomBorderThickness; + + label.Position = new Point( + background.Width / 2 - label.Width / 2, + background.Height / 2 - label.Height / 2 + ); + } + } +} \ No newline at end of file diff --git a/Mods/ConvenientChests/CategorizeChests/Interface/Widgets/Label.cs b/Mods/ConvenientChests/CategorizeChests/Interface/Widgets/Label.cs new file mode 100644 index 00000000..dbb92f53 --- /dev/null +++ b/Mods/ConvenientChests/CategorizeChests/Interface/Widgets/Label.cs @@ -0,0 +1,52 @@ +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Graphics; +using StardewValley; + +namespace ConvenientChests.CategorizeChests.Interface.Widgets +{ + /// + /// A simple text element. + /// + class Label : Widget + { + private string _Text; + public string Text + { + get => _Text; + set + { + _Text = value; + RecalculateDimensions(); + } + } + + public readonly SpriteFont Font; + public readonly Color Color; + + public Label(string text, Color color, SpriteFont font) + { + Font = font; + Color = color; + Text = text; + + RecalculateDimensions(); + } + + public Label(string text, Color color) + : this(text, color, Game1.smallFont) + { + } + + public override void Draw(SpriteBatch batch) + { + batch.DrawString(Font, Text, new Vector2(GlobalPosition.X, GlobalPosition.Y), Color); + } + + private void RecalculateDimensions() + { + var measure = Font.MeasureString(Text); + Width = (int) measure.X; + Height = (int) measure.Y; + } + } +} \ No newline at end of file diff --git a/Mods/ConvenientChests/CategorizeChests/Interface/Widgets/LabeledCheckbox.cs b/Mods/ConvenientChests/CategorizeChests/Interface/Widgets/LabeledCheckbox.cs new file mode 100644 index 00000000..404c84bc --- /dev/null +++ b/Mods/ConvenientChests/CategorizeChests/Interface/Widgets/LabeledCheckbox.cs @@ -0,0 +1,49 @@ +using System; +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Graphics; + +namespace ConvenientChests.CategorizeChests.Interface.Widgets +{ + /// + /// A checkbox with a label next to it, like so: [x] Foo + /// + class LabeledCheckbox : Widget + { + public event Action OnChange; + public bool Checked { get; set; } = false; + + private readonly Widget CheckedBox; + private readonly Widget UncheckedBox; + private readonly Label Label; + + public LabeledCheckbox(string labelText) + { + CheckedBox = AddChild(new Stamp(Sprites.FilledCheckbox)); + UncheckedBox = AddChild(new Stamp(Sprites.EmptyCheckbox)); + + Label = AddChild(new Label(labelText, Color.Black)); + var padding = (int) Label.Font.MeasureString(" ").X; + + Height = Math.Max(CheckedBox.Height, Label.Height); + CheckedBox.CenterVertically(); + UncheckedBox.CenterVertically(); + Label.CenterVertically(); + Label.X = CheckedBox.X + CheckedBox.Width + padding; + Width = Label.X + Label.Width; + } + + public override bool ReceiveLeftClick(Point point) + { + Checked = !Checked; + OnChange?.Invoke(Checked); + return true; + } + + public override void Draw(SpriteBatch batch) + { + var box = Checked ? CheckedBox : UncheckedBox; + box.Draw(batch); + Label.Draw(batch); + } + } +} \ No newline at end of file diff --git a/Mods/ConvenientChests/CategorizeChests/Interface/Widgets/SpriteButton.cs b/Mods/ConvenientChests/CategorizeChests/Interface/Widgets/SpriteButton.cs new file mode 100644 index 00000000..7a133bbd --- /dev/null +++ b/Mods/ConvenientChests/CategorizeChests/Interface/Widgets/SpriteButton.cs @@ -0,0 +1,25 @@ +using Microsoft.Xna.Framework.Graphics; + +namespace ConvenientChests.CategorizeChests.Interface.Widgets +{ + /// + /// A button that uses a single TextureRegion to display itself. + /// + class SpriteButton : Button + { + private readonly TextureRegion TextureRegion; + + public SpriteButton(TextureRegion textureRegion) + { + TextureRegion = textureRegion; + Width = TextureRegion.Width; + Height = TextureRegion.Height; + } + + public override void Draw(SpriteBatch batch) + { + batch.Draw(TextureRegion.Texture, TextureRegion.Region, GlobalPosition.X, GlobalPosition.Y, + TextureRegion.Width, TextureRegion.Height); + } + } +} \ No newline at end of file diff --git a/Mods/ConvenientChests/CategorizeChests/Interface/Widgets/Stamp.cs b/Mods/ConvenientChests/CategorizeChests/Interface/Widgets/Stamp.cs new file mode 100644 index 00000000..59e365c3 --- /dev/null +++ b/Mods/ConvenientChests/CategorizeChests/Interface/Widgets/Stamp.cs @@ -0,0 +1,25 @@ +using Microsoft.Xna.Framework.Graphics; + +namespace ConvenientChests.CategorizeChests.Interface.Widgets +{ + /// + /// A simple non-interactive sprite. + /// + class Stamp : Widget + { + private readonly TextureRegion TextureRegion; + + public Stamp(TextureRegion textureRegion) + { + TextureRegion = textureRegion; + Width = TextureRegion.Width; + Height = TextureRegion.Height; + } + + public override void Draw(SpriteBatch batch) + { + batch.Draw(TextureRegion.Texture, TextureRegion.Region, GlobalPosition.X, GlobalPosition.Y, + TextureRegion.Width, TextureRegion.Height); + } + } +} \ No newline at end of file diff --git a/Mods/ConvenientChests/CategorizeChests/Interface/Widgets/TextButton.cs b/Mods/ConvenientChests/CategorizeChests/Interface/Widgets/TextButton.cs new file mode 100644 index 00000000..fd3fb6a2 --- /dev/null +++ b/Mods/ConvenientChests/CategorizeChests/Interface/Widgets/TextButton.cs @@ -0,0 +1,54 @@ +using Microsoft.Xna.Framework; + +namespace ConvenientChests.CategorizeChests.Interface.Widgets +{ + /// + /// A button shown as text on a background. + /// + class TextButton : Button + { + private readonly Background Background; + private readonly Label Label; + + private int LeftPadding => Background.Graphic.LeftBorderThickness; + private int RightPadding => Background.Graphic.RightBorderThickness; + private int TopPadding => Background.Graphic.TopBorderThickness; + private int BottomPadding => Background.Graphic.BottomBorderThickness; + + public TextButton(string text, NineSlice backgroundTexture) + { + Label = new Label(text, Color.Black); + Background = new Background(backgroundTexture); + + Width = Background.Width = Label.Width + LeftPadding + RightPadding; + Height = Background.Height = Label.Height + TopPadding + BottomPadding; + + AddChild(Background); + AddChild(Label); + + CenterLabel(); + } + + protected override void OnDimensionsChanged() + { + base.OnDimensionsChanged(); + + if (Background != null) + { + Background.Width = Width; + Background.Height = Height; + } + + if (Label != null) + CenterLabel(); + } + + private void CenterLabel() + { + Label.Position = new Point( + LeftPadding + (Width - RightPadding - LeftPadding) / 2 - Label.Width / 2, + TopPadding + (Height - BottomPadding - TopPadding) / 2 - Label.Height / 2 + ); + } + } +} \ No newline at end of file diff --git a/Mods/ConvenientChests/CategorizeChests/Interface/Widgets/Widget.cs b/Mods/ConvenientChests/CategorizeChests/Interface/Widgets/Widget.cs new file mode 100644 index 00000000..da83bff3 --- /dev/null +++ b/Mods/ConvenientChests/CategorizeChests/Interface/Widgets/Widget.cs @@ -0,0 +1,243 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Graphics; +using StardewModdingAPI; +using StardewValley; + +namespace ConvenientChests.CategorizeChests.Interface.Widgets +{ + /// + /// A positioned, resizable element in the interface + /// that can also contain other elements. + /// + public class Widget : IDisposable + { + Widget _Parent; + public Widget Parent + { + get => _Parent; + set + { + _Parent = value; + OnParent(value); + } + } + + List _Children = new List(); + public IEnumerable Children => _Children; + + public Point Position { get; set; } + + public int X + { + get => Position.X; + set { Position = new Point(value, Position.Y); } + } + + public int Y + { + get => Position.Y; + set { Position = new Point(Position.X, value); } + } + + int _Width; + public int Width + { + get => _Width; + set + { + _Width = value; + OnDimensionsChanged(); + } + } + + int _Height; + public int Height + { + get { return _Height; } + set + { + _Height = value; + OnDimensionsChanged(); + } + } + + public Widget() + { + Position = Point.Zero; + Width = 1; + Height = 1; + } + + protected virtual void OnParent(Widget parent) + { + } + + public virtual void Draw(SpriteBatch batch) + { + DrawChildren(batch); + } + + protected void DrawChildren(SpriteBatch batch) + { + foreach (var child in Children) + { + child.Draw(batch); + } + } + + public Rectangle LocalBounds => new Rectangle(0, 0, Width, Height); + public Rectangle GlobalBounds => new Rectangle(GlobalPosition.X, GlobalPosition.Y, Width, Height); + public Point GlobalPosition => Globalize(Point.Zero); + + public bool Contains(Point point) + { + return point.X >= Position.X && point.X <= Position.X + Width + && point.Y >= Position.Y && point.Y <= Position.Y + Height; + } + + public Point Globalize(Point point) + { + var global = new Point(point.X + Position.X, point.Y + Position.Y); + return Parent != null ? Parent.Globalize(global) : global; + } + + public virtual bool ReceiveButtonPress(SButton input) + { + return PropagateButtonPress(input); + } + + public virtual bool ReceiveLeftClick(Point point) + { + return PropagateLeftClick(point); + } + + public virtual bool ReceiveCursorHover(Point point) + { + return PropagateCursorHover(point); + } + + public virtual bool ReceiveScrollWheelAction(int amount) + { + return PropagateScrollWheelAction(amount); + } + + protected bool PropagateButtonPress(SButton input) + { + foreach (var child in Children) + { + var handled = child.ReceiveButtonPress(input); + if (handled) + return true; + } + + return false; + } + + protected bool PropagateScrollWheelAction(int amount) + { + foreach (var child in Children) + { + var handled = child.ReceiveScrollWheelAction(amount); + if (handled) + return true; + } + + return false; + } + + protected bool PropagateLeftClick(Point point) + { + foreach (var child in Children) + { + var localPoint = new Point(point.X - child.Position.X, point.Y - child.Position.Y); + + if (child.LocalBounds.Contains(localPoint)) + { + var handled = child.ReceiveLeftClick(localPoint); + if (handled) + return true; + } + } + return false; + } + + protected bool PropagateCursorHover(Point point) + { + foreach (var child in Children) + { + var localPoint = new Point(point.X - child.Position.X, point.Y - child.Position.Y); + + if (child.LocalBounds.Contains(localPoint)) + { + var handled = child.ReceiveCursorHover(localPoint); + if (handled) + return true; + } + } + return false; + } + + public T AddChild(T child) where T : Widget + { + child.Parent = this; + _Children.Add(child); + + OnContentsChanged(); + + return child; + } + + public void RemoveChild(Widget child) + { + _Children.Remove(child); + child.Parent = null; + + OnContentsChanged(); + } + + public void RemoveChildren() + { + RemoveChildren(c => true); + } + + public void RemoveChildren(Predicate shouldRemove) + { + foreach (var child in Children.Where(c => shouldRemove(c))) + { + child.Parent = null; + } + + _Children.RemoveAll(shouldRemove); + + OnContentsChanged(); + } + + protected virtual void OnContentsChanged() + { + } + + protected virtual void OnDimensionsChanged() + { + } + + public void CenterHorizontally() + { + var containerWidth = (Parent != null) ? Parent.Width : Game1.viewport.Width; // TODO + X = containerWidth / 2 - Width / 2; + } + + public void CenterVertically() + { + var containerHeight = (Parent != null) ? Parent.Height : Game1.viewport.Height; // TODO + Y = containerHeight / 2 - Height / 2; + } + + public virtual void Dispose() { + foreach (Widget child in Children) + child.Dispose(); + } + } +} \ No newline at end of file diff --git a/Mods/ConvenientChests/CategorizeChests/Interface/Widgets/WrapBag.cs b/Mods/ConvenientChests/CategorizeChests/Interface/Widgets/WrapBag.cs new file mode 100644 index 00000000..a3ecd7d5 --- /dev/null +++ b/Mods/ConvenientChests/CategorizeChests/Interface/Widgets/WrapBag.cs @@ -0,0 +1,41 @@ +using System; +using Microsoft.Xna.Framework; + +namespace ConvenientChests.CategorizeChests.Interface.Widgets +{ + /// + /// A container that automatically positions its children in rows, + /// wrapping to a new row as appropriate. + /// + class WrapBag : Widget + { + public WrapBag(int width) + { + Width = width; + } + + protected override void OnContentsChanged() + { + base.OnContentsChanged(); + + var x = 0; + var y = 0; + var lowestBottom = 0; + foreach (var child in Children) + { + if (x + child.Width > Width && x > 0) + { + x = 0; + y = lowestBottom; + } + + child.Position = new Point(x, y); + x += child.Width; + + lowestBottom = Math.Max(lowestBottom, child.Position.Y + child.Height); + } + + Height = lowestBottom; + } + } +} \ No newline at end of file diff --git a/Mods/ConvenientChests/CategorizeChests/ItemHelper.cs b/Mods/ConvenientChests/CategorizeChests/ItemHelper.cs new file mode 100644 index 00000000..031e98d5 --- /dev/null +++ b/Mods/ConvenientChests/CategorizeChests/ItemHelper.cs @@ -0,0 +1,103 @@ +using System.Collections.Generic; +using System.Linq; +using ConvenientChests.CategorizeChests.Framework; +using Microsoft.Xna.Framework; +using StardewValley; +using StardewValley.Objects; +using StardewValley.Tools; + +namespace ConvenientChests.CategorizeChests { + internal static class ItemHelper { + public static ItemKey ToItemKey(this Item item) => new ItemKey(GetItemType(item), GetItemID(item)); + + public static Item GetCopy(this Item item) { + if (item == null) + return null; + + var copy = item.getOne(); + copy.Stack = item.Stack; + return copy; + } + + public static IEnumerable GetWeapons() { + foreach (var e in Game1.content.Load>("Data\\weapons")) + if (e.Value.Split('/')[8] == "4") + yield return new Slingshot(e.Key); + + else + yield return new MeleeWeapon(e.Key); + } + + public static ItemType GetItemType(Item item) { + switch (item) { + case Boots _: + return ItemType.Boots; + + case Furniture _: + return ItemType.Furniture; + + case Hat _: + return ItemType.Hat; + + case Ring _: + return ItemType.Ring; + + case Wallpaper w: + return w.isFloor.Value + ? ItemType.Flooring + : ItemType.Wallpaper; + + case MeleeWeapon _: + case Slingshot _: + return ItemType.Weapon; + + case Tool _: + return ItemType.Tool; + + case Fence f: + return f.isGate.Value + ? ItemType.Gate + : ItemType.Object; + + case Object _: + switch (item.Category) { + case Object.FishCategory: + return ItemType.Fish; + + case Object.BigCraftableCategory: + return ItemType.BigCraftable; + + default: + return ItemType.Object; + } + } + + return ItemType.Object; + } + + public static int GetItemID(Item item) { + switch (item) { + case Boots a: + return a.indexInTileSheet.Value; + + case Ring a: + return a.indexInTileSheet.Value; + + case Hat a: + return a.which.Value; + + case Tool a: + return a.InitialParentTileIndex; + + case Fence a: + if (a.isGate.Value) + return 0; + + return a.whichType.Value; + + default: + return item.ParentSheetIndex; + } + } + } +} \ No newline at end of file diff --git a/Mods/ConvenientChests/CategorizeChests/Utility.cs b/Mods/ConvenientChests/CategorizeChests/Utility.cs new file mode 100644 index 00000000..519cfdfb --- /dev/null +++ b/Mods/ConvenientChests/CategorizeChests/Utility.cs @@ -0,0 +1,46 @@ +using System; +using System.Collections.Generic; + +namespace ConvenientChests.CategorizeChests +{ + static class Utility + { + public static int Mod(int x, int m) + { + return (x % m + m) % m; + } + + public static IEnumerable> Batch(this IEnumerable source, int batchSize) + { + using (var enumerator = source.GetEnumerator()) + while (enumerator.MoveNext()) + yield return YieldBatchElements(enumerator, batchSize - 1); + } + + private static IEnumerable YieldBatchElements( + IEnumerator source, int batchSize) + { + yield return source.Current; + for (int i = 0; i < batchSize && source.MoveNext(); i++) + yield return source.Current; + } + + public static IDictionary> KeyBy(this IEnumerable values, + Func makeKey) + { + var dict = new Dictionary>(); + + foreach (var value in values) + { + var key = makeKey(value); + + if (!dict.ContainsKey(key)) + dict[key] = new List(); + + ((List) dict[key]).Add(value); + } + + return dict; + } + } +} \ No newline at end of file diff --git a/Mods/ConvenientChests/Config.cs b/Mods/ConvenientChests/Config.cs new file mode 100644 index 00000000..1b7afaa9 --- /dev/null +++ b/Mods/ConvenientChests/Config.cs @@ -0,0 +1,15 @@ +using StardewModdingAPI; + +namespace ConvenientChests +{ + public class Config + { + public bool CategorizeChests { get; set; } = true; + public bool StashToExistingStacks { get; set; } = true; + + public bool StashToNearbyChests { get; set; } = true; + public int StashRadius { get; set; } = 5; + public SButton StashKey { get; set; } = SButton.Back; + public SButton? StashButton { get; set; } = SButton.RightStick; + } +} diff --git a/Mods/ConvenientChests/ConvenientChests.csproj b/Mods/ConvenientChests/ConvenientChests.csproj new file mode 100644 index 00000000..9b95ab91 --- /dev/null +++ b/Mods/ConvenientChests/ConvenientChests.csproj @@ -0,0 +1,210 @@ + + + + + Debug + AnyCPU + {84A712EC-5F80-43DC-879C-D3604B6F5644} + Library + Properties + ConvenientChests + ConvenientChests + v4.5.2 + 512 + true + + + true + full + false + bin\Debug\ + DEBUG;TRACE + prompt + 4 + + + pdbonly + true + bin\Release\ + TRACE + prompt + 4 + + + + ..\assemblies\Mod.dll + + + ..\assemblies\StardewValley.dll + + + False + ..\assemblies\BmFont.dll + + + False + ..\assemblies\Google.Android.Vending.Expansion.Downloader.dll + + + False + ..\assemblies\Google.Android.Vending.Expansion.ZipFile.dll + + + False + ..\assemblies\Google.Android.Vending.Licensing.dll + + + False + ..\assemblies\Java.Interop.dll + + + False + ..\assemblies\Microsoft.AppCenter.dll + + + False + ..\assemblies\Microsoft.AppCenter.Analytics.dll + + + False + ..\assemblies\Microsoft.AppCenter.Analytics.Android.Bindings.dll + + + False + ..\assemblies\Microsoft.AppCenter.Android.Bindings.dll + + + False + ..\assemblies\Microsoft.AppCenter.Crashes.dll + + + False + ..\assemblies\Microsoft.AppCenter.Crashes.Android.Bindings.dll + + + False + ..\assemblies\Mono.Android.dll + + + False + ..\assemblies\Mono.Security.dll + + + False + ..\assemblies\MonoGame.Framework.dll + + + ..\assemblies\mscorlib.dll + + + ..\assemblies\System.dll + + + ..\assemblies\System.Xml + + + ..\assemblies\System.Net.Http + + + ..\assemblies\System.Runtime.Serialization + + + False + ..\assemblies\Xamarin.Android.Arch.Core.Common.dll + + + False + ..\assemblies\Xamarin.Android.Arch.Lifecycle.Common.dll + + + False + ..\assemblies\Xamarin.Android.Arch.Lifecycle.Runtime.dll + + + False + ..\assemblies\Xamarin.Android.Support.Annotations.dll + + + False + ..\assemblies\Xamarin.Android.Support.Compat.dll + + + False + ..\assemblies\Xamarin.Android.Support.Core.UI.dll + + + False + ..\assemblies\Xamarin.Android.Support.Core.Utils.dll + + + False + ..\assemblies\Xamarin.Android.Support.Fragment.dll + + + False + ..\assemblies\Xamarin.Android.Support.Media.Compat.dll + + + False + ..\assemblies\Xamarin.Android.Support.v4.dll + + + False + ..\assemblies\xTile.dll + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Mods/ConvenientChests/ModEntry.cs b/Mods/ConvenientChests/ModEntry.cs new file mode 100644 index 00000000..40ef1a36 --- /dev/null +++ b/Mods/ConvenientChests/ModEntry.cs @@ -0,0 +1,47 @@ +using ConvenientChests.CategorizeChests; +using ConvenientChests.StackToNearbyChests; +using StardewModdingAPI; + +namespace ConvenientChests { + /// The mod entry class loaded by SMAPI. + public class ModEntry : StardewModdingAPI.Mod + { + public static Config Config { get; private set; } + internal static IModHelper StaticHelper { get; private set; } + internal static IMonitor StaticMonitor { get; private set; } + + internal static void Log(string s, LogLevel l = LogLevel.Trace) => StaticMonitor.Log(s, l); + + public static StashToNearbyChestsModule StashNearby; + public static CategorizeChestsModule CategorizeChests; + + /// The mod entry point, called after the mod is first loaded. + /// Provides simplified APIs for writing mods. + public override void Entry(IModHelper helper) { + Config = helper.ReadConfig(); + StaticMonitor = this.Monitor; + StaticHelper = this.Helper; + + helper.Events.GameLoop.SaveLoaded += (sender, e) => this.LoadModules(); + helper.Events.GameLoop.ReturnedToTitle += (sender, e) => this.UnloadModules(); + } + + private void LoadModules() { + StashNearby = new StashToNearbyChestsModule(this); + if (Config.StashToNearbyChests) + StashNearby.Activate(); + + CategorizeChests = new CategorizeChestsModule(this); + if (Config.CategorizeChests) + CategorizeChests.Activate(); + } + + private void UnloadModules() { + StashNearby.Deactivate(); + StashNearby = null; + + CategorizeChests.Deactivate(); + CategorizeChests = null; + } + } +} diff --git a/Mods/ConvenientChests/Module.cs b/Mods/ConvenientChests/Module.cs new file mode 100644 index 00000000..c328c4e0 --- /dev/null +++ b/Mods/ConvenientChests/Module.cs @@ -0,0 +1,17 @@ +using StardewModdingAPI; +using StardewModdingAPI.Events; + +namespace ConvenientChests { + public abstract class Module { + public bool IsActive { get; protected set; } = false; + public ModEntry ModEntry { get; } + public Config Config => ModEntry.Config; + public IMonitor Monitor => ModEntry.Monitor; + public IModEvents Events => ModEntry.Helper.Events; + + public Module(ModEntry modEntry) => ModEntry = modEntry; + + public abstract void Activate(); + public abstract void Deactivate(); + } +} diff --git a/Mods/ConvenientChests/Properties/AssemblyInfo.cs b/Mods/ConvenientChests/Properties/AssemblyInfo.cs new file mode 100644 index 00000000..8d33854f --- /dev/null +++ b/Mods/ConvenientChests/Properties/AssemblyInfo.cs @@ -0,0 +1,36 @@ +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; + +// 有关程序集的一般信息由以下 +// 控制。更改这些特性值可修改 +// 与程序集关联的信息。 +[assembly: AssemblyTitle("ConvenientChests")] +[assembly: AssemblyDescription("")] +[assembly: AssemblyConfiguration("")] +[assembly: AssemblyCompany("")] +[assembly: AssemblyProduct("ConvenientChests")] +[assembly: AssemblyCopyright("Copyright © 2019")] +[assembly: AssemblyTrademark("")] +[assembly: AssemblyCulture("")] + +// 将 ComVisible 设置为 false 会使此程序集中的类型 +//对 COM 组件不可见。如果需要从 COM 访问此程序集中的类型 +//请将此类型的 ComVisible 特性设置为 true。 +[assembly: ComVisible(false)] + +// 如果此项目向 COM 公开,则下列 GUID 用于类型库的 ID +[assembly: Guid("84a712ec-5f80-43dc-879c-d3604b6f5644")] + +// 程序集的版本信息由下列四个值组成: +// +// 主版本 +// 次版本 +// 生成号 +// 修订号 +// +// 可以指定所有值,也可以使用以下所示的 "*" 预置版本号和修订号 +//通过使用 "*",如下所示: +// [assembly: AssemblyVersion("1.0.*")] +[assembly: AssemblyVersion("1.0.0.0")] +[assembly: AssemblyFileVersion("1.0.0.0")] diff --git a/Mods/ConvenientChests/StackToNearbyChests/StackLogic.cs b/Mods/ConvenientChests/StackToNearbyChests/StackLogic.cs new file mode 100644 index 00000000..7a48472c --- /dev/null +++ b/Mods/ConvenientChests/StackToNearbyChests/StackLogic.cs @@ -0,0 +1,87 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using ConvenientChests.CategorizeChests.Framework; +using Microsoft.Xna.Framework; +using StardewValley; +using StardewValley.Buildings; +using StardewValley.Locations; +using StardewValley.Objects; +using Object = StardewValley.Object; + +namespace ConvenientChests.StackToNearbyChests { + public static class StackLogic { + public delegate bool AcceptingFunction(Chest c, Item i); + + public static IEnumerable GetNearbyChests(this Farmer farmer, int radius) + => GetNearbyChests(farmer.currentLocation, farmer.getTileLocation(), radius); + + public static void StashToChest(Chest chest, AcceptingFunction f) { + ModEntry.Log("Stash to current chest"); + + var inventory = Game1.player.Items.Where(i => i != null).ToList(); + var toBeMoved = inventory.Where(i => f(chest, i)).ToList(); + + if (toBeMoved.Any() && chest.DumpItemsToChest(Game1.player.Items, toBeMoved).Any()) + Game1.playSound(Game1.soundBank.GetCue("pickUpItem").Name); + } + + public static void StashToNearbyChests(int radius, AcceptingFunction f) { + ModEntry.Log("Stash to nearby chests"); + + var movedAtLeastOne = false; + + foreach (var chest in Game1.player.GetNearbyChests(radius)) { + var moveItems = Game1.player.Items + .Where(i => i != null) + .Where(i => f(chest, i)) + .ToList(); + + if (!moveItems.Any()) + continue; + + var movedItems = chest.DumpItemsToChest(Game1.player.Items, moveItems); + if (movedItems.Any()) + movedAtLeastOne = true; + } + + if (!movedAtLeastOne) + return; + + // List of sounds: https://gist.github.com/gasolinewaltz/46b1473415d412e220a21cb84dd9aad6 + Game1.playSound(Game1.soundBank.GetCue("pickUpItem").Name); + } + + private static IEnumerable GetNearbyChests(GameLocation location, Vector2 point, int radius) { + // chests + foreach (Chest c in GetNearbyObjects(location, point, radius)) + yield return c; + + switch (location) { + // fridge + case FarmHouse farmHouse when farmHouse.upgradeLevel > 0: + if (InRadius(radius, point, farmHouse.getKitchenStandingSpot().X + 1, farmHouse.getKitchenStandingSpot().Y - 2)) + yield return farmHouse.fridge.Value; + break; + + // buildings + case BuildableGameLocation l: + foreach (var building in l.buildings.Where(b => InRadius(radius, point, b.tileX.Value, b.tileY.Value))) + if (building is JunimoHut junimoHut) + yield return junimoHut.output.Value; + + else if (building is Mill mill) + yield return mill.output.Value; + break; + } + } + + private static IEnumerable GetNearbyObjects(GameLocation location, Vector2 point, int radius) where T : Object => + location.Objects.Pairs + .Where(p => p.Value is T && InRadius(radius, point, p.Key)) + .Select(p => (T) p.Value); + + private static bool InRadius(int radius, Vector2 a, Vector2 b) => Math.Abs(a.X - b.X) < radius && Math.Abs(a.Y - b.Y) < radius; + private static bool InRadius(int radius, Vector2 a, int x, int y) => Math.Abs(a.X - x) < radius && Math.Abs(a.Y - y) < radius; + } +} \ No newline at end of file diff --git a/Mods/ConvenientChests/StackToNearbyChests/StashToNearbyChestsModule.cs b/Mods/ConvenientChests/StackToNearbyChests/StashToNearbyChestsModule.cs new file mode 100644 index 00000000..65822e12 --- /dev/null +++ b/Mods/ConvenientChests/StackToNearbyChests/StashToNearbyChestsModule.cs @@ -0,0 +1,59 @@ +using ConvenientChests.CategorizeChests.Framework; +using StardewModdingAPI.Events; +using StardewValley; +using StardewValley.Menus; +using StardewValley.Objects; + +namespace ConvenientChests.StackToNearbyChests { + public class StashToNearbyChestsModule : Module { + public StackLogic.AcceptingFunction AcceptingFunction { get; private set; } + + public StashToNearbyChestsModule(ModEntry modEntry) : base(modEntry) { } + + public override void Activate() { + IsActive = true; + + // Acceptor + AcceptingFunction = CreateAcceptingFunction(); + + // Events + this.Events.Input.ButtonPressed += OnButtonPressed; + } + + private StackLogic.AcceptingFunction CreateAcceptingFunction() { + if (Config.CategorizeChests && Config.StashToExistingStacks) + return (chest, item) => ModEntry.CategorizeChests.ChestAcceptsItem(chest, item) || chest.ContainsItem(item); + + if (Config.CategorizeChests) + return (chest, item) => ModEntry.CategorizeChests.ChestAcceptsItem(chest, item); + + if (Config.StashToExistingStacks) + return (chest, item) => chest.ContainsItem(item); + + return (chest, item) => false; + } + + public override void Deactivate() { + IsActive = false; + + // Events + this.Events.Input.ButtonPressed -= OnButtonPressed; + } + + private void TryStashNearby() { + if (Game1.player.currentLocation == null) + return; + + if (Game1.activeClickableMenu is ItemGrabMenu m && m.behaviorOnItemGrab?.Target is Chest c) + StackLogic.StashToChest(c, AcceptingFunction); + + else + StackLogic.StashToNearbyChests(Config.StashRadius, AcceptingFunction); + } + + private void OnButtonPressed(object sender, ButtonPressedEventArgs e) { + if (e.Button == Config.StashKey || e.Button == Config.StashButton) + TryStashNearby(); + } + } +} \ No newline at end of file diff --git a/Mods/Mods.sln b/Mods/Mods.sln new file mode 100644 index 00000000..400ec87e --- /dev/null +++ b/Mods/Mods.sln @@ -0,0 +1,61 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio 15 +VisualStudioVersion = 15.0.28307.572 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AutoSpeed", "AutoSpeed\AutoSpeed.csproj", "{5B089EEE-F22C-4753-B90D-16D4CD3F5D61}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Automate", "Automate\Automate.csproj", "{5EF944E3-D54B-4936-B507-A40C17B17B8E}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AutoFish", "AutoFish\AutoFish.csproj", "{8B08A816-6125-4277-A9EE-CA6AF9E279FC}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TimeSpeed", "TimeSpeed\TimeSpeed.csproj", "{09E76025-DB21-4D9F-B8B1-571D779AC5E6}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SkullCavernElevator", "SkullCavernElevator\SkullCavernElevator.csproj", "{50C6FB69-D475-4D69-9B1F-D4B36E41B5E5}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ScytheHarvesting", "ScytheHarvesting\ScytheHarvesting.csproj", "{2EE8D569-519E-453A-8066-E269DACC73A2}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ConvenientChests", "ConvenientChests\ConvenientChests.csproj", "{84A712EC-5F80-43DC-879C-D3604B6F5644}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {5B089EEE-F22C-4753-B90D-16D4CD3F5D61}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5B089EEE-F22C-4753-B90D-16D4CD3F5D61}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5B089EEE-F22C-4753-B90D-16D4CD3F5D61}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5B089EEE-F22C-4753-B90D-16D4CD3F5D61}.Release|Any CPU.Build.0 = Release|Any CPU + {5EF944E3-D54B-4936-B507-A40C17B17B8E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5EF944E3-D54B-4936-B507-A40C17B17B8E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5EF944E3-D54B-4936-B507-A40C17B17B8E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5EF944E3-D54B-4936-B507-A40C17B17B8E}.Release|Any CPU.Build.0 = Release|Any CPU + {8B08A816-6125-4277-A9EE-CA6AF9E279FC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8B08A816-6125-4277-A9EE-CA6AF9E279FC}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8B08A816-6125-4277-A9EE-CA6AF9E279FC}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8B08A816-6125-4277-A9EE-CA6AF9E279FC}.Release|Any CPU.Build.0 = Release|Any CPU + {09E76025-DB21-4D9F-B8B1-571D779AC5E6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {09E76025-DB21-4D9F-B8B1-571D779AC5E6}.Debug|Any CPU.Build.0 = Debug|Any CPU + {09E76025-DB21-4D9F-B8B1-571D779AC5E6}.Release|Any CPU.ActiveCfg = Release|Any CPU + {09E76025-DB21-4D9F-B8B1-571D779AC5E6}.Release|Any CPU.Build.0 = Release|Any CPU + {50C6FB69-D475-4D69-9B1F-D4B36E41B5E5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {50C6FB69-D475-4D69-9B1F-D4B36E41B5E5}.Debug|Any CPU.Build.0 = Debug|Any CPU + {50C6FB69-D475-4D69-9B1F-D4B36E41B5E5}.Release|Any CPU.ActiveCfg = Release|Any CPU + {50C6FB69-D475-4D69-9B1F-D4B36E41B5E5}.Release|Any CPU.Build.0 = Release|Any CPU + {2EE8D569-519E-453A-8066-E269DACC73A2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2EE8D569-519E-453A-8066-E269DACC73A2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2EE8D569-519E-453A-8066-E269DACC73A2}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2EE8D569-519E-453A-8066-E269DACC73A2}.Release|Any CPU.Build.0 = Release|Any CPU + {84A712EC-5F80-43DC-879C-D3604B6F5644}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {84A712EC-5F80-43DC-879C-D3604B6F5644}.Debug|Any CPU.Build.0 = Debug|Any CPU + {84A712EC-5F80-43DC-879C-D3604B6F5644}.Release|Any CPU.ActiveCfg = Release|Any CPU + {84A712EC-5F80-43DC-879C-D3604B6F5644}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {8AC5C977-D29F-4858-A7AC-D3269E944613} + EndGlobalSection +EndGlobal diff --git a/Mods/ScytheHarvesting/ModConfig.cs b/Mods/ScytheHarvesting/ModConfig.cs new file mode 100644 index 00000000..6ce457ab --- /dev/null +++ b/Mods/ScytheHarvesting/ModConfig.cs @@ -0,0 +1,16 @@ +namespace ScytheHarvesting +{ + using System; + using System.Diagnostics; + using System.Runtime.CompilerServices; + + public class ModConfig + { + public bool EnableMod { get; set; } = true; + + public bool EnableFlowers { get; set; } = false; + + public bool EnableSunflowers { get; set; } = true; + } +} + diff --git a/Mods/ScytheHarvesting/Properties/AssemblyInfo.cs b/Mods/ScytheHarvesting/Properties/AssemblyInfo.cs new file mode 100644 index 00000000..d5928eeb --- /dev/null +++ b/Mods/ScytheHarvesting/Properties/AssemblyInfo.cs @@ -0,0 +1,36 @@ +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; + +// 有关程序集的一般信息由以下 +// 控制。更改这些特性值可修改 +// 与程序集关联的信息。 +[assembly: AssemblyTitle("ScytheHarvesting")] +[assembly: AssemblyDescription("")] +[assembly: AssemblyConfiguration("")] +[assembly: AssemblyCompany("")] +[assembly: AssemblyProduct("ScytheHarvesting")] +[assembly: AssemblyCopyright("Copyright © 2019")] +[assembly: AssemblyTrademark("")] +[assembly: AssemblyCulture("")] + +// 将 ComVisible 设置为 false 会使此程序集中的类型 +//对 COM 组件不可见。如果需要从 COM 访问此程序集中的类型 +//请将此类型的 ComVisible 特性设置为 true。 +[assembly: ComVisible(false)] + +// 如果此项目向 COM 公开,则下列 GUID 用于类型库的 ID +[assembly: Guid("2ee8d569-519e-453a-8066-e269dacc73a2")] + +// 程序集的版本信息由下列四个值组成: +// +// 主版本 +// 次版本 +// 生成号 +// 修订号 +// +// 可以指定所有值,也可以使用以下所示的 "*" 预置版本号和修订号 +//通过使用 "*",如下所示: +// [assembly: AssemblyVersion("1.0.*")] +[assembly: AssemblyVersion("1.0.0.0")] +[assembly: AssemblyFileVersion("1.0.0.0")] diff --git a/Mods/ScytheHarvesting/ScytheHarvesting.cs b/Mods/ScytheHarvesting/ScytheHarvesting.cs new file mode 100644 index 00000000..b158112a --- /dev/null +++ b/Mods/ScytheHarvesting/ScytheHarvesting.cs @@ -0,0 +1,252 @@ +using Microsoft.Xna.Framework; +using SMDroid.Options; +using StardewModdingAPI; +using StardewValley; +using StardewValley.Menus; +using StardewValley.TerrainFeatures; +using StardewValley.Tools; +using System; +using System.Collections.Generic; +using System.Linq; + +namespace ScytheHarvesting +{ + + public class ScytheHarvesting : StardewModdingAPI.Mod + { + public static ModConfig config; + private static int TickCount { get; set; } = 0; + private void CountCurrentHarvestableCrop() + { + IEnumerable> enumerable = Game1.currentLocation.terrainFeatures.Pairs; + if (enumerable != null) + { + IEnumerable enumerable2 = from x in enumerable + select x.Value into x + where x is HoeDirt + select x; + this.CountOfCropsReadyForHarvest = (from x in enumerable2 + select (HoeDirt) x into x + where x.crop != null + where x.readyForHarvest() + select x).Count(); + } + } + + private void CreateSunflowerSeeds(int index, int x, int y, int quantity) + { + Game1.createMultipleObjectDebris(index, x, y, quantity); + } + + public override void Entry(IModHelper helper) + { + config = this.Helper.ReadConfig(); + helper.Events.GameLoop.UpdateTicked += this.Events_TickUpdate; + } + public override List GetConfigMenuItems() + { + List options = new List(); + ModOptionsCheckbox _optionsCheckboxEnabled = new ModOptionsCheckbox("镰刀收割", 0x8765, this.Toogle, -1, -1); + _optionsCheckboxEnabled.isChecked = config.EnableMod; + options.Add(_optionsCheckboxEnabled); + ModOptionsCheckbox _optionsCheckboxEnableFlowers = new ModOptionsCheckbox("收割花朵", 0x8765, delegate (bool value) { + if (config.EnableFlowers != value) + { + config.EnableFlowers = value; + base.Helper.WriteConfig(config); + } + }, -1, -1); + _optionsCheckboxEnableFlowers.isChecked = config.EnableFlowers; + options.Add(_optionsCheckboxEnableFlowers); + return options; + } + + private void Events_TickUpdate(object sender, EventArgs e) + { + TickCount++; + if(TickCount == 10) + { + TickCount = 0; + if ((Context.IsWorldReady && (Game1.currentLocation != null)) && ((Context.IsWorldReady && config.EnableMod) && (Game1.currentLocation.IsFarm || Game1.currentLocation.Name.Equals("Greenhouse")))) + { + this.SetTargetAsXP(); + IEnumerable> enumerable = Game1.currentLocation.terrainFeatures.Pairs; + if (enumerable != null) + { + List list = new List(); + this.FarmHasSunflowers = false; + foreach (KeyValuePair pair in enumerable) + { + if (pair.Value is HoeDirt) + { + list.Add((HoeDirt)pair.Value); + } + } + foreach (HoeDirt dirt in list) + { + if (dirt.crop != null) + { + if (dirt.crop.indexOfHarvest.Value != 0x1a5) + { + if (config.EnableFlowers) + { + dirt.crop.harvestMethod.Value = 1; + } + else if ((((dirt.crop.indexOfHarvest.Value != 0x24f) && (dirt.crop.indexOfHarvest.Value != 0x251)) && ((dirt.crop.indexOfHarvest.Value != 0x253) && (dirt.crop.indexOfHarvest.Value != 0x255))) && (dirt.crop.indexOfHarvest.Value != 0x178)) + { + dirt.crop.harvestMethod.Value = 1; + } + } + else if (config.EnableSunflowers) + { + this.FarmHasSunflowers = true; + dirt.crop.harvestMethod.Value = 1; + } + } + } + } + if (enumerable != null) + { + int num = (from x in from x in enumerable select x.Value + where x is HoeDirt + select (HoeDirt)x into x + where x.crop != null + where x.readyForHarvest() + select x).Count(); + int num2 = Math.Max(0, this.CountOfCropsReadyForHarvest - num); + if ((num2 > 0) && (this.HoveredCrop != 0)) + { + string str = Game1.objectInformation[this.HoveredCrop]; + char[] separator = new char[] { '/' }; + int num3 = Convert.ToInt32(str.Split(separator)[1]); + float num4 = (float)(16.0 * Math.Log((0.018 * num3) + 1.0, 2.71828182845905)); + float num5 = num4 * num2; + if (num5 <= 0f) + { + num5 = 15 * num2; + } + Game1.player.gainExperience(0, (int)Math.Round((double)num5)); + if ((this.HoveredCrop == 0x1a5) && this.FarmHasSunflowers) + { + int num6 = new Random().Next(1, 10); + if ((num6 >= 1) && (num6 <= 3)) + { + this.CreateSunflowerSeeds(0x1af, this.HoveredX, this.HoveredY, 1); + } + else if ((num6 >= 4) && (num6 <= 6)) + { + this.CreateSunflowerSeeds(0x1af, this.HoveredX, this.HoveredY, 2); + } + else if ((num6 >= 7) || (num6 <= 8)) + { + this.CreateSunflowerSeeds(0x1af, this.HoveredX, this.HoveredY, 3); + } + } + } + } + this.CountCurrentHarvestableCrop(); + } + } + } + + private IDictionary GetHarvestMethod() + { + IDictionary dictionary = new Dictionary(); + foreach (KeyValuePair pair in Game1.content.Load>(@"Data\Crops")) + { + char[] separator = new char[] { '/' }; + string[] strArray = pair.Value.Split(separator); + int index = 3; + int key = Convert.ToInt32(strArray[index]); + int num3 = 5; + int num4 = Convert.ToInt32(strArray[num3]); + if (!dictionary.ContainsKey(key)) + { + dictionary.Add(key, num4); + } + } + return dictionary; + } + + private void SetTargetAsXP() + { + Item currentItem = Game1.player.CurrentItem; + if ((currentItem is MeleeWeapon) && currentItem.Name.Equals("Scythe")) + { + IEnumerable> source = Game1.currentLocation.terrainFeatures.Pairs; + Vector2 toolLocation = Game1.player.GetToolLocation(false); + if ((Game1.currentLocation.IsFarm || Game1.currentLocation.Name.Equals("Greenhouse")) && (source != null)) + { + int tx = ((int) toolLocation.X) / 0x40; + int ty = ((int) toolLocation.Y) / 0x40; + TerrainFeature feature = source.FirstOrDefault>(x => ((x.Key.X == tx) && (x.Key.Y == ty))).Value; + if (feature is HoeDirt) + { + HoeDirt dirt = (HoeDirt) feature; + if (dirt.crop != null) + { + if ((dirt.crop.currentPhase.Value >= (dirt.crop.phaseDays.Count() - 1)) && (!dirt.crop.fullyGrown.Value || (dirt.crop.dayOfCurrentPhase.Value <= 0))) + { + this.HoveredCrop = dirt.crop.indexOfHarvest.Value; + this.HoveredX = tx; + this.HoveredY = ty; + } + } + else + { + this.HoveredCrop = 0; + this.HoveredX = 0; + this.HoveredY = 0; + } + } + } + } + } + + private void Toogle(bool enabled) + { + if (config.EnableMod == enabled) + { + return; + } + if (enabled) + { + config.EnableMod = true; + base.Helper.WriteConfig(config); + } + else + { + config.EnableMod = false; + base.Helper.WriteConfig(config); + IDictionary harvestMethod = this.GetHarvestMethod(); + foreach (GameLocation location in Game1.locations) + { + if (location.IsFarm || location.Name.Equals("Greenhouse")) + { + foreach (KeyValuePair pair in location.terrainFeatures.Pairs) + { + HoeDirt dirt; + int num = 0; + if ((((dirt = pair.Value as HoeDirt) != null) && (dirt.crop != null)) && harvestMethod.TryGetValue(dirt.crop.indexOfHarvest.Value, out num)) + { + dirt.crop.harvestMethod.Value = num; + } + } + } + } + } + } + + private int CountOfCropsReadyForHarvest { get; set; } + + private int HoveredCrop { get; set; } + + private int HoveredX { get; set; } + + private int HoveredY { get; set; } + + private bool FarmHasSunflowers { get; set; } + + } +} + diff --git a/Mods/ScytheHarvesting/ScytheHarvesting.csproj b/Mods/ScytheHarvesting/ScytheHarvesting.csproj new file mode 100644 index 00000000..1bde0f62 --- /dev/null +++ b/Mods/ScytheHarvesting/ScytheHarvesting.csproj @@ -0,0 +1,162 @@ + + + + + Debug + AnyCPU + {2EE8D569-519E-453A-8066-E269DACC73A2} + Library + Properties + ScytheHarvesting + ScytheHarvesting + v4.5.2 + 512 + true + + + true + full + false + bin\Debug\ + DEBUG;TRACE + prompt + 4 + + + pdbonly + true + bin\Release\ + TRACE + prompt + 4 + + + + ..\assemblies\Mod.dll + + + ..\assemblies\StardewValley.dll + + + False + ..\assemblies\BmFont.dll + + + False + ..\assemblies\Google.Android.Vending.Expansion.Downloader.dll + + + False + ..\assemblies\Google.Android.Vending.Expansion.ZipFile.dll + + + False + ..\assemblies\Google.Android.Vending.Licensing.dll + + + False + ..\assemblies\Java.Interop.dll + + + False + ..\assemblies\Microsoft.AppCenter.dll + + + False + ..\assemblies\Microsoft.AppCenter.Analytics.dll + + + False + ..\assemblies\Microsoft.AppCenter.Analytics.Android.Bindings.dll + + + False + ..\assemblies\Microsoft.AppCenter.Android.Bindings.dll + + + False + ..\assemblies\Microsoft.AppCenter.Crashes.dll + + + False + ..\assemblies\Microsoft.AppCenter.Crashes.Android.Bindings.dll + + + False + ..\assemblies\Mono.Android.dll + + + False + ..\assemblies\Mono.Security.dll + + + False + ..\assemblies\MonoGame.Framework.dll + + + ..\assemblies\mscorlib.dll + + + ..\assemblies\System.dll + + + ..\assemblies\System.Xml + + + ..\assemblies\System.Net.Http + + + ..\assemblies\System.Runtime.Serialization + + + False + ..\assemblies\Xamarin.Android.Arch.Core.Common.dll + + + False + ..\assemblies\Xamarin.Android.Arch.Lifecycle.Common.dll + + + False + ..\assemblies\Xamarin.Android.Arch.Lifecycle.Runtime.dll + + + False + ..\assemblies\Xamarin.Android.Support.Annotations.dll + + + False + ..\assemblies\Xamarin.Android.Support.Compat.dll + + + False + ..\assemblies\Xamarin.Android.Support.Core.UI.dll + + + False + ..\assemblies\Xamarin.Android.Support.Core.Utils.dll + + + False + ..\assemblies\Xamarin.Android.Support.Fragment.dll + + + False + ..\assemblies\Xamarin.Android.Support.Media.Compat.dll + + + False + ..\assemblies\Xamarin.Android.Support.v4.dll + + + False + ..\assemblies\xTile.dll + + + + + + + + + \ No newline at end of file diff --git a/Mods/SkullCavernElevator/ModConfig.cs b/Mods/SkullCavernElevator/ModConfig.cs new file mode 100644 index 00000000..7b56a1e9 --- /dev/null +++ b/Mods/SkullCavernElevator/ModConfig.cs @@ -0,0 +1,13 @@ +namespace SkullCavernElevator +{ + using System; + using System.Runtime.CompilerServices; + + internal class ModConfig + { + public int elevatorStep { get; set; } = 5; + + public double difficulty { get; set; } = 1.0; + } +} + diff --git a/Mods/SkullCavernElevator/ModEntry.cs b/Mods/SkullCavernElevator/ModEntry.cs new file mode 100644 index 00000000..5ab6d2ed --- /dev/null +++ b/Mods/SkullCavernElevator/ModEntry.cs @@ -0,0 +1,120 @@ +using Microsoft.Xna.Framework; +using SkullCavernElevator.SkullCavernElevator; +using StardewModdingAPI; +using StardewModdingAPI.Events; +using StardewValley; +using StardewValley.Locations; +using StardewValley.Menus; +using xTile; +using xTile.Tiles; + +namespace SkullCavernElevator +{ + class ModEntry : StardewModdingAPI.Mod + { + // Fields + private IModHelper helper; + private ModConfig config; + + public override void Entry(IModHelper helper) + { + this.helper = helper; + Helper.Events.Player.Warped += MineEvents_MineLevelChanged; + Helper.Events.Display.MenuChanged += MenuChanged; + Helper.Events.GameLoop.SaveLoaded += SetUpSkullCave; + this.config = helper.ReadConfig(); + } + private Vector2 findLadder(MineShaft ms) + { + Map map = ms.map; + for (int i = 0; i < map.GetLayer("Buildings").LayerHeight; i++) + { + for (int j = 0; j < map.GetLayer("Buildings").LayerWidth; j++) + { + if ((map.GetLayer("Buildings").Tiles[j, i] != null) && (map.GetLayer("Buildings").Tiles[j, i].TileIndex == 0x73)) + { + return new Vector2((float)j, (float)(i + 1)); + } + } + } + return this.helper.Reflection.GetField(ms, "tileBeneathLadder", true).GetValue(); + } + private void MenuChanged(object sender, MenuChangedEventArgs e) + { + if (!(e.NewMenu is MineElevatorMenu) || Game1.currentLocation.Name == "Mine" || e.NewMenu is MyElevatorMenu || e.NewMenu is MyElevatorMenuWithScrollbar) + { + return; + } + if (Game1.currentLocation is MineShaft) + { + MineShaft mineShaft = Game1.currentLocation as MineShaft; + if (mineShaft != null && mineShaft.mineLevel <= 120) + { + return; + } + } + if (Game1.player.deepestMineLevel > 120 + 121 * config.elevatorStep) + { + Game1.activeClickableMenu = (new MyElevatorMenuWithScrollbar(config.elevatorStep, config.difficulty)); + } + else + { + Game1.activeClickableMenu = (new MyElevatorMenu(config.elevatorStep, config.difficulty)); + } + } + private void MineEvents_MineLevelChanged(object sender, WarpedEventArgs e) + { + MineShaft shaft; + if (((shaft = e.NewLocation as MineShaft) != null) && e.IsLocalPlayer) + { + base.Monitor.Log("Current lowest minelevel of player " + Game1.player.deepestMineLevel, LogLevel.Debug); + base.Monitor.Log("Value of MineShaft.lowestMineLevel " + MineShaft.lowestLevelReached, LogLevel.Debug); + base.Monitor.Log("Value of current mineShaft level " + shaft.mineLevel, LogLevel.Debug); + if ((Game1.hasLoadedGame && (Game1.mine != null)) && (((((Game1.CurrentMineLevel - 120) % this.config.elevatorStep) == 0) && (Game1.CurrentMineLevel > 120)) && (Game1.currentLocation is MineShaft))) + { + MineShaft currentLocation = Game1.currentLocation as MineShaft; + TileSheet tileSheet = Game1.getLocationFromName("Mine").map.GetTileSheet("untitled tile sheet"); + currentLocation.map.AddTileSheet(new TileSheet("z_path_objects_custom_sheet", currentLocation.map, tileSheet.ImageSource, tileSheet.SheetSize, tileSheet.TileSize)); + currentLocation.map.DisposeTileSheets(Game1.mapDisplayDevice); + currentLocation.map.LoadTileSheets(Game1.mapDisplayDevice); + Vector2 vector1 = this.findLadder(currentLocation); + int tileX = ((int)vector1.X) + 1; + int tileY = ((int)vector1.Y) - 3; + typeof(MineShaft).GetMethods(); + currentLocation.setMapTileIndex(tileX, tileY + 2, 0x70, "Buildings", 1); + currentLocation.setMapTileIndex(tileX, tileY + 1, 0x60, "Front", 1); + currentLocation.setMapTileIndex(tileX, tileY, 80, "Front", 1); + currentLocation.setMapTile(tileX, tileY, 80, "Front", "MineElevator", 1); + currentLocation.setMapTile(tileX, tileY + 1, 0x60, "Front", "MineElevator", 1); + currentLocation.setMapTile(tileX, tileY + 2, 0x70, "Buildings", "MineElevator", 1); + this.helper.Reflection.GetMethod(currentLocation, "prepareElevator", true).Invoke(new object[0]); + Point point = Utility.findTile(currentLocation, 80, "Buildings"); + object[] objArray1 = new object[] { "x ", point.X, " y ", point.Y }; + base.Monitor.Log(string.Concat(objArray1), LogLevel.Debug); + } + } + } + private void SetUpSkullCave(object sender, SaveLoadedEventArgs e) + { + if (Game1.hasLoadedGame && (Game1.CurrentEvent == null)) + { + GameLocation location = Game1.getLocationFromName("SkullCave"); + TileSheet tileSheet = Game1.getLocationFromName("Mine").map.GetTileSheet("untitled tile sheet"); + location.map.AddTileSheet(new TileSheet("z_path_objects_custom_sheet", location.map, tileSheet.ImageSource, tileSheet.SheetSize, tileSheet.TileSize)); + location.map.DisposeTileSheets(Game1.mapDisplayDevice); + location.map.LoadTileSheets(Game1.mapDisplayDevice); + location.setMapTileIndex(4, 3, 0x70, "Buildings", 2); + location.setMapTileIndex(4, 2, 0x60, "Front", 2); + location.setMapTileIndex(4, 1, 80, "Front", 2); + location.setMapTile(4, 3, 0x70, "Buildings", "MineElevator", 2); + location.setMapTile(4, 2, 0x60, "Front", "MineElevator", 2); + location.setMapTile(4, 1, 80, "Front", "MineElevator", 2); + } + } + } + + + + +} + diff --git a/Mods/SkullCavernElevator/MyElevatorMenuWithScrollbar.cs b/Mods/SkullCavernElevator/MyElevatorMenuWithScrollbar.cs new file mode 100644 index 00000000..c8c5f455 --- /dev/null +++ b/Mods/SkullCavernElevator/MyElevatorMenuWithScrollbar.cs @@ -0,0 +1,250 @@ +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Graphics; +using StardewValley; +using StardewValley.Locations; +using StardewValley.Menus; +using System; + +namespace SkullCavernElevator +{ + public class MyElevatorMenuWithScrollbar : MineElevatorMenu + { + // Fields + private const int SCROLLSTEP = 11; + private const int ELEVATORSIZE = 0x79; + public ClickableTextureComponent upArrow; + public ClickableTextureComponent downArrow; + public ClickableTextureComponent scrollBar; + public Rectangle scrollBarRunner; + private int currentItemIndex; + private int elevatorStep; + private int maxElevators; + private bool scrolling; + + // Methods + public MyElevatorMenuWithScrollbar(int elevatorStep, double difficulty) + { + this.elevatorStep = 5; + base.initialize(0, 0, 0, 0, true); + this.elevatorStep = elevatorStep; + this.maxElevators = (int)(((double)((Game1.player.deepestMineLevel - 120) / elevatorStep)) / difficulty); + if (((Game1.gameMode == 3) && (Game1.player != null)) && !Game1.eventUp) + { + Game1.player.Halt(); + base.elevators.Clear(); + int num = 120; + base.width = (num > 50) ? (0x1e4 + (IClickableMenu.borderWidth * 2)) : Math.Min((int)(220 + (IClickableMenu.borderWidth * 2)), (int)((num * 0x2c) + (IClickableMenu.borderWidth * 2))); + base.height = Math.Max((int)(0x40 + (IClickableMenu.borderWidth * 3)), (int)(((((num * 0x2c) / (base.width - IClickableMenu.borderWidth)) * 0x2c) + 0x40) + (IClickableMenu.borderWidth * 3))); + base.xPositionOnScreen = (Game1.viewport.Width / 2) - (base.width / 2); + base.yPositionOnScreen = (Game1.viewport.Height / 2) - (base.height / 2); + Game1.playSound("crystal"); + this.upArrow = new ClickableTextureComponent(new Rectangle((base.xPositionOnScreen + base.width) + 0x10, base.yPositionOnScreen + 0x40, 0x2c, 0x30), Game1.mouseCursors, new Rectangle(0x1a5, 0x1cb, 11, 12), 4f, false); + this.downArrow = new ClickableTextureComponent(new Rectangle((base.xPositionOnScreen + base.width) + 0x10, (base.yPositionOnScreen + base.height) - 0x40, 0x2c, 0x30), Game1.mouseCursors, new Rectangle(0x1a5, 0x1d8, 11, 12), 4f, false); + this.scrollBar = new ClickableTextureComponent(new Rectangle(this.upArrow.bounds.X + 12, (this.upArrow.bounds.Y + this.upArrow.bounds.Height) + 4, 0x18, 40), Game1.mouseCursors, new Rectangle(0x1b3, 0x1cf, 6, 10), 4f, false); + this.scrollBarRunner = new Rectangle(this.scrollBar.bounds.X, (this.upArrow.bounds.Y + this.upArrow.bounds.Height) + 4, this.scrollBar.bounds.Width, ((base.height - 0x80) - this.upArrow.bounds.Height) - 8); + int x = (base.xPositionOnScreen + IClickableMenu.borderWidth) + ((IClickableMenu.spaceToClearSideBorder * 3) / 4); + int y = (base.yPositionOnScreen + IClickableMenu.borderWidth) + (IClickableMenu.borderWidth / 3); + base.elevators.Add(new ClickableComponent(new Rectangle(x, y, 0x2c, 0x2c), "0")); + int num4 = (x + 0x40) - 20; + if (num4 > ((base.xPositionOnScreen + base.width) - IClickableMenu.borderWidth)) + { + num4 = (base.xPositionOnScreen + IClickableMenu.borderWidth) + ((IClickableMenu.spaceToClearSideBorder * 3) / 4); + y += 0x2c; + } + for (int i = 1; i <= num; i++) + { + base.elevators.Add(new ClickableComponent(new Rectangle(num4, y, 0x2c, 0x2c), (i * elevatorStep).ToString())); + num4 += 0x2c; + if (num4 > ((base.xPositionOnScreen + base.width) - IClickableMenu.borderWidth)) + { + num4 = (base.xPositionOnScreen + IClickableMenu.borderWidth) + ((IClickableMenu.spaceToClearSideBorder * 3) / 4); + y += 0x2c; + } + } + base.initializeUpperRightCloseButton(); + } + } + + private void downArrowPressed() + { + this.downArrow.scale = this.downArrow.baseScale; + this.currentItemIndex += 11; + if (this.currentItemIndex > (this.maxElevators - 0x79)) + { + this.currentItemIndex = (this.maxElevators - 0x79) + 1; + } + this.setScrollBarToCurrentIndex(); + } + + public override void draw(SpriteBatch b) + { + b.Draw(Game1.fadeToBlackRect, Game1.graphics.GraphicsDevice.Viewport.Bounds, Color.Black * 0.4f); + Game1.drawDialogueBox(base.xPositionOnScreen, (base.yPositionOnScreen - 0x40) + 8, base.width + 0x15, base.height + 0x40, false, true, null, false, false); + base.upperRightCloseButton.draw(b); + this.upArrow.draw(b); + this.downArrow.draw(b); + IClickableMenu.drawTextureBox(b, Game1.mouseCursors, new Rectangle(0x193, 0x17f, 6, 6), this.scrollBarRunner.X, this.scrollBarRunner.Y, this.scrollBarRunner.Width, this.scrollBarRunner.Height, Color.White, 4f, false); + this.scrollBar.draw(b); + for (int i = 0; i < 0x79; i++) + { + ClickableComponent elevator = base.elevators[i]; + elevator.name = ((i + this.currentItemIndex) * this.elevatorStep).ToString(); + drawElevator(b, elevator); + } + base.drawMouse(b); + } + private static void drawElevator(SpriteBatch b, ClickableComponent elevator) + { + b.Draw(Game1.mouseCursors, new Vector2((float)(elevator.bounds.X - 4), (float)(elevator.bounds.Y + 4)), new Rectangle((elevator.scale > 1.0) ? 0x10b : 0x100, 0x100, 10, 10), Color.Black * 0.5f, 0f, Vector2.Zero, (float)4f, SpriteEffects.None, 0.865f); + b.Draw(Game1.mouseCursors, new Vector2((float)elevator.bounds.X, (float)elevator.bounds.Y), new Rectangle((elevator.scale > 1.0) ? 0x10b : 0x100, 0x100, 10, 10), Color.White, 0f, Vector2.Zero, (float)4f, SpriteEffects.None, 0.868f); + Vector2 position = new Vector2((float)((elevator.bounds.X + 0x10) + (NumberSprite.numberOfDigits(Convert.ToInt32(elevator.name)) * 6)), (float)((elevator.bounds.Y + 0x18) - (NumberSprite.getHeight() / 4))); + NumberSprite.draw(Convert.ToInt32(elevator.name), b, position, (((Game1.CurrentMineLevel == (Convert.ToInt32(elevator.name) + 120)) && Game1.currentLocation == (Game1.mine)) || ((Convert.ToInt32(elevator.name) == 0) && Game1.currentLocation != (Game1.mine))) ? (Color.Gray * 0.75f) : Color.Gold, 0.5f, 0.86f, 1f, 0, 0); + } + public override void leftClickHeld(int x, int y) + { + if (!GameMenu.forcePreventClose) + { + base.leftClickHeld(x, y); + if (this.scrolling) + { + int y2 = scrollBar.bounds.Y; + scrollBar.bounds.Y = Math.Min(base.yPositionOnScreen + base.height - 64 - 12 - scrollBar.bounds.Height, Math.Max(y, base.yPositionOnScreen + upArrow.bounds.Height + 20)); + currentItemIndex = Math.Min(maxElevators - 121 + 1, Math.Max(0, (int)((double)(maxElevators - 121) * (double)((float)(y - scrollBarRunner.Y) / (float)scrollBarRunner.Height)))); + setScrollBarToCurrentIndex(); + int y3 = scrollBar.bounds.Y; + if (y2 != y3) + { + Game1.playSound("shiny4"); + } + } + } + } + public override void performHoverAction(int x, int y) + { + if (!GameMenu.forcePreventClose) + { + this.upArrow.tryHover(x, y, 0.1f); + this.downArrow.tryHover(x, y, 0.1f); + this.scrollBar.tryHover(x, y, 0.1f); + foreach (ClickableComponent local1 in base.elevators) + { + local1.scale = !local1.containsPoint(x, y) ? 1f : 2f; + } + } + } + public override void receiveLeftClick(int x, int y, bool playSound = true) + { + if (downArrow.containsPoint(x, y)) + { + if (currentItemIndex < Math.Max(0, maxElevators - 121)) + { + downArrowPressed(); + Game1.playSound("shwip"); + } + } + else if (upArrow.containsPoint(x, y)) + { + if (currentItemIndex > 0) + { + upArrowPressed(); + Game1.playSound("shwip"); + } + } + else if (scrollBar.containsPoint(x, y)) + { + scrolling = true; + } + else if (!downArrow.containsPoint(x, y) && x > base.xPositionOnScreen + base.width && x < base.xPositionOnScreen + base.width + 128 && y > base.yPositionOnScreen && y < base.yPositionOnScreen + base.height) + { + scrolling = true; + this.leftClickHeld(x, y); + this.releaseLeftClick(x, y); + } + else if (this.isWithinBounds(x, y)) + { + bool flag = false; + foreach (ClickableComponent elevator in base.elevators) + { + if (elevator.containsPoint(x, y)) + { + MineShaft mineShaft = (Game1.currentLocation as MineShaft); + if (((mineShaft != null) ? new int?(mineShaft.mineLevel) : null) == Convert.ToInt32(elevator.name) + 120) + { + return; + } + Game1.playSound("smallSelect"); + if (Convert.ToInt32(elevator.name) == 0) + { + if ((Game1.currentLocation)!=(Game1.mine)) + { + return; + } + Game1.warpFarmer("SkullCave", 3, 4, 2); + Game1.exitActiveMenu(); + Game1.changeMusicTrack("none"); + flag = true; + } + else + { + if ((Game1.currentLocation)==(Game1.mine) && Convert.ToInt32(elevator.name) == Game1.mine.mineLevel) + { + return; + } + Game1.player.ridingMineElevator = true; + Game1.enterMine(Convert.ToInt32(elevator.name) + 120); + Game1.exitActiveMenu(); + flag = true; + } + } + } + if (!flag) + { + this.receiveLeftClick(x, y, true); + } + } + else + { + Game1.exitActiveMenu(); + } + } + public override void receiveScrollWheelAction(int direction) + { + if (!GameMenu.forcePreventClose) + { + base.receiveScrollWheelAction(direction); + if ((direction > 0) && (this.currentItemIndex > 0)) + { + this.upArrowPressed(); + Game1.playSound("shiny4"); + } + else if ((direction < 0) && (this.currentItemIndex < Math.Max(0, this.maxElevators - 0x79))) + { + this.downArrowPressed(); + Game1.playSound("shiny4"); + } + } + } + private void setScrollBarToCurrentIndex() + { + if (base.elevators.Count > 0) + { + this.scrollBar.bounds.Y = (((int)((((double)this.scrollBarRunner.Height) / ((double)Math.Max(1, (this.maxElevators - 0x79) + 1))) * this.currentItemIndex)) + this.upArrow.bounds.Bottom) + 4; + if (this.currentItemIndex == ((this.maxElevators - 0x79) + 1)) + { + this.scrollBar.bounds.Y = (this.downArrow.bounds.Y - this.scrollBar.bounds.Height) - 4; + } + } + } + private void upArrowPressed() + { + this.upArrow.scale = this.upArrow.baseScale; + this.currentItemIndex -= 11; + if (this.currentItemIndex < 0) + { + this.currentItemIndex = 0; + } + this.setScrollBarToCurrentIndex(); + } + } +} + diff --git a/Mods/SkullCavernElevator/Properties/AssemblyInfo.cs b/Mods/SkullCavernElevator/Properties/AssemblyInfo.cs new file mode 100644 index 00000000..9a904a84 --- /dev/null +++ b/Mods/SkullCavernElevator/Properties/AssemblyInfo.cs @@ -0,0 +1,36 @@ +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; + +// 有关程序集的一般信息由以下 +// 控制。更改这些特性值可修改 +// 与程序集关联的信息。 +[assembly: AssemblyTitle("SkullCavernElevator")] +[assembly: AssemblyDescription("")] +[assembly: AssemblyConfiguration("")] +[assembly: AssemblyCompany("")] +[assembly: AssemblyProduct("SkullCavernElevator")] +[assembly: AssemblyCopyright("Copyright © 2019")] +[assembly: AssemblyTrademark("")] +[assembly: AssemblyCulture("")] + +// 将 ComVisible 设置为 false 会使此程序集中的类型 +//对 COM 组件不可见。如果需要从 COM 访问此程序集中的类型 +//请将此类型的 ComVisible 特性设置为 true。 +[assembly: ComVisible(false)] + +// 如果此项目向 COM 公开,则下列 GUID 用于类型库的 ID +[assembly: Guid("50c6fb69-d475-4d69-9b1f-d4b36e41b5e5")] + +// 程序集的版本信息由下列四个值组成: +// +// 主版本 +// 次版本 +// 生成号 +// 修订号 +// +// 可以指定所有值,也可以使用以下所示的 "*" 预置版本号和修订号 +//通过使用 "*",如下所示: +// [assembly: AssemblyVersion("1.0.*")] +[assembly: AssemblyVersion("1.0.0.0")] +[assembly: AssemblyFileVersion("1.0.0.0")] diff --git a/Mods/SkullCavernElevator/SkullCavernElevator.csproj b/Mods/SkullCavernElevator/SkullCavernElevator.csproj new file mode 100644 index 00000000..9d97f2a4 --- /dev/null +++ b/Mods/SkullCavernElevator/SkullCavernElevator.csproj @@ -0,0 +1,164 @@ + + + + + Debug + AnyCPU + {50C6FB69-D475-4D69-9B1F-D4B36E41B5E5} + Library + Properties + SkullCavernElevator + SkullCavernElevator + v4.5.2 + 512 + true + + + true + full + false + bin\Debug\ + DEBUG;TRACE + prompt + 4 + + + pdbonly + true + bin\Release\ + TRACE + prompt + 4 + + + + ..\assemblies\Mod.dll + + + ..\assemblies\StardewValley.dll + + + False + ..\assemblies\BmFont.dll + + + False + ..\assemblies\Google.Android.Vending.Expansion.Downloader.dll + + + False + ..\assemblies\Google.Android.Vending.Expansion.ZipFile.dll + + + False + ..\assemblies\Google.Android.Vending.Licensing.dll + + + False + ..\assemblies\Java.Interop.dll + + + False + ..\assemblies\Microsoft.AppCenter.dll + + + False + ..\assemblies\Microsoft.AppCenter.Analytics.dll + + + False + ..\assemblies\Microsoft.AppCenter.Analytics.Android.Bindings.dll + + + False + ..\assemblies\Microsoft.AppCenter.Android.Bindings.dll + + + False + ..\assemblies\Microsoft.AppCenter.Crashes.dll + + + False + ..\assemblies\Microsoft.AppCenter.Crashes.Android.Bindings.dll + + + False + ..\assemblies\Mono.Android.dll + + + False + ..\assemblies\Mono.Security.dll + + + False + ..\assemblies\MonoGame.Framework.dll + + + ..\assemblies\mscorlib.dll + + + ..\assemblies\System.dll + + + ..\assemblies\System.Xml + + + ..\assemblies\System.Net.Http + + + ..\assemblies\System.Runtime.Serialization + + + False + ..\assemblies\Xamarin.Android.Arch.Core.Common.dll + + + False + ..\assemblies\Xamarin.Android.Arch.Lifecycle.Common.dll + + + False + ..\assemblies\Xamarin.Android.Arch.Lifecycle.Runtime.dll + + + False + ..\assemblies\Xamarin.Android.Support.Annotations.dll + + + False + ..\assemblies\Xamarin.Android.Support.Compat.dll + + + False + ..\assemblies\Xamarin.Android.Support.Core.UI.dll + + + False + ..\assemblies\Xamarin.Android.Support.Core.Utils.dll + + + False + ..\assemblies\Xamarin.Android.Support.Fragment.dll + + + False + ..\assemblies\Xamarin.Android.Support.Media.Compat.dll + + + False + ..\assemblies\Xamarin.Android.Support.v4.dll + + + False + ..\assemblies\xTile.dll + + + + + + + + + + + \ No newline at end of file diff --git a/Mods/SkullCavernElevator/SkullCavernElevator/MyElevatorMenu.cs b/Mods/SkullCavernElevator/SkullCavernElevator/MyElevatorMenu.cs new file mode 100644 index 00000000..5d3019fa --- /dev/null +++ b/Mods/SkullCavernElevator/SkullCavernElevator/MyElevatorMenu.cs @@ -0,0 +1,109 @@ +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Graphics; +using StardewValley; +using StardewValley.Locations; +using StardewValley.Menus; +using System; + +namespace SkullCavernElevator.SkullCavernElevator +{ + public class MyElevatorMenu : MineElevatorMenu + { + // Methods + public MyElevatorMenu(int elevatorStep, double difficulty) + { + base.initialize(0, 0, 0, 0, true); + if (((Game1.gameMode == 3) && (Game1.player != null)) && !Game1.eventUp) + { + Game1.player.Halt(); + base.elevators.Clear(); + int num = (int)(((double)((Game1.player.deepestMineLevel - 120) / elevatorStep)) / difficulty); + base.width = (num > 50) ? (0x1e4 + (IClickableMenu.borderWidth * 2)) : Math.Min((int)(220 + (IClickableMenu.borderWidth * 2)), (int)((num * 0x2c) + (IClickableMenu.borderWidth * 2))); + base.height = Math.Max((int)(0x40 + (IClickableMenu.borderWidth * 3)), (int)(((((num * 0x2c) / (base.width - IClickableMenu.borderWidth)) * 0x2c) + 0x40) + (IClickableMenu.borderWidth * 3))); + base.xPositionOnScreen = (Game1.viewport.Width / 2) - (base.width / 2); + base.yPositionOnScreen = (Game1.viewport.Height / 2) - (base.height / 2); + Game1.playSound("crystal"); + int x = (base.xPositionOnScreen + IClickableMenu.borderWidth) + ((IClickableMenu.spaceToClearSideBorder * 3) / 4); + int y = (base.yPositionOnScreen + IClickableMenu.borderWidth) + (IClickableMenu.borderWidth / 3); + base.elevators.Add(new ClickableComponent(new Rectangle(x, y, 0x2c, 0x2c), "0")); + int num4 = (x + 0x40) - 20; + if (num4 > ((base.xPositionOnScreen + base.width) - IClickableMenu.borderWidth)) + { + num4 = (base.xPositionOnScreen + IClickableMenu.borderWidth) + ((IClickableMenu.spaceToClearSideBorder * 3) / 4); + y += 0x2c; + } + for (int i = 1; i <= num; i++) + { + base.elevators.Add(new ClickableComponent(new Rectangle(num4, y, 0x2c, 0x2c), (i * elevatorStep).ToString())); + num4 = (num4 + 0x40) - 20; + if (num4 > ((base.xPositionOnScreen + base.width) - IClickableMenu.borderWidth)) + { + num4 = (base.xPositionOnScreen + IClickableMenu.borderWidth) + ((IClickableMenu.spaceToClearSideBorder * 3) / 4); + y += 0x2c; + } + } + base.initializeUpperRightCloseButton(); + } + } + public override void draw(SpriteBatch b) + { + base.draw(b); + foreach (ClickableComponent component in base.elevators) + { + Vector2 position = new Vector2((float)((component.bounds.X + 0x10) + (NumberSprite.numberOfDigits(Convert.ToInt32(component.name)) * 6)), (float)((component.bounds.Y + 0x18) - (NumberSprite.getHeight() / 4))); + NumberSprite.draw(Convert.ToInt32(component.name), b, position, (((Game1.CurrentMineLevel == (Convert.ToInt32(component.name) + 120)) && Game1.currentLocation == Game1.mine) || ((Convert.ToInt32(component.name) == 0) && Game1.currentLocation != Game1.mine)) ? (Color.Gray * 0.75f) : Color.Gold, 0.5f, 0.86f, 1f, 0, 0); + } + } + public override void receiveLeftClick(int x, int y, bool playSound = true) + { + if (this.isWithinBounds(x, y)) + { + bool flag = false; + foreach (ClickableComponent elevator in base.elevators) + { + if (elevator.containsPoint(x, y)) + { + MineShaft mineShaft = (Game1.currentLocation as MineShaft); + if (((mineShaft != null) ? new int?(mineShaft.mineLevel) : null) == Convert.ToInt32(elevator.name) + 120) + { + return; + } + Game1.playSound("smallSelect"); + if (Convert.ToInt32(elevator.name) == 0) + { + if (Game1.currentLocation != Game1.mine) + { + return; + } + Game1.warpFarmer("SkullCave", 3, 4, 2); + Game1.exitActiveMenu(); + Game1.changeMusicTrack("none"); + flag = true; + } + else + { + if ((Game1.currentLocation == Game1.mine) && Convert.ToInt32(elevator.name) == Game1.mine.mineLevel) + { + return; + } + Game1.player.ridingMineElevator = true; + Game1.enterMine(Convert.ToInt32(elevator.name) + 120); + Game1.exitActiveMenu(); + flag = true; + } + } + } + if (!flag) + { + base.receiveLeftClick(x, y, true); + } + } + else + { + Game1.exitActiveMenu(); + } + } + } + +} + diff --git a/Mods/TimeSpeed/Framework/LocationType.cs b/Mods/TimeSpeed/Framework/LocationType.cs new file mode 100644 index 00000000..1cf7875b --- /dev/null +++ b/Mods/TimeSpeed/Framework/LocationType.cs @@ -0,0 +1,15 @@ +namespace TimeSpeed.Framework +{ + /// Represents a general location type relative to . + internal enum LocationType + { + /// The location is inside a building. + Indoors, + + /// The location is outside. + Outdoors, + + /// The mines or skull cavern. + Mine + } +} diff --git a/Mods/TimeSpeed/Framework/ModConfig.cs b/Mods/TimeSpeed/Framework/ModConfig.cs new file mode 100644 index 00000000..9848f03b --- /dev/null +++ b/Mods/TimeSpeed/Framework/ModConfig.cs @@ -0,0 +1,127 @@ +using System; +using System.Collections.Generic; +using System.Runtime.Serialization; +using StardewValley; +using StardewValley.Locations; + +namespace TimeSpeed.Framework +{ + /// The mod configuration model. + internal class ModConfig + { + /********* + ** Accessors + *********/ + /// The default number of seconds per 10-game-minutes, or null to freeze time globally. The game uses 7 seconds by default. + public double? DefaultTickLength { get; set; } = 7.0; + + /// The number of seconds per 10-game-minutes (or null to freeze time) for each location. The key can be a location name, 'Mine', or . + /// Most location names can be found at "\Stardew Valley\Content\Maps" directory. They usually match the file name without its extension. 'Mine' is a special case which includes all mine maps. + /// + /// This will set the Mines and Skull Cavern to 28 seconds per 10-game-minutes, freeze time indoors and use for outdoors: + /// + /// "TickLengthByLocation": { + /// "Mine": 28, + /// "Indoors": null + /// } + /// + /// + /// This will freeze time on your farm and set it to 14 seconds per 10-game-minutes elsewhere. + /// + /// "TickLengthByLocation": { + /// "Indoors": 14, + /// "Outdoors": 14, + /// "Farm":null + /// } + /// + /// + /// This will freeze time in the Saloon. All other locations will default to . + /// + /// "TickLengthByLocation": { + /// "Saloon": null + /// } + /// + /// + public Dictionary TickLengthByLocation { get; set; } = new Dictionary + { + { LocationType.Indoors.ToString(), 14 }, + { LocationType.Outdoors.ToString(), 7 }, + { LocationType.Mine.ToString(), 7 } + }; + + /// Whether to change tick length on festival days. + public bool EnableOnFestivalDays { get; set; } = false; + + /// The time at which to freeze time everywhere (or null to disable this). This should be 24-hour military time (e.g. 800 for 8am, 1600 for 8pm, etc). + public int? FreezeTimeAt { get; set; } = null; + + /// Whether to show a message about the time settings when you enter a location. + public bool LocationNotify { get; set; } = false; + + /// The keyboard bindings used to control the flow of time. See available keys at . Set a key to null to disable it. + public ModControlsConfig Keys { get; set; } = new ModControlsConfig(); + + + /********* + ** Public methods + *********/ + /// Get whether time should be frozen at a given location. + /// The game location. + public bool ShouldFreeze(GameLocation location) + { + return this.GetTickLengthOrFreeze(location) == null; + } + + /// Get whether the time should be frozen at a given time of day. + /// The time of day in 24-hour military format (e.g. 1600 for 8pm). + public bool ShouldFreeze(int time) + { + return this.FreezeTimeAt == time; + } + + /// Get whether time settings should be applied on a given day. + /// The season to check. + /// The day of month to check. + public bool ShouldScale(string season, int dayOfMonth) + { + return this.EnableOnFestivalDays || !Utility.isFestivalDay(dayOfMonth, season); + } + + /// Get the tick interval to apply for a location. + /// The game location. + public int? GetTickInterval(GameLocation location) + { + return (int?)((this.GetTickLengthOrFreeze(location) ?? this.DefaultTickLength) * 1000); + } + + + /********* + ** Private methods + *********/ + /// The method called after the config file is deserialised. + /// The deserialisation context. + [OnDeserialized] + private void OnDeserializedMethod(StreamingContext context) + { + this.TickLengthByLocation = new Dictionary(this.TickLengthByLocation, StringComparer.OrdinalIgnoreCase); + } + + /// Get the tick length to apply for a given location, or null to freeze time. + /// The game location. + private double? GetTickLengthOrFreeze(GameLocation location) + { + // check by location name + if (this.TickLengthByLocation.TryGetValue(location.Name, out double? tickLength)) + return tickLength; + if (location is MineShaft && this.TickLengthByLocation.TryGetValue(LocationType.Mine.ToString(), out tickLength)) + return tickLength; + + // check by location type + if (this.TickLengthByLocation.TryGetValue((location.IsOutdoors ? LocationType.Outdoors : LocationType.Indoors).ToString(), out tickLength)) + return tickLength; + + // default + return this.DefaultTickLength; + } + } +} diff --git a/Mods/TimeSpeed/Framework/ModControlsConfig.cs b/Mods/TimeSpeed/Framework/ModControlsConfig.cs new file mode 100644 index 00000000..99aba12c --- /dev/null +++ b/Mods/TimeSpeed/Framework/ModControlsConfig.cs @@ -0,0 +1,23 @@ +using StardewModdingAPI; + +namespace TimeSpeed.Framework +{ + /// The keyboard bindings used to control the flow of time. See available keys at . Set a key to null to disable it. + internal class ModControlsConfig + { + /********* + ** Accessors + *********/ + /// Freeze or unfreeze time. Freezing time will stay in effect until you unfreeze it; unfreezing time will stay in effect until you enter a new location with time settings. + public SButton? FreezeTime { get; set; } = SButton.N; + + /// Slow down time by one second per 10-game-minutes. Combine with Control to increase by 100 seconds, Shift to increase by 10 seconds, or Alt to increase by 0.1 seconds. + public SButton? IncreaseTickInterval { get; set; } = SButton.OemPeriod; + + /// Speed up time by one second per 10-game-minutes. Combine with Control to decrease by 100 seconds, Shift to decrease by 10 seconds, or Alt to decrease by 0.1 seconds. + public SButton? DecreaseTickInterval { get; set; } = SButton.OemComma; + + /// Reload all values from the config file and apply them immediately. Time will stay frozen if it was frozen via hotkey. + public SButton? ReloadConfig { get; set; } = SButton.B; + } +} diff --git a/Mods/TimeSpeed/Framework/Notifier.cs b/Mods/TimeSpeed/Framework/Notifier.cs new file mode 100644 index 00000000..af6ff3b9 --- /dev/null +++ b/Mods/TimeSpeed/Framework/Notifier.cs @@ -0,0 +1,25 @@ +using StardewValley; + +namespace TimeSpeed.Framework +{ + /// Displays messages to the user in-game. + internal class Notifier + { + /********* + ** Public methods + *********/ + /// Display a message for one second. + /// The message to display. + public void QuickNotify(string message) + { + Game1.hudMessages.Add(new HUDMessage(message, 2) { timeLeft = 1000 }); + } + + /// Display a message for two seconds. + /// The message to display. + public void ShortNotify(string message) + { + Game1.hudMessages.Add(new HUDMessage(message, 2) { timeLeft = 2000 }); + } + } +} diff --git a/Mods/TimeSpeed/Framework/TickProgressChangedEventArgs.cs b/Mods/TimeSpeed/Framework/TickProgressChangedEventArgs.cs new file mode 100644 index 00000000..1c906240 --- /dev/null +++ b/Mods/TimeSpeed/Framework/TickProgressChangedEventArgs.cs @@ -0,0 +1,33 @@ +using System; + +namespace TimeSpeed.Framework +{ + /// Contains information about a change to the value. + internal class TickProgressChangedEventArgs : EventArgs + { + /********* + ** Accessors + *********/ + /// The previous progress value. + public double PreviousProgress { get; } + + /// The new progress value. + public double NewProgress { get; } + + /// Whether a new tick occurred since the last check. + public bool TimeChanged => this.NewProgress < this.PreviousProgress; + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The previous progress value. + /// The new progress value. + public TickProgressChangedEventArgs(double previousProgess, double newProgress) + { + this.PreviousProgress = previousProgess; + this.NewProgress = newProgress; + } + } +} diff --git a/Mods/TimeSpeed/Framework/TimeHelper.cs b/Mods/TimeSpeed/Framework/TimeHelper.cs new file mode 100644 index 00000000..967658cb --- /dev/null +++ b/Mods/TimeSpeed/Framework/TimeHelper.cs @@ -0,0 +1,53 @@ +using System; +using StardewValley; + +namespace TimeSpeed.Framework +{ + /// Provides helper methods for tracking time flow. + internal class TimeHelper + { + /********* + ** Fields + *********/ + /// The previous tick progress. + private double PreviousProgress; + + /// The handlers to notify when the tick progress changes. + private event EventHandler Handlers; + + + /********* + ** Accessors + *********/ + /// The game's default tick interval in milliseconds for the current location. + public int CurrentDefaultTickInterval => 7000 + (Game1.currentLocation?.getExtraMillisecondsPerInGameMinuteForThisLocation() ?? 0); + + /// The percentage of the that's elapsed since the last tick. + public double TickProgress + { + get => (double)Game1.gameTimeInterval / this.CurrentDefaultTickInterval; + set => Game1.gameTimeInterval = (int)(value * this.CurrentDefaultTickInterval); + } + + + /********* + ** Public methods + *********/ + /// Update the time tracking. + public void Update() + { + // ReSharper disable once CompareOfFloatsByEqualityOperator - intended + if (this.PreviousProgress != this.TickProgress) + this.Handlers?.Invoke(null, new TickProgressChangedEventArgs(this.PreviousProgress, this.TickProgress)); + + this.PreviousProgress = this.TickProgress; + } + + /// Register an event handler to notify when the changes. + /// The event handler to notify. + public void WhenTickProgressChanged(EventHandler handler) + { + this.Handlers += handler; + } + } +} diff --git a/Mods/TimeSpeed/ModEntry.cs b/Mods/TimeSpeed/ModEntry.cs new file mode 100644 index 00000000..00435f84 --- /dev/null +++ b/Mods/TimeSpeed/ModEntry.cs @@ -0,0 +1,336 @@ +using System; +using System.Collections.Generic; +using Microsoft.Xna.Framework.Input; +using SMDroid.Options; +using StardewModdingAPI; +using StardewModdingAPI.Events; +using StardewValley; +using StardewValley.Menus; +using TimeSpeed.Framework; + +namespace TimeSpeed +{ + /// The entry class called by SMAPI. + internal class ModEntry : Mod + { + /********* + ** Properties + *********/ + /// Whether time features should be enabled. + private bool ShouldEnable => Context.IsWorldReady && Context.IsMainPlayer; + + /// Displays messages to the user. + private readonly Notifier Notifier = new Notifier(); + + /// Provides helper methods for tracking time flow. + private readonly TimeHelper TimeHelper = new TimeHelper(); + + /// The mod configuration. + private ModConfig Config; + + /// Whether time should be frozen everywhere. + private bool FrozenGlobally; + + /// Whether time should be frozen at the current location. + private bool FrozenAtLocation; + + /// Whether time should be frozen. + private bool Frozen + { + get => this.FrozenGlobally || this.FrozenAtLocation; + set => this.FrozenGlobally = this.FrozenAtLocation = value; + } + + /// Whether the flow of time should be adjusted. + private bool AdjustTime; + + /// Backing field for . + private int _tickInterval; + + /// The number of seconds per 10-game-minutes to apply. + private int TickInterval + { + get => this._tickInterval; + set => this._tickInterval = Math.Max(value, 0); + } + + + /********* + ** Public methods + *********/ + /// The mod entry point, called after the mod is first loaded. + /// Provides simplified APIs for writing mods. + public override void Entry(IModHelper helper) + { + // read config + this.Config = helper.ReadConfig(); + + // add time events + this.TimeHelper.WhenTickProgressChanged(this.OnTickProgressed); + helper.Events.GameLoop.UpdateTicked += this.OnUpdateTicked; + helper.Events.GameLoop.SaveLoaded += this.OnSaveLoaded; + helper.Events.GameLoop.TimeChanged += this.OnTimeChanged; + helper.Events.GameLoop.DayStarted += this.OnDayStarted; + helper.Events.Input.ButtonPressed += this.OnButtonPressed; + helper.Events.Player.Warped += this.OnWarped; + + // add time freeze/unfreeze notification + { + bool wasPaused = false; + helper.Events.Display.RenderingHud += (sender, args) => + { + wasPaused = Game1.paused; + if (this.Frozen) Game1.paused = true; + }; + + helper.Events.Display.RenderedHud += (sender, args) => + { + Game1.paused = wasPaused; + }; + } + } + + + public override List GetConfigMenuItems() + { + List options = new List(); + ModOptionsSlider _optionsSliderSpeed = new ModOptionsSlider("时速倍数", 0x8765, delegate (int value) { + if (value == 0) + { + if (!this.Frozen) + { + this.Config.DefaultTickLength = null; + this.Frozen = true; + } + } + else + { + this.Frozen = false; + if ((int)(7 * this.TickInterval / 1000) != value) + { + this.Config.DefaultTickLength = 7.0 / value; + this.TickInterval = 7000 / value; + } + } + }, -1, -1); + _optionsSliderSpeed.sliderMinValue = 0; + _optionsSliderSpeed.sliderMaxValue = 10; + if (!this.Frozen) + { + _optionsSliderSpeed.value = 7000 / this.TickInterval; + } + else + { + _optionsSliderSpeed.value = 0; + } + options.Add(_optionsSliderSpeed); + return options; + } + + /********* + ** Private methods + *********/ + /**** + ** Event handlers + ****/ + /// Raised after the player loads a save slot and the world is initialised. + /// The event sender. + /// The event arguments. + private void OnSaveLoaded(object sender, SaveLoadedEventArgs e) + { + if (!Context.IsMainPlayer) + this.Monitor.Log("Disabled mod; only works for the main player in multiplayer.", LogLevel.Warn); + } + + /// Raised after the game begins a new day (including when the player loads a save). + /// The event sender. + /// The event arguments. + private void OnDayStarted(object sender, DayStartedEventArgs e) + { + this.UpdateScaleForDay(Game1.currentSeason, Game1.dayOfMonth); + this.UpdateSettingsForLocation(Game1.currentLocation); + } + + /// Raised after the player presses a button on the keyboard, controller, or mouse. + /// The event sender. + /// The event arguments. + private void OnButtonPressed(object sender, ButtonPressedEventArgs e) + { + if (!this.ShouldEnable || !Context.IsPlayerFree) + return; + + SButton key = e.Button; + if (key == this.Config.Keys.FreezeTime) + this.ToogleFreeze(); + else if (key == this.Config.Keys.IncreaseTickInterval || key == this.Config.Keys.DecreaseTickInterval) + this.ChangeTickInterval(increase: key == this.Config.Keys.IncreaseTickInterval); + else if (key == this.Config.Keys.ReloadConfig) + this.ReloadConfig(); + } + + /// Raised after a player warps to a new location. + /// The event sender. + /// The event arguments. + private void OnWarped(object sender, WarpedEventArgs e) + { + if (!this.ShouldEnable || !e.IsLocalPlayer) + return; + + this.UpdateSettingsForLocation(e.NewLocation); + } + + /// Raised after the in-game clock time changes. + /// The event sender. + /// The event arguments. + private void OnTimeChanged(object sender, TimeChangedEventArgs e) + { + if (!this.ShouldEnable) + return; + + this.UpdateFreezeForTime(Game1.timeOfDay); + } + + /// Raised after the game state is updated (≈60 times per second). + /// The event sender. + /// The event arguments. + private void OnUpdateTicked(object sender, UpdateTickedEventArgs e) + { + this.TimeHelper.Update(); + } + + /// Raised after the value changes. + /// The event sender. + /// The event arguments. + private void OnTickProgressed(object sender, TickProgressChangedEventArgs e) + { + if (!this.ShouldEnable) + return; + + if (this.Frozen) + this.TimeHelper.TickProgress = e.TimeChanged ? 0 : e.PreviousProgress; + else + { + if (!this.AdjustTime) + return; + if (this.TickInterval == 0) + this.TickInterval = 1000; + + if (e.TimeChanged) + this.TimeHelper.TickProgress = this.ScaleTickProgress(this.TimeHelper.TickProgress, this.TickInterval); + else + this.TimeHelper.TickProgress = e.PreviousProgress + this.ScaleTickProgress(e.NewProgress - e.PreviousProgress, this.TickInterval); + } + } + + /**** + ** Methods + ****/ + /// Reload from the config file. + private void ReloadConfig() + { + this.Config = this.Helper.ReadConfig(); + this.UpdateScaleForDay(Game1.currentSeason, Game1.dayOfMonth); + this.UpdateSettingsForLocation(Game1.currentLocation); + this.Notifier.ShortNotify("Time feels differently now..."); + } + + /// Increment or decrement the tick interval, taking into account the held modifier key if applicable. + /// Whether to increment the tick interval; else decrement. + private void ChangeTickInterval(bool increase) + { + // get offset to apply + int change = 1000; + { + KeyboardState state = Keyboard.GetState(); + if (state.IsKeyDown(Keys.LeftControl)) + change *= 100; + else if (state.IsKeyDown(Keys.LeftShift)) + change *= 10; + else if (state.IsKeyDown(Keys.LeftAlt)) + change /= 10; + } + + // update tick interval + if (!increase) + { + int minAllowed = Math.Min(this.TickInterval, change); + this.TickInterval = Math.Max(minAllowed, this.TickInterval - change); + } + else + this.TickInterval = this.TickInterval + change; + + // log change + this.Notifier.QuickNotify($"10 minutes feels like {this.TickInterval / 1000} seconds."); + this.Monitor.Log($"Tick length set to {this.TickInterval / 1000d: 0.##} seconds.", LogLevel.Info); + } + + /// Toggle whether time is frozen. + private void ToogleFreeze() + { + if (!this.Frozen) + { + this.FrozenGlobally = true; + this.Notifier.QuickNotify("Hey, you stopped the time!"); + this.Monitor.Log("Time is frozen globally.", LogLevel.Info); + } + else + { + this.Frozen = false; + this.Notifier.QuickNotify("Time feels as usual now..."); + this.Monitor.Log($"Time is temporarily unfrozen at \"{Game1.currentLocation.Name}\".", LogLevel.Info); + } + } + + /// Update the time freeze settings for the given time of day. + /// The time of day in 24-hour military format (e.g. 1600 for 8pm). + private void UpdateFreezeForTime(int time) + { + if (this.Config.ShouldFreeze(time)) + { + this.FrozenGlobally = true; + this.Notifier.ShortNotify("Time suddenly stops..."); + this.Monitor.Log($"Time automatically set to frozen at {Game1.timeOfDay}.", LogLevel.Info); + } + } + + /// Update the time settings for the given location. + /// The game location. + private void UpdateSettingsForLocation(GameLocation location) + { + if (location == null) + return; + + // update time settings + this.FrozenAtLocation = this.FrozenGlobally || this.Config.ShouldFreeze(location); + if (this.Config.GetTickInterval(location) != null) + this.TickInterval = this.Config.GetTickInterval(location) ?? this.TickInterval; + + // notify player + if (this.Config.LocationNotify) + { + if (this.FrozenGlobally) + this.Notifier.ShortNotify("Looks like time stopped everywhere..."); + else if (this.FrozenAtLocation) + this.Notifier.ShortNotify("It feels like time is frozen here..."); + else + this.Notifier.ShortNotify($"10 minutes feels more like {this.TickInterval / 1000} seconds here..."); + } + } + + /// Update the time settings for the given date. + /// The current season. + /// The current day of month. + private void UpdateScaleForDay(string season, int dayOfMonth) + { + this.AdjustTime = this.Config.ShouldScale(season, dayOfMonth); + } + + /// Get the adjusted progress towards the next 10-game-minute tick. + /// The current progress. + /// The new tick interval. + private double ScaleTickProgress(double progress, int newTickInterval) + { + return progress * this.TimeHelper.CurrentDefaultTickInterval / newTickInterval; + } + } +} diff --git a/Mods/TimeSpeed/Properties/AssemblyInfo.cs b/Mods/TimeSpeed/Properties/AssemblyInfo.cs new file mode 100644 index 00000000..7b703229 --- /dev/null +++ b/Mods/TimeSpeed/Properties/AssemblyInfo.cs @@ -0,0 +1,36 @@ +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; + +// 有关程序集的一般信息由以下 +// 控制。更改这些特性值可修改 +// 与程序集关联的信息。 +[assembly: AssemblyTitle("TimeSpeed")] +[assembly: AssemblyDescription("")] +[assembly: AssemblyConfiguration("")] +[assembly: AssemblyCompany("")] +[assembly: AssemblyProduct("TimeSpeed")] +[assembly: AssemblyCopyright("Copyright © 2019")] +[assembly: AssemblyTrademark("")] +[assembly: AssemblyCulture("")] + +// 将 ComVisible 设置为 false 会使此程序集中的类型 +//对 COM 组件不可见。如果需要从 COM 访问此程序集中的类型 +//请将此类型的 ComVisible 特性设置为 true。 +[assembly: ComVisible(false)] + +// 如果此项目向 COM 公开,则下列 GUID 用于类型库的 ID +[assembly: Guid("09e76025-db21-4d9f-b8b1-571d779ac5e6")] + +// 程序集的版本信息由下列四个值组成: +// +// 主版本 +// 次版本 +// 生成号 +// 修订号 +// +// 可以指定所有值,也可以使用以下所示的 "*" 预置版本号和修订号 +//通过使用 "*",如下所示: +// [assembly: AssemblyVersion("1.0.*")] +[assembly: AssemblyVersion("1.0.0.0")] +[assembly: AssemblyFileVersion("1.0.0.0")] diff --git a/Mods/TimeSpeed/TimeSpeed.csproj b/Mods/TimeSpeed/TimeSpeed.csproj new file mode 100644 index 00000000..ab32bf7c --- /dev/null +++ b/Mods/TimeSpeed/TimeSpeed.csproj @@ -0,0 +1,167 @@ + + + + + Debug + AnyCPU + {09E76025-DB21-4D9F-B8B1-571D779AC5E6} + Library + Properties + TimeSpeed + TimeSpeed + v4.5.2 + 512 + true + + + true + full + false + bin\Debug\ + DEBUG;TRACE + prompt + 4 + + + pdbonly + true + bin\Release\ + TRACE + prompt + 4 + + + + ..\assemblies\Mod.dll + + + ..\assemblies\StardewValley.dll + + + False + ..\assemblies\BmFont.dll + + + False + ..\assemblies\Google.Android.Vending.Expansion.Downloader.dll + + + False + ..\assemblies\Google.Android.Vending.Expansion.ZipFile.dll + + + False + ..\assemblies\Google.Android.Vending.Licensing.dll + + + False + ..\assemblies\Java.Interop.dll + + + False + ..\assemblies\Microsoft.AppCenter.dll + + + False + ..\assemblies\Microsoft.AppCenter.Analytics.dll + + + False + ..\assemblies\Microsoft.AppCenter.Analytics.Android.Bindings.dll + + + False + ..\assemblies\Microsoft.AppCenter.Android.Bindings.dll + + + False + ..\assemblies\Microsoft.AppCenter.Crashes.dll + + + False + ..\assemblies\Microsoft.AppCenter.Crashes.Android.Bindings.dll + + + False + ..\assemblies\Mono.Android.dll + + + False + ..\assemblies\Mono.Security.dll + + + False + ..\assemblies\MonoGame.Framework.dll + + + ..\assemblies\mscorlib.dll + + + ..\assemblies\System.dll + + + ..\assemblies\System.Xml + + + ..\assemblies\System.Net.Http + + + ..\assemblies\System.Runtime.Serialization + + + False + ..\assemblies\Xamarin.Android.Arch.Core.Common.dll + + + False + ..\assemblies\Xamarin.Android.Arch.Lifecycle.Common.dll + + + False + ..\assemblies\Xamarin.Android.Arch.Lifecycle.Runtime.dll + + + False + ..\assemblies\Xamarin.Android.Support.Annotations.dll + + + False + ..\assemblies\Xamarin.Android.Support.Compat.dll + + + False + ..\assemblies\Xamarin.Android.Support.Core.UI.dll + + + False + ..\assemblies\Xamarin.Android.Support.Core.Utils.dll + + + False + ..\assemblies\Xamarin.Android.Support.Fragment.dll + + + False + ..\assemblies\Xamarin.Android.Support.Media.Compat.dll + + + False + ..\assemblies\Xamarin.Android.Support.v4.dll + + + False + ..\assemblies\xTile.dll + + + + + + + + + + + + + + \ No newline at end of file diff --git a/PatchStep.txt b/PatchStep.txt new file mode 100644 index 00000000..13e513ad --- /dev/null +++ b/PatchStep.txt @@ -0,0 +1,21 @@ +1. Inject assembly reference, namespace: SMDroid + +2.Modify class StardewValley.Game1, modify constructor methodinsert Instructions at beginning: +newobj System.Void SMDroid.ModEntry::.ctor() +stsfld StardewValley.ModHooks StardewValley.Game1::hooks + +3.Modify class StardewValley.ModHooks, inject method: + public virtual void OnGame1_Update(GameTime time); + public virtual LocalizedContentManager OnGame1_CreateContentManager(IServiceProvider serviceProvider, string rootDirectory) => null; + +4.Modify class StardewValley.Game1, modify method Update(GameTime gameTime), insert Instructions at beginning: +ldsfld StardewValley.ModHooks StardewValley.Game1::hooks +ldarg.1 +callvirt System.Void StardewValley.ModHooks::OnGame1_Update(Microsoft.Xna.Framework.GameTime) + +5.Modify class StardewValley.Game1, modify method CreateContentManager(GameTime gameTime), replace Instructions to: +ldsfld StardewValley.ModHooks StardewValley.Game1::hooks +ldarg.1 +ldarg.2 +callvirt StardewValley.LocalizedContentManager StardewValley.ModHooks::OnGame1_CreateContentManager(System.IServiceProvider,System.String) +ret \ No newline at end of file diff --git a/SMAPI b/SMAPI new file mode 160000 index 00000000..9e521091 --- /dev/null +++ b/SMAPI @@ -0,0 +1 @@ +Subproject commit 9e521091fe4b13a9091bcb30c9f73ef210ace2dd