diff --git a/Install/How to install.txt b/Install/How to install.txt new file mode 100644 index 000000000..6e23fb52d --- /dev/null +++ b/Install/How to install.txt @@ -0,0 +1,6 @@ +1.Patch the StardewValley.dllsee PatchStep.txt +2.Put the folder SMDroid into /sdcard/ +3.Put the patched StardewValley.dll into /sdcard/SMDroid/ +4.Replace StardewValley.dll and StardewModdingAPI.dll into game's base.apk(copy to user folder to operate if no root acesss), compress level must be storage.Resign apk if you have no root access. +5.Reinstall the patched base.apk +Enjoy it. \ No newline at end of file diff --git a/Install/PatchStep.txt b/Install/PatchStep.txt new file mode 100644 index 000000000..b6171e532 --- /dev/null +++ b/Install/PatchStep.txt @@ -0,0 +1,73 @@ +1. Inject assembly reference, namespace: StardewModdingAPI + +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 void OnGame1_Draw(GameTime time, RenderTarget2D toBuffer); + 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 + +6.Modify class StardewValley.Game1, modify method Draw(GameTime gameTime, RenderTarget2D toBuffer), modify Instructions at beginning: +ldsfld StardewValley.ModHooks StardewValley.Game1::hooks +ldarg.1 +ldnull +callvirt System.Void StardewValley.ModHooks::OnGame1_Draw(Microsoft.Xna.Framework.GameTime,Microsoft.Xna.Framework.Graphics.RenderTarget2D) + + +Optional Section + +Fix back button + +Modify class StardewValley.Game1, modify method updateAndroidMenus(), modify Instructions at beginning: +ldsfld StardewValley.InputState StardewValley.Game1::input +callvirt Microsoft.Xna.Framework.Input.GamePadState StardewValley.InputState::GetGamePadState() + + + +Json Asset Support + +Modify class StardewValley.Object, modify method checkForAction,insert instructions at beginning: +ldsfld StardewValley.ModHooks StardewValley.Game1::hooks +ldarg.0 +callvirt System.Boolean StardewValley.ModHooks::OnObject_checkForAction(StardewValley.Object) +brtrue.s -> (6) ldarg.2 +ldc.i4.0 +ret + +modify method isIndexOkForBasicShippedCategory,replace instructions: +ldarg.0 +ldc.i4 434 +bne.un.s -> (5) ldsfld StardewValley.ModHooks StardewValley.Game1::hooks +ldc.i4.0 +ret +ldsfld StardewValley.ModHooks StardewValley.Game1::hooks +ldarg.0 +ldloca.s -> (0) (System.Boolean) +callvirt System.Void StardewValley.ModHooks::OnObject_isIndexOkForBasicShippedCategory(System.Int32,System.Boolean&) +ldloc.0 +ret + +modify method canBePlacedHere insert instructions at beginning: +ldsfld StardewValley.ModHooks StardewValley.Game1::hooks +ldarg.0 +ldarg.1 +ldarg.2 +ldloca.s -> (1) (System.Boolean) +callvirt System.Boolean StardewValley.ModHooks::OnObject_canBePlacedHere(StardewValley.Object,StardewValley.GameLocation,Microsoft.Xna.Framework.Vector2,System.Boolean&) +brtrue.s -> (9) ldarg.1 +ldloc.1 +ret \ No newline at end of file diff --git a/Install/SMDroid/MonoGame.Framework.dll b/Install/SMDroid/MonoGame.Framework.dll new file mode 100644 index 000000000..c43c4482a Binary files /dev/null and b/Install/SMDroid/MonoGame.Framework.dll differ diff --git a/Install/SMDroid/StardewModdingAPI.dll b/Install/SMDroid/StardewModdingAPI.dll new file mode 100644 index 000000000..3f8f3e1d2 Binary files /dev/null and b/Install/SMDroid/StardewModdingAPI.dll differ diff --git a/Install/SMDroid/mscorlib.dll b/Install/SMDroid/mscorlib.dll new file mode 100644 index 000000000..48e3802fb Binary files /dev/null and b/Install/SMDroid/mscorlib.dll differ diff --git a/Install/SMDroid/smapi-internal/StardewModdingAPI.config.json b/Install/SMDroid/smapi-internal/StardewModdingAPI.config.json new file mode 100644 index 000000000..7c61adcfb --- /dev/null +++ b/Install/SMDroid/smapi-internal/StardewModdingAPI.config.json @@ -0,0 +1,77 @@ +/* + + + +This file contains advanced configuration for SMAPI. You generally shouldn't change this file. + + + +*/ +{ + /** + * The console color theme to use. The possible values are: + * - AutoDetect: SMAPI will assume a light background on Mac, and detect the background color automatically on Linux or Windows. + * - LightBackground: use darker text colors that look better on a white or light background. + * - DarkBackground: use lighter text colors that look better on a black or dark background. + */ + "ColorScheme": "AutoDetect", + + /** + * Whether SMAPI should check for newer versions of SMAPI and mods when you load the game. If new + * versions are available, an alert will be shown in the console. This doesn't affect the load + * time even if your connection is offline or slow, because it happens in the background. + */ + "CheckForUpdates": true, + + /** + * Whether to enable features intended for mod developers. Currently this only makes TRACE-level + * messages appear in the console. + */ + "DeveloperMode": false, + + /** + * Whether to add a section to the 'mod issues' list for mods which directly use potentially + * sensitive .NET APIs like file or shell access. Note that many mods do this legitimately as + * part of their normal functionality, so these warnings are meaningless without further + * investigation. When this is commented out, it'll be true for local debug builds and false + * otherwise. + */ + //"ParanoidWarnings": true, + + /** + * Whether SMAPI should show newer beta versions as an available update. When this is commented + * out, it'll be true if the current SMAPI version is beta, and false otherwise. + */ + //"UseBetaChannel": true, + + /** + * SMAPI's GitHub project name, used to perform update checks. + */ + "GitHubProjectName": "Pathoschild/SMAPI", + + /** + * The base URL for SMAPI's web API, used to perform update checks. + * Note: the protocol will be changed to http:// on Linux/Mac due to OpenSSL issues with the + * game's bundled Mono. + */ + "WebApiBaseUrl": "https://api.smapi.io", + + /** + * Whether SMAPI should log more information about the game context. + */ + "VerboseLogging": false, + + /** + * Whether to generate a 'SMAPI-latest.metadata-dump.json' file in the logs folder with the full mod + * metadata for detected mods. This is only needed when troubleshooting some cases. + */ + "DumpMetadata": false, + + /** + * The mod IDs SMAPI should ignore when performing update checks or validating update keys. + */ + "SuppressUpdateChecks": [ + "SMAPI.ConsoleCommands", + "SMAPI.SaveBackup" + ] +} diff --git a/src/SMAPI.Web/wwwroot/StardewModdingAPI.metadata.json b/Install/SMDroid/smapi-internal/StardewModdingAPI.metadata.json similarity index 80% rename from src/SMAPI.Web/wwwroot/StardewModdingAPI.metadata.json rename to Install/SMDroid/smapi-internal/StardewModdingAPI.metadata.json index 08e341140..9de692fee 100644 --- a/src/SMAPI.Web/wwwroot/StardewModdingAPI.metadata.json +++ b/Install/SMDroid/smapi-internal/StardewModdingAPI.metadata.json @@ -59,15 +59,15 @@ "Default | UpdateKey": "Nexus:2270" }, - //"Content Patcher": { - // "ID": "Pathoschild.ContentPatcher", - // "Default | UpdateKey": "Nexus:1915" - //}, + "Content Patcher": { + "ID": "Pathoschild.ContentPatcher", + "Default | UpdateKey": "Nexus:1915" + }, - //"Custom Farming Redux": { - // "ID": "Platonymous.CustomFarming", - // "Default | UpdateKey": "Nexus:991" - //}, + "Custom Farming Redux": { + "ID": "Platonymous.CustomFarming", + "Default | UpdateKey": "Nexus:991" + }, "Custom Shirts": { "ID": "Platonymous.CustomShirts", @@ -234,141 +234,6 @@ "~ | StatusReasonPhrase": "debug mode was removed in SMAPI 1.0." }, - /********* - ** Broke in SMAPI 3.0 (runtime errors due to lifecycle changes) - *********/ - "Advancing Sprinklers": { - "ID": "warix3.advancingsprinklers", - "~1.0.0 | Status": "AssumeBroken" - }, - - "Arcade 2048": { - "ID": "Platonymous.2048", - "~1.0.6 | Status": "AssumeBroken" // possibly due to PyTK - }, - - "Arcade Snake": { - "ID": "Platonymous.Snake", - "~1.1.0 | Status": "AssumeBroken" // possibly due to PyTK - }, - - "Better Sprinklers": { - "ID": "Speeder.BetterSprinklers", - "~2.3.1-unofficial.7-pathoschild | Status": "AssumeBroken" - }, - - "Companion NPCs": { - "ID": "Redwood.CompanionNPCs", - "~0.0.9 | Status": "AssumeBroken" - }, - - "Content Patcher": { - "ID": "Pathoschild.ContentPatcher", - "Default | UpdateKey": "Nexus:1915", - "~1.6.4 | Status": "AssumeBroken" - }, - - "Crop Transplant Mod": { - "ID": "DIGUS.CropTransplantMod", - "~1.1.3 | Status": "AssumeBroken" - }, - - "Custom Adventure Guild Challenges": { - "ID": "DefenTheNation.CustomGuildChallenges", - "~1.8 | Status": "AssumeBroken" - }, - - "Custom Farming Redux": { - "ID": "Platonymous.CustomFarming", - "Default | UpdateKey": "Nexus:991", - "~2.10.10 | Status": "AssumeBroken" // possibly due to PyTK - }, - - "Deep Woods": { - "ID": "maxvollmer.deepwoodsmod", - "~1.5-beta.1 | Status": "AssumeBroken" - }, - - "Everlasting Baits and Unbreaking Tackles": { - "ID": "DIGUS.EverlastingBaitsAndUnbreakableTacklesMod", - "~1.2.4 | Status": "AssumeBroken" - }, - - "Farmhouse Redone": { - "ID": "mabelsyrup.farmhouse", - "~0.2 | Status": "AssumeBroken" - }, - - "Geode Info Menu": { - "ID": "cat.geodeinfomenu", - "~1.5 | Status": "AssumeBroken" - }, - - "Harp of Yoba Redux": { - "ID": "Platonymous.HarpOfYobaRedux", - "~2.6.3 | Status": "AssumeBroken" // possibly due to PyTK - }, - - "Infested Levels": { - "ID": "Eireon.InfestedLevels", - "~1.0.5 | Status": "AssumeBroken" - }, - - "JoJaBan - Arcade Sokoban": { - "ID": "Platonymous.JoJaBan", - "~0.4.3 | Status": "AssumeBroken" // possibly due to PyTK - }, - - "Level Extender": { - "ID": "DevinLematty.LevelExtender", - "~3.1 | Status": "AssumeBroken" - }, - - "Mod Update Menu": { - "ID": "cat.modupdatemenu", - "~1.4 | Status": "AssumeBroken" - }, - - "Mushroom Levels": { - "ID": "Eireon.MushroomLevels", - "~1.0.4 | Status": "AssumeBroken" - }, - - "Notes": { - "ID": "Platonymous.Notes", - "~1.0.5 | Status": "AssumeBroken" // possibly due to PyTK - }, - - "Quick Start": { - "ID": "WuestMan.QuickStart", - "~1.5 | Status": "AssumeBroken" - }, - - "Seed Bag": { - "ID": "Platonymous.SeedBag", - "~1.2.7 | Status": "AssumeBroken" // possibly due to PyTK - }, - - "Separate Money": { - "ID": "funnysnek.SeparateMoney", - "~1.4.2 | Status": "AssumeBroken" - }, - - "Split Money": { - "ID": "Platonymous.SplitMoney", - "~1.2.4 | Status": "AssumeBroken" // possibly due to PyTK - }, - - "Stack to Nearby Chests": { - "ID": "Ilyaki.StackToNearbyChests", - "~1.4.4 | Status": "AssumeBroken" - }, - - "Tree Transplant": { - "ID": "TreeTransplant", - "~1.0.5 | Status": "AssumeBroken" - }, - /********* ** Broke in SDV 1.3.36 *********/ diff --git a/Install/SMDroid/xTile.dll b/Install/SMDroid/xTile.dll new file mode 100644 index 000000000..993f58471 Binary files /dev/null and b/Install/SMDroid/xTile.dll differ diff --git a/Mods/AutoFish/AutoFish.csproj b/Mods/AutoFish/AutoFish.csproj new file mode 100644 index 000000000..5241dcfd0 --- /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\StardewModdingAPI.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 000000000..7f13085bb --- /dev/null +++ b/Mods/AutoFish/AutoFish/ModConfig.cs @@ -0,0 +1,17 @@ +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; + public bool autoPlay { get; set; } = true; + } +} diff --git a/Mods/AutoFish/AutoFish/ModEntry.cs b/Mods/AutoFish/AutoFish/ModEntry.cs new file mode 100644 index 000000000..0ded242c9 --- /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 _optionsCheckboxPlay = new ModOptionsCheckbox("自动钓鱼", 0x8765, delegate (bool value) { + this.Config.autoPlay = value; + this.Helper.WriteConfig(this.Config); + }, -1, -1); + _optionsCheckboxPlay.isChecked = this.Config.autoPlay; + options.Add(_optionsCheckboxPlay); + 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); + 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 (this.Config.autoPlay && 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 000000000..322b9e55b --- /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 000000000..d29f361ca --- /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 000000000..55ee33c66 --- /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 000000000..2e6dc10fb --- /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 000000000..b1f5b73b5 --- /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 000000000..818ba5ea5 --- /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 000000000..50f2e1051 --- /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 000000000..b5c3f9e33 --- /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 000000000..1907a05dd --- /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 000000000..4ee34b505 --- /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\StardewModdingAPI.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 000000000..0508d2490 --- /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 000000000..613bb4b12 --- /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 000000000..485b4ade8 --- /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 000000000..d92b51ab9 --- /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 000000000..d2135e576 --- /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 000000000..0992709fa --- /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 000000000..2267974c8 --- /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 000000000..6c62c42ef --- /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 000000000..f8f8f4931 --- /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 000000000..9dbc845f2 --- /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 000000000..599367867 --- /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 000000000..e31eccd90 --- /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 000000000..623d11f62 --- /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 000000000..0a187d0c2 --- /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 000000000..654c0f043 --- /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 000000000..d091cd89f --- /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 000000000..1a31be930 --- /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 000000000..43b3ed333 --- /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 000000000..87286cc97 --- /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 000000000..00e684326 --- /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 000000000..5158f55e8 --- /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 000000000..8caf4d7dc --- /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 000000000..efc8adfcc --- /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 000000000..3f643ef47 --- /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 000000000..4f6e6d09a --- /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 000000000..703784407 --- /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 000000000..6f2adb2ff --- /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 000000000..3cd95f130 --- /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 000000000..cc0b08b3c --- /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 000000000..a436db130 --- /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 000000000..093408e47 --- /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 000000000..2ce84cee3 --- /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 000000000..6b814c436 --- /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 000000000..054c35169 --- /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 000000000..0cbc6e774 --- /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 000000000..8a48511c6 --- /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 000000000..dce6ed8de --- /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 000000000..172156ac6 --- /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 000000000..301773c9e --- /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 000000000..0a8397dc3 --- /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 000000000..1327f11c3 --- /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 000000000..f61990a6e --- /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 000000000..746a2e76b --- /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 000000000..41b04858e --- /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 000000000..9f2ac69f5 --- /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 000000000..aaa5eda7b --- /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 000000000..ce9fe3b67 --- /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 000000000..59fa3250d --- /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 000000000..a0ec02f17 --- /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 000000000..4a63532f2 --- /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 000000000..c1adb0c03 --- /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 000000000..260a1edaf --- /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 000000000..42197319e --- /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 000000000..fcc20fbbe --- /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 000000000..806ecbdbf --- /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 000000000..f8df55eb6 --- /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 000000000..aaa738417 --- /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 000000000..b7275df56 --- /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 000000000..d394c482f --- /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 000000000..b0da39bc8 --- /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 000000000..969366c24 --- /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 000000000..1777e755a 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 000000000..846ea60cc 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 000000000..97af06c44 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 000000000..5768cb4e7 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 000000000..63e8ac2b1 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 000000000..eb05b434a 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 000000000..88c31da96 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 000000000..44d3bd528 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 000000000..d69884805 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 000000000..720736e4d --- /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 000000000..a84f42267 --- /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 000000000..9ee33a2d9 --- /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 000000000..158013257 --- /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 000000000..13898dbc6 --- /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 000000000..6c649fcac --- /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 000000000..6081e89b5 --- /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 000000000..f7f48248b --- /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 000000000..c213f02e8 --- /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 000000000..4cb7c36d4 --- /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 000000000..4952043ff --- /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 000000000..277c95c61 --- /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 000000000..14b80ffb8 --- /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 000000000..a41135e59 --- /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 000000000..2c4d92a11 --- /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 000000000..17327ed80 --- /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 000000000..a945c8c33 --- /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 000000000..d5aa4fce5 --- /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 000000000..f90cfb749 --- /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 000000000..b2a61ed32 --- /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 000000000..b35e6f359 --- /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 000000000..68d8e05a7 --- /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 000000000..ef21dd310 --- /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 000000000..40b174f02 --- /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 000000000..b7c3be5e2 --- /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 000000000..29e781674 --- /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 000000000..c96aeb92c --- /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 000000000..4b515ec50 --- /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 000000000..3da68991e --- /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 000000000..98cf678ec --- /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 000000000..4bad98e7f --- /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 000000000..6f0530d84 --- /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 000000000..020ebfadd --- /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 000000000..989fb8027 --- /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/ContentPatcher/Common/CommonHelper.cs b/Mods/ContentPatcher/Common/CommonHelper.cs new file mode 100644 index 000000000..720736e4d --- /dev/null +++ b/Mods/ContentPatcher/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/ContentPatcher/Common/DataParsers/CropDataParser.cs b/Mods/ContentPatcher/Common/DataParsers/CropDataParser.cs new file mode 100644 index 000000000..a84f42267 --- /dev/null +++ b/Mods/ContentPatcher/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/ContentPatcher/Common/Integrations/Automate/AutomateIntegration.cs b/Mods/ContentPatcher/Common/Integrations/Automate/AutomateIntegration.cs new file mode 100644 index 000000000..9ee33a2d9 --- /dev/null +++ b/Mods/ContentPatcher/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/ContentPatcher/Common/Integrations/Automate/IAutomateApi.cs b/Mods/ContentPatcher/Common/Integrations/Automate/IAutomateApi.cs new file mode 100644 index 000000000..158013257 --- /dev/null +++ b/Mods/ContentPatcher/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/ContentPatcher/Common/Integrations/BaseIntegration.cs b/Mods/ContentPatcher/Common/Integrations/BaseIntegration.cs new file mode 100644 index 000000000..13898dbc6 --- /dev/null +++ b/Mods/ContentPatcher/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/ContentPatcher/Common/Integrations/BetterJunimos/BetterJunimosIntegration.cs b/Mods/ContentPatcher/Common/Integrations/BetterJunimos/BetterJunimosIntegration.cs new file mode 100644 index 000000000..6c649fcac --- /dev/null +++ b/Mods/ContentPatcher/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/ContentPatcher/Common/Integrations/BetterJunimos/IBetterJunimosApi.cs b/Mods/ContentPatcher/Common/Integrations/BetterJunimos/IBetterJunimosApi.cs new file mode 100644 index 000000000..6081e89b5 --- /dev/null +++ b/Mods/ContentPatcher/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/ContentPatcher/Common/Integrations/BetterSprinklers/BetterSprinklersIntegration.cs b/Mods/ContentPatcher/Common/Integrations/BetterSprinklers/BetterSprinklersIntegration.cs new file mode 100644 index 000000000..f7f48248b --- /dev/null +++ b/Mods/ContentPatcher/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/ContentPatcher/Common/Integrations/BetterSprinklers/IBetterSprinklersApi.cs b/Mods/ContentPatcher/Common/Integrations/BetterSprinklers/IBetterSprinklersApi.cs new file mode 100644 index 000000000..c213f02e8 --- /dev/null +++ b/Mods/ContentPatcher/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/ContentPatcher/Common/Integrations/Cobalt/CobaltIntegration.cs b/Mods/ContentPatcher/Common/Integrations/Cobalt/CobaltIntegration.cs new file mode 100644 index 000000000..4cb7c36d4 --- /dev/null +++ b/Mods/ContentPatcher/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/ContentPatcher/Common/Integrations/Cobalt/ICobaltApi.cs b/Mods/ContentPatcher/Common/Integrations/Cobalt/ICobaltApi.cs new file mode 100644 index 000000000..4952043ff --- /dev/null +++ b/Mods/ContentPatcher/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/ContentPatcher/Common/Integrations/CustomFarmingRedux/CustomFarmingReduxIntegration.cs b/Mods/ContentPatcher/Common/Integrations/CustomFarmingRedux/CustomFarmingReduxIntegration.cs new file mode 100644 index 000000000..277c95c61 --- /dev/null +++ b/Mods/ContentPatcher/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/ContentPatcher/Common/Integrations/CustomFarmingRedux/ICustomFarmingApi.cs b/Mods/ContentPatcher/Common/Integrations/CustomFarmingRedux/ICustomFarmingApi.cs new file mode 100644 index 000000000..14b80ffb8 --- /dev/null +++ b/Mods/ContentPatcher/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/ContentPatcher/Common/Integrations/FarmExpansion/FarmExpansionIntegration.cs b/Mods/ContentPatcher/Common/Integrations/FarmExpansion/FarmExpansionIntegration.cs new file mode 100644 index 000000000..a41135e59 --- /dev/null +++ b/Mods/ContentPatcher/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/ContentPatcher/Common/Integrations/FarmExpansion/IFarmExpansionApi.cs b/Mods/ContentPatcher/Common/Integrations/FarmExpansion/IFarmExpansionApi.cs new file mode 100644 index 000000000..2c4d92a11 --- /dev/null +++ b/Mods/ContentPatcher/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/ContentPatcher/Common/Integrations/IModIntegration.cs b/Mods/ContentPatcher/Common/Integrations/IModIntegration.cs new file mode 100644 index 000000000..17327ed80 --- /dev/null +++ b/Mods/ContentPatcher/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/ContentPatcher/Common/Integrations/LineSprinklers/ILineSprinklersApi.cs b/Mods/ContentPatcher/Common/Integrations/LineSprinklers/ILineSprinklersApi.cs new file mode 100644 index 000000000..a945c8c33 --- /dev/null +++ b/Mods/ContentPatcher/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/ContentPatcher/Common/Integrations/LineSprinklers/LineSprinklersIntegration.cs b/Mods/ContentPatcher/Common/Integrations/LineSprinklers/LineSprinklersIntegration.cs new file mode 100644 index 000000000..d5aa4fce5 --- /dev/null +++ b/Mods/ContentPatcher/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/ContentPatcher/Common/Integrations/PelicanFiber/PelicanFiberIntegration.cs b/Mods/ContentPatcher/Common/Integrations/PelicanFiber/PelicanFiberIntegration.cs new file mode 100644 index 000000000..f90cfb749 --- /dev/null +++ b/Mods/ContentPatcher/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/ContentPatcher/Common/Integrations/PrismaticTools/IPrismaticToolsApi.cs b/Mods/ContentPatcher/Common/Integrations/PrismaticTools/IPrismaticToolsApi.cs new file mode 100644 index 000000000..b2a61ed32 --- /dev/null +++ b/Mods/ContentPatcher/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/ContentPatcher/Common/Integrations/PrismaticTools/PrismaticToolsIntegration.cs b/Mods/ContentPatcher/Common/Integrations/PrismaticTools/PrismaticToolsIntegration.cs new file mode 100644 index 000000000..b35e6f359 --- /dev/null +++ b/Mods/ContentPatcher/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/ContentPatcher/Common/Integrations/SimpleSprinkler/ISimplerSprinklerApi.cs b/Mods/ContentPatcher/Common/Integrations/SimpleSprinkler/ISimplerSprinklerApi.cs new file mode 100644 index 000000000..68d8e05a7 --- /dev/null +++ b/Mods/ContentPatcher/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/ContentPatcher/Common/Integrations/SimpleSprinkler/SimpleSprinklerIntegration.cs b/Mods/ContentPatcher/Common/Integrations/SimpleSprinkler/SimpleSprinklerIntegration.cs new file mode 100644 index 000000000..ef21dd310 --- /dev/null +++ b/Mods/ContentPatcher/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/ContentPatcher/Common/PathUtilities.cs b/Mods/ContentPatcher/Common/PathUtilities.cs new file mode 100644 index 000000000..40b174f02 --- /dev/null +++ b/Mods/ContentPatcher/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/ContentPatcher/Common/SpriteInfo.cs b/Mods/ContentPatcher/Common/SpriteInfo.cs new file mode 100644 index 000000000..b7c3be5e2 --- /dev/null +++ b/Mods/ContentPatcher/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/ContentPatcher/Common/StringEnumArrayConverter.cs b/Mods/ContentPatcher/Common/StringEnumArrayConverter.cs new file mode 100644 index 000000000..29e781674 --- /dev/null +++ b/Mods/ContentPatcher/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/ContentPatcher/Common/TileHelper.cs b/Mods/ContentPatcher/Common/TileHelper.cs new file mode 100644 index 000000000..c96aeb92c --- /dev/null +++ b/Mods/ContentPatcher/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/ContentPatcher/Common/UI/BaseOverlay.cs b/Mods/ContentPatcher/Common/UI/BaseOverlay.cs new file mode 100644 index 000000000..4b515ec50 --- /dev/null +++ b/Mods/ContentPatcher/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/ContentPatcher/Common/UI/CommonSprites.cs b/Mods/ContentPatcher/Common/UI/CommonSprites.cs new file mode 100644 index 000000000..3da68991e --- /dev/null +++ b/Mods/ContentPatcher/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/ContentPatcher/Common/Utilities/ConstraintSet.cs b/Mods/ContentPatcher/Common/Utilities/ConstraintSet.cs new file mode 100644 index 000000000..98cf678ec --- /dev/null +++ b/Mods/ContentPatcher/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/ContentPatcher/Common/Utilities/InvariantDictionary.cs b/Mods/ContentPatcher/Common/Utilities/InvariantDictionary.cs new file mode 100644 index 000000000..4bad98e7f --- /dev/null +++ b/Mods/ContentPatcher/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/ContentPatcher/Common/Utilities/InvariantHashSet.cs b/Mods/ContentPatcher/Common/Utilities/InvariantHashSet.cs new file mode 100644 index 000000000..6f0530d84 --- /dev/null +++ b/Mods/ContentPatcher/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/ContentPatcher/Common/Utilities/ObjectReferenceComparer.cs b/Mods/ContentPatcher/Common/Utilities/ObjectReferenceComparer.cs new file mode 100644 index 000000000..020ebfadd --- /dev/null +++ b/Mods/ContentPatcher/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/ContentPatcher/ContentPatcher.csproj b/Mods/ContentPatcher/ContentPatcher.csproj new file mode 100644 index 000000000..9bad46ead --- /dev/null +++ b/Mods/ContentPatcher/ContentPatcher.csproj @@ -0,0 +1,271 @@ + + + + + Debug + AnyCPU + {8E1D56B0-D640-4EB0-A703-E280C40A655D} + Library + Properties + ContentPatcher + ContentPatcher + v4.5.2 + 512 + true + + + true + full + false + bin\Debug\ + DEBUG;TRACE + prompt + 7.2 + 4 + + + pdbonly + true + bin\Release\ + TRACE + prompt + 4 + 7.2 + + + + ..\assemblies\StardewModdingAPI.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/ContentPatcher/Framework/CaseInsensitiveExtensions.cs b/Mods/ContentPatcher/Framework/CaseInsensitiveExtensions.cs new file mode 100644 index 000000000..11dee20c2 --- /dev/null +++ b/Mods/ContentPatcher/Framework/CaseInsensitiveExtensions.cs @@ -0,0 +1,52 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace ContentPatcher.Framework +{ + /// Provides case-insensitive extension methods. + internal static class CaseInsensitiveExtensions + { + /********* + ** Public methods + *********/ + /// Get the set difference of two sequences, using the invariant culture and ignoring case. + /// The first sequence to compare. + /// The second sequence to compare. + /// or is . + public static IEnumerable ExceptIgnoreCase(this IEnumerable source, IEnumerable other) + { + return source.Except(other, StringComparer.InvariantCultureIgnoreCase); + } + + /// Group the elements of a sequence according to a specified key selector function, comparing the keys using the invariant culture and ignoring case. + /// The type of the elements of . + /// The sequence whose elements to group. + /// A function to extract the key for each element. + /// or is . + public static IEnumerable> GroupByIgnoreCase(this IEnumerable source, Func keySelector) + { + return source.GroupBy(keySelector, StringComparer.InvariantCultureIgnoreCase); + } + + /// Sort the elements of a sequence in ascending order by using a specified comparer. + /// The type of the elements of . + /// A sequence of values to order. + /// A function to extract a key from an element. + /// or is . + public static IOrderedEnumerable OrderByIgnoreCase(this IEnumerable source, Func keySelector) + { + return source.OrderBy(keySelector, StringComparer.InvariantCultureIgnoreCase); + } + + /// Perform a subsequent ordering of the elements in a sequence in ascending order according to a key. + /// The type of the elements of . + /// The sequence whose elements to group. + /// A function to extract the key for each element. + /// or is . + public static IOrderedEnumerable ThenByIgnoreCase(this IOrderedEnumerable source, Func keySelector) + { + return source.ThenBy(keySelector, StringComparer.InvariantCultureIgnoreCase); + } + } +} diff --git a/Mods/ContentPatcher/Framework/Commands/CommandHandler.cs b/Mods/ContentPatcher/Framework/Commands/CommandHandler.cs new file mode 100644 index 000000000..5bfa45012 --- /dev/null +++ b/Mods/ContentPatcher/Framework/Commands/CommandHandler.cs @@ -0,0 +1,356 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Text.RegularExpressions; +using ContentPatcher.Framework.Conditions; +using ContentPatcher.Framework.Patches; +using ContentPatcher.Framework.Tokens; +using Pathoschild.Stardew.Common.Utilities; +using StardewModdingAPI; + +namespace ContentPatcher.Framework.Commands +{ + /// Handles the 'patch' console command. + internal class CommandHandler + { + /********* + ** Fields + *********/ + /// Encapsulates monitoring and logging. + private readonly IMonitor Monitor; + + /// Manages loaded tokens. + private readonly TokenManager TokenManager; + + /// Manages loaded patches. + private readonly PatchManager PatchManager; + + /// A callback which immediately updates the current condition context. + private readonly Action UpdateContext; + + /// A regex pattern matching asset names which incorrectly include the Content folder. + private readonly Regex AssetNameWithContentPattern = new Regex(@"^Content[/\\]", RegexOptions.Compiled | RegexOptions.IgnoreCase); + + /// A regex pattern matching asset names which incorrectly include an extension. + private readonly Regex AssetNameWithExtensionPattern = new Regex(@"(\.\w+)$", RegexOptions.Compiled | RegexOptions.IgnoreCase); + + /// A regex pattern matching asset names which incorrectly include the locale code. + private readonly Regex AssetNameWithLocalePattern = new Regex(@"^\.(?:de-DE|es-ES|ja-JP|pt-BR|ru-RU|zh-CN)(?:\.xnb)?$", RegexOptions.Compiled | RegexOptions.IgnoreCase); + + + /********* + ** Accessors + *********/ + /// The name of the root command. + public string CommandName { get; } = "patch"; + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// Manages loaded tokens. + /// Manages loaded patches. + /// Encapsulates monitoring and logging. + /// A callback which immediately updates the current condition context. + public CommandHandler(TokenManager tokenManager, PatchManager patchManager, IMonitor monitor, Action updateContext) + { + this.TokenManager = tokenManager; + this.PatchManager = patchManager; + this.Monitor = monitor; + this.UpdateContext = updateContext; + } + + /// Handle a console command. + /// The command arguments. + /// Returns whether the command was handled. + public bool Handle(string[] args) + { + string subcommand = args.FirstOrDefault(); + string[] subcommandArgs = args.Skip(1).ToArray(); + + switch (subcommand?.ToLower()) + { + case null: + case "help": + return this.HandleHelp(subcommandArgs); + + case "summary": + return this.HandleSummary(); + + case "update": + return this.HandleUpdate(); + + default: + this.Monitor.Log($"The '{this.CommandName} {args[0]}' command isn't valid. Type '{this.CommandName} help' for a list of valid commands."); + return false; + } + } + + + /********* + ** Private methods + *********/ + /**** + ** Commands + ****/ + /// Handle the 'patch help' command. + /// The subcommand arguments. + /// Returns whether the command was handled. + private bool HandleHelp(string[] args) + { + // generate command info + var helpEntries = new InvariantDictionary + { + ["help"] = $"{this.CommandName} help\n Usage: {this.CommandName} help\n Lists all available {this.CommandName} commands.\n\n Usage: {this.CommandName} help \n Provides information for a specific {this.CommandName} command.\n - cmd: The {this.CommandName} command name.", + ["summary"] = $"{this.CommandName} summary\n Usage: {this.CommandName} summary\n Shows a summary of the current conditions and loaded patches.", + ["update"] = $"{this.CommandName} update\n Usage: {this.CommandName} update\n Imediately refreshes the condition context and rechecks all patches." + }; + + // build output + StringBuilder help = new StringBuilder(); + if (!args.Any()) + { + help.AppendLine( + $"The '{this.CommandName}' command is the entry point for Content Patcher commands. These are " + + "intended for troubleshooting and aren't intended for players. You use it by specifying a more " + + $"specific command (like 'help' in '{this.CommandName} help'). Here are the available commands:\n\n" + ); + foreach (var entry in helpEntries.OrderByIgnoreCase(p => p.Key)) + { + help.AppendLine(entry.Value); + help.AppendLine(); + } + } + else if (helpEntries.TryGetValue(args[0], out string entry)) + help.AppendLine(entry); + else + help.AppendLine($"Unknown command '{this.CommandName} {args[0]}'. Type '{this.CommandName} help' for available commands."); + + // write output + this.Monitor.Log(help.ToString()); + + return true; + } + + /// Handle the 'patch summary' command. + /// Returns whether the command was handled. + private bool HandleSummary() + { + StringBuilder output = new StringBuilder(); + + // add condition summary + output.AppendLine(); + output.AppendLine("====================="); + output.AppendLine("== Global tokens =="); + output.AppendLine("====================="); + { + // get data + IToken[] tokens = + ( + from token in this.TokenManager.GetTokens(enforceContext: false) + let subkeys = token.GetSubkeys().ToArray() + let rootValues = !token.RequiresSubkeys ? token.GetValues(token.Name).ToArray() : new string[0] + let multiValue = + subkeys.Length > 1 + || rootValues.Length > 1 + || (subkeys.Length == 1 && token.GetValues(subkeys[0]).Count() > 1) + orderby multiValue, token.Name.Key // single-value tokens first, then alphabetically + select token + ) + .ToArray(); + int labelWidth = tokens.Max(p => p.Name.Key.Length); + + // print table header + output.AppendLine($" {"token name".PadRight(labelWidth)} | value"); + output.AppendLine($" {"".PadRight(labelWidth, '-')} | -----"); + + // print tokens + foreach (IToken token in tokens) + { + output.Append($" {token.Name.Key.PadRight(labelWidth)} | "); + + if (!token.IsValidInContext) + output.AppendLine("[ ] n/a"); + else if (token.RequiresSubkeys) + { + bool isFirst = true; + foreach (TokenName name in token.GetSubkeys().OrderByIgnoreCase(key => key.Subkey)) + { + if (isFirst) + { + output.Append("[X] "); + isFirst = false; + } + else + output.Append($" {"".PadRight(labelWidth, ' ')} | "); + output.AppendLine($":{name.Subkey}: {string.Join(", ", token.GetValues(name))}"); + } + } + else + output.AppendLine("[X] " + string.Join(", ", token.GetValues(token.Name).OrderByIgnoreCase(p => p))); + } + } + output.AppendLine(); + + // add patch summary + var patches = this.GetAllPatches() + .GroupByIgnoreCase(p => p.ContentPack.Manifest.Name) + .OrderByIgnoreCase(p => p.Key); + + output.AppendLine( + "=====================\n" + + "== Content patches ==\n" + + "=====================\n" + + "The following patches were loaded. For each patch:\n" + + " - 'loaded' shows whether the patch is loaded and enabled (see details for the reason if not).\n" + + " - 'conditions' shows whether the patch matches with the current conditions (see details for the reason if not). If this is unexpectedly false, check (a) the conditions above and (b) your Where field.\n" + + " - 'applied' shows whether the target asset was loaded and patched. If you expected it to be loaded by this point but it's false, double-check (a) that the game has actually loaded the asset yet, and (b) your Targets field is correct.\n" + + "\n" + ); + foreach (IGrouping patchGroup in patches) + { + ModTokenContext tokenContext = this.TokenManager.TrackLocalTokens(patchGroup.First().ContentPack.Pack); + output.AppendLine($"{patchGroup.Key}:"); + output.AppendLine("".PadRight(patchGroup.Key.Length + 1, '-')); + + // print tokens + { + IToken[] localTokens = tokenContext + .GetTokens(localOnly: true, enforceContext: false) + .Where(p => p.Name.Key != ConditionType.HasFile.ToString()) // no value to display + .ToArray(); + if (localTokens.Any()) + { + output.AppendLine(); + output.AppendLine(" Local tokens:"); + foreach (IToken token in localTokens.OrderBy(p => p.Name)) + { + if (token.RequiresSubkeys) + { + foreach (TokenName name in token.GetSubkeys().OrderBy(p => p)) + output.AppendLine($" {name}: {string.Join(", ", token.GetValues(name))}"); + } + else + output.AppendLine($" {token.Name}: {string.Join(", ", token.GetValues(token.Name))}"); + } + } + } + + // print patches + output.AppendLine(); + output.AppendLine(" loaded | conditions | applied | name + details"); + output.AppendLine(" ------- | ---------- | ------- | --------------"); + foreach (PatchInfo patch in patchGroup.OrderByIgnoreCase(p => p.ShortName)) + { + // log checkbox and patch name + output.Append($" [{(patch.IsLoaded ? "X" : " ")}] | [{(patch.MatchesContext ? "X" : " ")}] | [{(patch.IsApplied ? "X" : " ")}] | {patch.ShortName}"); + + // log raw target (if not in name) + if (!patch.ShortName.Contains($"{patch.Type} {patch.RawTargetAsset}")) + output.Append($" | {patch.Type} {patch.RawTargetAsset}"); + + // log parsed target if tokenised + if (patch.MatchesContext && patch.ParsedTargetAsset != null && patch.ParsedTargetAsset.Tokens.Any()) + output.Append($" | => {patch.ParsedTargetAsset.Value}"); + + // log reason not applied + string errorReason = this.GetReasonNotLoaded(patch, tokenContext); + if (errorReason != null) + output.Append($" // {errorReason}"); + + // log common issues + if (errorReason == null && patch.IsLoaded && !patch.IsApplied && patch.ParsedTargetAsset?.Value != null) + { + string assetName = patch.ParsedTargetAsset.Value; + + List issues = new List(); + if (this.AssetNameWithContentPattern.IsMatch(assetName)) + issues.Add("shouldn't include 'Content/' prefix"); + if (this.AssetNameWithExtensionPattern.IsMatch(assetName)) + { + var match = this.AssetNameWithExtensionPattern.Match(assetName); + issues.Add($"shouldn't include '{match.Captures[0]}' extension"); + } + if (this.AssetNameWithLocalePattern.IsMatch(assetName)) + issues.Add("shouldn't include language code (use conditions instead)"); + + if (issues.Any()) + output.Append($" | hint: asset name may be incorrect ({string.Join("; ", issues)})."); + } + + // end line + output.AppendLine(); + } + output.AppendLine(); // blank line between groups + } + + this.Monitor.Log(output.ToString()); + return true; + } + + /// Handle the 'patch update' command. + /// Returns whether the command was handled. + private bool HandleUpdate() + { + this.UpdateContext(); + return true; + } + + + /**** + ** Helpers + ****/ + /// Get basic info about all patches, including those which couldn't be loaded. + public IEnumerable GetAllPatches() + { + foreach (IPatch patch in this.PatchManager.GetPatches()) + yield return new PatchInfo(patch); + foreach (DisabledPatch patch in this.PatchManager.GetPermanentlyDisabledPatches()) + yield return new PatchInfo(patch); + } + + /// Get a human-readable reason that the patch isn't applied. + /// The patch to check. + /// The token context for the content pack. + private string GetReasonNotLoaded(PatchInfo patch, IContext tokenContext) + { + if (patch.IsApplied) + return null; + + // load error + if (!patch.IsLoaded) + return $"not loaded: {patch.ReasonDisabled}"; + + // uses tokens not available in the current context + { + IList tokensOutOfContext = patch + .TokensUsed + .Union(patch.ParsedConditions.Keys) + .Where(p => !tokenContext.GetToken(p, enforceContext: false).IsValidInContext) + .OrderByIgnoreCase(p => p.ToString()) + .ToArray(); + + if (tokensOutOfContext.Any()) + return $"uses tokens not available right now: {string.Join(", ", tokensOutOfContext)}"; + } + + // conditions not matched + if (!patch.MatchesContext && patch.ParsedConditions != null) + { + string[] failedConditions = ( + from condition in patch.ParsedConditions.Values + orderby condition.Name.ToString() + where !condition.IsMatch(tokenContext) + select $"{condition.Name} ({string.Join(", ", condition.Values)})" + ).ToArray(); + + if (failedConditions.Any()) + return $"conditions don't match: {string.Join(", ", failedConditions)}"; + } + + return null; + } + } +} diff --git a/Mods/ContentPatcher/Framework/Commands/PatchInfo.cs b/Mods/ContentPatcher/Framework/Commands/PatchInfo.cs new file mode 100644 index 000000000..05babf5ef --- /dev/null +++ b/Mods/ContentPatcher/Framework/Commands/PatchInfo.cs @@ -0,0 +1,99 @@ +using System.Linq; +using ContentPatcher.Framework.Conditions; +using ContentPatcher.Framework.Patches; +using ContentPatcher.Framework.Tokens; + +namespace ContentPatcher.Framework.Commands +{ + /// A summary of patch info shown in the SMAPI console. + internal class PatchInfo + { + /********* + ** Accessors + *********/ + /// The patch name shown in log messages, without the content pack prefix. + public string ShortName { get; } + + /// The patch type. + public string Type { get; } + + /// The asset name to intercept. + public string RawTargetAsset { get; } + + /// The parsed asset name (if available). + public TokenString ParsedTargetAsset { get; } + + /// The parsed conditions (if available). + public ConditionDictionary ParsedConditions { get; } + + /// The content pack which requested the patch. + public ManagedContentPack ContentPack { get; } + + /// Whether the patch is loaded. + public bool IsLoaded { get; } + + /// Whether the patch should be applied in the current context. + public bool MatchesContext { get; } + + /// Whether the patch is currently applied. + public bool IsApplied { get; } + + /// The reason this patch is disabled (if applicable). + public string ReasonDisabled { get; } + + /// The tokens used by this patch in its fields. + public TokenName[] TokensUsed { get; } + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The patch to represent. + public PatchInfo(DisabledPatch patch) + { + this.ShortName = this.GetShortName(patch.ContentPack, patch.LogName); + this.Type = patch.Type; + this.RawTargetAsset = patch.AssetName; + this.ParsedTargetAsset = null; + this.ParsedConditions = null; + this.ContentPack = patch.ContentPack; + this.IsLoaded = false; + this.MatchesContext = false; + this.IsApplied = false; + this.ReasonDisabled = patch.ReasonDisabled; + this.TokensUsed = new TokenName[0]; + } + + /// Construct an instance. + /// The patch to represent. + public PatchInfo(IPatch patch) + { + this.ShortName = this.GetShortName(patch.ContentPack, patch.LogName); + this.Type = patch.Type.ToString(); + this.RawTargetAsset = patch.RawTargetAsset.Raw; + this.ParsedTargetAsset = patch.RawTargetAsset; + this.ParsedConditions = patch.Conditions; + this.ContentPack = patch.ContentPack; + this.IsLoaded = true; + this.MatchesContext = patch.MatchesContext; + this.IsApplied = patch.IsApplied; + this.TokensUsed = patch.GetTokensUsed().ToArray(); + } + + + /********* + ** Private methods + *********/ + /// Get the patch name shown in log messages, without the content pack prefix. + /// The content pack which requested the patch. + /// The unique patch name shown in log messages. + private string GetShortName(ManagedContentPack contentPack, string logName) + { + string prefix = contentPack.Manifest.Name + " > "; + return logName.StartsWith(prefix) + ? logName.Substring(prefix.Length) + : logName; + } + } +} diff --git a/Mods/ContentPatcher/Framework/Conditions/Condition.cs b/Mods/ContentPatcher/Framework/Conditions/Condition.cs new file mode 100644 index 000000000..50347bdf2 --- /dev/null +++ b/Mods/ContentPatcher/Framework/Conditions/Condition.cs @@ -0,0 +1,41 @@ +using System.Linq; +using ContentPatcher.Framework.Tokens; +using Pathoschild.Stardew.Common.Utilities; + +namespace ContentPatcher.Framework.Conditions +{ + /// A condition that can be checked against the token context. + internal class Condition + { + /********* + ** Accessors + *********/ + /// The token name in the context. + public TokenName Name { get; } + + /// The token values for which this condition is valid. + public InvariantHashSet Values { get; } + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The token name in the context. + /// The token values for which this condition is valid. + public Condition(TokenName name, InvariantHashSet values) + { + this.Name = name; + this.Values = values; + } + + /// Whether the condition matches. + /// The condition context. + public bool IsMatch(IContext context) + { + return context + .GetValues(this.Name, enforceContext: true) + .Any(value => this.Values.Contains(value)); + } + } +} diff --git a/Mods/ContentPatcher/Framework/Conditions/ConditionDictionary.cs b/Mods/ContentPatcher/Framework/Conditions/ConditionDictionary.cs new file mode 100644 index 000000000..e882239c4 --- /dev/null +++ b/Mods/ContentPatcher/Framework/Conditions/ConditionDictionary.cs @@ -0,0 +1,21 @@ +using System.Collections.Generic; +using ContentPatcher.Framework.Tokens; +using Pathoschild.Stardew.Common.Utilities; + +namespace ContentPatcher.Framework.Conditions +{ + /// A set of conditions that can be checked against the context. + internal class ConditionDictionary : Dictionary + { + /********* + ** Public methods + *********/ + /// Add an element with the given key and condition values. + /// The token name to add. + /// The token values to match. + public void Add(TokenName name, IEnumerable values) + { + this.Add(name, new Condition(name, new InvariantHashSet(values))); + } + } +} diff --git a/Mods/ContentPatcher/Framework/Conditions/ConditionType.cs b/Mods/ContentPatcher/Framework/Conditions/ConditionType.cs new file mode 100644 index 000000000..54f6fa7ad --- /dev/null +++ b/Mods/ContentPatcher/Framework/Conditions/ConditionType.cs @@ -0,0 +1,93 @@ +namespace ContentPatcher.Framework.Conditions +{ + /// The condition types that can be checked. + internal enum ConditionType + { + /**** + ** Tokenisable basic conditions + ****/ + /// The day of month. + Day, + + /// The name. + DayOfWeek, + + /// The total number of days played in the current save. + DaysPlayed, + + /// The farm cave type. + FarmCave, + + /// The upgrade level for the main farmhouse. + FarmhouseUpgrade, + + /// The current farm name. + FarmName, + + /// The current farm type. + FarmType, + + /// The name. + Language, + + /// The name of the current player. + PlayerName, + + /// The gender of the current player. + PlayerGender, + + /// The preferred pet selected by the player. + PreferredPet, + + /// The season name. + Season, + + /// The current weather. + Weather, + + /// The current year number. + Year, + + /**** + ** Other basic conditions + ****/ + /// The name of today's festival (if any), or 'wedding' if the current player is getting married. + DayEvent, + + /// A letter ID or mail flag set for the player. + HasFlag, + + /// An installed mod ID. + HasMod, + + /// A profession ID the player has. + HasProfession, + + /// An event ID the player saw. + HasSeenEvent, + + /// The special items in the player's wallet. + HasWalletItem, + + /// The current player's internal spouse name (if any). + Spouse, + + /**** + ** Multi-part conditions + ****/ + /// The current player's number of hearts with the character. + Hearts, + + /// The current player's relationship status with the character (matching ) + Relationship, + + /// The current player's level for a skill. + SkillLevel, + + /**** + ** Magic conditions + ****/ + /// Whether a file exists in the content pack's folder. + HasFile + }; +} diff --git a/Mods/ContentPatcher/Framework/Conditions/PatchType.cs b/Mods/ContentPatcher/Framework/Conditions/PatchType.cs new file mode 100644 index 000000000..4ddfa106a --- /dev/null +++ b/Mods/ContentPatcher/Framework/Conditions/PatchType.cs @@ -0,0 +1,15 @@ +namespace ContentPatcher.Framework.Conditions +{ + /// The patch type. + internal enum PatchType + { + /// Load the initial version of the file. + Load, + + /// Edit an image. + EditImage, + + /// Edit a data file. + EditData + } +} diff --git a/Mods/ContentPatcher/Framework/Conditions/TokenString.cs b/Mods/ContentPatcher/Framework/Conditions/TokenString.cs new file mode 100644 index 000000000..16adbe2c6 --- /dev/null +++ b/Mods/ContentPatcher/Framework/Conditions/TokenString.cs @@ -0,0 +1,144 @@ +using System.Collections.Generic; +using System.Linq; +using System.Text; +using ContentPatcher.Framework.Lexing; +using ContentPatcher.Framework.Lexing.LexTokens; +using ContentPatcher.Framework.Tokens; +using Pathoschild.Stardew.Common.Utilities; + +namespace ContentPatcher.Framework.Conditions +{ + /// A string value which can contain condition tokens. + internal class TokenString + { + /********* + ** Fields + *********/ + /// The lexical tokens parsed from the raw string. + private readonly ILexToken[] LexTokens; + + /// The underlying value for . + private string ValueImpl; + + /// The underlying value for . + private bool IsReadyImpl; + + + /********* + ** Accessors + *********/ + /// The raw string without token substitution. + public string Raw { get; } + + /// The tokens used in the string. + public HashSet Tokens { get; } = new HashSet(); + + /// The unrecognised tokens in the string. + public InvariantHashSet InvalidTokens { get; } = new InvariantHashSet(); + + /// Whether the string contains any tokens (including invalid tokens). + public bool HasAnyTokens => this.Tokens.Count > 0 || this.InvalidTokens.Count > 0; + + /// Whether the token string value may change depending on the context. + public bool IsMutable { get; } + + /// Whether the token string consists of a single token with no surrounding text. + public bool IsSingleTokenOnly { get; } + + /// The string with tokens substituted for the last context update. + public string Value => this.ValueImpl; + + /// Whether all tokens in the value have been replaced. + public bool IsReady => this.IsReadyImpl; + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The raw string before token substitution. + /// The available token context. + public TokenString(string raw, IContext tokenContext) + { + // set raw value + this.Raw = raw?.Trim(); + if (string.IsNullOrWhiteSpace(this.Raw)) + { + this.ValueImpl = this.Raw; + this.IsReadyImpl = true; + return; + } + + // extract tokens + this.LexTokens = new Lexer().ParseBits(raw, impliedBraces: false).ToArray(); + foreach (LexTokenToken token in this.LexTokens.OfType()) + { + TokenName name = new TokenName(token.Name, token.InputArg?.Text); + if (tokenContext.Contains(name, enforceContext: false)) + this.Tokens.Add(name); + else + this.InvalidTokens.Add(token.Text); + } + + // set metadata + this.IsMutable = this.Tokens.Any(); + if (!this.IsMutable) + { + this.ValueImpl = this.Raw; + this.IsReadyImpl = !this.InvalidTokens.Any(); + } + this.IsSingleTokenOnly = this.LexTokens.Length == 1 && this.LexTokens.First().Type == LexTokenType.Token; + } + + /// Update the with the given tokens. + /// Provides access to contextual tokens. + /// Returns whether the value changed. + public bool UpdateContext(IContext context) + { + if (!this.IsMutable) + return false; + + string prevValue = this.Value; + this.GetApplied(context, out this.ValueImpl, out this.IsReadyImpl); + return this.Value != prevValue; + } + + + /********* + ** Private methods + *********/ + /// Get a new string with tokens substituted. + /// Provides access to contextual tokens. + /// The input string with tokens substituted. + /// Whether all tokens in the have been replaced. + private void GetApplied(IContext context, out string result, out bool isReady) + { + bool allReplaced = true; + StringBuilder str = new StringBuilder(); + foreach (ILexToken lexToken in this.LexTokens) + { + switch (lexToken) + { + case LexTokenToken lexTokenToken: + TokenName name = new TokenName(lexTokenToken.Name, lexTokenToken.InputArg?.Text); + IToken token = context.GetToken(name, enforceContext: true); + if (token != null) + str.Append(token.GetValues(name).FirstOrDefault()); + else + { + allReplaced = false; + str.Append(lexToken.Text); + } + break; + + default: + str.Append(lexToken.Text); + break; + } + } + + result = str.ToString(); + isReady = allReplaced; + } + } +} diff --git a/Mods/ContentPatcher/Framework/Conditions/Weather.cs b/Mods/ContentPatcher/Framework/Conditions/Weather.cs new file mode 100644 index 000000000..028a3cfda --- /dev/null +++ b/Mods/ContentPatcher/Framework/Conditions/Weather.cs @@ -0,0 +1,21 @@ +namespace ContentPatcher.Framework.Conditions +{ + /// An in-game weather. + internal enum Weather + { + /// The weather is sunny (including festival/wedding days). This is the default weather if no other value applies. + Sun, + + /// Rain is falling, but without lightning. + Rain, + + /// Rain is falling with lightning. + Storm, + + /// Snow is falling. + Snow, + + /// The wind is blowing with visible debris (e.g. flower petals in spring and leaves in fall). + Wind + } +} diff --git a/Mods/ContentPatcher/Framework/ConfigFileHandler.cs b/Mods/ContentPatcher/Framework/ConfigFileHandler.cs new file mode 100644 index 000000000..1763ebcb9 --- /dev/null +++ b/Mods/ContentPatcher/Framework/ConfigFileHandler.cs @@ -0,0 +1,206 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using ContentPatcher.Framework.Conditions; +using ContentPatcher.Framework.ConfigModels; +using ContentPatcher.Framework.Tokens; +using Pathoschild.Stardew.Common.Utilities; +using StardewModdingAPI; + +namespace ContentPatcher.Framework +{ + /// Handles the logic for reading, normalising, and saving the configuration for a content pack. + internal class ConfigFileHandler + { + /********* + ** Fields + *********/ + /// The name of the config file. + private readonly string Filename; + + /// Parse a comma-delimited set of case-insensitive condition values. + private readonly Func ParseCommaDelimitedField; + + /// A callback to invoke when a validation warning occurs. This is passed the content pack, label, and reason phrase respectively. + private readonly Action LogWarning; + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The name of the config file. + /// Parse a comma-delimited set of case-insensitive condition values. + /// A callback to invoke when a validation warning occurs. This is passed the content pack, label, and reason phrase respectively. + public ConfigFileHandler(string filename, Func parseCommandDelimitedField, Action logWarning) + { + this.Filename = filename; + this.ParseCommaDelimitedField = parseCommandDelimitedField; + this.LogWarning = logWarning; + } + + /// Read the configuration file for a content pack. + /// The content pack. + /// The raw config schema from the mod's content.json. + public InvariantDictionary Read(ManagedContentPack contentPack, InvariantDictionary rawSchema) + { + InvariantDictionary config = this.LoadConfigSchema(rawSchema, logWarning: (field, reason) => this.LogWarning(contentPack, $"{nameof(ContentConfig.ConfigSchema)} field '{field}'", reason)); + this.LoadConfigValues(contentPack, config, logWarning: (field, reason) => this.LogWarning(contentPack, $"{this.Filename} > {field}", reason)); + return config; + } + + /// Save the configuration file for a content pack. + /// The content pack. + /// The configuration to save. + /// The mod helper through which to save the file. + public void Save(ManagedContentPack contentPack, InvariantDictionary config, IModHelper modHelper) + { + // save if settings valid + if (config.Any()) + { + InvariantDictionary data = new InvariantDictionary(config.ToDictionary(p => p.Key, p => string.Join(", ", p.Value.Value))); + contentPack.WriteJsonFile(this.Filename, data); + } + + // delete if no settings + else + { + FileInfo file = new FileInfo(Path.Combine(contentPack.GetFullPath(this.Filename))); + if (file.Exists) + file.Delete(); + } + } + + + /********* + ** Private methods + *********/ + /// Parse a raw config schema for a content pack. + /// The raw config schema. + /// The callback to invoke on each validation warning, passed the field name and reason respectively. + private InvariantDictionary LoadConfigSchema(InvariantDictionary rawSchema, Action logWarning) + { + InvariantDictionary schema = new InvariantDictionary(); + if (rawSchema == null || !rawSchema.Any()) + return schema; + + foreach (string rawKey in rawSchema.Keys) + { + ConfigSchemaFieldConfig field = rawSchema[rawKey]; + + // validate format + if (!TokenName.TryParse(rawKey, out TokenName name)) + { + logWarning(rawKey, $"the name '{rawKey}' is not in a valid format."); + continue; + } + if (name.HasSubkey()) + { + logWarning(rawKey, $"the name '{rawKey}' can't have a subkey (:)."); + continue; + } + + // validate reserved keys + if (name.TryGetConditionType(out ConditionType _)) + { + logWarning(rawKey, $"can't use {name.Key} as a config field, because it's a reserved condition key."); + continue; + } + + // read allowed values + InvariantHashSet allowValues = this.ParseCommaDelimitedField(field.AllowValues); + if (!allowValues.Any()) + { + logWarning(rawKey, $"no {nameof(ConfigSchemaFieldConfig.AllowValues)} specified."); + continue; + } + + // read default values + InvariantHashSet defaultValues = this.ParseCommaDelimitedField(field.Default); + { + // inject default + if (!defaultValues.Any() && !field.AllowBlank) + defaultValues = new InvariantHashSet(allowValues.First()); + + // validate values + string[] invalidValues = defaultValues.ExceptIgnoreCase(allowValues).ToArray(); + if (invalidValues.Any()) + { + logWarning(rawKey, $"default values '{string.Join(", ", invalidValues)}' are not allowed according to {nameof(ConfigSchemaFieldConfig.AllowBlank)}."); + continue; + } + + // validate allow multiple + if (!field.AllowMultiple && defaultValues.Count > 1) + { + logWarning(rawKey, $"can't have multiple default values because {nameof(ConfigSchemaFieldConfig.AllowMultiple)} is false."); + continue; + } + } + + // add to schema + schema[rawKey] = new ConfigField(allowValues, defaultValues, field.AllowBlank, field.AllowMultiple); + } + + return schema; + } + + /// Load config values from the content pack. + /// The content pack whose config file to read. + /// The config schema. + /// The callback to invoke on each validation warning, passed the field name and reason respectively. + private void LoadConfigValues(ManagedContentPack contentPack, InvariantDictionary config, Action logWarning) + { + if (!config.Any()) + return; + + // read raw config + InvariantDictionary configValues = new InvariantDictionary( + from entry in (contentPack.ReadJsonFile>(this.Filename) ?? new InvariantDictionary()) + let key = entry.Key.Trim() + let value = this.ParseCommaDelimitedField(entry.Value) + select new KeyValuePair(key, value) + ); + + // remove invalid values + foreach (string key in configValues.Keys.ExceptIgnoreCase(config.Keys).ToArray()) + { + logWarning(key, "no such field supported by this content pack."); + configValues.Remove(key); + } + + // inject default values + foreach (string key in config.Keys) + { + ConfigField field = config[key]; + if (!configValues.TryGetValue(key, out InvariantHashSet values) || (!field.AllowBlank && !values.Any())) + configValues[key] = field.DefaultValues; + } + + // parse each field + foreach (string key in config.Keys) + { + // set value + ConfigField field = config[key]; + field.Value = configValues[key]; + + // validate allow-multiple + if (!field.AllowMultiple && field.Value.Count > 1) + { + logWarning(key, "field only allows a single value."); + field.Value = field.DefaultValues; + continue; + } + + // validate allow-values + string[] invalidValues = field.Value.ExceptIgnoreCase(field.AllowValues).ToArray(); + if (invalidValues.Any()) + { + logWarning(key, $"found invalid values ({string.Join(", ", invalidValues)}), expected: {string.Join(", ", field.AllowValues)}."); + field.Value = field.DefaultValues; + } + } + } + } +} diff --git a/Mods/ContentPatcher/Framework/ConfigModels/ConfigField.cs b/Mods/ContentPatcher/Framework/ConfigModels/ConfigField.cs new file mode 100644 index 000000000..544763c47 --- /dev/null +++ b/Mods/ContentPatcher/Framework/ConfigModels/ConfigField.cs @@ -0,0 +1,43 @@ +using Pathoschild.Stardew.Common.Utilities; + +namespace ContentPatcher.Framework.ConfigModels +{ + /// The parsed schema and value for a field in the config.json file. + internal class ConfigField + { + /********* + ** Accessors + *********/ + /// The values to allow. + public InvariantHashSet AllowValues { get; } + + /// The default values if the field is missing or (if is false) blank. + public InvariantHashSet DefaultValues { get; } + + /// Whether to allow blank values. + public bool AllowBlank { get; } + + /// Whether the player can specify multiple values for this field. + public bool AllowMultiple { get; } + + /// The value read from the player settings. + public InvariantHashSet Value { get; set; } + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The values to allow. + /// The default values if the field is missing or (if is false) blank. + /// Whether to allow blank values. + /// Whether the player can specify multiple values for this field. + public ConfigField(InvariantHashSet allowValues, InvariantHashSet defaultValues, bool allowBlank, bool allowMultiple) + { + this.AllowValues = allowValues; + this.DefaultValues = defaultValues; + this.AllowBlank = allowBlank; + this.AllowMultiple = allowMultiple; + } + } +} diff --git a/Mods/ContentPatcher/Framework/ConfigModels/ConfigSchemaFieldConfig.cs b/Mods/ContentPatcher/Framework/ConfigModels/ConfigSchemaFieldConfig.cs new file mode 100644 index 000000000..197ddf7c5 --- /dev/null +++ b/Mods/ContentPatcher/Framework/ConfigModels/ConfigSchemaFieldConfig.cs @@ -0,0 +1,18 @@ +namespace ContentPatcher.Framework.ConfigModels +{ + /// The schema for a field in the config.json file. + internal class ConfigSchemaFieldConfig + { + /// The comma-delimited values to allow. + public string AllowValues { get; set; } + + /// The default value if the field is missing or (if is false) blank. + public string Default { get; set; } + + /// Whether to allow blank values. + public bool AllowBlank { get; set; } = false; + + /// Whether the player can specify multiple values for this field. + public bool AllowMultiple { get; set; } = false; + } +} diff --git a/Mods/ContentPatcher/Framework/ConfigModels/ContentConfig.cs b/Mods/ContentPatcher/Framework/ConfigModels/ContentConfig.cs new file mode 100644 index 000000000..96b5c9029 --- /dev/null +++ b/Mods/ContentPatcher/Framework/ConfigModels/ContentConfig.cs @@ -0,0 +1,21 @@ +using Pathoschild.Stardew.Common.Utilities; +using StardewModdingAPI; + +namespace ContentPatcher.Framework.ConfigModels +{ + /// The model for a content patch file. + internal class ContentConfig + { + /// The format version. + public ISemanticVersion Format { get; set; } + + /// The user-defined tokens whose values may depend on other tokens. + public DynamicTokenConfig[] DynamicTokens { get; set; } + + /// The changes to make. + public PatchConfig[] Changes { get; set; } + + /// The schema for the config.json file (if any). + public InvariantDictionary ConfigSchema { get; set; } + } +} diff --git a/Mods/ContentPatcher/Framework/ConfigModels/DynamicTokenConfig.cs b/Mods/ContentPatcher/Framework/ConfigModels/DynamicTokenConfig.cs new file mode 100644 index 000000000..197b81d28 --- /dev/null +++ b/Mods/ContentPatcher/Framework/ConfigModels/DynamicTokenConfig.cs @@ -0,0 +1,20 @@ +using Pathoschild.Stardew.Common.Utilities; + +namespace ContentPatcher.Framework.ConfigModels +{ + /// A user-defined token whose value may depend on other tokens. + internal class DynamicTokenConfig + { + /********* + ** Accessors + *********/ + /// The name of the token to set. + public string Name { get; set; } + + /// The value to set. + public string Value { get; set; } + + /// The criteria to apply. See readme for valid values. + public InvariantDictionary When { get; set; } + } +} diff --git a/Mods/ContentPatcher/Framework/ConfigModels/ModConfig.cs b/Mods/ContentPatcher/Framework/ConfigModels/ModConfig.cs new file mode 100644 index 000000000..b3f547280 --- /dev/null +++ b/Mods/ContentPatcher/Framework/ConfigModels/ModConfig.cs @@ -0,0 +1,39 @@ +using Newtonsoft.Json; +using Pathoschild.Stardew.Common; +using StardewModdingAPI; + +namespace ContentPatcher.Framework.ConfigModels +{ + /// The mod configuration. + internal class ModConfig + { + /********* + ** Accessors + *********/ + /// Whether to enable debug features. + public bool EnableDebugFeatures { get; set; } + + /// The control bindings. + public ModConfigControls Controls { get; set; } = new ModConfigControls(); + + + /********* + ** Nested models + *********/ + /// A set of control bindings. + internal class ModConfigControls + { + /// Toggle the display of debug information. + [JsonConverter(typeof(StringEnumArrayConverter))] + public SButton[] ToggleDebug { get; set; } = { SButton.F3 }; + + /// Switch to the previous texture. + [JsonConverter(typeof(StringEnumArrayConverter))] + public SButton[] DebugPrevTexture { get; set; } = { SButton.LeftControl }; + + /// Switch to the next texture. + [JsonConverter(typeof(StringEnumArrayConverter))] + public SButton[] DebugNextTexture { get; set; } = { SButton.RightControl }; + } + } +} diff --git a/Mods/ContentPatcher/Framework/ConfigModels/PatchConfig.cs b/Mods/ContentPatcher/Framework/ConfigModels/PatchConfig.cs new file mode 100644 index 000000000..733585bfa --- /dev/null +++ b/Mods/ContentPatcher/Framework/ConfigModels/PatchConfig.cs @@ -0,0 +1,83 @@ +using System.Collections.Generic; +using Microsoft.Xna.Framework; +using Pathoschild.Stardew.Common.Utilities; + +namespace ContentPatcher.Framework.ConfigModels +{ + /// The input settings for a patch from the configuration file. + internal class PatchConfig + { + /********* + ** Accessors + *********/ + /**** + ** All actions + ****/ + /// A name for this patch shown in log messages. + public string LogName { get; set; } + + /// The patch type to apply. + public string Action { get; set; } + + /// The asset key to change. + public string Target { get; set; } + + /// Whether to apply this patch. + /// This must be a string to support config tokens. + public string Enabled { get; set; } = "true"; + + /// The criteria to apply. See readme for valid values. + public InvariantDictionary When { get; set; } + + /**** + ** Some actions + ****/ + /// The local file to load. + public string FromFile { get; set; } + + /**** + ** EditImage + ****/ + /// The sprite area from which to read an image. + public Rectangle FromArea { get; set; } + + /// The sprite area to overwrite. + public Rectangle ToArea { get; set; } + + /// Indicates how the image should be patched. + public string PatchMode { get; set; } + + /**** + ** EditData + ****/ + /// The data records to edit. + public IDictionary Entries { get; set; } + + /// The individual fields to edit in data records. + public IDictionary> Fields { get; set; } + + + /********* + ** Public methods + *********/ + /// Construct an instance. + public PatchConfig() { } + + /// Construct an instance. + /// The other patch to clone. + public PatchConfig(PatchConfig other) + { + this.LogName = other.LogName; + this.Action = other.Action; + this.Target = other.Target; + this.Enabled = other.Enabled; + this.When = other.When != null ? new InvariantDictionary(other.When) : null; + this.FromFile = other.FromFile; + this.FromArea = other.FromArea; + this.ToArea = other.ToArea; + this.PatchMode = other.PatchMode; + this.Entries = other.Entries != null ? new Dictionary(other.Entries) : null; + this.Fields = other.Fields != null ? new Dictionary>(other.Fields) : null; + } + } +} diff --git a/Mods/ContentPatcher/Framework/Constants/FarmCaveType.cs b/Mods/ContentPatcher/Framework/Constants/FarmCaveType.cs new file mode 100644 index 000000000..d9d5fb74a --- /dev/null +++ b/Mods/ContentPatcher/Framework/Constants/FarmCaveType.cs @@ -0,0 +1,17 @@ +using StardewValley; + +namespace ContentPatcher.Framework.Constants +{ + /// A farm cave type. + internal enum FarmCaveType + { + /// The player hasn't chosen a farm cave yet. + None = Farmer.caveNothing, + + /// The fruit bat cave. + Bats = Farmer.caveBats, + + /// The mushroom cave. + Mushrooms = Farmer.caveMushrooms + } +} diff --git a/Mods/ContentPatcher/Framework/Constants/FarmType.cs b/Mods/ContentPatcher/Framework/Constants/FarmType.cs new file mode 100644 index 000000000..1cd48ec9f --- /dev/null +++ b/Mods/ContentPatcher/Framework/Constants/FarmType.cs @@ -0,0 +1,26 @@ +using StardewValley; + +namespace ContentPatcher.Framework.Constants +{ + /// A farm type. + internal enum FarmType + { + /// The standard farm type. + Standard = 0, + + /// The riverland farm type. + Riverland = 1, + + /// The forest farm type. + Forest = 2, + + /// The hill-top farm type. + Hilltop = 3, + + /// The wilderness farm type. + Wilderness = 4, + + /// A custom farm type. + Custom = 100 + } +} diff --git a/Mods/ContentPatcher/Framework/Constants/Gender.cs b/Mods/ContentPatcher/Framework/Constants/Gender.cs new file mode 100644 index 000000000..1b1a2a75f --- /dev/null +++ b/Mods/ContentPatcher/Framework/Constants/Gender.cs @@ -0,0 +1,12 @@ +namespace ContentPatcher.Framework.Constants +{ + /// A player gender. + internal enum Gender + { + /// The female gender. + Female, + + /// The male gender. + Male + } +} diff --git a/Mods/ContentPatcher/Framework/Constants/PetType.cs b/Mods/ContentPatcher/Framework/Constants/PetType.cs new file mode 100644 index 000000000..c189bd77d --- /dev/null +++ b/Mods/ContentPatcher/Framework/Constants/PetType.cs @@ -0,0 +1,12 @@ +namespace ContentPatcher.Framework.Constants +{ + /// A pet type. + internal enum PetType + { + /// The cat pet. + Cat, + + /// The dog pet. + Dog + } +} diff --git a/Mods/ContentPatcher/Framework/Constants/Profession.cs b/Mods/ContentPatcher/Framework/Constants/Profession.cs new file mode 100644 index 000000000..8191cade3 --- /dev/null +++ b/Mods/ContentPatcher/Framework/Constants/Profession.cs @@ -0,0 +1,113 @@ +using StardewValley; + +namespace ContentPatcher.Framework.Constants +{ + /// A player profession. + internal enum Profession + { + /*** + ** Combat + ***/ + /// The acrobat profession for the combat skill. + Acrobat = Farmer.acrobat, + + /// The brute profession for the combat skill. + Brute = Farmer.brute, + + /// The defender profession for the combat skill. + Defender = Farmer.defender, + + /// The desperado profession for the combat skill. + Desperado = Farmer.desperado, + + /// The fighter profession for the combat skill. + Fighter = Farmer.fighter, + + /// The scout profession for the combat skill. + Scout = Farmer.scout, + + /*** + ** Farming + ***/ + /// The agriculturist profession for the farming skill. + Agriculturist = Farmer.agriculturist, + + /// The shepherd profession for the farming skill. + Artisan = Farmer.artisan, + + /// The coopmaster profession for the farming skill. + Coopmaster = Farmer.butcher, // game's constant name doesn't match usage + + /// The rancher profession for the farming skill. + Rancher = Farmer.rancher, + + /// The shepherd profession for the farming skill. + Shepherd = Farmer.shepherd, + + /// The tiller profession for the farming skill. + Tiller = Farmer.tiller, + + /*** + ** Fishing + ***/ + /// The angler profession for the fishing skill. + Angler = Farmer.angler, + + /// The fisher profession for the fishing skill. + Fisher = Farmer.fisher, + + /// The mariner profession for the fishing skill. + Mariner = Farmer.baitmaster, // game's constant name is confusing + + /// The pirate profession for the fishing skill. + Pirate = Farmer.pirate, + + /// The luremaster profession for the fishing skill. + Luremaster = Farmer.mariner, // game's constant name is confusing + + /// The trapper profession for the fishing skill. + Trapper = Farmer.trapper, + + /*** + ** Foraging + ***/ + /// The botanist profession for the foraging skill. + Botanist = Farmer.botanist, + + /// The forester profession for the foraging skill. + Forester = Farmer.forester, + + /// The gatherer profession for the foraging skill. + Gatherer = Farmer.gatherer, + + /// The lumberjack profession for the foraging skill. + Lumberjack = Farmer.lumberjack, + + /// The tapper profession for the foraging skill. + Tapper = Farmer.tapper, + + /// The tracker profession for the foraging skill. + Tracker = Farmer.tracker, + + /*** + ** Mining + ***/ + /// The blacksmith profession for the foraging skill. + Blacksmith = Farmer.blacksmith, + + /// The excavator profession for the foraging skill. + Excavator = Farmer.excavator, + + /// The gemologist profession for the foraging skill. + Gemologist = Farmer.gemologist, + + /// The geologist profession for the foraging skill. + Geologist = Farmer.geologist, + + /// The miner profession for the foraging skill. + Miner = Farmer.miner, + + /// The prospector profession for the foraging skill. + Prospector = Farmer.burrower, // game's constant name is confusing + } +} diff --git a/Mods/ContentPatcher/Framework/Constants/Skill.cs b/Mods/ContentPatcher/Framework/Constants/Skill.cs new file mode 100644 index 000000000..8d209152b --- /dev/null +++ b/Mods/ContentPatcher/Framework/Constants/Skill.cs @@ -0,0 +1,26 @@ +using StardewValley; + +namespace ContentPatcher.Framework.Constants +{ + /// A player Skill. + internal enum Skill + { + /// The combat skill. + Combat = Farmer.combatSkill, + + /// The farming skill. + Farming = Farmer.farmingSkill, + + /// The fishing skill. + Fishing = Farmer.fishingSkill, + + /// The foraging skill. + Foraging = Farmer.foragingSkill, + + /// The luck skill. + Luck = Farmer.luckSkill, + + /// The mining skill. + Mining = Farmer.miningSkill + } +} diff --git a/Mods/ContentPatcher/Framework/Constants/WalletItem.cs b/Mods/ContentPatcher/Framework/Constants/WalletItem.cs new file mode 100644 index 000000000..6a603d9c6 --- /dev/null +++ b/Mods/ContentPatcher/Framework/Constants/WalletItem.cs @@ -0,0 +1,36 @@ +namespace ContentPatcher.Framework.Constants +{ + /// A special item slot in the player's wallet. + internal enum WalletItem + { + /// Unlocks speaking to the Dwarf. + DwarvishTranslationGuide, + + /// Unlocks the sewers. + RustyKey, + + /// Unlocks the desert casino. + ClubCard, + + /// Permanently increases daily luck. + SpecialCharm, + + /// Unlocks the Skull Cavern in the desert, and the Junimo Kart machine in the Stardrop Saloon. + SkullKey, + + /// Unlocks the ability to find secret notes. + MagnifyingGlass, + + /// Unlocks the Witch's Swamp. + DarkTalisman, + + /// Unlocks magical buildings through the Wizard, and the dark shrines in the Witch's Swamp. + MagicInk, + + /// Increases sell price of blackberries and salmonberries. + BearsKnowledge, + + /// Increases sell price of spring onions. + SpringOnionMastery + } +} diff --git a/Mods/ContentPatcher/Framework/DebugOverlay.cs b/Mods/ContentPatcher/Framework/DebugOverlay.cs new file mode 100644 index 000000000..3915fecc6 --- /dev/null +++ b/Mods/ContentPatcher/Framework/DebugOverlay.cs @@ -0,0 +1,109 @@ +using System; +using System.Collections.Generic; +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 ContentPatcher.Framework +{ + /// Renders debug information to the screen. + internal class DebugOverlay : BaseOverlay + { + /********* + ** Fields + *********/ + /// The size of the margin around the displayed legend. + private readonly int Margin = 30; + + /// The padding between the border and content. + private readonly int Padding = 5; + + /// The content helper from which to read textures. + private readonly IContentHelper Content; + + /// The spritesheets to render. + private readonly string[] TextureNames; + + /// The current spritesheet to display. + private string CurrentName; + + /// The current texture to display. + private Texture2D CurrentTexture; + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The SMAPI events available for mods. + /// An API for checking and changing input state. + /// The content helper from which to read textures. + public DebugOverlay(IModEvents events, IInputHelper inputHelper, IContentHelper contentHelper) + : base(events, inputHelper) + { + this.Content = contentHelper; + this.TextureNames = this.GetTextureNames(contentHelper).OrderByIgnoreCase(p => p).ToArray(); + this.NextTexture(); + } + + /// Switch to the next texture. + public void NextTexture() + { + int index = Array.IndexOf(this.TextureNames, this.CurrentName) + 1; + if (index >= this.TextureNames.Length) + index = 0; + this.CurrentName = this.TextureNames[index]; + this.CurrentTexture = this.Content.Load(this.CurrentName, ContentSource.GameContent); + } + + /// Switch to the previous data map. + public void PrevTexture() + { + int index = Array.IndexOf(this.TextureNames, this.CurrentName) - 1; + if (index < 0) + index = this.TextureNames.Length - 1; + this.CurrentName = this.TextureNames[index]; + this.CurrentTexture = this.Content.Load(this.CurrentName, ContentSource.GameContent); + } + + + /********* + ** Protected methods + *********/ + /// Draw to the screen. + /// The sprite batch to which to draw. + protected override void Draw(SpriteBatch spriteBatch) + { + Vector2 labelSize = Game1.smallFont.MeasureString(this.CurrentName); + int contentWidth = (int)Math.Max(labelSize.X, this.CurrentTexture?.Width ?? 0); + + CommonHelper.DrawScroll(spriteBatch, new Vector2(this.Margin), new Vector2(contentWidth, labelSize.Y + this.Padding + (this.CurrentTexture?.Height ?? (int)labelSize.Y)), out Vector2 contentPos, out Rectangle _, padding: this.Padding); + spriteBatch.DrawString(Game1.smallFont, this.CurrentName, new Vector2(contentPos.X + ((contentWidth - labelSize.X) / 2), contentPos.Y), Color.Black); + + if (this.CurrentTexture != null) + spriteBatch.Draw(this.CurrentTexture, contentPos + new Vector2(0, labelSize.Y + this.Padding), Color.White); + else + spriteBatch.DrawString(Game1.smallFont, "(null)", contentPos + new Vector2(0, labelSize.Y + this.Padding), Color.Black); + } + + /// Get all texture asset names in the given content helper. + /// The content helper to search. + private IEnumerable GetTextureNames(IContentHelper contentHelper) + { + // get all texture keys from the content helper (this is such a hack) + IList textureKeys = new List(); + contentHelper.InvalidateCache(asset => + { + if (asset.DataType == typeof(Texture2D) && !asset.AssetName.Contains("..") && !asset.AssetName.StartsWith(StardewModdingAPI.Constants.ExecutionPath)) + textureKeys.Add(asset.AssetName); + return false; + }); + return textureKeys; + } + } +} diff --git a/Mods/ContentPatcher/Framework/GenericTokenContext.cs b/Mods/ContentPatcher/Framework/GenericTokenContext.cs new file mode 100644 index 000000000..ecf711f77 --- /dev/null +++ b/Mods/ContentPatcher/Framework/GenericTokenContext.cs @@ -0,0 +1,84 @@ +using System; +using System.Collections.Generic; +using ContentPatcher.Framework.Tokens; + +namespace ContentPatcher.Framework +{ + /// A generic token context. + /// The token type to store. + internal class GenericTokenContext : IContext where TToken : class, IToken + { + /********* + ** Accessors + *********/ + /// The available tokens. + public IDictionary Tokens { get; } = new Dictionary(); + + + /********* + ** Accessors + *********/ + /// Save the given token to the context. + /// The token to save. + public void Save(TToken token) + { + this.Tokens[token.Name] = token; + } + + /// Get whether the context contains the given token. + /// The token name. + /// Whether to only consider tokens that are available in the context. + public bool Contains(TokenName name, bool enforceContext) + { + return this.GetToken(name, enforceContext) != null; + } + + /// Get the underlying token which handles a key. + /// The token name. + /// Whether to only consider tokens that are available in the context. + /// Returns the matching token, or null if none was found. + public IToken GetToken(TokenName name, bool enforceContext) + { + return this.Tokens.TryGetValue(name.GetRoot(), out TToken token) && this.ShouldConsider(token, enforceContext) + ? token + : null; + } + + /// Get the underlying tokens. + /// Whether to only consider tokens that are available in the context. + public IEnumerable GetTokens(bool enforceContext) + { + foreach (TToken token in this.Tokens.Values) + { + if (this.ShouldConsider(token, enforceContext)) + yield return token; + } + } + + /// Get the current values of the given token for comparison. + /// The token name. + /// Whether to only consider tokens that are available in the context. + /// Return the values of the matching token, or an empty list if the token doesn't exist. + /// The specified key is null. + public IEnumerable GetValues(TokenName name, bool enforceContext) + { + IToken token = this.GetToken(name, enforceContext); + return token?.GetValues(name) ?? new string[0]; + } + + + /********* + ** Private methods + *********/ + /// Get whether a given token should be considered. + /// The token to check. + /// Whether to only consider tokens that are available in the context. + private bool ShouldConsider(IToken token, bool enforceContext) + { + return !enforceContext || token.IsValidInContext; + } + } + + /// A generic token context. + internal class GenericTokenContext : GenericTokenContext { } +} diff --git a/Mods/ContentPatcher/Framework/Lexing/LexTokens/ILexToken.cs b/Mods/ContentPatcher/Framework/Lexing/LexTokens/ILexToken.cs new file mode 100644 index 000000000..55bd0d241 --- /dev/null +++ b/Mods/ContentPatcher/Framework/Lexing/LexTokens/ILexToken.cs @@ -0,0 +1,15 @@ +namespace ContentPatcher.Framework.Lexing.LexTokens +{ + /// A lexical token within a string, which combines one or more patterns into a cohesive part. + internal interface ILexToken + { + /********* + ** Accessors + *********/ + /// The lexical token type. + LexTokenType Type { get; } + + /// A text representation of the lexical token. + string Text { get; } + } +} diff --git a/Mods/ContentPatcher/Framework/Lexing/LexTokens/LexBit.cs b/Mods/ContentPatcher/Framework/Lexing/LexTokens/LexBit.cs new file mode 100644 index 000000000..aa0ba7a7d --- /dev/null +++ b/Mods/ContentPatcher/Framework/Lexing/LexTokens/LexBit.cs @@ -0,0 +1,28 @@ +namespace ContentPatcher.Framework.Lexing.LexTokens +{ + /// A low-level character pattern within a string/ + internal class LexBit + { + /********* + ** Accessors + *********/ + /// The lexical character pattern type. + public LexBitType Type { get; } + + /// The raw matched text. + public string Text { get; } + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The lexical character pattern type. + /// The raw matched text. + public LexBit(LexBitType type, string text) + { + this.Type = type; + this.Text = text; + } + } +} diff --git a/Mods/ContentPatcher/Framework/Lexing/LexTokens/LexBitType.cs b/Mods/ContentPatcher/Framework/Lexing/LexTokens/LexBitType.cs new file mode 100644 index 000000000..2909cbe1e --- /dev/null +++ b/Mods/ContentPatcher/Framework/Lexing/LexTokens/LexBitType.cs @@ -0,0 +1,21 @@ +namespace ContentPatcher.Framework.Lexing.LexTokens +{ + /// A lexical character pattern type. + public enum LexBitType + { + /// A literal string. + Literal, + + /// The characters which start a token ('{{'). + StartToken, + + /// The characters which end a token ('}}'). + EndToken, + + /// The character which separates a token name from its input argument (':'). + InputArgSeparator, + + /// The character which pipes the output of one token into the input of another ('|'). + TokenPipe + } +} diff --git a/Mods/ContentPatcher/Framework/Lexing/LexTokens/LexTokenInputArg.cs b/Mods/ContentPatcher/Framework/Lexing/LexTokens/LexTokenInputArg.cs new file mode 100644 index 000000000..a55009b05 --- /dev/null +++ b/Mods/ContentPatcher/Framework/Lexing/LexTokens/LexTokenInputArg.cs @@ -0,0 +1,33 @@ +using System.Linq; + +namespace ContentPatcher.Framework.Lexing.LexTokens +{ + /// A lexical token representing the input argument for a Content Patcher token. + internal readonly struct LexTokenInputArg : ILexToken + { + /********* + ** Accessors + *********/ + /// The lexical token type. + public LexTokenType Type { get; } + + /// A text representation of the lexical token. + public string Text { get; } + + /// The lexical tokens making up the input argument. + public ILexToken[] Parts { get; } + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The lexical tokens making up the input argument. + public LexTokenInputArg(ILexToken[] tokenParts) + { + this.Type = LexTokenType.TokenInput; + this.Text = string.Join("", tokenParts.Select(p => p.Text)); + this.Parts = tokenParts; + } + } +} diff --git a/Mods/ContentPatcher/Framework/Lexing/LexTokens/LexTokenLiteral.cs b/Mods/ContentPatcher/Framework/Lexing/LexTokens/LexTokenLiteral.cs new file mode 100644 index 000000000..8bec58487 --- /dev/null +++ b/Mods/ContentPatcher/Framework/Lexing/LexTokens/LexTokenLiteral.cs @@ -0,0 +1,27 @@ +namespace ContentPatcher.Framework.Lexing.LexTokens +{ + /// A lexical token representing a literal string value. + internal readonly struct LexTokenLiteral : ILexToken + { + /********* + ** Accessors + *********/ + /// The lexical token type. + public LexTokenType Type { get; } + + /// A text representation of the lexical token. + public string Text { get; } + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The literal text value. + public LexTokenLiteral(string text) + { + this.Type = LexTokenType.Literal; + this.Text = text; + } + } +} diff --git a/Mods/ContentPatcher/Framework/Lexing/LexTokens/LexTokenPipe.cs b/Mods/ContentPatcher/Framework/Lexing/LexTokens/LexTokenPipe.cs new file mode 100644 index 000000000..d7f4ab71e --- /dev/null +++ b/Mods/ContentPatcher/Framework/Lexing/LexTokens/LexTokenPipe.cs @@ -0,0 +1,27 @@ +namespace ContentPatcher.Framework.Lexing.LexTokens +{ + /// A lexical token which represents a pipe that transfers the output of one token into the input of another. + internal readonly struct LexTokenPipe : ILexToken + { + /********* + ** Accessors + *********/ + /// The lexical token type. + public LexTokenType Type { get; } + + /// A text representation of the lexical token. + public string Text { get; } + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// A text representation of the lexical token. + public LexTokenPipe(string text) + { + this.Type = LexTokenType.TokenPipe; + this.Text = text; + } + } +} diff --git a/Mods/ContentPatcher/Framework/Lexing/LexTokens/LexTokenToken.cs b/Mods/ContentPatcher/Framework/Lexing/LexTokens/LexTokenToken.cs new file mode 100644 index 000000000..3735f99f8 --- /dev/null +++ b/Mods/ContentPatcher/Framework/Lexing/LexTokens/LexTokenToken.cs @@ -0,0 +1,72 @@ +using System.Text; + +namespace ContentPatcher.Framework.Lexing.LexTokens +{ + /// A lexical token representing a Content Patcher token. + internal readonly struct LexTokenToken : ILexToken + { + /********* + ** Accessors + *********/ + /// The lexical token type. + public LexTokenType Type { get; } + + /// A text representation of the lexical token. + public string Text { get; } + + /// The Content Patcher token name. + public string Name { get; } + + /// The input argument passed to the Content Patcher token. + public LexTokenInputArg? InputArg { get; } + + /// Whether the token omits the start/end character patterns because it's in a token-only context. + public bool ImpliedBraces { get; } + + /// A sequence of tokens to invoke after this token is processed, each getting the output of the previous token as its input. + public LexTokenToken[] PipedTokens { get; } + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The Content Patcher token name. + /// The input argument passed to the Content Patcher token. + /// Whether the token omits the start/end character patterns because it's in a token-only context. + /// A sequence of tokens to invoke after this token is processed, each getting the output of the previous token as its input. + public LexTokenToken(string name, LexTokenInputArg? inputArg, bool impliedBraces, LexTokenToken[] pipedTokens) + { + this.Type = LexTokenType.Token; + this.Text = LexTokenToken.GetRawText(name, inputArg, impliedBraces); + this.Name = name; + this.InputArg = inputArg; + this.ImpliedBraces = impliedBraces; + this.PipedTokens = pipedTokens; + } + + + /********* + ** Private methods + *********/ + /// Get a string representation of a token. + /// The Content Patcher token name. + /// The input argument passed to the Content Patcher token. + /// Whether the token omits the start/end character patterns because it's in a token-only context. + private static string GetRawText(string name, LexTokenInputArg? tokenInputArgArgument, bool impliedBraces) + { + StringBuilder str = new StringBuilder(); + if (!impliedBraces) + str.Append("{{"); + str.Append(name); + if (tokenInputArgArgument != null) + { + str.Append(":"); + str.Append(tokenInputArgArgument.Value.Text); + } + if (!impliedBraces) + str.Append("}}"); + return str.ToString(); + } + } +} diff --git a/Mods/ContentPatcher/Framework/Lexing/LexTokens/LexTokenType.cs b/Mods/ContentPatcher/Framework/Lexing/LexTokens/LexTokenType.cs new file mode 100644 index 000000000..5d6eed19a --- /dev/null +++ b/Mods/ContentPatcher/Framework/Lexing/LexTokens/LexTokenType.cs @@ -0,0 +1,18 @@ +namespace ContentPatcher.Framework.Lexing.LexTokens +{ + /// A lexical token type. + public enum LexTokenType + { + /// A literal string. + Literal, + + /// A Content Patcher token. + Token, + + /// The input argument to a Content Patcher token. + TokenInput, + + /// A pipe which transfers the output of one token into the input of another. + TokenPipe + } +} diff --git a/Mods/ContentPatcher/Framework/Lexing/Lexer.cs b/Mods/ContentPatcher/Framework/Lexing/Lexer.cs new file mode 100644 index 000000000..f2d16b3a3 --- /dev/null +++ b/Mods/ContentPatcher/Framework/Lexing/Lexer.cs @@ -0,0 +1,305 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; +using ContentPatcher.Framework.Lexing.LexTokens; + +namespace ContentPatcher.Framework.Lexing +{ + /// Handles parsing raw strings into tokens. + internal class Lexer + { + /********* + ** Fields + *********/ + /// A regular expression which matches lexical patterns that split lexical patterns. For example, ':' is a pattern that splits a token name and its input arguments. The split pattern is itself a lexical pattern. + private readonly Regex LexicalSplitPattern = new Regex(@"({{|}}|:|\|)", RegexOptions.Compiled); + + + /********* + ** Public methods + *********/ + /// Break a raw string into its constituent lexical character patterns. + /// The raw text to tokenise. + public IEnumerable TokeniseString(string rawText) + { + // special cases + if (rawText == null) + yield break; + if (string.IsNullOrWhiteSpace(rawText)) + { + yield return new LexBit(LexBitType.Literal, rawText); + yield break; + } + + // parse + string[] parts = this.LexicalSplitPattern.Split(rawText); + foreach (string part in parts) + { + if (part == "") + continue; // split artifact + + LexBitType type; + switch (part) + { + case "{{": + type = LexBitType.StartToken; + break; + + case "}}": + type = LexBitType.EndToken; + break; + + case ":": + type = LexBitType.InputArgSeparator; + break; + + case "|": + type = LexBitType.TokenPipe; + break; + + default: + type = LexBitType.Literal; + break; + } + + yield return new LexBit(type, part); + } + } + + /// Parse a sequence of lexical character patterns into higher-level lexical tokens. + /// The raw text to tokenise. + /// Whether we're parsing a token context (so the outer '{{' and '}}' are implied); else parse as a tokenisable string which main contain a mix of literal and {{token}} values. + public IEnumerable ParseBits(string rawText, bool impliedBraces) + { + IEnumerable bits = this.TokeniseString(rawText); + return this.ParseBits(bits, impliedBraces); + } + + /// Parse a sequence of lexical character patterns into higher-level lexical tokens. + /// The lexical character patterns to parse. + /// Whether we're parsing a token context (so the outer '{{' and '}}' are implied); else parse as a tokenisable string which main contain a mix of literal and {{token}} values. + public IEnumerable ParseBits(IEnumerable bits, bool impliedBraces) + { + return this.ParseBitQueue(new Queue(bits), impliedBraces, trim: false); + } + + + /********* + ** Private methods + *********/ + /// Parse a sequence of lexical character patterns into higher-level lexical tokens. + /// The lexical character patterns to parse. + /// Whether we're parsing a token context (so the outer '{{' and '}}' are implied); else parse as a tokenisable string which main contain a mix of literal and {{token}} values. + /// Whether the value should be trimmed. + private IEnumerable ParseBitQueue(Queue input, bool impliedBraces, bool trim) + { + // perform a raw parse + IEnumerable RawParse() + { + // 'Implied braces' means we're parsing inside a token. This necessarily starts with a token name, + // optionally followed by an input argument and token pipes. + if (impliedBraces) + { + while (input.Any()) + { + yield return this.ExtractToken(input, impliedBraces: true); + if (!input.Any()) + yield break; + + var next = input.Peek(); + switch (next.Type) + { + case LexBitType.TokenPipe: + yield return new LexTokenPipe(input.Dequeue().Text); + break; + + default: + throw new InvalidOperationException($"Unexpected {next.Type}, expected {LexBitType.Literal} or {LexBitType.TokenPipe}"); + } + } + yield break; + } + + // Otherwise this is a tokenisable string which may contain a mix of literal and {{token}} values. + while (input.Any()) + { + LexBit next = input.Peek(); + switch (next.Type) + { + // start token + case LexBitType.StartToken: + yield return this.ExtractToken(input, impliedBraces: false); + break; + + // pipe/separator outside token + case LexBitType.Literal: + case LexBitType.TokenPipe: + case LexBitType.InputArgSeparator: + input.Dequeue(); + yield return new LexTokenLiteral(next.Text); + break; + + // anything else is invalid + default: + throw new InvalidOperationException($"Unexpected {next.Type}, expected {LexBitType.StartToken} or {LexBitType.Literal}"); + } + } + } + + // normalise literal values + LinkedList tokens = new LinkedList(RawParse()); + IList> removeQueue = new List>(); + for (LinkedListNode node = tokens.First; node != null; node = node.Next) + { + if (node.Value.Type != LexTokenType.Literal) + continue; + + // fetch info + ILexToken current = node.Value; + ILexToken previous = node.Previous?.Value; + ILexToken next = node.Next?.Value; + string newText = node.Value.Text; + + // collapse sequential literals + if (previous?.Type == LexTokenType.Literal) + { + newText = previous.Text + newText; + removeQueue.Add(node.Previous); + } + + // trim before/after separator + if (next?.Type == LexTokenType.TokenInput || next?.Type == LexTokenType.TokenPipe) + newText = newText.TrimEnd(); + if (previous?.Type == LexTokenType.TokenInput || previous?.Type == LexTokenType.TokenPipe) + newText = newText.TrimStart(); + + // trim whole result + if (trim && (previous == null || next == null)) + { + if (previous == null) + newText = newText.TrimStart(); + if (next == null) + newText = newText.TrimEnd(); + + if (newText == "") + removeQueue.Add(node); + } + + // replace value if needed + if (newText != current.Text) + node.Value = new LexTokenLiteral(newText); + } + foreach (LinkedListNode entry in removeQueue) + tokens.Remove(entry); + + // yield result + return tokens; + } + + /// Extract a token from the front of a lexical input queue. + /// The input from which to extract a token. The extracted lexical bits will be removed from the queue. + /// Whether we're parsing a token context (so the outer '{{' and '}}' are implied); else parse as a tokenisable string which main contain a mix of literal and {{token}} values. + /// Whether a should signal the end of the token. Only valid if is true. + /// Returns the token, or multiple tokens if chained using . + public LexTokenToken ExtractToken(Queue input, bool impliedBraces, bool endBeforePipe = false) + { + LexBit GetNextAndAssert() + { + if (!input.Any()) + throw new InvalidOperationException(); + return input.Dequeue(); + } + + // start token + if (!impliedBraces) + { + LexBit startToken = GetNextAndAssert(); + if (startToken.Type != LexBitType.StartToken) + throw new InvalidOperationException($"Unexpected {startToken.Type} at start of token."); + } + + // extract token name + LexBit name = GetNextAndAssert(); + if (name.Type != LexBitType.Literal) + throw new InvalidOperationException($"Unexpected {name.Type} where token name should be."); + + // extract input argument if present + LexTokenInputArg? inputArg = null; + if (input.Any() && input.Peek().Type == LexBitType.InputArgSeparator) + { + input.Dequeue(); + inputArg = this.ExtractInputArgument(input); + } + + // extract piped tokens + IList pipedTokens = new List(); + if (!endBeforePipe) + { + while (input.Any() && input.Peek().Type == LexBitType.TokenPipe) + { + input.Dequeue(); + pipedTokens.Add(this.ExtractToken(input, impliedBraces: true, endBeforePipe: true)); + } + } + + // end token + if (!impliedBraces) + { + LexBit endToken = GetNextAndAssert(); + if (endToken.Type != LexBitType.EndToken) + throw new InvalidOperationException($"Unexpected {endToken.Type} before end of token."); + } + + return new LexTokenToken(name.Text.Trim(), inputArg, impliedBraces, pipedTokens.ToArray()); + } + + /// Extract a token input argument from the front of a lexical input queue. + /// The input from which to extract an input argument. The extracted lexical bits will be removed from the queue. + public LexTokenInputArg ExtractInputArgument(Queue input) + { + // extract input arg parts + Queue inputArgBits = new Queue(); + int tokenDepth = 0; + bool reachedEnd = false; + while (!reachedEnd && input.Any()) + { + LexBit next = input.Peek(); + switch (next.Type) + { + case LexBitType.StartToken: + tokenDepth++; + inputArgBits.Enqueue(input.Dequeue()); + break; + + case LexBitType.TokenPipe: + if (tokenDepth > 0) + throw new InvalidOperationException($"Unexpected {next.Type} within token input argument"); + + reachedEnd = true; + break; + + case LexBitType.EndToken: + tokenDepth--; + + if (tokenDepth < 0) + { + reachedEnd = true; + break; + } + + inputArgBits.Enqueue(input.Dequeue()); + break; + + default: + inputArgBits.Enqueue(input.Dequeue()); + break; + } + } + + // parse + ILexToken[] tokenised = this.ParseBitQueue(inputArgBits, impliedBraces: false, trim: true).ToArray(); + return new LexTokenInputArg(tokenised); + } + } +} diff --git a/Mods/ContentPatcher/Framework/ManagedContentPack.cs b/Mods/ContentPatcher/Framework/ManagedContentPack.cs new file mode 100644 index 000000000..2db4cbcec --- /dev/null +++ b/Mods/ContentPatcher/Framework/ManagedContentPack.cs @@ -0,0 +1,111 @@ +using System; +using System.Collections.Generic; +using System.IO; +using Pathoschild.Stardew.Common; +using StardewModdingAPI; + +namespace ContentPatcher.Framework +{ + /// Handles loading assets from content packs. + internal class ManagedContentPack + { + /********* + ** Fields + *********/ + /// A dictionary which matches case-insensitive relative paths to the exact path on disk, for case-insensitive file lookups on Linux/Mac. + private IDictionary RelativePaths; + + + /********* + ** Accessors + *********/ + /// The managed content pack. + public IContentPack Pack { get; } + + /// The content pack's manifest. + public IManifest Manifest => this.Pack.Manifest; + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The content pack to manage. + public ManagedContentPack(IContentPack pack) + { + this.Pack = pack; + } + + /// Get whether a file exists in the content pack. + /// The asset key. + public bool HasFile(string key) + { + return this.GetRealPath(key) != null; + } + + /// Get an asset from the content pack. + /// The asset type. + /// The asset key. + public T Load(string key) + { + key = this.GetRealPath(key) ?? throw new FileNotFoundException($"The file '{key}' does not exist in the {this.Pack.Manifest.Name} content patch folder."); + return this.Pack.LoadAsset(key); + } + + /// Read a JSON file from the content pack folder. + /// The model type. + /// The file path relative to the content pack directory. + /// Returns the deserialised model, or null if the file doesn't exist or is empty. + public TModel ReadJsonFile(string path) where TModel : class + { + return this.Pack.ReadJsonFile(path); + } + + /// Save data to a JSON file in the content pack's folder. + /// The model type. This should be a plain class that has public properties for the data you want. The properties can be complex types. + /// The file path relative to the mod folder. + /// The arbitrary data to save. + public void WriteJsonFile(string path, TModel data) where TModel : class + { + this.Pack.WriteJsonFile(path, data); + } + + /// Get the raw absolute path for a path within the content pack. + /// The path relative to the content pack folder. + public string GetFullPath(string relativePath) + { + return Path.Combine(this.Pack.DirectoryPath, relativePath); + } + + + /********* + ** Private methods + *********/ + /// Get the actual relative path within the content pack for a file, matched case-insensitively, or null if not found. + /// The case-insensitive asset key. + private string GetRealPath(string key) + { + key = PathUtilities.NormalisePathSeparators(key); + + // cache file paths + if (this.RelativePaths == null) + { + this.RelativePaths = new Dictionary(StringComparer.InvariantCultureIgnoreCase); + foreach (string path in this.GetRealRelativePaths()) + this.RelativePaths[path] = path; + } + + // find match + return this.RelativePaths.TryGetValue(key, out string relativePath) + ? relativePath + : null; + } + + /// Get all relative paths in the content pack directory. + private IEnumerable GetRealRelativePaths() + { + foreach (string path in Directory.EnumerateFiles(this.Pack.DirectoryPath, "*", SearchOption.AllDirectories)) + yield return path.Substring(this.Pack.DirectoryPath.Length + 1); + } + } +} diff --git a/Mods/ContentPatcher/Framework/Migrations/AggregateMigration.cs b/Mods/ContentPatcher/Framework/Migrations/AggregateMigration.cs new file mode 100644 index 000000000..81ecee1f8 --- /dev/null +++ b/Mods/ContentPatcher/Framework/Migrations/AggregateMigration.cs @@ -0,0 +1,105 @@ +using System.Collections.Generic; +using System.Linq; +using ContentPatcher.Framework.Conditions; +using ContentPatcher.Framework.ConfigModels; +using ContentPatcher.Framework.Tokens; +using StardewModdingAPI; + +namespace ContentPatcher.Framework.Migrations +{ + /// Aggregates content pack migrations. + internal class AggregateMigration : IMigration + { + /********* + ** Fields + *********/ + /// The valid format versions. + private readonly HashSet ValidVersions; + + /// The migrations to apply. + private readonly IMigration[] Migrations; + + + /********* + ** Accessors + *********/ + /// The version to which this migration applies. + public ISemanticVersion Version { get; } + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The content pack version. + /// The valid format versions. + /// The migrations to apply. + public AggregateMigration(ISemanticVersion version, string[] validVersions, IMigration[] migrations) + { + this.Version = version; + this.ValidVersions = new HashSet(validVersions); + this.Migrations = migrations.Where(m => m.Version.IsNewerThan(version)).ToArray(); + } + + /// Migrate a content pack. + /// The content pack data to migrate. + /// An error message which indicates why migration failed. + /// Returns whether the content pack was successfully migrated. + public bool TryMigrate(ContentConfig content, out string error) + { + // validate format version + if (!this.ValidVersions.Contains(content.Format.ToString())) + { + error = $"unsupported format {content.Format} (supported version: {string.Join(", ", this.ValidVersions)})."; + return false; + } + + // apply migrations + foreach (IMigration migration in this.Migrations) + { + if (!migration.TryMigrate(content, out error)) + return false; + } + + // no issues found + error = null; + return true; + } + + /// Migrate a token name. + /// The token name to migrate. + /// An error message which indicates why migration failed (if any). + /// Returns whether migration succeeded. + public bool TryMigrate(ref TokenName name, out string error) + { + // apply migrations + foreach (IMigration migration in this.Migrations) + { + if (!migration.TryMigrate(ref name, out error)) + return false; + } + + // no issues found + error = null; + return true; + } + + /// Migrate a tokenised string. + /// The tokenised string to migrate. + /// An error message which indicates why migration failed (if any). + /// Returns whether migration succeeded. + public bool TryMigrate(ref TokenString tokenStr, out string error) + { + // apply migrations + foreach (IMigration migration in this.Migrations) + { + if (!migration.TryMigrate(ref tokenStr, out error)) + return false; + } + + // no issues found + error = null; + return true; + } + } +} diff --git a/Mods/ContentPatcher/Framework/Migrations/BaseMigration.cs b/Mods/ContentPatcher/Framework/Migrations/BaseMigration.cs new file mode 100644 index 000000000..5d09c324c --- /dev/null +++ b/Mods/ContentPatcher/Framework/Migrations/BaseMigration.cs @@ -0,0 +1,96 @@ +using ContentPatcher.Framework.Conditions; +using ContentPatcher.Framework.ConfigModels; +using ContentPatcher.Framework.Tokens; +using Pathoschild.Stardew.Common.Utilities; +using StardewModdingAPI; + +namespace ContentPatcher.Framework.Migrations +{ + /// The base implementation for a format version migrator. + internal abstract class BaseMigration : IMigration + { + /********* + ** Private methods + *********/ + /// The tokens added in this format version. + protected InvariantHashSet AddedTokens { get; set; } + + + /********* + ** Accessors + *********/ + /// The format version to which this migration applies. + public ISemanticVersion Version { get; } + + + /********* + ** Public methods + *********/ + /// Migrate a content pack. + /// The content pack data to migrate. + /// An error message which indicates why migration failed. + /// Returns whether the content pack was successfully migrated. + public virtual bool TryMigrate(ContentConfig content, out string error) + { + error = null; + return true; + } + + /// Migrate a token name. + /// The token name to migrate. + /// An error message which indicates why migration failed (if any). + /// Returns whether migration succeeded. + public virtual bool TryMigrate(ref TokenName name, out string error) + { + // tokens which need a higher version + if (this.AddedTokens.Contains(name.Key)) + { + error = this.GetNounPhraseError($"using token {name}"); + return false; + } + + // no issue found + error = null; + return true; + } + + /// Migrate a tokenised string. + /// The tokenised string to migrate. + /// An error message which indicates why migration failed (if any). + /// Returns whether migration succeeded. + public virtual bool TryMigrate(ref TokenString tokenStr, out string error) + { + // tokens which need a high version + foreach (TokenName token in tokenStr.Tokens) + { + if (this.AddedTokens.Contains(token.Key)) + { + error = this.GetNounPhraseError($"using token {token.Key}"); + return false; + } + } + + // no issue found + error = null; + return true; + } + + + /********* + ** Protected methods + *********/ + /// Construct an instance. + /// The version to which this migration applies. + protected BaseMigration(ISemanticVersion version) + { + this.Version = version; + } + + /// Get an error message indicating an action or feature requires a newer format version. + /// The noun phrase, like "using X feature". + protected string GetNounPhraseError(string nounPhrase) + { + return $"{nounPhrase} requires {nameof(ContentConfig.Format)} version {this.Version} or later"; + } + } +} diff --git a/Mods/ContentPatcher/Framework/Migrations/IMigration.cs b/Mods/ContentPatcher/Framework/Migrations/IMigration.cs new file mode 100644 index 000000000..a8704ebc5 --- /dev/null +++ b/Mods/ContentPatcher/Framework/Migrations/IMigration.cs @@ -0,0 +1,39 @@ +using ContentPatcher.Framework.Conditions; +using ContentPatcher.Framework.ConfigModels; +using ContentPatcher.Framework.Tokens; +using StardewModdingAPI; + +namespace ContentPatcher.Framework.Migrations +{ + /// Migrates patches to a given format version. + internal interface IMigration + { + /********* + ** Accessors + *********/ + /// The format version to which this migration applies. + ISemanticVersion Version { get; } + + + /********* + ** Public methods + *********/ + /// Migrate a content pack. + /// The content pack data to migrate. + /// An error message which indicates why migration failed. + /// Returns whether migration succeeded. + bool TryMigrate(ContentConfig content, out string error); + + /// Migrate a token name. + /// The token name to migrate. + /// An error message which indicates why migration failed (if any). + /// Returns whether migration succeeded. + bool TryMigrate(ref TokenName name, out string error); + + /// Migrate a tokenised string. + /// The tokenised string to migrate. + /// An error message which indicates why migration failed (if any). + /// Returns whether migration succeeded. + bool TryMigrate(ref TokenString tokenStr, out string error); + } +} diff --git a/Mods/ContentPatcher/Framework/Migrations/Migration_1_3.cs b/Mods/ContentPatcher/Framework/Migrations/Migration_1_3.cs new file mode 100644 index 000000000..027a2763d --- /dev/null +++ b/Mods/ContentPatcher/Framework/Migrations/Migration_1_3.cs @@ -0,0 +1,56 @@ +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using ContentPatcher.Framework.ConfigModels; +using StardewModdingAPI; + +namespace ContentPatcher.Framework.Migrations +{ + /// Migrate patches to format version 1.3. + [SuppressMessage("ReSharper", "InconsistentNaming", Justification = "Named for clarity.")] + internal class Migration_1_3 : BaseMigration + { + /********* + ** Public methods + *********/ + /// Construct an instance. + public Migration_1_3() + : base(new SemanticVersion(1, 3, 0)) { } + + /// Migrate a content pack. + /// The content pack data to migrate. + /// An error message which indicates why migration failed. + /// Returns whether the content pack was successfully migrated. + public override bool TryMigrate(ContentConfig content, out string error) + { + if (!base.TryMigrate(content, out error)) + return false; + + // 1.3 adds config.json + if (content.ConfigSchema?.Any() == true) + { + error = this.GetNounPhraseError($"using the {nameof(ContentConfig.ConfigSchema)} field"); + return false; + } + + // check patch format + foreach (PatchConfig patch in content.Changes) + { + // 1.3 adds tokens in FromFile + if (patch.FromFile != null && patch.FromFile.Contains("{{")) + { + error = this.GetNounPhraseError($"using the {{{{token}}}} feature in {nameof(PatchConfig.FromFile)} fields"); + return false; + } + + // 1.3 adds When + if (content.Changes.Any(p => p.When != null && p.When.Any())) + { + error = this.GetNounPhraseError($"using the condition feature ({nameof(ContentConfig.Changes)}.{nameof(PatchConfig.When)} field)"); + return false; + } + } + + return true; + } + } +} diff --git a/Mods/ContentPatcher/Framework/Migrations/Migration_1_4.cs b/Mods/ContentPatcher/Framework/Migrations/Migration_1_4.cs new file mode 100644 index 000000000..22024b535 --- /dev/null +++ b/Mods/ContentPatcher/Framework/Migrations/Migration_1_4.cs @@ -0,0 +1,30 @@ +using System.Diagnostics.CodeAnalysis; +using ContentPatcher.Framework.Conditions; +using Pathoschild.Stardew.Common.Utilities; +using StardewModdingAPI; + +namespace ContentPatcher.Framework.Migrations +{ + /// Migrate patches to format version 1.4. + [SuppressMessage("ReSharper", "InconsistentNaming", Justification = "Named for clarity.")] + internal class Migration_1_4 : BaseMigration + { + /********* + ** Public methods + *********/ + /// Construct an instance. + public Migration_1_4() + : base(new SemanticVersion(1, 4, 0)) + { + this.AddedTokens = new InvariantHashSet + { + ConditionType.DayEvent.ToString(), + ConditionType.HasFlag.ToString(), + ConditionType.HasSeenEvent.ToString(), + ConditionType.Hearts.ToString(), + ConditionType.Relationship.ToString(), + ConditionType.Spouse.ToString() + }; + } + } +} diff --git a/Mods/ContentPatcher/Framework/Migrations/Migration_1_5.cs b/Mods/ContentPatcher/Framework/Migrations/Migration_1_5.cs new file mode 100644 index 000000000..c4f0ac54d --- /dev/null +++ b/Mods/ContentPatcher/Framework/Migrations/Migration_1_5.cs @@ -0,0 +1,65 @@ +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using ContentPatcher.Framework.Conditions; +using ContentPatcher.Framework.ConfigModels; +using Pathoschild.Stardew.Common.Utilities; +using StardewModdingAPI; + +namespace ContentPatcher.Framework.Migrations +{ + /// Migrate patches to format version 1.5. + [SuppressMessage("ReSharper", "InconsistentNaming", Justification = "Named for clarity.")] + internal class Migration_1_5 : BaseMigration + { + /********* + ** Public methods + *********/ + /// Construct an instance. + public Migration_1_5() + : base(new SemanticVersion(1, 5, 0)) + { + this.AddedTokens = new InvariantHashSet + { + ConditionType.FarmCave.ToString(), + ConditionType.FarmhouseUpgrade.ToString(), + ConditionType.FarmName.ToString(), + ConditionType.HasFile.ToString(), + ConditionType.HasProfession.ToString(), + ConditionType.PlayerGender.ToString(), + ConditionType.PlayerName.ToString(), + ConditionType.PreferredPet.ToString(), + ConditionType.Year.ToString() + }; + } + + /// Migrate a content pack. + /// The content pack data to migrate. + /// An error message which indicates why migration failed. + /// Returns whether the content pack was successfully migrated. + public override bool TryMigrate(ContentConfig content, out string error) + { + if (!base.TryMigrate(content, out error)) + return false; + + // 1.5 adds dynamic tokens + if (content.DynamicTokens?.Any() == true) + { + error = this.GetNounPhraseError($"using the {nameof(ContentConfig.DynamicTokens)} field"); + return false; + } + + // check patch format + foreach (PatchConfig patch in content.Changes) + { + // 1.5 adds multiple Target values + if (patch.Target?.Contains(",") == true) + { + error = this.GetNounPhraseError($"specifying multiple {nameof(PatchConfig.Target)} values"); + return false; + } + } + + return true; + } + } +} diff --git a/Mods/ContentPatcher/Framework/Migrations/Migration_1_6.cs b/Mods/ContentPatcher/Framework/Migrations/Migration_1_6.cs new file mode 100644 index 000000000..0936f9d91 --- /dev/null +++ b/Mods/ContentPatcher/Framework/Migrations/Migration_1_6.cs @@ -0,0 +1,46 @@ +using System.Diagnostics.CodeAnalysis; +using ContentPatcher.Framework.Conditions; +using ContentPatcher.Framework.ConfigModels; +using Pathoschild.Stardew.Common.Utilities; +using StardewModdingAPI; + +namespace ContentPatcher.Framework.Migrations +{ + /// Migrate patches to format version 1.6. + [SuppressMessage("ReSharper", "InconsistentNaming", Justification = "Named for clarity.")] + internal class Migration_1_6 : BaseMigration + { + /********* + ** Public methods + *********/ + /// Construct an instance. + public Migration_1_6() + : base(new SemanticVersion(1, 6, 0)) + { + this.AddedTokens = new InvariantHashSet + { + ConditionType.HasWalletItem.ToString(), + ConditionType.SkillLevel.ToString() + }; + } + + /// Migrate a content pack. + /// The content pack data to migrate. + /// An error message which indicates why migration failed. + /// Returns whether the content pack was successfully migrated. + public override bool TryMigrate(ContentConfig content, out string error) + { + if (!base.TryMigrate(content, out error)) + return false; + + // before 1.6, the 'sun' weather included 'wind' + foreach (PatchConfig patch in content.Changes) + { + if (patch.When != null && patch.When.TryGetValue(ConditionType.Weather.ToString(), out string value) && value.Contains("Sun")) + patch.When[ConditionType.Weather.ToString()] = $"{value}, Wind"; + } + + return true; + } + } +} diff --git a/Mods/ContentPatcher/Framework/ModTokenContext.cs b/Mods/ContentPatcher/Framework/ModTokenContext.cs new file mode 100644 index 000000000..92dcbc5d0 --- /dev/null +++ b/Mods/ContentPatcher/Framework/ModTokenContext.cs @@ -0,0 +1,161 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using ContentPatcher.Framework.Tokens; + +namespace ContentPatcher.Framework +{ + /// Manages the token context for a specific content pack. + internal class ModTokenContext : IContext + { + /********* + ** Fields + *********/ + /// The available global tokens. + private readonly IContext GlobalContext; + + /// The standard self-contained tokens. + private readonly GenericTokenContext StandardContext = new GenericTokenContext(); + + /// The dynamic tokens whose value depends on . + private readonly GenericTokenContext DynamicContext = new GenericTokenContext(); + + /// The conditional values used to set the values of tokens. + private readonly IList DynamicTokenValues = new List(); + + /// The underlying token contexts in priority order. + private readonly IContext[] Contexts; + + + /********* + ** Public methods + *********/ + /**** + ** Token management + ****/ + /// Construct an instance. + /// Manages the available global tokens. + public ModTokenContext(TokenManager tokenManager) + { + this.GlobalContext = tokenManager; + this.Contexts = new[] { this.GlobalContext, this.StandardContext, this.DynamicContext }; + } + + /// Add a standard token to the context. + /// The config token to add. + public void Add(IToken token) + { + if (token.Name.HasSubkey()) + throw new InvalidOperationException($"Can't register the '{token.Name}' mod token because subkeys aren't supported."); + if (this.GlobalContext.Contains(token.Name, enforceContext: false)) + throw new InvalidOperationException($"Can't register the '{token.Name}' mod token because there's a global token with that name."); + if (this.StandardContext.Contains(token.Name, enforceContext: false)) + throw new InvalidOperationException($"The '{token.Name}' token is already registered."); + + this.StandardContext.Tokens[token.Name] = token; + } + + /// Add a dynamic token value to the context. + /// The token to add. + public void Add(DynamicTokenValue tokenValue) + { + // validate + if (this.GlobalContext.Contains(tokenValue.Name, enforceContext: false)) + throw new InvalidOperationException($"Can't register a '{tokenValue.Name}' token because there's a global token with that name."); + if (this.StandardContext.Contains(tokenValue.Name, enforceContext: false)) + throw new InvalidOperationException($"Can't register a '{tokenValue.Name}' dynamic token because there's a config token with that name."); + + // get (or create) token + if (!this.DynamicContext.Tokens.TryGetValue(tokenValue.Name, out DynamicToken token)) + this.DynamicContext.Save(token = new DynamicToken(tokenValue.Name)); + + // add token value + token.AddAllowedValues(tokenValue.Value); + this.DynamicTokenValues.Add(tokenValue); + } + + /// Update the current context. + public void UpdateContext(IContext globalContext) + { + // update config tokens + foreach (IToken token in this.StandardContext.Tokens.Values) + { + if (token.IsMutable) + token.UpdateContext(this); + } + + // reset dynamic tokens + foreach (DynamicToken token in this.DynamicContext.Tokens.Values) + token.SetValidInContext(false); + foreach (DynamicTokenValue tokenValue in this.DynamicTokenValues) + { + if (tokenValue.Conditions.Values.All(p => p.IsMatch(this))) + { + DynamicToken token = this.DynamicContext.Tokens[tokenValue.Name]; + token.SetValue(tokenValue.Value); + token.SetValidInContext(true); + } + } + } + + /// Get the underlying tokens. + /// Whether to only return local tokens. + /// Whether to only consider tokens that are available in the context. + public IEnumerable GetTokens(bool localOnly, bool enforceContext) + { + foreach (IContext context in this.Contexts) + { + if (localOnly && context == this.GlobalContext) + continue; + + foreach (IToken token in context.GetTokens(enforceContext)) + yield return token; + } + } + + /**** + ** IContext + ****/ + /// Get whether the context contains the given token. + /// The token name. + /// Whether to only consider tokens that are available in the context. + public bool Contains(TokenName name, bool enforceContext) + { + return this.Contexts.Any(p => p.Contains(name, enforceContext)); + } + + /// Get the underlying token which handles a name. + /// The token name. + /// Whether to only consider tokens that are available in the context. + /// Returns the matching token, or null if none was found. + public IToken GetToken(TokenName name, bool enforceContext) + { + foreach (IContext context in this.Contexts) + { + IToken token = context.GetToken(name, enforceContext); + if (token != null) + return token; + } + + return null; + } + + /// Get the underlying tokens. + /// Whether to only consider tokens that are available in the context. + public IEnumerable GetTokens(bool enforceContext) + { + return this.GetTokens(localOnly: false, enforceContext: enforceContext); + } + + /// Get the current values of the given token for comparison. + /// The token name. + /// Whether to only consider tokens that are available in the context. + /// Return the values of the matching token, or an empty list if the token doesn't exist. + /// The specified token name is null. + public IEnumerable GetValues(TokenName name, bool enforceContext) + { + IToken token = this.GetToken(name, enforceContext); + return token?.GetValues(name) ?? Enumerable.Empty(); + } + } +} diff --git a/Mods/ContentPatcher/Framework/PatchManager.cs b/Mods/ContentPatcher/Framework/PatchManager.cs new file mode 100644 index 000000000..7c43f84ee --- /dev/null +++ b/Mods/ContentPatcher/Framework/PatchManager.cs @@ -0,0 +1,310 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using ContentPatcher.Framework.Conditions; +using ContentPatcher.Framework.ConfigModels; +using ContentPatcher.Framework.Patches; +using ContentPatcher.Framework.Tokens; +using ContentPatcher.Framework.Validators; +using Microsoft.Xna.Framework.Graphics; +using Pathoschild.Stardew.Common.Utilities; +using StardewModdingAPI; + +namespace ContentPatcher.Framework +{ + /// Manages loaded patches. + internal class PatchManager : IAssetLoader, IAssetEditor + { + /********* + ** Fields + *********/ + /**** + ** State + ****/ + /// Manages the available contextual tokens. + private readonly TokenManager TokenManager; + + /// Encapsulates monitoring and logging. + private readonly IMonitor Monitor; + + /// Handle special validation logic on loaded or edited assets. + private readonly IAssetValidator[] AssetValidators; + + /// The patches which are permanently disabled for this session. + private readonly IList PermanentlyDisabledPatches = new List(); + + /// The patches to apply. + private readonly HashSet Patches = new HashSet(); + + /// The patches to apply, indexed by asset name. + private InvariantDictionary> PatchesByCurrentTarget = new InvariantDictionary>(); + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// Encapsulates monitoring and logging. + /// Manages the available contextual tokens. + /// Handle special validation logic on loaded or edited assets. + public PatchManager(IMonitor monitor, TokenManager tokenManager, IAssetValidator[] assetValidators) + { + this.Monitor = monitor; + this.TokenManager = tokenManager; + this.AssetValidators = assetValidators; + } + + /**** + ** Patching + ****/ + /// Get whether this instance can load the initial version of the given asset. + /// Basic metadata about the asset being loaded. + public bool CanLoad(IAssetInfo asset) + { + IPatch[] patches = this.GetCurrentLoaders(asset).ToArray(); + if (patches.Length > 1) + { + this.Monitor.Log($"Multiple patches want to load {asset.AssetName} ({string.Join(", ", from entry in patches orderby entry.LogName select entry.LogName)}). None will be applied.", LogLevel.Error); + return false; + } + + bool canLoad = patches.Any(); + this.Monitor.VerboseLog($"check: [{(canLoad ? "X" : " ")}] can load {asset.AssetName}"); + return canLoad; + } + + /// Get whether this instance can edit the given asset. + /// Basic metadata about the asset being loaded. + public bool CanEdit(IAssetInfo asset) + { + bool canEdit = this.GetCurrentEditors(asset).Any(); + this.Monitor.VerboseLog($"check: [{(canEdit ? "X" : " ")}] can edit {asset.AssetName}"); + return canEdit; + } + + /// Load a matched asset. + /// Basic metadata about the asset being loaded. + public T Load(IAssetInfo asset) + { + // get applicable patches for context + IPatch[] patches = this.GetCurrentLoaders(asset).ToArray(); + if (!patches.Any()) + throw new InvalidOperationException($"Can't load asset key '{asset.AssetName}' because no patches currently apply. This should never happen because it means validation failed."); + if (patches.Length > 1) + throw new InvalidOperationException($"Can't load asset key '{asset.AssetName}' because multiple patches apply ({string.Join(", ", from entry in patches orderby entry.LogName select entry.LogName)}). This should never happen because it means validation failed."); + + // apply patch + IPatch patch = patches.Single(); + if (this.Monitor.IsVerbose) + this.Monitor.VerboseLog($"Patch \"{patch.LogName}\" loaded {asset.AssetName}."); + else + this.Monitor.Log($"{patch.ContentPack.Manifest.Name} loaded {asset.AssetName}.", LogLevel.Trace); + + T data = patch.Load(asset); + + foreach (IAssetValidator validator in this.AssetValidators) + { + if (!validator.TryValidate(asset, data, patch, out string error)) + { + this.Monitor.Log($"Can't apply patch {patch.LogName} to {asset.AssetName}: {error}.", LogLevel.Error); + return default; + } + } + + patch.IsApplied = true; + return data; + } + + /// Edit a matched asset. + /// A helper which encapsulates metadata about an asset and enables changes to it. + public void Edit(IAssetData asset) + { + IPatch[] patches = this.GetCurrentEditors(asset).ToArray(); + if (!patches.Any()) + throw new InvalidOperationException($"Can't edit asset key '{asset.AssetName}' because no patches currently apply. This should never happen."); + + InvariantHashSet loggedContentPacks = new InvariantHashSet(); + foreach (IPatch patch in patches) + { + if (this.Monitor.IsVerbose) + this.Monitor.VerboseLog($"Applied patch \"{patch.LogName}\" to {asset.AssetName}."); + else if (loggedContentPacks.Add(patch.ContentPack.Manifest.Name)) + this.Monitor.Log($"{patch.ContentPack.Manifest.Name} edited {asset.AssetName}.", LogLevel.Trace); + + try + { + patch.Edit(asset); + patch.IsApplied = true; + } + catch (Exception ex) + { + this.Monitor.Log($"unhandled exception applying patch: {patch.LogName}.\n{ex}", LogLevel.Error); + patch.IsApplied = false; + } + } + } + + /// Update the current context. + /// The content helper through which to invalidate assets. + public void UpdateContext(IContentHelper contentHelper) + { + this.Monitor.VerboseLog("Propagating context..."); + + // update patches + InvariantHashSet reloadAssetNames = new InvariantHashSet(); + string prevAssetName = null; + foreach (IPatch patch in this.Patches.OrderByIgnoreCase(p => p.TargetAsset).ThenByIgnoreCase(p => p.LogName)) + { + // log asset name + if (this.Monitor.IsVerbose && prevAssetName != patch.TargetAsset) + { + this.Monitor.VerboseLog($" {patch.TargetAsset}:"); + prevAssetName = patch.TargetAsset; + } + + // track old values + string wasAssetName = patch.TargetAsset; + bool wasApplied = patch.MatchesContext; + + // update patch + IContext tokenContext = this.TokenManager.TrackLocalTokens(patch.ContentPack.Pack); + bool changed = patch.UpdateContext(tokenContext); + bool shouldApply = patch.MatchesContext; + + // track patches to reload + bool reload = (wasApplied && changed) || (!wasApplied && shouldApply); + if (reload) + { + patch.IsApplied = false; + if (wasApplied) + reloadAssetNames.Add(wasAssetName); + if (shouldApply) + reloadAssetNames.Add(patch.TargetAsset); + } + + // log change + if (this.Monitor.IsVerbose) + { + IList changes = new List(); + if (wasApplied != shouldApply) + changes.Add(shouldApply ? "enabled" : "disabled"); + if (wasAssetName != patch.TargetAsset) + changes.Add($"target: {wasAssetName} => {patch.TargetAsset}"); + string changesStr = string.Join(", ", changes); + + this.Monitor.VerboseLog($" [{(shouldApply ? "X" : " ")}] {patch.LogName}: {(changes.Any() ? changesStr : "OK")}"); + } + + // warn for invalid load patch + if (patch is LoadPatch loadPatch && patch.MatchesContext && !patch.ContentPack.HasFile(loadPatch.FromLocalAsset.Value)) + this.Monitor.Log($"Patch error: {patch.LogName} has a {nameof(PatchConfig.FromFile)} which matches non-existent file '{loadPatch.FromLocalAsset.Value}'.", LogLevel.Error); + } + + // rebuild asset name lookup + this.PatchesByCurrentTarget = new InvariantDictionary>( + from patchGroup in this.Patches.GroupByIgnoreCase(p => p.TargetAsset) + let key = patchGroup.Key + let value = new HashSet(patchGroup) + select new KeyValuePair>(key, value) + ); + + // reload assets if needed + if (reloadAssetNames.Any()) + { + this.Monitor.VerboseLog($" reloading {reloadAssetNames.Count} assets: {string.Join(", ", reloadAssetNames.OrderByIgnoreCase(p => p))}"); + contentHelper.InvalidateCache(asset => + { + this.Monitor.VerboseLog($" [{(reloadAssetNames.Contains(asset.AssetName) ? "X" : " ")}] reload {asset.AssetName}"); + return reloadAssetNames.Contains(asset.AssetName); + }); + } + } + + /**** + ** Patches + ****/ + /// Add a patch. + /// The patch to add. + public void Add(IPatch patch) + { + // set initial context + IContext tokenContext = this.TokenManager.TrackLocalTokens(patch.ContentPack.Pack); + patch.UpdateContext(tokenContext); + + // add to patch list + this.Monitor.VerboseLog($" added {patch.Type} {patch.TargetAsset}."); + this.Patches.Add(patch); + + // add to lookup cache + if (this.PatchesByCurrentTarget.TryGetValue(patch.TargetAsset, out HashSet patches)) + patches.Add(patch); + else + this.PatchesByCurrentTarget[patch.TargetAsset] = new HashSet { patch }; + } + + /// Add a patch that's permanently disabled for this session. + /// The patch to add. + public void AddPermanentlyDisabled(DisabledPatch patch) + { + this.PermanentlyDisabledPatches.Add(patch); + } + + /// Get valid patches regardless of context. + public IEnumerable GetPatches() + { + return this.Patches; + } + + /// Get valid patches regardless of context. + /// The asset name for which to find patches. + public IEnumerable GetPatches(string assetName) + { + if (this.PatchesByCurrentTarget.TryGetValue(assetName, out HashSet patches)) + return patches; + return new IPatch[0]; + } + + /// Get patches which are permanently disabled for this session, along with the reason they were. + public IEnumerable GetPermanentlyDisabledPatches() + { + return this.PermanentlyDisabledPatches; + } + + /// Get patches which load the given asset in the current context. + /// The asset being intercepted. + public IEnumerable GetCurrentLoaders(IAssetInfo asset) + { + return this + .GetPatches(asset.AssetName) + .Where(patch => patch.Type == PatchType.Load && patch.MatchesContext && patch.IsValidInContext); + } + + /// Get patches which edit the given asset in the current context. + /// The asset being intercepted. + public IEnumerable GetCurrentEditors(IAssetInfo asset) + { + PatchType? patchType = this.GetEditType(asset.DataType); + if (patchType == null) + return new IPatch[0]; + + return this + .GetPatches(asset.AssetName) + .Where(patch => patch.Type == patchType && patch.MatchesContext); + } + + /********* + ** Private methods + *********/ + /// Get the patch type which applies when editing a given asset type. + /// The asset type. + private PatchType? GetEditType(Type assetType) + { + if (assetType == typeof(Texture2D)) + return PatchType.EditImage; + if (assetType.IsGenericType && assetType.GetGenericTypeDefinition() == typeof(Dictionary<,>)) + return PatchType.EditData; + + return null; + } + } +} diff --git a/Mods/ContentPatcher/Framework/Patches/DisabledPatch.cs b/Mods/ContentPatcher/Framework/Patches/DisabledPatch.cs new file mode 100644 index 000000000..1d0a7b8d5 --- /dev/null +++ b/Mods/ContentPatcher/Framework/Patches/DisabledPatch.cs @@ -0,0 +1,42 @@ +namespace ContentPatcher.Framework.Patches +{ + /// An invalid patch that couldn't be loaded. + internal class DisabledPatch + { + /********* + ** Accessors + *********/ + /// A unique name for this patch shown in log messages. + public string LogName { get; } + + /// The raw patch type. + public string Type { get; } + + /// The raw asset name to intercept. + public string AssetName { get; } + + /// The content pack which requested the patch. + public ManagedContentPack ContentPack { get; } + + /// The reason this patch is disabled. + public string ReasonDisabled { get; } + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// A unique name for this patch shown in log messages. + /// The raw patch type. + /// The raw asset name to intercept. + /// The content pack which requested the patch. + /// The reason this patch is disabled. + public DisabledPatch(string logName, string type, string assetName, ManagedContentPack contentPack, string reasonDisabled) + { + this.LogName = logName; + this.Type = type; + this.ContentPack = contentPack; + this.AssetName = assetName; + this.ReasonDisabled = reasonDisabled; + } + } +} diff --git a/Mods/ContentPatcher/Framework/Patches/EditDataPatch.cs b/Mods/ContentPatcher/Framework/Patches/EditDataPatch.cs new file mode 100644 index 000000000..0b7464175 --- /dev/null +++ b/Mods/ContentPatcher/Framework/Patches/EditDataPatch.cs @@ -0,0 +1,184 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using ContentPatcher.Framework.Conditions; +using ContentPatcher.Framework.ConfigModels; +using ContentPatcher.Framework.Tokens; +using Microsoft.Xna.Framework.Graphics; +using StardewModdingAPI; + +namespace ContentPatcher.Framework.Patches +{ + /// Metadata for a data to edit into a data file. + internal class EditDataPatch : Patch + { + /********* + ** Fields + *********/ + /// Encapsulates monitoring and logging. + private readonly IMonitor Monitor; + + /// The data records to edit. + private readonly EditDataPatchRecord[] Records; + + /// The data fields to edit. + private readonly EditDataPatchField[] Fields; + + /// The token strings which contain mutable tokens. + private readonly TokenString[] MutableTokenStrings; + + /// Whether the next context update is the first one. + private bool IsFirstUpdate = true; + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// A unique name for this patch shown in log messages. + /// The content pack which requested the patch. + /// The normalised asset name to intercept. + /// The conditions which determine whether this patch should be applied. + /// The data records to edit. + /// The data fields to edit. + /// Encapsulates monitoring and logging. + /// Normalise an asset name. + public EditDataPatch(string logName, ManagedContentPack contentPack, TokenString assetName, ConditionDictionary conditions, IEnumerable records, IEnumerable fields, IMonitor monitor, Func normaliseAssetName) + : base(logName, PatchType.EditData, contentPack, assetName, conditions, normaliseAssetName) + { + this.Records = records.ToArray(); + this.Fields = fields.ToArray(); + this.Monitor = monitor; + this.MutableTokenStrings = this.GetTokenStrings(this.Records, this.Fields).Where(str => str.Tokens.Any()).ToArray(); + } + + /// Update the patch data when the context changes. + /// Provides access to contextual tokens. + /// Returns whether the patch data changed. + public override bool UpdateContext(IContext context) + { + bool changed = base.UpdateContext(context); + + // We need to update all token strings once. After this first time, we can skip + // updating any immutable tokens. + if (this.IsFirstUpdate) + { + this.IsFirstUpdate = false; + foreach (TokenString str in this.GetTokenStrings(this.Records, this.Fields)) + changed |= str.UpdateContext(context); + } + else + { + foreach (TokenString str in this.MutableTokenStrings) + changed |= str.UpdateContext(context); + } + + return changed; + } + + /// Get the tokens used by this patch in its fields. + public override IEnumerable GetTokensUsed() + { + if (this.MutableTokenStrings.Length == 0) + return base.GetTokensUsed(); + + return base + .GetTokensUsed() + .Union(this.MutableTokenStrings.SelectMany(p => p.Tokens)); + } + + /// Apply the patch to a loaded asset. + /// The asset type. + /// The asset to edit. + /// The current patch type doesn't support editing assets. + public override void Edit(IAssetData asset) + { + // validate + if (!typeof(T).IsGenericType || typeof(T).GetGenericTypeDefinition() != typeof(Dictionary<,>)) + { + this.Monitor.Log($"Can't apply data patch \"{this.LogName}\" to {this.TargetAsset}: this file isn't a data file (found {(typeof(T) == typeof(Texture2D) ? "image" : typeof(T).Name)}).", LogLevel.Warn); + return; + } + + // get dictionary's key type + Type keyType = typeof(T).GetGenericArguments().FirstOrDefault(); + if (keyType == null) + throw new InvalidOperationException("Can't parse the asset's dictionary key type."); + + // get underlying apply method + MethodInfo method = this.GetType().GetMethod(nameof(this.ApplyImpl), BindingFlags.Instance | BindingFlags.NonPublic); + if (method == null) + throw new InvalidOperationException("Can't fetch the internal apply method."); + + // invoke method + method + .MakeGenericMethod(keyType) + .Invoke(this, new object[] { asset }); + } + + + /********* + ** Private methods + *********/ + /// Get all token strings in the given data. + /// The data records to edit. + /// The data fields to edit. + private IEnumerable GetTokenStrings(IEnumerable records, IEnumerable fields) + { + foreach (TokenString tokenStr in records.SelectMany(p => p.GetTokenStrings())) + yield return tokenStr; + foreach (TokenString tokenStr in fields.SelectMany(p => p.GetTokenStrings())) + yield return tokenStr; + } + + /// Apply the patch to an asset. + /// The dictionary key type. + /// The asset to edit. + private void ApplyImpl(IAssetData asset) + { + IDictionary data = asset.AsDictionary().Data; + + // apply records + if (this.Records != null) + { + foreach (EditDataPatchRecord record in this.Records) + { + TKey key = (TKey)Convert.ChangeType(record.Key.Value, typeof(TKey)); + if (record.Value.Value != null) + data[key] = record.Value.Value; + else + data.Remove(key); + } + } + + // apply fields + if (this.Fields != null) + { + foreach (var recordGroup in this.Fields.GroupByIgnoreCase(p => p.Key.Value)) + { + TKey key = (TKey)Convert.ChangeType(recordGroup.Key, typeof(TKey)); + if (!data.ContainsKey(key)) + { + this.Monitor.Log($"Can't apply data patch \"{this.LogName}\" to {this.TargetAsset}: there's no record matching key '{key}' under {nameof(PatchConfig.Fields)}.", LogLevel.Warn); + continue; + } + + string[] actualFields = data[key].Split('/'); + foreach (EditDataPatchField field in recordGroup) + { + if (field.FieldIndex < 0 || field.FieldIndex > actualFields.Length - 1) + { + this.Monitor.Log($"Can't apply data field \"{this.LogName}\" to {this.TargetAsset}: record '{key}' under {nameof(PatchConfig.Fields)} has no field with index {field.FieldIndex} (must be 0 to {actualFields.Length - 1}).", LogLevel.Warn); + continue; + } + + actualFields[field.FieldIndex] = field.Value.Value; + } + + data[key] = string.Join("/", actualFields); + } + } + } + } +} diff --git a/Mods/ContentPatcher/Framework/Patches/EditDataPatchField.cs b/Mods/ContentPatcher/Framework/Patches/EditDataPatchField.cs new file mode 100644 index 000000000..ae34ab5dd --- /dev/null +++ b/Mods/ContentPatcher/Framework/Patches/EditDataPatchField.cs @@ -0,0 +1,43 @@ +using System.Collections.Generic; +using ContentPatcher.Framework.Conditions; + +namespace ContentPatcher.Framework.Patches +{ + /// An specific field in a data file to change. + internal class EditDataPatchField + { + /********* + ** Accessors + *********/ + /// The unique key for the entry in the data file. + public TokenString Key { get; } + + /// The field index to change. + public int FieldIndex { get; } + + /// The entry value to set. + public TokenString Value { get; } + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The unique key for the entry in the data file. + /// The field number to change. + /// The entry value to set. + public EditDataPatchField(TokenString key, int field, TokenString value) + { + this.Key = key; + this.FieldIndex = field; + this.Value = value; + } + + /// Get all token strings used in the record. + public IEnumerable GetTokenStrings() + { + yield return this.Key; + yield return this.Value; + } + } +} diff --git a/Mods/ContentPatcher/Framework/Patches/EditDataPatchRecord.cs b/Mods/ContentPatcher/Framework/Patches/EditDataPatchRecord.cs new file mode 100644 index 000000000..ededaa27d --- /dev/null +++ b/Mods/ContentPatcher/Framework/Patches/EditDataPatchRecord.cs @@ -0,0 +1,38 @@ +using System.Collections.Generic; +using ContentPatcher.Framework.Conditions; + +namespace ContentPatcher.Framework.Patches +{ + /// An entry in a data file to change. + internal class EditDataPatchRecord + { + /********* + ** Accessors + *********/ + /// The unique key for the entry in the data file. + public TokenString Key { get; } + + /// The entry value to set. + public TokenString Value { get; } + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The unique key for the entry in the data file. + /// The entry value to set. + public EditDataPatchRecord(TokenString key, TokenString value) + { + this.Key = key; + this.Value = value; + } + + /// Get all token strings used in the record. + public IEnumerable GetTokenStrings() + { + yield return this.Key; + yield return this.Value; + } + } +} diff --git a/Mods/ContentPatcher/Framework/Patches/EditImagePatch.cs b/Mods/ContentPatcher/Framework/Patches/EditImagePatch.cs new file mode 100644 index 000000000..081d32501 --- /dev/null +++ b/Mods/ContentPatcher/Framework/Patches/EditImagePatch.cs @@ -0,0 +1,129 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using ContentPatcher.Framework.Conditions; +using ContentPatcher.Framework.Tokens; +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Graphics; +using StardewModdingAPI; +using StardewValley; + +namespace ContentPatcher.Framework.Patches +{ + /// Metadata for an asset that should be patched with a new image. + internal class EditImagePatch : Patch + { + /********* + ** Fields + *********/ + /// Encapsulates monitoring and logging. + private readonly IMonitor Monitor; + + /// The asset key to load from the content pack instead. + private readonly TokenString FromLocalAsset; + + /// The sprite area from which to read an image. + private readonly Rectangle? FromArea; + + /// The sprite area to overwrite. + private readonly Rectangle? ToArea; + + /// Indicates how the image should be patched. + private readonly PatchMode PatchMode; + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// A unique name for this patch shown in log messages. + /// The content pack which requested the patch. + /// The normalised asset name to intercept. + /// The conditions which determine whether this patch should be applied. + /// The asset key to load from the content pack instead. + /// The sprite area from which to read an image. + /// The sprite area to overwrite. + /// Indicates how the image should be patched. + /// Encapsulates monitoring and logging. + /// Normalise an asset name. + public EditImagePatch(string logName, ManagedContentPack contentPack, TokenString assetName, ConditionDictionary conditions, TokenString fromLocalAsset, Rectangle fromArea, Rectangle toArea, PatchMode patchMode, IMonitor monitor, Func normaliseAssetName) + : base(logName, PatchType.EditImage, contentPack, assetName, conditions, normaliseAssetName) + { + this.FromLocalAsset = fromLocalAsset; + this.FromArea = fromArea != Rectangle.Empty ? fromArea : null as Rectangle?; + this.ToArea = toArea != Rectangle.Empty ? toArea : null as Rectangle?; + this.PatchMode = patchMode; + this.Monitor = monitor; + } + + /// Update the patch data when the context changes. + /// The condition context. + /// Returns whether the patch data changed. + public override bool UpdateContext(IContext context) + { + bool localAssetChanged = this.FromLocalAsset.UpdateContext(context); + return base.UpdateContext(context) || localAssetChanged; + } + + /// Apply the patch to a loaded asset. + /// The asset type. + /// The asset to edit. + public override void Edit(IAssetData asset) + { + // validate + if (typeof(T) != typeof(Texture2D)) + { + this.Monitor.Log($"Can't apply image patch \"{this.LogName}\" to {this.TargetAsset}: this file isn't an image file (found {typeof(T)}).", LogLevel.Warn); + return; + } + + // fetch data + Texture2D source = this.ContentPack.Load(this.FromLocalAsset.Value); + Rectangle sourceArea = this.FromArea ?? new Rectangle(0, 0, source.Width, source.Height); + Rectangle targetArea = this.ToArea ?? new Rectangle(0, 0, sourceArea.Width, sourceArea.Height); + IAssetDataForImage editor = asset.AsImage(); + + // validate error conditions + if (sourceArea.X < 0 || sourceArea.Y < 0 || sourceArea.Width < 0 || sourceArea.Height < 0) + { + this.Monitor.Log($"Can't apply image patch \"{this.LogName}\": source area (X:{sourceArea.X}, Y:{sourceArea.Y}, Width:{sourceArea.Width}, Height:{sourceArea.Height}) has negative values, which isn't valid.", LogLevel.Error); + return; + } + if (targetArea.X < 0 || targetArea.Y < 0 || targetArea.Width < 0 || targetArea.Height < 0) + { + this.Monitor.Log($"Can't apply image patch \"{this.LogName}\": target area (X:{targetArea.X}, Y:{targetArea.Y}, Width:{targetArea.Width}, Height:{targetArea.Height}) has negative values, which isn't valid.", LogLevel.Error); + return; + } + if (targetArea.Right > editor.Data.Width) + { + this.Monitor.Log($"Can't apply image patch \"{this.LogName}\": target area (X:{targetArea.X}, Y:{targetArea.Y}, Width:{targetArea.Width}, Height:{targetArea.Height}) extends past the right edge of the image (Width:{editor.Data.Width}), which isn't allowed. Patches can only extend the tilesheet downwards.", LogLevel.Error); + return; + } + if (sourceArea.Width != targetArea.Width || sourceArea.Height != targetArea.Height) + { + string sourceAreaLabel = this.FromArea.HasValue ? $"{nameof(this.FromArea)}" : "source image"; + string targetAreaLabel = this.ToArea.HasValue ? $"{nameof(this.ToArea)}" : "target image"; + this.Monitor.Log($"Can't apply image patch \"{this.LogName}\": {sourceAreaLabel} size (Width:{sourceArea.Width}, Height:{sourceArea.Height}) doesn't match {targetAreaLabel} size (Width:{targetArea.Width}, Height:{targetArea.Height}).", LogLevel.Error); + return; + } + + // extend tilesheet if needed + if (targetArea.Bottom > editor.Data.Height) + { + Texture2D original = editor.Data; + Texture2D texture = new Texture2D(Game1.graphics.GraphicsDevice, original.Width, targetArea.Bottom); + editor.ReplaceWith(texture); + editor.PatchImage(original); + } + + // apply source image + editor.PatchImage(source, sourceArea, this.ToArea, this.PatchMode); + } + + /// Get the tokens used by this patch in its fields. + public override IEnumerable GetTokensUsed() + { + return base.GetTokensUsed().Union(this.FromLocalAsset.Tokens); + } + } +} diff --git a/Mods/ContentPatcher/Framework/Patches/IPatch.cs b/Mods/ContentPatcher/Framework/Patches/IPatch.cs new file mode 100644 index 000000000..3c50775e0 --- /dev/null +++ b/Mods/ContentPatcher/Framework/Patches/IPatch.cs @@ -0,0 +1,68 @@ +using System.Collections.Generic; +using ContentPatcher.Framework.Conditions; +using ContentPatcher.Framework.Tokens; +using StardewModdingAPI; + +namespace ContentPatcher.Framework.Patches +{ + /// A patch which can be applied to an asset. + internal interface IPatch + { + /********* + ** Accessors + *********/ + /// A unique name for this patch shown in log messages. + string LogName { get; } + + /// The patch type. + PatchType Type { get; } + + /// The content pack which requested the patch. + ManagedContentPack ContentPack { get; } + + /// The asset key to load from the content pack instead. + TokenString FromLocalAsset { get; } + + /// The normalised asset name to intercept. + string TargetAsset { get; } + + /// The raw asset name to intercept, including tokens. + TokenString RawTargetAsset { get; } + + /// The conditions which determine whether this patch should be applied. + ConditionDictionary Conditions { get; } + + /// Whether this patch should be applied in the latest context. + bool MatchesContext { get; } + + /// Whether this patch is valid if is true. + bool IsValidInContext { get; } + + /// Whether the patch is currently applied to the target asset. + bool IsApplied { get; set; } + + + /********* + ** Public methods + *********/ + /// Update the patch data when the context changes. + /// Provides access to contextual tokens. + /// Returns whether the patch data changed. + bool UpdateContext(IContext context); + + /// Load the initial version of the asset. + /// The asset type. + /// The asset to load. + /// The current patch type doesn't support loading assets. + T Load(IAssetInfo asset); + + /// Apply the patch to a loaded asset. + /// The asset type. + /// The asset to edit. + /// The current patch type doesn't support editing assets. + void Edit(IAssetData asset); + + /// Get the tokens used by this patch in its fields. + IEnumerable GetTokensUsed(); + } +} diff --git a/Mods/ContentPatcher/Framework/Patches/LoadPatch.cs b/Mods/ContentPatcher/Framework/Patches/LoadPatch.cs new file mode 100644 index 000000000..a3c83fa19 --- /dev/null +++ b/Mods/ContentPatcher/Framework/Patches/LoadPatch.cs @@ -0,0 +1,65 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using ContentPatcher.Framework.Conditions; +using ContentPatcher.Framework.Tokens; +using Microsoft.Xna.Framework.Graphics; +using StardewModdingAPI; + +namespace ContentPatcher.Framework.Patches +{ + /// Metadata for an asset that should be replaced with a content pack file. + internal class LoadPatch : Patch + { + /********* + ** Public methods + *********/ + /// Construct an instance. + /// A unique name for this patch shown in log messages. + /// The content pack which requested the patch. + /// The normalised asset name to intercept. + /// The conditions which determine whether this patch should be applied. + /// The asset key to load from the content pack instead. + /// Normalise an asset name. + public LoadPatch(string logName, ManagedContentPack contentPack, TokenString assetName, ConditionDictionary conditions, TokenString localAsset, Func normaliseAssetName) + : base(logName, PatchType.Load, contentPack, assetName, conditions, normaliseAssetName) + { + this.FromLocalAsset = localAsset; + } + + /// Load the initial version of the asset. + /// The asset to load. + public override T Load(IAssetInfo asset) + { + T data = this.ContentPack.Load(this.FromLocalAsset.Value); + return (data as object) is Texture2D texture + ? (T)(object)this.CloneTexture(texture) + : data; + } + + /// Get the tokens used by this patch in its fields. + public override IEnumerable GetTokensUsed() + { + return base.GetTokensUsed().Union(this.FromLocalAsset.Tokens); + } + + + /********* + ** Private methods + *********/ + /// Clone a texture. + /// The texture to clone. + /// Cloning a texture is necessary when loading to avoid having it shared between different content managers, which can lead to undesirable effects like two players having synchronised texture changes. + private Texture2D CloneTexture(Texture2D source) + { + // get data + int[] pixels = new int[source.Width * source.Height]; + source.GetData(pixels); + + // create clone + Texture2D target = new Texture2D(source.GraphicsDevice, source.Width, source.Height); + target.SetData(pixels); + return target; + } + } +} diff --git a/Mods/ContentPatcher/Framework/Patches/Patch.cs b/Mods/ContentPatcher/Framework/Patches/Patch.cs new file mode 100644 index 000000000..02c009cb4 --- /dev/null +++ b/Mods/ContentPatcher/Framework/Patches/Patch.cs @@ -0,0 +1,136 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using ContentPatcher.Framework.Conditions; +using ContentPatcher.Framework.Tokens; +using StardewModdingAPI; + +namespace ContentPatcher.Framework.Patches +{ + /// Metadata for a conditional patch. + internal abstract class Patch : IPatch + { + /********* + ** Fields + *********/ + /// Normalise an asset name. + private readonly Func NormaliseAssetName; + + + /********* + ** Accessors + *********/ + /// The last context used to update this patch. + protected IContext LastContext { get; private set; } + + /// A unique name for this patch shown in log messages. + public string LogName { get; } + + /// The patch type. + public PatchType Type { get; } + + /// The content pack which requested the patch. + public ManagedContentPack ContentPack { get; } + + /// The raw asset key to intercept (if applicable), including tokens. + public TokenString FromLocalAsset { get; protected set; } + + /// The normalised asset name to intercept. + public string TargetAsset { get; private set; } + + /// The raw asset name to intercept, including tokens. + public TokenString RawTargetAsset { get; } + + /// The conditions which determine whether this patch should be applied. + public ConditionDictionary Conditions { get; } + + /// Whether this patch should be applied in the latest context. + public bool MatchesContext { get; private set; } + + /// Whether this patch is valid if is true. + public bool IsValidInContext { get; protected set; } = true; + + /// Whether the patch is currently applied to the target asset. + public bool IsApplied { get; set; } + + + /********* + ** Public methods + *********/ + /// Update the patch data when the context changes. + /// Provides access to contextual tokens. + /// Returns whether the patch data changed. + public virtual bool UpdateContext(IContext context) + { + this.LastContext = context; + + // update conditions + bool conditionsChanged; + { + bool wasMatch = this.MatchesContext; + this.MatchesContext = + (this.Conditions.Count == 0 || this.Conditions.Values.All(p => p.IsMatch(context))) + && this.GetTokensUsed().All(p => context.Contains(p, enforceContext: true)); + conditionsChanged = wasMatch != this.MatchesContext; + } + // update target asset + bool targetChanged = this.RawTargetAsset.UpdateContext(context); + this.TargetAsset = this.NormaliseAssetName(this.RawTargetAsset.Value); + + // update source asset + bool sourceChanged = false; + if (this.FromLocalAsset != null) + { + sourceChanged = this.FromLocalAsset.UpdateContext(context); + this.IsValidInContext = this.FromLocalAsset.IsReady && this.ContentPack.HasFile(this.FromLocalAsset.Value); + } + + return conditionsChanged || targetChanged || sourceChanged; + } + + /// Load the initial version of the asset. + /// The asset type. + /// The asset to load. + /// The current patch type doesn't support loading assets. + public virtual T Load(IAssetInfo asset) + { + throw new NotSupportedException("This patch type doesn't support loading assets."); + } + + /// Apply the patch to a loaded asset. + /// The asset type. + /// The asset to edit. + /// The current patch type doesn't support editing assets. + public virtual void Edit(IAssetData asset) + { + throw new NotSupportedException("This patch type doesn't support loading assets."); + } + + /// Get the tokens used by this patch in its fields. + public virtual IEnumerable GetTokensUsed() + { + return this.RawTargetAsset.Tokens; + } + + + /********* + ** Protected methods + *********/ + /// Construct an instance. + /// A unique name for this patch shown in log messages. + /// The patch type. + /// The content pack which requested the patch. + /// The normalised asset name to intercept. + /// The conditions which determine whether this patch should be applied. + /// Normalise an asset name. + protected Patch(string logName, PatchType type, ManagedContentPack contentPack, TokenString assetName, ConditionDictionary conditions, Func normaliseAssetName) + { + this.LogName = logName; + this.Type = type; + this.ContentPack = contentPack; + this.RawTargetAsset = assetName; + this.Conditions = conditions; + this.NormaliseAssetName = normaliseAssetName; + } + } +} diff --git a/Mods/ContentPatcher/Framework/RawContentPack.cs b/Mods/ContentPatcher/Framework/RawContentPack.cs new file mode 100644 index 000000000..84bc56a75 --- /dev/null +++ b/Mods/ContentPatcher/Framework/RawContentPack.cs @@ -0,0 +1,40 @@ +using ContentPatcher.Framework.ConfigModels; +using ContentPatcher.Framework.Migrations; +using StardewModdingAPI; + +namespace ContentPatcher.Framework +{ + /// A content pack being loaded. + internal class RawContentPack + { + /********* + ** Accessors + *********/ + /// The managed content pack instance. + public ManagedContentPack ManagedPack { get; } + + /// The raw content configuration for this content pack. + public ContentConfig Content { get; } + + /// The migrations to apply for the content pack version. + public IMigration Migrator { get; } + + /// The content pack's manifest. + public IManifest Manifest => this.ManagedPack.Manifest; + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The managed content pack instance. + /// The raw content configuration for this content pack. + /// The migrations to apply for the content pack version. + public RawContentPack(ManagedContentPack contentPack, ContentConfig content, IMigration migrator) + { + this.ManagedPack = contentPack; + this.Content = content; + this.Migrator = migrator; + } + } +} diff --git a/Mods/ContentPatcher/Framework/TokenManager.cs b/Mods/ContentPatcher/Framework/TokenManager.cs new file mode 100644 index 000000000..ebe432d69 --- /dev/null +++ b/Mods/ContentPatcher/Framework/TokenManager.cs @@ -0,0 +1,232 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using ContentPatcher.Framework.Conditions; +using ContentPatcher.Framework.Constants; +using ContentPatcher.Framework.Tokens; +using ContentPatcher.Framework.Tokens.ValueProviders; +using Pathoschild.Stardew.Common.Utilities; +using StardewModdingAPI; +using StardewModdingAPI.Utilities; +using StardewValley; + +namespace ContentPatcher.Framework +{ + /// Manages the available contextual tokens. + internal class TokenManager : IContext + { + /********* + ** Fields + *********/ + /// The available global tokens. + private readonly GenericTokenContext GlobalContext = new GenericTokenContext(); + + /// The available tokens defined within the context of each content pack. + private readonly Dictionary LocalTokens = new Dictionary(); + + + /********* + ** Accessors + *********/ + /// Whether the basic save info is loaded (including the date, weather, and player info). The in-game locations and world may not exist yet. + public bool IsBasicInfoLoaded { get; set; } + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The content helper from which to load data assets. + /// The installed mod IDs. + public TokenManager(IContentHelper contentHelper, IEnumerable installedMods) + { + foreach (IValueProvider valueProvider in this.GetGlobalValueProviders(contentHelper, installedMods)) + this.GlobalContext.Tokens[new TokenName(valueProvider.Name)] = new GenericToken(valueProvider); + } + + /// Get the tokens which are defined for a specific content pack. This returns a reference to the list, which can be held for a live view of the tokens. If the content pack isn't currently tracked, this will add it. + /// The content pack to manage. + public ModTokenContext TrackLocalTokens(IContentPack contentPack) + { + if (!this.LocalTokens.TryGetValue(contentPack, out ModTokenContext localTokens)) + { + this.LocalTokens[contentPack] = localTokens = new ModTokenContext(this); + foreach (IValueProvider valueProvider in this.GetLocalValueProviders(contentPack)) + localTokens.Add(new GenericToken(valueProvider)); + } + + return localTokens; + } + + /// Update the current context. + public void UpdateContext() + { + foreach (IToken token in this.GlobalContext.Tokens.Values) + { + if (token.IsMutable) + token.UpdateContext(this); + } + + foreach (ModTokenContext localContext in this.LocalTokens.Values) + localContext.UpdateContext(this); + } + + /**** + ** IContext + ****/ + /// Get whether the context contains the given token. + /// The token name. + /// Whether to only consider tokens that are available in the context. + public bool Contains(TokenName name, bool enforceContext) + { + return this.GlobalContext.Contains(name, enforceContext); + } + + /// Get the underlying token which handles a key. + /// The token name. + /// Whether to only consider tokens that are available in the context. + /// Returns the matching token, or null if none was found. + public IToken GetToken(TokenName name, bool enforceContext) + { + return this.GlobalContext.GetToken(name, enforceContext); + } + + /// Get the underlying tokens. + /// Whether to only consider tokens that are available in the context. + public IEnumerable GetTokens(bool enforceContext) + { + return this.GlobalContext.GetTokens(enforceContext); + } + + /// Get the current values of the given token for comparison. + /// The token name. + /// Whether to only consider tokens that are available in the context. + /// Return the values of the matching token, or an empty list if the token doesn't exist. + /// The specified key is null. + public IEnumerable GetValues(TokenName name, bool enforceContext) + { + return this.GlobalContext.GetValues(name, enforceContext); + } + + + /********* + ** Private methods + *********/ + /// Get the global value providers with which to initialise the token manager. + /// The content helper from which to load data assets. + /// The installed mod IDs. + private IEnumerable GetGlobalValueProviders(IContentHelper contentHelper, IEnumerable installedMods) + { + bool NeedsBasicInfo() => this.IsBasicInfoLoaded; + + // installed mods + yield return new ImmutableValueProvider(ConditionType.HasMod.ToString(), new InvariantHashSet(installedMods), canHaveMultipleValues: true); + + // language + yield return new ConditionTypeValueProvider(ConditionType.Language, () => contentHelper.CurrentLocaleConstant.ToString(), allowedValues: Enum.GetNames(typeof(LocalizedContentManager.LanguageCode)).Where(p => p != LocalizedContentManager.LanguageCode.th.ToString())); + + // in-game date + yield return new ConditionTypeValueProvider(ConditionType.Season, () => SDate.Now().Season, NeedsBasicInfo, allowedValues: new[] { "Spring", "Summer", "Fall", "Winter" }); + yield return new ConditionTypeValueProvider(ConditionType.Day, () => SDate.Now().Day.ToString(CultureInfo.InvariantCulture), NeedsBasicInfo, allowedValues: Enumerable.Range(1, 28).Select(p => p.ToString())); + yield return new ConditionTypeValueProvider(ConditionType.DayOfWeek, () => SDate.Now().DayOfWeek.ToString(), NeedsBasicInfo, allowedValues: Enum.GetNames(typeof(DayOfWeek))); + yield return new ConditionTypeValueProvider(ConditionType.Year, () => SDate.Now().Year.ToString(CultureInfo.InvariantCulture), NeedsBasicInfo); + yield return new ConditionTypeValueProvider(ConditionType.DaysPlayed, () => Game1.stats.DaysPlayed.ToString(CultureInfo.InvariantCulture), NeedsBasicInfo); + + // other in-game conditions + yield return new ConditionTypeValueProvider(ConditionType.DayEvent, () => this.GetDayEvent(contentHelper), NeedsBasicInfo); + yield return new ConditionTypeValueProvider(ConditionType.FarmCave, () => this.GetEnum(Game1.player.caveChoice.Value, FarmCaveType.None).ToString(), NeedsBasicInfo); + yield return new ConditionTypeValueProvider(ConditionType.FarmhouseUpgrade, () => Game1.player.HouseUpgradeLevel.ToString(), NeedsBasicInfo); + yield return new ConditionTypeValueProvider(ConditionType.FarmName, () => Game1.player.farmName.Value, NeedsBasicInfo); + yield return new ConditionTypeValueProvider(ConditionType.FarmType, () => this.GetEnum(Game1.whichFarm, FarmType.Custom).ToString(), NeedsBasicInfo); + yield return new ConditionTypeValueProvider(ConditionType.HasFlag, () => this.GetMailFlags(), NeedsBasicInfo); + yield return new ConditionTypeValueProvider(ConditionType.HasSeenEvent, () => this.GetEventsSeen(), NeedsBasicInfo); + yield return new ConditionTypeValueProvider(ConditionType.PlayerGender, () => (Game1.player.IsMale ? Gender.Male : Gender.Female).ToString(), NeedsBasicInfo); + yield return new ConditionTypeValueProvider(ConditionType.PreferredPet, () => (Game1.player.catPerson ? PetType.Cat : PetType.Dog).ToString(), NeedsBasicInfo); + yield return new ConditionTypeValueProvider(ConditionType.PlayerName, () => Game1.player.Name, NeedsBasicInfo); + yield return new ConditionTypeValueProvider(ConditionType.Spouse, () => Game1.player?.spouse, NeedsBasicInfo); + yield return new ConditionTypeValueProvider(ConditionType.Weather, () => this.GetCurrentWeather(), NeedsBasicInfo, allowedValues: Enum.GetNames(typeof(Weather))); + yield return new HasProfessionValueProvider(NeedsBasicInfo); + yield return new HasWalletItemValueProvider(NeedsBasicInfo); + yield return new SkillLevelValueProvider(NeedsBasicInfo); + yield return new VillagerRelationshipValueProvider(); + yield return new VillagerHeartsValueProvider(); + } + + /// Get the local value providers with which to initialise a local context. + /// The content pack for which to get tokens. + private IEnumerable GetLocalValueProviders(IContentPack contentPack) + { + yield return new HasFileValueProvider(contentPack.DirectoryPath); + } + + /// Get a constant for a given value. + /// The constant enum type. + /// The value to convert. + /// The value to use if the value is invalid. + private TEnum GetEnum(int value, TEnum defaultValue) + { + return Enum.IsDefined(typeof(TEnum), value) + ? (TEnum)(object)value + : defaultValue; + } + + /// Get the current weather from the game state. + private string GetCurrentWeather() + { + if (Utility.isFestivalDay(Game1.dayOfMonth, Game1.currentSeason) || (SaveGame.loaded?.weddingToday ?? Game1.weddingToday)) + return Weather.Sun.ToString(); + + if (Game1.isSnowing) + return Weather.Snow.ToString(); + if (RainManager.Instance.isRaining) + return (Game1.isLightning ? Weather.Storm : Weather.Rain).ToString(); + if (SaveGame.loaded?.isDebrisWeather ?? WeatherDebrisManager.Instance.isDebrisWeather) + return Weather.Wind.ToString(); + + return Weather.Sun.ToString(); + } + + /// Get the event IDs seen by the player. + private IEnumerable GetEventsSeen() + { + Farmer player = Game1.player; + if (player == null) + return new string[0]; + + return player.eventsSeen + .OrderBy(p => p) + .Select(p => p.ToString(CultureInfo.InvariantCulture)); + } + + /// Get the letter IDs and mail flags set for the player. + /// See game logic in . + private IEnumerable GetMailFlags() + { + Farmer player = Game1.player; + if (player == null) + return new string[0]; + + return player + .mailReceived + .Union(player.mailForTomorrow) + .Union(player.mailbox); + } + + /// Get the name for today's day event (e.g. wedding or festival) from the game data. + /// The content helper from which to load festival data. + private string GetDayEvent(IContentHelper contentHelper) + { + // marriage + if (SaveGame.loaded?.weddingToday ?? Game1.weddingToday) + return "wedding"; + + // festival + IDictionary festivalDates = contentHelper.Load>("Data\\Festivals\\FestivalDates", ContentSource.GameContent); + if (festivalDates.TryGetValue($"{Game1.currentSeason}{Game1.dayOfMonth}", out string festivalName)) + return festivalName; + + return null; + } + } +} diff --git a/Mods/ContentPatcher/Framework/Tokens/DynamicToken.cs b/Mods/ContentPatcher/Framework/Tokens/DynamicToken.cs new file mode 100644 index 000000000..252879105 --- /dev/null +++ b/Mods/ContentPatcher/Framework/Tokens/DynamicToken.cs @@ -0,0 +1,50 @@ +using ContentPatcher.Framework.Tokens.ValueProviders; +using Pathoschild.Stardew.Common.Utilities; + +namespace ContentPatcher.Framework.Tokens +{ + /// A dynamic token defined by a content pack. + internal class DynamicToken : GenericToken + { + /********* + ** Accessors + *********/ + /// The underlying value provider. + private readonly DynamicTokenValueProvider DynamicValues; + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The token name. + public DynamicToken(TokenName name) + : base(new DynamicTokenValueProvider(name.Key)) + { + this.DynamicValues = (DynamicTokenValueProvider)base.Values; + } + + /// Add a set of possible values. + /// The possible values to add. + public void AddAllowedValues(InvariantHashSet possibleValues) + { + this.DynamicValues.AddAllowedValues(possibleValues); + this.CanHaveMultipleRootValues = this.DynamicValues.CanHaveMultipleValues(); + } + + /// Set the current values. + /// The values to set. + public void SetValue(InvariantHashSet values) + { + this.DynamicValues.SetValue(values); + } + + /// Set whether the token is valid in the current context. + /// The value to set. + public void SetValidInContext(bool validInContext) + { + this.DynamicValues.SetValidInContext(validInContext); + this.IsValidInContext = this.DynamicValues.IsValidInContext; + } + } +} diff --git a/Mods/ContentPatcher/Framework/Tokens/DynamicTokenValue.cs b/Mods/ContentPatcher/Framework/Tokens/DynamicTokenValue.cs new file mode 100644 index 000000000..20ca92468 --- /dev/null +++ b/Mods/ContentPatcher/Framework/Tokens/DynamicTokenValue.cs @@ -0,0 +1,36 @@ +using ContentPatcher.Framework.Conditions; +using Pathoschild.Stardew.Common.Utilities; + +namespace ContentPatcher.Framework.Tokens +{ + /// A conditional value for a dynamic token. + internal class DynamicTokenValue + { + /********* + ** Accessors + *********/ + /// The name of the token whose value to set. + public TokenName Name { get; } + + /// The token value to set. + public InvariantHashSet Value { get; } + + /// The conditions that must match to set this value. + public ConditionDictionary Conditions { get; } + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The name of the token whose value to set. + /// The token value to set. + /// The conditions that must match to set this value. + public DynamicTokenValue(TokenName key, InvariantHashSet value, ConditionDictionary conditions) + { + this.Name = key; + this.Value = value; + this.Conditions = conditions; + } + } +} diff --git a/Mods/ContentPatcher/Framework/Tokens/GenericToken.cs b/Mods/ContentPatcher/Framework/Tokens/GenericToken.cs new file mode 100644 index 000000000..59771756c --- /dev/null +++ b/Mods/ContentPatcher/Framework/Tokens/GenericToken.cs @@ -0,0 +1,239 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using ContentPatcher.Framework.Tokens.ValueProviders; +using Pathoschild.Stardew.Common.Utilities; + +namespace ContentPatcher.Framework.Tokens +{ + /// A combination of one or more value providers. + internal class GenericToken : IToken + { + /********* + ** Fields + *********/ + /// The underlying value provider. + protected IValueProvider Values { get; } + + /// Whether the root token may contain multiple values. + protected bool CanHaveMultipleRootValues { get; set; } + + + /********* + ** Accessors + *********/ + /// The token name. + public TokenName Name { get; } + + /// Whether the value can change after it's initialised. + public bool IsMutable { get; protected set; } = true; + + /// Whether this token recognises subkeys (e.g. Relationship:Abigail is a Relationship token with a Abigail subkey). + public bool CanHaveSubkeys { get; } + + /// Whether this token only allows subkeys (see ). + public bool RequiresSubkeys { get; } + + /// Whether the token is applicable in the current context. + public bool IsValidInContext { get; protected set; } + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The underlying value provider. + public GenericToken(IValueProvider provider) + { + this.Values = provider; + + this.Name = TokenName.Parse(provider.Name); + this.CanHaveSubkeys = provider.AllowsInput; + this.RequiresSubkeys = provider.RequiresInput; + this.CanHaveMultipleRootValues = provider.CanHaveMultipleValues(); + this.IsValidInContext = provider.IsValidInContext; + } + + /// Update the token data when the context changes. + /// The condition context. + /// Returns whether the token data changed. + public virtual void UpdateContext(IContext context) + { + if (this.Values.IsMutable) + { + this.Values.UpdateContext(context); + this.IsValidInContext = this.Values.IsValidInContext; + } + } + + /// Whether the token may return multiple values for the given name. + /// The token name. + public bool CanHaveMultipleValues(TokenName name) + { + return this.Values.CanHaveMultipleValues(name.Subkey); + } + + /// Perform custom validation. + /// The token name to validate. + /// The values to validate. + /// The validation error, if any. + /// Returns whether validation succeeded. + public bool TryValidate(TokenName name, InvariantHashSet values, out string error) + { + // parse data + KeyValuePair[] pairs = this.GetSubkeyValuePairsFor(name, values).ToArray(); + + // restrict to allowed subkeys + if (this.CanHaveSubkeys) + { + InvariantHashSet validKeys = this.GetAllowedSubkeys(); + if (validKeys?.Any() == true) + { + string[] invalidSubkeys = + ( + from pair in pairs + where pair.Key.Subkey != null && !validKeys.Contains(pair.Key.Subkey) + select pair.Key.Subkey + ) + .Distinct() + .ToArray(); + if (invalidSubkeys.Any()) + { + error = $"invalid subkeys ({string.Join(", ", invalidSubkeys)}); expected one of {string.Join(", ", validKeys)}"; + return false; + } + } + } + + // restrict to allowed values + { + InvariantHashSet validValues = this.GetAllowedValues(name); + if (validValues?.Any() == true) + { + string[] invalidValues = + ( + from pair in pairs + where !validValues.Contains(pair.Value) + select pair.Value + ) + .Distinct() + .ToArray(); + if (invalidValues.Any()) + { + error = $"invalid values ({string.Join(", ", invalidValues)}); expected one of {string.Join(", ", validValues)}"; + return false; + } + } + } + + // custom validation + foreach (KeyValuePair pair in pairs) + { + if (!this.Values.TryValidate(pair.Key.Subkey, new InvariantHashSet { pair.Value }, out error)) + return false; + } + + // no issues found + error = null; + return true; + } + + /// Get the current subkeys (if supported). + public virtual IEnumerable GetSubkeys() + { + return this.Values.GetValidInputs()?.Select(input => new TokenName(this.Name.Key, input)); + } + + /// Get the allowed values for a token name (or null if any value is allowed). + /// The key doesn't match this token, or the key does not respect or . + public virtual InvariantHashSet GetAllowedValues(TokenName name) + { + return this.Values.GetAllowedValues(name.Subkey); + } + + /// Get the current token values. + /// The token name to check. + /// The key doesn't match this token, or the key does not respect or . + public virtual IEnumerable GetValues(TokenName name) + { + this.AssertTokenName(name); + return this.Values.GetValues(name.Subkey); + } + + + /********* + ** Protected methods + *********/ + /// Get the allowed subkeys (or null if any value is allowed). + protected virtual InvariantHashSet GetAllowedSubkeys() + { + return this.Values.GetValidInputs(); + } + + /// Get the current token values. + /// The token name to check, if applicable. + /// The key doesn't match this token, or the key does not respect . + protected void AssertTokenName(TokenName? name) + { + if (name == null) + { + // missing subkey + if (this.RequiresSubkeys) + throw new InvalidOperationException($"The '{this.Name}' token requires a subkey."); + } + else + { + // not same root key + if (!this.Name.IsSameRootKey(name.Value)) + throw new InvalidOperationException($"The specified token key ({name}) is not handled by this token ({this.Name})."); + + // no subkey allowed + if (!this.CanHaveSubkeys && name.Value.HasSubkey()) + throw new InvalidOperationException($"The '{this.Name}' token does not allow subkeys (:)."); + } + } + + /// Try to parse a raw case-insensitive string into an enum value. + /// The enum type. + /// The raw string to parse. + /// The resulting enum value. + /// When parsing a numeric value, whether it must match one of the named enum values. + protected bool TryParseEnum(string raw, out TEnum result, bool mustBeNamed = true) where TEnum : struct + { + if (!Enum.TryParse(raw, true, out result)) + return false; + + if (mustBeNamed && !Enum.IsDefined(typeof(TEnum), result)) + return false; + + return true; + } + + /// Get the subkey/value pairs used in the given name and values. + /// The token name to validate. + /// The values to validate. + /// Returns the subkey/value pairs found. If the includes a subkey, the are treated as values of that subkey. Otherwise if is true, then each value is treated as subkey:value (if they contain a colon) or value (with a null subkey). + protected IEnumerable> GetSubkeyValuePairsFor(TokenName name, InvariantHashSet values) + { + // no subkeys in values + if (!this.CanHaveSubkeys || name.HasSubkey()) + { + foreach (string value in values) + yield return new KeyValuePair(name, value); + } + + // possible subkeys in values + else + { + foreach (string value in values) + { + string[] parts = value.Split(new[] { ':' }, 2); + if (parts.Length < 2) + yield return new KeyValuePair(name, parts[0]); + else + yield return new KeyValuePair(new TokenName(name.Key, parts[0]), parts[1]); + } + } + } + } +} diff --git a/Mods/ContentPatcher/Framework/Tokens/IContext.cs b/Mods/ContentPatcher/Framework/Tokens/IContext.cs new file mode 100644 index 000000000..e8f9253e3 --- /dev/null +++ b/Mods/ContentPatcher/Framework/Tokens/IContext.cs @@ -0,0 +1,31 @@ +using System; +using System.Collections.Generic; + +namespace ContentPatcher.Framework.Tokens +{ + /// Provides access to contextual tokens. + internal interface IContext + { + /// Get whether the context contains the given token. + /// The token name. + /// Whether to only consider tokens that are available in the context. + bool Contains(TokenName name, bool enforceContext); + + /// Get the underlying token which handles a key. + /// The token name. + /// Whether to only consider tokens that are available in the context. + /// Returns the matching token, or null if none was found. + IToken GetToken(TokenName name, bool enforceContext); + + /// Get the underlying tokens. + /// Whether to only consider tokens that are available in the context. + IEnumerable GetTokens(bool enforceContext); + + /// Get the current values of the given token for comparison. + /// The token name. + /// Whether to only consider tokens that are available in the context. + /// Return the values of the matching token, or an empty list if the token doesn't exist. + /// The specified key is null. + IEnumerable GetValues(TokenName name, bool enforceContext); + } +} diff --git a/Mods/ContentPatcher/Framework/Tokens/IToken.cs b/Mods/ContentPatcher/Framework/Tokens/IToken.cs new file mode 100644 index 000000000..dec7a2b66 --- /dev/null +++ b/Mods/ContentPatcher/Framework/Tokens/IToken.cs @@ -0,0 +1,60 @@ +using System; +using System.Collections.Generic; +using Pathoschild.Stardew.Common.Utilities; + +namespace ContentPatcher.Framework.Tokens +{ + /// A token whose value may change depending on the current context. + internal interface IToken + { + /********* + ** Accessors + *********/ + /// The token name. + TokenName Name { get; } + + /// Whether the token is applicable in the current context. + bool IsValidInContext { get; } + + /// Whether the value can change after it's initialised. + bool IsMutable { get; } + + /// Whether this token recognises subkeys (e.g. Relationship:Abigail is a Relationship token with a Abigail subkey). + bool CanHaveSubkeys { get; } + + /// Whether this token only allows subkeys (see ). + bool RequiresSubkeys { get; } + + + /********* + ** Public methods + *********/ + /// Update the token data when the context changes. + /// The condition context. + /// Returns whether the token data changed. + void UpdateContext(IContext context); + + /// Whether the token may return multiple values for the given name. + /// The token name. + bool CanHaveMultipleValues(TokenName name); + + /// Perform custom validation. + /// The token name to validate. + /// The values to validate. + /// The validation error, if any. + /// Returns whether validation succeeded. + bool TryValidate(TokenName name, InvariantHashSet values, out string error); + + /// Get the current subkeys (if supported). + IEnumerable GetSubkeys(); + + /// Get the allowed values for a token name (or null if any value is allowed). + /// The key doesn't match this token, or the key does not respect or . + InvariantHashSet GetAllowedValues(TokenName name); + + /// Get the current token values. + /// The token name to check. + /// The key doesn't match this token, or the key does not respect or . + IEnumerable GetValues(TokenName name); + } +} diff --git a/Mods/ContentPatcher/Framework/Tokens/ImmutableToken.cs b/Mods/ContentPatcher/Framework/Tokens/ImmutableToken.cs new file mode 100644 index 000000000..c171ee1b3 --- /dev/null +++ b/Mods/ContentPatcher/Framework/Tokens/ImmutableToken.cs @@ -0,0 +1,20 @@ +using ContentPatcher.Framework.Tokens.ValueProviders; +using Pathoschild.Stardew.Common.Utilities; + +namespace ContentPatcher.Framework.Tokens +{ + /// A tokens whose values don't change after it's initialised. + internal class ImmutableToken : GenericToken + { + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The token name. + /// Get the current token values. + /// The allowed values (or null if any value is allowed). + /// Whether the root may contain multiple values (or null to set it based on the given values). + public ImmutableToken(string name, InvariantHashSet values, InvariantHashSet allowedValues = null, bool? canHaveMultipleValues = null) + : base(new ImmutableValueProvider(name, values, allowedValues, canHaveMultipleValues)) { } + } +} diff --git a/Mods/ContentPatcher/Framework/Tokens/TokenName.cs b/Mods/ContentPatcher/Framework/Tokens/TokenName.cs new file mode 100644 index 000000000..ecdd7e495 --- /dev/null +++ b/Mods/ContentPatcher/Framework/Tokens/TokenName.cs @@ -0,0 +1,158 @@ +using System; +using ContentPatcher.Framework.Conditions; + +namespace ContentPatcher.Framework.Tokens +{ + /// Represents a token key and subkey if applicable (e.g. Relationship:Abigail is token key Relationship and subkey Abigail). + internal struct TokenName : IEquatable, IComparable + { + /********* + ** Accessors + *********/ + /// The token type. + public string Key { get; } + + /// The token subkey indicating which in-game object the condition type applies to, if applicable. For example, the NPC name when is . + public string Subkey { get; } + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The condition type. + /// A unique key indicating which in-game object the condition type applies to. For example, the NPC name when is . + public TokenName(string tokenKey, string subkey = null) + { + this.Key = tokenKey?.Trim(); + this.Subkey = subkey?.Trim(); + } + + /// Construct an instance. + /// The condition type. + /// A unique key indicating which in-game object the condition type applies to. For example, the NPC name when is . + public TokenName(ConditionType tokenKey, string subkey = null) + : this(tokenKey.ToString(), subkey) { } + + /// Get a string representation for this instance. + public override string ToString() + { + return this.HasSubkey() + ? $"{this.Key}:{this.Subkey}" + : this.Key; + } + + /// Get whether this key has the same root as another. + /// The other key to check. + public bool IsSameRootKey(TokenName other) + { + if (this.Key == null) + return other.Key == null; + + return this.Key.Equals(other.Key, StringComparison.InvariantCultureIgnoreCase); + } + + /// Whether this token key specifies a subkey. + public bool HasSubkey() + { + return !string.IsNullOrWhiteSpace(this.Subkey); + } + + /// Try to parse the as a global condition type. + /// The parsed condition type, if applicable. + public bool TryGetConditionType(out ConditionType type) + { + return Enum.TryParse(this.Key, true, out type); + } + + /// Get the root token (without the ). + public TokenName GetRoot() + { + return this.HasSubkey() + ? new TokenName(this.Key) + : this; + } + + /**** + ** IEquatable + ****/ + /// Get whether the current object is equal to another object of the same type. + /// An object to compare with this object. + public bool Equals(TokenName other) + { + return this.CompareTo(other) == 0; + } + + /// Get whether this instance and a specified object are equal. + /// The object to compare with the current instance. + public override bool Equals(object obj) + { + return obj is TokenName other && this.Equals(other); + } + + /// Get the hash code for this instance. + public override int GetHashCode() + { + return this.ToString().ToLowerInvariant().GetHashCode(); + } + + /**** + ** IComparable + ****/ + /// Compares the current instance with another object of the same type and returns an integer that indicates whether the current instance precedes, follows, or occurs in the same position in the sort order as the other object. + /// An object to compare with this instance. + /// A value that indicates the relative order of the objects being compared. The return value has these meanings: Value Meaning Less than zero This instance precedes in the sort order. Zero This instance occurs in the same position in the sort order as . Greater than zero This instance follows in the sort order. + public int CompareTo(object obj) + { + return string.Compare(this.ToString(), obj?.ToString(), StringComparison.OrdinalIgnoreCase); + } + + /**** + ** Static parsing + ****/ + /// Parse a raw string into a condition key if it's valid. + /// The raw string. + /// Returns true if was successfully parsed, else false. + public static TokenName Parse(string raw) + { + if (string.IsNullOrWhiteSpace(raw)) + throw new ArgumentNullException(nameof(raw)); + + // extract parts + string key; + string subkey; + { + string[] parts = raw.Trim().Split(new[] { ':' }, 2); + + key = parts[0].Trim(); + if (key == "") + throw new ArgumentException($"The main key in '{raw}' can't be blank."); + + subkey = parts.Length == 2 ? parts[1].Trim() : null; + if (subkey == "") + subkey = null; + } + + // create instance + return new TokenName(key, subkey); + } + + /// Parse a raw string into a condition key if it's valid. + /// The raw string. + /// The parsed condition key. + /// Returns true if was successfully parsed, else false. + public static bool TryParse(string raw, out TokenName key) + { + try + { + key = TokenName.Parse(raw); + return true; + } + catch + { + key = default(TokenName); + return false; + } + } + } +} diff --git a/Mods/ContentPatcher/Framework/Tokens/ValueProviders/BaseValueProvider.cs b/Mods/ContentPatcher/Framework/Tokens/ValueProviders/BaseValueProvider.cs new file mode 100644 index 000000000..ac1d4bbf3 --- /dev/null +++ b/Mods/ContentPatcher/Framework/Tokens/ValueProviders/BaseValueProvider.cs @@ -0,0 +1,248 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using ContentPatcher.Framework.Conditions; +using Pathoschild.Stardew.Common.Utilities; + +namespace ContentPatcher.Framework.Tokens.ValueProviders +{ + /// The base class for a value provider. + internal abstract class BaseValueProvider : IValueProvider + { + /********* + ** Fields + *********/ + /// Whether multiple values may exist when no input is provided. + protected bool CanHaveMultipleValuesForRoot { get; set; } + + /// Whether multiple values may exist when an input argument is provided. + protected bool CanHaveMultipleValuesForInput { get; set; } + + + /********* + ** Accessors + *********/ + /// The value provider name. + public string Name { get; } + + /// Whether the provided values can change after the provider is initialised. + public bool IsMutable { get; protected set; } = true; + + /// Whether the value provider allows an input argument (e.g. an NPC name for a relationship token). + public bool AllowsInput { get; private set; } + + /// Whether the value provider requires an input argument to work, and does not provide values without it (see ). + public bool RequiresInput { get; private set; } + + /// Whether values exist in the current context. + public bool IsValidInContext { get; protected set; } + + + /********* + ** Public methods + *********/ + /// Update the underlying values. + /// The condition context. + /// Returns whether the values changed. + public virtual void UpdateContext(IContext context) { } + + /// Whether the value provider may return multiple values for the given input. + /// The input argument, if applicable. + public bool CanHaveMultipleValues(string input = null) + { + return input != null + ? this.CanHaveMultipleValuesForInput + : this.CanHaveMultipleValuesForRoot; + } + + /// Validate that the provided values are valid for the input argument (regardless of whether they match). + /// The input argument, if applicable. + /// The values to validate. + /// The validation error, if any. + /// Returns whether validation succeeded. + public bool TryValidate(string input, InvariantHashSet values, out string error) + { + // parse data + KeyValuePair[] pairs = this.GetInputValuePairs(input, values).ToArray(); + + // restrict to allowed input + if (this.AllowsInput) + { + InvariantHashSet validInputs = this.GetValidInputs(); + if (validInputs?.Any() == true) + { + string[] invalidInputs = + ( + from pair in pairs + where pair.Key != null && !validInputs.Contains(pair.Key) + select pair.Key + ) + .Distinct() + .ToArray(); + if (invalidInputs.Any()) + { + error = $"invalid input arguments ({string.Join(", ", invalidInputs)}), expected any of {string.Join(", ", validInputs)}"; + return false; + } + } + } + + // restrict to allowed values + { + InvariantHashSet validValues = this.GetAllowedValues(input); + if (validValues?.Any() == true) + { + string[] invalidValues = + ( + from pair in pairs + where !validValues.Contains(pair.Value) + select pair.Value + ) + .Distinct() + .ToArray(); + if (invalidValues.Any()) + { + error = $"invalid values ({string.Join(", ", invalidValues)}); expected one of {string.Join(", ", validValues)}"; + return false; + } + } + } + + // custom validation + foreach (KeyValuePair pair in pairs) + { + if (!this.TryValidate(pair.Key, pair.Value, out error)) + return false; + } + + // no issues found + error = null; + return true; + } + + /// Get the set of valid input arguments if restricted, or an empty collection if unrestricted. + public virtual InvariantHashSet GetValidInputs() + { + return new InvariantHashSet(); + } + + /// Get the allowed values for an input argument (or null if any value is allowed). + /// The input argument, if applicable. + /// The input argument doesn't match this value provider, or does not respect or . + public virtual InvariantHashSet GetAllowedValues(string input) + { + return null; + } + + /// Get the current values. + /// The input argument, if applicable. + /// The input argument doesn't match this value provider, or does not respect or . + public virtual IEnumerable GetValues(string input) + { + this.AssertInputArgument(input); + yield break; + } + + + /********* + ** Protected methods + *********/ + /// Construct an instance. + /// The value provider name. + /// Whether the root value provider may contain multiple values. + protected BaseValueProvider(string name, bool canHaveMultipleValuesForRoot) + { + this.Name = name; + this.CanHaveMultipleValuesForRoot = canHaveMultipleValuesForRoot; + } + + /// Construct an instance. + /// The value provider name. + /// Whether the root value provider may contain multiple values. + protected BaseValueProvider(ConditionType type, bool canHaveMultipleValuesForRoot) + : this(type.ToString(), canHaveMultipleValuesForRoot) { } + + /// Validate that the provided value is valid for an input argument (regardless of whether they match). + /// The input argument, if applicable. + /// The value to validate. + /// The validation error, if any. + /// Returns whether validation succeeded. + protected virtual bool TryValidate(string input, string value, out string error) + { + error = null; + return true; + } + + /// Enable input arguments for this value provider. + /// Whether an input argument is required when using this value provider. + /// Whether the value provider may return multiple values for an input argument. + protected void EnableInputArguments(bool required, bool canHaveMultipleValues) + { + this.AllowsInput = true; + this.RequiresInput = required; + this.CanHaveMultipleValuesForInput = canHaveMultipleValues; + } + + /// Assert that an input argument is valid for the value provider. + /// The input argument to check, if applicable. + /// The input argument doesn't match this value provider, or does not respect or . + protected void AssertInputArgument(string input) + { + if (input == null) + { + // missing input argument + if (this.RequiresInput) + throw new InvalidOperationException($"The '{this.Name}' token requires an input argument."); + } + else + { + // no subkey allowed + if (!this.AllowsInput) + throw new InvalidOperationException($"The '{this.Name}' token does not allow input arguments."); + } + } + + /// Try to parse a raw case-insensitive string into an enum value. + /// The enum type. + /// The raw string to parse. + /// The resulting enum value. + /// When parsing a numeric value, whether it must match one of the named enum values. + protected bool TryParseEnum(string raw, out TEnum result, bool mustBeNamed = true) where TEnum : struct + { + if (!Enum.TryParse(raw, true, out result)) + return false; + + if (mustBeNamed && !Enum.IsDefined(typeof(TEnum), result)) + return false; + + return true; + } + + /// Parse a user-defined set of values for input/value pairs. For example, "Abigail:10" for a relationship token would be parsed as input argument 'Abigail' with value '10'. + /// The current input argument, if applicable. + /// The values to parse. + /// Returns the input/value pairs found. If is non-null, the are treated as values for that input argument. Otherwise if is true, then each value is treated as input:value (if they contain a colon) or value (with a null input). + protected IEnumerable> GetInputValuePairs(string input, InvariantHashSet values) + { + // no input arguments in values + if (!this.AllowsInput || input != null) + { + foreach (string value in values) + yield return new KeyValuePair(input, value); + } + + // possible input arguments in values + else + { + foreach (string value in values) + { + string[] parts = value.Split(new[] { ':' }, 2); + if (parts.Length < 2) + yield return new KeyValuePair(input, parts[0]); + else + yield return new KeyValuePair(parts[0], parts[1]); + } + } + } + } +} diff --git a/Mods/ContentPatcher/Framework/Tokens/ValueProviders/ConditionTypeValueProvider.cs b/Mods/ContentPatcher/Framework/Tokens/ValueProviders/ConditionTypeValueProvider.cs new file mode 100644 index 000000000..c39da86de --- /dev/null +++ b/Mods/ContentPatcher/Framework/Tokens/ValueProviders/ConditionTypeValueProvider.cs @@ -0,0 +1,90 @@ +using System; +using System.Collections.Generic; +using ContentPatcher.Framework.Conditions; +using Pathoschild.Stardew.Common.Utilities; + +namespace ContentPatcher.Framework.Tokens.ValueProviders +{ + /// A value provider for a built-in condition whose value may change with the context. + internal class ConditionTypeValueProvider : BaseValueProvider + { + /********* + ** Fields + *********/ + /// The allowed root values (or null if any value is allowed). + private readonly InvariantHashSet AllowedRootValues; + + /// Get the current values. + private readonly Func FetchValues; + + /// Get whether the value provider is applicable in the current context, or null if it's always applicable. + private readonly Func IsValidInContextImpl; + + /// The values as of the last context update. + private readonly InvariantHashSet Values = new InvariantHashSet(); + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The condition type. + /// Get the current values. + /// Get whether the value provider is applicable in the current context, or null if it's always applicable. + /// Whether the root may contain multiple values. + /// The allowed values (or null if any value is allowed). + public ConditionTypeValueProvider(ConditionType type, Func> values, Func isValidInContext = null, bool canHaveMultipleValues = false, IEnumerable allowedValues = null) + : base(type, canHaveMultipleValues) + { + this.IsValidInContextImpl = isValidInContext; + this.AllowedRootValues = allowedValues != null ? new InvariantHashSet(allowedValues) : null; + this.FetchValues = () => new InvariantHashSet(values()); + this.EnableInputArguments(required: false, canHaveMultipleValues: false); + } + + /// Construct an instance. + /// The condition type. + /// Get the current value. + /// Get whether the value provider is applicable in the current context, or null if it's always applicable. + /// Whether the root may contain multiple values. + /// The allowed values (or null if any value is allowed). + public ConditionTypeValueProvider(ConditionType type, Func value, Func isValidInContext = null, bool canHaveMultipleValues = false, IEnumerable allowedValues = null) + : this(type, () => new[] { value() }, isValidInContext, canHaveMultipleValues, allowedValues) { } + + /// Update the underlying values. + /// The condition context. + /// Returns whether the values changed. + public override void UpdateContext(IContext context) + { + this.IsValidInContext = this.IsValidInContextImpl == null || this.IsValidInContextImpl(); + this.Values.Clear(); + if (this.IsValidInContext) + { + foreach (string value in this.FetchValues()) + this.Values.Add(value); + } + } + + /// Get the allowed values for an input argument (or null if any value is allowed). + /// The input argument, if applicable. + /// The input argument doesn't match this value provider, or does not respect or . + public override InvariantHashSet GetAllowedValues(string input) + { + return input != null + ? InvariantHashSet.Boolean() + : this.AllowedRootValues; + } + + /// Get the current values. + /// The input argument, if applicable. + /// The input argument doesn't match this value provider, or does not respect or . + public override IEnumerable GetValues(string input) + { + this.AssertInputArgument(input); + + if (input != null) + return new[] { this.Values.Contains(input).ToString() }; + return this.Values; + } + } +} diff --git a/Mods/ContentPatcher/Framework/Tokens/ValueProviders/DynamicTokenValueProvider.cs b/Mods/ContentPatcher/Framework/Tokens/ValueProviders/DynamicTokenValueProvider.cs new file mode 100644 index 000000000..158319fae --- /dev/null +++ b/Mods/ContentPatcher/Framework/Tokens/ValueProviders/DynamicTokenValueProvider.cs @@ -0,0 +1,77 @@ +using System; +using System.Collections.Generic; +using Pathoschild.Stardew.Common.Utilities; + +namespace ContentPatcher.Framework.Tokens.ValueProviders +{ + /// A value provider for user-defined dynamic tokens. + internal class DynamicTokenValueProvider : BaseValueProvider + { + /********* + ** Fields + *********/ + /// The allowed root values (or null if any value is allowed). + private readonly InvariantHashSet AllowedRootValues; + + /// The current values. + private InvariantHashSet Values = new InvariantHashSet(); + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The value provider name. + public DynamicTokenValueProvider(string name) + : base(name, canHaveMultipleValuesForRoot: false) + { + this.AllowedRootValues = new InvariantHashSet(); + this.EnableInputArguments(required: false, canHaveMultipleValues: false); + } + + /// Add a set of possible values. + /// The possible values to add. + public void AddAllowedValues(InvariantHashSet possibleValues) + { + foreach (string value in possibleValues) + this.AllowedRootValues.Add(value); + this.CanHaveMultipleValuesForRoot = this.CanHaveMultipleValuesForRoot || possibleValues.Count > 1; + } + + /// Set the current values. + /// The values to set. + public void SetValue(InvariantHashSet values) + { + this.Values = values; + } + + /// Set whether the token is valid in the current context. + /// The value to set. + public void SetValidInContext(bool validInContext) + { + this.IsValidInContext = validInContext; + } + + /// Get the allowed values for an input argument (or null if any value is allowed). + /// The input argument, if applicable. + /// The input argument doesn't match this value provider, or does not respect or . + public override InvariantHashSet GetAllowedValues(string input) + { + return input != null + ? InvariantHashSet.Boolean() + : this.AllowedRootValues; + } + + /// Get the current values. + /// The input argument, if applicable. + /// The input argument doesn't match this value provider, or does not respect or . + public override IEnumerable GetValues(string input) + { + this.AssertInputArgument(input); + + if (input != null) + return new[] { this.Values.Contains(input).ToString() }; + return this.Values; + } + } +} diff --git a/Mods/ContentPatcher/Framework/Tokens/ValueProviders/HasFileValueProvider.cs b/Mods/ContentPatcher/Framework/Tokens/ValueProviders/HasFileValueProvider.cs new file mode 100644 index 000000000..4ff86b361 --- /dev/null +++ b/Mods/ContentPatcher/Framework/Tokens/ValueProviders/HasFileValueProvider.cs @@ -0,0 +1,100 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using ContentPatcher.Framework.Conditions; +using Pathoschild.Stardew.Common; +using Pathoschild.Stardew.Common.Utilities; + +namespace ContentPatcher.Framework.Tokens.ValueProviders +{ + /// A value provider which checks whether a file exists in the content pack's folder. + internal class HasFileValueProvider : BaseValueProvider + { + /********* + ** Fields + *********/ + /// The mod folder from which to load assets. + private readonly string ModFolder; + + /// The context as of the last update. + private IContext TokenContext; + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The absolute path to the mod folder. + public HasFileValueProvider(string modFolder) + : base(ConditionType.HasFile, canHaveMultipleValuesForRoot: false) + { + this.ModFolder = modFolder; + this.EnableInputArguments(required: true, canHaveMultipleValues: false); + } + + /// Update the underlying values. + /// The condition context. + /// Returns whether the values changed. + public override void UpdateContext(IContext context) + { + this.TokenContext = context; + this.IsValidInContext = true; + } + + /// Get the allowed values for an input argument (or null if any value is allowed). + /// The input argument, if applicable. + /// The input argument doesn't match this value provider, or does not respect or . + public override InvariantHashSet GetAllowedValues(string input) + { + return input != null + ? InvariantHashSet.Boolean() + : null; + } + + /// Get the current values. + /// The input argument, if applicable. + /// The input argument doesn't match this value provider, or does not respect or . + public override IEnumerable GetValues(string input) + { + this.AssertInputArgument(input); + + yield return this.GetPathExists(input).ToString(); + } + + + /********* + ** Private methods + *********/ + /// Get whether the given file path exists. + /// A relative file path. + /// The path is not relative or contains directory climbing (../). + private bool GetPathExists(string path) + { + if (string.IsNullOrWhiteSpace(path)) + return false; + + // parse tokens + TokenString tokenStr = new TokenString(path, this.TokenContext); + if (tokenStr.InvalidTokens.Any()) + return false; + tokenStr.UpdateContext(this.TokenContext); + path = tokenStr.Value; + + // get normalised path + if (string.IsNullOrWhiteSpace(path)) + return false; + path = PathUtilities.NormalisePathSeparators(path); + + // validate + if (Path.IsPathRooted(path)) + throw new InvalidOperationException($"The {ConditionType.HasFile} token requires a relative path."); + if (!PathUtilities.IsSafeRelativePath(path)) + throw new InvalidOperationException($"The {ConditionType.HasFile} token requires a relative path and cannot contain directory climbing (../)."); + + // check file existence + string fullPath = Path.Combine(this.ModFolder, PathUtilities.NormalisePathSeparators(path)); + return File.Exists(fullPath); + } + } +} diff --git a/Mods/ContentPatcher/Framework/Tokens/ValueProviders/HasProfessionValueProvider.cs b/Mods/ContentPatcher/Framework/Tokens/ValueProviders/HasProfessionValueProvider.cs new file mode 100644 index 000000000..88a984b21 --- /dev/null +++ b/Mods/ContentPatcher/Framework/Tokens/ValueProviders/HasProfessionValueProvider.cs @@ -0,0 +1,100 @@ +using System; +using System.Collections.Generic; +using ContentPatcher.Framework.Conditions; +using ContentPatcher.Framework.Constants; +using Pathoschild.Stardew.Common.Utilities; +using StardewValley; + +namespace ContentPatcher.Framework.Tokens.ValueProviders +{ + /// A value provider for the player's professions. + internal class HasProfessionValueProvider : BaseValueProvider + { + /********* + ** Fields + *********/ + /// Get whether the player data is available in the current context. + private readonly Func IsPlayerDataAvailable; + + /// The player's current professions. + private readonly HashSet Professions = new HashSet(); + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// Get whether the player data is available in the current context. + public HasProfessionValueProvider(Func isPlayerDataAvailable) + : base(ConditionType.HasProfession, canHaveMultipleValuesForRoot: true) + { + this.IsPlayerDataAvailable = isPlayerDataAvailable; + this.EnableInputArguments(required: false, canHaveMultipleValues: false); + } + + /// Update the underlying values. + /// The condition context. + /// Returns whether the values changed. + public override void UpdateContext(IContext context) + { + this.Professions.Clear(); + this.IsValidInContext = this.IsPlayerDataAvailable(); + if (this.IsValidInContext) + { + foreach (int professionID in Game1.player.professions) + this.Professions.Add((Profession)professionID); + } + } + + /// Get the allowed values for a token name (or null if any value is allowed). + /// The input argument, if applicable. + /// The input argument doesn't match this token, or does not respect or . + public override InvariantHashSet GetAllowedValues(string input) + { + return input != null + ? InvariantHashSet.Boolean() + : null; + } + + /// Get the current values. + /// The input argument, if applicable. + /// The input argument doesn't match this token, or does not respect or . + public override IEnumerable GetValues(string input) + { + this.AssertInputArgument(input); + + if (input != null) + { + bool hasProfession = this.TryParseEnum(input, out Profession profession, mustBeNamed: false) && this.Professions.Contains(profession); + yield return hasProfession.ToString(); + } + else + { + foreach (Profession profession in this.Professions) + yield return profession.ToString(); + } + } + + /// Validate that the provided value is valid for an input argument (regardless of whether they match). + /// The input argument, if applicable. + /// The value to validate. + /// The validation error, if any. + /// Returns whether validation succeeded. + protected override bool TryValidate(string input, string value, out string error) + { + if (!base.TryValidate(input, value, out error)) + return false; + + // validate profession IDs + string profession = input ?? value; + if (!this.TryParseEnum(profession, out Profession _, mustBeNamed: false)) + { + error = $"can't parse '{profession}' as a profession ID; must be one of [{string.Join(", ", Enum.GetNames(typeof(Profession)).OrderByIgnoreCase(p => p))}] or an integer ID."; + return false; + } + + error = null; + return true; + } + } +} diff --git a/Mods/ContentPatcher/Framework/Tokens/ValueProviders/HasWalletItemValueProvider.cs b/Mods/ContentPatcher/Framework/Tokens/ValueProviders/HasWalletItemValueProvider.cs new file mode 100644 index 000000000..9cd2203ce --- /dev/null +++ b/Mods/ContentPatcher/Framework/Tokens/ValueProviders/HasWalletItemValueProvider.cs @@ -0,0 +1,94 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using ContentPatcher.Framework.Conditions; +using ContentPatcher.Framework.Constants; +using Pathoschild.Stardew.Common.Utilities; +using StardewValley; + +namespace ContentPatcher.Framework.Tokens.ValueProviders +{ + /// A value provider for the player's wallet items. + internal class HasWalletItemValueProvider : BaseValueProvider + { + /********* + ** Fields + *********/ + /// Get whether the player data is available in the current context. + private readonly Func IsPlayerDataAvailable; + + /// The defined wallet items and whether the player has them. + private readonly IDictionary> WalletItems = new Dictionary> + { + [WalletItem.DwarvishTranslationGuide] = () => Game1.player.canUnderstandDwarves, + [WalletItem.RustyKey] = () => Game1.player.hasRustyKey, + [WalletItem.ClubCard] = () => Game1.player.hasClubCard, + [WalletItem.SpecialCharm] = () => Game1.player.hasSpecialCharm, + [WalletItem.SkullKey] = () => Game1.player.hasSkullKey, + [WalletItem.MagnifyingGlass] = () => Game1.player.hasMagnifyingGlass, + [WalletItem.DarkTalisman] = () => Game1.player.hasDarkTalisman, + [WalletItem.MagicInk] = () => Game1.player.hasMagicInk, + [WalletItem.BearsKnowledge] = () => Game1.player.eventsSeen.Contains(2120303), + [WalletItem.SpringOnionMastery] = () => Game1.player.eventsSeen.Contains(3910979) + }; + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// Get whether the player data is available in the current context. + public HasWalletItemValueProvider(Func isPlayerDataAvailable) + : base(ConditionType.HasWalletItem, canHaveMultipleValuesForRoot: true) + { + this.IsPlayerDataAvailable = isPlayerDataAvailable; + this.EnableInputArguments(required: false, canHaveMultipleValues: false); + } + + /// Update the underlying values. + /// The condition context. + /// Returns whether the values changed. + public override void UpdateContext(IContext context) + { + this.IsValidInContext = this.IsPlayerDataAvailable(); + } + + /// Get the set of valid input arguments if restricted, or an empty collection if unrestricted. + public override InvariantHashSet GetValidInputs() + { + return new InvariantHashSet(this.WalletItems.Keys.Select(p => p.ToString())); + } + + /// Get the allowed values for an input argument (or null if any value is allowed). + /// The input argument, if applicable. + /// The input argument doesn't match this value provider, or does not respect or . + public override InvariantHashSet GetAllowedValues(string input) + { + return input != null + ? InvariantHashSet.Boolean() + : this.GetValidInputs(); + } + + /// Get the current values. + /// The input argument, if applicable. + /// The input argument doesn't match this value provider, or does not respect or . + public override IEnumerable GetValues(string input) + { + this.AssertInputArgument(input); + + if (input != null) + { + bool hasItem = this.TryParseEnum(input, out WalletItem item) && this.WalletItems[item](); + yield return hasItem.ToString(); + } + else + { + foreach (KeyValuePair> pair in this.WalletItems) + { + if (pair.Value()) + yield return pair.Key.ToString(); + } + } + } + } +} diff --git a/Mods/ContentPatcher/Framework/Tokens/ValueProviders/IValueProvider.cs b/Mods/ContentPatcher/Framework/Tokens/ValueProviders/IValueProvider.cs new file mode 100644 index 000000000..9512a52fc --- /dev/null +++ b/Mods/ContentPatcher/Framework/Tokens/ValueProviders/IValueProvider.cs @@ -0,0 +1,61 @@ +using System; +using System.Collections.Generic; +using Pathoschild.Stardew.Common.Utilities; + +namespace ContentPatcher.Framework.Tokens.ValueProviders +{ + /// Provides values for a token name with optional input. + internal interface IValueProvider + { + /********* + ** Accessors + *********/ + /// The value provider name. + string Name { get; } + + /// Whether values exist in the current context. + bool IsValidInContext { get; } + + /// Whether the provided values can change after the provider is initialised. + bool IsMutable { get; } + + /// Whether the value provider allows an input argument (e.g. an NPC name for a relationship token). + bool AllowsInput { get; } + + /// Whether the value provider requires an input argument to work, and does not provide values without it (see ). + bool RequiresInput { get; } + + + /********* + ** Public methods + *********/ + /// Update the underlying values. + /// The condition context. + /// Returns whether the values changed. + void UpdateContext(IContext context); + + /// Whether the value provider may return multiple values for the given input. + /// The input argument, if applicable. + bool CanHaveMultipleValues(string input = null); + + /// Validate that the provided values are valid for the input argument (regardless of whether they match). + /// The input argument, if applicable. + /// The values to validate. + /// The validation error, if any. + /// Returns whether validation succeeded. + bool TryValidate(string input, InvariantHashSet values, out string error); + + /// Get the set of valid input arguments if restricted, or an empty collection if unrestricted. + InvariantHashSet GetValidInputs(); + + /// Get the allowed values for an input argument (or null if any value is allowed). + /// The input argument, if applicable. + /// The input argument doesn't match this value provider, or does not respect or . + InvariantHashSet GetAllowedValues(string input); + + /// Get the current values. + /// The input argument, if applicable. + /// The input argument doesn't match this value provider, or does not respect or . + IEnumerable GetValues(string input); + } +} diff --git a/Mods/ContentPatcher/Framework/Tokens/ValueProviders/ImmutableValueProvider.cs b/Mods/ContentPatcher/Framework/Tokens/ValueProviders/ImmutableValueProvider.cs new file mode 100644 index 000000000..54f62fa4a --- /dev/null +++ b/Mods/ContentPatcher/Framework/Tokens/ValueProviders/ImmutableValueProvider.cs @@ -0,0 +1,61 @@ +using System; +using System.Collections.Generic; +using Pathoschild.Stardew.Common.Utilities; + +namespace ContentPatcher.Framework.Tokens.ValueProviders +{ + /// A value provider whose values don't change after it's initialised. + internal class ImmutableValueProvider : BaseValueProvider + { + /********* + ** Fields + *********/ + /// The allowed root values (or null if any value is allowed). + private readonly InvariantHashSet AllowedRootValues; + + /// The current token values. + private readonly InvariantHashSet Values; + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The value provider name. + /// Get the current token values. + /// The allowed values (or null if any value is allowed). + /// Whether the root may contain multiple values (or null to set it based on the given values). + public ImmutableValueProvider(string name, InvariantHashSet values, InvariantHashSet allowedValues = null, bool? canHaveMultipleValues = null) + : base(name, canHaveMultipleValuesForRoot: false) + { + this.Values = values ?? new InvariantHashSet(); + this.AllowedRootValues = allowedValues; + this.CanHaveMultipleValuesForRoot = canHaveMultipleValues ?? (this.Values.Count > 1 || this.AllowedRootValues == null || this.AllowedRootValues.Count > 1); + this.EnableInputArguments(required: false, canHaveMultipleValues: false); + this.IsMutable = false; + this.IsValidInContext = true; + } + + /// Get the allowed values for an input argument (or null if any value is allowed). + /// The input argument, if applicable. + /// The input argument doesn't match this value provider, or does not respect or . + public override InvariantHashSet GetAllowedValues(string input) + { + return input != null + ? InvariantHashSet.Boolean() + : this.AllowedRootValues; + } + + /// Get the current values. + /// The input argument, if applicable. + /// The input argument doesn't match this value provider, or does not respect or . + public override IEnumerable GetValues(string input) + { + this.AssertInputArgument(input); + + if (input != null) + return new[] { this.Values.Contains(input).ToString() }; + return this.Values; + } + } +} diff --git a/Mods/ContentPatcher/Framework/Tokens/ValueProviders/SkillLevelValueProvider.cs b/Mods/ContentPatcher/Framework/Tokens/ValueProviders/SkillLevelValueProvider.cs new file mode 100644 index 000000000..b3bf6e7a3 --- /dev/null +++ b/Mods/ContentPatcher/Framework/Tokens/ValueProviders/SkillLevelValueProvider.cs @@ -0,0 +1,77 @@ +using System; +using System.Collections.Generic; +using ContentPatcher.Framework.Conditions; +using ContentPatcher.Framework.Constants; +using Pathoschild.Stardew.Common.Utilities; +using StardewValley; + +namespace ContentPatcher.Framework.Tokens.ValueProviders +{ + /// A value provider for the player's skill levels. + internal class SkillLevelValueProvider : BaseValueProvider + { + /********* + ** Fields + *********/ + /// Get whether the player data is available in the current context. + private readonly Func IsPlayerDataAvailable; + + /// The player's current skill levels. + private readonly IDictionary SkillLevels = new Dictionary(); + + + /********* + ** Public methods + *********/ + /// Construct an instance. + public SkillLevelValueProvider(Func isPlayerDataAvailable) + : base(ConditionType.SkillLevel, canHaveMultipleValuesForRoot: true) + { + this.IsPlayerDataAvailable = isPlayerDataAvailable; + this.EnableInputArguments(required: false, canHaveMultipleValues: false); + } + + /// Update the underlying values. + /// The condition context. + /// Returns whether the values changed. + public override void UpdateContext(IContext context) + { + this.SkillLevels.Clear(); + this.IsValidInContext = this.IsPlayerDataAvailable(); + if (this.IsValidInContext) + { + this.SkillLevels[Skill.Combat] = Game1.player.CombatLevel; + this.SkillLevels[Skill.Farming] = Game1.player.FarmingLevel; + this.SkillLevels[Skill.Fishing] = Game1.player.FishingLevel; + this.SkillLevels[Skill.Foraging] = Game1.player.ForagingLevel; + this.SkillLevels[Skill.Luck] = Game1.player.LuckLevel; + this.SkillLevels[Skill.Mining] = Game1.player.MiningLevel; + } + } + + /// Get the set of valid input arguments if restricted, or an empty collection if unrestricted. + public override InvariantHashSet GetValidInputs() + { + return new InvariantHashSet(Enum.GetNames(typeof(Skill))); + } + + /// Get the current values. + /// The input argument, if applicable. + /// The input argument doesn't match this value provider, or does not respect or . + public override IEnumerable GetValues(string input) + { + this.AssertInputArgument(input); + + if (input != null) + { + if (this.TryParseEnum(input, out Skill skill) && this.SkillLevels.TryGetValue(skill, out int level)) + yield return level.ToString(); + } + else + { + foreach (var pair in this.SkillLevels) + yield return $"{pair.Key}:{pair.Value}"; + } + } + } +} diff --git a/Mods/ContentPatcher/Framework/Tokens/ValueProviders/VillagerHeartsValueProvider.cs b/Mods/ContentPatcher/Framework/Tokens/ValueProviders/VillagerHeartsValueProvider.cs new file mode 100644 index 000000000..304de6ce2 --- /dev/null +++ b/Mods/ContentPatcher/Framework/Tokens/ValueProviders/VillagerHeartsValueProvider.cs @@ -0,0 +1,70 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using ContentPatcher.Framework.Conditions; +using Pathoschild.Stardew.Common.Utilities; +using StardewModdingAPI; +using StardewValley; + +namespace ContentPatcher.Framework.Tokens.ValueProviders +{ + /// A value provider for NPC friendship hearts. + internal class VillagerHeartsValueProvider : BaseValueProvider + { + /********* + ** Fields + *********/ + /// The relationships by NPC. + private readonly InvariantDictionary Values = new InvariantDictionary(); + + + /********* + ** Public methods + *********/ + /// Construct an instance. + public VillagerHeartsValueProvider() + : base(ConditionType.Hearts, canHaveMultipleValuesForRoot: false) + { + this.EnableInputArguments(required: false, canHaveMultipleValues: false); + } + + /// Update the underlying values. + /// The condition context. + /// Returns whether the values changed. + public override void UpdateContext(IContext context) + { + this.Values.Clear(); + this.IsValidInContext = Context.IsWorldReady; + if (this.IsValidInContext) + { + foreach (KeyValuePair pair in Game1.player.friendshipData.Pairs) + this.Values[pair.Key] = (pair.Value.Points / NPC.friendshipPointsPerHeartLevel).ToString(CultureInfo.InvariantCulture); + } + } + + /// Get the set of valid input arguments if restricted, or an empty collection if unrestricted. + public override InvariantHashSet GetValidInputs() + { + return new InvariantHashSet(this.Values.Keys); + } + + /// Get the current values. + /// The input argument, if applicable. + /// The input argument doesn't match this value provider, or does not respect or . + public override IEnumerable GetValues(string input) + { + this.AssertInputArgument(input); + + if (input != null) + { + if (this.Values.TryGetValue(input, out string value)) + yield return value; + } + else + { + foreach (var pair in this.Values) + yield return $"{pair.Key}:{pair.Value}"; + } + } + } +} diff --git a/Mods/ContentPatcher/Framework/Tokens/ValueProviders/VillagerRelationshipValueProvider.cs b/Mods/ContentPatcher/Framework/Tokens/ValueProviders/VillagerRelationshipValueProvider.cs new file mode 100644 index 000000000..478d26148 --- /dev/null +++ b/Mods/ContentPatcher/Framework/Tokens/ValueProviders/VillagerRelationshipValueProvider.cs @@ -0,0 +1,69 @@ +using System; +using System.Collections.Generic; +using ContentPatcher.Framework.Conditions; +using Pathoschild.Stardew.Common.Utilities; +using StardewModdingAPI; +using StardewValley; + +namespace ContentPatcher.Framework.Tokens.ValueProviders +{ + /// A value provider for NPC relationship types. + internal class VillagerRelationshipValueProvider : BaseValueProvider + { + /********* + ** Fields + *********/ + /// The relationships by NPC. + private readonly InvariantDictionary Values = new InvariantDictionary(); + + + /********* + ** Public methods + *********/ + /// Construct an instance. + public VillagerRelationshipValueProvider() + : base(ConditionType.Relationship, canHaveMultipleValuesForRoot: false) + { + this.EnableInputArguments(required: false, canHaveMultipleValues: false); + } + + /// Update the underlying values. + /// The condition context. + /// Returns whether the values changed. + public override void UpdateContext(IContext context) + { + this.Values.Clear(); + this.IsValidInContext = Context.IsWorldReady; + if (this.IsValidInContext) + { + foreach (KeyValuePair pair in Game1.player.friendshipData.Pairs) + this.Values[pair.Key] = pair.Value.Status.ToString(); + } + } + + /// Get the set of valid input arguments if restricted, or an empty collection if unrestricted. + public override InvariantHashSet GetValidInputs() + { + return new InvariantHashSet(this.Values.Keys); + } + + /// Get the current values. + /// The input argument, if applicable. + /// The input argument doesn't match this value provider, or does not respect or . + public override IEnumerable GetValues(string input) + { + this.AssertInputArgument(input); + + if (input != null) + { + if (this.Values.TryGetValue(input, out string value)) + yield return value; + } + else + { + foreach (var pair in this.Values) + yield return $"{pair.Key}:{pair.Value}"; + } + } + } +} diff --git a/Mods/ContentPatcher/Framework/Validators/BaseValidator.cs b/Mods/ContentPatcher/Framework/Validators/BaseValidator.cs new file mode 100644 index 000000000..337d4df26 --- /dev/null +++ b/Mods/ContentPatcher/Framework/Validators/BaseValidator.cs @@ -0,0 +1,24 @@ +using ContentPatcher.Framework.Patches; +using StardewModdingAPI; + +namespace ContentPatcher.Framework.Validators +{ + /// The base implementation for a content pack validator. + internal abstract class BaseValidator : IAssetValidator + { + /********* + ** Public methods + *********/ + /// Validate a content pack. + /// The asset being loaded. + /// The loaded asset data to validate. + /// The patch which loaded the asset. + /// An error message which indicates why validation failed. + /// Returns whether validation succeeded. + public virtual bool TryValidate(IAssetInfo asset, T data, IPatch patch, out string error) + { + error = null; + return false; + } + } +} diff --git a/Mods/ContentPatcher/Framework/Validators/IAssetValidator.cs b/Mods/ContentPatcher/Framework/Validators/IAssetValidator.cs new file mode 100644 index 000000000..08ff56cee --- /dev/null +++ b/Mods/ContentPatcher/Framework/Validators/IAssetValidator.cs @@ -0,0 +1,20 @@ +using ContentPatcher.Framework.Patches; +using StardewModdingAPI; + +namespace ContentPatcher.Framework.Validators +{ + /// Performs validation logic for an asset being loaded. + internal interface IAssetValidator + { + /********* + ** Public methods + *********/ + /// Validate a content pack. + /// The asset being loaded. + /// The loaded asset data to validate. + /// The patch which loaded the asset. + /// An error message which indicates why validation failed. + /// Returns whether validation succeeded. + bool TryValidate(IAssetInfo asset, T data, IPatch patch, out string error); + } +} diff --git a/Mods/ContentPatcher/Framework/Validators/StardewValley_1_3_36_Validator.cs b/Mods/ContentPatcher/Framework/Validators/StardewValley_1_3_36_Validator.cs new file mode 100644 index 000000000..810ccad06 --- /dev/null +++ b/Mods/ContentPatcher/Framework/Validators/StardewValley_1_3_36_Validator.cs @@ -0,0 +1,91 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using ContentPatcher.Framework.Patches; +using StardewModdingAPI; +using xTile; +using xTile.Tiles; + +namespace ContentPatcher.Framework.Validators +{ + /// Validate content packs for compatibility with Stardew Valley 1.3.36. + [SuppressMessage("ReSharper", "InconsistentNaming", Justification = "Named for clarity.")] + internal class StardewValley_1_3_36_Validator : BaseValidator + { + /********* + ** Fields + *********/ + /// A map of tilesheets removed in Stardew Valley 1.3.36 and the new tilesheets that should be referenced instead. + private IDictionary ObsoleteTilesheets = new Dictionary(StringComparer.InvariantCultureIgnoreCase) + { + ["mine"] = "Mines/mine", + ["mine_dark"] = "Mines/mine_dark", + ["mine_lava"] = "Mines/mine_lava" + }; + + + /********* + ** Public methods + *********/ + /// Validate a content pack. + /// The asset being loaded. + /// The loaded asset data to validate. + /// The patch which loaded the asset. + /// An error message which indicates why validation failed. + /// Returns whether validation succeeded. + public override bool TryValidate(IAssetInfo asset, T data, IPatch patch, out string error) + { + // detect vanilla tilesheets removed in SDV 1.3.36 + if (data is Map map) + { + string mapFolderPath = Path.GetDirectoryName(patch.FromLocalAsset.Value); + foreach (TileSheet tilesheet in map.TileSheets) + { + string curKey = tilesheet.ImageSource; + + // skip if tilesheet exists relative to the content pack + string mapRelativeSource = Path.Combine(mapFolderPath, curKey); + if (patch.ContentPack.HasFile(mapRelativeSource)) + continue; + + // detect obsolete tilesheet references + if (this.IsObsoleteTilesheet(curKey, out string newKey)) + { + error = $"references vanilla tilesheet '{curKey}' removed in Stardew Valley 1.3.36, should use '{newKey}' instead"; + return false; + } + } + } + + error = null; + return true; + } + + + /********* + ** Private methods + *********/ + /// Get whether a given tilesheet image source is obsolete. + /// The tilesheet image source. + /// The key that should be replaced with, if it's obsolete. + private bool IsObsoleteTilesheet(string curKey, out string newKey) + { + if (curKey == null) + { + newKey = null; + return false; + } + + // exact match + if (this.ObsoleteTilesheets.TryGetValue(curKey, out newKey)) + return true; + + // strip .png + if (curKey.EndsWith(".png", StringComparison.InvariantCultureIgnoreCase) && this.ObsoleteTilesheets.TryGetValue(curKey.Substring(0, curKey.Length - 4), out newKey)) + return true; + + return false; + } + } +} diff --git a/Mods/ContentPatcher/ModEntry.cs b/Mods/ContentPatcher/ModEntry.cs new file mode 100644 index 000000000..d59112092 --- /dev/null +++ b/Mods/ContentPatcher/ModEntry.cs @@ -0,0 +1,811 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using System.Linq; +using ContentPatcher.Framework; +using ContentPatcher.Framework.Commands; +using ContentPatcher.Framework.Conditions; +using ContentPatcher.Framework.ConfigModels; +using ContentPatcher.Framework.Lexing; +using ContentPatcher.Framework.Lexing.LexTokens; +using ContentPatcher.Framework.Migrations; +using ContentPatcher.Framework.Patches; +using ContentPatcher.Framework.Tokens; +using ContentPatcher.Framework.Validators; +using Pathoschild.Stardew.Common.Utilities; +using StardewModdingAPI; +using StardewModdingAPI.Enums; +using StardewModdingAPI.Events; + +namespace ContentPatcher +{ + /// The mod entry point. + internal class ModEntry : Mod + { + /********* + ** Fields + *********/ + /// The name of the file which contains patch metadata. + private readonly string PatchFileName = "content.json"; + + /// The name of the file which contains player settings. + private readonly string ConfigFileName = "config.json"; + + /// The supported format versions. + private readonly string[] SupportedFormatVersions = { "1.0", "1.3", "1.4", "1.5", "1.6" }; + + /// The format version migrations to apply. + private readonly Func Migrations = () => new IMigration[] + { + new Migration_1_3(), + new Migration_1_4(), + new Migration_1_5(), + new Migration_1_6() + }; + + /// The special validation logic to apply to assets affected by patches. + private readonly Func AssetValidators = () => new IAssetValidator[] + { + new StardewValley_1_3_36_Validator() + }; + + /// Manages the available contextual tokens. + private TokenManager TokenManager; + + /// Manages loaded patches. + private PatchManager PatchManager; + + /// Handles the 'patch' console command. + private CommandHandler CommandHandler; + + /// The mod configuration. + private ModConfig Config; + + /// The debug overlay (if enabled). + private DebugOverlay DebugOverlay; + + + /********* + ** 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(); + + // init migrations + IMigration[] migrations = this.Migrations(); + + // fetch content packs + RawContentPack[] contentPacks = this.GetContentPacks(migrations).ToArray(); + string[] installedMods = + (contentPacks.Select(p => p.Manifest.UniqueID)) + .Concat(helper.ModRegistry.GetAll().Select(p => p.Manifest.UniqueID)) + .OrderByIgnoreCase(p => p) + .ToArray(); + + // load content packs and context + this.TokenManager = new TokenManager(helper.Content, installedMods); + this.PatchManager = new PatchManager(this.Monitor, this.TokenManager, this.AssetValidators()); + this.LoadContentPacks(contentPacks); + this.TokenManager.UpdateContext(); + + // register patcher + helper.Content.AssetLoaders.Add(this.PatchManager); + helper.Content.AssetEditors.Add(this.PatchManager); + + // set up events + if (this.Config.EnableDebugFeatures) + helper.Events.Input.ButtonPressed += this.OnButtonPressed; + helper.Events.GameLoop.ReturnedToTitle += this.OnReturnedToTitle; + helper.Events.GameLoop.DayStarted += this.OnDayStarted; + helper.Events.Specialised.LoadStageChanged += this.OnLoadStageChanged; + + // set up commands + this.CommandHandler = new CommandHandler(this.TokenManager, this.PatchManager, this.Monitor, this.UpdateContext); + helper.ConsoleCommands.Add(this.CommandHandler.CommandName, $"Starts a Content Patcher command. Type '{this.CommandHandler.CommandName} help' for details.", (name, args) => this.CommandHandler.Handle(args)); + } + + + /********* + ** Private methods + *********/ + /**** + ** Event handlers + ****/ + /// The method invoked when the player presses a button. + /// The event sender. + /// The event data. + private void OnButtonPressed(object sender, ButtonPressedEventArgs e) + { + if (this.Config.EnableDebugFeatures) + { + // toggle overlay + if (this.Config.Controls.ToggleDebug.Contains(e.Button)) + { + if (this.DebugOverlay == null) + this.DebugOverlay = new DebugOverlay(this.Helper.Events, this.Helper.Input, this.Helper.Content); + else + { + this.DebugOverlay.Dispose(); + this.DebugOverlay = null; + } + return; + } + + // cycle textures + if (this.DebugOverlay != null) + { + if (this.Config.Controls.DebugPrevTexture.Contains(e.Button)) + this.DebugOverlay.PrevTexture(); + if (this.Config.Controls.DebugNextTexture.Contains(e.Button)) + this.DebugOverlay.NextTexture(); + } + } + } + + /// Raised when the low-level stage in the game's loading process has changed. This is an advanced event for mods which need to run code at specific points in the loading process. The available stages or when they happen might change without warning in future versions (e.g. due to changes in the game's load process), so mods using this event are more likely to break or have bugs. + /// The event sender. + /// The event data. + private void OnLoadStageChanged(object sender, LoadStageChangedEventArgs e) + { + switch (e.NewStage) + { + case LoadStage.CreatedBasicInfo: + case LoadStage.SaveLoadedBasicInfo: + this.Monitor.VerboseLog($"Updating context: load stage changed to {e.NewStage}."); + this.TokenManager.IsBasicInfoLoaded = true; + this.UpdateContext(); + break; + } + } + + /// The method invoked when a new day starts. + /// The event sender. + /// The event data. + private void OnDayStarted(object sender, DayStartedEventArgs e) + { + this.Monitor.VerboseLog("Updating context: new day started."); + this.TokenManager.IsBasicInfoLoaded = true; + this.UpdateContext(); + } + + /// The method invoked when the player returns to the title screen. + /// The event sender. + /// The event data. + private void OnReturnedToTitle(object sender, ReturnedToTitleEventArgs e) + { + this.Monitor.VerboseLog("Updating context: returned to title."); + this.TokenManager.IsBasicInfoLoaded = false; + this.UpdateContext(); + } + + /**** + ** Methods + ****/ + /// Update the current context. + private void UpdateContext() + { + this.TokenManager.UpdateContext(); + this.PatchManager.UpdateContext(this.Helper.Content); + } + + /// Load the registered content packs. + /// The format version migrations to apply. + /// Returns the loaded content pack IDs. + [SuppressMessage("ReSharper", "AccessToModifiedClosure", Justification = "The value is used immediately, so this isn't an issue.")] + private IEnumerable GetContentPacks(IMigration[] migrations) + { + this.Monitor.VerboseLog("Preloading content packs..."); + + foreach (IContentPack contentPack in this.Helper.ContentPacks.GetOwned()) + { + RawContentPack rawContentPack; + try + { + // validate content.json has required fields + ContentConfig content = contentPack.ReadJsonFile(this.PatchFileName); + if (content == null) + { + this.Monitor.Log($"Ignored content pack '{contentPack.Manifest.Name}' because it has no {this.PatchFileName} file.", LogLevel.Error); + continue; + } + if (content.Format == null || content.Changes == null) + { + this.Monitor.Log($"Ignored content pack '{contentPack.Manifest.Name}' because it doesn't specify the required {nameof(ContentConfig.Format)} or {nameof(ContentConfig.Changes)} fields.", LogLevel.Error); + continue; + } + + // apply migrations + IMigration migrator = new AggregateMigration(content.Format, this.SupportedFormatVersions, migrations); + if (!migrator.TryMigrate(content, out string error)) + { + this.Monitor.Log($"Loading content pack '{contentPack.Manifest.Name}' failed: {error}.", LogLevel.Error); + continue; + } + + // init + rawContentPack = new RawContentPack(new ManagedContentPack(contentPack), content, migrator); + } + catch (Exception ex) + { + this.Monitor.Log($"Error preloading content pack '{contentPack.Manifest.Name}'. Technical details:\n{ex}", LogLevel.Error); + continue; + } + + yield return rawContentPack; + } + } + + /// Load the patches from all registered content packs. + /// The content packs to load. + /// Returns the loaded content pack IDs. + [SuppressMessage("ReSharper", "AccessToModifiedClosure", Justification = "The value is used immediately, so this isn't an issue.")] + private void LoadContentPacks(IEnumerable contentPacks) + { + // load content packs + ConfigFileHandler configFileHandler = new ConfigFileHandler(this.ConfigFileName, this.ParseCommaDelimitedField, (pack, label, reason) => this.Monitor.Log($"Ignored {pack.Manifest.Name} > {label}: {reason}")); + foreach (RawContentPack current in contentPacks) + { + this.Monitor.VerboseLog($"Loading content pack '{current.Manifest.Name}'..."); + + try + { + ContentConfig content = current.Content; + + // load tokens + ModTokenContext tokenContext = this.TokenManager.TrackLocalTokens(current.ManagedPack.Pack); + { + // load config.json + InvariantDictionary config = configFileHandler.Read(current.ManagedPack, content.ConfigSchema); + configFileHandler.Save(current.ManagedPack, config, this.Helper); + if (config.Any()) + this.Monitor.VerboseLog($" found config.json with {config.Count} fields..."); + + // load config tokens + foreach (KeyValuePair pair in config) + { + ConfigField field = pair.Value; + tokenContext.Add(new ImmutableToken(pair.Key, field.Value, allowedValues: field.AllowValues, canHaveMultipleValues: field.AllowMultiple)); + } + + // load dynamic tokens + foreach (DynamicTokenConfig entry in content.DynamicTokens ?? new DynamicTokenConfig[0]) + { + void LogSkip(string reason) => this.Monitor.Log($"Ignored {current.Manifest.Name} > dynamic token '{entry.Name}': {reason}", LogLevel.Warn); + + // validate token key + if (!TokenName.TryParse(entry.Name, out TokenName name)) + { + LogSkip("the name could not be parsed as a token key."); + continue; + } + if (name.HasSubkey()) + { + LogSkip("the token name cannot contain a subkey (:)."); + continue; + } + if (name.TryGetConditionType(out ConditionType conflictingType)) + { + LogSkip($"conflicts with global token '{conflictingType}'."); + continue; + } + if (config.ContainsKey(name.Key)) + { + LogSkip($"conflicts with player config token '{conflictingType}'."); + continue; + } + + // parse values + InvariantHashSet values = entry.Value != null ? this.ParseCommaDelimitedField(entry.Value) : new InvariantHashSet(); + + // parse conditions + ConditionDictionary conditions; + { + if (!this.TryParseConditions(entry.When, tokenContext, current.Migrator, out conditions, out string error)) + { + this.Monitor.Log($"Ignored {current.Manifest.Name} > '{entry.Name}' token: its {nameof(DynamicTokenConfig.When)} field is invalid: {error}.", LogLevel.Warn); + continue; + } + } + + // add token + tokenContext.Add(new DynamicTokenValue(name, values, conditions)); + } + } + + // load patches + content.Changes = this.SplitPatches(content.Changes).ToArray(); + this.NamePatches(current.ManagedPack, content.Changes); + foreach (PatchConfig patch in content.Changes) + { + this.Monitor.VerboseLog($" loading {patch.LogName}..."); + this.LoadPatch(current.ManagedPack, patch, tokenContext, current.Migrator, logSkip: reasonPhrase => this.Monitor.Log($"Ignored {patch.LogName}: {reasonPhrase}", LogLevel.Warn)); + } + } + catch (Exception ex) + { + this.Monitor.Log($"Error loading content pack '{current.Manifest.Name}'. Technical details:\n{ex}", LogLevel.Error); + continue; + } + } + } + + /// Split patches with multiple target values. + /// The patches to split. + private IEnumerable SplitPatches(IEnumerable patches) + { + foreach (PatchConfig patch in patches) + { + if (string.IsNullOrWhiteSpace(patch.Target) || !patch.Target.Contains(",")) + { + yield return patch; + continue; + } + + int i = 0; + foreach (string target in patch.Target.Split(',')) + { + i++; + yield return new PatchConfig(patch) + { + LogName = !string.IsNullOrWhiteSpace(patch.LogName) ? $"{patch.LogName} {"".PadRight(i, 'I')}" : "", + Target = target.Trim() + }; + } + } + } + + /// Set a unique name for all patches in a content pack. + /// The content pack. + /// The patches to name. + private void NamePatches(ManagedContentPack contentPack, PatchConfig[] patches) + { + // add default log names + foreach (PatchConfig patch in patches) + { + if (string.IsNullOrWhiteSpace(patch.LogName)) + patch.LogName = $"{patch.Action} {patch.Target}"; + } + + // detect duplicate names + InvariantHashSet duplicateNames = new InvariantHashSet( + from patch in patches + group patch by patch.LogName into nameGroup + where nameGroup.Count() > 1 + select nameGroup.Key + ); + + // make names unique + int i = 0; + foreach (PatchConfig patch in patches) + { + i++; + + if (duplicateNames.Contains(patch.LogName)) + patch.LogName = $"entry #{i} ({patch.LogName})"; + + patch.LogName = $"{contentPack.Manifest.Name} > {patch.LogName}"; + } + } + + /// Load one patch from a content pack's content.json file. + /// The content pack being loaded. + /// The change to load. + /// The tokens available for this content pack. + /// The migrator which validates and migrates content pack data. + /// The callback to invoke with the error reason if loading it fails. + private bool LoadPatch(ManagedContentPack pack, PatchConfig entry, IContext tokenContext, IMigration migrator, Action logSkip) + { + bool TrackSkip(string reason, bool warn = true) + { + this.PatchManager.AddPermanentlyDisabled(new DisabledPatch(entry.LogName, entry.Action, entry.Target, pack, reason)); + if (warn) + logSkip(reason); + return false; + } + + try + { + // normalise patch fields + if (entry.When == null) + entry.When = new InvariantDictionary(); + + // parse action + if (!Enum.TryParse(entry.Action, true, out PatchType action)) + { + return TrackSkip(string.IsNullOrWhiteSpace(entry.Action) + ? $"must set the {nameof(PatchConfig.Action)} field." + : $"invalid {nameof(PatchConfig.Action)} value '{entry.Action}', expected one of: {string.Join(", ", Enum.GetNames(typeof(PatchType)))}." + ); + } + + // parse target asset + TokenString assetName; + { + if (string.IsNullOrWhiteSpace(entry.Target)) + return TrackSkip($"must set the {nameof(PatchConfig.Target)} field."); + if (!this.TryParseTokenString(entry.Target, tokenContext, migrator, out string error, out assetName)) + return TrackSkip($"the {nameof(PatchConfig.Target)} is invalid: {error}"); + } + + // parse 'enabled' + bool enabled = true; + { + if (entry.Enabled != null && !this.TryParseEnabled(entry.Enabled, tokenContext, migrator, out string error, out enabled)) + return TrackSkip($"invalid {nameof(PatchConfig.Enabled)} value '{entry.Enabled}': {error}"); + } + + // parse conditions + ConditionDictionary conditions; + { + if (!this.TryParseConditions(entry.When, tokenContext, migrator, out conditions, out string error)) + return TrackSkip($"the {nameof(PatchConfig.When)} field is invalid: {error}."); + } + + // get patch instance + IPatch patch; + switch (action) + { + // load asset + case PatchType.Load: + { + // init patch + if (!this.TryPrepareLocalAsset(pack, entry.FromFile, tokenContext, migrator, out string error, out TokenString fromAsset)) + return TrackSkip(error); + patch = new LoadPatch(entry.LogName, pack, assetName, conditions, fromAsset, this.Helper.Content.NormaliseAssetName); + } + break; + + // edit data + case PatchType.EditData: + { + // validate + if (entry.Entries == null && entry.Fields == null) + return TrackSkip($"either {nameof(PatchConfig.Entries)} or {nameof(PatchConfig.Fields)} must be specified for a '{action}' change."); + if (entry.Entries != null && entry.Entries.Any(p => p.Value != null && p.Value.Trim() == "")) + return TrackSkip($"the {nameof(PatchConfig.Entries)} can't contain empty values."); + if (entry.Fields != null && entry.Fields.Any(p => p.Value == null || p.Value.Any(n => n.Value == null))) + return TrackSkip($"the {nameof(PatchConfig.Fields)} can't contain empty values."); + + // parse entries + List entries = new List(); + if (entry.Entries != null) + { + foreach (KeyValuePair pair in entry.Entries) + { + if (!this.TryParseTokenString(pair.Key, tokenContext, migrator, out string keyError, out TokenString key)) + return TrackSkip($"{nameof(PatchConfig.Entries)} > '{key}' key is invalid: {keyError}."); + if (!this.TryParseTokenString(pair.Value, tokenContext, migrator, out string error, out TokenString value)) + return TrackSkip($"{nameof(PatchConfig.Entries)} > '{key}' value is invalid: {error}."); + entries.Add(new EditDataPatchRecord(key, value)); + } + } + + // parse fields + List fields = new List(); + if (entry.Fields != null) + { + foreach (KeyValuePair> recordPair in entry.Fields) + { + if (!this.TryParseTokenString(recordPair.Key, tokenContext, migrator, out string keyError, out TokenString key)) + return TrackSkip($"{nameof(PatchConfig.Fields)} > entry {recordPair.Key} is invalid: {keyError}."); + + foreach (var fieldPair in recordPair.Value) + { + int field = fieldPair.Key; + if (!this.TryParseTokenString(fieldPair.Value, tokenContext, migrator, out string valueError, out TokenString value)) + return TrackSkip($"{nameof(PatchConfig.Fields)} > entry {recordPair.Key} > field {field} is invalid: {valueError}."); + if (value.Raw?.Contains("/") == true) + return TrackSkip($"{nameof(PatchConfig.Fields)} > entry {recordPair.Key} > field {field} is invalid: value can't contain field delimiter character '/'."); + + fields.Add(new EditDataPatchField(key, field, value)); + } + } + } + + // save + patch = new EditDataPatch(entry.LogName, pack, assetName, conditions, entries, fields, this.Monitor, this.Helper.Content.NormaliseAssetName); + } + break; + + // edit image + case PatchType.EditImage: + { + // read patch mode + PatchMode patchMode = PatchMode.Replace; + if (!string.IsNullOrWhiteSpace(entry.PatchMode) && !Enum.TryParse(entry.PatchMode, true, out patchMode)) + return TrackSkip($"the {nameof(PatchConfig.PatchMode)} is invalid. Expected one of these values: [{string.Join(", ", Enum.GetNames(typeof(PatchMode)))}]."); + + // save + if (!this.TryPrepareLocalAsset(pack, entry.FromFile, tokenContext, migrator, out string error, out TokenString fromAsset)) + return TrackSkip(error); + patch = new EditImagePatch(entry.LogName, pack, assetName, conditions, fromAsset, entry.FromArea, entry.ToArea, patchMode, this.Monitor, this.Helper.Content.NormaliseAssetName); + } + break; + + default: + return TrackSkip($"unsupported patch type '{action}'."); + } + + // skip if not enabled + // note: we process the patch even if it's disabled, so any errors are caught by the modder instead of only failing after the patch is enabled. + if (!enabled) + return TrackSkip($"{nameof(PatchConfig.Enabled)} is false.", warn: false); + + // save patch + this.PatchManager.Add(patch); + return true; + } + catch (Exception ex) + { + return TrackSkip($"error reading info. Technical details:\n{ex}"); + } + } + + /// Normalise and parse the given condition values. + /// The raw condition values to normalise. + /// The tokens available for this content pack. + /// The migrator which validates and migrates content pack data. + /// The normalised conditions. + /// An error message indicating why normalisation failed. + private bool TryParseConditions(InvariantDictionary raw, IContext tokenContext, IMigration migrator, out ConditionDictionary conditions, out string error) + { + conditions = new ConditionDictionary(); + + // no conditions + if (raw == null || !raw.Any()) + { + error = null; + return true; + } + + // parse conditions + Lexer lexer = new Lexer(); + foreach (KeyValuePair pair in raw) + { + // parse condition key + ILexToken[] lexTokens = lexer.ParseBits(pair.Key, impliedBraces: true).ToArray(); + if (lexTokens.Length != 1 || !(lexTokens[0] is LexTokenToken lexToken) || lexToken.PipedTokens.Any()) + { + error = $"'{pair.Key}' isn't a valid token name"; + conditions = null; + return false; + } + TokenName name = new TokenName(lexToken.Name, lexToken.InputArg?.Text); + + // apply migrations + if (!migrator.TryMigrate(ref name, out error)) + { + conditions = null; + return false; + } + + // get token + IToken token = tokenContext.GetToken(name, enforceContext: false); + if (token == null) + { + error = $"'{pair.Key}' isn't a valid condition; must be one of {string.Join(", ", tokenContext.GetTokens(enforceContext: false).Select(p => p.Name).OrderBy(p => p))}"; + conditions = null; + return false; + } + + // validate subkeys + if (!token.CanHaveSubkeys) + { + if (name.HasSubkey()) + { + error = $"{name.Key} conditions don't allow subkeys (:)"; + conditions = null; + return false; + } + } + else if (token.RequiresSubkeys) + { + if (!name.HasSubkey()) + { + error = $"{name.Key} conditions must specify a token subkey (see readme for usage)"; + conditions = null; + return false; + } + } + + // parse values + InvariantHashSet values = this.ParseCommaDelimitedField(pair.Value); + if (!values.Any()) + { + error = $"{name} can't be empty"; + conditions = null; + return false; + } + + // validate token keys & values + if (!token.TryValidate(name, values, out string customError)) + { + error = $"invalid {name} condition: {customError}"; + conditions = null; + return false; + } + + // create condition + conditions[name] = new Condition(name, values); + } + + // return parsed conditions + error = null; + return true; + } + + /// Parse a comma-delimited set of case-insensitive condition values. + /// The field value to parse. + public InvariantHashSet ParseCommaDelimitedField(string field) + { + if (string.IsNullOrWhiteSpace(field)) + return new InvariantHashSet(); + + IEnumerable values = ( + from value in field.Split(',') + where !string.IsNullOrWhiteSpace(value) + select value.Trim() + ); + return new InvariantHashSet(values); + } + + /// Parse a boolean value from a string which can contain tokens, and validate that it's valid. + /// The raw string which may contain tokens. + /// The tokens available for this content pack. + /// The migrator which validates and migrates content pack data. + /// An error phrase indicating why parsing failed (if applicable). + /// The parsed value. + private bool TryParseEnabled(string rawValue, IContext tokenContext, IMigration migrator, out string error, out bool parsed) + { + parsed = false; + + // analyse string + if (!this.TryParseTokenString(rawValue, tokenContext, migrator, out error, out TokenString tokenString)) + return false; + + // validate & extract tokens + string text = rawValue; + if (tokenString.HasAnyTokens) + { + // only one token allowed + if (!tokenString.IsSingleTokenOnly) + { + error = "can't be treated as a true/false value because it contains multiple tokens."; + return false; + } + + // check token options + TokenName tokenName = tokenString.Tokens.First(); + IToken token = tokenContext.GetToken(tokenName, enforceContext: false); + InvariantHashSet allowedValues = token?.GetAllowedValues(tokenName); + if (token == null || token.IsMutable || !token.IsValidInContext) + { + error = $"can only use static tokens in this field, consider using a {nameof(PatchConfig.When)} condition instead."; + return false; + } + if (allowedValues == null || !allowedValues.All(p => bool.TryParse(p, out _))) + { + error = "that token isn't restricted to 'true' or 'false'."; + return false; + } + if (token.CanHaveMultipleValues(tokenName)) + { + error = "can't be treated as a true/false value because that token can have multiple values."; + return false; + } + + text = token.GetValues(tokenName).First(); + } + + // parse text + if (!bool.TryParse(text, out parsed)) + { + error = $"can't parse {tokenString.Raw} as a true/false value."; + return false; + } + return true; + } + + /// Parse a string which can contain tokens, and validate that it's valid. + /// The raw string which may contain tokens. + /// The tokens available for this content pack. + /// The migrator which validates and migrates content pack data. + /// An error phrase indicating why parsing failed (if applicable). + /// The parsed value. + private bool TryParseTokenString(string rawValue, IContext tokenContext, IMigration migrator, out string error, out TokenString parsed) + { + // parse + parsed = new TokenString(rawValue, tokenContext); + if (!migrator.TryMigrate(ref parsed, out error)) + return false; + + // validate unknown tokens + if (parsed.InvalidTokens.Any()) + { + error = $"found unknown tokens ({string.Join(", ", parsed.InvalidTokens.OrderBy(p => p))})"; + parsed = null; + return false; + } + + // validate tokens + foreach (TokenName tokenName in parsed.Tokens) + { + IToken token = tokenContext.GetToken(tokenName, enforceContext: false); + if (token == null) + { + error = $"{{{{{tokenName}}}}} can't be used as a token because that token could not be found."; // should never happen + parsed = null; + return false; + } + if (token.CanHaveMultipleValues(tokenName)) + { + error = $"{{{{{tokenName}}}}} can't be used as a token because it can have multiple values."; + parsed = null; + return false; + } + } + + // looks OK + error = null; + return true; + } + + + /// Prepare a local asset file for a patch to use. + /// The content pack being loaded. + /// The asset path in the content patch. + /// The tokens available for this content pack. + /// The migrator which validates and migrates content pack data. + /// The error reason if preparing the asset fails. + /// The parsed value. + /// Returns whether the local asset was successfully prepared. + private bool TryPrepareLocalAsset(ManagedContentPack pack, string path, IContext tokenContext, IMigration migrator, out string error, out TokenString tokenedPath) + { + // normalise raw value + path = this.NormaliseLocalAssetPath(pack, path); + if (path == null) + { + error = $"must set the {nameof(PatchConfig.FromFile)} field for this action type."; + tokenedPath = null; + return false; + } + + // tokenise + if (!this.TryParseTokenString(path, tokenContext, migrator, out string tokenError, out tokenedPath)) + { + error = $"the {nameof(PatchConfig.FromFile)} is invalid: {tokenError}"; + tokenedPath = null; + return false; + } + + // looks OK + error = null; + return true; + } + + /// Get a normalised file path relative to the content pack folder. + /// The content pack. + /// The relative asset path. + private string NormaliseLocalAssetPath(ManagedContentPack contentPack, string path) + { + // normalise asset name + if (string.IsNullOrWhiteSpace(path)) + return null; + string newPath = this.Helper.Content.NormaliseAssetName(path); + + // add .xnb extension if needed (it's stripped from asset names) + string fullPath = contentPack.GetFullPath(newPath); + if (!File.Exists(fullPath)) + { + if (File.Exists($"{fullPath}.xnb") || Path.GetExtension(path) == ".xnb") + newPath += ".xnb"; + } + + return newPath; + } + } +} diff --git a/Mods/ContentPatcher/Properties/AssemblyInfo.cs b/Mods/ContentPatcher/Properties/AssemblyInfo.cs new file mode 100644 index 000000000..2d23c767e --- /dev/null +++ b/Mods/ContentPatcher/Properties/AssemblyInfo.cs @@ -0,0 +1,36 @@ +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; + +// 有关程序集的一般信息由以下 +// 控制。更改这些特性值可修改 +// 与程序集关联的信息。 +[assembly: AssemblyTitle("ContentPatcher")] +[assembly: AssemblyDescription("")] +[assembly: AssemblyConfiguration("")] +[assembly: AssemblyCompany("")] +[assembly: AssemblyProduct("ContentPatcher")] +[assembly: AssemblyCopyright("Copyright © 2019")] +[assembly: AssemblyTrademark("")] +[assembly: AssemblyCulture("")] + +// 将 ComVisible 设置为 false 会使此程序集中的类型 +//对 COM 组件不可见。如果需要从 COM 访问此程序集中的类型 +//请将此类型的 ComVisible 特性设置为 true。 +[assembly: ComVisible(false)] + +// 如果此项目向 COM 公开,则下列 GUID 用于类型库的 ID +[assembly: Guid("8e1d56b0-d640-4eb0-a703-e280c40a655d")] + +// 程序集的版本信息由下列四个值组成: +// +// 主版本 +// 次版本 +// 生成号 +// 修订号 +// +// 可以指定所有值,也可以使用以下所示的 "*" 预置版本号和修订号 +//通过使用 "*",如下所示: +// [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 000000000..71881bb33 --- /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 000000000..00578b9ab --- /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 000000000..a3fd06f4f --- /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 000000000..698c40e5b --- /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 000000000..bf77a936b --- /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 000000000..8981db0b6 --- /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 000000000..6e4302203 --- /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 000000000..301947e45 --- /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 000000000..eab1e1a48 --- /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 000000000..f60fa1363 --- /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 000000000..dcf12be87 --- /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 000000000..0480688ea --- /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 000000000..717b4a091 --- /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 000000000..ec85664c0 --- /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 000000000..a07804ef5 --- /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 000000000..13703a3f2 --- /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 000000000..19a7a28b6 --- /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 000000000..9c6643618 --- /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 000000000..1e5b9a8c3 --- /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 000000000..5dcfbf8b6 --- /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 000000000..c5772d2cf --- /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 000000000..33e9c7ab7 --- /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 000000000..e9282f02a --- /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 000000000..ccacd36d8 --- /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 000000000..6d9608688 --- /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 000000000..1a0816a45 --- /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 000000000..5b2baa529 --- /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 000000000..11805173e --- /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 000000000..6399fae5c --- /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 000000000..387bf0632 --- /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 000000000..0c2d31e77 --- /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 000000000..8c8c14d50 --- /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 000000000..60fb34c1e --- /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 000000000..1223a0fed --- /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 000000000..a492c5bc9 --- /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 000000000..16377bcca --- /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 000000000..dbb92f538 --- /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 000000000..404c84bc5 --- /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 000000000..7a133bbdd --- /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 000000000..59e365c3d --- /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 000000000..fd3fb6a28 --- /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 000000000..da83bff3f --- /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 000000000..a3ecd7d54 --- /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 000000000..031e98d5a --- /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 000000000..519cfdfb6 --- /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 000000000..1b7afaa93 --- /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 000000000..6c62bcbbd --- /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\StardewModdingAPI.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 000000000..40ef1a36b --- /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 000000000..c328c4e0f --- /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 000000000..8d33854f5 --- /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 000000000..7a48472c9 --- /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 000000000..65822e12c --- /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/JsonAssets/Api.cs b/Mods/JsonAssets/Api.cs new file mode 100644 index 000000000..dc46c49a6 --- /dev/null +++ b/Mods/JsonAssets/Api.cs @@ -0,0 +1,120 @@ +using System; +using System.Collections.Generic; + +namespace JsonAssets +{ + public interface IApi + { + void LoadAssets(string path); + + int GetObjectId(string name); + int GetCropId(string name); + int GetFruitTreeId(string name); + int GetBigCraftableId(string name); + int GetHatId(string name); + int GetWeaponId(string name); + + IDictionary GetAllObjectIds(); + IDictionary GetAllCropIds(); + IDictionary GetAllFruitTreeIds(); + IDictionary GetAllBigCraftableIds(); + IDictionary GetAllHatIds(); + IDictionary GetAllWeaponIds(); + + event EventHandler IdsAssigned; + event EventHandler AddedItemsToShop; + } + + public class Api : IApi + { + private readonly Action loadFolder; + + public Api(Action loadFolder) + { + this.loadFolder = loadFolder; + } + + public void LoadAssets(string path) + { + this.loadFolder(path); + } + + public int GetObjectId(string name) + { + return Mod.instance.objectIds.ContainsKey(name) ? Mod.instance.objectIds[name] : -1; + } + + public int GetCropId(string name) + { + return Mod.instance.cropIds.ContainsKey(name) ? Mod.instance.cropIds[name] : -1; + } + + public int GetFruitTreeId(string name) + { + return Mod.instance.fruitTreeIds.ContainsKey(name) ? Mod.instance.fruitTreeIds[name] : -1; + } + + public int GetBigCraftableId(string name) + { + return Mod.instance.bigCraftableIds.ContainsKey(name) ? Mod.instance.bigCraftableIds[name] : -1; + } + + public int GetHatId(string name) + { + return Mod.instance.hatIds.ContainsKey(name) ? Mod.instance.hatIds[name] : -1; + } + + public int GetWeaponId(string name) + { + return Mod.instance.weaponIds.ContainsKey(name) ? Mod.instance.weaponIds[name] : -1; + } + + public IDictionary GetAllObjectIds() + { + return new Dictionary(Mod.instance.objectIds); + } + + public IDictionary GetAllCropIds() + { + return new Dictionary(Mod.instance.cropIds); + } + + public IDictionary GetAllFruitTreeIds() + { + return new Dictionary(Mod.instance.fruitTreeIds); + } + + public IDictionary GetAllBigCraftableIds() + { + return new Dictionary(Mod.instance.bigCraftableIds); + } + + public IDictionary GetAllHatIds() + { + return new Dictionary(Mod.instance.hatIds); + } + + public IDictionary GetAllWeaponIds() + { + return new Dictionary(Mod.instance.weaponIds); + } + + public event EventHandler IdsAssigned; + internal void InvokeIdsAssigned() + { + Log.trace("Event: IdsAssigned"); + if (IdsAssigned == null) + return; + Util.invokeEvent("JsonAssets.Api.IdsAssigned", IdsAssigned.GetInvocationList(), null); + } + + public event EventHandler AddedItemsToShop; + internal void InvokeAddedItemsToShop() + { + Log.trace("Event: AddedItemsToShop"); + if (AddedItemsToShop == null) + return; + Util.invokeEvent("JsonAssets.Api.AddedItemsToShop", AddedItemsToShop.GetInvocationList(), null); + } + } +} diff --git a/Mods/JsonAssets/ContentInjector.cs b/Mods/JsonAssets/ContentInjector.cs new file mode 100644 index 000000000..ae4daedd5 --- /dev/null +++ b/Mods/JsonAssets/ContentInjector.cs @@ -0,0 +1,412 @@ +using JsonAssets.Data; +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Graphics; +using StardewModdingAPI; +using StardewValley; +using System; +using System.Collections.Generic; + +namespace JsonAssets +{ + public class ContentInjector : IAssetEditor + { + public bool CanEdit(IAssetInfo asset) + { + if (asset.AssetNameEquals("Data\\ObjectInformation")) + return true; + if (asset.AssetNameEquals("Data\\Crops")) + return true; + if (asset.AssetNameEquals("Data\\fruitTrees")) + return true; + if (asset.AssetNameEquals("Data\\CookingRecipes")) + return true; + if (asset.AssetNameEquals("Data\\CraftingRecipes")) + return true; + if (asset.AssetNameEquals("Data\\BigCraftablesInformation")) + return true; + if (asset.AssetNameEquals("Data\\hats")) + return true; + if (asset.AssetNameEquals("Data\\weapons")) + return true; + if (asset.AssetNameEquals("Data\\NPCGiftTastes")) + return true; + if (asset.AssetNameEquals("Maps\\springobjects")) + return true; + if (asset.AssetNameEquals("TileSheets\\crops")) + return true; + if (asset.AssetNameEquals("TileSheets\\fruitTrees")) + return true; + if (asset.AssetNameEquals("TileSheets\\Craftables") || asset.AssetNameEquals("TileSheets\\Craftables_indoor") || asset.AssetNameEquals("TileSheets\\Craftables_outdoor")) + return true; // _indoor/_outdoor for Seasonal Immersion compat + if (asset.AssetNameEquals("Characters\\Farmer\\hats")) + return true; + if (asset.AssetNameEquals("TileSheets\\weapons")) + return true; + return false; + } + + public void Edit(IAssetData asset) + { + if (asset.AssetNameEquals("Data\\ObjectInformation")) + { + IDictionary data = asset.AsDictionary().Data; + foreach (ObjectData obj in Mod.instance.objects) + { + try + { + Log.trace($"Injecting to objects: {obj.GetObjectId()}: {obj.GetObjectInformation()}"); + data.Add(obj.GetObjectId(), obj.GetObjectInformation()); + } + catch (Exception e) + { + Log.error($"Exception injecting object information for {obj.Name}: {e}"); + } + } + } + else if (asset.AssetNameEquals("Data\\Crops")) + { + IDictionary data = asset.AsDictionary().Data; + foreach (CropData crop in Mod.instance.crops) + { + try + { + Log.trace($"Injecting to crops: {crop.GetSeedId()}: {crop.GetCropInformation()}"); + data.Add(crop.GetSeedId(), crop.GetCropInformation()); + } + catch (Exception e) + { + Log.error($"Exception injecting crop for {crop.Name}: {e}"); + } + } + } + else if (asset.AssetNameEquals("Data\\fruitTrees")) + { + IDictionary data = asset.AsDictionary().Data; + foreach (FruitTreeData fruitTree in Mod.instance.fruitTrees) + { + try + { + Log.trace($"Injecting to fruit trees: {fruitTree.GetSaplingId()}: {fruitTree.GetFruitTreeInformation()}"); + data.Add(fruitTree.GetSaplingId(), fruitTree.GetFruitTreeInformation()); + } + catch (Exception e) + { + Log.error($"Exception injecting fruit tree for {fruitTree.Name}: {e}"); + } + } + } + else if (asset.AssetNameEquals("Data\\CookingRecipes")) + { + IDictionary data = asset.AsDictionary().Data; + foreach (ObjectData obj in Mod.instance.objects) + { + try + { + if (obj.Recipe == null) + continue; + if (obj.Category != ObjectData.Category_.Cooking) + continue; + Log.trace($"Injecting to cooking recipes: {obj.Name}: {obj.Recipe.GetRecipeString(obj)}"); + data.Add(obj.Name, obj.Recipe.GetRecipeString(obj)); + } + catch (Exception e) + { + Log.error($"Exception injecting cooking recipe for {obj.Name}: {e}"); + } + } + } + else if (asset.AssetNameEquals("Data\\CraftingRecipes")) + { + IDictionary data = asset.AsDictionary().Data; + foreach (ObjectData obj in Mod.instance.objects) + { + try + { + if (obj.Recipe == null) + continue; + if (obj.Category == ObjectData.Category_.Cooking) + continue; + Log.trace($"Injecting to crafting recipes: {obj.Name}: {obj.Recipe.GetRecipeString(obj)}"); + data.Add(obj.Name, obj.Recipe.GetRecipeString(obj)); + } + catch (Exception e) + { + Log.error($"Exception injecting crafting recipe for {obj.Name}: {e}"); + } + } + foreach (BigCraftableData big in Mod.instance.bigCraftables) + { + try + { + if (big.Recipe == null) + continue; + Log.trace($"Injecting to crafting recipes: {big.Name}: {big.Recipe.GetRecipeString(big)}"); + data.Add(big.Name, big.Recipe.GetRecipeString(big)); + } + catch (Exception e) + { + Log.error($"Exception injecting crafting recipe for {big.Name}: {e}"); + } + } + } + else if (asset.AssetNameEquals("Data\\BigCraftablesInformation")) + { + IDictionary data = asset.AsDictionary().Data; + foreach (BigCraftableData big in Mod.instance.bigCraftables) + { + try + { + Log.trace($"Injecting to big craftables: {big.GetCraftableId()}: {big.GetCraftableInformation()}"); + data.Add(big.GetCraftableId(), big.GetCraftableInformation()); + } + catch (Exception e) + { + Log.error($"Exception injecting object information for {big.Name}: {e}"); + } + } + } + else if (asset.AssetNameEquals("Data\\hats")) + { + IDictionary data = asset.AsDictionary().Data; + foreach (HatData hat in Mod.instance.hats) + { + try + { + Log.trace($"Injecting to hats: {hat.GetHatId()}: {hat.GetHatInformation()}"); + data.Add(hat.GetHatId(), hat.GetHatInformation()); + } + catch (Exception e) + { + Log.error($"Exception injecting hat information for {hat.Name}: {e}"); + } + } + } + else if (asset.AssetNameEquals("Data\\weapons")) + { + IDictionary data = asset.AsDictionary().Data; + foreach (WeaponData weapon in Mod.instance.weapons) + { + try + { + Log.trace($"Injecting to weapons: {weapon.GetWeaponId()}: {weapon.GetWeaponInformation()}"); + data.Add(weapon.GetWeaponId(), weapon.GetWeaponInformation()); + } + catch (Exception e) + { + Log.error($"Exception injecting weapon information for {weapon.Name}: {e}"); + } + } + } + else if (asset.AssetNameEquals("Data\\NPCGiftTastes")) + { + IDictionary data = asset.AsDictionary().Data; + // TODO: This could be optimized from mn to... m + n? + // Basically, iterate through objects and create Dictionary + // Iterate through objects, each section and add to dict[npc][approp. section] + // Point is, I'm doing this the lazy way right now + Dictionary newData = new Dictionary(data); + foreach (KeyValuePair npc in data) + { + if (npc.Key.StartsWith("Universal_")) + continue; + + string[] sections = npc.Value.Split('/'); + if ( sections.Length != 11 ) + { + Log.warn($"Bad gift taste data for {npc.Key}!"); + continue; + } + + string loveStr = sections[0]; + List loveIds = new List(sections[1].Split(' ')); + string likeStr = sections[2]; + List likeIds = new List(sections[3].Split(' ')); + string dislikeStr = sections[4]; + List dislikeIds = new List(sections[5].Split(' ')); + string hateStr = sections[6]; + List hateIds = new List(sections[7].Split(' ')); + string neutralStr = sections[8]; + List neutralIds = new List(sections[9].Split(' ')); + + foreach (ObjectData obj in Mod.instance.objects ) + { + if (obj.GiftTastes == null) + continue; + if (obj.GiftTastes.Love != null && obj.GiftTastes.Love.Contains(npc.Key)) + loveIds.Add(obj.GetObjectId().ToString()); + if (obj.GiftTastes.Like != null && obj.GiftTastes.Like.Contains(npc.Key)) + likeIds.Add(obj.GetObjectId().ToString()); + if (obj.GiftTastes.Neutral != null && obj.GiftTastes.Neutral.Contains(npc.Key)) + neutralIds.Add(obj.GetObjectId().ToString()); + if (obj.GiftTastes.Dislike != null && obj.GiftTastes.Dislike.Contains(npc.Key)) + dislikeIds.Add(obj.GetObjectId().ToString()); + if (obj.GiftTastes.Hate != null && obj.GiftTastes.Hate.Contains(npc.Key)) + hateIds.Add(obj.GetObjectId().ToString()); + } + + string loveIdStr = string.Join(" ", loveIds); + string likeIdStr = string.Join(" ", likeIds); + string dislikeIdStr = string.Join(" ", dislikeIds); + string hateIdStr = string.Join(" ", hateIds); + string neutralIdStr = string.Join(" ", neutralIds); + newData[npc.Key] = $"{loveStr}/{loveIdStr}/{likeStr}/{likeIdStr}/{dislikeStr}/{dislikeIdStr}/{hateStr}/{hateIdStr}/{neutralStr}/{neutralIdStr}/ "; + + Log.trace($"Adding gift tastes for {npc.Key}: {newData[npc.Key]}"); + } + asset.ReplaceWith(newData); + } + else if (asset.AssetNameEquals("Maps\\springobjects")) + { + Texture2D oldTex = asset.AsImage().Data; + Texture2D newTex = new Texture2D(Game1.graphics.GraphicsDevice, oldTex.Width, Math.Max(oldTex.Height, 4096)); + asset.ReplaceWith(newTex); + asset.AsImage().PatchImage(oldTex); + + foreach (ObjectData obj in Mod.instance.objects) + { + try + { + Log.trace($"Injecting {obj.Name} sprites @ {this.objectRect(obj.GetObjectId())}"); + asset.AsImage().PatchImage(obj.texture, null, this.objectRect(obj.GetObjectId())); + if (obj.IsColored) + { + Log.trace($"Injecting {obj.Name} color sprites @ {this.objectRect(obj.GetObjectId() + 1)}"); + asset.AsImage().PatchImage(obj.textureColor, null, this.objectRect(obj.GetObjectId() + 1)); + } + } + catch ( Exception e ) + { + Log.error($"Exception injecting sprite for {obj.Name}: {e}"); + } + } + } + else if (asset.AssetNameEquals("TileSheets\\crops")) + { + Texture2D oldTex = asset.AsImage().Data; + Texture2D newTex = new Texture2D(Game1.graphics.GraphicsDevice, oldTex.Width, Math.Max(oldTex.Height, 4096)); + asset.ReplaceWith(newTex); + asset.AsImage().PatchImage(oldTex); + + foreach (CropData crop in Mod.instance.crops) + { + try + { + Log.trace($"Injecting {crop.Name} crop images @ {this.cropRect(crop.GetCropSpriteIndex())}"); + asset.AsImage().PatchImage(crop.texture, null, this.cropRect(crop.GetCropSpriteIndex())); + } + catch (Exception e) + { + Log.error($"Exception injecting crop sprite for {crop.Name}: {e}"); + } + } + } + else if (asset.AssetNameEquals("TileSheets\\fruitTrees")) + { + Texture2D oldTex = asset.AsImage().Data; + Texture2D newTex = new Texture2D(Game1.graphics.GraphicsDevice, oldTex.Width, Math.Max(oldTex.Height, 4096)); + asset.ReplaceWith(newTex); + asset.AsImage().PatchImage(oldTex); + + foreach (FruitTreeData fruitTree in Mod.instance.fruitTrees) + { + try + { + Log.trace($"Injecting {fruitTree.Name} fruit tree images @ {this.fruitTreeRect(fruitTree.GetFruitTreeIndex())}"); + asset.AsImage().PatchImage(fruitTree.texture, null, this.fruitTreeRect(fruitTree.GetFruitTreeIndex())); + } + catch (Exception e) + { + Log.error($"Exception injecting fruit tree sprite for {fruitTree.Name}: {e}"); + } + } + } + else if (asset.AssetNameEquals("TileSheets\\Craftables") || asset.AssetNameEquals("TileSheets\\Craftables_indoor") || asset.AssetNameEquals("TileSheets\\Craftables_outdoor")) + { + Texture2D oldTex = asset.AsImage().Data; + Texture2D newTex = new Texture2D(Game1.graphics.GraphicsDevice, oldTex.Width, Math.Max(oldTex.Height, 4096)); + asset.ReplaceWith(newTex); + asset.AsImage().PatchImage(oldTex); + Log.trace($"Big craftables are now ({oldTex.Width}, {Math.Max(oldTex.Height, 4096)})"); + + foreach (BigCraftableData big in Mod.instance.bigCraftables) + { + try + { + Log.trace($"Injecting {big.Name} sprites @ {this.bigCraftableRect(big.GetCraftableId())}"); + asset.AsImage().PatchImage(big.texture, null, this.bigCraftableRect(big.GetCraftableId())); + } + catch (Exception e) + { + Log.error($"Exception injecting sprite for {big.Name}: {e}"); + } + } + } + else if (asset.AssetNameEquals("Characters\\Farmer\\hats")) + { + Texture2D oldTex = asset.AsImage().Data; + Texture2D newTex = new Texture2D(Game1.graphics.GraphicsDevice, oldTex.Width, Math.Max(oldTex.Height, 4096)); + asset.ReplaceWith(newTex); + asset.AsImage().PatchImage(oldTex); + Log.trace($"Hats are now ({oldTex.Width}, {Math.Max(oldTex.Height, 4096)})"); + + foreach (HatData hat in Mod.instance.hats) + { + try + { + Log.trace($"Injecting {hat.Name} sprites @ {this.hatRect(hat.GetHatId())}"); + asset.AsImage().PatchImage(hat.texture, null, this.hatRect(hat.GetHatId())); + } + catch (Exception e) + { + Log.error($"Exception injecting sprite for {hat.Name}: {e}"); + } + } + } + else if (asset.AssetNameEquals("TileSheets\\weapons")) + { + Texture2D oldTex = asset.AsImage().Data; + Texture2D newTex = new Texture2D(Game1.graphics.GraphicsDevice, oldTex.Width, Math.Max(oldTex.Height, 4096)); + asset.ReplaceWith(newTex); + asset.AsImage().PatchImage(oldTex); + Log.trace($"Weapons are now ({oldTex.Width}, {Math.Max(oldTex.Height, 4096)})"); + + foreach (WeaponData weapon in Mod.instance.weapons) + { + try + { + Log.trace($"Injecting {weapon.Name} sprites @ {this.weaponRect(weapon.GetWeaponId())}"); + asset.AsImage().PatchImage(weapon.texture, null, this.weaponRect(weapon.GetWeaponId())); + } + catch (Exception e) + { + Log.error($"Exception injecting sprite for {weapon.Name}: {e}"); + } + } + } + } + private Rectangle objectRect(int index) + { + return new Rectangle(index % 24 * 16, index / 24 * 16, 16, 16); + } + private Rectangle cropRect(int index) + { + return new Rectangle(index % 2 * 128, index / 2 * 32, 128, 32); + } + private Rectangle fruitTreeRect(int index) + { + return new Rectangle(0, index * 80, 432, 80); + } + private Rectangle bigCraftableRect(int index) + { + return new Rectangle(index % 8 * 16, index / 8 * 32, 16, 32); + } + private Rectangle hatRect(int index) + { + return new Rectangle(index % 12 * 20, index / 12 * 80, 20, 80); + } + private Rectangle weaponRect(int index) + { + return new Rectangle(index % 8 * 16, index / 8 * 16, 16, 16); + } + } +} diff --git a/Mods/JsonAssets/Data/BigCraftableData.cs b/Mods/JsonAssets/Data/BigCraftableData.cs new file mode 100644 index 000000000..c44a57d90 --- /dev/null +++ b/Mods/JsonAssets/Data/BigCraftableData.cs @@ -0,0 +1,103 @@ +using Microsoft.Xna.Framework.Graphics; +using Newtonsoft.Json; +using StardewValley; +using System.Collections.Generic; + +namespace JsonAssets.Data +{ + public class BigCraftableData : DataNeedsId + { + [JsonIgnore] + internal Texture2D texture; + + public class Recipe_ + { + public class Ingredient + { + public object Object { get; set; } + public int Count { get; set; } + } + // Possibly friendship option (letters, like vanilla) and/or skill levels (on levelup?) + public int ResultCount { get; set; } = 1; + public IList Ingredients { get; set; } = new List(); + + public bool IsDefault { get; set; } = false; + public bool CanPurchase { get; set; } = false; + public int PurchasePrice { get; set; } + public string PurchaseFrom { get; set; } = "Gus"; + public IList PurchaseRequirements { get; set; } = new List(); + + internal string GetRecipeString( BigCraftableData parent ) + { + string str = ""; + foreach (Ingredient ingredient in this.Ingredients) + str += Mod.instance.ResolveObjectId(ingredient.Object) + " " + ingredient.Count + " "; + str = str.Substring(0, str.Length - 1); + str += $"/what is this for?/{parent.id}/true/null"; + return str; + } + + internal string GetPurchaseRequirementString() + { + string str = $"1234567890"; + foreach (string cond in this.PurchaseRequirements) + str += $"/{cond}"; + return str; + } + } + + public string Description { get; set; } + + public int Price { get; set; } + + public bool ProvidesLight { get; set; } = false; + + public Recipe_ Recipe { get; set; } + + public bool CanPurchase { get; set; } = false; + public int PurchasePrice { get; set; } + public string PurchaseFrom { get; set; } = "Pierre"; + public IList PurchaseRequirements { get; set; } = new List(); + + public Dictionary NameLocalization = new Dictionary(); + public Dictionary DescriptionLocalization = new Dictionary(); + + public string LocalizedName() + { + LocalizedContentManager.LanguageCode currLang = LocalizedContentManager.CurrentLanguageCode; + if (currLang == LocalizedContentManager.LanguageCode.en) + return this.Name; + if (this.NameLocalization == null || !this.NameLocalization.ContainsKey(currLang.ToString())) + return this.Name; + return this.NameLocalization[currLang.ToString()]; + } + + public string LocalizedDescription() + { + LocalizedContentManager.LanguageCode currLang = LocalizedContentManager.CurrentLanguageCode; + if (currLang == LocalizedContentManager.LanguageCode.en) + return this.Description; + if (this.DescriptionLocalization == null || !this.DescriptionLocalization.ContainsKey(currLang.ToString())) + return this.Description; + return this.DescriptionLocalization[currLang.ToString()]; + } + + public int GetCraftableId() { return this.id; } + + internal string GetCraftableInformation() + { + string str = $"{this.Name}/{this.Price}/-300/Crafting -9/{this.LocalizedDescription()}/true/true/0/{this.LocalizedName()}"; + if (this.ProvidesLight) + str += "/true"; + return str; + } + + internal string GetPurchaseRequirementString() + { + string str = $"1234567890"; + foreach (string cond in this.PurchaseRequirements) + str += $"/{cond}"; + return str; + } + } +} diff --git a/Mods/JsonAssets/Data/ContentPackData.cs b/Mods/JsonAssets/Data/ContentPackData.cs new file mode 100644 index 000000000..8e50ee215 --- /dev/null +++ b/Mods/JsonAssets/Data/ContentPackData.cs @@ -0,0 +1,13 @@ +using System.Collections.Generic; + +namespace JsonAssets.Data +{ + public class ContentPackData + { + public string Name { get; set; } + public string Description { get; set; } + public string Version { get; set; } + public string Author { get; set; } + public IList UpdateKeys { get; set; } = new List(); + } +} diff --git a/Mods/JsonAssets/Data/CropData.cs b/Mods/JsonAssets/Data/CropData.cs new file mode 100644 index 000000000..9dfb34fe4 --- /dev/null +++ b/Mods/JsonAssets/Data/CropData.cs @@ -0,0 +1,73 @@ +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Graphics; +using Newtonsoft.Json; +using System.Collections.Generic; + +namespace JsonAssets.Data +{ + public class CropData : DataNeedsId + { + [JsonIgnore] + internal Texture2D texture; + + public object Product { get; set; } + public string SeedName { get; set; } + public string SeedDescription { get; set; } + + public IList Seasons { get; set; } = new List(); + public IList Phases { get; set; } = new List(); + public int RegrowthPhase { get; set; } = -1; + public bool HarvestWithScythe { get; set; } = false; + public bool TrellisCrop { get; set; } = false; + public IList Colors { get; set; } = new List(); + public class Bonus_ + { + public int MinimumPerHarvest { get; set; } + public int MaximumPerHarvest { get; set; } + public int MaxIncreasePerFarmLevel { get; set; } + public double ExtraChance { get; set; } + } + public Bonus_ Bonus { get; set; } = null; + + public IList SeedPurchaseRequirements { get; set; } = new List(); + public int SeedPurchasePrice { get; set; } + public string SeedPurchaseFrom { get; set; } = "Pierre"; + + public Dictionary SeedNameLocalization = new Dictionary(); + public Dictionary SeedDescriptionLocalization = new Dictionary(); + + internal ObjectData seed; + public int GetSeedId() { return this.seed.id; } + public int GetCropSpriteIndex() { return this.id; } + internal string GetCropInformation() + { + string str = ""; + //str += GetProductId() + "/"; + foreach (int phase in this.Phases ) + { + str += phase + " "; + } + str = str.Substring(0, str.Length - 1) + "/"; + foreach (string season in this.Seasons) + { + str += season + " "; + } + str = str.Substring(0, str.Length - 1) + "/"; + str += $"{this.GetCropSpriteIndex()}/{Mod.instance.ResolveObjectId(this.Product)}/{this.RegrowthPhase}/"; + str += (this.HarvestWithScythe ? "1" : "0") + "/"; + if (this.Bonus != null) + str += $"true {this.Bonus.MinimumPerHarvest} {this.Bonus.MaximumPerHarvest} {this.Bonus.MaxIncreasePerFarmLevel} {this.Bonus.ExtraChance}/"; + else str += "false/"; + str += (this.TrellisCrop ? "true" : "false") + "/"; + if (this.Colors != null && this.Colors.Count > 0) + { + str += "true"; + foreach (Color color in this.Colors) + str += $" {color.R} {color.G} {color.B}"; + } + else + str += "false"; + return str; + } + } +} diff --git a/Mods/JsonAssets/Data/DataNeedsId.cs b/Mods/JsonAssets/Data/DataNeedsId.cs new file mode 100644 index 000000000..a18f2ec3c --- /dev/null +++ b/Mods/JsonAssets/Data/DataNeedsId.cs @@ -0,0 +1,9 @@ +namespace JsonAssets.Data +{ + public abstract class DataNeedsId + { + public string Name { get; set; } + + internal int id = -1; + } +} diff --git a/Mods/JsonAssets/Data/FruitTreeData.cs b/Mods/JsonAssets/Data/FruitTreeData.cs new file mode 100644 index 000000000..97a5e19b0 --- /dev/null +++ b/Mods/JsonAssets/Data/FruitTreeData.cs @@ -0,0 +1,33 @@ +using Microsoft.Xna.Framework.Graphics; +using Newtonsoft.Json; +using System.Collections.Generic; + +namespace JsonAssets.Data +{ + public class FruitTreeData : DataNeedsId + { + [JsonIgnore] + internal Texture2D texture; + + public object Product { get; set; } + public string SaplingName { get; set; } + public string SaplingDescription { get; set; } + + public string Season { get; set; } + + public IList SaplingPurchaseRequirements { get; set; } = new List(); + public int SaplingPurchasePrice { get; set; } + public string SaplingPurchaseFrom { get; set; } = "Pierre"; + + public Dictionary SaplingNameLocalization = new Dictionary(); + public Dictionary SaplingDescriptionLocalization = new Dictionary(); + + internal ObjectData sapling; + public int GetSaplingId() { return this.sapling.id; } + public int GetFruitTreeIndex() { return this.id; } + internal string GetFruitTreeInformation() + { + return $"{this.GetFruitTreeIndex()}/{this.Season}/{Mod.instance.ResolveObjectId(this.Product)}/what goes here?"; + } + } +} diff --git a/Mods/JsonAssets/Data/HatData.cs b/Mods/JsonAssets/Data/HatData.cs new file mode 100644 index 000000000..c1142d222 --- /dev/null +++ b/Mods/JsonAssets/Data/HatData.cs @@ -0,0 +1,48 @@ +using Microsoft.Xna.Framework.Graphics; +using Newtonsoft.Json; +using StardewValley; +using System.Collections.Generic; + +namespace JsonAssets.Data +{ + class HatData : DataNeedsId + { + [JsonIgnore] + internal Texture2D texture; + + public string Description { get; set; } + public int PurchasePrice { get; set; } + public bool ShowHair { get; set; } + public bool IgnoreHairstyleOffset { get; set; } + + public Dictionary NameLocalization = new Dictionary(); + public Dictionary DescriptionLocalization = new Dictionary(); + + public string LocalizedName() + { + LocalizedContentManager.LanguageCode currLang = LocalizedContentManager.CurrentLanguageCode; + if (currLang == LocalizedContentManager.LanguageCode.en) + return this.Name; + if (this.NameLocalization == null || !this.NameLocalization.ContainsKey(currLang.ToString())) + return this.Name; + return this.NameLocalization[currLang.ToString()]; + } + + public string LocalizedDescription() + { + LocalizedContentManager.LanguageCode currLang = LocalizedContentManager.CurrentLanguageCode; + if (currLang == LocalizedContentManager.LanguageCode.en) + return this.Description; + if (this.DescriptionLocalization == null || !this.DescriptionLocalization.ContainsKey(currLang.ToString())) + return this.Description; + return this.DescriptionLocalization[currLang.ToString()]; + } + + public int GetHatId() { return this.id; } + + internal string GetHatInformation() + { + return $"{this.Name}/{this.LocalizedDescription()}/" + (this.ShowHair ? "true" : "false" ) + "/" + (this.IgnoreHairstyleOffset ? "true" : "false") + $"/{this.LocalizedName()}"; + } + } +} diff --git a/Mods/JsonAssets/Data/ObjectData.cs b/Mods/JsonAssets/Data/ObjectData.cs new file mode 100644 index 000000000..82bc91680 --- /dev/null +++ b/Mods/JsonAssets/Data/ObjectData.cs @@ -0,0 +1,175 @@ +using Microsoft.Xna.Framework.Graphics; +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; +using StardewValley; +using System.Collections.Generic; +using SObject = StardewValley.Object; + +namespace JsonAssets.Data +{ + public class ObjectData : DataNeedsId + { + [JsonIgnore] + internal Texture2D texture; + [JsonIgnore] + internal Texture2D textureColor; + + [JsonConverter(typeof(StringEnumConverter))] + public enum Category_ + { + // SDV Patcher made these static readonly, so I can't use them in the enum + Vegetable = -75, //SObject.VegetableCategory, + Fruit = -79, //SObject.FruitsCategory, + Flower = -80, //SObject.flowersCategory, + Gem = -2, //SObject.GemCategory, + Fish = -4, //SObject.FishCategory, + Egg = -5, //SObject.EggCategory, + Milk = -6, //SObject.MilkCategory, + Cooking = -7, //SObject.CookingCategory, + Crafting = -8, //SObject.CraftingCategory, + Mineral = -12, //SObject.mineralsCategory, + Meat = -14, //SObject.meatCategory, + Metal = -15, //SObject.metalResources, + Junk = -20, //SObject.junkCategory, + Syrup = -27, //SObject.syrupCategory, + MonsterLoot = -28, //SObject.monsterLootCategory, + ArtisanGoods = -26, //SObject.artisanGoodsCategory, + Seeds = -74, //SObject.SeedsCategory, + Ring = -96, //SObject.ringCategory, + AnimalGoods = -18, //SObject.sellAtPierresAndMarnies + } + + public class Recipe_ + { + public class Ingredient + { + public object Object { get; set; } + public int Count { get; set; } + } + // Possibly friendship option (letters, like vanilla) and/or skill levels (on levelup?) + public int ResultCount { get; set; } = 1; + public IList Ingredients { get; set; } = new List(); + + public bool IsDefault { get; set; } = false; + public bool CanPurchase { get; set; } = false; + public int PurchasePrice { get; set; } + public string PurchaseFrom { get; set; } = "Gus"; + public IList PurchaseRequirements { get; set; } = new List(); + + internal string GetRecipeString( ObjectData parent ) + { + string str = ""; + foreach (Ingredient ingredient in this.Ingredients) + str += Mod.instance.ResolveObjectId(ingredient.Object) + " " + ingredient.Count + " "; + str = str.Substring(0, str.Length - 1); + str += $"/what is this for?/{parent.id}/"; + if (parent.Category != Category_.Cooking) + str += "false/"; + str += "/null"; // TODO: Requirement + return str; + } + + internal string GetPurchaseRequirementString() + { + string str = $"1234567890"; + foreach (string cond in this.PurchaseRequirements) + str += $"/{cond}"; + return str; + } + } + + public class FoodBuffs_ + { + public int Farming { get; set; } = 0; + public int Fishing { get; set; } = 0; + public int Mining { get; set; } = 0; + public int Luck { get; set; } = 0; + public int Foraging { get; set; } = 0; + public int MaxStamina { get; set; } = 0; + public int MagnetRadius { get; set; } = 0; + public int Speed { get; set; } = 0; + public int Defense { get; set; } = 0; + public int Attack { get; set; } = 0; + public int Duration { get; set; } = 0; + } + + public string Description { get; set; } + public Category_ Category { get; set; } + public bool IsColored { get; set; } = false; + + public int Price { get; set; } + + public Recipe_ Recipe { get; set; } + + public int Edibility { get; set; } = SObject.inedible; + public bool EdibleIsDrink { get; set; } = false; + public FoodBuffs_ EdibleBuffs = new FoodBuffs_(); + + public bool CanPurchase { get; set; } = false; + public int PurchasePrice { get; set; } + public string PurchaseFrom { get; set; } = "Pierre"; + public IList PurchaseRequirements { get; set; } = new List(); + + public class GiftTastes_ + { + public IList Love = new List(); + public IList Like = new List(); + public IList Neutral = new List(); + public IList Dislike = new List(); + public IList Hate = new List(); + } + public GiftTastes_ GiftTastes; + + public Dictionary NameLocalization = new Dictionary(); + public Dictionary DescriptionLocalization = new Dictionary(); + + public string LocalizedName() + { + LocalizedContentManager.LanguageCode currLang = LocalizedContentManager.CurrentLanguageCode; + if (currLang == LocalizedContentManager.LanguageCode.en) + return this.Name; + if (this.NameLocalization == null || !this.NameLocalization.ContainsKey(currLang.ToString())) + return this.Name; + return this.NameLocalization[currLang.ToString()]; + } + + public string LocalizedDescription() + { + LocalizedContentManager.LanguageCode currLang = LocalizedContentManager.CurrentLanguageCode; + if (currLang == LocalizedContentManager.LanguageCode.en) + return this.Description; + if (this.DescriptionLocalization == null || !this.DescriptionLocalization.ContainsKey(currLang.ToString())) + return this.Description; + return this.DescriptionLocalization[currLang.ToString()]; + } + + public int GetObjectId() { return this.id; } + + internal string GetObjectInformation() + { + if (this.Edibility != SObject.inedible) + { + int itype = (int)this.Category; + string str = $"{this.Name}/{this.Price}/{this.Edibility}/{this.Category} {itype}/{this.LocalizedName()}/{this.LocalizedDescription()}/"; + str += (this.EdibleIsDrink ? "drink" : "food") + "/"; + if (this.EdibleBuffs == null) + this.EdibleBuffs = new FoodBuffs_(); + str += $"{this.EdibleBuffs.Farming} {this.EdibleBuffs.Fishing} {this.EdibleBuffs.Mining} 0 {this.EdibleBuffs.Luck} {this.EdibleBuffs.Foraging} 0 {this.EdibleBuffs.MaxStamina} {this.EdibleBuffs.MagnetRadius} {this.EdibleBuffs.Speed} {this.EdibleBuffs.Defense} {this.EdibleBuffs.Attack}/{this.EdibleBuffs.Duration}"; + return str; + } + else + { + int itype = (int)this.Category; + return $"{this.Name}/{this.Price}/{this.Edibility}/Basic {itype}/{this.LocalizedName()}/{this.LocalizedDescription()}"; + } + } + + internal string GetPurchaseRequirementString() + { + string str = $"1234567890"; + foreach (string cond in this.PurchaseRequirements) + str += $"/{cond}"; + return str; + } + } +} diff --git a/Mods/JsonAssets/Data/WeaponData.cs b/Mods/JsonAssets/Data/WeaponData.cs new file mode 100644 index 000000000..726eb91dd --- /dev/null +++ b/Mods/JsonAssets/Data/WeaponData.cs @@ -0,0 +1,82 @@ +using Microsoft.Xna.Framework.Graphics; +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; +using StardewValley; +using StardewValley.Tools; +using System.Collections.Generic; +using SObject = StardewValley.Object; + +namespace JsonAssets.Data +{ + public class WeaponData : DataNeedsId + { + [JsonIgnore] + internal Texture2D texture; + + [JsonConverter(typeof(StringEnumConverter))] + public enum Type_ + { + Dagger = MeleeWeapon.dagger, + Club = MeleeWeapon.club, + Sword = MeleeWeapon.defenseSword, + } + + public string Description { get; set; } + public Type_ Type { get; set; } + + public int MinimumDamage { get; set; } + public int MaximumDamage { get; set; } + public double Knockback { get; set; } + public int Speed { get; set; } + public int Accuracy { get; set; } + public int Defense { get; set; } + public int MineDropVar { get; set; } + public int MineDropMinimumLevel { get; set; } + public int ExtraSwingArea { get; set; } + public double CritChance { get; set; } + public double CritMultiplier { get; set; } + + public bool CanPurchase { get; set; } = false; + public int PurchasePrice { get; set; } + public string PurchaseFrom { get; set; } = "Pierre"; + public IList PurchaseRequirements { get; set; } = new List(); + + public Dictionary NameLocalization = new Dictionary(); + public Dictionary DescriptionLocalization = new Dictionary(); + + public string LocalizedName() + { + LocalizedContentManager.LanguageCode currLang = LocalizedContentManager.CurrentLanguageCode; + if (currLang == LocalizedContentManager.LanguageCode.en) + return this.Name; + if (this.NameLocalization == null || !this.NameLocalization.ContainsKey(currLang.ToString())) + return this.Name; + return this.NameLocalization[currLang.ToString()]; + } + + public string LocalizedDescription() + { + LocalizedContentManager.LanguageCode currLang = LocalizedContentManager.CurrentLanguageCode; + if (currLang == LocalizedContentManager.LanguageCode.en) + return this.Description; + if (this.DescriptionLocalization == null || !this.DescriptionLocalization.ContainsKey(currLang.ToString())) + return this.Description; + return this.DescriptionLocalization[currLang.ToString()]; + } + + public int GetWeaponId() { return this.id; } + + internal string GetWeaponInformation() + { + return $"{this.Name}/{this.LocalizedDescription()}/{this.MinimumDamage}/{this.MaximumDamage}/{this.Knockback}/{this.Speed}/{this.Accuracy}/{this.Defense}/{(int)this.Type}/{this.MineDropVar}/{this.MineDropMinimumLevel}/{this.ExtraSwingArea}/{this.CritChance}/{this.CritMultiplier}/{this.LocalizedName()}"; + } + + internal string GetPurchaseRequirementString() + { + string str = $"1234567890"; + foreach (string cond in this.PurchaseRequirements) + str += $"/{cond}"; + return str; + } + } +} diff --git a/Mods/JsonAssets/JsonAssets.csproj b/Mods/JsonAssets/JsonAssets.csproj new file mode 100644 index 000000000..9209ea54a --- /dev/null +++ b/Mods/JsonAssets/JsonAssets.csproj @@ -0,0 +1,174 @@ + + + + + Debug + AnyCPU + {F56B5F8E-0069-4029-8DCD-89002B7285E3} + Library + Properties + JsonAssets + JsonAssets + v4.5.2 + 512 + true + + + true + full + false + bin\Debug\ + DEBUG;TRACE + prompt + 4 + + + pdbonly + true + bin\Release\ + TRACE + prompt + 4 + + + + ..\assemblies\StardewModdingAPI.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.dll + + + ..\assemblies\System.Net.Http.dll + + + ..\assemblies\System.Runtime.Serialization.dll + + + 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/JsonAssets/Log.cs b/Mods/JsonAssets/Log.cs new file mode 100644 index 000000000..1f31e1593 --- /dev/null +++ b/Mods/JsonAssets/Log.cs @@ -0,0 +1,33 @@ +using StardewModdingAPI; +using System; + +namespace JsonAssets +{ + class Log + { + public static void trace(String str) + { + Mod.instance.Monitor.Log(str, LogLevel.Trace); + } + + public static void debug(String str) + { + Mod.instance.Monitor.Log(str, LogLevel.Debug); + } + + public static void info(String str) + { + Mod.instance.Monitor.Log(str, LogLevel.Info); + } + + public static void warn(String str) + { + Mod.instance.Monitor.Log(str, LogLevel.Warn); + } + + public static void error(String str) + { + Mod.instance.Monitor.Log(str, LogLevel.Error); + } + } +} diff --git a/Mods/JsonAssets/Mod.cs b/Mods/JsonAssets/Mod.cs new file mode 100644 index 000000000..5e95e11b0 --- /dev/null +++ b/Mods/JsonAssets/Mod.cs @@ -0,0 +1,925 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using JsonAssets.Data; +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Graphics; +using StardewModdingAPI; +using StardewModdingAPI.Events; +using StardewValley; +using StardewValley.Menus; +using StardewValley.Locations; +using StardewValley.TerrainFeatures; +using StardewValley.Objects; +using System.Reflection; +using Netcode; +using StardewValley.Buildings; +using Harmony; +using System.Text.RegularExpressions; +using JsonAssets.Overrides; +using Newtonsoft.Json; +using StardewValley.Tools; + +// TODO: Refactor recipes + +namespace JsonAssets +{ + public class Mod : StardewModdingAPI.Mod + { + public static Mod instance; + + /// The mod entry point, called after the mod is first loaded. + /// Provides simplified APIs for writing mods. + public override void Entry(IModHelper helper) + { + instance = this; + + helper.Events.Display.MenuChanged += this.onMenuChanged; + helper.Events.GameLoop.Saved += this.onSaved; + helper.Events.Player.InventoryChanged += this.onInventoryChanged; + helper.Events.GameLoop.SaveCreated += this.onCreated; + helper.Events.Specialised.LoadStageChanged += this.onLoadStageChanged; + helper.Events.Multiplayer.PeerContextReceived += this.clientConnected; + + Log.info("Loading content packs..."); + foreach (IContentPack contentPack in this.Helper.ContentPacks.GetOwned()) + this.loadData(contentPack); + if (Directory.Exists(Path.Combine(this.Helper.DirectoryPath, "ContentPacks"))) + { + foreach (string dir in Directory.EnumerateDirectories(Path.Combine(this.Helper.DirectoryPath, "ContentPacks"))) + this.loadData(dir); + } + + this.resetAtTitle(); + + try + { + helper.Events.Hook.ObjectCanBePlacedHere += (args) => ObjectCanPlantHereOverride.Prefix(args.__instance, args.location, args.tile, ref args.__result); + helper.Events.Hook.ObjectCheckForAction += (args) => ObjectNoActionHook.Prefix(args.__instance); + helper.Events.Hook.ObjectIsIndexOkForBasicShippedCategory += (args) => { ObjectCollectionShippingHook.Postfix(args.index, ref args.__result); return true; }; + } + catch (Exception e) + { + Log.error($"Exception doing harmony stuff: {e}"); + } + } + + private Api api; + public override object GetApi() + { + return this.api ?? (this.api = new Api(this.loadData)); + } + + private void loadData(string dir) + { + // read initial info + IContentPack temp = this.Helper.ContentPacks.CreateFake(dir); + ContentPackData info = temp.ReadJsonFile("content-pack.json"); + if (info == null) + { + Log.warn($"\tNo {dir}/content-pack.json!"); + return; + } + + // load content pack + IContentPack contentPack = this.Helper.ContentPacks.CreateTemporary(dir, id: Guid.NewGuid().ToString("N"), name: info.Name, description: info.Description, author: info.Author, version: new SemanticVersion(info.Version)); + this.loadData(contentPack); + } + + private Dictionary dupObjects = new Dictionary(); + private Dictionary dupCrops = new Dictionary(); + private Dictionary dupFruitTrees = new Dictionary(); + private Dictionary dupBigCraftables = new Dictionary(); + private Dictionary dupHats = new Dictionary(); + private Dictionary dupWeapons = new Dictionary(); + + private readonly Regex SeasonLimiter = new Regex("(z(?: spring| summer| fall| winter){2,4})", RegexOptions.IgnoreCase | RegexOptions.Compiled); + private void loadData(IContentPack contentPack) + { + Log.info($"\t{contentPack.Manifest.Name} {contentPack.Manifest.Version} by {contentPack.Manifest.Author} - {contentPack.Manifest.Description}"); + + // load objects + DirectoryInfo objectsDir = new DirectoryInfo(Path.Combine(contentPack.DirectoryPath, "Objects")); + if (objectsDir.Exists) + { + foreach (DirectoryInfo dir in objectsDir.EnumerateDirectories()) + { + string relativePath = $"Objects/{dir.Name}"; + + // load data + ObjectData obj = contentPack.ReadJsonFile($"{relativePath}/object.json"); + if (obj == null) + continue; + + // save object + obj.texture = contentPack.LoadAsset($"{relativePath}/object.png"); + if (obj.IsColored) + obj.textureColor = contentPack.LoadAsset($"{relativePath}/color.png"); + this.objects.Add(obj); + + // save ring + if (obj.Category == ObjectData.Category_.Ring) + this.myRings.Add(obj); + + // Duplicate check + if (this.dupObjects.ContainsKey(obj.Name)) + Log.error($"Duplicate object: {obj.Name} just added by {contentPack.Manifest.Name}, already added by {this.dupObjects[obj.Name].Manifest.Name}!"); + else + this.dupObjects[obj.Name] = contentPack; + } + } + + // load crops + DirectoryInfo cropsDir = new DirectoryInfo(Path.Combine(contentPack.DirectoryPath, "Crops")); + if (cropsDir.Exists) + { + foreach (DirectoryInfo dir in cropsDir.EnumerateDirectories()) + { + string relativePath = $"Crops/{dir.Name}"; + + // load data + CropData crop = contentPack.ReadJsonFile($"{relativePath}/crop.json"); + if (crop == null) + continue; + + // save crop + crop.texture = contentPack.LoadAsset($"{relativePath}/crop.png"); + this.crops.Add(crop); + + // save seeds + crop.seed = new ObjectData + { + texture = contentPack.LoadAsset($"{relativePath}/seeds.png"), + Name = crop.SeedName, + Description = crop.SeedDescription, + Category = ObjectData.Category_.Seeds, + Price = crop.SeedPurchasePrice, + CanPurchase = true, + PurchaseFrom = crop.SeedPurchaseFrom, + PurchasePrice = crop.SeedPurchasePrice, + PurchaseRequirements = crop.SeedPurchaseRequirements ?? new List(), + NameLocalization = crop.SeedNameLocalization, + DescriptionLocalization = crop.SeedDescriptionLocalization + }; + + // TODO: Clean up this chunk + // I copy/pasted it from the unofficial update decompiled + string str = ""; + string[] array = new[] { "spring", "summer", "fall", "winter" } + .Except(crop.Seasons) + .ToArray(); + foreach (string season in array) + { + str += $"/z {season}"; + } + string strtrimstart = str.TrimStart(new char[] { '/' }); + if (crop.SeedPurchaseRequirements != null && crop.SeedPurchaseRequirements.Count > 0) + { + for (int index = 0; index < crop.SeedPurchaseRequirements.Count; index++) + { + if (this.SeasonLimiter.IsMatch(crop.SeedPurchaseRequirements[index])) + { + crop.SeedPurchaseRequirements[index] = strtrimstart; + Log.warn($" Faulty season requirements for {crop.SeedName}!\n Fixed season requirements: {crop.SeedPurchaseRequirements[index]}"); + } + } + if (!crop.SeedPurchaseRequirements.Contains(str.TrimStart('/'))) + { + Log.trace($" Adding season requirements for {crop.SeedName}:\n New season requirements: {strtrimstart}"); + crop.seed.PurchaseRequirements.Add(strtrimstart); + } + } + else + { + Log.trace($" Adding season requirements for {crop.SeedName}:\n New season requirements: {strtrimstart}"); + crop.seed.PurchaseRequirements.Add(strtrimstart); + } + + this.objects.Add(crop.seed); + + // Duplicate check + if (this.dupCrops.ContainsKey(crop.Name)) + Log.error($"Duplicate crop: {crop.Name} just added by {contentPack.Manifest.Name}, already added by {this.dupCrops[crop.Name].Manifest.Name}!"); + else + this.dupCrops[crop.Name] = contentPack; + } + } + + // load fruit trees + DirectoryInfo fruitTreesDir = new DirectoryInfo(Path.Combine(contentPack.DirectoryPath, "FruitTrees")); + if (fruitTreesDir.Exists) + { + foreach (DirectoryInfo dir in fruitTreesDir.EnumerateDirectories()) + { + string relativePath = $"FruitTrees/{dir.Name}"; + + // load data + FruitTreeData tree = contentPack.ReadJsonFile($"{relativePath}/tree.json"); + if (tree == null) + continue; + + // save fruit tree + tree.texture = contentPack.LoadAsset($"{relativePath}/tree.png"); + this.fruitTrees.Add(tree); + + // save seed + tree.sapling = new ObjectData + { + texture = contentPack.LoadAsset($"{relativePath}/sapling.png"), + Name = tree.SaplingName, + Description = tree.SaplingDescription, + Category = ObjectData.Category_.Seeds, + Price = tree.SaplingPurchasePrice, + CanPurchase = true, + PurchaseRequirements = tree.SaplingPurchaseRequirements, + PurchaseFrom = tree.SaplingPurchaseFrom, + PurchasePrice = tree.SaplingPurchasePrice, + NameLocalization = tree.SaplingNameLocalization, + DescriptionLocalization = tree.SaplingDescriptionLocalization + }; + this.objects.Add(tree.sapling); + + // Duplicate check + if (this.dupFruitTrees.ContainsKey(tree.Name)) + Log.error($"Duplicate fruit tree: {tree.Name} just added by {contentPack.Manifest.Name}, already added by {this.dupFruitTrees[tree.Name].Manifest.Name}!"); + else + this.dupFruitTrees[tree.Name] = contentPack; + } + } + + // load big craftables + DirectoryInfo bigCraftablesDir = new DirectoryInfo(Path.Combine(contentPack.DirectoryPath, "BigCraftables")); + if (bigCraftablesDir.Exists) + { + foreach (DirectoryInfo dir in bigCraftablesDir.EnumerateDirectories()) + { + string relativePath = $"BigCraftables/{dir.Name}"; + + // load data + BigCraftableData craftable = contentPack.ReadJsonFile($"{relativePath}/big-craftable.json"); + if (craftable == null) + continue; + + // save craftable + craftable.texture = contentPack.LoadAsset($"{relativePath}/big-craftable.png"); + this.bigCraftables.Add(craftable); + + // Duplicate check + if (this.dupBigCraftables.ContainsKey(craftable.Name)) + Log.error($"Duplicate big craftable: {craftable.Name} just added by {contentPack.Manifest.Name}, already added by {this.dupBigCraftables[craftable.Name].Manifest.Name}!"); + else + this.dupBigCraftables[craftable.Name] = contentPack; + } + } + + // load hats + DirectoryInfo hatsDir = new DirectoryInfo(Path.Combine(contentPack.DirectoryPath, "Hats")); + if (hatsDir.Exists) + { + foreach (DirectoryInfo dir in hatsDir.EnumerateDirectories()) + { + string relativePath = $"Hats/{dir.Name}"; + + // load data + HatData hat = contentPack.ReadJsonFile($"{relativePath}/hat.json"); + if (hat == null) + continue; + + // save object + hat.texture = contentPack.LoadAsset($"{relativePath}/hat.png"); + this.hats.Add(hat); + + // Duplicate check + if (this.dupHats.ContainsKey(hat.Name)) + Log.error($"Duplicate hat: {hat.Name} just added by {contentPack.Manifest.Name}, already added by {this.dupHats[hat.Name].Manifest.Name}!"); + else + this.dupBigCraftables[hat.Name] = contentPack; + } + } + + // Load weapons + // load objects + DirectoryInfo weaponsDir = new DirectoryInfo(Path.Combine(contentPack.DirectoryPath, "Weapons")); + if (weaponsDir.Exists) + { + foreach (DirectoryInfo dir in weaponsDir.EnumerateDirectories()) + { + string relativePath = $"Weapons/{dir.Name}"; + + // load data + WeaponData weapon = contentPack.ReadJsonFile($"{relativePath}/weapon.json"); + if (weapon == null) + continue; + + // save object + weapon.texture = contentPack.LoadAsset($"{relativePath}/weapon.png"); + this.weapons.Add(weapon); + + // Duplicate check + if (this.dupWeapons.ContainsKey(weapon.Name)) + Log.error($"Duplicate weapon: {weapon.Name} just added by {contentPack.Manifest.Name}, already added by {this.dupWeapons[weapon.Name].Manifest.Name}!"); + else + this.dupBigCraftables[weapon.Name] = contentPack; + } + } + } + + private void resetAtTitle() + { + this.didInit = false; + // When we go back to the title menu we need to reset things so things don't break when + // going back to a save. + this.clearIds(out this.objectIds, this.objects.ToList()); + this.clearIds(out this.cropIds, this.crops.ToList()); + this.clearIds(out this.fruitTreeIds, this.fruitTrees.ToList()); + this.clearIds(out this.bigCraftableIds, this.bigCraftables.ToList()); + this.clearIds(out this.hatIds, this.hats.ToList()); + this.clearIds(out this.weaponIds, this.weapons.ToList()); + + IAssetEditor editor = this.Helper.Content.AssetEditors.FirstOrDefault(p => p is ContentInjector); + if (editor != null) + this.Helper.Content.AssetEditors.Remove(editor); + } + + private void onCreated(object sender, SaveCreatedEventArgs e) + { + Log.debug("Loading stuff early (creation)"); + this.initStuff( loadIdFiles: false ); + } + + private void onLoadStageChanged(object sender, LoadStageChangedEventArgs e) + { + if (e.NewStage == StardewModdingAPI.Enums.LoadStage.SaveParsed) + { + Log.debug("Loading stuff early (loading)"); + this.initStuff( loadIdFiles: true ); + } + else if ( e.NewStage == StardewModdingAPI.Enums.LoadStage.SaveLoadedLocations ) + { + Log.debug("Fixing IDs"); + this.fixIdsEverywhere(); + } + else if ( e.NewStage == StardewModdingAPI.Enums.LoadStage.Loaded ) + { + Log.debug("Adding default recipes"); + foreach (ObjectData obj in this.objects) + { + if (obj.Recipe != null && obj.Recipe.IsDefault && !Game1.player.knowsRecipe(obj.Name)) + { + if (obj.Category == ObjectData.Category_.Cooking) + { + Game1.player.cookingRecipes.Add(obj.Name, 0); + } + else + { + Game1.player.craftingRecipes.Add(obj.Name, 0); + } + } + } + foreach (BigCraftableData big in this.bigCraftables) + { + if (big.Recipe != null && big.Recipe.IsDefault && !Game1.player.knowsRecipe(big.Name)) + { + Game1.player.craftingRecipes.Add(big.Name, 0); + } + } + } + } + + private void clientConnected(object sender, PeerContextReceivedEventArgs e) + { + if (!Context.IsMainPlayer && !this.didInit) + { + Log.debug("Loading stuff early (MP client)"); + this.initStuff( loadIdFiles: false ); + } + } + + /// Raised after a game menu is opened, closed, or replaced. + /// The event sender. + /// The event arguments. + private void onMenuChanged(object sender, MenuChangedEventArgs e) + { + if ( e.NewMenu == null ) + return; + + if ( e.NewMenu is TitleMenu ) + { + this.resetAtTitle(); + return; + } + if (e.OldMenu is ShopMenu) + { + return; + } + + ShopMenu menu = e.NewMenu as ShopMenu; + bool hatMouse = menu != null && menu.potraitPersonDialogue == Game1.parseText(Game1.content.LoadString("Strings\\StringsFromCSFiles:ShopMenu.cs.11494"), Game1.dialogueFont, Game1.tileSize * 5 - Game1.pixelZoom * 4); + if (menu == null || menu.portraitPerson == null && !hatMouse) + return; + + //if (menu.portraitPerson.name == "Pierre") + { + Log.trace($"Adding objects to {menu.portraitPerson?.Name}'s shop"); + List forSale = this.Helper.Reflection.GetField>(menu, "forSale").GetValue(); + int count = forSale.Count; + Dictionary itemPriceAndStock = this.Helper.Reflection.GetField>(menu, "itemPriceAndStock").GetValue(); + + IReflectedMethod precondMeth = this.Helper.Reflection.GetMethod(Game1.currentLocation, "checkEventPrecondition"); + foreach (ObjectData obj in this.objects) + { + if (obj.Recipe != null && obj.Recipe.CanPurchase) + { + bool add = true; + // Can't use continue here or the item might not sell + if (obj.Recipe.PurchaseFrom != menu.portraitPerson?.Name || (obj.Recipe.PurchaseFrom == "HatMouse" && hatMouse) ) + add = false; + if (Game1.player.craftingRecipes.ContainsKey(obj.Name) || Game1.player.cookingRecipes.ContainsKey(obj.Name)) + add = false; + if (obj.Recipe.PurchaseRequirements != null && obj.Recipe.PurchaseRequirements.Count > 0 && + precondMeth.Invoke(new object[] { obj.Recipe.GetPurchaseRequirementString() }) == -1) + add = false; + if (add) + { + StardewValley.Object recipeObj = new StardewValley.Object(obj.id, 1, true, obj.Recipe.PurchasePrice, 0); + forSale.Add(recipeObj); + itemPriceAndStock.Add(recipeObj, new int[] { obj.Recipe.PurchasePrice, 1 }); + Log.trace($"\tAdding recipe for {obj.Name}"); + } + } + if (!obj.CanPurchase) + continue; + if (obj.PurchaseFrom != menu.portraitPerson?.Name || (obj.PurchaseFrom == "HatMouse" && hatMouse)) + continue; + if (obj.PurchaseRequirements != null && obj.PurchaseRequirements.Count > 0 && + precondMeth.Invoke(new object[] { obj.GetPurchaseRequirementString() }) == -1) + continue; + Item item = new StardewValley.Object(Vector2.Zero, obj.id, int.MaxValue); + forSale.Add(item); + itemPriceAndStock.Add(item, new int[] { obj.PurchasePrice, int.MaxValue }); + Log.trace($"\tAdding {obj.Name}"); + } + foreach (BigCraftableData big in this.bigCraftables) + { + if (big.Recipe != null && big.Recipe.CanPurchase) + { + bool add = true; + // Can't use continue here or the item might not sell + if (big.Recipe.PurchaseFrom != menu.portraitPerson?.Name || (big.Recipe.PurchaseFrom == "HatMouse" && hatMouse)) + add = false; + if (Game1.player.craftingRecipes.ContainsKey(big.Name) || Game1.player.cookingRecipes.ContainsKey(big.Name)) + add = false; + if (big.Recipe.PurchaseRequirements != null && big.Recipe.PurchaseRequirements.Count > 0 && + precondMeth.Invoke(new object[] { big.Recipe.GetPurchaseRequirementString() }) == -1) + add = false; + if (add) + { + StardewValley.Object recipeObj = new StardewValley.Object(new Vector2(0, 0), big.id, true); + forSale.Add(recipeObj); + itemPriceAndStock.Add(recipeObj, new int[] { big.Recipe.PurchasePrice, 1 }); + Log.trace($"\tAdding recipe for {big.Name}"); + } + } + if (!big.CanPurchase) + continue; + if (big.PurchaseFrom != menu.portraitPerson?.Name || (big.PurchaseFrom == "HatMouse" && hatMouse)) + continue; + if (big.PurchaseRequirements != null && big.PurchaseRequirements.Count > 0 && + precondMeth.Invoke(new object[] { big.GetPurchaseRequirementString() }) == -1) + continue; + Item item = new StardewValley.Object(Vector2.Zero, big.id, false); + forSale.Add(item); + itemPriceAndStock.Add(item, new int[] { big.PurchasePrice, int.MaxValue }); + Log.trace($"\tAdding {big.Name}"); + } + if ( hatMouse ) + { + foreach (HatData hat in this.hats ) + { + Item item = new Hat(hat.GetHatId()); + forSale.Add(item); + itemPriceAndStock.Add(item, new int[] { hat.PurchasePrice, int.MaxValue }); + Log.trace($"\tAdding {hat.Name}"); + } + } + foreach (WeaponData weapon in this.weapons) + { + if (!weapon.CanPurchase) + continue; + if (weapon.PurchaseFrom != menu.portraitPerson?.Name || (weapon.PurchaseFrom == "HatMouse" && hatMouse)) + continue; + if (weapon.PurchaseRequirements != null && weapon.PurchaseRequirements.Count > 0 && + precondMeth.Invoke(new object[] { weapon.GetPurchaseRequirementString() }) == -1) + continue; + Item item = new StardewValley.Tools.MeleeWeapon(weapon.id); + forSale.Add(item); + itemPriceAndStock.Add(item, new int[] { weapon.PurchasePrice, int.MaxValue }); + Log.trace($"\tAdding {weapon.Name}"); + } + if(count != forSale.Count) + { + Game1.activeClickableMenu = new ShopMenu(itemPriceAndStock, this.Helper.Reflection.GetField(menu, "currency").GetValue(), this.Helper.Reflection.GetField(menu, "personName").GetValue()); + } + } + + ( ( Api )this.api ).InvokeAddedItemsToShop(); + } + + private bool didInit = false; + private void initStuff( bool loadIdFiles ) + { + if (this.didInit) + return; + this.didInit = true; + + // load object ID mappings from save folder + if (loadIdFiles) + { + IDictionary LoadDictionary(string filename) + { + string path = Path.Combine(Constants.CurrentSavePath, "JsonAssets", filename); + return File.Exists(path) + ? JsonConvert.DeserializeObject>(File.ReadAllText(path)) + : new Dictionary(); + } + Directory.CreateDirectory(Path.Combine(Constants.CurrentSavePath, "JsonAssets")); + this.oldObjectIds = LoadDictionary("ids-objects.json"); + this.oldCropIds = LoadDictionary("ids-crops.json"); + this.oldFruitTreeIds = LoadDictionary("ids-fruittrees.json"); + this.oldBigCraftableIds = LoadDictionary("ids-big-craftables.json"); + this.oldHatIds = LoadDictionary("ids-hats.json"); + this.oldWeaponIds = LoadDictionary("ids-weapons.json"); + + Log.trace("OLD IDS START"); + foreach (KeyValuePair id in this.oldObjectIds) + Log.trace("\tObject " + id.Key + " = " + id.Value); + foreach (KeyValuePair id in this.oldCropIds) + Log.trace("\tCrop " + id.Key + " = " + id.Value); + foreach (KeyValuePair id in this.oldFruitTreeIds) + Log.trace("\tFruit Tree " + id.Key + " = " + id.Value); + foreach (KeyValuePair id in this.oldBigCraftableIds) + Log.trace("\tBigCraftable " + id.Key + " = " + id.Value); + foreach (KeyValuePair id in this.oldHatIds) + Log.trace("\tHat " + id.Key + " = " + id.Value); + foreach (KeyValuePair id in this.oldWeaponIds) + Log.trace("\tWeapon " + id.Key + " = " + id.Value); + Log.trace("OLD IDS END"); + } + + // assign IDs + this.objectIds = this.AssignIds("objects", StartingObjectId, this.objects.ToList()); + this.cropIds = this.AssignIds("crops", StartingCropId, this.crops.ToList()); + this.fruitTreeIds = this.AssignIds("fruittrees", StartingFruitTreeId, this.fruitTrees.ToList()); + this.bigCraftableIds = this.AssignIds("big-craftables", StartingBigCraftableId, this.bigCraftables.ToList()); + this.hatIds = this.AssignIds("hats", StartingHatId, this.hats.ToList()); + this.weaponIds = this.AssignIds("weapons", StartingWeaponId, this.weapons.ToList()); + + this.api.InvokeIdsAssigned(); + + // init + this.Helper.Content.AssetEditors.Add(new ContentInjector()); + } + + /// Raised after the game finishes writing data to the save file (except the initial save creation). + /// The event sender. + /// The event arguments. + private void onSaved(object sender, SavedEventArgs e) + { + if (!Directory.Exists(Path.Combine(Constants.CurrentSavePath, "JsonAssets"))) + Directory.CreateDirectory(Path.Combine(Constants.CurrentSavePath, "JsonAssets")); + + File.WriteAllText(Path.Combine(Constants.CurrentSavePath, "JsonAssets", "ids-objects.json"), JsonConvert.SerializeObject(this.objectIds)); + File.WriteAllText(Path.Combine(Constants.CurrentSavePath, "JsonAssets", "ids-crops.json"), JsonConvert.SerializeObject(this.cropIds)); + File.WriteAllText(Path.Combine(Constants.CurrentSavePath, "JsonAssets", "ids-fruittrees.json"), JsonConvert.SerializeObject(this.fruitTreeIds)); + File.WriteAllText(Path.Combine(Constants.CurrentSavePath, "JsonAssets", "ids-big-craftables.json"), JsonConvert.SerializeObject(this.bigCraftableIds)); + File.WriteAllText(Path.Combine(Constants.CurrentSavePath, "JsonAssets", "ids-hats.json"), JsonConvert.SerializeObject(this.hatIds)); + File.WriteAllText(Path.Combine(Constants.CurrentSavePath, "JsonAssets", "ids-weapons.json"), JsonConvert.SerializeObject(this.weaponIds)); + } + + internal IList myRings = new List(); + + /// Raised after items are added or removed to a player's inventory. NOTE: this event is currently only raised for the current player. + /// The event sender. + /// The event arguments. + private void onInventoryChanged(object sender, InventoryChangedEventArgs e) + { + if (!e.IsLocalPlayer) + return; + + IList ringIds = new List(); + foreach (ObjectData ring in this.myRings) + ringIds.Add(ring.id); + + for (int i = 0; i < Game1.player.Items.Count; ++i) + { + Item item = Game1.player.Items[i]; + if (item is StardewValley.Object obj && ringIds.Contains(obj.ParentSheetIndex)) + { + Log.trace($"Turning a ring-object of {obj.ParentSheetIndex} into a proper ring"); + Game1.player.Items[i] = new StardewValley.Objects.Ring(obj.ParentSheetIndex); + } + } + } + + private const int StartingObjectId = 2000; + private const int StartingCropId = 100; + private const int StartingFruitTreeId = 10; + private const int StartingBigCraftableId = 300; + private const int StartingHatId = 50; + private const int StartingWeaponId = 64; + + internal IList objects = new List(); + internal IList crops = new List(); + internal IList fruitTrees = new List(); + internal IList bigCraftables = new List(); + internal IList hats = new List(); + internal IList weapons = new List(); + + internal IDictionary objectIds; + internal IDictionary cropIds; + internal IDictionary fruitTreeIds; + internal IDictionary bigCraftableIds; + internal IDictionary hatIds; + internal IDictionary weaponIds; + + internal IDictionary oldObjectIds; + internal IDictionary oldCropIds; + internal IDictionary oldFruitTreeIds; + internal IDictionary oldBigCraftableIds; + internal IDictionary oldHatIds; + internal IDictionary oldWeaponIds; + + internal IDictionary origObjects; + internal IDictionary origCrops; + internal IDictionary origFruitTrees; + internal IDictionary origBigCraftables; + internal IDictionary origHats; + internal IDictionary origWeapons; + + public int ResolveObjectId(object data) + { + if (data.GetType() == typeof(long)) + return (int)(long)data; + else + { + if (this.objectIds.ContainsKey((string)data)) + return this.objectIds[(string)data]; + + foreach (KeyValuePair obj in Game1.objectInformation ) + { + if (obj.Value.Split('/')[0] == (string)data) + return obj.Key; + } + + Log.warn($"No idea what '{data}' is!"); + return 0; + } + } + + private Dictionary AssignIds(string type, int starting, IList data) + { + Dictionary ids = new Dictionary(); + + int currId = starting; + foreach (DataNeedsId d in data) + { + if (d.id == -1) + { + Log.trace($"New ID: {d.Name} = {currId}"); + ids.Add(d.Name, currId++); + if (type == "objects" && ((ObjectData)d).IsColored) + ++currId; + d.id = ids[d.Name]; + } + } + + return ids; + } + + private void clearIds(out IDictionary ids, List objs) + { + ids = null; + foreach ( DataNeedsId obj in objs ) + { + obj.id = -1; + } + } + + private IDictionary cloneIdDictAndRemoveOurs( IDictionary full, IDictionary ours ) + { + Dictionary ret = new Dictionary(full); + foreach (KeyValuePair obj in ours) + ret.Remove(obj.Value); + return ret; + } + + private void fixIdsEverywhere() + { + this.origObjects = this.cloneIdDictAndRemoveOurs(Game1.objectInformation, this.objectIds); + this.origCrops = this.cloneIdDictAndRemoveOurs(Game1.content.Load>("Data\\Crops"), this.cropIds); + this.origFruitTrees = this.cloneIdDictAndRemoveOurs(Game1.content.Load>("Data\\fruitTrees"), this.fruitTreeIds); + this.origBigCraftables = this.cloneIdDictAndRemoveOurs(Game1.bigCraftablesInformation, this.bigCraftableIds); + this.origHats = this.cloneIdDictAndRemoveOurs(Game1.content.Load>("Data\\hats"), this.hatIds); + this.origWeapons = this.cloneIdDictAndRemoveOurs(Game1.content.Load>("Data\\weapons"), this.weaponIds); + + this.fixItemList(Game1.player.Items); + foreach (GameLocation loc in Game1.locations ) + this.fixLocation(loc); + } + + [System.Diagnostics.CodeAnalysis.SuppressMessage( "SMAPI.CommonErrors", "AvoidNetField") ] + private void fixLocation( GameLocation loc ) + { + if (loc is FarmHouse fh) + { +#pragma warning disable AvoidImplicitNetFieldCast + if (fh.fridge.Value?.items != null) +#pragma warning restore AvoidImplicitNetFieldCast + this.fixItemList(fh.fridge.Value.items); + } + + IList toRemove = new List(); + foreach (Vector2 tfk in loc.terrainFeatures.Keys ) + { + TerrainFeature tf = loc.terrainFeatures[tfk]; + if ( tf is HoeDirt hd ) + { + if (hd.crop == null) + continue; + + if (this.fixId(this.oldCropIds, this.cropIds, hd.crop.rowInSpriteSheet, this.origCrops)) + hd.crop = null; + else + { + string key = this.cropIds.FirstOrDefault(x => x.Value == hd.crop.rowInSpriteSheet.Value).Key; + CropData c = this.crops.FirstOrDefault(x => x.Name == key); + if ( c != null ) // Non-JA crop + hd.crop.indexOfHarvest.Value = this.ResolveObjectId(c.Product); + } + } + else if ( tf is FruitTree ft ) + { + if (this.fixId(this.oldFruitTreeIds, this.fruitTreeIds, ft.treeType, this.origFruitTrees)) + toRemove.Add(tfk); + else + { + string key = this.oldFruitTreeIds.FirstOrDefault(x => x.Value == ft.treeType.Value).Key; + FruitTreeData ftt = this.fruitTrees.FirstOrDefault(x => x.Name == key); + if ( ftt != null ) // Non-JA fruit tree + ft.indexOfFruit.Value = this.ResolveObjectId(ftt.Product); + } + } + } + foreach (Vector2 rem in toRemove) + loc.terrainFeatures.Remove(rem); + + toRemove.Clear(); + foreach (Vector2 objk in loc.netObjects.Keys ) + { + StardewValley.Object obj = loc.netObjects[objk]; + if ( obj is Chest chest ) + { + this.fixItemList(chest.items); + } + else + { + if (!obj.bigCraftable.Value) + { + if (this.fixId(this.oldObjectIds, this.objectIds, obj.parentSheetIndex, this.origObjects)) + toRemove.Add(objk); + } + else + { + if (this.fixId(this.oldBigCraftableIds, this.bigCraftableIds, obj.parentSheetIndex, this.origBigCraftables)) + toRemove.Add(objk); + } + } + + if ( obj.heldObject.Value != null ) + { + if (this.fixId(this.oldObjectIds, this.objectIds, obj.heldObject.Value.parentSheetIndex, this.origObjects)) + obj.heldObject.Value = null; + + if ( obj.heldObject.Value is Chest chest2 ) + { + this.fixItemList(chest2.items); + } + } + } + foreach (Vector2 rem in toRemove) + loc.objects.Remove(rem); + + toRemove.Clear(); + foreach (Vector2 objk in loc.overlayObjects.Keys) + { + StardewValley.Object obj = loc.overlayObjects[objk]; + if (obj is Chest chest) + { + this.fixItemList(chest.items); + } + else + { + if (!obj.bigCraftable.Value) + { + if (this.fixId(this.oldObjectIds, this.objectIds, obj.parentSheetIndex, this.origObjects)) + toRemove.Add(objk); + } + else + { + if (this.fixId(this.oldBigCraftableIds, this.bigCraftableIds, obj.parentSheetIndex, this.origBigCraftables)) + toRemove.Add(objk); + } + } + + if (obj.heldObject.Value != null) + { + if (this.fixId(this.oldObjectIds, this.objectIds, obj.heldObject.Value.parentSheetIndex, this.origObjects)) + obj.heldObject.Value = null; + + if (obj.heldObject.Value is Chest chest2) + { + this.fixItemList(chest2.items); + } + } + } + foreach (Vector2 rem in toRemove) + loc.overlayObjects.Remove(rem); + + if (loc is BuildableGameLocation buildLoc) + foreach (Building building in buildLoc.buildings) + { + if (building.indoors.Value != null) + this.fixLocation(building.indoors.Value); + if ( building is Mill mill ) + { + this.fixItemList(mill.input.Value.items); + this.fixItemList(mill.output.Value.items); + } + } + } + + [System.Diagnostics.CodeAnalysis.SuppressMessage("SMAPI.CommonErrors", "AvoidNetField")] + private void fixItemList( IList< Item > items ) + { + for ( int i = 0; i < items.Count; ++i ) + { + Item item = items[i]; + if ( item is StardewValley.Object obj ) + { + if (!obj.bigCraftable.Value) + { + if (this.fixId(this.oldObjectIds, this.objectIds, obj.parentSheetIndex, this.origObjects)) + items[i] = null; + } + else + { + if (this.fixId(this.oldBigCraftableIds, this.bigCraftableIds, obj.parentSheetIndex, this.origBigCraftables)) + items[i] = null; + } + } + else if ( item is Hat hat ) + { + if (this.fixId(this.oldHatIds, this.hatIds, hat.which, this.origHats)) + items[i] = null; + } + else if ( item is MeleeWeapon weapon ) + { + if (this.fixId(this.oldWeaponIds, this.weaponIds, weapon.initialParentTileIndex, this.origWeapons)) + items[i] = null; + else if (this.fixId(this.oldWeaponIds, this.weaponIds, weapon.currentParentTileIndex, this.origWeapons)) + items[i] = null; + else if (this.fixId(this.oldWeaponIds, this.weaponIds, weapon.currentParentTileIndex, this.origWeapons)) + items[i] = null; + } + else if ( item is Ring ring ) + { + if (this.fixId(this.oldObjectIds, this.objectIds, ring.indexInTileSheet, this.origObjects)) + items[i] = null; + } + } + } + + // Return true if the item should be deleted, false otherwise. + // Only remove something if old has it but not new + private bool fixId(IDictionary oldIds, IDictionary newIds, NetInt id, IDictionary origData ) + { + if (origData.ContainsKey(id.Value)) + return false; + + if (oldIds.Values.Contains(id.Value)) + { + int id_ = id.Value; + string key = oldIds.FirstOrDefault(x => x.Value == id_).Key; + + if (newIds.ContainsKey(key)) + { + id.Value = newIds[key]; + return false; + } + else return true; + } + else return false; + } + } +} diff --git a/Mods/JsonAssets/Overrides/Object.cs b/Mods/JsonAssets/Overrides/Object.cs new file mode 100644 index 000000000..aa8cd480c --- /dev/null +++ b/Mods/JsonAssets/Overrides/Object.cs @@ -0,0 +1,71 @@ +using Microsoft.Xna.Framework; +using StardewValley; +using StardewValley.Objects; + +namespace JsonAssets.Overrides +{ + public class ObjectCanPlantHereOverride + { + public static bool Prefix(StardewValley.Object __instance, GameLocation l, Vector2 tile, ref bool __result) + { + if (!__instance.bigCraftable.Value && Mod.instance.objectIds.Values.Contains(__instance.ParentSheetIndex)) + { + if (__instance.Category == StardewValley.Object.SeedsCategory) + { + bool isTree = false; + foreach (Data.FruitTreeData tree in Mod.instance.fruitTrees) + { + if (tree.sapling.id == __instance.ParentSheetIndex) + { + isTree = true; + break; + } + } + + Object lobj = l.objects.ContainsKey(tile) ? l.objects[tile] : null; + if (isTree) + { + __result = lobj == null && !l.isTileOccupiedForPlacement(tile, __instance); + return false; + } + else + { + if (l.isTileHoeDirt(tile) || (lobj is IndoorPot)) + __result = l.isTileOccupiedForPlacement(tile); + else + __result = false; + return false; + } + } + return true; + } + else + return true; + } + } + + public static class ObjectNoActionHook + { + public static bool Prefix(StardewValley.Object __instance) + { + if (__instance.bigCraftable.Value && Mod.instance.bigCraftableIds.Values.Contains(__instance.ParentSheetIndex)) + return false; + return true; + } + } + + public static class ObjectCollectionShippingHook + { + public static void Postfix(int index, ref bool __result) + { + foreach (Data.ObjectData ring in Mod.instance.myRings) + { + if (ring.GetObjectId() == index) + { + __result = false; + break; + } + } + } + } +} diff --git a/Mods/JsonAssets/Properties/AssemblyInfo.cs b/Mods/JsonAssets/Properties/AssemblyInfo.cs new file mode 100644 index 000000000..0879c7a96 --- /dev/null +++ b/Mods/JsonAssets/Properties/AssemblyInfo.cs @@ -0,0 +1,36 @@ +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; + +// 有关程序集的一般信息由以下 +// 控制。更改这些特性值可修改 +// 与程序集关联的信息。 +[assembly: AssemblyTitle("JsonAssets")] +[assembly: AssemblyDescription("")] +[assembly: AssemblyConfiguration("")] +[assembly: AssemblyCompany("")] +[assembly: AssemblyProduct("JsonAssets")] +[assembly: AssemblyCopyright("Copyright © 2019")] +[assembly: AssemblyTrademark("")] +[assembly: AssemblyCulture("")] + +// 将 ComVisible 设置为 false 会使此程序集中的类型 +//对 COM 组件不可见。如果需要从 COM 访问此程序集中的类型 +//请将此类型的 ComVisible 特性设置为 true。 +[assembly: ComVisible(false)] + +// 如果此项目向 COM 公开,则下列 GUID 用于类型库的 ID +[assembly: Guid("f56b5f8e-0069-4029-8dcd-89002b7285e3")] + +// 程序集的版本信息由下列四个值组成: +// +// 主版本 +// 次版本 +// 生成号 +// 修订号 +// +// 可以指定所有值,也可以使用以下所示的 "*" 预置版本号和修订号 +//通过使用 "*",如下所示: +// [assembly: AssemblyVersion("1.0.*")] +[assembly: AssemblyVersion("1.0.0.0")] +[assembly: AssemblyFileVersion("1.0.0.0")] diff --git a/Mods/JsonAssets/Util.cs b/Mods/JsonAssets/Util.cs new file mode 100644 index 000000000..d5bf3a2ee --- /dev/null +++ b/Mods/JsonAssets/Util.cs @@ -0,0 +1,28 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace JsonAssets +{ + // Copied from SpaceCore + // TODO: Add SC as a dependency instead + public class Util + { + // Stolen from SMAPI + public static void invokeEvent(string name, IEnumerable handlers, object sender) + { + EventArgs args = new EventArgs(); + foreach (EventHandler handler in handlers.Cast()) + { + try + { + handler.Invoke(sender, args); + } + catch (Exception e) + { + Log.error($"Exception while handling event {name}:\n{e}"); + } + } + } + } +} diff --git a/Mods/LookupAnything/Common/.Common.shproj b/Mods/LookupAnything/Common/.Common.shproj new file mode 100644 index 000000000..3a1cea20b --- /dev/null +++ b/Mods/LookupAnything/Common/.Common.shproj @@ -0,0 +1,13 @@ + + + + b9e9edfc-e98a-4370-994f-40a9f39a0284 + 14.0 + + + + + + + + diff --git a/Mods/LookupAnything/Common/Common.projitems b/Mods/LookupAnything/Common/Common.projitems new file mode 100644 index 000000000..a94dfbe3a --- /dev/null +++ b/Mods/LookupAnything/Common/Common.projitems @@ -0,0 +1,46 @@ + + + + $(MSBuildAllProjects);$(MSBuildThisFileFullPath) + true + b9e9edfc-e98a-4370-994f-40a9f39a0284 + + + Common + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Mods/LookupAnything/Common/CommonHelper.cs b/Mods/LookupAnything/Common/CommonHelper.cs new file mode 100644 index 000000000..720736e4d --- /dev/null +++ b/Mods/LookupAnything/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/LookupAnything/Common/DataParsers/CropDataParser.cs b/Mods/LookupAnything/Common/DataParsers/CropDataParser.cs new file mode 100644 index 000000000..a84f42267 --- /dev/null +++ b/Mods/LookupAnything/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/LookupAnything/Common/Integrations/Automate/AutomateIntegration.cs b/Mods/LookupAnything/Common/Integrations/Automate/AutomateIntegration.cs new file mode 100644 index 000000000..9ee33a2d9 --- /dev/null +++ b/Mods/LookupAnything/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/LookupAnything/Common/Integrations/Automate/IAutomateApi.cs b/Mods/LookupAnything/Common/Integrations/Automate/IAutomateApi.cs new file mode 100644 index 000000000..158013257 --- /dev/null +++ b/Mods/LookupAnything/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/LookupAnything/Common/Integrations/BaseIntegration.cs b/Mods/LookupAnything/Common/Integrations/BaseIntegration.cs new file mode 100644 index 000000000..13898dbc6 --- /dev/null +++ b/Mods/LookupAnything/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/LookupAnything/Common/Integrations/BetterJunimos/BetterJunimosIntegration.cs b/Mods/LookupAnything/Common/Integrations/BetterJunimos/BetterJunimosIntegration.cs new file mode 100644 index 000000000..6c649fcac --- /dev/null +++ b/Mods/LookupAnything/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/LookupAnything/Common/Integrations/BetterJunimos/IBetterJunimosApi.cs b/Mods/LookupAnything/Common/Integrations/BetterJunimos/IBetterJunimosApi.cs new file mode 100644 index 000000000..6081e89b5 --- /dev/null +++ b/Mods/LookupAnything/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/LookupAnything/Common/Integrations/BetterSprinklers/BetterSprinklersIntegration.cs b/Mods/LookupAnything/Common/Integrations/BetterSprinklers/BetterSprinklersIntegration.cs new file mode 100644 index 000000000..f7f48248b --- /dev/null +++ b/Mods/LookupAnything/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/LookupAnything/Common/Integrations/BetterSprinklers/IBetterSprinklersApi.cs b/Mods/LookupAnything/Common/Integrations/BetterSprinklers/IBetterSprinklersApi.cs new file mode 100644 index 000000000..c213f02e8 --- /dev/null +++ b/Mods/LookupAnything/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/LookupAnything/Common/Integrations/Cobalt/CobaltIntegration.cs b/Mods/LookupAnything/Common/Integrations/Cobalt/CobaltIntegration.cs new file mode 100644 index 000000000..4cb7c36d4 --- /dev/null +++ b/Mods/LookupAnything/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/LookupAnything/Common/Integrations/Cobalt/ICobaltApi.cs b/Mods/LookupAnything/Common/Integrations/Cobalt/ICobaltApi.cs new file mode 100644 index 000000000..4952043ff --- /dev/null +++ b/Mods/LookupAnything/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/LookupAnything/Common/Integrations/CustomFarmingRedux/CustomFarmingReduxIntegration.cs b/Mods/LookupAnything/Common/Integrations/CustomFarmingRedux/CustomFarmingReduxIntegration.cs new file mode 100644 index 000000000..277c95c61 --- /dev/null +++ b/Mods/LookupAnything/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/LookupAnything/Common/Integrations/CustomFarmingRedux/ICustomFarmingApi.cs b/Mods/LookupAnything/Common/Integrations/CustomFarmingRedux/ICustomFarmingApi.cs new file mode 100644 index 000000000..14b80ffb8 --- /dev/null +++ b/Mods/LookupAnything/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/LookupAnything/Common/Integrations/FarmExpansion/FarmExpansionIntegration.cs b/Mods/LookupAnything/Common/Integrations/FarmExpansion/FarmExpansionIntegration.cs new file mode 100644 index 000000000..a41135e59 --- /dev/null +++ b/Mods/LookupAnything/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/LookupAnything/Common/Integrations/FarmExpansion/IFarmExpansionApi.cs b/Mods/LookupAnything/Common/Integrations/FarmExpansion/IFarmExpansionApi.cs new file mode 100644 index 000000000..2c4d92a11 --- /dev/null +++ b/Mods/LookupAnything/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/LookupAnything/Common/Integrations/IModIntegration.cs b/Mods/LookupAnything/Common/Integrations/IModIntegration.cs new file mode 100644 index 000000000..17327ed80 --- /dev/null +++ b/Mods/LookupAnything/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/LookupAnything/Common/Integrations/LineSprinklers/ILineSprinklersApi.cs b/Mods/LookupAnything/Common/Integrations/LineSprinklers/ILineSprinklersApi.cs new file mode 100644 index 000000000..a945c8c33 --- /dev/null +++ b/Mods/LookupAnything/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/LookupAnything/Common/Integrations/LineSprinklers/LineSprinklersIntegration.cs b/Mods/LookupAnything/Common/Integrations/LineSprinklers/LineSprinklersIntegration.cs new file mode 100644 index 000000000..d5aa4fce5 --- /dev/null +++ b/Mods/LookupAnything/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/LookupAnything/Common/Integrations/PelicanFiber/PelicanFiberIntegration.cs b/Mods/LookupAnything/Common/Integrations/PelicanFiber/PelicanFiberIntegration.cs new file mode 100644 index 000000000..f90cfb749 --- /dev/null +++ b/Mods/LookupAnything/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/LookupAnything/Common/Integrations/PrismaticTools/IPrismaticToolsApi.cs b/Mods/LookupAnything/Common/Integrations/PrismaticTools/IPrismaticToolsApi.cs new file mode 100644 index 000000000..b2a61ed32 --- /dev/null +++ b/Mods/LookupAnything/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/LookupAnything/Common/Integrations/PrismaticTools/PrismaticToolsIntegration.cs b/Mods/LookupAnything/Common/Integrations/PrismaticTools/PrismaticToolsIntegration.cs new file mode 100644 index 000000000..b35e6f359 --- /dev/null +++ b/Mods/LookupAnything/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/LookupAnything/Common/Integrations/SimpleSprinkler/ISimplerSprinklerApi.cs b/Mods/LookupAnything/Common/Integrations/SimpleSprinkler/ISimplerSprinklerApi.cs new file mode 100644 index 000000000..68d8e05a7 --- /dev/null +++ b/Mods/LookupAnything/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/LookupAnything/Common/Integrations/SimpleSprinkler/SimpleSprinklerIntegration.cs b/Mods/LookupAnything/Common/Integrations/SimpleSprinkler/SimpleSprinklerIntegration.cs new file mode 100644 index 000000000..ef21dd310 --- /dev/null +++ b/Mods/LookupAnything/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/LookupAnything/Common/PathUtilities.cs b/Mods/LookupAnything/Common/PathUtilities.cs new file mode 100644 index 000000000..40b174f02 --- /dev/null +++ b/Mods/LookupAnything/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/LookupAnything/Common/SpriteInfo.cs b/Mods/LookupAnything/Common/SpriteInfo.cs new file mode 100644 index 000000000..b7c3be5e2 --- /dev/null +++ b/Mods/LookupAnything/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/LookupAnything/Common/StringEnumArrayConverter.cs b/Mods/LookupAnything/Common/StringEnumArrayConverter.cs new file mode 100644 index 000000000..29e781674 --- /dev/null +++ b/Mods/LookupAnything/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/LookupAnything/Common/TileHelper.cs b/Mods/LookupAnything/Common/TileHelper.cs new file mode 100644 index 000000000..c96aeb92c --- /dev/null +++ b/Mods/LookupAnything/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/LookupAnything/Common/UI/BaseOverlay.cs b/Mods/LookupAnything/Common/UI/BaseOverlay.cs new file mode 100644 index 000000000..4b515ec50 --- /dev/null +++ b/Mods/LookupAnything/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/LookupAnything/Common/UI/CommonSprites.cs b/Mods/LookupAnything/Common/UI/CommonSprites.cs new file mode 100644 index 000000000..3da68991e --- /dev/null +++ b/Mods/LookupAnything/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/LookupAnything/Common/Utilities/ConstraintSet.cs b/Mods/LookupAnything/Common/Utilities/ConstraintSet.cs new file mode 100644 index 000000000..98cf678ec --- /dev/null +++ b/Mods/LookupAnything/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/LookupAnything/Common/Utilities/InvariantDictionary.cs b/Mods/LookupAnything/Common/Utilities/InvariantDictionary.cs new file mode 100644 index 000000000..4bad98e7f --- /dev/null +++ b/Mods/LookupAnything/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/LookupAnything/Common/Utilities/InvariantHashSet.cs b/Mods/LookupAnything/Common/Utilities/InvariantHashSet.cs new file mode 100644 index 000000000..6f0530d84 --- /dev/null +++ b/Mods/LookupAnything/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/LookupAnything/Common/Utilities/ObjectReferenceComparer.cs b/Mods/LookupAnything/Common/Utilities/ObjectReferenceComparer.cs new file mode 100644 index 000000000..020ebfadd --- /dev/null +++ b/Mods/LookupAnything/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/LookupAnything/Components/DebugInterface.cs b/Mods/LookupAnything/Components/DebugInterface.cs new file mode 100644 index 000000000..68aed6c0a --- /dev/null +++ b/Mods/LookupAnything/Components/DebugInterface.cs @@ -0,0 +1,145 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Graphics; +using Pathoschild.Stardew.Common; +using Pathoschild.Stardew.LookupAnything.Framework; +using Pathoschild.Stardew.LookupAnything.Framework.Constants; +using Pathoschild.Stardew.LookupAnything.Framework.Subjects; +using Pathoschild.Stardew.LookupAnything.Framework.Targets; +using StardewModdingAPI; +using StardewValley; + +namespace Pathoschild.Stardew.LookupAnything.Components +{ + /// Draws debug information to the screen. + internal class DebugInterface + { + /********* + ** Fields + *********/ + /// Provides utility methods for interacting with the game code. + private readonly GameHelper GameHelper; + + /// Finds and analyses lookup targets in the world. + private readonly TargetFactory TargetFactory; + + /// Encapsulates monitoring and logging. + private readonly IMonitor Monitor; + + /// The warning text to display when debug mode is enabled. + private readonly string WarningText; + + + /********* + ** Accessors + *********/ + /// Whether the debug interface is enabled. + public bool Enabled { get; set; } + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// Provides utility methods for interacting with the game code. + /// Finds and analyses lookup targets in the world. + /// The mod configuration. + /// Encapsulates monitoring and logging. + public DebugInterface(GameHelper gameHelper, TargetFactory targetFactory, ModConfig config, IMonitor monitor) + { + // save fields + this.GameHelper = gameHelper; + this.TargetFactory = targetFactory; + this.Monitor = monitor; + + // generate warning text + this.WarningText = $"Debug info enabled; press {string.Join(" or ", config.Controls.ToggleDebug)} to disable."; + } + + /// Draw debug metadata to the screen. + /// The sprite batch being drawn. + public void Draw(SpriteBatch spriteBatch) + { + if (!this.Enabled) + return; + + this.Monitor.InterceptErrors("drawing debug info", () => + { + // get location info + GameLocation currentLocation = Game1.currentLocation; + Vector2 cursorTile = Game1.currentCursorTile; + Vector2 cursorPosition = this.GameHelper.GetScreenCoordinatesFromCursor(); + + // show 'debug enabled' warning + cursor position + { + string metadata = $"{this.WarningText} Cursor tile ({cursorTile.X}, {cursorTile.Y}), position ({cursorPosition.X}, {cursorPosition.Y})."; + this.GameHelper.DrawHoverBox(spriteBatch, metadata, Vector2.Zero, Game1.viewport.Width); + } + + // show cursor pixel + spriteBatch.DrawLine(cursorPosition.X - 1, cursorPosition.Y - 1, new Vector2(Game1.pixelZoom, Game1.pixelZoom), Color.DarkRed); + + // show targets within detection radius + Rectangle tileArea = this.GameHelper.GetScreenCoordinatesFromTile(Game1.currentCursorTile); + IEnumerable targets = this.TargetFactory + .GetNearbyTargets(currentLocation, cursorTile, includeMapTile: false) + .OrderBy(p => p.Type == TargetType.Unknown ? 0 : 1); + // if targets overlap, prioritise info on known targets + foreach (ITarget target in targets) + { + // get metadata + bool spriteAreaIntersects = target.GetWorldArea().Intersects(tileArea); + ISubject subject = this.TargetFactory.GetSubjectFrom(target); + + // draw tile + { + Rectangle tile = this.GameHelper.GetScreenCoordinatesFromTile(target.GetTile()); + Color color = (subject != null ? Color.Green : Color.Red) * .5f; + spriteBatch.DrawLine(tile.X, tile.Y, new Vector2(tile.Width, tile.Height), color); + } + + // draw sprite box + if (subject != null) + { + int borderSize = 3; + Color borderColor = Color.Green; + if (!spriteAreaIntersects) + { + borderSize = 1; + borderColor *= 0.5f; + } + + Rectangle spriteBox = target.GetWorldArea(); + spriteBatch.DrawLine(spriteBox.X, spriteBox.Y, new Vector2(spriteBox.Width, borderSize), borderColor); // top + spriteBatch.DrawLine(spriteBox.X, spriteBox.Y, new Vector2(borderSize, spriteBox.Height), borderColor); // left + spriteBatch.DrawLine(spriteBox.X + spriteBox.Width, spriteBox.Y, new Vector2(borderSize, spriteBox.Height), borderColor); // right + spriteBatch.DrawLine(spriteBox.X, spriteBox.Y + spriteBox.Height, new Vector2(spriteBox.Width, borderSize), borderColor); // bottom + } + } + + // show current target name (if any) + { + ISubject subject = this.TargetFactory.GetSubjectFrom(Game1.player, currentLocation, LookupMode.Cursor, includeMapTile: false); + if (subject != null) + this.GameHelper.DrawHoverBox(spriteBatch, subject.Name, new Vector2(Game1.getMouseX(), Game1.getMouseY()) + new Vector2(Game1.tileSize / 2f), Game1.viewport.Width / 4f); + } + }, this.OnDrawError); + } + + + /********* + ** Public methods + *********/ + /// The method invoked when an unhandled exception is intercepted. + /// The intercepted exception. + private void OnDrawError(Exception ex) + { + this.Monitor.InterceptErrors("handling an error in the debug code", () => + { + this.Enabled = false; + }); + } + } +} diff --git a/Mods/LookupAnything/Components/LookupMenu.cs b/Mods/LookupAnything/Components/LookupMenu.cs new file mode 100644 index 000000000..f9fd5cef4 --- /dev/null +++ b/Mods/LookupAnything/Components/LookupMenu.cs @@ -0,0 +1,418 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Graphics; +using Microsoft.Xna.Framework.Input; +using Pathoschild.Stardew.Common; +using Pathoschild.Stardew.LookupAnything.Framework; +using Pathoschild.Stardew.LookupAnything.Framework.Constants; +using Pathoschild.Stardew.LookupAnything.Framework.DebugFields; +using Pathoschild.Stardew.LookupAnything.Framework.Fields; +using Pathoschild.Stardew.LookupAnything.Framework.Subjects; +using StardewModdingAPI; +using StardewValley; +using StardewValley.Menus; + +namespace Pathoschild.Stardew.LookupAnything.Components +{ + /// A UI which shows information about an item. + internal class LookupMenu : IClickableMenu + { + /********* + ** Fields + *********/ + /// The subject metadata. + private readonly ISubject Subject; + + /// Encapsulates logging and monitoring. + private readonly IMonitor Monitor; + + /// A callback which shows a new lookup for a given subject. + private readonly Action ShowNewPage; + + /// The data to display for this subject. + private readonly ICustomField[] Fields; + + /// The aspect ratio of the page background. + private readonly Vector2 AspectRatio = new Vector2(Sprites.Letter.Sprite.Width, Sprites.Letter.Sprite.Height); + + /// Simplifies access to private game code. + private readonly IReflectionHelper Reflection; + + /// The amount to scroll long content on each up/down scroll. + private readonly int ScrollAmount; + + /// The clickable 'scroll up' icon. + private readonly ClickableTextureComponent ScrollUpButton; + + /// The clickable 'scroll down' icon. + private readonly ClickableTextureComponent ScrollDownButton; + + /// The spacing around the scroll buttons. + private readonly int ScrollButtonGutter = 15; + + /// The maximum pixels to scroll. + private int MaxScroll; + + /// The number of pixels to scroll. + private int CurrentScroll; + + /// Whether the game's draw mode has been validated for compatibility. + private bool ValidatedDrawMode; + + /// Click areas for link fields that open a new subject. + private readonly IDictionary LinkFieldAreas = new Dictionary(); + + + /********* + ** Accessors + *********/ + /// Whether the lookup is showing information for a tile. + public bool IsTileLookup { get; set; } + + + /********* + ** Public methods + *********/ + /**** + ** Constructors + ****/ + /// Construct an instance. + /// Provides utility methods for interacting with the game code. + /// The metadata to display. + /// Provides metadata that's not available from the game data directly. + /// Encapsulates logging and monitoring. + /// Simplifies access to private game code. + /// The amount to scroll long content on each up/down scroll. + /// Whether to display debug fields. + /// A callback which shows a new lookup for a given subject. + public LookupMenu(GameHelper gameHelper, ISubject subject, Metadata metadata, IMonitor monitor, IReflectionHelper reflectionHelper, int scroll, bool showDebugFields, Action showNewPage) + { + // save data + this.Subject = subject; + this.Fields = subject.GetData(metadata).Where(p => p.HasValue).ToArray(); + this.Monitor = monitor; + this.Reflection = reflectionHelper; + this.ScrollAmount = scroll; + this.ShowNewPage = showNewPage; + + // save debug fields + if (showDebugFields) + { + IDebugField[] debugFields = subject.GetDebugFields(metadata).ToArray(); + this.Fields = this.Fields + .Concat(new[] + { + new DataMiningField(gameHelper, "debug (pinned)", debugFields.Where(p => p.IsPinned)), + new DataMiningField(gameHelper, "debug (raw)", debugFields.Where(p => !p.IsPinned)) + }) + .ToArray(); + } + + // add scroll buttons + this.ScrollUpButton = new ClickableTextureComponent(Rectangle.Empty, Sprites.Icons.Sheet, Sprites.Icons.UpArrow, 1); + this.ScrollDownButton = new ClickableTextureComponent(Rectangle.Empty, Sprites.Icons.Sheet, Sprites.Icons.DownArrow, 1); + + // update layout + this.UpdateLayout(); + } + + /**** + ** Events + ****/ + /// The method invoked when the player left-clicks on the lookup UI. + /// The X-position of the cursor. + /// The Y-position of the cursor. + /// Whether to enable sound. + public override void receiveLeftClick(int x, int y, bool playSound = true) + { + this.HandleLeftClick(x, y); + } + + /// The method invoked when the player right-clicks on the lookup UI. + /// The X-position of the cursor. + /// The Y-position of the cursor. + /// Whether to enable sound. + public override void receiveRightClick(int x, int y, bool playSound = true) { } + + /// The method invoked when the player scrolls the mouse wheel on the lookup UI. + /// The scroll direction. + public override void receiveScrollWheelAction(int direction) + { + if (direction > 0) // positive number scrolls content up + this.ScrollUp(); + else + this.ScrollDown(); + } + + /// The method called when the game window changes size. + /// The former viewport. + /// The new viewport. + public override void gameWindowSizeChanged(Rectangle oldBounds, Rectangle newBounds) + { + this.UpdateLayout(); + } + + /// The method called when the player presses a controller button. + /// The controller button pressed. + public override void receiveGamePadButton(Buttons button) + { + switch (button) + { + // left click + case Buttons.A: + Point p = Game1.getMousePosition(); + this.HandleLeftClick(p.X, p.Y); + break; + + // exit + case Buttons.B: + this.exitThisMenu(); + break; + + // scroll up + case Buttons.RightThumbstickUp: + this.ScrollUp(); + break; + + // scroll down + case Buttons.RightThumbstickDown: + this.ScrollDown(); + break; + } + } + + /**** + ** Methods + ****/ + /// Scroll up the menu content by the specified amount (if possible). + public void ScrollUp() + { + this.CurrentScroll -= this.ScrollAmount; + } + + /// Scroll down the menu content by the specified amount (if possible). + public void ScrollDown() + { + this.CurrentScroll += this.ScrollAmount; + } + + /// Handle a left-click from the player's mouse or controller. + /// The x-position of the cursor. + /// The y-position of the cursor. + public void HandleLeftClick(int x, int y) + { + // close menu when clicked outside + if (!this.isWithinBounds(x, y)) + this.exitThisMenu(); + + // scroll up or down + else if (this.ScrollUpButton.containsPoint(x, y)) + this.ScrollUp(); + else if (this.ScrollDownButton.containsPoint(x, y)) + this.ScrollDown(); + + // custom link fields + else + { + foreach (var area in this.LinkFieldAreas) + { + if (area.Value.Contains(x, y)) + { + ISubject subject = area.Key.GetLinkSubject(); + if (subject != null) + this.ShowNewPage(subject); + break; + } + } + } + } + + /// Render the UI. + /// The sprite batch being drawn. + public override void draw(SpriteBatch spriteBatch) + { + this.Monitor.InterceptErrors("drawing the lookup info", () => + { + ISubject subject = this.Subject; + + // disable when game is using immediate sprite sorting + // (This prevents Lookup Anything from creating new sprite batches, which breaks its core rendering logic. + // Fortunately this very rarely happens; the only known case is the Stardew Valley Fair, when the only thing + // you can look up anyway is the farmer.) + if (!this.ValidatedDrawMode) + { + IReflectedField sortModeField = + this.Reflection.GetField(Game1.spriteBatch, "spriteSortMode", required: false) // XNA + ?? this.Reflection.GetField(Game1.spriteBatch, "_sortMode"); // MonoGame + if (sortModeField.GetValue() == SpriteSortMode.Immediate) + { + this.Monitor.Log("Aborted the lookup because the game's current rendering mode isn't compatible with the mod's UI. This only happens in rare cases (e.g. the Stardew Valley Fair).", LogLevel.Warn); + this.exitThisMenu(playSound: false); + return; + } + this.ValidatedDrawMode = true; + } + + // calculate dimensions + int x = this.xPositionOnScreen; + int y = this.yPositionOnScreen; + const int gutter = 15; + float leftOffset = gutter; + float topOffset = gutter; + float contentWidth = this.width - gutter * 2; + float contentHeight = this.height - gutter * 2; + int tableBorderWidth = 1; + + // get font + SpriteFont font = Game1.smallFont; + float lineHeight = font.MeasureString("ABC").Y; + float spaceWidth = DrawHelper.GetSpaceWidth(font); + + // draw background + // (This uses a separate sprite batch because it needs to be drawn before the + // foreground batch, and we can't use the foreground batch because the background is + // outside the clipping area.) + using (SpriteBatch backgroundBatch = new SpriteBatch(Game1.graphics.GraphicsDevice)) + { + backgroundBatch.Begin(SpriteSortMode.Deferred, BlendState.NonPremultiplied, SamplerState.PointClamp, null, null); + backgroundBatch.DrawSprite(Sprites.Letter.Sheet, Sprites.Letter.Sprite, x, y, scale: this.width / (float)Sprites.Letter.Sprite.Width); + backgroundBatch.End(); + } + + // draw foreground + // (This uses a separate sprite batch to set a clipping area for scrolling.) + using (SpriteBatch contentBatch = new SpriteBatch(Game1.graphics.GraphicsDevice)) + { + GraphicsDevice device = Game1.graphics.GraphicsDevice; + Rectangle prevScissorRectangle = device.ScissorRectangle; + try + { + // begin draw + device.ScissorRectangle = new Rectangle(x + gutter, y + gutter, (int)contentWidth, (int)contentHeight); + contentBatch.Begin(SpriteSortMode.Deferred, BlendState.NonPremultiplied, SamplerState.PointClamp, null, new RasterizerState { ScissorTestEnable = true }); + + // scroll view + this.CurrentScroll = Math.Max(0, this.CurrentScroll); // don't scroll past top + this.CurrentScroll = Math.Min(this.MaxScroll, this.CurrentScroll); // don't scroll past bottom + topOffset -= this.CurrentScroll; // scrolled down == move text up + + // draw portrait + if (subject.DrawPortrait(contentBatch, new Vector2(x + leftOffset, y + topOffset), new Vector2(70, 70))) + leftOffset += 72; + + // draw fields + float wrapWidth = this.width - leftOffset - gutter; + { + // draw name & item type + { + Vector2 nameSize = contentBatch.DrawTextBlock(font, $"{subject.Name}.", new Vector2(x + leftOffset, y + topOffset), wrapWidth, bold: Constant.AllowBold); + Vector2 typeSize = contentBatch.DrawTextBlock(font, $"{subject.Type}.", new Vector2(x + leftOffset + nameSize.X + spaceWidth, y + topOffset), wrapWidth); + topOffset += Math.Max(nameSize.Y, typeSize.Y); + } + + // draw description + if (subject.Description != null) + { + Vector2 size = contentBatch.DrawTextBlock(font, subject.Description?.Replace(Environment.NewLine, " "), new Vector2(x + leftOffset, y + topOffset), wrapWidth); + topOffset += size.Y; + } + + // draw spacer + topOffset += lineHeight; + + // draw custom fields + if (this.Fields.Any()) + { + ICustomField[] fields = this.Fields; + float cellPadding = 3; + float labelWidth = fields.Where(p => p.HasValue).Max(p => font.MeasureString(p.Label).X); + float valueWidth = wrapWidth - labelWidth - cellPadding * 4 - tableBorderWidth; + foreach (ICustomField field in fields) + { + if (!field.HasValue) + continue; + + // draw label & value + Vector2 labelSize = contentBatch.DrawTextBlock(font, field.Label, new Vector2(x + leftOffset + cellPadding, y + topOffset + cellPadding), wrapWidth); + Vector2 valuePosition = new Vector2(x + leftOffset + labelWidth + cellPadding * 3, y + topOffset + cellPadding); + Vector2 valueSize = + field.DrawValue(contentBatch, font, valuePosition, valueWidth) + ?? contentBatch.DrawTextBlock(font, field.Value, valuePosition, valueWidth); + Vector2 rowSize = new Vector2(labelWidth + valueWidth + cellPadding * 4, Math.Max(labelSize.Y, valueSize.Y)); + + // draw table row + Color lineColor = Color.Gray; + contentBatch.DrawLine(x + leftOffset, y + topOffset, new Vector2(rowSize.X, tableBorderWidth), lineColor); // top + contentBatch.DrawLine(x + leftOffset, y + topOffset + rowSize.Y, new Vector2(rowSize.X, tableBorderWidth), lineColor); // bottom + contentBatch.DrawLine(x + leftOffset, y + topOffset, new Vector2(tableBorderWidth, rowSize.Y), lineColor); // left + contentBatch.DrawLine(x + leftOffset + labelWidth + cellPadding * 2, y + topOffset, new Vector2(tableBorderWidth, rowSize.Y), lineColor); // middle + contentBatch.DrawLine(x + leftOffset + rowSize.X, y + topOffset, new Vector2(tableBorderWidth, rowSize.Y), lineColor); // right + + // track link area + if (field is ILinkField linkField) + this.LinkFieldAreas[linkField] = new Rectangle((int)valuePosition.X, (int)valuePosition.Y, (int)valueSize.X, (int)valueSize.Y); + + // update offset + topOffset += Math.Max(labelSize.Y, valueSize.Y); + } + } + } + + // update max scroll + this.MaxScroll = Math.Max(0, (int)(topOffset - contentHeight + this.CurrentScroll)); + + // draw scroll icons + if (this.MaxScroll > 0 && this.CurrentScroll > 0) + this.ScrollUpButton.draw(contentBatch); + if (this.MaxScroll > 0 && this.CurrentScroll < this.MaxScroll) + this.ScrollDownButton.draw(spriteBatch); + + // end draw + contentBatch.End(); + } + finally + { + device.ScissorRectangle = prevScissorRectangle; + } + } + + // draw cursor + this.drawMouse(Game1.spriteBatch); + }, this.OnDrawError); + } + + + /********* + ** Private methods + *********/ + /// Update the layout dimensions based on the current game scale. + private void UpdateLayout() + { + // update size + this.width = Math.Min(Game1.tileSize * 14, Game1.viewport.Width); + this.height = Math.Min((int)(this.AspectRatio.Y / this.AspectRatio.X * this.width), Game1.viewport.Height); + + // update position + Vector2 origin = Utility.getTopLeftPositionForCenteringOnScreen(this.width, this.height); + this.xPositionOnScreen = (int)origin.X; + this.yPositionOnScreen = (int)origin.Y; + + // update up/down buttons + int x = this.xPositionOnScreen; + int y = this.yPositionOnScreen; + int gutter = this.ScrollButtonGutter; + float contentHeight = this.height - gutter * 2; + this.ScrollUpButton.bounds = new Rectangle(x + gutter, (int)(y + contentHeight - Sprites.Icons.UpArrow.Height - gutter - Sprites.Icons.DownArrow.Height), Sprites.Icons.UpArrow.Height, Sprites.Icons.UpArrow.Width); + this.ScrollDownButton.bounds = new Rectangle(x + gutter, (int)(y + contentHeight - Sprites.Icons.DownArrow.Height), Sprites.Icons.DownArrow.Height, Sprites.Icons.DownArrow.Width); + } + + /// The method invoked when an unhandled exception is intercepted. + /// The intercepted exception. + private void OnDrawError(Exception ex) + { + this.Monitor.InterceptErrors("handling an error in the lookup code", () => this.exitThisMenu()); + } + } +} diff --git a/Mods/LookupAnything/Components/Sprites.cs b/Mods/LookupAnything/Components/Sprites.cs new file mode 100644 index 000000000..e7bf3ad13 --- /dev/null +++ b/Mods/LookupAnything/Components/Sprites.cs @@ -0,0 +1,56 @@ +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Graphics; +using Pathoschild.Stardew.Common; +using StardewValley; + +namespace Pathoschild.Stardew.LookupAnything.Components +{ + /// 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 Sprites + { + /********* + ** Accessors + *********/ + /// Sprites used to draw a letter. + public static class Letter + { + /// The sprite sheet containing the letter sprites. + public static Texture2D Sheet => Game1.content.Load("LooseSprites\\letterBG"); + + /// The letter background (including edges and corners). + public static readonly Rectangle Sprite = new Rectangle(0, 0, 320, 180); + } + + /// Sprites used to draw icons. + public static class Icons + { + /// The sprite sheet containing the icon sprites. + public static Texture2D Sheet => Game1.mouseCursors; + + /// An empty checkbox icon. + public static readonly Rectangle EmptyCheckbox = new Rectangle(227, 425, 9, 9); + + /// A filled checkbox icon. + public static readonly Rectangle FilledCheckbox = new Rectangle(236, 425, 9, 9); + + /// A filled heart indicating a friendship level. + public static readonly Rectangle FilledHeart = new Rectangle(211, 428, 7, 6); + + /// An empty heart indicating a missing friendship level. + public static readonly Rectangle EmptyHeart = new Rectangle(218, 428, 7, 6); + + /// A down arrow for scrolling content. + public static readonly Rectangle DownArrow = new Rectangle(12, 76, 40, 44); + + /// An up arrow for scrolling content. + public static readonly Rectangle UpArrow = new Rectangle(76, 72, 40, 44); + + /// A stardrop icon. + public static readonly Rectangle Stardrop = new Rectangle(346, 392, 8, 8); + } + + /// A blank pixel which can be colorised and stretched to draw geometric shapes. + public static readonly Texture2D Pixel = CommonHelper.Pixel; + } +} diff --git a/Mods/LookupAnything/DataParser.cs b/Mods/LookupAnything/DataParser.cs new file mode 100644 index 000000000..b154fab40 --- /dev/null +++ b/Mods/LookupAnything/DataParser.cs @@ -0,0 +1,379 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.Xna.Framework; +using Pathoschild.Stardew.LookupAnything.Framework; +using Pathoschild.Stardew.LookupAnything.Framework.Constants; +using Pathoschild.Stardew.LookupAnything.Framework.Data; +using Pathoschild.Stardew.LookupAnything.Framework.Models; +using StardewModdingAPI; +using StardewValley; +using StardewValley.Characters; +using StardewValley.Objects; +using SFarmer = StardewValley.Farmer; +using SObject = StardewValley.Object; + +namespace Pathoschild.Stardew.LookupAnything +{ + /// Parses the raw game data into usable models. These may be expensive operations and should be cached. + internal class DataParser + { + /********* + ** Fields + *********/ + /// Provides utility methods for interacting with the game code. + private readonly GameHelper GameHelper; + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// Provides utility methods for interacting with the game code. + public DataParser(GameHelper gameHelper) + { + this.GameHelper = gameHelper; + } + + /// Read parsed data about the Community Center bundles. + /// Derived from the constructor and . + public IEnumerable GetBundles() + { + IDictionary data = Game1.content.Load>("Data\\Bundles"); + foreach (var entry in data) + { + // parse key + string[] keyParts = entry.Key.Split('/'); + string area = keyParts[0]; + int id = int.Parse(keyParts[1]); + + // parse bundle info + string[] valueParts = entry.Value.Split('/'); + string name = valueParts[0]; + string reward = valueParts[1]; + string displayName = LocalizedContentManager.CurrentLanguageCode == LocalizedContentManager.LanguageCode.en + ? name // field isn't present in English + : valueParts.Last(); // number of fields varies, but display name is always last + + // parse ingredients + List ingredients = new List(); + string[] ingredientData = valueParts[2].Split(' '); + for (int i = 0; i < ingredientData.Length; i += 3) + { + int index = i / 3; + int itemID = int.Parse(ingredientData[i]); + int stack = int.Parse(ingredientData[i + 1]); + ItemQuality quality = (ItemQuality)int.Parse(ingredientData[i + 2]); + ingredients.Add(new BundleIngredientModel(index, itemID, stack, quality)); + } + + // create bundle + yield return new BundleModel(id, name, displayName, area, reward, ingredients); + } + } + + /// Get parsed data about the friendship between a player and NPC. + /// The player. + /// The NPC. + /// The current friendship data. + /// Provides metadata that's not available from the game data directly. + public FriendshipModel GetFriendshipForVillager(SFarmer player, NPC npc, Friendship friendship, Metadata metadata) + { + return new FriendshipModel(player, npc, friendship, metadata.Constants); + } + + /// Get parsed data about the friendship between a player and NPC. + /// The player. + /// The pet. + public FriendshipModel GetFriendshipForPet(SFarmer player, Pet pet) + { + return new FriendshipModel(pet.friendshipTowardFarmer, Pet.maxFriendship / 10, Pet.maxFriendship); + } + + /// Get parsed data about the friendship between a player and NPC. + /// The player. + /// The farm animal. + /// Provides metadata that's not available from the game data directly. + public FriendshipModel GetFriendshipForAnimal(SFarmer player, FarmAnimal animal, Metadata metadata) + { + return new FriendshipModel(animal.friendshipTowardFarmer.Value, metadata.Constants.AnimalFriendshipPointsPerLevel, metadata.Constants.AnimalFriendshipMaxPoints); + } + + /// Get the raw gift tastes from the underlying data. + /// The game's object data. + /// Reverse engineered from Data\NPCGiftTastes and . + public IEnumerable GetGiftTastes(ObjectModel[] objects) + { + // extract raw values + var tastes = new List(); + { + // define data schema + var universal = new Dictionary + { + ["Universal_Love"] = GiftTaste.Love, + ["Universal_Like"] = GiftTaste.Like, + ["Universal_Neutral"] = GiftTaste.Neutral, + ["Universal_Dislike"] = GiftTaste.Dislike, + ["Universal_Hate"] = GiftTaste.Hate + }; + var personalMetadataKeys = new Dictionary + { + // metadata is paired: odd values contain a list of item references, even values contain the reaction dialogue + [1] = GiftTaste.Love, + [3] = GiftTaste.Like, + [5] = GiftTaste.Dislike, + [7] = GiftTaste.Hate, + [9] = GiftTaste.Neutral + }; + + // read data + IDictionary data = Game1.NPCGiftTastes; + foreach (string villager in data.Keys) + { + string tasteStr = data[villager]; + + if (universal.ContainsKey(villager)) + { + GiftTaste taste = universal[villager]; + tastes.AddRange( + from refID in tasteStr.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries) + select new GiftTasteModel(taste, "*", int.Parse(refID), isUniversal: true) + ); + } + else + { + string[] personalData = tasteStr.Split('/'); + foreach (KeyValuePair taste in personalMetadataKeys) + { + tastes.AddRange( + from refID in + personalData[taste.Key].Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries) + select new GiftTasteModel(taste.Value, villager, int.Parse(refID)) + ); + } + } + } + } + + // get sanitised data + HashSet validItemIDs = new HashSet(objects.Select(p => p.ParentSpriteIndex)); + HashSet validCategories = new HashSet(objects.Where(p => p.Category != 0).Select(p => p.Category)); + return tastes + .Where(model => validCategories.Contains(model.RefID) || validItemIDs.Contains(model.RefID)); // ignore invalid entries + } + + /// Parse monster data. + /// Reverse engineered from , , and the constructor. + public IEnumerable GetMonsters() + { + Dictionary data = Game1.content.Load>("Data\\Monsters"); + + foreach (var entry in data) + { + // monster fields + string[] fields = entry.Value.Split('/'); + string name = entry.Key; + int health = int.Parse(fields[0]); + int damageToFarmer = int.Parse(fields[1]); + //int minCoins = int.Parse(fields[2]); + //int maxCoins = int.Parse(fields[3]) + 1; + bool isGlider = bool.Parse(fields[4]); + int durationOfRandomMovements = int.Parse(fields[5]); + int resilience = int.Parse(fields[7]); + double jitteriness = double.Parse(fields[8]); + int moveTowardsPlayerThreshold = int.Parse(fields[9]); + int speed = int.Parse(fields[10]); + double missChance = double.Parse(fields[11]); + bool isMineMonster = bool.Parse(fields[12]); + + // drops + var drops = new List(); + string[] dropFields = fields[6].Split(' '); + for (int i = 0; i < dropFields.Length; i += 2) + { + // get drop info + int itemID = int.Parse(dropFields[i]); + float chance = float.Parse(dropFields[i + 1]); + int maxDrops = 1; + + // if itemID is negative, game randomly drops 1-3 + if (itemID < 0) + { + itemID = -itemID; + maxDrops = 3; + } + + // some item IDs have special meaning + if (itemID == Debris.copperDebris) + itemID = SObject.copper; + else if (itemID == Debris.ironDebris) + itemID = SObject.iron; + else if (itemID == Debris.coalDebris) + itemID = SObject.coal; + else if (itemID == Debris.goldDebris) + itemID = SObject.gold; + else if (itemID == Debris.coinsDebris) + continue; // no drop + else if (itemID == Debris.iridiumDebris) + itemID = SObject.iridium; + else if (itemID == Debris.woodDebris) + itemID = SObject.wood; + else if (itemID == Debris.stoneDebris) + itemID = SObject.stone; + + // add drop + drops.Add(new ItemDropData(itemID, maxDrops, chance)); + } + if (isMineMonster && Game1.player.timesReachedMineBottom >= 1) + { + drops.Add(new ItemDropData(SObject.diamondIndex, 1, 0.008f)); + drops.Add(new ItemDropData(SObject.prismaticShardIndex, 1, 0.008f)); + } + + // yield data + yield return new MonsterData( + name: name, + health: health, + damageToFarmer: damageToFarmer, + isGlider: isGlider, + durationOfRandomMovements: durationOfRandomMovements, + resilience: resilience, + jitteriness: jitteriness, + moveTowardsPlayerThreshold: moveTowardsPlayerThreshold, + speed: speed, + missChance: missChance, + isMineMonster: isMineMonster, + drops: drops + ); + } + } + + /// Parse gift tastes. + /// The monitor with which to log errors. + /// Derived from the . + public IEnumerable GetObjects(IMonitor monitor) + { + IDictionary data = Game1.objectInformation; + + foreach (var pair in data) + { + int parentSpriteIndex = pair.Key; + + ObjectModel model; + try + { + + string[] fields = pair.Value.Split('/'); + + // ring + if (parentSpriteIndex >= Ring.ringLowerIndexRange && parentSpriteIndex <= Ring.ringUpperIndexRange) + { + model = new ObjectModel( + parentSpriteIndex: parentSpriteIndex, + name: fields[0], + description: fields[1], + price: int.Parse(fields[2]), + edibility: -300, + type: fields[3], + category: SObject.ringCategory + ); + } + + // any other object + else + { + string name = fields[SObject.objectInfoNameIndex]; + int price = int.Parse(fields[SObject.objectInfoPriceIndex]); + int edibility = int.Parse(fields[SObject.objectInfoEdibilityIndex]); + string description = fields[SObject.objectInfoDescriptionIndex]; + + // type & category + string[] typeParts = fields[SObject.objectInfoTypeIndex].Split(' '); + string typeName = typeParts[0]; + int category = 0; + if (typeParts.Length > 1) + category = int.Parse(typeParts[1]); + + model = new ObjectModel(parentSpriteIndex, name, description, price, edibility, typeName, category); + } + } + catch (Exception ex) + { + monitor.Log($"Couldn't parse object #{parentSpriteIndex} from Content\\Data\\ObjectInformation.xnb due to an invalid format.\nObject data: {pair.Value}\nError: {ex}", LogLevel.Warn); + continue; + } + yield return model; + } + } + + /// Get the recipe ingredients. + /// Provides metadata that's not available from the game data directly. + /// Simplifies access to private game code. + /// Provides translations stored in the mod folder. + public RecipeModel[] GetRecipes(Metadata metadata, IReflectionHelper reflectionHelper, ITranslationHelper translations) + { + List recipes = new List(); + + // cooking recipes + recipes.AddRange( + from entry in CraftingRecipe.cookingRecipes + let recipe = new CraftingRecipe(entry.Key, isCookingRecipe: true) + select new RecipeModel(recipe, reflectionHelper, translations) + ); + + // crafting recipes + recipes.AddRange( + from entry in CraftingRecipe.craftingRecipes + let recipe = new CraftingRecipe(entry.Key, isCookingRecipe: false) + select new RecipeModel(recipe, reflectionHelper, translations) + ); + + // machine recipes + recipes.AddRange( + from entry in metadata.MachineRecipes + let machine = new SObject(Vector2.Zero, entry.MachineID) + select new RecipeModel(null, RecipeType.MachineInput, machine.DisplayName, entry.Ingredients, ingredient => this.CreateRecipeItem(ingredient.ParentSheetIndex, entry.Output), false, entry.ExceptIngredients, outputItemIndex: entry.Output) + ); + + // building recipes + recipes.AddRange( + from entry in metadata.BuildingRecipes + let building = new BluePrint(entry.BuildingKey) + select new RecipeModel(null, RecipeType.BuildingBlueprint, building.displayName, entry.Ingredients, ingredient => this.CreateRecipeItem(ingredient.ParentSheetIndex, entry.Output), false, entry.ExceptIngredients) + ); + + return recipes.ToArray(); + } + + /********* + ** Private methods + *********/ + /// Create a custom recipe output. + /// The input ingredient ID. + /// The output item ID. + private SObject CreateRecipeItem(int inputID, int outputID) + { + SObject item = this.GameHelper.GetObjectBySpriteIndex(outputID); + switch (outputID) + { + case 342: + item.preserve.Value = SObject.PreserveType.Pickle; + item.preservedParentSheetIndex.Value = inputID; + break; + case 344: + item.preserve.Value = SObject.PreserveType.Jelly; + item.preservedParentSheetIndex.Value = inputID; + break; + case 348: + item.preserve.Value = SObject.PreserveType.Wine; + item.preservedParentSheetIndex.Value = inputID; + break; + case 350: + item.preserve.Value = SObject.PreserveType.Juice; + item.preservedParentSheetIndex.Value = inputID; + break; + } + return item; + } + } +} diff --git a/Mods/LookupAnything/DrawHelper.cs b/Mods/LookupAnything/DrawHelper.cs new file mode 100644 index 000000000..972e770a0 --- /dev/null +++ b/Mods/LookupAnything/DrawHelper.cs @@ -0,0 +1,188 @@ +using System; +using System.Collections.Generic; +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Graphics; +using Pathoschild.Stardew.Common; +using Pathoschild.Stardew.LookupAnything.Components; +using Pathoschild.Stardew.LookupAnything.Framework; +using StardewValley; + +namespace Pathoschild.Stardew.LookupAnything +{ + /// Provides utility methods for drawing to the screen. + internal static class DrawHelper + { + /********* + ** Public methods + *********/ + /**** + ** Fonts + ****/ + /// Get the dimensions of a space character. + /// The font to measure. + public static float GetSpaceWidth(SpriteFont font) + { + return CommonHelper.GetSpaceWidth(font); + } + + /**** + ** Drawing + ****/ + /// Draw a sprite to the screen. + /// The sprite batch being drawn. + /// The sprite sheet containing the sprite. + /// The sprite coordinates and dimensions in the sprite sheet. + /// The X-position at which to draw the sprite. + /// The X-position at which to draw the sprite. + /// The color to tint the sprite. + /// The scale to draw. + public static void DrawSprite(this SpriteBatch spriteBatch, Texture2D sheet, Rectangle sprite, float x, float y, Color? color = null, float scale = 1) + { + spriteBatch.Draw(sheet, new Vector2(x, y), sprite, color ?? Color.White, 0, Vector2.Zero, scale, SpriteEffects.None, 0); + } + + /// Draw a sprite to the screen scaled and centered to fit the given dimensions. + /// The sprite batch being drawn. + /// The sprite to draw. + /// The X-position at which to draw the sprite. + /// The X-position at which to draw the sprite. + /// The size to draw. + /// The color to tint the sprite. + public static void DrawSpriteWithin(this SpriteBatch spriteBatch, SpriteInfo sprite, float x, float y, Vector2 size, Color? color = null) + { + spriteBatch.DrawSpriteWithin(sprite.Spritesheet, sprite.SourceRectangle, x, y, size, color ?? Color.White); + } + + /// Draw a sprite to the screen scaled and centered to fit the given dimensions. + /// The sprite batch being drawn. + /// The sprite sheet containing the sprite. + /// The sprite coordinates and dimensions in the sprite sheet. + /// The X-position at which to draw the sprite. + /// The X-position at which to draw the sprite. + /// The size to draw. + /// The color to tint the sprite. + public static void DrawSpriteWithin(this SpriteBatch spriteBatch, Texture2D sheet, Rectangle sprite, float x, float y, Vector2 size, Color? color = null) + { + // calculate dimensions + float largestDimension = Math.Max(sprite.Width, sprite.Height); + float scale = size.X / largestDimension; + float leftOffset = Math.Max((size.X - (sprite.Width * scale)) / 2, 0); + float topOffset = Math.Max((size.Y - (sprite.Height * scale)) / 2, 0); + + // draw + spriteBatch.DrawSprite(sheet, sprite, x + leftOffset, y + topOffset, color ?? Color.White, scale); + } + + /// 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, Vector2 size, Color? color = null) + { + batch.Draw(Sprites.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, Vector2 position, float wrapWidth, Color? color = null, bool bold = false, float scale = 1) + { + return batch.DrawTextBlock(font, new IFormattedText[] { new FormattedText(text, color, bold) }, position, wrapWidth, scale); + } + + /// 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 font scale. + /// Returns the text dimensions. + public static Vector2 DrawTextBlock(this SpriteBatch batch, SpriteFont font, IEnumerable text, Vector2 position, float wrapWidth, float scale = 1) + { + if (text == null) + return new Vector2(0, 0); + + // track draw values + float xOffset = 0; + float yOffset = 0; + float lineHeight = font.MeasureString("ABC").Y * scale; + float spaceWidth = DrawHelper.GetSpaceWidth(font) * scale; + float blockWidth = 0; + float blockHeight = lineHeight; + + // draw text snippets + foreach (IFormattedText snippet in text) + { + if (snippet?.Text == null) + continue; + + // get word list + List words = new List(); + foreach (string word in snippet.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); + } + + // draw words to screen + 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 (snippet.Bold) + Utility.drawBoldText(batch, word, font, wordPosition, snippet.Color ?? Color.Black, scale); + else + batch.DrawString(font, word, wordPosition, snippet.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); + } + } +} diff --git a/Mods/LookupAnything/Framework/Constants/ChildAge.cs b/Mods/LookupAnything/Framework/Constants/ChildAge.cs new file mode 100644 index 000000000..032275048 --- /dev/null +++ b/Mods/LookupAnything/Framework/Constants/ChildAge.cs @@ -0,0 +1,20 @@ +using StardewValley.Characters; + +namespace Pathoschild.Stardew.LookupAnything.Framework.Constants +{ + /// The growth stage for a player's child. + internal enum ChildAge + { + /// The child was born days ago. + Newborn = Child.newborn, + + /// The child is older than newborn, and can sit on its own. + Baby = Child.baby, + + /// The child is older than baby, and can crawl around. + Crawler = Child.crawler, + + /// The child is older than crawler, and can toddle around. + Toddler = Child.toddler + } +} diff --git a/Mods/LookupAnything/Framework/Constants/Constant.cs b/Mods/LookupAnything/Framework/Constants/Constant.cs new file mode 100644 index 000000000..0c8454726 --- /dev/null +++ b/Mods/LookupAnything/Framework/Constants/Constant.cs @@ -0,0 +1,73 @@ +using Microsoft.Xna.Framework; +using StardewValley; + +namespace Pathoschild.Stardew.LookupAnything.Framework.Constants +{ + /// Constant mod values. + internal static class Constant + { + /********* + ** Accessors + *********/ + /// The maximum stack size for which to calculate a stack price (e.g. to avoid showing a stack size for infinite store inventory). + public static readonly int MaxStackSizeForPricing = 999; + + /// Whether bold text should be enabled where needed. + /// This is disabled for languages like Chinese which are difficult to read in bold. + public static bool AllowBold => Game1.content.GetCurrentLanguage() != LocalizedContentManager.LanguageCode.zh; + + /// The largest expected sprite size (measured in tiles). + /// This is used to account for sprites that extend beyond their tile when searching for targets. These values should be large enough to cover the largest target sprites, but small enough to minimise expensive cursor collision checks. + public static readonly Vector2 MaxTargetSpriteSize = new Vector2(3, 5); + + /// Equivalent to , but for building targets. + public static readonly Vector2 MaxBuildingTargetSpriteSize = new Vector2(10, 10); + + /// The keys referenced by the mod. + public static class MailLetters + { + /// Set when the spouse gives the player a stardrop. + public const string ReceivedSpouseStardrop = "CF_Spouse"; + + /// Set when the player buys a Joja membership, which demolishes the community center. + public const string JojaMember = "JojaMember"; + } + + /// The season names. + public static class SeasonNames + { + /// The internal name for Spring. + public const string Spring = "spring"; + + /// The internal name for Summer. + public const string Summer = "summer"; + + /// The internal name for Fall. + public const string Fall = "fall"; + + /// The internal name for Winter. + public const string Winter = "winter"; + } + + /// The names of items referenced by the mod. + public static class ItemNames + { + /// The internal name for the heater object. + public static string Heater = "Heater"; + } + + /// The names of buildings referenced by the mod. + public static class BuildingNames + { + /// The internal name for the Gold Clock. + public static string GoldClock = "Gold Clock"; + } + + /// The parent sheet indexes referenced by the mod. + public static class ObjectIndexes + { + /// The parent sheet index for the auto-grabber. + public static int AutoGrabber = 165; + } + } +} diff --git a/Mods/LookupAnything/Framework/Constants/FacingDirection.cs b/Mods/LookupAnything/Framework/Constants/FacingDirection.cs new file mode 100644 index 000000000..8bd51b8ca --- /dev/null +++ b/Mods/LookupAnything/Framework/Constants/FacingDirection.cs @@ -0,0 +1,18 @@ +namespace Pathoschild.Stardew.LookupAnything.Framework.Constants +{ + /// The direction a player is facing. + internal enum FacingDirection + { + /// The player is facing the top of the screen. + Up = 0, + + /// The player is facing the right side of the screen. + Right = 1, + + /// The player is facing the bottom of the screen. + Down = 2, + + /// The player is facing the left side of the screen. + Left = 3 + } +} diff --git a/Mods/LookupAnything/Framework/Constants/FenceType.cs b/Mods/LookupAnything/Framework/Constants/FenceType.cs new file mode 100644 index 000000000..549788bca --- /dev/null +++ b/Mods/LookupAnything/Framework/Constants/FenceType.cs @@ -0,0 +1,13 @@ +using StardewValley; + +namespace Pathoschild.Stardew.LookupAnything.Framework.Constants +{ + /// Indicates a fence type. Better fences last longer. + internal enum FenceType + { + Wood = Fence.wood, + Stone = Fence.stone, + Iron = Fence.steel, // sic + Hardwood = Fence.gold // sic + } +} diff --git a/Mods/LookupAnything/Framework/Constants/FruitTreeGrowthStage.cs b/Mods/LookupAnything/Framework/Constants/FruitTreeGrowthStage.cs new file mode 100644 index 000000000..0cf4c460b --- /dev/null +++ b/Mods/LookupAnything/Framework/Constants/FruitTreeGrowthStage.cs @@ -0,0 +1,14 @@ +using StardewValley.TerrainFeatures; + +namespace Pathoschild.Stardew.LookupAnything.Framework.Constants +{ + /// Indicates a tree's growth stage. + internal enum FruitTreeGrowthStage + { + Seed = FruitTree.seedStage, + Sprout = FruitTree.sproutStage, + Sapling = FruitTree.saplingStage, + Bush = FruitTree.bushStage, + Tree = FruitTree.treeStage + } +} diff --git a/Mods/LookupAnything/Framework/Constants/GiftTaste.cs b/Mods/LookupAnything/Framework/Constants/GiftTaste.cs new file mode 100644 index 000000000..8a2187125 --- /dev/null +++ b/Mods/LookupAnything/Framework/Constants/GiftTaste.cs @@ -0,0 +1,14 @@ +using StardewValley; + +namespace Pathoschild.Stardew.LookupAnything.Framework.Constants +{ + /// Indicates how much an NPC likes a particular gift. + internal enum GiftTaste + { + Hate = NPC.gift_taste_hate, + Dislike = NPC.gift_taste_dislike, + Neutral = NPC.gift_taste_neutral, + Like = NPC.gift_taste_like, + Love = NPC.gift_taste_love + } +} diff --git a/Mods/LookupAnything/Framework/Constants/ItemQuality.cs b/Mods/LookupAnything/Framework/Constants/ItemQuality.cs new file mode 100644 index 000000000..f135cad28 --- /dev/null +++ b/Mods/LookupAnything/Framework/Constants/ItemQuality.cs @@ -0,0 +1,43 @@ +using System; +using SObject = StardewValley.Object; + +namespace Pathoschild.Stardew.LookupAnything.Framework.Constants +{ + /// Indicates an item quality. (Higher-quality items are sold at a higher price.) + internal enum ItemQuality + { + Normal = SObject.lowQuality, + Silver = SObject.medQuality, + Gold = SObject.highQuality, + Iridium = SObject.bestQuality + } + + /// Extension methods for . + internal static class ItemQualityExtensions + { + /// Get the quality name. + /// The quality. + public static string GetName(this ItemQuality current) + { + return current.ToString().ToLower(); + } + + /// Get the next better quality. + /// The current quality. + public static ItemQuality GetNext(this ItemQuality current) + { + switch (current) + { + case ItemQuality.Normal: + return ItemQuality.Silver; + case ItemQuality.Silver: + return ItemQuality.Gold; + case ItemQuality.Gold: + case ItemQuality.Iridium: + return ItemQuality.Iridium; + default: + throw new NotSupportedException($"Unknown quality '{current}'."); + } + } + } +} diff --git a/Mods/LookupAnything/Framework/Constants/ItemSpriteType.cs b/Mods/LookupAnything/Framework/Constants/ItemSpriteType.cs new file mode 100644 index 000000000..60b42fe91 --- /dev/null +++ b/Mods/LookupAnything/Framework/Constants/ItemSpriteType.cs @@ -0,0 +1,30 @@ +namespace Pathoschild.Stardew.LookupAnything.Framework.Constants +{ + /// Indicates the sprite sheet used to draw an object. A given sprite ID can be duplicated between two sprite sheets. + internal enum ItemSpriteType + { + /// The Data\ObjectInformation.xnb () sprite sheet used to draw most inventory items and some placeable objects. + Object, + + /// The Data\BigCraftablesInformation.xnb () sprite sheet used to draw furniture, scarecrows, tappers, crafting stations, and similar placeable objects. + BigCraftable, + + /// The Data\Boots.xnb sprite sheet used to draw boot equipment. + Boots, + + /// The Data\hats.xnb sprite sheet used to draw boot equipment. + Hat, + + /// The TileSheets\furniture.xnb sprite sheet used to draw furniture. + Furniture, + + /// The TileSheets\weapons.xnb sprite sheet used to draw tools and weapons. + Tool, + + /// The Maps\walls_and_floors sprite sheet used to draw wallpapers and flooring. + Wallpaper, + + /// The item isn't covered by one of the known types. + Unknown + } +} diff --git a/Mods/LookupAnything/Framework/Constants/L10n.cs b/Mods/LookupAnything/Framework/Constants/L10n.cs new file mode 100644 index 000000000..93aa4a613 --- /dev/null +++ b/Mods/LookupAnything/Framework/Constants/L10n.cs @@ -0,0 +1,814 @@ +using System.Diagnostics.CodeAnalysis; +using StardewValley; + +namespace Pathoschild.Stardew.LookupAnything.Framework.Constants +{ + /// Localisation Keys matching the mod's i18n schema. + [SuppressMessage("ReSharper", "MemberHidesStaticFromOuterClass", Justification = "Irrelevant in this context.")] + [SuppressMessage("ReSharper", "InconsistentNaming", Justification = "Deliberately named to keep translation keys short.")] + internal static class L10n + { + /********* + ** Accessors + *********/ + /// Generic field value translations. + public static class Generic + { + /// A value like {{seasonName}} {{dayNumber}}. Expected tokens: {{seasonName}}, {{seasonNumber}}, {{dayNumber}}, {{year}}. + public const string Date = "generic.date"; + + /// A value like {{seasonName}} {{dayNumber}} in year {{Year}}. Expected tokens: {{seasonName}}, {{seasonNumber}}, {{dayNumber}}, {{year}}. + public const string DateWithYear = "generic.date-with-year"; + + /// A value like {{percent}}%. + public const string Percent = "generic.percent"; + + /// A value like {{percent}}% chance of {{label}}. + public const string PercentChanceOf = "generic.percent-chance-of"; + + /// A value like {{percent}}% ({{value}} of {{max}}). + public const string PercentRatio = "generic.percent-ratio"; + + /// A value like {{value}} of {{max}}. + public const string Ratio = "generic.ratio"; + + /// A value like {{min}} to {{max}}. + public const string Range = "generic.range"; + + /// A value like yes. + public const string Yes = "generic.yes"; + + /// A value like no. + public const string No = "generic.no"; + + /// A value like {{count}} seconds. + public const string Seconds = "generic.seconds"; + + /// A value like {{count}} minutes. + public const string Minutes = "generic.minutes"; + + /// A value like {{count}} hours. + public const string Hours = "generic.hours"; + + /// A value like {{count}} days. + public const string Days = "generic.days"; + + /// A value like in {{count}} days. + public const string InXDays = "generic.in-x-days"; + + /// A value like tomorrow. + public const string Tomorrow = "generic.tomorrow"; + + /// A value like {{price}}g. + public const string Price = "generic.price"; + + /// A value like {{price}}g ({{quality}}). + public const string PriceForQuality = "generic.price-for-quality"; + + /// A value like {{price}}g for stack of {{count}}. + public const string PriceForStack = "generic.price-for-stack"; + } + + /// Lookup subject types. + public static class Types + { + /// A value like Building. + public const string Building = "type.building"; + + /// A value like {{fruitName}} Tree. + public const string FruitTree = "type.fruit-tree"; + + /// A value like Monster. + public const string Monster = "type.monster"; + + /// A value like Player. + public const string Player = "type.player"; + + /// A value like Map tile. + public const string Tile = "type.map-tile"; + + /// A value like Tree. + public const string Tree = "type.tree"; + + /// A value like Villager. + public const string Villager = "type.villager"; + + /// A value like Other. + public const string Other = "type.other"; + } + + /// Community Center bundle areas. + public static class BundleAreas + { + /// A value like Pantry. + public const string Pantry = "bundle-area.pantry"; + + /// A value like Crafts Room. + public const string CraftsRoom = "bundle-area.crafts-room"; + + /// A value like Fish Tank. + public const string FishTank = "bundle-area.fish-tank"; + + /// A value like Boiler Room. + public const string BoilerRoom = "bundle-area.boiler-room"; + + /// A value like Vault. + public const string Vault = "bundle-area.vault"; + + /// A value like Bulletin Board. + public const string BulletinBoard = "bundle-area.bulletin-board"; + } + + /// Recipe types. + public static class RecipeTypes + { + /// A value like Cooking. + public const string Cooking = "recipe-type.cooking"; + + /// A value like Crafting. + public const string Crafting = "recipe-type.crafting"; + } + + /// Animal lookup translations. + public static class Animal + { + /**** + ** Labels + ****/ + /// A value like Love. + public const string Love = "animal.love"; + + /// A value like Happiness. + public const string Happiness = "animal.happiness"; + + /// A value like Mood today. + public const string Mood = "animal.mood"; + + /// A value like Complaints. + public const string Complaints = "animal.complaints"; + + /// A value like Produce ready. + public const string ProduceReady = "animal.produce-ready"; + + /// A value like Growth. + public const string Growth = "animal.growth"; + + /// A value like Sells for. + public const string SellsFor = "animal.sells-for"; + + /**** + ** Values + ****/ + /// A value like was disturbed by {{name}}. + public const string ComplaintsWildAnimalAttack = "animal.complaints.wild-animal-attack"; + + /// A value like wasn't fed yesterday. + public const string ComplaintsHungry = "animal.complaints.hungry"; + + /// A value like was left outside last night. + public const string ComplaintsLeftOut = "animal.complaints.left-out"; + + /// A value like moved into new home. + public const string ComplaintsNewHome = "animal.complaints.new-home"; + + /// A value like no heater in winter. + public const string ComplaintsNoHeater = "animal.complaints.no-heater"; + + /// A value like hasn't been petted today. + public const string ComplaintsNotPetted = "animal.complaints.not-petted"; + } + + /// building lookup translations. + public static class Building + { + /**** + ** Labels + ****/ + /// A value like Animals. + public const string Animals = "building.animals"; + + /// A value like Construction. + public const string Construction = "building.construction"; + + /// A value like Feed trough. + public const string FeedTrough = "building.feed-trough"; + + /// A value like Horse. + public const string Horse = "building.horse"; + + /// A value like Horse. + public const string HorseLocation = "building.horse-location"; + + /// A value like Harvesting enabled. + public const string JunimoHarvestingEnabled = "building.junimo-harvesting-enabled"; + + /// A value like Owner. + public const string Owner = "building.owner"; + + /// A value like Produce ready. + public const string OutputProcessing = "building.output-processing"; + + /// A value like Produce ready. + public const string OutputReady = "building.output-ready"; + + /// A value like Slimes. + public const string Slimes = "building.slimes"; + + /// A value like Stored hay. + public const string StoredHay = "building.stored-hay"; + + /// A value like Upgrades. + public const string Upgrades = "building.upgrades"; + + /// A value like Water trough. + public const string WaterTrough = "building.water-trough"; + + /**** + ** Values + ****/ + /// A value like {{count}} of max {{max}} animals. + public const string AnimalsSummary = "building.animals.summary"; + + /// A value like ready on {{date}}. + public const string ConstructionSummary = "building.construction.summary"; + + /// A value like automated. + public const string FeedTroughAutomated = "building.feed-trough.automated"; + + /// A value like {{filled}} of {{max}} feed slots filled. + public const string FeedTroughSummary = "building.feed-trough.summary"; + + /// A value like {{location}} ({{x}}, {{y}}). + public const string HorseLocationSummary = "building.horse-location.summary"; + + /// A value like no owner. + public const string OwnerNone = "building.owner.none"; + + /// A value like {{count}} of max {{max}} slimes. + public const string SlimesSummary = "building.slimes.summary"; + + /// A value like {{hayCount}} hay (max capacity: {{maxHay}}). + public const string StoredHaySummaryOneSilo = "building.stored-hay.summary-one-silo"; + + /// A value like {{hayCount}} hay in {{siloCount}} silos (max capacity: {{maxHay}}). + public const string StoredHaySummaryMultipleSilos = "building.stored-hay.summary-multiple-silos"; + + /// A value like up to 4 animals, add cows. + public const string UpgradesBarn0 = "building.upgrades.barn.0"; + + /// A value like up to 8 animals, add pregnancy and goats. + public const string UpgradesBarn1 = "building.upgrades.barn.1"; + + /// A value like up to 12 animals, add autofeed, pigs, and sheep". + public const string UpgradesBarn2 = "building.upgrades.barn.2"; + + /// A value like initial cabin. + public const string UpgradesCabin0 = "building.upgrades.cabin.0"; + + /// A value like add kitchen, enable marriage. + public const string UpgradesCabin1 = "building.upgrades.cabin.1"; + + /// A value like enable children. + public const string UpgradesCabin2 = "building.upgrades.cabin.2"; + + /// A value like up to 4 animals; add chickens. + public const string UpgradesCoop0 = "building.upgrades.coop.0"; + + /// A value like up to 8 animals; add incubator, dinosaurs, and ducks. + public const string UpgradesCoop1 = "building.upgrades.coop.1"; + + /// A value like up to 12 animals; add autofeed and rabbits. + public const string UpgradesCoop2 = "building.upgrades.coop.2"; + + /// A value like {{filled}} of {{max}} water troughs filled. + public const string WaterTroughSummary = "building.water-trough.summary"; + } + + /// Fruit tree lookup translations. + public static class FruitTree + { + /**** + ** Labels + ****/ + /// A value like Complaints. + public const string Complaints = "fruit-tree.complaints"; + + /// A value like Growth. + public const string Growth = "fruit-tree.growth"; + + /// A value like {{fruitName}} Tree. + public const string Name = "fruit-tree.name"; + + /// A value like Next fruit. + public const string NextFruit = "fruit-tree.next-fruit"; + + /// A value like Season. + public const string Season = "fruit-tree.season"; + + /// A value like Quality. + public const string Quality = "fruit-tree.quality"; + + /**** + ** Values + ****/ + /// A value like can't grow because there are adjacent objects. + public const string ComplaintsAdjacentObjects = "fruit-tree.complaints.adjacent-objects"; + + /// A value like mature on {{date}}. + public const string GrowthSummary = "fruit-tree.growth.summary"; + + /// A value like struck by lightning! Will recover in {{count}} days.. + public const string NextFruitStruckByLightning = "fruit-tree.next-fruit.struck-by-lightning"; + + /// A value like out of season. + public const string NextFruitOutOfSeason = "fruit-tree.next-fruit.out-of-season"; + + /// A value like won't grow any more fruit until you harvest those it has. + public const string NextFruitMaxFruit = "fruit-tree.next-fruit.max-fruit"; + + /// A value like too young to bear fruit. + public const string NextFruitTooYoung = "fruit-tree.next-fruit.too-young"; + + /// A value like {{quality}} now. + public const string QualityNow = "fruit-tree.quality.now"; + + /// A value like {{quality}} on {{date}}. + public const string QualityOnDate = "fruit-tree.quality.on-date"; + + /// A value like {{quality}} on {{date}} next year. + public const string QualityOnDateNextYear = "fruit-tree.quality.on-date-next-year"; + + /// A value like {{season}} (or anytime in greenhouse). + public const string SeasonSummary = "fruit-tree.season.summary"; + } + + /// Crop lookup translations. + public static class Crop + { + /**** + ** Labels + ****/ + /// A value like Crop. + public const string Summary = "crop.summary"; + + /// A value like Harvest. + public const string Harvest = "crop.harvest"; + + /**** + ** Values + ****/ + /// A value like This crop is dead.. + public const string SummaryDead = "crop.summary.dead"; + + /// A value like drops {{count}}. + public const string SummaryDropsX = "crop.summary.drops-x"; + + /// A value like drops {{min}} to {{max}} ({{percent}}% chance of extra crops). + public const string SummaryDropsXToY = "crop.summary.drops-x-to-y"; + + /// A value like harvest after {{daysToFirstHarvest}} days. + public const string SummaryHarvestOnce = "crop.summary.harvest-once"; + + /// A value like harvest after {{daysToFirstHarvest}} days, then every {{daysToNextHarvests}} days. + public const string SummaryHarvestMulti = "crop.summary.harvest-multi"; + + /// A value like grows in {{seasons}}. + public const string SummarySeasons = "crop.summary.seasons"; + + /// A value like sells for {{price}}. + public const string SummarySellsFor = "crop.summary.sells-for"; + + /// A value like now. + public const string HarvestNow = "crop.harvest.now"; + + /// A value like too late in the season for the next harvest (would be on {{date}}). + public const string HarvestTooLate = "crop.harvest.too-late"; + } + + /// Item lookup translations. + public static class Item + { + /// A value like Aging. + public const string CaskSchedule = "item.cask-schedule"; + + /// A value like Bait. + public const string CrabpotBait = "item.crabpot-bait"; + + /// A value like Needs bait!. + public const string CrabpotBaitNeeded = "item.crabpot-bait-needed"; + + /// A value like Not needed due to Luremaster profession.. + public const string CrabpotBaitNotNeeded = "item.crabpot-bait-not-needed"; + + /// A value like Contents. + public const string Contents = "item.contents"; + + /// A value like Needed for. + public const string NeededFor = "item.needed-for"; + + /// A value like Sells for. + public const string SellsFor = "item.sells-for"; + + /// A value like Sells to. + public const string SellsTo = "item.sells-to"; + + /// A value like Likes this. + public const string LikesThis = "item.likes-this"; + + /// A value like Loves this. + public const string LovesThis = "item.loves-this"; + + /// A value like Health. + public const string FenceHealth = "item.fence-health"; + + /// A value like Recipes. + public const string Recipes = "item.recipes"; + + /// A value like Owned. + public const string Owned = "item.number-owned"; + + /// A value like Cooked. + public const string Cooked = "item.number-cooked"; + + /// A value like Crafted. + public const string Crafted = "item.number-crafted"; + + /// A value like See also. + public const string SeeAlso = "item.see-also"; + + /**** + ** Values + ****/ + /// A value like {{quality}} ready now. + public const string CaskScheduleNow = "item.cask-schedule.now"; + + /// A value like {{quality}} now (use pickaxe to stop aging). + public const string CaskSchedulePartial = "item.cask-schedule.now-partial"; + + /// A value like {{quality}} tomorrow. + public const string CaskScheduleTomorrow = "item.cask-schedule.tomorrow"; + + /// A value like {{quality}} in {{count}} days ({{date}}). + public const string CaskScheduleInXDays = "item.cask-schedule.in-x-days"; + + /// A value like has {{name}}. + public const string ContentsPlaced = "item.contents.placed"; + + /// A value like {{name}} ready. + public const string ContentsReady = "item.contents.ready"; + + /// A value like {{name}} in {{time}}. + public const string ContentsPartial = "item.contents.partial"; + + /// A value like community center ({{bundles}}). + public const string NeededForCommunityCenter = "item.needed-for.community-center"; + + /// A value like full shipment achievement (ship one). + public const string NeededForFullShipment = "item.needed-for.full-shipment"; + + /// A value like polyculture achievement (ship {{count}} more). + public const string NeededForPolyculture = "item.needed-for.polyculture"; + + /// A value like full collection achievement (donate one to museum). + public const string NeededForFullCollection = "item.needed-for.full-collection"; + + /// A value like gourmet chef achievement (cook {{recipes}}). + public const string NeededForGourmetChef = "item.needed-for.gourmet-chef"; + + /// A value like craft master achievement (make {{recipes}}). + public const string NeededForCraftMaster = "item.needed-for.craft-master"; + + /// A value like shipping box. + public const string SellsToShippingBox = "item.sells-to.shipping-box"; + + /// A value like no decay with Gold Clock. + public const string FenceHealthGoldClock = "item.fence-health.gold-clock"; + + /// A value like {{percent}}% (roughly {{count}} days left). + public const string FenceHealthSummary = "item.fence-health.summary"; + + /// A value like {{name}} (needs {{count}}). + public const string RecipesEntry = "item.recipes.entry"; + + /// A value like you own {{count}} of these. + public const string OwnedSummary = "item.number-owned.summary"; + + /// A value like you made {{count}} of these. + public const string CraftedSummary = "item.number-crafted.summary"; + } + + /// Monster lookup translations. + public static class Monster + { + /**** + ** Labels + ****/ + /// A value like Invincible. + public const string Invincible = "monster.invincible"; + + /// A value like Health. + public const string Health = "monster.health"; + + /// A value like Drops. + public const string Drops = "monster.drops"; + + /// A value like XP. + public const string Experience = "monster.experience"; + + /// A value like Defence. + public const string Defence = "monster.defence"; + + /// A value like Attack. + public const string Attack = "monster.attack"; + + /// A value like Adventure Guild. + public const string AdventureGuild = "monster.adventure-guild"; + + /**** + ** Values + ****/ + /// A value like nothing. + public const string DropsNothing = "monster.drops.nothing"; + + /// A value like complete. + public const string AdventureGuildComplete = "monster.adventure-guild.complete"; + + /// A value like in progress. + public const string AdventureGuildIncomplete = "monster.adventure-guild.incomplete"; + + /// A value like killed {{count}} of {{requiredCount}}. + public const string AdventureGuildProgress = "monster.adventure-guild.progress"; + } + + /// NPC lookup translations. + public static class Npc + { + /**** + ** Labels + ****/ + /// A value like Birthday. + public const string Birthday = "npc.birthday"; + + /// A value like Can romance. + public const string CanRomance = "npc.can-romance"; + + /// A value like Friendship. + public const string Friendship = "npc.friendship"; + + /// A value like Talked today. + public const string TalkedToday = "npc.talked-today"; + + /// A value like Gifted today. + public const string GiftedToday = "npc.gifted-today"; + + /// A value like Gifted this week. + public const string GiftedThisWeek = "npc.gifted-this-week"; + + /// A value like Likes gifts. + public const string LikesGifts = "npc.likes-gifts"; + + /// A value like Loves gifts. + public const string LovesGifts = "npc.loves-gifts"; + + /// A value like Neutral gifts. + public const string NeutralGifts = "npc.neutral-gifts"; + + /**** + ** Values + ****/ + /// A value like You're married! <. + public const string CanRomanceMarried = "npc.can-romance.married"; + + /// A value like You haven't met them yet.. + public const string FriendshipNotMet = "npc.friendship.not-met"; + + /// A value like need bouquet for next. + public const string FriendshipNeedBouquet = "npc.friendship.need-bouquet"; + + /// A value like next in {{count}} pts. + public const string FriendshipNeedPoints = "npc.friendship.need-points"; + } + + /// NPC child lookup translations. + public static class NpcChild + { + /**** + ** Labels + ****/ + /// A value like Age. + public const string Age = "npc.child.age"; + + /**** + ** Values + ****/ + /// A value like {{label}} ({{count}} days to {{nextLabel}}). + public const string AgeDescriptionPartial = "npc.child.age.description-partial"; + + /// A value like {{label}}. + public const string AgeDescriptionGrown = "npc.child.age.description-grown"; + + /// A value like newborn. + public const string AgeNewborn = "npc.child.age.newborn"; + + /// A value like baby. + public const string AgeBaby = "npc.child.age.baby"; + + /// A value like crawler. + public const string AgeCrawler = "npc.child.age.crawler"; + + /// A value like toddler. + public const string AgeToddler = "npc.child.age.toddler"; + } + + /// Pet lookup translations. + public static class Pet + { + /// A value like Love. + public const string Love = "pet.love"; + + /// A value like Petted today. + public const string PettedToday = "pet.petted-today"; + } + + /// Player lookup translations. + public static class Player + { + /**** + ** Labels + ****/ + /// A value like Farm name. + public const string FarmName = "player.farm-name"; + + /// A value like Farm map. + public const string FarmMap = "player.farm-map"; + + /// A value like Favourite thing. + public const string FavoriteThing = "player.favorite-thing"; + + /// A value like Gender. + public const string Gender = "player.gender"; + + /// A value like Spouse. + public const string Spouse = "player.spouse"; + + /// A value like Combat skill. + public const string CombatSkill = "player.combat-skill"; + + /// A value like Farming skill. + public const string FarmingSkill = "player.farming-skill"; + + /// A value like Foraging skill. + public const string ForagingSkill = "player.foraging-skill"; + + /// A value like Fishing skill. + public const string FishingSkill = "player.fishing-skill"; + + /// A value like Mining skill. + public const string MiningSkill = "player.mining-skill"; + + /// A value like Luck. + public const string Luck = "player.luck"; + + /**** + ** Values + ****/ + /// A value like Custom. + public const string FarmMapCustom = "player.farm-map.custom"; + + /// A value like male. + public const string GenderMale = "player.gender.male"; + + /// A value like female. + public const string GenderFemale = "player.gender.female"; + + /// A value like ({{percent}}% to many random checks). + public const string LuckSummary = "player.luck.summary"; + + /// A value like level {{level}} ({{expNeeded}} XP to next). + public const string SkillProgress = "player.skill.progress"; + + /// A value like level {{level}}. + public const string SkillProgressLast = "player.skill.progress-last"; + } + + /// Tile lookup translations. + public static class Tile + { + /**** + ** Labels + ****/ + /// A value like A tile position on the map. This is displayed because you enabled tile lookups in the configuration.. + public const string Description = "tile.description"; + + /// A value like Map name. + public const string MapName = "tile.map-name"; + + /// A value like Tile. + public const string TileField = "tile.tile"; + + /// A value like {{layerName}}: tile index. + public const string TileIndex = "tile.tile-index"; + + /// A value like {{layerName}}: tilesheet. + public const string TileSheet = "tile.tilesheet"; + + /// A value like {{layerName}}: blend mode. + public const string BlendMode = "tile.blend-mode"; + + /// A value like {{layerName}}: ix props: {{propertyName}}. + public const string IndexProperty = "tile.index-property"; + + /// A value like {{layerName}}: props: {{propertyName}}. + public const string TileProperty = "tile.tile-property"; + + /**** + ** Values + ****/ + /// A value like no tile here. + public const string TileFieldNoneFound = "tile.tile.none-here"; + } + + /// Wild tree lookup translations. + public static class Tree + { + /**** + ** Labels + ****/ + /// A value like Maple Tree. + public const string NameMaple = "tree.name.maple"; + + /// A value like Oak Tree. + public const string NameOak = "tree.name.oak"; + + /// A value like Pine Tree. + public const string NamePine = "tree.name.pine"; + + /// A value like Palm Tree. + public const string NamePalm = "tree.name.palm"; + + /// A value like Big Mushroom. + public const string NameBigMushroom = "tree.name.big-mushroom"; + + /// A value like Unknown Tree. + public const string NameUnknown = "tree.name.unknown"; + + /// A value like Growth stage. + public const string Stage = "tree.stage"; + + /// A value like Next growth. + public const string NextGrowth = "tree.next-growth"; + + /// A value like Has seed. + public const string HasSeed = "tree.has-seed"; + + /**** + ** Values + ****/ + /// A value like Fully grown. + public const string StageDone = "tree.stage.done"; + + /// A value like {{stageName}} ({{step}} of {{max}}). + public const string StagePartial = "tree.stage.partial"; + + /// A value like can't grow in winter outside greenhouse. + public const string NextGrowthWinter = "tree.next-growth.winter"; + + /// A value like can't grow because other trees are too close. + public const string NextGrowthAdjacentTrees = "tree.next-growth.adjacent-trees"; + + /// A value like 20% chance to grow into {{stage}} tomorrow. + public const string NextGrowthRandom = "tree.next-growth.random"; + } + + /********* + ** Public methods + *********/ + /// Get a translation key for an enum value. + /// The tree growth stage. + public static string For(WildTreeGrowthStage stage) + { + return $"tree.stages.{stage}"; + } + + /// Get a translation key for an enum value. + /// The item quality. + public static string For(ItemQuality quality) + { + return $"quality.{quality.GetName()}"; + } + + /// Get a translation key for an enum value. + /// The friendship status. + public static string For(FriendshipStatus status) + { + return $"friendship-status.{status.ToString().ToLower()}"; + } + + /// Get a translation key for an enum value. + /// The child age. + public static string For(ChildAge age) + { + return $"npc.child.age.{age.ToString().ToLower()}"; + } + } +} diff --git a/Mods/LookupAnything/Framework/Constants/LookupMode.cs b/Mods/LookupAnything/Framework/Constants/LookupMode.cs new file mode 100644 index 000000000..811e1f90d --- /dev/null +++ b/Mods/LookupAnything/Framework/Constants/LookupMode.cs @@ -0,0 +1,12 @@ +namespace Pathoschild.Stardew.LookupAnything.Framework.Constants +{ + /// Indicates how to lookup targets. + internal enum LookupMode + { + /// Lookup whatever's under the cursor. + Cursor, + + /// Lookup whatever's in front of the player. + FacingPlayer + } +} diff --git a/Mods/LookupAnything/Framework/Constants/TreeType.cs b/Mods/LookupAnything/Framework/Constants/TreeType.cs new file mode 100644 index 000000000..02a893e13 --- /dev/null +++ b/Mods/LookupAnything/Framework/Constants/TreeType.cs @@ -0,0 +1,14 @@ +using StardewValley.TerrainFeatures; + +namespace Pathoschild.Stardew.LookupAnything.Framework.Constants +{ + /// Indicates a tree type. + internal enum TreeType + { + Oak = Tree.bushyTree, + Maple = Tree.leafyTree, + Pine = Tree.pineTree, + Palm = Tree.palmTree, + BigMushroom = Tree.mushroomTree + } +} diff --git a/Mods/LookupAnything/Framework/Constants/WildTreeGrowthStage.cs b/Mods/LookupAnything/Framework/Constants/WildTreeGrowthStage.cs new file mode 100644 index 000000000..12a69a076 --- /dev/null +++ b/Mods/LookupAnything/Framework/Constants/WildTreeGrowthStage.cs @@ -0,0 +1,15 @@ +using StardewTree = StardewValley.TerrainFeatures.Tree; + +namespace Pathoschild.Stardew.LookupAnything.Framework.Constants +{ + /// Indicates a tree's growth stage. + internal enum WildTreeGrowthStage + { + Seed = StardewTree.seedStage, + Sprout = StardewTree.sproutStage, + Sapling = StardewTree.saplingStage, + Bush = StardewTree.bushStage, + SmallTree = StardewTree.treeStage - 1, // an intermediate stage between bush and tree, no constant + Tree = StardewTree.treeStage + } +} diff --git a/Mods/LookupAnything/Framework/Data/AdventureGuildQuestData.cs b/Mods/LookupAnything/Framework/Data/AdventureGuildQuestData.cs new file mode 100644 index 000000000..d69e638c5 --- /dev/null +++ b/Mods/LookupAnything/Framework/Data/AdventureGuildQuestData.cs @@ -0,0 +1,12 @@ +namespace Pathoschild.Stardew.LookupAnything.Framework.Data +{ + /// Information about an Adventure Guild monster-slaying quest. + internal class AdventureGuildQuestData + { + /// The names of the monsters in this category. + public string[] Targets { get; set; } + + /// The number of kills required for the reward. + public int RequiredKills { get; set; } + } +} diff --git a/Mods/LookupAnything/Framework/Data/BuildingRecipeData.cs b/Mods/LookupAnything/Framework/Data/BuildingRecipeData.cs new file mode 100644 index 000000000..3581609d2 --- /dev/null +++ b/Mods/LookupAnything/Framework/Data/BuildingRecipeData.cs @@ -0,0 +1,23 @@ +using System.Collections.Generic; + +namespace Pathoschild.Stardew.LookupAnything.Framework.Data +{ + /// Metadata for a building recipe. + internal class BuildingRecipeData + { + /********* + ** Accessors + *********/ + /// The building key. + public string BuildingKey { get; set; } + + /// The items needed to craft the recipe (item ID => number needed). + public IDictionary Ingredients { get; set; } + + /// The ingredients which can't be used in this recipe (typically exceptions for a category ingredient). + public int[] ExceptIngredients { get; set; } + + /// The item created by the recipe. + public int Output { get; set; } + } +} diff --git a/Mods/LookupAnything/Framework/Data/CharacterData.cs b/Mods/LookupAnything/Framework/Data/CharacterData.cs new file mode 100644 index 000000000..12bfb14b7 --- /dev/null +++ b/Mods/LookupAnything/Framework/Data/CharacterData.cs @@ -0,0 +1,22 @@ +namespace Pathoschild.Stardew.LookupAnything.Framework.Data +{ + /// Provides override metadata about a game NPC. + internal class CharacterData + { + /********* + ** Accessors + *********/ + /**** + ** Identify object + ****/ + /// The NPC identifier, like "Horse" (any NPCs of type Horse) or "Villager::Gunther" (any NPCs of type Villager with the name "Gunther"). + public string ID { get; set; } + + + /**** + ** Overrides + ****/ + /// The translation key which should override the NPC description (if any). + public string DescriptionKey { get; set; } + } +} diff --git a/Mods/LookupAnything/Framework/Data/ConstantData.cs b/Mods/LookupAnything/Framework/Data/ConstantData.cs new file mode 100644 index 000000000..d47a01b44 --- /dev/null +++ b/Mods/LookupAnything/Framework/Data/ConstantData.cs @@ -0,0 +1,98 @@ +using System.Collections.Generic; +using Pathoschild.Stardew.LookupAnything.Framework.Constants; + +namespace Pathoschild.Stardew.LookupAnything.Framework.Data +{ + /// Constant values hardcoded by the game. + internal class ConstantData + { + /********* + ** Accessors + *********/ + /**** + ** Farm animals + ****/ + /// The number of friendship points per level for a farm animal. + /// Derived from . + public int AnimalFriendshipPointsPerLevel { get; set; } + + /// The maximum number of friendship points for a farm animal. + /// Derived from . + public int AnimalFriendshipMaxPoints { get; set; } + + /// The maximum happiness points for a farm animal. + /// Derived from . + public int AnimalMaxHappiness { get; set; } + + /// The number of days until a fruit tree produces a better-quality fruit. + /// Derived from . + public int FruitTreeQualityGrowthTime { get; set; } + + /**** + ** NPCs + ****/ + /// The names of villagers with social data (e.g. birthdays or gift tastes). + public string[] AsocialVillagers { get; set; } + + /// The number of hearts for dateable NPCs which are locked until you give them a bouquet. + public int DatingHearts { get; set; } + + /// The maximum friendship points for a married NPC. + public int SpouseMaxFriendship { get; set; } + + /// The minimum friendship points with a married NPC before they give the player a stardrop. + public int SpouseFriendshipForStardrop { get; set; } + + /**** + ** Players + ****/ + /// The maximum experience points for a skill. + /// Derived from . + public int PlayerMaxSkillPoints { get; set; } + + /// The experience points needed for each skill level. + /// Derived from . + public int[] PlayerSkillPointsPerLevel { get; set; } + + /**** + ** Time + ****/ + /// The number of days in each season. + public int DaysInSeason { get; set; } + + /// The fractional rate at which fences decay (calculated as minutes divided by this value). + /// Derived from . + public float FenceDecayRate { get; set; } + + /**** + ** Crafting + ****/ + /// The age thresholds for casks. + /// Derived from . + public IDictionary CaskAgeSchedule { get; set; } + + /**** + ** Items + ****/ + /// Items which can have an iridium quality. This is a list of category IDs (negative) or item IDs (positive). + /// + /// The following can have iridium quality: + /// • animal produce; + /// • fruit tree produce; + /// • artisanal products aged in the cask (derived from ); + /// • forage crops. + /// + public int[] ItemsWithIridiumQuality { get; set; } + + /**** + ** Achievements + ****/ + /// The crops that must be shipped for the polyculture achievement. + /// Derived from . + public int[] PolycultureCrops { get; set; } + + /// The number of each crop that must be shipped for the polyculture achievement. + /// Derived from . + public int PolycultureCount { get; set; } + } +} diff --git a/Mods/LookupAnything/Framework/Data/ItemDropData.cs b/Mods/LookupAnything/Framework/Data/ItemDropData.cs new file mode 100644 index 000000000..b0797712e --- /dev/null +++ b/Mods/LookupAnything/Framework/Data/ItemDropData.cs @@ -0,0 +1,33 @@ +namespace Pathoschild.Stardew.LookupAnything.Framework.Data +{ + /// A loot entry parsed from the game data. + internal class ItemDropData + { + /********* + ** Accessors + *********/ + /// The item's parent sprite index. + public int ItemID { get; } + + /// The maximum number to drop. + public int MaxDrop { get; } + + /// The probability that the item will be dropped. + public float Probability { get; } + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The item's parent sprite index. + /// The maximum number to drop. + /// The probability that the item will be dropped. + public ItemDropData(int itemID, int maxDrop, float probability) + { + this.ItemID = itemID; + this.MaxDrop = maxDrop; + this.Probability = probability; + } + } +} diff --git a/Mods/LookupAnything/Framework/Data/MachineRecipeData.cs b/Mods/LookupAnything/Framework/Data/MachineRecipeData.cs new file mode 100644 index 000000000..2c818df08 --- /dev/null +++ b/Mods/LookupAnything/Framework/Data/MachineRecipeData.cs @@ -0,0 +1,23 @@ +using System.Collections.Generic; + +namespace Pathoschild.Stardew.LookupAnything.Framework.Data +{ + /// Metadata for a machine recipe. + internal class MachineRecipeData + { + /********* + ** Accessors + *********/ + /// The machine item ID. + public int MachineID { get; set; } + + /// The items needed to craft the recipe (item ID => number needed). + public IDictionary Ingredients { get; set; } + + /// The ingredients which can't be used in this recipe (typically exceptions for a category ingredient). + public int[] ExceptIngredients { get; set; } + + /// The item created by the recipe. + public int Output { get; set; } + } +} diff --git a/Mods/LookupAnything/Framework/Data/MonsterData.cs b/Mods/LookupAnything/Framework/Data/MonsterData.cs new file mode 100644 index 000000000..18324b91b --- /dev/null +++ b/Mods/LookupAnything/Framework/Data/MonsterData.cs @@ -0,0 +1,82 @@ +using System.Collections.Generic; +using System.Linq; + +namespace Pathoschild.Stardew.LookupAnything.Framework.Data +{ + /// A monster entry parsed from the game data. + internal class MonsterData + { + /********* + ** Accessors + *********/ + /// The monster name. + public string Name { get; } + + /// The monster's health points. + public int Health { get; } + + /// The damage points the monster afflicts on the player. + public int DamageToFarmer { get; } + + /// Whether the monster can fly. + public bool IsGlider { get; } + + /// The amount of time between random movement changes (in milliseconds). + public int DurationOfRandomMovements { get; } + + /// The monster's damage resistance. (This amount is subtracted from received damage points.) + public int Resilience { get; } + + /// The probability that a monster will randomly change direction when checked. + public double Jitteriness { get; } + + /// The tile distance within which the monster will begin moving towards the player. + public int MoveTowardsPlayerThreshold { get; } + + /// The speed at which the monster moves. + public int Speed { get; } + + /// The probability that the player will miss when attacking this monster. + public double MissChance { get; } + + /// Whether the monster appears in the mines. If true, the monster's base stats are increased once the player has reached the bottom of the mine at least once. + public bool IsMineMonster { get; } + + /// The items dropped by this monster and their probability to drop. + public ItemDropData[] Drops { get; } + + + /********* + ** public methods + *********/ + /// Construct an instance. + /// The monster name. + /// The monster's health points. + /// The damage points the monster afflicts on the player. + /// Whether the monster can fly. + /// The amount of time between random movement changes (in milliseconds). + /// The amount of time between random movement changes (in milliseconds). + /// The monster's damage resistance. + /// The probability that a monster will randomly change direction when checked. + /// The tile distance within which the monster will begin moving towards the player. + /// The speed at which the monster moves. + /// The probability that the player will miss when attacking this monster. + /// Whether the monster appears in the mines. + /// The items dropped by this monster and their probability to drop. + public MonsterData(string name, int health, int damageToFarmer, bool isGlider, int durationOfRandomMovements, int resilience, double jitteriness, int moveTowardsPlayerThreshold, int speed, double missChance, bool isMineMonster, IEnumerable drops) + { + this.Name = name; + this.Health = health; + this.DamageToFarmer = damageToFarmer; + this.IsGlider = isGlider; + this.DurationOfRandomMovements = durationOfRandomMovements; + this.Resilience = resilience; + this.Jitteriness = jitteriness; + this.MoveTowardsPlayerThreshold = moveTowardsPlayerThreshold; + this.Speed = speed; + this.MissChance = missChance; + this.IsMineMonster = isMineMonster; + this.Drops = drops.ToArray(); + } + } +} diff --git a/Mods/LookupAnything/Framework/Data/ObjectContext.cs b/Mods/LookupAnything/Framework/Data/ObjectContext.cs new file mode 100644 index 000000000..a6b79bf34 --- /dev/null +++ b/Mods/LookupAnything/Framework/Data/ObjectContext.cs @@ -0,0 +1,18 @@ +using System; + +namespace Pathoschild.Stardew.LookupAnything.Framework.Data +{ + /// The context in which to override an object. + [Flags] + internal enum ObjectContext + { + /// Objects in the world. + World = 1, + + /// Objects in an item inventory. + Inventory = 2, + + /// Objects in any context. + Any = ObjectContext.World | ObjectContext.Inventory + } +} diff --git a/Mods/LookupAnything/Framework/Data/ObjectData.cs b/Mods/LookupAnything/Framework/Data/ObjectData.cs new file mode 100644 index 000000000..66c382eda --- /dev/null +++ b/Mods/LookupAnything/Framework/Data/ObjectData.cs @@ -0,0 +1,38 @@ +using Pathoschild.Stardew.LookupAnything.Framework.Constants; + +namespace Pathoschild.Stardew.LookupAnything.Framework.Data +{ + /// Provides override metadata about a game item. + internal class ObjectData + { + /********* + ** Accessors + *********/ + /**** + ** Identify object + ****/ + /// The context in which to override the object. + public ObjectContext Context { get; set; } = ObjectContext.Any; + + /// The sprite sheet used to draw the object. A given sprite ID can be duplicated between two sprite sheets. + public ItemSpriteType SpriteSheet { get; set; } = ItemSpriteType.Object; + + /// The sprite IDs for this object. + public int[] SpriteID { get; set; } + + /**** + ** Overrides + ****/ + /// The translation key which should override the item name (if any). + public string NameKey { get; set; } + + /// The translation key which should override the item description (if any). + public string DescriptionKey { get; set; } + + /// The translation key which should override the item type name (if any). + public string TypeKey { get; set; } + + /// Whether the player can pick up this item. + public bool? ShowInventoryFields { get; set; } + } +} diff --git a/Mods/LookupAnything/Framework/Data/ShopData.cs b/Mods/LookupAnything/Framework/Data/ShopData.cs new file mode 100644 index 000000000..44e319ac3 --- /dev/null +++ b/Mods/LookupAnything/Framework/Data/ShopData.cs @@ -0,0 +1,18 @@ +namespace Pathoschild.Stardew.LookupAnything.Framework.Data +{ + /// Metadata for a shop that isn't available from the game data directly. + internal class ShopData + { + /********* + ** Accessors + *********/ + /// The internal name of the shop's indoor location. + public string LocationName { get; set; } + + /// The translation key for the shop name. + public string DisplayKey { get; set; } + + /// The categories of items that the player can sell to this shop. + public int[] BuysCategories { get; set; } + } +} diff --git a/Mods/LookupAnything/Framework/DebugFields/GenericDebugField.cs b/Mods/LookupAnything/Framework/DebugFields/GenericDebugField.cs new file mode 100644 index 000000000..7b16d10d3 --- /dev/null +++ b/Mods/LookupAnything/Framework/DebugFields/GenericDebugField.cs @@ -0,0 +1,56 @@ +using System.Globalization; + +namespace Pathoschild.Stardew.LookupAnything.Framework.DebugFields +{ + /// A generic debug field containing a raw datamining value. + internal class GenericDebugField : IDebugField + { + /********* + ** Accessors + *********/ + /// A short field label. + public string Label { get; protected set; } + + /// The field value. + public string Value { get; protected set; } + + /// Whether the field should be displayed. + public bool HasValue { get; protected set; } + + /// Whether the field should be highlighted for special attention. + public bool IsPinned { get; protected set; } + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// A short field label. + /// The field value. + /// Whether the field should be displayed (or null to check the ). + /// Whether the field should be highlighted for special attention. + public GenericDebugField(string label, string value, bool? hasValue = null, bool pinned = false) + { + this.Label = label; + this.Value = value; + this.HasValue = hasValue ?? !string.IsNullOrWhiteSpace(this.Value); + this.IsPinned = pinned; + } + + /// Construct an instance. + /// A short field label. + /// The field value. + /// Whether the field should be displayed (or null to check the ). + /// Whether the field should be highlighted for special attention. + public GenericDebugField(string label, int value, bool? hasValue = null, bool pinned = false) + : this(label, value.ToString(CultureInfo.InvariantCulture), hasValue, pinned) { } + + /// Construct an instance. + /// A short field label. + /// The field value. + /// Whether the field should be displayed (or null to check the ). + /// Whether the field should be highlighted for special attention. + public GenericDebugField(string label, float value, bool? hasValue = null, bool pinned = false) + : this(label, value.ToString(CultureInfo.InvariantCulture), hasValue, pinned) { } + } +} diff --git a/Mods/LookupAnything/Framework/DebugFields/IDebugField.cs b/Mods/LookupAnything/Framework/DebugFields/IDebugField.cs new file mode 100644 index 000000000..db20e4672 --- /dev/null +++ b/Mods/LookupAnything/Framework/DebugFields/IDebugField.cs @@ -0,0 +1,21 @@ +namespace Pathoschild.Stardew.LookupAnything.Framework.DebugFields +{ + /// A debug field containing a raw datamining value. + internal interface IDebugField + { + /********* + ** Accessors + *********/ + /// A short field label. + string Label { get; } + + /// The field value. + string Value { get; } + + /// Whether the field should be displayed. + bool HasValue { get; } + + /// Whether the field should be highlighted for special attention. + bool IsPinned { get; } + } +} diff --git a/Mods/LookupAnything/Framework/Fields/CharacterFriendshipField.cs b/Mods/LookupAnything/Framework/Fields/CharacterFriendshipField.cs new file mode 100644 index 000000000..f346736a1 --- /dev/null +++ b/Mods/LookupAnything/Framework/Fields/CharacterFriendshipField.cs @@ -0,0 +1,116 @@ +using System; +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Graphics; +using Pathoschild.Stardew.LookupAnything.Components; +using Pathoschild.Stardew.LookupAnything.Framework.Constants; +using Pathoschild.Stardew.LookupAnything.Framework.Models; +using StardewModdingAPI; +using StardewValley; + +namespace Pathoschild.Stardew.LookupAnything.Framework.Fields +{ + /// A metadata field which shows friendship points. + internal class CharacterFriendshipField : GenericField + { + /********* + ** Fields + *********/ + /// The player's current friendship data with the NPC. + private readonly FriendshipModel Friendship; + + /// Provides translations stored in the mod folder. + private readonly ITranslationHelper Translations; + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// Provides utility methods for interacting with the game code. + /// A short field label. + /// The player's current friendship data with the NPC. + /// Provides translations stored in the mod folder. + public CharacterFriendshipField(GameHelper gameHelper, string label, FriendshipModel friendship, ITranslationHelper translations) + : base(gameHelper, label, hasValue: true) + { + this.Friendship = friendship; + this.Translations = translations; + } + + /// Draw the value (or return null to render the using the default format). + /// The sprite batch being drawn. + /// The recommended font. + /// The position at which to draw. + /// The maximum width before which content should be wrapped. + /// Returns the drawn dimensions, or null to draw the using the default format. + public override Vector2? DrawValue(SpriteBatch spriteBatch, SpriteFont font, Vector2 position, float wrapWidth) + { + FriendshipModel friendship = this.Friendship; + + // draw status + float leftOffset = 0; + { + string statusText = this.Translations.Get(L10n.For(friendship.Status)); + Vector2 textSize = spriteBatch.DrawTextBlock(font, statusText, new Vector2(position.X + leftOffset, position.Y), wrapWidth - leftOffset); + leftOffset += textSize.X + DrawHelper.GetSpaceWidth(font); + } + + // draw hearts + for (int i = 0; i < friendship.TotalHearts; i++) + { + // get icon + Color color; + Rectangle icon; + if (friendship.LockedHearts >= friendship.TotalHearts - i) + { + icon = Sprites.Icons.FilledHeart; + color = Color.Black * 0.35f; + } + else if (i >= friendship.FilledHearts) + { + icon = Sprites.Icons.EmptyHeart; + color = Color.White; + } + else + { + icon = Sprites.Icons.FilledHeart; + color = Color.White; + } + + // draw + spriteBatch.DrawSprite(Sprites.Icons.Sheet, icon, position.X + leftOffset, position.Y, color, Game1.pixelZoom); + leftOffset += Sprites.Icons.FilledHeart.Width * Game1.pixelZoom; + } + + // draw stardrop (if applicable) + if (friendship.HasStardrop) + { + leftOffset += 1; + float zoom = (Sprites.Icons.EmptyHeart.Height / (Sprites.Icons.Stardrop.Height * 1f)) * Game1.pixelZoom; + spriteBatch.DrawSprite(Sprites.Icons.Sheet, Sprites.Icons.Stardrop, position.X + leftOffset, position.Y, Color.White * 0.25f, zoom); + leftOffset += Sprites.Icons.Stardrop.Width * zoom; + } + + // get caption text + string caption = null; + if (this.Friendship.EmptyHearts == 0 && this.Friendship.LockedHearts > 0) + caption = $"({this.Translations.Get(L10n.Npc.FriendshipNeedBouquet)})"; + else + { + int pointsToNext = this.Friendship.GetPointsToNext(); + if (pointsToNext > 0) + caption = $"({this.Translations.Get(L10n.Npc.FriendshipNeedPoints, new { count = pointsToNext })})"; + } + + // draw caption + { + float spaceSize = DrawHelper.GetSpaceWidth(font); + Vector2 textSize = Vector2.Zero; + if (caption != null) + textSize = spriteBatch.DrawTextBlock(font, caption, new Vector2(position.X + leftOffset + spaceSize, position.Y), wrapWidth - leftOffset); + + return new Vector2(Sprites.Icons.FilledHeart.Width * Game1.pixelZoom * this.Friendship.TotalHearts + textSize.X + spaceSize, Math.Max(Sprites.Icons.FilledHeart.Height * Game1.pixelZoom, textSize.Y)); + } + } + } +} diff --git a/Mods/LookupAnything/Framework/Fields/CharacterGiftTastesField.cs b/Mods/LookupAnything/Framework/Fields/CharacterGiftTastesField.cs new file mode 100644 index 000000000..1974fe1ff --- /dev/null +++ b/Mods/LookupAnything/Framework/Fields/CharacterGiftTastesField.cs @@ -0,0 +1,66 @@ +using System.Collections.Generic; +using System.Linq; +using Microsoft.Xna.Framework; +using Pathoschild.Stardew.LookupAnything.Framework.Constants; +using StardewValley; + +namespace Pathoschild.Stardew.LookupAnything.Framework.Fields +{ + /// A metadata field which shows which items an NPC likes receiving. + internal class CharacterGiftTastesField : GenericField + { + /********* + ** Public methods + *********/ + /// Construct an instance. + /// Provides utility methods for interacting with the game code. + /// A short field label. + /// The items by how much this NPC likes receiving them. + /// The gift taste to show. + public CharacterGiftTastesField(GameHelper gameHelper, string label, IDictionary giftTastes, GiftTaste showTaste) + : base(gameHelper, label, CharacterGiftTastesField.GetText(gameHelper, giftTastes, showTaste)) { } + + + /********* + ** Private methods + *********/ + /// Get the text to display. + /// Provides utility methods for interacting with the game code. + /// The items by how much this NPC likes receiving them. + /// The gift taste to show. + private static IEnumerable GetText(GameHelper gameHelper, IDictionary giftTastes, GiftTaste showTaste) + { + if (!giftTastes.ContainsKey(showTaste)) + yield break; + + // get item data + Item[] ownedItems = gameHelper.GetAllOwnedItems().ToArray(); + Item[] inventory = Game1.player.Items.Where(p => p != null).ToArray(); + var items = + ( + from item in giftTastes[showTaste] + let isInventory = inventory.Any(p => p.ParentSheetIndex == item.ParentSheetIndex && p.Category == item.Category) + let isOwned = ownedItems.Any(p => p.ParentSheetIndex == item.ParentSheetIndex && p.Category == item.Category) + orderby isInventory descending, isOwned descending, item.DisplayName + select new { Item = item, IsInventory = isInventory, IsOwned = isOwned } + ) + .ToArray(); + + // generate text + for (int i = 0, last = items.Length - 1; i <= last; i++) + { + var entry = items[i]; + string text = i != last + ? entry.Item.DisplayName + "," + : entry.Item.DisplayName; + + if (entry.IsInventory) + yield return new FormattedText(text, Color.Green); + else if (entry.IsOwned) + yield return new FormattedText(text, Color.Black); + else + yield return new FormattedText(text, Color.Gray); + } + } + } +} diff --git a/Mods/LookupAnything/Framework/Fields/CheckboxListField.cs b/Mods/LookupAnything/Framework/Fields/CheckboxListField.cs new file mode 100644 index 000000000..a226e04e7 --- /dev/null +++ b/Mods/LookupAnything/Framework/Fields/CheckboxListField.cs @@ -0,0 +1,72 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Graphics; +using Pathoschild.Stardew.LookupAnything.Components; +using StardewValley; + +namespace Pathoschild.Stardew.LookupAnything.Framework.Fields +{ + /// A metadata field which shows a list of checkbox values. + internal class CheckboxListField : GenericField + { + /********* + ** Fields + *********/ + /// The checkbox values to display. + private readonly KeyValuePair[] Checkboxes; + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// Provides utility methods for interacting with the game code. + /// A short field label. + /// The checkbox labels and values to display. + public CheckboxListField(GameHelper gameHelper, string label, IEnumerable> checkboxes) + : base(gameHelper, label, hasValue: true) + { + this.Checkboxes = checkboxes.ToArray(); + } + + /// Draw the value (or return null to render the using the default format). + /// The sprite batch being drawn. + /// The recommended font. + /// The position at which to draw. + /// The maximum width before which content should be wrapped. + /// Returns the drawn dimensions, or null to draw the using the default format. + public override Vector2? DrawValue(SpriteBatch spriteBatch, SpriteFont font, Vector2 position, float wrapWidth) + { + float topOffset = 0; + float checkboxSize = Sprites.Icons.FilledCheckbox.Width * (Game1.pixelZoom / 2); + float lineHeight = Math.Max(checkboxSize, Game1.smallFont.MeasureString("ABC").Y); + float checkboxOffset = (lineHeight - checkboxSize) / 2; + + foreach (KeyValuePair entry in this.Checkboxes) + { + // draw icon + spriteBatch.Draw( + texture: Sprites.Icons.Sheet, + position: new Vector2(position.X, position.Y + topOffset + checkboxOffset), + sourceRectangle: entry.Value ? Sprites.Icons.FilledCheckbox : Sprites.Icons.EmptyCheckbox, + color: Color.White, + rotation: 0, + origin: Vector2.Zero, + scale: checkboxSize / Sprites.Icons.FilledCheckbox.Width, + effects: SpriteEffects.None, + layerDepth: 1f + ); + + // draw text + Vector2 textSize = spriteBatch.DrawTextBlock(Game1.smallFont, entry.Key, new Vector2(position.X + checkboxSize + 7, position.Y + topOffset), wrapWidth - checkboxSize - 7); + + // update offset + topOffset += Math.Max(checkboxSize, textSize.Y); + } + + return new Vector2(wrapWidth, topOffset); + } + } +} diff --git a/Mods/LookupAnything/Framework/Fields/DataMiningField.cs b/Mods/LookupAnything/Framework/Fields/DataMiningField.cs new file mode 100644 index 000000000..a8abb09cd --- /dev/null +++ b/Mods/LookupAnything/Framework/Fields/DataMiningField.cs @@ -0,0 +1,47 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.Xna.Framework; +using Pathoschild.Stardew.LookupAnything.Framework.DebugFields; + +namespace Pathoschild.Stardew.LookupAnything.Framework.Fields +{ + /// Shows a collection of debug fields. + internal class DataMiningField : GenericField + { + /********* + ** Public methods + *********/ + /// Construct an instance. + /// Provides utility methods for interacting with the game code. + /// A short field label. + /// The debug fields to display. + public DataMiningField(GameHelper gameHelper, string label, IEnumerable fields) + : base(gameHelper, label) + { + IDebugField[] fieldArray = fields?.ToArray() ?? new IDebugField[0]; + this.HasValue = fieldArray.Any(); + if (this.HasValue) + this.Value = this.GetFormattedText(fieldArray).ToArray(); + } + + + /********* + ** Private methods + *********/ + /// Get a formatted representation of a set of debug fields. + /// The debug fields to display. + private IEnumerable GetFormattedText(IDebugField[] fields) + { + for (int i = 0, last = fields.Length - 1; i <= last; i++) + { + IDebugField field = fields[i]; + yield return new FormattedText("*", Color.Red, bold: true); + yield return new FormattedText($"{field.Label}:"); + yield return i != last + ? new FormattedText($"{field.Value}{Environment.NewLine}") + : new FormattedText(field.Value); + } + } + } +} diff --git a/Mods/LookupAnything/Framework/Fields/GenericField.cs b/Mods/LookupAnything/Framework/Fields/GenericField.cs new file mode 100644 index 000000000..f1004d82a --- /dev/null +++ b/Mods/LookupAnything/Framework/Fields/GenericField.cs @@ -0,0 +1,147 @@ +using System.Collections.Generic; +using System.Linq; +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Graphics; +using Pathoschild.Stardew.LookupAnything.Framework.Constants; +using StardewModdingAPI; + +namespace Pathoschild.Stardew.LookupAnything.Framework.Fields +{ + /// A generic metadata field shown as an extended property in the lookup UI. + internal class GenericField : ICustomField + { + /********* + ** Accessors + *********/ + /// Provides utility methods for interacting with the game code. + protected GameHelper GameHelper; + + + /********* + ** Accessors + *********/ + /// A short field label. + public string Label { get; protected set; } + + /// The field value. + public IFormattedText[] Value { get; protected set; } + + /// Whether the field should be displayed. + public bool HasValue { get; protected set; } + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// Provides utility methods for interacting with the game code. + /// A short field label. + /// The field value. + /// Whether the field should be displayed (or null to check the ). + public GenericField(GameHelper gameHelper, string label, string value, bool? hasValue = null) + { + this.GameHelper = gameHelper; + this.Label = label; + this.Value = this.FormatValue(value); + this.HasValue = hasValue ?? this.Value?.Any() == true; + } + + /// Construct an instance. + /// Provides utility methods for interacting with the game code. + /// A short field label. + /// The field value. + /// Whether the field should be displayed (or null to check the ). + public GenericField(GameHelper gameHelper, string label, IFormattedText value, bool? hasValue = null) + : this(gameHelper, label, new[] { value }, hasValue) { } + + /// Construct an instance. + /// Provides utility methods for interacting with the game code. + /// A short field label. + /// The field value. + /// Whether the field should be displayed (or null to check the ). + public GenericField(GameHelper gameHelper, string label, IEnumerable value, bool? hasValue = null) + { + this.GameHelper = gameHelper; + this.Label = label; + this.Value = value.ToArray(); + this.HasValue = hasValue ?? this.Value?.Any() == true; + } + + /// Draw the value (or return null to render the using the default format). + /// The sprite batch being drawn. + /// The recommended font. + /// The position at which to draw. + /// The maximum width before which content should be wrapped. + /// Returns the drawn dimensions, or null to draw the using the default format. + public virtual Vector2? DrawValue(SpriteBatch spriteBatch, SpriteFont font, Vector2 position, float wrapWidth) + { + return null; + } + + + /********* + ** Protected methods + *********/ + /// Construct an instance. + /// Provides utility methods for interacting with the game code. + /// A short field label. + /// Whether the field should be displayed. + protected GenericField(GameHelper gameHelper, string label, bool hasValue = false) + : this(gameHelper, label, null as string, hasValue) { } + + /// Wrap text into a list of formatted snippets. + /// The text to wrap. + protected IFormattedText[] FormatValue(string value) + { + return !string.IsNullOrWhiteSpace(value) + ? new IFormattedText[] { new FormattedText(value) } + : new IFormattedText[0]; + } + + /// Get the display value for sale price data. + /// The flat sale price. + /// The number of items in the stack. + /// Provides translations stored in the mod folder. + public static string GetSaleValueString(int saleValue, int stackSize, ITranslationHelper translations) + { + return GenericField.GetSaleValueString(new Dictionary { [ItemQuality.Normal] = saleValue }, stackSize, translations); + } + + /// Get the display value for sale price data. + /// The sale price data. + /// The number of items in the stack. + /// Provides methods for fetching translations and generating text. + public static string GetSaleValueString(IDictionary saleValues, int stackSize, ITranslationHelper translations) + { + // can't be sold + if (saleValues == null || !saleValues.Any() || saleValues.Values.All(p => p == 0)) + return null; + + // one quality + if (saleValues.Count == 1) + { + string result = translations.Get(L10n.Generic.Price, new { price = saleValues.First().Value }); + if (stackSize > 1 && stackSize <= Constant.MaxStackSizeForPricing) + result += $" ({translations.Get(L10n.Generic.PriceForStack, new { price = saleValues.First().Value * stackSize, count = stackSize })})"; + return result; + } + + // prices by quality + List priceStrings = new List(); + for (ItemQuality quality = ItemQuality.Normal; ; quality = quality.GetNext()) + { + if (saleValues.ContainsKey(quality)) + { + priceStrings.Add(quality == ItemQuality.Normal + ? translations.Get(L10n.Generic.Price, new { price = saleValues[quality] }) + : translations.Get(L10n.Generic.PriceForQuality, new { price = saleValues[quality], quality = translations.Get(L10n.For(quality)) }) + ); + } + + if (quality.GetNext() == quality) + break; + } + return string.Join(", ", priceStrings); + } + } +} diff --git a/Mods/LookupAnything/Framework/Fields/ICustomField.cs b/Mods/LookupAnything/Framework/Fields/ICustomField.cs new file mode 100644 index 000000000..0477567da --- /dev/null +++ b/Mods/LookupAnything/Framework/Fields/ICustomField.cs @@ -0,0 +1,33 @@ +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Graphics; + +namespace Pathoschild.Stardew.LookupAnything.Framework.Fields +{ + /// A metadata field shown as an extended property in the lookup UI. + internal interface ICustomField + { + /********* + ** Accessors + *********/ + /// A short field label. + string Label { get; } + + /// The field value. + IFormattedText[] Value { get; } + + /// Whether the field should be displayed. + bool HasValue { get; } + + + /********* + ** Public methods + *********/ + /// Draw the value (or return null to render the using the default format). + /// The sprite batch being drawn. + /// The recommended font. + /// The position at which to draw. + /// The maximum width before which content should be wrapped. + /// Returns the drawn dimensions, or null to draw the using the default format. + Vector2? DrawValue(SpriteBatch spriteBatch, SpriteFont font, Vector2 position, float wrapWidth); + } +} diff --git a/Mods/LookupAnything/Framework/Fields/ILinkField.cs b/Mods/LookupAnything/Framework/Fields/ILinkField.cs new file mode 100644 index 000000000..388ebc34e --- /dev/null +++ b/Mods/LookupAnything/Framework/Fields/ILinkField.cs @@ -0,0 +1,14 @@ +using Pathoschild.Stardew.LookupAnything.Framework.Subjects; + +namespace Pathoschild.Stardew.LookupAnything.Framework.Fields +{ + /// A field which links to another entry. + internal interface ILinkField : ICustomField + { + /********* + ** Public methods + *********/ + /// Get the subject the link points to. + ISubject GetLinkSubject(); + } +} diff --git a/Mods/LookupAnything/Framework/Fields/ItemDropListField.cs b/Mods/LookupAnything/Framework/Fields/ItemDropListField.cs new file mode 100644 index 000000000..dc3e0b90d --- /dev/null +++ b/Mods/LookupAnything/Framework/Fields/ItemDropListField.cs @@ -0,0 +1,116 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Graphics; +using Pathoschild.Stardew.Common; +using Pathoschild.Stardew.LookupAnything.Framework.Constants; +using Pathoschild.Stardew.LookupAnything.Framework.Data; +using StardewModdingAPI; +using StardewValley; +using StardewValley.Objects; +using SObject = StardewValley.Object; + +namespace Pathoschild.Stardew.LookupAnything.Framework.Fields +{ + /// A metadata field which shows a list of item drops. + internal class ItemDropListField : GenericField + { + /********* + ** Fields + *********/ + /// The possible drops. + private readonly Tuple[] Drops; + + /// The text to display if there are no items. + private readonly string DefaultText; + + /// Provides translations stored in the mod folder. + private readonly ITranslationHelper Translations; + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// Provides utility methods for interacting with the game code. + /// A short field label. + /// The possible drops. + /// Provides translations stored in the mod folder. + /// The text to display if there are no items (or null to hide the field). + public ItemDropListField(GameHelper gameHelper, string label, IEnumerable drops, ITranslationHelper translations, string defaultText = null) + : base(gameHelper, label) + { + this.Drops = this + .GetEntries(drops, gameHelper) + .OrderByDescending(p => p.Item1.Probability) + .ThenBy(p => p.Item2.DisplayName) + .ToArray(); + this.DefaultText = defaultText; + this.HasValue = defaultText != null || this.Drops.Any(); + this.Translations = translations; + } + + /// Draw the value (or return null to render the using the default format). + /// The sprite batch being drawn. + /// The recommended font. + /// The position at which to draw. + /// The maximum width before which content should be wrapped. + /// Returns the drawn dimensions, or null to draw the using the default format. + public override Vector2? DrawValue(SpriteBatch spriteBatch, SpriteFont font, Vector2 position, float wrapWidth) + { + if (!this.Drops.Any()) + return spriteBatch.DrawTextBlock(font, this.DefaultText, position, wrapWidth); + + // get icon size + Vector2 iconSize = new Vector2(font.MeasureString("ABC").Y); + + // list drops + bool canReroll = Game1.player.isWearingRing(Ring.burglarsRing); + float height = 0; + foreach (var entry in this.Drops) + { + // get data + ItemDropData drop = entry.Item1; + SObject item = entry.Item2; + SpriteInfo sprite = entry.Item3; + bool isGuaranteed = drop.Probability > .99f; + + // draw icon + spriteBatch.DrawSpriteWithin(sprite, position.X, position.Y + height, iconSize, isGuaranteed ? Color.White : Color.White * 0.5f); + + // draw text + string text = isGuaranteed ? item.DisplayName : this.Translations.Get(L10n.Generic.PercentChanceOf, new { percent = Math.Round(drop.Probability, 4) * 100, label = item.DisplayName }); + if (drop.MaxDrop > 1) + text += " (" + this.Translations.Get(L10n.Generic.Range, new { min = 1, max = drop.MaxDrop }) + ")"; + Vector2 textSize = spriteBatch.DrawTextBlock(font, text, position + new Vector2(iconSize.X + 5, height + 5), wrapWidth, isGuaranteed ? Color.Black : Color.Gray); + + // cross out item if it definitely won't drop + if (!isGuaranteed && !canReroll) + spriteBatch.DrawLine(position.X + iconSize.X + 5, position.Y + height + iconSize.Y / 2, new Vector2(textSize.X, 1), Color.Gray); + + height += textSize.Y + 5; + } + + // return size + return new Vector2(wrapWidth, height); + } + + + /********* + ** Private methods + *********/ + /// Get the internal drop list entries. + /// The possible drops. + /// Provides utility methods for interacting with the game code. + private IEnumerable> GetEntries(IEnumerable drops, GameHelper gameHelper) + { + foreach (ItemDropData drop in drops) + { + SObject item = this.GameHelper.GetObjectBySpriteIndex(drop.ItemID); + SpriteInfo sprite = gameHelper.GetSprite(item); + yield return Tuple.Create(drop, item, sprite); + } + } + } +} diff --git a/Mods/LookupAnything/Framework/Fields/ItemGiftTastesField.cs b/Mods/LookupAnything/Framework/Fields/ItemGiftTastesField.cs new file mode 100644 index 000000000..13dd15bcb --- /dev/null +++ b/Mods/LookupAnything/Framework/Fields/ItemGiftTastesField.cs @@ -0,0 +1,37 @@ +using System.Collections.Generic; +using System.Linq; +using Pathoschild.Stardew.LookupAnything.Framework.Constants; + +namespace Pathoschild.Stardew.LookupAnything.Framework.Fields +{ + /// A metadata field which shows how much each NPC likes receiving this item. + internal class ItemGiftTastesField : GenericField + { + /********* + ** Public methods + *********/ + /// Construct an instance. + /// Provides utility methods for interacting with the game code. + /// A short field label. + /// NPCs by how much they like receiving this item. + /// The gift taste to show. + public ItemGiftTastesField(GameHelper gameHelper, string label, IDictionary giftTastes, GiftTaste showTaste) + : base(gameHelper, label, ItemGiftTastesField.GetText(giftTastes, showTaste)) { } + + + /********* + ** Private methods + *********/ + /// Get the text to display. + /// NPCs by how much they like receiving this item. + /// The gift taste to show. + private static string GetText(IDictionary giftTastes, GiftTaste showTaste) + { + if (!giftTastes.ContainsKey(showTaste)) + return null; + + string[] names = giftTastes[showTaste].OrderBy(p => p).ToArray(); + return string.Join(", ", names); + } + } +} diff --git a/Mods/LookupAnything/Framework/Fields/ItemIconField.cs b/Mods/LookupAnything/Framework/Fields/ItemIconField.cs new file mode 100644 index 000000000..9dff8ab4d --- /dev/null +++ b/Mods/LookupAnything/Framework/Fields/ItemIconField.cs @@ -0,0 +1,58 @@ +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Graphics; +using Pathoschild.Stardew.Common; +using StardewValley; + +namespace Pathoschild.Stardew.LookupAnything.Framework.Fields +{ + /// A metadata field which shows an item icon. + internal class ItemIconField : GenericField + { + /********* + ** Fields + *********/ + /// The item icon to draw. + private readonly SpriteInfo Sprite; + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// Provides utility methods for interacting with the game code. + /// A short field label. + /// The item for which to display an icon. + /// The text to display (if not the item name). + public ItemIconField(GameHelper gameHelper, string label, Item item, string text = null) + : base(gameHelper, label, hasValue: item != null) + { + this.Sprite = gameHelper.GetSprite(item); + if (item != null) + { + this.Value = !string.IsNullOrWhiteSpace(text) + ? this.FormatValue(text) + : this.FormatValue(item.DisplayName); + } + } + + /// Draw the value (or return null to render the using the default format). + /// The sprite batch being drawn. + /// The recommended font. + /// The position at which to draw. + /// The maximum width before which content should be wrapped. + /// Returns the drawn dimensions, or null to draw the using the default format. + public override Vector2? DrawValue(SpriteBatch spriteBatch, SpriteFont font, Vector2 position, float wrapWidth) + { + // get icon size + float textHeight = font.MeasureString("ABC").Y; + Vector2 iconSize = new Vector2(textHeight); + + // draw icon & text + spriteBatch.DrawSpriteWithin(this.Sprite, position.X, position.Y, iconSize); + Vector2 textSize = spriteBatch.DrawTextBlock(font, this.Value, position + new Vector2(iconSize.X + 5, 5), wrapWidth); + + // return size + return new Vector2(wrapWidth, textSize.Y + 5); + } + } +} diff --git a/Mods/LookupAnything/Framework/Fields/ItemIconListField.cs b/Mods/LookupAnything/Framework/Fields/ItemIconListField.cs new file mode 100644 index 000000000..0fa7161a0 --- /dev/null +++ b/Mods/LookupAnything/Framework/Fields/ItemIconListField.cs @@ -0,0 +1,81 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Graphics; +using Pathoschild.Stardew.Common; +using StardewValley; + +namespace Pathoschild.Stardew.LookupAnything.Framework.Fields +{ + /// A metadata field which shows a list of linked item names with icons. + internal class ItemIconListField : GenericField + { + /********* + ** Fields + *********/ + /// The items to draw. + private readonly Tuple[] Items; + + /// Whether to draw the stack size on the item icon. + private readonly bool ShowStackSize; + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// Provides utility methods for interacting with the game code. + /// A short field label. + /// The items to display. + /// Whether to draw the stack size on the item icon. + public ItemIconListField(GameHelper gameHelper, string label, IEnumerable items, bool showStackSize) + : base(gameHelper, label, hasValue: items != null) + { + if (items == null) + return; + + this.Items = items.Where(p => p != null).Select(item => Tuple.Create(item, gameHelper.GetSprite(item))).ToArray(); + this.HasValue = this.Items.Any(); + this.ShowStackSize = showStackSize; + } + + /// Draw the value (or return null to render the using the default format). + /// The sprite batch being drawn. + /// The recommended font. + /// The position at which to draw. + /// The maximum width before which content should be wrapped. + /// Returns the drawn dimensions, or null to draw the using the default format. + public override Vector2? DrawValue(SpriteBatch spriteBatch, SpriteFont font, Vector2 position, float wrapWidth) + { + // get icon size + float textHeight = font.MeasureString("ABC").Y; + Vector2 iconSize = new Vector2(textHeight); + + // draw list + const int padding = 5; + int topOffset = 0; + foreach (Tuple entry in this.Items) + { + Item item = entry.Item1; + SpriteInfo sprite = entry.Item2; + + // draw icon + spriteBatch.DrawSpriteWithin(sprite, position.X, position.Y + topOffset, iconSize); + if (this.ShowStackSize && item.Stack > 1) + { + float scale = 2f; //sprite.SourceRectangle.Width / iconSize.X; + Vector2 sizePos = position + new Vector2(iconSize.X - Utility.getWidthOfTinyDigitString(item.Stack, scale), iconSize.Y + topOffset - 6f * scale); + Utility.drawTinyDigits(item.Stack, spriteBatch, sizePos, scale: scale, layerDepth: 1f, Color.White); + } + + Vector2 textSize = spriteBatch.DrawTextBlock(font, item.DisplayName, position + new Vector2(iconSize.X + padding, topOffset), wrapWidth); + + topOffset += (int)Math.Max(iconSize.Y, textSize.Y) + padding; + } + + // return size + return new Vector2(wrapWidth, topOffset + padding); + } + } +} diff --git a/Mods/LookupAnything/Framework/Fields/LinkField.cs b/Mods/LookupAnything/Framework/Fields/LinkField.cs new file mode 100644 index 000000000..b28e0526d --- /dev/null +++ b/Mods/LookupAnything/Framework/Fields/LinkField.cs @@ -0,0 +1,37 @@ +using System; +using Microsoft.Xna.Framework; +using Pathoschild.Stardew.LookupAnything.Framework.Subjects; + +namespace Pathoschild.Stardew.LookupAnything.Framework.Fields +{ + /// A metadata field containing clickable links. + internal class LinkField : GenericField, ILinkField + { + /********* + ** Fields + *********/ + /// Gets the subject the link points to. + private readonly Func Subject; + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// Provides utility methods for interacting with the game code. + /// A short field label. + /// The link text. + /// Gets the subject the link points to. + public LinkField(GameHelper gameHelper, string label, string text, Func subject) + : base(gameHelper, label, new FormattedText(text, Color.Blue)) + { + this.Subject = subject; + } + + /// Get the subject the link points to. + public ISubject GetLinkSubject() + { + return this.Subject(); + } + } +} diff --git a/Mods/LookupAnything/Framework/Fields/PercentageBarField.cs b/Mods/LookupAnything/Framework/Fields/PercentageBarField.cs new file mode 100644 index 000000000..794d4d8e6 --- /dev/null +++ b/Mods/LookupAnything/Framework/Fields/PercentageBarField.cs @@ -0,0 +1,94 @@ +using System; +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Graphics; +using Pathoschild.Stardew.LookupAnything.Components; + +namespace Pathoschild.Stardew.LookupAnything.Framework.Fields +{ + /// A metadata field which shows a progress bar UI. + internal class PercentageBarField : GenericField + { + /********* + ** Fields + *********/ + /// The current progress value. + protected readonly int CurrentValue; + + /// The maximum progress value. + protected readonly int MaxValue; + + /// The text to show next to the progress bar (if any). + protected readonly string Text; + + /// The color of the filled bar. + protected readonly Color FilledColor; + + /// The color of the empty bar. + protected readonly Color EmptyColor; + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// Provides utility methods for interacting with the game code. + /// A short field label. + /// The current progress value. + /// The maximum progress value. + /// The color of the filled bar. + /// The color of the empty bar. + /// The text to show next to the progress bar (if any). + public PercentageBarField(GameHelper gameHelper, string label, int currentValue, int maxValue, Color filledColor, Color emptyColor, string text) + : base(gameHelper, label, hasValue: true) + { + this.CurrentValue = currentValue; + this.MaxValue = maxValue; + this.FilledColor = filledColor; + this.EmptyColor = emptyColor; + this.Text = text; + } + + /// Draw the value (or return null to render the using the default format). + /// The sprite batch being drawn. + /// The recommended font. + /// The position at which to draw. + /// The maximum width before which content should be wrapped. + /// Returns the drawn dimensions, or null to draw the using the default format. + public override Vector2? DrawValue(SpriteBatch spriteBatch, SpriteFont font, Vector2 position, float wrapWidth) + { + Vector2 barSize = this.DrawBar(spriteBatch, position, this.CurrentValue / (this.MaxValue * 1f), this.FilledColor, this.EmptyColor, wrapWidth); + Vector2 textSize = !string.IsNullOrWhiteSpace(this.Text) + ? spriteBatch.DrawTextBlock(font, this.Text, new Vector2(position.X + barSize.X + 3, position.Y), wrapWidth) + : Vector2.Zero; + return new Vector2(barSize.X + 3 + textSize.X, Math.Max(barSize.Y, textSize.Y)); + } + + + /********* + ** Protected methods + *********/ + /// Draw a percentage bar. + /// The sprite batch being drawn. + /// The position at which to draw. + /// The percentage value (between 0 and 1). + /// The color of the filled bar. + /// The color of the empty bar. + /// The maximum width before which content should be wrapped. + /// Returns the drawn dimensions, or null to draw the using the default format. + protected Vector2 DrawBar(SpriteBatch spriteBatch, Vector2 position, float ratio, Color filledColor, Color emptyColor, float maxWidth = 100) + { + int barHeight = 22; + ratio = Math.Min(1f, ratio); + float width = Math.Min(100, maxWidth); + float filledWidth = width * ratio; + float emptyWidth = width - filledWidth; + + if (filledWidth > 0) + spriteBatch.Draw(Sprites.Pixel, new Rectangle((int)position.X, (int)position.Y, (int)filledWidth, barHeight), filledColor); + if (emptyWidth > 0) + spriteBatch.Draw(Sprites.Pixel, new Rectangle((int)(position.X + filledWidth), (int)position.Y, (int)emptyWidth, barHeight), emptyColor); + + return new Vector2(width, barHeight); + } + } +} diff --git a/Mods/LookupAnything/Framework/Fields/RecipesForIngredientField.cs b/Mods/LookupAnything/Framework/Fields/RecipesForIngredientField.cs new file mode 100644 index 000000000..ae0192d34 --- /dev/null +++ b/Mods/LookupAnything/Framework/Fields/RecipesForIngredientField.cs @@ -0,0 +1,127 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Graphics; +using Pathoschild.Stardew.Common; +using Pathoschild.Stardew.LookupAnything.Framework.Constants; +using Pathoschild.Stardew.LookupAnything.Framework.Models; +using StardewModdingAPI; +using StardewValley; + +namespace Pathoschild.Stardew.LookupAnything.Framework.Fields +{ + /// A metadata field which shows a list of recipes containing an ingredient. + internal class RecipesForIngredientField : GenericField + { + /********* + ** Fields + *********/ + /// Metadata needed to draw a recipe. + private struct Entry + { + /// The recipe name. + public string Name; + + /// The recipe type. + public string Type; + + /// Whether the player knows the recipe. + public bool IsKnown; + + /// The number of the item required for the recipe. + public int NumberRequired; + + /// The sprite to display. + public SpriteInfo Sprite; + } + + /// The recipe data to list (type => recipe => {player knows recipe, number required for recipe}). + private readonly Entry[] Recipes; + + /// Provides translations stored in the mod folder. + private readonly ITranslationHelper Translations; + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// Provides utility methods for interacting with the game code. + /// A short field label. + /// The ingredient item. + /// The recipe to list. + /// Provides translations stored in the mod folder. + public RecipesForIngredientField(GameHelper gameHelper, string label, Item ingredient, RecipeModel[] recipes, ITranslationHelper translations) + : base(gameHelper, label, hasValue: true) + { + this.Translations = translations; + this.Recipes = this.GetRecipeEntries(this.GameHelper, ingredient, recipes).OrderBy(p => p.Type).ThenBy(p => p.Name).ToArray(); + } + + /// Draw the value (or return null to render the using the default format). + /// The sprite batch being drawn. + /// The recommended font. + /// The position at which to draw. + /// The maximum width before which content should be wrapped. + /// Returns the drawn dimensions, or null to draw the using the default format. + public override Vector2? DrawValue(SpriteBatch spriteBatch, SpriteFont font, Vector2 position, float wrapWidth) + { + const float leftIndent = 16; + float height = 0; + + // get icon size + float textHeight = font.MeasureString("ABC").Y; + Vector2 iconSize = new Vector2(textHeight); + + // draw recipes + string lastType = null; + foreach (Entry entry in this.Recipes) + { + // draw type + if (entry.Type != lastType) + { + height += spriteBatch.DrawTextBlock(font, $"{entry.Type}:", position + new Vector2(0, height), wrapWidth).Y; + lastType = entry.Type; + } + + // draw icon + Color iconColor = entry.IsKnown ? Color.White : Color.White * .5f; + spriteBatch.DrawSpriteWithin(entry.Sprite, position.X + leftIndent, position.Y + height, iconSize, iconColor); + + // draw text + Color color = entry.IsKnown ? Color.Black : Color.Gray; + Vector2 textSize = spriteBatch.DrawTextBlock(font, this.Translations.Get(L10n.Item.RecipesEntry, new { name = entry.Name, count = entry.NumberRequired }), position + new Vector2(leftIndent + iconSize.X + 3, height + 5), wrapWidth - iconSize.X, color); + + height += Math.Max(iconSize.Y, textSize.Y) + 5; + } + + return new Vector2(wrapWidth, height); + } + + + /********* + ** Private methods + *********/ + /// Get the recipe entries. + /// Provides utility methods for interacting with the game code. + /// The ingredient item. + /// The recipe to list. + private IEnumerable GetRecipeEntries(GameHelper gameHelper, Item ingredient, IEnumerable recipes) + { + foreach (RecipeModel recipe in recipes) + { + Item output = recipe.CreateItem(ingredient); + SpriteInfo customSprite = gameHelper.GetSprite(output); + yield return new Entry + { + Name = output.DisplayName, + Type = recipe.DisplayType, + IsKnown = !recipe.MustBeLearned || recipe.KnowsRecipe(Game1.player), + NumberRequired = recipe.Ingredients.ContainsKey(ingredient.ParentSheetIndex) ? recipe.Ingredients[ingredient.ParentSheetIndex] : recipe.Ingredients[ingredient.Category], + Sprite = customSprite + }; + } + } + } +} diff --git a/Mods/LookupAnything/Framework/Fields/SkillBarField.cs b/Mods/LookupAnything/Framework/Fields/SkillBarField.cs new file mode 100644 index 000000000..824ef0d20 --- /dev/null +++ b/Mods/LookupAnything/Framework/Fields/SkillBarField.cs @@ -0,0 +1,76 @@ +using System; +using System.Linq; +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Graphics; +using Pathoschild.Stardew.LookupAnything.Framework.Constants; +using StardewModdingAPI; + +namespace Pathoschild.Stardew.LookupAnything.Framework.Fields +{ + /// A metadata field which shows experience points for a skill. + /// Skill calculations reverse-engineered from . + internal class SkillBarField : PercentageBarField + { + /********* + ** Fields + *********/ + /// The experience points needed for each skill level. + private readonly int[] SkillPointsPerLevel; + + /// Provides translations stored in the mod folder. + private readonly ITranslationHelper Translations; + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// Provides utility methods for interacting with the game code. + /// A short field label. + /// The current progress value. + /// The maximum experience points for a skill. + /// The experience points needed for each skill level. + /// Provides translations stored in the mod folder. + public SkillBarField(GameHelper gameHelper, string label, int experience, int maxSkillPoints, int[] skillPointsPerLevel, ITranslationHelper translations) + : base(gameHelper, label, experience, maxSkillPoints, Color.Green, Color.Gray, null) + { + this.SkillPointsPerLevel = skillPointsPerLevel; + this.Translations = translations; + } + + /// Draw the value (or return null to render the using the default format). + /// The sprite batch being drawn. + /// The recommended font. + /// The position at which to draw. + /// The maximum width before which content should be wrapped. + /// Returns the drawn dimensions, or null to draw the using the default format. + public override Vector2? DrawValue(SpriteBatch spriteBatch, SpriteFont font, Vector2 position, float wrapWidth) + { + int[] pointsPerLevel = this.SkillPointsPerLevel; + + // generate text + int nextLevelExp = pointsPerLevel.FirstOrDefault(p => p - this.CurrentValue > 0); + int pointsForNextLevel = nextLevelExp > 0 ? nextLevelExp - this.CurrentValue : 0; + int currentLevel = nextLevelExp > 0 ? Array.IndexOf(pointsPerLevel, nextLevelExp) : pointsPerLevel.Length; + string text = pointsForNextLevel > 0 + ? this.Translations.Get(L10n.Player.SkillProgress, new { level = currentLevel, expNeeded = pointsForNextLevel }) + : this.Translations.Get(L10n.Player.SkillProgressLast, new { level = currentLevel }); + + // draw bars + const int barWidth = 25; + float leftOffset = 0; + int barHeight = 0; + foreach (int levelExp in pointsPerLevel) + { + float progress = Math.Min(1f, this.CurrentValue / (levelExp * 1f)); + Vector2 barSize = this.DrawBar(spriteBatch, position + new Vector2(leftOffset, 0), progress, this.FilledColor, this.EmptyColor, barWidth); + barHeight = (int)barSize.Y; + leftOffset += barSize.X + 2; + } + + // draw text + Vector2 textSize = spriteBatch.DrawTextBlock(font, text, position + new Vector2(leftOffset, 0), wrapWidth - leftOffset); + return new Vector2(leftOffset + textSize.X, Math.Max(barHeight, textSize.Y)); + } + } +} diff --git a/Mods/LookupAnything/Framework/FormattedText.cs b/Mods/LookupAnything/Framework/FormattedText.cs new file mode 100644 index 000000000..987a67d80 --- /dev/null +++ b/Mods/LookupAnything/Framework/FormattedText.cs @@ -0,0 +1,35 @@ +using Microsoft.Xna.Framework; + +namespace Pathoschild.Stardew.LookupAnything.Framework +{ + /// A snippet of formatted text. + internal struct FormattedText : IFormattedText + { + /******** + ** Accessors + *********/ + /// The text to format. + public string Text { get; } + + /// The font color (or null for the default color). + public Color? Color { get; } + + /// Whether to draw bold text. + public bool Bold { get; } + + + /******** + ** Public methods + *********/ + /// Construct an instance. + /// The text to format. + /// The font color (or null for the default color). + /// Whether to draw bold text. + public FormattedText(string text, Color? color = null, bool bold = false) + { + this.Text = text; + this.Color = color; + this.Bold = bold; + } + } +} diff --git a/Mods/LookupAnything/Framework/IFormattedText.cs b/Mods/LookupAnything/Framework/IFormattedText.cs new file mode 100644 index 000000000..fbb0d3886 --- /dev/null +++ b/Mods/LookupAnything/Framework/IFormattedText.cs @@ -0,0 +1,17 @@ +using Microsoft.Xna.Framework; + +namespace Pathoschild.Stardew.LookupAnything.Framework +{ + /// A snippet of formatted text. + internal interface IFormattedText + { + /// The font color (or null for the default color). + Color? Color { get; } + + /// The text to format. + string Text { get; } + + /// Whether to draw bold text. + bool Bold { get; } + } +} diff --git a/Mods/LookupAnything/Framework/InternalExtensions.cs b/Mods/LookupAnything/Framework/InternalExtensions.cs new file mode 100644 index 000000000..71d106f70 --- /dev/null +++ b/Mods/LookupAnything/Framework/InternalExtensions.cs @@ -0,0 +1,41 @@ +using Pathoschild.Stardew.LookupAnything.Framework.Constants; +using StardewValley; +using StardewValley.Objects; +using Object = StardewValley.Object; + +namespace Pathoschild.Stardew.LookupAnything.Framework +{ + /// Provides utility extension methods. + internal static class InternalExtensions + { + /********* + ** Public methods + *********/ + /**** + ** Items + ****/ + /// Get the sprite sheet to which the item's refers. + /// The item to check. + public static ItemSpriteType GetSpriteType(this Item item) + { + if (item is Object obj) + { + if (obj is Furniture) + return ItemSpriteType.Furniture; + if (obj is Wallpaper) + return ItemSpriteType.Wallpaper; + return obj.bigCraftable.Value + ? ItemSpriteType.BigCraftable + : ItemSpriteType.Object; + } + if (item is Boots) + return ItemSpriteType.Boots; + if (item is Hat) + return ItemSpriteType.Hat; + if (item is Tool) + return ItemSpriteType.Tool; + + return ItemSpriteType.Unknown; + } + } +} diff --git a/Mods/LookupAnything/Framework/Metadata.cs b/Mods/LookupAnything/Framework/Metadata.cs new file mode 100644 index 000000000..75adab77e --- /dev/null +++ b/Mods/LookupAnything/Framework/Metadata.cs @@ -0,0 +1,76 @@ +using System.Linq; +using Pathoschild.Stardew.LookupAnything.Framework.Constants; +using Pathoschild.Stardew.LookupAnything.Framework.Data; +using StardewValley; + +namespace Pathoschild.Stardew.LookupAnything.Framework +{ + /// Provides metadata that's not available from the game data directly (e.g. because it's buried in the logic). + internal class Metadata + { + /********* + ** Accessors + *********/ + /// Constant values hardcoded by the game. + public ConstantData Constants { get; set; } + + /// Metadata for game objects (including inventory items, terrain features, crops, trees, and other map objects). + public ObjectData[] Objects { get; set; } + + /// Metadata for NPCs in the game. + public CharacterData[] Characters { get; set; } + + /// Information about Adventure Guild monster-slaying quests. + /// Derived from . + public AdventureGuildQuestData[] AdventureGuildQuests { get; set; } + + /// The building recipes. + /// Derived from . + public BuildingRecipeData[] BuildingRecipes { get; set; } + + /// The machine recipes. + /// Derived from . + public MachineRecipeData[] MachineRecipes { get; set; } + + /// The shops that buy items from the player. + /// Derived from constructor. + public ShopData[] Shops { get; set; } + + + /********* + ** Public methods + *********/ + /// Get whether the metadata seems to be basically valid. + public bool LooksValid() + { + return new object[] { this.Constants, this.Objects, this.Characters, this.AdventureGuildQuests, this.BuildingRecipes, this.MachineRecipes, this.Shops }.All(p => p != null); + } + + /// Get overrides for a game object. + /// The item for which to get overrides. + /// The context for which to get an override. + public ObjectData GetObject(Item item, ObjectContext context) + { + ItemSpriteType sheet = item.GetSpriteType(); + return this.Objects + .FirstOrDefault(obj => obj.SpriteSheet == sheet && obj.SpriteID.Contains(item.ParentSheetIndex) && obj.Context.HasFlag(context)); + } + + /// Get overrides for a game object. + /// The character for which to get overrides. + /// The character type. + public CharacterData GetCharacter(NPC character, TargetType type) + { + return + this.Characters?.FirstOrDefault(p => p.ID == $"{type}::{character.Name}") // override by type + name + ?? this.Characters?.FirstOrDefault(p => p.ID == type.ToString()); // override by type + } + + /// Get the adventurer guild quest for the specified monster (if any). + /// The monster name. + public AdventureGuildQuestData GetAdventurerGuildQuest(string monster) + { + return this.AdventureGuildQuests.FirstOrDefault(p => p.Targets.Contains(monster)); + } + } +} diff --git a/Mods/LookupAnything/Framework/ModConfig.cs b/Mods/LookupAnything/Framework/ModConfig.cs new file mode 100644 index 000000000..b24092409 --- /dev/null +++ b/Mods/LookupAnything/Framework/ModConfig.cs @@ -0,0 +1,56 @@ +using Newtonsoft.Json; +using Pathoschild.Stardew.Common; +using StardewModdingAPI; + +namespace Pathoschild.Stardew.LookupAnything.Framework +{ + /// The parsed mod configuration. + internal class ModConfig + { + /********* + ** Accessors + *********/ + /// Whether to close the lookup UI when the lookup key is release. + public bool HideOnKeyUp { get; set; } + + /// The amount to scroll long content on each up/down scroll. + public int ScrollAmount { get; set; } = 160; + + /// Whether to show advanced data mining fields. + public bool ShowDataMiningFields { get; set; } + + /// Whether to include map tiles as lookup targets. + public bool EnableTileLookups { get; set; } + + /// The control bindings. + public ModConfigControls Controls { get; set; } = new ModConfigControls(); + + + /********* + ** Nested models + *********/ + /// A set of control bindings. + internal class ModConfigControls + { + /// The control which toggles the lookup UI for something under the cursor. + [JsonConverter(typeof(StringEnumArrayConverter))] + public SButton[] ToggleLookup { get; set; } = { SButton.Help, SButton.VolumeDown }; + + /// The control which toggles the lookup UI for something in front of the player. + [JsonConverter(typeof(StringEnumArrayConverter))] + public SButton[] ToggleLookupInFrontOfPlayer { get; set; } = new SButton[0]; + + /// The control which scrolls up long content. + [JsonConverter(typeof(StringEnumArrayConverter))] + public SButton[] ScrollUp { get; set; } = { SButton.Up }; + + /// The control which scrolls down long content. + [JsonConverter(typeof(StringEnumArrayConverter))] + public SButton[] ScrollDown { get; set; } = { SButton.Down }; + + /// Toggle the display of debug information. + [JsonConverter(typeof(StringEnumArrayConverter))] + public SButton[] ToggleDebug { get; set; } = new SButton[0]; + } + } +} diff --git a/Mods/LookupAnything/Framework/Models/BundleIngredientModel.cs b/Mods/LookupAnything/Framework/Models/BundleIngredientModel.cs new file mode 100644 index 000000000..2e3a2d312 --- /dev/null +++ b/Mods/LookupAnything/Framework/Models/BundleIngredientModel.cs @@ -0,0 +1,40 @@ +using Pathoschild.Stardew.LookupAnything.Framework.Constants; + +namespace Pathoschild.Stardew.LookupAnything.Framework.Models +{ + /// An item slot for a bundle. + internal class BundleIngredientModel + { + /********* + ** Accessors + *********/ + /// The ingredient's index in the bundle. + public int Index { get; } + + /// The required item's parent sprite index (or -1 for a monetary bundle). + public int ItemID { get; } + + /// The number of items required. + public int Stack { get; } + + /// The required item quality. + public ItemQuality Quality { get; } + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The ingredient's index in the bundle. + /// The required item's parent sprite index (or -1 for a monetary bundle). + /// The number of items required. + /// The required item quality. + public BundleIngredientModel(int index, int itemID, int stack, ItemQuality quality) + { + this.Index = index; + this.ItemID = itemID; + this.Stack = stack; + this.Quality = quality; + } + } +} diff --git a/Mods/LookupAnything/Framework/Models/BundleModel.cs b/Mods/LookupAnything/Framework/Models/BundleModel.cs new file mode 100644 index 000000000..7a4b421fd --- /dev/null +++ b/Mods/LookupAnything/Framework/Models/BundleModel.cs @@ -0,0 +1,51 @@ +using System.Collections.Generic; +using System.Linq; + +namespace Pathoschild.Stardew.LookupAnything.Framework.Models +{ + /// A bundle entry parsed from the game's data files. + internal class BundleModel + { + /********* + ** Accessors + *********/ + /// The unique bundle ID. + public int ID { get; } + + /// The bundle name. + public string Name { get; } + + /// The translated bundle name. + public string DisplayName { get; } + + /// The community center area containing the bundle. + public string Area { get; } + + /// The unparsed reward description, which can be parsed with . + public string RewardData { get; } + + /// The required item ingredients. + public BundleIngredientModel[] Ingredients { get; } + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The unique bundle ID. + /// The bundle name. + /// The translated bundle name. + /// The community center area containing the bundle. + /// The unparsed reward description. + /// The required item ingredients. + public BundleModel(int id, string name, string displayName, string area, string rewardData, IEnumerable ingredients) + { + this.ID = id; + this.Name = name; + this.DisplayName = displayName; + this.Area = area; + this.RewardData = rewardData; + this.Ingredients = ingredients.ToArray(); + } + } +} diff --git a/Mods/LookupAnything/Framework/Models/FriendshipModel.cs b/Mods/LookupAnything/Framework/Models/FriendshipModel.cs new file mode 100644 index 000000000..ffcf08869 --- /dev/null +++ b/Mods/LookupAnything/Framework/Models/FriendshipModel.cs @@ -0,0 +1,128 @@ +using Pathoschild.Stardew.LookupAnything.Framework.Data; +using StardewValley; +using SFarmer = StardewValley.Farmer; + +namespace Pathoschild.Stardew.LookupAnything.Framework.Models +{ + /// Summarises details about the friendship between an NPC and a player. + internal class FriendshipModel + { + /********* + ** Accessors + *********/ + /**** + ** Flags + ****/ + /// Whether the player can date the NPC. + public bool CanDate { get; set; } + + /// Whether the NPC is dating the player. + public bool IsDating { get; set; } + + /// Whether the NPC is married to the player. + public bool IsSpouse { get; set; } + + /// Whether the NPC has a stardrop to give to the player once they reach enough points. + public bool HasStardrop { get; set; } + + /// Whether the player talked to them today. + public bool TalkedToday { get; set; } + + /// The number of gifts the player gave the NPC today. + public int GiftsToday { get; set; } + + /// The number of gifts the player gave the NPC this week. + public int GiftsThisWeek { get; set; } + + /// The current friendship status. + public FriendshipStatus Status { get; set; } + + /**** + ** Points + ****/ + /// The player's current friendship points with the NPC. + public int Points { get; } + + /// The number of friendship points needed to obtain a stardrop (if applicable). + public int? StardropPoints { get; } + + /// The maximum number of points which the player can currently reach with an NPC. + public int MaxPoints { get; } + + /// The number of points per heart level. + public int PointsPerLevel { get; } + + + /**** + ** Hearts + ****/ + /// The number of filled hearts in their friendship meter. + public int FilledHearts { get; set; } + + /// The number of empty hearts in their friendship meter. + public int EmptyHearts { get; set; } + + /// The number of locked hearts in their friendship meter. + public int LockedHearts { get; set; } + + /// The total number of hearts that can be unlocked with this NPC. + public int TotalHearts => this.FilledHearts + this.EmptyHearts + this.LockedHearts; + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The player. + /// The NPC. + /// The constant assumptions. + /// The current friendship data. + public FriendshipModel(SFarmer player, NPC npc, Friendship friendship, ConstantData constants) + { + // flags + this.CanDate = npc.datable.Value; + this.IsDating = friendship.IsDating(); + this.IsSpouse = friendship.IsMarried(); + this.Status = friendship.Status; + this.TalkedToday = friendship.TalkedToToday; + this.GiftsToday = friendship.GiftsToday; + this.GiftsThisWeek = friendship.GiftsThisWeek; + + // points + this.MaxPoints = this.IsSpouse ? constants.SpouseMaxFriendship : NPC.maxFriendshipPoints; + this.Points = friendship.Points; + this.PointsPerLevel = NPC.friendshipPointsPerHeartLevel; + this.FilledHearts = this.Points / NPC.friendshipPointsPerHeartLevel; + this.LockedHearts = this.CanDate && !this.IsDating ? constants.DatingHearts : 0; + this.EmptyHearts = this.MaxPoints / NPC.friendshipPointsPerHeartLevel - this.FilledHearts - this.LockedHearts; + if (this.IsSpouse) + { + this.StardropPoints = constants.SpouseFriendshipForStardrop; + this.HasStardrop = !player.mailReceived.Contains(Constants.Constant.MailLetters.ReceivedSpouseStardrop); + } + } + + /// Construct an instance. + /// The player's current friendship points with the NPC. + /// The number of points per heart level. + /// The maximum number of points which the player can currently reach with an NPC. + public FriendshipModel(int points, int pointsPerLevel, int maxPoints) + { + this.Points = points; + this.PointsPerLevel = pointsPerLevel; + this.MaxPoints = maxPoints; + this.FilledHearts = this.Points / pointsPerLevel; + this.EmptyHearts = this.MaxPoints / pointsPerLevel - this.FilledHearts; + } + + /// Get the number of points to the next heart level or startdrop. + public int GetPointsToNext() + { + if (this.Points < this.MaxPoints) + return this.PointsPerLevel - (this.Points % this.PointsPerLevel); + if (this.StardropPoints.HasValue && this.Points < this.StardropPoints) + return this.StardropPoints.Value - this.Points; + return 0; + } + } +} diff --git a/Mods/LookupAnything/Framework/Models/GiftTasteModel.cs b/Mods/LookupAnything/Framework/Models/GiftTasteModel.cs new file mode 100644 index 000000000..508cd3744 --- /dev/null +++ b/Mods/LookupAnything/Framework/Models/GiftTasteModel.cs @@ -0,0 +1,53 @@ +using Pathoschild.Stardew.LookupAnything.Framework.Constants; + +namespace Pathoschild.Stardew.LookupAnything.Framework.Models +{ + /// A raw gift taste entry parsed from the game's data files. + internal class GiftTasteModel + { + /********* + ** Accessors + *********/ + /// How much the target villager likes this item. + public GiftTaste Taste { get; private set; } + + /// The name of the target villager. + public string Villager { get; } + + /// The item parent sprite index (if positive) or category (if negative). + public int RefID { get; set; } + + /// Whether this gift taste applies to all villagers unless otherwise excepted. + public bool IsUniversal { get; } + + /// Whether the refers to a category of items, instead of a specific item ID. + public bool IsCategory => this.RefID < 0; + + /// The precedence used to resolve conflicting tastes (lower is better). + public int Precedence { get; set; } + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// How much the target villager likes this item. + /// The name of the target villager. + /// The item parent sprite index (if positive) or category (if negative). + /// Whether this gift taste applies to all villagers unless otherwise excepted. + public GiftTasteModel(GiftTaste taste, string villager, int refID, bool isUniversal = false) + { + this.Taste = taste; + this.Villager = villager; + this.RefID = refID; + this.IsUniversal = isUniversal; + } + + /// Override the taste value. + /// The taste value to set. + public void SetTaste(GiftTaste taste) + { + this.Taste = taste; + } + } +} diff --git a/Mods/LookupAnything/Framework/Models/ObjectModel.cs b/Mods/LookupAnything/Framework/Models/ObjectModel.cs new file mode 100644 index 000000000..f0f4d71d5 --- /dev/null +++ b/Mods/LookupAnything/Framework/Models/ObjectModel.cs @@ -0,0 +1,54 @@ +namespace Pathoschild.Stardew.LookupAnything.Framework.Models +{ + /// An object entry parsed from the game's data files. + internal class ObjectModel + { + /********* + ** Accessors + *********/ + /// The object's index in the object sprite sheet. + public int ParentSpriteIndex { get; } + + /// The object name. + public string Name { get; } + + /// The base description. This may be overridden by game logic (e.g. for the Gunther-can-tell-you-more messages). + public string Description { get; } + + /// The base sale price. + public int Price { get; } + + /// How edible the item is, where -300 is inedible. + public int Edibility { get; } + + /// The type name. + public string Type { get; } + + /// The category ID (or 0 if there is none). + public int Category { get; } + + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The object's index in the object sprite sheet. + /// The object name. + /// The base description. + /// The base sale price. + /// How edible the item is, where -300 is inedible. + /// The type name. + /// The category ID (or 0 if there is none). + public ObjectModel(int parentSpriteIndex, string name, string description, int price, int edibility, string type, int category) + { + this.ParentSpriteIndex = parentSpriteIndex; + this.Name = name; + this.Description = description; + this.Price = price; + this.Edibility = edibility; + this.Type = type; + this.Category = category; + } + } +} diff --git a/Mods/LookupAnything/Framework/Models/RecipeModel.cs b/Mods/LookupAnything/Framework/Models/RecipeModel.cs new file mode 100644 index 000000000..58dd39cef --- /dev/null +++ b/Mods/LookupAnything/Framework/Models/RecipeModel.cs @@ -0,0 +1,115 @@ +using System; +using System.Collections.Generic; +using Pathoschild.Stardew.LookupAnything.Framework.Constants; +using StardewModdingAPI; +using StardewValley; + +namespace Pathoschild.Stardew.LookupAnything.Framework.Models +{ + /// Represents metadata about a recipe. + internal class RecipeModel + { + /********* + ** Fields + *********/ + /// The item that be created by this recipe, given the ingredient. + private readonly Func Item; + + + /********* + ** Accessors + *********/ + /// The recipe's lookup name (if any). + public string Key { get; } + + /// The recipe type. + public RecipeType Type { get; } + + /// The display name for the machine or building name for which the recipe applies. + public string DisplayType { get; } + + /// The items needed to craft the recipe (item ID => number needed). + public IDictionary Ingredients { get; } + + /// The item ID produced by this recipe, if applicable. + public int? OutputItemIndex { get; } + + /// The ingredients which can't be used in this recipe (typically exceptions for a category ingredient). + public int[] ExceptIngredients { get; } + + /// Whether the recipe must be learned before it can be used. + public bool MustBeLearned { get; } + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The recipe to parse. + /// Simplifies access to private game code. + /// Provides translations stored in the mod folder. + public RecipeModel(CraftingRecipe recipe, IReflectionHelper reflectionHelper, ITranslationHelper translations) + : this( + key: recipe.name, + type: recipe.isCookingRecipe ? RecipeType.Cooking : RecipeType.Crafting, + displayType: translations.Get(recipe.isCookingRecipe ? L10n.RecipeTypes.Cooking : L10n.RecipeTypes.Crafting), + ingredients: reflectionHelper.GetField>(recipe, "recipeList").GetValue(), + item: item => recipe.createItem(), + mustBeLearned: true, + outputItemIndex: reflectionHelper.GetField>(recipe, "itemToProduce").GetValue()[0] + ) + { } + + /// Construct an instance. + /// The recipe's lookup name (if any). + /// The recipe type. + /// The display name for the machine or building name for which the recipe applies. + /// The items needed to craft the recipe (item ID => number needed). + /// The item that be created by this recipe. + /// Whether the recipe must be learned before it can be used. + /// The ingredients which can't be used in this recipe (typically exceptions for a category ingredient). + /// The item ID produced by this recipe, if applicable. + public RecipeModel(string key, RecipeType type, string displayType, IDictionary ingredients, Func item, bool mustBeLearned, int[] exceptIngredients = null, int? outputItemIndex = null) + { + this.Key = key; + this.Type = type; + this.DisplayType = displayType; + this.Ingredients = ingredients; + this.ExceptIngredients = exceptIngredients ?? new int[0]; + this.Item = item; + this.MustBeLearned = mustBeLearned; + this.OutputItemIndex = outputItemIndex; + } + + /// Create the item crafted by this recipe. + /// The ingredient for which to create an item. + public Item CreateItem(Item ingredient) + { + return this.Item(ingredient); + } + + /// Get whether a player knows this recipe. + /// The farmer to check. + public bool KnowsRecipe(Farmer farmer) + { + return this.Key != null && farmer.knowsRecipe(this.Key); + } + + /// Get the number of times this player has crafted the recipe. + /// Returns the times crafted, or -1 if unknown (e.g. some recipe types like furnace aren't tracked). + public int GetTimesCrafted(Farmer player) + { + switch (this.Type) + { + case RecipeType.Cooking: + return this.OutputItemIndex.HasValue && player.recipesCooked.TryGetValue(this.OutputItemIndex.Value, out int timesCooked) ? timesCooked : 0; + + case RecipeType.Crafting: + return player.craftingRecipes.TryGetValue(this.Key, out int timesCrafted) ? timesCrafted : 0; + + default: + return -1; + } + } + } +} diff --git a/Mods/LookupAnything/Framework/Models/RecipeType.cs b/Mods/LookupAnything/Framework/Models/RecipeType.cs new file mode 100644 index 000000000..1dd907a18 --- /dev/null +++ b/Mods/LookupAnything/Framework/Models/RecipeType.cs @@ -0,0 +1,18 @@ +namespace Pathoschild.Stardew.LookupAnything.Framework.Models +{ + /// Indicates an in-game recipe type. + internal enum RecipeType + { + /// The recipe is cooked in the kitchen. + Cooking, + + /// The recipe is crafted through the game menu. + Crafting, + + /// The recipe represents the input for a crafting machine like a furnace. + MachineInput, + + /// The recipe represents the materials needed to construct a building through Robin or the Wizard. + BuildingBlueprint + } +} diff --git a/Mods/LookupAnything/Framework/Subjects/BaseSubject.cs b/Mods/LookupAnything/Framework/Subjects/BaseSubject.cs new file mode 100644 index 000000000..ab3972c15 --- /dev/null +++ b/Mods/LookupAnything/Framework/Subjects/BaseSubject.cs @@ -0,0 +1,177 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Graphics; +using Netcode; +using Pathoschild.Stardew.LookupAnything.Framework.DebugFields; +using Pathoschild.Stardew.LookupAnything.Framework.Fields; +using StardewModdingAPI; + +namespace Pathoschild.Stardew.LookupAnything.Framework.Subjects +{ + /// The base class for object metadata. + internal abstract class BaseSubject : ISubject + { + /********* + ** Fields + *********/ + /// Provides translations stored in the mod folder. + protected ITranslationHelper Text { get; } + + /// Provides utility methods for interacting with the game code. + protected GameHelper GameHelper { get; } + + + /********* + ** Accessors + *********/ + /// The display name. + public string Name { get; protected set; } + + /// The object description (if applicable). + public string Description { get; protected set; } + + /// The object type. + public string Type { get; protected set; } + + + /********* + ** Public methods + *********/ + /// Get the data to display for this subject. + /// Provides metadata that's not available from the game data directly. + public abstract IEnumerable GetData(Metadata metadata); + + /// Get raw debug data to display for this subject. + /// Provides metadata that's not available from the game data directly. + public abstract IEnumerable GetDebugFields(Metadata metadata); + + /// Draw the subject portrait (if available). + /// The sprite batch being drawn. + /// The position at which to draw. + /// The size of the portrait to draw. + /// Returns true if a portrait was drawn, else false. + public abstract bool DrawPortrait(SpriteBatch spriteBatch, Vector2 position, Vector2 size); + + + /********* + ** Protected methods + *********/ + /// Construct an instance. + /// Provides utility methods for interacting with the game code. + /// Provides translations stored in the mod folder. + protected BaseSubject(GameHelper gameHelper, ITranslationHelper translations) + { + this.GameHelper = gameHelper; + this.Text = translations; + } + + /// Construct an instance. + /// Provides utility methods for interacting with the game code. + /// The display name. + /// The object description (if applicable). + /// The object type. + /// Provides translations stored in the mod folder. + protected BaseSubject(GameHelper gameHelper, string name, string description, string type, ITranslationHelper translations) + : this(gameHelper, translations) + { + this.Initialise(name, description, type); + } + + /// Initialise the base values. + /// The display name. + /// The object description (if applicable). + /// The object type. + protected void Initialise(string name, string description, string type) + { + this.Name = name; + this.Description = description; + this.Type = type; + } + + /// Get all debug fields by reflecting over an instance. + /// The object instance over which to reflect. + protected IEnumerable GetDebugFieldsFrom(object obj) + { + if (obj == null) + yield break; + + for (Type type = obj.GetType(); type != null; type = type.BaseType) + { + // get fields & properties + var fields = + ( + from field in type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.FlattenHierarchy) + where !field.IsLiteral // exclude constants + select new { field.Name, Type = field.FieldType, Value = this.GetDebugValue(obj, field) } + ) + .Concat( + from property in type.GetProperties(BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.FlattenHierarchy) + where property.CanRead + select new { property.Name, Type = property.PropertyType, Value = this.GetDebugValue(obj, property) } + ) + .OrderBy(field => field.Name, StringComparer.InvariantCultureIgnoreCase); + + // yield valid values + IDictionary seenValues = new Dictionary(StringComparer.InvariantCulture); + foreach (var field in fields) + { + if (seenValues.TryGetValue(field.Name, out string value) && value == field.Value) + continue; // key/value pair differs only in the key case + if (field.Value == field.Type.ToString()) + continue; // can't be displayed + + yield return new GenericDebugField($"{type.Name}::{field.Name}", field.Value); + } + } + } + + /// Get a human-readable representation of a value. + /// The underlying value. + protected string Stringify(object value) + { + return this.Text.Stringify(value); + } + + /// Get a translation for the current locale. + /// The translation key. + /// An anonymous object containing token key/value pairs, like new { value = 42, name = "Cranberries" }. + /// The doesn't match an available translation. + protected Translation Translate(string key, object tokens = null) + { + return this.Text.Get(key, tokens); + } + + /// Get a human-readable value for a debug value. + /// The object whose values to read. + /// The field to read. + private string GetDebugValue(object obj, FieldInfo field) + { + try + { + return this.Stringify(field.GetValue(obj)); + } + catch (Exception ex) + { + return $"error reading field: {ex.Message}"; + } + } + + /// Get a human-readable value for a debug value. + /// The object whose values to read. + /// The property to read. + private string GetDebugValue(object obj, PropertyInfo property) + { + try + { + return this.Stringify(property.GetValue(obj)); + } + catch (Exception ex) + { + return $"error reading property: {ex.Message}"; + } + } + } +} diff --git a/Mods/LookupAnything/Framework/Subjects/BuildingSubject.cs b/Mods/LookupAnything/Framework/Subjects/BuildingSubject.cs new file mode 100644 index 000000000..af6e8b33e --- /dev/null +++ b/Mods/LookupAnything/Framework/Subjects/BuildingSubject.cs @@ -0,0 +1,322 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Content; +using Microsoft.Xna.Framework.Graphics; +using Pathoschild.Stardew.LookupAnything.Framework.Constants; +using Pathoschild.Stardew.LookupAnything.Framework.DebugFields; +using Pathoschild.Stardew.LookupAnything.Framework.Fields; +using StardewModdingAPI; +using StardewModdingAPI.Utilities; +using StardewValley; +using StardewValley.Buildings; +using StardewValley.Characters; +using StardewValley.Locations; +using StardewValley.Monsters; +using SObject = StardewValley.Object; + +namespace Pathoschild.Stardew.LookupAnything.Framework.Subjects +{ + /// Describes a constructed building. + internal class BuildingSubject : BaseSubject + { + /********* + ** Fields + *********/ + /// Simplifies access to private game code. + private readonly IReflectionHelper Reflection; + + /// The lookup target. + private readonly Building Target; + + /// The building's source rectangle in its spritesheet. + private readonly Rectangle SourceRectangle; + + /// Provides metadata that's not available from the game data directly. + private readonly Metadata Metadata; + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// Provides utility methods for interacting with the game code. + /// The lookup target. + /// Provides metadata that's not available from the game data directly. + /// The building's source rectangle in its spritesheet. + /// Provides translations stored in the mod folder. + /// Simplifies access to private game code. + public BuildingSubject(GameHelper gameHelper, Metadata metadata, Building building, Rectangle sourceRectangle, ITranslationHelper translations, IReflectionHelper reflectionHelper) + : base(gameHelper, building.buildingType.Value, null, translations.Get(L10n.Types.Building), translations) + { + // init + this.Metadata = metadata; + this.Reflection = reflectionHelper; + this.Target = building; + this.SourceRectangle = sourceRectangle; + + // get name/description from blueprint if available + try + { + BluePrint blueprint = new BluePrint(building.buildingType.Value); + this.Name = blueprint.displayName; + this.Description = blueprint.description; + } + catch (ContentLoadException) + { + // use default values + } + } + + /// Get the data to display for this subject. + /// Provides metadata that's not available from the game data directly. + public override IEnumerable GetData(Metadata metadata) + { + var text = this.Text; + + // get info + Building building = this.Target; + bool built = !building.isUnderConstruction(); + int? upgradeLevel = this.GetUpgradeLevel(building); + + // construction / upgrade + if (!built || building.daysUntilUpgrade.Value > 0) + { + int daysLeft = building.isUnderConstruction() ? building.daysOfConstructionLeft.Value : building.daysUntilUpgrade.Value; + SDate readyDate = SDate.Now().AddDays(daysLeft); + yield return new GenericField(this.GameHelper, text.Get(L10n.Building.Construction), text.Get(L10n.Building.ConstructionSummary, new { date = readyDate })); + } + + // owner + Farmer owner = this.GetOwner(); + if (owner != null) + yield return new LinkField(this.GameHelper, text.Get(L10n.Building.Owner), owner.Name, () => new FarmerSubject(this.GameHelper, owner, text, this.Reflection)); + else if (building.indoors.Value is Cabin) + yield return new GenericField(this.GameHelper, text.Get(L10n.Building.Owner), text.Get(L10n.Building.OwnerNone)); + + // stable horse + if (built && building is Stable stable) + { + Horse horse = Utility.findHorse(stable.HorseId); + if (horse != null) + { + yield return new LinkField(this.GameHelper, text.Get(L10n.Building.Horse), horse.Name, () => new CharacterSubject(this.GameHelper, horse, TargetType.Horse, this.Metadata, text, this.Reflection)); + yield return new GenericField(this.GameHelper, text.Get(L10n.Building.HorseLocation), text.Get(L10n.Building.HorseLocationSummary, new { location = horse.currentLocation.Name, x = horse.getTileX(), y = horse.getTileY() })); + } + } + + // animals + if (built && building.indoors.Value is AnimalHouse animalHouse) + { + // animal counts + yield return new GenericField(this.GameHelper, text.Get(L10n.Building.Animals), text.Get(L10n.Building.AnimalsSummary, new { count = animalHouse.animalsThatLiveHere.Count, max = animalHouse.animalLimit.Value })); + + // feed trough + if ((building is Barn || building is Coop) && upgradeLevel >= 2) + yield return new GenericField(this.GameHelper, text.Get(L10n.Building.FeedTrough), text.Get(L10n.Building.FeedTroughAutomated)); + else + { + this.GetFeedMetrics(animalHouse, out int totalFeedSpaces, out int filledFeedSpaces); + yield return new GenericField(this.GameHelper, text.Get(L10n.Building.FeedTrough), text.Get(L10n.Building.FeedTroughSummary, new { filled = filledFeedSpaces, max = totalFeedSpaces })); + } + } + + // slimes + if (built && building.indoors.Value is SlimeHutch slimeHutch) + { + // slime count + int slimeCount = slimeHutch.characters.OfType().Count(); + yield return new GenericField(this.GameHelper, text.Get(L10n.Building.Slimes), text.Get(L10n.Building.SlimesSummary, new { count = slimeCount, max = 20 })); + + // water trough + yield return new GenericField(this.GameHelper, text.Get(L10n.Building.WaterTrough), text.Get(L10n.Building.WaterTroughSummary, new { filled = slimeHutch.waterSpots.Count(p => p), max = slimeHutch.waterSpots.Count })); + } + + // upgrade level + if (built) + { + var upgradeLevelSummary = this.GetUpgradeLevelSummary(building, upgradeLevel).ToArray(); + if (upgradeLevelSummary.Any()) + yield return new CheckboxListField(this.GameHelper, text.Get(L10n.Building.Upgrades), upgradeLevelSummary); + } + + // silo hay + if (built && building.buildingType.Value == "Silo") + { + Farm farm = Game1.getFarm(); + int siloCount = Utility.numSilos(); + yield return new GenericField( + this.GameHelper, + text.Get(L10n.Building.StoredHay), + text.Get(siloCount == 1 ? L10n.Building.StoredHaySummaryOneSilo : L10n.Building.StoredHaySummaryMultipleSilos, new { hayCount = farm.piecesOfHay, siloCount = siloCount, maxHay = Math.Max(farm.piecesOfHay.Value, siloCount * 240) }) + ); + } + + if (built && building is JunimoHut hut) + { + yield return new GenericField(this.GameHelper, text.Get(L10n.Building.JunimoHarvestingEnabled), text.Stringify(!hut.noHarvest.Value)); + yield return new ItemIconListField(this.GameHelper, text.Get(L10n.Building.OutputReady), hut.output.Value?.items, showStackSize: true); + } + + // mill output + if (built && building is Mill mill) + { + yield return new ItemIconListField(this.GameHelper, text.Get(L10n.Building.OutputProcessing), mill.input.Value?.items, showStackSize: true); + yield return new ItemIconListField(this.GameHelper, text.Get(L10n.Building.OutputReady), mill.output.Value?.items, showStackSize: true); + } + } + + /// Get raw debug data to display for this subject. + /// Provides metadata that's not available from the game data directly. + public override IEnumerable GetDebugFields(Metadata metadata) + { + Building target = this.Target; + + // pinned fields + yield return new GenericDebugField("building type", target.buildingType.Value, pinned: true); + yield return new GenericDebugField("days of construction left", target.daysOfConstructionLeft.Value, pinned: true); + yield return new GenericDebugField("name of indoors", target.nameOfIndoors, pinned: true); + + // raw fields + foreach (IDebugField field in this.GetDebugFieldsFrom(target)) + yield return field; + } + + /// Draw the subject portrait (if available). + /// The sprite batch being drawn. + /// The position at which to draw. + /// The size of the portrait to draw. + /// Returns true if a portrait was drawn, else false. + /// Derived from , modified to draw within the target size. + public override bool DrawPortrait(SpriteBatch spriteBatch, Vector2 position, Vector2 size) + { + Building target = this.Target; + spriteBatch.Draw(target.texture.Value, position, this.SourceRectangle, target.color.Value, 0.0f, Vector2.Zero, size.X / this.SourceRectangle.Width, SpriteEffects.None, 0.89f); + return true; + } + + + /********* + ** Private fields + *********/ + /// Get the building owner, if any. + private Farmer GetOwner() + { + Building target = this.Target; + + // stable + if (target is Stable stable) + { + long ownerID = stable.owner.Value; + return Game1.getFarmerMaybeOffline(ownerID); + } + + // cabin + if (this.Target.indoors.Value is Cabin cabin) + return cabin.owner; + + return null; + } + + /// Get the upgrade level for a building, if applicable. + /// The building to check. + private int? GetUpgradeLevel(Building building) + { + // barn + if (building is Barn barn && int.TryParse(barn.nameOfIndoorsWithoutUnique.Substring("Barn".Length), out int barnUpgradeLevel)) + return barnUpgradeLevel - 1; // Barn2 is first upgrade + + // cabin + if (building.indoors.Value is Cabin cabin) + return cabin.upgradeLevel; + + // coop + if (building is Coop coop && int.TryParse(coop.nameOfIndoorsWithoutUnique.Substring("Coop".Length), out int coopUpgradeLevel)) + return coopUpgradeLevel - 1; // Coop2 is first upgrade + + return null; + } + + /// Get the feed metrics for an animal building. + /// The animal building to check. + /// The total number of feed trough spaces. + /// The number of feed trough spaces which contain hay. + private void GetFeedMetrics(AnimalHouse building, out int total, out int filled) + { + var map = building.Map; + total = 0; + filled = 0; + + for (int x = 0; x < map.Layers[0].LayerWidth; x++) + { + for (int y = 0; y < map.Layers[0].LayerHeight; y++) + { + if (building.doesTileHaveProperty(x, y, "Trough", "Back") != null) + { + total++; + if (building.objects.TryGetValue(new Vector2(x, y), out SObject obj) && obj.ParentSheetIndex == 178) + filled++; + } + } + } + } + + /// Get the upgrade levels for a building, for use with a checkbox field. + /// The building to check. + /// The current upgrade level, if applicable. + private IEnumerable> GetUpgradeLevelSummary(Building building, int? upgradeLevel) + { + // barn + if (building is Barn) + { + yield return new KeyValuePair( + key: new IFormattedText[] { new FormattedText(this.Text.Get(L10n.Building.UpgradesBarn0)) }, + value: true + ); + yield return new KeyValuePair( + key: new IFormattedText[] { new FormattedText(this.Text.Get(L10n.Building.UpgradesBarn1)) }, + value: upgradeLevel >= 1 + ); + yield return new KeyValuePair( + key: new IFormattedText[] { new FormattedText(this.Text.Get(L10n.Building.UpgradesBarn2)) }, + value: upgradeLevel >= 2 + ); + } + + // cabin + else if (building.indoors.Value is Cabin) + { + yield return new KeyValuePair( + key: new IFormattedText[] { new FormattedText(this.Text.Get(L10n.Building.UpgradesCabin0)) }, + value: true + ); + yield return new KeyValuePair( + key: new IFormattedText[] { new FormattedText(this.Text.Get(L10n.Building.UpgradesCabin1)) }, + value: upgradeLevel >= 1 + ); + yield return new KeyValuePair( + key: new IFormattedText[] { new FormattedText(this.Text.Get(L10n.Building.UpgradesCabin2)) }, + value: upgradeLevel >= 2 + ); + } + + // coop + else if (building is Coop) + { + yield return new KeyValuePair( + key: new IFormattedText[] { new FormattedText(this.Text.Get(L10n.Building.UpgradesCoop0)) }, + value: true + ); + yield return new KeyValuePair( + key: new IFormattedText[] { new FormattedText(this.Text.Get(L10n.Building.UpgradesCoop1)) }, + value: upgradeLevel >= 1 + ); + yield return new KeyValuePair( + key: new IFormattedText[] { new FormattedText(this.Text.Get(L10n.Building.UpgradesCoop2)) }, + value: upgradeLevel >= 2 + ); + } + } + } +} diff --git a/Mods/LookupAnything/Framework/Subjects/CharacterSubject.cs b/Mods/LookupAnything/Framework/Subjects/CharacterSubject.cs new file mode 100644 index 000000000..597955cec --- /dev/null +++ b/Mods/LookupAnything/Framework/Subjects/CharacterSubject.cs @@ -0,0 +1,272 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Graphics; +using Pathoschild.Stardew.LookupAnything.Framework.Constants; +using Pathoschild.Stardew.LookupAnything.Framework.Data; +using Pathoschild.Stardew.LookupAnything.Framework.DebugFields; +using Pathoschild.Stardew.LookupAnything.Framework.Fields; +using Pathoschild.Stardew.LookupAnything.Framework.Models; +using StardewModdingAPI; +using StardewModdingAPI.Utilities; +using StardewValley; +using StardewValley.Characters; +using StardewValley.Menus; +using StardewValley.Monsters; + +namespace Pathoschild.Stardew.LookupAnything.Framework.Subjects +{ + /// Describes an NPC (including villagers, monsters, and pets). + internal class CharacterSubject : BaseSubject + { + /********* + ** Fields + *********/ + /// The NPC type.s + private readonly TargetType TargetType; + + /// The lookup target. + private readonly NPC Target; + + /// Simplifies access to private game code. + private readonly IReflectionHelper Reflection; + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// Provides utility methods for interacting with the game code. + /// The lookup target. + /// The NPC type. + /// Provides metadata that's not available from the game data directly. + /// Provides translations stored in the mod folder. + /// Simplifies access to private game code. + /// Reverse engineered from . + public CharacterSubject(GameHelper gameHelper, NPC npc, TargetType type, Metadata metadata, ITranslationHelper translations, IReflectionHelper reflectionHelper) + : base(gameHelper, translations) + { + this.Reflection = reflectionHelper; + + // get display type + string typeName; + if (type == TargetType.Villager) + typeName = this.Text.Get(L10n.Types.Villager); + else if (type == TargetType.Monster) + typeName = this.Text.Get(L10n.Types.Monster); + else + typeName = npc.GetType().Name; + + // initialise + this.Target = npc; + this.TargetType = type; + CharacterData overrides = metadata.GetCharacter(npc, type); + string name = npc.getName(); + string description = overrides?.DescriptionKey != null ? translations.Get(overrides.DescriptionKey) : null; + this.Initialise(name, description, typeName); + } + + /// Get the data to display for this subject. + /// Provides metadata that's not available from the game data directly. + public override IEnumerable GetData(Metadata metadata) + { + NPC npc = this.Target; + + switch (this.TargetType) + { + case TargetType.Villager: + // special NPCs like Gunther + if (metadata.Constants.AsocialVillagers.Contains(npc.Name)) + { + // no data + } + + // children + else if (npc is Child child) + { + // birthday + SDate birthday = SDate.Now().AddDays(-child.daysOld); + yield return new GenericField(this.GameHelper, this.Text.Get(L10n.Npc.Birthday), this.Text.Stringify(birthday, withYear: true)); + + // age + { + ChildAge stage = (ChildAge)child.Age; + int daysOld = child.daysOld; + int daysToNext = this.GetDaysToNextChildGrowth(stage, daysOld); + bool isGrown = daysToNext == -1; + int daysAtNext = daysOld + (isGrown ? 0 : daysToNext); + + string ageLabel = this.Translate(L10n.NpcChild.Age); + string ageName = this.Translate(L10n.For(stage)); + string ageDesc = isGrown + ? this.Translate(L10n.NpcChild.AgeDescriptionGrown, new { label = ageName }) + : this.Translate(L10n.NpcChild.AgeDescriptionPartial, new { label = ageName, count = daysToNext, nextLabel = this.Text.Get(L10n.For(stage + 1)) }); + + yield return new PercentageBarField(this.GameHelper, ageLabel, child.daysOld, daysAtNext, Color.Green, Color.Gray, ageDesc); + } + + // friendship + if (Game1.player.friendshipData.ContainsKey(child.Name)) + { + FriendshipModel friendship = this.GameHelper.GetFriendshipForVillager(Game1.player, child, Game1.player.friendshipData[child.Name], metadata); + yield return new CharacterFriendshipField(this.GameHelper, this.Translate(L10n.Npc.Friendship), friendship, this.Text); + yield return new GenericField(this.GameHelper, this.Translate(L10n.Npc.TalkedToday), this.Stringify(Game1.player.friendshipData[child.Name].TalkedToToday)); + } + } + + // villagers + else + { + // birthday + if (npc.Birthday_Season != null) + { + SDate birthday = new SDate(npc.Birthday_Day, npc.Birthday_Season); + yield return new GenericField(this.GameHelper, this.Text.Get(L10n.Npc.Birthday), this.Text.Stringify(birthday)); + } + + // friendship + if (Game1.player.friendshipData.ContainsKey(npc.Name)) + { + FriendshipModel friendship = this.GameHelper.GetFriendshipForVillager(Game1.player, npc, Game1.player.friendshipData[npc.Name], metadata); + yield return new GenericField(this.GameHelper, this.Translate(L10n.Npc.CanRomance), friendship.IsSpouse ? this.Translate(L10n.Npc.CanRomanceMarried) : this.Stringify(friendship.CanDate)); + yield return new CharacterFriendshipField(this.GameHelper, this.Translate(L10n.Npc.Friendship), friendship, this.Text); + yield return new GenericField(this.GameHelper, this.Translate(L10n.Npc.TalkedToday), this.Stringify(friendship.TalkedToday)); + yield return new GenericField(this.GameHelper, this.Translate(L10n.Npc.GiftedToday), this.Stringify(friendship.GiftsToday > 0)); + if (!friendship.IsSpouse) + yield return new GenericField(this.GameHelper, this.Translate(L10n.Npc.GiftedThisWeek), this.Translate(L10n.Generic.Ratio, new { value = friendship.GiftsThisWeek, max = NPC.maxGiftsPerWeek })); + } + else + yield return new GenericField(this.GameHelper, this.Translate(L10n.Npc.Friendship), this.Translate(L10n.Npc.FriendshipNotMet)); + + // gift tastes + var giftTastes = this.GetGiftTastes(npc, metadata); + yield return new CharacterGiftTastesField(this.GameHelper, this.Translate(L10n.Npc.LovesGifts), giftTastes, GiftTaste.Love); + yield return new CharacterGiftTastesField(this.GameHelper, this.Translate(L10n.Npc.LikesGifts), giftTastes, GiftTaste.Like); + yield return new CharacterGiftTastesField(this.GameHelper, this.Translate(L10n.Npc.NeutralGifts), giftTastes, GiftTaste.Neutral); + } + break; + + case TargetType.Pet: + Pet pet = (Pet)npc; + yield return new CharacterFriendshipField(this.GameHelper, this.Translate(L10n.Pet.Love), this.GameHelper.GetFriendshipForPet(Game1.player, pet), this.Text); + yield return new GenericField(this.GameHelper, this.Translate(L10n.Pet.PettedToday), this.Stringify(this.Reflection.GetField(pet, "wasPetToday").GetValue())); + break; + + case TargetType.Monster: + // basic info + Monster monster = (Monster)npc; + yield return new GenericField(this.GameHelper, this.Translate(L10n.Monster.Invincible), this.Translate(L10n.Generic.Seconds, new { count = this.Reflection.GetField(monster, "invincibleCountdown").GetValue() }), hasValue: monster.isInvincible()); + yield return new PercentageBarField(this.GameHelper, this.Translate(L10n.Monster.Health), monster.Health, monster.MaxHealth, Color.Green, Color.Gray, this.Translate(L10n.Generic.PercentRatio, new { percent = Math.Round((monster.Health / (monster.MaxHealth * 1f) * 100)), value = monster.Health, max = monster.MaxHealth })); + yield return new ItemDropListField(this.GameHelper, this.Translate(L10n.Monster.Drops), this.GetMonsterDrops(monster), this.Text, defaultText: this.Translate(L10n.Monster.DropsNothing)); + yield return new GenericField(this.GameHelper, this.Translate(L10n.Monster.Experience), this.Stringify(monster.ExperienceGained)); + yield return new GenericField(this.GameHelper, this.Translate(L10n.Monster.Defence), this.Stringify(monster.resilience.Value)); + yield return new GenericField(this.GameHelper, this.Translate(L10n.Monster.Attack), this.Stringify(monster.DamageToFarmer)); + + // Adventure Guild quest + AdventureGuildQuestData adventureGuildQuest = metadata.GetAdventurerGuildQuest(monster.Name); + if (adventureGuildQuest != null) + { + int kills = adventureGuildQuest.Targets.Select(p => Game1.stats.getMonstersKilled(p)).Sum(); + yield return new GenericField(this.GameHelper, this.Translate(L10n.Monster.AdventureGuild), $"{this.Translate(kills >= adventureGuildQuest.RequiredKills ? L10n.Monster.AdventureGuildComplete : L10n.Monster.AdventureGuildIncomplete)} ({this.Translate(L10n.Monster.AdventureGuildProgress, new { count = kills, requiredCount = adventureGuildQuest.RequiredKills })})"); + } + break; + } + } + + /// Get raw debug data to display for this subject. + /// Provides metadata that's not available from the game data directly. + public override IEnumerable GetDebugFields(Metadata metadata) + { + NPC target = this.Target; + Pet pet = target as Pet; + + // pinned fields + yield return new GenericDebugField("facing direction", this.Stringify((FacingDirection)target.FacingDirection), pinned: true); + yield return new GenericDebugField("walking towards player", this.Stringify(target.IsWalkingTowardPlayer), pinned: true); + if (Game1.player.friendshipData.ContainsKey(target.Name)) + { + FriendshipModel friendship = this.GameHelper.GetFriendshipForVillager(Game1.player, target, Game1.player.friendshipData[target.Name], metadata); + yield return new GenericDebugField("friendship", $"{friendship.Points} (max {friendship.MaxPoints})", pinned: true); + } + if (pet != null) + yield return new GenericDebugField("friendship", $"{pet.friendshipTowardFarmer} of {Pet.maxFriendship})", pinned: true); + + // raw fields + foreach (IDebugField field in this.GetDebugFieldsFrom(target)) + yield return field; + } + + /// Get a monster's possible drops. + /// The monster whose drops to get. + private IEnumerable GetMonsterDrops(Monster monster) + { + int[] drops = monster.objectsToDrop.ToArray(); + ItemDropData[] possibleDrops = this.GameHelper.GetMonsterData().First(p => p.Name == monster.Name).Drops; + + return ( + from possibleDrop in possibleDrops + let isGuaranteed = drops.Contains(possibleDrop.ItemID) + select new ItemDropData(possibleDrop.ItemID, possibleDrop.MaxDrop, isGuaranteed ? 1 : possibleDrop.Probability) + ); + } + + /// Draw the subject portrait (if available). + /// The sprite batch being drawn. + /// The position at which to draw. + /// The size of the portrait to draw. + /// Returns true if a portrait was drawn, else false. + public override bool DrawPortrait(SpriteBatch spriteBatch, Vector2 position, Vector2 size) + { + NPC npc = this.Target; + + // use character portrait (most NPCs) + if (npc.Portrait != null) + { + spriteBatch.DrawSprite(npc.Portrait, new Rectangle(0, 0, NPC.portrait_width, NPC.portrait_height), position.X, position.Y, Color.White, size.X / NPC.portrait_width); + return true; + } + + // else draw sprite (e.g. for pets) + npc.Sprite.draw(spriteBatch, position, 1, 0, 0, Color.White, scale: size.X / npc.Sprite.getWidth()); + return true; + } + + + /********* + ** Private methods + *********/ + /// Get how much an NPC likes receiving each item as a gift. + /// The NPC. + /// Provides metadata that's not available from the game data directly. + private IDictionary GetGiftTastes(NPC npc, Metadata metadata) + { + return this.GameHelper.GetGiftTastes(npc, metadata) + .GroupBy(entry => entry.Value) // gift taste + .ToDictionary( + tasteGroup => tasteGroup.Key, // gift taste + tasteGroup => tasteGroup.Select(entry => (Item)entry.Key).ToArray() // items + ); + } + + /// Get the number of days until a child grows to the next stage. + /// The child's current growth stage. + /// The child's current age in days. + /// Returns a number of days, or -1 if the child won't grow any further. + /// Derived from . + private int GetDaysToNextChildGrowth(ChildAge stage, int daysOld) + { + switch (stage) + { + case ChildAge.Newborn: + return 13 - daysOld; + case ChildAge.Baby: + return 27 - daysOld; + case ChildAge.Crawler: + return 55 - daysOld; + default: + return -1; + } + } + } +} diff --git a/Mods/LookupAnything/Framework/Subjects/FarmAnimalSubject.cs b/Mods/LookupAnything/Framework/Subjects/FarmAnimalSubject.cs new file mode 100644 index 000000000..1dd7d55d8 --- /dev/null +++ b/Mods/LookupAnything/Framework/Subjects/FarmAnimalSubject.cs @@ -0,0 +1,133 @@ +using System; +using System.Collections.Generic; +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Graphics; +using Pathoschild.Stardew.LookupAnything.Framework.Constants; +using Pathoschild.Stardew.LookupAnything.Framework.DebugFields; +using Pathoschild.Stardew.LookupAnything.Framework.Fields; +using StardewModdingAPI; +using StardewModdingAPI.Utilities; +using StardewValley; + +namespace Pathoschild.Stardew.LookupAnything.Framework.Subjects +{ + /// Describes a farm animal. + internal class FarmAnimalSubject : BaseSubject + { + /********* + ** Fields + *********/ + /// The lookup target. + private readonly FarmAnimal Target; + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// Provides utility methods for interacting with the game code. + /// The lookup target. + /// Provides translations stored in the mod folder. + /// Reverse engineered from . + public FarmAnimalSubject(GameHelper gameHelper, FarmAnimal animal, ITranslationHelper translations) + : base(gameHelper, animal.displayName, null, animal.type.Value, translations) + { + this.Target = animal; + } + + /// Get the data to display for this subject. + /// Provides metadata that's not available from the game data directly. + public override IEnumerable GetData(Metadata metadata) + { + FarmAnimal animal = this.Target; + + // calculate maturity + bool isFullyGrown = animal.age.Value >= animal.ageWhenMature.Value; + int daysUntilGrown = 0; + SDate dayOfMaturity = null; + if (!isFullyGrown) + { + daysUntilGrown = animal.ageWhenMature.Value - animal.age.Value; + dayOfMaturity = SDate.Now().AddDays(daysUntilGrown); + } + + // yield fields + yield return new CharacterFriendshipField(this.GameHelper, this.Translate(L10n.Animal.Love), this.GameHelper.GetFriendshipForAnimal(Game1.player, animal, metadata), this.Text); + yield return new PercentageBarField(this.GameHelper, this.Translate(L10n.Animal.Happiness), animal.happiness.Value, byte.MaxValue, Color.Green, Color.Gray, this.Translate(L10n.Generic.Percent, new { percent = Math.Round(animal.happiness.Value / (metadata.Constants.AnimalMaxHappiness * 1f) * 100) })); + yield return new GenericField(this.GameHelper, this.Translate(L10n.Animal.Mood), animal.getMoodMessage()); + yield return new GenericField(this.GameHelper, this.Translate(L10n.Animal.Complaints), this.GetMoodReason(animal)); + yield return new ItemIconField(this.GameHelper, this.Translate(L10n.Animal.ProduceReady), animal.currentProduce.Value > 0 ? this.GameHelper.GetObjectBySpriteIndex(animal.currentProduce.Value) : null); + if (!isFullyGrown) + yield return new GenericField(this.GameHelper, this.Translate(L10n.Animal.Growth), $"{this.Translate(L10n.Generic.Days, new { count = daysUntilGrown })} ({this.Stringify(dayOfMaturity)})"); + yield return new GenericField(this.GameHelper, this.Translate(L10n.Animal.SellsFor), GenericField.GetSaleValueString(animal.getSellPrice(), 1, this.Text)); + } + + /// Get raw debug data to display for this subject. + /// Provides metadata that's not available from the game data directly. + public override IEnumerable GetDebugFields(Metadata metadata) + { + FarmAnimal target = this.Target; + + // pinned fields + yield return new GenericDebugField("age", $"{target.age} days", pinned: true); + yield return new GenericDebugField("friendship", $"{target.friendshipTowardFarmer} (max {metadata.Constants.AnimalMaxHappiness})", pinned: true); + yield return new GenericDebugField("fullness", this.Stringify(target.fullness.Value), pinned: true); + yield return new GenericDebugField("happiness", this.Stringify(target.happiness.Value), pinned: true); + + // raw fields + foreach (IDebugField field in this.GetDebugFieldsFrom(target)) + yield return field; + } + + /// Draw the subject portrait (if available). + /// The sprite batch being drawn. + /// The position at which to draw. + /// The size of the portrait to draw. + /// Returns true if a portrait was drawn, else false. + public override bool DrawPortrait(SpriteBatch spriteBatch, Vector2 position, Vector2 size) + { + FarmAnimal animal = this.Target; + animal.Sprite.draw(spriteBatch, position, 1, 0, 0, Color.White, scale: size.X / animal.Sprite.getWidth()); + return true; + } + + + /********* + ** Private methods + *********/ + /// Get a short explanation for the animal's current mod. + /// The farm animal. + private string GetMoodReason(FarmAnimal animal) + { + List factors = new List(); + + // winter without heat + if (Game1.IsWinter && Game1.currentLocation.numberOfObjectsWithName(Constant.ItemNames.Heater) <= 0) + factors.Add(this.Translate(L10n.Animal.ComplaintsNoHeater)); + + // mood + switch (animal.moodMessage.Value) + { + case FarmAnimal.newHome: + factors.Add(this.Translate(L10n.Animal.ComplaintsNewHome)); + break; + case FarmAnimal.hungry: + factors.Add(this.Translate(L10n.Animal.ComplaintsHungry)); + break; + case FarmAnimal.disturbedByDog: + factors.Add(this.Translate(L10n.Animal.ComplaintsWildAnimalAttack)); + break; + case FarmAnimal.leftOutAtNight: + factors.Add(this.Translate(L10n.Animal.ComplaintsLeftOut)); + break; + } + + // not pet + if (!animal.wasPet.Value) + factors.Add(this.Translate(L10n.Animal.ComplaintsNotPetted)); + + // return factors + return string.Join(", ", factors); + } + } +} diff --git a/Mods/LookupAnything/Framework/Subjects/FarmerSubject.cs b/Mods/LookupAnything/Framework/Subjects/FarmerSubject.cs new file mode 100644 index 000000000..10f08596c --- /dev/null +++ b/Mods/LookupAnything/Framework/Subjects/FarmerSubject.cs @@ -0,0 +1,202 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +//using System.Xml.Linq; +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Graphics; +using Pathoschild.Stardew.LookupAnything.Framework.Constants; +using Pathoschild.Stardew.LookupAnything.Framework.DebugFields; +using Pathoschild.Stardew.LookupAnything.Framework.Fields; +using StardewModdingAPI; +using StardewValley; +using StardewValley.Objects; +using SFarmer = StardewValley.Farmer; + +namespace Pathoschild.Stardew.LookupAnything.Framework.Subjects +{ + /// Describes a farmer (i.e. player). + internal class FarmerSubject : BaseSubject + { + /********* + ** Fields + *********/ + /// Simplifies access to private game code. + private readonly IReflectionHelper Reflection; + + /// The lookup target. + private readonly SFarmer Target; + + /// Whether this is being displayed on the load menu, before the save data is fully initialised. + private readonly bool IsLoadMenu; + + ///// The raw save data for this player, if is true. + //private readonly Lazy RawSaveData; + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// Provides utility methods for interacting with the game code. + /// The lookup target. + /// Provides translations stored in the mod folder. + /// Simplifies access to private game code. + /// Whether this is being displayed on the load menu, before the save data is fully initialised. + public FarmerSubject(GameHelper gameHelper, SFarmer farmer, ITranslationHelper translations, IReflectionHelper reflectionHelper, bool isLoadMenu = false) + : base(gameHelper, farmer.Name, null, translations.Get(L10n.Types.Player), translations) + { + this.Reflection = reflectionHelper; + this.Target = farmer; + this.IsLoadMenu = isLoadMenu; + //this.RawSaveData = isLoadMenu + // ? new Lazy(() => this.ReadSaveFile(farmer.slotName)) + // : null; + } + + /// Get the data to display for this subject. + /// Provides metadata that's not available from the game data directly. + public override IEnumerable GetData(Metadata metadata) + { + SFarmer target = this.Target; + + int maxSkillPoints = metadata.Constants.PlayerMaxSkillPoints; + int[] skillPointsPerLevel = metadata.Constants.PlayerSkillPointsPerLevel; + string luckSummary = this.Translate(L10n.Player.LuckSummary, new { percent = (Game1.dailyLuck >= 0 ? "+" : "") + Math.Round(Game1.dailyLuck * 100, 2) }); + + yield return new GenericField(this.GameHelper, this.Translate(L10n.Player.Gender), this.Translate(target.IsMale ? L10n.Player.GenderMale : L10n.Player.GenderFemale)); + yield return new GenericField(this.GameHelper, this.Translate(L10n.Player.FarmName), target.farmName.Value); + yield return new GenericField(this.GameHelper, this.Translate(L10n.Player.FarmMap), this.GetFarmType()); + yield return new GenericField(this.GameHelper, this.Translate(L10n.Player.FavoriteThing), target.favoriteThing.Value); + yield return new GenericField(this.GameHelper, this.Translate(L10n.Player.Spouse), this.GetSpouseName()); + yield return new SkillBarField(this.GameHelper, this.Translate(L10n.Player.FarmingSkill), target.experiencePoints[SFarmer.farmingSkill], maxSkillPoints, skillPointsPerLevel, this.Text); + yield return new SkillBarField(this.GameHelper, this.Translate(L10n.Player.MiningSkill), target.experiencePoints[SFarmer.miningSkill], maxSkillPoints, skillPointsPerLevel, this.Text); + yield return new SkillBarField(this.GameHelper, this.Translate(L10n.Player.ForagingSkill), target.experiencePoints[SFarmer.foragingSkill], maxSkillPoints, skillPointsPerLevel, this.Text); + yield return new SkillBarField(this.GameHelper, this.Translate(L10n.Player.FishingSkill), target.experiencePoints[SFarmer.fishingSkill], maxSkillPoints, skillPointsPerLevel, this.Text); + yield return new SkillBarField(this.GameHelper, this.Translate(L10n.Player.CombatSkill), target.experiencePoints[SFarmer.combatSkill], maxSkillPoints, skillPointsPerLevel, this.Text); + yield return new GenericField(this.GameHelper, this.Translate(L10n.Player.Luck), $"{this.GetSpiritLuckMessage()}{Environment.NewLine}({luckSummary})"); + } + + /// Get raw debug data to display for this subject. + /// Provides metadata that's not available from the game data directly. + public override IEnumerable GetDebugFields(Metadata metadata) + { + SFarmer target = this.Target; + + // pinned fields + yield return new GenericDebugField("immunity", target.immunity, pinned: true); + yield return new GenericDebugField("resilience", target.resilience, pinned: true); + yield return new GenericDebugField("magnetic radius", target.MagneticRadius, pinned: true); + + // raw fields + foreach (IDebugField field in this.GetDebugFieldsFrom(target)) + yield return field; + } + + /// Draw the subject portrait (if available). + /// The sprite batch being drawn. + /// The position at which to draw. + /// The size of the portrait to draw. + /// Returns true if a portrait was drawn, else false. + public override bool DrawPortrait(SpriteBatch spriteBatch, Vector2 position, Vector2 size) + { + SFarmer target = this.Target; + + if (this.IsLoadMenu) + target.FarmerRenderer.draw(spriteBatch, new FarmerSprite.AnimationFrame(0, 0, false, false), 0, new Rectangle(0, 0, 16, 32), position, Vector2.Zero, 0.8f, 2, Color.White, 0.0f, 1f, target); + else + { + FarmerSprite sprite = target.FarmerSprite; + target.FarmerRenderer.draw(spriteBatch, sprite.CurrentAnimationFrame, sprite.CurrentFrame, sprite.SourceRect, position, Vector2.Zero, 0.8f, Color.White, 0, 1f, target); + } + + return true; + } + + + /********* + ** Private methods + *********/ + /// Get a summary of the player's luck today. + /// Derived from . + private string GetSpiritLuckMessage() + { + // inject daily luck if not loaded yet + //if (this.IsLoadMenu) + //{ + // string rawDailyLuck = this.RawSaveData.Value?.Element("dailyLuck")?.Value; + // if (rawDailyLuck == null) + // return null; + + // Game1.dailyLuck = double.Parse(rawDailyLuck); + //} + + // get daily luck message + TV tv = new TV(); + return this.Reflection.GetMethod(tv, "getFortuneForecast").Invoke(); + } + + /// Get the human-readable farm type selected by the player. + private string GetFarmType() + { + // get farm type + int farmType = Game1.whichFarm; + //if (this.IsLoadMenu) + //{ + // string rawType = this.RawSaveData.Value?.Element("whichFarm")?.Value; + // farmType = rawType != null ? int.Parse(rawType) : -1; + //} + + // get type name + switch (farmType) + { + case -1: + return null; + + case Farm.combat_layout: + return Game1.content.LoadString("Strings\\UI:Character_FarmCombat").Split('_').FirstOrDefault(); + case Farm.default_layout: + return Game1.content.LoadString("Strings\\UI:Character_FarmStandard").Split('_').FirstOrDefault(); + case Farm.forest_layout: + return Game1.content.LoadString("Strings\\UI:Character_FarmForaging").Split('_').FirstOrDefault(); + case Farm.mountains_layout: + return Game1.content.LoadString("Strings\\UI:Character_FarmMining").Split('_').FirstOrDefault(); + case Farm.riverlands_layout: + return Game1.content.LoadString("Strings\\UI:Character_FarmFishing").Split('_').FirstOrDefault(); + + default: + return this.Translate(L10n.Player.FarmMapCustom); + } + } + + /// Get the player's spouse name, if they're married. + /// Returns the spouse name, or null if they're not married. + private string GetSpouseName() + { + if (this.IsLoadMenu) + return this.Target.spouse; + + long? spousePlayerID = this.Target.team.GetSpouse(this.Target.UniqueMultiplayerID); + SFarmer spousePlayer = spousePlayerID.HasValue ? Game1.getFarmerMaybeOffline(spousePlayerID.Value) : null; + + return spousePlayer?.displayName ?? Game1.player.getSpouse()?.displayName; + } + + /// Load the raw save file as an XML document. + /// The slot file to read. + //private XElement ReadSaveFile(string slotName) + //{ + // if (slotName == null) + // return null; // not available (e.g. farmhand) + + // // get file + // FileInfo file = new FileInfo(Path.Combine(StardewModdingAPI.Constants.SavesPath, slotName, slotName)); + // if (!file.Exists) + // return null; + + // // read contents + // string text = File.ReadAllText(file.FullName); + // return XElement.Parse(text); + //} + } +} diff --git a/Mods/LookupAnything/Framework/Subjects/FruitTreeSubject.cs b/Mods/LookupAnything/Framework/Subjects/FruitTreeSubject.cs new file mode 100644 index 000000000..11e6370e1 --- /dev/null +++ b/Mods/LookupAnything/Framework/Subjects/FruitTreeSubject.cs @@ -0,0 +1,218 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Graphics; +using Pathoschild.Stardew.LookupAnything.Framework.Constants; +using Pathoschild.Stardew.LookupAnything.Framework.DebugFields; +using Pathoschild.Stardew.LookupAnything.Framework.Fields; +using StardewModdingAPI; +using StardewModdingAPI.Utilities; +using StardewValley; +using StardewValley.TerrainFeatures; + +namespace Pathoschild.Stardew.LookupAnything.Framework.Subjects +{ + /// Describes a non-fruit tree. + internal class FruitTreeSubject : BaseSubject + { + /********* + ** Fields + *********/ + /// The underlying target. + private readonly FruitTree Target; + + /// The tree's tile position. + private readonly Vector2 Tile; + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// Provides utility methods for interacting with the game code. + /// The lookup target. + /// The tree's tile position. + /// Provides translations stored in the mod folder. + public FruitTreeSubject(GameHelper gameHelper, FruitTree tree, Vector2 tile, ITranslationHelper translations) + : base(gameHelper, translations.Get(L10n.FruitTree.Name, new { fruitName = gameHelper.GetObjectBySpriteIndex(tree.indexOfFruit.Value).DisplayName }), null, translations.Get(L10n.Types.FruitTree), translations) + { + this.Target = tree; + this.Tile = tile; + } + + /// Get the data to display for this subject. + /// Provides metadata that's not available from the game data directly. + /// Tree growth algorithm reverse engineered from . + public override IEnumerable GetData(Metadata metadata) + { + FruitTree tree = this.Target; + + // get basic info + bool isMature = tree.daysUntilMature.Value <= 0; + bool isDead = tree.stump.Value; + bool isStruckByLightning = tree.struckByLightningCountdown.Value > 0; + + // show next fruit + if (isMature && !isDead) + { + string label = this.Translate(L10n.FruitTree.NextFruit); + if (isStruckByLightning) + yield return new GenericField(this.GameHelper, label, this.Translate(L10n.FruitTree.NextFruitStruckByLightning, new { count = tree.struckByLightningCountdown })); + else if (Game1.currentSeason != tree.fruitSeason.Value && !tree.GreenHouseTree) + yield return new GenericField(this.GameHelper, label, this.Translate(L10n.FruitTree.NextFruitOutOfSeason)); + else if (tree.fruitsOnTree.Value == FruitTree.maxFruitsOnTrees) + yield return new GenericField(this.GameHelper, label, this.Translate(L10n.FruitTree.NextFruitMaxFruit)); + else + yield return new GenericField(this.GameHelper, label, this.Translate(L10n.Generic.Tomorrow)); + } + + // show growth data + if (!isMature) + { + SDate dayOfMaturity = SDate.Now().AddDays(tree.daysUntilMature.Value); + string grownOnDateText = this.Translate(L10n.FruitTree.GrowthSummary, new { date = this.Stringify(dayOfMaturity) }); + string daysUntilGrownText = this.Text.GetPlural(tree.daysUntilMature.Value, L10n.Generic.Tomorrow, L10n.Generic.InXDays).Tokens(new { count = tree.daysUntilMature }); + string growthText = $"{grownOnDateText} ({daysUntilGrownText})"; + + yield return new GenericField(this.GameHelper, this.Translate(L10n.FruitTree.NextFruit), this.Translate(L10n.FruitTree.NextFruitTooYoung)); + yield return new GenericField(this.GameHelper, this.Translate(L10n.FruitTree.Growth), growthText); + if (this.HasAdjacentObjects(this.Tile)) + yield return new GenericField(this.GameHelper, this.Translate(L10n.FruitTree.Complaints), this.Translate(L10n.FruitTree.ComplaintsAdjacentObjects)); + } + else + { + // get quality schedule + ItemQuality currentQuality = this.GetCurrentQuality(tree, metadata.Constants.FruitTreeQualityGrowthTime); + if (currentQuality == ItemQuality.Iridium) + yield return new GenericField(this.GameHelper, this.Translate(L10n.FruitTree.Quality), this.Translate(L10n.FruitTree.QualityNow, new { quality = this.Translate(L10n.For(currentQuality)) })); + else + { + string[] summary = this + .GetQualitySchedule(tree, currentQuality, metadata.Constants.FruitTreeQualityGrowthTime) + .Select(entry => + { + // read schedule + ItemQuality quality = entry.Key; + int daysLeft = entry.Value; + SDate date = SDate.Now().AddDays(daysLeft); + int yearOffset = date.Year - Game1.year; + + // generate summary line + string qualityName = this.Translate(L10n.For(quality)); + + if (daysLeft <= 0) + return "-" + this.Translate(L10n.FruitTree.QualityNow, new { quality = qualityName }); + + string line; + if (yearOffset == 0) + line = $"-{this.Translate(L10n.FruitTree.QualityOnDate, new { quality = qualityName, date = this.Stringify(date) })}"; + else if (yearOffset == 1) + line = $"-{this.Translate(L10n.FruitTree.QualityOnDateNextYear, new { quality = qualityName, date = this.Stringify(date) })}"; + else + line = $"-{this.Translate(L10n.FruitTree.QualityOnDate, new { quality = qualityName, date = this.Text.Stringify(date, withYear: true), year = date.Year })}"; + + line += $" ({this.Text.GetPlural(daysLeft, L10n.Generic.Tomorrow, L10n.Generic.InXDays).Tokens(new { count = daysLeft })})"; + + return line; + }) + .ToArray(); + + yield return new GenericField(this.GameHelper, this.Translate(L10n.FruitTree.Quality), string.Join(Environment.NewLine, summary)); + } + } + + // show season + yield return new GenericField(this.GameHelper, this.Translate(L10n.FruitTree.Season), this.Translate(L10n.FruitTree.SeasonSummary, new { season = this.Text.GetSeasonName(tree.fruitSeason.Value) })); + } + + /// Get raw debug data to display for this subject. + /// Provides metadata that's not available from the game data directly. + public override IEnumerable GetDebugFields(Metadata metadata) + { + FruitTree target = this.Target; + + // pinned fields + yield return new GenericDebugField("mature in", $"{target.daysUntilMature} days", pinned: true); + yield return new GenericDebugField("growth stage", target.growthStage.Value, pinned: true); + yield return new GenericDebugField("health", target.health.Value, pinned: true); + + // raw fields + foreach (IDebugField field in this.GetDebugFieldsFrom(target)) + yield return field; + } + + /// Draw the subject portrait (if available). + /// The sprite batch being drawn. + /// The position at which to draw. + /// The size of the portrait to draw. + /// Returns true if a portrait was drawn, else false. + public override bool DrawPortrait(SpriteBatch spriteBatch, Vector2 position, Vector2 size) + { + this.Target.drawInMenu(spriteBatch, position, Vector2.Zero, 1, 1); + return true; + } + + + /********* + ** Private methods + *********/ + /// Whether there are adjacent objects that prevent growth. + /// The tree's position in the current location. + private bool HasAdjacentObjects(Vector2 position) + { + GameLocation location = Game1.currentLocation; + return ( + from adjacentTile in Utility.getSurroundingTileLocationsArray(position) + let isOccupied = location.isTileOccupied(adjacentTile) + let isEmptyDirt = location.terrainFeatures.ContainsKey(adjacentTile) && location.terrainFeatures[adjacentTile] is HoeDirt && ((HoeDirt)location.terrainFeatures[adjacentTile])?.crop == null + select isOccupied && !isEmptyDirt + ).Any(p => p); + } + + /// Get the fruit quality produced by a tree. + /// The fruit tree. + /// The number of days before the tree begins producing a higher quality. + private ItemQuality GetCurrentQuality(FruitTree tree, int daysPerQuality) + { + int maturityLevel = Math.Max(0, Math.Min(3, -tree.daysUntilMature.Value / daysPerQuality)); + switch (maturityLevel) + { + case 0: + return ItemQuality.Normal; + case 1: + return ItemQuality.Silver; + case 2: + return ItemQuality.Gold; + case 3: + return ItemQuality.Iridium; + default: + throw new NotSupportedException($"Unexpected quality level {maturityLevel}."); + } + } + + /// Get a schedule indicating when a fruit tree will begin producing higher-quality fruit. + /// The fruit tree. + /// The current quality produced by the tree. + /// The number of days before the tree begins producing a higher quality. + private IEnumerable> GetQualitySchedule(FruitTree tree, ItemQuality currentQuality, int daysPerQuality) + { + if (tree.daysUntilMature.Value > 0) + yield break; // not mature yet + + // yield current + yield return new KeyValuePair(currentQuality, 0); + + // yield future qualities + int dayOffset = daysPerQuality - Math.Abs(tree.daysUntilMature.Value % daysPerQuality); + foreach (ItemQuality futureQuality in new[] { ItemQuality.Silver, ItemQuality.Gold, ItemQuality.Iridium }) + { + if (currentQuality >= futureQuality) + continue; + + yield return new KeyValuePair(futureQuality, dayOffset); + dayOffset += daysPerQuality; + } + } + } +} diff --git a/Mods/LookupAnything/Framework/Subjects/ISubject.cs b/Mods/LookupAnything/Framework/Subjects/ISubject.cs new file mode 100644 index 000000000..160d03236 --- /dev/null +++ b/Mods/LookupAnything/Framework/Subjects/ISubject.cs @@ -0,0 +1,43 @@ +using System.Collections.Generic; +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Graphics; +using Pathoschild.Stardew.LookupAnything.Framework.DebugFields; +using Pathoschild.Stardew.LookupAnything.Framework.Fields; + +namespace Pathoschild.Stardew.LookupAnything.Framework.Subjects +{ + /// Provides metadata about something in the game. + internal interface ISubject + { + /********* + ** Accessors + *********/ + /// The display name. + string Name { get; } + + /// The item description (if applicable). + string Description { get; } + + /// The item type (if applicable). + string Type { get; } + + + /********* + ** Public methods + *********/ + /// Get the data to display for this subject. + /// Provides metadata that's not available from the game data directly. + IEnumerable GetData(Metadata metadata); + + /// Get raw debug data to display for this subject. + /// Provides metadata that's not available from the game data directly. + IEnumerable GetDebugFields(Metadata metadata); + + /// Draw the subject portrait (if available). + /// The sprite batch being drawn. + /// The position at which to draw. + /// The size of the portrait to draw. + /// Returns true if a portrait was drawn, else false. + bool DrawPortrait(SpriteBatch spriteBatch, Vector2 position, Vector2 size); + } +} diff --git a/Mods/LookupAnything/Framework/Subjects/ItemSubject.cs b/Mods/LookupAnything/Framework/Subjects/ItemSubject.cs new file mode 100644 index 000000000..dce98f09d --- /dev/null +++ b/Mods/LookupAnything/Framework/Subjects/ItemSubject.cs @@ -0,0 +1,698 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Graphics; +using Pathoschild.Stardew.Common.DataParsers; +using Pathoschild.Stardew.LookupAnything.Framework.Constants; +using Pathoschild.Stardew.LookupAnything.Framework.Data; +using Pathoschild.Stardew.LookupAnything.Framework.DebugFields; +using Pathoschild.Stardew.LookupAnything.Framework.Fields; +using Pathoschild.Stardew.LookupAnything.Framework.Models; +using StardewModdingAPI; +using StardewModdingAPI.Utilities; +using StardewValley; +using StardewValley.Locations; +using StardewValley.Objects; +using SObject = StardewValley.Object; + +namespace Pathoschild.Stardew.LookupAnything.Framework.Subjects +{ + /// Describes a Stardew Valley item. + internal class ItemSubject : BaseSubject + { + /********* + ** Fields + *********/ + /// The lookup target. + private readonly Item Target; + + /// The menu item to render, which may be different from the item that was looked up (e.g. for fences). + private readonly Item DisplayItem; + + /// The crop which will drop the item (if applicable). + private readonly Crop FromCrop; + + /// The crop grown by this seed item (if applicable). + private readonly Crop SeedForCrop; + + /// The context of the object being looked up. + private readonly ObjectContext Context; + + /// Whether the item quality is known. This is true for an inventory item, false for a map object. + private readonly bool KnownQuality; + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// Provides utility methods for interacting with the game code. + /// Provides translations stored in the mod folder. + /// The underlying target. + /// The context of the object being looked up. + /// Whether the item quality is known. This is true for an inventory item, false for a map object. + /// The crop associated with the item (if applicable). + public ItemSubject(GameHelper gameHelper, ITranslationHelper translations, Item item, ObjectContext context, bool knownQuality, Crop fromCrop = null) + : base(gameHelper, translations) + { + this.Target = item; + this.DisplayItem = this.GetMenuItem(item); + this.FromCrop = fromCrop; + if ((item as SObject)?.Type == "Seeds") + this.SeedForCrop = new Crop(item.ParentSheetIndex, 0, 0); + this.Context = context; + this.KnownQuality = knownQuality; + this.Initialise(this.DisplayItem.DisplayName, this.GetDescription(this.DisplayItem), this.GetTypeValue(this.DisplayItem)); + } + + /// Get the data to display for this subject. + /// Provides metadata that's not available from the game data directly. + public override IEnumerable GetData(Metadata metadata) + { + // get data + Item item = this.Target; + SObject obj = item as SObject; + bool isCrop = this.FromCrop != null; + bool isSeed = this.SeedForCrop != null; + bool isDeadCrop = this.FromCrop?.dead.Value == true; + bool canSell = obj?.canBeShipped() == true || metadata.Shops.Any(shop => shop.BuysCategories.Contains(item.Category)); + + // get overrides + bool showInventoryFields = true; + { + ObjectData objData = metadata.GetObject(item, this.Context); + if (objData != null) + { + this.Name = objData.NameKey != null ? this.Translate(objData.NameKey) : this.Name; + this.Description = objData.DescriptionKey != null ? this.Translate(objData.DescriptionKey) : this.Description; + this.Type = objData.TypeKey != null ? this.Translate(objData.TypeKey) : this.Type; + showInventoryFields = objData.ShowInventoryFields ?? true; + } + } + + // don't show data for dead crop + if (isDeadCrop) + { + yield return new GenericField(this.GameHelper, this.Translate(L10n.Crop.Summary), this.Translate(L10n.Crop.SummaryDead)); + yield break; + } + + // crop fields + foreach (ICustomField field in this.GetCropFields(this.FromCrop ?? this.SeedForCrop, isSeed, metadata)) + yield return field; + + // indoor pot crop + if (obj is IndoorPot pot) + { + Crop potCrop = pot.hoeDirt.Value.crop; + if (potCrop != null) + { + Item drop = this.GameHelper.GetObjectBySpriteIndex(potCrop.indexOfHarvest.Value); + yield return new LinkField(this.GameHelper, this.Translate(L10n.Item.Contents), drop.DisplayName, () => new ItemSubject(this.GameHelper, this.Text, this.GameHelper.GetObjectBySpriteIndex(potCrop.indexOfHarvest.Value), ObjectContext.World, knownQuality: false, fromCrop: potCrop)); + } + } + + // machine output + foreach (ICustomField field in this.GetMachineOutputFields(obj, metadata)) + yield return field; + + // item + if (showInventoryFields) + { + // needed for + foreach (ICustomField field in this.GetNeededForFields(obj, metadata)) + yield return field; + + // sale data + if (canSell && !isCrop) + { + // sale price + string saleValueSummary = GenericField.GetSaleValueString(this.GetSaleValue(item, this.KnownQuality, metadata), item.Stack, this.Text); + yield return new GenericField(this.GameHelper, this.Translate(L10n.Item.SellsFor), saleValueSummary); + + // sell to + List buyers = new List(); + if (obj?.canBeShipped() == true) + buyers.Add(this.Translate(L10n.Item.SellsToShippingBox)); + buyers.AddRange( + from shop in metadata.Shops + where shop.BuysCategories.Contains(item.Category) + let name = this.Translate(shop.DisplayKey).ToString() + orderby name + select name + ); + yield return new GenericField(this.GameHelper, this.Translate(L10n.Item.SellsTo), string.Join(", ", buyers)); + } + + // gift tastes + var giftTastes = this.GetGiftTastes(item, metadata); + yield return new ItemGiftTastesField(this.GameHelper, this.Translate(L10n.Item.LovesThis), giftTastes, GiftTaste.Love); + yield return new ItemGiftTastesField(this.GameHelper, this.Translate(L10n.Item.LikesThis), giftTastes, GiftTaste.Like); + } + + // fence + if (item is Fence fence) + { + string healthLabel = this.Translate(L10n.Item.FenceHealth); + + // health + if (Game1.getFarm().isBuildingConstructed(Constant.BuildingNames.GoldClock)) + yield return new GenericField(this.GameHelper, healthLabel, this.Translate(L10n.Item.FenceHealthGoldClock)); + else + { + float maxHealth = fence.isGate.Value ? fence.maxHealth.Value * 2 : fence.maxHealth.Value; + float health = fence.health.Value / maxHealth; + double daysLeft = Math.Round(fence.health.Value * metadata.Constants.FenceDecayRate / 60 / 24); + double percent = Math.Round(health * 100); + yield return new PercentageBarField(this.GameHelper, healthLabel, (int)fence.health.Value, (int)maxHealth, Color.Green, Color.Red, this.Translate(L10n.Item.FenceHealthSummary, new { percent = percent, count = daysLeft })); + } + } + + // recipes + if (item.GetSpriteType() == ItemSpriteType.Object) + { + RecipeModel[] recipes = this.GameHelper.GetRecipesForIngredient(this.DisplayItem).ToArray(); + if (recipes.Any()) + yield return new RecipesForIngredientField(this.GameHelper, this.Translate(L10n.Item.Recipes), item, recipes, this.Text); + } + + // owned and times cooked/crafted + if (showInventoryFields && !isCrop && !(item is Tool)) + { + // owned + yield return new GenericField(this.GameHelper, this.Translate(L10n.Item.Owned), this.Translate(L10n.Item.OwnedSummary, new { count = this.GameHelper.CountOwnedItems(item) })); + + // times crafted + RecipeModel[] recipes = this.GameHelper + .GetRecipes() + .Where(recipe => recipe.OutputItemIndex == this.Target.ParentSheetIndex) + .ToArray(); + if (recipes.Any()) + { + string label = this.Translate(recipes.First().Type == RecipeType.Cooking ? L10n.Item.Cooked : L10n.Item.Crafted); + int timesCrafted = recipes.Sum(recipe => recipe.GetTimesCrafted(Game1.player)); + yield return new GenericField(this.GameHelper, label, this.Translate(L10n.Item.CraftedSummary, new { count = timesCrafted })); + } + } + + // see also crop + bool seeAlsoCrop = + isSeed + && item.ParentSheetIndex != this.SeedForCrop.indexOfHarvest.Value // skip seeds which produce themselves (e.g. coffee beans) + && !(item.ParentSheetIndex >= 495 && item.ParentSheetIndex <= 497) // skip random seasonal seeds + && item.ParentSheetIndex != 770; // skip mixed seeds + if (seeAlsoCrop) + { + Item drop = this.GameHelper.GetObjectBySpriteIndex(this.SeedForCrop.indexOfHarvest.Value); + yield return new LinkField(this.GameHelper, this.Translate(L10n.Item.SeeAlso), drop.DisplayName, () => new ItemSubject(this.GameHelper, this.Text, drop, ObjectContext.Inventory, false, this.SeedForCrop)); + } + } + + /// Get the data to display for this subject. + /// Provides metadata that's not available from the game data directly. + public override IEnumerable GetDebugFields(Metadata metadata) + { + Item target = this.Target; + SObject obj = target as SObject; + Crop crop = this.FromCrop ?? this.SeedForCrop; + + // pinned fields + yield return new GenericDebugField("item ID", target.ParentSheetIndex, pinned: true); + yield return new GenericDebugField("category", $"{target.Category} ({target.getCategoryName()})", pinned: true); + if (obj != null) + { + yield return new GenericDebugField("edibility", obj.Edibility, pinned: true); + yield return new GenericDebugField("item type", obj.Type, pinned: true); + } + if (crop != null) + { + yield return new GenericDebugField("crop fully grown", this.Stringify(crop.fullyGrown.Value), pinned: true); + yield return new GenericDebugField("crop phase", $"{crop.currentPhase} (day {crop.dayOfCurrentPhase} in phase)", pinned: true); + } + + // raw fields + foreach (IDebugField field in this.GetDebugFieldsFrom(target)) + yield return field; + if (crop != null) + { + foreach (IDebugField field in this.GetDebugFieldsFrom(crop)) + yield return new GenericDebugField($"crop::{field.Label}", field.Value, field.HasValue, field.IsPinned); + } + } + + /// Draw the subject portrait (if available). + /// The sprite batch being drawn. + /// The position at which to draw. + /// The size of the portrait to draw. + /// Returns true if a portrait was drawn, else false. + public override bool DrawPortrait(SpriteBatch spriteBatch, Vector2 position, Vector2 size) + { + Item item = this.DisplayItem; + + // draw stackable object + if ((item as SObject)?.Stack > 1) + { + // remove stack number (doesn't play well with clipped content) + SObject obj = (SObject)item; + obj = new SObject(obj.ParentSheetIndex, 1, obj.IsRecipe, obj.Price, obj.Quality); + obj.bigCraftable.Value = obj.bigCraftable.Value; + obj.drawInMenu(spriteBatch, position, 1); + return true; + } + + // draw generic item + item.drawInMenu(spriteBatch, position, 1); + return true; + } + + + /********* + ** Private methods + *********/ + /// Get the equivalent menu item for the specified target. (For example, the inventory item matching a fence object.) + /// The target item. + private Item GetMenuItem(Item item) + { + // fence + if (item is Fence fence) + { + // get equivalent object's sprite ID + FenceType fenceType = (FenceType)fence.whichType.Value; + int? spriteID = null; + if (fence.isGate.Value) + spriteID = 325; + else if (fenceType == FenceType.Wood) + spriteID = 322; + else if (fenceType == FenceType.Stone) + spriteID = 323; + else if (fenceType == FenceType.Iron) + spriteID = 324; + else if (fenceType == FenceType.Hardwood) + spriteID = 298; + + // get object + if (spriteID.HasValue) + return new SObject(spriteID.Value, 1); + } + + return item; + } + + /// Get the item description. + /// The item. + [SuppressMessage("ReSharper", "AssignmentIsFullyDiscarded", Justification = "Discarding the value is deliberate. We need to call the property to trigger the data load, but we don't actually need the result.")] + private string GetDescription(Item item) + { + try + { + _ = item.DisplayName; // force display name to load, which is needed to get the description outside the inventory for some reason + return item.getDescription(); + } + catch (KeyNotFoundException) + { + return null; // e.g. incubator + } + } + + /// Get the item type. + /// The item. + private string GetTypeValue(Item item) + { + string categoryName = item.getCategoryName(); + return !string.IsNullOrWhiteSpace(categoryName) + ? categoryName + : this.Translate(L10n.Types.Other); + } + + /// Get the custom fields for a crop. + /// The crop to represent. + /// Whether the crop being displayed is for an unplanted seed. + /// Provides metadata that's not available from the game data directly. + private IEnumerable GetCropFields(Crop crop, bool isSeed, Metadata metadata) + { + if (crop == null) + yield break; + + var data = new CropDataParser(crop); + + // add next-harvest field + if (!isSeed) + { + // get next harvest + SDate nextHarvest = data.GetNextHarvest(); + int daysToNextHarvest = nextHarvest.DaysSinceStart - SDate.Now().DaysSinceStart; + + // generate field + string summary; + if (data.CanHarvestNow) + summary = this.Translate(L10n.Crop.HarvestNow); + else if (!Game1.currentLocation.IsGreenhouse && !data.Seasons.Contains(nextHarvest.Season)) + summary = this.Translate(L10n.Crop.HarvestTooLate, new { date = this.Stringify(nextHarvest) }); + else + summary = $"{this.Stringify(nextHarvest)} ({this.Text.GetPlural(daysToNextHarvest, L10n.Generic.Tomorrow, L10n.Generic.InXDays).Tokens(new { count = daysToNextHarvest })})"; + + yield return new GenericField(this.GameHelper, this.Translate(L10n.Crop.Harvest), summary); + } + + // crop summary + { + List summary = new List(); + + // harvest + summary.Add(data.HasMultipleHarvests + ? this.Translate(L10n.Crop.SummaryHarvestOnce, new { daysToFirstHarvest = data.DaysToFirstHarvest }) + : this.Translate(L10n.Crop.SummaryHarvestMulti, new { daysToFirstHarvest = data.DaysToFirstHarvest, daysToNextHarvests = data.DaysToSubsequentHarvest }) + ); + + // seasons + summary.Add(this.Translate(L10n.Crop.SummarySeasons, new { seasons = string.Join(", ", this.Text.GetSeasonNames(data.Seasons)) })); + + // drops + if (crop.minHarvest != crop.maxHarvest && crop.chanceForExtraCrops.Value > 0) + summary.Add(this.Translate(L10n.Crop.SummaryDropsXToY, new { min = crop.minHarvest, max = crop.maxHarvest, percent = Math.Round(crop.chanceForExtraCrops.Value * 100, 2) })); + else if (crop.minHarvest.Value > 1) + summary.Add(this.Translate(L10n.Crop.SummaryDropsX, new { count = crop.minHarvest })); + + // crop sale price + Item drop = data.GetSampleDrop(); + summary.Add(this.Translate(L10n.Crop.SummarySellsFor, new { price = GenericField.GetSaleValueString(this.GetSaleValue(drop, false, metadata), 1, this.Text) })); + + // generate field + yield return new GenericField(this.GameHelper, this.Translate(L10n.Crop.Summary), "-" + string.Join($"{Environment.NewLine}-", summary)); + } + } + + /// Get the custom fields for machine output. + /// The machine whose output to represent. + /// Provides metadata that's not available from the game data directly. + private IEnumerable GetMachineOutputFields(SObject machine, Metadata metadata) + { + if (machine == null) + yield break; + + SObject heldObj = machine.heldObject.Value; + int minutesLeft = machine.MinutesUntilReady; + + // cask + if (machine is Cask cask) + { + // output item + if (heldObj != null) + { + ItemQuality curQuality = (ItemQuality)heldObj.Quality; + string curQualityName = this.Translate(L10n.For(curQuality)); + + // calculate aging schedule + float effectiveAge = metadata.Constants.CaskAgeSchedule.Values.Max() - cask.daysToMature.Value; + var schedule = + ( + from entry in metadata.Constants.CaskAgeSchedule + let quality = entry.Key + let baseDays = entry.Value + where baseDays > effectiveAge + orderby baseDays ascending + let daysLeft = (int)Math.Ceiling((baseDays - effectiveAge) / cask.agingRate.Value) + select new + { + Quality = quality, + DaysLeft = daysLeft, + HarvestDate = SDate.Now().AddDays(daysLeft) + } + ) + .ToArray(); + + // display fields + yield return new ItemIconField(this.GameHelper, this.Translate(L10n.Item.Contents), heldObj); + if (minutesLeft <= 0 || !schedule.Any()) + yield return new GenericField(this.GameHelper, this.Translate(L10n.Item.CaskSchedule), this.Translate(L10n.Item.CaskScheduleNow, new { quality = curQualityName })); + else + { + string scheduleStr = string.Join(Environment.NewLine, ( + from entry in schedule + let tokens = new { quality = this.Translate(L10n.For(entry.Quality)), count = entry.DaysLeft, date = entry.HarvestDate } + let str = this.Text.GetPlural(entry.DaysLeft, L10n.Item.CaskScheduleTomorrow, L10n.Item.CaskScheduleInXDays).Tokens(tokens) + select $"-{str}" + )); + yield return new GenericField(this.GameHelper, this.Translate(L10n.Item.CaskSchedule), this.Translate(L10n.Item.CaskSchedulePartial, new { quality = curQualityName }) + Environment.NewLine + scheduleStr); + } + } + } + + // crab pot + else if (machine is CrabPot pot) + { + // bait + if (heldObj == null) + { + if (pot.bait.Value != null) + yield return new ItemIconField(this.GameHelper, this.Translate(L10n.Item.CrabpotBait), pot.bait.Value); + else if (Game1.player.professions.Contains(11)) // no bait needed if luremaster + yield return new GenericField(this.GameHelper, this.Translate(L10n.Item.CrabpotBait), this.Translate(L10n.Item.CrabpotBaitNotNeeded)); + else + yield return new GenericField(this.GameHelper, this.Translate(L10n.Item.CrabpotBait), this.Translate(L10n.Item.CrabpotBaitNeeded)); + } + + // output item + if (heldObj != null) + { + string summary = this.Translate(L10n.Item.ContentsReady, new { name = heldObj.DisplayName }); + yield return new ItemIconField(this.GameHelper, this.Translate(L10n.Item.Contents), heldObj, summary); + } + } + + // furniture + else if (machine is Furniture) + { + // displayed item + if (heldObj != null) + { + string summary = this.Translate(L10n.Item.ContentsPlaced, new { name = heldObj.DisplayName }); + yield return new ItemIconField(this.GameHelper, this.Translate(L10n.Item.Contents), heldObj, summary); + } + } + + // auto-grabber + else if (machine.ParentSheetIndex == Constant.ObjectIndexes.AutoGrabber) + { + string readyText = this.Text.Stringify(heldObj is Chest output && output.items.Any()); + yield return new GenericField(this.GameHelper, this.Translate(L10n.Item.Contents), readyText); + } + + // generic machine + else + { + // output item + if (heldObj != null) + { + + string summary = minutesLeft <= 0 + ? this.Translate(L10n.Item.ContentsReady, new { name = heldObj.DisplayName }) + : this.Translate(L10n.Item.ContentsPartial, new { name = heldObj.DisplayName, time = this.Stringify(TimeSpan.FromMinutes(minutesLeft)) }); + yield return new ItemIconField(this.GameHelper, this.Translate(L10n.Item.Contents), heldObj, summary); + } + } + } + + /// Get the custom fields indicating what an item is needed for. + /// The machine whose output to represent. + /// Provides metadata that's not available from the game data directly. + private IEnumerable GetNeededForFields(SObject obj, Metadata metadata) + { + if (obj == null) + yield break; + + List neededFor = new List(); + + // fetch info + var recipes = + ( + from recipe in this.GameHelper.GetRecipesForIngredient(this.DisplayItem) + let item = recipe.CreateItem(this.DisplayItem) + orderby item.DisplayName + select new { recipe.Type, item.DisplayName, TimesCrafted = recipe.GetTimesCrafted(Game1.player) } + ) + .ToArray(); + + // bundles + { + string[] missingBundles = + ( + from bundle in this.GetUnfinishedBundles(obj) + orderby bundle.Area, bundle.DisplayName + let countNeeded = this.GetIngredientCountNeeded(bundle, obj) + select countNeeded > 1 + ? $"{this.GetTranslatedBundleArea(bundle)}: {bundle.DisplayName} x {countNeeded}" + : $"{this.GetTranslatedBundleArea(bundle)}: {bundle.DisplayName}" + ) + .ToArray(); + if (missingBundles.Any()) + neededFor.Add(this.Translate(L10n.Item.NeededForCommunityCenter, new { bundles = string.Join(", ", missingBundles) })); + } + + // polyculture achievement (ship 15 crops) + if (metadata.Constants.PolycultureCrops.Contains(obj.ParentSheetIndex)) + { + int needed = metadata.Constants.PolycultureCount - this.GameHelper.GetShipped(obj.ParentSheetIndex); + if (needed > 0) + neededFor.Add(this.Translate(L10n.Item.NeededForPolyculture, new { count = needed })); + } + + // full shipment achievement (ship every item) + if (this.GameHelper.GetFullShipmentAchievementItems().Any(p => p.Key == obj.ParentSheetIndex && !p.Value)) + neededFor.Add(this.Translate(L10n.Item.NeededForFullShipment)); + + // full collection achievement (donate every artifact) + LibraryMuseum museum = Game1.locations.OfType().FirstOrDefault(); + if (museum != null && museum.isItemSuitableForDonation(obj)) + neededFor.Add(this.Translate(L10n.Item.NeededForFullCollection)); + + // gourmet chef achievement (cook every recipe) + { + string[] uncookedNames = (from recipe in recipes where recipe.Type == RecipeType.Cooking && recipe.TimesCrafted <= 0 select recipe.DisplayName).ToArray(); + if (uncookedNames.Any()) + neededFor.Add(this.Translate(L10n.Item.NeededForGourmetChef, new { recipes = string.Join(", ", uncookedNames) })); + } + + // craft master achievement (craft every item) + { + string[] uncraftedNames = (from recipe in recipes where recipe.Type == RecipeType.Crafting && recipe.TimesCrafted <= 0 select recipe.DisplayName).ToArray(); + if (uncraftedNames.Any()) + neededFor.Add(this.Translate(L10n.Item.NeededForCraftMaster, new { recipes = string.Join(", ", uncraftedNames) })); + } + + // yield + if (neededFor.Any()) + yield return new GenericField(this.GameHelper, this.Translate(L10n.Item.NeededFor), string.Join(", ", neededFor)); + } + + /// Get unfinished bundles which require this item. + /// The item for which to find bundles. + private IEnumerable GetUnfinishedBundles(SObject item) + { + // no bundles for Joja members + if (Game1.player.hasOrWillReceiveMail(Constant.MailLetters.JojaMember)) + yield break; + + // get community center + CommunityCenter communityCenter = Game1.locations.OfType().First(); + if (communityCenter.areAllAreasComplete()) + yield break; + + // get bundles + if (item.GetType() == typeof(SObject) && !item.bigCraftable.Value) // avoid false positives with hats, furniture, etc + { + foreach (BundleModel bundle in this.GameHelper.GetBundleData()) + { + // ignore completed bundle + if (communityCenter.isBundleComplete(bundle.ID)) + continue; + + bool isMissing = this.GetIngredientsFromBundle(bundle, item).Any(p => this.IsIngredientNeeded(bundle, p)); + if (isMissing) + yield return bundle; + } + } + } + + /// Get the translated name for a bundle's area. + /// The bundle. + private string GetTranslatedBundleArea(BundleModel bundle) + { + switch (bundle.Area) + { + case "Pantry": + return this.Translate(L10n.BundleAreas.Pantry); + case "Crafts Room": + return this.Translate(L10n.BundleAreas.CraftsRoom); + case "Fish Tank": + return this.Translate(L10n.BundleAreas.FishTank); + case "Boiler Room": + return this.Translate(L10n.BundleAreas.BoilerRoom); + case "Vault": + return this.Translate(L10n.BundleAreas.Vault); + case "Bulletin Board": + return this.Translate(L10n.BundleAreas.BulletinBoard); + default: + return bundle.Area; + } + } + + /// Get the possible sale values for an item. + /// The item. + /// Whether the item quality is known. This is true for an inventory item, false for a map object. + /// Provides metadata that's not available from the game data directly. + private IDictionary GetSaleValue(Item item, bool qualityIsKnown, Metadata metadata) + { + // get sale price + // derived from ShopMenu::receiveLeftClick + int GetPrice(Item i) + { + int price = (i as SObject)?.sellToStorePrice() ?? (i.salePrice() / 2); + return price > 0 ? price : 0; + } + + // single quality + if (!this.GameHelper.CanHaveQuality(item) || qualityIsKnown) + { + ItemQuality quality = qualityIsKnown && item is SObject obj + ? (ItemQuality)obj.Quality + : ItemQuality.Normal; + + return new Dictionary { [quality] = GetPrice(item) }; + } + + // multiple qualities + int[] iridiumItems = metadata.Constants.ItemsWithIridiumQuality; + var prices = new Dictionary + { + [ItemQuality.Normal] = GetPrice(new SObject(item.ParentSheetIndex, 1)), + [ItemQuality.Silver] = GetPrice(new SObject(item.ParentSheetIndex, 1, quality: (int)ItemQuality.Silver)), + [ItemQuality.Gold] = GetPrice(new SObject(item.ParentSheetIndex, 1, quality: (int)ItemQuality.Gold)) + }; + if (item.GetSpriteType() == ItemSpriteType.Object && (iridiumItems.Contains(item.Category) || iridiumItems.Contains(item.ParentSheetIndex))) + prices[ItemQuality.Iridium] = GetPrice(new SObject(item.ParentSheetIndex, 1, quality: (int)ItemQuality.Iridium)); + return prices; + } + + /// Get how much each NPC likes receiving an item as a gift. + /// The potential gift item. + /// Provides metadata that's not available from the game data directly. + private IDictionary GetGiftTastes(Item item, Metadata metadata) + { + return this.GameHelper.GetGiftTastes(item, metadata) + .GroupBy(p => p.Value, p => p.Key.getName()) + .ToDictionary(p => p.Key, p => p.Distinct().ToArray()); + } + + /// Get bundle ingredients matching the given item. + /// The bundle to search. + /// The item to match. + private IEnumerable GetIngredientsFromBundle(BundleModel bundle, SObject item) + { + return bundle.Ingredients + .Where(p => p.ItemID == item.ParentSheetIndex && p.Quality <= (ItemQuality)item.Quality); // get ingredients + } + + /// Get whether an ingredient is still needed for a bundle. + /// The bundle to check. + /// The ingredient to check. + private bool IsIngredientNeeded(BundleModel bundle, BundleIngredientModel ingredient) + { + CommunityCenter communityCenter = Game1.locations.OfType().First(); + + return !communityCenter.bundles[bundle.ID][ingredient.Index]; + } + + /// Get the number of an ingredient needed for a bundle. + /// The bundle to check. + /// The ingredient to check. + private int GetIngredientCountNeeded(BundleModel bundle, SObject item) + { + return this.GetIngredientsFromBundle(bundle, item) + .Where(p => this.IsIngredientNeeded(bundle, p)) + .Sum(p => p.Stack); + } + } +} diff --git a/Mods/LookupAnything/Framework/Subjects/TileSubject.cs b/Mods/LookupAnything/Framework/Subjects/TileSubject.cs new file mode 100644 index 000000000..8a5ee791e --- /dev/null +++ b/Mods/LookupAnything/Framework/Subjects/TileSubject.cs @@ -0,0 +1,118 @@ +using System.Collections.Generic; +using System.Linq; +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Graphics; +using Pathoschild.Stardew.LookupAnything.Framework.Constants; +using Pathoschild.Stardew.LookupAnything.Framework.DebugFields; +using Pathoschild.Stardew.LookupAnything.Framework.Fields; +using StardewModdingAPI; +using StardewValley; +using xTile.Layers; +using xTile.ObjectModel; +using xTile.Tiles; + +namespace Pathoschild.Stardew.LookupAnything.Framework.Subjects +{ + /// Describes a map tile. + internal class TileSubject : BaseSubject + { + /********* + ** Fields + *********/ + /// The game location. + private readonly GameLocation Location; + + /// The tile position. + private readonly Vector2 Position; + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// Provides utility methods for interacting with the game code. + /// The game location. + /// The tile position. + /// Provides translations stored in the mod folder. + public TileSubject(GameHelper gameHelper, GameLocation location, Vector2 position, ITranslationHelper translations) + : base(gameHelper, $"({position.X}, {position.Y})", translations.Get(L10n.Tile.Description), translations.Get(L10n.Types.Tile), translations) + { + this.Location = location; + this.Position = position; + } + + /// Get the data to display for this subject. + /// Provides metadata that's not available from the game data directly. + public override IEnumerable GetData(Metadata metadata) + { + // yield map data + yield return new GenericField(this.GameHelper, this.Translate(L10n.Tile.MapName), this.Location.Name); + + // get tiles + Tile[] tiles = this.GetTiles(this.Location, this.Position).ToArray(); + if (!tiles.Any()) + { + yield return new GenericField(this.GameHelper, this.Translate(L10n.Tile.TileField), this.Translate(L10n.Tile.TileFieldNoneFound)); + yield break; + } + + // fetch tile data + foreach (Tile tile in tiles) + { + string layerName = tile.Layer.Id; + yield return new GenericField(this.GameHelper, this.Translate(L10n.Tile.TileIndex, new { layerName = layerName }), this.Stringify(tile.TileIndex)); + yield return new GenericField(this.GameHelper, this.Translate(L10n.Tile.TileSheet, new { layerName = layerName }), tile.TileSheet.ImageSource.Replace("\\", ": ").Replace("/", ": ")); + yield return new GenericField(this.GameHelper, this.Translate(L10n.Tile.BlendMode, new { layerName = layerName }), this.Stringify(tile.BlendMode)); + foreach (KeyValuePair property in tile.TileIndexProperties) + yield return new GenericField(this.GameHelper, this.Translate(L10n.Tile.IndexProperty, new { layerName = layerName, propertyName = property.Key }), property.Value); + foreach (KeyValuePair property in tile.Properties) + yield return new GenericField(this.GameHelper, this.Translate(L10n.Tile.TileProperty, new { layerName = layerName, propertyName = property.Key }), property.Value); + } + } + + /// Get raw debug data to display for this subject. + /// Provides metadata that's not available from the game data directly. + public override IEnumerable GetDebugFields(Metadata metadata) + { + Tile[] tiles = this.GetTiles(this.Location, this.Position).ToArray(); + foreach (Tile tile in tiles) + { + foreach (IDebugField field in this.GetDebugFieldsFrom(tile)) + yield return new GenericDebugField($"{tile.Layer.Id}::{field.Label}", field.Value, field.HasValue, field.IsPinned); + } + } + + /// Draw the subject portrait (if available). + /// The sprite batch being drawn. + /// The position at which to draw. + /// The size of the portrait to draw. + /// Returns true if a portrait was drawn, else false. + public override bool DrawPortrait(SpriteBatch spriteBatch, Vector2 position, Vector2 size) + { + return false; + } + + + /********* + ** Private methods + *********/ + /// Get the tiles at the specified tile position. + /// The game location. + /// The tile position. + private IEnumerable GetTiles(GameLocation location, Vector2 position) + { + if (position.X < 0 || position.Y < 0) + yield break; + + foreach (Layer layer in location.map.Layers) + { + if (position.X > layer.LayerWidth || position.Y > layer.LayerHeight) + continue; + + Tile tile = layer.Tiles[(int)position.X, (int)position.Y]; + if (tile != null) + yield return tile; + } + } + } +} diff --git a/Mods/LookupAnything/Framework/Subjects/TreeSubject.cs b/Mods/LookupAnything/Framework/Subjects/TreeSubject.cs new file mode 100644 index 000000000..ccdfb92a5 --- /dev/null +++ b/Mods/LookupAnything/Framework/Subjects/TreeSubject.cs @@ -0,0 +1,143 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Graphics; +using Pathoschild.Stardew.LookupAnything.Framework.Constants; +using Pathoschild.Stardew.LookupAnything.Framework.DebugFields; +using Pathoschild.Stardew.LookupAnything.Framework.Fields; +using StardewModdingAPI; +using StardewValley; +using StardewValley.TerrainFeatures; + +namespace Pathoschild.Stardew.LookupAnything.Framework.Subjects +{ + /// Describes a non-fruit tree. + internal class TreeSubject : BaseSubject + { + /********* + ** Fields + *********/ + /// The underlying target. + private readonly Tree Target; + + /// The tree's tile position. + private readonly Vector2 Tile; + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// Provides utility methods for interacting with the game code. + /// The lookup target. + /// The tree's tile position. + /// Provides translations stored in the mod folder. + public TreeSubject(GameHelper gameHelper, Tree tree, Vector2 tile, ITranslationHelper translations) + : base(gameHelper, TreeSubject.GetName(translations, tree), null, translations.Get(L10n.Types.Tree), translations) + { + this.Target = tree; + this.Tile = tile; + } + + /// Get the data to display for this subject. + /// Provides metadata that's not available from the game data directly. + /// Tree growth algorithm reverse engineered from . + public override IEnumerable GetData(Metadata metadata) + { + Tree tree = this.Target; + + // get growth stage + WildTreeGrowthStage stage = (WildTreeGrowthStage)Math.Min(tree.growthStage.Value, (int)WildTreeGrowthStage.Tree); + bool isFullyGrown = stage == WildTreeGrowthStage.Tree; + yield return new GenericField(this.GameHelper, this.Translate(L10n.Tree.Stage), isFullyGrown + ? this.Translate(L10n.Tree.StageDone) + : this.Translate(L10n.Tree.StagePartial, new { stageName = this.Translate(L10n.For(stage)), step = (int)stage, max = (int)WildTreeGrowthStage.Tree }) + ); + + // get growth scheduler + if (!isFullyGrown) + { + string label = this.Translate(L10n.Tree.NextGrowth); + if (Game1.IsWinter && !Game1.currentLocation.IsGreenhouse) + yield return new GenericField(this.GameHelper, label, this.Translate(L10n.Tree.NextGrowthWinter)); + else if (stage == WildTreeGrowthStage.SmallTree && this.HasAdjacentTrees(this.Tile)) + yield return new GenericField(this.GameHelper, label, this.Translate(L10n.Tree.NextGrowthAdjacentTrees)); + else + yield return new GenericField(this.GameHelper, label, this.Translate(L10n.Tree.NextGrowthRandom, new { stage = this.Translate(L10n.For(stage + 1)) })); + } + + // get seed + if (isFullyGrown) + yield return new GenericField(this.GameHelper, this.Translate(L10n.Tree.HasSeed), this.Stringify(tree.hasSeed.Value)); + } + + /// Get the data to display for this subject. + /// Provides metadata that's not available from the game data directly. + public override IEnumerable GetDebugFields(Metadata metadata) + { + Tree target = this.Target; + + // pinned fields + yield return new GenericDebugField("has seed", this.Stringify(target.hasSeed.Value), pinned: true); + yield return new GenericDebugField("growth stage", target.growthStage.Value, pinned: true); + yield return new GenericDebugField("health", target.health.Value, pinned: true); + + // raw fields + foreach (IDebugField field in this.GetDebugFieldsFrom(target)) + yield return field; + } + + /// Draw the subject portrait (if available). + /// The sprite batch being drawn. + /// The position at which to draw. + /// The size of the portrait to draw. + /// Returns true if a portrait was drawn, else false. + public override bool DrawPortrait(SpriteBatch spriteBatch, Vector2 position, Vector2 size) + { + this.Target.drawInMenu(spriteBatch, position, Vector2.Zero, 1, 1); + return true; + } + + + /********* + ** Private methods + *********/ + /// Get a display name for the tree. + /// Provides translations stored in the mod folder. + /// The tree object. + private static string GetName(ITranslationHelper translations, Tree tree) + { + TreeType type = (TreeType)tree.treeType.Value; + switch (type) + { + case TreeType.Maple: + return translations.Get(L10n.Tree.NameMaple); + case TreeType.Oak: + return translations.Get(L10n.Tree.NameOak); + case TreeType.Pine: + return translations.Get(L10n.Tree.NamePine); + case TreeType.Palm: + return translations.Get(L10n.Tree.NamePalm); + case TreeType.BigMushroom: + return translations.Get(L10n.Tree.NameBigMushroom); + default: + return translations.Get(L10n.Tree.NameUnknown); + } + } + + /// Whether there are adjacent trees that prevent growth. + /// The tree's position in the current location. + private bool HasAdjacentTrees(Vector2 position) + { + GameLocation location = Game1.currentLocation; + return ( + from adjacentTile in Utility.getSurroundingTileLocationsArray(position) + let otherTree = location.terrainFeatures.ContainsKey(adjacentTile) + ? location.terrainFeatures[adjacentTile] as Tree + : null + select otherTree != null && otherTree.growthStage.Value >= (int)WildTreeGrowthStage.SmallTree + ).Any(p => p); + } + } +} diff --git a/Mods/LookupAnything/Framework/TargetFactory.cs b/Mods/LookupAnything/Framework/TargetFactory.cs new file mode 100644 index 000000000..bf14c344b --- /dev/null +++ b/Mods/LookupAnything/Framework/TargetFactory.cs @@ -0,0 +1,552 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.Xna.Framework; +using Pathoschild.Stardew.LookupAnything.Framework.Constants; +using Pathoschild.Stardew.LookupAnything.Framework.Data; +using Pathoschild.Stardew.LookupAnything.Framework.Subjects; +using Pathoschild.Stardew.LookupAnything.Framework.Targets; +using StardewModdingAPI; +using StardewValley; +using StardewValley.Buildings; +using StardewValley.Characters; +using StardewValley.Locations; +using StardewValley.Menus; +using StardewValley.Monsters; +using StardewValley.TerrainFeatures; +using SObject = StardewValley.Object; + +namespace Pathoschild.Stardew.LookupAnything.Framework +{ + /// Finds and analyses lookup targets in the world. + internal class TargetFactory + { + /********* + ** Fields + *********/ + /// Provides metadata that's not available from the game data directly. + private readonly Metadata Metadata; + + /// Simplifies access to private game code. + private readonly IReflectionHelper Reflection; + + /// Provides translations stored in the mod folder. + private readonly ITranslationHelper Translations; + + /// Provides utility methods for interacting with the game code. + private readonly GameHelper GameHelper; + + + /********* + ** Public methods + *********/ + /**** + ** Constructors + ****/ + /// Construct an instance. + /// Provides metadata that's not available from the game data directly. + /// Provides translations stored in the mod folder. + /// Simplifies access to private game code. + /// Provides utility methods for interacting with the game code. + public TargetFactory(Metadata metadata, ITranslationHelper translations, IReflectionHelper reflection, GameHelper gameHelper) + { + this.Metadata = metadata; + this.Translations = translations; + this.Reflection = reflection; + this.GameHelper = gameHelper; + } + + /**** + ** Targets + ****/ + /// Get all potential lookup targets in the current location. + /// The current location. + /// The tile from which to search for targets. + /// Whether to allow matching the map tile itself. + public IEnumerable GetNearbyTargets(GameLocation location, Vector2 originTile, bool includeMapTile) + { + // NPCs + foreach (NPC npc in location.characters) + { + if (!this.GameHelper.CouldSpriteOccludeTile(npc.getTileLocation(), originTile)) + continue; + + TargetType type = TargetType.Unknown; + if (npc is Child || npc.isVillager()) + type = TargetType.Villager; + else if (npc is Horse) + type = TargetType.Horse; + else if (npc is Junimo) + type = TargetType.Junimo; + else if (npc is Pet) + type = TargetType.Pet; + else if (npc is Monster) + type = TargetType.Monster; + + yield return new CharacterTarget(this.GameHelper, type, npc, npc.getTileLocation(), this.Reflection); + } + + // animals + foreach (FarmAnimal animal in (location as Farm)?.animals.Values ?? (location as AnimalHouse)?.animals.Values ?? Enumerable.Empty()) + { + if (!this.GameHelper.CouldSpriteOccludeTile(animal.getTileLocation(), originTile)) + continue; + + yield return new FarmAnimalTarget(this.GameHelper, animal, animal.getTileLocation()); + } + + // map objects + foreach (KeyValuePair pair in location.objects.Pairs) + { + Vector2 spriteTile = pair.Key; + SObject obj = pair.Value; + + if (!this.GameHelper.CouldSpriteOccludeTile(spriteTile, originTile)) + continue; + + yield return new ObjectTarget(this.GameHelper, obj, spriteTile, this.Reflection); + } + + // furniture + if (location is DecoratableLocation decoratableLocation) + { + foreach (var furniture in decoratableLocation.furniture) + yield return new ObjectTarget(this.GameHelper, furniture, furniture.TileLocation, this.Reflection); + } + + // terrain features + foreach (KeyValuePair pair in location.terrainFeatures.Pairs) + { + Vector2 spriteTile = pair.Key; + TerrainFeature feature = pair.Value; + + if (!this.GameHelper.CouldSpriteOccludeTile(spriteTile, originTile)) + continue; + + if (feature is HoeDirt dirt && dirt.crop != null) + yield return new CropTarget(this.GameHelper, dirt, spriteTile, this.Reflection); + else if (feature is FruitTree fruitTree) + { + if (this.Reflection.GetField(feature, "alpha").GetValue() < 0.8f) + continue; // ignore when tree is faded out (so player can lookup things behind it) + yield return new FruitTreeTarget(this.GameHelper, fruitTree, spriteTile); + } + else if (feature is Tree wildTree) + { + if (this.Reflection.GetField(feature, "alpha").GetValue() < 0.8f) + continue; // ignore when tree is faded out (so player can lookup things behind it) + yield return new TreeTarget(this.GameHelper, wildTree, spriteTile, this.Reflection); + } + else + yield return new UnknownTarget(this.GameHelper, feature, spriteTile); + } + + // players + foreach (Farmer farmer in location.farmers) + { + if (!this.GameHelper.CouldSpriteOccludeTile(farmer.getTileLocation(), originTile)) + continue; + + yield return new FarmerTarget(this.GameHelper, farmer); + } + + // buildings + if (location is BuildableGameLocation buildableLocation) + { + foreach (Building building in buildableLocation.buildings) + { + if (!this.GameHelper.CouldSpriteOccludeTile(new Vector2(building.tileX.Value, building.tileY.Value + building.tilesHigh.Value), originTile, Constant.MaxBuildingTargetSpriteSize)) + continue; + + yield return new BuildingTarget(this.GameHelper, building); + } + } + + // tiles + if (includeMapTile) + yield return new TileTarget(this.GameHelper, originTile); + } + + /// Get the target on the specified tile. + /// The current location. + /// The tile to search. + /// Whether to allow matching the map tile itself. + public ITarget GetTargetFromTile(GameLocation location, Vector2 tile, bool includeMapTile) + { + return ( + from target in this.GetNearbyTargets(location, tile, includeMapTile) + where + target.Type != TargetType.Unknown + && target.IsAtTile(tile) + select target + ).FirstOrDefault(); + } + + /// Get the target at the specified coordinate. + /// The current location. + /// The tile to search. + /// The viewport-relative pixel coordinate to search. + /// Whether to allow matching the map tile itself. + public ITarget GetTargetFromScreenCoordinate(GameLocation location, Vector2 tile, Vector2 position, bool includeMapTile) + { + // get target sprites which might overlap cursor position (first approximation) + Rectangle tileArea = this.GameHelper.GetScreenCoordinatesFromTile(tile); + var candidates = ( + from target in this.GetNearbyTargets(location, tile, includeMapTile) + let spriteArea = target.GetWorldArea() + let isAtTile = target.IsAtTile(tile) + where + target.Type != TargetType.Unknown + && (isAtTile || spriteArea.Intersects(tileArea)) + orderby + target.Type != TargetType.Tile ? 0 : 1, // Tiles are always under anything else. + spriteArea.Y descending, // A higher Y value is closer to the foreground, and will occlude any sprites behind it. + spriteArea.X ascending // If two sprites at the same Y coordinate overlap, assume the left sprite occludes the right. + + select new { target, spriteArea, isAtTile } + ).ToArray(); + + // choose best match + return + candidates.FirstOrDefault(p => p.target.SpriteIntersectsPixel(tile, position, p.spriteArea))?.target // sprite pixel under cursor + ?? candidates.FirstOrDefault(p => p.isAtTile)?.target; // tile under cursor + } + + /**** + ** Subjects + ****/ + /// Get metadata for a Stardew object at the specified position. + /// The player performing the lookup. + /// The current location. + /// The lookup target mode. + /// Whether to allow matching the map tile itself. + public ISubject GetSubjectFrom(Farmer player, GameLocation location, LookupMode lookupMode, bool includeMapTile) + { + // get target + ITarget target; + switch (lookupMode) + { + // under cursor + case LookupMode.Cursor: + target = this.GetTargetFromScreenCoordinate(location, Game1.currentCursorTile, this.GameHelper.GetScreenCoordinatesFromCursor(), includeMapTile); + break; + + // in front of player + case LookupMode.FacingPlayer: + Vector2 tile = this.GetFacingTile(Game1.player); + target = this.GetTargetFromTile(location, tile, includeMapTile); + break; + + default: + throw new NotSupportedException($"Unknown lookup mode '{lookupMode}'."); + } + + // get subject + return target != null + ? this.GetSubjectFrom(target) + : null; + } + + /// Get metadata for a Stardew object represented by a target. + /// The target. + public ISubject GetSubjectFrom(ITarget target) + { + switch (target.Type) + { + // NPC + case TargetType.Horse: + case TargetType.Junimo: + case TargetType.Pet: + case TargetType.Monster: + case TargetType.Villager: + return new CharacterSubject(this.GameHelper, target.GetValue(), target.Type, this.Metadata, this.Translations, this.Reflection); + + // player + case TargetType.Farmer: + return new FarmerSubject(this.GameHelper, target.GetValue(), this.Translations, this.Reflection); + + // animal + case TargetType.FarmAnimal: + return new FarmAnimalSubject(this.GameHelper, target.GetValue(), this.Translations); + + // crop + case TargetType.Crop: + Crop crop = target.GetValue().crop; + return new ItemSubject(this.GameHelper, this.Translations, this.GameHelper.GetObjectBySpriteIndex(crop.indexOfHarvest.Value), ObjectContext.World, knownQuality: false, fromCrop: crop); + + // tree + case TargetType.FruitTree: + return new FruitTreeSubject(this.GameHelper, target.GetValue(), target.GetTile(), this.Translations); + case TargetType.WildTree: + return new TreeSubject(this.GameHelper, target.GetValue(), target.GetTile(), this.Translations); + + // object + case TargetType.InventoryItem: + return new ItemSubject(this.GameHelper, this.Translations, target.GetValue(), ObjectContext.Inventory, knownQuality: false); + case TargetType.Object: + return new ItemSubject(this.GameHelper, this.Translations, target.GetValue(), ObjectContext.World, knownQuality: false); + + // building + case TargetType.Building: + return new BuildingSubject(this.GameHelper, this.Metadata, target.GetValue(), target.GetSpritesheetArea(), this.Translations, this.Reflection); + + // tile + case TargetType.Tile: + return new TileSubject(this.GameHelper, Game1.currentLocation, target.GetValue(), this.Translations); + } + + return null; + } + + /// Get metadata for a menu element at the specified position. + /// The active menu. + /// The cursor's viewport-relative coordinates. + public ISubject GetSubjectFrom(IClickableMenu menu, Vector2 cursorPos) + { + switch (menu) + { + // calendar + case Billboard billboard: + { + // get target day + int selectedDay = -1; + for (int i = 0; i < billboard.calendarDays.Count; i++) + { + if (billboard.calendarDays[i].containsPoint((int)cursorPos.X, (int)cursorPos.Y)) + { + selectedDay = i + 1; + break; + } + } + if (selectedDay == -1) + return null; + + // get villager with a birthday on that date + NPC target = this.GameHelper.GetAllCharacters().FirstOrDefault(p => p.Birthday_Season == Game1.currentSeason && p.Birthday_Day == selectedDay); + if (target != null) + return new CharacterSubject(this.GameHelper, target, TargetType.Villager, this.Metadata, this.Translations, this.Reflection); + } + break; + + // chest + case MenuWithInventory inventoryMenu: + { + Item item = inventoryMenu.hoveredItem; + if (item != null) + return new ItemSubject(this.GameHelper, this.Translations, item, ObjectContext.Inventory, knownQuality: true); + } + break; + + // inventory + case GameMenu gameMenu: + { + List tabs = this.Reflection.GetField>(gameMenu, "pages").GetValue(); + IClickableMenu curTab = tabs[gameMenu.currentTab]; + switch (curTab) + { + // inventory + case InventoryPage _: + { + Item item = this.Reflection.GetField(curTab, "hoveredItem").GetValue(); + if (item != null) + return new ItemSubject(this.GameHelper, this.Translations, item, ObjectContext.Inventory, knownQuality: true); + } + break; + + // collections menu + // derived from CollectionsPage::performHoverAction + case CollectionsPage collectionsTab: + { + int currentTab = this.Reflection.GetField(curTab, "currentTab").GetValue(); + if (currentTab == CollectionsPage.achievementsTab || currentTab == CollectionsPage.secretNotesTab) + break; + + int currentPage = this.Reflection.GetField(curTab, "currentPage").GetValue(); + + foreach (ClickableTextureComponent component in collectionsTab.collections[currentTab][currentPage]) + { + if (component.containsPoint((int)cursorPos.X, (int)cursorPos.Y)) + { + int itemID = Convert.ToInt32(component.name.Split(' ')[0]); + SObject obj = new SObject(itemID, 1); + return new ItemSubject(this.GameHelper, this.Translations, obj, ObjectContext.Inventory, knownQuality: false); + } + } + } + break; + + // cooking or crafting menu + case CraftingPage _: + { + // player inventory item + Item item = this.Reflection.GetField(curTab, "hoverItem").GetValue(); + if (item != null) + return new ItemSubject(this.GameHelper, this.Translations, item, ObjectContext.Inventory, knownQuality: true); + + // crafting recipe + CraftingRecipe recipe = this.Reflection.GetField(curTab, "hoverRecipe").GetValue(); + if (recipe != null) + return new ItemSubject(this.GameHelper, this.Translations, recipe.createItem(), ObjectContext.Inventory, knownQuality: true); + } + break; + + // social tab + case SocialPage _: + { + // get villagers on current page + int scrollOffset = this.Reflection.GetField(curTab, "slotPosition").GetValue(); + ClickableTextureComponent[] entries = this.Reflection + .GetField>(curTab, "sprites") + .GetValue() + .Skip(scrollOffset) + .ToArray(); + + // find hovered villager + ClickableTextureComponent entry = entries.FirstOrDefault(p => p.containsPoint((int)cursorPos.X, (int)cursorPos.Y)); + if (entry != null) + { + int index = Array.IndexOf(entries, entry) + scrollOffset; + object socialID = this.Reflection.GetField>(curTab, "names").GetValue()[index]; + if (socialID is long playerID) + { + Farmer player = Game1.getFarmer(playerID); + return new FarmerSubject(this.GameHelper, player, this.Translations, this.Reflection); + } + else if (socialID is string villagerName) + { + NPC npc = this.GameHelper.GetAllCharacters().FirstOrDefault(p => p.isVillager() && p.Name == villagerName); + if (npc != null) + return new CharacterSubject(this.GameHelper, npc, TargetType.Villager, this.Metadata, this.Translations, this.Reflection); + } + } + } + break; + } + } + break; + + // Community Center bundle menu + case JunimoNoteMenu bundleMenu: + { + // hovered inventory item + { + Item item = this.Reflection.GetField(menu, "hoveredItem").GetValue(); + if (item != null) + return new ItemSubject(this.GameHelper, this.Translations, item, ObjectContext.Inventory, knownQuality: true); + } + + // list of required ingredients + for (int i = 0; i < bundleMenu.ingredientList.Count; i++) + { + if (bundleMenu.ingredientList[i].containsPoint((int)cursorPos.X, (int)cursorPos.Y)) + { + Bundle bundle = this.Reflection.GetField(bundleMenu, "currentPageBundle").GetValue(); + var ingredient = bundle.ingredients[i]; + var item = this.GameHelper.GetObjectBySpriteIndex(ingredient.index, ingredient.stack); + item.Quality = ingredient.quality; + return new ItemSubject(this.GameHelper, this.Translations, item, ObjectContext.Inventory, knownQuality: true); + } + } + + // list of submitted ingredients + foreach (ClickableTextureComponent slot in bundleMenu.ingredientSlots) + { + if (slot.item != null && slot.containsPoint((int)cursorPos.X, (int)cursorPos.Y)) + return new ItemSubject(this.GameHelper, this.Translations, slot.item, ObjectContext.Inventory, knownQuality: true); + } + } + break; + + // kitchen + case CraftingPage _: + { + CraftingRecipe recipe = this.Reflection.GetField(menu, "hoverRecipe").GetValue(); + if (recipe != null) + return new ItemSubject(this.GameHelper, this.Translations, recipe.createItem(), ObjectContext.Inventory, knownQuality: true); + } + break; + + // load menu + case TitleMenu _ when TitleMenu.subMenu is LoadGameMenu loadMenu: + { + //ClickableComponent button = loadMenu.slotButtons.FirstOrDefault(p => p.containsPoint((int)cursorPos.X, (int)cursorPos.Y)); + //if (button != null) + //{ + // int index = this.Reflection.GetField(loadMenu, "currentItemIndex").GetValue() + int.Parse(button.name); + // var slots = this.Reflection.GetProperty>(loadMenu, "MenuSlots").GetValue(); + // LoadGameMenu.SaveFileSlot slot = slots[index] as LoadGameMenu.SaveFileSlot; + // if (slot?.Farmer != null) + // return new FarmerSubject(this.GameHelper, slot.Farmer, this.Translations, this.Reflection, isLoadMenu: true); + //} + } + break; + + // shop + case ShopMenu _: + { + Item item = this.Reflection.GetField(menu, "hoveredItem").GetValue(); + if (item != null) + return new ItemSubject(this.GameHelper, this.Translations, item, ObjectContext.Inventory, knownQuality: true); + } + break; + + // toolbar + case Toolbar _: + { + // find hovered slot + List slots = this.Reflection.GetField>(menu, "buttons").GetValue(); + ClickableComponent hoveredSlot = slots.FirstOrDefault(slot => slot.containsPoint((int)cursorPos.X, (int)cursorPos.Y)); + if (hoveredSlot == null) + return null; + + // get inventory index + int index = slots.IndexOf(hoveredSlot); + if (index < 0 || index > Game1.player.Items.Count - 1) + return null; + + // get hovered item + Item item = Game1.player.Items[index]; + if (item != null) + return new ItemSubject(this.GameHelper, this.Translations, item, ObjectContext.Inventory, knownQuality: true); + } + break; + + // by convention (for mod support) + default: + { + Item item = this.Reflection.GetField(menu, "HoveredItem", required: false)?.GetValue(); // ChestsAnywhere + if (item != null) + return new ItemSubject(this.GameHelper, this.Translations, item, ObjectContext.Inventory, knownQuality: true); + } + break; + } + + return null; + } + + + /********* + ** Private methods + *********/ + /// Get the tile the player is facing. + /// The player to check. + private Vector2 GetFacingTile(Farmer player) + { + Vector2 tile = player.getTileLocation(); + FacingDirection direction = (FacingDirection)player.FacingDirection; + switch (direction) + { + case FacingDirection.Up: + return tile + new Vector2(0, -1); + case FacingDirection.Right: + return tile + new Vector2(1, 0); + case FacingDirection.Down: + return tile + new Vector2(0, 1); + case FacingDirection.Left: + return tile + new Vector2(-1, 0); + default: + throw new NotSupportedException($"Unknown facing direction {direction}"); + } + } + } +} diff --git a/Mods/LookupAnything/Framework/TargetType.cs b/Mods/LookupAnything/Framework/TargetType.cs new file mode 100644 index 000000000..f303919ab --- /dev/null +++ b/Mods/LookupAnything/Framework/TargetType.cs @@ -0,0 +1,66 @@ +namespace Pathoschild.Stardew.LookupAnything.Framework +{ + /// The type of an in-game object for the mod's purposes. + internal enum TargetType + { + /// The target type isn't recognised by the mod. + Unknown, + + /**** + ** NPCs + ****/ + /// A farm animal. + FarmAnimal, + + /// A player's horse. + Horse, + + /// A forest spirit. + Junimo, + + /// A hostile monster NPC. + Monster, + + /// A player's cat or dog. + Pet, + + /// A player character. + Farmer, + + /// A passive character NPC (including the dwarf and Krobus). + Villager, + + /**** + ** Objects + ****/ + /// An inventory item. + InventoryItem, + + /// A map object. + Object, + + /**** + ** Terrain features + ****/ + /// A fruit tree. + FruitTree, + + /// A non-fruit tree. + WildTree, + + /// A terrain feature consisting of a tilled plot of land with a planted crop. + Crop, + + /// A generic terrain feature. + TerrainFeature, + + /**** + ** Other + ****/ + /// A constructed building. + Building, + + /// A map tile. + Tile + } +} diff --git a/Mods/LookupAnything/Framework/Targets/BuildingTarget.cs b/Mods/LookupAnything/Framework/Targets/BuildingTarget.cs new file mode 100644 index 000000000..f0f611f2e --- /dev/null +++ b/Mods/LookupAnything/Framework/Targets/BuildingTarget.cs @@ -0,0 +1,96 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.Xna.Framework; +using StardewValley; +using StardewValley.Buildings; + +namespace Pathoschild.Stardew.LookupAnything.Framework.Targets +{ + /// Positional metadata about a constructed building. + internal class BuildingTarget : GenericTarget + { + /********* + ** Fields + *********/ + /// The building's tile area. + private readonly Rectangle TileArea; + + /// Spritesheet areas to treat as part of the sprite even if they're transparent, indexed by value. + private static readonly IDictionary SpriteCollisionOverrides = new Dictionary + { + ["Barn"] = new[] { new Rectangle(48, 90, 32, 22) }, // animal door + ["Big Barn"] = new[] { new Rectangle(64, 90, 32, 22) }, // animal door + ["Deluxe Barn"] = new[] { new Rectangle(64, 90, 32, 22) }, // animal door + + ["Coop"] = new[] { new Rectangle(33, 97, 14, 15) }, + ["Big Coop"] = new[] { new Rectangle(33, 97, 14, 15) }, + ["Deluxe Coop"] = new[] { new Rectangle(33, 97, 14, 15) } + }; + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// Provides utility methods for interacting with the game code. + /// The underlying in-game entity. + public BuildingTarget(GameHelper gameHelper, Building value) + : base(gameHelper, TargetType.Building, value, new Vector2(value.tileX.Value, value.tileY.Value)) + { + this.TileArea = new Rectangle(value.tileX.Value, value.tileY.Value, value.tilesWide.Value, value.tilesHigh.Value); + } + + /// Get the sprite's source rectangle within its texture. + public override Rectangle GetSpritesheetArea() + { + return this.Value.getSourceRectForMenu(); + } + + /// Get a rectangle which roughly bounds the visible sprite relative the viewport. + public override Rectangle GetWorldArea() + { + // get source rectangle adjusted for zoom + Rectangle sourceRect = this.GetSpritesheetArea(); + sourceRect = new Rectangle(sourceRect.X * Game1.pixelZoom, sourceRect.Y * Game1.pixelZoom, sourceRect.Width * Game1.pixelZoom, sourceRect.Height * Game1.pixelZoom); + + // get foundation area adjusted for zoom + Rectangle bounds = new Rectangle( + x: this.TileArea.X * Game1.tileSize, + y: this.TileArea.Y * Game1.tileSize, + width: this.TileArea.Width * Game1.tileSize, + height: this.TileArea.Height * Game1.tileSize + ); + + // get combined sprite area adjusted for viewport + return new Rectangle( + x: bounds.X - (sourceRect.Width - bounds.Width + 1) - Game1.viewport.X, + y: bounds.Y - (sourceRect.Height - bounds.Height + 1) - Game1.viewport.Y, + width: Math.Max(bounds.Width, sourceRect.Width), + height: Math.Max(bounds.Height, sourceRect.Height) + ); + } + + /// Get whether the visible sprite intersects the specified coordinate. This can be an expensive test. + /// The tile to search. + /// The viewport-relative coordinates to search. + /// The approximate sprite area calculated by . + public override bool SpriteIntersectsPixel(Vector2 tile, Vector2 position, Rectangle spriteArea) + { + Rectangle sourceRect = this.GetSpritesheetArea(); + + // check sprite + if (base.SpriteIntersectsPixel(tile, position, spriteArea, this.Value.texture.Value, sourceRect)) + return true; + + // special exceptions + if (BuildingTarget.SpriteCollisionOverrides.TryGetValue(this.Value.buildingType.Value, out Rectangle[] overrides)) + { + Vector2 spriteSheetPosition = this.GameHelper.GetSpriteSheetCoordinates(position, spriteArea, sourceRect); + return overrides.Any(p => p.Contains((int)spriteSheetPosition.X, (int)spriteSheetPosition.Y)); + } + + return false; + } + } +} diff --git a/Mods/LookupAnything/Framework/Targets/CharacterTarget.cs b/Mods/LookupAnything/Framework/Targets/CharacterTarget.cs new file mode 100644 index 000000000..8c4ce65cf --- /dev/null +++ b/Mods/LookupAnything/Framework/Targets/CharacterTarget.cs @@ -0,0 +1,93 @@ +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Graphics; +using StardewModdingAPI; +using StardewValley; +using StardewValley.Monsters; + +namespace Pathoschild.Stardew.LookupAnything.Framework.Targets +{ + /// Positional metadata about an NPC. + internal class CharacterTarget : GenericTarget + { + /********* + ** Fields + *********/ + /// Simplifies access to private game code. + private readonly IReflectionHelper Reflection; + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// Provides utility methods for interacting with the game code. + /// The target type. + /// The underlying in-game entity. + /// The object's tile position in the current location (if applicable). + /// Simplifies access to private game code. + public CharacterTarget(GameHelper gameHelper, TargetType type, NPC value, Vector2? tilePosition, IReflectionHelper reflectionHelper) + : base(gameHelper, type, value, tilePosition) + { + this.Reflection = reflectionHelper; + } + + /// Get the sprite's source rectangle within its texture. + public override Rectangle GetSpritesheetArea() + { + return this.Value.Sprite.SourceRect; + } + + /// Get a rectangle which roughly bounds the visible sprite relative the viewport. + public override Rectangle GetWorldArea() + { + NPC npc = this.Value; + AnimatedSprite sprite = npc.Sprite; + var boundingBox = npc.GetBoundingBox(); // the 'occupied' area at the NPC's feet + + // calculate y origin + float yOrigin; + if (npc is DustSpirit) + yOrigin = boundingBox.Bottom; + else if (npc is Bat) + yOrigin = boundingBox.Center.Y; + else if (npc is Bug) + yOrigin = boundingBox.Top - sprite.SpriteHeight * Game1.pixelZoom + (float)(System.Math.Sin(Game1.currentGameTime.TotalGameTime.Milliseconds / 1000.0 * (2.0 * System.Math.PI)) * 10.0); + else if (npc is SquidKid squidKid) + { + int yOffset = this.Reflection.GetField(squidKid, "yOffset").GetValue(); + yOrigin = boundingBox.Bottom - sprite.SpriteHeight * Game1.pixelZoom + yOffset; + } + else + yOrigin = boundingBox.Top; + + // get bounding box + int height = sprite.SpriteHeight * Game1.pixelZoom; + int width = sprite.SpriteWidth * Game1.pixelZoom; + float x = boundingBox.Center.X - (width / 2); + float y = yOrigin + boundingBox.Height - height + npc.yJumpOffset * 2; + + return new Rectangle((int)(x - Game1.viewport.X), (int)(y - Game1.viewport.Y), width, height); + } + + /// Get whether the visible sprite intersects the specified coordinate. This can be an expensive test. + /// The tile to search. + /// The viewport-relative coordinates to search. + /// The approximate sprite area calculated by . + public override bool SpriteIntersectsPixel(Vector2 tile, Vector2 position, Rectangle spriteArea) + { + NPC npc = this.Value; + AnimatedSprite sprite = npc.Sprite; + + // allow any part of the sprite area for monsters + // (Monsters have complicated and inconsistent sprite behaviour which isn't really + // worth reverse-engineering, and sometimes move around so much that a pixel-perfect + // check is inconvenient anyway.) + if (npc is Monster) + return spriteArea.Contains((int)position.X, (int)position.Y); + + // check sprite for non-monster NPCs + SpriteEffects spriteEffects = npc.flip ? SpriteEffects.FlipHorizontally : SpriteEffects.None; + return this.SpriteIntersectsPixel(tile, position, spriteArea, sprite.Texture, sprite.sourceRect, spriteEffects); + } + } +} diff --git a/Mods/LookupAnything/Framework/Targets/CropTarget.cs b/Mods/LookupAnything/Framework/Targets/CropTarget.cs new file mode 100644 index 000000000..f32835f62 --- /dev/null +++ b/Mods/LookupAnything/Framework/Targets/CropTarget.cs @@ -0,0 +1,74 @@ +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Graphics; +using StardewModdingAPI; +using StardewValley; +using StardewValley.TerrainFeatures; + +namespace Pathoschild.Stardew.LookupAnything.Framework.Targets +{ + /// Positional metadata about a crop. + internal class CropTarget : GenericTarget + { + /********* + ** Fields + *********/ + /// Simplifies access to private game code. + private readonly IReflectionHelper Reflection; + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// Provides utility methods for interacting with the game code. + /// The underlying in-game entity. + /// The object's tile position in the current location (if applicable). + /// Simplifies access to private game code. + public CropTarget(GameHelper gameHelper, HoeDirt value, Vector2? tilePosition, IReflectionHelper reflectionHelper) + : base(gameHelper, TargetType.Crop, value, tilePosition) + { + this.Reflection = reflectionHelper; + } + + /// Get the sprite's source rectangle within its texture. + public override Rectangle GetSpritesheetArea() + { + return this.Reflection.GetMethod(this.Value.crop, "getSourceRect").Invoke(this.Value.crop.rowInSpriteSheet.Value); + } + + /// Get a rectangle which roughly bounds the visible sprite relative the viewport. + public override Rectangle GetWorldArea() + { + return this.GetSpriteArea(this.Value.getBoundingBox(this.GetTile()), this.GetSpritesheetArea()); + } + + /// Get whether the visible sprite intersects the specified coordinate. This can be an expensive test. + /// The tile to search. + /// The viewport-relative coordinates to search. + /// The approximate sprite area calculated by . + /// Derived from . + public override bool SpriteIntersectsPixel(Vector2 tile, Vector2 position, Rectangle spriteArea) + { + Crop crop = this.Value.crop; + SpriteEffects spriteEffects = crop.flip.Value ? SpriteEffects.FlipHorizontally : SpriteEffects.None; + + // base crop + if (this.SpriteIntersectsPixel(tile, position, spriteArea, Game1.cropSpriteSheet, this.GetSpritesheetArea(), spriteEffects)) + return true; + + // crop in last phase (may have fruit, be identical to base crop, or be blank) + if (crop.tintColor.Value != Color.White && crop.currentPhase.Value == crop.phaseDays.Count - 1 && !crop.dead.Value) + { + var sourceRectangle = new Rectangle( + x: (crop.fullyGrown.Value ? (crop.dayOfCurrentPhase.Value <= 0 ? 6 : 7) : crop.currentPhase.Value + 1 + 1) * 16 + (crop.rowInSpriteSheet.Value % 2 != 0 ? 128 : 0), + y: crop.rowInSpriteSheet.Value / 2 * 16 * 2, + width: 16, + height: 32 + ); + return this.SpriteIntersectsPixel(tile, position, spriteArea, Game1.cropSpriteSheet, sourceRectangle, spriteEffects); + } + + return false; + } + } +} diff --git a/Mods/LookupAnything/Framework/Targets/FarmAnimalTarget.cs b/Mods/LookupAnything/Framework/Targets/FarmAnimalTarget.cs new file mode 100644 index 000000000..0be181caf --- /dev/null +++ b/Mods/LookupAnything/Framework/Targets/FarmAnimalTarget.cs @@ -0,0 +1,42 @@ +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Graphics; +using StardewValley; + +namespace Pathoschild.Stardew.LookupAnything.Framework.Targets +{ + /// Positional metadata about a farm animal. + internal class FarmAnimalTarget : GenericTarget + { + /********* + ** Public methods + *********/ + /// Construct an instance. + /// Provides utility methods for interacting with the game code. + /// The underlying in-game entity. + /// The object's tile position in the current location (if applicable). + public FarmAnimalTarget(GameHelper gameHelper, FarmAnimal value, Vector2? tilePosition = null) + : base(gameHelper, TargetType.FarmAnimal, value, tilePosition) { } + + /// Get the sprite's source rectangle within its texture. + public override Rectangle GetSpritesheetArea() + { + return this.Value.Sprite.SourceRect; + } + + /// Get a rectangle which roughly bounds the visible sprite relative the viewport. + public override Rectangle GetWorldArea() + { + return this.GetSpriteArea(this.Value.GetBoundingBox(), this.GetSpritesheetArea()); + } + + /// Get whether the visible sprite intersects the specified coordinate. This can be an expensive test. + /// The tile to search. + /// The viewport-relative coordinates to search. + /// The approximate sprite area calculated by . + public override bool SpriteIntersectsPixel(Vector2 tile, Vector2 position, Rectangle spriteArea) + { + SpriteEffects spriteEffects = this.Value.flip ? SpriteEffects.FlipHorizontally : SpriteEffects.None; + return this.SpriteIntersectsPixel(tile, position, spriteArea, this.Value.Sprite.Texture, this.GetSpritesheetArea(), spriteEffects); + } + } +} diff --git a/Mods/LookupAnything/Framework/Targets/FarmerTarget.cs b/Mods/LookupAnything/Framework/Targets/FarmerTarget.cs new file mode 100644 index 000000000..16965340e --- /dev/null +++ b/Mods/LookupAnything/Framework/Targets/FarmerTarget.cs @@ -0,0 +1,39 @@ +using Microsoft.Xna.Framework; +using StardewValley; + +namespace Pathoschild.Stardew.LookupAnything.Framework.Targets +{ + /// Positional metadata about a farmer (i.e. player). + internal class FarmerTarget : GenericTarget + { + /********* + ** Public methods + *********/ + /// Construct an instance. + /// Provides utility methods for interacting with the game code. + /// The underlying in-game entity. + public FarmerTarget(GameHelper gameHelper, Farmer value) + : base(gameHelper, TargetType.Farmer, value, value.getTileLocation()) { } + + /// Get the sprite's source rectangle within its texture. + public override Rectangle GetSpritesheetArea() + { + return this.Value.FarmerSprite.SourceRect; + } + + /// Get a rectangle which roughly bounds the visible sprite relative the viewport. + public override Rectangle GetWorldArea() + { + return this.GetSpriteArea(this.Value.GetBoundingBox(), this.GetSpritesheetArea()); + } + + /// Get whether the visible sprite intersects the specified coordinate. This can be an expensive test. + /// The tile to search. + /// The viewport-relative coordinates to search. + /// The approximate sprite area calculated by . + public override bool SpriteIntersectsPixel(Vector2 tile, Vector2 position, Rectangle spriteArea) + { + return spriteArea.Contains((int)position.X, (int)position.Y); + } + } +} diff --git a/Mods/LookupAnything/Framework/Targets/FruitTreeTarget.cs b/Mods/LookupAnything/Framework/Targets/FruitTreeTarget.cs new file mode 100644 index 000000000..a40967ddc --- /dev/null +++ b/Mods/LookupAnything/Framework/Targets/FruitTreeTarget.cs @@ -0,0 +1,93 @@ +using System; +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Graphics; +using StardewValley; +using StardewValley.TerrainFeatures; + +namespace Pathoschild.Stardew.LookupAnything.Framework.Targets +{ + /// Positional metadata about a fruit tree. + internal class FruitTreeTarget : GenericTarget + { + /********* + ** Public methods + *********/ + /// Construct an instance. + /// Provides utility methods for interacting with the game code. + /// The underlying in-game entity. + /// The object's tile position in the current location (if applicable). + public FruitTreeTarget(GameHelper gameHelper, FruitTree value, Vector2? tilePosition = null) + : base(gameHelper, TargetType.FruitTree, value, tilePosition) { } + + /// Get the sprite's source rectangle within its texture. + public override Rectangle GetSpritesheetArea() + { + FruitTree tree = this.Value; + + // stump + if (tree.stump.Value) + return new Rectangle(384, tree.treeType.Value * 5 * 16 + 48, 48, 32); + + // growing tree + if (tree.growthStage.Value < 4) + { + switch (tree.growthStage.Value) + { + case 0: + case 1: + case 2: + return new Rectangle(tree.growthStage.Value * 48, tree.treeType.Value * 5 * 16, 48, 80); + + default: + return new Rectangle(144, tree.treeType.Value * 5 * 16, 48, 80); + } + } + + // grown tree + return new Rectangle((12 + (tree.GreenHouseTree ? 1 : Utility.getSeasonNumber(Game1.currentSeason)) * 3) * 16, tree.treeType.Value * 5 * 16, 48, 16 + 64); + } + + /// Get a rectangle which roughly bounds the visible sprite relative the viewport. + /// Reverse-engineered from . + public override Rectangle GetWorldArea() + { + FruitTree tree = this.Value; + Rectangle sprite = this.GetSpritesheetArea(); + + int width = sprite.Width * Game1.pixelZoom; + int height = sprite.Height * Game1.pixelZoom; + int x, y; + if (tree.growthStage.Value < 4) + { + // apply crazy offset logic for growing fruit trees + Vector2 tile = this.GetTile(); + Vector2 offset = new Vector2((float)Math.Max(-8.0, Math.Min(Game1.tileSize, Math.Sin(tile.X * 200.0 / (2.0 * Math.PI)) * -16.0)), (float)Math.Max(-8.0, Math.Min(Game1.tileSize, Math.Sin(tile.X * 200.0 / (2.0 * Math.PI)) * -16.0))); + Vector2 centerBottom = new Vector2(tile.X * Game1.tileSize + Game1.tileSize / 2 + offset.X, tile.Y * Game1.tileSize - sprite.Height + Game1.tileSize * 2 + offset.Y) - new Vector2(Game1.viewport.X, Game1.viewport.Y); + x = (int)centerBottom.X - width / 2; + y = (int)centerBottom.Y - height; + } + else + { + // grown trees are centered on tile + Rectangle tileArea = base.GetWorldArea(); + x = tileArea.Center.X - width / 2; + y = tileArea.Bottom - height; + } + + return new Rectangle(x, y, width, height); + } + + /// Get whether the visible sprite intersects the specified coordinate. This can be an expensive test. + /// The tile to search. + /// The viewport-relative coordinates to search. + /// The approximate sprite area calculated by . + /// Reverse engineered from . + public override bool SpriteIntersectsPixel(Vector2 tile, Vector2 position, Rectangle spriteArea) + { + Texture2D spriteSheet = FruitTree.texture; + Rectangle sourceRectangle = this.GetSpritesheetArea(); + SpriteEffects spriteEffects = this.Value.flipped.Value ? SpriteEffects.FlipHorizontally : SpriteEffects.None; + return this.SpriteIntersectsPixel(tile, position, spriteArea, spriteSheet, sourceRectangle, spriteEffects); + } + } +} diff --git a/Mods/LookupAnything/Framework/Targets/GenericTarget.cs b/Mods/LookupAnything/Framework/Targets/GenericTarget.cs new file mode 100644 index 000000000..7c872245c --- /dev/null +++ b/Mods/LookupAnything/Framework/Targets/GenericTarget.cs @@ -0,0 +1,124 @@ +using System; +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Graphics; +using StardewValley; + +namespace Pathoschild.Stardew.LookupAnything.Framework.Targets +{ + /// Positional metadata about an object in the world. + /// The underlying value type. + internal abstract class GenericTarget : ITarget + { + /********* + ** Fields + *********/ + /// Provides utility methods for interacting with the game code. + protected GameHelper GameHelper { get; } + + /// The underlying in-game object. + protected TValue Value { get; } + + + /********* + ** Accessors + *********/ + /// The target type. + public TargetType Type { get; set; } + + /// The object's tile position in the current location (if applicable). + public Vector2? Tile { get; set; } + + + /********* + ** Public methods + *********/ + /// Get the target's tile position, or throw an exception if it doesn't have one. + /// The target doesn't have a tile position. + public Vector2 GetTile() + { + if (this.Tile == null) + throw new InvalidOperationException($"This {this.Type} target doesn't have a tile position."); + return this.Tile.Value; + } + + /// Get whether the object is at the specified map tile position. + /// The map tile position. + public bool IsAtTile(Vector2 position) + { + return this.Tile != null && this.Tile == position; + } + + /// Get a strongly-typed instance. + /// The expected value type. + public T GetValue() + { + return (T)(object)this.Value; + } + + /// Get the sprite's source rectangle within its texture. + public abstract Rectangle GetSpritesheetArea(); + + /// Get a rectangle which roughly bounds the visible sprite relative the viewport. + public virtual Rectangle GetWorldArea() + { + return this.GameHelper.GetScreenCoordinatesFromTile(this.GetTile()); + } + + /// Get whether the visible sprite intersects the specified coordinate. This can be an expensive test. + /// The tile to search. + /// The viewport-relative coordinates to search. + /// The approximate sprite area calculated by . + public virtual bool SpriteIntersectsPixel(Vector2 tile, Vector2 position, Rectangle spriteArea) + { + return this.IsAtTile(tile); + } + + + /********* + ** Protected methods + *********/ + /// Construct an instance. + /// Provides utility methods for interacting with the game code. + /// The target type. + /// The underlying in-game entity. + /// The object's tile position in the current location (if applicable). + protected GenericTarget(GameHelper gameHelper, TargetType type, TValue value, Vector2? tilePosition = null) + { + this.GameHelper = gameHelper; + this.Type = type; + this.Value = value; + this.Tile = tilePosition; + } + + /// Get a rectangle which roughly bounds the visible sprite. + /// The occupied 'floor space' at the bottom of the sprite in the world. + /// The sprite's source rectangle in the sprite sheet. + protected Rectangle GetSpriteArea(Rectangle boundingBox, Rectangle sourceRectangle) + { + int height = sourceRectangle.Height * Game1.pixelZoom; + int width = sourceRectangle.Width * Game1.pixelZoom; + int x = boundingBox.Center.X - (width / 2); + int y = boundingBox.Y + boundingBox.Height - height; + return new Rectangle(x - Game1.viewport.X, y - Game1.viewport.Y, width, height); + } + + /// Get whether the visible sprite intersects the specified coordinate. This can be an expensive test. + /// The tile to search. + /// The viewport-relative coordinates to search. + /// The approximate sprite area calculated by . + /// The sprite sheet containing the displayed sprite. + /// The coordinates and dimensions of the sprite within the sprite sheet. + /// The transformation to apply on the sprite. + protected bool SpriteIntersectsPixel(Vector2 tile, Vector2 position, Rectangle spriteArea, Texture2D spriteSheet, Rectangle spriteSourceRectangle, SpriteEffects spriteEffects = SpriteEffects.None) + { + // get sprite sheet coordinate + Vector2 spriteSheetPosition = this.GameHelper.GetSpriteSheetCoordinates(position, spriteArea, spriteSourceRectangle, spriteEffects); + if (!spriteSourceRectangle.Contains((int)spriteSheetPosition.X, (int)spriteSheetPosition.Y)) + return false; + + // check pixel + Color pixel = this.GameHelper.GetSpriteSheetPixel(spriteSheet, spriteSheetPosition); + return pixel.A != 0; // pixel not transparent + } + } +} diff --git a/Mods/LookupAnything/Framework/Targets/ITarget.cs b/Mods/LookupAnything/Framework/Targets/ITarget.cs new file mode 100644 index 000000000..4554e8d0f --- /dev/null +++ b/Mods/LookupAnything/Framework/Targets/ITarget.cs @@ -0,0 +1,45 @@ +using Microsoft.Xna.Framework; + +namespace Pathoschild.Stardew.LookupAnything.Framework.Targets +{ + /// Positional metadata about an object in the world. + internal interface ITarget + { + /********* + ** Accessors + *********/ + /// The target type. + TargetType Type { get; set; } + + /// The object's tile position in the current location (if applicable). + Vector2? Tile { get; set; } + + + /********* + ** Public methods + *********/ + /// Get the target's tile position, or throw an exception if it doesn't have one. + /// The target doesn't have a tile position. + Vector2 GetTile(); + + /// Get whether the object is at the specified map tile position. + /// The map tile position. + bool IsAtTile(Vector2 position); + + /// Get a strongly-typed value. + /// The expected value type. + T GetValue(); + + /// Get the sprite's source rectangle within its texture. + Rectangle GetSpritesheetArea(); + + /// Get a rectangle which roughly bounds the visible sprite relative the viewport. + Rectangle GetWorldArea(); + + /// Get whether the visible sprite intersects the specified coordinate. This can be an expensive test. + /// The tile to search. + /// The viewport-relative coordinates to search. + /// The approximate sprite area calculated by . + bool SpriteIntersectsPixel(Vector2 tile, Vector2 position, Rectangle spriteArea); + } +} diff --git a/Mods/LookupAnything/Framework/Targets/ObjectTarget.cs b/Mods/LookupAnything/Framework/Targets/ObjectTarget.cs new file mode 100644 index 000000000..6a7aa44df --- /dev/null +++ b/Mods/LookupAnything/Framework/Targets/ObjectTarget.cs @@ -0,0 +1,160 @@ +using System; +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Graphics; +using Pathoschild.Stardew.Common; +using StardewModdingAPI; +using StardewValley; +using StardewValley.Objects; +using SObject = StardewValley.Object; + +namespace Pathoschild.Stardew.LookupAnything.Framework.Targets +{ + /// Positional metadata about a world object. + internal class ObjectTarget : GenericTarget + { + /********* + ** Public methods + *********/ + /// Simplifies access to private game code. + private readonly IReflectionHelper Reflection; + + /// The item sprite. + private readonly SpriteInfo CustomSprite; + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// Provides utility methods for interacting with the game code. + /// The underlying in-game entity. + /// The object's tile position in the current location (if applicable). + /// Simplifies access to private game code. + public ObjectTarget(GameHelper gameHelper, SObject value, Vector2? tilePosition, IReflectionHelper reflection) + : base(gameHelper, TargetType.Object, value, tilePosition) + { + this.Reflection = reflection; + this.CustomSprite = gameHelper.GetSprite(value, onlyCustom: true); // only get sprite if it's custom; else we'll use contextual logic (e.g. for fence direction) + } + + /// Get the sprite's source rectangle within its texture. + public override Rectangle GetSpritesheetArea() + { + if (this.CustomSprite != null) + return this.CustomSprite.SourceRectangle; + + SObject obj = this.Value; + switch (obj) + { + case Fence fence: + return this.GetSpritesheetArea(fence, Game1.currentLocation); + + case Furniture furniture: + return furniture.sourceRect.Value; + + default: + return obj.bigCraftable.Value + ? SObject.getSourceRectForBigCraftable(obj.ParentSheetIndex) + : Game1.getSourceRectForStandardTileSheet(Game1.objectSpriteSheet, obj.ParentSheetIndex, SObject.spriteSheetTileSize, SObject.spriteSheetTileSize); + } + + } + + /// Get a rectangle which roughly bounds the visible sprite relative the viewport. + public override Rectangle GetWorldArea() + { + // get object info + SObject obj = this.Value; + Rectangle boundingBox = obj.getBoundingBox(this.GetTile()); + + // get sprite area + if (this.CustomSprite != null) + { + Rectangle spriteArea = this.GetSpriteArea(boundingBox, this.CustomSprite.SourceRectangle); + return new Rectangle( + x: spriteArea.X, + y: spriteArea.Y - (spriteArea.Height / 2), // custom sprite areas are offset from game logic + width: spriteArea.Width, + height: spriteArea.Height + ); + } + + return this.GetSpriteArea(boundingBox, this.GetSpritesheetArea()); + } + + /// Get whether the visible sprite intersects the specified coordinate. This can be an expensive test. + /// The tile to search. + /// The viewport-relative coordinates to search. + /// The approximate sprite area calculated by . + public override bool SpriteIntersectsPixel(Vector2 tile, Vector2 position, Rectangle spriteArea) + { + SObject obj = this.Value; + + // get texture + Texture2D spriteSheet; + if (this.CustomSprite != null) + spriteSheet = this.CustomSprite.Spritesheet; + else if (obj is Furniture) + spriteSheet = Furniture.furnitureTexture; + else if (obj is Fence) + spriteSheet = this.Reflection.GetField>(obj, "fenceTexture").GetValue().Value; + else if (obj.bigCraftable.Value) + spriteSheet = Game1.bigCraftableSpriteSheet; + else + spriteSheet = Game1.objectSpriteSheet; + + // check pixel from sprite sheet + Rectangle sourceRectangle = this.GetSpritesheetArea(); + SpriteEffects spriteEffects = obj.Flipped ? SpriteEffects.FlipHorizontally : SpriteEffects.None; + return this.SpriteIntersectsPixel(tile, position, spriteArea, spriteSheet, sourceRectangle, spriteEffects); + } + + /// Get the source rectangle for a fence texture. + /// The fence object. + /// The location containing the fence target. + /// Reverse-engineered from . + private Rectangle GetSpritesheetArea(Fence fence, GameLocation location) + { + int spriteID = 1; + if (fence.health.Value > 1.0) + { + int index = 0; + Vector2 tile = fence.TileLocation; + + // connected to right fence + tile.X += 1; + if (location.objects.ContainsKey(tile) && location.objects[tile] is Fence && ((Fence)location.objects[tile]).countsForDrawing(fence.whichType.Value)) + index += 100; + + // connected to left fence + tile.X -= 2; + if (location.objects.ContainsKey(tile) && location.objects[tile] is Fence && ((Fence)location.objects[tile]).countsForDrawing(fence.whichType.Value)) + index += 10; + + // connected to top fence + tile.X += 1; + tile.Y += 1; + if (location.objects.ContainsKey(tile) && location.objects[tile] is Fence && ((Fence)location.objects[tile]).countsForDrawing(fence.whichType.Value)) + index += 500; + + // connected to bottom fence + tile.Y -= 2; + if (location.objects.ContainsKey(tile) && location.objects[tile] is Fence && ((Fence)location.objects[tile]).countsForDrawing(fence.whichType.Value)) + index += 1000; + if (fence.isGate.Value) + { + if (index == 110) + return new Rectangle(fence.gatePosition.Value == Fence.gateOpenedPosition ? 24 : 0, 128, 24, 32); + if (index == 1500) + return new Rectangle(fence.gatePosition.Value == Fence.gateClosedPosition ? 16 : 0, 160, 16, 16); + spriteID = Fence.sourceRectForSoloGate; + } + else + spriteID = Fence.fenceDrawGuide[index]; + } + + Texture2D texture = this.Reflection.GetField>(fence, "fenceTexture").GetValue().Value; + return new Rectangle(spriteID * Fence.fencePieceWidth % texture.Bounds.Width, spriteID * Fence.fencePieceWidth / texture.Bounds.Width * Fence.fencePieceHeight, Fence.fencePieceWidth, Fence.fencePieceHeight); + } + } +} diff --git a/Mods/LookupAnything/Framework/Targets/TileTarget.cs b/Mods/LookupAnything/Framework/Targets/TileTarget.cs new file mode 100644 index 000000000..3c173b925 --- /dev/null +++ b/Mods/LookupAnything/Framework/Targets/TileTarget.cs @@ -0,0 +1,23 @@ +using Microsoft.Xna.Framework; + +namespace Pathoschild.Stardew.LookupAnything.Framework.Targets +{ + /// Positional metadata about a map tile. + internal class TileTarget : GenericTarget + { + /********* + ** Public methods + *********/ + /// Construct an instance. + /// Provides utility methods for interacting with the game code. + /// The tile position. + public TileTarget(GameHelper gameHelper, Vector2 position) + : base(gameHelper, TargetType.Tile, position, position) { } + + /// Get the sprite's source rectangle within its texture. + public override Rectangle GetSpritesheetArea() + { + return Rectangle.Empty; + } + } +} diff --git a/Mods/LookupAnything/Framework/Targets/TreeTarget.cs b/Mods/LookupAnything/Framework/Targets/TreeTarget.cs new file mode 100644 index 000000000..ca7f1b302 --- /dev/null +++ b/Mods/LookupAnything/Framework/Targets/TreeTarget.cs @@ -0,0 +1,101 @@ +using System; +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Graphics; +using Pathoschild.Stardew.LookupAnything.Framework.Constants; +using StardewModdingAPI; +using StardewValley; +using StardewValley.TerrainFeatures; + +namespace Pathoschild.Stardew.LookupAnything.Framework.Targets +{ + /// Positional metadata about a wild tree. + internal class TreeTarget : GenericTarget + { + /********* + ** Public methods + *********/ + /// Simplifies access to private game code. + private readonly IReflectionHelper Reflection; + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// Provides utility methods for interacting with the game code. + /// The underlying in-game entity. + /// The object's tile position in the current location (if applicable). + /// Simplifies access to private game code. + public TreeTarget(GameHelper gameHelper, Tree value, Vector2? tilePosition, IReflectionHelper reflectionHelper) + : base(gameHelper, TargetType.WildTree, value, tilePosition) + { + this.Reflection = reflectionHelper; + } + + /// Get the sprite's source rectangle within its texture. + public override Rectangle GetSpritesheetArea() + { + Tree tree = this.Value; + + // stump + if (tree.stump.Value) + return Tree.stumpSourceRect; + + // growing tree + if (tree.growthStage.Value < 5) + { + switch ((WildTreeGrowthStage)tree.growthStage.Value) + { + case WildTreeGrowthStage.Seed: + return new Rectangle(32, 128, 16, 16); + case WildTreeGrowthStage.Sprout: + return new Rectangle(0, 128, 16, 16); + case WildTreeGrowthStage.Sapling: + return new Rectangle(16, 128, 16, 16); + default: + return new Rectangle(0, 96, 16, 32); + } + } + + // grown tree + return Tree.treeTopSourceRect; + } + + /// Get a rectangle which roughly bounds the visible sprite relative the viewport. + /// Reverse-engineered from . + public override Rectangle GetWorldArea() + { + return this.GetSpriteArea(this.Value.getBoundingBox(this.GetTile()), this.GetSpritesheetArea()); + } + + /// Get whether the visible sprite intersects the specified coordinate. This can be an expensive test. + /// The tile to search. + /// The viewport-relative coordinates to search. + /// The approximate sprite area calculated by . + /// Reverse engineered from . + public override bool SpriteIntersectsPixel(Vector2 tile, Vector2 position, Rectangle spriteArea) + { + // get tree + Tree tree = this.Value; + WildTreeGrowthStage growth = (WildTreeGrowthStage)tree.growthStage.Value; + + // get sprite data + Texture2D spriteSheet = this.Reflection.GetField>(tree, "texture").GetValue().Value; + SpriteEffects spriteEffects = tree.flipped.Value ? SpriteEffects.FlipHorizontally : SpriteEffects.None; + + // check tree sprite + if (this.SpriteIntersectsPixel(tile, position, spriteArea, spriteSheet, this.GetSpritesheetArea(), spriteEffects)) + return true; + + // check stump attached to bottom of grown tree + if (growth == WildTreeGrowthStage.Tree) + { + Rectangle stumpSpriteArea = new Rectangle(spriteArea.Center.X - (Tree.stumpSourceRect.Width / 2 * Game1.pixelZoom), spriteArea.Y + spriteArea.Height - Tree.stumpSourceRect.Height * Game1.pixelZoom, Tree.stumpSourceRect.Width * Game1.pixelZoom, Tree.stumpSourceRect.Height * Game1.pixelZoom); + if (stumpSpriteArea.Contains((int)position.X, (int)position.Y) && this.SpriteIntersectsPixel(tile, position, stumpSpriteArea, spriteSheet, Tree.stumpSourceRect, spriteEffects)) + return true; + } + + return false; + } + } +} diff --git a/Mods/LookupAnything/Framework/Targets/UnknownTarget.cs b/Mods/LookupAnything/Framework/Targets/UnknownTarget.cs new file mode 100644 index 000000000..5c5c13017 --- /dev/null +++ b/Mods/LookupAnything/Framework/Targets/UnknownTarget.cs @@ -0,0 +1,24 @@ +using Microsoft.Xna.Framework; + +namespace Pathoschild.Stardew.LookupAnything.Framework.Targets +{ + /// Positional metadata about an unknown object. + internal class UnknownTarget : GenericTarget + { + /********* + ** Public methods + *********/ + /// Construct an instance. + /// Provides utility methods for interacting with the game code. + /// The underlying in-game entity. + /// The object's tile position in the current location (if applicable). + public UnknownTarget(GameHelper gameHelper, object value, Vector2? tilePosition = null) + : base(gameHelper, TargetType.Unknown, value, tilePosition) { } + + /// Get the sprite's source rectangle within its texture. + public override Rectangle GetSpritesheetArea() + { + return Rectangle.Empty; + } + } +} diff --git a/Mods/LookupAnything/Framework/TranslationHelperExtensions.cs b/Mods/LookupAnything/Framework/TranslationHelperExtensions.cs new file mode 100644 index 000000000..834bea7ef --- /dev/null +++ b/Mods/LookupAnything/Framework/TranslationHelperExtensions.cs @@ -0,0 +1,181 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Text; +using Microsoft.Xna.Framework; +using Netcode; +using Pathoschild.Stardew.LookupAnything.Framework.Constants; +using StardewModdingAPI; +using StardewModdingAPI.Utilities; +using StardewValley; +using StardewValley.Network; + +namespace Pathoschild.Stardew.LookupAnything.Framework +{ + /// Provides extension methods for . + internal static class TranslationHelperExtensions + { + /********* + ** Public methods + *********/ + /// Select the correct translation based on the plural form. + /// The translation helper. + /// The number. + /// The singular form. + /// The plural form. + public static Translation GetPlural(this ITranslationHelper translations, int count, string singleKey, string pluralKey) + { + return translations.Get(count == 1 ? singleKey : pluralKey); + } + + /// Get a translated season name from the game. + /// The translation helper. + /// The English season name. + public static string GetSeasonName(this ITranslationHelper translations, string season) + { + if (string.IsNullOrWhiteSpace(season)) + return season; + + int id = Utility.getSeasonNumber(season); + if (id == -1) + throw new InvalidOperationException($"Can't translate unknown season '{season}'."); + return Utility.getSeasonNameFromNumber(id); + } + + /// Get translated season names from the game. + /// The translation helper. + /// The English season names. + public static IEnumerable GetSeasonNames(this ITranslationHelper translations, IEnumerable seasons) + { + foreach (string season in seasons) + yield return translations.GetSeasonName(season); + } + + /// Get a human-readable representation of a value. + /// The translation helper. + /// The game date. + /// Whether to include the year number. + public static string Stringify(this ITranslationHelper translations, SDate date, bool withYear) + { + string key = withYear ? L10n.Generic.DateWithYear : L10n.Generic.Date; + return translations.Get(key, new + { + seasonNumber = Utility.getSeasonNumber(date.Season), + seasonName = Utility.getSeasonNameFromNumber(Utility.getSeasonNumber(date.Season)), + dayNumber = date.Day, + year = date.Year + }); + } + + /// Get a human-readable representation of a value. + /// The translation helper. + /// The underlying value. + public static string Stringify(this ITranslationHelper translations, object value) + { + switch (value) + { + case null: + return null; + + // net types + case NetBool net: + return translations.Stringify(net.Value); + case NetByte net: + return translations.Stringify(net.Value); + case NetColor net: + return translations.Stringify(net.Value); + case NetDancePartner net: + return translations.Stringify(net.Value?.Name); + case NetDouble net: + return translations.Stringify(net.Value); + case NetFloat net: + return translations.Stringify(net.Value); + case NetGuid net: + return translations.Stringify(net.Value); + case NetInt net: + return translations.Stringify(net.Value); + case NetLocationRef net: + return translations.Stringify(net.Value?.uniqueName ?? net.Value?.Name); + case NetLong net: + return translations.Stringify(net.Value); + case NetPoint net: + return translations.Stringify(net.Value); + case NetPosition net: + return translations.Stringify(net.Value); + case NetRectangle net: + return translations.Stringify(net.Value); + case NetString net: + return translations.Stringify(net.Value); + case NetVector2 net: + return translations.Stringify(net.Value); + + // core types + case bool boolean: + return translations.Get(boolean ? L10n.Generic.Yes : L10n.Generic.No); + case Color color: + return $"(r:{color.R} g:{color.G} b:{color.B} a:{color.A})"; + case SDate date: + return translations.Stringify(date, withYear: false); + case TimeSpan span: + { + List parts = new List(); + if (span.Days > 0) + parts.Add(translations.Get(L10n.Generic.Days, new { count = span.Days })); + if (span.Hours > 0) + parts.Add(translations.Get(L10n.Generic.Hours, new { count = span.Hours })); + if (span.Minutes > 0) + parts.Add(translations.Get(L10n.Generic.Minutes, new { count = span.Minutes })); + return string.Join(", ", parts); + } + case Vector2 vector: + return $"({vector.X}, {vector.Y})"; + case Rectangle rect: + return $"(x:{rect.X}, y:{rect.Y}, width:{rect.Width}, height:{rect.Height})"; + + // game types + case AnimatedSprite sprite: + return $"(textureName: {sprite.textureName.Value}, currentFrame:{sprite.currentFrame}, loop:{sprite.loop}, sourceRect:{translations.Stringify(sprite.sourceRect)})"; + case Stats stats: + { + StringBuilder str = new StringBuilder(); + foreach (FieldInfo field in stats.GetType().GetFields()) + str.AppendLine($"- {field.Name}: {translations.Stringify(field.GetValue(stats))}"); + return str.ToString(); + } + + // enumerable + case IEnumerable array when !(value is string): + { + string[] values = (from val in array.Cast() select translations.Stringify(val)).ToArray(); + return "(" + string.Join(", ", values) + ")"; + } + + default: + // key/value pair + { + Type type = value.GetType(); + if (type.IsGenericType) + { + Type genericType = type.GetGenericTypeDefinition(); + if (genericType == typeof(NetDictionary<,,,,>)) + { + object dict = type.GetProperty("FieldDict").GetValue(value); + return translations.Stringify(dict); + } + if (genericType == typeof(KeyValuePair<,>)) + { + string k = translations.Stringify(type.GetProperty(nameof(KeyValuePair.Key)).GetValue(value)); + string v = translations.Stringify(type.GetProperty(nameof(KeyValuePair.Value)).GetValue(value)); + return $"({k}: {v})"; + } + } + } + + // anything else + return value.ToString(); + } + } + } +} diff --git a/Mods/LookupAnything/GameHelper.cs b/Mods/LookupAnything/GameHelper.cs new file mode 100644 index 000000000..a952ded33 --- /dev/null +++ b/Mods/LookupAnything/GameHelper.cs @@ -0,0 +1,525 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Graphics; +using Pathoschild.Stardew.Common; +using Pathoschild.Stardew.Common.Integrations.CustomFarmingRedux; +using Pathoschild.Stardew.LookupAnything.Framework; +using Pathoschild.Stardew.LookupAnything.Framework.Constants; +using Pathoschild.Stardew.LookupAnything.Framework.Data; +using Pathoschild.Stardew.LookupAnything.Framework.Models; +using StardewModdingAPI; +using StardewValley; +using StardewValley.Buildings; +using StardewValley.Characters; +using StardewValley.Locations; +using StardewValley.Objects; +using StardewValley.Tools; +using SObject = StardewValley.Object; + +namespace Pathoschild.Stardew.LookupAnything +{ + /// Provides utility methods for interacting with the game code. + internal class GameHelper + { + /********* + ** Fields + *********/ + /// The cached object data. + private Lazy Objects; + + /// The cached villagers' gift tastes. + private Lazy GiftTastes; + + /// The cached recipes. + private Lazy Recipes; + + /// The Custom Farming Redux integration. + private readonly CustomFarmingReduxIntegration CustomFarmingRedux; + + /// Parses the raw game data into usable models. + private readonly DataParser DataParser; + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The Custom Farming Redux integration. + public GameHelper(CustomFarmingReduxIntegration customFarmingRedux) + { + this.DataParser = new DataParser(this); + this.CustomFarmingRedux = customFarmingRedux; + } + + /// Reset the low-level cache used to store expensive query results, so the data is recalculated on demand. + /// Provides metadata that's not available from the game data directly. + /// Simplifies access to private game code. + /// Provides translations stored in the mod folder. + /// The monitor with which to log errors. + public void ResetCache(Metadata metadata, IReflectionHelper reflectionHelper, ITranslationHelper translations, IMonitor monitor) + { + this.Objects = new Lazy(() => this.DataParser.GetObjects(monitor).ToArray()); + this.GiftTastes = new Lazy(() => this.DataParser.GetGiftTastes(this.Objects.Value).ToArray()); + this.Recipes = new Lazy(() => this.DataParser.GetRecipes(metadata, reflectionHelper, translations).ToArray()); + } + + /**** + ** Data helpers + ****/ + /// Get the number of times the player has shipped a given item. + /// The item's parent sprite index. + public int GetShipped(int itemID) + { + return Game1.player.basicShipped.ContainsKey(itemID) + ? Game1.player.basicShipped[itemID] + : 0; + } + + /// Get all shippable items. + /// Derived from . + public IEnumerable> GetFullShipmentAchievementItems() + { + return ( + from obj in this.Objects.Value + where obj.Type != "Arch" && obj.Type != "Fish" && obj.Type != "Mineral" && obj.Type != "Cooking" && SObject.isPotentialBasicShippedCategory(obj.ParentSpriteIndex, obj.Category.ToString()) + select new KeyValuePair(obj.ParentSpriteIndex, Game1.player.basicShipped.ContainsKey(obj.ParentSpriteIndex)) + ); + } + + /// Get all items owned by the player. + /// Derived from . + public IEnumerable GetAllOwnedItems() + { + List items = new List(); + + // inventory + items.AddRange(Game1.player.Items); + + // in locations + foreach (GameLocation location in CommonHelper.GetLocations()) + { + // map objects + foreach (SObject item in location.objects.Values) + { + // chest + if (item is Chest chest) + { + if (chest.playerChest.Value) + { + items.Add(chest); + items.AddRange(chest.items); + } + } + + // auto-grabber + else if (item.ParentSheetIndex == 165 && item.heldObject.Value is Chest grabberChest) + { + items.Add(item); + items.AddRange(grabberChest.items); + } + + // cask + else if (item is Cask) + { + items.Add(item); + items.Add(item.heldObject.Value); // cask contents can be retrieved anytime + } + + // craftable + else if (item.bigCraftable.Value) + { + items.Add(item); + if (item.MinutesUntilReady == 0) + items.Add(item.heldObject.Value); + } + + // anything else + else if (!item.IsSpawnedObject) + { + items.Add(item); + items.Add(item.heldObject.Value); + } + } + + // furniture + if (location is DecoratableLocation decorableLocation) + { + foreach (Furniture furniture in decorableLocation.furniture) + { + items.Add(furniture); + items.Add(furniture.heldObject.Value); + } + } + + // building output + if (location is Farm farm) + { + foreach (var building in farm.buildings) + { + if (building is Mill mill) + items.AddRange(mill.output.Value.items); + else if (building is JunimoHut hut) + items.AddRange(hut.output.Value.items); + } + } + + // farmhouse fridge + if (location is FarmHouse house) + items.AddRange(house.fridge.Value.items); + } + + return items.Where(p => p != null); + } + + /// Get all NPCs currently in the world. + public IEnumerable GetAllCharacters() + { + List characters = new List(); + Utility.getAllCharacters(characters); + return characters.Distinct(); // fix rare issue where the game duplicates an NPC (seems to happen when the player's child is born) + } + + /// Count how many of an item the player owns. + /// The item to count. + public int CountOwnedItems(Item item) + { + return ( + from worldItem in this.GetAllOwnedItems() + where this.AreEquivalent(worldItem, item) + let canStack = worldItem.canStackWith(worldItem) + select canStack ? Math.Max(1, worldItem.Stack) : 1 + ).Sum(); + } + + /// Get whether two items are the same type (ignoring flavour text like 'blueberry wine' vs 'cranberry wine'). + /// The first item to compare. + /// The second item to compare. + private bool AreEquivalent(Item a, Item b) + { + return + // same generic item type + a.GetType() == b.GetType() + && a.Category == b.Category + && a.ParentSheetIndex == b.ParentSheetIndex + + // same discriminators + && a.GetSpriteType() == b.GetSpriteType() + && (a as Boots)?.indexInTileSheet == (b as Boots)?.indexInTileSheet + && (a as BreakableContainer)?.Type == (b as BreakableContainer)?.Type + && (a as Fence)?.isGate == (b as Fence)?.isGate + && (a as Fence)?.whichType == (b as Fence)?.whichType + && (a as Hat)?.which == (b as Hat)?.which + && (a as MeleeWeapon)?.type == (b as MeleeWeapon)?.type + && (a as Ring)?.indexInTileSheet == (b as Ring)?.indexInTileSheet + && (a as Tool)?.InitialParentTileIndex == (b as Tool)?.InitialParentTileIndex + && (a as Tool)?.CurrentParentTileIndex == (b as Tool)?.CurrentParentTileIndex; + } + + /// Get whether the specified NPC has social data like a birthday and gift tastes. + /// The NPC to check. + /// Provides metadata that's not available from the game data directly. + public bool IsSocialVillager(NPC npc, Metadata metadata) + { + return npc.isVillager() && !metadata.Constants.AsocialVillagers.Contains(npc.Name); + } + + /// Get how much each NPC likes receiving an item as a gift. + /// The item to check. + /// Provides metadata that's not available from the game data directly. + public IEnumerable> GetGiftTastes(Item item, Metadata metadata) + { + if (!item.canBeGivenAsGift()) + yield break; + + foreach (NPC npc in this.GetAllCharacters()) + { + if (!this.IsSocialVillager(npc, metadata)) + continue; + + GiftTaste? taste = this.GetGiftTaste(npc, item); + if (taste.HasValue) + yield return new KeyValuePair(npc, taste.Value); + } + } + + /// Get the items a specified NPC can receive. + /// The NPC to check. + /// Provides metadata that's not available from the game data directly. + public IDictionary GetGiftTastes(NPC npc, Metadata metadata) + { + if (!this.IsSocialVillager(npc, metadata)) + return new Dictionary(); + + // get giftable items + HashSet giftableItemIDs = new HashSet( + from int refID in this.GiftTastes.Value.Select(p => p.RefID) + from ObjectModel obj in this.Objects.Value + where obj.ParentSpriteIndex == refID || obj.Category == refID + select obj.ParentSpriteIndex + ); + + // get gift tastes + return + ( + from int itemID in giftableItemIDs + let item = this.GetObjectBySpriteIndex(itemID) + let taste = this.GetGiftTaste(npc, item) + where taste.HasValue + select new { Item = item, Taste = taste.Value } + ) + .ToDictionary(p => p.Item, p => p.Taste); + } + + /// Get parsed data about the friendship between a player and NPC. + /// The player. + /// The NPC. + /// The current friendship data. + /// Provides metadata that's not available from the game data directly. + public FriendshipModel GetFriendshipForVillager(Farmer player, NPC npc, Friendship friendship, Metadata metadata) + { + return this.DataParser.GetFriendshipForVillager(player, npc, friendship, metadata); + } + + /// Get parsed data about the friendship between a player and NPC. + /// The player. + /// The pet. + public FriendshipModel GetFriendshipForPet(Farmer player, Pet pet) + { + return this.DataParser.GetFriendshipForPet(player, pet); + } + + /// Get parsed data about the friendship between a player and NPC. + /// The player. + /// The farm animal. + /// Provides metadata that's not available from the game data directly. + public FriendshipModel GetFriendshipForAnimal(Farmer player, FarmAnimal animal, Metadata metadata) + { + return this.DataParser.GetFriendshipForAnimal(player, animal, metadata); + } + + /// Parse monster data. + public IEnumerable GetMonsterData() + { + return this.DataParser.GetMonsters(); + } + + /// Read parsed data about the Community Center bundles. + public IEnumerable GetBundleData() + { + return this.DataParser.GetBundles(); + } + + /// Get the recipes for which an item is needed. + public IEnumerable GetRecipes() + { + return this.Recipes.Value; + } + + /// Get the recipes for which an item is needed. + /// The item. + public IEnumerable GetRecipesForIngredient(Item item) + { + if (item is SObject obj && obj.bigCraftable.Value) + return new RecipeModel[0]; // bigcraftables never valid as an ingredient + + return ( + from recipe in this.GetRecipes() + where + (recipe.Ingredients.ContainsKey(item.ParentSheetIndex) || recipe.Ingredients.ContainsKey(item.Category)) + && recipe.ExceptIngredients?.Contains(item.ParentSheetIndex) != true + select recipe + ); + } + + /// Get an object by its parent sprite index. + /// The parent sprite index. + /// The number of items in the stack. + public SObject GetObjectBySpriteIndex(int index, int stack = 1) + { + return new SObject(index, stack); + } + + /// Get whether an item can have a quality (which increases its sale price). + /// The item. + public bool CanHaveQuality(Item item) + { + // check category + if (new[] { "Artifact", "Trash", "Crafting", "Seed", "Decor", "Resource", "Fertilizer", "Bait", "Fishing Tackle" }.Contains(item.getCategoryName())) + return false; + + // check type + if (new[] { "Crafting", "asdf" /*dig spots*/, "Quest" }.Contains((item as SObject)?.Type)) + return false; + + return true; + } + + /**** + ** Coordinates + ****/ + /// Get the viewport coordinates from the current cursor position. + public Vector2 GetScreenCoordinatesFromCursor() + { + return new Vector2(Game1.getOldMouseX(), Game1.getOldMouseY()); + } + + /// Get the viewport coordinates represented by a tile position. + /// The absolute coordinates. + public Vector2 GetScreenCoordinatesFromAbsolute(Vector2 coordinates) + { + return coordinates - new Vector2(Game1.viewport.X, Game1.viewport.Y); + } + + /// Get the viewport coordinates represented by a tile position. + /// The tile position. + public Rectangle GetScreenCoordinatesFromTile(Vector2 tile) + { + Vector2 position = this.GetScreenCoordinatesFromAbsolute(tile * new Vector2(Game1.tileSize)); + return new Rectangle((int)position.X, (int)position.Y, Game1.tileSize, Game1.tileSize); + } + + /// Get whether a sprite on a given tile could occlude a specified tile position. + /// The tile of the possible sprite. + /// The tile to check for possible occlusion. + /// The largest expected sprite size (measured in tiles). + public bool CouldSpriteOccludeTile(Vector2 spriteTile, Vector2 occludeTile, Vector2? spriteSize = null) + { + spriteSize = spriteSize ?? Constant.MaxTargetSpriteSize; + return + spriteTile.Y >= occludeTile.Y // sprites never extend downard from their tile + && Math.Abs(spriteTile.X - occludeTile.X) <= spriteSize.Value.X + && Math.Abs(spriteTile.Y - occludeTile.Y) <= spriteSize.Value.Y; + } + + /// Get the pixel coordinates within a sprite sheet corresponding to a sprite displayed in the world. + /// The pixel position in the world. + /// The sprite rectangle in the world. + /// The sprite rectangle in the sprite sheet. + /// The transformation to apply on the sprite. + public Vector2 GetSpriteSheetCoordinates(Vector2 worldPosition, Rectangle worldRectangle, Rectangle spriteRectangle, SpriteEffects spriteEffects = SpriteEffects.None) + { + // get position within sprite rectangle + float x = (worldPosition.X - worldRectangle.X) / Game1.pixelZoom; + float y = (worldPosition.Y - worldRectangle.Y) / Game1.pixelZoom; + + // flip values + if (spriteEffects.HasFlag(SpriteEffects.FlipHorizontally)) + x = spriteRectangle.Width - x; + if (spriteEffects.HasFlag(SpriteEffects.FlipVertically)) + y = spriteRectangle.Height - y; + + // get position within sprite sheet + x += spriteRectangle.X; + y += spriteRectangle.Y; + + // return coordinates + return new Vector2(x, y); + } + + /// Get a pixel from a sprite sheet. + /// The pixel value type. + /// The sprite sheet. + /// The position of the pixel within the sprite sheet. + public TPixel GetSpriteSheetPixel(Texture2D spriteSheet, Vector2 position) where TPixel : struct + { + // get pixel index + int x = (int)position.X; + int y = (int)position.Y; + int spriteIndex = y * spriteSheet.Width + x; // (pixels in preceding rows) + (preceding pixels in current row) + + // get pixel + TPixel[] pixels = new TPixel[spriteSheet.Width * spriteSheet.Height]; + spriteSheet.GetData(pixels); + return pixels[spriteIndex]; + } + + /// Get the sprite for an item. + /// The item. + /// Only return the sprite info if it's custom. + /// Returns a tuple containing the sprite sheet and the sprite's position and dimensions within the sheet. + public SpriteInfo GetSprite(Item item, bool onlyCustom = false) + { + SObject obj = item as SObject; + + // Custom Farming Redux + if (obj != null && this.CustomFarmingRedux.IsLoaded) + { + SpriteInfo data = this.CustomFarmingRedux.GetSprite(obj); + if (data != null) + return data; + } + + if (onlyCustom) + return null; + + // standard object + if (obj != null) + { + return obj.bigCraftable.Value + ? new SpriteInfo(Game1.bigCraftableSpriteSheet, SObject.getSourceRectForBigCraftable(obj.ParentSheetIndex)) + : new SpriteInfo(Game1.objectSpriteSheet, Game1.getSourceRectForStandardTileSheet(Game1.objectSpriteSheet, obj.ParentSheetIndex, SObject.spriteSheetTileSize, SObject.spriteSheetTileSize)); + } + + // boots or ring + if (item is Boots || item is Ring) + { + int indexInTileSheet = (item as Boots)?.indexInTileSheet ?? ((Ring)item).indexInTileSheet; + return new SpriteInfo(Game1.objectSpriteSheet, Game1.getSourceRectForStandardTileSheet(Game1.objectSpriteSheet, indexInTileSheet, SObject.spriteSheetTileSize, SObject.spriteSheetTileSize)); + } + + // unknown item + return null; + } + + + /**** + ** 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 Vector2 DrawHoverBox(SpriteBatch spriteBatch, string label, Vector2 position, float wrapWidth) + { + return CommonHelper.DrawHoverBox(spriteBatch, label, position, wrapWidth); + } + + /// Show an informational message to the player. + /// The message to show. + public void ShowInfoMessage(string message) + { + CommonHelper.ShowInfoMessage(message); + } + + /// Show an error message to the player. + /// The message to show. + public void ShowErrorMessage(string message) + { + CommonHelper.ShowErrorMessage(message); + } + + + /********* + ** Private methods + *********/ + /// Get an NPC's preference for an item. + /// The NPC whose gift taste to get. + /// The item to check. + /// Returns the NPC's gift taste if applicable, else null. + private GiftTaste? GetGiftTaste(NPC npc, Item item) + { + try + { + return (GiftTaste)npc.getGiftTasteForThisItem(item); + } + catch + { + // fails for non-social NPCs + return null; + } + } + } +} diff --git a/Mods/LookupAnything/LookupAnything.csproj b/Mods/LookupAnything/LookupAnything.csproj new file mode 100644 index 000000000..be3028fc3 --- /dev/null +++ b/Mods/LookupAnything/LookupAnything.csproj @@ -0,0 +1,282 @@ + + + + + Debug + AnyCPU + {D1395D25-1604-4104-AFF9-49097E4B9B90} + Library + Properties + LookupAnything + LookupAnything + v4.5.2 + 512 + true + + + true + full + false + bin\Debug\ + DEBUG;TRACE + prompt + 7.2 + 4 + + + pdbonly + true + bin\Release\ + TRACE + prompt + 4 + 7.2 + + + + ..\assemblies\StardewModdingAPI.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.dll + + + ..\assemblies\System.Net.Http.dll + + + ..\assemblies\System.Runtime.Serialization.dll + + + 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/LookupAnything/ModEntry.cs b/Mods/LookupAnything/ModEntry.cs new file mode 100644 index 000000000..83aa624db --- /dev/null +++ b/Mods/LookupAnything/ModEntry.cs @@ -0,0 +1,327 @@ +using System.Collections.Generic; +using System.Linq; +using System.Text; +using Microsoft.Xna.Framework; +using Pathoschild.Stardew.Common; +using Pathoschild.Stardew.Common.Integrations.CustomFarmingRedux; +using Pathoschild.Stardew.LookupAnything.Components; +using Pathoschild.Stardew.LookupAnything.Framework; +using Pathoschild.Stardew.LookupAnything.Framework.Constants; +using Pathoschild.Stardew.LookupAnything.Framework.Subjects; +using StardewModdingAPI; +using StardewModdingAPI.Events; +using StardewValley; +using StardewValley.Menus; + +namespace Pathoschild.Stardew.LookupAnything +{ + /// The mod entry point. + internal class ModEntry : Mod + { + /********* + ** Fields + *********/ + /**** + ** Configuration + ****/ + /// The mod configuration. + private ModConfig Config; + + /// Provides metadata that's not available from the game data directly. + private Metadata Metadata; + + /// The name of the file containing data for the field. + private readonly string DatabaseFileName = "data.json"; + + /**** + ** Validation + ****/ + /// Whether the metadata validation passed. + private bool IsDataValid; + + /**** + ** State + ****/ + /// The previous menus shown before the current lookup UI was opened. + private readonly Stack PreviousMenus = new Stack(); + + /// Provides utility methods for interacting with the game code. + private GameHelper GameHelper; + + /// Finds and analyses lookup targets in the world. + private TargetFactory TargetFactory; + + /// Draws debug information to the screen. + private DebugInterface DebugInterface; + + + /********* + ** 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) + { + // load config + this.Config = this.Helper.ReadConfig(); + + // load & validate database + this.LoadMetadata(); + this.IsDataValid = this.Metadata.LooksValid(); + if (!this.IsDataValid) + { + this.Monitor.Log("The data.json file seems to be missing or corrupt. Lookups will be disabled.", LogLevel.Error); + this.IsDataValid = false; + } + + // validate translations + if (!helper.Translation.GetTranslations().Any()) + this.Monitor.Log("The translation files in this mod's i18n folder seem to be missing. The mod will still work, but you'll see 'missing translation' messages. Try reinstalling the mod to fix this.", LogLevel.Warn); + + // hook up events + helper.Events.GameLoop.GameLaunched += this.OnGameLaunched; + helper.Events.GameLoop.DayStarted += this.OnDayStarted; + helper.Events.Display.RenderedHud += this.OnRenderedHud; + helper.Events.Display.MenuChanged += this.OnMenuChanged; + helper.Events.Input.ButtonPressed += this.OnButtonPressed; + if (this.Config.HideOnKeyUp) + helper.Events.Input.ButtonReleased += this.OnButtonReleased; + } + + + /********* + ** Private methods + *********/ + /**** + ** Event handlers + ****/ + /// The method invoked on the first update tick, once all mods are initialised. + /// The event sender. + /// The event data. + private void OnGameLaunched(object sender, GameLaunchedEventArgs e) + { + if (!this.IsDataValid) + return; + + // initialise functionality + var customFarming = new CustomFarmingReduxIntegration(this.Helper.ModRegistry, this.Monitor); + this.GameHelper = new GameHelper(customFarming); + this.TargetFactory = new TargetFactory(this.Metadata, this.Helper.Translation, this.Helper.Reflection, this.GameHelper); + this.DebugInterface = new DebugInterface(this.GameHelper, this.TargetFactory, this.Config, this.Monitor); + } + + /// The method invoked when a new day starts. + /// The event sender. + /// The event data. + private void OnDayStarted(object sender, DayStartedEventArgs e) + { + // reset low-level cache once per game day (used for expensive queries that don't change within a day) + this.GameHelper.ResetCache(this.Metadata, this.Helper.Reflection, this.Helper.Translation, this.Monitor); + } + + /// The method invoked when the player presses a button. + /// The event sender. + /// The event data. + private void OnButtonPressed(object sender, ButtonPressedEventArgs e) + { + this.Monitor.InterceptErrors("handling your input", $"handling input '{e.Button}'", () => + { + var controls = this.Config.Controls; + + if (controls.ToggleLookup.Contains(e.Button)) + this.ToggleLookup(LookupMode.Cursor); + else if (controls.ToggleLookupInFrontOfPlayer.Contains(e.Button) && Context.IsWorldReady) + this.ToggleLookup(LookupMode.FacingPlayer); + else if (controls.ScrollUp.Contains(e.Button)) + (Game1.activeClickableMenu as LookupMenu)?.ScrollUp(); + else if (controls.ScrollDown.Contains(e.Button)) + (Game1.activeClickableMenu as LookupMenu)?.ScrollDown(); + else if (controls.ToggleDebug.Contains(e.Button) && Context.IsPlayerFree) + this.DebugInterface.Enabled = !this.DebugInterface.Enabled; + }); + } + + /// The method invoked when the player releases a button. + /// The event sender. + /// The event data. + private void OnButtonReleased(object sender, ButtonReleasedEventArgs e) + { + // perform bound action + this.Monitor.InterceptErrors("handling your input", $"handling input release '{e.Button}'", () => + { + var controls = this.Config.Controls; + + if (controls.ToggleLookup.Contains(e.Button) || controls.ToggleLookupInFrontOfPlayer.Contains(e.Button)) + this.HideLookup(); + }); + } + + /// The method invoked when the player closes a displayed menu. + /// The event sender. + /// The event data. + private void OnMenuChanged(object sender, MenuChangedEventArgs e) + { + // restore the previous menu if it was hidden to show the lookup UI + this.Monitor.InterceptErrors("restoring the previous menu", () => + { + if (e.NewMenu == null && e.OldMenu is LookupMenu && this.PreviousMenus.Any()) + Game1.activeClickableMenu = this.PreviousMenus.Pop(); + }); + } + + /// The method invoked when the interface is rendering. + /// The event sender. + /// The event data. + private void OnRenderedHud(object sender, RenderedHudEventArgs e) + { + // render debug interface + if (this.DebugInterface.Enabled) + this.DebugInterface.Draw(Game1.spriteBatch); + } + + /**** + ** Helpers + ****/ + /// Show the lookup UI for the current target. + /// The lookup target mode. + private void ToggleLookup(LookupMode lookupMode) + { + if (Game1.activeClickableMenu is LookupMenu) + this.HideLookup(); + else + this.ShowLookup(lookupMode); + } + + /// Show the lookup UI for the current target. + /// The lookup target mode. + private void ShowLookup(LookupMode lookupMode) + { + // disable lookups if metadata is invalid + if (!this.IsDataValid) + { + this.GameHelper.ShowErrorMessage("The mod doesn't seem to be installed correctly: its data.json file is missing or corrupt."); + return; + } + + // show menu + StringBuilder logMessage = new StringBuilder("Received a lookup request..."); + this.Monitor.InterceptErrors("looking that up", () => + { + try + { + // get target + ISubject subject = this.GetSubject(logMessage, lookupMode); + if (subject == null) + { + this.Monitor.Log($"{logMessage} no target found.", LogLevel.Trace); + return; + } + + // show lookup UI + this.Monitor.Log(logMessage.ToString(), LogLevel.Trace); + this.ShowLookupFor(subject); + } + catch + { + this.Monitor.Log($"{logMessage} an error occurred.", LogLevel.Trace); + throw; + } + }); + } + + /// Show a lookup menu for the given subject. + /// The subject to look up. + internal void ShowLookupFor(ISubject subject) + { + this.Monitor.InterceptErrors("looking that up", () => + { + this.Monitor.Log($"Showing {subject.GetType().Name}::{subject.Type}::{subject.Name}.", LogLevel.Trace); + + LookupMenu lookupMenu = new LookupMenu(this.GameHelper, subject, this.Metadata, this.Monitor, this.Helper.Reflection, this.Config.ScrollAmount, this.Config.ShowDataMiningFields, this.ShowLookupFor); + if (this.ShouldRestoreMenu(Game1.activeClickableMenu)) + { + this.PreviousMenus.Push(Game1.activeClickableMenu); + this.Helper.Reflection.GetField(typeof(Game1), "_activeClickableMenu").SetValue(lookupMenu); // bypass Game1.activeClickableMenu, which disposes the previous menu + } + else + Game1.activeClickableMenu = lookupMenu; + }); + } + + /// Get the most relevant subject under the player's cursor. + /// The log message to which to append search details. + /// The lookup target mode. + private ISubject GetSubject(StringBuilder logMessage, LookupMode lookupMode) + { + // menu under cursor + if (lookupMode == LookupMode.Cursor) + { + Vector2 cursorPos = this.GameHelper.GetScreenCoordinatesFromCursor(); + + // try menu + if (Game1.activeClickableMenu != null) + { + logMessage.Append($" searching the open '{Game1.activeClickableMenu.GetType().Name}' menu..."); + return this.TargetFactory.GetSubjectFrom(Game1.activeClickableMenu, cursorPos); + } + + // try HUD under cursor + foreach (IClickableMenu menu in Game1.onScreenMenus) + { + if (menu.isWithinBounds((int)cursorPos.X, (int)cursorPos.Y)) + { + logMessage.Append($" searching the on-screen '{menu.GetType().Name}' menu..."); + return this.TargetFactory.GetSubjectFrom(menu, cursorPos); + } + } + } + + // try world + if (Game1.activeClickableMenu == null) + { + logMessage.Append(" searching the world..."); + return this.TargetFactory.GetSubjectFrom(Game1.player, Game1.currentLocation, lookupMode, this.Config.EnableTileLookups); + } + + // not found + return null; + } + + /// Show the lookup UI for the current target. + private void HideLookup() + { + this.Monitor.InterceptErrors("closing the menu", () => + { + if (Game1.activeClickableMenu is LookupMenu) + { + Game1.playSound("bigDeSelect"); // match default behaviour when closing a menu + Game1.activeClickableMenu = null; + } + }); + } + + /// Load the file containing metadata that's not available from the game directly. + private void LoadMetadata() + { + this.Monitor.InterceptErrors("loading metadata", () => + { + this.Metadata = this.Helper.Data.ReadJsonFile(this.DatabaseFileName); + }); + } + + /// Get whether a given menu should be restored when the lookup ends. + /// The menu to check. + private bool ShouldRestoreMenu(IClickableMenu menu) + { + // no menu + if (menu == null) + return false; + + // if 'hide on key up' is enabled, all lookups should close on key up + if (this.Config.HideOnKeyUp && menu is LookupMenu) + return false; + + return true; + } + } +} diff --git a/Mods/LookupAnything/Properties/AssemblyInfo.cs b/Mods/LookupAnything/Properties/AssemblyInfo.cs new file mode 100644 index 000000000..f33a0f25c --- /dev/null +++ b/Mods/LookupAnything/Properties/AssemblyInfo.cs @@ -0,0 +1,36 @@ +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; + +// 有关程序集的一般信息由以下 +// 控制。更改这些特性值可修改 +// 与程序集关联的信息。 +[assembly: AssemblyTitle("LookupAnything")] +[assembly: AssemblyDescription("")] +[assembly: AssemblyConfiguration("")] +[assembly: AssemblyCompany("")] +[assembly: AssemblyProduct("LookupAnything")] +[assembly: AssemblyCopyright("Copyright © 2019")] +[assembly: AssemblyTrademark("")] +[assembly: AssemblyCulture("")] + +// 将 ComVisible 设置为 false 会使此程序集中的类型 +//对 COM 组件不可见。如果需要从 COM 访问此程序集中的类型 +//请将此类型的 ComVisible 特性设置为 true。 +[assembly: ComVisible(false)] + +// 如果此项目向 COM 公开,则下列 GUID 用于类型库的 ID +[assembly: Guid("d1395d25-1604-4104-aff9-49097e4b9b90")] + +// 程序集的版本信息由下列四个值组成: +// +// 主版本 +// 次版本 +// 生成号 +// 修订号 +// +// 可以指定所有值,也可以使用以下所示的 "*" 预置版本号和修订号 +//通过使用 "*",如下所示: +// [assembly: AssemblyVersion("1.0.*")] +[assembly: AssemblyVersion("1.0.0.0")] +[assembly: AssemblyFileVersion("1.0.0.0")] diff --git a/Mods/Mods.sln b/Mods/Mods.sln new file mode 100644 index 000000000..90d0eab58 --- /dev/null +++ b/Mods/Mods.sln @@ -0,0 +1,85 @@ + +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 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ContentPatcher", "ContentPatcher\ContentPatcher.csproj", "{8E1D56B0-D640-4EB0-A703-E280C40A655D}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LookupAnything", "LookupAnything\LookupAnything.csproj", "{D1395D25-1604-4104-AFF9-49097E4B9B90}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "JsonAssets", "JsonAssets\JsonAssets.csproj", "{F56B5F8E-0069-4029-8DCD-89002B7285E3}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "UIInfoSuite", "UI Info Suite\UIInfoSuite.csproj", "{E23D348D-B57A-4EE0-93B6-DEB8F6B094EC}" +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 + {8E1D56B0-D640-4EB0-A703-E280C40A655D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8E1D56B0-D640-4EB0-A703-E280C40A655D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8E1D56B0-D640-4EB0-A703-E280C40A655D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8E1D56B0-D640-4EB0-A703-E280C40A655D}.Release|Any CPU.Build.0 = Release|Any CPU + {D1395D25-1604-4104-AFF9-49097E4B9B90}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D1395D25-1604-4104-AFF9-49097E4B9B90}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D1395D25-1604-4104-AFF9-49097E4B9B90}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D1395D25-1604-4104-AFF9-49097E4B9B90}.Release|Any CPU.Build.0 = Release|Any CPU + {F56B5F8E-0069-4029-8DCD-89002B7285E3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F56B5F8E-0069-4029-8DCD-89002B7285E3}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F56B5F8E-0069-4029-8DCD-89002B7285E3}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F56B5F8E-0069-4029-8DCD-89002B7285E3}.Release|Any CPU.Build.0 = Release|Any CPU + {E23D348D-B57A-4EE0-93B6-DEB8F6B094EC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E23D348D-B57A-4EE0-93B6-DEB8F6B094EC}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E23D348D-B57A-4EE0-93B6-DEB8F6B094EC}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E23D348D-B57A-4EE0-93B6-DEB8F6B094EC}.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 000000000..6ce457abc --- /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 000000000..d5928eeb9 --- /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 000000000..4919c08db --- /dev/null +++ b/Mods/ScytheHarvesting/ScytheHarvesting.cs @@ -0,0 +1,255 @@ +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; + helper.Events.Input.ButtonPressed += this.Events_MouseActionOnHoeDirt; + } + 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_MouseActionOnHoeDirt(object sender, EventArgs e) + { + + if ((config.EnableMod && Context.IsWorldReady) && (Game1.currentLocation.IsFarm || (Game1.currentLocation.Name == "Greenhouse"))) + { + this.SetTargetAsXP(); + } + } + + private void Events_TickUpdate(object sender, EventArgs e) + { + if ((Context.IsWorldReady && (Game1.currentLocation != null)) && ((Context.IsWorldReady && config.EnableMod) && (Game1.currentLocation.IsFarm || Game1.currentLocation.Name.Equals("Greenhouse")))) + { + 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 000000000..a82f471aa --- /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\StardewModdingAPI.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 000000000..7b56a1e94 --- /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 000000000..5ab6d2ed8 --- /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 000000000..c8c5f4557 --- /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 000000000..9a904a84e --- /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 000000000..a5a66043e --- /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\StardewModdingAPI.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 000000000..5d3019fad --- /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 000000000..1cf7875b9 --- /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 000000000..9848f03b2 --- /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 000000000..99aba12cc --- /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 000000000..af6ff3b96 --- /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 000000000..1c9062401 --- /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 000000000..967658cb3 --- /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 000000000..00435f845 --- /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 000000000..7b703229b --- /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 000000000..c40358ce6 --- /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\StardewModdingAPI.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/UI Info Suite/Extensions/CollectionExtensions.cs b/Mods/UI Info Suite/Extensions/CollectionExtensions.cs new file mode 100644 index 000000000..2b27ac8d0 --- /dev/null +++ b/Mods/UI Info Suite/Extensions/CollectionExtensions.cs @@ -0,0 +1,25 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace UIInfoSuite.Extensions +{ + static class CollectionExtensions + { + + public static TValue SafeGet(this IDictionary dictionary, Tkey key, TValue defaultValue = default(TValue)) + { + TValue value = defaultValue; + + if (dictionary != null) + { + if (!dictionary.TryGetValue(key, out value)) + value = defaultValue; + } + + return value; + } + } +} diff --git a/Mods/UI Info Suite/Extensions/ObjectExtensions.cs b/Mods/UI Info Suite/Extensions/ObjectExtensions.cs new file mode 100644 index 000000000..9d7c4c1d2 --- /dev/null +++ b/Mods/UI Info Suite/Extensions/ObjectExtensions.cs @@ -0,0 +1,77 @@ +using Microsoft.Xna.Framework; +using StardewModdingAPI; +using StardewValley; +using System; +using System.Collections.Generic; + +namespace UIInfoSuite.Extensions +{ + public static class ObjectExtensions + { + #region Memebers + private static readonly Dictionary _npcHeadShotSize = new Dictionary() + { + { "Piere", 9 }, + { "Sebastian", 7 }, + { "Evelyn", 5 }, + { "Penny", 6 }, + { "Jas", 6 }, + { "Caroline", 5 }, + { "Dwarf", 5 }, + { "Sam", 9 }, + { "Maru", 6 }, + { "Wizard", 9 }, + { "Jodi", 7 }, + { "Krobus", 7 }, + { "Alex", 8 }, + { "Kent", 10 }, + { "Linus", 4 }, + { "Harvey", 9 }, + { "Shane", 8 }, + { "Haley", 6 }, + { "Robin", 7 }, + { "Marlon", 2 }, + { "Emily", 8 }, + { "Marnie", 5 }, + { "Abigail", 7 }, + { "Leah", 6 }, + { "George", 5 }, + { "Elliott", 9 }, + { "Gus", 7 }, + { "Lewis", 8 }, + { "Demetrius", 11 }, + { "Pam", 5 }, + { "Vincent", 6 }, + { "Sandy", 7 }, + { "Clint", 10 }, + { "Willy", 10 } + }; + + #endregion + + public static Rectangle GetHeadShot(this NPC npc) + { + int size; + if (!_npcHeadShotSize.TryGetValue(npc.Name, out size)) + size = 4; + + Rectangle mugShotSourceRect = npc.getMugShotSourceRect(); + mugShotSourceRect.Height -= size / 2; + mugShotSourceRect.Y -= size / 2; + return mugShotSourceRect; + } + + public static string SafeGetString(this IModHelper helper, string key) + { + string result = string.Empty; + + if (!string.IsNullOrEmpty(key) && + helper != null) + { + result = helper.Translation.Get(key); + } + + return result; + } + } +} diff --git a/Mods/UI Info Suite/Extensions/StringExtensions.cs b/Mods/UI Info Suite/Extensions/StringExtensions.cs new file mode 100644 index 000000000..b54a3d5c2 --- /dev/null +++ b/Mods/UI Info Suite/Extensions/StringExtensions.cs @@ -0,0 +1,47 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace UIInfoSuite.Extensions +{ + static class StringExtensions + { + + + public static int SafeParseInt32(this string s) + { + int result = 0; + + if (!string.IsNullOrWhiteSpace(s)) + { + int.TryParse(s, out result); + } + + return result; + } + + public static long SafeParseInt64(this string s) + { + long result = 0; + + if (!string.IsNullOrWhiteSpace(s)) + long.TryParse(s, out result); + + return result; + } + + public static bool SafeParseBool(this string s) + { + bool result = false; + + if (!string.IsNullOrWhiteSpace(s)) + { + bool.TryParse(s, out result); + } + + return result; + } + } +} diff --git a/Mods/UI Info Suite/IconHandler.cs b/Mods/UI Info Suite/IconHandler.cs new file mode 100644 index 000000000..53a0b8f9c --- /dev/null +++ b/Mods/UI Info Suite/IconHandler.cs @@ -0,0 +1,48 @@ +using Microsoft.Xna.Framework; +using StardewValley; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace UIInfoSuite +{ + class IconHandler + { + public static IconHandler Handler { get; private set; } + + static IconHandler() + { + if (Handler == null) + Handler = new IconHandler(); + } + + private int _amountOfVisibleIcons; + + private IconHandler() + { + + } + + public Point GetNewIconPosition() + { + int yPos = Game1.options.zoomButtons ? 320 : 290; + int xPosition = (int)Tools.GetWidthInPlayArea() - 214 - 69 * this._amountOfVisibleIcons; + ++this._amountOfVisibleIcons; + return new Point(xPosition, yPos); + } + + public void Reset(object sender, EventArgs e) + { + this._amountOfVisibleIcons = 0; + } + private int scaledViewportWidth + { + get + { + return (int)(((float)Game1.clientBounds.Width) / Game1.menuButtonScale); + } + } + } +} diff --git a/Mods/UI Info Suite/LEEvents.cs b/Mods/UI Info Suite/LEEvents.cs new file mode 100644 index 000000000..22c8b69af --- /dev/null +++ b/Mods/UI Info Suite/LEEvents.cs @@ -0,0 +1,14 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace UIInfoSuite +{ + public interface LEEvents + { + event EventHandler OnXPChanged; + void raiseEvent(); + } +} diff --git a/Mods/UI Info Suite/LanguageKeys.cs b/Mods/UI Info Suite/LanguageKeys.cs new file mode 100644 index 000000000..108d1e5f8 --- /dev/null +++ b/Mods/UI Info Suite/LanguageKeys.cs @@ -0,0 +1,29 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace UIInfoSuite +{ + static class LanguageKeys + { + public const string Days = "Days"; + public const string DaysToMature = "DaysToMature"; + public const string Hours = "Hours"; + public const string Minutes = "Minutes"; + public const string ReadyToHarvest = "ReadyToHarvest"; + public const string TodaysRecipe = "TodaysRecipe"; + public const string MaybeStayHome = "MaybeStayHome"; + public const string NotFeelingLuckyAtAll = "NotFeelingLuckyAtAll"; + public const string LuckyButNotTooLucky = "LuckyButNotTooLucky"; + public const string FeelingLucky = "FeelingLucky"; + public const string TravelingMerchantIsInTown = "TravelingMerchantIsInTown"; + public const string HarvestPrice = "HarvestPrice"; + public const string LevelUp = "LevelUp"; + public const string Calendar = "Calendar"; + public const string Billboard = "Billboard"; + public const string DaysUntilToolIsUpgraded = "DaysUntilToolIsUpgraded"; + public const string ToolIsFinishedBeingUpgraded = "ToolIsFinishedBeingUpgraded"; + } +} diff --git a/Mods/UI Info Suite/LevelExtenderInterface.cs b/Mods/UI Info Suite/LevelExtenderInterface.cs new file mode 100644 index 000000000..33f7be287 --- /dev/null +++ b/Mods/UI Info Suite/LevelExtenderInterface.cs @@ -0,0 +1,14 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace UIInfoSuite +{ + public interface LevelExtenderInterface + { + int[] currentXP(); + int[] requiredXP(); + } +} diff --git a/Mods/UI Info Suite/ModConfig.cs b/Mods/UI Info Suite/ModConfig.cs new file mode 100644 index 000000000..2d880c961 --- /dev/null +++ b/Mods/UI Info Suite/ModConfig.cs @@ -0,0 +1,89 @@ +using StardewModdingAPI; + +namespace UIInfoSuite +{ + class ModConfig + { + public string[] KeysForBarrelAndCropTimes { get; set; } = new string[] + { + SButton.LeftShift.ToString() + }; + + public bool CanRightClickForBarrelAndCropTimes { get; set; } = true; + + public int[][] Sprinkler { get; set; } = new int[][] + { + new int[] { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }, + new int[] { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }, + new int[] { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }, + new int[] { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }, + new int[] { 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0 }, + new int[] { 0, 0, 0, 0, 1, 0, 1, 0, 0, 0, 0 }, + new int[] { 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0 }, + new int[] { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }, + new int[] { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }, + new int[] { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }, + new int[] { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 } + }; + + public int[][] QualitySprinkler { get; set; } = new int[][] + { + new int[] { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }, + new int[] { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }, + new int[] { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }, + new int[] { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }, + new int[] { 0, 0, 0, 0, 1, 1, 1, 0, 0, 0, 0 }, + new int[] { 0, 0, 0, 0, 1, 0, 1, 0, 0, 0, 0 }, + new int[] { 0, 0, 0, 0, 1, 1, 1, 0, 0, 0, 0 }, + new int[] { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }, + new int[] { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }, + new int[] { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }, + new int[] { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 } + }; + + public int[][] IridiumSprinkler { get; set; } = new int[][] + { + new int[] { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }, + new int[] { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }, + new int[] { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }, + new int[] { 0, 0, 0, 1, 1, 1, 1, 1, 0, 0, 0 }, + new int[] { 0, 0, 0, 1, 1, 1, 1, 1, 0, 0, 0 }, + new int[] { 0, 0, 0, 1, 1, 0, 1, 1, 0, 0, 0 }, + new int[] { 0, 0, 0, 1, 1, 1, 1, 1, 0, 0, 0 }, + new int[] { 0, 0, 0, 1, 1, 1, 1, 1, 0, 0, 0 }, + new int[] { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }, + new int[] { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }, + new int[] { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 } + }; + + public int[][] PrismaticSprinkler { get; set; } = new int[][] + { + new int[] { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }, + new int[] { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }, + new int[] { 0, 0, 1, 1, 1, 1, 1, 1, 1, 0, 0 }, + new int[] { 0, 0, 1, 1, 1, 1, 1, 1, 1, 0, 0 }, + new int[] { 0, 0, 1, 1, 1, 1, 1, 1, 1, 0, 0 }, + new int[] { 0, 0, 1, 1, 1, 0, 1, 1, 1, 0, 0 }, + new int[] { 0, 0, 1, 1, 1, 1, 1, 1, 1, 0, 0 }, + new int[] { 0, 0, 1, 1, 1, 1, 1, 1, 1, 0, 0 }, + new int[] { 0, 0, 1, 1, 1, 1, 1, 1, 1, 0, 0 }, + new int[] { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }, + new int[] { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 } + }; + + public int[][] Beehouse { get; set; } = new int[][] + { + new int[] { 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0 }, + new int[] { 0, 0, 0, 0, 1, 1, 1, 0, 0, 0, 0 }, + new int[] { 0, 0, 0, 1, 1, 1, 1, 1, 0, 0, 0 }, + new int[] { 0, 0, 1, 1, 1, 1, 1, 1, 1, 0, 0 }, + new int[] { 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0 }, + new int[] { 0, 1, 1, 1, 1, 0, 1, 1, 1, 1, 0 }, + new int[] { 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0 }, + new int[] { 0, 0, 1, 1, 1, 1, 1, 1, 1, 0, 0 }, + new int[] { 0, 0, 0, 1, 1, 1, 1, 1, 0, 0, 0 }, + new int[] { 0, 0, 0, 0, 1, 1, 1, 0, 0, 0, 0 }, + new int[] { 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0 } + }; + } +} diff --git a/Mods/UI Info Suite/ModEntry.cs b/Mods/UI Info Suite/ModEntry.cs new file mode 100644 index 000000000..df7e6f34a --- /dev/null +++ b/Mods/UI Info Suite/ModEntry.cs @@ -0,0 +1,135 @@ +using UIInfoSuite.Options; +using UIInfoSuite.UIElements; +using StardewModdingAPI; +using StardewModdingAPI.Events; +using StardewValley; +using System; +using System.Collections.Generic; +using System.IO; +using System.Xml; + +namespace UIInfoSuite +{ + public class ModEntry : Mod + { + private SkipIntro _skipIntro; + + private string _modDataFileName; + private readonly Dictionary _options = new Dictionary(); + + public static IMonitor MonitorObject { get; private set; } + public static IModHelper HelperObject { get; private set; } + + private ModOptionsPageHandler _modOptionsPageHandler; + + /// The mod entry point, called after the mod is first loaded. + /// Provides simplified APIs for writing mods. + public override void Entry(IModHelper helper) + { + //Helper = helper; + MonitorObject = this.Monitor; + HelperObject = this.Helper; + this._skipIntro = new SkipIntro(helper.Events); + + this.Monitor.Log("starting.", LogLevel.Debug); + helper.Events.GameLoop.SaveLoaded += this.OnSaveLoaded; + helper.Events.GameLoop.Saved += this.OnSaved; + helper.Events.GameLoop.ReturnedToTitle += this.OnReturnedToTitle; + helper.Events.Display.Rendering += IconHandler.Handler.Reset; + + //Resources = new ResourceManager("UIInfoSuite.Resource.strings", Assembly.GetAssembly(typeof(ModEntry))); + //try + //{ + // //Test to make sure the culture specific files are there + // Resources.GetString(LanguageKeys.Days, ModEntry.SpecificCulture); + //} + //catch + //{ + // Resources = Properties.Resources.ResourceManager; + //} + } + + /// Raised after the game returns to the title screen. + /// The event sender. + private void OnReturnedToTitle(object sender, ReturnedToTitleEventArgs e) + { + this._modOptionsPageHandler?.Dispose(); + this._modOptionsPageHandler = null; + } + + /// Raised after the game finishes writing data to the save file (except the initial save creation). + /// The event sender. + /// The event arguments. + private void OnSaved(object sender, EventArgs e) + { + if (!string.IsNullOrWhiteSpace(this._modDataFileName)) + { + if (File.Exists(this._modDataFileName)) + File.Delete(this._modDataFileName); + XmlWriterSettings settings = new XmlWriterSettings(); + settings.Indent = true; + settings.IndentChars = " "; + using (XmlWriter writer = XmlWriter.Create(File.Open(this._modDataFileName, FileMode.Create, FileAccess.Write, FileShare.ReadWrite), settings)) + { + writer.WriteStartElement("options"); + + foreach (KeyValuePair option in this._options) + { + writer.WriteStartElement("option"); + writer.WriteAttributeString("name", option.Key); + writer.WriteValue(option.Value); + writer.WriteEndElement(); + } + writer.WriteEndElement(); + writer.Close(); + } + } + } + + /// 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) + { + try + { + try + { + this._modDataFileName = Path.Combine(this.Helper.DirectoryPath, Game1.player.Name + "_modData.xml"); + } + catch + { + this.Monitor.Log("Error: Player name contains character that cannot be used in file name. Using generic file name." + Environment.NewLine + + "Options may not be able to be different between characters.", LogLevel.Warn); + this._modDataFileName = Path.Combine(this.Helper.DirectoryPath, "default_modData.xml"); + } + + if (File.Exists(this._modDataFileName)) + { + XmlDocument document = new XmlDocument(); + + document.Load(this._modDataFileName); + XmlNodeList nodes = document.GetElementsByTagName("option"); + + foreach (XmlNode node in nodes) + { + string key = node.Attributes["name"]?.Value; + string value = node.InnerText; + + if (key != null) + this._options[key] = value; + } + + } + } + catch (Exception ex) + { + this.Monitor.Log("Error loading mod config. " + ex.Message + Environment.NewLine + ex.StackTrace, LogLevel.Error); + } + + this._modOptionsPageHandler = new ModOptionsPageHandler(this.Helper, this._options); + } + + + } +} diff --git a/Mods/UI Info Suite/Options/ModOptionsCheckbox.cs b/Mods/UI Info Suite/Options/ModOptionsCheckbox.cs new file mode 100644 index 000000000..15289deaf --- /dev/null +++ b/Mods/UI Info Suite/Options/ModOptionsCheckbox.cs @@ -0,0 +1,63 @@ +using UIInfoSuite.Extensions; +using StardewValley; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Microsoft.Xna.Framework.Graphics; +using Microsoft.Xna.Framework; +using StardewValley.Menus; + +namespace UIInfoSuite.Options +{ + class ModOptionsCheckbox : ModOptionsElement + { + private const int PixelSize = 9; + + private readonly Action _toggleOptionsDelegate; + private bool _isChecked; + private readonly IDictionary _options; + private readonly string _optionKey; + + public ModOptionsCheckbox( + string label, + int whichOption, + Action toggleOptionDelegate, + IDictionary options, + string optionKey, + bool defaultValue = true, + int x = -1, + int y = -1) + : base(label, x, y, PixelSize * Game1.pixelZoom, PixelSize * Game1.pixelZoom, whichOption) + { + this._toggleOptionsDelegate = toggleOptionDelegate; + this._options = options; + this._optionKey = optionKey; + + if (!this._options.ContainsKey(this._optionKey)) + this._options[this._optionKey] = defaultValue.ToString(); + + this._isChecked = this._options[this._optionKey].SafeParseBool(); + this._toggleOptionsDelegate(this._isChecked); + } + + public override void ReceiveLeftClick(int x, int y) + { + if (this._canClick) + { + Game1.playSound("drumkit6"); + base.ReceiveLeftClick(x, y); + this._isChecked = !this._isChecked; + this._options[this._optionKey] = this._isChecked.ToString(); + this._toggleOptionsDelegate(this._isChecked); + } + } + + public override void Draw(SpriteBatch batch, int slotX, int slotY) + { + batch.Draw(Game1.mouseCursors, new Vector2(slotX + this.Bounds.X, slotY + this.Bounds.Y), new Rectangle?(this._isChecked ? OptionsCheckbox.sourceRectChecked : OptionsCheckbox.sourceRectUnchecked), Color.White * (this._canClick ? 1f : 0.33f), 0.0f, Vector2.Zero, Game1.pixelZoom, SpriteEffects.None, 0.4f); + base.Draw(batch, slotX, slotY); + } + } +} diff --git a/Mods/UI Info Suite/Options/ModOptionsElement.cs b/Mods/UI Info Suite/Options/ModOptionsElement.cs new file mode 100644 index 000000000..b1d599694 --- /dev/null +++ b/Mods/UI Info Suite/Options/ModOptionsElement.cs @@ -0,0 +1,83 @@ +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Graphics; +using Microsoft.Xna.Framework.Input; +using StardewValley; +using StardewValley.BellsAndWhistles; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace UIInfoSuite.Options +{ + public class ModOptionsElement + { + private const int DefaultX = 8; + private const int DefaultY = 4; + private const int DefaultPixelSize = 9; + private Rectangle _bounds; + private string _label; + private int _whichOption; + protected bool _canClick = true; + + public Rectangle Bounds { get { return this._bounds; } } + + public ModOptionsElement(string label) + : this(label, -1, -1, DefaultPixelSize * Game1.pixelZoom, DefaultPixelSize * Game1.pixelZoom) + { + + } + + public ModOptionsElement(string label, int x, int y, int width, int height, int whichOption = -1) + { + if (x < 0) + x = DefaultX * Game1.pixelZoom; + + if (y < 0) + y = DefaultY * Game1.pixelZoom; + + this._bounds = new Rectangle(x, y, width, height); + this._label = label; + this._whichOption = whichOption; + } + + public virtual void ReceiveLeftClick(int x, int y) + { + + } + + public virtual void LeftClickHeld(int x, int y) + { + + } + + public virtual void LeftClickReleased(int x, int y) + { + + } + + public virtual void ReceiveKeyPress(Keys key) + { + + } + + public virtual void Draw(SpriteBatch batch, int slotX, int slotY) + { + if (this._whichOption < 0) + { + SpriteText.drawString(batch, this._label, slotX + this._bounds.X, slotY + this._bounds.Y + Game1.pixelZoom * 3, 999, -1, 999, 1, 0.1f); + } + else + { + Utility.drawTextWithShadow(batch, + this._label, + Game1.dialogueFont, + new Vector2(slotX + this._bounds.X + this._bounds.Width + Game1.pixelZoom * 2, slotY + this._bounds.Y), + this._canClick ? Game1.textColor : Game1.textColor * 0.33f, + 1f, + 0.1f); + } + } + } +} diff --git a/Mods/UI Info Suite/Options/ModOptionsPage.cs b/Mods/UI Info Suite/Options/ModOptionsPage.cs new file mode 100644 index 000000000..7eec42bd1 --- /dev/null +++ b/Mods/UI Info Suite/Options/ModOptionsPage.cs @@ -0,0 +1,360 @@ +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Input; +using StardewValley; +using StardewValley.Menus; +using System; +using System.Collections.Generic; +using Microsoft.Xna.Framework.Graphics; +using StardewModdingAPI.Events; + +namespace UIInfoSuite.Options +{ + public class ModOptionsPage : IClickableMenu + { + private const int Width = 800; + + private List _optionSlots = new List(); + private List _options; + private string _hoverText; + private int _optionsSlotHeld; + private int _currentItemIndex; + private bool _isScrolling; + private ClickableTextureComponent _upArrow; + private ClickableTextureComponent _downArrow; + private ClickableTextureComponent _scrollBar; + private Rectangle _scrollBarRunner; + + public ModOptionsPage(List options, IModEvents events) + : base(Game1.activeClickableMenu.xPositionOnScreen, Game1.activeClickableMenu.yPositionOnScreen + 10, Width, Game1.activeClickableMenu.height) + { + this._options = options; + this._upArrow = new ClickableTextureComponent( + new Rectangle( + this.xPositionOnScreen + this.width + Game1.tileSize / 4, + this.yPositionOnScreen + Game1.tileSize, + 11 * Game1.pixelZoom, + 12 * Game1.pixelZoom), + Game1.mouseCursors, + new Rectangle(421, 459, 11, 12), + Game1.pixelZoom); + + this._downArrow = new ClickableTextureComponent( + new Rectangle( + this._upArrow.bounds.X, + this.yPositionOnScreen + this.height - Game1.tileSize, + this._upArrow.bounds.Width, + this._upArrow.bounds.Height), + Game1.mouseCursors, + new Rectangle(421, 472, 11, 12), + Game1.pixelZoom); + + this._scrollBar = new ClickableTextureComponent( + new Rectangle( + this._upArrow.bounds.X + Game1.pixelZoom * 3, + this._upArrow.bounds.Y + this._upArrow.bounds.Height + Game1.pixelZoom, + 6 * Game1.pixelZoom, + 10 * Game1.pixelZoom), + Game1.mouseCursors, + new Rectangle(435, 463, 6, 10), + Game1.pixelZoom); + + this._scrollBarRunner = new Rectangle(this._scrollBar.bounds.X, + this._scrollBar.bounds.Y, + this._scrollBar.bounds.Width, + this.height - Game1.tileSize * 2 - this._upArrow.bounds.Height - Game1.pixelZoom * 2); + + for (int i = 0; i < 7; ++i) + this._optionSlots.Add(new ClickableComponent( + new Rectangle( + this.xPositionOnScreen + Game1.tileSize / 4, + this.yPositionOnScreen + Game1.tileSize * 5 / 4 + Game1.pixelZoom + i * (this.height - Game1.tileSize * 2) / 7, + this.width - Game1.tileSize / 2, + (this.height - Game1.tileSize * 2) / 7 + Game1.pixelZoom), + i.ToString())); + + events.Display.MenuChanged += this.OnMenuChanged; + } + + /// Raised after a game menu is opened, closed, or replaced. + /// The event sender. + /// The event arguments. + private void OnMenuChanged(object sender, MenuChangedEventArgs e) + { + if (e.NewMenu is GameMenu) + { + this.xPositionOnScreen = Game1.activeClickableMenu.xPositionOnScreen; + this.yPositionOnScreen = Game1.activeClickableMenu.yPositionOnScreen + 10; + this.height = Game1.activeClickableMenu.height; + + for (int i = 0; i < this._optionSlots.Count; ++i) + { + ClickableComponent next = this._optionSlots[i]; + next.bounds.X = this.xPositionOnScreen + Game1.tileSize / 4; + next.bounds.Y = this.yPositionOnScreen + Game1.tileSize * 5 / 4 + Game1.pixelZoom + i * (this.height - Game1.tileSize * 2) / 7; + next.bounds.Width = this.width - Game1.tileSize / 2; + next.bounds.Height = (this.height - Game1.tileSize * 2) / 7 + Game1.pixelZoom; + } + + this._upArrow.bounds.X = this.xPositionOnScreen + this.width + Game1.tileSize / 4; + this._upArrow.bounds.Y = this.yPositionOnScreen + Game1.tileSize; + this._upArrow.bounds.Width = 11 * Game1.pixelZoom; + this._upArrow.bounds.Height = 12 * Game1.pixelZoom; + + this._downArrow.bounds.X = this._upArrow.bounds.X; + this._downArrow.bounds.Y = this.yPositionOnScreen + this.height - Game1.tileSize; + this._downArrow.bounds.Width = this._upArrow.bounds.Width; + this._downArrow.bounds.Height = this._upArrow.bounds.Height; + + this._scrollBar.bounds.X = this._upArrow.bounds.X + Game1.pixelZoom * 3; + this._scrollBar.bounds.Y = this._upArrow.bounds.Y + this._upArrow.bounds.Height + Game1.pixelZoom; + this._scrollBar.bounds.Width = 6 * Game1.pixelZoom; + this._scrollBar.bounds.Height = 10 * Game1.pixelZoom; + + this._scrollBarRunner.X = this._scrollBar.bounds.X; + this._scrollBarRunner.Y = this._scrollBar.bounds.Y; + this._scrollBarRunner.Width = this._scrollBar.bounds.Width; + this._scrollBarRunner.Height = this.height - Game1.tileSize * 2 - this._upArrow.bounds.Height - Game1.pixelZoom * 2; + } + } + + private void SetScrollBarToCurrentItem() + { + if (this._options.Count > 0) + { + this._scrollBar.bounds.Y = this._scrollBarRunner.Height / Math.Max(1, this._options.Count - 7 + 1) * this._currentItemIndex + this._upArrow.bounds.Bottom + Game1.pixelZoom; + + if (this._currentItemIndex == this._options.Count - 7) + { + this._scrollBar.bounds.Y = this._downArrow.bounds.Y - this._scrollBar.bounds.Height - Game1.pixelZoom; + } + } + } + + public override void leftClickHeld(int x, int y) + { + if (!GameMenu.forcePreventClose) + { + base.leftClickHeld(x, y); + + if (this._isScrolling) + { + int yBefore = this._scrollBar.bounds.Y; + + this._scrollBar.bounds.Y = Math.Min( + this.yPositionOnScreen + this.height - Game1.tileSize - Game1.pixelZoom * 3 - this._scrollBar.bounds.Height, + Math.Max( + y, + this.yPositionOnScreen + this._upArrow.bounds.Height + Game1.pixelZoom * 5)); + + this._currentItemIndex = Math.Min( + this._options.Count - 7, + Math.Max( + 0, + this._options.Count * (y - this._scrollBarRunner.Y) / this._scrollBarRunner.Height)); + + this.SetScrollBarToCurrentItem(); + + if (yBefore != this._scrollBar.bounds.Y) + Game1.playSound("shiny4"); + } + else if (this._optionsSlotHeld > -1 && this._optionsSlotHeld + this._currentItemIndex < this._options.Count) + { + this._options[this._currentItemIndex + this._optionsSlotHeld].LeftClickHeld( + x - this._optionSlots[this._optionsSlotHeld].bounds.X, + y - this._optionSlots[this._optionsSlotHeld].bounds.Y); + } + } + } + + public override void receiveKeyPress(Keys key) + { + if (this._optionsSlotHeld > -1 && + this._optionsSlotHeld + this._currentItemIndex < this._options.Count) + { + this._options[this._currentItemIndex + this._optionsSlotHeld].ReceiveKeyPress(key); + } + } + + 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._options.Count - 7)) + { + this.DownArrowPressed(); + Game1.playSound("shiny4"); + } + } + } + + public override void releaseLeftClick(int x, int y) + { + if (!GameMenu.forcePreventClose) + { + base.releaseLeftClick(x, y); + + if (this._optionsSlotHeld > -1 && this._optionsSlotHeld + this._currentItemIndex < this._options.Count) + { + ClickableComponent optionSlot = this._optionSlots[this._optionsSlotHeld]; + this._options[this._currentItemIndex + this._optionsSlotHeld].LeftClickReleased(x - optionSlot.bounds.X, y - optionSlot.bounds.Y); + } + this._optionsSlotHeld = -1; + this._isScrolling = false; + } + } + + private void DownArrowPressed() + { + this._downArrow.scale = this._downArrow.baseScale; + ++this._currentItemIndex; + this.SetScrollBarToCurrentItem(); + } + + private void UpArrowPressed() + { + this._upArrow.scale = this._upArrow.baseScale; + --this._currentItemIndex; + this.SetScrollBarToCurrentItem(); + } + + public override void receiveLeftClick(int x, int y, bool playSound = true) + { + if (!GameMenu.forcePreventClose) + { + if (this._downArrow.containsPoint(x, y) && this._currentItemIndex < Math.Max(0, this._options.Count - 7)) + { + this.DownArrowPressed(); + Game1.playSound("shwip"); + } + else if (this._upArrow.containsPoint(x, y) && this._currentItemIndex > 0) + { + this.UpArrowPressed(); + Game1.playSound("shwip"); + } + else if (this._scrollBar.containsPoint(x, y)) + { + this._isScrolling = true; + } + else if (!this._downArrow.containsPoint(x, y) && + x > this.xPositionOnScreen + this.width && + x < this.xPositionOnScreen + this.width + Game1.tileSize * 2 && + y > this.yPositionOnScreen && + y < this.yPositionOnScreen + this.height) + { + this._isScrolling = true; + base.leftClickHeld(x, y); + base.releaseLeftClick(x, y); + } + this._currentItemIndex = Math.Max(0, Math.Min(this._options.Count - 7, this._currentItemIndex)); + for (int i = 0; i < this._optionSlots.Count; ++i) + { + if (this._optionSlots[i].bounds.Contains(x, y) && + this._currentItemIndex + i < this._options.Count && + this._options[this._currentItemIndex + i].Bounds.Contains(x - this._optionSlots[i].bounds.X, y - this._optionSlots[i].bounds.Y)) + { + this._options[this._currentItemIndex + i].ReceiveLeftClick( + x - this._optionSlots[i].bounds.X, + y - this._optionSlots[i].bounds.Y); + this._optionsSlotHeld = i; + break; + } + } + } + } + + + public override void receiveRightClick(int x, int y, bool playSound = true) + { + + } + + public override void receiveGamePadButton(Buttons b) + { + if (b == Buttons.A) + { + this.receiveLeftClick(Game1.getMouseX(), Game1.getMouseY()); + } + } + + public override void performHoverAction(int x, int y) + { + if (!GameMenu.forcePreventClose) + { + this._hoverText = ""; + this._upArrow.tryHover(x, y); + this._downArrow.tryHover(x, y); + this._scrollBar.tryHover(x, y); + } + } + + public override void draw(SpriteBatch batch) + { + Game1.drawDialogueBox(this.xPositionOnScreen, this.yPositionOnScreen - 10, this.width, this.height, false, true); + batch.End(); + batch.Begin(SpriteSortMode.FrontToBack, BlendState.NonPremultiplied, SamplerState.PointClamp, null, null); + for (int i = 0; i < this._optionSlots.Count; ++i) + { + if (this._currentItemIndex >= 0 && + this._currentItemIndex + i < this._options.Count) + { + this._options[this._currentItemIndex + i].Draw( + batch, + this._optionSlots[i].bounds.X, + this._optionSlots[i].bounds.Y); + } + } + batch.End(); + batch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointClamp, null, null); + if (!GameMenu.forcePreventClose) + { + this._upArrow.draw(batch); + this._downArrow.draw(batch); + if (this._options.Count > 7) + { + IClickableMenu.drawTextureBox( + batch, + Game1.mouseCursors, + new Rectangle(403, 383, 6, 6), + this._scrollBarRunner.X, + this._scrollBarRunner.Y, + this._scrollBarRunner.Width, + this._scrollBarRunner.Height, + Color.White, + Game1.pixelZoom, + false); + this._scrollBar.draw(batch); + } + } + if (this._hoverText != "") + IClickableMenu.drawHoverText(batch, this._hoverText, Game1.smallFont); + + //if (Game1.options.hardwareCursor) + //{ + // Game1.spriteBatch.Draw( + // Game1.mouseCursors, + // new Vector2( + // Game1.getMouseX(), + // Game1.getMouseY()), + // new Rectangle?( + // Game1.getSourceRectForStandardTileSheet( + // Game1.mouseCursors, + // Game1.mouseCursor, + // 16, + // 16)), + // Color.White, + // 0.0f, + // Vector2.Zero, + // (float)(Game1.pixelZoom + (Game1.dialogueButtonScale / 150.0)), + // SpriteEffects.None, + // 1f); + //} + } + } +} diff --git a/Mods/UI Info Suite/Options/ModOptionsPageButton.cs b/Mods/UI Info Suite/Options/ModOptionsPageButton.cs new file mode 100644 index 000000000..b5e11d61a --- /dev/null +++ b/Mods/UI Info Suite/Options/ModOptionsPageButton.cs @@ -0,0 +1,108 @@ +using Microsoft.Xna.Framework; +using StardewModdingAPI; +using StardewModdingAPI.Events; +using StardewValley; +using StardewValley.Menus; +using System; +using Microsoft.Xna.Framework.Graphics; + +namespace UIInfoSuite.Options +{ + class ModOptionsPageButton : IClickableMenu + { + + public Rectangle Bounds { get; } + //private readonly ModOptionsPageHandler _optionsPageHandler; + //private bool _hasClicked; + + public event EventHandler OnLeftClicked; + + public ModOptionsPageButton(IModEvents events) + { + //_optionsPageHandler = optionsPageHandler; + this.width = 64; + this.height = 64; + GameMenu activeClickableMenu = Game1.activeClickableMenu as GameMenu; + + this.xPositionOnScreen = activeClickableMenu.xPositionOnScreen + activeClickableMenu.width - 200; + this.yPositionOnScreen = activeClickableMenu.yPositionOnScreen + 16; + this.Bounds = new Rectangle(this.xPositionOnScreen, this.yPositionOnScreen, this.width, this.height); + events.Input.ButtonPressed += this.OnButtonPressed; + events.Display.MenuChanged += this.OnMenuChanged; + } + + /// Raised after a game menu is opened, closed, or replaced. + /// The event sender. + /// The event arguments. + private void OnMenuChanged(object sender, MenuChangedEventArgs e) + { + if (e.NewMenu is GameMenu menu) + { + this.xPositionOnScreen = menu.xPositionOnScreen + menu.width - 200; + } + } + + /// Raised after the player presses a button on the keyboard, controller, or mouse. + /// The event sender. + /// The event arguments. + public void OnButtonPressed(object sender, ButtonPressedEventArgs e) + { + if (e.Button == SButton.MouseLeft || e.Button == SButton.ControllerA) + { + int x = (int)e.Cursor.ScreenPixels.X; + int y = (int)e.Cursor.ScreenPixels.Y; + if (this.isWithinBounds(x, y)) + { + this.receiveLeftClick(x, y); + OnLeftClicked?.Invoke(this, null); + } + } + + //if (e.NewState.LeftButton != ButtonState.Pressed || !(Game1.activeClickableMenu is GameMenu)) + //{ + // _hasClicked = false; + //} + //else if ((Game1.activeClickableMenu as GameMenu).currentTab != 3 && + // isWithinBounds(e.NewPosition.X, e.NewPosition.Y) && + // !_hasClicked) + //{ + // receiveLeftClick(e.NewPosition.X, e.NewPosition.Y); + + //} + } + + public override void draw(SpriteBatch b) + { + base.draw(b); + Game1.spriteBatch.Draw(Game1.mouseCursors, + new Vector2(this.xPositionOnScreen, this.yPositionOnScreen), + new Rectangle(16, 368, 16, 16), + Color.White, + 0.0f, + Vector2.Zero, + Game1.pixelZoom, + SpriteEffects.None, + 1f); + + b.Draw(Game1.mouseCursors, + new Vector2(this.xPositionOnScreen + 8, this.yPositionOnScreen + 14), + new Rectangle(32, 672, 16, 16), + Color.White, + 0.0f, + Vector2.Zero, + 3f, + SpriteEffects.None, + 1f); + + if (this.isWithinBounds(Game1.getMouseX(), Game1.getMouseY())) + { + IClickableMenu.drawHoverText(Game1.spriteBatch, "UI Info Mod Options", Game1.smallFont); + } + Tools.DrawMouseCursor(); + } + + public override void receiveRightClick(int x, int y, bool playSound = true) + { + } + } +} diff --git a/Mods/UI Info Suite/Options/ModOptionsPageHandler.cs b/Mods/UI Info Suite/Options/ModOptionsPageHandler.cs new file mode 100644 index 000000000..b4ef195ad --- /dev/null +++ b/Mods/UI Info Suite/Options/ModOptionsPageHandler.cs @@ -0,0 +1,179 @@ +using UIInfoSuite.UIElements; +using StardewModdingAPI; +using StardewModdingAPI.Events; +using StardewValley; +using StardewValley.Menus; +using System; +using System.Collections.Generic; +using System.Reflection; +using UIInfoSuite.Extensions; + +namespace UIInfoSuite.Options +{ + class ModOptionsPageHandler : IDisposable + { + private List _optionsElements = new List(); + private readonly List _elementsToDispose; + private readonly IDictionary _options; + private ModOptionsPageButton _modOptionsPageButton; + private ModOptionsPage _modOptionsPage; + private readonly IModHelper _helper; + + private int _modOptionsTabPageNumber; + + private readonly LuckOfDay _luckOfDay; + private readonly ShowBirthdayIcon _showBirthdayIcon; + private readonly ShowAccurateHearts _showAccurateHearts; + private readonly LocationOfTownsfolk _locationOfTownsfolk; + private readonly ShowWhenAnimalNeedsPet _showWhenAnimalNeedsPet; + private readonly ShowCalendarAndBillboardOnGameMenuButton _showCalendarAndBillboardOnGameMenuButton; + private readonly ShowCropAndBarrelTime _showCropAndBarrelTime; + private readonly ShowItemEffectRanges _showScarecrowAndSprinklerRange; + //private readonly ExperienceBar _experienceBar; + private readonly ShowItemHoverInformation _showItemHoverInformation; + private readonly ShowTravelingMerchant _showTravelingMerchant; + private readonly ShopHarvestPrices _shopHarvestPrices; + private readonly ShowQueenOfSauceIcon _showQueenOfSauceIcon; + private readonly ShowToolUpgradeStatus _showToolUpgradeStatus; + + public ModOptionsPageHandler(IModHelper helper, IDictionary options) + { + this._options = options; + helper.Events.Display.MenuChanged += this.ToggleModOptions; + this._helper = helper; + ModConfig modConfig = this._helper.ReadConfig(); + this._luckOfDay = new LuckOfDay(helper); + this._showBirthdayIcon = new ShowBirthdayIcon(helper.Events); + this._showAccurateHearts = new ShowAccurateHearts(helper.Events); + this._locationOfTownsfolk = new LocationOfTownsfolk(helper, this._options); + this._showWhenAnimalNeedsPet = new ShowWhenAnimalNeedsPet(helper); + this._showCalendarAndBillboardOnGameMenuButton = new ShowCalendarAndBillboardOnGameMenuButton(helper); + this._showScarecrowAndSprinklerRange = new ShowItemEffectRanges(modConfig, helper.Events); + //this._experienceBar = new ExperienceBar(helper); + this._showItemHoverInformation = new ShowItemHoverInformation(helper.Events); + this._shopHarvestPrices = new ShopHarvestPrices(helper); + this._showQueenOfSauceIcon = new ShowQueenOfSauceIcon(helper); + this._showTravelingMerchant = new ShowTravelingMerchant(helper); + this._showCropAndBarrelTime = new ShowCropAndBarrelTime(helper); + this._showToolUpgradeStatus = new ShowToolUpgradeStatus(helper); + + this._elementsToDispose = new List() + { + this._luckOfDay, + this._showBirthdayIcon, + this._showAccurateHearts, + this._locationOfTownsfolk, + this._showWhenAnimalNeedsPet, + this._showCalendarAndBillboardOnGameMenuButton, + this._showCropAndBarrelTime, + //this._experienceBar, + this._showItemHoverInformation, + this._showTravelingMerchant, + this._shopHarvestPrices, + this._showQueenOfSauceIcon, + this._showToolUpgradeStatus + }; + + int whichOption = 1; + Version thisVersion = Assembly.GetAssembly(this.GetType()).GetName().Version; + this._optionsElements.Add(new ModOptionsElement("UI Info Suite v" + + thisVersion.Major + "." + thisVersion.Minor + "." + thisVersion.Build)); + this._optionsElements.Add(new ModOptionsCheckbox(this._helper.SafeGetString(OptionKeys.ShowLuckIcon), whichOption++, this._luckOfDay.Toggle, this._options, OptionKeys.ShowLuckIcon)); + //this._optionsElements.Add(new ModOptionsCheckbox(this._helper.SafeGetString(OptionKeys.ShowLevelUpAnimation), whichOption++, this._experienceBar.ToggleLevelUpAnimation, this._options, OptionKeys.ShowLevelUpAnimation)); + //this._optionsElements.Add(new ModOptionsCheckbox(this._helper.SafeGetString(OptionKeys.ShowExperienceBar), whichOption++, this._experienceBar.ToggleShowExperienceBar, this._options, OptionKeys.ShowExperienceBar)); + //this._optionsElements.Add(new ModOptionsCheckbox(this._helper.SafeGetString(OptionKeys.AllowExperienceBarToFadeOut), whichOption++, this._experienceBar.ToggleExperienceBarFade, this._options, OptionKeys.AllowExperienceBarToFadeOut)); + //this._optionsElements.Add(new ModOptionsCheckbox(this._helper.SafeGetString(OptionKeys.ShowExperienceGain), whichOption++, this._experienceBar.ToggleShowExperienceGain, this._options, OptionKeys.ShowExperienceGain)); + this._optionsElements.Add(new ModOptionsCheckbox(this._helper.SafeGetString(OptionKeys.ShowLocationOfTownsPeople), whichOption++, this._locationOfTownsfolk.ToggleShowNPCLocationsOnMap, this._options, OptionKeys.ShowLocationOfTownsPeople)); + this._optionsElements.Add(new ModOptionsCheckbox(this._helper.SafeGetString(OptionKeys.ShowBirthdayIcon), whichOption++, this._showBirthdayIcon.ToggleOption, this._options, OptionKeys.ShowBirthdayIcon)); + this._optionsElements.Add(new ModOptionsCheckbox(this._helper.SafeGetString(OptionKeys.ShowHeartFills), whichOption++, this._showAccurateHearts.ToggleOption, this._options, OptionKeys.ShowHeartFills)); + this._optionsElements.Add(new ModOptionsCheckbox(this._helper.SafeGetString(OptionKeys.ShowAnimalsNeedPets), whichOption++, this._showWhenAnimalNeedsPet.ToggleOption, this._options, OptionKeys.ShowAnimalsNeedPets)); + this._optionsElements.Add(new ModOptionsCheckbox(this._helper.SafeGetString(OptionKeys.DisplayCalendarAndBillboard), whichOption++, this._showCalendarAndBillboardOnGameMenuButton.ToggleOption, this._options, OptionKeys.DisplayCalendarAndBillboard)); + this._optionsElements.Add(new ModOptionsCheckbox(this._helper.SafeGetString(OptionKeys.ShowCropAndBarrelTooltip), whichOption++, this._showCropAndBarrelTime.ToggleOption, this._options, OptionKeys.ShowCropAndBarrelTooltip)); + this._optionsElements.Add(new ModOptionsCheckbox(this._helper.SafeGetString(OptionKeys.ShowItemEffectRanges), whichOption++, this._showScarecrowAndSprinklerRange.ToggleOption, this._options, OptionKeys.ShowItemEffectRanges)); + this._optionsElements.Add(new ModOptionsCheckbox(this._helper.SafeGetString(OptionKeys.ShowExtraItemInformation), whichOption++, this._showItemHoverInformation.ToggleOption, this._options, OptionKeys.ShowExtraItemInformation)); + this._optionsElements.Add(new ModOptionsCheckbox(this._helper.SafeGetString(OptionKeys.ShowTravelingMerchant), whichOption++, this._showTravelingMerchant.ToggleOption, this._options, OptionKeys.ShowTravelingMerchant)); + this._optionsElements.Add(new ModOptionsCheckbox(this._helper.SafeGetString(OptionKeys.ShowHarvestPricesInShop), whichOption++, this._shopHarvestPrices.ToggleOption, this._options, OptionKeys.ShowHarvestPricesInShop)); + this._optionsElements.Add(new ModOptionsCheckbox(this._helper.SafeGetString(OptionKeys.ShowWhenNewRecipesAreAvailable), whichOption++, this._showQueenOfSauceIcon.ToggleOption, this._options, OptionKeys.ShowWhenNewRecipesAreAvailable)); + this._optionsElements.Add(new ModOptionsCheckbox(this._helper.SafeGetString(OptionKeys.ShowToolUpgradeStatus), whichOption++, this._showToolUpgradeStatus.ToggleOption, this._options, OptionKeys.ShowToolUpgradeStatus)); + + } + + + public void Dispose() + { + foreach (IDisposable item in this._elementsToDispose) + item.Dispose(); + } + + private void OnButtonLeftClicked(object sender, EventArgs e) + { + if (Game1.activeClickableMenu is GameMenu) + { + this.SetActiveClickableMenuToModOptionsPage(); + Game1.playSound("smallSelect"); + } + } + + /// Raised after a game menu is opened, closed, or replaced. + /// The event sender. + /// The event arguments. + private void ToggleModOptions(object sender, MenuChangedEventArgs e) + { + // remove from old menu + if (e.OldMenu != null) + { + this._helper.Events.Display.RenderedActiveMenu -= this.DrawButton; + if (this._modOptionsPageButton != null) + this._modOptionsPageButton.OnLeftClicked -= this.OnButtonLeftClicked; + + if (e.OldMenu is GameMenu oldMenu) + { + List tabPages = oldMenu.pages; + tabPages.Remove(this._modOptionsPage); + } + } + + // add to new menu + if (e.NewMenu is GameMenu newMenu) + { + if (this._modOptionsPageButton == null) + { + this._modOptionsPage = new ModOptionsPage(this._optionsElements, this._helper.Events); + this._modOptionsPageButton = new ModOptionsPageButton(this._helper.Events); + } + + this._helper.Events.Display.RenderedActiveMenu += this.DrawButton; + this._modOptionsPageButton.OnLeftClicked += this.OnButtonLeftClicked; + List tabPages = newMenu.pages; + + this._modOptionsTabPageNumber = tabPages.Count; + tabPages.Add(this._modOptionsPage); + } + } + + private void SetActiveClickableMenuToModOptionsPage() + { + if (Game1.activeClickableMenu is GameMenu menu) + menu.currentTab = this._modOptionsTabPageNumber; + } + + private void DrawButton(object sender, EventArgs e) + { + if (Game1.activeClickableMenu is GameMenu && + (Game1.activeClickableMenu as GameMenu).currentTab != 3) //don't render when the map is showing + { + if ((Game1.activeClickableMenu as GameMenu).currentTab == this._modOptionsTabPageNumber) + { + this._modOptionsPageButton.yPositionOnScreen = Game1.activeClickableMenu.yPositionOnScreen + 24; + } + else + { + this._modOptionsPageButton.yPositionOnScreen = Game1.activeClickableMenu.yPositionOnScreen + 16; + } + this._modOptionsPageButton.draw(Game1.spriteBatch); + + //Might need to render hover text here + } + } + } +} diff --git a/Mods/UI Info Suite/Options/ModOptionsPageIcon.cs b/Mods/UI Info Suite/Options/ModOptionsPageIcon.cs new file mode 100644 index 000000000..818bb291a --- /dev/null +++ b/Mods/UI Info Suite/Options/ModOptionsPageIcon.cs @@ -0,0 +1,19 @@ +using StardewValley.Menus; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace UIInfoSuite.Options +{ + class ModOptionsPageIcon : IClickableMenu + { + + + public override void receiveRightClick(int x, int y, bool playSound = true) + { + + } + } +} diff --git a/Mods/UI Info Suite/Options/OptionKeys.cs b/Mods/UI Info Suite/Options/OptionKeys.cs new file mode 100644 index 000000000..10dbe24cc --- /dev/null +++ b/Mods/UI Info Suite/Options/OptionKeys.cs @@ -0,0 +1,31 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace UIInfoSuite.Options +{ + public static class OptionKeys + { + public const string AllowExperienceBarToFadeOut = "AllowExperienceBarToFadeOut"; + public const string ShowExperienceBar = "ShowExperienceBar"; + public const string ShowExperienceGain = "ShowExperienceGain"; + public const string ShowLevelUpAnimation = "ShowLevelUpAnimation"; + public const string ShowHeartFills = "ShowHeartFills"; + public const string ShowExtraItemInformation = "ShowExtraItemInformation"; + public const string ShowLocationOfTownsPeople = "ShowLocationOfTownsPeople"; + public const string ShowLuckIcon = "ShowLuckIcon"; + public const string ShowTravelingMerchant = "ShowTravelingMerchant"; + public const string ShowLocationOfTownsPeopleShowQuestIcon = "ShowLocationOfTownsPeopleShowQuestIcon"; + public const string ShowCropAndBarrelTooltip = "ShowCropAndBarrelTooltip"; + public const string ShowBirthdayIcon = "ShowBirthdayIcon"; + public const string ShowAnimalsNeedPets = "ShowAnimalsNeedPets"; + public const string ShowItemEffectRanges = "ShowItemEffectRanges"; + public const string ShowItemsRequiredForBundles = "ShowItemsRequiredForBundles"; + public const string ShowHarvestPricesInShop = "ShowHarvestPricesInShop"; + public const string DisplayCalendarAndBillboard = "DisplayCalendarAndBillboard"; + public const string ShowWhenNewRecipesAreAvailable = "ShowWhenNewRecipesAreAvailable"; + public const string ShowToolUpgradeStatus = "ShowToolUpgradeStatus"; + } +} diff --git a/Mods/UI Info Suite/Properties/AssemblyInfo.cs b/Mods/UI Info Suite/Properties/AssemblyInfo.cs new file mode 100644 index 000000000..0617a05f7 --- /dev/null +++ b/Mods/UI Info Suite/Properties/AssemblyInfo.cs @@ -0,0 +1,36 @@ +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; + +// 有关程序集的一般信息由以下 +// 控制。更改这些特性值可修改 +// 与程序集关联的信息。 +[assembly: AssemblyTitle("UI Info Suite")] +[assembly: AssemblyDescription("")] +[assembly: AssemblyConfiguration("")] +[assembly: AssemblyCompany("")] +[assembly: AssemblyProduct("UI Info Suite")] +[assembly: AssemblyCopyright("Copyright © 2019")] +[assembly: AssemblyTrademark("")] +[assembly: AssemblyCulture("")] + +// 将 ComVisible 设置为 false 会使此程序集中的类型 +//对 COM 组件不可见。如果需要从 COM 访问此程序集中的类型 +//请将此类型的 ComVisible 特性设置为 true。 +[assembly: ComVisible(false)] + +// 如果此项目向 COM 公开,则下列 GUID 用于类型库的 ID +[assembly: Guid("e23d348d-b57a-4ee0-93b6-deb8f6b094ec")] + +// 程序集的版本信息由下列四个值组成: +// +// 主版本 +// 次版本 +// 生成号 +// 修订号 +// +// 可以指定所有值,也可以使用以下所示的 "*" 预置版本号和修订号 +//通过使用 "*",如下所示: +// [assembly: AssemblyVersion("1.0.*")] +[assembly: AssemblyVersion("1.0.0.0")] +[assembly: AssemblyFileVersion("1.0.0.0")] diff --git a/Mods/UI Info Suite/Tools.cs b/Mods/UI Info Suite/Tools.cs new file mode 100644 index 000000000..6278bf10a --- /dev/null +++ b/Mods/UI Info Suite/Tools.cs @@ -0,0 +1,128 @@ +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Graphics; +using StardewValley; +using StardewValley.Menus; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace UIInfoSuite +{ + static class Tools + { + + public static void CreateSafeDelayedDialogue(string dialogue, int timer) + { + Task.Factory.StartNew(() => + { + Thread.Sleep(timer); + + do + { + Thread.Sleep(TimeSpan.FromSeconds(1)); + } + while (Game1.activeClickableMenu is GameMenu); + Game1.setDialogue(dialogue, true); + }); + } + + public static int GetWidthInPlayArea() + { + int result = 0; + + if (Game1.isOutdoorMapSmallerThanViewport()) + { + int right = Game1.graphics.GraphicsDevice.Viewport.TitleSafeArea.Right; + int totalWidth = Game1.currentLocation.map.Layers[0].LayerWidth * Game1.tileSize; + int someOtherWidth = Game1.graphics.GraphicsDevice.Viewport.TitleSafeArea.Right - totalWidth; + + result = right - someOtherWidth / 2; + } + else + { + result = Game1.graphics.GraphicsDevice.Viewport.TitleSafeArea.Right; + } + + return result; + } + + public static int GetTruePrice(Item item) + { + int truePrice = 0; + + if (item is StardewValley.Object objectItem) + { + truePrice = objectItem.sellToStorePrice() * 2; + } + else if (item is StardewValley.Item thing) + { + truePrice = thing.salePrice(); + } + + + return truePrice; + } + + public static void DrawMouseCursor() + { + if (!Game1.options.hardwareCursor) + { + int mouseCursorToRender = Game1.options.gamepadControls ? Game1.mouseCursor + 44 : Game1.mouseCursor; + Rectangle what = Game1.getSourceRectForStandardTileSheet(Game1.mouseCursors, mouseCursorToRender, 16, 16); + + Game1.spriteBatch.Draw( + Game1.mouseCursors, + new Vector2(Game1.getMouseX(), Game1.getMouseY()), + what,//new Rectangle?(Game1.getSourceRectForStandardTileSheet(Game1.mouseCursors, Game1.mouseCursor + 32, 16, 16)), + Color.White, + 0.0f, + Vector2.Zero, + Game1.pixelZoom + (Game1.dialogueButtonScale / 150.0f), + SpriteEffects.None, + 1f); + } + } + + public static Item GetHoveredItem() + { + Item hoverItem = null; + + for (int i = 0; i < Game1.onScreenMenus.Count; ++i) + { + Toolbar onScreenMenu = Game1.onScreenMenus[i] as Toolbar; + if (onScreenMenu != null) + { + FieldInfo hoverItemField = typeof(Toolbar).GetField("hoverItem", BindingFlags.Instance | BindingFlags.NonPublic); + hoverItem = hoverItemField.GetValue(onScreenMenu) as Item; + //hoverItemField.SetValue(onScreenMenu, null); + } + } + + if (Game1.activeClickableMenu is GameMenu gameMenu) + { + List menuList = gameMenu.pages; + foreach (IClickableMenu menu in menuList) + { + if (menu is InventoryPage) + { + FieldInfo hoveredItemField = typeof(InventoryPage).GetField("hoveredItem", BindingFlags.Instance | BindingFlags.NonPublic); + hoverItem = hoveredItemField.GetValue(menu) as Item; + //typeof(InventoryPage).GetField("hoverText", BindingFlags.Instance | BindingFlags.NonPublic).SetValue(menu, ""); + } + } + } + + if (Game1.activeClickableMenu is ItemGrabMenu) + { + hoverItem = (Game1.activeClickableMenu as MenuWithInventory).hoveredItem; + //(Game1.activeClickableMenu as MenuWithInventory).hoveredItem = null; + } + + return hoverItem; + } + } +} diff --git a/Mods/UI Info Suite/UIElements/ExperienceBar.cs b/Mods/UI Info Suite/UIElements/ExperienceBar.cs new file mode 100644 index 000000000..5c579e8c5 --- /dev/null +++ b/Mods/UI Info Suite/UIElements/ExperienceBar.cs @@ -0,0 +1,623 @@ +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Graphics; +using StardewModdingAPI; +using StardewModdingAPI.Enums; +using StardewModdingAPI.Events; +using StardewValley; +using StardewValley.Menus; +using StardewValley.Tools; +using System; +using System.Collections.Generic; +using System.IO; +using System.Media; +using System.Threading; +using System.Threading.Tasks; +using System.Timers; +using UIInfoSuite.Extensions; + +namespace UIInfoSuite.UIElements +{ + class ExperienceBar : IDisposable + { + + public interface LevelExtenderEvents + { + event EventHandler OnXPChanged; + } + + private const int MaxBarWidth = 175; + + private int[] _currentExperience = new int[5]; + private int[] _currentLevelExtenderExperience = new int[5]; + private readonly List _experiencePointDisplays = new List(); + private readonly TimeSpan _levelUpPauseTime = TimeSpan.FromSeconds(2); + private readonly Color _iconColor = Color.White; + private Color _experienceFillColor = Color.Blue; + private Rectangle _experienceIconPosition = new Rectangle(10, 428, 10, 10); + private Item _previousItem = null; + private bool _experienceBarShouldBeVisible = false; + private bool _shouldDrawLevelUp = false; + private System.Timers.Timer _timeToDisappear = new System.Timers.Timer(); + private readonly TimeSpan _timeBeforeExperienceBarFades = TimeSpan.FromSeconds(8); + //private SoundEffectInstance _soundEffect; + private Rectangle _levelUpIconRectangle = new Rectangle(120, 428, 10, 10); + private bool _allowExperienceBarToFadeOut = true; + private bool _showExperienceGain = true; + private bool _showLevelUpAnimation = true; + private bool _showExperienceBar = true; + private readonly IModHelper _helper; + private SoundPlayer _player; + + private LevelExtenderInterface _levelExtenderAPI; + + private int _currentSkillLevel = 0; + private int _experienceRequiredToLevel = -1; + private int _experienceFromPreviousLevels = -1; + private int _experienceEarnedThisLevel = -1; + + public ExperienceBar(IModHelper helper) + { + this._helper = helper; + string path = string.Empty; + try + { + path = Path.Combine(this._helper.DirectoryPath, "LevelUp.wav"); + this._player = new SoundPlayer(path); + //path = path.Replace(Environment.CurrentDirectory, ""); + //path = path.TrimStart(Path.DirectorySeparatorChar); + //_soundEffect = SoundEffect.FromStream(TitleContainer.OpenStream(path)).CreateInstance(); + //_soundEffect.Volume = 1f; + } + catch (Exception ex) + { + ModEntry.MonitorObject.Log("Error loading sound file from " + path + ": " + ex.Message + Environment.NewLine + ex.StackTrace, LogLevel.Error); + } + this._timeToDisappear.Elapsed += this.StopTimerAndFadeBarOut; + helper.Events.Display.RenderingHud += this.OnRenderingHud; + helper.Events.Player.Warped += this.OnWarped_RemoveAllExperiencePointDisplays; + + object something = this._helper.ModRegistry.GetApi("DevinLematty.LevelExtender"); + try + { + this._levelExtenderAPI = this._helper.ModRegistry.GetApi("DevinLematty.LevelExtender"); + } + catch (Exception ex) + { + int j = 4; + } + int f = 3; + + //if (something != null) + //{ + // try + // { + // var methods = something.GetType().GetMethods(); + // var currentXPMethod = something.GetType().GetMethod("currentXP"); + + // foreach (var method in methods) + // { + + // } + // } + // catch (Exception ex) + // { + // int f = 3; + // } + //} + } + + private void LoadModApis(object sender, EventArgs e) + { + + } + + public void Dispose() + { + this._helper.Events.Player.LevelChanged -= this.OnLevelChanged; + this._helper.Events.Display.RenderingHud -= this.OnRenderingHud; + this._helper.Events.Player.Warped -= this.OnWarped_RemoveAllExperiencePointDisplays; + this._helper.Events.GameLoop.UpdateTicked -= this.OnUpdateTicked_DetermineIfExperienceHasBeenGained; + this._timeToDisappear.Elapsed -= this.StopTimerAndFadeBarOut; + this._timeToDisappear.Stop(); + this._timeToDisappear.Dispose(); + this._timeToDisappear = null; + } + + public void ToggleLevelUpAnimation(bool showLevelUpAnimation) + { + this._showLevelUpAnimation = showLevelUpAnimation; + this._helper.Events.Player.LevelChanged -= this.OnLevelChanged; + + if (this._showLevelUpAnimation) + { + this._helper.Events.Player.LevelChanged += this.OnLevelChanged; + } + } + + public void ToggleExperienceBarFade(bool allowExperienceBarToFadeOut) + { + this._allowExperienceBarToFadeOut = allowExperienceBarToFadeOut; + } + + public void ToggleShowExperienceGain(bool showExperienceGain) + { + this._helper.Events.GameLoop.UpdateTicked -= this.OnUpdateTicked_DetermineIfExperienceHasBeenGained; + for (int i = 0; i < this._currentExperience.Length; ++i) + this._currentExperience[i] = Game1.player.experiencePoints[i]; + this._showExperienceGain = showExperienceGain; + + if (this._levelExtenderAPI != null) + { + for (int i = 0; i < this._currentLevelExtenderExperience.Length; ++i) + this._currentLevelExtenderExperience[i] = this._levelExtenderAPI.currentXP()[i]; + } + + if (showExperienceGain) + { + this._helper.Events.GameLoop.UpdateTicked += this.OnUpdateTicked_DetermineIfExperienceHasBeenGained; + } + } + + + public void ToggleShowExperienceBar(bool showExperienceBar) + { + this._helper.Events.GameLoop.UpdateTicked -= this.OnUpdateTicked_DetermineIfExperienceHasBeenGained; + //GraphicsEvents.OnPreRenderHudEvent -= OnPreRenderHudEvent; + //PlayerEvents.Warped -= RemoveAllExperiencePointDisplays; + this._showExperienceBar = showExperienceBar; + if (showExperienceBar) + { + //GraphicsEvents.OnPreRenderHudEvent += OnPreRenderHudEvent; + //PlayerEvents.Warped += RemoveAllExperiencePointDisplays; + this._helper.Events.GameLoop.UpdateTicked += this.OnUpdateTicked_DetermineIfExperienceHasBeenGained; + } + } + + /// Raised after a player skill level changes. This happens as soon as they level up, not when the game notifies the player after their character goes to bed. + /// The event sender. + /// The event arguments. + private void OnLevelChanged(object sender, LevelChangedEventArgs e) + { + if (this._showLevelUpAnimation && e.IsLocalPlayer) + { + switch (e.Skill) + { + case SkillType.Combat: this._levelUpIconRectangle.X = 120; break; + case SkillType.Farming: this._levelUpIconRectangle.X = 10; break; + case SkillType.Fishing: this._levelUpIconRectangle.X = 20; break; + case SkillType.Foraging: this._levelUpIconRectangle.X = 60; break; + case SkillType.Mining: this._levelUpIconRectangle.X = 30; break; + } + this._shouldDrawLevelUp = true; + this.ShowExperienceBar(); + + float previousAmbientVolume = Game1.options.ambientVolumeLevel; + float previousMusicVolume = Game1.options.musicVolumeLevel; + + //if (_soundEffect != null) + // _soundEffect.Volume = previousMusicVolume <= 0.01f ? 0 : Math.Min(1, previousMusicVolume + 0.3f); + + //Task.Factory.StartNew(() => + //{ + // Thread.Sleep(100); + // Game1.musicCategory.SetVolume((float)Math.Max(0, Game1.options.musicVolumeLevel - 0.3)); + // Game1.ambientCategory.SetVolume((float)Math.Max(0, Game1.options.ambientVolumeLevel - 0.3)); + // if (_soundEffect != null) + // _soundEffect.Play(); + //}); + + Task.Factory.StartNew(() => + { + Thread.Sleep(100); + this._player.Play(); + }); + + Task.Factory.StartNew(() => + { + Thread.Sleep(this._levelUpPauseTime); + this._shouldDrawLevelUp = false; + //Game1.musicCategory.SetVolume(previousMusicVolume); + //Game1.ambientCategory.SetVolume(previousAmbientVolume); + }); + } + } + + private void StopTimerAndFadeBarOut(object sender, ElapsedEventArgs e) + { + this._timeToDisappear?.Stop(); + this._experienceBarShouldBeVisible = false; + } + + /// Raised after a player warps to a new location. + /// The event sender. + /// The event arguments. + private void OnWarped_RemoveAllExperiencePointDisplays(object sender, WarpedEventArgs e) + { + if (e.IsLocalPlayer) + this._experiencePointDisplays.Clear(); + } + + /// Raised after the game state is updated (≈60 times per second). + /// The event sender. + /// The event arguments. + private void OnUpdateTicked_DetermineIfExperienceHasBeenGained(object sender, UpdateTickedEventArgs e) + { + if (!e.IsMultipleOf(15)) // quarter second + return; + + Item currentItem = Game1.player.CurrentItem; + + int currentLevelIndex = -1; + + int[] levelExtenderExperience = null; + if (this._levelExtenderAPI != null) + levelExtenderExperience = this._levelExtenderAPI.currentXP(); + + for (int i = 0; i < this._currentExperience.Length; ++i) + { + if (this._currentExperience[i] != Game1.player.experiencePoints[i] || + (this._levelExtenderAPI != null && + this._currentLevelExtenderExperience[i] != levelExtenderExperience[i])) + { + currentLevelIndex = i; + break; + } + } + + if (currentLevelIndex > -1) + { + switch (currentLevelIndex) + { + case 0: + { + this._experienceFillColor = new Color(255, 251, 35, 0.38f); + this._experienceIconPosition.X = 10; + this._currentSkillLevel = Game1.player.farmingLevel.Value; + break; + } + + case 1: + { + this._experienceFillColor = new Color(17, 84, 252, 0.63f); + this._experienceIconPosition.X = 20; + this._currentSkillLevel = Game1.player.fishingLevel.Value; + break; + } + + case 2: + { + this._experienceFillColor = new Color(0, 234, 0, 0.63f); + this._experienceIconPosition.X = 60; + this._currentSkillLevel = Game1.player.foragingLevel.Value; + break; + } + + case 3: + { + this._experienceFillColor = new Color(145, 104, 63, 0.63f); + this._experienceIconPosition.X = 30; + this._currentSkillLevel = Game1.player.miningLevel.Value; + break; + } + + case 4: + { + this._experienceFillColor = new Color(204, 0, 3, 0.63f); + this._experienceIconPosition.X = 120; + this._currentSkillLevel = Game1.player.combatLevel.Value; + break; + } + } + + this._experienceRequiredToLevel = this.GetExperienceRequiredToLevel(this._currentSkillLevel); + this._experienceFromPreviousLevels = this.GetExperienceRequiredToLevel(this._currentSkillLevel - 1); + this._experienceEarnedThisLevel = Game1.player.experiencePoints[currentLevelIndex] - this._experienceFromPreviousLevels; + int experiencePreviouslyEarnedThisLevel = this._currentExperience[currentLevelIndex] - this._experienceFromPreviousLevels; + + if (this._experienceRequiredToLevel <= 0 && + this._levelExtenderAPI != null) + { + this._experienceEarnedThisLevel = this._levelExtenderAPI.currentXP()[currentLevelIndex]; + this._experienceFromPreviousLevels = this._currentExperience[currentLevelIndex] - this._experienceEarnedThisLevel; + this._experienceRequiredToLevel = this._levelExtenderAPI.requiredXP()[currentLevelIndex] + this._experienceFromPreviousLevels; + } + + this.ShowExperienceBar(); + if (this._showExperienceGain && + this._experienceRequiredToLevel > 0) + { + int currentExperienceToUse = Game1.player.experiencePoints[currentLevelIndex]; + int previousExperienceToUse = this._currentExperience[currentLevelIndex]; + if (this._levelExtenderAPI != null && + this._currentSkillLevel > 9) + { + currentExperienceToUse = this._levelExtenderAPI.currentXP()[currentLevelIndex]; + previousExperienceToUse = this._currentLevelExtenderExperience[currentLevelIndex]; + } + + int experienceGain = currentExperienceToUse - previousExperienceToUse; + + if (experienceGain > 0) + { + this._experiencePointDisplays.Add( + new ExperiencePointDisplay( + experienceGain, + Game1.player.getLocalPosition(Game1.viewport))); + } + } + + this._currentExperience[currentLevelIndex] = Game1.player.experiencePoints[currentLevelIndex]; + + if (this._levelExtenderAPI != null) + this._currentLevelExtenderExperience[currentLevelIndex] = this._levelExtenderAPI.currentXP()[currentLevelIndex]; + + } + else if (this._previousItem != currentItem) + { + if (currentItem is FishingRod) + { + this._experienceFillColor = new Color(17, 84, 252, 0.63f); + currentLevelIndex = 1; + this._experienceIconPosition.X = 20; + this._currentSkillLevel = Game1.player.fishingLevel.Value; + } + else if (currentItem is Pickaxe) + { + this._experienceFillColor = new Color(145, 104, 63, 0.63f); + currentLevelIndex = 3; + this._experienceIconPosition.X = 30; + this._currentSkillLevel = Game1.player.miningLevel.Value; + } + else if (currentItem is MeleeWeapon && + currentItem.Name != "Scythe") + { + this._experienceFillColor = new Color(204, 0, 3, 0.63f); + currentLevelIndex = 4; + this._experienceIconPosition.X = 120; + this._currentSkillLevel = Game1.player.combatLevel.Value; + } + else if (Game1.currentLocation is Farm && + !(currentItem is Axe)) + { + this._experienceFillColor = new Color(255, 251, 35, 0.38f); + currentLevelIndex = 0; + this._experienceIconPosition.X = 10; + this._currentSkillLevel = Game1.player.farmingLevel.Value; + } + else + { + this._experienceFillColor = new Color(0, 234, 0, 0.63f); + currentLevelIndex = 2; + this._experienceIconPosition.X = 60; + this._currentSkillLevel = Game1.player.foragingLevel.Value; + } + + this._experienceRequiredToLevel = this.GetExperienceRequiredToLevel(this._currentSkillLevel); + this._experienceFromPreviousLevels = this.GetExperienceRequiredToLevel(this._currentSkillLevel - 1); + this._experienceEarnedThisLevel = Game1.player.experiencePoints[currentLevelIndex] - this._experienceFromPreviousLevels; + + if (this._experienceRequiredToLevel <= 0 && + this._levelExtenderAPI != null) + { + this._experienceEarnedThisLevel = this._levelExtenderAPI.currentXP()[currentLevelIndex]; + this._experienceFromPreviousLevels = this._currentExperience[currentLevelIndex] - this._experienceEarnedThisLevel; + this._experienceRequiredToLevel = this._levelExtenderAPI.requiredXP()[currentLevelIndex] + this._experienceFromPreviousLevels; + } + + this.ShowExperienceBar(); + this._previousItem = currentItem; + } + + } + + /// Raised before drawing the HUD (item toolbar, clock, etc) to the screen. The vanilla HUD may be hidden at this point (e.g. because a menu is open). + /// The event sender. + /// The event arguments. + private void OnRenderingHud(object sender, RenderingHudEventArgs e) + { + if (!Game1.eventUp) + { + if (this._shouldDrawLevelUp) + { + Vector2 playerLocalPosition = Game1.player.getLocalPosition(Game1.viewport); + Game1.spriteBatch.Draw( + Game1.mouseCursors, + new Vector2( + playerLocalPosition.X - 74, + playerLocalPosition.Y - 130), + this._levelUpIconRectangle, + this._iconColor, + 0, + Vector2.Zero, + Game1.pixelZoom, + SpriteEffects.None, + 0.85f); + + Game1.drawWithBorder( + this._helper.SafeGetString( + LanguageKeys.LevelUp), + Color.DarkSlateGray, + Color.PaleTurquoise, + new Vector2( + playerLocalPosition.X - 28, + playerLocalPosition.Y - 130)); + } + + for (int i = this._experiencePointDisplays.Count - 1; i >= 0; --i) + { + if (this._experiencePointDisplays[i].IsInvisible) + { + this._experiencePointDisplays.RemoveAt(i); + } + else + { + this._experiencePointDisplays[i].Draw(); + } + } + + if (this._experienceRequiredToLevel > 0 && + this._experienceBarShouldBeVisible && + this._showExperienceBar) + { + int experienceDifferenceBetweenLevels = this._experienceRequiredToLevel - this._experienceFromPreviousLevels; + int barWidth = (int)((double)this._experienceEarnedThisLevel / experienceDifferenceBetweenLevels * MaxBarWidth); + + this.DrawExperienceBar(barWidth, this._experienceEarnedThisLevel, experienceDifferenceBetweenLevels, this._currentSkillLevel); + + } + + } + } + + private int GetExperienceRequiredToLevel(int currentLevel) + { + int amount = 0; + + //if (currentLevel < 10) + //{ + switch (currentLevel) + { + case 0: amount = 100; break; + case 1: amount = 380; break; + case 2: amount = 770; break; + case 3: amount = 1300; break; + case 4: amount = 2150; break; + case 5: amount = 3300; break; + case 6: amount = 4800; break; + case 7: amount = 6900; break; + case 8: amount = 10000; break; + case 9: amount = 15000; break; + } + //} + //else if (_levelExtenderAPI != null && + // currentLevel < 100) + //{ + // var requiredXP = _levelExtenderAPI.requiredXP(); + // amount = requiredXP[currentLevel]; + //} + return amount; + } + + private void ShowExperienceBar() + { + if (this._timeToDisappear != null) + { + if (this._allowExperienceBarToFadeOut) + { + this._timeToDisappear.Interval = this._timeBeforeExperienceBarFades.TotalMilliseconds; + this._timeToDisappear.Start(); + } + else + { + this._timeToDisappear.Stop(); + } + } + + this._experienceBarShouldBeVisible = true; + } + + private void DrawExperienceBar(int barWidth, int experienceGainedThisLevel, int experienceRequiredForNextLevel, int currentLevel) + { + float leftSide = Game1.graphics.GraphicsDevice.Viewport.TitleSafeArea.Left; + + if (Game1.isOutdoorMapSmallerThanViewport()) + { + int num3 = Game1.currentLocation.map.Layers[0].LayerWidth * Game1.tileSize; + leftSide += (Game1.graphics.GraphicsDevice.Viewport.TitleSafeArea.Right - num3) / 2; + } + + Game1.drawDialogueBox( + (int)leftSide, + Game1.graphics.GraphicsDevice.Viewport.TitleSafeArea.Bottom - 160, + 240, + 160, + false, + true); + + Game1.spriteBatch.Draw( + Game1.staminaRect, + new Rectangle( + (int)leftSide + 32, + Game1.graphics.GraphicsDevice.Viewport.TitleSafeArea.Bottom - 63, + barWidth, + 31), + this._experienceFillColor); + + Game1.spriteBatch.Draw( + Game1.staminaRect, + new Rectangle( + (int)leftSide + 32, + Game1.graphics.GraphicsDevice.Viewport.TitleSafeArea.Bottom - 63, + Math.Min(4, barWidth), + 31), + this._experienceFillColor); + + Game1.spriteBatch.Draw( + Game1.staminaRect, + new Rectangle( + (int)leftSide + 32, + Game1.graphics.GraphicsDevice.Viewport.TitleSafeArea.Bottom - 63, + barWidth, + 4), + this._experienceFillColor); + + Game1.spriteBatch.Draw( + Game1.staminaRect, + new Rectangle( + (int)leftSide + 32, + Game1.graphics.GraphicsDevice.Viewport.TitleSafeArea.Bottom - 36, + barWidth, + 4), + this._experienceFillColor); + + ClickableTextureComponent textureComponent = + new ClickableTextureComponent( + "", + new Rectangle( + (int)leftSide - 36, + Game1.graphics.GraphicsDevice.Viewport.TitleSafeArea.Bottom - 80, + 260, + 100), + "", + "", + Game1.mouseCursors, + new Rectangle(0, 0, 0, 0), + Game1.pixelZoom); + + if (textureComponent.containsPoint(Game1.getMouseX(), Game1.getMouseY())) + { + Game1.drawWithBorder( + experienceGainedThisLevel + "/" + experienceRequiredForNextLevel, + Color.Black, + Color.Black, + new Vector2( + leftSide + 33, + Game1.graphics.GraphicsDevice.Viewport.TitleSafeArea.Bottom - 70)); + } + else + { + Game1.spriteBatch.Draw( + Game1.mouseCursors, + new Vector2( + leftSide + 54, + Game1.graphics.GraphicsDevice.Viewport.TitleSafeArea.Bottom - 62), + this._experienceIconPosition, + this._iconColor, + 0, + Vector2.Zero, + 2.9f, + SpriteEffects.None, + 0.85f); + + Game1.drawWithBorder( + currentLevel.ToString(), + Color.Black * 0.6f, + Color.Black, + new Vector2( + leftSide + 33, + Game1.graphics.GraphicsDevice.Viewport.TitleSafeArea.Bottom - 70)); + } + } + + } +} diff --git a/Mods/UI Info Suite/UIElements/ExperiencePointDisplay.cs b/Mods/UI Info Suite/UIElements/ExperiencePointDisplay.cs new file mode 100644 index 000000000..4031d8346 --- /dev/null +++ b/Mods/UI Info Suite/UIElements/ExperiencePointDisplay.cs @@ -0,0 +1,42 @@ +using Microsoft.Xna.Framework; +using StardewValley; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace UIInfoSuite.UIElements +{ + class ExperiencePointDisplay + { + private int _alpha = 100; + private float _experiencePoints; + private Vector2 _position; + + public ExperiencePointDisplay(float experiencePoints, Vector2 position) + { + this._position = position; + this._experiencePoints = experiencePoints; + } + + public void Draw() + { + this._position.Y -= 0.5f; + --this._alpha; + Game1.drawWithBorder( + "Exp " + this._experiencePoints, + Color.DarkSlateGray * ((float)this._alpha / 100f), + Color.PaleTurquoise * ((float)this._alpha / 100f), + new Vector2(this._position.X - 28, this._position.Y - 130), + 0.0f, + 0.8f, + 0.0f); + } + + public bool IsInvisible + { + get { return this._alpha < 3; } + } + } +} diff --git a/Mods/UI Info Suite/UIElements/LocationOfTownsfolk.cs b/Mods/UI Info Suite/UIElements/LocationOfTownsfolk.cs new file mode 100644 index 000000000..dd3b4da52 --- /dev/null +++ b/Mods/UI Info Suite/UIElements/LocationOfTownsfolk.cs @@ -0,0 +1,524 @@ +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Graphics; +using UIInfoSuite.Extensions; +using StardewModdingAPI; +using StardewModdingAPI.Events; +using StardewValley; +using StardewValley.Menus; +using StardewValley.Quests; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Text; + +namespace UIInfoSuite.UIElements +{ + class LocationOfTownsfolk : IDisposable + { + #region Members + private List _townsfolk = new List(); + private List _checkboxes = new List(); + private const int SocialPanelWidth = 190; + private const int SocialPanelXOffset = 160; + private SocialPage _socialPage; + private string[] _friendNames; + private readonly IDictionary _options; + private readonly IModHelper _helper; + + private static readonly Dictionary> _mapLocations = new Dictionary>() + { + { "HarveyRoom", new KeyValuePair(677, 304) }, + { "BathHouse_Pool", new KeyValuePair(576, 60) }, + { "WizardHouseBasement", new KeyValuePair(196, 352) }, + { "BugLand", new KeyValuePair(0, 0) }, + { "Desert", new KeyValuePair(60, 92) }, + { "Cellar", new KeyValuePair(0, 0) }, + { "JojaMart", new KeyValuePair(872, 280) }, + { "Tent", new KeyValuePair(784, 128) }, + { "HaleyHouse", new KeyValuePair(652, 408) }, + { "Hospital", new KeyValuePair(677, 304) }, + { "FarmHouse", new KeyValuePair(470, 260) }, + { "Farm", new KeyValuePair(470, 260) }, + { "ScienceHouse", new KeyValuePair(732, 148) }, + { "ManorHouse", new KeyValuePair(768, 395) }, + { "AdventureGuild", new KeyValuePair(0, 0) }, + { "SeedShop", new KeyValuePair(696, 296) }, + { "Blacksmith", new KeyValuePair(852, 388) }, + { "JoshHouse", new KeyValuePair(740, 320) }, + { "SandyHouse", new KeyValuePair(40, 40) }, + { "Tunnel", new KeyValuePair(0, 0) }, + { "CommunityCenter", new KeyValuePair(692, 204) }, + { "Backwoods", new KeyValuePair(460, 156) }, + { "ElliottHouse", new KeyValuePair(826, 550) }, + { "SebastianRoom", new KeyValuePair(732, 148) }, + { "BathHouse_Entry", new KeyValuePair(576, 60) }, + { "Greenhouse", new KeyValuePair(0, 0) }, + { "Sewer", new KeyValuePair(380, 596) }, + { "WizardHouse", new KeyValuePair(196, 352) }, + { "Trailer", new KeyValuePair(780, 360) }, + { "pamHouseUpgrade", new KeyValuePair(780, 360) }, + { "Forest", new KeyValuePair(80, 272) }, + { "Woods", new KeyValuePair(100, 272) }, + { "WitchSwamp", new KeyValuePair(0, 0) }, + { "ArchaeologyHouse", new KeyValuePair(892, 416) }, + { "FishShop", new KeyValuePair(844, 608) }, + { "Saloon", new KeyValuePair(714, 354) }, + { "LeahHouse", new KeyValuePair(452, 436) }, + { "Town", new KeyValuePair(680, 360) }, + { "Mountain", new KeyValuePair(762, 154) }, + { "BusStop", new KeyValuePair(516, 224) }, + { "Railroad", new KeyValuePair(644, 64) }, + { "SkullCave", new KeyValuePair(0, 0) }, + { "BathHouse_WomensLocker", new KeyValuePair(576, 60) }, + { "Beach", new KeyValuePair(790, 550) }, + { "BathHouse_MensLocker", new KeyValuePair(576, 60) }, + { "Mine", new KeyValuePair(880, 100) }, + { "WitchHut", new KeyValuePair(0, 0) }, + { "AnimalShop", new KeyValuePair(420, 392) }, + { "SamHouse", new KeyValuePair(612, 396) }, + { "WitchWarpCave", new KeyValuePair(0, 0) }, + { "Club", new KeyValuePair(60, 92) } + }; + +#endregion + + public LocationOfTownsfolk(IModHelper helper, IDictionary options) + { + this._helper = helper; + this._options = options; + } + + public void Dispose() + { + this.ToggleShowNPCLocationsOnMap(false); + } + + public void ToggleShowNPCLocationsOnMap(bool showLocations) + { + this.ExtendMenuIfNeeded(); + this._helper.Events.Display.RenderedActiveMenu -= this.OnRenderedActiveMenu_DrawSocialPageOptions; + this._helper.Events.Display.RenderedActiveMenu -= this.OnRenderedActiveMenu_DrawNPCLocationsOnMap; + this._helper.Events.Input.ButtonPressed -= this.OnButtonPressed_ForSocialPage; + this._helper.Events.Display.MenuChanged -= this.OnMenuChanged; + + if (showLocations) + { + this._helper.Events.Display.RenderedActiveMenu += this.OnRenderedActiveMenu_DrawSocialPageOptions; + this._helper.Events.Display.RenderedActiveMenu += this.OnRenderedActiveMenu_DrawNPCLocationsOnMap; + this._helper.Events.Input.ButtonPressed += this.OnButtonPressed_ForSocialPage; + this._helper.Events.Display.MenuChanged += this.OnMenuChanged; + } + } + + /// Raised after a game menu is opened, closed, or replaced. + /// The event sender. + /// The event arguments. + private void OnMenuChanged(object sender, MenuChangedEventArgs e) + { + this.ExtendMenuIfNeeded(); + } + + private void ExtendMenuIfNeeded() + { + if (Game1.activeClickableMenu is GameMenu gameMenu) + { + List clickableMenuList = gameMenu.pages; + + foreach (IClickableMenu menu in clickableMenuList) + { + if (menu is SocialPage) + { + this._socialPage = menu as SocialPage; + this._friendNames = (typeof(SocialPage).GetField("names", BindingFlags.Instance | BindingFlags.NonPublic).GetValue(menu) as List) + .Select(name => name.ToString()) + .ToArray(); + break; + } + } + this._townsfolk.Clear(); + foreach (GameLocation location in Game1.locations) + { + foreach (NPC npc in location.characters) + { + if (Game1.player.friendshipData.ContainsKey(npc.Name)) + this._townsfolk.Add(npc); + } + } + this._checkboxes.Clear(); + foreach (string friendName in this._friendNames) + { + int hashCode = friendName.GetHashCode(); + OptionsCheckbox checkbox = new OptionsCheckbox("", hashCode); + this._checkboxes.Add(checkbox); + + //default to on + bool optionForThisFriend = true; + if (!Game1.player.friendshipData.ContainsKey(friendName)) + { + checkbox.greyedOut = true; + optionForThisFriend = false; + } + else + { + string optionValue = this._options.SafeGet(hashCode.ToString()); + + if (string.IsNullOrEmpty(optionValue)) + { + this._options[hashCode.ToString()] = optionForThisFriend.ToString(); + } + else + { + optionForThisFriend = optionValue.SafeParseBool(); + } + } + checkbox.isChecked = optionForThisFriend; + } + } + } + + /// Raised after the player presses a button on the keyboard, controller, or mouse. + /// The event sender. + /// The event arguments. + private void OnButtonPressed_ForSocialPage(object sender, ButtonPressedEventArgs e) + { + if (Game1.activeClickableMenu is GameMenu && (e.Button == SButton.MouseLeft || e.Button == SButton.ControllerA)) + { + this.CheckSelectedBox(); + } + } + + private void CheckSelectedBox() + { + if (Game1.activeClickableMenu is GameMenu) + { + int slotPosition = (int)typeof(SocialPage) + .GetField("slotPosition", BindingFlags.Instance | BindingFlags.NonPublic) + .GetValue(this._socialPage); + + for (int i = slotPosition; i < slotPosition + 5; ++i) + { + OptionsCheckbox checkbox = this._checkboxes[i]; + if (checkbox.bounds.Contains(Game1.getMouseX(), Game1.getMouseY()) && + !checkbox.greyedOut) + { + checkbox.isChecked = !checkbox.isChecked; + this._options[checkbox.whichOption.ToString()] = checkbox.isChecked.ToString(); + Game1.playSound("drumkit6"); + } + } + } + } + + /// When a menu is open ( isn't null), raised after that menu is drawn to the sprite batch but before it's rendered to the screen. + /// The event sender. + /// The event arguments. + private void OnRenderedActiveMenu_DrawNPCLocationsOnMap(object sender, RenderedActiveMenuEventArgs e) + { + if (Game1.activeClickableMenu is GameMenu gameMenu) + { + if (gameMenu.currentTab == 4) + { + List namesToShow = new List(); + foreach (NPC character in this._townsfolk) + { + try + { + int hashCode = character.Name.GetHashCode(); + + bool drawCharacter = this._options.SafeGet(hashCode.ToString()).SafeParseBool(); + + if (drawCharacter) + { + KeyValuePair location = new KeyValuePair((int)character.Position.X, (int)character.position.Y); + string locationName = character.currentLocation?.Name ?? character.DefaultMap; + + switch (locationName) + { + case "Town": + case "Forest": + { + int xStart = 0; + int yStart = 0; + int areaWidth = 0; + int areaHeight = 0; + + switch (locationName) + { + case "Town": + { + xStart = 595; + yStart = 163; + areaWidth = 345; + areaHeight = 330; + break; + } + + case "Forest": + { + xStart = 183; + yStart = 378; + areaWidth = 319; + areaHeight = 261; + break; + } + } + xTile.Map map = character.currentLocation.Map; + + float xScale = (float)areaWidth / (float)map.DisplayWidth; + float yScale = (float)areaHeight / (float)map.DisplayHeight; + + float scaledX = character.position.X * xScale; + float scaledY = character.position.Y * yScale; + int xPos = (int)scaledX + xStart; + int yPos = (int)scaledY + yStart; + location = new KeyValuePair(xPos, yPos); + + break; + } + + default: + { + _mapLocations.TryGetValue(locationName, out location); + break; + } + } + + //if (character.currentLocation.Name == "Town") + //{ + // String locationName = character.currentLocation.Name; + // xTile.Map map = character.currentLocation.Map; + // int xStart = 595; + // int yStart = 163; + // int townWidth = 345; + // int townHeight = 330; + + // float xScale = (float)townWidth / (float)map.DisplayWidth; + // float yScale = (float)townHeight / (float)map.DisplayHeight; + + // float scaledX = character.position.X * xScale; + // float scaledY = character.position.Y * yScale; + // int xPos = (int)scaledX + xStart; + // int yPos = (int)scaledY + yStart; + // location = new KeyValuePair(xPos, yPos); + //} + //else + //{ + // _mapLocations.TryGetValue(character.currentLocation.name, out location); + //} + Rectangle headShot = character.GetHeadShot(); + int xBase = Game1.activeClickableMenu.xPositionOnScreen - 20; + int yBase = Game1.activeClickableMenu.yPositionOnScreen + 40; + + int x = xBase + location.Key; + int y = yBase + location.Value; + + Color color = character.CurrentDialogue.Count <= 0 ? + Color.Gray : Color.White; + ClickableTextureComponent textureComponent = + new ClickableTextureComponent( + character.Name, + new Rectangle(x, y, 0, 0), + null, + character.Name, + character.Sprite.Texture, + headShot, + 2.3f); + + float headShotScale = 2f; + Game1.spriteBatch.Draw( + character.Sprite.Texture, + new Vector2(x, y), + new Rectangle?(headShot), + color, + 0.0f, + Vector2.Zero, + headShotScale, + SpriteEffects.None, + 1f); + + int mouseX = Game1.getMouseX(); + int mouseY = Game1.getMouseY(); + + if (mouseX >= x && mouseX <= x + headShot.Width * headShotScale && + mouseY >= y && mouseY <= y + headShot.Height * headShotScale) + { + namesToShow.Add(character.displayName); + } + + foreach (Quest quest in Game1.player.questLog) + { + if (quest.accepted.Value && quest.dailyQuest.Value && !quest.completed.Value) + { + bool isQuestTarget = false; + switch (quest.questType.Value) + { + case 3: isQuestTarget = (quest as ItemDeliveryQuest).target.Value == character.Name; break; + case 4: isQuestTarget = (quest as SlayMonsterQuest).target.Value == character.Name; break; + case 7: isQuestTarget = (quest as FishingQuest).target.Value == character.Name; break; + case 10: isQuestTarget = (quest as ResourceCollectionQuest).target.Value == character.Name; break; + } + + if (isQuestTarget) + Game1.spriteBatch.Draw( + Game1.mouseCursors, + new Vector2(x + 10, y - 12), + new Rectangle(394, 495, 4, 10), + Color.White, + 0.0f, + Vector2.Zero, + 3f, + SpriteEffects.None, + 1f); + } + } + } + } + catch (Exception ex) + { + ModEntry.MonitorObject.Log(ex.Message + Environment.NewLine + ex.StackTrace, LogLevel.Error); + } + } + + if (namesToShow.Count > 0) + { + StringBuilder text = new StringBuilder(); + int longestLength = 0; + foreach (string name in namesToShow) + { + text.AppendLine(name); + longestLength = Math.Max(longestLength, (int)Math.Ceiling(Game1.smallFont.MeasureString(name).Length())); + } + + int windowHeight = Game1.smallFont.LineSpacing * namesToShow.Count + 25; + Vector2 windowPos = new Vector2(Game1.getMouseX() + 40, Game1.getMouseY() - windowHeight); + IClickableMenu.drawTextureBox( + Game1.spriteBatch, + (int)windowPos.X, + (int)windowPos.Y, + longestLength + 30, + Game1.smallFont.LineSpacing * namesToShow.Count + 25, + Color.White); + + Game1.spriteBatch.DrawString( + Game1.smallFont, + text, + new Vector2(windowPos.X + 17, windowPos.Y + 17), + Game1.textShadowColor); + + Game1.spriteBatch.DrawString( + Game1.smallFont, + text, + new Vector2(windowPos.X + 15, windowPos.Y + 15), + Game1.textColor); + } + + //The cursor needs to show up in front of the character faces + Tools.DrawMouseCursor(); + + string hoverText = (string)typeof(MapPage) + .GetField( + "hoverText", + BindingFlags.Instance | BindingFlags.NonPublic) + .GetValue(gameMenu.pages[gameMenu.currentTab]); + + IClickableMenu.drawHoverText( + Game1.spriteBatch, + hoverText, + Game1.smallFont); + } + } + } + + /// When a menu is open ( isn't null), raised after that menu is drawn to the sprite batch but before it's rendered to the screen. + /// The event sender. + /// The event arguments. + private void OnRenderedActiveMenu_DrawSocialPageOptions(object sender, RenderedActiveMenuEventArgs e) + { + if (Game1.activeClickableMenu is GameMenu gameMenu && gameMenu.currentTab == 2) + { + Game1.drawDialogueBox( + Game1.activeClickableMenu.xPositionOnScreen - SocialPanelXOffset, + Game1.activeClickableMenu.yPositionOnScreen, + SocialPanelWidth, + Game1.activeClickableMenu.height, + false, + true); + + int slotPosition = (int)typeof(SocialPage) + .GetField("slotPosition", BindingFlags.Instance | BindingFlags.NonPublic) + .GetValue(this._socialPage); + int yOffset = 0; + + for (int i = slotPosition; i < slotPosition + 5 && i < this._friendNames.Length; ++i) + { + OptionsCheckbox checkbox = this._checkboxes[i]; + checkbox.bounds.X = Game1.activeClickableMenu.xPositionOnScreen - 60; + + checkbox.bounds.Y = Game1.activeClickableMenu.yPositionOnScreen + 130 + yOffset; + + checkbox.draw(Game1.spriteBatch, 0, 0); + yOffset += 112; + Color color = checkbox.isChecked ? Color.White : Color.Gray; + + Game1.spriteBatch.Draw( + Game1.mouseCursors, + new Vector2(checkbox.bounds.X - 50, checkbox.bounds.Y), + new Rectangle(80, 0, 16, 16), + color, + 0.0f, + Vector2.Zero, + 3f, + SpriteEffects.None, + 1f); + + if (yOffset != 560) + { + Game1.spriteBatch.Draw( + Game1.staminaRect, + new Rectangle( + checkbox.bounds.X - 50, + checkbox.bounds.Y + 72, + SocialPanelWidth / 2 - 6, + 4), + Color.SaddleBrown); + + Game1.spriteBatch.Draw( + Game1.staminaRect, + new Rectangle( + checkbox.bounds.X - 50, + checkbox.bounds.Y + 76, + SocialPanelWidth / 2 - 6, + 4), + Color.BurlyWood); + } + if (!Game1.options.hardwareCursor) + { + Game1.spriteBatch.Draw( + Game1.mouseCursors, + new Vector2( + Game1.getMouseX(), + Game1.getMouseY()), + Game1.getSourceRectForStandardTileSheet( + Game1.mouseCursors, + Game1.mouseCursor, + 16, + 16), + Color.White, + 0.0f, + Vector2.Zero, + Game1.pixelZoom + (Game1.dialogueButtonScale / 150.0f), + SpriteEffects.None, + 1f); + } + + if (checkbox.bounds.Contains(Game1.getMouseX(), Game1.getMouseY())) + IClickableMenu.drawHoverText( + Game1.spriteBatch, + "Track on map", + Game1.dialogueFont); + } + } + } + } +} diff --git a/Mods/UI Info Suite/UIElements/LuckOfDay.cs b/Mods/UI Info Suite/UIElements/LuckOfDay.cs new file mode 100644 index 000000000..53a81e564 --- /dev/null +++ b/Mods/UI Info Suite/UIElements/LuckOfDay.cs @@ -0,0 +1,135 @@ +using Microsoft.Xna.Framework; +using StardewModdingAPI; +using StardewModdingAPI.Events; +using StardewValley; +using StardewValley.Menus; +using System; +using UIInfoSuite.Extensions; + +namespace UIInfoSuite.UIElements +{ + class LuckOfDay : IDisposable + { + private string _hoverText = string.Empty; + private Color _color = new Color(Color.White.ToVector4()); + private ClickableTextureComponent _icon; + private readonly IModHelper _helper; + + public void Toggle(bool showLuckOfDay) + { + this._helper.Events.Player.Warped -= this.OnWarped; + this._helper.Events.Display.RenderingHud -= this.OnRenderingHud; + this._helper.Events.Display.RenderedHud -= this.OnRenderedHud; + this._helper.Events.GameLoop.UpdateTicked -= this.OnUpdateTicked; + + if (showLuckOfDay) + { + this.AdjustIconXToBlackBorder(); + this._helper.Events.Player.Warped += this.OnWarped; + this._helper.Events.GameLoop.UpdateTicked += this.OnUpdateTicked; + this._helper.Events.Display.RenderingHud += this.OnRenderingHud; + this._helper.Events.Display.RenderedHud += this.OnRenderedHud; + } + } + + public LuckOfDay(IModHelper helper) + { + this._helper = helper; + } + + public void Dispose() + { + this.Toggle(false); + } + + /// Raised after the game state is updated (≈60 times per second). + /// The event sender. + /// The event arguments. + private void OnUpdateTicked(object sender, UpdateTickedEventArgs e) + { + // calculate luck + if (e.IsMultipleOf(30)) // half second + { + this._color = new Color(Color.White.ToVector4()); + + if (Game1.dailyLuck < -0.04) + { + this._hoverText = this._helper.SafeGetString(LanguageKeys.MaybeStayHome); + this._color.B = 155; + this._color.G = 155; + } + else if (Game1.dailyLuck < 0) + { + this._hoverText = this._helper.SafeGetString(LanguageKeys.NotFeelingLuckyAtAll); + this._color.B = 165; + this._color.G = 165; + this._color.R = 165; + this._color *= 0.8f; + } + else if (Game1.dailyLuck <= 0.04) + { + this._hoverText = this._helper.SafeGetString(LanguageKeys.LuckyButNotTooLucky); + } + else + { + this._hoverText = this._helper.SafeGetString(LanguageKeys.FeelingLucky); + this._color.B = 155; + this._color.R = 155; + } + } + } + + /// Raised after drawing the HUD (item toolbar, clock, etc) to the sprite batch, but before it's rendered to the screen. The vanilla HUD may be hidden at this point (e.g. because a menu is open). + /// The event sender. + /// The event arguments. + private void OnRenderedHud(object sender, RenderedHudEventArgs e) + { + // draw hover text + if (this._icon.containsPoint((int)(Game1.getMouseX() * Game1.options.zoomLevel), (int)(Game1.getMouseY() * Game1.options.zoomLevel))) + IClickableMenu.drawHoverText(Game1.spriteBatch, this._hoverText, Game1.dialogueFont); + } + + /// Raised before drawing the HUD (item toolbar, clock, etc) to the screen. The vanilla HUD may be hidden at this point (e.g. because a menu is open). + /// The event sender. + /// The event arguments. + private void OnRenderingHud(object sender, RenderingHudEventArgs e) + { + // draw dice icon + if (!Game1.eventUp && Game1.activeClickableMenu == null) + { + Point iconPosition = IconHandler.Handler.GetNewIconPosition(); + this._icon.bounds.X = iconPosition.X; + this._icon.bounds.Y = iconPosition.Y; + this._icon.draw(Game1.spriteBatch, this._color, 1f); + + } + } + + /// Raised after a player warps to a new location. + /// The event sender. + /// The event arguments. + private void OnWarped(object sender, WarpedEventArgs e) + { + // adjust icon X to black border + if (e.IsLocalPlayer) + { + this.AdjustIconXToBlackBorder(); + } + } + + private void AdjustIconXToBlackBorder() + { + this._icon = new ClickableTextureComponent("", + new Rectangle(Tools.GetWidthInPlayArea() - 174, + 320, + 10 * 6, + 10 * 6), + "", + "", + Game1.mouseCursors, + new Rectangle(50, 428, 10, 14), + 6, + false); + } + } +} diff --git a/Mods/UI Info Suite/UIElements/ShopHarvestPrices.cs b/Mods/UI Info Suite/UIElements/ShopHarvestPrices.cs new file mode 100644 index 000000000..5e9d2780d --- /dev/null +++ b/Mods/UI Info Suite/UIElements/ShopHarvestPrices.cs @@ -0,0 +1,175 @@ +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Graphics; +using StardewModdingAPI; +using StardewModdingAPI.Events; +using StardewValley; +using StardewValley.Menus; +using System; +using System.Reflection; +using UIInfoSuite.Extensions; + +namespace UIInfoSuite.UIElements +{ + class ShopHarvestPrices : IDisposable + { + private readonly IModHelper _helper; + + public ShopHarvestPrices(IModHelper helper) + { + this._helper = helper; + } + + public void ToggleOption(bool shopHarvestPrices) + { + this._helper.Events.Display.RenderedActiveMenu -= this.OnRenderedActiveMenu; + + if (shopHarvestPrices) + { + this._helper.Events.Display.RenderedActiveMenu += this.OnRenderedActiveMenu; + } + } + + public void Dispose() + { + this.ToggleOption(false); + } + + /// When a menu is open ( isn't null), raised after that menu is drawn to the sprite batch but before it's rendered to the screen. + /// The event sender. + /// The event arguments. + private void OnRenderedActiveMenu(object sender, RenderedActiveMenuEventArgs e) + { + // draw shop harvest prices + if (Game1.activeClickableMenu is ShopMenu menu) + { + if (typeof(ShopMenu).GetField("hoveredItem", BindingFlags.Instance | BindingFlags.NonPublic).GetValue(menu) is Item hoverItem) + { + string text = string.Empty; + bool itemHasPriceInfo = Tools.GetTruePrice(hoverItem) > 0; + + if (hoverItem is StardewValley.Object && + (hoverItem as StardewValley.Object).Type == "Seeds" && + itemHasPriceInfo && + hoverItem.Name != "Mixed Seeds" && + hoverItem.Name != "Winter Seeds") + { + Debris debris = new Debris(); + debris.init(new Crop( + hoverItem.ParentSheetIndex, + 0, + 0) + .indexOfHarvest.Value, + Game1.player.position, + Game1.player.position); + StardewValley.Object temp = + new StardewValley.Object( + debris.chunkType.Value, + 1); + text = " " + temp.Price; + } + + Item heldItem = typeof(ShopMenu).GetField("heldItem", BindingFlags.Instance | BindingFlags.NonPublic).GetValue(menu) as Item; + if (heldItem == null) + { + int value = 0; + switch (hoverItem.ParentSheetIndex) + { + case 628: value = 50; break; + case 629: value = 80; break; + case 630: + case 633: value = 100; break; + + case 631: + case 632: value = 140; break; + } + + if (value > 0) + text = " " + value; + + if (text != "" && + (hoverItem as StardewValley.Object).Type == "Seeds") + { + string textToRender = this._helper.SafeGetString( + LanguageKeys.HarvestPrice); + int xPosition = menu.xPositionOnScreen - 30; + int yPosition = menu.yPositionOnScreen + 580; + IClickableMenu.drawTextureBox( + Game1.spriteBatch, + xPosition + 20, + yPosition - 52, + 264, + 108, + Color.White); + Game1.spriteBatch.DrawString( + Game1.dialogueFont, + textToRender, + new Vector2(xPosition + 30, yPosition - 38), + Color.Black * 0.2f); + Game1.spriteBatch.DrawString( + Game1.dialogueFont, + textToRender, + new Vector2(xPosition + 32, yPosition - 40), + Color.Black * 0.8f); + xPosition += 80; + + Game1.spriteBatch.Draw( + Game1.mouseCursors, + new Vector2(xPosition, yPosition), + new Rectangle(60, 428, 10, 10), + Color.White, + 0, + Vector2.Zero, + Game1.pixelZoom, + SpriteEffects.None, + 0.85f); + + Game1.spriteBatch.Draw( + Game1.debrisSpriteSheet, + new Vector2(xPosition + 32, yPosition + 10), + Game1.getSourceRectForStandardTileSheet(Game1.debrisSpriteSheet, 8, 16, 16), + Color.White, + 0, + new Vector2(8, 8), + 4, + SpriteEffects.None, + 0.95f); + + Game1.spriteBatch.DrawString( + Game1.dialogueFont, + text, + new Vector2(xPosition - 2, yPosition + 6), + Color.Black * 0.2f); + + Game1.spriteBatch.DrawString( + Game1.dialogueFont, + text, + new Vector2(xPosition, yPosition + 4), + Color.Black * 0.8f); + + string hoverText = this._helper.Reflection.GetField(menu, "hoverText").GetValue(); + string hoverTitle = this._helper.Reflection.GetField(menu, "boldTitleText").GetValue(); + Item hoverItem2 = this._helper.Reflection.GetField(menu, "hoveredItem").GetValue(); + int currency = this._helper.Reflection.GetField(menu, "currency").GetValue(); + int hoverPrice = this._helper.Reflection.GetField(menu, "hoverPrice").GetValue(); + IReflectedMethod getHoveredItemExtraItemIndex = this._helper.Reflection.GetMethod(menu, "getHoveredItemExtraItemIndex"); + IReflectedMethod getHoveredItemExtraItemAmount = this._helper.Reflection.GetMethod(menu, "getHoveredItemExtraItemAmount"); + + IClickableMenu.drawToolTip( + Game1.spriteBatch, + hoverText, + hoverTitle, + hoverItem2, + heldItem != null, + -1, + currency, + getHoveredItemExtraItemIndex.Invoke(new object[0]), + getHoveredItemExtraItemAmount.Invoke(new object[0]), + null, + hoverPrice); + } + } + } + } + } + } +} diff --git a/Mods/UI Info Suite/UIElements/ShowAccurateHearts.cs b/Mods/UI Info Suite/UIElements/ShowAccurateHearts.cs new file mode 100644 index 000000000..60b338a8e --- /dev/null +++ b/Mods/UI Info Suite/UIElements/ShowAccurateHearts.cs @@ -0,0 +1,182 @@ +using Microsoft.Xna.Framework; +using StardewModdingAPI.Events; +using StardewValley; +using StardewValley.Menus; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; + +namespace UIInfoSuite.UIElements +{ + class ShowAccurateHearts : IDisposable + { + private string[] _friendNames; + private SocialPage _socialPage; + private IModEvents _events; + + private readonly int[][] _numArray = new int[][] + { + new int[] { 1, 1, 0, 1, 1 }, + new int[] { 1, 1, 1, 1, 1 }, + new int[] { 0, 1, 1, 1, 0 }, + new int[] { 0, 0, 1, 0, 0 } + }; + + public ShowAccurateHearts(IModEvents events) + { + this._events = events; + } + + public void ToggleOption(bool showAccurateHearts) + { + this._events.Display.MenuChanged -= this.OnMenuChanged; + this._events.Display.RenderedActiveMenu -= this.OnRenderedActiveMenu; + + if (showAccurateHearts) + { + this._events.Display.MenuChanged += this.OnMenuChanged; + this._events.Display.RenderedActiveMenu += this.OnRenderedActiveMenu; + } + } + + public void Dispose() + { + this.ToggleOption(false); + } + + /// When a menu is open ( isn't null), raised after that menu is drawn to the sprite batch but before it's rendered to the screen. + /// The event sender. + /// The event arguments. + private void OnRenderedActiveMenu(object sender, RenderedActiveMenuEventArgs e) + { + // draw heart fills + if (Game1.activeClickableMenu is GameMenu gameMenu) + { + if (gameMenu.currentTab == 2) + { + if (this._socialPage != null) + { + int slotPosition = (int)typeof(SocialPage) + .GetField( + "slotPosition", + BindingFlags.Instance | BindingFlags.NonPublic) + .GetValue(this._socialPage); + int yOffset = 0; + + for (int i = slotPosition; i < slotPosition + 5 && i < this._friendNames.Length; ++i) + { + int yPosition = Game1.activeClickableMenu.yPositionOnScreen + 130 + yOffset; + yOffset += 112; + Friendship friendshipValues; + string nextName = this._friendNames[i]; + if (Game1.player.friendshipData.TryGetValue(nextName, out friendshipValues)) + { + int friendshipRawValue = friendshipValues.Points; + + if (friendshipRawValue > 0) + { + int pointsToNextHeart = friendshipRawValue % 250; + int numHearts = friendshipRawValue / 250; + + if (friendshipRawValue < 3000 && + this._friendNames[i] == Game1.player.spouse || + friendshipRawValue < 2500) + { + this.DrawEachIndividualSquare(numHearts, pointsToNextHeart, yPosition); + //if (!Game1.options.hardwareCursor) + // Game1.spriteBatch.Draw( + // Game1.mouseCursors, + // new Vector2(Game1.getMouseX(), Game1.getMouseY()), + // Game1.getSourceRectForStandardTileSheet( + // Game1.mouseCursors, Game1.mouseCursor, + // 16, + // 16), + // Color.White, + // 0.0f, + // Vector2.Zero, + // Game1.pixelZoom + (float)(Game1.dialogueButtonScale / 150.0), + // SpriteEffects.None, + // 1f); + } + } + } + } + + string hoverText = typeof(GameMenu).GetField("hoverText", BindingFlags.Instance | BindingFlags.NonPublic).GetValue(gameMenu) as string; + IClickableMenu.drawHoverText( + Game1.spriteBatch, + hoverText, + Game1.smallFont); + } + else + { + this.ExtendMenuIfNeeded(); + } + } + } + } + + /// Raised after a game menu is opened, closed, or replaced. + /// The event sender. + /// The event arguments. + private void OnMenuChanged(object sender, MenuChangedEventArgs e) + { + this.ExtendMenuIfNeeded(); + } + + private void ExtendMenuIfNeeded() + { + if (Game1.activeClickableMenu is GameMenu gameMenu) + { + List menuList = gameMenu.pages; + + foreach (IClickableMenu menu in menuList) + { + if (menu is SocialPage page) + { + this._socialPage = page; + this._friendNames = (typeof(SocialPage).GetField("names", BindingFlags.Instance | BindingFlags.NonPublic).GetValue(this._socialPage) as List) + .Select(name => name.ToString()) + .ToArray(); + break; + } + } + } + } + + private void DrawEachIndividualSquare(int friendshipLevel, int friendshipPoints, int yPosition) + { + int numberOfPointsToDraw = (int)(((double)friendshipPoints) / 12.5); + int num2; + + if (friendshipLevel > 10) + { + num2 = 32 * (friendshipLevel - 10); + yPosition += 28; + } + else + { + num2 = 32 * friendshipLevel; + } + + for (int i = 3; i >= 0 && numberOfPointsToDraw > 0; --i) + { + for (int j = 0; j < 5 && numberOfPointsToDraw > 0; ++j, --numberOfPointsToDraw) + { + if (this._numArray[i][j] == 1) + { + Game1.spriteBatch.Draw( + Game1.staminaRect, + new Rectangle( + Game1.activeClickableMenu.xPositionOnScreen + 316 + num2 + j * 4, + yPosition + 14 + i * 4, + 4, + 4), + Color.Crimson); + } + } + } + } + } +} diff --git a/Mods/UI Info Suite/UIElements/ShowBirthdayIcon.cs b/Mods/UI Info Suite/UIElements/ShowBirthdayIcon.cs new file mode 100644 index 000000000..62ca197ee --- /dev/null +++ b/Mods/UI Info Suite/UIElements/ShowBirthdayIcon.cs @@ -0,0 +1,151 @@ +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Graphics; +using UIInfoSuite.Extensions; +using StardewModdingAPI.Events; +using StardewValley; +using StardewValley.Menus; +using System; + +namespace UIInfoSuite.UIElements +{ + class ShowBirthdayIcon : IDisposable + { + private NPC _birthdayNPC; + private ClickableTextureComponent _birthdayIcon; + private readonly IModEvents _events; + + public ShowBirthdayIcon(IModEvents events) + { + this._events = events; + } + + public void ToggleOption(bool showBirthdayIcon) + { + this._events.GameLoop.DayStarted -= this.OnDayStarted; + this._events.Display.RenderingHud -= this.OnRenderingHud; + this._events.Display.RenderedHud -= this.OnRenderedHud; + this._events.GameLoop.UpdateTicked -= this.OnUpdateTicked; + + if (showBirthdayIcon) + { + this.CheckForBirthday(); + this._events.GameLoop.DayStarted += this.OnDayStarted; + this._events.Display.RenderingHud += this.OnRenderingHud; + this._events.Display.RenderedHud += this.OnRenderedHud; + this._events.GameLoop.UpdateTicked += this.OnUpdateTicked; + } + } + + /// Raised after the game state is updated (≈60 times per second). + /// The event sender. + /// The event arguments. + private void OnUpdateTicked(object sender, UpdateTickedEventArgs e) + { + // check if gift has been given + if (e.IsOneSecond && this._birthdayNPC != null && Game1.player?.friendshipData != null) + { + Game1.player.friendshipData.FieldDict.TryGetValue(this._birthdayNPC.Name, out Netcode.NetRef netRef); + //var birthdayNPCDetails = Game1.player.friendshipData.SafeGet(_birthdayNPC.name); + Friendship birthdayNPCDetails = netRef; + if (birthdayNPCDetails != null) + { + if (birthdayNPCDetails.GiftsToday == 1) + this._birthdayNPC = null; + } + } + } + + public void Dispose() + { + this.ToggleOption(false); + } + + /// 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.CheckForBirthday(); + } + + private void CheckForBirthday() + { + this._birthdayNPC = null; + foreach (GameLocation location in Game1.locations) + { + foreach (NPC character in location.characters) + { + if (character.isBirthday(Game1.currentSeason, Game1.dayOfMonth)) + { + this._birthdayNPC = character; + break; + } + } + + if (this._birthdayNPC != null) + break; + } + } + + /// Raised before drawing the HUD (item toolbar, clock, etc) to the screen. + /// The event sender. + /// The event arguments. + private void OnRenderingHud(object sender, EventArgs e) + { + // draw birthday icon + if (!Game1.eventUp && Game1.activeClickableMenu == null) + { + if (this._birthdayNPC != null) + { + Rectangle headShot = this._birthdayNPC.GetHeadShot(); + Point iconPosition = IconHandler.Handler.GetNewIconPosition(); + float scale = 4.35f; + + Game1.spriteBatch.Draw( + Game1.mouseCursors, + new Vector2(iconPosition.X, iconPosition.Y), + new Rectangle(228, 409, 16, 16), + Color.White, + 0.0f, + Vector2.Zero, + scale, + SpriteEffects.None, + 1f); + + this._birthdayIcon = + new ClickableTextureComponent( + this._birthdayNPC.Name, + new Rectangle( + iconPosition.X - 7, + iconPosition.Y - 2, + (int)(16.0 * scale), + (int)(16.0 * scale)), + null, + this._birthdayNPC.Name, + this._birthdayNPC.Sprite.Texture, + headShot, + 2f); + + this._birthdayIcon.draw(Game1.spriteBatch); + } + } + } + + /// Raised after drawing the HUD (item toolbar, clock, etc) to the sprite batch, but before it's rendered to the screen. + /// The event sender. + /// The event arguments. + private void OnRenderedHud(object sender, RenderedHudEventArgs e) + { + // draw hover text + if (this._birthdayNPC != null && + (this._birthdayIcon?.containsPoint((int)(Game1.getMouseX() * Game1.options.zoomLevel), (int)(Game1.getMouseY() * Game1.options.zoomLevel)) ?? false)) + { + string hoverText = string.Format("{0}'s Birthday", this._birthdayNPC.Name); + IClickableMenu.drawHoverText( + Game1.spriteBatch, + hoverText, + Game1.dialogueFont); + } + } + } +} diff --git a/Mods/UI Info Suite/UIElements/ShowCalendarAndBillboardOnGameMenuButton.cs b/Mods/UI Info Suite/UIElements/ShowCalendarAndBillboardOnGameMenuButton.cs new file mode 100644 index 000000000..6809f2282 --- /dev/null +++ b/Mods/UI Info Suite/UIElements/ShowCalendarAndBillboardOnGameMenuButton.cs @@ -0,0 +1,126 @@ +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Graphics; +using UIInfoSuite.Extensions; +using StardewModdingAPI.Events; +using StardewValley; +using StardewValley.Menus; +using System; +using System.Collections.Generic; +using System.IO; +using System.Reflection; +using StardewModdingAPI; + +namespace UIInfoSuite.UIElements +{ + class ShowCalendarAndBillboardOnGameMenuButton : IDisposable + { + private ClickableTextureComponent _showBillboardButton = + new ClickableTextureComponent( + new Rectangle(0, 0, 99, 60), + Game1.content.Load(Path.Combine("Maps", "summer_town")), + new Rectangle(122, 291, 35, 20), + 3f); + + private readonly IModHelper _helper; + + private Item _hoverItem = null; + private Item _heldItem = null; + + public ShowCalendarAndBillboardOnGameMenuButton(IModHelper helper) + { + this._helper = helper; + } + + public void ToggleOption(bool showCalendarAndBillboard) + { + this._helper.Events.Display.RenderedActiveMenu -= this.OnRenderedActiveMenu; + this._helper.Events.Input.ButtonPressed -= this.OnButtonPressed; + this._helper.Events.GameLoop.UpdateTicked -= this.OnUpdateTicked; + + if (showCalendarAndBillboard) + { + this._helper.Events.Display.RenderedActiveMenu += this.OnRenderedActiveMenu; + this._helper.Events.Input.ButtonPressed += this.OnButtonPressed; + this._helper.Events.GameLoop.UpdateTicked += this.OnUpdateTicked; + } + } + + /// Raised after the game state is updated (≈60 times per second). + /// The event sender. + /// The event arguments. + private void OnUpdateTicked(object sender, EventArgs e) + { + // get hover item + this._hoverItem = Tools.GetHoveredItem(); + if (Game1.activeClickableMenu is GameMenu gameMenu) + { + List menuList = gameMenu.pages; + + if (menuList[0] is InventoryPage inventory) + { + this._heldItem = Game1.player.CursorSlotItem; + } + } + } + + public void Dispose() + { + this.ToggleOption(false); + } + + /// 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 (e.Button == SButton.MouseLeft) + this.ActivateBillboard(); + else if (e.Button == SButton.ControllerA) + this.ActivateBillboard(); + } + + private void ActivateBillboard() + { + if (Game1.activeClickableMenu is GameMenu && + (Game1.activeClickableMenu as GameMenu).currentTab == 0 && + this._showBillboardButton.containsPoint(Game1.getMouseX(), Game1.getMouseY()) + && this._heldItem == null) + { + if (Game1.questOfTheDay != null && + string.IsNullOrEmpty(Game1.questOfTheDay.currentObjective)) + Game1.questOfTheDay.currentObjective = "wat?"; + + Game1.activeClickableMenu = + new Billboard(!(Game1.getMouseX() < + this._showBillboardButton.bounds.X + this._showBillboardButton.bounds.Width / 2)); + } + } + + /// When a menu is open ( isn't null), raised after that menu is drawn to the sprite batch but before it's rendered to the screen. + /// The event sender. + /// The event arguments. + private void OnRenderedActiveMenu(object sender, EventArgs e) + { + if (this._hoverItem == null && + Game1.activeClickableMenu is GameMenu gameMenu && + gameMenu.currentTab == 0 + && this._heldItem == null) + { + this._showBillboardButton.bounds.X = Game1.activeClickableMenu.xPositionOnScreen + Game1.activeClickableMenu.width - 160; + + this._showBillboardButton.bounds.Y = Game1.activeClickableMenu.yPositionOnScreen + Game1.activeClickableMenu.height - 300; + this._showBillboardButton.draw(Game1.spriteBatch); + if (this._showBillboardButton.containsPoint(Game1.getMouseX(), Game1.getMouseY())) + { + string hoverText = Game1.getMouseX() < + this._showBillboardButton.bounds.X + this._showBillboardButton.bounds.Width / 2 ? + LanguageKeys.Calendar : LanguageKeys.Billboard; + IClickableMenu.drawHoverText( + Game1.spriteBatch, + this._helper.SafeGetString(hoverText), + Game1.dialogueFont); + } + } + } + } +} diff --git a/Mods/UI Info Suite/UIElements/ShowCropAndBarrelTime.cs b/Mods/UI Info Suite/UIElements/ShowCropAndBarrelTime.cs new file mode 100644 index 000000000..0c0de8778 --- /dev/null +++ b/Mods/UI Info Suite/UIElements/ShowCropAndBarrelTime.cs @@ -0,0 +1,258 @@ +using Microsoft.Xna.Framework; +using UIInfoSuite.Extensions; +using StardewModdingAPI.Events; +using StardewValley; +using StardewValley.Menus; +using StardewValley.TerrainFeatures; +using System; +using System.Collections.Generic; +using System.Text; +using StardewValley.Objects; +using StardewModdingAPI; +using StardewValley.Locations; +using StardewValley.Buildings; + +namespace UIInfoSuite.UIElements +{ + class ShowCropAndBarrelTime : IDisposable + { + private readonly Dictionary _indexOfCropNames = new Dictionary(); + private StardewValley.Object _currentTile; + private TerrainFeature _terrain; + private Building _currentTileBuilding = null; + private readonly IModHelper _helper; + + public ShowCropAndBarrelTime(IModHelper helper) + { + this._helper = helper; + } + + public void ToggleOption(bool showCropAndBarrelTimes) + { + this._helper.Events.Display.RenderingHud -= this.OnRenderingHud; + this._helper.Events.GameLoop.UpdateTicked -= this.OnUpdateTicked; + + if (showCropAndBarrelTimes) + { + this._helper.Events.Display.RenderingHud += this.OnRenderingHud; + this._helper.Events.GameLoop.UpdateTicked += this.OnUpdateTicked; + } + } + + /// 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 (!e.IsMultipleOf(4)) + return; + + // get tile under cursor + this._currentTileBuilding = Game1.currentLocation is BuildableGameLocation buildableLocation + ? buildableLocation.getBuildingAt(Game1.currentCursorTile) + : null; + if (Game1.currentLocation != null && Game1.activeClickableMenu == null) + { + if (Game1.currentLocation.Objects == null || + !Game1.currentLocation.Objects.TryGetValue(Game1.currentCursorTile, out this._currentTile)) + { + this._currentTile = null; + } + + if (Game1.currentLocation.terrainFeatures == null || + !Game1.currentLocation.terrainFeatures.TryGetValue(Game1.currentCursorTile, out this._terrain)) + { + if (this._currentTile is IndoorPot pot && + pot.hoeDirt.Value != null) + { + this._terrain = pot.hoeDirt.Value; + } + else + { + this._terrain = null; + } + } + } + else + { + this._currentTile = null; + this._terrain = null; + } + } + + public void Dispose() + { + this.ToggleOption(false); + } + + /// Raised before drawing the HUD (item toolbar, clock, etc) to the screen. The vanilla HUD may be hidden at this point (e.g. because a menu is open). + /// The event sender. + /// The event arguments. + private void OnRenderingHud(object sender, RenderingHudEventArgs e) + { + // draw hover tooltip + if (this._currentTileBuilding != null) + { + if (this._currentTileBuilding is Mill millBuilding) + { + if (millBuilding.input.Value != null) + { + if (!millBuilding.input.Value.isEmpty()) + { + int wheatCount = 0; + int beetCount = 0; + + foreach (Item item in millBuilding.input.Value.items) + { + if (item != null && + !string.IsNullOrEmpty(item.Name)) + { + switch (item.Name) + { + case "Wheat": wheatCount = item.Stack; break; + case "Beet": beetCount = item.Stack; break; + } + } + } + + StringBuilder builder = new StringBuilder(); + + if (wheatCount > 0) + builder.Append(wheatCount + " wheat"); + + if (beetCount > 0) + { + if (wheatCount > 0) + builder.Append(Environment.NewLine); + builder.Append(beetCount + " beets"); + } + + if (builder.Length > 0) + { + IClickableMenu.drawHoverText( + Game1.spriteBatch, + builder.ToString(), + Game1.smallFont); + } + } + } + } + } + else if (this._currentTile != null && + (!this._currentTile.bigCraftable.Value || + this._currentTile.MinutesUntilReady > 0)) + { + if (this._currentTile.bigCraftable.Value && + this._currentTile.MinutesUntilReady > 0 && + this._currentTile.heldObject.Value != null && + this._currentTile.Name != "Heater") + { + StringBuilder hoverText = new StringBuilder(); + hoverText.AppendLine(this._currentTile.heldObject.Value.DisplayName); + + if (this._currentTile is Cask) + { + Cask currentCask = this._currentTile as Cask; + hoverText.Append((int)(currentCask.daysToMature.Value / currentCask.agingRate.Value)) + .Append(" " + this._helper.SafeGetString( + LanguageKeys.DaysToMature)); + } + else + { + int hours = this._currentTile.MinutesUntilReady / 60; + int minutes = this._currentTile.MinutesUntilReady % 60; + if (hours > 0) + hoverText.Append(hours).Append(" ") + .Append(this._helper.SafeGetString( + LanguageKeys.Hours)) + .Append(", "); + hoverText.Append(minutes).Append(" ") + .Append(this._helper.SafeGetString( + LanguageKeys.Minutes)); + } + IClickableMenu.drawHoverText( + Game1.spriteBatch, + hoverText.ToString(), + Game1.smallFont); + } + } + else if (this._terrain != null) + { + if (this._terrain is HoeDirt) + { + HoeDirt hoeDirt = this._terrain as HoeDirt; + if (hoeDirt.crop != null && + !hoeDirt.crop.dead.Value) + { + int num = 0; + + if (hoeDirt.crop.fullyGrown.Value && + hoeDirt.crop.dayOfCurrentPhase.Value > 0) + { + num = hoeDirt.crop.dayOfCurrentPhase.Value; + } + else + { + for (int i = 0; i < hoeDirt.crop.phaseDays.Count - 1; ++i) + { + if (hoeDirt.crop.currentPhase.Value == i) + num -= hoeDirt.crop.dayOfCurrentPhase.Value; + + if (hoeDirt.crop.currentPhase.Value <= i) + num += hoeDirt.crop.phaseDays[i]; + } + } + + if (hoeDirt.crop.indexOfHarvest.Value > 0) + { + string hoverText = this._indexOfCropNames.SafeGet(hoeDirt.crop.indexOfHarvest.Value); + if (string.IsNullOrEmpty(hoverText)) + { + Debris debris = new Debris(); + debris.init(hoeDirt.crop.indexOfHarvest.Value, Vector2.Zero, Vector2.Zero); + hoverText = new StardewValley.Object(debris.chunkType.Value, 1).DisplayName; + this._indexOfCropNames.Add(hoeDirt.crop.indexOfHarvest.Value, hoverText); + } + + StringBuilder finalHoverText = new StringBuilder(); + finalHoverText.Append(hoverText).Append(": "); + if (num > 0) + { + finalHoverText.Append(num).Append(" ") + .Append(this._helper.SafeGetString( + LanguageKeys.Days)); + } + else + { + finalHoverText.Append(this._helper.SafeGetString( + LanguageKeys.ReadyToHarvest)); + } + IClickableMenu.drawHoverText( + Game1.spriteBatch, + finalHoverText.ToString(), + Game1.smallFont); + } + } + } + else if (this._terrain is FruitTree) + { + FruitTree tree = this._terrain as FruitTree; + Debris debris = new Debris(); + debris.init(tree.indexOfFruit.Value, Vector2.Zero, Vector2.Zero); + string text = new StardewValley.Object(debris.chunkType.Value, 1).DisplayName; + if (tree.daysUntilMature.Value > 0) + { + text += Environment.NewLine + tree.daysUntilMature.Value + " " + + this._helper.SafeGetString( + LanguageKeys.DaysToMature); + + } + IClickableMenu.drawHoverText( + Game1.spriteBatch, + text, + Game1.smallFont); + } + } + } + } +} diff --git a/Mods/UI Info Suite/UIElements/ShowItemEffectRanges.cs b/Mods/UI Info Suite/UIElements/ShowItemEffectRanges.cs new file mode 100644 index 000000000..d5471acf6 --- /dev/null +++ b/Mods/UI Info Suite/UIElements/ShowItemEffectRanges.cs @@ -0,0 +1,240 @@ +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Graphics; +using StardewModdingAPI.Events; +using StardewValley; +using StardewValley.Buildings; +using StardewValley.Locations; +using System; +using System.Collections.Generic; + +namespace UIInfoSuite.UIElements +{ + class ShowItemEffectRanges : IDisposable + { + private readonly List _effectiveArea = new List(); + private readonly ModConfig _modConfig; + private readonly IModEvents _events; + + private static readonly int[][] _junimoHutArray = new int[17][] + { + new int[17] { 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1 }, + new int[17] { 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1 }, + new int[17] { 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1 }, + new int[17] { 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1 }, + new int[17] { 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1 }, + new int[17] { 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1 }, + new int[17] { 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1 }, + new int[17] { 1, 1, 1, 1, 1, 1, 1, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1 }, + new int[17] { 1, 1, 1, 1, 1, 1, 1, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1 }, + new int[17] { 1, 1, 1, 1, 1, 1, 1, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1 }, + new int[17] { 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1 }, + new int[17] { 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1 }, + new int[17] { 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1 }, + new int[17] { 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1 }, + new int[17] { 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1 }, + new int[17] { 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1 }, + new int[17] { 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1 } + }; + + public ShowItemEffectRanges(ModConfig modConfig, IModEvents events) + { + this._modConfig = modConfig; + this._events = events; + } + + public void ToggleOption(bool showItemEffectRanges) + { + this._events.Display.Rendered -= this.OnRendered; + this._events.GameLoop.UpdateTicked -= this.OnUpdateTicked; + + if (showItemEffectRanges) + { + this._events.Display.Rendered += this.OnRendered; + this._events.GameLoop.UpdateTicked += this.OnUpdateTicked; + } + } + + public void Dispose() + { + this.ToggleOption(false); + } + + /// 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 (!e.IsMultipleOf(4)) + return; + + // check draw tile outlines + this._effectiveArea.Clear(); + if (Game1.activeClickableMenu == null && + !Game1.eventUp) + { + if (Game1.currentLocation is BuildableGameLocation buildableLocation) + { + Building building = buildableLocation.getBuildingAt(Game1.currentCursorTile); + + if (building is JunimoHut) + { + foreach (Building nextBuilding in buildableLocation.buildings) + { + if (nextBuilding is JunimoHut nextHut) + this.ParseConfigToHighlightedArea(_junimoHutArray, nextHut.tileX.Value + 1, nextHut.tileY.Value + 1); + } + } + } + + if (Game1.player.CurrentItem != null) + { + string name = Game1.player.CurrentItem.Name.ToLower(); + Item currentItem = Game1.player.CurrentItem; + List objects = null; + + int[][] arrayToUse = null; + + if (name.Contains("arecrow")) + { + arrayToUse = new int[17][]; + for (int i = 0; i < 17; ++i) + { + arrayToUse[i] = new int[17]; + for (int j = 0; j < 17; ++j) + { + arrayToUse[i][j] = (Math.Abs(i - 8) + Math.Abs(j - 8) <= 12) ? 1 : 0; + } + } + this.ParseConfigToHighlightedArea(arrayToUse, this.TileUnderMouseX, this.TileUnderMouseY); + objects = this.GetObjectsInLocationOfSimilarName("arecrow"); + if (objects != null) + { + foreach (StardewValley.Object next in objects) + { + this.ParseConfigToHighlightedArea(arrayToUse, (int)next.TileLocation.X, (int)next.TileLocation.Y); + } + } + + } + else if (name.Contains("sprinkler")) + { + if (name.Contains("iridium")) + { + arrayToUse = this._modConfig.IridiumSprinkler; + } + else if (name.Contains("quality")) + { + arrayToUse = this._modConfig.QualitySprinkler; + } + else if (name.Contains("prismatic")) + { + arrayToUse = this._modConfig.PrismaticSprinkler; + } + else + { + arrayToUse = this._modConfig.Sprinkler; + } + + if (arrayToUse != null) + this.ParseConfigToHighlightedArea(arrayToUse, this.TileUnderMouseX, this.TileUnderMouseY); + + objects = this.GetObjectsInLocationOfSimilarName("sprinkler"); + + if (objects != null) + { + foreach (StardewValley.Object next in objects) + { + string objectName = next.name.ToLower(); + if (objectName.Contains("iridium")) + { + arrayToUse = this._modConfig.IridiumSprinkler; + } + else if (objectName.Contains("quality")) + { + arrayToUse = this._modConfig.QualitySprinkler; + } + else if (name.Contains("prismatic")) + { + arrayToUse = this._modConfig.PrismaticSprinkler; + } + else + { + arrayToUse = this._modConfig.Sprinkler; + } + + if (arrayToUse != null) + this.ParseConfigToHighlightedArea(arrayToUse, (int)next.TileLocation.X, (int)next.TileLocation.Y); + } + } + } + else if (name.Contains("bee house")) + { + this.ParseConfigToHighlightedArea(this._modConfig.Beehouse, this.TileUnderMouseX, this.TileUnderMouseY); + } + + } + } + } + + /// Raised after the game draws to the sprite patch in a draw tick, just before the final sprite batch is rendered to the screen. + /// The event sender. + /// The event arguments. + private void OnRendered(object sender, RenderedEventArgs e) + { + // draw tile outlines + foreach (Point point in this._effectiveArea) + Game1.spriteBatch.Draw( + Game1.mouseCursors, + Game1.GlobalToLocal(new Vector2(point.X * Game1.tileSize, point.Y * Game1.tileSize)), + new Rectangle(194, 388, 16, 16), + Color.White * 0.7f, + 0.0f, + Vector2.Zero, + Game1.pixelZoom, + SpriteEffects.None, + 0.01f); + } + + private void ParseConfigToHighlightedArea(int[][] highlightedLocation, int xPos, int yPos) + { + int xOffset = highlightedLocation.Length / 2; + for (int i = 0; i < highlightedLocation.Length; ++i) + { + int yOffset = highlightedLocation[i].Length / 2; + for (int j = 0; j < highlightedLocation[i].Length; ++j) + { + if (highlightedLocation[i][j] == 1) + this._effectiveArea.Add(new Point(xPos + i - xOffset, yPos + j - yOffset)); + } + } + } + + private int TileUnderMouseX + { + get { return (Game1.getMouseX() + Game1.viewport.X) / Game1.tileSize; } + } + + private int TileUnderMouseY + { + get { return (Game1.getMouseY() + Game1.viewport.Y) / Game1.tileSize; } + } + + private List GetObjectsInLocationOfSimilarName(string nameContains) + { + List result = new List(); + + if (!string.IsNullOrEmpty(nameContains)) + { + nameContains = nameContains.ToLower(); + StardewValley.Network.OverlaidDictionary objects = Game1.currentLocation.Objects; + + foreach (StardewValley.Object nextThing in objects.Values) + { + if (nextThing.name.ToLower().Contains(nameContains)) + result.Add(nextThing); + } + } + return result; + } + } +} diff --git a/Mods/UI Info Suite/UIElements/ShowItemHoverInformation.cs b/Mods/UI Info Suite/UIElements/ShowItemHoverInformation.cs new file mode 100644 index 000000000..f9cb2cb37 --- /dev/null +++ b/Mods/UI Info Suite/UIElements/ShowItemHoverInformation.cs @@ -0,0 +1,937 @@ +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Graphics; +using UIInfoSuite.Extensions; +using StardewModdingAPI.Events; +using StardewValley; +using StardewValley.Locations; +using StardewValley.Menus; +using StardewValley.Objects; +using StardewValley.Tools; +using System; +using System.Collections.Generic; + +namespace UIInfoSuite.UIElements +{ + class ShowItemHoverInformation : IDisposable + { + private readonly Dictionary> _prunedRequiredBundles = new Dictionary>(); + private readonly ClickableTextureComponent _bundleIcon = + new ClickableTextureComponent( + "", + new Rectangle(0, 0, Game1.tileSize, Game1.tileSize), + "", + Game1.content.LoadString("Strings\\UI:GameMenu_JunimoNote_Hover", new object[0]), + Game1.mouseCursors, + new Rectangle(331, 374, 15, 14), + Game1.pixelZoom); + + private Item _hoverItem; + private CommunityCenter _communityCenter; + private Dictionary _bundleData; + private readonly IModEvents _events; + + public ShowItemHoverInformation(IModEvents events) + { + this._events = events; + } + + public void ToggleOption(bool showItemHoverInformation) + { + this._events.Player.InventoryChanged -= this.OnInventoryChanged; + this._events.Display.Rendered -= this.OnRendered; + this._events.Display.RenderedHud -= this.OnRenderedHud; + this._events.Display.Rendering -= this.OnRendering; + + if (showItemHoverInformation) + { + this._communityCenter = Game1.getLocationFromName("CommunityCenter") as CommunityCenter; + this._bundleData = Game1.content.Load>("Data\\Bundles"); + this.PopulateRequiredBundles(); + + this._events.Player.InventoryChanged += this.OnInventoryChanged; + this._events.Display.Rendered += this.OnRendered; + this._events.Display.RenderedHud += this.OnRenderedHud; + this._events.Display.Rendering += this.OnRendering; + } + } + + public void Dispose() + { + this.ToggleOption(false); + } + + /// Raised before the game draws anything to the screen in a draw tick, as soon as the sprite batch is opened. + /// The event sender. + /// The event arguments. + private void OnRendering(object sender, EventArgs e) + { + this._hoverItem = Tools.GetHoveredItem(); + } + + /// Raised after drawing the HUD (item toolbar, clock, etc) to the sprite batch, but before it's rendered to the screen. The vanilla HUD may be hidden at this point (e.g. because a menu is open). Content drawn to the sprite batch at this point will appear over the HUD. + /// The event sender. + /// The event arguments. + private void OnRenderedHud(object sender, EventArgs e) + { + if (Game1.activeClickableMenu == null) + { + this.DrawAdvancedTooltip(); + } + } + + /// Raised after the game draws to the sprite patch in a draw tick, just before the final sprite batch is rendered to the screen. + /// The event sender. + /// The event arguments. + private void OnRendered(object sender, EventArgs e) + { + if (Game1.activeClickableMenu != null) + { + this.DrawAdvancedTooltip(); + } + } + + /// Raised after items are added or removed to a player's inventory. NOTE: this event is currently only raised for the current player. + /// The event sender. + /// The event arguments. + private void OnInventoryChanged(object sender, InventoryChangedEventArgs e) + { + if (e.IsLocalPlayer) + this.PopulateRequiredBundles(); + } + + private void PopulateRequiredBundles() + { + this._prunedRequiredBundles.Clear(); + foreach (KeyValuePair bundle in this._bundleData) + { + string[] bundleRoomInfo = bundle.Key.Split('/'); + string bundleRoom = bundleRoomInfo[0]; + int roomNum; + + switch(bundleRoom) + { + case "Pantry": roomNum = 0; break; + case "Crafts Room": roomNum = 1; break; + case "Fish Tank": roomNum = 2; break; + case "Boiler Room": roomNum = 3; break; + case "Vault": roomNum = 4; break; + case "Bulletin Board": roomNum = 5; break; + default: continue; + } + + if (this._communityCenter.shouldNoteAppearInArea(roomNum)) + { + int bundleNumber = bundleRoomInfo[1].SafeParseInt32(); + string[] bundleInfo = bundle.Value.Split('/'); + string bundleName = bundleInfo[0]; + string[] bundleValues = bundleInfo[2].Split(' '); + List source = new List(); + + for (int i = 0; i < bundleValues.Length; i += 3) + { + int bundleValue = bundleValues[i].SafeParseInt32(); + if (bundleValue != -1 && + !this._communityCenter.bundles[bundleNumber][i / 3]) + { + source.Add(bundleValue); + } + } + + if (source.Count > 0) + this._prunedRequiredBundles.Add(bundleName, source); + } + } + } + + private void DrawAdvancedTooltip() + { + if (this._hoverItem != null && + this._hoverItem.Name != "Scythe" && + !(this._hoverItem is StardewValley.Tools.FishingRod)) + { + //String text = string.Empty; + //String extra = string.Empty; + int truePrice = Tools.GetTruePrice(this._hoverItem); + int itemPrice = 0; + int stackPrice = 0; + + if (truePrice > 0) + { + itemPrice = truePrice / 2; + //int width = (int)Game1.smallFont.MeasureString(" ").Length(); + //int numberOfSpaces = 46 / ((int)Game1.smallFont.MeasureString(" ").Length()) + 1; + //StringBuilder spaces = new StringBuilder(); + //for (int i = 0; i < numberOfSpaces; ++i) + //{ + // spaces.Append(" "); + //} + //text = "\n" + spaces.ToString() + (truePrice / 2); + if (this._hoverItem.getStack() > 1) + { + stackPrice = (itemPrice * this._hoverItem.getStack()); + //text += " (" + (truePrice / 2 * _hoverItem.getStack()) + ")"; + } + } + int cropPrice = 0; + + //bool flag = false; + if (this._hoverItem is StardewValley.Object && + (this._hoverItem as StardewValley.Object).Type == "Seeds" && + itemPrice > 0 && + (this._hoverItem.Name != "Mixed Seeds" || + this._hoverItem.Name != "Winter Seeds")) + { + Debris debris = new Debris(); + debris.init(new Crop(this._hoverItem.ParentSheetIndex, 0, 0).indexOfHarvest.Value, Game1.player.position, Game1.player.position); + StardewValley.Object itemObject = new StardewValley.Object(debris.chunkType.Value, 1); + //extra += " " + itemObject.Price; + cropPrice = itemObject.Price; + //flag = true; + } + + //String hoverTile = _hoverItem.DisplayName + text + extra; + //String description = _hoverItem.getDescription(); + //Vector2 vector2 = DrawTooltip(Game1.spriteBatch, _hoverItem.getDescription(), hoverTile, _hoverItem); + //vector2.X += 30; + //vector2.Y -= 10; + + string requiredBundleName = null; + + foreach (KeyValuePair> requiredBundle in this._prunedRequiredBundles) + { + if (requiredBundle.Value.Contains(this._hoverItem.ParentSheetIndex) && + !this._hoverItem.Name.Contains("arecrow") && + this._hoverItem.Name != "Chest" && + this._hoverItem.Name != "Recycling Machine" && + this._hoverItem.Name != "Solid Gold Lewis") + { + requiredBundleName = requiredBundle.Key; + break; + } + } + + int largestTextWidth = 0; + int stackTextWidth = (int)(Game1.smallFont.MeasureString(stackPrice.ToString()).Length()); + int itemTextWidth = (int)(Game1.smallFont.MeasureString(itemPrice.ToString()).Length()); + largestTextWidth = (stackTextWidth > itemTextWidth) ? stackTextWidth : itemTextWidth; + int windowWidth = Math.Max(largestTextWidth + 90, string.IsNullOrEmpty(requiredBundleName) ? 100 : 300); + + int windowHeight = 75; + + if (stackPrice > 0) + windowHeight += 40; + + if (cropPrice > 0) + windowHeight += 40; + + int windowY = Game1.getMouseY() + 20; + + windowY = Game1.viewport.Height - windowHeight - windowY < 0 ? Game1.viewport.Height - windowHeight : windowY; + + int windowX = Game1.getMouseX() - windowWidth - 25; + + if (Game1.getMouseX() > Game1.viewport.Width - 300) + { + windowX = Game1.viewport.Width - windowWidth - 350; + } + else if (windowX < 0) + { + windowX = Game1.getMouseX() + 350; + } + + Vector2 windowPos = new Vector2(windowX, windowY); + Vector2 currentDrawPos = new Vector2(windowPos.X + 30, windowPos.Y + 40); + + + if (itemPrice > 0) + { + + + IClickableMenu.drawTextureBox( + Game1.spriteBatch, + Game1.menuTexture, + new Rectangle(0, 256, 60, 60), + (int)windowPos.X, + (int)windowPos.Y, + windowWidth, + windowHeight, + Color.White); + + Game1.spriteBatch.Draw( + Game1.debrisSpriteSheet, + new Vector2(currentDrawPos.X, currentDrawPos.Y + 4), + Game1.getSourceRectForStandardTileSheet(Game1.debrisSpriteSheet, 8, 16, 16), + Color.White, + 0, + new Vector2(8, 8), + Game1.pixelZoom, + SpriteEffects.None, + 0.95f); + + Game1.spriteBatch.DrawString( + Game1.smallFont, + itemPrice.ToString(), + new Vector2(currentDrawPos.X + 22, currentDrawPos.Y - 8), + Game1.textShadowColor); + + Game1.spriteBatch.DrawString( + Game1.smallFont, + itemPrice.ToString(), + new Vector2(currentDrawPos.X + 20, currentDrawPos.Y - 10), + Game1.textColor); + + currentDrawPos.Y += 40; + + if (stackPrice > 0) + { + Game1.spriteBatch.Draw( + Game1.debrisSpriteSheet, + new Vector2(currentDrawPos.X, currentDrawPos.Y), + Game1.getSourceRectForStandardTileSheet(Game1.debrisSpriteSheet, 8, 16, 16), + Color.White, + 0, + new Vector2(8, 8), + Game1.pixelZoom, + SpriteEffects.None, + 0.95f); + + Game1.spriteBatch.Draw( + Game1.debrisSpriteSheet, + new Vector2(currentDrawPos.X, currentDrawPos.Y + 10), + Game1.getSourceRectForStandardTileSheet(Game1.debrisSpriteSheet, 8, 16, 16), + Color.White, + 0, + new Vector2(8, 8), + Game1.pixelZoom, + SpriteEffects.None, + 0.95f); + + Game1.spriteBatch.DrawString( + Game1.smallFont, + stackPrice.ToString(), + new Vector2(currentDrawPos.X + 22, currentDrawPos.Y - 8), + Game1.textShadowColor); + + Game1.spriteBatch.DrawString( + Game1.smallFont, + stackPrice.ToString(), + new Vector2(currentDrawPos.X + 20, currentDrawPos.Y - 10), + Game1.textColor); + + currentDrawPos.Y += 40; + } + + //Game1.spriteBatch.Draw( + // Game1.debrisSpriteSheet, + // new Vector2(vector2.X, vector2.Y), + // Game1.getSourceRectForStandardTileSheet(Game1.debrisSpriteSheet, 8, 16, 16), + // Color.White, + // 0, + // new Vector2(8, 8), + // Game1.pixelZoom, + // SpriteEffects.None, + // 0.95f); + + if (cropPrice > 0) + { + //Game1.spriteBatch.Draw( + // Game1.mouseCursors, new Vector2(vector2.X + Game1.dialogueFont.MeasureString(text).X - 10.0f, vector2.Y - 20f), + // new Rectangle(60, 428, 10, 10), + // Color.White, + // 0.0f, + // Vector2.Zero, + // Game1.pixelZoom, + // SpriteEffects.None, + // 0.85f); + + Game1.spriteBatch.Draw( + Game1.mouseCursors, + new Vector2(currentDrawPos.X - 15, currentDrawPos.Y - 10), + new Rectangle(60, 428, 10, 10), + Color.White, + 0.0f, + Vector2.Zero, + Game1.pixelZoom * 0.75f, + SpriteEffects.None, + 0.85f); + + Game1.spriteBatch.DrawString( + Game1.smallFont, + cropPrice.ToString(), + new Vector2(currentDrawPos.X + 22, currentDrawPos.Y - 8), + Game1.textShadowColor); + + Game1.spriteBatch.DrawString( + Game1.smallFont, + cropPrice.ToString(), + new Vector2(currentDrawPos.X + 20, currentDrawPos.Y - 10), + Game1.textColor); + } + } + + if (!string.IsNullOrEmpty(requiredBundleName)) + { + int num1 = (int)windowPos.X - 30; + int num2 = (int)windowPos.Y - 10; + int num3 = num1 + 52; + int y3 = num2 - 2; + int num4 = 288; + int height = 36; + int num5 = 36; + int width = num4 / num5; + int num6 = 6; + + for (int i = 0; i < 36; ++i) + { + float num7 = (float)(i >= num6 ? 0.92 - (i - num6) * (1.0 / (num5 - num6)) : 0.92f); + Game1.spriteBatch.Draw( + Game1.staminaRect, + new Rectangle(num3 + width * i, y3, width, height), + Color.Crimson * num7); + } + + Game1.spriteBatch.DrawString( + Game1.dialogueFont, + requiredBundleName, + new Vector2(num1 + 72, num2), + Color.White); + + this._bundleIcon.bounds.X = num1 + 16; + this._bundleIcon.bounds.Y = num2; + this._bundleIcon.scale = 3; + this._bundleIcon.draw(Game1.spriteBatch); + } + //RestoreMenuState(); + } + } + + private void RestoreMenuState() + { + if (Game1.activeClickableMenu is ItemGrabMenu) + { + (Game1.activeClickableMenu as MenuWithInventory).hoveredItem = this._hoverItem; + } + } + + + private static Vector2 DrawTooltip(SpriteBatch batch, string hoverText, string hoverTitle, Item hoveredItem) + { + bool flag = hoveredItem != null && + hoveredItem is StardewValley.Object && + (hoveredItem as StardewValley.Object).Edibility != -300; + + int healAmmountToDisplay = flag ? (hoveredItem as StardewValley.Object).Edibility : -1; + string[] buffIconsToDisplay = null; + if (flag) + { + string objectInfo = Game1.objectInformation[(hoveredItem as StardewValley.Object).ParentSheetIndex]; + if (Game1.objectInformation[(hoveredItem as StardewValley.Object).ParentSheetIndex].Split('/').Length >= 7) + { + buffIconsToDisplay = Game1.objectInformation[(hoveredItem as StardewValley.Object).ParentSheetIndex].Split('/')[6].Split('^'); + } + } + + return DrawHoverText(batch, hoverText, Game1.smallFont, -1, -1, -1, hoverTitle, -1, buffIconsToDisplay, hoveredItem); + } + + private static Vector2 DrawHoverText(SpriteBatch batch, string text, SpriteFont font, int xOffset = 0, int yOffset = 0, int moneyAmountToDisplayAtBottom = -1, string boldTitleText = null, int healAmountToDisplay = -1, string[] buffIconsToDisplay = null, Item hoveredItem = null) + { + Vector2 result = Vector2.Zero; + + if (string.IsNullOrEmpty(text)) + { + result = Vector2.Zero; + } + else + { + if (string.IsNullOrEmpty(boldTitleText)) + boldTitleText = null; + + int num1 = 20; + int infoWindowWidth = (int)Math.Max(healAmountToDisplay != -1 ? font.MeasureString(healAmountToDisplay.ToString() + "+ Energy" + (Game1.tileSize / 2)).X : 0, Math.Max(font.MeasureString(text).X, boldTitleText != null ? Game1.dialogueFont.MeasureString(boldTitleText).X : 0)) + Game1.tileSize / 2; + int extraInfoBackgroundHeight = (int)Math.Max( + num1 * 3, + font.MeasureString(text).Y + Game1.tileSize / 2 + (moneyAmountToDisplayAtBottom > -1 ? (font.MeasureString(string.Concat(moneyAmountToDisplayAtBottom)).Y + 4.0) : 0) + (boldTitleText != null ? Game1.dialogueFont.MeasureString(boldTitleText).Y + (Game1.tileSize / 4) : 0) + (healAmountToDisplay != -1 ? 38 : 0)); + if (buffIconsToDisplay != null) + { + for (int i = 0; i < buffIconsToDisplay.Length; ++i) + { + if (!buffIconsToDisplay[i].Equals("0")) + extraInfoBackgroundHeight += 34; + } + extraInfoBackgroundHeight += 4; + } + + string categoryName = null; + if (hoveredItem != null) + { + extraInfoBackgroundHeight += (Game1.tileSize + 4) * hoveredItem.attachmentSlots(); + categoryName = hoveredItem.getCategoryName(); + if (categoryName.Length > 0) + extraInfoBackgroundHeight += (int)font.MeasureString("T").Y; + + if (hoveredItem is MeleeWeapon) + { + extraInfoBackgroundHeight = (int)(Math.Max( + num1 * 3, + (boldTitleText != null ? + Game1.dialogueFont.MeasureString(boldTitleText).Y + (Game1.tileSize / 4) + : 0) + + Game1.tileSize / 2) + + font.MeasureString("T").Y + + (moneyAmountToDisplayAtBottom > -1 ? + font.MeasureString(string.Concat(moneyAmountToDisplayAtBottom)).Y + 4.0 + : 0) + + (hoveredItem as MeleeWeapon).getNumberOfDescriptionCategories() * + Game1.pixelZoom * 12 + + font.MeasureString(Game1.parseText((hoveredItem as MeleeWeapon).Description, + Game1.smallFont, + Game1.tileSize * 4 + + Game1.tileSize / 4)).Y); + + infoWindowWidth = (int)Math.Max(infoWindowWidth, font.MeasureString("99-99 Damage").X + (15 * Game1.pixelZoom) + (Game1.tileSize / 2)); + } + else if (hoveredItem is Boots) + { + Boots hoveredBoots = hoveredItem as Boots; + extraInfoBackgroundHeight = extraInfoBackgroundHeight - (int)font.MeasureString(text).Y + (int)(hoveredBoots.getNumberOfDescriptionCategories() * Game1.pixelZoom * 12 + font.MeasureString(Game1.parseText(hoveredBoots.description, Game1.smallFont, Game1.tileSize * 4 + Game1.tileSize / 4)).Y); + infoWindowWidth = (int)Math.Max(infoWindowWidth, font.MeasureString("99-99 Damage").X + (15 * Game1.pixelZoom) + (Game1.tileSize / 2)); + } + else if (hoveredItem is StardewValley.Object && + (hoveredItem as StardewValley.Object).Edibility != -300) + { + StardewValley.Object hoveredObject = hoveredItem as StardewValley.Object; + healAmountToDisplay = (int)Math.Ceiling(hoveredObject.Edibility * 2.5) + hoveredObject.Quality * hoveredObject.Edibility; + extraInfoBackgroundHeight += (Game1.tileSize / 2 + Game1.pixelZoom * 2) * (healAmountToDisplay > 0 ? 2 : 1); + } + } + + //Crafting ingredients were never used + + int xPos = Game1.getOldMouseX() + Game1.tileSize / 2 + xOffset; + int yPos = Game1.getOldMouseY() + Game1.tileSize / 2 + yOffset; + + if (xPos + infoWindowWidth > Game1.viewport.Width) + { + xPos = Game1.viewport.Width - infoWindowWidth; + yPos += Game1.tileSize / 4; + } + + if (yPos + extraInfoBackgroundHeight > Game1.viewport.Height) + { + xPos += Game1.tileSize / 4; + yPos = Game1.viewport.Height - extraInfoBackgroundHeight; + } + int hoveredItemHeight = (int)(hoveredItem == null || categoryName.Length <= 0 ? 0 : font.MeasureString("asd").Y); + + IClickableMenu.drawTextureBox( + batch, + Game1.menuTexture, + new Rectangle(0, 256, 60, 60), + xPos, + yPos, + infoWindowWidth, + extraInfoBackgroundHeight, + Color.White); + + if (boldTitleText != null) + { + IClickableMenu.drawTextureBox( + batch, + Game1.menuTexture, + new Rectangle(0, 256, 60, 60), + xPos, + yPos, + infoWindowWidth, + (int)(Game1.dialogueFont.MeasureString(boldTitleText).Y + Game1.tileSize / 2 + hoveredItemHeight - Game1.pixelZoom), + Color.White, + 1, + false); + + batch.Draw( + Game1.menuTexture, + new Rectangle(xPos + Game1.pixelZoom * 3, yPos + (int)Game1.dialogueFont.MeasureString(boldTitleText).Y + Game1.tileSize / 2 + hoveredItemHeight - Game1.pixelZoom, infoWindowWidth - Game1.pixelZoom * 6, Game1.pixelZoom), + new Rectangle(44, 300, 4, 4), + Color.White); + + batch.DrawString( + Game1.dialogueFont, + boldTitleText, + new Vector2(xPos + Game1.tileSize / 4, yPos + Game1.tileSize / 4 + 4) + new Vector2(2, 2), + Game1.textShadowColor); + + batch.DrawString( + Game1.dialogueFont, + boldTitleText, + new Vector2(xPos + Game1.tileSize / 4, yPos + Game1.tileSize / 4 + 4) + new Vector2(0, 2), + Game1.textShadowColor); + + batch.DrawString( + Game1.dialogueFont, + boldTitleText, + new Vector2(xPos + Game1.tileSize / 4, yPos + Game1.tileSize / 4 + 4), + Game1.textColor); + + yPos += (int)Game1.dialogueFont.MeasureString(boldTitleText).Y; + } + + int yPositionToReturn = yPos; + if (hoveredItem != null && categoryName.Length > 0) + { + yPos -= 4; + Utility.drawTextWithShadow( + batch, + categoryName, + font, + new Vector2(xPos + Game1.tileSize / 4, yPos + Game1.tileSize / 4 + 4), + hoveredItem.getCategoryColor(), + 1, + -1, + 2, + 2); + yPos += (int)(font.MeasureString("T").Y + (boldTitleText != null ? Game1.tileSize / 4 : 0) + Game1.pixelZoom); + } + else + { + yPos += (boldTitleText != null ? Game1.tileSize / 4 : 0); + } + + if (hoveredItem is Boots) + { + Boots boots = hoveredItem as Boots; + Utility.drawTextWithShadow( + batch, + Game1.parseText( + boots.description, + Game1.smallFont, + Game1.tileSize * 4 + Game1.tileSize / 4), + font, + new Vector2(xPos + Game1.tileSize / 4, yPos + Game1.tileSize / 4 + 4), + Game1.textColor); + + yPos += (int)font.MeasureString( + Game1.parseText( + boots.description, + Game1.smallFont, + Game1.tileSize * 4 + Game1.tileSize / 4)).Y; + + if (boots.defenseBonus.Value > 0) + { + Utility.drawWithShadow( + batch, + Game1.mouseCursors, + new Vector2(xPos + Game1.tileSize / 4 + Game1.pixelZoom, yPos + Game1.tileSize / 4 + 4), + new Rectangle(110, 428, 10, 10), + Color.White, + 0, + Vector2.Zero, + Game1.pixelZoom); + + Utility.drawTextWithShadow( + batch, + Game1.content.LoadString("Strings\\UI:ItemHover_DefenseBonus", new object[] { boots.defenseBonus.Value }), + font, + new Vector2(xPos + Game1.tileSize / 4 + Game1.pixelZoom * 13, yPos + Game1.tileSize / 4 + Game1.pixelZoom * 3), + Game1.textColor * 0.9f); + yPos += (int)Math.Max(font.MeasureString("TT").Y, 12 * Game1.pixelZoom); + } + + if (boots.immunityBonus.Value > 0) + { + Utility.drawWithShadow( + batch, + Game1.mouseCursors, + new Vector2(xPos + Game1.tileSize / 4 + Game1.pixelZoom, yPos + Game1.tileSize / 4 + 4), + new Rectangle(150, 428, 10, 10), + Color.White, + 0, + Vector2.Zero, + Game1.pixelZoom); + Utility.drawTextWithShadow( + batch, + Game1.content.LoadString("Strings\\UI:ItemHover_ImmunityBonus", new object[] { boots.immunityBonus.Value }), + font, + new Vector2(xPos + Game1.tileSize / 4 + Game1.pixelZoom * 13, yPos + Game1.tileSize / 4 + Game1.pixelZoom * 3), + Game1.textColor * 0.9f); + + yPos += (int)Math.Max(font.MeasureString("TT").Y, 12 * Game1.pixelZoom); + } + } + else if (hoveredItem is MeleeWeapon) + { + MeleeWeapon meleeWeapon = hoveredItem as MeleeWeapon; + Utility.drawTextWithShadow( + batch, + Game1.parseText(meleeWeapon.Description, Game1.smallFont, Game1.tileSize * 4 + Game1.tileSize / 4), + font, + new Vector2(xPos + Game1.tileSize / 4, yPos + Game1.tileSize / 4 + 4), + Game1.textColor); + yPos += (int)font.MeasureString(Game1.parseText(meleeWeapon.Description, Game1.smallFont, Game1.tileSize * 4 + Game1.tileSize / 4)).Y; + + if ((meleeWeapon as Tool).IndexOfMenuItemView != 47) + { + Utility.drawWithShadow( + batch, + Game1.mouseCursors, + new Vector2(xPos + Game1.tileSize / 4 + Game1.pixelZoom, yPos + Game1.tileSize / 4 + 4), + new Rectangle(120, 428, 10, 10), + Color.White, + 0, + Vector2.Zero, + Game1.pixelZoom); + + Utility.drawTextWithShadow( + batch, + Game1.content.LoadString("Strings\\UI:ItemHover_Damage", new object[] { meleeWeapon.minDamage.Value, meleeWeapon.maxDamage.Value }), + font, + new Vector2(xPos + Game1.tileSize / 4 + Game1.pixelZoom * 13, yPos + Game1.tileSize / 4 + Game1.pixelZoom * 3), + Game1.textColor * 0.9f); + yPos += (int)Math.Max(font.MeasureString("TT").Y, 12 * Game1.pixelZoom); + + if (meleeWeapon.speed.Value != (meleeWeapon.type.Value == 2 ? -8 : 0)) + { + Utility.drawWithShadow( + batch, + Game1.mouseCursors, + new Vector2(xPos + Game1.tileSize / 4 + Game1.pixelZoom, yPos + Game1.tileSize / 4 + 4), + new Rectangle(130, 428, 10, 10), + Color.White, + 0, + Vector2.Zero, + Game1.pixelZoom, + false, + 1); + bool flag = meleeWeapon.type.Value == 2 ? meleeWeapon.speed.Value < -8 : meleeWeapon.speed.Value < 0; + string speedText = ((meleeWeapon.type.Value == 2 ? meleeWeapon.speed.Value + 8 : meleeWeapon.speed.Value) / 2).ToString(); + Utility.drawTextWithShadow( + batch, + Game1.content.LoadString("Strings\\UI:ItemHover_Speed", new object[] { (meleeWeapon.speed.Value > 0 ? "+" : "") + speedText }), + font, + new Vector2(xPos + Game1.tileSize / 4 + Game1.pixelZoom * 13, yPos + Game1.tileSize / 4 + Game1.pixelZoom * 3), + flag ? Color.DarkRed : Game1.textColor * 0.9f); + yPos += (int)Math.Max(font.MeasureString("TT").Y, 12 * Game1.pixelZoom); + } + + if (meleeWeapon.addedDefense.Value > 0) + { + Utility.drawWithShadow( + batch, + Game1.mouseCursors, + new Vector2(xPos + Game1.tileSize / 4 + Game1.pixelZoom, yPos + Game1.tileSize / 4 + 4), + new Rectangle(110, 428, 10, 10), + Color.White, + 0.0f, + Vector2.Zero, + Game1.pixelZoom, + false, + 1f); + Utility.drawTextWithShadow( + batch, + Game1.content.LoadString("Strings\\UI:ItemHover_DefenseBonus", new object[] { meleeWeapon.addedDefense.Value }), + font, + new Vector2(xPos + Game1.tileSize / 4 + Game1.pixelZoom * 13, yPos + Game1.tileSize / 4 + Game1.pixelZoom * 3), + Game1.textColor * 0.9f); + yPos += (int)Math.Max(font.MeasureString("TT").Y, 12 * Game1.pixelZoom); + } + + if (meleeWeapon.critChance.Value / 0.02 >= 2.0) + { + Utility.drawWithShadow( + batch, + Game1.mouseCursors, + new Vector2(xPos + Game1.tileSize / 4 + Game1.pixelZoom, yPos + Game1.tileSize / 4 + 4), + new Rectangle(40, 428, 10, 10), + Color.White, + 0.0f, + Vector2.Zero, + Game1.pixelZoom, + false, + 1f); + Utility.drawTextWithShadow( + batch, Game1.content.LoadString("Strings\\UI:ItemHover_CritChanceBonus", new object[] { meleeWeapon.critChance.Value / 0.02 }), + font, + new Vector2(xPos + Game1.tileSize / 4 + Game1.pixelZoom * 13, yPos + Game1.tileSize / 4 + Game1.pixelZoom * 3), + Game1.textColor * 0.9f); + yPos += (int)Math.Max(font.MeasureString("TT").Y, 12 * Game1.pixelZoom); + } + + if (((double)meleeWeapon.critMultiplier.Value - 3.0) / 0.02 >= 1.0) + { + Utility.drawWithShadow( + batch, + Game1.mouseCursors, + new Vector2(xPos + Game1.tileSize / 4, yPos + Game1.tileSize / 4 + 4), + new Rectangle(160, 428, 10, 10), + Color.White, + 0.0f, + Vector2.Zero, + Game1.pixelZoom, + false, + 1f); + + Utility.drawTextWithShadow( + batch, Game1.content.LoadString("Strings\\UI:ItemHover_CritPowerBonus", new object[] { (int)((meleeWeapon.critMultiplier.Value - 3.0) / 0.02) }), + font, + new Vector2(xPos + Game1.tileSize / 4 + Game1.pixelZoom * 11, yPos + Game1.tileSize / 4 + Game1.pixelZoom * 3), + Game1.textColor * 0.9f); + yPos += (int)Math.Max(font.MeasureString("TT").Y, 12 * Game1.pixelZoom); + } + + if (meleeWeapon.knockback.Value != meleeWeapon.defaultKnockBackForThisType(meleeWeapon.type.Value)) + { + Utility.drawWithShadow( + batch, + Game1.mouseCursors, + new Vector2(xPos + Game1.tileSize / 4 + Game1.pixelZoom, yPos + Game1.tileSize / 4 + 4), + new Rectangle(70, 428, 10, 10), + Color.White, + 0.0f, + Vector2.Zero, Game1.pixelZoom, + false, + 1f); + Utility.drawTextWithShadow( + batch, + Game1.content.LoadString( + "Strings\\UI:ItemHover_Weight", + new object[] { meleeWeapon.knockback.Value > meleeWeapon.defaultKnockBackForThisType(meleeWeapon.type.Value) ? "+" : "" + Math.Ceiling(Math.Abs(meleeWeapon.knockback.Value - meleeWeapon.defaultKnockBackForThisType(meleeWeapon.type.Value) * 10.0)) }), + font, + new Vector2(xPos + Game1.tileSize / 4 + Game1.pixelZoom * 13, yPos + Game1.tileSize / 4 + Game1.pixelZoom * 3), + Game1.textColor * 0.9f); + yPos += (int)Math.Max(font.MeasureString("TT").Y, 12 * Game1.pixelZoom); + } + } + + } + else if (text.Length > 1) + { + int textXPos = xPos + Game1.tileSize / 4; + int textYPos = yPos + Game1.tileSize / 4 + 4; + batch.DrawString( + font, + text, + new Vector2(textXPos, textYPos) + new Vector2(2, 2), + Game1.textShadowColor); + + batch.DrawString( + font, + text, + new Vector2(textXPos, textYPos) + new Vector2(0, 2), + Game1.textShadowColor); + + batch.DrawString( + font, + text, + new Vector2(textXPos, textYPos) + new Vector2(2, 0), + Game1.textShadowColor); + + batch.DrawString( + font, + text, + new Vector2(textXPos, textYPos), + Game1.textColor * 0.9f); + + yPos += (int)font.MeasureString(text).Y + 4; + } + + if (healAmountToDisplay != -1) + { + Utility.drawWithShadow( + batch, + Game1.mouseCursors, + new Vector2(xPos + Game1.tileSize / 4 + Game1.pixelZoom, yPos + Game1.tileSize / 4), + new Rectangle(healAmountToDisplay < 0 ? 140 : 0, 428, 10, 10), + Color.White, + 0.0f, + Vector2.Zero, + 3f, + false, + 0.95f); + Utility.drawTextWithShadow( + batch, Game1.content.LoadString("Strings\\UI:ItemHover_Energy", new object[] { ((healAmountToDisplay > 0 ? "+" : "") + healAmountToDisplay) }), + font, + new Vector2(xPos + Game1.tileSize / 4 + 34 + Game1.pixelZoom, yPos + Game1.tileSize / 4 + 8), + Game1.textColor); + yPos += 34; + + if (healAmountToDisplay > 0) + { + Utility.drawWithShadow( + batch, + Game1.mouseCursors, + new Vector2(xPos + Game1.tileSize / 4 + Game1.pixelZoom, yPos + Game1.tileSize / 4), + new Rectangle(0, 438, 10, 10), + Color.White, + 0, + Vector2.Zero, + 3, + false, + 0.95f); + + Utility.drawTextWithShadow( + batch, + Game1.content.LoadString( + "Strings\\UI:ItemHover_Health", + new object[] { "+" + (healAmountToDisplay * 0.4) }), + font, + new Vector2(xPos + Game1.tileSize / 4 + 34 + Game1.pixelZoom, yPos + Game1.tileSize / 4 + 8), + Game1.textColor); + + yPos += 34; + } + } + + if (buffIconsToDisplay != null) + { + for (int i = 0; i < buffIconsToDisplay.Length; ++i) + { + string buffIcon = buffIconsToDisplay[i]; + if (buffIcon != "0") + { + Utility.drawWithShadow( + batch, + Game1.mouseCursors, + new Vector2(xPos + Game1.tileSize / 4 + Game1.pixelZoom, yPos + Game1.tileSize / 4), + new Rectangle(10 + i * 10, 428, 10, 10), + Color.White, + 0, Vector2.Zero, + 3, + false, + 0.95f); + + string textToDraw = (buffIcon.SafeParseInt32() > 0 ? "+" : string.Empty) + buffIcon + " "; + + //if (i <= 10) + // textToDraw = Game1.content.LoadString("Strings\\UI:ItemHover_Buff" + i, new object[] { textToDraw }); + + Utility.drawTextWithShadow( + batch, + textToDraw, + font, + new Vector2(xPos + Game1.tileSize / 4 + 34 + Game1.pixelZoom, yPos + Game1.tileSize / 4 + 8), + Game1.textColor); + yPos += 34; + } + } + } + + if (hoveredItem != null && + hoveredItem.attachmentSlots() > 0) + { + yPos += 16; + hoveredItem.drawAttachments(batch, xPos + Game1.tileSize / 4, yPos); + if (moneyAmountToDisplayAtBottom > -1) + yPos += Game1.tileSize * hoveredItem.attachmentSlots(); + } + + if (moneyAmountToDisplayAtBottom > -1) + { + + } + + result = new Vector2(xPos, yPositionToReturn); + } + + return result; + } + } +} diff --git a/Mods/UI Info Suite/UIElements/ShowQueenOfSauceIcon.cs b/Mods/UI Info Suite/UIElements/ShowQueenOfSauceIcon.cs new file mode 100644 index 000000000..ed24118f2 --- /dev/null +++ b/Mods/UI Info Suite/UIElements/ShowQueenOfSauceIcon.cs @@ -0,0 +1,254 @@ +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Graphics; +using StardewModdingAPI; +using StardewModdingAPI.Events; +using StardewValley; +using StardewValley.Menus; +using StardewValley.Objects; +using System; +using System.Collections.Generic; +using System.Reflection; +using UIInfoSuite.Extensions; + +namespace UIInfoSuite.UIElements +{ + class ShowQueenOfSauceIcon : IDisposable + { + private Dictionary _recipesByDescription = new Dictionary(); + private Dictionary _recipes = new Dictionary(); + private string _todaysRecipe; + private NPC _gus; + private bool _drawQueenOfSauceIcon = false; + private bool _drawDishOfDayIcon = false; + private ClickableTextureComponent _queenOfSauceIcon; + private readonly IModHelper _helper; + + public void ToggleOption(bool showQueenOfSauceIcon) + { + this._helper.Events.Display.RenderingHud -= this.OnRenderingHud; + this._helper.Events.Display.RenderedHud -= this.OnRenderedHud; + this._helper.Events.GameLoop.DayStarted -= this.OnDayStarted; + this._helper.Events.GameLoop.UpdateTicked -= this.OnUpdateTicked; + + if (showQueenOfSauceIcon) + { + this.LoadRecipes(); + this.CheckForNewRecipe(); + this._helper.Events.GameLoop.DayStarted += this.OnDayStarted; + this._helper.Events.Display.RenderingHud += this.OnRenderingHud; + this._helper.Events.Display.RenderedHud += this.OnRenderedHud; + this._helper.Events.GameLoop.UpdateTicked += this.OnUpdateTicked; + } + } + + /// Raised after the game state is updated (≈60 times per second). + /// The event sender. + /// The event arguments. + private void OnUpdateTicked(object sender, UpdateTickedEventArgs e) + { + // check if learned recipe + if (e.IsOneSecond && this._drawQueenOfSauceIcon && Game1.player.knowsRecipe(this._todaysRecipe)) + this._drawQueenOfSauceIcon = false; + } + + public ShowQueenOfSauceIcon(IModHelper helper) + { + this._helper = helper; + } + + private void LoadRecipes() + { + if (this._recipes.Count == 0) + { + this._recipes = Game1.content.Load>("Data\\TV\\CookingChannel"); + + foreach (KeyValuePair next in this._recipes) + { + string[] values = next.Value.Split('/'); + + if (values.Length > 1) + { + this._recipesByDescription[values[1]] = values[0]; + } + } + } + } + + private void FindGus() + { + foreach (GameLocation location in Game1.locations) + { + foreach (NPC npc in location.characters) + { + if (npc.Name == "Gus") + { + this._gus = npc; + break; + } + } + if (this._gus != null) + break; + } + } + + private string[] GetTodaysRecipe() + { + string[] array1 = new string[2]; + int recipeNum = (int)(Game1.stats.DaysPlayed % 224 / 7); + //var recipes = Game1.content.Load>("Data\\TV\\CookingChannel"); + + string recipeValue = this._recipes.SafeGet(recipeNum.ToString()); + string[] splitValues = null; + string key = null; + bool checkCraftingRecipes = true; + + if (string.IsNullOrEmpty(recipeValue)) + { + recipeValue = this._recipes["1"]; + checkCraftingRecipes = false; + } + splitValues = recipeValue.Split('/'); + key = splitValues[0]; + + ///Game code sets this to splitValues[1] to display the language specific + ///recipe name. We are skipping a bunch of their steps to just get the + ///english name needed to tell if the player knows the recipe or not + array1[0] = key; + if (checkCraftingRecipes) + { + string craftingRecipesValue = CraftingRecipe.cookingRecipes.SafeGet(key); + if (!string.IsNullOrEmpty(craftingRecipesValue)) + splitValues = craftingRecipesValue.Split('/'); + } + + string languageRecipeName = (this._helper.Content.CurrentLocaleConstant == LocalizedContentManager.LanguageCode.en) ? + key : splitValues[splitValues.Length - 1]; + + array1[1] = languageRecipeName; + + //String str = null; + //if (!Game1.player.cookingRecipes.ContainsKey(key)) + //{ + // str = Game1.content.LoadString(@"Strings\StringsFromCSFiles:TV.cs.13153", languageRecipeName); + //} + //else + //{ + // str = Game1.content.LoadString(@"Strings\StringsFromCSFiles:TV.cs.13151", languageRecipeName); + //} + //array1[1] = str; + + return array1; + } + + /// Raised before drawing the HUD (item toolbar, clock, etc) to the screen. The vanilla HUD may be hidden at this point (e.g. because a menu is open). + /// The event sender. + /// The event arguments. + private void OnRenderingHud(object sender, RenderingHudEventArgs e) + { + // draw icon + if (!Game1.eventUp && Game1.activeClickableMenu == null) + { + if (this._drawQueenOfSauceIcon) + { + Point iconPosition = IconHandler.Handler.GetNewIconPosition(); + + this._queenOfSauceIcon = new ClickableTextureComponent( + new Rectangle(iconPosition.X, iconPosition.Y, 10 * 6, 10 * 6), + Game1.mouseCursors, + new Rectangle(609, 361, 28, 28), + 1.95f); + this._queenOfSauceIcon.draw(Game1.spriteBatch); + } + + if (this._drawDishOfDayIcon) + { + Point iconLocation = IconHandler.Handler.GetNewIconPosition(); + float scale = 4.35f; + + Game1.spriteBatch.Draw( + Game1.objectSpriteSheet, + new Vector2(iconLocation.X, iconLocation.Y), + new Rectangle(306, 291, 14, 14), + Color.White, + 0, + Vector2.Zero, + scale, + SpriteEffects.None, + 1f); + + ClickableTextureComponent texture = + new ClickableTextureComponent( + this._gus.Name, + new Rectangle( + iconLocation.X - 7, + iconLocation.Y - 2, + (int)(16.0 * scale), + (int)(16.0 * scale)), + null, + this._gus.Name, + this._gus.Sprite.Texture, + this._gus.GetHeadShot(), + 3f); + + texture.draw(Game1.spriteBatch); + + if (texture.containsPoint((int)(Game1.getMouseX() * Game1.options.zoomLevel), (int)(Game1.getMouseY() * Game1.options.zoomLevel))) + { + IClickableMenu.drawHoverText( + Game1.spriteBatch, + "Gus is selling " + Game1.dishOfTheDay.DisplayName + " recipe today!", + Game1.dialogueFont); + } + } + } + } + + /// Raised after drawing the HUD (item toolbar, clock, etc) to the sprite batch, but before it's rendered to the screen. + /// The event sender. + /// The event arguments. + private void OnRenderedHud(object sender, RenderedHudEventArgs e) + { + // draw hover text + if (this._drawQueenOfSauceIcon && + this._queenOfSauceIcon.containsPoint((int)(Game1.getMouseX() * Game1.options.zoomLevel), (int)(Game1.getMouseY() * Game1.options.zoomLevel))) + { + IClickableMenu.drawHoverText( + Game1.spriteBatch, + this._helper.SafeGetString( + LanguageKeys.TodaysRecipe) + this._todaysRecipe, + Game1.dialogueFont); + } + } + + public void Dispose() + { + this.ToggleOption(false); + } + + /// 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.CheckForNewRecipe(); + } + + private void CheckForNewRecipe() + { + TV tv = new TV(); + int numRecipesKnown = Game1.player.cookingRecipes.Count(); + string[] recipes = typeof(TV).GetMethod("getWeeklyRecipe", BindingFlags.Instance | BindingFlags.NonPublic).Invoke(tv, null) as string[]; + //String[] recipe = GetTodaysRecipe(); + //_todaysRecipe = recipe[1]; + this._todaysRecipe = this._recipesByDescription.SafeGet(recipes[0]); + + if (Game1.player.cookingRecipes.Count() > numRecipesKnown) + Game1.player.cookingRecipes.Remove(this._todaysRecipe); + + this._drawQueenOfSauceIcon = (Game1.dayOfMonth % 7 == 0 || (Game1.dayOfMonth - 3) % 7 == 0) && + Game1.stats.DaysPlayed > 5 && + !Game1.player.knowsRecipe(this._todaysRecipe); + //_drawDishOfDayIcon = !Game1.player.knowsRecipe(Game1.dishOfTheDay.Name); + } + } +} diff --git a/Mods/UI Info Suite/UIElements/ShowToolUpgradeStatus.cs b/Mods/UI Info Suite/UIElements/ShowToolUpgradeStatus.cs new file mode 100644 index 000000000..98a359acb --- /dev/null +++ b/Mods/UI Info Suite/UIElements/ShowToolUpgradeStatus.cs @@ -0,0 +1,156 @@ +using Microsoft.Xna.Framework; +using StardewModdingAPI; +using StardewModdingAPI.Events; +using StardewValley; +using StardewValley.Menus; +using System; +using UIInfoSuite.Extensions; + +namespace UIInfoSuite.UIElements +{ + class ShowToolUpgradeStatus : IDisposable + { + private readonly IModHelper _helper; + private Rectangle _toolTexturePosition; + private string _hoverText; + private Tool _toolBeingUpgraded; + private ClickableTextureComponent _toolUpgradeIcon; + + public ShowToolUpgradeStatus(IModHelper helper) + { + this._helper = helper; + } + + public void ToggleOption(bool showToolUpgradeStatus) + { + this._helper.Events.Display.RenderingHud -= this.OnRenderingHud; + this._helper.Events.Display.RenderedHud -= this.OnRenderedHud; + this._helper.Events.GameLoop.DayStarted -= this.OnDayStarted; + this._helper.Events.GameLoop.UpdateTicked -= this.OnUpdateTicked; + + if (showToolUpgradeStatus) + { + this.UpdateToolInfo(); + this._helper.Events.Display.RenderingHud += this.OnRenderingHud; + this._helper.Events.Display.RenderedHud += this.OnRenderedHud; + this._helper.Events.GameLoop.DayStarted += this.OnDayStarted; + this._helper.Events.GameLoop.UpdateTicked += this.OnUpdateTicked; + } + } + + /// 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 (e.IsOneSecond && this._toolBeingUpgraded != Game1.player.toolBeingUpgraded.Value) + this.UpdateToolInfo(); + } + + /// 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.UpdateToolInfo(); + } + + private void UpdateToolInfo() + { + // + if (Game1.player.toolBeingUpgraded.Value != null) + { + this._toolBeingUpgraded = Game1.player.toolBeingUpgraded.Value; + this._toolTexturePosition = new Rectangle(); + + if (this._toolBeingUpgraded is StardewValley.Tools.WateringCan) + { + this._toolTexturePosition.X = 32; + this._toolTexturePosition.Y = 228; + this._toolTexturePosition.Width = 16; + this._toolTexturePosition.Height = 11; + } + else + { + this._toolTexturePosition.Width = 16; + this._toolTexturePosition.Height = 16; + this._toolTexturePosition.X = 81; + this._toolTexturePosition.Y = 31; + + if (!(this._toolBeingUpgraded is StardewValley.Tools.Hoe)) + { + this._toolTexturePosition.Y += 64; + + if (!(this._toolBeingUpgraded is StardewValley.Tools.Pickaxe)) + { + this._toolTexturePosition.Y += 64; + } + } + } + + this._toolTexturePosition.X += (111 * this._toolBeingUpgraded.UpgradeLevel); + + if (this._toolTexturePosition.X > Game1.toolSpriteSheet.Width) + { + this._toolTexturePosition.Y += 32; + this._toolTexturePosition.X -= 333; + } + + if (Game1.player.daysLeftForToolUpgrade.Value > 0) + { + this._hoverText = string.Format(this._helper.SafeGetString(LanguageKeys.DaysUntilToolIsUpgraded), + Game1.player.daysLeftForToolUpgrade.Value, this._toolBeingUpgraded.DisplayName); + } + else + { + this._hoverText = string.Format(this._helper.SafeGetString(LanguageKeys.ToolIsFinishedBeingUpgraded), + this._toolBeingUpgraded.DisplayName); + } + } + else + { + this._toolBeingUpgraded = null; + } + + } + + /// Raised before drawing the HUD (item toolbar, clock, etc) to the screen. The vanilla HUD may be hidden at this point (e.g. because a menu is open). Content drawn to the sprite batch at this point will appear under the HUD. + /// The event sender. + /// The event arguments. + private void OnRenderingHud(object sender, RenderingHudEventArgs e) + { + // draw tool upgrade status + if (!Game1.eventUp && this._toolBeingUpgraded != null && Game1.activeClickableMenu == null) + { + Point iconPosition = IconHandler.Handler.GetNewIconPosition(); + this._toolUpgradeIcon = + new ClickableTextureComponent( + new Rectangle(iconPosition.X, iconPosition.Y, 60, 60), + Game1.toolSpriteSheet, + this._toolTexturePosition, + 3.75f); + this._toolUpgradeIcon.draw(Game1.spriteBatch); + } + } + + /// Raised after drawing the HUD (item toolbar, clock, etc) to the sprite batch, but before it's rendered to the screen. + /// The event sender. + /// The event arguments. + private void OnRenderedHud(object sender, RenderedHudEventArgs e) + { + // draw hover text + if (this._toolBeingUpgraded != null && this._toolUpgradeIcon.containsPoint((int)(Game1.getMouseX() * Game1.options.zoomLevel), (int)(Game1.getMouseY() * Game1.options.zoomLevel))) + { + IClickableMenu.drawHoverText( + Game1.spriteBatch, + this._hoverText, Game1.dialogueFont); + } + } + + public void Dispose() + { + this.ToggleOption(false); + this._toolBeingUpgraded = null; + } + } +} diff --git a/Mods/UI Info Suite/UIElements/ShowTravelingMerchant.cs b/Mods/UI Info Suite/UIElements/ShowTravelingMerchant.cs new file mode 100644 index 000000000..d6c1244d3 --- /dev/null +++ b/Mods/UI Info Suite/UIElements/ShowTravelingMerchant.cs @@ -0,0 +1,92 @@ +using Microsoft.Xna.Framework; +using StardewModdingAPI; +using StardewModdingAPI.Events; +using StardewValley; +using StardewValley.Menus; +using System; +using UIInfoSuite.Extensions; + +namespace UIInfoSuite.UIElements +{ + class ShowTravelingMerchant : IDisposable + { + private bool _travelingMerchantIsHere = false; + private ClickableTextureComponent _travelingMerchantIcon; + private readonly IModHelper _helper; + + public void ToggleOption(bool showTravelingMerchant) + { + this._helper.Events.Display.RenderingHud -= this.OnRenderingHud; + this._helper.Events.Display.RenderedHud -= this.OnRenderedHud; + this._helper.Events.GameLoop.DayStarted -= this.OnDayStarted; + + if (showTravelingMerchant) + { + this.UpdateTravelingMerchant(); + this._helper.Events.Display.RenderingHud += this.OnRenderingHud; + this._helper.Events.Display.RenderedHud += this.OnRenderedHud; + this._helper.Events.GameLoop.DayStarted += this.OnDayStarted; + } + } + + + public ShowTravelingMerchant(IModHelper helper) + { + this._helper = helper; + } + + public void Dispose() + { + this.ToggleOption(false); + } + + /// 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, EventArgs e) + { + this.UpdateTravelingMerchant(); + } + + private void UpdateTravelingMerchant() + { + int dayOfWeek = Game1.dayOfMonth % 7; + this._travelingMerchantIsHere = dayOfWeek == 0 || dayOfWeek == 5; + } + + /// Raised before drawing the HUD (item toolbar, clock, etc) to the screen. The vanilla HUD may be hidden at this point (e.g. because a menu is open). + /// The event sender. + /// The event arguments. + private void OnRenderingHud(object sender, RenderingHudEventArgs e) + { + // draw traveling merchant + if (!Game1.eventUp && this._travelingMerchantIsHere && Game1.activeClickableMenu == null) + { + Point iconPosition = IconHandler.Handler.GetNewIconPosition(); + this._travelingMerchantIcon = + new ClickableTextureComponent( + new Rectangle(iconPosition.X, iconPosition.Y, 60, 60), + Game1.mouseCursors, + new Rectangle(192, 1411, 20, 20), + 3f); + this._travelingMerchantIcon.draw(Game1.spriteBatch); + } + } + + /// Raised after drawing the HUD (item toolbar, clock, etc) to the sprite batch, but before it's rendered to the screen. + /// The event sender. + /// The event arguments. + private void OnRenderedHud(object sender, RenderedHudEventArgs e) + { + // draw hover text + if (this._travelingMerchantIsHere && this._travelingMerchantIcon.containsPoint((int)(Game1.getMouseX() * Game1.options.zoomLevel), (int)(Game1.getMouseY() * Game1.options.zoomLevel))) + { + string hoverText = this._helper.SafeGetString( + LanguageKeys.TravelingMerchantIsInTown); + IClickableMenu.drawHoverText( + Game1.spriteBatch, + hoverText, Game1.dialogueFont); + } + } + } +} diff --git a/Mods/UI Info Suite/UIElements/ShowWhenAnimalNeedsPet.cs b/Mods/UI Info Suite/UIElements/ShowWhenAnimalNeedsPet.cs new file mode 100644 index 000000000..19493358a --- /dev/null +++ b/Mods/UI Info Suite/UIElements/ShowWhenAnimalNeedsPet.cs @@ -0,0 +1,243 @@ +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Graphics; +using Netcode; +using StardewModdingAPI; +using StardewModdingAPI.Events; +using StardewValley; +using StardewValley.Characters; +using StardewValley.Network; +using System; +using System.Timers; + +namespace UIInfoSuite.UIElements +{ + class ShowWhenAnimalNeedsPet : IDisposable + { + private readonly Timer _timer = new Timer(); + private float _yMovementPerDraw; + private float _alpha; + private readonly IModHelper _helper; + + public ShowWhenAnimalNeedsPet(IModHelper helper) + { + this._timer.Elapsed += this.StartDrawingPetNeeds; + this._helper = helper; + } + + public void ToggleOption(bool showWhenAnimalNeedsPet) + { + this._timer.Stop(); + this._helper.Events.Player.Warped -= this.OnWarped; + this._helper.Events.Display.RenderingHud -= this.OnRenderingHud_DrawAnimalHasProduct; + + if (showWhenAnimalNeedsPet) + { + this._timer.Start(); + this._helper.Events.Player.Warped += this.OnWarped; + this._helper.Events.Display.RenderingHud += this.OnRenderingHud_DrawAnimalHasProduct; + } + } + + public void Dispose() + { + this.ToggleOption(false); + } + + /// Raised before drawing the HUD (item toolbar, clock, etc) to the screen. The vanilla HUD may be hidden at this point (e.g. because a menu is open). + /// The event sender. + /// The event arguments. + private void OnRenderingHud_DrawAnimalHasProduct(object sender, RenderingHudEventArgs e) + { + if (!Game1.eventUp && + Game1.activeClickableMenu == null && + Game1.currentLocation != null) + { + NetLongDictionary> animalsInCurrentLocation = this.GetAnimalsInCurrentLocation(); + if (animalsInCurrentLocation != null) + { + foreach (System.Collections.Generic.KeyValuePair animal in animalsInCurrentLocation.Pairs) + { + if (!animal.Value.IsEmoting && + animal.Value.currentProduce.Value != 430 && + animal.Value.currentProduce.Value > 0 && + animal.Value.age.Value >= animal.Value.ageWhenMature.Value) + { + Vector2 positionAboveAnimal = this.GetPetPositionAboveAnimal(animal.Value); + positionAboveAnimal.Y += (float)(Math.Sin(Game1.currentGameTime.TotalGameTime.TotalMilliseconds / 300.0 + (double)animal.Value.Name.GetHashCode()) * 5.0); + Game1.spriteBatch.Draw( + Game1.emoteSpriteSheet, + new Vector2(positionAboveAnimal.X + 14f, positionAboveAnimal.Y), + new Rectangle(3 * (Game1.tileSize / 4) % Game1.emoteSpriteSheet.Width, 3 * (Game1.tileSize / 4) / Game1.emoteSpriteSheet.Width * (Game1.tileSize / 4), Game1.tileSize / 4, Game1.tileSize / 4), + Color.White * 0.9f, + 0.0f, + Vector2.Zero, + 4f, + SpriteEffects.None, + 1f); + + Rectangle sourceRectangle = GameLocation.getSourceRectForObject(animal.Value.currentProduce.Value); + Game1.spriteBatch.Draw( + Game1.objectSpriteSheet, + new Vector2(positionAboveAnimal.X + 28f, positionAboveAnimal.Y + 8f), + sourceRectangle, + Color.White * 0.9f, + 0.0f, + Vector2.Zero, + 2.2f, + SpriteEffects.None, + 1f); + } + } + } + } + } + + /// Raised after a player warps to a new location. + /// The event sender. + /// The event arguments. + private void OnWarped(object sender, WarpedEventArgs e) + { + if (e.IsLocalPlayer) + { + if (e.NewLocation is AnimalHouse || e.NewLocation is Farm) + { + this._timer.Interval = 1000; + this._timer.Start(); + } + else + { + this._timer.Stop(); + this.StopDrawingPetNeeds(); + } + } + } + + /// Raised before drawing the HUD (item toolbar, clock, etc) to the screen. The vanilla HUD may be hidden at this point (e.g. because a menu is open). + /// The event sender. + /// The event arguments. + private void OnRenderingHud_DrawNeedsPetTooltip(object sender, RenderingHudEventArgs e) + { + if (!Game1.eventUp && Game1.activeClickableMenu == null) + { + this.DrawIconForFarmAnimals(); + this.DrawIconForPets(); + } + } + + private void StartDrawingPetNeeds(object sender, ElapsedEventArgs e) + { + this._timer.Stop(); + this._helper.Events.Display.RenderingHud += this.OnRenderingHud_DrawNeedsPetTooltip; + this._helper.Events.GameLoop.UpdateTicked += this.UpdateTicked; + this._yMovementPerDraw = -3f; + this._alpha = 1f; + } + + private void StopDrawingPetNeeds() + { + this._helper.Events.Display.RenderingHud -= this.OnRenderingHud_DrawNeedsPetTooltip; + this._helper.Events.GameLoop.UpdateTicked -= this.UpdateTicked; + } + + /// Raised after the game state is updated (≈60 times per second). + /// The event sender. + /// The event arguments. + private void UpdateTicked(object sender, UpdateTickedEventArgs e) + { + // update pet draw + if (e.IsMultipleOf(2)) + { + this._yMovementPerDraw += 0.3f; + this._alpha -= 0.014f; + if (this._alpha < 0.1f) + { + this.StopDrawingPetNeeds(); + this._timer.Start(); + } + } + } + + private void DrawIconForFarmAnimals() + { + NetLongDictionary> animalsInCurrentLocation = this.GetAnimalsInCurrentLocation(); + + if (animalsInCurrentLocation != null) + { + foreach (System.Collections.Generic.KeyValuePair animal in animalsInCurrentLocation.Pairs) + { + if (!animal.Value.IsEmoting && + !animal.Value.wasPet.Value) + { + Vector2 positionAboveAnimal = this.GetPetPositionAboveAnimal(animal.Value); + string animalType = animal.Value.type.Value.ToLower(); + + if (animalType.Contains("cow") || + animalType.Contains("sheep") || + animalType.Contains("goat") || + animalType.Contains("pig")) + { + positionAboveAnimal.X += 50f; + positionAboveAnimal.Y += 50f; + } + Game1.spriteBatch.Draw( + Game1.mouseCursors, + new Vector2(positionAboveAnimal.X, positionAboveAnimal.Y + this._yMovementPerDraw), + new Rectangle(32, 0, 16, 16), + Color.White * this._alpha, + 0.0f, + Vector2.Zero, + 4f, + SpriteEffects.None, + 1f); + } + } + } + } + + private void DrawIconForPets() + { + foreach (NPC character in Game1.currentLocation.characters) + { + if (character is Pet && + !this._helper.Reflection.GetField(character, "wasPetToday").GetValue()) + { + Vector2 positionAboveAnimal = this.GetPetPositionAboveAnimal(character); + positionAboveAnimal.X += 50f; + positionAboveAnimal.Y -= 20f; + Game1.spriteBatch.Draw( + Game1.mouseCursors, + new Vector2(positionAboveAnimal.X, positionAboveAnimal.Y + this._yMovementPerDraw), + new Rectangle(32, 0, 16, 16), + Color.White * this._alpha, + 0.0f, + Vector2.Zero, + 4f, + SpriteEffects.None, + 1f); + } + } + } + + private Vector2 GetPetPositionAboveAnimal(Character animal) + { + return new Vector2(Game1.viewport.Width <= Game1.currentLocation.map.DisplayWidth ? (animal.position.X - Game1.viewport.X + 16) * Game1.options.zoomLevel : (animal.position.X + ((Game1.viewport.Width - Game1.currentLocation.map.DisplayWidth) / 2 + 18)) * Game1.options.zoomLevel, + Game1.viewport.Height <= Game1.currentLocation.map.DisplayHeight ? (animal.position.Y - Game1.viewport.Y - 34) * Game1.options.zoomLevel : (animal.position.Y + ((Game1.viewport.Height - Game1.currentLocation.map.DisplayHeight) / 2 - 50)) * Game1.options.zoomLevel); + } + + private NetLongDictionary> GetAnimalsInCurrentLocation() + { + NetLongDictionary> animals = null; + + if (Game1.currentLocation is AnimalHouse) + { + animals = (Game1.currentLocation as AnimalHouse).animals; + } + else if (Game1.currentLocation is Farm) + { + animals = (Game1.currentLocation as Farm).animals; + } + + return animals; + } + } +} diff --git a/Mods/UI Info Suite/UIElements/SkipIntro.cs b/Mods/UI Info Suite/UIElements/SkipIntro.cs new file mode 100644 index 000000000..c3f3489f7 --- /dev/null +++ b/Mods/UI Info Suite/UIElements/SkipIntro.cs @@ -0,0 +1,63 @@ +using StardewModdingAPI; +using StardewModdingAPI.Events; +using StardewValley; +using StardewValley.Menus; +using System; + +namespace UIInfoSuite.UIElements +{ + class SkipIntro + { + private readonly IModEvents _events; + //private bool _skipIntro = false; + + public SkipIntro(IModEvents events) + { + this._events = events; + + //GameEvents.QuarterSecondTick += CheckForSkip; + events.Input.ButtonPressed += this.OnButtonPressed; + events.GameLoop.SaveLoaded += this.OnSaveLoaded; + //MenuEvents.MenuChanged += SkipToTitleButtons; + } + + /// Raised after the player loads a save slot and the world is initialised. + /// The event sender. + /// The event arguments. + private void OnSaveLoaded(object sender, EventArgs e) + { + // stop checking for skip key + this._events.Input.ButtonPressed -= this.OnButtonPressed; + this._events.GameLoop.SaveLoaded -= this.OnSaveLoaded; + } + + /// 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 (Game1.activeClickableMenu is TitleMenu menu && e.Button == SButton.Escape) + { + menu.skipToTitleButtons(); + this._events.Input.ButtonPressed -= this.OnButtonPressed; + } + } + + //private void CheckForSkip(object sender, EventArgs e) + //{ + // if (Game1.activeClickableMenu is TitleMenu && + // _skipIntro) + // { + // _skipIntro = false; + // (Game1.activeClickableMenu as TitleMenu)?.skipToTitleButtons(); + // } + //} + + //private void SkipToTitleButtons(object sender, EventArgsClickableMenuChanged e) + //{ + // TitleMenu menu = e.NewMenu as TitleMenu; + // menu?.skipToTitleButtons(); + // //MenuEvents.MenuChanged -= SkipToTitleButtons; + //} + } +} diff --git a/Mods/UI Info Suite/UIInfoSuite.csproj b/Mods/UI Info Suite/UIInfoSuite.csproj new file mode 100644 index 000000000..28e1fe964 --- /dev/null +++ b/Mods/UI Info Suite/UIInfoSuite.csproj @@ -0,0 +1,193 @@ + + + + + Debug + AnyCPU + {E23D348D-B57A-4EE0-93B6-DEB8F6B094EC} + Library + Properties + UIInfoSuite + UIInfoSuite + v4.5.2 + 512 + true + + + true + full + false + bin\Debug\ + DEBUG;TRACE + prompt + 4 + + + pdbonly + true + bin\Release\ + TRACE + prompt + 4 + + + + ..\assemblies\StardewModdingAPI.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.dll + + + ..\assemblies\System.Net.Http.dll + + + ..\assemblies\System.Runtime.Serialization.dll + + + 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/src/Harmony/Attributes.cs b/src/Harmony/Attributes.cs new file mode 100644 index 000000000..14e4f5131 --- /dev/null +++ b/src/Harmony/Attributes.cs @@ -0,0 +1,325 @@ +using System; +using System.Collections.Generic; + +namespace Harmony +{ + public enum MethodType + { + Normal, + Getter, + Setter, + Constructor, + StaticConstructor + } + + [Obsolete("This enum will be removed in the next major version. To define special methods, use MethodType")] + public enum PropertyMethod + { + Getter, + Setter + } + + public enum ArgumentType + { + Normal, + Ref, + Out, + Pointer + } + + public enum HarmonyPatchType + { + All, + Prefix, + Postfix, + Transpiler + } + + public class HarmonyAttribute : Attribute + { + public HarmonyMethod info = new HarmonyMethod(); + } + + [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = true)] + public class HarmonyPatch : HarmonyAttribute + { + // no argument (for use with TargetMethod) + + public HarmonyPatch() + { + } + + // starting with 'Type' + + public HarmonyPatch(Type declaringType) + { + info.declaringType = declaringType; + } + + public HarmonyPatch(Type declaringType, Type[] argumentTypes) + { + info.declaringType = declaringType; + info.argumentTypes = argumentTypes; + } + + public HarmonyPatch(Type declaringType, string methodName) + { + info.declaringType = declaringType; + info.methodName = methodName; + } + + public HarmonyPatch(Type declaringType, string methodName, params Type[] argumentTypes) + { + info.declaringType = declaringType; + info.methodName = methodName; + info.argumentTypes = argumentTypes; + } + + public HarmonyPatch(Type declaringType, string methodName, Type[] argumentTypes, ArgumentType[] argumentVariations) + { + info.declaringType = declaringType; + info.methodName = methodName; + ParseSpecialArguments(argumentTypes, argumentVariations); + } + + public HarmonyPatch(Type declaringType, MethodType methodType) + { + info.declaringType = declaringType; + info.methodType = methodType; + } + + public HarmonyPatch(Type declaringType, MethodType methodType, params Type[] argumentTypes) + { + info.declaringType = declaringType; + info.methodType = methodType; + info.argumentTypes = argumentTypes; + } + + public HarmonyPatch(Type declaringType, MethodType methodType, Type[] argumentTypes, ArgumentType[] argumentVariations) + { + info.declaringType = declaringType; + info.methodType = methodType; + ParseSpecialArguments(argumentTypes, argumentVariations); + } + + public HarmonyPatch(Type declaringType, string propertyName, MethodType methodType) + { + info.declaringType = declaringType; + info.methodName = propertyName; + info.methodType = methodType; + } + + // starting with 'string' + + public HarmonyPatch(string methodName) + { + info.methodName = methodName; + } + + public HarmonyPatch(string methodName, params Type[] argumentTypes) + { + info.methodName = methodName; + info.argumentTypes = argumentTypes; + } + + public HarmonyPatch(string methodName, Type[] argumentTypes, ArgumentType[] argumentVariations) + { + info.methodName = methodName; + ParseSpecialArguments(argumentTypes, argumentVariations); + } + + public HarmonyPatch(string propertyName, MethodType methodType) + { + info.methodName = propertyName; + info.methodType = methodType; + } + + // starting with 'MethodType' + + public HarmonyPatch(MethodType methodType) + { + info.methodType = methodType; + } + + public HarmonyPatch(MethodType methodType, params Type[] argumentTypes) + { + info.methodType = methodType; + info.argumentTypes = argumentTypes; + } + + public HarmonyPatch(MethodType methodType, Type[] argumentTypes, ArgumentType[] argumentVariations) + { + info.methodType = methodType; + ParseSpecialArguments(argumentTypes, argumentVariations); + } + + // starting with 'Type[]' + + public HarmonyPatch(Type[] argumentTypes) + { + info.argumentTypes = argumentTypes; + } + + public HarmonyPatch(Type[] argumentTypes, ArgumentType[] argumentVariations) + { + ParseSpecialArguments(argumentTypes, argumentVariations); + } + + // Obsolete attributes + + [Obsolete("This attribute will be removed in the next major version. Use HarmonyPatch together with MethodType.Getter or MethodType.Setter instead")] + public HarmonyPatch(string propertyName, PropertyMethod type) + { + info.methodName = propertyName; + info.methodType = type == PropertyMethod.Getter ? MethodType.Getter : MethodType.Setter; + } + + // + + private void ParseSpecialArguments(Type[] argumentTypes, ArgumentType[] argumentVariations) + { + if (argumentVariations == null || argumentVariations.Length == 0) + { + info.argumentTypes = argumentTypes; + return; + } + + if (argumentTypes.Length < argumentVariations.Length) + throw new ArgumentException("argumentVariations contains more elements than argumentTypes", nameof(argumentVariations)); + + var types = new List(); + for (var i = 0; i < argumentTypes.Length; i++) + { + var type = argumentTypes[i]; + switch (argumentVariations[i]) + { + case ArgumentType.Ref: + case ArgumentType.Out: + type = type.MakeByRefType(); + break; + case ArgumentType.Pointer: + type = type.MakePointerType(); + break; + } + types.Add(type); + } + info.argumentTypes = types.ToArray(); + } + } + + [AttributeUsage(AttributeTargets.Class)] + public class HarmonyPatchAll : HarmonyAttribute + { + public HarmonyPatchAll() + { + } + } + + [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)] + public class HarmonyPriority : HarmonyAttribute + { + public HarmonyPriority(int prioritiy) + { + info.prioritiy = prioritiy; + } + } + + [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)] + public class HarmonyBefore : HarmonyAttribute + { + public HarmonyBefore(params string[] before) + { + info.before = before; + } + } + + [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)] + public class HarmonyAfter : HarmonyAttribute + { + public HarmonyAfter(params string[] after) + { + info.after = after; + } + } + + // If you don't want to use the special method names you can annotate + // using the following attributes: + + [AttributeUsage(AttributeTargets.Method)] + public class HarmonyPrepare : Attribute + { + } + + [AttributeUsage(AttributeTargets.Method)] + public class HarmonyCleanup : Attribute + { + } + + [AttributeUsage(AttributeTargets.Method)] + public class HarmonyTargetMethod : Attribute + { + } + + [AttributeUsage(AttributeTargets.Method)] + public class HarmonyTargetMethods : Attribute + { + } + + [AttributeUsage(AttributeTargets.Method)] + public class HarmonyPrefix : Attribute + { + } + + [AttributeUsage(AttributeTargets.Method)] + public class HarmonyPostfix : Attribute + { + } + + [AttributeUsage(AttributeTargets.Method)] + public class HarmonyTranspiler : Attribute + { + } + + [AttributeUsage(AttributeTargets.Parameter | AttributeTargets.Method | AttributeTargets.Class, AllowMultiple = true)] + public class HarmonyArgument : Attribute + { + public string OriginalName { get; private set; } + public int Index { get; private set; } + public string NewName { get; private set; } + + public HarmonyArgument(string originalName) : this(originalName, null) + { + } + + public HarmonyArgument(int index) : this(index, null) + { + } + + public HarmonyArgument(string originalName, string newName) + { + OriginalName = originalName; + Index = -1; + NewName = newName; + } + + public HarmonyArgument(int index, string name) + { + OriginalName = null; + Index = index; + NewName = name; + } + } + + // This attribute is for Harmony patching itself to the latest + // + [AttributeUsage(AttributeTargets.Method | AttributeTargets.Constructor)] + internal class UpgradeToLatestVersion : Attribute + { + public int version; + + public UpgradeToLatestVersion(int version) + { + this.version = version; + } + } +} \ No newline at end of file diff --git a/src/Harmony/CodeInstruction.cs b/src/Harmony/CodeInstruction.cs new file mode 100644 index 000000000..a6d897262 --- /dev/null +++ b/src/Harmony/CodeInstruction.cs @@ -0,0 +1,62 @@ +using Harmony.ILCopying; +using System.Collections.Generic; +using System.Linq; +using System.Reflection.Emit; + +namespace Harmony +{ + public class CodeInstruction + { + public OpCode opcode; + public object operand; + public List