diff --git a/Install/PatchStep.txt b/Install/PatchStep.txt
index fa0a6b0a..f6dd9eaa 100644
--- a/Install/PatchStep.txt
+++ b/Install/PatchStep.txt
@@ -6,6 +6,7 @@ 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:
@@ -18,4 +19,10 @@ ldsfld StardewValley.ModHooks StardewValley.Game1::hooks
ldarg.1
ldarg.2
callvirt StardewValley.LocalizedContentManager StardewValley.ModHooks::OnGame1_CreateContentManager(System.IServiceProvider,System.String)
-ret
\ No newline at end of file
+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)
\ No newline at end of file
diff --git a/Mods/LookupAnything/Common/.Common.shproj b/Mods/LookupAnything/Common/.Common.shproj
new file mode 100644
index 00000000..3a1cea20
--- /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 00000000..a94dfbe3
--- /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 00000000..720736e4
--- /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 00000000..a84f4226
--- /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 00000000..9ee33a2d
--- /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 00000000..15801325
--- /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 00000000..13898dbc
--- /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 00000000..6c649fca
--- /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 00000000..6081e89b
--- /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 00000000..f7f48248
--- /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 00000000..c213f02e
--- /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 00000000..4cb7c36d
--- /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 00000000..4952043f
--- /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 00000000..277c95c6
--- /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 00000000..14b80ffb
--- /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 00000000..a41135e5
--- /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 00000000..2c4d92a1
--- /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 00000000..17327ed8
--- /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 00000000..a945c8c3
--- /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 00000000..d5aa4fce
--- /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 00000000..f90cfb74
--- /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 00000000..b2a61ed3
--- /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 00000000..b35e6f35
--- /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 00000000..68d8e05a
--- /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 00000000..ef21dd31
--- /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 00000000..40b174f0
--- /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 00000000..b7c3be5e
--- /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 00000000..29e78167
--- /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 00000000..c96aeb92
--- /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 00000000..4b515ec5
--- /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 00000000..3da68991
--- /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 00000000..98cf678e
--- /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 00000000..4bad98e7
--- /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 00000000..6f0530d8
--- /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 00000000..020ebfad
--- /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 00000000..68aed6c0
--- /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 00000000..f9fd5cef
--- /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 00000000..e7bf3ad1
--- /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 00000000..b154fab4
--- /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 00000000..972e770a
--- /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 00000000..03227504
--- /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 00000000..0c845472
--- /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 00000000..8bd51b8c
--- /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 00000000..549788bc
--- /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 00000000..0cf4c460
--- /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 00000000..8a218712
--- /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 00000000..f135cad2
--- /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 00000000..60b42fe9
--- /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 00000000..93aa4a61
--- /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 00000000..811e1f90
--- /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 00000000..02a893e1
--- /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 00000000..12a69a07
--- /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 00000000..d69e638c
--- /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 00000000..3581609d
--- /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 00000000..12bfb14b
--- /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 00000000..d47a01b4
--- /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 00000000..b0797712
--- /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 00000000..2c818df0
--- /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 00000000..18324b91
--- /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 00000000..a6b79bf3
--- /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 00000000..66c382ed
--- /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 00000000..44e319ac
--- /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 00000000..7b16d10d
--- /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 00000000..db20e467
--- /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 00000000..f346736a
--- /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 00000000..1974fe1f
--- /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 00000000..a226e04e
--- /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 00000000..a8abb09c
--- /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 00000000..f1004d82
--- /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 00000000..0477567d
--- /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 00000000..388ebc34
--- /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 00000000..dc3e0b90
--- /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 00000000..13dd15bc
--- /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 00000000..9dff8ab4
--- /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 00000000..0fa7161a
--- /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 00000000..b28e0526
--- /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 00000000..794d4d8e
--- /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 00000000..ae0192d3
--- /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 00000000..824ef0d2
--- /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 00000000..987a67d8
--- /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 00000000..fbb0d388
--- /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 00000000..71d106f7
--- /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 00000000..75adab77
--- /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 00000000..b2409240
--- /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 00000000..2e3a2d31
--- /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 00000000..7a4b421f
--- /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 00000000..ffcf0886
--- /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 00000000..508cd374
--- /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 00000000..f0f4d71d
--- /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 00000000..58dd39ce
--- /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 00000000..1dd907a1
--- /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 00000000..ab3972c1
--- /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 00000000..af6e8b33
--- /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 00000000..597955ce
--- /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 00000000..1dd7d55d
--- /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 00000000..10f08596
--- /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 00000000..11e6370e
--- /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 00000000..160d0323
--- /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 00000000..dce98f09
--- /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 00000000..8a5ee791
--- /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 00000000..ccdfb92a
--- /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 00000000..bf14c344
--- /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 00000000..f303919a
--- /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 00000000..f0f611f2
--- /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 00000000..8c4ce65c
--- /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 00000000..f32835f6
--- /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 00000000..0be181ca
--- /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 00000000..16965340
--- /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 00000000..a40967dd
--- /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 00000000..7c872245
--- /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 00000000..4554e8d0
--- /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 00000000..6a7aa44d
--- /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);
+ }
+
+ ///