From 22f46d14a941f78932539cfaa0446d73dc5ad33b Mon Sep 17 00:00:00 2001 From: BruceChen Date: Sat, 28 Mar 2026 02:16:04 +0800 Subject: [PATCH 1/3] Fix negative location error --- MinecraftClient/Protocol/Handlers/DataTypes.cs | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/MinecraftClient/Protocol/Handlers/DataTypes.cs b/MinecraftClient/Protocol/Handlers/DataTypes.cs index 473757c1..78456c2d 100644 --- a/MinecraftClient/Protocol/Handlers/DataTypes.cs +++ b/MinecraftClient/Protocol/Handlers/DataTypes.cs @@ -1715,16 +1715,15 @@ namespace MinecraftClient.Protocol.Handlers public byte[] GetLocation(Location location) { byte[] locationBytes; + ulong x = (ulong)(int)Math.Floor(location.X) & 0x3FFFFFF; + ulong y = (ulong)(int)Math.Floor(location.Y) & 0xFFF; + ulong z = (ulong)(int)Math.Floor(location.Z) & 0x3FFFFFF; if (protocolversion >= Protocol18Handler.MC_1_14_Version) { - locationBytes = BitConverter.GetBytes(((((ulong)location.X) & 0x3FFFFFF) << 38) | - ((((ulong)location.Z) & 0x3FFFFFF) << 12) | - (((ulong)location.Y) & 0xFFF)); + locationBytes = BitConverter.GetBytes((x << 38) | (z << 12) | y); } else - locationBytes = BitConverter.GetBytes(((((ulong)location.X) & 0x3FFFFFF) << 38) | - ((((ulong)location.Y) & 0xFFF) << 26) | - (((ulong)location.Z) & 0x3FFFFFF)); + locationBytes = BitConverter.GetBytes((x << 38) | (y << 26) | z); Array.Reverse(locationBytes); //Endianness return locationBytes; From ef133f3d6d5ec38602460be55fc211be8e71b731 Mon Sep 17 00:00:00 2001 From: BruceChen Date: Sat, 28 Mar 2026 02:16:31 +0800 Subject: [PATCH 2/3] Enhance container handling for Minecraft protocol updates - Updated the Container class to include a protocol version parameter for accurate container type mapping. - Modified the GetContainerType method to account for changes in container types introduced in Minecraft 1.20.4. - Added new container types, including Crafter, to the ContainerType enum. - Adjusted ContainerTypeExtensions to reflect the new container mappings and ensure compatibility with the updated protocol. --- MinecraftClient/Inventory/Container.cs | 74 +++++++++++++++---- MinecraftClient/Inventory/ContainerType.cs | 3 +- .../Inventory/ContainerTypeExtensions.cs | 9 ++- .../Protocol/Handlers/Protocol18.cs | 2 +- 4 files changed, 69 insertions(+), 19 deletions(-) diff --git a/MinecraftClient/Inventory/Container.cs b/MinecraftClient/Inventory/Container.cs index 98908655..f2258fee 100644 --- a/MinecraftClient/Inventory/Container.cs +++ b/MinecraftClient/Inventory/Container.cs @@ -1,4 +1,4 @@ -using System.Collections.Generic; +using System.Collections.Generic; namespace MinecraftClient.Inventory { @@ -91,10 +91,11 @@ namespace MinecraftClient.Inventory /// Container ID /// Container Type /// Container Title - public Container(int id, int typeID, string title) + /// Protocol version for version-specific mapping + public Container(int id, int typeID, string title, int protocolVersion = 0) { ID = id; - Type = GetContainerType(typeID); + Type = GetContainerType(typeID, protocolVersion); Title = title; Items = new(); Properties = new(); @@ -131,22 +132,62 @@ namespace MinecraftClient.Inventory /// Get container type from Type ID /// /// Container Type ID + /// Protocol version (menu registry changed across versions) /// Container Type - public static ContainerType GetContainerType(int typeID) + public static ContainerType GetContainerType(int typeID, int protocolVersion = 0) { - // https://wiki.vg/Inventory didn't state the inventory ID, assume that list start with 0 + // MC 1.20.4 (protocol 765) added crafter_3x3 at index 7, shifting all subsequent IDs by +1. + // Registry order from decompiled MenuType.java: + // 1.14-1.20.2: generic_9x1..generic_3x3(6), anvil(7), beacon(8), ... stonecutter(22) + // 1.20.4+: generic_9x1..generic_3x3(6), crafter_3x3(7), anvil(8), beacon(9), ... stonecutter(24) + if (protocolVersion >= 765) + { + return typeID switch + { +#pragma warning disable format // @formatter:off + 0 => ContainerType.Generic_9x1, + 1 => ContainerType.Generic_9x2, + 2 => ContainerType.Generic_9x3, + 3 => ContainerType.Generic_9x4, + 4 => ContainerType.Generic_9x5, + 5 => ContainerType.Generic_9x6, + 6 => ContainerType.Generic_3x3, + 7 => ContainerType.Crafter, + 8 => ContainerType.Anvil, + 9 => ContainerType.Beacon, + 10 => ContainerType.BlastFurnace, + 11 => ContainerType.BrewingStand, + 12 => ContainerType.Crafting, + 13 => ContainerType.Enchantment, + 14 => ContainerType.Furnace, + 15 => ContainerType.Grindstone, + 16 => ContainerType.Hopper, + 17 => ContainerType.Lectern, + 18 => ContainerType.Loom, + 19 => ContainerType.Merchant, + 20 => ContainerType.ShulkerBox, + 21 => ContainerType.SmightingTable, + 22 => ContainerType.Smoker, + 23 => ContainerType.Cartography, + 24 => ContainerType.Stonecutter, + _ => ContainerType.Unknown, +#pragma warning restore format // @formatter:on + }; + } + return typeID switch { - 0 => ContainerType.Generic_9x1, - 1 => ContainerType.Generic_9x2, - 2 => ContainerType.Generic_9x3, - 3 => ContainerType.Generic_9x4, - 4 => ContainerType.Generic_9x5, - 5 => ContainerType.Generic_9x6, - 6 => ContainerType.Generic_3x3, - 7 => ContainerType.Anvil, - 8 => ContainerType.Beacon, - 9 => ContainerType.BlastFurnace, +#pragma warning disable format // @formatter:off + 0 => ContainerType.Generic_9x1, + 1 => ContainerType.Generic_9x2, + 2 => ContainerType.Generic_9x3, + 3 => ContainerType.Generic_9x4, + 4 => ContainerType.Generic_9x5, + 5 => ContainerType.Generic_9x6, + 6 => ContainerType.Generic_3x3, + 7 => ContainerType.Anvil, + 8 => ContainerType.Beacon, + 9 => ContainerType.BlastFurnace, 10 => ContainerType.BrewingStand, 11 => ContainerType.Crafting, 12 => ContainerType.Enchantment, @@ -160,7 +201,8 @@ namespace MinecraftClient.Inventory 20 => ContainerType.Smoker, 21 => ContainerType.Cartography, 22 => ContainerType.Stonecutter, - _ => ContainerType.Unknown, + _ => ContainerType.Unknown, +#pragma warning restore format // @formatter:on }; } diff --git a/MinecraftClient/Inventory/ContainerType.cs b/MinecraftClient/Inventory/ContainerType.cs index 76d05416..e82878fe 100644 --- a/MinecraftClient/Inventory/ContainerType.cs +++ b/MinecraftClient/Inventory/ContainerType.cs @@ -1,4 +1,4 @@ -namespace MinecraftClient.Inventory +namespace MinecraftClient.Inventory { // For MC 1.14 after ONLY public enum ContainerType @@ -10,6 +10,7 @@ Generic_9x5, Generic_9x6, Generic_3x3, + Crafter, Anvil, Beacon, BlastFurnace, diff --git a/MinecraftClient/Inventory/ContainerTypeExtensions.cs b/MinecraftClient/Inventory/ContainerTypeExtensions.cs index 4fe16373..4644e553 100644 --- a/MinecraftClient/Inventory/ContainerTypeExtensions.cs +++ b/MinecraftClient/Inventory/ContainerTypeExtensions.cs @@ -1,4 +1,4 @@ -namespace MinecraftClient.Inventory +namespace MinecraftClient.Inventory { public static class ContainerTypeExtensions { @@ -13,9 +13,14 @@ { #pragma warning disable format // @formatter:off ContainerType.PlayerInventory => 46, + ContainerType.Generic_9x1 => 45, + ContainerType.Generic_9x2 => 54, ContainerType.Generic_9x3 => 63, + ContainerType.Generic_9x4 => 72, + ContainerType.Generic_9x5 => 81, ContainerType.Generic_9x6 => 90, ContainerType.Generic_3x3 => 45, + ContainerType.Crafter => 45, ContainerType.Crafting => 46, ContainerType.BlastFurnace => 39, ContainerType.Furnace => 39, @@ -27,6 +32,7 @@ ContainerType.Anvil => 39, ContainerType.Hopper => 41, ContainerType.ShulkerBox => 63, + ContainerType.SmightingTable => 39, ContainerType.Loom => 40, ContainerType.Stonecutter => 38, ContainerType.Lectern => 37, @@ -52,6 +58,7 @@ ContainerType.Generic_9x3 => AsciiArt.Container_Generic_9x3, ContainerType.Generic_9x6 => AsciiArt.Container_Generic_9x6, ContainerType.Generic_3x3 => AsciiArt.Container_Generic_3x3, + ContainerType.Crafter => AsciiArt.Container_Generic_3x3, ContainerType.Crafting => AsciiArt.Container_Crafting, ContainerType.BlastFurnace => AsciiArt.Container_Furnace, ContainerType.Furnace => AsciiArt.Container_Furnace, diff --git a/MinecraftClient/Protocol/Handlers/Protocol18.cs b/MinecraftClient/Protocol/Handlers/Protocol18.cs index 48568f0f..b6cdcd05 100644 --- a/MinecraftClient/Protocol/Handlers/Protocol18.cs +++ b/MinecraftClient/Protocol/Handlers/Protocol18.cs @@ -2319,7 +2319,7 @@ namespace MinecraftClient.Protocol.Handlers var windowId = dataTypes.ReadNextVarInt(packetData); var windowType = dataTypes.ReadNextVarInt(packetData); var title = dataTypes.ReadNextChat(packetData); - Container inventory = new(windowId, windowType, ChatParser.ParseText(title)); + Container inventory = new(windowId, windowType, ChatParser.ParseText(title), protocolVersion); handler.OnInventoryOpen(windowId, inventory); } } From 0194380fbc1899a234f838882b64f054c59d0c10 Mon Sep 17 00:00:00 2001 From: BruceChen Date: Sat, 28 Mar 2026 02:17:09 +0800 Subject: [PATCH 3/3] TUI support for more container --- MinecraftClient/Commands/Inventory.cs | 2 +- MinecraftClient/McClient.cs | 9 + .../Translations/Translations.Designer.cs | 135 ++++ .../Resources/Translations/Translations.resx | 45 ++ MinecraftClient/Tui/BrewingStandView.cs | 152 ++++ MinecraftClient/Tui/ContainerViewBase.cs | 730 ++++++++++++++++++ MinecraftClient/Tui/ContainerViewModel.cs | 271 +++++++ MinecraftClient/Tui/CraftingView.cs | 112 +++ MinecraftClient/Tui/EnchantingTableView.cs | 198 +++++ MinecraftClient/Tui/FurnaceView.cs | 133 ++++ MinecraftClient/Tui/GridContainerView.cs | 31 + MinecraftClient/Tui/GrindstoneView.cs | 126 +++ MinecraftClient/Tui/HopperView.cs | 30 + MinecraftClient/Tui/InventoryApp.cs | 9 +- MinecraftClient/Tui/InventoryMainView.cs | 665 +--------------- MinecraftClient/Tui/InventoryTuiHost.cs | 29 +- MinecraftClient/Tui/InventoryViewModel.cs | 190 +---- 17 files changed, 2045 insertions(+), 822 deletions(-) create mode 100644 MinecraftClient/Tui/BrewingStandView.cs create mode 100644 MinecraftClient/Tui/ContainerViewBase.cs create mode 100644 MinecraftClient/Tui/ContainerViewModel.cs create mode 100644 MinecraftClient/Tui/CraftingView.cs create mode 100644 MinecraftClient/Tui/EnchantingTableView.cs create mode 100644 MinecraftClient/Tui/FurnaceView.cs create mode 100644 MinecraftClient/Tui/GridContainerView.cs create mode 100644 MinecraftClient/Tui/GrindstoneView.cs create mode 100644 MinecraftClient/Tui/HopperView.cs diff --git a/MinecraftClient/Commands/Inventory.cs b/MinecraftClient/Commands/Inventory.cs index 5936afd0..cc90f185 100644 --- a/MinecraftClient/Commands/Inventory.cs +++ b/MinecraftClient/Commands/Inventory.cs @@ -435,7 +435,7 @@ namespace MinecraftClient.Commands return r.SetAndReturn(CmdResult.Status.Fail, msg); } - if (container.Type != ContainerType.PlayerInventory) + if (!Tui.ContainerViewBase.HasTuiSupport(container.Type)) { handler.Log.Warn(string.Format(Translations.cmd_inventory_tui_unsupported_container, inventoryId)); return r.SetAndReturn(CmdResult.Status.Fail); diff --git a/MinecraftClient/McClient.cs b/MinecraftClient/McClient.cs index e76b9b80..938a85bf 100644 --- a/MinecraftClient/McClient.cs +++ b/MinecraftClient/McClient.cs @@ -3124,6 +3124,13 @@ namespace MinecraftClient Log.Info(string.Format(Translations.extra_inventory_open, inventoryID, inventory.Title)); Log.Info(Translations.extra_inventory_interact); DispatchBotEvent(bot => bot.OnInventoryOpen(inventoryID)); + + if (ConsoleIO.Backend is Tui.TuiConsoleBackend + && Tui.ContainerViewBase.HasTuiSupport(inventory.Type) + && Tui.InventoryTuiHost.CanLaunch) + { + Tui.InventoryTuiHost.Launch(this, inventoryID); + } } } @@ -3146,6 +3153,8 @@ namespace MinecraftClient Log.Info(string.Format(Translations.extra_inventory_close, inventoryID)); DispatchBotEvent(bot => bot.OnInventoryClose(inventoryID)); } + + Tui.InventoryTuiHost.NotifyInventoryClosed(inventoryID); } /// diff --git a/MinecraftClient/Resources/Translations/Translations.Designer.cs b/MinecraftClient/Resources/Translations/Translations.Designer.cs index cce4eda2..3a2c4bc9 100644 --- a/MinecraftClient/Resources/Translations/Translations.Designer.cs +++ b/MinecraftClient/Resources/Translations/Translations.Designer.cs @@ -6530,6 +6530,15 @@ namespace MinecraftClient { } } + /// + /// Looks up a localized string similar to Durability. + /// + internal static string tui_inventory_durability { + get { + return ResourceManager.GetString("tui.inventory.durability", resourceCulture); + } + } + /// /// Looks up a localized string similar to Container not found. /// @@ -6664,5 +6673,131 @@ namespace MinecraftClient { return ResourceManager.GetString("effect.name.with_amplifier", resourceCulture); } } + + /// + /// Looks up a localized string similar to Container. + /// + internal static string tui_container_label { + get { + return ResourceManager.GetString("tui.container.label", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Input. + /// + internal static string tui_furnace_input { + get { + return ResourceManager.GetString("tui.furnace.input", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Fuel. + /// + internal static string tui_furnace_fuel { + get { + return ResourceManager.GetString("tui.furnace.fuel", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Output. + /// + internal static string tui_furnace_output { + get { + return ResourceManager.GetString("tui.furnace.output", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Item. + /// + internal static string tui_enchanting_item { + get { + return ResourceManager.GetString("tui.enchanting.item", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Lapis. + /// + internal static string tui_enchanting_lapis { + get { + return ResourceManager.GetString("tui.enchanting.lapis", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Enchant Options. + /// + internal static string tui_enchanting_options { + get { + return ResourceManager.GetString("tui.enchanting.options", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Option {0}. + /// + internal static string tui_enchanting_option_slot { + get { + return ResourceManager.GetString("tui.enchanting.option_slot", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Fuel. + /// + internal static string tui_brewing_fuel { + get { + return ResourceManager.GetString("tui.brewing.fuel", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Ingredient. + /// + internal static string tui_brewing_ingredient { + get { + return ResourceManager.GetString("tui.brewing.ingredient", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Bottle {0}. + /// + internal static string tui_brewing_bottle { + get { + return ResourceManager.GetString("tui.brewing.bottle", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Input 1. + /// + internal static string tui_grindstone_input1 { + get { + return ResourceManager.GetString("tui.grindstone.input1", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Input 2. + /// + internal static string tui_grindstone_input2 { + get { + return ResourceManager.GetString("tui.grindstone.input2", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Crafting. + /// + internal static string tui_crafting_grid { + get { + return ResourceManager.GetString("tui.crafting.grid", resourceCulture); + } + } } } diff --git a/MinecraftClient/Resources/Translations/Translations.resx b/MinecraftClient/Resources/Translations/Translations.resx index 45662bd5..68202894 100644 --- a/MinecraftClient/Resources/Translations/Translations.resx +++ b/MinecraftClient/Resources/Translations/Translations.resx @@ -2302,6 +2302,9 @@ see item details. Slot #{0} Count: {1} + + Durability + Container not found @@ -2347,4 +2350,46 @@ see item details. {0} {1} + + Container + + + Input + + + Fuel + + + Output + + + Item + + + Lapis + + + Enchant Options + + + Option {0} + + + Fuel + + + Ingredient + + + Bottle {0} + + + Input 1 + + + Input 2 + + + Crafting + \ No newline at end of file diff --git a/MinecraftClient/Tui/BrewingStandView.cs b/MinecraftClient/Tui/BrewingStandView.cs new file mode 100644 index 00000000..6a00071b --- /dev/null +++ b/MinecraftClient/Tui/BrewingStandView.cs @@ -0,0 +1,152 @@ +using System.Collections.ObjectModel; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Layout; +using Avalonia.Media; +using MinecraftClient.Inventory; + +namespace MinecraftClient.Tui +{ + public class BrewingStandView : ContainerViewBase + { + private readonly BrewingViewModel _brewVm; + + public BrewingStandView(McClient handler, int windowId) + : base(new BrewingViewModel(handler, windowId)) + { + _brewVm = (BrewingViewModel)_vm; + Initialize(); + } + + protected override int GetTotalSlotRows() + { + return 3 + 3 + 1; + } + + protected override Control BuildContainerSpecificArea() + { + var panel = new StackPanel + { + Spacing = 0, + HorizontalAlignment = HorizontalAlignment.Center, + }; + + var topRow = new StackPanel + { + Orientation = Orientation.Horizontal, + HorizontalAlignment = HorizontalAlignment.Center, + Spacing = 0, + }; + + var fuelCol = new StackPanel + { + VerticalAlignment = VerticalAlignment.Center, + }; + fuelCol.Children.Add(new TextBlock + { + Text = Translations.tui_brewing_fuel, + Foreground = BrEquipLbl, + FontWeight = FontWeight.Bold, + HorizontalAlignment = HorizontalAlignment.Center, + }); + fuelCol.Children.Add(CreateSlotCell(_brewVm.FuelSlot, 0, 0)); + topRow.Children.Add(fuelCol); + + topRow.Children.Add(new Border { Width = 2 }); + + var ingredientCol = new StackPanel + { + VerticalAlignment = VerticalAlignment.Center, + }; + ingredientCol.Children.Add(new TextBlock + { + Text = Translations.tui_brewing_ingredient, + Foreground = BrEquipLbl, + FontWeight = FontWeight.Bold, + HorizontalAlignment = HorizontalAlignment.Center, + }); + ingredientCol.Children.Add(CreateSlotCell(_brewVm.IngredientSlot, 0, 1)); + topRow.Children.Add(ingredientCol); + + panel.Children.Add(topRow); + + panel.Children.Add(new TextBlock + { + Text = "\u25bc", + Foreground = new SolidColorBrush(Color.FromRgb(140, 140, 140)), + HorizontalAlignment = HorizontalAlignment.Center, + }); + + var bottleRow = new StackPanel + { + Orientation = Orientation.Horizontal, + HorizontalAlignment = HorizontalAlignment.Center, + Spacing = 0, + }; + for (int i = 0; i < 3; i++) + { + var bottlePanel = new StackPanel + { + VerticalAlignment = VerticalAlignment.Center, + }; + bottlePanel.Children.Add(new TextBlock + { + Text = string.Format(Translations.tui_brewing_bottle, i + 1), + Foreground = BrEquipLbl, + FontWeight = FontWeight.Bold, + HorizontalAlignment = HorizontalAlignment.Center, + }); + bottlePanel.Children.Add(CreateSlotCell(_brewVm.BottleSlots[i], 1, i)); + bottleRow.Children.Add(bottlePanel); + } + + panel.Children.Add(bottleRow); + + return panel; + } + } + + public class BrewingViewModel : ContainerViewModel + { + public ObservableCollection BottleSlots { get; } = new(); + public SlotViewModel IngredientSlot { get; private set; } = null!; + public SlotViewModel FuelSlot { get; private set; } = null!; + + public BrewingViewModel(McClient handler, int windowId) + : base(handler, windowId, ContainerType.BrewingStand) + { + IngredientSlot = SlotMap[3]; + FuelSlot = SlotMap[4]; + } + + protected override void InitializeSlots() + { + SlotMap.Clear(); + + for (int i = 0; i <= 2; i++) + { + var slot = new SlotViewModel(i); + BottleSlots.Add(slot); + SlotMap[i] = slot; + } + + SlotMap[3] = new SlotViewModel(3); + SlotMap[4] = new SlotViewModel(4); + + for (int i = 5; i <= 31; i++) + { + var slot = new SlotViewModel(i); + MainInventorySlots.Add(slot); + SlotMap[i] = slot; + } + + for (int i = 32; i <= 40; i++) + { + int hotbarIdx = i - 32; + var slot = new SlotViewModel(i, isHotbar: true, hotbarIndex: hotbarIdx); + HotbarSlots.Add(slot); + SlotMap[i] = slot; + } + } + } +} diff --git a/MinecraftClient/Tui/ContainerViewBase.cs b/MinecraftClient/Tui/ContainerViewBase.cs new file mode 100644 index 00000000..2a5b7668 --- /dev/null +++ b/MinecraftClient/Tui/ContainerViewBase.cs @@ -0,0 +1,730 @@ +using System; +using System.Collections.ObjectModel; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Controls.Primitives; +using Avalonia.Controls.Templates; +using Avalonia.Input; +using Avalonia.Layout; +using Avalonia.Media; +using MinecraftClient.Inventory; + +namespace MinecraftClient.Tui +{ + public abstract class ContainerViewBase : UserControl + { + protected static readonly IBrush BrSlotEmptyA = new SolidColorBrush(Color.FromRgb(40, 40, 40)); + protected static readonly IBrush BrSlotEmptyB = new SolidColorBrush(Color.FromRgb(55, 55, 55)); + protected static readonly IBrush BrSlotFillA = new SolidColorBrush(Color.FromRgb(60, 60, 75)); + protected static readonly IBrush BrSlotFillB = new SolidColorBrush(Color.FromRgb(75, 75, 90)); + protected static readonly IBrush BrSlotHover = new SolidColorBrush(Color.FromRgb(100, 100, 140)); + protected static readonly IBrush BrName = Brushes.White; + protected static readonly IBrush BrCount = Brushes.Yellow; + protected static readonly IBrush BrDim = new SolidColorBrush(Color.FromRgb(80, 80, 80)); + protected static readonly IBrush BrEquipLbl = Brushes.DarkCyan; + protected static readonly IBrush BrInfoHighlight = new SolidColorBrush(Color.FromRgb(40, 40, 60)); + protected static readonly IBrush BrHeldItemBg = new SolidColorBrush(Color.FromRgb(60, 50, 80)); + protected static readonly IBrush BrHeldItemBorder = Brushes.Yellow; + + protected int _slotW; + protected int _slotH; + protected int _nameMaxLen; + protected int _nameLines; + protected int _termW; + + protected readonly ContainerViewModel _vm; + protected TextBlock _titleText = null!; + protected Border _infoDetailBorder = null!; + protected TextBlock _infoDetailText = null!; + protected TextBlock _cursorItemText = null!; + protected TextBlock _helpText = null!; + + protected TextBlock[] _hotbarIndicators = new TextBlock[9]; + protected int _currentHotbarSlot = -1; + + protected Border? _lastHoveredSlotBorder; + + protected Canvas _overlayCanvas = null!; + protected Border _heldItemFloater = null!; + protected TextBlock _heldItemFloaterName = null!; + protected TextBlock _heldItemFloaterCount = null!; + + protected ScrollViewer _chatScrollViewer = null!; + protected ObservableCollection? _chatLines; + protected int _lastTermW; + protected int _lastTermH; + protected bool _chatScrollToBottom = true; + + protected ContainerViewBase(ContainerViewModel vm) + { + _vm = vm; + _currentHotbarSlot = vm.Handler.GetCurrentSlot(); + + _chatLines = TuiConsoleBackend.Instance?.GetView()?.GetRecentLogLines(50) + ?? new ObservableCollection(); + } + + protected void Initialize() + { + RebuildUi(); + } + + protected abstract int GetTotalSlotRows(); + + protected abstract Control BuildContainerSpecificArea(); + + protected virtual void OnContainerDataChanged() { } + + protected virtual void RebuildUi() + { + int termH; + try + { + _termW = System.Console.WindowWidth; + termH = System.Console.WindowHeight; + } + catch + { + _termW = 120; + termH = 40; + } + + _lastTermW = _termW; + _lastTermH = termH; + + int availW = _termW - 26; + _slotW = Math.Clamp(availW / 9, 8, 18); + _nameMaxLen = _slotW; + + int totalRows = GetTotalSlotRows(); + int overhead = 4; + int chatMinH = 1; + _slotH = Math.Clamp((termH - overhead - chatMinH) / totalRows, 2, 5); + _nameLines = _slotH; + + _vm.SetSlotDisplayParams(_nameMaxLen, _nameLines); + + _lastHoveredSlotBorder = null; + + _titleText = new TextBlock + { + FontWeight = FontWeight.Bold, + Foreground = Brushes.Cyan, + HorizontalAlignment = HorizontalAlignment.Center, + }; + + _infoDetailText = new TextBlock + { + TextWrapping = TextWrapping.Wrap, + Foreground = Brushes.White, + }; + + _infoDetailBorder = new Border + { + Background = Brushes.Transparent, + Padding = new Thickness(0), + Child = _infoDetailText, + }; + + _cursorItemText = new TextBlock + { + Foreground = Brushes.Yellow, + FontWeight = FontWeight.Bold, + TextWrapping = TextWrapping.Wrap, + }; + + _helpText = new TextBlock + { + TextWrapping = TextWrapping.Wrap, + Foreground = new SolidColorBrush(Color.FromRgb(140, 140, 140)), + Text = Translations.tui_inventory_controls_help, + }; + + _heldItemFloaterName = new TextBlock + { + Foreground = Brushes.White, + FontWeight = FontWeight.Bold, + TextWrapping = TextWrapping.Wrap, + }; + _heldItemFloaterCount = new TextBlock + { + Foreground = BrCount, + FontWeight = FontWeight.Bold, + }; + _heldItemFloater = new Border + { + Background = BrHeldItemBg, + BorderBrush = BrHeldItemBorder, + BorderThickness = new Thickness(1), + Padding = new Thickness(1, 0), + IsVisible = false, + MaxWidth = 24, + Child = new StackPanel + { + Children = { _heldItemFloaterName, _heldItemFloaterCount }, + }, + }; + + _overlayCanvas = new Canvas { IsHitTestVisible = false }; + _overlayCanvas.Children.Add(_heldItemFloater); + + var chatLines = _chatLines!; + chatLines.CollectionChanged += (_, _) => + { + Avalonia.Threading.Dispatcher.UIThread.Post(() => + { + var sv = _chatScrollViewer; + if (sv.Extent.Height > sv.Viewport.Height) + sv.Offset = new Vector(0, sv.Extent.Height - sv.Viewport.Height); + }, Avalonia.Threading.DispatcherPriority.Background); + }; + var chatItemsControl = new ItemsControl + { + ItemsSource = chatLines, + Focusable = false, + ItemTemplate = new FuncDataTemplate((s, _) => + new TextBlock + { + Text = s, + Foreground = Brushes.Gray, + Padding = new Thickness(0), + Margin = new Thickness(0), + TextWrapping = TextWrapping.Wrap, + }), + }; + _chatScrollViewer = new ScrollViewer + { + Content = chatItemsControl, + Background = Brushes.Black, + HorizontalScrollBarVisibility = ScrollBarVisibility.Disabled, + VerticalScrollBarVisibility = ScrollBarVisibility.Hidden, + Padding = new Thickness(0), + }; + + _hotbarIndicators = new TextBlock[9]; + + Content = BuildRootLayout(); + UpdateTitle(); + UpdateInfoPanel(); + + _chatScrollToBottom = true; + _chatScrollViewer.ScrollChanged += OnChatScrollChanged; + } + + private void OnChatScrollChanged(object? sender, ScrollChangedEventArgs e) + { + if (!_chatScrollToBottom) return; + var sv = _chatScrollViewer; + if (sv.Extent.Height > sv.Viewport.Height) + { + sv.Offset = new Vector(0, sv.Extent.Height - sv.Viewport.Height); + _chatScrollToBottom = false; + } + } + + protected virtual Control BuildRootLayout() + { + var inventoryArea = BuildMainArea(); + DockPanel.SetDock(_titleText, Dock.Top); + DockPanel.SetDock(inventoryArea, Dock.Top); + + var mainContent = new DockPanel + { + Children = { _titleText, inventoryArea, _chatScrollViewer } + }; + + return new Panel + { + Background = Brushes.Black, + Children = { mainContent, _overlayCanvas } + }; + } + + protected virtual Control BuildMainArea() + { + var infoPanel = BuildInfoPanel(); + DockPanel.SetDock(infoPanel, Dock.Right); + + return new DockPanel + { + Children = { infoPanel, BuildInventoryPanel() } + }; + } + + protected virtual Control BuildInventoryPanel() + { + var root = new StackPanel + { + Spacing = 0, + HorizontalAlignment = HorizontalAlignment.Center, + }; + + root.Children.Add(BuildContainerSpecificArea()); + root.Children.Add(BuildSeparator()); + root.Children.Add(BuildSlotGrid(_vm.MainInventorySlots, 9)); + root.Children.Add(BuildHotbarSection()); + + return new Border + { + BorderThickness = new Thickness(1), + BorderBrush = Brushes.Gray, + Child = root, + }; + } + + protected Control BuildSeparator() + { + return new Border + { + Height = 1, + Background = Brushes.Transparent, + Margin = new Thickness(0, 0, 0, 0), + }; + } + + protected Control BuildInfoPanel() + { + return new Border + { + BorderThickness = new Thickness(1), + BorderBrush = Brushes.Gray, + Padding = new Thickness(1), + Width = 24, + Child = new StackPanel + { + Children = + { + new TextBlock { Text = Translations.tui_inventory_item_info, FontWeight = FontWeight.Bold, Foreground = Brushes.Cyan }, + _infoDetailBorder, + new TextBlock { Text = Translations.tui_inventory_held_item, FontWeight = FontWeight.Bold, Foreground = Brushes.Yellow, Margin = new Thickness(0, 1, 0, 0) }, + _cursorItemText, + new TextBlock { Text = Translations.tui_inventory_controls, FontWeight = FontWeight.Bold, Foreground = Brushes.Green, Margin = new Thickness(0, 1, 0, 0) }, + _helpText, + } + } + }; + } + + protected Control BuildHotbarSection() + { + var panel = new StackPanel { Spacing = 0 }; + + var numberRow = new StackPanel + { + Orientation = Orientation.Horizontal, + HorizontalAlignment = HorizontalAlignment.Center, + }; + for (int i = 0; i < 9; i++) + { + bool active = i == _currentHotbarSlot; + string label = active ? $"{i + 1} \u25bc" : $" {i + 1} "; + + var tb = new TextBlock + { + Text = label, + Width = _slotW, + TextAlignment = TextAlignment.Center, + Foreground = active ? Brushes.LightGreen : Brushes.DarkCyan, + FontWeight = FontWeight.Bold, + }; + _hotbarIndicators[i] = tb; + numberRow.Children.Add(tb); + } + panel.Children.Add(numberRow); + panel.Children.Add(BuildSlotGrid(_vm.HotbarSlots, 9)); + return panel; + } + + protected Control BuildSlotGrid(ObservableCollection slots, int columns) + { + var grid = new Grid(); + int rows = (slots.Count + columns - 1) / columns; + + for (int r = 0; r < rows; r++) + grid.RowDefinitions.Add(new RowDefinition(GridLength.Auto)); + for (int c = 0; c < columns; c++) + grid.ColumnDefinitions.Add(new ColumnDefinition(GridLength.Auto)); + + for (int i = 0; i < slots.Count; i++) + { + int row = i / columns; + int col = i % columns; + var cell = CreateSlotCell(slots[i], row, col); + Grid.SetRow(cell, row); + Grid.SetColumn(cell, col); + grid.Children.Add(cell); + } + + return grid; + } + + protected static IBrush GetSlotBg(bool isEmpty, int row, int col) + { + bool isA = (row + col) % 2 == 0; + return isEmpty + ? (isA ? BrSlotEmptyA : BrSlotEmptyB) + : (isA ? BrSlotFillA : BrSlotFillB); + } + + protected Border CreateSlotCell(SlotViewModel slot, int row = 0, int col = 0) + { + var nameTb = new TextBlock + { + TextWrapping = TextWrapping.Wrap, + Padding = new Thickness(0), + Margin = new Thickness(0), + VerticalAlignment = VerticalAlignment.Top, + }; + + var countTb = new TextBlock + { + Foreground = BrCount, + FontWeight = FontWeight.Bold, + Padding = new Thickness(0), + Margin = new Thickness(0), + HorizontalAlignment = HorizontalAlignment.Right, + VerticalAlignment = VerticalAlignment.Bottom, + }; + + ApplySlotVisual(slot, nameTb, countTb); + + int r = row, c = col; + var border = new Border + { + Width = _slotW, + Height = _slotH, + Background = GetSlotBg(slot.IsEmpty, r, c), + Child = new Panel + { + Children = { nameTb, countTb }, + }, + Tag = (slot, r, c), + }; + + border.PointerPressed += OnSlotPointerPressed; + border.PointerEntered += OnSlotPointerEnter; + border.PointerExited += OnSlotPointerExit; + border.PointerMoved += OnSlotPointerMoved; + + slot.PropertyChanged += (_, _) => + { + ApplySlotVisual(slot, nameTb, countTb); + border.Background = GetSlotBg(slot.IsEmpty, r, c); + }; + + return border; + } + + protected static void ApplySlotVisual(SlotViewModel slot, TextBlock nameTb, TextBlock countTb) + { + if (slot.IsEmpty) + { + nameTb.Text = ""; + nameTb.Foreground = BrDim; + countTb.Text = ""; + } + else + { + nameTb.Text = slot.ItemDisplayText; + nameTb.Foreground = BrName; + countTb.Text = slot.CountDisplay; + } + } + + protected TextBlock MakeLabel(string text) + { + return new TextBlock + { + Text = text, + Foreground = BrEquipLbl, + VerticalAlignment = VerticalAlignment.Center, + Margin = new Thickness(1, 0, 0, 0), + FontWeight = FontWeight.Bold, + }; + } + + #region Pointer / Keyboard interaction + + private void OnSlotPointerPressed(object? sender, PointerPressedEventArgs e) + { + if (sender is not Border border || border.Tag is not (SlotViewModel slot, int, int)) + return; + + SetHover(border, slot); + + var point = e.GetCurrentPoint(border); + bool isShift = (e.KeyModifiers & KeyModifiers.Shift) != 0; + + WindowActionType action; + if (point.Properties.IsRightButtonPressed) + action = isShift ? WindowActionType.ShiftRightClick : WindowActionType.RightClick; + else + action = isShift ? WindowActionType.ShiftClick : WindowActionType.LeftClick; + + _vm.PerformAction(slot.SlotId, action); + UpdateInfoPanel(); + UpdateHeldItemFloater(e); + OnContainerDataChanged(); + e.Handled = true; + } + + private void OnSlotPointerEnter(object? sender, PointerEventArgs e) + { + if (sender is Border b && b.Tag is (SlotViewModel slot, int, int)) + { + SetHover(b, slot); + UpdateHeldItemFloater(e); + } + } + + private void OnSlotPointerMoved(object? sender, PointerEventArgs e) + { + if (sender is Border b && b.Tag is (SlotViewModel slot, int, int)) + { + SetHover(b, slot); + UpdateHeldItemFloater(e); + } + } + + private void OnSlotPointerExit(object? sender, PointerEventArgs e) + { + if (sender is Border b && b.Tag is (SlotViewModel slot, int row, int col)) + b.Background = GetSlotBg(slot.IsEmpty, row, col); + } + + protected void SetHover(Border border, SlotViewModel slot) + { + if (_lastHoveredSlotBorder != null && _lastHoveredSlotBorder != border) + { + if (_lastHoveredSlotBorder.Tag is (SlotViewModel oldSlot, int or, int oc)) + _lastHoveredSlotBorder.Background = GetSlotBg(oldSlot.IsEmpty, or, oc); + } + + _lastHoveredSlotBorder = border; + border.Background = BrSlotHover; + _vm.HoveredSlot = slot; + UpdateInfoPanel(); + } + + protected void UpdateHeldItemFloater(PointerEventArgs e) + { + if (!_vm.HasCursorItem) + { + _heldItemFloater.IsVisible = false; + return; + } + + _heldItemFloaterName.Text = _vm.CursorItemInfo; + _heldItemFloaterCount.Text = ""; + + try + { + var pos = e.GetPosition(_overlayCanvas); + double left = pos.X + 2; + double remainingW = _termW - left - 2; + int maxW = Math.Max(8, (int)remainingW); + _heldItemFloater.MaxWidth = maxW; + Canvas.SetLeft(_heldItemFloater, left); + Canvas.SetTop(_heldItemFloater, pos.Y); + } + catch + { + _heldItemFloater.MaxWidth = 24; + Canvas.SetLeft(_heldItemFloater, 0); + Canvas.SetTop(_heldItemFloater, 0); + } + + _heldItemFloater.IsVisible = true; + } + + protected void UpdateInfoPanel() + { + _infoDetailText.Text = _vm.HoveredSlotDetailText; + + bool hasHoveredItem = _vm.HoveredSlot != null && !_vm.HoveredSlot.IsEmpty; + _infoDetailBorder.Background = hasHoveredItem ? BrInfoHighlight : Brushes.Transparent; + + if (_vm.HasCursorItem) + { + _cursorItemText.Text = _vm.CursorItemInfo; + _cursorItemText.Foreground = Brushes.Yellow; + } + else + { + _cursorItemText.Text = Translations.tui_inventory_cursor_empty; + _cursorItemText.Foreground = BrDim; + _heldItemFloater.IsVisible = false; + } + } + + protected void UpdateTitle() + { + _titleText.Text = _vm.Title; + } + + protected void CloseInventory() + { + if (_vm.WindowId != 0) + _vm.Handler.CloseInventory(_vm.WindowId); + + if (ConsoleIO.Backend is TuiConsoleBackend tuiBackend) + tuiBackend.GetView()?.HideOverlay(); + else + (Application.Current?.ApplicationLifetime as IControlledApplicationLifetime)?.Shutdown(); + } + + protected override void OnKeyDown(KeyEventArgs e) + { + base.OnKeyDown(e); + + switch (e.Key) + { + case Key.Escape: + case Key.E: + CloseInventory(); + e.Handled = true; + break; + + case Key.C: + if ((e.KeyModifiers & KeyModifiers.Shift) != 0 && + _vm.HoveredSlot != null && !_vm.HoveredSlot.IsEmpty) + { + _vm.PerformAction(_vm.HoveredSlot.SlotId, WindowActionType.ShiftClick); + UpdateInfoPanel(); + } + e.Handled = true; + break; + + case Key.Q: + if (_vm.HoveredSlot != null && !_vm.HoveredSlot.IsEmpty) + { + var action = (e.KeyModifiers & KeyModifiers.Control) != 0 + ? WindowActionType.DropItemStack + : WindowActionType.DropItem; + _vm.PerformAction(_vm.HoveredSlot.SlotId, action); + UpdateInfoPanel(); + } + e.Handled = true; + break; + + case Key.R: + _vm.RefreshFromContainer(); + _currentHotbarSlot = _vm.Handler.GetCurrentSlot(); + UpdateHotbarIndicators(); + UpdateInfoPanel(); + OnContainerDataChanged(); + e.Handled = true; + break; + } + } + + protected void UpdateHotbarIndicators() + { + for (int i = 0; i < 9; i++) + { + bool active = i == _currentHotbarSlot; + _hotbarIndicators[i].Text = active ? $"{i + 1} \u25bc" : $" {i + 1} "; + _hotbarIndicators[i].Foreground = active ? Brushes.LightGreen : Brushes.DarkCyan; + } + } + + #endregion + + #region Lifecycle + + protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnAttachedToVisualTree(e); + Focusable = true; + Focus(); + AddHandler(KeyDownEvent, OnTunnelKeyDown, Avalonia.Interactivity.RoutingStrategies.Tunnel); + SizeChanged += OnViewSizeChanged; + } + + private void OnTunnelKeyDown(object? sender, KeyEventArgs e) + { + if (e.Key == Key.Escape) + { + CloseInventory(); + e.Handled = true; + } + } + + private void OnViewSizeChanged(object? sender, SizeChangedEventArgs e) + { + int newW, newH; + try + { + newW = System.Console.WindowWidth; + newH = System.Console.WindowHeight; + } + catch { return; } + + if (newW == _lastTermW && newH == _lastTermH) return; + + _vm.RefreshFromContainer(); + _currentHotbarSlot = _vm.Handler.GetCurrentSlot(); + RebuildUi(); + Focus(); + } + + protected override void OnGotFocus(GotFocusEventArgs e) + { + base.OnGotFocus(e); + Focusable = true; + } + + #endregion + + public static bool HasTuiSupport(ContainerType type) + { + return type switch + { + ContainerType.PlayerInventory => true, + ContainerType.Generic_9x1 => true, + ContainerType.Generic_9x2 => true, + ContainerType.Generic_9x3 => true, + ContainerType.Generic_9x4 => true, + ContainerType.Generic_9x5 => true, + ContainerType.Generic_9x6 => true, + ContainerType.Generic_3x3 => true, + ContainerType.Crafter => true, + ContainerType.ShulkerBox => true, + ContainerType.Crafting => true, + ContainerType.Furnace => true, + ContainerType.BlastFurnace => true, + ContainerType.Smoker => true, + ContainerType.Enchantment => true, + ContainerType.BrewingStand => true, + ContainerType.Hopper => true, + ContainerType.Grindstone => true, + _ => false, + }; + } + + public static ContainerViewBase CreateView(ContainerType type, McClient handler, int windowId) + { + return type switch + { + ContainerType.PlayerInventory => new PlayerInventoryView(handler, windowId), + ContainerType.Generic_9x3 or ContainerType.ShulkerBox => new GridContainerView(handler, windowId, type, 3, 9), + ContainerType.Generic_9x6 => new GridContainerView(handler, windowId, type, 6, 9), + ContainerType.Generic_3x3 or ContainerType.Crafter + => new GridContainerView(handler, windowId, type, 3, 3), + ContainerType.Generic_9x1 => new GridContainerView(handler, windowId, type, 1, 9), + ContainerType.Generic_9x2 => new GridContainerView(handler, windowId, type, 2, 9), + ContainerType.Generic_9x4 => new GridContainerView(handler, windowId, type, 4, 9), + ContainerType.Generic_9x5 => new GridContainerView(handler, windowId, type, 5, 9), + ContainerType.Crafting => new CraftingView(handler, windowId), + ContainerType.Furnace or ContainerType.BlastFurnace or ContainerType.Smoker + => new FurnaceView(handler, windowId, type), + ContainerType.Enchantment => new EnchantingTableView(handler, windowId), + ContainerType.BrewingStand => new BrewingStandView(handler, windowId), + ContainerType.Hopper => new HopperView(handler, windowId), + ContainerType.Grindstone => new GrindstoneView(handler, windowId), + _ => throw new ArgumentException($"No TUI view for {type}"), + }; + } + } +} diff --git a/MinecraftClient/Tui/ContainerViewModel.cs b/MinecraftClient/Tui/ContainerViewModel.cs new file mode 100644 index 00000000..1effcdae --- /dev/null +++ b/MinecraftClient/Tui/ContainerViewModel.cs @@ -0,0 +1,271 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.ComponentModel; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Text; +using MinecraftClient.Inventory; +using MinecraftClient.Protocol.Handlers.StructuredComponents.Components._1_20_6; + +namespace MinecraftClient.Tui +{ + public class ContainerViewModel : INotifyPropertyChanged + { + private SlotViewModel? _hoveredSlot; + private string _title = ""; + private string _statusText = ""; + private string _cursorItemInfo = ""; + private bool _hasCursorItem; + + public McClient Handler { get; } + public int WindowId { get; } + public ContainerType ContainerType { get; } + + public ObservableCollection ContainerSlots { get; } = new(); + public ObservableCollection MainInventorySlots { get; } = new(); + public ObservableCollection HotbarSlots { get; } = new(); + + public string Title + { + get => _title; + set { _title = value; OnPropertyChanged(); } + } + + public string StatusText + { + get => _statusText; + set { _statusText = value; OnPropertyChanged(); } + } + + public string CursorItemInfo + { + get => _cursorItemInfo; + set { _cursorItemInfo = value; OnPropertyChanged(); } + } + + public bool HasCursorItem + { + get => _hasCursorItem; + set { _hasCursorItem = value; OnPropertyChanged(); } + } + + public SlotViewModel? HoveredSlot + { + get => _hoveredSlot; + set + { + if (_hoveredSlot != null) + _hoveredSlot.IsHovered = false; + _hoveredSlot = value; + if (_hoveredSlot != null) + _hoveredSlot.IsHovered = true; + OnPropertyChanged(); + OnPropertyChanged(nameof(HoveredSlotDetailText)); + } + } + + public string HoveredSlotDetailText + { + get + { + if (_hoveredSlot == null) + return Translations.tui_inventory_hover_hint; + + if (_hoveredSlot.IsEmpty) + return $"Slot #{_hoveredSlot.SlotId}\n{Translations.tui_inventory_slot_empty}"; + + var sb = new StringBuilder(); + sb.AppendLine(_hoveredSlot.ItemTypeName); + sb.AppendLine(string.Format(Translations.tui_inventory_slot_detail, _hoveredSlot.SlotId, _hoveredSlot.ItemCount)); + + var item = _hoveredSlot.RawItem; + if (item != null) + AppendItemExtras(sb, item); + + return sb.ToString().TrimEnd(); + } + } + + protected Dictionary SlotMap { get; } = new(); + + public ContainerViewModel(McClient handler, int windowId, ContainerType containerType) + { + Handler = handler; + WindowId = windowId; + ContainerType = containerType; + + InitializeSlots(); + RefreshFromContainer(); + } + + public void SetSlotDisplayParams(int maxWidth, int maxLines) + { + foreach (var kvp in SlotMap) + { + kvp.Value.NameMaxWidth = maxWidth; + kvp.Value.NameMaxLines = maxLines; + } + RefreshFromContainer(); + } + + protected virtual void InitializeSlots() + { + SlotMap.Clear(); + + int slotCount = ContainerType.SlotCount(); + if (slotCount == 0) return; + + int playerInvStart = slotCount - 36; + + for (int i = 0; i < playerInvStart; i++) + { + var slot = new SlotViewModel(i); + ContainerSlots.Add(slot); + SlotMap[i] = slot; + } + + for (int i = playerInvStart; i < playerInvStart + 27; i++) + { + var slot = new SlotViewModel(i); + MainInventorySlots.Add(slot); + SlotMap[i] = slot; + } + + for (int i = playerInvStart + 27; i < slotCount; i++) + { + int hotbarIdx = i - (playerInvStart + 27); + var slot = new SlotViewModel(i, isHotbar: true, hotbarIndex: hotbarIdx); + HotbarSlots.Add(slot); + SlotMap[i] = slot; + } + } + + public virtual void RefreshFromContainer() + { + Inventory.Container? container = Handler.GetInventory(WindowId); + if (container == null) + { + StatusText = Translations.tui_inventory_container_not_found; + return; + } + + Title = string.Format(Translations.tui_inventory_title, WindowId, container.Title); + + foreach (var kvp in SlotMap) + { + Item? item = container.Items.TryGetValue(kvp.Key, out var it) ? it : null; + kvp.Value.Update(item); + } + + UpdateCursorItem(container); + int itemCount = 0; + foreach (var kvp in container.Items) + { + if (kvp.Key >= 0 && !kvp.Value.IsEmpty) + itemCount++; + } + StatusText = string.Format(Translations.tui_inventory_item_count, itemCount); + + OnPropertyChanged(nameof(HoveredSlotDetailText)); + } + + protected void UpdateCursorItem(Inventory.Container _) + { + var playerInv = Handler.GetInventory(0); + if (playerInv != null && playerInv.Items.TryGetValue(-1, out var cursorItem) && !cursorItem.IsEmpty) + { + CursorItemInfo = FormatItemDetail(cursorItem); + HasCursorItem = true; + } + else + { + CursorItemInfo = ""; + HasCursorItem = false; + } + } + + protected static string FormatItemDetail(Item item) + { + var sb = new StringBuilder(); + sb.AppendLine($"x{item.Count} {item.GetTypeString()}"); + AppendItemExtras(sb, item); + if (sb.Length > 0 && sb[sb.Length - 1] == '\n') + sb.Remove(sb.Length - 1, 1); + return sb.ToString(); + } + + private static void AppendItemExtras(StringBuilder sb, Item item) + { + int damage = item.Damage; + if (damage != 0) + { + int maxDamage = item.Components?.OfType().FirstOrDefault()?.MaxDamage ?? 0; + if (maxDamage > 0) + sb.AppendLine($"{Translations.tui_inventory_durability}: {maxDamage - damage}/{maxDamage}"); + else + sb.AppendLine($"{Translations.cmd_inventory_damage}: {damage}"); + } + + try + { + var enchList = item.EnchantmentList; + if (enchList is not null) + { + bool isFirstEnchantment = true; + foreach (var ench in enchList) + { + string name = EnchantmentMapping.GetEnchantmentName(ench.Type); + string level = EnchantmentMapping.ConvertLevelToRomanNumbers(ench.Level); + if (isFirstEnchantment) + { + isFirstEnchantment = false; + sb.Append($"{name} {level}"); + } + else + { + sb.Append($" | {name} {level}"); + } + } + } + else if (item.NBT is not null && + (item.NBT.TryGetValue("Enchantments", out object? enchantments) || + item.NBT.TryGetValue("StoredEnchantments", out enchantments))) + { + bool isFirstEnchantment = true; + foreach (Dictionary enchantment in (object[])enchantments) + { + short level = (short)enchantment["lvl"]; + string id = ((string)enchantment["id"]).Replace(':', '.'); + string name = Protocol.Message.ChatParser.TranslateString("enchantment." + id) ?? id; + string levelStr = Protocol.Message.ChatParser.TranslateString("enchantment.level." + level) ?? level.ToString(); + if (isFirstEnchantment) + { + isFirstEnchantment = false; + sb.Append($"{name} {levelStr}"); + } + else + { + sb.Append($" | {name} {levelStr}"); + } + } + } + } + catch { } + } + + public bool PerformAction(int slotId, WindowActionType action) + { + bool result = Handler.DoWindowAction(WindowId, slotId, action); + RefreshFromContainer(); + return result; + } + + public event PropertyChangedEventHandler? PropertyChanged; + + protected void OnPropertyChanged([CallerMemberName] string? name = null) + { + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name)); + } + } +} diff --git a/MinecraftClient/Tui/CraftingView.cs b/MinecraftClient/Tui/CraftingView.cs new file mode 100644 index 00000000..dbbfc508 --- /dev/null +++ b/MinecraftClient/Tui/CraftingView.cs @@ -0,0 +1,112 @@ +using System.Collections.ObjectModel; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Layout; +using Avalonia.Media; +using MinecraftClient.Inventory; + +namespace MinecraftClient.Tui +{ + public class CraftingView : ContainerViewBase + { + private readonly CraftingViewModel _craftVm; + + public CraftingView(McClient handler, int windowId) + : base(new CraftingViewModel(handler, windowId)) + { + _craftVm = (CraftingViewModel)_vm; + Initialize(); + } + + protected override int GetTotalSlotRows() + { + return 3 + 3 + 1; + } + + protected override Control BuildContainerSpecificArea() + { + var row = new StackPanel + { + Orientation = Orientation.Horizontal, + HorizontalAlignment = HorizontalAlignment.Center, + }; + + var gridPanel = new StackPanel { Spacing = 0 }; + gridPanel.Children.Add(new TextBlock + { + Text = Translations.tui_crafting_grid, + Foreground = BrEquipLbl, + FontWeight = FontWeight.Bold, + HorizontalAlignment = HorizontalAlignment.Center, + }); + gridPanel.Children.Add(BuildSlotGrid(_craftVm.CraftingGridSlots, 3)); + row.Children.Add(gridPanel); + + row.Children.Add(new TextBlock + { + Text = " \u2192 ", + Foreground = Brushes.White, + FontWeight = FontWeight.Bold, + VerticalAlignment = VerticalAlignment.Center, + }); + + var outPanel = new StackPanel + { + VerticalAlignment = VerticalAlignment.Center, + }; + outPanel.Children.Add(new TextBlock + { + Text = Translations.tui_inventory_output, + Foreground = BrEquipLbl, + FontWeight = FontWeight.Bold, + HorizontalAlignment = HorizontalAlignment.Center, + }); + outPanel.Children.Add(CreateSlotCell(_craftVm.OutputSlot, 0, 0)); + row.Children.Add(outPanel); + + return row; + } + } + + public class CraftingViewModel : ContainerViewModel + { + public ObservableCollection CraftingGridSlots { get; } = new(); + public SlotViewModel OutputSlot { get; private set; } = null!; + + public CraftingViewModel(McClient handler, int windowId) + : base(handler, windowId, ContainerType.Crafting) + { + OutputSlot = SlotMap[0]; + } + + protected override void InitializeSlots() + { + SlotMap.Clear(); + + var output = new SlotViewModel(0); + SlotMap[0] = output; + + for (int i = 1; i <= 9; i++) + { + var slot = new SlotViewModel(i); + CraftingGridSlots.Add(slot); + SlotMap[i] = slot; + } + + for (int i = 10; i <= 36; i++) + { + var slot = new SlotViewModel(i); + MainInventorySlots.Add(slot); + SlotMap[i] = slot; + } + + for (int i = 37; i <= 45; i++) + { + int hotbarIdx = i - 37; + var slot = new SlotViewModel(i, isHotbar: true, hotbarIndex: hotbarIdx); + HotbarSlots.Add(slot); + SlotMap[i] = slot; + } + } + } +} diff --git a/MinecraftClient/Tui/EnchantingTableView.cs b/MinecraftClient/Tui/EnchantingTableView.cs new file mode 100644 index 00000000..c1386d7a --- /dev/null +++ b/MinecraftClient/Tui/EnchantingTableView.cs @@ -0,0 +1,198 @@ +using System.Collections.ObjectModel; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Layout; +using Avalonia.Media; +using MinecraftClient.Inventory; + +namespace MinecraftClient.Tui +{ + public class EnchantingTableView : ContainerViewBase + { + private readonly EnchantingViewModel _enchantVm; + private readonly TextBlock[] _enchantNameLabels = new TextBlock[3]; + private readonly TextBlock[] _enchantCostLabels = new TextBlock[3]; + + public EnchantingTableView(McClient handler, int windowId) + : base(new EnchantingViewModel(handler, windowId)) + { + _enchantVm = (EnchantingViewModel)_vm; + Initialize(); + } + + private void RefreshEnchantOptions() + { + var container = _vm.Handler.GetInventory(_vm.WindowId); + if (container == null) return; + + int protocolVersion = _vm.Handler.GetProtocolVersion(); + + for (int i = 0; i < 3; i++) + { + if (_enchantNameLabels[i] == null) continue; + + short levelReq = container.Properties.TryGetValue(i, out var lr) ? lr : (short)0; + short enchantId = container.Properties.TryGetValue(i + 4, out var eid) ? eid : (short)-1; + short enchantLevel = container.Properties.TryGetValue(i + 7, out var el) ? el : (short)0; + + if (levelReq > 0 && enchantId >= 0) + { + try + { + var enchant = EnchantmentMapping.GetEnchantmentById(protocolVersion, enchantId); + string name = EnchantmentMapping.GetEnchantmentName(enchant); + string roman = EnchantmentMapping.ConvertLevelToRomanNumbers(enchantLevel); + _enchantNameLabels[i].Text = $"{name} {roman}"; + _enchantCostLabels[i].Text = $" ({levelReq})"; + } + catch + { + _enchantNameLabels[i].Text = string.Format(Translations.tui_enchanting_option_slot, i + 1); + _enchantCostLabels[i].Text = levelReq > 0 ? $" ({levelReq})" : ""; + } + } + else + { + _enchantNameLabels[i].Text = string.Format(Translations.tui_enchanting_option_slot, i + 1); + _enchantCostLabels[i].Text = ""; + } + } + } + + protected override void OnContainerDataChanged() + { + RefreshEnchantOptions(); + } + + protected override int GetTotalSlotRows() + { + return 3 + 3 + 1; + } + + protected override Control BuildContainerSpecificArea() + { + var panel = new StackPanel + { + Orientation = Orientation.Horizontal, + HorizontalAlignment = HorizontalAlignment.Center, + Spacing = 0, + }; + + var slotsCol = new StackPanel + { + Spacing = 0, + VerticalAlignment = VerticalAlignment.Center, + }; + + slotsCol.Children.Add(new TextBlock + { + Text = Translations.tui_enchanting_item, + Foreground = BrEquipLbl, + FontWeight = FontWeight.Bold, + HorizontalAlignment = HorizontalAlignment.Center, + }); + slotsCol.Children.Add(CreateSlotCell(_enchantVm.ItemSlot, 0, 0)); + + slotsCol.Children.Add(new TextBlock + { + Text = Translations.tui_enchanting_lapis, + Foreground = new SolidColorBrush(Color.FromRgb(60, 80, 200)), + FontWeight = FontWeight.Bold, + HorizontalAlignment = HorizontalAlignment.Center, + }); + slotsCol.Children.Add(CreateSlotCell(_enchantVm.LapisSlot, 1, 0)); + + panel.Children.Add(slotsCol); + + var optionsCol = new StackPanel + { + VerticalAlignment = VerticalAlignment.Center, + Margin = new Thickness(2, 0, 0, 0), + }; + + optionsCol.Children.Add(new TextBlock + { + Text = Translations.tui_enchanting_options, + Foreground = Brushes.Magenta, + FontWeight = FontWeight.Bold, + }); + + int optionWidth = System.Math.Max(_slotW * 4, 30); + + for (int i = 0; i < 3; i++) + { + var nameLabel = new TextBlock + { + Text = string.Format(Translations.tui_enchanting_option_slot, i + 1), + Foreground = new SolidColorBrush(Color.FromRgb(100, 200, 70)), + TextWrapping = TextWrapping.NoWrap, + }; + _enchantNameLabels[i] = nameLabel; + + var costLabel = new TextBlock + { + Foreground = new SolidColorBrush(Color.FromRgb(100, 200, 70)), + FontWeight = FontWeight.Bold, + VerticalAlignment = VerticalAlignment.Center, + }; + _enchantCostLabels[i] = costLabel; + + var content = new DockPanel(); + DockPanel.SetDock(costLabel, Dock.Right); + content.Children.Add(costLabel); + content.Children.Add(nameLabel); + + optionsCol.Children.Add(new Border + { + Background = new SolidColorBrush(Color.FromRgb(55, 50, 40)), + MinWidth = optionWidth, + MinHeight = _slotH, + Padding = new Thickness(1, 0), + Child = content, + }); + } + + RefreshEnchantOptions(); + + panel.Children.Add(optionsCol); + + return panel; + } + } + + public class EnchantingViewModel : ContainerViewModel + { + public SlotViewModel ItemSlot { get; private set; } = null!; + public SlotViewModel LapisSlot { get; private set; } = null!; + + public EnchantingViewModel(McClient handler, int windowId) + : base(handler, windowId, ContainerType.Enchantment) + { + ItemSlot = SlotMap[0]; + LapisSlot = SlotMap[1]; + } + + protected override void InitializeSlots() + { + SlotMap.Clear(); + + SlotMap[0] = new SlotViewModel(0); + SlotMap[1] = new SlotViewModel(1); + + for (int i = 2; i <= 28; i++) + { + var slot = new SlotViewModel(i); + MainInventorySlots.Add(slot); + SlotMap[i] = slot; + } + + for (int i = 29; i <= 37; i++) + { + int hotbarIdx = i - 29; + var slot = new SlotViewModel(i, isHotbar: true, hotbarIndex: hotbarIdx); + HotbarSlots.Add(slot); + SlotMap[i] = slot; + } + } + } +} diff --git a/MinecraftClient/Tui/FurnaceView.cs b/MinecraftClient/Tui/FurnaceView.cs new file mode 100644 index 00000000..cde8054c --- /dev/null +++ b/MinecraftClient/Tui/FurnaceView.cs @@ -0,0 +1,133 @@ +using System.Collections.ObjectModel; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Layout; +using Avalonia.Media; +using MinecraftClient.Inventory; + +namespace MinecraftClient.Tui +{ + public class FurnaceView : ContainerViewBase + { + private readonly FurnaceViewModel _furnaceVm; + + public FurnaceView(McClient handler, int windowId, ContainerType type) + : base(new FurnaceViewModel(handler, windowId, type)) + { + _furnaceVm = (FurnaceViewModel)_vm; + Initialize(); + } + + protected override int GetTotalSlotRows() + { + return 3 + 3 + 1; + } + + protected override Control BuildContainerSpecificArea() + { + var panel = new StackPanel + { + Orientation = Orientation.Horizontal, + HorizontalAlignment = HorizontalAlignment.Center, + Spacing = 0, + }; + + var leftCol = new StackPanel + { + Spacing = 0, + VerticalAlignment = VerticalAlignment.Center, + }; + + leftCol.Children.Add(new TextBlock + { + Text = Translations.tui_furnace_input, + Foreground = BrEquipLbl, + FontWeight = FontWeight.Bold, + HorizontalAlignment = HorizontalAlignment.Center, + }); + leftCol.Children.Add(CreateSlotCell(_furnaceVm.InputSlot, 0, 0)); + + leftCol.Children.Add(new TextBlock + { + Text = "\u2592\u2592\u2592", + Foreground = new SolidColorBrush(Color.FromRgb(180, 100, 40)), + HorizontalAlignment = HorizontalAlignment.Center, + }); + + leftCol.Children.Add(new TextBlock + { + Text = Translations.tui_furnace_fuel, + Foreground = BrEquipLbl, + FontWeight = FontWeight.Bold, + HorizontalAlignment = HorizontalAlignment.Center, + }); + leftCol.Children.Add(CreateSlotCell(_furnaceVm.FuelSlot, 1, 0)); + + panel.Children.Add(leftCol); + + panel.Children.Add(new TextBlock + { + Text = " \u2192 ", + Foreground = Brushes.White, + FontWeight = FontWeight.Bold, + VerticalAlignment = VerticalAlignment.Center, + }); + + var rightCol = new StackPanel + { + VerticalAlignment = VerticalAlignment.Center, + }; + rightCol.Children.Add(new TextBlock + { + Text = Translations.tui_furnace_output, + Foreground = BrEquipLbl, + FontWeight = FontWeight.Bold, + HorizontalAlignment = HorizontalAlignment.Center, + }); + rightCol.Children.Add(CreateSlotCell(_furnaceVm.OutputSlot, 0, 1)); + + panel.Children.Add(rightCol); + + return panel; + } + } + + public class FurnaceViewModel : ContainerViewModel + { + public SlotViewModel InputSlot { get; private set; } = null!; + public SlotViewModel FuelSlot { get; private set; } = null!; + public SlotViewModel OutputSlot { get; private set; } = null!; + + public FurnaceViewModel(McClient handler, int windowId, ContainerType type) + : base(handler, windowId, type) + { + InputSlot = SlotMap[0]; + FuelSlot = SlotMap[1]; + OutputSlot = SlotMap[2]; + } + + protected override void InitializeSlots() + { + SlotMap.Clear(); + + SlotMap[0] = new SlotViewModel(0); + SlotMap[1] = new SlotViewModel(1); + SlotMap[2] = new SlotViewModel(2); + + for (int i = 3; i <= 29; i++) + { + var slot = new SlotViewModel(i); + MainInventorySlots.Add(slot); + SlotMap[i] = slot; + } + + for (int i = 30; i <= 38; i++) + { + int hotbarIdx = i - 30; + var slot = new SlotViewModel(i, isHotbar: true, hotbarIndex: hotbarIdx); + HotbarSlots.Add(slot); + SlotMap[i] = slot; + } + } + } +} diff --git a/MinecraftClient/Tui/GridContainerView.cs b/MinecraftClient/Tui/GridContainerView.cs new file mode 100644 index 00000000..c2da293b --- /dev/null +++ b/MinecraftClient/Tui/GridContainerView.cs @@ -0,0 +1,31 @@ +using Avalonia.Controls; +using Avalonia.Layout; +using Avalonia.Media; +using MinecraftClient.Inventory; + +namespace MinecraftClient.Tui +{ + public class GridContainerView : ContainerViewBase + { + private readonly int _gridRows; + private readonly int _gridCols; + + public GridContainerView(McClient handler, int windowId, ContainerType type, int rows, int cols) + : base(new ContainerViewModel(handler, windowId, type)) + { + _gridRows = rows; + _gridCols = cols; + Initialize(); + } + + protected override int GetTotalSlotRows() + { + return _gridRows + 3 + 1; + } + + protected override Control BuildContainerSpecificArea() + { + return BuildSlotGrid(_vm.ContainerSlots, _gridCols); + } + } +} diff --git a/MinecraftClient/Tui/GrindstoneView.cs b/MinecraftClient/Tui/GrindstoneView.cs new file mode 100644 index 00000000..04a283a6 --- /dev/null +++ b/MinecraftClient/Tui/GrindstoneView.cs @@ -0,0 +1,126 @@ +using System.Collections.ObjectModel; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Layout; +using Avalonia.Media; +using MinecraftClient.Inventory; + +namespace MinecraftClient.Tui +{ + public class GrindstoneView : ContainerViewBase + { + private readonly GrindstoneViewModel _grindVm; + + public GrindstoneView(McClient handler, int windowId) + : base(new GrindstoneViewModel(handler, windowId)) + { + _grindVm = (GrindstoneViewModel)_vm; + Initialize(); + } + + protected override int GetTotalSlotRows() + { + return 2 + 3 + 1; + } + + protected override Control BuildContainerSpecificArea() + { + var row = new StackPanel + { + Orientation = Orientation.Horizontal, + HorizontalAlignment = HorizontalAlignment.Center, + Spacing = 0, + }; + + var inputCol = new StackPanel + { + Spacing = 0, + VerticalAlignment = VerticalAlignment.Center, + }; + + inputCol.Children.Add(new TextBlock + { + Text = Translations.tui_grindstone_input1, + Foreground = BrEquipLbl, + FontWeight = FontWeight.Bold, + HorizontalAlignment = HorizontalAlignment.Center, + }); + inputCol.Children.Add(CreateSlotCell(_grindVm.Input1Slot, 0, 0)); + + inputCol.Children.Add(new TextBlock + { + Text = Translations.tui_grindstone_input2, + Foreground = BrEquipLbl, + FontWeight = FontWeight.Bold, + HorizontalAlignment = HorizontalAlignment.Center, + }); + inputCol.Children.Add(CreateSlotCell(_grindVm.Input2Slot, 1, 0)); + + row.Children.Add(inputCol); + + row.Children.Add(new TextBlock + { + Text = "=>", + Foreground = Brushes.White, + FontWeight = FontWeight.Bold, + VerticalAlignment = VerticalAlignment.Center, + Padding = new Thickness(1, 0), + }); + + var outCol = new StackPanel + { + VerticalAlignment = VerticalAlignment.Center, + }; + outCol.Children.Add(new TextBlock + { + Text = Translations.tui_inventory_output, + Foreground = BrEquipLbl, + FontWeight = FontWeight.Bold, + HorizontalAlignment = HorizontalAlignment.Center, + }); + outCol.Children.Add(CreateSlotCell(_grindVm.OutputSlot, 0, 1)); + row.Children.Add(outCol); + + return row; + } + } + + public class GrindstoneViewModel : ContainerViewModel + { + public SlotViewModel Input1Slot { get; private set; } = null!; + public SlotViewModel Input2Slot { get; private set; } = null!; + public SlotViewModel OutputSlot { get; private set; } = null!; + + public GrindstoneViewModel(McClient handler, int windowId) + : base(handler, windowId, ContainerType.Grindstone) + { + Input1Slot = SlotMap[0]; + Input2Slot = SlotMap[1]; + OutputSlot = SlotMap[2]; + } + + protected override void InitializeSlots() + { + SlotMap.Clear(); + + SlotMap[0] = new SlotViewModel(0); + SlotMap[1] = new SlotViewModel(1); + SlotMap[2] = new SlotViewModel(2); + + for (int i = 3; i <= 29; i++) + { + var slot = new SlotViewModel(i); + MainInventorySlots.Add(slot); + SlotMap[i] = slot; + } + + for (int i = 30; i <= 38; i++) + { + int hotbarIdx = i - 30; + var slot = new SlotViewModel(i, isHotbar: true, hotbarIndex: hotbarIdx); + HotbarSlots.Add(slot); + SlotMap[i] = slot; + } + } + } +} diff --git a/MinecraftClient/Tui/HopperView.cs b/MinecraftClient/Tui/HopperView.cs new file mode 100644 index 00000000..bb69b2ae --- /dev/null +++ b/MinecraftClient/Tui/HopperView.cs @@ -0,0 +1,30 @@ +using Avalonia.Controls; +using Avalonia.Layout; +using MinecraftClient.Inventory; + +namespace MinecraftClient.Tui +{ + public class HopperView : ContainerViewBase + { + public HopperView(McClient handler, int windowId) + : base(new ContainerViewModel(handler, windowId, ContainerType.Hopper)) + { + Initialize(); + } + + protected override int GetTotalSlotRows() + { + return 1 + 3 + 1; + } + + protected override Control BuildContainerSpecificArea() + { + var grid = BuildSlotGrid(_vm.ContainerSlots, 5); + return new StackPanel + { + HorizontalAlignment = HorizontalAlignment.Center, + Children = { grid }, + }; + } + } +} diff --git a/MinecraftClient/Tui/InventoryApp.cs b/MinecraftClient/Tui/InventoryApp.cs index ac71bb39..ea866300 100644 --- a/MinecraftClient/Tui/InventoryApp.cs +++ b/MinecraftClient/Tui/InventoryApp.cs @@ -2,6 +2,7 @@ using Avalonia; using Avalonia.Controls; using Avalonia.Controls.ApplicationLifetimes; using Consolonia.Themes; +using MinecraftClient.Inventory; namespace MinecraftClient.Tui { @@ -16,9 +17,15 @@ namespace MinecraftClient.Tui { if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) { + var handler = InventoryTuiHost.ActiveHandler!; + var windowId = InventoryTuiHost.ActiveWindowId; + var container = handler.GetInventory(windowId); + var containerType = container?.Type ?? ContainerType.PlayerInventory; + var view = ContainerViewBase.CreateView(containerType, handler, windowId); + desktop.MainWindow = new Window { - Content = new InventoryMainView(), + Content = view, Title = "MCC Inventory" }; } diff --git a/MinecraftClient/Tui/InventoryMainView.cs b/MinecraftClient/Tui/InventoryMainView.cs index 7cd95974..d141870d 100644 --- a/MinecraftClient/Tui/InventoryMainView.cs +++ b/MinecraftClient/Tui/InventoryMainView.cs @@ -1,304 +1,39 @@ -using System; -using System.Collections.ObjectModel; using Avalonia; using Avalonia.Controls; -using Avalonia.Controls.ApplicationLifetimes; -using Avalonia.Controls.Primitives; -using Avalonia.Controls.Templates; -using Avalonia.Input; using Avalonia.Layout; using Avalonia.Media; -using MinecraftClient.Inventory; namespace MinecraftClient.Tui { - public class InventoryMainView : UserControl + public class PlayerInventoryView : ContainerViewBase { - private static readonly IBrush BrSlotEmptyA = new SolidColorBrush(Color.FromRgb(40, 40, 40)); - private static readonly IBrush BrSlotEmptyB = new SolidColorBrush(Color.FromRgb(55, 55, 55)); - private static readonly IBrush BrSlotFillA = new SolidColorBrush(Color.FromRgb(60, 60, 75)); - private static readonly IBrush BrSlotFillB = new SolidColorBrush(Color.FromRgb(75, 75, 90)); - private static readonly IBrush BrSlotHover = new SolidColorBrush(Color.FromRgb(100, 100, 140)); - private static readonly IBrush BrName = Brushes.White; - private static readonly IBrush BrCount = Brushes.Yellow; - private static readonly IBrush BrDim = new SolidColorBrush(Color.FromRgb(80, 80, 80)); - private static readonly IBrush BrEquipLbl = Brushes.DarkCyan; - private static readonly IBrush BrInfoHighlight = new SolidColorBrush(Color.FromRgb(40, 40, 60)); - private static readonly IBrush BrHeldItemBg = new SolidColorBrush(Color.FromRgb(60, 50, 80)); - private static readonly IBrush BrHeldItemBorder = Brushes.Yellow; - - private int _slotW; - private int _slotH; - private int _nameMaxLen; - private int _nameLines; + private readonly PlayerInventoryViewModel _playerVm; private int _topGap; - private int _termW; - private readonly InventoryViewModel _vm; - private TextBlock _titleText = null!; - private Border _infoDetailBorder = null!; - private TextBlock _infoDetailText = null!; - private TextBlock _cursorItemText = null!; - private TextBlock _helpText = null!; - - private TextBlock[] _hotbarIndicators = new TextBlock[9]; - private int _currentHotbarSlot = -1; - - private Border? _lastHoveredSlotBorder; - - private Canvas _overlayCanvas = null!; - private Border _heldItemFloater = null!; - private TextBlock _heldItemFloaterName = null!; - private TextBlock _heldItemFloaterCount = null!; - - private ScrollViewer _chatScrollViewer = null!; - private ObservableCollection? _chatLines; - private int _lastTermW; - private int _lastTermH; - - public InventoryMainView() + public PlayerInventoryView(McClient handler, int windowId) + : base(new PlayerInventoryViewModel(handler, windowId)) { - var handler = InventoryTuiHost.ActiveHandler - ?? throw new InvalidOperationException("No active McClient"); - int windowId = InventoryTuiHost.ActiveWindowId; - - _vm = new InventoryViewModel(handler, windowId); - _currentHotbarSlot = handler.GetCurrentSlot(); - - _chatLines = TuiConsoleBackend.Instance?.GetView()?.GetRecentLogLines(50) - ?? new ObservableCollection(); - - RebuildUi(); + _playerVm = (PlayerInventoryViewModel)_vm; + Initialize(); } - private void RebuildUi() + protected override int GetTotalSlotRows() { - int termH; - try - { - _termW = System.Console.WindowWidth; - termH = System.Console.WindowHeight; - } - catch - { - _termW = 120; - termH = 40; - } - - _lastTermW = _termW; - _lastTermH = termH; - - int availW = _termW - 26; - _slotW = Math.Clamp(availW / 9, 8, 18); - _nameMaxLen = _slotW; - - int topUsedW = _slotW * 4 + 8 + _slotW * 2 + 4 + _slotW; - _topGap = Math.Max(2, (_slotW * 9 - topUsedW) / 2); - - _slotH = Math.Clamp((termH - 8) / 6, 2, 5); - _nameLines = _slotH; - - _vm.SetSlotDisplayParams(_nameMaxLen, _nameLines); - - _lastHoveredSlotBorder = null; - - _titleText = new TextBlock - { - FontWeight = FontWeight.Bold, - Foreground = Brushes.Cyan, - HorizontalAlignment = HorizontalAlignment.Center, - }; - - _infoDetailText = new TextBlock - { - TextWrapping = TextWrapping.Wrap, - Foreground = Brushes.White, - }; - - _infoDetailBorder = new Border - { - Background = Brushes.Transparent, - Padding = new Thickness(0), - Child = _infoDetailText, - }; - - _cursorItemText = new TextBlock - { - Foreground = Brushes.Yellow, - FontWeight = FontWeight.Bold, - TextWrapping = TextWrapping.Wrap, - }; - - _helpText = new TextBlock - { - TextWrapping = TextWrapping.Wrap, - Foreground = new SolidColorBrush(Color.FromRgb(140, 140, 140)), - Text = Translations.tui_inventory_controls_help, - }; - - _heldItemFloaterName = new TextBlock - { - Foreground = Brushes.White, - FontWeight = FontWeight.Bold, - TextWrapping = TextWrapping.Wrap, - }; - _heldItemFloaterCount = new TextBlock - { - Foreground = BrCount, - FontWeight = FontWeight.Bold, - }; - _heldItemFloater = new Border - { - Background = BrHeldItemBg, - BorderBrush = BrHeldItemBorder, - BorderThickness = new Thickness(1), - Padding = new Thickness(1, 0), - IsVisible = false, - MaxWidth = 24, - Child = new StackPanel - { - Children = { _heldItemFloaterName, _heldItemFloaterCount }, - }, - }; - - _overlayCanvas = new Canvas { IsHitTestVisible = false }; - _overlayCanvas.Children.Add(_heldItemFloater); - - var chatLines = _chatLines!; - chatLines.CollectionChanged += (_, _) => - { - Avalonia.Threading.Dispatcher.UIThread.Post(() => - { - var sv = _chatScrollViewer; - if (sv.Extent.Height > sv.Viewport.Height) - sv.Offset = new Vector(0, sv.Extent.Height - sv.Viewport.Height); - }, Avalonia.Threading.DispatcherPriority.Background); - }; - var chatItemsControl = new ItemsControl - { - ItemsSource = chatLines, - Focusable = false, - ItemTemplate = new FuncDataTemplate((s, _) => - new TextBlock - { - Text = s, - Foreground = Brushes.Gray, - Padding = new Thickness(0), - Margin = new Thickness(0), - TextWrapping = TextWrapping.Wrap, - }), - }; - _chatScrollViewer = new ScrollViewer - { - Content = chatItemsControl, - Background = Brushes.Black, - HorizontalScrollBarVisibility = ScrollBarVisibility.Disabled, - VerticalScrollBarVisibility = ScrollBarVisibility.Hidden, - Padding = new Thickness(0), - }; - - _hotbarIndicators = new TextBlock[9]; - - Content = BuildRootLayout(); - UpdateTitle(); - UpdateInfoPanel(); - - _chatScrollToBottom = true; - _chatScrollViewer.ScrollChanged += OnChatScrollChanged; + return 6; } - private bool _chatScrollToBottom = true; - - private void OnChatScrollChanged(object? sender, ScrollChangedEventArgs e) + protected override void RebuildUi() { - if (!_chatScrollToBottom) return; - var sv = _chatScrollViewer; - if (sv.Extent.Height > sv.Viewport.Height) - { - sv.Offset = new Vector(0, sv.Extent.Height - sv.Viewport.Height); - _chatScrollToBottom = false; - } + int availW = 0; + try { availW = System.Console.WindowWidth - 26; } catch { availW = 94; } + int slotW = System.Math.Clamp(availW / 9, 8, 18); + int topUsedW = slotW * 4 + 8 + slotW * 2 + 4 + slotW; + _topGap = System.Math.Max(2, (slotW * 9 - topUsedW) / 2); + + base.RebuildUi(); } - private Control BuildRootLayout() - { - // Layout (top-down): - // Title - // [InfoPanel(right)] [InventoryGrid(left)] <-- inventory area - // ChatScrollViewer (full width, fills remaining) - - var inventoryArea = BuildMainArea(); - DockPanel.SetDock(_titleText, Dock.Top); - DockPanel.SetDock(inventoryArea, Dock.Top); - - var mainContent = new DockPanel - { - Children = { _titleText, inventoryArea, _chatScrollViewer } - }; - - return new Panel - { - Background = Brushes.Black, - Children = { mainContent, _overlayCanvas } - }; - } - - private Control BuildMainArea() - { - var infoPanel = BuildInfoPanel(); - DockPanel.SetDock(infoPanel, Dock.Right); - - return new DockPanel - { - Children = { infoPanel, BuildInventoryPanel() } - }; - } - - private Control BuildInfoPanel() - { - return new Border - { - BorderThickness = new Thickness(1), - BorderBrush = Brushes.Gray, - Padding = new Thickness(1), - Width = 24, - Child = new StackPanel - { - Children = - { - new TextBlock { Text = Translations.tui_inventory_item_info, FontWeight = FontWeight.Bold, Foreground = Brushes.Cyan }, - _infoDetailBorder, - new TextBlock { Text = Translations.tui_inventory_held_item, FontWeight = FontWeight.Bold, Foreground = Brushes.Yellow, Margin = new Thickness(0, 1, 0, 0) }, - _cursorItemText, - new TextBlock { Text = Translations.tui_inventory_controls, FontWeight = FontWeight.Bold, Foreground = Brushes.Green, Margin = new Thickness(0, 1, 0, 0) }, - _helpText, - } - } - }; - } - - private Control BuildInventoryPanel() - { - var root = new StackPanel - { - Spacing = 0, - HorizontalAlignment = HorizontalAlignment.Center, - }; - - root.Children.Add(BuildTopSection()); - root.Children.Add(new Border { Height = 1 }); - root.Children.Add(BuildSlotGrid(_vm.MainInventorySlots, 9)); - root.Children.Add(BuildHotbarSection()); - - return new Border - { - BorderThickness = new Thickness(1), - BorderBrush = Brushes.Gray, - Child = root, - }; - } - - private Control BuildTopSection() + protected override Control BuildContainerSpecificArea() { var row = new StackPanel { @@ -318,7 +53,7 @@ namespace MinecraftClient.Tui FontWeight = FontWeight.Bold, HorizontalAlignment = HorizontalAlignment.Center, }); - offPanel.Children.Add(CreateSlotCell(_vm.OffhandSlot, 0, 0)); + offPanel.Children.Add(CreateSlotCell(_playerVm.OffhandSlot, 0, 0)); row.Children.Add(offPanel); var equipGrid = new Grid @@ -332,7 +67,7 @@ namespace MinecraftClient.Tui var lbl = MakeLabel(label); Grid.SetRow(lbl, r); Grid.SetColumn(lbl, gc); equipGrid.Children.Add(lbl); - var btn = CreateSlotCell(_vm.EquipmentSlots[eqIdx], r, gc / 2); + var btn = CreateSlotCell(_playerVm.EquipmentSlots[eqIdx], r, gc / 2); Grid.SetRow(btn, r); Grid.SetColumn(btn, gc + 1); equipGrid.Children.Add(btn); } @@ -354,7 +89,7 @@ namespace MinecraftClient.Tui for (int ci = 0; ci < 4; ci++) { int cr = ci / 2, cc = ci % 2; - var cs = CreateSlotCell(_vm.CraftingInputSlots[ci], cr, cc); + var cs = CreateSlotCell(_playerVm.CraftingInputSlots[ci], cr, cc); Grid.SetRow(cs, cr); Grid.SetColumn(cs, cc); craftGrid.Children.Add(cs); @@ -382,7 +117,7 @@ namespace MinecraftClient.Tui FontWeight = FontWeight.Bold, HorizontalAlignment = HorizontalAlignment.Center, }); - craftOutPanel.Children.Add(CreateSlotCell(_vm.CraftingOutputSlot, 0, 1)); + craftOutPanel.Children.Add(CreateSlotCell(_playerVm.CraftingOutputSlot, 0, 1)); Grid.SetRow(craftOutPanel, 0); Grid.SetColumn(craftOutPanel, 3); Grid.SetRowSpan(craftOutPanel, 2); craftGrid.Children.Add(craftOutPanel); @@ -390,363 +125,5 @@ namespace MinecraftClient.Tui row.Children.Add(craftGrid); return row; } - - private Control BuildHotbarSection() - { - var panel = new StackPanel { Spacing = 0 }; - - var numberRow = new StackPanel - { - Orientation = Orientation.Horizontal, - HorizontalAlignment = HorizontalAlignment.Center, - }; - for (int i = 0; i < 9; i++) - { - bool active = i == _currentHotbarSlot; - string label = active ? $"{i + 1} \u25bc" : $" {i + 1} "; - - var tb = new TextBlock - { - Text = label, - Width = _slotW, - TextAlignment = TextAlignment.Center, - Foreground = active ? Brushes.LightGreen : Brushes.DarkCyan, - FontWeight = FontWeight.Bold, - }; - _hotbarIndicators[i] = tb; - numberRow.Children.Add(tb); - } - panel.Children.Add(numberRow); - panel.Children.Add(BuildSlotGrid(_vm.HotbarSlots, 9)); - return panel; - } - - private TextBlock MakeLabel(string text) - { - return new TextBlock - { - Text = text, - Foreground = BrEquipLbl, - VerticalAlignment = VerticalAlignment.Center, - Margin = new Thickness(1, 0, 0, 0), - FontWeight = FontWeight.Bold, - }; - } - - private Control BuildSlotGrid(ObservableCollection slots, int columns) - { - var grid = new Grid(); - int rows = (slots.Count + columns - 1) / columns; - - for (int r = 0; r < rows; r++) - grid.RowDefinitions.Add(new RowDefinition(GridLength.Auto)); - for (int c = 0; c < columns; c++) - grid.ColumnDefinitions.Add(new ColumnDefinition(GridLength.Auto)); - - for (int i = 0; i < slots.Count; i++) - { - int row = i / columns; - int col = i % columns; - var cell = CreateSlotCell(slots[i], row, col); - Grid.SetRow(cell, row); - Grid.SetColumn(cell, col); - grid.Children.Add(cell); - } - - return grid; - } - - private static IBrush GetSlotBg(bool isEmpty, int row, int col) - { - bool isA = (row + col) % 2 == 0; - return isEmpty - ? (isA ? BrSlotEmptyA : BrSlotEmptyB) - : (isA ? BrSlotFillA : BrSlotFillB); - } - - private Border CreateSlotCell(SlotViewModel slot, int row = 0, int col = 0) - { - var nameTb = new TextBlock - { - TextWrapping = TextWrapping.Wrap, - Padding = new Thickness(0), - Margin = new Thickness(0), - VerticalAlignment = VerticalAlignment.Top, - }; - - var countTb = new TextBlock - { - Foreground = BrCount, - FontWeight = FontWeight.Bold, - Padding = new Thickness(0), - Margin = new Thickness(0), - HorizontalAlignment = HorizontalAlignment.Right, - VerticalAlignment = VerticalAlignment.Bottom, - }; - - ApplySlotVisual(slot, nameTb, countTb); - - int r = row, c = col; - var border = new Border - { - Width = _slotW, - Height = _slotH, - Background = GetSlotBg(slot.IsEmpty, r, c), - Child = new Panel - { - Children = { nameTb, countTb }, - }, - Tag = (slot, r, c), - }; - - border.PointerPressed += OnSlotPointerPressed; - border.PointerEntered += OnSlotPointerEnter; - border.PointerExited += OnSlotPointerExit; - border.PointerMoved += OnSlotPointerMoved; - - slot.PropertyChanged += (_, _) => - { - ApplySlotVisual(slot, nameTb, countTb); - border.Background = GetSlotBg(slot.IsEmpty, r, c); - }; - - return border; - } - - private void ApplySlotVisual(SlotViewModel slot, TextBlock nameTb, TextBlock countTb) - { - if (slot.IsEmpty) - { - nameTb.Text = ""; - nameTb.Foreground = BrDim; - countTb.Text = ""; - } - else - { - nameTb.Text = slot.ItemDisplayText; - nameTb.Foreground = BrName; - countTb.Text = slot.CountDisplay; - } - } - - private void OnSlotPointerPressed(object? sender, PointerPressedEventArgs e) - { - if (sender is not Border border || border.Tag is not (SlotViewModel slot, int, int)) - return; - - SetHover(border, slot); - - var point = e.GetCurrentPoint(border); - bool isShift = (e.KeyModifiers & KeyModifiers.Shift) != 0; - - WindowActionType action; - if (point.Properties.IsRightButtonPressed) - action = isShift ? WindowActionType.ShiftRightClick : WindowActionType.RightClick; - else - action = isShift ? WindowActionType.ShiftClick : WindowActionType.LeftClick; - - _vm.PerformAction(slot.SlotId, action); - UpdateInfoPanel(); - UpdateHeldItemFloater(e); - e.Handled = true; - } - - private void OnSlotPointerEnter(object? sender, PointerEventArgs e) - { - if (sender is Border b && b.Tag is (SlotViewModel slot, int, int)) - { - SetHover(b, slot); - UpdateHeldItemFloater(e); - } - } - - private void OnSlotPointerMoved(object? sender, PointerEventArgs e) - { - if (sender is Border b && b.Tag is (SlotViewModel slot, int, int)) - { - SetHover(b, slot); - UpdateHeldItemFloater(e); - } - } - - private void OnSlotPointerExit(object? sender, PointerEventArgs e) - { - if (sender is Border b && b.Tag is (SlotViewModel slot, int row, int col)) - b.Background = GetSlotBg(slot.IsEmpty, row, col); - } - - private void SetHover(Border border, SlotViewModel slot) - { - if (_lastHoveredSlotBorder != null && _lastHoveredSlotBorder != border) - { - if (_lastHoveredSlotBorder.Tag is (SlotViewModel oldSlot, int or, int oc)) - _lastHoveredSlotBorder.Background = GetSlotBg(oldSlot.IsEmpty, or, oc); - } - - _lastHoveredSlotBorder = border; - border.Background = BrSlotHover; - _vm.HoveredSlot = slot; - UpdateInfoPanel(); - } - - private void UpdateHeldItemFloater(PointerEventArgs e) - { - if (!_vm.HasCursorItem) - { - _heldItemFloater.IsVisible = false; - return; - } - - _heldItemFloaterName.Text = _vm.CursorItemInfo; - _heldItemFloaterCount.Text = ""; - - try - { - var pos = e.GetPosition(_overlayCanvas); - double left = pos.X + 2; - double remainingW = _termW - left - 2; - int maxW = Math.Max(8, (int)remainingW); - _heldItemFloater.MaxWidth = maxW; - Canvas.SetLeft(_heldItemFloater, left); - Canvas.SetTop(_heldItemFloater, pos.Y); - } - catch - { - _heldItemFloater.MaxWidth = 24; - Canvas.SetLeft(_heldItemFloater, 0); - Canvas.SetTop(_heldItemFloater, 0); - } - - _heldItemFloater.IsVisible = true; - } - - private void UpdateInfoPanel() - { - _infoDetailText.Text = _vm.HoveredSlotDetailText; - - bool hasHoveredItem = _vm.HoveredSlot != null && !_vm.HoveredSlot.IsEmpty; - _infoDetailBorder.Background = hasHoveredItem ? BrInfoHighlight : Brushes.Transparent; - - if (_vm.HasCursorItem) - { - _cursorItemText.Text = _vm.CursorItemInfo; - _cursorItemText.Foreground = Brushes.Yellow; - } - else - { - _cursorItemText.Text = Translations.tui_inventory_cursor_empty; - _cursorItemText.Foreground = BrDim; - _heldItemFloater.IsVisible = false; - } - } - - private void UpdateTitle() - { - _titleText.Text = _vm.Title; - } - - private void CloseInventory() - { - if (ConsoleIO.Backend is TuiConsoleBackend tuiBackend) - tuiBackend.GetView()?.HideOverlay(); - else - (Application.Current?.ApplicationLifetime as IControlledApplicationLifetime)?.Shutdown(); - } - - protected override void OnKeyDown(KeyEventArgs e) - { - base.OnKeyDown(e); - - switch (e.Key) - { - case Key.Escape: - case Key.E: - CloseInventory(); - e.Handled = true; - break; - - case Key.C: - if ((e.KeyModifiers & KeyModifiers.Shift) != 0 && - _vm.HoveredSlot != null && !_vm.HoveredSlot.IsEmpty) - { - _vm.PerformAction(_vm.HoveredSlot.SlotId, WindowActionType.ShiftClick); - UpdateInfoPanel(); - } - e.Handled = true; - break; - - case Key.Q: - if (_vm.HoveredSlot != null && !_vm.HoveredSlot.IsEmpty) - { - var action = (e.KeyModifiers & KeyModifiers.Control) != 0 - ? WindowActionType.DropItemStack - : WindowActionType.DropItem; - _vm.PerformAction(_vm.HoveredSlot.SlotId, action); - UpdateInfoPanel(); - } - e.Handled = true; - break; - - case Key.R: - _vm.RefreshFromContainer(); - _currentHotbarSlot = _vm.Handler.GetCurrentSlot(); - UpdateHotbarIndicators(); - UpdateInfoPanel(); - e.Handled = true; - break; - } - } - - private void UpdateHotbarIndicators() - { - for (int i = 0; i < 9; i++) - { - bool active = i == _currentHotbarSlot; - _hotbarIndicators[i].Text = active ? $"{i + 1} \u25bc" : $" {i + 1} "; - _hotbarIndicators[i].Foreground = active ? Brushes.LightGreen : Brushes.DarkCyan; - } - } - - protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) - { - base.OnAttachedToVisualTree(e); - Focusable = true; - Focus(); - AddHandler(KeyDownEvent, OnTunnelKeyDown, Avalonia.Interactivity.RoutingStrategies.Tunnel); - SizeChanged += OnViewSizeChanged; - } - - private void OnTunnelKeyDown(object? sender, KeyEventArgs e) - { - if (e.Key == Key.Escape) - { - CloseInventory(); - e.Handled = true; - } - } - - private void OnViewSizeChanged(object? sender, SizeChangedEventArgs e) - { - int newW, newH; - try - { - newW = System.Console.WindowWidth; - newH = System.Console.WindowHeight; - } - catch { return; } - - if (newW == _lastTermW && newH == _lastTermH) return; - - _vm.RefreshFromContainer(); - _currentHotbarSlot = _vm.Handler.GetCurrentSlot(); - RebuildUi(); - Focus(); - } - - protected override void OnGotFocus(GotFocusEventArgs e) - { - base.OnGotFocus(e); - Focusable = true; - } } } diff --git a/MinecraftClient/Tui/InventoryTuiHost.cs b/MinecraftClient/Tui/InventoryTuiHost.cs index c67c1892..f740ffd4 100644 --- a/MinecraftClient/Tui/InventoryTuiHost.cs +++ b/MinecraftClient/Tui/InventoryTuiHost.cs @@ -22,6 +22,30 @@ namespace MinecraftClient.Tui public static bool IsRunning => _isRunning; + /// + /// Called by McClient.OnInventoryClose when the server closes a container. + /// If the closed window matches the active TUI window, auto-close the TUI. + /// + public static void NotifyInventoryClosed(int windowId) + { + if (!_isRunning || windowId != ActiveWindowId) + return; + + if (ConsoleIO.Backend is TuiConsoleBackend) + { + Dispatcher.UIThread.Post(() => + { + var view = TuiConsoleBackend.Instance?.GetView(); + view?.HideOverlay(); + }); + } + else + { + (Avalonia.Application.Current?.ApplicationLifetime + as Avalonia.Controls.ApplicationLifetimes.IControlledApplicationLifetime)?.Shutdown(); + } + } + /// /// Whether the TUI can be launched (classic mode has a one-shot limit). /// @@ -89,7 +113,10 @@ namespace MinecraftClient.Tui var view = TuiConsoleBackend.Instance?.GetView(); if (view != null) { - var content = new InventoryMainView(); + var container = ActiveHandler!.GetInventory(ActiveWindowId); + var content = ContainerViewBase.CreateView( + container?.Type ?? ContainerType.PlayerInventory, + ActiveHandler, ActiveWindowId); view.ShowOverlay(content, () => { ActiveHandler = null; diff --git a/MinecraftClient/Tui/InventoryViewModel.cs b/MinecraftClient/Tui/InventoryViewModel.cs index bfb383b2..3b29928f 100644 --- a/MinecraftClient/Tui/InventoryViewModel.cs +++ b/MinecraftClient/Tui/InventoryViewModel.cs @@ -1,152 +1,48 @@ -using System; -using System.Collections.Generic; using System.Collections.ObjectModel; -using System.ComponentModel; -using System.Runtime.CompilerServices; -using System.Text; using MinecraftClient.Inventory; namespace MinecraftClient.Tui { - public class InventoryViewModel : INotifyPropertyChanged + public class PlayerInventoryViewModel : ContainerViewModel { - private SlotViewModel? _hoveredSlot; - private string _title = ""; - private string _statusText = ""; - private string _cursorItemInfo = ""; - private bool _hasCursorItem; - - public McClient Handler { get; } - public int WindowId { get; } - public ObservableCollection EquipmentSlots { get; } = new(); public ObservableCollection CraftingInputSlots { get; } = new(); public SlotViewModel CraftingOutputSlot { get; } - public ObservableCollection MainInventorySlots { get; } = new(); - public ObservableCollection HotbarSlots { get; } = new(); public SlotViewModel OffhandSlot { get; } - public string Title + public PlayerInventoryViewModel(McClient handler, int windowId) + : base(handler, windowId, ContainerType.PlayerInventory) { - get => _title; - set { _title = value; OnPropertyChanged(); } + CraftingOutputSlot = SlotMap[0]; + OffhandSlot = SlotMap[45]; } - public string StatusText + protected override void InitializeSlots() { - get => _statusText; - set { _statusText = value; OnPropertyChanged(); } - } + SlotMap.Clear(); - public string CursorItemInfo - { - get => _cursorItemInfo; - set { _cursorItemInfo = value; OnPropertyChanged(); } - } - - public bool HasCursorItem - { - get => _hasCursorItem; - set { _hasCursorItem = value; OnPropertyChanged(); } - } - - public SlotViewModel? HoveredSlot - { - get => _hoveredSlot; - set - { - if (_hoveredSlot != null) - _hoveredSlot.IsHovered = false; - _hoveredSlot = value; - if (_hoveredSlot != null) - _hoveredSlot.IsHovered = true; - OnPropertyChanged(); - OnPropertyChanged(nameof(HoveredSlotDetailText)); - } - } - - /// - /// Multi-line detail text for the hovered slot. - /// - public string HoveredSlotDetailText - { - get - { - if (_hoveredSlot == null) - return Translations.tui_inventory_hover_hint; - - if (_hoveredSlot.IsEmpty) - return $"Slot #{_hoveredSlot.SlotId}\n{Translations.tui_inventory_slot_empty}"; - - var sb = new StringBuilder(); - sb.AppendLine(_hoveredSlot.ItemTypeName); - sb.AppendLine(string.Format(Translations.tui_inventory_slot_detail, _hoveredSlot.SlotId, _hoveredSlot.ItemCount)); - - string fullInfo = _hoveredSlot.FullInfo; - if (!string.IsNullOrEmpty(fullInfo)) - { - string[] parts = fullInfo.Split(" | "); - for (int i = 1; i < parts.Length; i++) - sb.AppendLine(parts[i].Trim()); - } - - return sb.ToString().TrimEnd(); - } - } - - private Dictionary _slotMap = new(); - private int _nameMaxLen = 9; - private int _nameMaxLines = 1; - - public InventoryViewModel(McClient handler, int windowId) - { - Handler = handler; - WindowId = windowId; - - CraftingOutputSlot = new SlotViewModel(0); - OffhandSlot = new SlotViewModel(45); - - InitializeSlots(); - RefreshFromContainer(); - } - - public void SetSlotDisplayParams(int maxWidth, int maxLines) - { - _nameMaxLen = maxWidth; - _nameMaxLines = maxLines; - foreach (var kvp in _slotMap) - { - kvp.Value.NameMaxWidth = maxWidth; - kvp.Value.NameMaxLines = maxLines; - } - RefreshFromContainer(); - } - - private void InitializeSlots() - { - _slotMap.Clear(); - - _slotMap[0] = CraftingOutputSlot; + var craftOut = new SlotViewModel(0); + SlotMap[0] = craftOut; for (int i = 1; i <= 4; i++) { var slot = new SlotViewModel(i); CraftingInputSlots.Add(slot); - _slotMap[i] = slot; + SlotMap[i] = slot; } for (int i = 5; i <= 8; i++) { var slot = new SlotViewModel(i); EquipmentSlots.Add(slot); - _slotMap[i] = slot; + SlotMap[i] = slot; } for (int i = 9; i <= 35; i++) { var slot = new SlotViewModel(i); MainInventorySlots.Add(slot); - _slotMap[i] = slot; + SlotMap[i] = slot; } for (int i = 36; i <= 44; i++) @@ -154,67 +50,11 @@ namespace MinecraftClient.Tui int hotbarIdx = i - 36; var slot = new SlotViewModel(i, isHotbar: true, hotbarIndex: hotbarIdx); HotbarSlots.Add(slot); - _slotMap[i] = slot; + SlotMap[i] = slot; } - _slotMap[45] = OffhandSlot; - } - - public void RefreshFromContainer() - { - Inventory.Container? container = Handler.GetInventory(WindowId); - if (container == null) - { - StatusText = Translations.tui_inventory_container_not_found; - return; - } - - Title = string.Format(Translations.tui_inventory_title, WindowId, container.Title); - - foreach (var kvp in _slotMap) - { - Item? item = container.Items.TryGetValue(kvp.Key, out var it) ? it : null; - kvp.Value.Update(item); - } - - UpdateCursorItem(container); - int itemCount = 0; - foreach (var kvp in container.Items) - { - if (kvp.Key >= 0 && !kvp.Value.IsEmpty) - itemCount++; - } - StatusText = string.Format(Translations.tui_inventory_item_count, itemCount); - - OnPropertyChanged(nameof(HoveredSlotDetailText)); - } - - private void UpdateCursorItem(Inventory.Container container) - { - if (container.Items.TryGetValue(-1, out var cursorItem) && !cursorItem.IsEmpty) - { - CursorItemInfo = $"x{cursorItem.Count} {cursorItem.GetTypeString()}"; - HasCursorItem = true; - } - else - { - CursorItemInfo = ""; - HasCursorItem = false; - } - } - - public bool PerformAction(int slotId, WindowActionType action) - { - bool result = Handler.DoWindowAction(WindowId, slotId, action); - RefreshFromContainer(); - return result; - } - - public event PropertyChangedEventHandler? PropertyChanged; - - private void OnPropertyChanged([CallerMemberName] string? name = null) - { - PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name)); + var offhand = new SlotViewModel(45); + SlotMap[45] = offhand; } } }