From 533ec65a45ad2eafa0da90c12cf3faf71f6443ea Mon Sep 17 00:00:00 2001 From: SoulSilverJD <165866563+SoulSilverJD@users.noreply.github.com> Date: Fri, 23 Jan 2026 22:50:25 -0500 Subject: [PATCH 01/24] Added Weird Items Case --- StarControl/Graphics/Sprite.cs | 74 ++++++++++++++++++++++++++++++++++ 1 file changed, 74 insertions(+) diff --git a/StarControl/Graphics/Sprite.cs b/StarControl/Graphics/Sprite.cs index 0e31947..ac7a3e9 100644 --- a/StarControl/Graphics/Sprite.cs +++ b/StarControl/Graphics/Sprite.cs @@ -1,6 +1,8 @@ using System.Diagnostics.CodeAnalysis; +using Microsoft.Xna.Framework; using Microsoft.Xna.Framework.Content; using Microsoft.Xna.Framework.Graphics; +using StardewValley; namespace StarControl.Graphics; @@ -21,7 +23,79 @@ public static Sprite ForItemId(string id) var data = ItemRegistry.GetDataOrErrorItem(id); return new(data.GetTexture(), data.GetSourceRect()); } + + public static Sprite FromItem(Item item) + { + ArgumentNullException.ThrowIfNull(item); + + // 1) Prefer the official item registry sprite (works for all vanilla + most mod items that register properly) + var qualifiedId = item.QualifiedItemId; + if (!string.IsNullOrEmpty(qualifiedId)) + { + try + { + var data = ItemRegistry.GetData(qualifiedId); + if (data is not null) + { + return new(data.GetTexture(), data.GetSourceRect()); + } + } + catch + { + // swallow and fall back below + } + } + // 2) Fallback: render the item into a small texture (works even for "weird" items like Item Bags) + var derivedTexture = TryRenderItemToTexture(item); + if (derivedTexture is not null) + { + return new(derivedTexture, derivedTexture.Bounds); + } + + // 3) Final fallback + return Sprites.Error(); + } + + private static Texture2D? TryRenderItemToTexture(Item item) + { + try + { + var graphicsDevice = Game1.graphics.GraphicsDevice; + var spriteBatch = Game1.spriteBatch; + + var previousTargets = graphicsDevice.GetRenderTargets(); + var renderTarget = new RenderTarget2D(graphicsDevice, 64, 64); + + graphicsDevice.SetRenderTarget(renderTarget); + graphicsDevice.Clear(Color.Transparent); + + spriteBatch.Begin( + SpriteSortMode.Deferred, + BlendState.AlphaBlend, + rasterizerState: new() { MultiSampleAntiAlias = false }, + samplerState: SamplerState.PointClamp + ); + + try + { + // drawInMenu is the most compatible "just draw whatever this item is" API + item.drawInMenu(spriteBatch, Vector2.Zero, 1f); + } + finally + { + spriteBatch.End(); + graphicsDevice.SetRenderTargets(previousTargets); + } + + return renderTarget; + } + catch + { + return null; + } + } + /// /// Attempts to load a sprite from configuration data. /// From 738ba6056a552e9c3d96474c438bea7e7ca52de4 Mon Sep 17 00:00:00 2001 From: SoulSilverJD <165866563+SoulSilverJD@users.noreply.github.com> Date: Fri, 23 Jan 2026 22:58:00 -0500 Subject: [PATCH 02/24] Weird item case --- StarControl/UI/RemappingViewModel.cs | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/StarControl/UI/RemappingViewModel.cs b/StarControl/UI/RemappingViewModel.cs index 710e673..662be70 100644 --- a/StarControl/UI/RemappingViewModel.cs +++ b/StarControl/UI/RemappingViewModel.cs @@ -422,13 +422,17 @@ internal partial class RemappableItemViewModel public static RemappableItemViewModel FromInventoryItem(Item item) { - var itemData = ItemRegistry.GetData(item.QualifiedItemId); + ArgumentNullException.ThrowIfNull(item); + + var qualifiedId = item.QualifiedItemId; // can be null/empty for some mod items + var sprite = Sprite.FromItem(item); + return new() { - Id = item.QualifiedItemId, + Id = qualifiedId ?? item.Name ?? item.GetType().FullName ?? "unknown", IdType = ItemIdType.GameItem, Enabled = true, - Sprite = new(itemData.GetTexture(), itemData.GetSourceRect()), + Sprite = sprite, Quality = item.Quality, Count = item.Stack, Tooltip = new(item.getDescription(), item.DisplayName, item), From e1ada1c1bd9755ffad67a0f6d2f76f0d886307de Mon Sep 17 00:00:00 2001 From: SoulSilverJD <165866563+SoulSilverJD@users.noreply.github.com> Date: Sat, 24 Jan 2026 03:15:05 -0500 Subject: [PATCH 03/24] I found the bugger --- StarControl/Menus/QuickSlotResolver.cs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/StarControl/Menus/QuickSlotResolver.cs b/StarControl/Menus/QuickSlotResolver.cs index e38d643..0f3d058 100644 --- a/StarControl/Menus/QuickSlotResolver.cs +++ b/StarControl/Menus/QuickSlotResolver.cs @@ -8,6 +8,11 @@ internal class QuickSlotResolver(Farmer player, ModMenu modMenu) { public static Item? ResolveInventoryItem(string id, ICollection items) { + // Allow exact inventory matches even if the item is not registered in ItemRegistry (e.g. Item Bags) + var exact = items.FirstOrDefault(i => i is not null && i.QualifiedItemId == id); + if (exact is not null) + return exact; + Logger.Log(LogCategory.QuickSlots, $"Searching for inventory item equivalent to '{id}'..."); if (ItemRegistry.GetData(id) is not { } data) { From 6f6bef737aced1ac25b6d1e633aad307f9fd2f19 Mon Sep 17 00:00:00 2001 From: SoulSilverJD <165866563+SoulSilverJD@users.noreply.github.com> Date: Sat, 24 Jan 2026 03:29:15 -0500 Subject: [PATCH 04/24] Get quick menu to pick up Icon --- StarControl/Menus/QuickSlotRenderer.cs | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/StarControl/Menus/QuickSlotRenderer.cs b/StarControl/Menus/QuickSlotRenderer.cs index f4b45c0..c6a04bc 100644 --- a/StarControl/Menus/QuickSlotRenderer.cs +++ b/StarControl/Menus/QuickSlotRenderer.cs @@ -1,7 +1,9 @@ using Microsoft.Xna.Framework; using Microsoft.Xna.Framework.Graphics; using StarControl.Config; +using StarControl.Data; using StarControl.Graphics; +using StardewValley; namespace StarControl.Menus; @@ -431,20 +433,27 @@ private Texture2D GetUiTexture() return uiTexture; } - private Sprite? GetSlotSprite(IItemLookup itemLookup) + private Sprite? GetSlotSprite(IItemLookup itemLookup, ICollection inventoryItems) { if (string.IsNullOrWhiteSpace(itemLookup.Id)) - { return null; - } + return itemLookup.IdType switch { - ItemIdType.GameItem => Sprite.ForItemId(itemLookup.Id), + ItemIdType.GameItem => QuickSlotResolver.ResolveInventoryItem( + itemLookup.Id, + inventoryItems + ) + is Item item + ? Sprite.FromItem(item) // handles Item Bags via drawInMenu fallback + : Sprite.ForItemId(itemLookup.Id), // fallback for normal items + ItemIdType.ModItem => GetModItemSprite(itemLookup.Id), _ => null, }; } + private static Color LumaGray(Color color, float lightness) { var v = (int)((color.R * 0.2126f + color.G * 0.7152f + color.B * 0.0722f) * lightness); @@ -492,7 +501,7 @@ private void RefreshSlots() LogLevel.Info ); } - sprite ??= GetSlotSprite(slotConfig); + sprite ??= GetSlotSprite(slotConfig, Game1.player.Items); if (sprite is not null) { slotSprites.Add(button, sprite); From 4ee324cf583f640d65e1cb24e1193b015ad525d8 Mon Sep 17 00:00:00 2001 From: SoulSilverJD <165866563+SoulSilverJD@users.noreply.github.com> Date: Sat, 24 Jan 2026 03:38:25 -0500 Subject: [PATCH 05/24] Wheels and such --- StarControl/Menus/InventoryMenuItem.cs | 752 ++++++++++++++----------- 1 file changed, 411 insertions(+), 341 deletions(-) diff --git a/StarControl/Menus/InventoryMenuItem.cs b/StarControl/Menus/InventoryMenuItem.cs index 46d884c..363a4d9 100644 --- a/StarControl/Menus/InventoryMenuItem.cs +++ b/StarControl/Menus/InventoryMenuItem.cs @@ -1,341 +1,411 @@ -using System.Runtime.CompilerServices; -using System.Text; -using Microsoft.Xna.Framework.Graphics; -using StardewValley.Enchantments; -using StardewValley.ItemTypeDefinitions; -using StardewValley.Objects; -using StardewValley.Tools; - -namespace StarControl.Menus; - -/// -/// A menu item that corresponds to an item in the player's inventory. -/// -internal class InventoryMenuItem : IRadialMenuItem -{ - public string Id => Item.QualifiedItemId; - - /// - /// The underlying inventory item. - /// - public Item Item { get; } - - public string Title { get; } - - public string Description { get; } - - public int? StackSize => Item.maximumStackSize() > 1 ? Item.Stack : null; - - public int? Quality => Item.Quality; - - public Texture2D? Texture { get; } - - public Rectangle? SourceRectangle { get; } - - public Rectangle? TintRectangle { get; } - - public Color? TintColor { get; } - - private static readonly ConditionalWeakTable DerivedTextures = []; - - public InventoryMenuItem(Item item) - { - Logger.Log(LogCategory.Menus, "Starting refresh of inventory menu."); - Item = item; - Title = item.DisplayName; - Description = UnparseText(item.getDescription()); - - var data = ItemRegistry.GetDataOrErrorItem(item.QualifiedItemId); - // Workaround for some mods creating items without registering item data; these mods - // typically only implement drawInMenu. This is not a good thing to do, and tends to cause - // other problems, but we can still attempt to show an icon. - if ( - data.IsErrorItem - // Exclude vanilla item classes; hacked items only apply to mods, and if we found an - // instance with a vanilla type, it means the item truly is missing (e.g. source mod is - // no longer installed, or the item is simply invalid) and we'd only waste time trying - // to derive a texture. - && item.GetType().Assembly != typeof(SObject).Assembly - && GetDerivedTexture(item) is { } texture - ) - { - Texture = texture; - SourceRectangle = texture.Bounds; - } - else - { - var textureData = GetTextureRedirect(item); - Texture = textureData?.GetTexture() ?? data.GetTexture(); - SourceRectangle = textureData?.GetSourceRect() ?? data.GetSourceRect(); - (TintRectangle, TintColor) = GetTinting(item, textureData ?? data); - } - } - - public ItemActivationResult Activate( - Farmer who, - DelayedActions delayedActions, - ItemActivationType activationType - ) - { - if (activationType == ItemActivationType.Instant) - { - if (Item is Tool tool) - { - var isActivating = IsActivating(); - if (!isActivating && (!Context.CanPlayerMove || who.canOnlyWalk || who.UsingTool)) - { - return ItemActivationResult.Ignored; - } - if (who.CurrentTool != tool) - { - var toolIndex = who.Items.IndexOf(tool); - if (toolIndex < 0) - { - return ItemActivationResult.Ignored; - } - who.CurrentToolIndex = who.Items.IndexOf(tool); - } - if (tool is FishingRod rod && rod.fishCaught) - { - rod.doneHoldingFish(who); - return ItemActivationResult.Used; - } - else if (tool is not MeleeWeapon) - { - who.FireTool(); - } - Game1.pressUseToolButton(); - return isActivating - ? ItemActivationResult.Used - : ItemActivationResult.ToolUseStarted; - } - else if (Item is SObject obj && obj.isPlaceable()) - { - var previousToolIndex = who.CurrentToolIndex; - var grabTile = Game1.GetPlacementGrabTile(); - var placementPosition = Utility.GetNearbyValidPlacementPosition( - who, - who.currentLocation, - obj, - (int)grabTile.X * 64, - (int)grabTile.Y * 64 - ); - try - { - who.CurrentToolIndex = who.Items.IndexOf(obj); - return Utility.tryToPlaceItem( - who.currentLocation, - obj, - (int)placementPosition.X, - (int)placementPosition.Y - ) - ? ItemActivationResult.Used - : ItemActivationResult.Ignored; - } - finally - { - who.CurrentToolIndex = previousToolIndex; - } - } - } - return FuzzyActivation.ConsumeOrSelect( - who, - Item, - delayedActions, - activationType == ItemActivationType.Secondary - ? InventoryAction.Select - : InventoryAction.Use - ); - } - - public void ContinueActivation() - { - var who = Game1.player; - if (Item is not Tool tool || who.CurrentTool != tool) - { - return; - } - if (!who.canReleaseTool || who.Stamina < 1 || tool is FishingRod) - { - return; - } - var maxPowerModifier = tool.hasEnchantmentOfType() ? 1 : 0; - var maxPower = tool.UpgradeLevel + maxPowerModifier; - if (who.toolPower.Value >= maxPower) - { - return; - } - if (who.toolHold.Value <= 0) - { - who.toolHold.Value = (int)(tool.AnimationSpeedModifier * 600); - } - else - { - who.toolHold.Value -= Game1.currentGameTime.ElapsedGameTime.Milliseconds; - if (who.toolHold.Value <= 0) - { - who.toolPowerIncrease(); - } - } - } - - public bool EndActivation() - { - var who = Game1.player; - if (Item is Tool tool && who.CurrentTool == tool && who.UsingTool && who.canReleaseTool) - { - if (Item is not FishingRod) - { - who.EndUsingTool(); - } - return true; - } - // This isn't equivalent to vanilla logic, but if we detect that the player is no longer - // using ANY tool (which is what UsingTool tells us) then any button that the controller is - // "holding" should be released anyway, so that it can be pressed again. - if (!who.UsingTool) - { - return true; - } - return false; - } - - public string? GetActivationSound( - Farmer who, - ItemActivationType activationType, - string defaultSound - ) - { - return Item is Tool && activationType == ItemActivationType.Instant ? null : defaultSound; - } - - private static Texture2D? GetDerivedTexture(Item item) - { - if (!DerivedTextures.TryGetValue(item, out var texture)) - { - try - { - var graphicsDevice = Game1.graphics.GraphicsDevice; - var spriteBatch = Game1.spriteBatch; - var previousTargets = graphicsDevice.GetRenderTargets(); - var renderTarget = new RenderTarget2D(graphicsDevice, 64, 64); - graphicsDevice.SetRenderTarget(renderTarget); - graphicsDevice.Clear(Color.Transparent); - spriteBatch.Begin( - SpriteSortMode.Deferred, - BlendState.AlphaBlend, - rasterizerState: new() { MultiSampleAntiAlias = false }, - samplerState: SamplerState.PointClamp - ); - try - { - item.drawInMenu(spriteBatch, Vector2.Zero, 1f); - texture = renderTarget; - DerivedTextures.Add(item, texture); - } - catch - { - renderTarget.Dispose(); - throw; - } - finally - { - spriteBatch.End(); - graphicsDevice.SetRenderTargets(previousTargets); - } - } - catch (Exception ex) - { - Logger.Log( - $"Error deriving texture for item '{item.Name}' ({item.QualifiedItemId}): {ex}", - LogLevel.Error - ); - } - } - return texture; - } - - public bool IsActivating() - { - return Item is Tool tool && Game1.player.CurrentTool == tool && Game1.player.UsingTool; - } - - private static ParsedItemData? GetTextureRedirect(Item item) - { - return item is SObject obj && item.ItemId == "SmokedFish" - ? ItemRegistry.GetData(obj.preservedParentSheetIndex.Value) - : null; - } - - private static (Rectangle? tintRect, Color? tintColor) GetTinting( - Item item, - ParsedItemData data - ) - { - if (item is not ColoredObject coloredObject) - { - return default; - } - if (item.ItemId == "SmokedFish") - { - // Smoked fish implementation is unique (and private) in ColoredObject. - // We don't care about the animation here, but should draw it darkened; the quirky - // way this is implemented is to draw a tinted version of the original item sprite - // (not an overlay) sprite over top of the original sprite. - return (data.GetSourceRect(), new Color(80, 30, 10) * 0.6f); - } - return !coloredObject.ColorSameIndexAsParentSheetIndex - ? (data.GetSourceRect(1), coloredObject.color.Value) - : (null, coloredObject.color.Value); - } - - // When we call Item.getDescription(), most implementations go through `Game1.parseText` - // which splits the string itself onto multiple lines. This tries to remove that, so that we - // can do our own wrapping using our own width. - // - // N.B. The reason we don't just use `ParsedItemData.Description` is that, at least in the - // current version, it's often only a "base description" and includes format placeholders, - // or is missing suffixes. - private static string UnparseText(string text) - { - var sb = new StringBuilder(); - var isWhitespace = false; - var newlineCount = 0; - foreach (var c in text) - { - if (c == ' ' || c == '\r' || c == '\n') - { - if (!isWhitespace) - { - sb.Append(' '); - } - isWhitespace = true; - if (c == '\n') - { - newlineCount++; - } - } - else - { - // If the original text has a "paragraph", the formatted text will often look - // strange if that is also collapsed into a space. So preserve _multiple_ - // newlines somewhat as a single "paragraph break". - if (newlineCount > 1) - { - // From implementation above, newlines are counted as whitespace so we know - // that the last character will always be a space when hitting here. - sb.Length--; - sb.Append("\r\n\r\n"); - } - sb.Append(c); - isWhitespace = false; - newlineCount = 0; - } - } - if (isWhitespace) - { - sb.Length--; - } - return sb.ToString(); - } -} +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Text; +using Microsoft.Xna.Framework.Graphics; +using StardewValley.Enchantments; +using StardewValley.ItemTypeDefinitions; +using StardewValley.Objects; +using StardewValley.Tools; + +namespace StarControl.Menus; + +/// +/// A menu item that corresponds to an item in the player's inventory. +/// +internal class InventoryMenuItem : IRadialMenuItem +{ + public string Id => Item.QualifiedItemId; + + /// + /// The underlying inventory item. + /// + public Item Item { get; } + + public string Title { get; } + + public string Description { get; } + + public int? StackSize => Item.maximumStackSize() > 1 ? Item.Stack : null; + + public int? Quality => Item.Quality; + + public Texture2D? Texture { get; } + + public Rectangle? SourceRectangle { get; } + + public Rectangle? TintRectangle { get; } + + public Color? TintColor { get; } + + private static readonly ConditionalWeakTable DerivedTextures = []; + + public InventoryMenuItem(Item item) + { + Item = item; + Title = item.DisplayName; + Description = UnparseText(item.getDescription()); + + var data = ItemRegistry.GetDataOrErrorItem(item.QualifiedItemId); + // Workaround for some mods creating items without registering item data; these mods + // typically only implement drawInMenu. This is not a good thing to do, and tends to cause + // other problems, but we can still attempt to show an icon. + if ( + data.IsErrorItem + // Exclude vanilla item classes; hacked items only apply to mods, and if we found an + // instance with a vanilla type, it means the item truly is missing (e.g. source mod is + // no longer installed, or the item is simply invalid) and we'd only waste time trying + // to derive a texture. + && item.GetType().Assembly != typeof(SObject).Assembly + && GetDerivedTexture(item) is { } texture + ) + { + Texture = texture; + SourceRectangle = texture.Bounds; + } + else + { + var textureData = GetTextureRedirect(item); + Texture = textureData?.GetTexture() ?? data.GetTexture(); + SourceRectangle = textureData?.GetSourceRect() ?? data.GetSourceRect(); + (TintRectangle, TintColor) = GetTinting(item, textureData ?? data); + } + } + + public ItemActivationResult Activate( + Farmer who, + DelayedActions delayedActions, + ItemActivationType activationType + ) + { + // Item Bags: open bag UI from Primary (wheel) and Instant actions. + if ( + Item is Tool bagTool + && ( + activationType == ItemActivationType.Primary + || activationType == ItemActivationType.Instant + ) + && TryOpenItemBagsMenu(bagTool) + ) + { + return ItemActivationResult.Used; + } + + if (activationType == ItemActivationType.Instant) + { + if (Item is Tool tool) + { + var isActivating = IsActivating(); + if (!isActivating && (!Context.CanPlayerMove || who.canOnlyWalk || who.UsingTool)) + { + return ItemActivationResult.Ignored; + } + if (who.CurrentTool != tool) + { + var toolIndex = who.Items.IndexOf(tool); + if (toolIndex < 0) + { + return ItemActivationResult.Ignored; + } + who.CurrentToolIndex = toolIndex; + } + if (tool is FishingRod rod && rod.fishCaught) + { + rod.doneHoldingFish(who); + return ItemActivationResult.Used; + } + else if (tool is not MeleeWeapon) + { + who.FireTool(); + } + Game1.pressUseToolButton(); + return isActivating + ? ItemActivationResult.Used + : ItemActivationResult.ToolUseStarted; + } + else if (Item is SObject obj && obj.isPlaceable()) + { + var previousToolIndex = who.CurrentToolIndex; + var grabTile = Game1.GetPlacementGrabTile(); + var placementPosition = Utility.GetNearbyValidPlacementPosition( + who, + who.currentLocation, + obj, + (int)grabTile.X * 64, + (int)grabTile.Y * 64 + ); + try + { + who.CurrentToolIndex = who.Items.IndexOf(obj); + return Utility.tryToPlaceItem( + who.currentLocation, + obj, + (int)placementPosition.X, + (int)placementPosition.Y + ) + ? ItemActivationResult.Used + : ItemActivationResult.Ignored; + } + finally + { + who.CurrentToolIndex = previousToolIndex; + } + } + } + + return FuzzyActivation.ConsumeOrSelect( + who, + Item, + delayedActions, + activationType == ItemActivationType.Secondary + ? InventoryAction.Select + : InventoryAction.Use + ); + } + + public void ContinueActivation() + { + var who = Game1.player; + if (Item is not Tool tool || who.CurrentTool != tool) + { + return; + } + if (!who.canReleaseTool || who.Stamina < 1 || tool is FishingRod) + { + return; + } + var maxPowerModifier = tool.hasEnchantmentOfType() ? 1 : 0; + var maxPower = tool.UpgradeLevel + maxPowerModifier; + if (who.toolPower.Value >= maxPower) + { + return; + } + if (who.toolHold.Value <= 0) + { + who.toolHold.Value = (int)(tool.AnimationSpeedModifier * 600); + } + else + { + who.toolHold.Value -= Game1.currentGameTime.ElapsedGameTime.Milliseconds; + if (who.toolHold.Value <= 0) + { + who.toolPowerIncrease(); + } + } + } + + public bool EndActivation() + { + var who = Game1.player; + if (Item is Tool tool && who.CurrentTool == tool && who.UsingTool && who.canReleaseTool) + { + if (Item is not FishingRod) + { + who.EndUsingTool(); + } + return true; + } + // This isn't equivalent to vanilla logic, but if we detect that the player is no longer + // using ANY tool (which is what UsingTool tells us) then any button that the controller is + // "holding" should be released anyway, so that it can be pressed again. + if (!who.UsingTool) + { + return true; + } + return false; + } + + public string? GetActivationSound( + Farmer who, + ItemActivationType activationType, + string defaultSound + ) + { + return Item is Tool && activationType == ItemActivationType.Instant ? null : defaultSound; + } + + private static Texture2D? GetDerivedTexture(Item item) + { + if (!DerivedTextures.TryGetValue(item, out var texture)) + { + try + { + var graphicsDevice = Game1.graphics.GraphicsDevice; + var spriteBatch = Game1.spriteBatch; + var previousTargets = graphicsDevice.GetRenderTargets(); + var renderTarget = new RenderTarget2D(graphicsDevice, 64, 64); + graphicsDevice.SetRenderTarget(renderTarget); + graphicsDevice.Clear(Color.Transparent); + spriteBatch.Begin( + SpriteSortMode.Deferred, + BlendState.AlphaBlend, + rasterizerState: new() { MultiSampleAntiAlias = false }, + samplerState: SamplerState.PointClamp + ); + try + { + item.drawInMenu(spriteBatch, Vector2.Zero, 1f); + texture = renderTarget; + DerivedTextures.Add(item, texture); + } + catch + { + renderTarget.Dispose(); + throw; + } + finally + { + spriteBatch.End(); + graphicsDevice.SetRenderTargets(previousTargets); + } + } + catch (Exception ex) + { + Logger.Log( + $"Error deriving texture for item '{item.Name}' ({item.QualifiedItemId}): {ex}", + LogLevel.Error + ); + } + } + return texture; + } + + public bool IsActivating() + { + return Item is Tool tool && Game1.player.CurrentTool == tool && Game1.player.UsingTool; + } + + private static ParsedItemData? GetTextureRedirect(Item item) + { + return item is SObject obj && item.ItemId == "SmokedFish" + ? ItemRegistry.GetData(obj.preservedParentSheetIndex.Value) + : null; + } + + private static (Rectangle? tintRect, Color? tintColor) GetTinting( + Item item, + ParsedItemData data + ) + { + if (item is not ColoredObject coloredObject) + { + return default; + } + if (item.ItemId == "SmokedFish") + { + // Smoked fish implementation is unique (and private) in ColoredObject. + // We don't care about the animation here, but should draw it darkened; the quirky + // way this is implemented is to draw a tinted version of the original item sprite + // (not an overlay) sprite over top of the original sprite. + return (data.GetSourceRect(), new Color(80, 30, 10) * 0.6f); + } + return !coloredObject.ColorSameIndexAsParentSheetIndex + ? (data.GetSourceRect(1), coloredObject.color.Value) + : (null, coloredObject.color.Value); + } + + // When we call Item.getDescription(), most implementations go through `Game1.parseText` + // which splits the string itself onto multiple lines. This tries to remove that, so that we + // can do our own wrapping using our own width. + // + // N.B. The reason we don't just use `ParsedItemData.Description` is that, at least in the + // current version, it's often only a "base description" and includes format placeholders, + // or is missing suffixes. + private static string UnparseText(string text) + { + var sb = new StringBuilder(); + var isWhitespace = false; + var newlineCount = 0; + foreach (var c in text) + { + if (c == ' ' || c == '\r' || c == '\n') + { + if (!isWhitespace) + { + sb.Append(' '); + } + isWhitespace = true; + if (c == '\n') + { + newlineCount++; + } + } + else + { + // If the original text has a "paragraph", the formatted text will often look + // strange if that is also collapsed into a space. So preserve _multiple_ + // newlines somewhat as a single "paragraph break". + if (newlineCount > 1) + { + // From implementation above, newlines are counted as whitespace so we know + // that the last character will always be a space when hitting here. + sb.Length--; + sb.Append("\r\n\r\n"); + } + sb.Append(c); + isWhitespace = false; + newlineCount = 0; + } + } + if (isWhitespace) + { + sb.Length--; + } + return sb.ToString(); + } + + // Cache reflection lookups (don’t re-scan assemblies every press) + private static Type? _itemBagsBaseType; + private static MethodInfo? _openContentsMethod; + + private static bool TryOpenItemBagsMenu(Tool tool) + { + try + { + _itemBagsBaseType ??= AppDomain + .CurrentDomain.GetAssemblies() + .Select(a => a.GetType("ItemBags.Bags.ItemBag", throwOnError: false)) + .FirstOrDefault(t => t is not null); + + if (_itemBagsBaseType is null) + return false; + + if (!_itemBagsBaseType.IsInstanceOfType(tool)) + return false; + + var player = Game1.player; + if (player is null || player.CursorSlotItem is not null) + return false; + + _openContentsMethod ??= _itemBagsBaseType.GetMethod( + "OpenContents", + BindingFlags.Public | BindingFlags.Instance + ); + + if (_openContentsMethod is null) + return false; + + var parameters = _openContentsMethod.GetParameters(); + + if (parameters.Length == 0) + { + _openContentsMethod.Invoke(tool, null); + return true; + } + + if (parameters.Length == 3) + { + _openContentsMethod.Invoke( + tool, + new object?[] { player.Items, player.MaxItems, null } + ); + return true; + } + + return false; + } + catch + { + return false; + } + } +} From 68f08c1fde729dabcfbbeebd58d1ebcea0e4291e Mon Sep 17 00:00:00 2001 From: SoulSilverJD <165866563+SoulSilverJD@users.noreply.github.com> Date: Sat, 24 Jan 2026 03:59:37 -0500 Subject: [PATCH 06/24] Add files via upload --- StarControl/Menus/QuickSlotRenderer.cs | 1 - StarControl/Menus/QuickSlotResolver.cs | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/StarControl/Menus/QuickSlotRenderer.cs b/StarControl/Menus/QuickSlotRenderer.cs index c6a04bc..d0bcfcc 100644 --- a/StarControl/Menus/QuickSlotRenderer.cs +++ b/StarControl/Menus/QuickSlotRenderer.cs @@ -453,7 +453,6 @@ is Item item }; } - private static Color LumaGray(Color color, float lightness) { var v = (int)((color.R * 0.2126f + color.G * 0.7152f + color.B * 0.0722f) * lightness); diff --git a/StarControl/Menus/QuickSlotResolver.cs b/StarControl/Menus/QuickSlotResolver.cs index 0f3d058..19b3e92 100644 --- a/StarControl/Menus/QuickSlotResolver.cs +++ b/StarControl/Menus/QuickSlotResolver.cs @@ -12,7 +12,7 @@ internal class QuickSlotResolver(Farmer player, ModMenu modMenu) var exact = items.FirstOrDefault(i => i is not null && i.QualifiedItemId == id); if (exact is not null) return exact; - + Logger.Log(LogCategory.QuickSlots, $"Searching for inventory item equivalent to '{id}'..."); if (ItemRegistry.GetData(id) is not { } data) { From 353d245147ab1a20cfa36f62e9bf0f6900196b25 Mon Sep 17 00:00:00 2001 From: SoulSilverJD <165866563+SoulSilverJD@users.noreply.github.com> Date: Sat, 24 Jan 2026 04:01:22 -0500 Subject: [PATCH 07/24] Add files via upload --- StarControl/Graphics/Sprite.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/StarControl/Graphics/Sprite.cs b/StarControl/Graphics/Sprite.cs index ac7a3e9..2434948 100644 --- a/StarControl/Graphics/Sprite.cs +++ b/StarControl/Graphics/Sprite.cs @@ -23,7 +23,7 @@ public static Sprite ForItemId(string id) var data = ItemRegistry.GetDataOrErrorItem(id); return new(data.GetTexture(), data.GetSourceRect()); } - + public static Sprite FromItem(Item item) { ArgumentNullException.ThrowIfNull(item); @@ -95,7 +95,7 @@ public static Sprite FromItem(Item item) return null; } } - + /// /// Attempts to load a sprite from configuration data. /// From e4e4aafa296b9b4a4f3857bba140fea6649afd41 Mon Sep 17 00:00:00 2001 From: SoulSilverJD <165866563+SoulSilverJD@users.noreply.github.com> Date: Sat, 24 Jan 2026 07:13:49 -0500 Subject: [PATCH 08/24] Missed Some Quick Action Menus --- .../UI/QuickSlotConfigurationViewModel.cs | 264 ++++++++------- .../UI/QuickSlotPickerItemViewModel.cs | 313 ++++++++++-------- 2 files changed, 318 insertions(+), 259 deletions(-) diff --git a/StarControl/UI/QuickSlotConfigurationViewModel.cs b/StarControl/UI/QuickSlotConfigurationViewModel.cs index 3c2d6da..7ac418c 100644 --- a/StarControl/UI/QuickSlotConfigurationViewModel.cs +++ b/StarControl/UI/QuickSlotConfigurationViewModel.cs @@ -1,112 +1,152 @@ -using System.ComponentModel; -using PropertyChanged.SourceGenerator; -using StarControl.Graphics; -using StardewValley.ItemTypeDefinitions; - -namespace StarControl.UI; - -internal partial class QuickSlotConfigurationViewModel -{ - private static readonly Color AssignedColor = new(50, 100, 50); - private static readonly Color UnassignedColor = new(60, 60, 60); - private static readonly Color UnavailableColor = new(0x44, 0x44, 0x44, 0x44); - - public Color CurrentAssignmentColor => IsAssigned ? AssignedColor : UnassignedColor; - public string CurrentAssignmentLabel => - IsAssigned - ? I18n.Config_QuickSlot_Assigned_Title() - : I18n.Config_QuickSlot_Unassigned_Title(); - - [DependsOn(nameof(ItemData), nameof(ModAction))] - public Sprite? Icon => GetIcon(); - public bool IsAssigned => ItemData is not null || ModAction is not null; - - public Color Tint => - ItemData is not null && Game1.player.Items.FindUsableItem(ItemData.QualifiedItemId) is null - ? UnavailableColor - : Color.White; - - [DependsOn(nameof(ItemData), nameof(ModAction))] - public TooltipData Tooltip => GetTooltip(); - - [Notify] - private ParsedItemData? itemData; - - [Notify] - private ModMenuItemConfigurationViewModel? modAction; - - [Notify] - private bool requireConfirmation; - - [Notify] - private bool useSecondaryAction; - - public void Clear() - { - ItemData = null; - ModAction = null; - UseSecondaryAction = false; - } - - private Sprite? GetIcon() - { - return ItemData is not null - ? new(ItemData.GetTexture(), ItemData.GetSourceRect()) - : ModAction?.Icon; - } - - private TooltipData GetTooltip() - { - if (ItemData is not null) - { - return new( - Title: ItemData.DisplayName, - Text: ItemData.Description, - Item: ItemRegistry.Create(ItemData.QualifiedItemId) - ); - } - if (ModAction is not null) - { - return !string.IsNullOrEmpty(ModAction.Description) - ? new(Title: ModAction.Name, Text: ModAction.Description) - : new(ModAction.Name); - } - return new(I18n.Config_QuickActions_EmptySlot_Title()); - } - - private void ModAction_PropertyChanged(object? sender, PropertyChangedEventArgs e) - { - if (e.PropertyName == nameof(ModAction.Icon)) - { - OnPropertyChanged(new(nameof(Icon))); - } - else if (e.PropertyName is nameof(ModAction.Name) or nameof(ModAction.Description)) - { - OnPropertyChanged(new(nameof(Tooltip))); - } - } - - private void OnItemDataChanged() - { - if (ItemData is not null) - { - ModAction = null; - } - } - - private void OnModActionChanged( - ModMenuItemConfigurationViewModel? oldValue, - ModMenuItemConfigurationViewModel? newValue - ) - { - if (oldValue is not null) - { - oldValue.PropertyChanged -= ModAction_PropertyChanged; - } - if (newValue is not null) - { - ItemData = null; - newValue.PropertyChanged += ModAction_PropertyChanged; - } - } -} +using System.ComponentModel; +using System.Linq; +using PropertyChanged.SourceGenerator; +using StarControl.Graphics; +using StardewValley; +using StardewValley.ItemTypeDefinitions; + +namespace StarControl.UI; + +internal partial class QuickSlotConfigurationViewModel +{ + private static readonly Color AssignedColor = new(50, 100, 50); + private static readonly Color UnassignedColor = new(60, 60, 60); + private static readonly Color UnavailableColor = new(0x44, 0x44, 0x44, 0x44); + + public Color CurrentAssignmentColor => IsAssigned ? AssignedColor : UnassignedColor; + public string CurrentAssignmentLabel => + IsAssigned + ? I18n.Config_QuickSlot_Assigned_Title() + : I18n.Config_QuickSlot_Unassigned_Title(); + + [DependsOn(nameof(ItemData), nameof(ModAction))] + public Sprite? Icon => GetIcon(); + public bool IsAssigned => ItemData is not null || ModAction is not null; + + public Color Tint => + ItemData is not null && Game1.player.Items.FindUsableItem(ItemData.QualifiedItemId) is null + ? UnavailableColor + : Color.White; + + [DependsOn(nameof(ItemData), nameof(ModAction))] + public TooltipData Tooltip => GetTooltip(); + + [Notify] + private ParsedItemData? itemData; + + [Notify] + private ModMenuItemConfigurationViewModel? modAction; + + [Notify] + private bool requireConfirmation; + + [Notify] + private bool useSecondaryAction; + + public void Clear() + { + ItemData = null; + ModAction = null; + UseSecondaryAction = false; + } + + private Sprite? GetIcon() + { + if (ItemData is not null) + { + // Weird-item-proofing: Item Bags and similar mods don't register ItemRegistry data. + // If this is an error item, try to find the real inventory item and render it via drawInMenu. + if (ItemData.IsErrorItem) + { + var invItem = Game1.player?.Items?.FirstOrDefault(i => + i is not null && i.QualifiedItemId == ItemData.QualifiedItemId + ); + + if (invItem is not null) + { + return Sprite.FromItem(invItem); + } + } + + // Normal path for registered items + return new(ItemData.GetTexture(), ItemData.GetSourceRect()); + } + + return ModAction?.Icon; + } + + private TooltipData GetTooltip() + { + if (ItemData is not null) + { + // Weird-item-proofing: if ItemData is an error item, use the real inventory item instead. + if (ItemData.IsErrorItem) + { + var invItem = Game1.player?.Items?.FirstOrDefault(i => + i is not null && i.QualifiedItemId == ItemData.QualifiedItemId + ); + + if (invItem is not null) + { + return new( + Title: invItem.DisplayName, + Text: invItem.getDescription(), + Item: invItem + ); + } + } + + // Normal path for registered items + return new( + Title: ItemData.DisplayName, + Text: ItemData.Description, + Item: ItemRegistry.Create(ItemData.QualifiedItemId) + ); + } + + if (ModAction is not null) + { + return !string.IsNullOrEmpty(ModAction.Description) + ? new(Title: ModAction.Name, Text: ModAction.Description) + : new(ModAction.Name); + } + + return new(I18n.Config_QuickActions_EmptySlot_Title()); + } + + private void ModAction_PropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (e.PropertyName == nameof(ModAction.Icon)) + { + OnPropertyChanged(new(nameof(Icon))); + } + else if (e.PropertyName is nameof(ModAction.Name) or nameof(ModAction.Description)) + { + OnPropertyChanged(new(nameof(Tooltip))); + } + } + + private void OnItemDataChanged() + { + if (ItemData is not null) + { + ModAction = null; + } + } + + private void OnModActionChanged( + ModMenuItemConfigurationViewModel? oldValue, + ModMenuItemConfigurationViewModel? newValue + ) + { + if (oldValue is not null) + { + oldValue.PropertyChanged -= ModAction_PropertyChanged; + } + if (newValue is not null) + { + ItemData = null; + newValue.PropertyChanged += ModAction_PropertyChanged; + } + } +} diff --git a/StarControl/UI/QuickSlotPickerItemViewModel.cs b/StarControl/UI/QuickSlotPickerItemViewModel.cs index bb5f34f..3eb9fcb 100644 --- a/StarControl/UI/QuickSlotPickerItemViewModel.cs +++ b/StarControl/UI/QuickSlotPickerItemViewModel.cs @@ -1,147 +1,166 @@ -using Microsoft.Xna.Framework.Graphics; -using StarControl.Graphics; -using StardewValley.ItemTypeDefinitions; -using StardewValley.Objects; - -namespace StarControl.UI; - -/// -/// View model for an item displayed in the quick-slot picker, supporting vanilla items and mod actions. -/// -/// -/// -/// Item images require a specialized context because they are not always simple textures. In many -/// cases, the item must be drawn with a tint or overlay, which is actually a second sprite. -/// -/// -/// Delegate to update a quick slot to reference this item. -/// The texture where the item image is located; both the base image and -/// the overlay (if applicable) are expected to be found in this texture. -/// The region of the where the base image -/// for the item is located. -/// The region of the where the tint or -/// overlay image for the item is located. -/// The tint color, which either applies to the image in the -/// , if one is specified, or the base image (in -/// ) if no is specified. -internal class QuickSlotPickerItemViewModel( - Action update, - Texture2D sourceTexture, - Rectangle? sourceRect, - Rectangle? tintRect = null, - Color? tintColor = null, - TooltipData? tooltip = null -) -{ - /// - /// Whether the image uses a separate . - /// - public bool HasTintSprite => TintSprite is not null; - - /// - /// Sprite data for the base image. - /// - public Sprite? Sprite { get; } = new(sourceTexture, sourceRect ?? sourceTexture.Bounds); - - /// - /// Tint color for the . - /// - public Color SpriteColor { get; } = (!tintRect.HasValue ? tintColor : null) ?? Color.White; - - /// - /// Sprite data for the overlay image, if any. - /// - public Sprite? TintSprite { get; } = - tintRect.HasValue ? new(sourceTexture, tintRect.Value) : null; - - /// - /// Tint color for the . - /// - public Color TintSpriteColor { get; } = tintColor ?? Color.White; - - public TooltipData? Tooltip { get; } = tooltip; - - private static readonly Color SmokedFishTintColor = new Color(80, 30, 10) * 0.6f; - - /// - /// Creates a new instance using the game data for a known item. - /// - /// The item to display. - public static QuickSlotPickerItemViewModel ForItem(Item item) - { - var data = ItemRegistry.GetDataOrErrorItem(item.QualifiedItemId); - if (item is SObject obj && obj.preserve.Value == SObject.PreserveType.SmokedFish) - { - var fishData = ItemRegistry.GetDataOrErrorItem(obj.GetPreservedItemId()); - return new( - slot => slot.ItemData = data, - fishData.GetTexture(), - fishData.GetSourceRect(), - fishData.GetSourceRect(), - SmokedFishTintColor - ); - } - Color? tintColor = null; - Rectangle? tintRect = null; - if (item is ColoredObject co) - { - tintColor = co.color.Value; - if (!co.ColorSameIndexAsParentSheetIndex) - { - tintRect = data.GetSourceRect(1); - } - } - TooltipData tooltip = !string.IsNullOrEmpty(item.getDescription()) - ? new(Title: item.DisplayName, Text: item.getDescription(), Item: item) - : new(Text: item.getDescription(), Item: item); - return new( - slot => slot.ItemData = data.GetBaseItem(), - data.GetTexture(), - data.GetSourceRect(), - tintRect, - tintColor, - tooltip - ); - } - - /// - /// Creates a new instance using the base data for an in-game item. - /// - /// The item data. - public static QuickSlotPickerItemViewModel ForItemData(ParsedItemData data) - { - TooltipData tooltip = !string.IsNullOrEmpty(data.Description) - ? new(Title: data.DisplayName, Text: data.Description) - : new(data.DisplayName); - return new( - slot => slot.ItemData = data.GetBaseItem(), - data.GetTexture(), - data.GetSourceRect(), - tooltip: tooltip - ); - } - - /// - /// Creates a new instance using the configuration for a mod action. - /// - /// The mod action. - public static QuickSlotPickerItemViewModel ForModAction(ModMenuItemConfigurationViewModel item) - { - var icon = item.Icon; - return new( - slot => slot.ModAction = item, - icon.Texture, - icon.SourceRect, - tooltip: item.Tooltip - ); - } - - /// - /// Updates a quick slot to point to this item. - /// - /// The quick slot to be updated. - public void UpdateSlot(QuickSlotConfigurationViewModel slot) - { - update(slot); - } -} +using Microsoft.Xna.Framework.Graphics; +using StarControl.Graphics; +using StardewValley; +using StardewValley.ItemTypeDefinitions; +using StardewValley.Objects; + +namespace StarControl.UI; + +/// +/// View model for an item displayed in the quick-slot picker, supporting vanilla items and mod actions. +/// +/// +/// +/// Item images require a specialized context because they are not always simple textures. In many +/// cases, the item must be drawn with a tint or overlay, which is actually a second sprite. +/// +/// +/// Delegate to update a quick slot to reference this item. +/// The texture where the item image is located; both the base image and +/// the overlay (if applicable) are expected to be found in this texture. +/// The region of the where the base image +/// for the item is located. +/// The region of the where the tint or +/// overlay image for the item is located. +/// The tint color, which either applies to the image in the +/// , if one is specified, or the base image (in +/// ) if no is specified. +internal class QuickSlotPickerItemViewModel( + Action update, + Texture2D sourceTexture, + Rectangle? sourceRect, + Rectangle? tintRect = null, + Color? tintColor = null, + TooltipData? tooltip = null +) +{ + /// + /// Whether the image uses a separate . + /// + public bool HasTintSprite => TintSprite is not null; + + /// + /// Sprite data for the base image. + /// + public Sprite? Sprite { get; } = new(sourceTexture, sourceRect ?? sourceTexture.Bounds); + + /// + /// Tint color for the . + /// + public Color SpriteColor { get; } = (!tintRect.HasValue ? tintColor : null) ?? Color.White; + + /// + /// Sprite data for the overlay image, if any. + /// + public Sprite? TintSprite { get; } = + tintRect.HasValue ? new(sourceTexture, tintRect.Value) : null; + + /// + /// Tint color for the . + /// + public Color TintSpriteColor { get; } = tintColor ?? Color.White; + + public TooltipData? Tooltip { get; } = tooltip; + + private static readonly Color SmokedFishTintColor = new Color(80, 30, 10) * 0.6f; + + /// + /// Creates a new instance using the game data for a known item. + /// + /// The item to display. + public static QuickSlotPickerItemViewModel ForItem(Item item) + { + // Keep SmokedFish behavior exactly as before (special tint/overlay logic) + var data = ItemRegistry.GetDataOrErrorItem(item.QualifiedItemId); + if (item is SObject obj && obj.preserve.Value == SObject.PreserveType.SmokedFish) + { + var fishData = ItemRegistry.GetDataOrErrorItem(obj.GetPreservedItemId()); + return new( + slot => slot.ItemData = data, + fishData.GetTexture(), + fishData.GetSourceRect(), + fishData.GetSourceRect(), + SmokedFishTintColor + ); + } + + // Tooltip is shared by both paths + TooltipData tooltip = !string.IsNullOrEmpty(item.getDescription()) + ? new(Title: item.DisplayName, Text: item.getDescription(), Item: item) + : new(Text: item.getDescription(), Item: item); + + // If the registry data is an error item AND the item isn't vanilla, render via drawInMenu. + if (data.IsErrorItem && item.GetType().Assembly != typeof(SObject).Assembly) + { + var sprite = Sprite.FromItem(item); + return new( + slot => slot.ItemData = data.GetBaseItem(), + sprite.Texture, + sprite.SourceRect, + tooltip: tooltip + ); + } + + // Normal (registered) items keep tint/overlay behavior + Color? tintColor = null; + Rectangle? tintRect = null; + if (item is ColoredObject co) + { + tintColor = co.color.Value; + if (!co.ColorSameIndexAsParentSheetIndex) + { + tintRect = data.GetSourceRect(1); + } + } + + return new( + slot => slot.ItemData = data.GetBaseItem(), + data.GetTexture(), + data.GetSourceRect(), + tintRect, + tintColor, + tooltip + ); + } + + /// + /// Creates a new instance using the base data for an in-game item. + /// + /// The item data. + public static QuickSlotPickerItemViewModel ForItemData(ParsedItemData data) + { + TooltipData tooltip = !string.IsNullOrEmpty(data.Description) + ? new(Title: data.DisplayName, Text: data.Description) + : new(data.DisplayName); + return new( + slot => slot.ItemData = data.GetBaseItem(), + data.GetTexture(), + data.GetSourceRect(), + tooltip: tooltip + ); + } + + /// + /// Creates a new instance using the configuration for a mod action. + /// + /// The mod action. + public static QuickSlotPickerItemViewModel ForModAction(ModMenuItemConfigurationViewModel item) + { + var icon = item.Icon; + return new( + slot => slot.ModAction = item, + icon.Texture, + icon.SourceRect, + tooltip: item.Tooltip + ); + } + + /// + /// Updates a quick slot to point to this item. + /// + /// The quick slot to be updated. + public void UpdateSlot(QuickSlotConfigurationViewModel slot) + { + update(slot); + } +} From f8a6621cd206d02f5bd270d90b54308b6de0c882 Mon Sep 17 00:00:00 2001 From: SoulSilverJD <165866563+SoulSilverJD@users.noreply.github.com> Date: Sat, 24 Jan 2026 17:04:27 -0500 Subject: [PATCH 09/24] Add files via upload --- StarControl/IItemLookup.cs | 38 ++++++++++++++++++++------------------ 1 file changed, 20 insertions(+), 18 deletions(-) diff --git a/StarControl/IItemLookup.cs b/StarControl/IItemLookup.cs index b8dd58b..a907f99 100644 --- a/StarControl/IItemLookup.cs +++ b/StarControl/IItemLookup.cs @@ -1,18 +1,20 @@ -namespace StarControl; - -/// -/// Provides the fields required to look up a menu item in any configured menu. -/// -public interface IItemLookup -{ - /// - /// Identifies an item to select or use. Can be the ID of a regular game item or of a Mod Menu - /// action, depending on the . - /// - string Id { get; } - - /// - /// The type of ID that the refers to. - /// - ItemIdType IdType { get; } -} +namespace StarControl; + +/// +/// Provides the fields required to look up a menu item in any configured menu. +/// +public interface IItemLookup +{ + /// + /// Identifies an item to select or use. Can be the ID of a regular game item or of a Mod Menu + /// action, depending on the . + /// + string Id { get; } + + string? SubId { get; } + + /// + /// The type of ID that the refers to. + /// + ItemIdType IdType { get; } +} From 6e94a34f7f8fe20de19a5b06392f512df4df87ea Mon Sep 17 00:00:00 2001 From: SoulSilverJD <165866563+SoulSilverJD@users.noreply.github.com> Date: Sat, 24 Jan 2026 17:05:15 -0500 Subject: [PATCH 10/24] Add files via upload --- StarControl/Config/QuickSlotConfiguration.cs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/StarControl/Config/QuickSlotConfiguration.cs b/StarControl/Config/QuickSlotConfiguration.cs index 953b89a..a278057 100644 --- a/StarControl/Config/QuickSlotConfiguration.cs +++ b/StarControl/Config/QuickSlotConfiguration.cs @@ -17,6 +17,8 @@ public class QuickSlotConfiguration : IConfigEquatable, /// public string Id { get; set; } = ""; + public string? SubId { get; set; } + /// /// Whether to display a confirmation dialog before activating the item in this slot. /// @@ -42,6 +44,7 @@ public bool Equals(QuickSlotConfiguration? other) } return IdType == other.IdType && Id == other.Id + && SubId == other.SubId && RequireConfirmation == other.RequireConfirmation && UseSecondaryAction == other.UseSecondaryAction; } From eea22238ee22e2df129abd2503d85643980acd75 Mon Sep 17 00:00:00 2001 From: SoulSilverJD <165866563+SoulSilverJD@users.noreply.github.com> Date: Sat, 24 Jan 2026 17:05:58 -0500 Subject: [PATCH 11/24] Add files via upload --- StarControl/Data/RemappingSlot.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/StarControl/Data/RemappingSlot.cs b/StarControl/Data/RemappingSlot.cs index a5893ae..3321794 100644 --- a/StarControl/Data/RemappingSlot.cs +++ b/StarControl/Data/RemappingSlot.cs @@ -29,4 +29,6 @@ public class RemappingSlot : IItemLookup /// Menu action, depending on the . /// public string Id { get; set; } = ""; + + public string? SubId { get; set; } } From ce11e7c9e3e363b51c9bb6526295618d0e24dddd Mon Sep 17 00:00:00 2001 From: SoulSilverJD <165866563+SoulSilverJD@users.noreply.github.com> Date: Sat, 24 Jan 2026 17:06:54 -0500 Subject: [PATCH 12/24] Add files via upload --- StarControl/Menus/QuickSlotController.cs | 2 +- StarControl/Menus/QuickSlotRenderer.cs | 1065 +++++++++++----------- StarControl/Menus/QuickSlotResolver.cs | 293 +++--- StarControl/Menus/RemappingController.cs | 2 +- 4 files changed, 698 insertions(+), 664 deletions(-) diff --git a/StarControl/Menus/QuickSlotController.cs b/StarControl/Menus/QuickSlotController.cs index 1904860..ebcbc2e 100644 --- a/StarControl/Menus/QuickSlotController.cs +++ b/StarControl/Menus/QuickSlotController.cs @@ -106,7 +106,7 @@ private void RefreshSlots() + $"secondary action = {slot.UseSecondaryAction}, " + $"require confirmation = {slot.RequireConfirmation}" ); - var slottedItem = resolver.ResolveItem(slot.Id, slot.IdType); + var slottedItem = resolver.ResolveItem(slot.Id, slot.SubId, slot.IdType); if (slottedItem is not null) { Logger.Log( diff --git a/StarControl/Menus/QuickSlotRenderer.cs b/StarControl/Menus/QuickSlotRenderer.cs index d0bcfcc..a12ec00 100644 --- a/StarControl/Menus/QuickSlotRenderer.cs +++ b/StarControl/Menus/QuickSlotRenderer.cs @@ -1,529 +1,536 @@ -using Microsoft.Xna.Framework; -using Microsoft.Xna.Framework.Graphics; -using StarControl.Config; -using StarControl.Data; -using StarControl.Graphics; -using StardewValley; - -namespace StarControl.Menus; - -internal class QuickSlotRenderer(GraphicsDevice graphicsDevice, ModConfig config) -{ - private const float MENU_SCALE = 0.7f; // scales the entire quick action menu - private const float SLOT_CONTENT_SCALE = 1.2f; // scales slot visuals without moving the menu - - private record ButtonFlash(FlashType Type, float DurationMs, float ElapsedMs = 0); - - private enum FlashType - { - Delay, - Error, - } - - private enum PromptPosition - { - Above, - Below, - Left, - Right, - } - - public float BackgroundOpacity { get; set; } = 1; - public float SpriteOpacity { get; set; } = 1; - - public IReadOnlyDictionary SlotItems { get; set; } = - new Dictionary(); - public IReadOnlyDictionary Slots { get; set; } = - new Dictionary(); - - public bool UnassignedButtonsVisible { get; set; } = true; - - private const int IMAGE_SIZE = 64; - private const int SLOT_PADDING = 20; - private const int SLOT_SIZE = (int)((IMAGE_SIZE + SLOT_PADDING * 2) * MENU_SCALE * 1f); - private const int SLOT_DISTANCE = (int)((SLOT_SIZE - 12) * MENU_SCALE * 2.6f); - private const int MARGIN_OUTER = 32; - private const int MARGIN_HORIZONTAL = 120; - private const int MARGIN_VERTICAL = 16; - private const int PROMPT_OFFSET = SLOT_SIZE / 2; - private const int PROMPT_SIZE = 26; - private const int BACKGROUND_RADIUS = (int)( - (SLOT_DISTANCE + SLOT_SIZE / 2 + MARGIN_OUTER) * 0.7f - ); - - private static readonly Color OuterBackgroundColor = new(16, 16, 16, 210); - - private readonly Dictionary flashes = []; - private readonly HashSet enabledSlots = []; - private readonly Dictionary slotSprites = []; - private readonly GraphicsDevice graphicsDevice = graphicsDevice; - - private Color disabledBackgroundColor = Color.Transparent; - private Color innerBackgroundColor = Color.Transparent; - private bool isDirty = true; - private float quickSlotScale = 1f; - private Texture2D outerBackground = null!; - private Texture2D slotBackground = null!; - private Texture2D? uiTexture; - private ButtonIconSet? uiTextureIconSet; - - public void Draw(SpriteBatch b, Rectangle viewport) - { - UpdateScale(); - if (isDirty) - { - innerBackgroundColor = (Color)config.Style.OuterBackgroundColor * 0.6f; - disabledBackgroundColor = LumaGray(innerBackgroundColor, 0.75f); - RefreshSlots(); - } - - var leftOrigin = new Point( - viewport.Left - + Scale(MARGIN_HORIZONTAL * MENU_SCALE) - + Scale(MARGIN_OUTER * MENU_SCALE) - + Scale(SLOT_SIZE / 2f), - viewport.Bottom - - Scale(MARGIN_VERTICAL * MENU_SCALE) - - Scale(MARGIN_OUTER * MENU_SCALE) - - Scale(SLOT_SIZE) - - Scale(SLOT_SIZE / 2f) - ); - var leftShoulderPosition = leftOrigin.AddX(Scale(SLOT_DISTANCE * MENU_SCALE)); - if ( - UnassignedButtonsVisible - || HasSlotSprite(SButton.DPadLeft) - || HasSlotSprite(SButton.DPadUp) - || HasSlotSprite(SButton.DPadRight) - || HasSlotSprite(SButton.DPadDown) - ) - { - var leftBackgroundRect = GetCircleRect( - leftOrigin.AddX(Scale(SLOT_DISTANCE * MENU_SCALE)), - Scale(BACKGROUND_RADIUS) - ); - b.Draw(outerBackground, leftBackgroundRect, OuterBackgroundColor * BackgroundOpacity); - DrawSlot(b, leftOrigin, SButton.DPadLeft, PromptPosition.Left); - DrawSlot( - b, - leftOrigin.Add( - Scale(SLOT_DISTANCE * MENU_SCALE), - -Scale(SLOT_DISTANCE * MENU_SCALE) - ), - SButton.DPadUp, - PromptPosition.Above - ); - DrawSlot( - b, - leftOrigin.Add( - Scale(SLOT_DISTANCE * MENU_SCALE), - Scale(SLOT_DISTANCE * MENU_SCALE) - ), - SButton.DPadDown, - PromptPosition.Below - ); - DrawSlot( - b, - leftOrigin.AddX(Scale(SLOT_DISTANCE * MENU_SCALE * 2f)), - SButton.DPadRight, - PromptPosition.Right - ); - leftShoulderPosition.Y -= Scale(SLOT_DISTANCE * 2.5f * MENU_SCALE); - } - if (enabledSlots.Contains(SButton.LeftShoulder)) - { - DrawSlot( - b, - leftShoulderPosition, - SButton.LeftShoulder, - PromptPosition.Above, - darken: true - ); - } - - var rightOrigin = new Point( - viewport.Right - - Scale(MARGIN_HORIZONTAL * MENU_SCALE) - - Scale(MARGIN_OUTER * MENU_SCALE) - - Scale(SLOT_SIZE / 2f), - leftOrigin.Y - ); - var rightShoulderPosition = rightOrigin.AddX(-Scale(SLOT_DISTANCE * MENU_SCALE)); - if ( - UnassignedButtonsVisible - || HasSlotSprite(SButton.ControllerX) - || HasSlotSprite(SButton.ControllerY) - || HasSlotSprite(SButton.ControllerA) - || HasSlotSprite(SButton.ControllerB) - ) - { - var rightBackgroundRect = GetCircleRect( - rightOrigin.AddX(-Scale(SLOT_DISTANCE * MENU_SCALE)), - Scale(BACKGROUND_RADIUS) - ); - b.Draw(outerBackground, rightBackgroundRect, OuterBackgroundColor * BackgroundOpacity); - DrawSlot(b, rightOrigin, SButton.ControllerB, PromptPosition.Right); - DrawSlot( - b, - rightOrigin.Add( - -Scale(SLOT_DISTANCE * MENU_SCALE), - -Scale(SLOT_DISTANCE * MENU_SCALE) - ), - SButton.ControllerY, - PromptPosition.Above - ); - DrawSlot( - b, - rightOrigin.Add( - -Scale(SLOT_DISTANCE * MENU_SCALE), - Scale(SLOT_DISTANCE * MENU_SCALE) - ), - SButton.ControllerA, - PromptPosition.Below - ); - DrawSlot( - b, - rightOrigin.AddX(-Scale(SLOT_DISTANCE * MENU_SCALE * 2f)), - SButton.ControllerX, - PromptPosition.Left - ); - rightShoulderPosition.Y -= Scale(SLOT_DISTANCE * 2.5f * MENU_SCALE); - } - if (enabledSlots.Contains(SButton.RightShoulder)) - { - DrawSlot( - b, - rightShoulderPosition, - SButton.RightShoulder, - PromptPosition.Above, - darken: true - ); - } - } - - private void UpdateScale(bool force = false) - { - var desiredScale = Math.Clamp(config.Style.QuickActionScale, 0.5f, 1.5f); - if ( - !force - && MathF.Abs(desiredScale - quickSlotScale) < 0.001f - && outerBackground is not null - ) - { - return; - } - quickSlotScale = desiredScale; - outerBackground?.Dispose(); - slotBackground?.Dispose(); - outerBackground = ShapeTexture.CreateCircle( - Scale((SLOT_SIZE + SLOT_SIZE / 2f + MARGIN_OUTER) * MENU_SCALE), - filled: true, - graphicsDevice: graphicsDevice - ); - slotBackground = ShapeTexture.CreateCircle( - Scale(SLOT_SIZE / 2f), - filled: true, - graphicsDevice: graphicsDevice - ); - isDirty = true; - } - - private int Scale(float value) - { - return (int)MathF.Round(value * quickSlotScale); - } - - public void FlashDelay(SButton button) - { - flashes[button] = new(FlashType.Delay, config.Input.ActivationDelayMs); - } - - public void FlashError(SButton button) - { - flashes[button] = new(FlashType.Error, Animation.ERROR_FLASH_DURATION_MS); - } - - public bool HasSlotSprite(SButton button) - { - return slotSprites.ContainsKey(button); - } - - public void Invalidate() - { - isDirty = true; - } - - public void Update(TimeSpan elapsed) - { - foreach (var (button, flash) in flashes) - { - var flashElapsedMs = flash.ElapsedMs + (float)elapsed.TotalMilliseconds; - if (flashElapsedMs >= flash.DurationMs) - { - flashes.Remove(button); - continue; - } - flashes[button] = flash with { ElapsedMs = flashElapsedMs }; - } - } - - private void DrawSlot( - SpriteBatch b, - Point origin, - SButton button, - PromptPosition promptPosition, - bool darken = false - ) - { - var isAssigned = slotSprites.TryGetValue(button, out var sprite); - if (!isAssigned && !UnassignedButtonsVisible) - { - return; - } - - var isEnabled = enabledSlots.Contains(button); - var backgroundRect = GetCircleRect(origin, Scale(SLOT_SIZE * SLOT_CONTENT_SCALE / 2f)); - if (darken) - { - var darkenRect = backgroundRect; - darkenRect.Inflate(4, 4); - b.Draw(slotBackground, darkenRect, Color.Black * BackgroundOpacity); - } - var backgroundColor = GetBackgroundColor(button, isAssigned && isEnabled); - b.Draw(slotBackground, backgroundRect, backgroundColor * BackgroundOpacity); - - var slotOpacity = isEnabled ? 1f : 0.5f; - - if (isAssigned) - { - var spriteRect = GetCircleRect( - origin, - Scale(IMAGE_SIZE * MENU_SCALE * SLOT_CONTENT_SCALE / 2f) - ); - if (SlotItems.TryGetValue(button, out var item) && item.Texture is not null) - { - ItemRenderer.Draw( - b, - item, - spriteRect, - config.Style, - opacity: slotOpacity * SpriteOpacity - ); - } - else - { - b.Draw( - sprite!.Texture, - spriteRect, - sprite.SourceRect, - Color.White * slotOpacity * SpriteOpacity - ); - } - } - - if (GetPromptSprite(button) is { } promptSprite) - { - var promptOffset = Scale(PROMPT_OFFSET * SLOT_CONTENT_SCALE); - var promptOrigin = promptPosition switch - { - PromptPosition.Above => origin.AddY(-promptOffset), - PromptPosition.Below => origin.AddY(promptOffset), - PromptPosition.Left => origin.AddX(-promptOffset), - PromptPosition.Right => origin.AddX(promptOffset), - _ => throw new ArgumentException( - $"Invalid prompt position: {promptPosition}", - nameof(promptPosition) - ), - }; - var promptRect = GetCircleRect( - promptOrigin, - Scale(PROMPT_SIZE * SLOT_CONTENT_SCALE / 2f) - ); - b.Draw( - promptSprite.Texture, - promptRect, - promptSprite.SourceRect, - Color.White * slotOpacity * SpriteOpacity - ); - } - } - - private Color GetBackgroundColor(SButton button, bool enabled) - { - var baseColor = enabled ? innerBackgroundColor : disabledBackgroundColor; - if (!flashes.TryGetValue(button, out var flash)) - { - return baseColor; - } - var (flashColor, position) = flash.Type switch - { - FlashType.Delay => ( - config.Style.HighlightColor, - Animation.GetDelayFlashPosition(flash.ElapsedMs) - ), - FlashType.Error => (Color.Red, Animation.GetErrorFlashPosition(flash.ElapsedMs)), - _ => (Color.White, 0), - }; - return Color.Lerp(baseColor, flashColor, position); - } - - private static Rectangle GetCircleRect(Point center, int radius) - { - int length = radius * 2; - return new(center.X - radius, center.Y - radius, length, length); - } - - private static Sprite GetIconSprite(IconConfig icon) - { - return !string.IsNullOrEmpty(icon.ItemId) - ? Sprite.ForItemId(icon.ItemId) - : Sprite.TryLoad(icon.TextureAssetPath, icon.SourceRect) - ?? Sprite.ForItemId("Error_Invalid"); - } - - private Sprite? GetModItemSprite(string id) - { - var itemConfig = config - .Items.ModMenuPages.SelectMany(items => items) - .FirstOrDefault(item => item.Id == id); - return itemConfig?.Icon is { } icon ? GetIconSprite(icon) : null; - } - - private Sprite? GetPromptSprite(SButton button) - { - var (rowIndex, columnIndex) = button switch - { - SButton.DPadUp => (1, 0), - SButton.DPadRight => (1, 1), - SButton.DPadDown => (1, 2), - SButton.DPadLeft => (1, 3), - SButton.ControllerA => (1, 4), - SButton.ControllerB => (1, 5), - SButton.ControllerX => (1, 6), - SButton.ControllerY => (1, 7), - SButton.LeftTrigger => (2, 0), - SButton.RightTrigger => (2, 1), - SButton.LeftShoulder => (2, 2), - SButton.RightShoulder => (2, 3), - SButton.ControllerBack => (2, 4), - SButton.ControllerStart => (2, 5), - SButton.LeftStick => (2, 6), - SButton.RightStick => (2, 7), - _ => (-1, -1), - }; - if (columnIndex == -1) - { - return null; - } - var texture = GetUiTexture(); - return new(texture, new(columnIndex * 16, rowIndex * 16, 16, 16)); - } - - private Texture2D GetUiTexture() - { - var desiredSet = config.Style.ButtonIconSet; - if (uiTexture is null || uiTextureIconSet != desiredSet) - { - var assetPath = - desiredSet == ButtonIconSet.PlayStation - ? Sprites.UI_PLAYSTATION_TEXTURE_PATH - : Sprites.UI_TEXTURE_PATH; - uiTexture = Game1.content.Load(assetPath); - uiTextureIconSet = desiredSet; - } - return uiTexture; - } - - private Sprite? GetSlotSprite(IItemLookup itemLookup, ICollection inventoryItems) - { - if (string.IsNullOrWhiteSpace(itemLookup.Id)) - return null; - - return itemLookup.IdType switch - { - ItemIdType.GameItem => QuickSlotResolver.ResolveInventoryItem( - itemLookup.Id, - inventoryItems - ) - is Item item - ? Sprite.FromItem(item) // handles Item Bags via drawInMenu fallback - : Sprite.ForItemId(itemLookup.Id), // fallback for normal items - - ItemIdType.ModItem => GetModItemSprite(itemLookup.Id), - _ => null, - }; - } - - private static Color LumaGray(Color color, float lightness) - { - var v = (int)((color.R * 0.2126f + color.G * 0.7152f + color.B * 0.0722f) * lightness); - return new(v, v, v); - } - - private void RefreshSlots() - { - Logger.Log(LogCategory.QuickSlots, "Starting refresh of quick slot renderer data."); - enabledSlots.Clear(); - slotSprites.Clear(); - foreach (var (button, slotConfig) in Slots) - { - Logger.Log(LogCategory.QuickSlots, $"Checking slot for {button}..."); - Sprite? sprite = null; - if (SlotItems.TryGetValue(button, out var item)) - { - if (item.Texture is not null) - { - Logger.Log( - LogCategory.QuickSlots, - $"Using configured item sprite for {item.Title} in {button} slot." - ); - sprite = new(item.Texture, item.SourceRectangle ?? item.Texture.Bounds); - } - else - { - Logger.Log( - LogCategory.QuickSlots, - $"Item {item.Title} in {button} slot has no texture; using default sprite." - ); - } - enabledSlots.Add(button); - Logger.Log( - LogCategory.QuickSlots, - $"Enabled {button} slot with '{item.Title}'.", - LogLevel.Info - ); - } - else - { - Logger.Log( - LogCategory.QuickSlots, - $"Disabled unassigned {button} slot.", - LogLevel.Info - ); - } - sprite ??= GetSlotSprite(slotConfig, Game1.player.Items); - if (sprite is not null) - { - slotSprites.Add(button, sprite); - } - } - isDirty = false; - } -} - -file static class PointExtensions -{ - public static Point Add(this Point point, int x, int y) - { - return new(point.X + x, point.Y + y); - } - - public static Point AddX(this Point point, int x) - { - return new(point.X + x, point.Y); - } - - public static Point AddY(this Point point, int y) - { - return new(point.X, point.Y + y); - } -} +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Graphics; +using StarControl.Config; +using StarControl.Data; +using StarControl.Graphics; +using StardewValley; + +namespace StarControl.Menus; + +internal class QuickSlotRenderer(GraphicsDevice graphicsDevice, ModConfig config) +{ + private const float MENU_SCALE = 0.7f; // scales the entire quick action menu + private const float SLOT_CONTENT_SCALE = 1.2f; // scales slot visuals without moving the menu + + private record ButtonFlash(FlashType Type, float DurationMs, float ElapsedMs = 0); + + private enum FlashType + { + Delay, + Error, + } + + private enum PromptPosition + { + Above, + Below, + Left, + Right, + } + + public float BackgroundOpacity { get; set; } = 1; + public float SpriteOpacity { get; set; } = 1; + + public IReadOnlyDictionary SlotItems { get; set; } = + new Dictionary(); + public IReadOnlyDictionary Slots { get; set; } = + new Dictionary(); + + public bool UnassignedButtonsVisible { get; set; } = true; + + private const int IMAGE_SIZE = 64; + private const int SLOT_PADDING = 20; + private const int SLOT_SIZE = (int)((IMAGE_SIZE + SLOT_PADDING * 2) * MENU_SCALE * 1f); + private const int SLOT_DISTANCE = (int)((SLOT_SIZE - 12) * MENU_SCALE * 2.6f); + private const int MARGIN_OUTER = 32; + private const int MARGIN_HORIZONTAL = 120; + private const int MARGIN_VERTICAL = 16; + private const int PROMPT_OFFSET = SLOT_SIZE / 2; + private const int PROMPT_SIZE = 26; + private const int BACKGROUND_RADIUS = (int)( + (SLOT_DISTANCE + SLOT_SIZE / 2 + MARGIN_OUTER) * 0.7f + ); + + private readonly Dictionary flashes = []; + private readonly HashSet enabledSlots = []; + private readonly Dictionary slotSprites = []; + private readonly GraphicsDevice graphicsDevice = graphicsDevice; + + private Color disabledBackgroundColor = Color.Transparent; + private Color innerBackgroundColor = Color.Transparent; + private bool isDirty = true; + private float quickSlotScale = 1f; + private Texture2D outerBackground = null!; + private Texture2D slotBackground = null!; + private Texture2D? uiTexture; + private ButtonIconSet? uiTextureIconSet; + + public void Draw(SpriteBatch b, Rectangle viewport) + { + UpdateScale(); + if (isDirty) + { + innerBackgroundColor = (Color)config.Style.OuterBackgroundColor * 0.6f; + disabledBackgroundColor = LumaGray(innerBackgroundColor, 0.75f); + RefreshSlots(); + } + + var leftOrigin = new Point( + viewport.Left + + Scale(MARGIN_HORIZONTAL * MENU_SCALE) + + Scale(MARGIN_OUTER * MENU_SCALE) + + Scale(SLOT_SIZE / 2f), + viewport.Bottom + - Scale(MARGIN_VERTICAL * MENU_SCALE) + - Scale(MARGIN_OUTER * MENU_SCALE) + - Scale(SLOT_SIZE) + - Scale(SLOT_SIZE / 2f) + ); + var leftShoulderPosition = leftOrigin.AddX(Scale(SLOT_DISTANCE * MENU_SCALE)); + if ( + UnassignedButtonsVisible + || HasSlotSprite(SButton.DPadLeft) + || HasSlotSprite(SButton.DPadUp) + || HasSlotSprite(SButton.DPadRight) + || HasSlotSprite(SButton.DPadDown) + ) + { + var leftBackgroundRect = GetCircleRect( + leftOrigin.AddX(Scale(SLOT_DISTANCE * MENU_SCALE)), + Scale(BACKGROUND_RADIUS) + ); + b.Draw( + outerBackground, + leftBackgroundRect, + (Color)config.Style.InnerBackgroundColor * BackgroundOpacity + ); + DrawSlot(b, leftOrigin, SButton.DPadLeft, PromptPosition.Left); + DrawSlot( + b, + leftOrigin.Add( + Scale(SLOT_DISTANCE * MENU_SCALE), + -Scale(SLOT_DISTANCE * MENU_SCALE) + ), + SButton.DPadUp, + PromptPosition.Above + ); + DrawSlot( + b, + leftOrigin.Add( + Scale(SLOT_DISTANCE * MENU_SCALE), + Scale(SLOT_DISTANCE * MENU_SCALE) + ), + SButton.DPadDown, + PromptPosition.Below + ); + DrawSlot( + b, + leftOrigin.AddX(Scale(SLOT_DISTANCE * MENU_SCALE * 2f)), + SButton.DPadRight, + PromptPosition.Right + ); + leftShoulderPosition.Y -= Scale(SLOT_DISTANCE * 2.5f * MENU_SCALE); + } + if (enabledSlots.Contains(SButton.LeftShoulder)) + { + DrawSlot( + b, + leftShoulderPosition, + SButton.LeftShoulder, + PromptPosition.Above, + darken: true + ); + } + + var rightOrigin = new Point( + viewport.Right + - Scale(MARGIN_HORIZONTAL * MENU_SCALE) + - Scale(MARGIN_OUTER * MENU_SCALE) + - Scale(SLOT_SIZE / 2f), + leftOrigin.Y + ); + var rightShoulderPosition = rightOrigin.AddX(-Scale(SLOT_DISTANCE * MENU_SCALE)); + if ( + UnassignedButtonsVisible + || HasSlotSprite(SButton.ControllerX) + || HasSlotSprite(SButton.ControllerY) + || HasSlotSprite(SButton.ControllerA) + || HasSlotSprite(SButton.ControllerB) + ) + { + var rightBackgroundRect = GetCircleRect( + rightOrigin.AddX(-Scale(SLOT_DISTANCE * MENU_SCALE)), + Scale(BACKGROUND_RADIUS) + ); + b.Draw( + outerBackground, + rightBackgroundRect, + (Color)config.Style.InnerBackgroundColor * BackgroundOpacity + ); + DrawSlot(b, rightOrigin, SButton.ControllerB, PromptPosition.Right); + DrawSlot( + b, + rightOrigin.Add( + -Scale(SLOT_DISTANCE * MENU_SCALE), + -Scale(SLOT_DISTANCE * MENU_SCALE) + ), + SButton.ControllerY, + PromptPosition.Above + ); + DrawSlot( + b, + rightOrigin.Add( + -Scale(SLOT_DISTANCE * MENU_SCALE), + Scale(SLOT_DISTANCE * MENU_SCALE) + ), + SButton.ControllerA, + PromptPosition.Below + ); + DrawSlot( + b, + rightOrigin.AddX(-Scale(SLOT_DISTANCE * MENU_SCALE * 2f)), + SButton.ControllerX, + PromptPosition.Left + ); + rightShoulderPosition.Y -= Scale(SLOT_DISTANCE * 2.5f * MENU_SCALE); + } + if (enabledSlots.Contains(SButton.RightShoulder)) + { + DrawSlot( + b, + rightShoulderPosition, + SButton.RightShoulder, + PromptPosition.Above, + darken: true + ); + } + } + + private void UpdateScale(bool force = false) + { + var desiredScale = Math.Clamp(config.Style.QuickActionScale, 0.5f, 1.5f); + if ( + !force + && MathF.Abs(desiredScale - quickSlotScale) < 0.001f + && outerBackground is not null + ) + { + return; + } + quickSlotScale = desiredScale; + outerBackground?.Dispose(); + slotBackground?.Dispose(); + outerBackground = ShapeTexture.CreateCircle( + Scale((SLOT_SIZE + SLOT_SIZE / 2f + MARGIN_OUTER) * MENU_SCALE), + filled: true, + graphicsDevice: graphicsDevice + ); + slotBackground = ShapeTexture.CreateCircle( + Scale(SLOT_SIZE / 2f), + filled: true, + graphicsDevice: graphicsDevice + ); + isDirty = true; + } + + private int Scale(float value) + { + return (int)MathF.Round(value * quickSlotScale); + } + + public void FlashDelay(SButton button) + { + flashes[button] = new(FlashType.Delay, config.Input.ActivationDelayMs); + } + + public void FlashError(SButton button) + { + flashes[button] = new(FlashType.Error, Animation.ERROR_FLASH_DURATION_MS); + } + + public bool HasSlotSprite(SButton button) + { + return slotSprites.ContainsKey(button); + } + + public void Invalidate() + { + isDirty = true; + } + + public void Update(TimeSpan elapsed) + { + foreach (var (button, flash) in flashes) + { + var flashElapsedMs = flash.ElapsedMs + (float)elapsed.TotalMilliseconds; + if (flashElapsedMs >= flash.DurationMs) + { + flashes.Remove(button); + continue; + } + flashes[button] = flash with { ElapsedMs = flashElapsedMs }; + } + } + + private void DrawSlot( + SpriteBatch b, + Point origin, + SButton button, + PromptPosition promptPosition, + bool darken = false + ) + { + var isAssigned = slotSprites.TryGetValue(button, out var sprite); + if (!isAssigned && !UnassignedButtonsVisible) + { + return; + } + + var isEnabled = enabledSlots.Contains(button); + var backgroundRect = GetCircleRect(origin, Scale(SLOT_SIZE * SLOT_CONTENT_SCALE / 2f)); + if (darken) + { + var darkenRect = backgroundRect; + darkenRect.Inflate(4, 4); + b.Draw(slotBackground, darkenRect, Color.Black * BackgroundOpacity); + } + var backgroundColor = GetBackgroundColor(button, isAssigned && isEnabled); + b.Draw(slotBackground, backgroundRect, backgroundColor * BackgroundOpacity); + + var slotOpacity = isEnabled ? 1f : 0.5f; + + if (isAssigned) + { + var spriteRect = GetCircleRect( + origin, + Scale(IMAGE_SIZE * MENU_SCALE * SLOT_CONTENT_SCALE / 2f) + ); + if (SlotItems.TryGetValue(button, out var item) && item.Texture is not null) + { + ItemRenderer.Draw( + b, + item, + spriteRect, + config.Style, + opacity: slotOpacity * SpriteOpacity + ); + } + else + { + b.Draw( + sprite!.Texture, + spriteRect, + sprite.SourceRect, + Color.White * slotOpacity * SpriteOpacity + ); + } + } + + if (GetPromptSprite(button) is { } promptSprite) + { + var promptOffset = Scale(PROMPT_OFFSET * SLOT_CONTENT_SCALE); + var promptOrigin = promptPosition switch + { + PromptPosition.Above => origin.AddY(-promptOffset), + PromptPosition.Below => origin.AddY(promptOffset), + PromptPosition.Left => origin.AddX(-promptOffset), + PromptPosition.Right => origin.AddX(promptOffset), + _ => throw new ArgumentException( + $"Invalid prompt position: {promptPosition}", + nameof(promptPosition) + ), + }; + var promptRect = GetCircleRect( + promptOrigin, + Scale(PROMPT_SIZE * SLOT_CONTENT_SCALE / 2f) + ); + b.Draw( + promptSprite.Texture, + promptRect, + promptSprite.SourceRect, + Color.White * slotOpacity * SpriteOpacity + ); + } + } + + private Color GetBackgroundColor(SButton button, bool enabled) + { + var baseColor = enabled ? innerBackgroundColor : disabledBackgroundColor; + if (!flashes.TryGetValue(button, out var flash)) + { + return baseColor; + } + var (flashColor, position) = flash.Type switch + { + FlashType.Delay => ( + config.Style.HighlightColor, + Animation.GetDelayFlashPosition(flash.ElapsedMs) + ), + FlashType.Error => (Color.Red, Animation.GetErrorFlashPosition(flash.ElapsedMs)), + _ => (Color.White, 0), + }; + return Color.Lerp(baseColor, flashColor, position); + } + + private static Rectangle GetCircleRect(Point center, int radius) + { + int length = radius * 2; + return new(center.X - radius, center.Y - radius, length, length); + } + + private static Sprite GetIconSprite(IconConfig icon) + { + return !string.IsNullOrEmpty(icon.ItemId) + ? Sprite.ForItemId(icon.ItemId) + : Sprite.TryLoad(icon.TextureAssetPath, icon.SourceRect) + ?? Sprite.ForItemId("Error_Invalid"); + } + + private Sprite? GetModItemSprite(string id) + { + var itemConfig = config + .Items.ModMenuPages.SelectMany(items => items) + .FirstOrDefault(item => item.Id == id); + return itemConfig?.Icon is { } icon ? GetIconSprite(icon) : null; + } + + private Sprite? GetPromptSprite(SButton button) + { + var (rowIndex, columnIndex) = button switch + { + SButton.DPadUp => (1, 0), + SButton.DPadRight => (1, 1), + SButton.DPadDown => (1, 2), + SButton.DPadLeft => (1, 3), + SButton.ControllerA => (1, 4), + SButton.ControllerB => (1, 5), + SButton.ControllerX => (1, 6), + SButton.ControllerY => (1, 7), + SButton.LeftTrigger => (2, 0), + SButton.RightTrigger => (2, 1), + SButton.LeftShoulder => (2, 2), + SButton.RightShoulder => (2, 3), + SButton.ControllerBack => (2, 4), + SButton.ControllerStart => (2, 5), + SButton.LeftStick => (2, 6), + SButton.RightStick => (2, 7), + _ => (-1, -1), + }; + if (columnIndex == -1) + { + return null; + } + var texture = GetUiTexture(); + return new(texture, new(columnIndex * 16, rowIndex * 16, 16, 16)); + } + + private Texture2D GetUiTexture() + { + var desiredSet = config.Style.ButtonIconSet; + if (uiTexture is null || uiTextureIconSet != desiredSet) + { + var assetPath = + desiredSet == ButtonIconSet.PlayStation + ? Sprites.UI_PLAYSTATION_TEXTURE_PATH + : Sprites.UI_TEXTURE_PATH; + uiTexture = Game1.content.Load(assetPath); + uiTextureIconSet = desiredSet; + } + return uiTexture; + } + + private Sprite? GetSlotSprite(IItemLookup itemLookup, ICollection inventoryItems) + { + if (string.IsNullOrWhiteSpace(itemLookup.Id)) + return null; + + return itemLookup.IdType switch + { + ItemIdType.GameItem => QuickSlotResolver.ResolveInventoryItem( + itemLookup.Id, + itemLookup.SubId, + inventoryItems + ) + is Item item + ? Sprite.FromItem(item) // ✅ handles Item Bags via drawInMenu fallback + : Sprite.ForItemId(itemLookup.Id), // fallback for normal items + + ItemIdType.ModItem => GetModItemSprite(itemLookup.Id), + _ => null, + }; + } + + private static Color LumaGray(Color color, float lightness) + { + var v = (int)((color.R * 0.2126f + color.G * 0.7152f + color.B * 0.0722f) * lightness); + return new(v, v, v); + } + + private void RefreshSlots() + { + Logger.Log(LogCategory.QuickSlots, "Starting refresh of quick slot renderer data."); + enabledSlots.Clear(); + slotSprites.Clear(); + foreach (var (button, slotConfig) in Slots) + { + Logger.Log(LogCategory.QuickSlots, $"Checking slot for {button}..."); + Sprite? sprite = null; + if (SlotItems.TryGetValue(button, out var item)) + { + if (item.Texture is not null) + { + Logger.Log( + LogCategory.QuickSlots, + $"Using configured item sprite for {item.Title} in {button} slot." + ); + sprite = new(item.Texture, item.SourceRectangle ?? item.Texture.Bounds); + } + else + { + Logger.Log( + LogCategory.QuickSlots, + $"Item {item.Title} in {button} slot has no texture; using default sprite." + ); + } + enabledSlots.Add(button); + Logger.Log( + LogCategory.QuickSlots, + $"Enabled {button} slot with '{item.Title}'.", + LogLevel.Info + ); + } + else + { + Logger.Log( + LogCategory.QuickSlots, + $"Disabled unassigned {button} slot.", + LogLevel.Info + ); + } + sprite ??= GetSlotSprite(slotConfig, Game1.player.Items); + if (sprite is not null) + { + slotSprites.Add(button, sprite); + } + } + isDirty = false; + } +} + +file static class PointExtensions +{ + public static Point Add(this Point point, int x, int y) + { + return new(point.X + x, point.Y + y); + } + + public static Point AddX(this Point point, int x) + { + return new(point.X + x, point.Y); + } + + public static Point AddY(this Point point, int y) + { + return new(point.X, point.Y + y); + } +} diff --git a/StarControl/Menus/QuickSlotResolver.cs b/StarControl/Menus/QuickSlotResolver.cs index 19b3e92..204c962 100644 --- a/StarControl/Menus/QuickSlotResolver.cs +++ b/StarControl/Menus/QuickSlotResolver.cs @@ -1,133 +1,160 @@ -using System.Reflection; -using StardewValley.ItemTypeDefinitions; -using StardewValley.Tools; - -namespace StarControl.Menus; - -internal class QuickSlotResolver(Farmer player, ModMenu modMenu) -{ - public static Item? ResolveInventoryItem(string id, ICollection items) - { - // Allow exact inventory matches even if the item is not registered in ItemRegistry (e.g. Item Bags) - var exact = items.FirstOrDefault(i => i is not null && i.QualifiedItemId == id); - if (exact is not null) - return exact; - - Logger.Log(LogCategory.QuickSlots, $"Searching for inventory item equivalent to '{id}'..."); - if (ItemRegistry.GetData(id) is not { } data) - { - Logger.Log( - LogCategory.QuickSlots, - $"'{id}' does not have validx item data; aborting search." - ); - return null; - } - // Melee weapons don't have upgrades or base items, but if we didn't find an exact match, it - // is often helpful to find any other melee weapon that's available. - // Only apply fuzzy matching to melee weapons; slingshots must match exactly - if ( - data.ItemType.Identifier == "(W)" - && !data.QualifiedItemId.Contains("Slingshot") - && !data.QualifiedItemId.Contains("Bow") - ) - { - // We'll match scythes to scythes, and non-scythes to non-scythes. - // Most likely, the player wants Iridium Scythe if the slot says Scythe. The upgraded - // version is simply better, like a more typical tool. - // - // With real weapons it's fuzzier because the highest-level weapon isn't necessarily - // appropriate for the situation. If there's one quick slot for Galaxy Sword and another - // for Galaxy Hammer, then activating those slots should activate their *specific* - // weapons respectively if both are in the inventory. - // - // So we match on the inferred "type" (scythe vs. weapon) and then for non-scythe - // weapons specifically (and only those), give preference to exact matches before - // sorting by level. - var isScythe = IsScythe(data); - Logger.Log( - LogCategory.QuickSlots, - $"Item '{id}' appears to be a weapon with (scythe = {isScythe})." - ); - var bestWeapon = items - .OfType() - .Where(weapon => weapon.Name.Contains("Scythe") == isScythe) - .OrderByDescending(weapon => !isScythe && weapon.QualifiedItemId == id) - .ThenByDescending(weapon => weapon.getItemLevel()) - .FirstOrDefault(); - if (bestWeapon is not null) - { - Logger.Log( - LogCategory.QuickSlots, - "Best weapon match in inventory is " - + $"{bestWeapon.Name} with ID {bestWeapon.QualifiedItemId}." - ); - return bestWeapon; - } - } - var baseItem = data.GetBaseItem(); - Logger.Log( - LogCategory.QuickSlots, - "Searching for regular item using base item " - + $"{baseItem.InternalName} with ID {baseItem.QualifiedItemId}." - ); - var match = items - .Where(item => item is not null) - .Where(item => - item.QualifiedItemId == id - || ItemRegistry - .GetDataOrErrorItem(item.QualifiedItemId) - .GetBaseItem() - .QualifiedItemId == baseItem.QualifiedItemId - ) - .OrderByDescending(item => item is Tool tool ? tool.UpgradeLevel : 0) - .ThenByDescending(item => item.Quality) - .FirstOrDefault(); - Logger.Log( - LogCategory.QuickSlots, - $"Best match by quality/upgrade level is " - + $"{match?.Name ?? "(nothing)"} with ID {match?.QualifiedItemId ?? "N/A"}." - ); - return match; - } - - private static bool IsScythe(ParsedItemData data) - { - var method = typeof(MeleeWeapon).GetMethod( - "IsScythe", - BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static, - binder: null, - types: [typeof(string)], - modifiers: null - ); - if (method is not null) - { - try - { - return (bool)method.Invoke(null, [data.QualifiedItemId])!; - } - catch - { - // Fall through to heuristic below. - } - } - var name = data.InternalName ?? string.Empty; - return name.Contains("Scythe", StringComparison.OrdinalIgnoreCase) - || data.QualifiedItemId.Contains("Scythe", StringComparison.OrdinalIgnoreCase); - } - - public IRadialMenuItem? ResolveItem(string id, ItemIdType idType) - { - if (string.IsNullOrEmpty(id)) - { - return null; - } - return idType switch - { - ItemIdType.GameItem => ResolveInventoryItem(id, player.Items) is { } item - ? new InventoryMenuItem(item) - : null, - ItemIdType.ModItem => modMenu.GetItem(id), - _ => null, - }; - } -} +using System.Reflection; +using StardewValley.ItemTypeDefinitions; +using StardewValley.Tools; + +namespace StarControl.Menus; + +internal class QuickSlotResolver(Farmer player, ModMenu modMenu) +{ + public static Item? ResolveInventoryItem(string id, string? subId, ICollection items) + { + if (string.IsNullOrEmpty(subId)) + return ResolveInventoryItem(id, items); + + return items.FirstOrDefault(i => + i is not null + && i.QualifiedItemId == id + && Compat.ItemBagsIdentity.TryGetBagTypeId(i) == subId + ); + } + + public static Item? ResolveInventoryItem(string id, ICollection items) + { + // Allow exact inventory matches even if the item is not registered in ItemRegistry (e.g. Item Bags) + var exact = items.FirstOrDefault(i => i is not null && i.QualifiedItemId == id); + if (exact is not null) + return exact; + + Logger.Log(LogCategory.QuickSlots, $"Searching for inventory item equivalent to '{id}'..."); + if (ItemRegistry.GetData(id) is not { } data) + { + Logger.Log( + LogCategory.QuickSlots, + $"'{id}' does not have validx item data; aborting search." + ); + return null; + } + // Melee weapons don't have upgrades or base items, but if we didn't find an exact match, it + // is often helpful to find any other melee weapon that's available. + // Only apply fuzzy matching to melee weapons; slingshots must match exactly + if ( + data.ItemType.Identifier == "(W)" + && !data.QualifiedItemId.Contains("Slingshot") + && !data.QualifiedItemId.Contains("Bow") + ) + { + // We'll match scythes to scythes, and non-scythes to non-scythes. + // Most likely, the player wants Iridium Scythe if the slot says Scythe. The upgraded + // version is simply better, like a more typical tool. + // + // With real weapons it's fuzzier because the highest-level weapon isn't necessarily + // appropriate for the situation. If there's one quick slot for Galaxy Sword and another + // for Galaxy Hammer, then activating those slots should activate their *specific* + // weapons respectively if both are in the inventory. + // + // So we match on the inferred "type" (scythe vs. weapon) and then for non-scythe + // weapons specifically (and only those), give preference to exact matches before + // sorting by level. + var isScythe = IsScythe(data); + Logger.Log( + LogCategory.QuickSlots, + $"Item '{id}' appears to be a weapon with (scythe = {isScythe})." + ); + var bestWeapon = items + .OfType() + .Where(weapon => weapon.Name.Contains("Scythe") == isScythe) + .OrderByDescending(weapon => !isScythe && weapon.QualifiedItemId == id) + .ThenByDescending(weapon => weapon.getItemLevel()) + .FirstOrDefault(); + if (bestWeapon is not null) + { + Logger.Log( + LogCategory.QuickSlots, + "Best weapon match in inventory is " + + $"{bestWeapon.Name} with ID {bestWeapon.QualifiedItemId}." + ); + return bestWeapon; + } + } + var baseItem = data.GetBaseItem(); + Logger.Log( + LogCategory.QuickSlots, + "Searching for regular item using base item " + + $"{baseItem.InternalName} with ID {baseItem.QualifiedItemId}." + ); + var match = items + .Where(item => item is not null) + .Where(item => + item.QualifiedItemId == id + || ItemRegistry + .GetDataOrErrorItem(item.QualifiedItemId) + .GetBaseItem() + .QualifiedItemId == baseItem.QualifiedItemId + ) + .OrderByDescending(item => item is Tool tool ? tool.UpgradeLevel : 0) + .ThenByDescending(item => item.Quality) + .FirstOrDefault(); + Logger.Log( + LogCategory.QuickSlots, + $"Best match by quality/upgrade level is " + + $"{match?.Name ?? "(nothing)"} with ID {match?.QualifiedItemId ?? "N/A"}." + ); + return match; + } + + private static bool IsScythe(ParsedItemData data) + { + var method = typeof(MeleeWeapon).GetMethod( + "IsScythe", + BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static, + binder: null, + types: [typeof(string)], + modifiers: null + ); + if (method is not null) + { + try + { + return (bool)method.Invoke(null, [data.QualifiedItemId])!; + } + catch + { + // Fall through to heuristic below. + } + } + var name = data.InternalName ?? string.Empty; + return name.Contains("Scythe", StringComparison.OrdinalIgnoreCase) + || data.QualifiedItemId.Contains("Scythe", StringComparison.OrdinalIgnoreCase); + } + + public IRadialMenuItem? ResolveItem(string id, ItemIdType idType) + { + if (string.IsNullOrEmpty(id)) + { + return null; + } + return idType switch + { + ItemIdType.GameItem => ResolveInventoryItem(id, player.Items) is { } item + ? new InventoryMenuItem(item) + : null, + ItemIdType.ModItem => modMenu.GetItem(id), + _ => null, + }; + } + + public IRadialMenuItem? ResolveItem(string id, string? subId, ItemIdType idType) + { + if (string.IsNullOrEmpty(id)) + return null; + + return idType switch + { + ItemIdType.GameItem => ResolveInventoryItem(id, subId, player.Items) is { } item + ? new InventoryMenuItem(item) + : null, + ItemIdType.ModItem => modMenu.GetItem(id), + _ => null, + }; + } +} diff --git a/StarControl/Menus/RemappingController.cs b/StarControl/Menus/RemappingController.cs index 7328419..66ab035 100644 --- a/StarControl/Menus/RemappingController.cs +++ b/StarControl/Menus/RemappingController.cs @@ -238,7 +238,7 @@ private void ResolveSlots() LogCategory.QuickSlots, $"Item data for remapping slot {button}: ID = {slot.IdType}:{slot.Id}" ); - var slottedItem = resolver.ResolveItem(slot.Id, slot.IdType); + var slottedItem = resolver.ResolveItem(slot.Id, slot.SubId, slot.IdType); if (slottedItem is not null) { Logger.Log( From 0d22fff25aa6f6626b58dc818ff53ee7374b7458 Mon Sep 17 00:00:00 2001 From: SoulSilverJD <165866563+SoulSilverJD@users.noreply.github.com> Date: Sat, 24 Jan 2026 17:07:47 -0500 Subject: [PATCH 13/24] Add files via upload --- .../UI/QuickSlotConfigurationViewModel.cs | 16 +- .../QuickSlotGroupConfigurationViewModel.cs | 2 + .../UI/QuickSlotPickerItemViewModel.cs | 18 +- StarControl/UI/RemappingViewModel.cs | 980 +++++++++--------- 4 files changed, 531 insertions(+), 485 deletions(-) diff --git a/StarControl/UI/QuickSlotConfigurationViewModel.cs b/StarControl/UI/QuickSlotConfigurationViewModel.cs index 7ac418c..280ead1 100644 --- a/StarControl/UI/QuickSlotConfigurationViewModel.cs +++ b/StarControl/UI/QuickSlotConfigurationViewModel.cs @@ -34,6 +34,9 @@ internal partial class QuickSlotConfigurationViewModel [Notify] private ParsedItemData? itemData; + [Notify] + private string? itemSubId; + [Notify] private ModMenuItemConfigurationViewModel? modAction; @@ -48,6 +51,7 @@ public void Clear() ItemData = null; ModAction = null; UseSecondaryAction = false; + ItemSubId = null; } private Sprite? GetIcon() @@ -59,7 +63,11 @@ public void Clear() if (ItemData.IsErrorItem) { var invItem = Game1.player?.Items?.FirstOrDefault(i => - i is not null && i.QualifiedItemId == ItemData.QualifiedItemId + i is not null + && i.QualifiedItemId == ItemData.QualifiedItemId + && ( + ItemSubId is null || Compat.ItemBagsIdentity.TryGetBagTypeId(i) == ItemSubId + ) ); if (invItem is not null) @@ -83,7 +91,11 @@ private TooltipData GetTooltip() if (ItemData.IsErrorItem) { var invItem = Game1.player?.Items?.FirstOrDefault(i => - i is not null && i.QualifiedItemId == ItemData.QualifiedItemId + i is not null + && i.QualifiedItemId == ItemData.QualifiedItemId + && ( + ItemSubId is null || Compat.ItemBagsIdentity.TryGetBagTypeId(i) == ItemSubId + ) ); if (invItem is not null) diff --git a/StarControl/UI/QuickSlotGroupConfigurationViewModel.cs b/StarControl/UI/QuickSlotGroupConfigurationViewModel.cs index 4d5adeb..b1ea333 100644 --- a/StarControl/UI/QuickSlotGroupConfigurationViewModel.cs +++ b/StarControl/UI/QuickSlotGroupConfigurationViewModel.cs @@ -72,6 +72,7 @@ IReadOnlyCollection modMenuPages { case ItemIdType.GameItem: target.ItemData = ItemRegistry.GetDataOrErrorItem(config.Id); + target.ItemSubId = config.SubId; break; case ItemIdType.ModItem: target.ModAction = modMenuPages @@ -112,6 +113,7 @@ IDictionary configs { IdType = target.ItemData is not null ? ItemIdType.GameItem : ItemIdType.ModItem, Id = target.ItemData?.QualifiedItemId ?? target.ModAction?.Id ?? "", + SubId = target.ItemData is not null ? target.ItemSubId : null, RequireConfirmation = target.RequireConfirmation, UseSecondaryAction = target.UseSecondaryAction, }; diff --git a/StarControl/UI/QuickSlotPickerItemViewModel.cs b/StarControl/UI/QuickSlotPickerItemViewModel.cs index 3eb9fcb..59fb4bb 100644 --- a/StarControl/UI/QuickSlotPickerItemViewModel.cs +++ b/StarControl/UI/QuickSlotPickerItemViewModel.cs @@ -76,7 +76,11 @@ public static QuickSlotPickerItemViewModel ForItem(Item item) { var fishData = ItemRegistry.GetDataOrErrorItem(obj.GetPreservedItemId()); return new( - slot => slot.ItemData = data, + slot => + { + slot.ItemData = data; + slot.ItemSubId = Compat.ItemBagsIdentity.TryGetBagTypeId(item); + }, fishData.GetTexture(), fishData.GetSourceRect(), fishData.GetSourceRect(), @@ -94,7 +98,11 @@ public static QuickSlotPickerItemViewModel ForItem(Item item) { var sprite = Sprite.FromItem(item); return new( - slot => slot.ItemData = data.GetBaseItem(), + slot => + { + slot.ItemData = data.GetBaseItem(); + slot.ItemSubId = Compat.ItemBagsIdentity.TryGetBagTypeId(item); + }, sprite.Texture, sprite.SourceRect, tooltip: tooltip @@ -114,7 +122,11 @@ public static QuickSlotPickerItemViewModel ForItem(Item item) } return new( - slot => slot.ItemData = data.GetBaseItem(), + slot => + { + slot.ItemData = data.GetBaseItem(); + slot.ItemSubId = Compat.ItemBagsIdentity.TryGetBagTypeId(item); + }, data.GetTexture(), data.GetSourceRect(), tintRect, diff --git a/StarControl/UI/RemappingViewModel.cs b/StarControl/UI/RemappingViewModel.cs index 662be70..66ebfe9 100644 --- a/StarControl/UI/RemappingViewModel.cs +++ b/StarControl/UI/RemappingViewModel.cs @@ -1,480 +1,500 @@ -using System.Collections; -using System.Reflection; -using Microsoft.Xna.Framework; -using Microsoft.Xna.Framework.Input; -using PropertyChanged.SourceGenerator; -using StarControl.Config; -using StarControl.Data; -using StarControl.Graphics; -using StarControl.Menus; - -namespace StarControl.UI; - -internal partial class RemappingViewModel( - IInputHelper inputHelper, - Farmer who, - IEnumerable modItems, - SButton menuToggleButton, - float thumbstickDeadZone, - ButtonIconSet buttonIconSet, - Action> onSave -) -{ - private const int BaseMenuWidth = 950; - private const int BaseMenuHeight = 700; - private const int MinMenuWidth = 720; - private const int MinMenuHeight = 520; - private const int ViewportPaddingWidth = 128; - private const int ViewportPaddingHeight = 120; - private const int MenuVerticalNudge = -24; - - public IMenuController? Controller { get; set; } - public bool IsItemHovered => HoveredItem is not null; - public bool IsSlotHovered => HoveredSlot is not null; - public bool IsSlotHoveredAndAssigned => HoveredSlot?.Item is not null; - public IReadOnlyList ItemGroups { get; } = - [ - new() - { - Name = I18n.Enum_QuickSlotItemSource_Inventory_Name(), - Items = who - .Items.Where(item => item is not null) - .Select(RemappableItemViewModel.FromInventoryItem) - .ToList(), - }, - new() - { - Name = I18n.Enum_QuickSlotItemSource_ModItems_Name(), - Items = modItems.Select(RemappableItemViewModel.FromMenuItem).ToList(), - }, - ]; - public IEnumerable Slots => slotsByButton.Values; - - [Notify] - private ButtonIconSet buttonIconSet = buttonIconSet; - - [Notify] - private string menuLayout = $"{BaseMenuWidth}px {BaseMenuHeight}px"; - - [Notify] - private bool canReassign; - - [Notify] - private RemappableItemViewModel? hoveredItem; - - [Notify] - private RemappingSlotViewModel? hoveredSlot; - - [Notify] - private bool showAssignTip = true; - - [Notify] - private bool showUnassignTip; - - private const double RightStickScrollRepeatMinMs = 35; - private const double RightStickScrollRepeatMaxMs = 140; - - private double lastRightStickScrollMs; - private bool menuLayoutInitialized; - private int menuWidth = BaseMenuWidth; - private int menuHeight = BaseMenuHeight; - private readonly Dictionary slotsByButton = new() - { - { SButton.DPadLeft, new(SButton.DPadLeft) }, - { SButton.DPadUp, new(SButton.DPadUp) }, - { SButton.DPadRight, new(SButton.DPadRight) }, - { SButton.DPadDown, new(SButton.DPadDown) }, - { SButton.ControllerX, new(SButton.ControllerX) }, - { SButton.ControllerY, new(SButton.ControllerY) }, - { SButton.ControllerB, new(SButton.ControllerB) }, - { SButton.LeftShoulder, new(SButton.LeftShoulder) }, - { SButton.RightShoulder, new(SButton.RightShoulder) }, - }; - - public bool AssignToSlot(SButton button, RemappableItemViewModel item) - { - if (!CanReassign || !slotsByButton.TryGetValue(button, out var slot)) - { - return false; - } - Game1.playSound("drumkit6"); - if (slot.Item is not null) - { - slot.Item.AssignedButton = SButton.None; - } - item.AssignedButton = button; - slot.Item = item; - Save(); - return true; - } - - public void Load(Dictionary data) - { - foreach (var slot in slotsByButton.Values) - { - if (slot.Item is { } previousItem) - { - previousItem.AssignedButton = SButton.None; - } - slot.Item = null; - } - foreach (var (button, slotData) in data) - { - if ( - string.IsNullOrEmpty(slotData.Id) - || !slotsByButton.TryGetValue(button, out var slot) - ) - { - continue; - } - var item = slotData.IdType switch - { - ItemIdType.GameItem => ItemGroups[0] - .Items.FirstOrDefault(item => item.Id == slotData.Id) - ?? RemappableItemViewModel.FromInventoryItem( - ItemRegistry.Create(slotData.Id), - who.Items - ), - ItemIdType.ModItem => ItemGroups[1] - .Items.FirstOrDefault(item => item.Id == slotData.Id), - _ => null, - }; - item ??= RemappableItemViewModel.Invalid(slotData.IdType, slotData.Id); - item.AssignedButton = button; - slot.Item = item; - } - UpdateTipVisibility(); - } - - public void Save() - { - var data = new Dictionary(); - foreach (var (button, slot) in slotsByButton) - { - if (slot.Item is { } item && !string.IsNullOrEmpty(item.Id)) - { - data[button] = new() { Id = slot.Item.Id, IdType = slot.Item.IdType }; - } - } - onSave(data); - } - - public void SetItemHovered(RemappableItemViewModel? item) - { - if (HoveredItem == item) - { - return; - } - if (HoveredItem is not null) - { - HoveredItem.Hovered = false; - } - HoveredItem = item; - if (item is not null) - { - item.Hovered = true; - } - } - - public void SetSlotHovered(RemappingSlotViewModel? slot) - { - HoveredSlot = slot; - UpdateTipVisibility(); - } - - public void UnassignSlot(RemappingSlotViewModel slot) - { - if (slot.Item is null) - { - return; - } - Game1.playSound("trashcan"); - slot.Item.AssignedButton = SButton.None; - slot.Item = null; - OnPropertyChanged(new(nameof(IsSlotHoveredAndAssigned))); - UpdateTipVisibility(); - Save(); - } - - public void Update() - { - if (!menuLayoutInitialized) - { - UpdateLayoutForViewport(); - menuLayoutInitialized = true; - } - CanReassign = - inputHelper.IsDown(SButton.LeftTrigger) || inputHelper.IsDown(SButton.RightTrigger); - HandleRightStickScroll(); - // IClickableMenu.receiveGamePadButton bizarrely does not receive some buttons such as the - // left/right stick. We have to check them for through the helper. - if ( - !CanReassign - && menuToggleButton - is not SButton.DPadUp - or SButton.DPadDown - or SButton.DPadLeft - or SButton.DPadRight - && inputHelper.GetState(menuToggleButton) == SButtonState.Pressed - ) - { - Controller?.Close(); - } - } - - public void InitializeLayout() - { - menuLayoutInitialized = false; - UpdateLayoutForViewport(); - menuLayoutInitialized = true; - } - - public Point GetMenuPosition() - { - var viewport = Game1.uiViewport; - var x = (viewport.Width - menuWidth) / 2; - var y = (viewport.Height - menuHeight) / 2 + MenuVerticalNudge; - return new Point(Math.Max(0, x), Math.Max(0, y)); - } - - private void UpdateLayoutForViewport() - { - var viewport = Game1.uiViewport; - var width = Math.Min( - BaseMenuWidth, - Math.Max(MinMenuWidth, viewport.Width - ViewportPaddingWidth) - ); - var height = Math.Min( - BaseMenuHeight, - Math.Max(MinMenuHeight, viewport.Height - ViewportPaddingHeight) - ); - menuWidth = width; - menuHeight = height; - MenuLayout = $"{width}px {height}px"; - } - - private void UpdateTipVisibility() - { - ShowUnassignTip = IsSlotHoveredAndAssigned; - ShowAssignTip = !ShowUnassignTip; - } - - private void HandleRightStickScroll() - { - if (Controller?.Menu is null) - { - return; - } - var nowMs = Game1.currentGameTime?.TotalGameTime.TotalMilliseconds ?? 0; - var state = Game1.playerOneIndex >= PlayerIndex.One ? Game1.input.GetGamePadState() : new(); - var stickY = state.ThumbSticks.Right.Y; - var absY = Math.Abs(stickY); - if (absY <= thumbstickDeadZone) - { - return; - } - var intensity = Math.Clamp((absY - thumbstickDeadZone) / (1f - thumbstickDeadZone), 0f, 1f); - var repeatMs = - RightStickScrollRepeatMaxMs - - (RightStickScrollRepeatMaxMs - RightStickScrollRepeatMinMs) * intensity; - if (nowMs - lastRightStickScrollMs < repeatMs) - { - return; - } - lastRightStickScrollMs = nowMs; - TryScrollActiveContainer(stickY > 0); - } - - private bool TryScrollActiveContainer(bool scrollUp) - { - var container = GetActiveScrollContainer(); - if (container is null) - { - return false; - } - var containerType = container.GetType(); - var scrollSizeProp = containerType.GetProperty("ScrollSize"); - var scrollSizeValue = scrollSizeProp?.GetValue(container); - var scrollSize = scrollSizeValue is float size ? size : 0f; - if (scrollSize <= 0f) - { - return false; - } - var methodName = scrollUp ? "ScrollBackward" : "ScrollForward"; - var scrollMethod = containerType.GetMethod(methodName); - if (scrollMethod is null) - { - return false; - } - var result = scrollMethod.Invoke(container, Array.Empty()); - var scrolled = result is bool didScroll && didScroll; - if (scrolled) - { - Game1.playSound("shwip"); - } - return scrolled; - } - - private object? GetActiveScrollContainer() - { - var viewProp = Controller?.Menu?.GetType().GetProperty("View"); - var rootView = viewProp?.GetValue(Controller?.Menu!); - if (rootView is null) - { - return null; - } - return FindScrollContainer(rootView); - } - - private static object? FindScrollContainer(object view) - { - var viewType = view.GetType(); - var viewTypeName = viewType.FullName ?? string.Empty; - if (viewTypeName == "StardewUI.Widgets.ScrollableView") - { - var innerView = viewType - .GetProperty("View", BindingFlags.NonPublic | BindingFlags.Instance) - ?.GetValue(view); - if (innerView is not null) - { - return innerView; - } - } - if (viewTypeName == "StardewUI.Widgets.ScrollContainer") - { - return view; - } - var getChildren = viewType.GetMethod("GetChildren", new[] { typeof(bool) }); - var children = getChildren?.Invoke(view, new object[] { true }) as IEnumerable; - if (children is null) - { - return null; - } - foreach (var child in children) - { - var childView = child?.GetType().GetProperty("View")?.GetValue(child); - if (childView is null) - { - continue; - } - var result = FindScrollContainer(childView); - if (result is not null) - { - return result; - } - } - return null; - } -} - -internal partial class RemappingSlotViewModel(SButton button) -{ - public SButton Button { get; } = button; - public int? Count => Item?.Count ?? 1; - public bool IsCountVisible => Count > 1; - public bool IsItemEnabled => Item?.Enabled == true; - public int Quality => Item?.Quality ?? 0; - - public Sprite? Sprite => Item?.Sprite; - - public TooltipData? Tooltip => Item?.Tooltip; - - [Notify] - private RemappableItemViewModel? item; -} - -internal partial class RemappableItemGroupViewModel -{ - [Notify] - private IReadOnlyList items = []; - - [Notify] - private string name = ""; -} - -internal partial class RemappableItemViewModel -{ - public string Id { get; init; } = ""; - - public ItemIdType IdType { get; init; } - public bool IsCountVisible => Count > 1; - - [Notify] - private SButton assignedButton; - - [Notify] - private int count = 1; - - [Notify] - private bool enabled; - - [Notify] - private bool hovered; - - [Notify] - private int quality; - - [Notify] - private Sprite? sprite; - - [Notify] - private TooltipData? tooltip; - - public static RemappableItemViewModel FromInventoryItem(Item item) - { - ArgumentNullException.ThrowIfNull(item); - - var qualifiedId = item.QualifiedItemId; // can be null/empty for some mod items - var sprite = Sprite.FromItem(item); - - return new() - { - Id = qualifiedId ?? item.Name ?? item.GetType().FullName ?? "unknown", - IdType = ItemIdType.GameItem, - Enabled = true, - Sprite = sprite, - Quality = item.Quality, - Count = item.Stack, - Tooltip = new(item.getDescription(), item.DisplayName, item), - }; - } - - public static RemappableItemViewModel FromInventoryItem( - Item item, - ICollection availableItems - ) - { - var result = FromInventoryItem(item); - result.Enabled = - QuickSlotResolver.ResolveInventoryItem(item.QualifiedItemId, availableItems) - is not null; - return result; - } - - public static RemappableItemViewModel FromMenuItem(IRadialMenuItem item) - { - return new() - { - Id = item.Id, - IdType = ItemIdType.ModItem, - Enabled = item.Enabled, - Sprite = item.Texture is not null - ? new(item.Texture, item.SourceRectangle ?? item.Texture.Bounds) - : Sprites.Error(), - Tooltip = !string.IsNullOrEmpty(item.Description) - ? new(item.Description, item.Title) - : new(item.Title), - }; - } - - public static RemappableItemViewModel Invalid(ItemIdType type, string id) - { - return new() - { - Id = id, - IdType = type, - Sprite = Sprites.Error(), - Tooltip = new(I18n.Remapping_InvalidItem_Description(id)), - }; - } -} +using System.Collections; +using System.Reflection; +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Input; +using PropertyChanged.SourceGenerator; +using StarControl.Config; +using StarControl.Data; +using StarControl.Graphics; +using StarControl.Menus; + +namespace StarControl.UI; + +internal partial class RemappingViewModel( + IInputHelper inputHelper, + Farmer who, + IEnumerable modItems, + SButton menuToggleButton, + float thumbstickDeadZone, + ButtonIconSet buttonIconSet, + Action> onSave +) +{ + private const int BaseMenuWidth = 950; + private const int BaseMenuHeight = 700; + private const int MinMenuWidth = 720; + private const int MinMenuHeight = 520; + private const int ViewportPaddingWidth = 128; + private const int ViewportPaddingHeight = 120; + private const int MenuVerticalNudge = -24; + + public IMenuController? Controller { get; set; } + public bool IsItemHovered => HoveredItem is not null; + public bool IsSlotHovered => HoveredSlot is not null; + public bool IsSlotHoveredAndAssigned => HoveredSlot?.Item is not null; + public IReadOnlyList ItemGroups { get; } = + [ + new() + { + Name = I18n.Enum_QuickSlotItemSource_Inventory_Name(), + Items = who + .Items.Where(item => item is not null) + .Select(RemappableItemViewModel.FromInventoryItem) + .ToList(), + }, + new() + { + Name = I18n.Enum_QuickSlotItemSource_ModItems_Name(), + Items = modItems.Select(RemappableItemViewModel.FromMenuItem).ToList(), + }, + ]; + public IEnumerable Slots => slotsByButton.Values; + + [Notify] + private ButtonIconSet buttonIconSet = buttonIconSet; + + [Notify] + private string menuLayout = $"{BaseMenuWidth}px {BaseMenuHeight}px"; + + [Notify] + private bool canReassign; + + [Notify] + private RemappableItemViewModel? hoveredItem; + + [Notify] + private RemappingSlotViewModel? hoveredSlot; + + [Notify] + private bool showAssignTip = true; + + [Notify] + private bool showUnassignTip; + + private const double RightStickScrollRepeatMinMs = 35; + private const double RightStickScrollRepeatMaxMs = 140; + + private double lastRightStickScrollMs; + private bool menuLayoutInitialized; + private int menuWidth = BaseMenuWidth; + private int menuHeight = BaseMenuHeight; + private readonly Dictionary slotsByButton = new() + { + { SButton.DPadLeft, new(SButton.DPadLeft) }, + { SButton.DPadUp, new(SButton.DPadUp) }, + { SButton.DPadRight, new(SButton.DPadRight) }, + { SButton.DPadDown, new(SButton.DPadDown) }, + { SButton.ControllerX, new(SButton.ControllerX) }, + { SButton.ControllerY, new(SButton.ControllerY) }, + { SButton.ControllerB, new(SButton.ControllerB) }, + { SButton.LeftShoulder, new(SButton.LeftShoulder) }, + { SButton.RightShoulder, new(SButton.RightShoulder) }, + }; + + public bool AssignToSlot(SButton button, RemappableItemViewModel item) + { + if (!CanReassign || !slotsByButton.TryGetValue(button, out var slot)) + { + return false; + } + Game1.playSound("drumkit6"); + if (slot.Item is not null) + { + slot.Item.AssignedButton = SButton.None; + } + item.AssignedButton = button; + slot.Item = item; + Save(); + return true; + } + + public void Load(Dictionary data) + { + foreach (var slot in slotsByButton.Values) + { + if (slot.Item is { } previousItem) + { + previousItem.AssignedButton = SButton.None; + } + slot.Item = null; + } + foreach (var (button, slotData) in data) + { + if ( + string.IsNullOrEmpty(slotData.Id) + || !slotsByButton.TryGetValue(button, out var slot) + ) + { + continue; + } + var item = slotData.IdType switch + { + ItemIdType.GameItem => ItemGroups[0] + .Items.FirstOrDefault(item => + item.Id == slotData.Id && item.SubId == slotData.SubId + ) + ?? ( + QuickSlotResolver.ResolveInventoryItem( + slotData.Id, + slotData.SubId, + who.Items + ) + is Item invItem + ? RemappableItemViewModel.FromInventoryItem(invItem, who.Items) + : null + ), + ItemIdType.ModItem => ItemGroups[1] + .Items.FirstOrDefault(item => item.Id == slotData.Id), + _ => null, + }; + item ??= RemappableItemViewModel.Invalid(slotData.IdType, slotData.Id); + item.AssignedButton = button; + slot.Item = item; + } + UpdateTipVisibility(); + } + + public void Save() + { + var data = new Dictionary(); + foreach (var (button, slot) in slotsByButton) + { + if (slot.Item is { } item && !string.IsNullOrEmpty(item.Id)) + { + data[button] = new() + { + Id = slot.Item.Id, + SubId = slot.Item.SubId, + IdType = slot.Item.IdType, + }; + } + } + onSave(data); + } + + public void SetItemHovered(RemappableItemViewModel? item) + { + if (HoveredItem == item) + { + return; + } + if (HoveredItem is not null) + { + HoveredItem.Hovered = false; + } + HoveredItem = item; + if (item is not null) + { + item.Hovered = true; + } + } + + public void SetSlotHovered(RemappingSlotViewModel? slot) + { + HoveredSlot = slot; + UpdateTipVisibility(); + } + + public void UnassignSlot(RemappingSlotViewModel slot) + { + if (slot.Item is null) + { + return; + } + Game1.playSound("trashcan"); + slot.Item.AssignedButton = SButton.None; + slot.Item = null; + OnPropertyChanged(new(nameof(IsSlotHoveredAndAssigned))); + UpdateTipVisibility(); + Save(); + } + + public void Update() + { + if (!menuLayoutInitialized) + { + UpdateLayoutForViewport(); + menuLayoutInitialized = true; + } + CanReassign = + inputHelper.IsDown(SButton.LeftTrigger) || inputHelper.IsDown(SButton.RightTrigger); + HandleRightStickScroll(); + // IClickableMenu.receiveGamePadButton bizarrely does not receive some buttons such as the + // left/right stick. We have to check them for through the helper. + if ( + !CanReassign + && menuToggleButton + is not SButton.DPadUp + or SButton.DPadDown + or SButton.DPadLeft + or SButton.DPadRight + && inputHelper.GetState(menuToggleButton) == SButtonState.Pressed + ) + { + Controller?.Close(); + } + } + + public void InitializeLayout() + { + menuLayoutInitialized = false; + UpdateLayoutForViewport(); + menuLayoutInitialized = true; + } + + public Point GetMenuPosition() + { + var viewport = Game1.uiViewport; + var x = (viewport.Width - menuWidth) / 2; + var y = (viewport.Height - menuHeight) / 2 + MenuVerticalNudge; + return new Point(Math.Max(0, x), Math.Max(0, y)); + } + + private void UpdateLayoutForViewport() + { + var viewport = Game1.uiViewport; + var width = Math.Min( + BaseMenuWidth, + Math.Max(MinMenuWidth, viewport.Width - ViewportPaddingWidth) + ); + var height = Math.Min( + BaseMenuHeight, + Math.Max(MinMenuHeight, viewport.Height - ViewportPaddingHeight) + ); + menuWidth = width; + menuHeight = height; + MenuLayout = $"{width}px {height}px"; + } + + private void UpdateTipVisibility() + { + ShowUnassignTip = IsSlotHoveredAndAssigned; + ShowAssignTip = !ShowUnassignTip; + } + + private void HandleRightStickScroll() + { + if (Controller?.Menu is null) + { + return; + } + var nowMs = Game1.currentGameTime?.TotalGameTime.TotalMilliseconds ?? 0; + var state = Game1.playerOneIndex >= PlayerIndex.One ? Game1.input.GetGamePadState() : new(); + var stickY = state.ThumbSticks.Right.Y; + var absY = Math.Abs(stickY); + if (absY <= thumbstickDeadZone) + { + return; + } + var intensity = Math.Clamp((absY - thumbstickDeadZone) / (1f - thumbstickDeadZone), 0f, 1f); + var repeatMs = + RightStickScrollRepeatMaxMs + - (RightStickScrollRepeatMaxMs - RightStickScrollRepeatMinMs) * intensity; + if (nowMs - lastRightStickScrollMs < repeatMs) + { + return; + } + lastRightStickScrollMs = nowMs; + TryScrollActiveContainer(stickY > 0); + } + + private bool TryScrollActiveContainer(bool scrollUp) + { + var container = GetActiveScrollContainer(); + if (container is null) + { + return false; + } + var containerType = container.GetType(); + var scrollSizeProp = containerType.GetProperty("ScrollSize"); + var scrollSizeValue = scrollSizeProp?.GetValue(container); + var scrollSize = scrollSizeValue is float size ? size : 0f; + if (scrollSize <= 0f) + { + return false; + } + var methodName = scrollUp ? "ScrollBackward" : "ScrollForward"; + var scrollMethod = containerType.GetMethod(methodName); + if (scrollMethod is null) + { + return false; + } + var result = scrollMethod.Invoke(container, Array.Empty()); + var scrolled = result is bool didScroll && didScroll; + if (scrolled) + { + Game1.playSound("shwip"); + } + return scrolled; + } + + private object? GetActiveScrollContainer() + { + var viewProp = Controller?.Menu?.GetType().GetProperty("View"); + var rootView = viewProp?.GetValue(Controller?.Menu!); + if (rootView is null) + { + return null; + } + return FindScrollContainer(rootView); + } + + private static object? FindScrollContainer(object view) + { + var viewType = view.GetType(); + var viewTypeName = viewType.FullName ?? string.Empty; + if (viewTypeName == "StardewUI.Widgets.ScrollableView") + { + var innerView = viewType + .GetProperty("View", BindingFlags.NonPublic | BindingFlags.Instance) + ?.GetValue(view); + if (innerView is not null) + { + return innerView; + } + } + if (viewTypeName == "StardewUI.Widgets.ScrollContainer") + { + return view; + } + var getChildren = viewType.GetMethod("GetChildren", new[] { typeof(bool) }); + var children = getChildren?.Invoke(view, new object[] { true }) as IEnumerable; + if (children is null) + { + return null; + } + foreach (var child in children) + { + var childView = child?.GetType().GetProperty("View")?.GetValue(child); + if (childView is null) + { + continue; + } + var result = FindScrollContainer(childView); + if (result is not null) + { + return result; + } + } + return null; + } +} + +internal partial class RemappingSlotViewModel(SButton button) +{ + public SButton Button { get; } = button; + public int? Count => Item?.Count ?? 1; + public bool IsCountVisible => Count > 1; + public bool IsItemEnabled => Item?.Enabled == true; + public int Quality => Item?.Quality ?? 0; + + public Sprite? Sprite => Item?.Sprite; + + public TooltipData? Tooltip => Item?.Tooltip; + + [Notify] + private RemappableItemViewModel? item; +} + +internal partial class RemappableItemGroupViewModel +{ + [Notify] + private IReadOnlyList items = []; + + [Notify] + private string name = ""; +} + +internal partial class RemappableItemViewModel +{ + public string Id { get; init; } = ""; + + public string? SubId { get; init; } + + public ItemIdType IdType { get; init; } + public bool IsCountVisible => Count > 1; + + [Notify] + private SButton assignedButton; + + [Notify] + private int count = 1; + + [Notify] + private bool enabled; + + [Notify] + private bool hovered; + + [Notify] + private int quality; + + [Notify] + private Sprite? sprite; + + [Notify] + private TooltipData? tooltip; + + public static RemappableItemViewModel FromInventoryItem(Item item) + { + ArgumentNullException.ThrowIfNull(item); + + var qualifiedId = item.QualifiedItemId; // can be null/empty for some mod items + var sprite = Sprite.FromItem(item); + + return new() + { + Id = qualifiedId ?? item.Name ?? item.GetType().FullName ?? "unknown", + IdType = ItemIdType.GameItem, + SubId = Compat.ItemBagsIdentity.TryGetBagTypeId(item), + Enabled = true, + Sprite = sprite, + Quality = item.Quality, + Count = item.Stack, + Tooltip = new(item.getDescription(), item.DisplayName, item), + }; + } + + public static RemappableItemViewModel FromInventoryItem( + Item item, + ICollection availableItems + ) + { + var result = FromInventoryItem(item); + result.Enabled = + QuickSlotResolver.ResolveInventoryItem( + item.QualifiedItemId, + result.SubId, + availableItems + ) + is not null; + return result; + } + + public static RemappableItemViewModel FromMenuItem(IRadialMenuItem item) + { + return new() + { + Id = item.Id, + IdType = ItemIdType.ModItem, + Enabled = item.Enabled, + Sprite = item.Texture is not null + ? new(item.Texture, item.SourceRectangle ?? item.Texture.Bounds) + : Sprites.Error(), + Tooltip = !string.IsNullOrEmpty(item.Description) + ? new(item.Description, item.Title) + : new(item.Title), + }; + } + + public static RemappableItemViewModel Invalid(ItemIdType type, string id) + { + return new() + { + Id = id, + IdType = type, + Sprite = Sprites.Error(), + Tooltip = new(I18n.Remapping_InvalidItem_Description(id)), + }; + } +} From 426f806b01c9f0056262ce402dbbd50755befcfc Mon Sep 17 00:00:00 2001 From: SoulSilverJD <165866563+SoulSilverJD@users.noreply.github.com> Date: Sat, 24 Jan 2026 17:11:11 -0500 Subject: [PATCH 14/24] To get bag id --- StarControl/Compat | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 StarControl/Compat diff --git a/StarControl/Compat b/StarControl/Compat new file mode 100644 index 0000000..4179ed5 --- /dev/null +++ b/StarControl/Compat @@ -0,0 +1,38 @@ +using System.Reflection; +using StardewValley; + +namespace StarControl.Compat; + +internal static class ItemBagsIdentity +{ + private static Type? itemBagBaseType; + private static MethodInfo? getTypeIdMethod; + + public static string? TryGetBagTypeId(Item item) + { + itemBagBaseType ??= AppDomain + .CurrentDomain.GetAssemblies() + .Select(a => a.GetType("ItemBags.Bags.ItemBag", throwOnError: false)) + .FirstOrDefault(t => t is not null); + + if (itemBagBaseType is null || !itemBagBaseType.IsInstanceOfType(item)) + return null; + + getTypeIdMethod ??= itemBagBaseType.GetMethod( + "GetTypeId", + BindingFlags.Public | BindingFlags.Instance + ); + + if (getTypeIdMethod?.ReturnType != typeof(string)) + return null; + + try + { + return getTypeIdMethod.Invoke(item, null) as string; + } + catch + { + return null; + } + } +} From 6cdcf7014bb7272aeee6ea14e3dc015cb3a3f4df Mon Sep 17 00:00:00 2001 From: SoulSilverJD <165866563+SoulSilverJD@users.noreply.github.com> Date: Sat, 24 Jan 2026 17:22:03 -0500 Subject: [PATCH 15/24] Delete StarControl/Compat --- StarControl/Compat | 38 -------------------------------------- 1 file changed, 38 deletions(-) delete mode 100644 StarControl/Compat diff --git a/StarControl/Compat b/StarControl/Compat deleted file mode 100644 index 4179ed5..0000000 --- a/StarControl/Compat +++ /dev/null @@ -1,38 +0,0 @@ -using System.Reflection; -using StardewValley; - -namespace StarControl.Compat; - -internal static class ItemBagsIdentity -{ - private static Type? itemBagBaseType; - private static MethodInfo? getTypeIdMethod; - - public static string? TryGetBagTypeId(Item item) - { - itemBagBaseType ??= AppDomain - .CurrentDomain.GetAssemblies() - .Select(a => a.GetType("ItemBags.Bags.ItemBag", throwOnError: false)) - .FirstOrDefault(t => t is not null); - - if (itemBagBaseType is null || !itemBagBaseType.IsInstanceOfType(item)) - return null; - - getTypeIdMethod ??= itemBagBaseType.GetMethod( - "GetTypeId", - BindingFlags.Public | BindingFlags.Instance - ); - - if (getTypeIdMethod?.ReturnType != typeof(string)) - return null; - - try - { - return getTypeIdMethod.Invoke(item, null) as string; - } - catch - { - return null; - } - } -} From db068ea53f90cadf39c18cd089ecc25b8e9510d0 Mon Sep 17 00:00:00 2001 From: SoulSilverJD <165866563+SoulSilverJD@users.noreply.github.com> Date: Sat, 24 Jan 2026 17:22:15 -0500 Subject: [PATCH 16/24] Create ItemBagsIdentity.cs --- StarControl/Compat/ItemBagsIdentity.cs | 38 ++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 StarControl/Compat/ItemBagsIdentity.cs diff --git a/StarControl/Compat/ItemBagsIdentity.cs b/StarControl/Compat/ItemBagsIdentity.cs new file mode 100644 index 0000000..4179ed5 --- /dev/null +++ b/StarControl/Compat/ItemBagsIdentity.cs @@ -0,0 +1,38 @@ +using System.Reflection; +using StardewValley; + +namespace StarControl.Compat; + +internal static class ItemBagsIdentity +{ + private static Type? itemBagBaseType; + private static MethodInfo? getTypeIdMethod; + + public static string? TryGetBagTypeId(Item item) + { + itemBagBaseType ??= AppDomain + .CurrentDomain.GetAssemblies() + .Select(a => a.GetType("ItemBags.Bags.ItemBag", throwOnError: false)) + .FirstOrDefault(t => t is not null); + + if (itemBagBaseType is null || !itemBagBaseType.IsInstanceOfType(item)) + return null; + + getTypeIdMethod ??= itemBagBaseType.GetMethod( + "GetTypeId", + BindingFlags.Public | BindingFlags.Instance + ); + + if (getTypeIdMethod?.ReturnType != typeof(string)) + return null; + + try + { + return getTypeIdMethod.Invoke(item, null) as string; + } + catch + { + return null; + } + } +} From b8971d6b499c70106f096879feb70b0e6b58d8af Mon Sep 17 00:00:00 2001 From: SoulSilverJD <165866563+SoulSilverJD@users.noreply.github.com> Date: Wed, 28 Jan 2026 02:16:46 -0500 Subject: [PATCH 17/24] Fix up Icon edge cases / Added extras --- StarControl/Menus/QuickSlotRenderer.cs | 25 +++- StarControl/Menus/QuickSlotResolver.cs | 167 ++++++++++++++++++++++++- 2 files changed, 185 insertions(+), 7 deletions(-) diff --git a/StarControl/Menus/QuickSlotRenderer.cs b/StarControl/Menus/QuickSlotRenderer.cs index a12ec00..6c3c417 100644 --- a/StarControl/Menus/QuickSlotRenderer.cs +++ b/StarControl/Menus/QuickSlotRenderer.cs @@ -453,7 +453,11 @@ private Texture2D GetUiTexture() ) is Item item ? Sprite.FromItem(item) // ✅ handles Item Bags via drawInMenu fallback - : Sprite.ForItemId(itemLookup.Id), // fallback for normal items + : ( + ItemRegistry.GetData(itemLookup.Id) is not null + ? Sprite.ForItemId(itemLookup.Id) // only for registered items + : null + ), // unregistered (ItemBags): don't force error icon ItemIdType.ModItem => GetModItemSprite(itemLookup.Id), _ => null, @@ -470,7 +474,7 @@ private void RefreshSlots() { Logger.Log(LogCategory.QuickSlots, "Starting refresh of quick slot renderer data."); enabledSlots.Clear(); - slotSprites.Clear(); + // Keep slotSprites so we can show a "last known" icon when a slot item is temporarily unavailable. foreach (var (button, slotConfig) in Slots) { Logger.Log(LogCategory.QuickSlots, $"Checking slot for {button}..."); @@ -507,10 +511,23 @@ private void RefreshSlots() LogLevel.Info ); } - sprite ??= GetSlotSprite(slotConfig, Game1.player.Items); + sprite ??= GetSlotSprite( + slotConfig, + QuickSlotResolver.GetExpandedPlayerItems(Game1.player) + ); if (sprite is not null) { - slotSprites.Add(button, sprite); + slotSprites[button] = sprite; // update/insert + } + else + { + // If we had a previous sprite for this slot, keep it so the icon stays visible (greyed out). + // If we have none, fall back to a generic item-id sprite so something renders. + // (like Item Bags), Sprite.ForItemId will show Error_Invalid, which is worse UX. + if (!slotSprites.ContainsKey(button) && !string.IsNullOrWhiteSpace(slotConfig.Id)) + { + slotSprites[button] = Sprite.ForItemId("Error_Invalid"); + } } } isDirty = false; diff --git a/StarControl/Menus/QuickSlotResolver.cs b/StarControl/Menus/QuickSlotResolver.cs index 204c962..ee38f19 100644 --- a/StarControl/Menus/QuickSlotResolver.cs +++ b/StarControl/Menus/QuickSlotResolver.cs @@ -1,4 +1,9 @@ -using System.Reflection; +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using StardewValley; using StardewValley.ItemTypeDefinitions; using StardewValley.Tools; @@ -127,6 +132,160 @@ private static bool IsScythe(ParsedItemData data) || data.QualifiedItemId.Contains("Scythe", StringComparison.OrdinalIgnoreCase); } + private static Type? ItemBagType; + private static PropertyInfo? ItemBagContentsProp; + + private static Type? OmniBagType; + private static PropertyInfo? OmniNestedBagsProp; + + private static Type? BundleBagType; + + private static bool TryInitItemBagsReflection() + { + if (ItemBagType is not null && ItemBagContentsProp is not null && BundleBagType is not null) + return true; + + // Find ItemBags.Bags.ItemBag + ItemBagType ??= AppDomain + .CurrentDomain.GetAssemblies() + .Select(a => a.GetType("ItemBags.Bags.ItemBag", throwOnError: false)) + .FirstOrDefault(t => t is not null); + + if (ItemBagType is null) + return false; + + // public List Contents { get; set; } + ItemBagContentsProp ??= ItemBagType.GetProperty( + "Contents", + BindingFlags.Public | BindingFlags.Instance + ); + if (ItemBagContentsProp is null) + return false; + + // Find BundleBag type so we can EXCLUDE traversing into its contents + BundleBagType ??= AppDomain + .CurrentDomain.GetAssemblies() + .Select(a => a.GetType("ItemBags.Bags.BundleBag", throwOnError: false)) + .FirstOrDefault(t => t is not null); + + if (BundleBagType is null) + return false; + + // Omni bag support (nested bags) + OmniBagType ??= AppDomain + .CurrentDomain.GetAssemblies() + .Select(a => a.GetType("ItemBags.Bags.OmniBag", throwOnError: false)) + .FirstOrDefault(t => t is not null); + + if (OmniBagType is not null) + OmniNestedBagsProp ??= OmniBagType.GetProperty( + "NestedBags", + BindingFlags.Public | BindingFlags.Instance + ); + + return true; + } + + internal static bool IsItemBag(Item item) => + ItemBagType is not null && ItemBagType.IsInstanceOfType(item); + + private static bool IsBundleBag(Item item) => + BundleBagType is not null && BundleBagType.IsInstanceOfType(item); + + private static IEnumerable EnumerateBagContents(Item bag) + { + // BundleBag is explicitly excluded + if (IsBundleBag(bag)) + yield break; + + if (ItemBagContentsProp?.GetValue(bag) is not IList list || list.Count == 0) + yield break; + + foreach (var obj in list) + if (obj is Item inner) + yield return inner; + } + + private static IEnumerable EnumerateOmniNestedBags(Item bag) + { + if (OmniBagType is null || OmniNestedBagsProp is null || !OmniBagType.IsInstanceOfType(bag)) + yield break; + + if (OmniNestedBagsProp.GetValue(bag) is not IList list || list.Count == 0) + yield break; + + foreach (var obj in list) + if (obj is Item innerBag) + yield return innerBag; + } + + /// + /// Returns an "effective inventory" which includes: + /// - the player's inventory + /// - contents of any ItemBags bags in the player's inventory (EXCEPT BundleBag contents) + /// - nested bags inside OmniBags (and then their contents too) + /// + public static ICollection GetExpandedPlayerItems(Farmer who) + { + var baseItems = who.Items; + + if (!TryInitItemBagsReflection()) + return baseItems; + + var expanded = new List(baseItems.Count + 16); + + // BFS over bags we discover (supports OmniBag nesting) + var seen = new HashSet(); + var bagQueue = new Queue(); + + // 1) Start with player inventory + foreach (var it in baseItems) + { + if (it is null) + continue; + expanded.Add(it); + + if (IsItemBag(it)) + { + if (seen.Add(it)) + bagQueue.Enqueue(it); + } + } + + // 2) Expand bags: add nested bags (omni) + contents (except bundle) + int depth = 0; + while (bagQueue.Count > 0 && depth < 6) + { + int layer = bagQueue.Count; + for (int i = 0; i < layer; i++) + { + var bag = bagQueue.Dequeue(); + + // Omni nested bags + foreach (var nestedBag in EnumerateOmniNestedBags(bag)) + { + expanded.Add(nestedBag); + if (IsItemBag(nestedBag) && seen.Add(nestedBag)) + bagQueue.Enqueue(nestedBag); + } + + // Bag contents (except BundleBag) + foreach (var innerItem in EnumerateBagContents(bag)) + { + expanded.Add(innerItem); + + // If someone manages to store a bag as an Item (or modded bag item), expand it too. + if (IsItemBag(innerItem) && seen.Add(innerItem)) + bagQueue.Enqueue(innerItem); + } + } + + depth++; + } + + return expanded; + } + public IRadialMenuItem? ResolveItem(string id, ItemIdType idType) { if (string.IsNullOrEmpty(id)) @@ -135,7 +294,8 @@ private static bool IsScythe(ParsedItemData data) } return idType switch { - ItemIdType.GameItem => ResolveInventoryItem(id, player.Items) is { } item + ItemIdType.GameItem => ResolveInventoryItem(id, GetExpandedPlayerItems(player)) + is { } item ? new InventoryMenuItem(item) : null, ItemIdType.ModItem => modMenu.GetItem(id), @@ -150,7 +310,8 @@ private static bool IsScythe(ParsedItemData data) return idType switch { - ItemIdType.GameItem => ResolveInventoryItem(id, subId, player.Items) is { } item + ItemIdType.GameItem => ResolveInventoryItem(id, subId, GetExpandedPlayerItems(player)) + is { } item ? new InventoryMenuItem(item) : null, ItemIdType.ModItem => modMenu.GetItem(id), From 7ed5404dcacdcd74a74ed8847f47f1b89311e3a9 Mon Sep 17 00:00:00 2001 From: SoulSilverJD <165866563+SoulSilverJD@users.noreply.github.com> Date: Wed, 28 Jan 2026 02:17:46 -0500 Subject: [PATCH 18/24] Part 2 --- .../UI/QuickSlotConfigurationViewModel.cs | 84 ++-- .../UI/QuickSlotPickerItemViewModel.cs | 2 +- StarControl/UI/QuickSlotPickerViewModel.cs | 407 ++++++++++-------- StarControl/UI/RemappingViewModel.cs | 37 +- 4 files changed, 310 insertions(+), 220 deletions(-) diff --git a/StarControl/UI/QuickSlotConfigurationViewModel.cs b/StarControl/UI/QuickSlotConfigurationViewModel.cs index 280ead1..f62cf79 100644 --- a/StarControl/UI/QuickSlotConfigurationViewModel.cs +++ b/StarControl/UI/QuickSlotConfigurationViewModel.cs @@ -1,7 +1,9 @@ +using System.Collections.Generic; using System.ComponentModel; using System.Linq; using PropertyChanged.SourceGenerator; using StarControl.Graphics; +using StarControl.Menus; using StardewValley; using StardewValley.ItemTypeDefinitions; @@ -12,6 +14,11 @@ internal partial class QuickSlotConfigurationViewModel private static readonly Color AssignedColor = new(50, 100, 50); private static readonly Color UnassignedColor = new(60, 60, 60); private static readonly Color UnavailableColor = new(0x44, 0x44, 0x44, 0x44); + private static readonly Dictionary LastKnownIcons = new(); + private static readonly Dictionary LastKnownTooltips = new(); + + private string IconCacheKey => + ItemData is null ? "" : $"{ItemData.QualifiedItemId}::{ItemSubId ?? ""}"; public Color CurrentAssignmentColor => IsAssigned ? AssignedColor : UnassignedColor; public string CurrentAssignmentLabel => @@ -23,10 +30,23 @@ internal partial class QuickSlotConfigurationViewModel public Sprite? Icon => GetIcon(); public bool IsAssigned => ItemData is not null || ModAction is not null; - public Color Tint => - ItemData is not null && Game1.player.Items.FindUsableItem(ItemData.QualifiedItemId) is null - ? UnavailableColor - : Color.White; + public Color Tint + { + get + { + if (ItemData is null || Game1.player is null) + return Color.White; + + var items = QuickSlotResolver.GetExpandedPlayerItems(Game1.player); + var resolved = QuickSlotResolver.ResolveInventoryItem( + ItemData.QualifiedItemId, + ItemSubId, + items + ); + + return resolved is null ? UnavailableColor : Color.White; + } + } [DependsOn(nameof(ItemData), nameof(ModAction))] public TooltipData Tooltip => GetTooltip(); @@ -58,24 +78,28 @@ public void Clear() { if (ItemData is not null) { - // Weird-item-proofing: Item Bags and similar mods don't register ItemRegistry data. - // If this is an error item, try to find the real inventory item and render it via drawInMenu. - if (ItemData.IsErrorItem) + // Use expanded inventory so Item Bags + OmniBag nested bags resolve. + if (Game1.player is not null) { - var invItem = Game1.player?.Items?.FirstOrDefault(i => - i is not null - && i.QualifiedItemId == ItemData.QualifiedItemId - && ( - ItemSubId is null || Compat.ItemBagsIdentity.TryGetBagTypeId(i) == ItemSubId - ) + var items = QuickSlotResolver.GetExpandedPlayerItems(Game1.player); + var invItem = QuickSlotResolver.ResolveInventoryItem( + ItemData.QualifiedItemId, + ItemSubId, + items ); if (invItem is not null) { - return Sprite.FromItem(invItem); + var sprite = Sprite.FromItem(invItem); + LastKnownIcons[IconCacheKey] = sprite; + return sprite; } } + // If it’s an error item (common for Item Bags), try last-known icon before falling back. + if (ItemData.IsErrorItem && LastKnownIcons.TryGetValue(IconCacheKey, out var cached)) + return cached; + // Normal path for registered items return new(ItemData.GetTexture(), ItemData.GetSourceRect()); } @@ -87,33 +111,35 @@ private TooltipData GetTooltip() { if (ItemData is not null) { - // Weird-item-proofing: if ItemData is an error item, use the real inventory item instead. - if (ItemData.IsErrorItem) + // Prefer a real item instance from expanded inventory (bags + omni) + if (Game1.player is not null) { - var invItem = Game1.player?.Items?.FirstOrDefault(i => - i is not null - && i.QualifiedItemId == ItemData.QualifiedItemId - && ( - ItemSubId is null || Compat.ItemBagsIdentity.TryGetBagTypeId(i) == ItemSubId - ) + var items = QuickSlotResolver.GetExpandedPlayerItems(Game1.player); + var invItem = QuickSlotResolver.ResolveInventoryItem( + ItemData.QualifiedItemId, + ItemSubId, + items ); if (invItem is not null) { - return new( + var tip = new TooltipData( Title: invItem.DisplayName, Text: invItem.getDescription(), Item: invItem ); + + LastKnownTooltips[IconCacheKey] = tip; + return tip; } } - // Normal path for registered items - return new( - Title: ItemData.DisplayName, - Text: ItemData.Description, - Item: ItemRegistry.Create(ItemData.QualifiedItemId) - ); + // IMPORTANT: do NOT call ItemRegistry.Create here for error/unresolvable items. + if (LastKnownTooltips.TryGetValue(IconCacheKey, out var cachedTip)) + return cachedTip; + + // That’s what is throwing in your SMAPI log and breaking the UI binding updates. + return new(Title: ItemData.DisplayName, Text: ItemData.Description); } if (ModAction is not null) diff --git a/StarControl/UI/QuickSlotPickerItemViewModel.cs b/StarControl/UI/QuickSlotPickerItemViewModel.cs index 59fb4bb..757722e 100644 --- a/StarControl/UI/QuickSlotPickerItemViewModel.cs +++ b/StarControl/UI/QuickSlotPickerItemViewModel.cs @@ -100,7 +100,7 @@ public static QuickSlotPickerItemViewModel ForItem(Item item) return new( slot => { - slot.ItemData = data.GetBaseItem(); + slot.ItemData = data; slot.ItemSubId = Compat.ItemBagsIdentity.TryGetBagTypeId(item); }, sprite.Texture, diff --git a/StarControl/UI/QuickSlotPickerViewModel.cs b/StarControl/UI/QuickSlotPickerViewModel.cs index 6e60a5b..bee0e50 100644 --- a/StarControl/UI/QuickSlotPickerViewModel.cs +++ b/StarControl/UI/QuickSlotPickerViewModel.cs @@ -1,187 +1,220 @@ -using System.ComponentModel; -using PropertyChanged.SourceGenerator; -using StardewValley.ItemTypeDefinitions; - -namespace StarControl.UI; - -internal enum QuickSlotItemSource -{ - Inventory, - GameItems, - ModItems, -} - -internal partial class QuickSlotPickerViewModel -{ - private const int MAX_RESULTS = 40; - - public Color AllModPagesTint => - ModMenuPageIndex < 0 ? new Color(0xaa, 0xcc, 0xee) : Color.White; - - public Vector2 ContentPanelSize - { - get => Pager.ContentPanelSize; - set => Pager.ContentPanelSize = value; - } - - public bool HasLoadedGame => Game1.hasLoadedGame; - public bool HasMoreModItems => - ModMenuPageIndex < 0 && ModMenuPages.Sum(page => page.Items.Count) > MAX_RESULTS; - public IEnumerable InventoryItems => - Game1 - .player.Items.Where(item => item is not null) - .Select(QuickSlotPickerItemViewModel.ForItem) - .ToArray(); - public EnumSegmentsViewModel ItemSource { get; } = new(); - public IEnumerable ModMenuItems => - ( - ModMenuPageIndex < 0 - ? ModMenuPages.SelectMany(page => page.Items).Take(MAX_RESULTS) - : ModMenuPages[ModMenuPageIndex].Items - ).Select(QuickSlotPickerItemViewModel.ForModAction); - public IReadOnlyList ModMenuPages { get; } - public string MoreModItemsMessage => - HasMoreModItems ? I18n.Config_QuickSlot_ModItems_LimitedResults(MAX_RESULTS) : ""; - public string MoreResultsMessage => - HasMoreSearchResults ? I18n.Config_QuickSlot_Search_LimitedResults(MAX_RESULTS) : ""; - public PagerViewModel Pager { get; } = - new() { Pages = [new(0), new(1), new(2)] }; - public bool SecondaryActionProhibited => !SecondaryActionAllowed; - public QuickSlotConfigurationViewModel Slot { get; } - - private readonly IReadOnlyList allItems; - - [Notify(Setter.Private)] - private bool hasMoreSearchResults; - - [Notify] - private int modMenuPageIndex = -1; // Use -1 for "all", if they fit - - [Notify(Setter.Private)] - private IReadOnlyList searchResults = []; - - [Notify] - private string searchText = ""; - - [Notify] - private bool secondaryActionAllowed; - - private readonly object searchLock = new(); - - private CancellationTokenSource searchCancellationTokenSource = new(); - - public QuickSlotPickerViewModel( - QuickSlotConfigurationViewModel slot, - IReadOnlyList allItems, - IReadOnlyList modMenuPages - ) - { - Slot = slot; - SecondaryActionAllowed = Slot.ItemData is not null || Slot.ModAction is not null; - this.allItems = allItems; - ModMenuPages = modMenuPages; - UpdateSearchResults(); - ItemSource.PropertyChanged += ItemSource_PropertyChanged; - } - - public void AssignItem(QuickSlotPickerItemViewModel item) - { - Game1.playSound("drumkit6"); - item.UpdateSlot(Slot); - SecondaryActionAllowed = Slot.ItemData is not null || Slot.ModAction is not null; - } - - public void ClearAssignment() - { - if (Slot.ItemData is null && Slot.ModAction is null) - { - return; - } - Game1.playSound("trashcan"); - Slot.Clear(); - SecondaryActionAllowed = false; - } - - public bool HandleButtonPress(SButton button) - { - if (!Pager.HandleButtonPress(button)) - { - return false; - } - ItemSource.SelectedIndex = Pager.SelectedPageIndex; - return true; - } - - public void SelectModMenuPage(int index) - { - if (index == ModMenuPageIndex) - { - return; - } - Game1.playSound("smallSelect"); - ModMenuPageIndex = index; - } - - private void ItemSource_PropertyChanged(object? sender, PropertyChangedEventArgs e) - { - Pager.SelectedPageIndex = ItemSource.SelectedIndex; - } - - private void OnModMenuPageIndexChanged(int oldValue, int newValue) - { - if (oldValue >= 0) - { - ModMenuPages[oldValue].Selected = false; - } - if (newValue >= 0) - { - ModMenuPages[newValue].Selected = true; - } - } - - private void OnSearchTextChanged() - { - UpdateSearchResults(); - } - - private void UpdateSearchResults() - { - searchCancellationTokenSource.Cancel(); - searchCancellationTokenSource = new(); - var cancellationToken = searchCancellationTokenSource.Token; - var searchTask = Task.Run( - () => allItems.Search(SearchText, cancellationToken), - cancellationToken - ); - searchTask.ContinueWith( - t => - { - if (t.IsFaulted) - { - Logger.Log($"Failed searching for items: {t.Exception}", LogLevel.Error); - return; - } - if (t.IsCanceled) - { - return; - } - lock (searchLock) - { - var results = new List(); - // Results can easily be limited using .Take(limit), but we also need to know if there are more, - // which no Linq extension can tell us. - using var resultEnumerator = t.Result.GetEnumerator(); - while (results.Count < MAX_RESULTS && resultEnumerator.MoveNext()) - { - results.Add( - QuickSlotPickerItemViewModel.ForItemData(resultEnumerator.Current) - ); - } - SearchResults = results; - HasMoreSearchResults = resultEnumerator.MoveNext(); - } - }, - cancellationToken - ); - } -} +using System.ComponentModel; +using PropertyChanged.SourceGenerator; +using StarControl.Menus; +using StardewValley.ItemTypeDefinitions; + +namespace StarControl.UI; + +internal enum QuickSlotItemSource +{ + Inventory, + GameItems, + ModItems, +} + +internal partial class QuickSlotPickerViewModel +{ + private const int MAX_RESULTS = 40; + + public Color AllModPagesTint => + ModMenuPageIndex < 0 ? new Color(0xaa, 0xcc, 0xee) : Color.White; + + public Vector2 ContentPanelSize + { + get => Pager.ContentPanelSize; + set => Pager.ContentPanelSize = value; + } + + public bool HasLoadedGame => Game1.hasLoadedGame; + public bool HasMoreModItems => + ModMenuPageIndex < 0 && ModMenuPages.Sum(page => page.Items.Count) > MAX_RESULTS; + public IEnumerable InventoryItems => + Game1.player is null + ? Array.Empty() + : GetInventoryAndNestedBags(Game1.player) + .Select(QuickSlotPickerItemViewModel.ForItem) + .ToArray(); + public EnumSegmentsViewModel ItemSource { get; } = new(); + public IEnumerable ModMenuItems => + ( + ModMenuPageIndex < 0 + ? ModMenuPages.SelectMany(page => page.Items).Take(MAX_RESULTS) + : ModMenuPages[ModMenuPageIndex].Items + ).Select(QuickSlotPickerItemViewModel.ForModAction); + public IReadOnlyList ModMenuPages { get; } + public string MoreModItemsMessage => + HasMoreModItems ? I18n.Config_QuickSlot_ModItems_LimitedResults(MAX_RESULTS) : ""; + public string MoreResultsMessage => + HasMoreSearchResults ? I18n.Config_QuickSlot_Search_LimitedResults(MAX_RESULTS) : ""; + public PagerViewModel Pager { get; } = + new() { Pages = [new(0), new(1), new(2)] }; + public bool SecondaryActionProhibited => !SecondaryActionAllowed; + public QuickSlotConfigurationViewModel Slot { get; } + + private readonly IReadOnlyList allItems; + + [Notify(Setter.Private)] + private bool hasMoreSearchResults; + + [Notify] + private int modMenuPageIndex = -1; // Use -1 for "all", if they fit + + [Notify(Setter.Private)] + private IReadOnlyList searchResults = []; + + [Notify] + private string searchText = ""; + + [Notify] + private bool secondaryActionAllowed; + + private readonly object searchLock = new(); + + private CancellationTokenSource searchCancellationTokenSource = new(); + + public QuickSlotPickerViewModel( + QuickSlotConfigurationViewModel slot, + IReadOnlyList allItems, + IReadOnlyList modMenuPages + ) + { + Slot = slot; + SecondaryActionAllowed = Slot.ItemData is not null || Slot.ModAction is not null; + this.allItems = allItems; + ModMenuPages = modMenuPages; + UpdateSearchResults(); + ItemSource.PropertyChanged += ItemSource_PropertyChanged; + } + + public void AssignItem(QuickSlotPickerItemViewModel item) + { + Game1.playSound("drumkit6"); + item.UpdateSlot(Slot); + SecondaryActionAllowed = Slot.ItemData is not null || Slot.ModAction is not null; + } + + public void ClearAssignment() + { + if (Slot.ItemData is null && Slot.ModAction is null) + { + return; + } + Game1.playSound("trashcan"); + Slot.Clear(); + SecondaryActionAllowed = false; + } + + public bool HandleButtonPress(SButton button) + { + if (!Pager.HandleButtonPress(button)) + { + return false; + } + ItemSource.SelectedIndex = Pager.SelectedPageIndex; + return true; + } + + public void SelectModMenuPage(int index) + { + if (index == ModMenuPageIndex) + { + return; + } + Game1.playSound("smallSelect"); + ModMenuPageIndex = index; + } + + private void ItemSource_PropertyChanged(object? sender, PropertyChangedEventArgs e) + { + Pager.SelectedPageIndex = ItemSource.SelectedIndex; + } + + private void OnModMenuPageIndexChanged(int oldValue, int newValue) + { + if (oldValue >= 0) + { + ModMenuPages[oldValue].Selected = false; + } + if (newValue >= 0) + { + ModMenuPages[newValue].Selected = true; + } + } + + private void OnSearchTextChanged() + { + UpdateSearchResults(); + } + + private void UpdateSearchResults() + { + searchCancellationTokenSource.Cancel(); + searchCancellationTokenSource = new(); + var cancellationToken = searchCancellationTokenSource.Token; + var searchTask = Task.Run( + () => allItems.Search(SearchText, cancellationToken), + cancellationToken + ); + searchTask.ContinueWith( + t => + { + if (t.IsFaulted) + { + Logger.Log($"Failed searching for items: {t.Exception}", LogLevel.Error); + return; + } + if (t.IsCanceled) + { + return; + } + lock (searchLock) + { + var results = new List(); + // Results can easily be limited using .Take(limit), but we also need to know if there are more, + // which no Linq extension can tell us. + using var resultEnumerator = t.Result.GetEnumerator(); + while (results.Count < MAX_RESULTS && resultEnumerator.MoveNext()) + { + results.Add( + QuickSlotPickerItemViewModel.ForItemData(resultEnumerator.Current) + ); + } + SearchResults = results; + HasMoreSearchResults = resultEnumerator.MoveNext(); + } + }, + cancellationToken + ); + } + + private static IEnumerable GetInventoryAndNestedBags(Farmer who) + { + // Base inventory items (tools, objects, the OmniBag item itself, etc.) + var result = new List(who.Items.Count + 8); + var seen = new HashSet(); + + foreach (var it in who.Items) + { + if (it is null) + continue; + + result.Add(it); + seen.Add(it); + } + + // Add nested bags from OmniBags, but DO NOT add bag contents + foreach (var it in QuickSlotResolver.GetExpandedPlayerItems(who)) + { + if (it is null) + continue; + + if (!QuickSlotResolver.IsItemBag(it)) + continue; + + if (seen.Add(it)) + result.Add(it); + } + + return result; + } +} diff --git a/StarControl/UI/RemappingViewModel.cs b/StarControl/UI/RemappingViewModel.cs index 66ebfe9..82835be 100644 --- a/StarControl/UI/RemappingViewModel.cs +++ b/StarControl/UI/RemappingViewModel.cs @@ -37,8 +37,7 @@ Action> onSave new() { Name = I18n.Enum_QuickSlotItemSource_Inventory_Name(), - Items = who - .Items.Where(item => item is not null) + Items = GetInventoryAndNestedBags(who) .Select(RemappableItemViewModel.FromInventoryItem) .ToList(), }, @@ -140,7 +139,10 @@ public void Load(Dictionary data) who.Items ) is Item invItem - ? RemappableItemViewModel.FromInventoryItem(invItem, who.Items) + ? RemappableItemViewModel.FromInventoryItem( + invItem, + QuickSlotResolver.GetExpandedPlayerItems(who) + ) : null ), ItemIdType.ModItem => ItemGroups[1] @@ -378,6 +380,35 @@ private bool TryScrollActiveContainer(bool scrollUp) } return null; } + + private static IEnumerable GetInventoryAndNestedBags(Farmer who) + { + var result = new List(who.Items.Count + 8); + var seen = new HashSet(); + + foreach (var it in who.Items) + { + if (it is null) + continue; + + result.Add(it); + seen.Add(it); + } + + foreach (var it in QuickSlotResolver.GetExpandedPlayerItems(who)) + { + if (it is null) + continue; + + if (!QuickSlotResolver.IsItemBag(it)) + continue; + + if (seen.Add(it)) + result.Add(it); + } + + return result; + } } internal partial class RemappingSlotViewModel(SButton button) From 556a21e51d8c1bac17c6dd2279dfda4112d366f3 Mon Sep 17 00:00:00 2001 From: SoulSilverJD <165866563+SoulSilverJD@users.noreply.github.com> Date: Wed, 28 Jan 2026 03:28:26 -0500 Subject: [PATCH 19/24] Add files via upload --- StarControl/IItemLookup.cs | 40 +++++++++++++++++++------------------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/StarControl/IItemLookup.cs b/StarControl/IItemLookup.cs index a907f99..e0283a4 100644 --- a/StarControl/IItemLookup.cs +++ b/StarControl/IItemLookup.cs @@ -1,20 +1,20 @@ -namespace StarControl; - -/// -/// Provides the fields required to look up a menu item in any configured menu. -/// -public interface IItemLookup -{ - /// - /// Identifies an item to select or use. Can be the ID of a regular game item or of a Mod Menu - /// action, depending on the . - /// - string Id { get; } - - string? SubId { get; } - - /// - /// The type of ID that the refers to. - /// - ItemIdType IdType { get; } -} +namespace StarControl; + +/// +/// Provides the fields required to look up a menu item in any configured menu. +/// +public interface IItemLookup +{ + /// + /// Identifies an item to select or use. Can be the ID of a regular game item or of a Mod Menu + /// action, depending on the . + /// + string Id { get; } + + string? SubId { get; } + + /// + /// The type of ID that the refers to. + /// + ItemIdType IdType { get; } +} From 4446709fc12d407dcfa7b2213086022a76bacfc8 Mon Sep 17 00:00:00 2001 From: SoulSilverJD <165866563+SoulSilverJD@users.noreply.github.com> Date: Wed, 28 Jan 2026 03:32:14 -0500 Subject: [PATCH 20/24] Add files via upload --- StarControl/Menus/InventoryMenuItem.cs | 822 +++++++++--------- StarControl/Menus/QuickSlotRenderer.cs | 1106 ++++++++++++------------ StarControl/Menus/QuickSlotResolver.cs | 642 +++++++------- 3 files changed, 1285 insertions(+), 1285 deletions(-) diff --git a/StarControl/Menus/InventoryMenuItem.cs b/StarControl/Menus/InventoryMenuItem.cs index 363a4d9..ed1c083 100644 --- a/StarControl/Menus/InventoryMenuItem.cs +++ b/StarControl/Menus/InventoryMenuItem.cs @@ -1,411 +1,411 @@ -using System.Reflection; -using System.Runtime.CompilerServices; -using System.Text; -using Microsoft.Xna.Framework.Graphics; -using StardewValley.Enchantments; -using StardewValley.ItemTypeDefinitions; -using StardewValley.Objects; -using StardewValley.Tools; - -namespace StarControl.Menus; - -/// -/// A menu item that corresponds to an item in the player's inventory. -/// -internal class InventoryMenuItem : IRadialMenuItem -{ - public string Id => Item.QualifiedItemId; - - /// - /// The underlying inventory item. - /// - public Item Item { get; } - - public string Title { get; } - - public string Description { get; } - - public int? StackSize => Item.maximumStackSize() > 1 ? Item.Stack : null; - - public int? Quality => Item.Quality; - - public Texture2D? Texture { get; } - - public Rectangle? SourceRectangle { get; } - - public Rectangle? TintRectangle { get; } - - public Color? TintColor { get; } - - private static readonly ConditionalWeakTable DerivedTextures = []; - - public InventoryMenuItem(Item item) - { - Item = item; - Title = item.DisplayName; - Description = UnparseText(item.getDescription()); - - var data = ItemRegistry.GetDataOrErrorItem(item.QualifiedItemId); - // Workaround for some mods creating items without registering item data; these mods - // typically only implement drawInMenu. This is not a good thing to do, and tends to cause - // other problems, but we can still attempt to show an icon. - if ( - data.IsErrorItem - // Exclude vanilla item classes; hacked items only apply to mods, and if we found an - // instance with a vanilla type, it means the item truly is missing (e.g. source mod is - // no longer installed, or the item is simply invalid) and we'd only waste time trying - // to derive a texture. - && item.GetType().Assembly != typeof(SObject).Assembly - && GetDerivedTexture(item) is { } texture - ) - { - Texture = texture; - SourceRectangle = texture.Bounds; - } - else - { - var textureData = GetTextureRedirect(item); - Texture = textureData?.GetTexture() ?? data.GetTexture(); - SourceRectangle = textureData?.GetSourceRect() ?? data.GetSourceRect(); - (TintRectangle, TintColor) = GetTinting(item, textureData ?? data); - } - } - - public ItemActivationResult Activate( - Farmer who, - DelayedActions delayedActions, - ItemActivationType activationType - ) - { - // Item Bags: open bag UI from Primary (wheel) and Instant actions. - if ( - Item is Tool bagTool - && ( - activationType == ItemActivationType.Primary - || activationType == ItemActivationType.Instant - ) - && TryOpenItemBagsMenu(bagTool) - ) - { - return ItemActivationResult.Used; - } - - if (activationType == ItemActivationType.Instant) - { - if (Item is Tool tool) - { - var isActivating = IsActivating(); - if (!isActivating && (!Context.CanPlayerMove || who.canOnlyWalk || who.UsingTool)) - { - return ItemActivationResult.Ignored; - } - if (who.CurrentTool != tool) - { - var toolIndex = who.Items.IndexOf(tool); - if (toolIndex < 0) - { - return ItemActivationResult.Ignored; - } - who.CurrentToolIndex = toolIndex; - } - if (tool is FishingRod rod && rod.fishCaught) - { - rod.doneHoldingFish(who); - return ItemActivationResult.Used; - } - else if (tool is not MeleeWeapon) - { - who.FireTool(); - } - Game1.pressUseToolButton(); - return isActivating - ? ItemActivationResult.Used - : ItemActivationResult.ToolUseStarted; - } - else if (Item is SObject obj && obj.isPlaceable()) - { - var previousToolIndex = who.CurrentToolIndex; - var grabTile = Game1.GetPlacementGrabTile(); - var placementPosition = Utility.GetNearbyValidPlacementPosition( - who, - who.currentLocation, - obj, - (int)grabTile.X * 64, - (int)grabTile.Y * 64 - ); - try - { - who.CurrentToolIndex = who.Items.IndexOf(obj); - return Utility.tryToPlaceItem( - who.currentLocation, - obj, - (int)placementPosition.X, - (int)placementPosition.Y - ) - ? ItemActivationResult.Used - : ItemActivationResult.Ignored; - } - finally - { - who.CurrentToolIndex = previousToolIndex; - } - } - } - - return FuzzyActivation.ConsumeOrSelect( - who, - Item, - delayedActions, - activationType == ItemActivationType.Secondary - ? InventoryAction.Select - : InventoryAction.Use - ); - } - - public void ContinueActivation() - { - var who = Game1.player; - if (Item is not Tool tool || who.CurrentTool != tool) - { - return; - } - if (!who.canReleaseTool || who.Stamina < 1 || tool is FishingRod) - { - return; - } - var maxPowerModifier = tool.hasEnchantmentOfType() ? 1 : 0; - var maxPower = tool.UpgradeLevel + maxPowerModifier; - if (who.toolPower.Value >= maxPower) - { - return; - } - if (who.toolHold.Value <= 0) - { - who.toolHold.Value = (int)(tool.AnimationSpeedModifier * 600); - } - else - { - who.toolHold.Value -= Game1.currentGameTime.ElapsedGameTime.Milliseconds; - if (who.toolHold.Value <= 0) - { - who.toolPowerIncrease(); - } - } - } - - public bool EndActivation() - { - var who = Game1.player; - if (Item is Tool tool && who.CurrentTool == tool && who.UsingTool && who.canReleaseTool) - { - if (Item is not FishingRod) - { - who.EndUsingTool(); - } - return true; - } - // This isn't equivalent to vanilla logic, but if we detect that the player is no longer - // using ANY tool (which is what UsingTool tells us) then any button that the controller is - // "holding" should be released anyway, so that it can be pressed again. - if (!who.UsingTool) - { - return true; - } - return false; - } - - public string? GetActivationSound( - Farmer who, - ItemActivationType activationType, - string defaultSound - ) - { - return Item is Tool && activationType == ItemActivationType.Instant ? null : defaultSound; - } - - private static Texture2D? GetDerivedTexture(Item item) - { - if (!DerivedTextures.TryGetValue(item, out var texture)) - { - try - { - var graphicsDevice = Game1.graphics.GraphicsDevice; - var spriteBatch = Game1.spriteBatch; - var previousTargets = graphicsDevice.GetRenderTargets(); - var renderTarget = new RenderTarget2D(graphicsDevice, 64, 64); - graphicsDevice.SetRenderTarget(renderTarget); - graphicsDevice.Clear(Color.Transparent); - spriteBatch.Begin( - SpriteSortMode.Deferred, - BlendState.AlphaBlend, - rasterizerState: new() { MultiSampleAntiAlias = false }, - samplerState: SamplerState.PointClamp - ); - try - { - item.drawInMenu(spriteBatch, Vector2.Zero, 1f); - texture = renderTarget; - DerivedTextures.Add(item, texture); - } - catch - { - renderTarget.Dispose(); - throw; - } - finally - { - spriteBatch.End(); - graphicsDevice.SetRenderTargets(previousTargets); - } - } - catch (Exception ex) - { - Logger.Log( - $"Error deriving texture for item '{item.Name}' ({item.QualifiedItemId}): {ex}", - LogLevel.Error - ); - } - } - return texture; - } - - public bool IsActivating() - { - return Item is Tool tool && Game1.player.CurrentTool == tool && Game1.player.UsingTool; - } - - private static ParsedItemData? GetTextureRedirect(Item item) - { - return item is SObject obj && item.ItemId == "SmokedFish" - ? ItemRegistry.GetData(obj.preservedParentSheetIndex.Value) - : null; - } - - private static (Rectangle? tintRect, Color? tintColor) GetTinting( - Item item, - ParsedItemData data - ) - { - if (item is not ColoredObject coloredObject) - { - return default; - } - if (item.ItemId == "SmokedFish") - { - // Smoked fish implementation is unique (and private) in ColoredObject. - // We don't care about the animation here, but should draw it darkened; the quirky - // way this is implemented is to draw a tinted version of the original item sprite - // (not an overlay) sprite over top of the original sprite. - return (data.GetSourceRect(), new Color(80, 30, 10) * 0.6f); - } - return !coloredObject.ColorSameIndexAsParentSheetIndex - ? (data.GetSourceRect(1), coloredObject.color.Value) - : (null, coloredObject.color.Value); - } - - // When we call Item.getDescription(), most implementations go through `Game1.parseText` - // which splits the string itself onto multiple lines. This tries to remove that, so that we - // can do our own wrapping using our own width. - // - // N.B. The reason we don't just use `ParsedItemData.Description` is that, at least in the - // current version, it's often only a "base description" and includes format placeholders, - // or is missing suffixes. - private static string UnparseText(string text) - { - var sb = new StringBuilder(); - var isWhitespace = false; - var newlineCount = 0; - foreach (var c in text) - { - if (c == ' ' || c == '\r' || c == '\n') - { - if (!isWhitespace) - { - sb.Append(' '); - } - isWhitespace = true; - if (c == '\n') - { - newlineCount++; - } - } - else - { - // If the original text has a "paragraph", the formatted text will often look - // strange if that is also collapsed into a space. So preserve _multiple_ - // newlines somewhat as a single "paragraph break". - if (newlineCount > 1) - { - // From implementation above, newlines are counted as whitespace so we know - // that the last character will always be a space when hitting here. - sb.Length--; - sb.Append("\r\n\r\n"); - } - sb.Append(c); - isWhitespace = false; - newlineCount = 0; - } - } - if (isWhitespace) - { - sb.Length--; - } - return sb.ToString(); - } - - // Cache reflection lookups (don’t re-scan assemblies every press) - private static Type? _itemBagsBaseType; - private static MethodInfo? _openContentsMethod; - - private static bool TryOpenItemBagsMenu(Tool tool) - { - try - { - _itemBagsBaseType ??= AppDomain - .CurrentDomain.GetAssemblies() - .Select(a => a.GetType("ItemBags.Bags.ItemBag", throwOnError: false)) - .FirstOrDefault(t => t is not null); - - if (_itemBagsBaseType is null) - return false; - - if (!_itemBagsBaseType.IsInstanceOfType(tool)) - return false; - - var player = Game1.player; - if (player is null || player.CursorSlotItem is not null) - return false; - - _openContentsMethod ??= _itemBagsBaseType.GetMethod( - "OpenContents", - BindingFlags.Public | BindingFlags.Instance - ); - - if (_openContentsMethod is null) - return false; - - var parameters = _openContentsMethod.GetParameters(); - - if (parameters.Length == 0) - { - _openContentsMethod.Invoke(tool, null); - return true; - } - - if (parameters.Length == 3) - { - _openContentsMethod.Invoke( - tool, - new object?[] { player.Items, player.MaxItems, null } - ); - return true; - } - - return false; - } - catch - { - return false; - } - } -} +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Text; +using Microsoft.Xna.Framework.Graphics; +using StardewValley.Enchantments; +using StardewValley.ItemTypeDefinitions; +using StardewValley.Objects; +using StardewValley.Tools; + +namespace StarControl.Menus; + +/// +/// A menu item that corresponds to an item in the player's inventory. +/// +internal class InventoryMenuItem : IRadialMenuItem +{ + public string Id => Item.QualifiedItemId; + + /// + /// The underlying inventory item. + /// + public Item Item { get; } + + public string Title { get; } + + public string Description { get; } + + public int? StackSize => Item.maximumStackSize() > 1 ? Item.Stack : null; + + public int? Quality => Item.Quality; + + public Texture2D? Texture { get; } + + public Rectangle? SourceRectangle { get; } + + public Rectangle? TintRectangle { get; } + + public Color? TintColor { get; } + + private static readonly ConditionalWeakTable DerivedTextures = []; + + public InventoryMenuItem(Item item) + { + Item = item; + Title = item.DisplayName; + Description = UnparseText(item.getDescription()); + + var data = ItemRegistry.GetDataOrErrorItem(item.QualifiedItemId); + // Workaround for some mods creating items without registering item data; these mods + // typically only implement drawInMenu. This is not a good thing to do, and tends to cause + // other problems, but we can still attempt to show an icon. + if ( + data.IsErrorItem + // Exclude vanilla item classes; hacked items only apply to mods, and if we found an + // instance with a vanilla type, it means the item truly is missing (e.g. source mod is + // no longer installed, or the item is simply invalid) and we'd only waste time trying + // to derive a texture. + && item.GetType().Assembly != typeof(SObject).Assembly + && GetDerivedTexture(item) is { } texture + ) + { + Texture = texture; + SourceRectangle = texture.Bounds; + } + else + { + var textureData = GetTextureRedirect(item); + Texture = textureData?.GetTexture() ?? data.GetTexture(); + SourceRectangle = textureData?.GetSourceRect() ?? data.GetSourceRect(); + (TintRectangle, TintColor) = GetTinting(item, textureData ?? data); + } + } + + public ItemActivationResult Activate( + Farmer who, + DelayedActions delayedActions, + ItemActivationType activationType + ) + { + // Item Bags: open bag UI from Primary (wheel) and Instant actions. + if ( + Item is Tool bagTool + && ( + activationType == ItemActivationType.Primary + || activationType == ItemActivationType.Instant + ) + && TryOpenItemBagsMenu(bagTool) + ) + { + return ItemActivationResult.Used; + } + + if (activationType == ItemActivationType.Instant) + { + if (Item is Tool tool) + { + var isActivating = IsActivating(); + if (!isActivating && (!Context.CanPlayerMove || who.canOnlyWalk || who.UsingTool)) + { + return ItemActivationResult.Ignored; + } + if (who.CurrentTool != tool) + { + var toolIndex = who.Items.IndexOf(tool); + if (toolIndex < 0) + { + return ItemActivationResult.Ignored; + } + who.CurrentToolIndex = toolIndex; + } + if (tool is FishingRod rod && rod.fishCaught) + { + rod.doneHoldingFish(who); + return ItemActivationResult.Used; + } + else if (tool is not MeleeWeapon) + { + who.FireTool(); + } + Game1.pressUseToolButton(); + return isActivating + ? ItemActivationResult.Used + : ItemActivationResult.ToolUseStarted; + } + else if (Item is SObject obj && obj.isPlaceable()) + { + var previousToolIndex = who.CurrentToolIndex; + var grabTile = Game1.GetPlacementGrabTile(); + var placementPosition = Utility.GetNearbyValidPlacementPosition( + who, + who.currentLocation, + obj, + (int)grabTile.X * 64, + (int)grabTile.Y * 64 + ); + try + { + who.CurrentToolIndex = who.Items.IndexOf(obj); + return Utility.tryToPlaceItem( + who.currentLocation, + obj, + (int)placementPosition.X, + (int)placementPosition.Y + ) + ? ItemActivationResult.Used + : ItemActivationResult.Ignored; + } + finally + { + who.CurrentToolIndex = previousToolIndex; + } + } + } + + return FuzzyActivation.ConsumeOrSelect( + who, + Item, + delayedActions, + activationType == ItemActivationType.Secondary + ? InventoryAction.Select + : InventoryAction.Use + ); + } + + public void ContinueActivation() + { + var who = Game1.player; + if (Item is not Tool tool || who.CurrentTool != tool) + { + return; + } + if (!who.canReleaseTool || who.Stamina < 1 || tool is FishingRod) + { + return; + } + var maxPowerModifier = tool.hasEnchantmentOfType() ? 1 : 0; + var maxPower = tool.UpgradeLevel + maxPowerModifier; + if (who.toolPower.Value >= maxPower) + { + return; + } + if (who.toolHold.Value <= 0) + { + who.toolHold.Value = (int)(tool.AnimationSpeedModifier * 600); + } + else + { + who.toolHold.Value -= Game1.currentGameTime.ElapsedGameTime.Milliseconds; + if (who.toolHold.Value <= 0) + { + who.toolPowerIncrease(); + } + } + } + + public bool EndActivation() + { + var who = Game1.player; + if (Item is Tool tool && who.CurrentTool == tool && who.UsingTool && who.canReleaseTool) + { + if (Item is not FishingRod) + { + who.EndUsingTool(); + } + return true; + } + // This isn't equivalent to vanilla logic, but if we detect that the player is no longer + // using ANY tool (which is what UsingTool tells us) then any button that the controller is + // "holding" should be released anyway, so that it can be pressed again. + if (!who.UsingTool) + { + return true; + } + return false; + } + + public string? GetActivationSound( + Farmer who, + ItemActivationType activationType, + string defaultSound + ) + { + return Item is Tool && activationType == ItemActivationType.Instant ? null : defaultSound; + } + + private static Texture2D? GetDerivedTexture(Item item) + { + if (!DerivedTextures.TryGetValue(item, out var texture)) + { + try + { + var graphicsDevice = Game1.graphics.GraphicsDevice; + var spriteBatch = Game1.spriteBatch; + var previousTargets = graphicsDevice.GetRenderTargets(); + var renderTarget = new RenderTarget2D(graphicsDevice, 64, 64); + graphicsDevice.SetRenderTarget(renderTarget); + graphicsDevice.Clear(Color.Transparent); + spriteBatch.Begin( + SpriteSortMode.Deferred, + BlendState.AlphaBlend, + rasterizerState: new() { MultiSampleAntiAlias = false }, + samplerState: SamplerState.PointClamp + ); + try + { + item.drawInMenu(spriteBatch, Vector2.Zero, 1f); + texture = renderTarget; + DerivedTextures.Add(item, texture); + } + catch + { + renderTarget.Dispose(); + throw; + } + finally + { + spriteBatch.End(); + graphicsDevice.SetRenderTargets(previousTargets); + } + } + catch (Exception ex) + { + Logger.Log( + $"Error deriving texture for item '{item.Name}' ({item.QualifiedItemId}): {ex}", + LogLevel.Error + ); + } + } + return texture; + } + + public bool IsActivating() + { + return Item is Tool tool && Game1.player.CurrentTool == tool && Game1.player.UsingTool; + } + + private static ParsedItemData? GetTextureRedirect(Item item) + { + return item is SObject obj && item.ItemId == "SmokedFish" + ? ItemRegistry.GetData(obj.preservedParentSheetIndex.Value) + : null; + } + + private static (Rectangle? tintRect, Color? tintColor) GetTinting( + Item item, + ParsedItemData data + ) + { + if (item is not ColoredObject coloredObject) + { + return default; + } + if (item.ItemId == "SmokedFish") + { + // Smoked fish implementation is unique (and private) in ColoredObject. + // We don't care about the animation here, but should draw it darkened; the quirky + // way this is implemented is to draw a tinted version of the original item sprite + // (not an overlay) sprite over top of the original sprite. + return (data.GetSourceRect(), new Color(80, 30, 10) * 0.6f); + } + return !coloredObject.ColorSameIndexAsParentSheetIndex + ? (data.GetSourceRect(1), coloredObject.color.Value) + : (null, coloredObject.color.Value); + } + + // When we call Item.getDescription(), most implementations go through `Game1.parseText` + // which splits the string itself onto multiple lines. This tries to remove that, so that we + // can do our own wrapping using our own width. + // + // N.B. The reason we don't just use `ParsedItemData.Description` is that, at least in the + // current version, it's often only a "base description" and includes format placeholders, + // or is missing suffixes. + private static string UnparseText(string text) + { + var sb = new StringBuilder(); + var isWhitespace = false; + var newlineCount = 0; + foreach (var c in text) + { + if (c == ' ' || c == '\r' || c == '\n') + { + if (!isWhitespace) + { + sb.Append(' '); + } + isWhitespace = true; + if (c == '\n') + { + newlineCount++; + } + } + else + { + // If the original text has a "paragraph", the formatted text will often look + // strange if that is also collapsed into a space. So preserve _multiple_ + // newlines somewhat as a single "paragraph break". + if (newlineCount > 1) + { + // From implementation above, newlines are counted as whitespace so we know + // that the last character will always be a space when hitting here. + sb.Length--; + sb.Append("\r\n\r\n"); + } + sb.Append(c); + isWhitespace = false; + newlineCount = 0; + } + } + if (isWhitespace) + { + sb.Length--; + } + return sb.ToString(); + } + + // Cache reflection lookups (don’t re-scan assemblies every press) + private static Type? _itemBagsBaseType; + private static MethodInfo? _openContentsMethod; + + private static bool TryOpenItemBagsMenu(Tool tool) + { + try + { + _itemBagsBaseType ??= AppDomain + .CurrentDomain.GetAssemblies() + .Select(a => a.GetType("ItemBags.Bags.ItemBag", throwOnError: false)) + .FirstOrDefault(t => t is not null); + + if (_itemBagsBaseType is null) + return false; + + if (!_itemBagsBaseType.IsInstanceOfType(tool)) + return false; + + var player = Game1.player; + if (player is null || player.CursorSlotItem is not null) + return false; + + _openContentsMethod ??= _itemBagsBaseType.GetMethod( + "OpenContents", + BindingFlags.Public | BindingFlags.Instance + ); + + if (_openContentsMethod is null) + return false; + + var parameters = _openContentsMethod.GetParameters(); + + if (parameters.Length == 0) + { + _openContentsMethod.Invoke(tool, null); + return true; + } + + if (parameters.Length == 3) + { + _openContentsMethod.Invoke( + tool, + new object?[] { player.Items, player.MaxItems, null } + ); + return true; + } + + return false; + } + catch + { + return false; + } + } +} diff --git a/StarControl/Menus/QuickSlotRenderer.cs b/StarControl/Menus/QuickSlotRenderer.cs index 6c3c417..435dc26 100644 --- a/StarControl/Menus/QuickSlotRenderer.cs +++ b/StarControl/Menus/QuickSlotRenderer.cs @@ -1,553 +1,553 @@ -using Microsoft.Xna.Framework; -using Microsoft.Xna.Framework.Graphics; -using StarControl.Config; -using StarControl.Data; -using StarControl.Graphics; -using StardewValley; - -namespace StarControl.Menus; - -internal class QuickSlotRenderer(GraphicsDevice graphicsDevice, ModConfig config) -{ - private const float MENU_SCALE = 0.7f; // scales the entire quick action menu - private const float SLOT_CONTENT_SCALE = 1.2f; // scales slot visuals without moving the menu - - private record ButtonFlash(FlashType Type, float DurationMs, float ElapsedMs = 0); - - private enum FlashType - { - Delay, - Error, - } - - private enum PromptPosition - { - Above, - Below, - Left, - Right, - } - - public float BackgroundOpacity { get; set; } = 1; - public float SpriteOpacity { get; set; } = 1; - - public IReadOnlyDictionary SlotItems { get; set; } = - new Dictionary(); - public IReadOnlyDictionary Slots { get; set; } = - new Dictionary(); - - public bool UnassignedButtonsVisible { get; set; } = true; - - private const int IMAGE_SIZE = 64; - private const int SLOT_PADDING = 20; - private const int SLOT_SIZE = (int)((IMAGE_SIZE + SLOT_PADDING * 2) * MENU_SCALE * 1f); - private const int SLOT_DISTANCE = (int)((SLOT_SIZE - 12) * MENU_SCALE * 2.6f); - private const int MARGIN_OUTER = 32; - private const int MARGIN_HORIZONTAL = 120; - private const int MARGIN_VERTICAL = 16; - private const int PROMPT_OFFSET = SLOT_SIZE / 2; - private const int PROMPT_SIZE = 26; - private const int BACKGROUND_RADIUS = (int)( - (SLOT_DISTANCE + SLOT_SIZE / 2 + MARGIN_OUTER) * 0.7f - ); - - private readonly Dictionary flashes = []; - private readonly HashSet enabledSlots = []; - private readonly Dictionary slotSprites = []; - private readonly GraphicsDevice graphicsDevice = graphicsDevice; - - private Color disabledBackgroundColor = Color.Transparent; - private Color innerBackgroundColor = Color.Transparent; - private bool isDirty = true; - private float quickSlotScale = 1f; - private Texture2D outerBackground = null!; - private Texture2D slotBackground = null!; - private Texture2D? uiTexture; - private ButtonIconSet? uiTextureIconSet; - - public void Draw(SpriteBatch b, Rectangle viewport) - { - UpdateScale(); - if (isDirty) - { - innerBackgroundColor = (Color)config.Style.OuterBackgroundColor * 0.6f; - disabledBackgroundColor = LumaGray(innerBackgroundColor, 0.75f); - RefreshSlots(); - } - - var leftOrigin = new Point( - viewport.Left - + Scale(MARGIN_HORIZONTAL * MENU_SCALE) - + Scale(MARGIN_OUTER * MENU_SCALE) - + Scale(SLOT_SIZE / 2f), - viewport.Bottom - - Scale(MARGIN_VERTICAL * MENU_SCALE) - - Scale(MARGIN_OUTER * MENU_SCALE) - - Scale(SLOT_SIZE) - - Scale(SLOT_SIZE / 2f) - ); - var leftShoulderPosition = leftOrigin.AddX(Scale(SLOT_DISTANCE * MENU_SCALE)); - if ( - UnassignedButtonsVisible - || HasSlotSprite(SButton.DPadLeft) - || HasSlotSprite(SButton.DPadUp) - || HasSlotSprite(SButton.DPadRight) - || HasSlotSprite(SButton.DPadDown) - ) - { - var leftBackgroundRect = GetCircleRect( - leftOrigin.AddX(Scale(SLOT_DISTANCE * MENU_SCALE)), - Scale(BACKGROUND_RADIUS) - ); - b.Draw( - outerBackground, - leftBackgroundRect, - (Color)config.Style.InnerBackgroundColor * BackgroundOpacity - ); - DrawSlot(b, leftOrigin, SButton.DPadLeft, PromptPosition.Left); - DrawSlot( - b, - leftOrigin.Add( - Scale(SLOT_DISTANCE * MENU_SCALE), - -Scale(SLOT_DISTANCE * MENU_SCALE) - ), - SButton.DPadUp, - PromptPosition.Above - ); - DrawSlot( - b, - leftOrigin.Add( - Scale(SLOT_DISTANCE * MENU_SCALE), - Scale(SLOT_DISTANCE * MENU_SCALE) - ), - SButton.DPadDown, - PromptPosition.Below - ); - DrawSlot( - b, - leftOrigin.AddX(Scale(SLOT_DISTANCE * MENU_SCALE * 2f)), - SButton.DPadRight, - PromptPosition.Right - ); - leftShoulderPosition.Y -= Scale(SLOT_DISTANCE * 2.5f * MENU_SCALE); - } - if (enabledSlots.Contains(SButton.LeftShoulder)) - { - DrawSlot( - b, - leftShoulderPosition, - SButton.LeftShoulder, - PromptPosition.Above, - darken: true - ); - } - - var rightOrigin = new Point( - viewport.Right - - Scale(MARGIN_HORIZONTAL * MENU_SCALE) - - Scale(MARGIN_OUTER * MENU_SCALE) - - Scale(SLOT_SIZE / 2f), - leftOrigin.Y - ); - var rightShoulderPosition = rightOrigin.AddX(-Scale(SLOT_DISTANCE * MENU_SCALE)); - if ( - UnassignedButtonsVisible - || HasSlotSprite(SButton.ControllerX) - || HasSlotSprite(SButton.ControllerY) - || HasSlotSprite(SButton.ControllerA) - || HasSlotSprite(SButton.ControllerB) - ) - { - var rightBackgroundRect = GetCircleRect( - rightOrigin.AddX(-Scale(SLOT_DISTANCE * MENU_SCALE)), - Scale(BACKGROUND_RADIUS) - ); - b.Draw( - outerBackground, - rightBackgroundRect, - (Color)config.Style.InnerBackgroundColor * BackgroundOpacity - ); - DrawSlot(b, rightOrigin, SButton.ControllerB, PromptPosition.Right); - DrawSlot( - b, - rightOrigin.Add( - -Scale(SLOT_DISTANCE * MENU_SCALE), - -Scale(SLOT_DISTANCE * MENU_SCALE) - ), - SButton.ControllerY, - PromptPosition.Above - ); - DrawSlot( - b, - rightOrigin.Add( - -Scale(SLOT_DISTANCE * MENU_SCALE), - Scale(SLOT_DISTANCE * MENU_SCALE) - ), - SButton.ControllerA, - PromptPosition.Below - ); - DrawSlot( - b, - rightOrigin.AddX(-Scale(SLOT_DISTANCE * MENU_SCALE * 2f)), - SButton.ControllerX, - PromptPosition.Left - ); - rightShoulderPosition.Y -= Scale(SLOT_DISTANCE * 2.5f * MENU_SCALE); - } - if (enabledSlots.Contains(SButton.RightShoulder)) - { - DrawSlot( - b, - rightShoulderPosition, - SButton.RightShoulder, - PromptPosition.Above, - darken: true - ); - } - } - - private void UpdateScale(bool force = false) - { - var desiredScale = Math.Clamp(config.Style.QuickActionScale, 0.5f, 1.5f); - if ( - !force - && MathF.Abs(desiredScale - quickSlotScale) < 0.001f - && outerBackground is not null - ) - { - return; - } - quickSlotScale = desiredScale; - outerBackground?.Dispose(); - slotBackground?.Dispose(); - outerBackground = ShapeTexture.CreateCircle( - Scale((SLOT_SIZE + SLOT_SIZE / 2f + MARGIN_OUTER) * MENU_SCALE), - filled: true, - graphicsDevice: graphicsDevice - ); - slotBackground = ShapeTexture.CreateCircle( - Scale(SLOT_SIZE / 2f), - filled: true, - graphicsDevice: graphicsDevice - ); - isDirty = true; - } - - private int Scale(float value) - { - return (int)MathF.Round(value * quickSlotScale); - } - - public void FlashDelay(SButton button) - { - flashes[button] = new(FlashType.Delay, config.Input.ActivationDelayMs); - } - - public void FlashError(SButton button) - { - flashes[button] = new(FlashType.Error, Animation.ERROR_FLASH_DURATION_MS); - } - - public bool HasSlotSprite(SButton button) - { - return slotSprites.ContainsKey(button); - } - - public void Invalidate() - { - isDirty = true; - } - - public void Update(TimeSpan elapsed) - { - foreach (var (button, flash) in flashes) - { - var flashElapsedMs = flash.ElapsedMs + (float)elapsed.TotalMilliseconds; - if (flashElapsedMs >= flash.DurationMs) - { - flashes.Remove(button); - continue; - } - flashes[button] = flash with { ElapsedMs = flashElapsedMs }; - } - } - - private void DrawSlot( - SpriteBatch b, - Point origin, - SButton button, - PromptPosition promptPosition, - bool darken = false - ) - { - var isAssigned = slotSprites.TryGetValue(button, out var sprite); - if (!isAssigned && !UnassignedButtonsVisible) - { - return; - } - - var isEnabled = enabledSlots.Contains(button); - var backgroundRect = GetCircleRect(origin, Scale(SLOT_SIZE * SLOT_CONTENT_SCALE / 2f)); - if (darken) - { - var darkenRect = backgroundRect; - darkenRect.Inflate(4, 4); - b.Draw(slotBackground, darkenRect, Color.Black * BackgroundOpacity); - } - var backgroundColor = GetBackgroundColor(button, isAssigned && isEnabled); - b.Draw(slotBackground, backgroundRect, backgroundColor * BackgroundOpacity); - - var slotOpacity = isEnabled ? 1f : 0.5f; - - if (isAssigned) - { - var spriteRect = GetCircleRect( - origin, - Scale(IMAGE_SIZE * MENU_SCALE * SLOT_CONTENT_SCALE / 2f) - ); - if (SlotItems.TryGetValue(button, out var item) && item.Texture is not null) - { - ItemRenderer.Draw( - b, - item, - spriteRect, - config.Style, - opacity: slotOpacity * SpriteOpacity - ); - } - else - { - b.Draw( - sprite!.Texture, - spriteRect, - sprite.SourceRect, - Color.White * slotOpacity * SpriteOpacity - ); - } - } - - if (GetPromptSprite(button) is { } promptSprite) - { - var promptOffset = Scale(PROMPT_OFFSET * SLOT_CONTENT_SCALE); - var promptOrigin = promptPosition switch - { - PromptPosition.Above => origin.AddY(-promptOffset), - PromptPosition.Below => origin.AddY(promptOffset), - PromptPosition.Left => origin.AddX(-promptOffset), - PromptPosition.Right => origin.AddX(promptOffset), - _ => throw new ArgumentException( - $"Invalid prompt position: {promptPosition}", - nameof(promptPosition) - ), - }; - var promptRect = GetCircleRect( - promptOrigin, - Scale(PROMPT_SIZE * SLOT_CONTENT_SCALE / 2f) - ); - b.Draw( - promptSprite.Texture, - promptRect, - promptSprite.SourceRect, - Color.White * slotOpacity * SpriteOpacity - ); - } - } - - private Color GetBackgroundColor(SButton button, bool enabled) - { - var baseColor = enabled ? innerBackgroundColor : disabledBackgroundColor; - if (!flashes.TryGetValue(button, out var flash)) - { - return baseColor; - } - var (flashColor, position) = flash.Type switch - { - FlashType.Delay => ( - config.Style.HighlightColor, - Animation.GetDelayFlashPosition(flash.ElapsedMs) - ), - FlashType.Error => (Color.Red, Animation.GetErrorFlashPosition(flash.ElapsedMs)), - _ => (Color.White, 0), - }; - return Color.Lerp(baseColor, flashColor, position); - } - - private static Rectangle GetCircleRect(Point center, int radius) - { - int length = radius * 2; - return new(center.X - radius, center.Y - radius, length, length); - } - - private static Sprite GetIconSprite(IconConfig icon) - { - return !string.IsNullOrEmpty(icon.ItemId) - ? Sprite.ForItemId(icon.ItemId) - : Sprite.TryLoad(icon.TextureAssetPath, icon.SourceRect) - ?? Sprite.ForItemId("Error_Invalid"); - } - - private Sprite? GetModItemSprite(string id) - { - var itemConfig = config - .Items.ModMenuPages.SelectMany(items => items) - .FirstOrDefault(item => item.Id == id); - return itemConfig?.Icon is { } icon ? GetIconSprite(icon) : null; - } - - private Sprite? GetPromptSprite(SButton button) - { - var (rowIndex, columnIndex) = button switch - { - SButton.DPadUp => (1, 0), - SButton.DPadRight => (1, 1), - SButton.DPadDown => (1, 2), - SButton.DPadLeft => (1, 3), - SButton.ControllerA => (1, 4), - SButton.ControllerB => (1, 5), - SButton.ControllerX => (1, 6), - SButton.ControllerY => (1, 7), - SButton.LeftTrigger => (2, 0), - SButton.RightTrigger => (2, 1), - SButton.LeftShoulder => (2, 2), - SButton.RightShoulder => (2, 3), - SButton.ControllerBack => (2, 4), - SButton.ControllerStart => (2, 5), - SButton.LeftStick => (2, 6), - SButton.RightStick => (2, 7), - _ => (-1, -1), - }; - if (columnIndex == -1) - { - return null; - } - var texture = GetUiTexture(); - return new(texture, new(columnIndex * 16, rowIndex * 16, 16, 16)); - } - - private Texture2D GetUiTexture() - { - var desiredSet = config.Style.ButtonIconSet; - if (uiTexture is null || uiTextureIconSet != desiredSet) - { - var assetPath = - desiredSet == ButtonIconSet.PlayStation - ? Sprites.UI_PLAYSTATION_TEXTURE_PATH - : Sprites.UI_TEXTURE_PATH; - uiTexture = Game1.content.Load(assetPath); - uiTextureIconSet = desiredSet; - } - return uiTexture; - } - - private Sprite? GetSlotSprite(IItemLookup itemLookup, ICollection inventoryItems) - { - if (string.IsNullOrWhiteSpace(itemLookup.Id)) - return null; - - return itemLookup.IdType switch - { - ItemIdType.GameItem => QuickSlotResolver.ResolveInventoryItem( - itemLookup.Id, - itemLookup.SubId, - inventoryItems - ) - is Item item - ? Sprite.FromItem(item) // ✅ handles Item Bags via drawInMenu fallback - : ( - ItemRegistry.GetData(itemLookup.Id) is not null - ? Sprite.ForItemId(itemLookup.Id) // only for registered items - : null - ), // unregistered (ItemBags): don't force error icon - - ItemIdType.ModItem => GetModItemSprite(itemLookup.Id), - _ => null, - }; - } - - private static Color LumaGray(Color color, float lightness) - { - var v = (int)((color.R * 0.2126f + color.G * 0.7152f + color.B * 0.0722f) * lightness); - return new(v, v, v); - } - - private void RefreshSlots() - { - Logger.Log(LogCategory.QuickSlots, "Starting refresh of quick slot renderer data."); - enabledSlots.Clear(); - // Keep slotSprites so we can show a "last known" icon when a slot item is temporarily unavailable. - foreach (var (button, slotConfig) in Slots) - { - Logger.Log(LogCategory.QuickSlots, $"Checking slot for {button}..."); - Sprite? sprite = null; - if (SlotItems.TryGetValue(button, out var item)) - { - if (item.Texture is not null) - { - Logger.Log( - LogCategory.QuickSlots, - $"Using configured item sprite for {item.Title} in {button} slot." - ); - sprite = new(item.Texture, item.SourceRectangle ?? item.Texture.Bounds); - } - else - { - Logger.Log( - LogCategory.QuickSlots, - $"Item {item.Title} in {button} slot has no texture; using default sprite." - ); - } - enabledSlots.Add(button); - Logger.Log( - LogCategory.QuickSlots, - $"Enabled {button} slot with '{item.Title}'.", - LogLevel.Info - ); - } - else - { - Logger.Log( - LogCategory.QuickSlots, - $"Disabled unassigned {button} slot.", - LogLevel.Info - ); - } - sprite ??= GetSlotSprite( - slotConfig, - QuickSlotResolver.GetExpandedPlayerItems(Game1.player) - ); - if (sprite is not null) - { - slotSprites[button] = sprite; // update/insert - } - else - { - // If we had a previous sprite for this slot, keep it so the icon stays visible (greyed out). - // If we have none, fall back to a generic item-id sprite so something renders. - // (like Item Bags), Sprite.ForItemId will show Error_Invalid, which is worse UX. - if (!slotSprites.ContainsKey(button) && !string.IsNullOrWhiteSpace(slotConfig.Id)) - { - slotSprites[button] = Sprite.ForItemId("Error_Invalid"); - } - } - } - isDirty = false; - } -} - -file static class PointExtensions -{ - public static Point Add(this Point point, int x, int y) - { - return new(point.X + x, point.Y + y); - } - - public static Point AddX(this Point point, int x) - { - return new(point.X + x, point.Y); - } - - public static Point AddY(this Point point, int y) - { - return new(point.X, point.Y + y); - } -} +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Graphics; +using StarControl.Config; +using StarControl.Data; +using StarControl.Graphics; +using StardewValley; + +namespace StarControl.Menus; + +internal class QuickSlotRenderer(GraphicsDevice graphicsDevice, ModConfig config) +{ + private const float MENU_SCALE = 0.7f; // scales the entire quick action menu + private const float SLOT_CONTENT_SCALE = 1.2f; // scales slot visuals without moving the menu + + private record ButtonFlash(FlashType Type, float DurationMs, float ElapsedMs = 0); + + private enum FlashType + { + Delay, + Error, + } + + private enum PromptPosition + { + Above, + Below, + Left, + Right, + } + + public float BackgroundOpacity { get; set; } = 1; + public float SpriteOpacity { get; set; } = 1; + + public IReadOnlyDictionary SlotItems { get; set; } = + new Dictionary(); + public IReadOnlyDictionary Slots { get; set; } = + new Dictionary(); + + public bool UnassignedButtonsVisible { get; set; } = true; + + private const int IMAGE_SIZE = 64; + private const int SLOT_PADDING = 20; + private const int SLOT_SIZE = (int)((IMAGE_SIZE + SLOT_PADDING * 2) * MENU_SCALE * 1f); + private const int SLOT_DISTANCE = (int)((SLOT_SIZE - 12) * MENU_SCALE * 2.6f); + private const int MARGIN_OUTER = 32; + private const int MARGIN_HORIZONTAL = 120; + private const int MARGIN_VERTICAL = 16; + private const int PROMPT_OFFSET = SLOT_SIZE / 2; + private const int PROMPT_SIZE = 26; + private const int BACKGROUND_RADIUS = (int)( + (SLOT_DISTANCE + SLOT_SIZE / 2 + MARGIN_OUTER) * 0.7f + ); + + private readonly Dictionary flashes = []; + private readonly HashSet enabledSlots = []; + private readonly Dictionary slotSprites = []; + private readonly GraphicsDevice graphicsDevice = graphicsDevice; + + private Color disabledBackgroundColor = Color.Transparent; + private Color innerBackgroundColor = Color.Transparent; + private bool isDirty = true; + private float quickSlotScale = 1f; + private Texture2D outerBackground = null!; + private Texture2D slotBackground = null!; + private Texture2D? uiTexture; + private ButtonIconSet? uiTextureIconSet; + + public void Draw(SpriteBatch b, Rectangle viewport) + { + UpdateScale(); + if (isDirty) + { + innerBackgroundColor = (Color)config.Style.OuterBackgroundColor * 0.6f; + disabledBackgroundColor = LumaGray(innerBackgroundColor, 0.75f); + RefreshSlots(); + } + + var leftOrigin = new Point( + viewport.Left + + Scale(MARGIN_HORIZONTAL * MENU_SCALE) + + Scale(MARGIN_OUTER * MENU_SCALE) + + Scale(SLOT_SIZE / 2f), + viewport.Bottom + - Scale(MARGIN_VERTICAL * MENU_SCALE) + - Scale(MARGIN_OUTER * MENU_SCALE) + - Scale(SLOT_SIZE) + - Scale(SLOT_SIZE / 2f) + ); + var leftShoulderPosition = leftOrigin.AddX(Scale(SLOT_DISTANCE * MENU_SCALE)); + if ( + UnassignedButtonsVisible + || HasSlotSprite(SButton.DPadLeft) + || HasSlotSprite(SButton.DPadUp) + || HasSlotSprite(SButton.DPadRight) + || HasSlotSprite(SButton.DPadDown) + ) + { + var leftBackgroundRect = GetCircleRect( + leftOrigin.AddX(Scale(SLOT_DISTANCE * MENU_SCALE)), + Scale(BACKGROUND_RADIUS) + ); + b.Draw( + outerBackground, + leftBackgroundRect, + (Color)config.Style.InnerBackgroundColor * BackgroundOpacity + ); + DrawSlot(b, leftOrigin, SButton.DPadLeft, PromptPosition.Left); + DrawSlot( + b, + leftOrigin.Add( + Scale(SLOT_DISTANCE * MENU_SCALE), + -Scale(SLOT_DISTANCE * MENU_SCALE) + ), + SButton.DPadUp, + PromptPosition.Above + ); + DrawSlot( + b, + leftOrigin.Add( + Scale(SLOT_DISTANCE * MENU_SCALE), + Scale(SLOT_DISTANCE * MENU_SCALE) + ), + SButton.DPadDown, + PromptPosition.Below + ); + DrawSlot( + b, + leftOrigin.AddX(Scale(SLOT_DISTANCE * MENU_SCALE * 2f)), + SButton.DPadRight, + PromptPosition.Right + ); + leftShoulderPosition.Y -= Scale(SLOT_DISTANCE * 2.5f * MENU_SCALE); + } + if (enabledSlots.Contains(SButton.LeftShoulder)) + { + DrawSlot( + b, + leftShoulderPosition, + SButton.LeftShoulder, + PromptPosition.Above, + darken: true + ); + } + + var rightOrigin = new Point( + viewport.Right + - Scale(MARGIN_HORIZONTAL * MENU_SCALE) + - Scale(MARGIN_OUTER * MENU_SCALE) + - Scale(SLOT_SIZE / 2f), + leftOrigin.Y + ); + var rightShoulderPosition = rightOrigin.AddX(-Scale(SLOT_DISTANCE * MENU_SCALE)); + if ( + UnassignedButtonsVisible + || HasSlotSprite(SButton.ControllerX) + || HasSlotSprite(SButton.ControllerY) + || HasSlotSprite(SButton.ControllerA) + || HasSlotSprite(SButton.ControllerB) + ) + { + var rightBackgroundRect = GetCircleRect( + rightOrigin.AddX(-Scale(SLOT_DISTANCE * MENU_SCALE)), + Scale(BACKGROUND_RADIUS) + ); + b.Draw( + outerBackground, + rightBackgroundRect, + (Color)config.Style.InnerBackgroundColor * BackgroundOpacity + ); + DrawSlot(b, rightOrigin, SButton.ControllerB, PromptPosition.Right); + DrawSlot( + b, + rightOrigin.Add( + -Scale(SLOT_DISTANCE * MENU_SCALE), + -Scale(SLOT_DISTANCE * MENU_SCALE) + ), + SButton.ControllerY, + PromptPosition.Above + ); + DrawSlot( + b, + rightOrigin.Add( + -Scale(SLOT_DISTANCE * MENU_SCALE), + Scale(SLOT_DISTANCE * MENU_SCALE) + ), + SButton.ControllerA, + PromptPosition.Below + ); + DrawSlot( + b, + rightOrigin.AddX(-Scale(SLOT_DISTANCE * MENU_SCALE * 2f)), + SButton.ControllerX, + PromptPosition.Left + ); + rightShoulderPosition.Y -= Scale(SLOT_DISTANCE * 2.5f * MENU_SCALE); + } + if (enabledSlots.Contains(SButton.RightShoulder)) + { + DrawSlot( + b, + rightShoulderPosition, + SButton.RightShoulder, + PromptPosition.Above, + darken: true + ); + } + } + + private void UpdateScale(bool force = false) + { + var desiredScale = Math.Clamp(config.Style.QuickActionScale, 0.5f, 1.5f); + if ( + !force + && MathF.Abs(desiredScale - quickSlotScale) < 0.001f + && outerBackground is not null + ) + { + return; + } + quickSlotScale = desiredScale; + outerBackground?.Dispose(); + slotBackground?.Dispose(); + outerBackground = ShapeTexture.CreateCircle( + Scale((SLOT_SIZE + SLOT_SIZE / 2f + MARGIN_OUTER) * MENU_SCALE), + filled: true, + graphicsDevice: graphicsDevice + ); + slotBackground = ShapeTexture.CreateCircle( + Scale(SLOT_SIZE / 2f), + filled: true, + graphicsDevice: graphicsDevice + ); + isDirty = true; + } + + private int Scale(float value) + { + return (int)MathF.Round(value * quickSlotScale); + } + + public void FlashDelay(SButton button) + { + flashes[button] = new(FlashType.Delay, config.Input.ActivationDelayMs); + } + + public void FlashError(SButton button) + { + flashes[button] = new(FlashType.Error, Animation.ERROR_FLASH_DURATION_MS); + } + + public bool HasSlotSprite(SButton button) + { + return slotSprites.ContainsKey(button); + } + + public void Invalidate() + { + isDirty = true; + } + + public void Update(TimeSpan elapsed) + { + foreach (var (button, flash) in flashes) + { + var flashElapsedMs = flash.ElapsedMs + (float)elapsed.TotalMilliseconds; + if (flashElapsedMs >= flash.DurationMs) + { + flashes.Remove(button); + continue; + } + flashes[button] = flash with { ElapsedMs = flashElapsedMs }; + } + } + + private void DrawSlot( + SpriteBatch b, + Point origin, + SButton button, + PromptPosition promptPosition, + bool darken = false + ) + { + var isAssigned = slotSprites.TryGetValue(button, out var sprite); + if (!isAssigned && !UnassignedButtonsVisible) + { + return; + } + + var isEnabled = enabledSlots.Contains(button); + var backgroundRect = GetCircleRect(origin, Scale(SLOT_SIZE * SLOT_CONTENT_SCALE / 2f)); + if (darken) + { + var darkenRect = backgroundRect; + darkenRect.Inflate(4, 4); + b.Draw(slotBackground, darkenRect, Color.Black * BackgroundOpacity); + } + var backgroundColor = GetBackgroundColor(button, isAssigned && isEnabled); + b.Draw(slotBackground, backgroundRect, backgroundColor * BackgroundOpacity); + + var slotOpacity = isEnabled ? 1f : 0.5f; + + if (isAssigned) + { + var spriteRect = GetCircleRect( + origin, + Scale(IMAGE_SIZE * MENU_SCALE * SLOT_CONTENT_SCALE / 2f) + ); + if (SlotItems.TryGetValue(button, out var item) && item.Texture is not null) + { + ItemRenderer.Draw( + b, + item, + spriteRect, + config.Style, + opacity: slotOpacity * SpriteOpacity + ); + } + else + { + b.Draw( + sprite!.Texture, + spriteRect, + sprite.SourceRect, + Color.White * slotOpacity * SpriteOpacity + ); + } + } + + if (GetPromptSprite(button) is { } promptSprite) + { + var promptOffset = Scale(PROMPT_OFFSET * SLOT_CONTENT_SCALE); + var promptOrigin = promptPosition switch + { + PromptPosition.Above => origin.AddY(-promptOffset), + PromptPosition.Below => origin.AddY(promptOffset), + PromptPosition.Left => origin.AddX(-promptOffset), + PromptPosition.Right => origin.AddX(promptOffset), + _ => throw new ArgumentException( + $"Invalid prompt position: {promptPosition}", + nameof(promptPosition) + ), + }; + var promptRect = GetCircleRect( + promptOrigin, + Scale(PROMPT_SIZE * SLOT_CONTENT_SCALE / 2f) + ); + b.Draw( + promptSprite.Texture, + promptRect, + promptSprite.SourceRect, + Color.White * slotOpacity * SpriteOpacity + ); + } + } + + private Color GetBackgroundColor(SButton button, bool enabled) + { + var baseColor = enabled ? innerBackgroundColor : disabledBackgroundColor; + if (!flashes.TryGetValue(button, out var flash)) + { + return baseColor; + } + var (flashColor, position) = flash.Type switch + { + FlashType.Delay => ( + config.Style.HighlightColor, + Animation.GetDelayFlashPosition(flash.ElapsedMs) + ), + FlashType.Error => (Color.Red, Animation.GetErrorFlashPosition(flash.ElapsedMs)), + _ => (Color.White, 0), + }; + return Color.Lerp(baseColor, flashColor, position); + } + + private static Rectangle GetCircleRect(Point center, int radius) + { + int length = radius * 2; + return new(center.X - radius, center.Y - radius, length, length); + } + + private static Sprite GetIconSprite(IconConfig icon) + { + return !string.IsNullOrEmpty(icon.ItemId) + ? Sprite.ForItemId(icon.ItemId) + : Sprite.TryLoad(icon.TextureAssetPath, icon.SourceRect) + ?? Sprite.ForItemId("Error_Invalid"); + } + + private Sprite? GetModItemSprite(string id) + { + var itemConfig = config + .Items.ModMenuPages.SelectMany(items => items) + .FirstOrDefault(item => item.Id == id); + return itemConfig?.Icon is { } icon ? GetIconSprite(icon) : null; + } + + private Sprite? GetPromptSprite(SButton button) + { + var (rowIndex, columnIndex) = button switch + { + SButton.DPadUp => (1, 0), + SButton.DPadRight => (1, 1), + SButton.DPadDown => (1, 2), + SButton.DPadLeft => (1, 3), + SButton.ControllerA => (1, 4), + SButton.ControllerB => (1, 5), + SButton.ControllerX => (1, 6), + SButton.ControllerY => (1, 7), + SButton.LeftTrigger => (2, 0), + SButton.RightTrigger => (2, 1), + SButton.LeftShoulder => (2, 2), + SButton.RightShoulder => (2, 3), + SButton.ControllerBack => (2, 4), + SButton.ControllerStart => (2, 5), + SButton.LeftStick => (2, 6), + SButton.RightStick => (2, 7), + _ => (-1, -1), + }; + if (columnIndex == -1) + { + return null; + } + var texture = GetUiTexture(); + return new(texture, new(columnIndex * 16, rowIndex * 16, 16, 16)); + } + + private Texture2D GetUiTexture() + { + var desiredSet = config.Style.ButtonIconSet; + if (uiTexture is null || uiTextureIconSet != desiredSet) + { + var assetPath = + desiredSet == ButtonIconSet.PlayStation + ? Sprites.UI_PLAYSTATION_TEXTURE_PATH + : Sprites.UI_TEXTURE_PATH; + uiTexture = Game1.content.Load(assetPath); + uiTextureIconSet = desiredSet; + } + return uiTexture; + } + + private Sprite? GetSlotSprite(IItemLookup itemLookup, ICollection inventoryItems) + { + if (string.IsNullOrWhiteSpace(itemLookup.Id)) + return null; + + return itemLookup.IdType switch + { + ItemIdType.GameItem => QuickSlotResolver.ResolveInventoryItem( + itemLookup.Id, + itemLookup.SubId, + inventoryItems + ) + is Item item + ? Sprite.FromItem(item) // ✅ handles Item Bags via drawInMenu fallback + : ( + ItemRegistry.GetData(itemLookup.Id) is not null + ? Sprite.ForItemId(itemLookup.Id) // only for registered items + : null + ), // unregistered (ItemBags): don't force error icon + + ItemIdType.ModItem => GetModItemSprite(itemLookup.Id), + _ => null, + }; + } + + private static Color LumaGray(Color color, float lightness) + { + var v = (int)((color.R * 0.2126f + color.G * 0.7152f + color.B * 0.0722f) * lightness); + return new(v, v, v); + } + + private void RefreshSlots() + { + Logger.Log(LogCategory.QuickSlots, "Starting refresh of quick slot renderer data."); + enabledSlots.Clear(); + // Keep slotSprites so we can show a "last known" icon when a slot item is temporarily unavailable. + foreach (var (button, slotConfig) in Slots) + { + Logger.Log(LogCategory.QuickSlots, $"Checking slot for {button}..."); + Sprite? sprite = null; + if (SlotItems.TryGetValue(button, out var item)) + { + if (item.Texture is not null) + { + Logger.Log( + LogCategory.QuickSlots, + $"Using configured item sprite for {item.Title} in {button} slot." + ); + sprite = new(item.Texture, item.SourceRectangle ?? item.Texture.Bounds); + } + else + { + Logger.Log( + LogCategory.QuickSlots, + $"Item {item.Title} in {button} slot has no texture; using default sprite." + ); + } + enabledSlots.Add(button); + Logger.Log( + LogCategory.QuickSlots, + $"Enabled {button} slot with '{item.Title}'.", + LogLevel.Info + ); + } + else + { + Logger.Log( + LogCategory.QuickSlots, + $"Disabled unassigned {button} slot.", + LogLevel.Info + ); + } + sprite ??= GetSlotSprite( + slotConfig, + QuickSlotResolver.GetExpandedPlayerItems(Game1.player) + ); + if (sprite is not null) + { + slotSprites[button] = sprite; // update/insert + } + else + { + // If we had a previous sprite for this slot, keep it so the icon stays visible (greyed out). + // If we have none, fall back to a generic item-id sprite so something renders. + // (like Item Bags), Sprite.ForItemId will show Error_Invalid, which is worse UX. + if (!slotSprites.ContainsKey(button) && !string.IsNullOrWhiteSpace(slotConfig.Id)) + { + slotSprites[button] = Sprite.ForItemId("Error_Invalid"); + } + } + } + isDirty = false; + } +} + +file static class PointExtensions +{ + public static Point Add(this Point point, int x, int y) + { + return new(point.X + x, point.Y + y); + } + + public static Point AddX(this Point point, int x) + { + return new(point.X + x, point.Y); + } + + public static Point AddY(this Point point, int y) + { + return new(point.X, point.Y + y); + } +} diff --git a/StarControl/Menus/QuickSlotResolver.cs b/StarControl/Menus/QuickSlotResolver.cs index ee38f19..6a50392 100644 --- a/StarControl/Menus/QuickSlotResolver.cs +++ b/StarControl/Menus/QuickSlotResolver.cs @@ -1,321 +1,321 @@ -using System; -using System.Collections; -using System.Collections.Generic; -using System.Linq; -using System.Reflection; -using StardewValley; -using StardewValley.ItemTypeDefinitions; -using StardewValley.Tools; - -namespace StarControl.Menus; - -internal class QuickSlotResolver(Farmer player, ModMenu modMenu) -{ - public static Item? ResolveInventoryItem(string id, string? subId, ICollection items) - { - if (string.IsNullOrEmpty(subId)) - return ResolveInventoryItem(id, items); - - return items.FirstOrDefault(i => - i is not null - && i.QualifiedItemId == id - && Compat.ItemBagsIdentity.TryGetBagTypeId(i) == subId - ); - } - - public static Item? ResolveInventoryItem(string id, ICollection items) - { - // Allow exact inventory matches even if the item is not registered in ItemRegistry (e.g. Item Bags) - var exact = items.FirstOrDefault(i => i is not null && i.QualifiedItemId == id); - if (exact is not null) - return exact; - - Logger.Log(LogCategory.QuickSlots, $"Searching for inventory item equivalent to '{id}'..."); - if (ItemRegistry.GetData(id) is not { } data) - { - Logger.Log( - LogCategory.QuickSlots, - $"'{id}' does not have validx item data; aborting search." - ); - return null; - } - // Melee weapons don't have upgrades or base items, but if we didn't find an exact match, it - // is often helpful to find any other melee weapon that's available. - // Only apply fuzzy matching to melee weapons; slingshots must match exactly - if ( - data.ItemType.Identifier == "(W)" - && !data.QualifiedItemId.Contains("Slingshot") - && !data.QualifiedItemId.Contains("Bow") - ) - { - // We'll match scythes to scythes, and non-scythes to non-scythes. - // Most likely, the player wants Iridium Scythe if the slot says Scythe. The upgraded - // version is simply better, like a more typical tool. - // - // With real weapons it's fuzzier because the highest-level weapon isn't necessarily - // appropriate for the situation. If there's one quick slot for Galaxy Sword and another - // for Galaxy Hammer, then activating those slots should activate their *specific* - // weapons respectively if both are in the inventory. - // - // So we match on the inferred "type" (scythe vs. weapon) and then for non-scythe - // weapons specifically (and only those), give preference to exact matches before - // sorting by level. - var isScythe = IsScythe(data); - Logger.Log( - LogCategory.QuickSlots, - $"Item '{id}' appears to be a weapon with (scythe = {isScythe})." - ); - var bestWeapon = items - .OfType() - .Where(weapon => weapon.Name.Contains("Scythe") == isScythe) - .OrderByDescending(weapon => !isScythe && weapon.QualifiedItemId == id) - .ThenByDescending(weapon => weapon.getItemLevel()) - .FirstOrDefault(); - if (bestWeapon is not null) - { - Logger.Log( - LogCategory.QuickSlots, - "Best weapon match in inventory is " - + $"{bestWeapon.Name} with ID {bestWeapon.QualifiedItemId}." - ); - return bestWeapon; - } - } - var baseItem = data.GetBaseItem(); - Logger.Log( - LogCategory.QuickSlots, - "Searching for regular item using base item " - + $"{baseItem.InternalName} with ID {baseItem.QualifiedItemId}." - ); - var match = items - .Where(item => item is not null) - .Where(item => - item.QualifiedItemId == id - || ItemRegistry - .GetDataOrErrorItem(item.QualifiedItemId) - .GetBaseItem() - .QualifiedItemId == baseItem.QualifiedItemId - ) - .OrderByDescending(item => item is Tool tool ? tool.UpgradeLevel : 0) - .ThenByDescending(item => item.Quality) - .FirstOrDefault(); - Logger.Log( - LogCategory.QuickSlots, - $"Best match by quality/upgrade level is " - + $"{match?.Name ?? "(nothing)"} with ID {match?.QualifiedItemId ?? "N/A"}." - ); - return match; - } - - private static bool IsScythe(ParsedItemData data) - { - var method = typeof(MeleeWeapon).GetMethod( - "IsScythe", - BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static, - binder: null, - types: [typeof(string)], - modifiers: null - ); - if (method is not null) - { - try - { - return (bool)method.Invoke(null, [data.QualifiedItemId])!; - } - catch - { - // Fall through to heuristic below. - } - } - var name = data.InternalName ?? string.Empty; - return name.Contains("Scythe", StringComparison.OrdinalIgnoreCase) - || data.QualifiedItemId.Contains("Scythe", StringComparison.OrdinalIgnoreCase); - } - - private static Type? ItemBagType; - private static PropertyInfo? ItemBagContentsProp; - - private static Type? OmniBagType; - private static PropertyInfo? OmniNestedBagsProp; - - private static Type? BundleBagType; - - private static bool TryInitItemBagsReflection() - { - if (ItemBagType is not null && ItemBagContentsProp is not null && BundleBagType is not null) - return true; - - // Find ItemBags.Bags.ItemBag - ItemBagType ??= AppDomain - .CurrentDomain.GetAssemblies() - .Select(a => a.GetType("ItemBags.Bags.ItemBag", throwOnError: false)) - .FirstOrDefault(t => t is not null); - - if (ItemBagType is null) - return false; - - // public List Contents { get; set; } - ItemBagContentsProp ??= ItemBagType.GetProperty( - "Contents", - BindingFlags.Public | BindingFlags.Instance - ); - if (ItemBagContentsProp is null) - return false; - - // Find BundleBag type so we can EXCLUDE traversing into its contents - BundleBagType ??= AppDomain - .CurrentDomain.GetAssemblies() - .Select(a => a.GetType("ItemBags.Bags.BundleBag", throwOnError: false)) - .FirstOrDefault(t => t is not null); - - if (BundleBagType is null) - return false; - - // Omni bag support (nested bags) - OmniBagType ??= AppDomain - .CurrentDomain.GetAssemblies() - .Select(a => a.GetType("ItemBags.Bags.OmniBag", throwOnError: false)) - .FirstOrDefault(t => t is not null); - - if (OmniBagType is not null) - OmniNestedBagsProp ??= OmniBagType.GetProperty( - "NestedBags", - BindingFlags.Public | BindingFlags.Instance - ); - - return true; - } - - internal static bool IsItemBag(Item item) => - ItemBagType is not null && ItemBagType.IsInstanceOfType(item); - - private static bool IsBundleBag(Item item) => - BundleBagType is not null && BundleBagType.IsInstanceOfType(item); - - private static IEnumerable EnumerateBagContents(Item bag) - { - // BundleBag is explicitly excluded - if (IsBundleBag(bag)) - yield break; - - if (ItemBagContentsProp?.GetValue(bag) is not IList list || list.Count == 0) - yield break; - - foreach (var obj in list) - if (obj is Item inner) - yield return inner; - } - - private static IEnumerable EnumerateOmniNestedBags(Item bag) - { - if (OmniBagType is null || OmniNestedBagsProp is null || !OmniBagType.IsInstanceOfType(bag)) - yield break; - - if (OmniNestedBagsProp.GetValue(bag) is not IList list || list.Count == 0) - yield break; - - foreach (var obj in list) - if (obj is Item innerBag) - yield return innerBag; - } - - /// - /// Returns an "effective inventory" which includes: - /// - the player's inventory - /// - contents of any ItemBags bags in the player's inventory (EXCEPT BundleBag contents) - /// - nested bags inside OmniBags (and then their contents too) - /// - public static ICollection GetExpandedPlayerItems(Farmer who) - { - var baseItems = who.Items; - - if (!TryInitItemBagsReflection()) - return baseItems; - - var expanded = new List(baseItems.Count + 16); - - // BFS over bags we discover (supports OmniBag nesting) - var seen = new HashSet(); - var bagQueue = new Queue(); - - // 1) Start with player inventory - foreach (var it in baseItems) - { - if (it is null) - continue; - expanded.Add(it); - - if (IsItemBag(it)) - { - if (seen.Add(it)) - bagQueue.Enqueue(it); - } - } - - // 2) Expand bags: add nested bags (omni) + contents (except bundle) - int depth = 0; - while (bagQueue.Count > 0 && depth < 6) - { - int layer = bagQueue.Count; - for (int i = 0; i < layer; i++) - { - var bag = bagQueue.Dequeue(); - - // Omni nested bags - foreach (var nestedBag in EnumerateOmniNestedBags(bag)) - { - expanded.Add(nestedBag); - if (IsItemBag(nestedBag) && seen.Add(nestedBag)) - bagQueue.Enqueue(nestedBag); - } - - // Bag contents (except BundleBag) - foreach (var innerItem in EnumerateBagContents(bag)) - { - expanded.Add(innerItem); - - // If someone manages to store a bag as an Item (or modded bag item), expand it too. - if (IsItemBag(innerItem) && seen.Add(innerItem)) - bagQueue.Enqueue(innerItem); - } - } - - depth++; - } - - return expanded; - } - - public IRadialMenuItem? ResolveItem(string id, ItemIdType idType) - { - if (string.IsNullOrEmpty(id)) - { - return null; - } - return idType switch - { - ItemIdType.GameItem => ResolveInventoryItem(id, GetExpandedPlayerItems(player)) - is { } item - ? new InventoryMenuItem(item) - : null, - ItemIdType.ModItem => modMenu.GetItem(id), - _ => null, - }; - } - - public IRadialMenuItem? ResolveItem(string id, string? subId, ItemIdType idType) - { - if (string.IsNullOrEmpty(id)) - return null; - - return idType switch - { - ItemIdType.GameItem => ResolveInventoryItem(id, subId, GetExpandedPlayerItems(player)) - is { } item - ? new InventoryMenuItem(item) - : null, - ItemIdType.ModItem => modMenu.GetItem(id), - _ => null, - }; - } -} +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using StardewValley; +using StardewValley.ItemTypeDefinitions; +using StardewValley.Tools; + +namespace StarControl.Menus; + +internal class QuickSlotResolver(Farmer player, ModMenu modMenu) +{ + public static Item? ResolveInventoryItem(string id, string? subId, ICollection items) + { + if (string.IsNullOrEmpty(subId)) + return ResolveInventoryItem(id, items); + + return items.FirstOrDefault(i => + i is not null + && i.QualifiedItemId == id + && Compat.ItemBagsIdentity.TryGetBagTypeId(i) == subId + ); + } + + public static Item? ResolveInventoryItem(string id, ICollection items) + { + // Allow exact inventory matches even if the item is not registered in ItemRegistry (e.g. Item Bags) + var exact = items.FirstOrDefault(i => i is not null && i.QualifiedItemId == id); + if (exact is not null) + return exact; + + Logger.Log(LogCategory.QuickSlots, $"Searching for inventory item equivalent to '{id}'..."); + if (ItemRegistry.GetData(id) is not { } data) + { + Logger.Log( + LogCategory.QuickSlots, + $"'{id}' does not have validx item data; aborting search." + ); + return null; + } + // Melee weapons don't have upgrades or base items, but if we didn't find an exact match, it + // is often helpful to find any other melee weapon that's available. + // Only apply fuzzy matching to melee weapons; slingshots must match exactly + if ( + data.ItemType.Identifier == "(W)" + && !data.QualifiedItemId.Contains("Slingshot") + && !data.QualifiedItemId.Contains("Bow") + ) + { + // We'll match scythes to scythes, and non-scythes to non-scythes. + // Most likely, the player wants Iridium Scythe if the slot says Scythe. The upgraded + // version is simply better, like a more typical tool. + // + // With real weapons it's fuzzier because the highest-level weapon isn't necessarily + // appropriate for the situation. If there's one quick slot for Galaxy Sword and another + // for Galaxy Hammer, then activating those slots should activate their *specific* + // weapons respectively if both are in the inventory. + // + // So we match on the inferred "type" (scythe vs. weapon) and then for non-scythe + // weapons specifically (and only those), give preference to exact matches before + // sorting by level. + var isScythe = IsScythe(data); + Logger.Log( + LogCategory.QuickSlots, + $"Item '{id}' appears to be a weapon with (scythe = {isScythe})." + ); + var bestWeapon = items + .OfType() + .Where(weapon => weapon.Name.Contains("Scythe") == isScythe) + .OrderByDescending(weapon => !isScythe && weapon.QualifiedItemId == id) + .ThenByDescending(weapon => weapon.getItemLevel()) + .FirstOrDefault(); + if (bestWeapon is not null) + { + Logger.Log( + LogCategory.QuickSlots, + "Best weapon match in inventory is " + + $"{bestWeapon.Name} with ID {bestWeapon.QualifiedItemId}." + ); + return bestWeapon; + } + } + var baseItem = data.GetBaseItem(); + Logger.Log( + LogCategory.QuickSlots, + "Searching for regular item using base item " + + $"{baseItem.InternalName} with ID {baseItem.QualifiedItemId}." + ); + var match = items + .Where(item => item is not null) + .Where(item => + item.QualifiedItemId == id + || ItemRegistry + .GetDataOrErrorItem(item.QualifiedItemId) + .GetBaseItem() + .QualifiedItemId == baseItem.QualifiedItemId + ) + .OrderByDescending(item => item is Tool tool ? tool.UpgradeLevel : 0) + .ThenByDescending(item => item.Quality) + .FirstOrDefault(); + Logger.Log( + LogCategory.QuickSlots, + $"Best match by quality/upgrade level is " + + $"{match?.Name ?? "(nothing)"} with ID {match?.QualifiedItemId ?? "N/A"}." + ); + return match; + } + + private static bool IsScythe(ParsedItemData data) + { + var method = typeof(MeleeWeapon).GetMethod( + "IsScythe", + BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static, + binder: null, + types: [typeof(string)], + modifiers: null + ); + if (method is not null) + { + try + { + return (bool)method.Invoke(null, [data.QualifiedItemId])!; + } + catch + { + // Fall through to heuristic below. + } + } + var name = data.InternalName ?? string.Empty; + return name.Contains("Scythe", StringComparison.OrdinalIgnoreCase) + || data.QualifiedItemId.Contains("Scythe", StringComparison.OrdinalIgnoreCase); + } + + private static Type? ItemBagType; + private static PropertyInfo? ItemBagContentsProp; + + private static Type? OmniBagType; + private static PropertyInfo? OmniNestedBagsProp; + + private static Type? BundleBagType; + + private static bool TryInitItemBagsReflection() + { + if (ItemBagType is not null && ItemBagContentsProp is not null && BundleBagType is not null) + return true; + + // Find ItemBags.Bags.ItemBag + ItemBagType ??= AppDomain + .CurrentDomain.GetAssemblies() + .Select(a => a.GetType("ItemBags.Bags.ItemBag", throwOnError: false)) + .FirstOrDefault(t => t is not null); + + if (ItemBagType is null) + return false; + + // public List Contents { get; set; } + ItemBagContentsProp ??= ItemBagType.GetProperty( + "Contents", + BindingFlags.Public | BindingFlags.Instance + ); + if (ItemBagContentsProp is null) + return false; + + // Find BundleBag type so we can EXCLUDE traversing into its contents + BundleBagType ??= AppDomain + .CurrentDomain.GetAssemblies() + .Select(a => a.GetType("ItemBags.Bags.BundleBag", throwOnError: false)) + .FirstOrDefault(t => t is not null); + + if (BundleBagType is null) + return false; + + // Omni bag support (nested bags) + OmniBagType ??= AppDomain + .CurrentDomain.GetAssemblies() + .Select(a => a.GetType("ItemBags.Bags.OmniBag", throwOnError: false)) + .FirstOrDefault(t => t is not null); + + if (OmniBagType is not null) + OmniNestedBagsProp ??= OmniBagType.GetProperty( + "NestedBags", + BindingFlags.Public | BindingFlags.Instance + ); + + return true; + } + + internal static bool IsItemBag(Item item) => + ItemBagType is not null && ItemBagType.IsInstanceOfType(item); + + private static bool IsBundleBag(Item item) => + BundleBagType is not null && BundleBagType.IsInstanceOfType(item); + + private static IEnumerable EnumerateBagContents(Item bag) + { + // BundleBag is explicitly excluded + if (IsBundleBag(bag)) + yield break; + + if (ItemBagContentsProp?.GetValue(bag) is not IList list || list.Count == 0) + yield break; + + foreach (var obj in list) + if (obj is Item inner) + yield return inner; + } + + private static IEnumerable EnumerateOmniNestedBags(Item bag) + { + if (OmniBagType is null || OmniNestedBagsProp is null || !OmniBagType.IsInstanceOfType(bag)) + yield break; + + if (OmniNestedBagsProp.GetValue(bag) is not IList list || list.Count == 0) + yield break; + + foreach (var obj in list) + if (obj is Item innerBag) + yield return innerBag; + } + + /// + /// Returns an "effective inventory" which includes: + /// - the player's inventory + /// - contents of any ItemBags bags in the player's inventory (EXCEPT BundleBag contents) + /// - nested bags inside OmniBags (and then their contents too) + /// + public static ICollection GetExpandedPlayerItems(Farmer who) + { + var baseItems = who.Items; + + if (!TryInitItemBagsReflection()) + return baseItems; + + var expanded = new List(baseItems.Count + 16); + + // BFS over bags we discover (supports OmniBag nesting) + var seen = new HashSet(); + var bagQueue = new Queue(); + + // 1) Start with player inventory + foreach (var it in baseItems) + { + if (it is null) + continue; + expanded.Add(it); + + if (IsItemBag(it)) + { + if (seen.Add(it)) + bagQueue.Enqueue(it); + } + } + + // 2) Expand bags: add nested bags (omni) + contents (except bundle) + int depth = 0; + while (bagQueue.Count > 0 && depth < 6) + { + int layer = bagQueue.Count; + for (int i = 0; i < layer; i++) + { + var bag = bagQueue.Dequeue(); + + // Omni nested bags + foreach (var nestedBag in EnumerateOmniNestedBags(bag)) + { + expanded.Add(nestedBag); + if (IsItemBag(nestedBag) && seen.Add(nestedBag)) + bagQueue.Enqueue(nestedBag); + } + + // Bag contents (except BundleBag) + foreach (var innerItem in EnumerateBagContents(bag)) + { + expanded.Add(innerItem); + + // If someone manages to store a bag as an Item (or modded bag item), expand it too. + if (IsItemBag(innerItem) && seen.Add(innerItem)) + bagQueue.Enqueue(innerItem); + } + } + + depth++; + } + + return expanded; + } + + public IRadialMenuItem? ResolveItem(string id, ItemIdType idType) + { + if (string.IsNullOrEmpty(id)) + { + return null; + } + return idType switch + { + ItemIdType.GameItem => ResolveInventoryItem(id, GetExpandedPlayerItems(player)) + is { } item + ? new InventoryMenuItem(item) + : null, + ItemIdType.ModItem => modMenu.GetItem(id), + _ => null, + }; + } + + public IRadialMenuItem? ResolveItem(string id, string? subId, ItemIdType idType) + { + if (string.IsNullOrEmpty(id)) + return null; + + return idType switch + { + ItemIdType.GameItem => ResolveInventoryItem(id, subId, GetExpandedPlayerItems(player)) + is { } item + ? new InventoryMenuItem(item) + : null, + ItemIdType.ModItem => modMenu.GetItem(id), + _ => null, + }; + } +} From 038593fc95121bebe386ce76a908def22a2646b4 Mon Sep 17 00:00:00 2001 From: SoulSilverJD <165866563+SoulSilverJD@users.noreply.github.com> Date: Wed, 28 Jan 2026 03:38:32 -0500 Subject: [PATCH 21/24] Add files via upload --- .../UI/QuickSlotConfigurationViewModel.cs | 380 +++--- .../UI/QuickSlotPickerItemViewModel.cs | 356 +++--- StarControl/UI/QuickSlotPickerViewModel.cs | 440 +++---- StarControl/UI/RemappingViewModel.cs | 1062 ++++++++--------- 4 files changed, 1119 insertions(+), 1119 deletions(-) diff --git a/StarControl/UI/QuickSlotConfigurationViewModel.cs b/StarControl/UI/QuickSlotConfigurationViewModel.cs index f62cf79..751e7a5 100644 --- a/StarControl/UI/QuickSlotConfigurationViewModel.cs +++ b/StarControl/UI/QuickSlotConfigurationViewModel.cs @@ -1,190 +1,190 @@ -using System.Collections.Generic; -using System.ComponentModel; -using System.Linq; -using PropertyChanged.SourceGenerator; -using StarControl.Graphics; -using StarControl.Menus; -using StardewValley; -using StardewValley.ItemTypeDefinitions; - -namespace StarControl.UI; - -internal partial class QuickSlotConfigurationViewModel -{ - private static readonly Color AssignedColor = new(50, 100, 50); - private static readonly Color UnassignedColor = new(60, 60, 60); - private static readonly Color UnavailableColor = new(0x44, 0x44, 0x44, 0x44); - private static readonly Dictionary LastKnownIcons = new(); - private static readonly Dictionary LastKnownTooltips = new(); - - private string IconCacheKey => - ItemData is null ? "" : $"{ItemData.QualifiedItemId}::{ItemSubId ?? ""}"; - - public Color CurrentAssignmentColor => IsAssigned ? AssignedColor : UnassignedColor; - public string CurrentAssignmentLabel => - IsAssigned - ? I18n.Config_QuickSlot_Assigned_Title() - : I18n.Config_QuickSlot_Unassigned_Title(); - - [DependsOn(nameof(ItemData), nameof(ModAction))] - public Sprite? Icon => GetIcon(); - public bool IsAssigned => ItemData is not null || ModAction is not null; - - public Color Tint - { - get - { - if (ItemData is null || Game1.player is null) - return Color.White; - - var items = QuickSlotResolver.GetExpandedPlayerItems(Game1.player); - var resolved = QuickSlotResolver.ResolveInventoryItem( - ItemData.QualifiedItemId, - ItemSubId, - items - ); - - return resolved is null ? UnavailableColor : Color.White; - } - } - - [DependsOn(nameof(ItemData), nameof(ModAction))] - public TooltipData Tooltip => GetTooltip(); - - [Notify] - private ParsedItemData? itemData; - - [Notify] - private string? itemSubId; - - [Notify] - private ModMenuItemConfigurationViewModel? modAction; - - [Notify] - private bool requireConfirmation; - - [Notify] - private bool useSecondaryAction; - - public void Clear() - { - ItemData = null; - ModAction = null; - UseSecondaryAction = false; - ItemSubId = null; - } - - private Sprite? GetIcon() - { - if (ItemData is not null) - { - // Use expanded inventory so Item Bags + OmniBag nested bags resolve. - if (Game1.player is not null) - { - var items = QuickSlotResolver.GetExpandedPlayerItems(Game1.player); - var invItem = QuickSlotResolver.ResolveInventoryItem( - ItemData.QualifiedItemId, - ItemSubId, - items - ); - - if (invItem is not null) - { - var sprite = Sprite.FromItem(invItem); - LastKnownIcons[IconCacheKey] = sprite; - return sprite; - } - } - - // If it’s an error item (common for Item Bags), try last-known icon before falling back. - if (ItemData.IsErrorItem && LastKnownIcons.TryGetValue(IconCacheKey, out var cached)) - return cached; - - // Normal path for registered items - return new(ItemData.GetTexture(), ItemData.GetSourceRect()); - } - - return ModAction?.Icon; - } - - private TooltipData GetTooltip() - { - if (ItemData is not null) - { - // Prefer a real item instance from expanded inventory (bags + omni) - if (Game1.player is not null) - { - var items = QuickSlotResolver.GetExpandedPlayerItems(Game1.player); - var invItem = QuickSlotResolver.ResolveInventoryItem( - ItemData.QualifiedItemId, - ItemSubId, - items - ); - - if (invItem is not null) - { - var tip = new TooltipData( - Title: invItem.DisplayName, - Text: invItem.getDescription(), - Item: invItem - ); - - LastKnownTooltips[IconCacheKey] = tip; - return tip; - } - } - - // IMPORTANT: do NOT call ItemRegistry.Create here for error/unresolvable items. - if (LastKnownTooltips.TryGetValue(IconCacheKey, out var cachedTip)) - return cachedTip; - - // That’s what is throwing in your SMAPI log and breaking the UI binding updates. - return new(Title: ItemData.DisplayName, Text: ItemData.Description); - } - - if (ModAction is not null) - { - return !string.IsNullOrEmpty(ModAction.Description) - ? new(Title: ModAction.Name, Text: ModAction.Description) - : new(ModAction.Name); - } - - return new(I18n.Config_QuickActions_EmptySlot_Title()); - } - - private void ModAction_PropertyChanged(object? sender, PropertyChangedEventArgs e) - { - if (e.PropertyName == nameof(ModAction.Icon)) - { - OnPropertyChanged(new(nameof(Icon))); - } - else if (e.PropertyName is nameof(ModAction.Name) or nameof(ModAction.Description)) - { - OnPropertyChanged(new(nameof(Tooltip))); - } - } - - private void OnItemDataChanged() - { - if (ItemData is not null) - { - ModAction = null; - } - } - - private void OnModActionChanged( - ModMenuItemConfigurationViewModel? oldValue, - ModMenuItemConfigurationViewModel? newValue - ) - { - if (oldValue is not null) - { - oldValue.PropertyChanged -= ModAction_PropertyChanged; - } - if (newValue is not null) - { - ItemData = null; - newValue.PropertyChanged += ModAction_PropertyChanged; - } - } -} +using System.Collections.Generic; +using System.ComponentModel; +using System.Linq; +using PropertyChanged.SourceGenerator; +using StarControl.Graphics; +using StarControl.Menus; +using StardewValley; +using StardewValley.ItemTypeDefinitions; + +namespace StarControl.UI; + +internal partial class QuickSlotConfigurationViewModel +{ + private static readonly Color AssignedColor = new(50, 100, 50); + private static readonly Color UnassignedColor = new(60, 60, 60); + private static readonly Color UnavailableColor = new(0x44, 0x44, 0x44, 0x44); + private static readonly Dictionary LastKnownIcons = new(); + private static readonly Dictionary LastKnownTooltips = new(); + + private string IconCacheKey => + ItemData is null ? "" : $"{ItemData.QualifiedItemId}::{ItemSubId ?? ""}"; + + public Color CurrentAssignmentColor => IsAssigned ? AssignedColor : UnassignedColor; + public string CurrentAssignmentLabel => + IsAssigned + ? I18n.Config_QuickSlot_Assigned_Title() + : I18n.Config_QuickSlot_Unassigned_Title(); + + [DependsOn(nameof(ItemData), nameof(ModAction))] + public Sprite? Icon => GetIcon(); + public bool IsAssigned => ItemData is not null || ModAction is not null; + + public Color Tint + { + get + { + if (ItemData is null || Game1.player is null) + return Color.White; + + var items = QuickSlotResolver.GetExpandedPlayerItems(Game1.player); + var resolved = QuickSlotResolver.ResolveInventoryItem( + ItemData.QualifiedItemId, + ItemSubId, + items + ); + + return resolved is null ? UnavailableColor : Color.White; + } + } + + [DependsOn(nameof(ItemData), nameof(ModAction))] + public TooltipData Tooltip => GetTooltip(); + + [Notify] + private ParsedItemData? itemData; + + [Notify] + private string? itemSubId; + + [Notify] + private ModMenuItemConfigurationViewModel? modAction; + + [Notify] + private bool requireConfirmation; + + [Notify] + private bool useSecondaryAction; + + public void Clear() + { + ItemData = null; + ModAction = null; + UseSecondaryAction = false; + ItemSubId = null; + } + + private Sprite? GetIcon() + { + if (ItemData is not null) + { + // Use expanded inventory so Item Bags + OmniBag nested bags resolve. + if (Game1.player is not null) + { + var items = QuickSlotResolver.GetExpandedPlayerItems(Game1.player); + var invItem = QuickSlotResolver.ResolveInventoryItem( + ItemData.QualifiedItemId, + ItemSubId, + items + ); + + if (invItem is not null) + { + var sprite = Sprite.FromItem(invItem); + LastKnownIcons[IconCacheKey] = sprite; + return sprite; + } + } + + // If it’s an error item (common for Item Bags), try last-known icon before falling back. + if (ItemData.IsErrorItem && LastKnownIcons.TryGetValue(IconCacheKey, out var cached)) + return cached; + + // Normal path for registered items + return new(ItemData.GetTexture(), ItemData.GetSourceRect()); + } + + return ModAction?.Icon; + } + + private TooltipData GetTooltip() + { + if (ItemData is not null) + { + // Prefer a real item instance from expanded inventory (bags + omni) + if (Game1.player is not null) + { + var items = QuickSlotResolver.GetExpandedPlayerItems(Game1.player); + var invItem = QuickSlotResolver.ResolveInventoryItem( + ItemData.QualifiedItemId, + ItemSubId, + items + ); + + if (invItem is not null) + { + var tip = new TooltipData( + Title: invItem.DisplayName, + Text: invItem.getDescription(), + Item: invItem + ); + + LastKnownTooltips[IconCacheKey] = tip; + return tip; + } + } + + // IMPORTANT: do NOT call ItemRegistry.Create here for error/unresolvable items. + if (LastKnownTooltips.TryGetValue(IconCacheKey, out var cachedTip)) + return cachedTip; + + // That’s what is throwing in your SMAPI log and breaking the UI binding updates. + return new(Title: ItemData.DisplayName, Text: ItemData.Description); + } + + if (ModAction is not null) + { + return !string.IsNullOrEmpty(ModAction.Description) + ? new(Title: ModAction.Name, Text: ModAction.Description) + : new(ModAction.Name); + } + + return new(I18n.Config_QuickActions_EmptySlot_Title()); + } + + private void ModAction_PropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (e.PropertyName == nameof(ModAction.Icon)) + { + OnPropertyChanged(new(nameof(Icon))); + } + else if (e.PropertyName is nameof(ModAction.Name) or nameof(ModAction.Description)) + { + OnPropertyChanged(new(nameof(Tooltip))); + } + } + + private void OnItemDataChanged() + { + if (ItemData is not null) + { + ModAction = null; + } + } + + private void OnModActionChanged( + ModMenuItemConfigurationViewModel? oldValue, + ModMenuItemConfigurationViewModel? newValue + ) + { + if (oldValue is not null) + { + oldValue.PropertyChanged -= ModAction_PropertyChanged; + } + if (newValue is not null) + { + ItemData = null; + newValue.PropertyChanged += ModAction_PropertyChanged; + } + } +} diff --git a/StarControl/UI/QuickSlotPickerItemViewModel.cs b/StarControl/UI/QuickSlotPickerItemViewModel.cs index 757722e..af9f58c 100644 --- a/StarControl/UI/QuickSlotPickerItemViewModel.cs +++ b/StarControl/UI/QuickSlotPickerItemViewModel.cs @@ -1,178 +1,178 @@ -using Microsoft.Xna.Framework.Graphics; -using StarControl.Graphics; -using StardewValley; -using StardewValley.ItemTypeDefinitions; -using StardewValley.Objects; - -namespace StarControl.UI; - -/// -/// View model for an item displayed in the quick-slot picker, supporting vanilla items and mod actions. -/// -/// -/// -/// Item images require a specialized context because they are not always simple textures. In many -/// cases, the item must be drawn with a tint or overlay, which is actually a second sprite. -/// -/// -/// Delegate to update a quick slot to reference this item. -/// The texture where the item image is located; both the base image and -/// the overlay (if applicable) are expected to be found in this texture. -/// The region of the where the base image -/// for the item is located. -/// The region of the where the tint or -/// overlay image for the item is located. -/// The tint color, which either applies to the image in the -/// , if one is specified, or the base image (in -/// ) if no is specified. -internal class QuickSlotPickerItemViewModel( - Action update, - Texture2D sourceTexture, - Rectangle? sourceRect, - Rectangle? tintRect = null, - Color? tintColor = null, - TooltipData? tooltip = null -) -{ - /// - /// Whether the image uses a separate . - /// - public bool HasTintSprite => TintSprite is not null; - - /// - /// Sprite data for the base image. - /// - public Sprite? Sprite { get; } = new(sourceTexture, sourceRect ?? sourceTexture.Bounds); - - /// - /// Tint color for the . - /// - public Color SpriteColor { get; } = (!tintRect.HasValue ? tintColor : null) ?? Color.White; - - /// - /// Sprite data for the overlay image, if any. - /// - public Sprite? TintSprite { get; } = - tintRect.HasValue ? new(sourceTexture, tintRect.Value) : null; - - /// - /// Tint color for the . - /// - public Color TintSpriteColor { get; } = tintColor ?? Color.White; - - public TooltipData? Tooltip { get; } = tooltip; - - private static readonly Color SmokedFishTintColor = new Color(80, 30, 10) * 0.6f; - - /// - /// Creates a new instance using the game data for a known item. - /// - /// The item to display. - public static QuickSlotPickerItemViewModel ForItem(Item item) - { - // Keep SmokedFish behavior exactly as before (special tint/overlay logic) - var data = ItemRegistry.GetDataOrErrorItem(item.QualifiedItemId); - if (item is SObject obj && obj.preserve.Value == SObject.PreserveType.SmokedFish) - { - var fishData = ItemRegistry.GetDataOrErrorItem(obj.GetPreservedItemId()); - return new( - slot => - { - slot.ItemData = data; - slot.ItemSubId = Compat.ItemBagsIdentity.TryGetBagTypeId(item); - }, - fishData.GetTexture(), - fishData.GetSourceRect(), - fishData.GetSourceRect(), - SmokedFishTintColor - ); - } - - // Tooltip is shared by both paths - TooltipData tooltip = !string.IsNullOrEmpty(item.getDescription()) - ? new(Title: item.DisplayName, Text: item.getDescription(), Item: item) - : new(Text: item.getDescription(), Item: item); - - // If the registry data is an error item AND the item isn't vanilla, render via drawInMenu. - if (data.IsErrorItem && item.GetType().Assembly != typeof(SObject).Assembly) - { - var sprite = Sprite.FromItem(item); - return new( - slot => - { - slot.ItemData = data; - slot.ItemSubId = Compat.ItemBagsIdentity.TryGetBagTypeId(item); - }, - sprite.Texture, - sprite.SourceRect, - tooltip: tooltip - ); - } - - // Normal (registered) items keep tint/overlay behavior - Color? tintColor = null; - Rectangle? tintRect = null; - if (item is ColoredObject co) - { - tintColor = co.color.Value; - if (!co.ColorSameIndexAsParentSheetIndex) - { - tintRect = data.GetSourceRect(1); - } - } - - return new( - slot => - { - slot.ItemData = data.GetBaseItem(); - slot.ItemSubId = Compat.ItemBagsIdentity.TryGetBagTypeId(item); - }, - data.GetTexture(), - data.GetSourceRect(), - tintRect, - tintColor, - tooltip - ); - } - - /// - /// Creates a new instance using the base data for an in-game item. - /// - /// The item data. - public static QuickSlotPickerItemViewModel ForItemData(ParsedItemData data) - { - TooltipData tooltip = !string.IsNullOrEmpty(data.Description) - ? new(Title: data.DisplayName, Text: data.Description) - : new(data.DisplayName); - return new( - slot => slot.ItemData = data.GetBaseItem(), - data.GetTexture(), - data.GetSourceRect(), - tooltip: tooltip - ); - } - - /// - /// Creates a new instance using the configuration for a mod action. - /// - /// The mod action. - public static QuickSlotPickerItemViewModel ForModAction(ModMenuItemConfigurationViewModel item) - { - var icon = item.Icon; - return new( - slot => slot.ModAction = item, - icon.Texture, - icon.SourceRect, - tooltip: item.Tooltip - ); - } - - /// - /// Updates a quick slot to point to this item. - /// - /// The quick slot to be updated. - public void UpdateSlot(QuickSlotConfigurationViewModel slot) - { - update(slot); - } -} +using Microsoft.Xna.Framework.Graphics; +using StarControl.Graphics; +using StardewValley; +using StardewValley.ItemTypeDefinitions; +using StardewValley.Objects; + +namespace StarControl.UI; + +/// +/// View model for an item displayed in the quick-slot picker, supporting vanilla items and mod actions. +/// +/// +/// +/// Item images require a specialized context because they are not always simple textures. In many +/// cases, the item must be drawn with a tint or overlay, which is actually a second sprite. +/// +/// +/// Delegate to update a quick slot to reference this item. +/// The texture where the item image is located; both the base image and +/// the overlay (if applicable) are expected to be found in this texture. +/// The region of the where the base image +/// for the item is located. +/// The region of the where the tint or +/// overlay image for the item is located. +/// The tint color, which either applies to the image in the +/// , if one is specified, or the base image (in +/// ) if no is specified. +internal class QuickSlotPickerItemViewModel( + Action update, + Texture2D sourceTexture, + Rectangle? sourceRect, + Rectangle? tintRect = null, + Color? tintColor = null, + TooltipData? tooltip = null +) +{ + /// + /// Whether the image uses a separate . + /// + public bool HasTintSprite => TintSprite is not null; + + /// + /// Sprite data for the base image. + /// + public Sprite? Sprite { get; } = new(sourceTexture, sourceRect ?? sourceTexture.Bounds); + + /// + /// Tint color for the . + /// + public Color SpriteColor { get; } = (!tintRect.HasValue ? tintColor : null) ?? Color.White; + + /// + /// Sprite data for the overlay image, if any. + /// + public Sprite? TintSprite { get; } = + tintRect.HasValue ? new(sourceTexture, tintRect.Value) : null; + + /// + /// Tint color for the . + /// + public Color TintSpriteColor { get; } = tintColor ?? Color.White; + + public TooltipData? Tooltip { get; } = tooltip; + + private static readonly Color SmokedFishTintColor = new Color(80, 30, 10) * 0.6f; + + /// + /// Creates a new instance using the game data for a known item. + /// + /// The item to display. + public static QuickSlotPickerItemViewModel ForItem(Item item) + { + // Keep SmokedFish behavior exactly as before (special tint/overlay logic) + var data = ItemRegistry.GetDataOrErrorItem(item.QualifiedItemId); + if (item is SObject obj && obj.preserve.Value == SObject.PreserveType.SmokedFish) + { + var fishData = ItemRegistry.GetDataOrErrorItem(obj.GetPreservedItemId()); + return new( + slot => + { + slot.ItemData = data; + slot.ItemSubId = Compat.ItemBagsIdentity.TryGetBagTypeId(item); + }, + fishData.GetTexture(), + fishData.GetSourceRect(), + fishData.GetSourceRect(), + SmokedFishTintColor + ); + } + + // Tooltip is shared by both paths + TooltipData tooltip = !string.IsNullOrEmpty(item.getDescription()) + ? new(Title: item.DisplayName, Text: item.getDescription(), Item: item) + : new(Text: item.getDescription(), Item: item); + + // If the registry data is an error item AND the item isn't vanilla, render via drawInMenu. + if (data.IsErrorItem && item.GetType().Assembly != typeof(SObject).Assembly) + { + var sprite = Sprite.FromItem(item); + return new( + slot => + { + slot.ItemData = data; + slot.ItemSubId = Compat.ItemBagsIdentity.TryGetBagTypeId(item); + }, + sprite.Texture, + sprite.SourceRect, + tooltip: tooltip + ); + } + + // Normal (registered) items keep tint/overlay behavior + Color? tintColor = null; + Rectangle? tintRect = null; + if (item is ColoredObject co) + { + tintColor = co.color.Value; + if (!co.ColorSameIndexAsParentSheetIndex) + { + tintRect = data.GetSourceRect(1); + } + } + + return new( + slot => + { + slot.ItemData = data.GetBaseItem(); + slot.ItemSubId = Compat.ItemBagsIdentity.TryGetBagTypeId(item); + }, + data.GetTexture(), + data.GetSourceRect(), + tintRect, + tintColor, + tooltip + ); + } + + /// + /// Creates a new instance using the base data for an in-game item. + /// + /// The item data. + public static QuickSlotPickerItemViewModel ForItemData(ParsedItemData data) + { + TooltipData tooltip = !string.IsNullOrEmpty(data.Description) + ? new(Title: data.DisplayName, Text: data.Description) + : new(data.DisplayName); + return new( + slot => slot.ItemData = data.GetBaseItem(), + data.GetTexture(), + data.GetSourceRect(), + tooltip: tooltip + ); + } + + /// + /// Creates a new instance using the configuration for a mod action. + /// + /// The mod action. + public static QuickSlotPickerItemViewModel ForModAction(ModMenuItemConfigurationViewModel item) + { + var icon = item.Icon; + return new( + slot => slot.ModAction = item, + icon.Texture, + icon.SourceRect, + tooltip: item.Tooltip + ); + } + + /// + /// Updates a quick slot to point to this item. + /// + /// The quick slot to be updated. + public void UpdateSlot(QuickSlotConfigurationViewModel slot) + { + update(slot); + } +} diff --git a/StarControl/UI/QuickSlotPickerViewModel.cs b/StarControl/UI/QuickSlotPickerViewModel.cs index bee0e50..52b1dca 100644 --- a/StarControl/UI/QuickSlotPickerViewModel.cs +++ b/StarControl/UI/QuickSlotPickerViewModel.cs @@ -1,220 +1,220 @@ -using System.ComponentModel; -using PropertyChanged.SourceGenerator; -using StarControl.Menus; -using StardewValley.ItemTypeDefinitions; - -namespace StarControl.UI; - -internal enum QuickSlotItemSource -{ - Inventory, - GameItems, - ModItems, -} - -internal partial class QuickSlotPickerViewModel -{ - private const int MAX_RESULTS = 40; - - public Color AllModPagesTint => - ModMenuPageIndex < 0 ? new Color(0xaa, 0xcc, 0xee) : Color.White; - - public Vector2 ContentPanelSize - { - get => Pager.ContentPanelSize; - set => Pager.ContentPanelSize = value; - } - - public bool HasLoadedGame => Game1.hasLoadedGame; - public bool HasMoreModItems => - ModMenuPageIndex < 0 && ModMenuPages.Sum(page => page.Items.Count) > MAX_RESULTS; - public IEnumerable InventoryItems => - Game1.player is null - ? Array.Empty() - : GetInventoryAndNestedBags(Game1.player) - .Select(QuickSlotPickerItemViewModel.ForItem) - .ToArray(); - public EnumSegmentsViewModel ItemSource { get; } = new(); - public IEnumerable ModMenuItems => - ( - ModMenuPageIndex < 0 - ? ModMenuPages.SelectMany(page => page.Items).Take(MAX_RESULTS) - : ModMenuPages[ModMenuPageIndex].Items - ).Select(QuickSlotPickerItemViewModel.ForModAction); - public IReadOnlyList ModMenuPages { get; } - public string MoreModItemsMessage => - HasMoreModItems ? I18n.Config_QuickSlot_ModItems_LimitedResults(MAX_RESULTS) : ""; - public string MoreResultsMessage => - HasMoreSearchResults ? I18n.Config_QuickSlot_Search_LimitedResults(MAX_RESULTS) : ""; - public PagerViewModel Pager { get; } = - new() { Pages = [new(0), new(1), new(2)] }; - public bool SecondaryActionProhibited => !SecondaryActionAllowed; - public QuickSlotConfigurationViewModel Slot { get; } - - private readonly IReadOnlyList allItems; - - [Notify(Setter.Private)] - private bool hasMoreSearchResults; - - [Notify] - private int modMenuPageIndex = -1; // Use -1 for "all", if they fit - - [Notify(Setter.Private)] - private IReadOnlyList searchResults = []; - - [Notify] - private string searchText = ""; - - [Notify] - private bool secondaryActionAllowed; - - private readonly object searchLock = new(); - - private CancellationTokenSource searchCancellationTokenSource = new(); - - public QuickSlotPickerViewModel( - QuickSlotConfigurationViewModel slot, - IReadOnlyList allItems, - IReadOnlyList modMenuPages - ) - { - Slot = slot; - SecondaryActionAllowed = Slot.ItemData is not null || Slot.ModAction is not null; - this.allItems = allItems; - ModMenuPages = modMenuPages; - UpdateSearchResults(); - ItemSource.PropertyChanged += ItemSource_PropertyChanged; - } - - public void AssignItem(QuickSlotPickerItemViewModel item) - { - Game1.playSound("drumkit6"); - item.UpdateSlot(Slot); - SecondaryActionAllowed = Slot.ItemData is not null || Slot.ModAction is not null; - } - - public void ClearAssignment() - { - if (Slot.ItemData is null && Slot.ModAction is null) - { - return; - } - Game1.playSound("trashcan"); - Slot.Clear(); - SecondaryActionAllowed = false; - } - - public bool HandleButtonPress(SButton button) - { - if (!Pager.HandleButtonPress(button)) - { - return false; - } - ItemSource.SelectedIndex = Pager.SelectedPageIndex; - return true; - } - - public void SelectModMenuPage(int index) - { - if (index == ModMenuPageIndex) - { - return; - } - Game1.playSound("smallSelect"); - ModMenuPageIndex = index; - } - - private void ItemSource_PropertyChanged(object? sender, PropertyChangedEventArgs e) - { - Pager.SelectedPageIndex = ItemSource.SelectedIndex; - } - - private void OnModMenuPageIndexChanged(int oldValue, int newValue) - { - if (oldValue >= 0) - { - ModMenuPages[oldValue].Selected = false; - } - if (newValue >= 0) - { - ModMenuPages[newValue].Selected = true; - } - } - - private void OnSearchTextChanged() - { - UpdateSearchResults(); - } - - private void UpdateSearchResults() - { - searchCancellationTokenSource.Cancel(); - searchCancellationTokenSource = new(); - var cancellationToken = searchCancellationTokenSource.Token; - var searchTask = Task.Run( - () => allItems.Search(SearchText, cancellationToken), - cancellationToken - ); - searchTask.ContinueWith( - t => - { - if (t.IsFaulted) - { - Logger.Log($"Failed searching for items: {t.Exception}", LogLevel.Error); - return; - } - if (t.IsCanceled) - { - return; - } - lock (searchLock) - { - var results = new List(); - // Results can easily be limited using .Take(limit), but we also need to know if there are more, - // which no Linq extension can tell us. - using var resultEnumerator = t.Result.GetEnumerator(); - while (results.Count < MAX_RESULTS && resultEnumerator.MoveNext()) - { - results.Add( - QuickSlotPickerItemViewModel.ForItemData(resultEnumerator.Current) - ); - } - SearchResults = results; - HasMoreSearchResults = resultEnumerator.MoveNext(); - } - }, - cancellationToken - ); - } - - private static IEnumerable GetInventoryAndNestedBags(Farmer who) - { - // Base inventory items (tools, objects, the OmniBag item itself, etc.) - var result = new List(who.Items.Count + 8); - var seen = new HashSet(); - - foreach (var it in who.Items) - { - if (it is null) - continue; - - result.Add(it); - seen.Add(it); - } - - // Add nested bags from OmniBags, but DO NOT add bag contents - foreach (var it in QuickSlotResolver.GetExpandedPlayerItems(who)) - { - if (it is null) - continue; - - if (!QuickSlotResolver.IsItemBag(it)) - continue; - - if (seen.Add(it)) - result.Add(it); - } - - return result; - } -} +using System.ComponentModel; +using PropertyChanged.SourceGenerator; +using StarControl.Menus; +using StardewValley.ItemTypeDefinitions; + +namespace StarControl.UI; + +internal enum QuickSlotItemSource +{ + Inventory, + GameItems, + ModItems, +} + +internal partial class QuickSlotPickerViewModel +{ + private const int MAX_RESULTS = 40; + + public Color AllModPagesTint => + ModMenuPageIndex < 0 ? new Color(0xaa, 0xcc, 0xee) : Color.White; + + public Vector2 ContentPanelSize + { + get => Pager.ContentPanelSize; + set => Pager.ContentPanelSize = value; + } + + public bool HasLoadedGame => Game1.hasLoadedGame; + public bool HasMoreModItems => + ModMenuPageIndex < 0 && ModMenuPages.Sum(page => page.Items.Count) > MAX_RESULTS; + public IEnumerable InventoryItems => + Game1.player is null + ? Array.Empty() + : GetInventoryAndNestedBags(Game1.player) + .Select(QuickSlotPickerItemViewModel.ForItem) + .ToArray(); + public EnumSegmentsViewModel ItemSource { get; } = new(); + public IEnumerable ModMenuItems => + ( + ModMenuPageIndex < 0 + ? ModMenuPages.SelectMany(page => page.Items).Take(MAX_RESULTS) + : ModMenuPages[ModMenuPageIndex].Items + ).Select(QuickSlotPickerItemViewModel.ForModAction); + public IReadOnlyList ModMenuPages { get; } + public string MoreModItemsMessage => + HasMoreModItems ? I18n.Config_QuickSlot_ModItems_LimitedResults(MAX_RESULTS) : ""; + public string MoreResultsMessage => + HasMoreSearchResults ? I18n.Config_QuickSlot_Search_LimitedResults(MAX_RESULTS) : ""; + public PagerViewModel Pager { get; } = + new() { Pages = [new(0), new(1), new(2)] }; + public bool SecondaryActionProhibited => !SecondaryActionAllowed; + public QuickSlotConfigurationViewModel Slot { get; } + + private readonly IReadOnlyList allItems; + + [Notify(Setter.Private)] + private bool hasMoreSearchResults; + + [Notify] + private int modMenuPageIndex = -1; // Use -1 for "all", if they fit + + [Notify(Setter.Private)] + private IReadOnlyList searchResults = []; + + [Notify] + private string searchText = ""; + + [Notify] + private bool secondaryActionAllowed; + + private readonly object searchLock = new(); + + private CancellationTokenSource searchCancellationTokenSource = new(); + + public QuickSlotPickerViewModel( + QuickSlotConfigurationViewModel slot, + IReadOnlyList allItems, + IReadOnlyList modMenuPages + ) + { + Slot = slot; + SecondaryActionAllowed = Slot.ItemData is not null || Slot.ModAction is not null; + this.allItems = allItems; + ModMenuPages = modMenuPages; + UpdateSearchResults(); + ItemSource.PropertyChanged += ItemSource_PropertyChanged; + } + + public void AssignItem(QuickSlotPickerItemViewModel item) + { + Game1.playSound("drumkit6"); + item.UpdateSlot(Slot); + SecondaryActionAllowed = Slot.ItemData is not null || Slot.ModAction is not null; + } + + public void ClearAssignment() + { + if (Slot.ItemData is null && Slot.ModAction is null) + { + return; + } + Game1.playSound("trashcan"); + Slot.Clear(); + SecondaryActionAllowed = false; + } + + public bool HandleButtonPress(SButton button) + { + if (!Pager.HandleButtonPress(button)) + { + return false; + } + ItemSource.SelectedIndex = Pager.SelectedPageIndex; + return true; + } + + public void SelectModMenuPage(int index) + { + if (index == ModMenuPageIndex) + { + return; + } + Game1.playSound("smallSelect"); + ModMenuPageIndex = index; + } + + private void ItemSource_PropertyChanged(object? sender, PropertyChangedEventArgs e) + { + Pager.SelectedPageIndex = ItemSource.SelectedIndex; + } + + private void OnModMenuPageIndexChanged(int oldValue, int newValue) + { + if (oldValue >= 0) + { + ModMenuPages[oldValue].Selected = false; + } + if (newValue >= 0) + { + ModMenuPages[newValue].Selected = true; + } + } + + private void OnSearchTextChanged() + { + UpdateSearchResults(); + } + + private void UpdateSearchResults() + { + searchCancellationTokenSource.Cancel(); + searchCancellationTokenSource = new(); + var cancellationToken = searchCancellationTokenSource.Token; + var searchTask = Task.Run( + () => allItems.Search(SearchText, cancellationToken), + cancellationToken + ); + searchTask.ContinueWith( + t => + { + if (t.IsFaulted) + { + Logger.Log($"Failed searching for items: {t.Exception}", LogLevel.Error); + return; + } + if (t.IsCanceled) + { + return; + } + lock (searchLock) + { + var results = new List(); + // Results can easily be limited using .Take(limit), but we also need to know if there are more, + // which no Linq extension can tell us. + using var resultEnumerator = t.Result.GetEnumerator(); + while (results.Count < MAX_RESULTS && resultEnumerator.MoveNext()) + { + results.Add( + QuickSlotPickerItemViewModel.ForItemData(resultEnumerator.Current) + ); + } + SearchResults = results; + HasMoreSearchResults = resultEnumerator.MoveNext(); + } + }, + cancellationToken + ); + } + + private static IEnumerable GetInventoryAndNestedBags(Farmer who) + { + // Base inventory items (tools, objects, the OmniBag item itself, etc.) + var result = new List(who.Items.Count + 8); + var seen = new HashSet(); + + foreach (var it in who.Items) + { + if (it is null) + continue; + + result.Add(it); + seen.Add(it); + } + + // Add nested bags from OmniBags, but DO NOT add bag contents + foreach (var it in QuickSlotResolver.GetExpandedPlayerItems(who)) + { + if (it is null) + continue; + + if (!QuickSlotResolver.IsItemBag(it)) + continue; + + if (seen.Add(it)) + result.Add(it); + } + + return result; + } +} diff --git a/StarControl/UI/RemappingViewModel.cs b/StarControl/UI/RemappingViewModel.cs index 82835be..c7e6711 100644 --- a/StarControl/UI/RemappingViewModel.cs +++ b/StarControl/UI/RemappingViewModel.cs @@ -1,531 +1,531 @@ -using System.Collections; -using System.Reflection; -using Microsoft.Xna.Framework; -using Microsoft.Xna.Framework.Input; -using PropertyChanged.SourceGenerator; -using StarControl.Config; -using StarControl.Data; -using StarControl.Graphics; -using StarControl.Menus; - -namespace StarControl.UI; - -internal partial class RemappingViewModel( - IInputHelper inputHelper, - Farmer who, - IEnumerable modItems, - SButton menuToggleButton, - float thumbstickDeadZone, - ButtonIconSet buttonIconSet, - Action> onSave -) -{ - private const int BaseMenuWidth = 950; - private const int BaseMenuHeight = 700; - private const int MinMenuWidth = 720; - private const int MinMenuHeight = 520; - private const int ViewportPaddingWidth = 128; - private const int ViewportPaddingHeight = 120; - private const int MenuVerticalNudge = -24; - - public IMenuController? Controller { get; set; } - public bool IsItemHovered => HoveredItem is not null; - public bool IsSlotHovered => HoveredSlot is not null; - public bool IsSlotHoveredAndAssigned => HoveredSlot?.Item is not null; - public IReadOnlyList ItemGroups { get; } = - [ - new() - { - Name = I18n.Enum_QuickSlotItemSource_Inventory_Name(), - Items = GetInventoryAndNestedBags(who) - .Select(RemappableItemViewModel.FromInventoryItem) - .ToList(), - }, - new() - { - Name = I18n.Enum_QuickSlotItemSource_ModItems_Name(), - Items = modItems.Select(RemappableItemViewModel.FromMenuItem).ToList(), - }, - ]; - public IEnumerable Slots => slotsByButton.Values; - - [Notify] - private ButtonIconSet buttonIconSet = buttonIconSet; - - [Notify] - private string menuLayout = $"{BaseMenuWidth}px {BaseMenuHeight}px"; - - [Notify] - private bool canReassign; - - [Notify] - private RemappableItemViewModel? hoveredItem; - - [Notify] - private RemappingSlotViewModel? hoveredSlot; - - [Notify] - private bool showAssignTip = true; - - [Notify] - private bool showUnassignTip; - - private const double RightStickScrollRepeatMinMs = 35; - private const double RightStickScrollRepeatMaxMs = 140; - - private double lastRightStickScrollMs; - private bool menuLayoutInitialized; - private int menuWidth = BaseMenuWidth; - private int menuHeight = BaseMenuHeight; - private readonly Dictionary slotsByButton = new() - { - { SButton.DPadLeft, new(SButton.DPadLeft) }, - { SButton.DPadUp, new(SButton.DPadUp) }, - { SButton.DPadRight, new(SButton.DPadRight) }, - { SButton.DPadDown, new(SButton.DPadDown) }, - { SButton.ControllerX, new(SButton.ControllerX) }, - { SButton.ControllerY, new(SButton.ControllerY) }, - { SButton.ControllerB, new(SButton.ControllerB) }, - { SButton.LeftShoulder, new(SButton.LeftShoulder) }, - { SButton.RightShoulder, new(SButton.RightShoulder) }, - }; - - public bool AssignToSlot(SButton button, RemappableItemViewModel item) - { - if (!CanReassign || !slotsByButton.TryGetValue(button, out var slot)) - { - return false; - } - Game1.playSound("drumkit6"); - if (slot.Item is not null) - { - slot.Item.AssignedButton = SButton.None; - } - item.AssignedButton = button; - slot.Item = item; - Save(); - return true; - } - - public void Load(Dictionary data) - { - foreach (var slot in slotsByButton.Values) - { - if (slot.Item is { } previousItem) - { - previousItem.AssignedButton = SButton.None; - } - slot.Item = null; - } - foreach (var (button, slotData) in data) - { - if ( - string.IsNullOrEmpty(slotData.Id) - || !slotsByButton.TryGetValue(button, out var slot) - ) - { - continue; - } - var item = slotData.IdType switch - { - ItemIdType.GameItem => ItemGroups[0] - .Items.FirstOrDefault(item => - item.Id == slotData.Id && item.SubId == slotData.SubId - ) - ?? ( - QuickSlotResolver.ResolveInventoryItem( - slotData.Id, - slotData.SubId, - who.Items - ) - is Item invItem - ? RemappableItemViewModel.FromInventoryItem( - invItem, - QuickSlotResolver.GetExpandedPlayerItems(who) - ) - : null - ), - ItemIdType.ModItem => ItemGroups[1] - .Items.FirstOrDefault(item => item.Id == slotData.Id), - _ => null, - }; - item ??= RemappableItemViewModel.Invalid(slotData.IdType, slotData.Id); - item.AssignedButton = button; - slot.Item = item; - } - UpdateTipVisibility(); - } - - public void Save() - { - var data = new Dictionary(); - foreach (var (button, slot) in slotsByButton) - { - if (slot.Item is { } item && !string.IsNullOrEmpty(item.Id)) - { - data[button] = new() - { - Id = slot.Item.Id, - SubId = slot.Item.SubId, - IdType = slot.Item.IdType, - }; - } - } - onSave(data); - } - - public void SetItemHovered(RemappableItemViewModel? item) - { - if (HoveredItem == item) - { - return; - } - if (HoveredItem is not null) - { - HoveredItem.Hovered = false; - } - HoveredItem = item; - if (item is not null) - { - item.Hovered = true; - } - } - - public void SetSlotHovered(RemappingSlotViewModel? slot) - { - HoveredSlot = slot; - UpdateTipVisibility(); - } - - public void UnassignSlot(RemappingSlotViewModel slot) - { - if (slot.Item is null) - { - return; - } - Game1.playSound("trashcan"); - slot.Item.AssignedButton = SButton.None; - slot.Item = null; - OnPropertyChanged(new(nameof(IsSlotHoveredAndAssigned))); - UpdateTipVisibility(); - Save(); - } - - public void Update() - { - if (!menuLayoutInitialized) - { - UpdateLayoutForViewport(); - menuLayoutInitialized = true; - } - CanReassign = - inputHelper.IsDown(SButton.LeftTrigger) || inputHelper.IsDown(SButton.RightTrigger); - HandleRightStickScroll(); - // IClickableMenu.receiveGamePadButton bizarrely does not receive some buttons such as the - // left/right stick. We have to check them for through the helper. - if ( - !CanReassign - && menuToggleButton - is not SButton.DPadUp - or SButton.DPadDown - or SButton.DPadLeft - or SButton.DPadRight - && inputHelper.GetState(menuToggleButton) == SButtonState.Pressed - ) - { - Controller?.Close(); - } - } - - public void InitializeLayout() - { - menuLayoutInitialized = false; - UpdateLayoutForViewport(); - menuLayoutInitialized = true; - } - - public Point GetMenuPosition() - { - var viewport = Game1.uiViewport; - var x = (viewport.Width - menuWidth) / 2; - var y = (viewport.Height - menuHeight) / 2 + MenuVerticalNudge; - return new Point(Math.Max(0, x), Math.Max(0, y)); - } - - private void UpdateLayoutForViewport() - { - var viewport = Game1.uiViewport; - var width = Math.Min( - BaseMenuWidth, - Math.Max(MinMenuWidth, viewport.Width - ViewportPaddingWidth) - ); - var height = Math.Min( - BaseMenuHeight, - Math.Max(MinMenuHeight, viewport.Height - ViewportPaddingHeight) - ); - menuWidth = width; - menuHeight = height; - MenuLayout = $"{width}px {height}px"; - } - - private void UpdateTipVisibility() - { - ShowUnassignTip = IsSlotHoveredAndAssigned; - ShowAssignTip = !ShowUnassignTip; - } - - private void HandleRightStickScroll() - { - if (Controller?.Menu is null) - { - return; - } - var nowMs = Game1.currentGameTime?.TotalGameTime.TotalMilliseconds ?? 0; - var state = Game1.playerOneIndex >= PlayerIndex.One ? Game1.input.GetGamePadState() : new(); - var stickY = state.ThumbSticks.Right.Y; - var absY = Math.Abs(stickY); - if (absY <= thumbstickDeadZone) - { - return; - } - var intensity = Math.Clamp((absY - thumbstickDeadZone) / (1f - thumbstickDeadZone), 0f, 1f); - var repeatMs = - RightStickScrollRepeatMaxMs - - (RightStickScrollRepeatMaxMs - RightStickScrollRepeatMinMs) * intensity; - if (nowMs - lastRightStickScrollMs < repeatMs) - { - return; - } - lastRightStickScrollMs = nowMs; - TryScrollActiveContainer(stickY > 0); - } - - private bool TryScrollActiveContainer(bool scrollUp) - { - var container = GetActiveScrollContainer(); - if (container is null) - { - return false; - } - var containerType = container.GetType(); - var scrollSizeProp = containerType.GetProperty("ScrollSize"); - var scrollSizeValue = scrollSizeProp?.GetValue(container); - var scrollSize = scrollSizeValue is float size ? size : 0f; - if (scrollSize <= 0f) - { - return false; - } - var methodName = scrollUp ? "ScrollBackward" : "ScrollForward"; - var scrollMethod = containerType.GetMethod(methodName); - if (scrollMethod is null) - { - return false; - } - var result = scrollMethod.Invoke(container, Array.Empty()); - var scrolled = result is bool didScroll && didScroll; - if (scrolled) - { - Game1.playSound("shwip"); - } - return scrolled; - } - - private object? GetActiveScrollContainer() - { - var viewProp = Controller?.Menu?.GetType().GetProperty("View"); - var rootView = viewProp?.GetValue(Controller?.Menu!); - if (rootView is null) - { - return null; - } - return FindScrollContainer(rootView); - } - - private static object? FindScrollContainer(object view) - { - var viewType = view.GetType(); - var viewTypeName = viewType.FullName ?? string.Empty; - if (viewTypeName == "StardewUI.Widgets.ScrollableView") - { - var innerView = viewType - .GetProperty("View", BindingFlags.NonPublic | BindingFlags.Instance) - ?.GetValue(view); - if (innerView is not null) - { - return innerView; - } - } - if (viewTypeName == "StardewUI.Widgets.ScrollContainer") - { - return view; - } - var getChildren = viewType.GetMethod("GetChildren", new[] { typeof(bool) }); - var children = getChildren?.Invoke(view, new object[] { true }) as IEnumerable; - if (children is null) - { - return null; - } - foreach (var child in children) - { - var childView = child?.GetType().GetProperty("View")?.GetValue(child); - if (childView is null) - { - continue; - } - var result = FindScrollContainer(childView); - if (result is not null) - { - return result; - } - } - return null; - } - - private static IEnumerable GetInventoryAndNestedBags(Farmer who) - { - var result = new List(who.Items.Count + 8); - var seen = new HashSet(); - - foreach (var it in who.Items) - { - if (it is null) - continue; - - result.Add(it); - seen.Add(it); - } - - foreach (var it in QuickSlotResolver.GetExpandedPlayerItems(who)) - { - if (it is null) - continue; - - if (!QuickSlotResolver.IsItemBag(it)) - continue; - - if (seen.Add(it)) - result.Add(it); - } - - return result; - } -} - -internal partial class RemappingSlotViewModel(SButton button) -{ - public SButton Button { get; } = button; - public int? Count => Item?.Count ?? 1; - public bool IsCountVisible => Count > 1; - public bool IsItemEnabled => Item?.Enabled == true; - public int Quality => Item?.Quality ?? 0; - - public Sprite? Sprite => Item?.Sprite; - - public TooltipData? Tooltip => Item?.Tooltip; - - [Notify] - private RemappableItemViewModel? item; -} - -internal partial class RemappableItemGroupViewModel -{ - [Notify] - private IReadOnlyList items = []; - - [Notify] - private string name = ""; -} - -internal partial class RemappableItemViewModel -{ - public string Id { get; init; } = ""; - - public string? SubId { get; init; } - - public ItemIdType IdType { get; init; } - public bool IsCountVisible => Count > 1; - - [Notify] - private SButton assignedButton; - - [Notify] - private int count = 1; - - [Notify] - private bool enabled; - - [Notify] - private bool hovered; - - [Notify] - private int quality; - - [Notify] - private Sprite? sprite; - - [Notify] - private TooltipData? tooltip; - - public static RemappableItemViewModel FromInventoryItem(Item item) - { - ArgumentNullException.ThrowIfNull(item); - - var qualifiedId = item.QualifiedItemId; // can be null/empty for some mod items - var sprite = Sprite.FromItem(item); - - return new() - { - Id = qualifiedId ?? item.Name ?? item.GetType().FullName ?? "unknown", - IdType = ItemIdType.GameItem, - SubId = Compat.ItemBagsIdentity.TryGetBagTypeId(item), - Enabled = true, - Sprite = sprite, - Quality = item.Quality, - Count = item.Stack, - Tooltip = new(item.getDescription(), item.DisplayName, item), - }; - } - - public static RemappableItemViewModel FromInventoryItem( - Item item, - ICollection availableItems - ) - { - var result = FromInventoryItem(item); - result.Enabled = - QuickSlotResolver.ResolveInventoryItem( - item.QualifiedItemId, - result.SubId, - availableItems - ) - is not null; - return result; - } - - public static RemappableItemViewModel FromMenuItem(IRadialMenuItem item) - { - return new() - { - Id = item.Id, - IdType = ItemIdType.ModItem, - Enabled = item.Enabled, - Sprite = item.Texture is not null - ? new(item.Texture, item.SourceRectangle ?? item.Texture.Bounds) - : Sprites.Error(), - Tooltip = !string.IsNullOrEmpty(item.Description) - ? new(item.Description, item.Title) - : new(item.Title), - }; - } - - public static RemappableItemViewModel Invalid(ItemIdType type, string id) - { - return new() - { - Id = id, - IdType = type, - Sprite = Sprites.Error(), - Tooltip = new(I18n.Remapping_InvalidItem_Description(id)), - }; - } -} +using System.Collections; +using System.Reflection; +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Input; +using PropertyChanged.SourceGenerator; +using StarControl.Config; +using StarControl.Data; +using StarControl.Graphics; +using StarControl.Menus; + +namespace StarControl.UI; + +internal partial class RemappingViewModel( + IInputHelper inputHelper, + Farmer who, + IEnumerable modItems, + SButton menuToggleButton, + float thumbstickDeadZone, + ButtonIconSet buttonIconSet, + Action> onSave +) +{ + private const int BaseMenuWidth = 950; + private const int BaseMenuHeight = 700; + private const int MinMenuWidth = 720; + private const int MinMenuHeight = 520; + private const int ViewportPaddingWidth = 128; + private const int ViewportPaddingHeight = 120; + private const int MenuVerticalNudge = -24; + + public IMenuController? Controller { get; set; } + public bool IsItemHovered => HoveredItem is not null; + public bool IsSlotHovered => HoveredSlot is not null; + public bool IsSlotHoveredAndAssigned => HoveredSlot?.Item is not null; + public IReadOnlyList ItemGroups { get; } = + [ + new() + { + Name = I18n.Enum_QuickSlotItemSource_Inventory_Name(), + Items = GetInventoryAndNestedBags(who) + .Select(RemappableItemViewModel.FromInventoryItem) + .ToList(), + }, + new() + { + Name = I18n.Enum_QuickSlotItemSource_ModItems_Name(), + Items = modItems.Select(RemappableItemViewModel.FromMenuItem).ToList(), + }, + ]; + public IEnumerable Slots => slotsByButton.Values; + + [Notify] + private ButtonIconSet buttonIconSet = buttonIconSet; + + [Notify] + private string menuLayout = $"{BaseMenuWidth}px {BaseMenuHeight}px"; + + [Notify] + private bool canReassign; + + [Notify] + private RemappableItemViewModel? hoveredItem; + + [Notify] + private RemappingSlotViewModel? hoveredSlot; + + [Notify] + private bool showAssignTip = true; + + [Notify] + private bool showUnassignTip; + + private const double RightStickScrollRepeatMinMs = 35; + private const double RightStickScrollRepeatMaxMs = 140; + + private double lastRightStickScrollMs; + private bool menuLayoutInitialized; + private int menuWidth = BaseMenuWidth; + private int menuHeight = BaseMenuHeight; + private readonly Dictionary slotsByButton = new() + { + { SButton.DPadLeft, new(SButton.DPadLeft) }, + { SButton.DPadUp, new(SButton.DPadUp) }, + { SButton.DPadRight, new(SButton.DPadRight) }, + { SButton.DPadDown, new(SButton.DPadDown) }, + { SButton.ControllerX, new(SButton.ControllerX) }, + { SButton.ControllerY, new(SButton.ControllerY) }, + { SButton.ControllerB, new(SButton.ControllerB) }, + { SButton.LeftShoulder, new(SButton.LeftShoulder) }, + { SButton.RightShoulder, new(SButton.RightShoulder) }, + }; + + public bool AssignToSlot(SButton button, RemappableItemViewModel item) + { + if (!CanReassign || !slotsByButton.TryGetValue(button, out var slot)) + { + return false; + } + Game1.playSound("drumkit6"); + if (slot.Item is not null) + { + slot.Item.AssignedButton = SButton.None; + } + item.AssignedButton = button; + slot.Item = item; + Save(); + return true; + } + + public void Load(Dictionary data) + { + foreach (var slot in slotsByButton.Values) + { + if (slot.Item is { } previousItem) + { + previousItem.AssignedButton = SButton.None; + } + slot.Item = null; + } + foreach (var (button, slotData) in data) + { + if ( + string.IsNullOrEmpty(slotData.Id) + || !slotsByButton.TryGetValue(button, out var slot) + ) + { + continue; + } + var item = slotData.IdType switch + { + ItemIdType.GameItem => ItemGroups[0] + .Items.FirstOrDefault(item => + item.Id == slotData.Id && item.SubId == slotData.SubId + ) + ?? ( + QuickSlotResolver.ResolveInventoryItem( + slotData.Id, + slotData.SubId, + who.Items + ) + is Item invItem + ? RemappableItemViewModel.FromInventoryItem( + invItem, + QuickSlotResolver.GetExpandedPlayerItems(who) + ) + : null + ), + ItemIdType.ModItem => ItemGroups[1] + .Items.FirstOrDefault(item => item.Id == slotData.Id), + _ => null, + }; + item ??= RemappableItemViewModel.Invalid(slotData.IdType, slotData.Id); + item.AssignedButton = button; + slot.Item = item; + } + UpdateTipVisibility(); + } + + public void Save() + { + var data = new Dictionary(); + foreach (var (button, slot) in slotsByButton) + { + if (slot.Item is { } item && !string.IsNullOrEmpty(item.Id)) + { + data[button] = new() + { + Id = slot.Item.Id, + SubId = slot.Item.SubId, + IdType = slot.Item.IdType, + }; + } + } + onSave(data); + } + + public void SetItemHovered(RemappableItemViewModel? item) + { + if (HoveredItem == item) + { + return; + } + if (HoveredItem is not null) + { + HoveredItem.Hovered = false; + } + HoveredItem = item; + if (item is not null) + { + item.Hovered = true; + } + } + + public void SetSlotHovered(RemappingSlotViewModel? slot) + { + HoveredSlot = slot; + UpdateTipVisibility(); + } + + public void UnassignSlot(RemappingSlotViewModel slot) + { + if (slot.Item is null) + { + return; + } + Game1.playSound("trashcan"); + slot.Item.AssignedButton = SButton.None; + slot.Item = null; + OnPropertyChanged(new(nameof(IsSlotHoveredAndAssigned))); + UpdateTipVisibility(); + Save(); + } + + public void Update() + { + if (!menuLayoutInitialized) + { + UpdateLayoutForViewport(); + menuLayoutInitialized = true; + } + CanReassign = + inputHelper.IsDown(SButton.LeftTrigger) || inputHelper.IsDown(SButton.RightTrigger); + HandleRightStickScroll(); + // IClickableMenu.receiveGamePadButton bizarrely does not receive some buttons such as the + // left/right stick. We have to check them for through the helper. + if ( + !CanReassign + && menuToggleButton + is not SButton.DPadUp + or SButton.DPadDown + or SButton.DPadLeft + or SButton.DPadRight + && inputHelper.GetState(menuToggleButton) == SButtonState.Pressed + ) + { + Controller?.Close(); + } + } + + public void InitializeLayout() + { + menuLayoutInitialized = false; + UpdateLayoutForViewport(); + menuLayoutInitialized = true; + } + + public Point GetMenuPosition() + { + var viewport = Game1.uiViewport; + var x = (viewport.Width - menuWidth) / 2; + var y = (viewport.Height - menuHeight) / 2 + MenuVerticalNudge; + return new Point(Math.Max(0, x), Math.Max(0, y)); + } + + private void UpdateLayoutForViewport() + { + var viewport = Game1.uiViewport; + var width = Math.Min( + BaseMenuWidth, + Math.Max(MinMenuWidth, viewport.Width - ViewportPaddingWidth) + ); + var height = Math.Min( + BaseMenuHeight, + Math.Max(MinMenuHeight, viewport.Height - ViewportPaddingHeight) + ); + menuWidth = width; + menuHeight = height; + MenuLayout = $"{width}px {height}px"; + } + + private void UpdateTipVisibility() + { + ShowUnassignTip = IsSlotHoveredAndAssigned; + ShowAssignTip = !ShowUnassignTip; + } + + private void HandleRightStickScroll() + { + if (Controller?.Menu is null) + { + return; + } + var nowMs = Game1.currentGameTime?.TotalGameTime.TotalMilliseconds ?? 0; + var state = Game1.playerOneIndex >= PlayerIndex.One ? Game1.input.GetGamePadState() : new(); + var stickY = state.ThumbSticks.Right.Y; + var absY = Math.Abs(stickY); + if (absY <= thumbstickDeadZone) + { + return; + } + var intensity = Math.Clamp((absY - thumbstickDeadZone) / (1f - thumbstickDeadZone), 0f, 1f); + var repeatMs = + RightStickScrollRepeatMaxMs + - (RightStickScrollRepeatMaxMs - RightStickScrollRepeatMinMs) * intensity; + if (nowMs - lastRightStickScrollMs < repeatMs) + { + return; + } + lastRightStickScrollMs = nowMs; + TryScrollActiveContainer(stickY > 0); + } + + private bool TryScrollActiveContainer(bool scrollUp) + { + var container = GetActiveScrollContainer(); + if (container is null) + { + return false; + } + var containerType = container.GetType(); + var scrollSizeProp = containerType.GetProperty("ScrollSize"); + var scrollSizeValue = scrollSizeProp?.GetValue(container); + var scrollSize = scrollSizeValue is float size ? size : 0f; + if (scrollSize <= 0f) + { + return false; + } + var methodName = scrollUp ? "ScrollBackward" : "ScrollForward"; + var scrollMethod = containerType.GetMethod(methodName); + if (scrollMethod is null) + { + return false; + } + var result = scrollMethod.Invoke(container, Array.Empty()); + var scrolled = result is bool didScroll && didScroll; + if (scrolled) + { + Game1.playSound("shwip"); + } + return scrolled; + } + + private object? GetActiveScrollContainer() + { + var viewProp = Controller?.Menu?.GetType().GetProperty("View"); + var rootView = viewProp?.GetValue(Controller?.Menu!); + if (rootView is null) + { + return null; + } + return FindScrollContainer(rootView); + } + + private static object? FindScrollContainer(object view) + { + var viewType = view.GetType(); + var viewTypeName = viewType.FullName ?? string.Empty; + if (viewTypeName == "StardewUI.Widgets.ScrollableView") + { + var innerView = viewType + .GetProperty("View", BindingFlags.NonPublic | BindingFlags.Instance) + ?.GetValue(view); + if (innerView is not null) + { + return innerView; + } + } + if (viewTypeName == "StardewUI.Widgets.ScrollContainer") + { + return view; + } + var getChildren = viewType.GetMethod("GetChildren", new[] { typeof(bool) }); + var children = getChildren?.Invoke(view, new object[] { true }) as IEnumerable; + if (children is null) + { + return null; + } + foreach (var child in children) + { + var childView = child?.GetType().GetProperty("View")?.GetValue(child); + if (childView is null) + { + continue; + } + var result = FindScrollContainer(childView); + if (result is not null) + { + return result; + } + } + return null; + } + + private static IEnumerable GetInventoryAndNestedBags(Farmer who) + { + var result = new List(who.Items.Count + 8); + var seen = new HashSet(); + + foreach (var it in who.Items) + { + if (it is null) + continue; + + result.Add(it); + seen.Add(it); + } + + foreach (var it in QuickSlotResolver.GetExpandedPlayerItems(who)) + { + if (it is null) + continue; + + if (!QuickSlotResolver.IsItemBag(it)) + continue; + + if (seen.Add(it)) + result.Add(it); + } + + return result; + } +} + +internal partial class RemappingSlotViewModel(SButton button) +{ + public SButton Button { get; } = button; + public int? Count => Item?.Count ?? 1; + public bool IsCountVisible => Count > 1; + public bool IsItemEnabled => Item?.Enabled == true; + public int Quality => Item?.Quality ?? 0; + + public Sprite? Sprite => Item?.Sprite; + + public TooltipData? Tooltip => Item?.Tooltip; + + [Notify] + private RemappableItemViewModel? item; +} + +internal partial class RemappableItemGroupViewModel +{ + [Notify] + private IReadOnlyList items = []; + + [Notify] + private string name = ""; +} + +internal partial class RemappableItemViewModel +{ + public string Id { get; init; } = ""; + + public string? SubId { get; init; } + + public ItemIdType IdType { get; init; } + public bool IsCountVisible => Count > 1; + + [Notify] + private SButton assignedButton; + + [Notify] + private int count = 1; + + [Notify] + private bool enabled; + + [Notify] + private bool hovered; + + [Notify] + private int quality; + + [Notify] + private Sprite? sprite; + + [Notify] + private TooltipData? tooltip; + + public static RemappableItemViewModel FromInventoryItem(Item item) + { + ArgumentNullException.ThrowIfNull(item); + + var qualifiedId = item.QualifiedItemId; // can be null/empty for some mod items + var sprite = Sprite.FromItem(item); + + return new() + { + Id = qualifiedId ?? item.Name ?? item.GetType().FullName ?? "unknown", + IdType = ItemIdType.GameItem, + SubId = Compat.ItemBagsIdentity.TryGetBagTypeId(item), + Enabled = true, + Sprite = sprite, + Quality = item.Quality, + Count = item.Stack, + Tooltip = new(item.getDescription(), item.DisplayName, item), + }; + } + + public static RemappableItemViewModel FromInventoryItem( + Item item, + ICollection availableItems + ) + { + var result = FromInventoryItem(item); + result.Enabled = + QuickSlotResolver.ResolveInventoryItem( + item.QualifiedItemId, + result.SubId, + availableItems + ) + is not null; + return result; + } + + public static RemappableItemViewModel FromMenuItem(IRadialMenuItem item) + { + return new() + { + Id = item.Id, + IdType = ItemIdType.ModItem, + Enabled = item.Enabled, + Sprite = item.Texture is not null + ? new(item.Texture, item.SourceRectangle ?? item.Texture.Bounds) + : Sprites.Error(), + Tooltip = !string.IsNullOrEmpty(item.Description) + ? new(item.Description, item.Title) + : new(item.Title), + }; + } + + public static RemappableItemViewModel Invalid(ItemIdType type, string id) + { + return new() + { + Id = id, + IdType = type, + Sprite = Sprites.Error(), + Tooltip = new(I18n.Remapping_InvalidItem_Description(id)), + }; + } +} From 5205e5a7ddb0eaf347895b61b2c8ebadb0b9a79b Mon Sep 17 00:00:00 2001 From: SoulSilverJD <165866563+SoulSilverJD@users.noreply.github.com> Date: Wed, 28 Jan 2026 03:50:49 -0500 Subject: [PATCH 22/24] Moving --- StarControl/Compatibility/ItemBagsIdentity.cs | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 StarControl/Compatibility/ItemBagsIdentity.cs diff --git a/StarControl/Compatibility/ItemBagsIdentity.cs b/StarControl/Compatibility/ItemBagsIdentity.cs new file mode 100644 index 0000000..ced28be --- /dev/null +++ b/StarControl/Compatibility/ItemBagsIdentity.cs @@ -0,0 +1,38 @@ +using System.Reflection; +using StardewValley; + +namespace StarControl.Compatibility; + +internal static class ItemBagsIdentity +{ + private static Type? itemBagBaseType; + private static MethodInfo? getTypeIdMethod; + + public static string? TryGetBagTypeId(Item item) + { + itemBagBaseType ??= AppDomain + .CurrentDomain.GetAssemblies() + .Select(a => a.GetType("ItemBags.Bags.ItemBag", throwOnError: false)) + .FirstOrDefault(t => t is not null); + + if (itemBagBaseType is null || !itemBagBaseType.IsInstanceOfType(item)) + return null; + + getTypeIdMethod ??= itemBagBaseType.GetMethod( + "GetTypeId", + BindingFlags.Public | BindingFlags.Instance + ); + + if (getTypeIdMethod?.ReturnType != typeof(string)) + return null; + + try + { + return getTypeIdMethod.Invoke(item, null) as string; + } + catch + { + return null; + } + } +} From 5210ee825002e7b7cd7eca0047154e9e5bf4c9c0 Mon Sep 17 00:00:00 2001 From: SoulSilverJD <165866563+SoulSilverJD@users.noreply.github.com> Date: Wed, 28 Jan 2026 03:54:46 -0500 Subject: [PATCH 23/24] Delete StarControl/Compat directory --- StarControl/Compat/ItemBagsIdentity.cs | 38 -------------------------- 1 file changed, 38 deletions(-) delete mode 100644 StarControl/Compat/ItemBagsIdentity.cs diff --git a/StarControl/Compat/ItemBagsIdentity.cs b/StarControl/Compat/ItemBagsIdentity.cs deleted file mode 100644 index 4179ed5..0000000 --- a/StarControl/Compat/ItemBagsIdentity.cs +++ /dev/null @@ -1,38 +0,0 @@ -using System.Reflection; -using StardewValley; - -namespace StarControl.Compat; - -internal static class ItemBagsIdentity -{ - private static Type? itemBagBaseType; - private static MethodInfo? getTypeIdMethod; - - public static string? TryGetBagTypeId(Item item) - { - itemBagBaseType ??= AppDomain - .CurrentDomain.GetAssemblies() - .Select(a => a.GetType("ItemBags.Bags.ItemBag", throwOnError: false)) - .FirstOrDefault(t => t is not null); - - if (itemBagBaseType is null || !itemBagBaseType.IsInstanceOfType(item)) - return null; - - getTypeIdMethod ??= itemBagBaseType.GetMethod( - "GetTypeId", - BindingFlags.Public | BindingFlags.Instance - ); - - if (getTypeIdMethod?.ReturnType != typeof(string)) - return null; - - try - { - return getTypeIdMethod.Invoke(item, null) as string; - } - catch - { - return null; - } - } -} From 295e11933e5f25aee761081091bffa50eec0a17e Mon Sep 17 00:00:00 2001 From: SoulSilverJD <165866563+SoulSilverJD@users.noreply.github.com> Date: Wed, 28 Jan 2026 04:04:11 -0500 Subject: [PATCH 24/24] Update QuickSlotResolver.cs --- StarControl/Menus/QuickSlotResolver.cs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/StarControl/Menus/QuickSlotResolver.cs b/StarControl/Menus/QuickSlotResolver.cs index 6a50392..9ab47f1 100644 --- a/StarControl/Menus/QuickSlotResolver.cs +++ b/StarControl/Menus/QuickSlotResolver.cs @@ -17,9 +17,7 @@ internal class QuickSlotResolver(Farmer player, ModMenu modMenu) return ResolveInventoryItem(id, items); return items.FirstOrDefault(i => - i is not null - && i.QualifiedItemId == id - && Compat.ItemBagsIdentity.TryGetBagTypeId(i) == subId + i is not null && i.QualifiedItemId == id && ItemBagsIdentity.TryGetBagTypeId(i) == subId ); }