From 6631180f8a1ef0b901b8554daf1fc4cf64f6cdb3 Mon Sep 17 00:00:00 2001 From: BruceChen Date: Sun, 29 Mar 2026 18:47:40 +0800 Subject: [PATCH 1/4] Minimap support --- .skills/mcc-version-adaptation/SKILL.md | 39 +- MinecraftClient/Commands/Minimap.cs | 256 ++ MinecraftClient/MinecraftClient.csproj | 2 + .../Protocol/Handlers/DataTypes.cs | 4 +- .../ConfigComments/ConfigComments.resx | 33 + .../Translations/Translations.Designer.cs | 153 + .../Resources/Translations/Translations.resx | 51 + MinecraftClient/Settings.cs | 47 + MinecraftClient/Tui/MainTuiView.cs | 125 +- MinecraftClient/Tui/MinimapBlockColors.json | 3552 +++++++++++++++++ MinecraftClient/Tui/MinimapColorMap.cs | 168 + MinecraftClient/Tui/MinimapControl.cs | 677 ++++ .../Tui/MinimapEntityCategories.json | 167 + .../Tui/MinimapEntityClassifier.cs | 178 + tools/README.md | 47 +- tools/gen_block_color_map.py | 268 ++ tools/gen_entity_category_map.py | 200 + 17 files changed, 5961 insertions(+), 6 deletions(-) create mode 100644 MinecraftClient/Commands/Minimap.cs create mode 100644 MinecraftClient/Tui/MinimapBlockColors.json create mode 100644 MinecraftClient/Tui/MinimapColorMap.cs create mode 100644 MinecraftClient/Tui/MinimapControl.cs create mode 100644 MinecraftClient/Tui/MinimapEntityCategories.json create mode 100644 MinecraftClient/Tui/MinimapEntityClassifier.cs create mode 100644 tools/gen_block_color_map.py create mode 100644 tools/gen_entity_category_map.py diff --git a/.skills/mcc-version-adaptation/SKILL.md b/.skills/mcc-version-adaptation/SKILL.md index ce21cf04..195b5592 100644 --- a/.skills/mcc-version-adaptation/SKILL.md +++ b/.skills/mcc-version-adaptation/SKILL.md @@ -215,7 +215,42 @@ The JSON maps block names (snake_case) → collision shape IDs → AABB coordina **Data source**: PrismarineJS `minecraft-data` repo, path: `data/pc//blockCollisionShapes.json`. Version availability can be checked via `data/dataPaths.json`. -## Step 9: Compile and Verify +## Step 9: Update Minimap Block Color Map + +Regenerate the block-to-MapColor mapping used by the TUI minimap. This maps each block's `Material` enum to the RGB color from Minecraft's official `MapColor` table. + +```bash +python3 $MCC_REPO/tools/gen_block_color_map.py $MCC_REPO/MinecraftOfficial/-decompiled +# e.g. python3 tools/gen_block_color_map.py MinecraftOfficial/26.1-rc-2-decompiled +``` + +Output: `MinecraftClient/Tui/MinimapBlockColors.json` (embedded as a resource via `.csproj`). + +The script parses `MapColor.java`, `DyeColor.java`, and `Blocks.java` from the decompiled source to extract each block's assigned map color. Blocks not matched to a known `Material` enum value are skipped. + +**When to update**: Whenever new blocks are added or existing blocks change their `mapColor()` assignment. If only items or entities changed, this step can be skipped. + +## Step 10: Update Minimap Entity Categories + +Regenerate the entity-to-MobCategory mapping used by the TUI minimap for classifying entities as hostile, passive, neutral, or non-living. + +```bash +python3 $MCC_REPO/tools/gen_entity_category_map.py $MCC_REPO/MinecraftOfficial/-decompiled +# e.g. python3 tools/gen_entity_category_map.py MinecraftOfficial/26.1-rc-2-decompiled +``` + +Output: `MinecraftClient/Tui/MinimapEntityCategories.json` (embedded as a resource via `.csproj`). + +The script parses `EntityType.java` to extract each entity's `MobCategory` assignment, then maps Minecraft's categories to MCC minimap categories: +- `MONSTER` -> hostile (with neutral overrides for conditionally hostile mobs like Enderman, Spider, Wolf) +- `CREATURE`/`AMBIENT`/`AXOLOTLS`/`WATER_*` -> passive +- `MISC` -> non_living (with passive overrides for Villager, WanderingTrader, ZombieHorse) + +The script maintains manual override lists for "neutral" mobs (attack only when provoked) since Minecraft has no machine-readable flag for this behavior. Review and update the `NEUTRAL_OVERRIDES` and `PASSIVE_OVERRIDES` sets in the script when new conditionally-hostile or misclassified mobs are added. + +**When to update**: Whenever new entity types are added. If only blocks or items changed, this step can be skipped. + +## Step 11: Compile and Verify ```bash dotnet build $MCC_REPO/MinecraftClient.sln -c Release @@ -274,3 +309,5 @@ All scripts are in `$MCC_REPO/tools/`. See `tools/README.md` for detailed usage. | `gen_entity_palette.py` | Generate EntityPalette C# | registries.json | | `gen_entity_metadata_palette.py` | Generate EntityMetadataPalette C# | Decompiled source | | `gen_block_shapes.py` | Download & compact block collision shapes | PrismarineJS minecraft-data | +| `gen_block_color_map.py` | Generate minimap block color JSON | Decompiled source (MapColor/DyeColor/Blocks) | +| `gen_entity_category_map.py` | Generate minimap entity category JSON | Decompiled source (EntityType.java) | diff --git a/MinecraftClient/Commands/Minimap.cs b/MinecraftClient/Commands/Minimap.cs new file mode 100644 index 00000000..897c88ae --- /dev/null +++ b/MinecraftClient/Commands/Minimap.cs @@ -0,0 +1,256 @@ +using System; +using Brigadier.NET; +using Brigadier.NET.Builder; +using MinecraftClient.CommandHandler; +using MinecraftClient.Tui; +using Avalonia.Threading; +using static MinecraftClient.CommandHandler.CmdResult; + +namespace MinecraftClient.Commands +{ + class Minimap : Command + { + public override string CmdName => "minimap"; + public override string CmdUsage => "minimap [on|off] | minimap zoom [in|out|<1-16>] | minimap names [players|hostile|neutral|passive] [on|off] | minimap names [all_on|all_off] | minimap position [top_left|top_right|center|bottom_left|bottom_right]"; + public override string CmdDesc => Translations.cmd_minimap_desc; + + public override void RegisterCommand(CommandDispatcher dispatcher) + { + dispatcher.Register(l => l.Literal("help") + .Then(l => l.Literal(CmdName) + .Executes(r => GetUsage(r.Source, string.Empty)) + ) + ); + + dispatcher.Register(l => l.Literal(CmdName) + .Executes(r => DoToggle(r.Source)) + .Then(l => l.Literal("on") + .Executes(r => DoOn(r.Source))) + .Then(l => l.Literal("off") + .Executes(r => DoOff(r.Source))) + .Then(l => l.Literal("zoom") + .Executes(r => DoZoomInfo(r.Source)) + .Then(l => l.Literal("in") + .Executes(r => DoZoomIn(r.Source))) + .Then(l => l.Literal("out") + .Executes(r => DoZoomOut(r.Source))) + .Then(l => l.Argument("level", Arguments.Integer(MinimapControl.MinZoom, MinimapControl.MaxZoom)) + .Executes(r => DoZoomSet(r.Source, Arguments.GetInteger(r, "level"))))) + .Then(l => l.Literal("names") + .Executes(r => DoNamesInfo(r.Source)) + .Then(l => l.Literal("all_on") + .Executes(r => DoNamesAll(r.Source, true))) + .Then(l => l.Literal("all_off") + .Executes(r => DoNamesAll(r.Source, false))) + .Then(l => l.Literal("players") + .Executes(r => DoNamesCatInfo(r.Source, MobCategory.Player)) + .Then(l => l.Literal("on") + .Executes(r => DoNamesCatSet(r.Source, MobCategory.Player, true))) + .Then(l => l.Literal("off") + .Executes(r => DoNamesCatSet(r.Source, MobCategory.Player, false)))) + .Then(l => l.Literal("hostile") + .Executes(r => DoNamesCatInfo(r.Source, MobCategory.Hostile)) + .Then(l => l.Literal("on") + .Executes(r => DoNamesCatSet(r.Source, MobCategory.Hostile, true))) + .Then(l => l.Literal("off") + .Executes(r => DoNamesCatSet(r.Source, MobCategory.Hostile, false)))) + .Then(l => l.Literal("neutral") + .Executes(r => DoNamesCatInfo(r.Source, MobCategory.Neutral)) + .Then(l => l.Literal("on") + .Executes(r => DoNamesCatSet(r.Source, MobCategory.Neutral, true))) + .Then(l => l.Literal("off") + .Executes(r => DoNamesCatSet(r.Source, MobCategory.Neutral, false)))) + .Then(l => l.Literal("passive") + .Executes(r => DoNamesCatInfo(r.Source, MobCategory.Passive)) + .Then(l => l.Literal("on") + .Executes(r => DoNamesCatSet(r.Source, MobCategory.Passive, true))) + .Then(l => l.Literal("off") + .Executes(r => DoNamesCatSet(r.Source, MobCategory.Passive, false))))) + .Then(l => l.Literal("position") + .Executes(r => DoPositionInfo(r.Source)) + .Then(l => l.Literal("top_left") + .Executes(r => DoPositionSet(r.Source, MinimapPosition.top_left))) + .Then(l => l.Literal("top_right") + .Executes(r => DoPositionSet(r.Source, MinimapPosition.top_right))) + .Then(l => l.Literal("center") + .Executes(r => DoPositionSet(r.Source, MinimapPosition.center))) + .Then(l => l.Literal("bottom_left") + .Executes(r => DoPositionSet(r.Source, MinimapPosition.bottom_left))) + .Then(l => l.Literal("bottom_right") + .Executes(r => DoPositionSet(r.Source, MinimapPosition.bottom_right)))) + .Then(l => l.Literal("_help") + .Executes(r => GetUsage(r.Source, string.Empty)) + .Redirect(dispatcher.GetRoot().GetChild("help")?.GetChild(CmdName))) + ); + } + + private int GetUsage(CmdResult r, string _) => + r.SetAndReturn(GetCmdDescTranslated()); + + private static MainTuiView? GetTuiView(CmdResult r) + { + if (ConsoleIO.Backend is not TuiConsoleBackend) + { + r.SetAndReturn(Status.Fail, Translations.cmd_minimap_tui_only); + return null; + } + return TuiConsoleBackend.Instance?.GetView(); + } + + private static int DoToggle(CmdResult r) + { + var view = GetTuiView(r); + if (view is null) return (int)r.status; + + bool wasVisible = view.IsMinimapVisible; + Dispatcher.UIThread.Post(() => view.ToggleMinimap()); + string msg = wasVisible + ? Translations.cmd_minimap_disabled + : Translations.cmd_minimap_enabled; + return r.SetAndReturn(Status.Done, msg); + } + + private static int DoOn(CmdResult r) + { + var view = GetTuiView(r); + if (view is null) return (int)r.status; + + Dispatcher.UIThread.Post(() => view.ShowMinimap()); + return r.SetAndReturn(Status.Done, Translations.cmd_minimap_enabled); + } + + private static int DoOff(CmdResult r) + { + var view = GetTuiView(r); + if (view is null) return (int)r.status; + + Dispatcher.UIThread.Post(() => view.HideMinimap()); + return r.SetAndReturn(Status.Done, Translations.cmd_minimap_disabled); + } + + private static int DoZoomInfo(CmdResult r) + { + var view = GetTuiView(r); + if (view is null) return (int)r.status; + + int current = view.GetMinimapZoom(); + return r.SetAndReturn(Status.Done, + string.Format(Translations.cmd_minimap_zoom_current, current, MinimapControl.MaxZoom)); + } + + private static int DoZoomIn(CmdResult r) + { + var view = GetTuiView(r); + if (view is null) return (int)r.status; + + int newLevel = Math.Max(view.GetMinimapZoom() - 1, MinimapControl.MinZoom); + Dispatcher.UIThread.Post(() => view.SetMinimapZoom(newLevel)); + return r.SetAndReturn(Status.Done, string.Format(Translations.cmd_minimap_zoom_set, newLevel)); + } + + private static int DoZoomOut(CmdResult r) + { + var view = GetTuiView(r); + if (view is null) return (int)r.status; + + int newLevel = Math.Min(view.GetMinimapZoom() + 1, MinimapControl.MaxZoom); + Dispatcher.UIThread.Post(() => view.SetMinimapZoom(newLevel)); + return r.SetAndReturn(Status.Done, string.Format(Translations.cmd_minimap_zoom_set, newLevel)); + } + + private static int DoZoomSet(CmdResult r, int level) + { + var view = GetTuiView(r); + if (view is null) return (int)r.status; + + Dispatcher.UIThread.Post(() => view.SetMinimapZoom(level)); + return r.SetAndReturn(Status.Done, string.Format(Translations.cmd_minimap_zoom_set, level)); + } + + private static int DoNamesInfo(CmdResult r) + { + var view = GetTuiView(r); + if (view is null) return (int)r.status; + + var nc = view.GetMinimapNameConfig(); + string status = string.Format(Translations.cmd_minimap_names_status, + BoolStr(nc.Players), BoolStr(nc.Hostile), BoolStr(nc.Neutral), BoolStr(nc.Passive)); + return r.SetAndReturn(Status.Done, status); + } + + private static int DoNamesAll(CmdResult r, bool on) + { + var view = GetTuiView(r); + if (view is null) return (int)r.status; + + Dispatcher.UIThread.Post(() => + { + view.GetMinimapNameConfig().SetAll(on); + view.SyncMinimapNameConfig(); + }); + string msg = on ? Translations.cmd_minimap_names_all_on : Translations.cmd_minimap_names_all_off; + return r.SetAndReturn(Status.Done, msg); + } + + private static int DoNamesCatInfo(CmdResult r, MobCategory cat) + { + var view = GetTuiView(r); + if (view is null) return (int)r.status; + + var nc = view.GetMinimapNameConfig(); + bool val = cat switch + { + MobCategory.Player => nc.Players, + MobCategory.Hostile => nc.Hostile, + MobCategory.Neutral => nc.Neutral, + MobCategory.Passive => nc.Passive, + _ => false, + }; + return r.SetAndReturn(Status.Done, + string.Format(Translations.cmd_minimap_names_cat, cat, BoolStr(val))); + } + + private static int DoNamesCatSet(CmdResult r, MobCategory cat, bool on) + { + var view = GetTuiView(r); + if (view is null) return (int)r.status; + + Dispatcher.UIThread.Post(() => + { + var nc = view.GetMinimapNameConfig(); + switch (cat) + { + case MobCategory.Player: nc.Players = on; break; + case MobCategory.Hostile: nc.Hostile = on; break; + case MobCategory.Neutral: nc.Neutral = on; break; + case MobCategory.Passive: nc.Passive = on; break; + } + view.SyncMinimapNameConfig(); + }); + return r.SetAndReturn(Status.Done, + string.Format(Translations.cmd_minimap_names_cat_set, cat, BoolStr(on))); + } + + private static int DoPositionInfo(CmdResult r) + { + var view = GetTuiView(r); + if (view is null) return (int)r.status; + + var pos = view.GetMinimapPosition(); + return r.SetAndReturn(Status.Done, + string.Format(Translations.cmd_minimap_position_current, pos)); + } + + private static int DoPositionSet(CmdResult r, MinimapPosition pos) + { + var view = GetTuiView(r); + if (view is null) return (int)r.status; + + Dispatcher.UIThread.Post(() => view.SetMinimapPosition(pos)); + return r.SetAndReturn(Status.Done, + string.Format(Translations.cmd_minimap_position_set, pos)); + } + + private static string BoolStr(bool v) => v ? "ON" : "OFF"; + } +} diff --git a/MinecraftClient/MinecraftClient.csproj b/MinecraftClient/MinecraftClient.csproj index 5df50933..372d153a 100644 --- a/MinecraftClient/MinecraftClient.csproj +++ b/MinecraftClient/MinecraftClient.csproj @@ -20,6 +20,8 @@ + + diff --git a/MinecraftClient/Protocol/Handlers/DataTypes.cs b/MinecraftClient/Protocol/Handlers/DataTypes.cs index 78456c2d..669ba5b3 100644 --- a/MinecraftClient/Protocol/Handlers/DataTypes.cs +++ b/MinecraftClient/Protocol/Handlers/DataTypes.cs @@ -664,8 +664,10 @@ namespace MinecraftClient.Protocol.Handlers } } - return new Entity(entityID, entityType, new Location(entityX, entityY, entityZ), entityYaw, entityPitch, + var entity = new Entity(entityID, entityType, new Location(entityX, entityY, entityZ), entityYaw, entityPitch, data); + entity.UUID = entityUUID; + return entity; } /// diff --git a/MinecraftClient/Resources/ConfigComments/ConfigComments.resx b/MinecraftClient/Resources/ConfigComments/ConfigComments.resx index 6d91740b..d4b82e42 100644 --- a/MinecraftClient/Resources/ConfigComments/ConfigComments.resx +++ b/MinecraftClient/Resources/ConfigComments/ConfigComments.resx @@ -933,6 +933,39 @@ Note: This does NOT require a Bot Token, only an Application ID. Discord must be Set to false to opt-out of Sentry error logging. + + Settings for the TUI minimap overlay that shows terrain and entities. + + + Whether the minimap is visible on startup in TUI mode. + + + Blocks per pixel, 1-16. 1 = closest (1:1), 16 = farthest (16 blocks per pixel). + + + Map width in pixels (characters). Range 10-120, default 40. + + + Map height in pixels (must be even, uses half-block chars). Range 4-80, default 40. + + + Minimap position: "top_left", "top_right", "center", "bottom_left", or "bottom_right". + + + Show player names on the minimap. + + + Show hostile mob names on the minimap. + + + Show neutral mob names on the minimap. + + + Show passive mob names on the minimap. + + + Minimap refresh interval in milliseconds (200-5000, default 1000). + Yggdrasil authlib multi-user selection. diff --git a/MinecraftClient/Resources/Translations/Translations.Designer.cs b/MinecraftClient/Resources/Translations/Translations.Designer.cs index 96419311..071bee44 100644 --- a/MinecraftClient/Resources/Translations/Translations.Designer.cs +++ b/MinecraftClient/Resources/Translations/Translations.Designer.cs @@ -6880,5 +6880,158 @@ namespace MinecraftClient { return ResourceManager.GetString("tui.crafting.grid", resourceCulture); } } + + /// + /// Looks up a localized string similar to Toggle the TUI minimap overlay, or adjust its zoom level.. + /// + internal static string cmd_minimap_desc { + get { + return ResourceManager.GetString("cmd.minimap.desc", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Minimap enabled.. + /// + internal static string cmd_minimap_enabled { + get { + return ResourceManager.GetString("cmd.minimap.enabled", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Minimap disabled.. + /// + internal static string cmd_minimap_disabled { + get { + return ResourceManager.GetString("cmd.minimap.disabled", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Minimap zoom set to {0}:1 (blocks per pixel).. + /// + internal static string cmd_minimap_zoom_set { + get { + return ResourceManager.GetString("cmd.minimap.zoom_set", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Current minimap zoom: {0}:1 blocks/px (range 1-{1}).. + /// + internal static string cmd_minimap_zoom_current { + get { + return ResourceManager.GetString("cmd.minimap.zoom_current", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The minimap command is only available in TUI mode.. + /// + internal static string cmd_minimap_tui_only { + get { + return ResourceManager.GetString("cmd.minimap.tui_only", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Hostile. + /// + internal static string tui_minimap_legend_hostile { + get { + return ResourceManager.GetString("tui.minimap.legend.hostile", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Passive. + /// + internal static string tui_minimap_legend_passive { + get { + return ResourceManager.GetString("tui.minimap.legend.passive", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Neutral. + /// + internal static string tui_minimap_legend_neutral { + get { + return ResourceManager.GetString("tui.minimap.legend.neutral", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Player. + /// + internal static string tui_minimap_legend_player { + get { + return ResourceManager.GetString("tui.minimap.legend.player", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Name display -- Players: {0}, Hostile: {1}, Neutral: {2}, Passive: {3}. + /// + internal static string cmd_minimap_names_status { + get { + return ResourceManager.GetString("cmd.minimap.names_status", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to All entity name labels enabled.. + /// + internal static string cmd_minimap_names_all_on { + get { + return ResourceManager.GetString("cmd.minimap.names_all_on", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to All entity name labels disabled.. + /// + internal static string cmd_minimap_names_all_off { + get { + return ResourceManager.GetString("cmd.minimap.names_all_off", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to {0} name display: {1}. + /// + internal static string cmd_minimap_names_cat { + get { + return ResourceManager.GetString("cmd.minimap.names_cat", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to {0} name display set to {1}.. + /// + internal static string cmd_minimap_names_cat_set { + get { + return ResourceManager.GetString("cmd.minimap.names_cat_set", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Current minimap position: {0}. + /// + internal static string cmd_minimap_position_current { + get { + return ResourceManager.GetString("cmd.minimap.position_current", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Minimap position set to: {0}. + /// + internal static string cmd_minimap_position_set { + get { + return ResourceManager.GetString("cmd.minimap.position_set", resourceCulture); + } + } } } diff --git a/MinecraftClient/Resources/Translations/Translations.resx b/MinecraftClient/Resources/Translations/Translations.resx index c3ecd406..ad782375 100644 --- a/MinecraftClient/Resources/Translations/Translations.resx +++ b/MinecraftClient/Resources/Translations/Translations.resx @@ -2419,4 +2419,55 @@ see item details. Crafting + + Toggle the TUI minimap overlay, or adjust its zoom level. + + + Minimap enabled. + + + Minimap disabled. + + + Minimap zoom set to {0}:1 (blocks per pixel). + + + Current minimap zoom: {0}:1 blocks/px (range 1-{1}). + + + The minimap command is only available in TUI mode. + + + Hostile + + + Passive + + + Neutral + + + Player + + + Name display -- Players: {0}, Hostile: {1}, Neutral: {2}, Passive: {3} + + + All entity name labels enabled. + + + All entity name labels disabled. + + + {0} name display: {1} + + + {0} name display set to {1}. + + + Current minimap position: {0} + + + Minimap position set to: {0} + diff --git a/MinecraftClient/Settings.cs b/MinecraftClient/Settings.cs index e77514da..299fe990 100644 --- a/MinecraftClient/Settings.cs +++ b/MinecraftClient/Settings.cs @@ -1108,6 +1108,9 @@ namespace MinecraftClient [TomlPrecedingComment("$Console.CommandSuggestion$")] public CommandSuggestionConfig CommandSuggestion = new(); + [TomlPrecedingComment("$Console.Minimap$")] + public MinimapConfig Minimap = new(); + public void OnSettingUpdate() { var backend = ConsoleIO.Backend; @@ -1246,6 +1249,50 @@ namespace MinecraftClient public enum ConsoleModeType { classic, tui }; public enum ConsoleColorModeType { disable, legacy_4bit, vt100_4bit, vt100_8bit, vt100_24bit }; + + [TomlDoNotInlineObject] + public class MinimapConfig + { + [TomlInlineComment("$Console.Minimap.Enabled$")] + public bool Enabled = false; + + [TomlInlineComment("$Console.Minimap.Zoom$")] + public int Zoom = Tui.MinimapControl.DefaultZoom; + + [TomlInlineComment("$Console.Minimap.Width$")] + public int Width = Tui.MinimapControl.DefaultWidth; + + [TomlInlineComment("$Console.Minimap.Height$")] + public int Height = Tui.MinimapControl.DefaultHeight; + + [TomlInlineComment("$Console.Minimap.Position$")] + public Tui.MinimapPosition Position = Tui.MinimapPosition.top_right; + + [TomlInlineComment("$Console.Minimap.ShowPlayerNames$")] + public bool ShowPlayerNames = false; + + [TomlInlineComment("$Console.Minimap.ShowHostileNames$")] + public bool ShowHostileNames = false; + + [TomlInlineComment("$Console.Minimap.ShowNeutralNames$")] + public bool ShowNeutralNames = false; + + [TomlInlineComment("$Console.Minimap.ShowPassiveNames$")] + public bool ShowPassiveNames = false; + + [TomlInlineComment("$Console.Minimap.RefreshInterval$")] + public int RefreshInterval = Tui.MinimapControl.DefaultRefreshMs; + + public void OnSettingUpdate() + { + Zoom = Math.Clamp(Zoom, Tui.MinimapControl.MinZoom, Tui.MinimapControl.MaxZoom); + Width = Math.Clamp(Width, 10, 120); + Height = Math.Clamp(Height, 4, 80); + if (Height % 2 != 0) Height++; + RefreshInterval = Math.Clamp(RefreshInterval, + Tui.MinimapControl.MinRefreshMs, Tui.MinimapControl.MaxRefreshMs); + } + } } } diff --git a/MinecraftClient/Tui/MainTuiView.cs b/MinecraftClient/Tui/MainTuiView.cs index c2d51220..1cde95e4 100644 --- a/MinecraftClient/Tui/MainTuiView.cs +++ b/MinecraftClient/Tui/MainTuiView.cs @@ -41,6 +41,10 @@ namespace MinecraftClient.Tui private long _lastLogClickTicks; private const int DoubleClickMsec = 500; + private readonly Border _minimapBorder; + private readonly MinimapControl _minimapControl; + private volatile bool _minimapVisible; + private readonly Border _suggestionBorder; private readonly StackPanel _suggestionPanel; private CommandSuggestion[] _suggestions = Array.Empty(); @@ -150,6 +154,29 @@ namespace MinecraftClient.Tui Margin = new Thickness(0, 0, 0, 1), }; + var mmCfg = Settings.Config.Console.Minimap; + mmCfg.OnSettingUpdate(); + _minimapControl = new MinimapControl(mmCfg.Width, mmCfg.Height); + _minimapControl.BlocksPerPixel = mmCfg.Zoom; + _minimapControl.RefreshIntervalMs = mmCfg.RefreshInterval; + _minimapControl.NameConfig.Players = mmCfg.ShowPlayerNames; + _minimapControl.NameConfig.Hostile = mmCfg.ShowHostileNames; + _minimapControl.NameConfig.Neutral = mmCfg.ShowNeutralNames; + _minimapControl.NameConfig.Passive = mmCfg.ShowPassiveNames; + + var (hAlign, vAlign, margin) = GetMinimapAlignment(mmCfg.Position); + _minimapBorder = new Border + { + Background = new SolidColorBrush(Color.FromArgb(220, 15, 15, 15)), + BorderBrush = new SolidColorBrush(Color.FromRgb(80, 80, 80)), + BorderThickness = new Thickness(1), + Child = _minimapControl, + IsVisible = false, + HorizontalAlignment = hAlign, + VerticalAlignment = vAlign, + Margin = margin, + }; + _mainContent = new DockPanel { Background = Brushes.Black, @@ -164,11 +191,18 @@ namespace MinecraftClient.Tui _rootPanel = new Panel { Background = Brushes.Black, - Children = { _mainContent, _notificationBorder, _suggestionBorder } + Children = { _mainContent, _minimapBorder, _notificationBorder, _suggestionBorder } }; Content = _rootPanel; + if (mmCfg.Enabled) + { + _minimapVisible = true; + _minimapBorder.IsVisible = true; + _minimapControl.Start(); + } + StartStatusBarTimer(); } @@ -986,6 +1020,95 @@ namespace MinecraftClient.Tui #endregion + #region Minimap + + public void ShowMinimap() + { + if (_minimapVisible) return; + _minimapVisible = true; + _minimapBorder.IsVisible = true; + _minimapControl.Start(); + Settings.Config.Console.Minimap.Enabled = true; + } + + public void HideMinimap() + { + if (!_minimapVisible) return; + _minimapVisible = false; + _minimapControl.Stop(); + _minimapBorder.IsVisible = false; + Settings.Config.Console.Minimap.Enabled = false; + } + + public void ToggleMinimap() + { + if (_minimapVisible) + HideMinimap(); + else + ShowMinimap(); + } + + public bool IsMinimapVisible => _minimapVisible; + + public void SetMinimapZoom(int level) + { + _minimapControl.BlocksPerPixel = level; + Settings.Config.Console.Minimap.Zoom = level; + } + + public int GetMinimapZoom() => _minimapControl.BlocksPerPixel; + + public NameDisplayConfig GetMinimapNameConfig() => _minimapControl.NameConfig; + + public void SyncMinimapNameConfig() + { + var nc = _minimapControl.NameConfig; + var cfg = Settings.Config.Console.Minimap; + cfg.ShowPlayerNames = nc.Players; + cfg.ShowHostileNames = nc.Hostile; + cfg.ShowNeutralNames = nc.Neutral; + cfg.ShowPassiveNames = nc.Passive; + } + + public void ResizeMinimap(int width, int height) + { + _minimapControl.Resize(width, height); + Settings.Config.Console.Minimap.Width = width; + Settings.Config.Console.Minimap.Height = height; + } + + public void SetMinimapPosition(MinimapPosition pos) + { + var (hAlign, vAlign, margin) = GetMinimapAlignment(pos); + _minimapBorder.HorizontalAlignment = hAlign; + _minimapBorder.VerticalAlignment = vAlign; + _minimapBorder.Margin = margin; + Settings.Config.Console.Minimap.Position = pos; + } + + public MinimapPosition GetMinimapPosition() => Settings.Config.Console.Minimap.Position; + + private static (HorizontalAlignment h, VerticalAlignment v, Thickness margin) GetMinimapAlignment(MinimapPosition pos) => pos switch + { + MinimapPosition.top_left => (HorizontalAlignment.Left, VerticalAlignment.Top, new Thickness(1, 1, 0, 0)), + MinimapPosition.top_right => (HorizontalAlignment.Right, VerticalAlignment.Top, new Thickness(0, 1, 1, 0)), + MinimapPosition.center => (HorizontalAlignment.Center, VerticalAlignment.Center, new Thickness(0)), + MinimapPosition.bottom_left => (HorizontalAlignment.Left, VerticalAlignment.Bottom, new Thickness(1, 0, 0, 2)), + MinimapPosition.bottom_right => (HorizontalAlignment.Right, VerticalAlignment.Bottom, new Thickness(0, 0, 1, 2)), + _ => (HorizontalAlignment.Right, VerticalAlignment.Top, new Thickness(0, 1, 1, 0)), + }; + + public void ApplyMinimapConfig() + { + var cfg = Settings.Config.Console.Minimap; + if (cfg.Enabled && !_minimapVisible) + ShowMinimap(); + else if (!cfg.Enabled && _minimapVisible) + HideMinimap(); + } + + #endregion + #region Overlay public void ShowOverlay(Control content, Action? onClose = null) diff --git a/MinecraftClient/Tui/MinimapBlockColors.json b/MinecraftClient/Tui/MinimapBlockColors.json new file mode 100644 index 00000000..af7d726c --- /dev/null +++ b/MinecraftClient/Tui/MinimapBlockColors.json @@ -0,0 +1,3552 @@ +{ + "version": "26.1-rc-2", + "colors": { + "AcaciaDoor": [ + 216, + 127, + 51 + ], + "AcaciaFence": [ + 216, + 127, + 51 + ], + "AcaciaFenceGate": [ + 216, + 127, + 51 + ], + "AcaciaHangingSign": [ + 216, + 127, + 51 + ], + "AcaciaPlanks": [ + 216, + 127, + 51 + ], + "AcaciaPressurePlate": [ + 216, + 127, + 51 + ], + "AcaciaSapling": [ + 0, + 124, + 0 + ], + "AcaciaShelf": [ + 216, + 127, + 51 + ], + "AcaciaSign": [ + 216, + 127, + 51 + ], + "AcaciaSlab": [ + 216, + 127, + 51 + ], + "AcaciaTrapdoor": [ + 216, + 127, + 51 + ], + "AcaciaWallHangingSign": [ + 216, + 127, + 51 + ], + "AcaciaWallSign": [ + 216, + 127, + 51 + ], + "AcaciaWood": [ + 76, + 76, + 76 + ], + "Allium": [ + 0, + 124, + 0 + ], + "AmethystBlock": [ + 127, + 63, + 178 + ], + "AmethystCluster": [ + 127, + 63, + 178 + ], + "AncientDebris": [ + 25, + 25, + 25 + ], + "Andesite": [ + 112, + 112, + 112 + ], + "Anvil": [ + 167, + 167, + 167 + ], + "AttachedMelonStem": [ + 0, + 124, + 0 + ], + "AttachedPumpkinStem": [ + 0, + 124, + 0 + ], + "Azalea": [ + 0, + 124, + 0 + ], + "AzureBluet": [ + 0, + 124, + 0 + ], + "Bamboo": [ + 0, + 124, + 0 + ], + "BambooDoor": [ + 229, + 229, + 51 + ], + "BambooFence": [ + 229, + 229, + 51 + ], + "BambooFenceGate": [ + 229, + 229, + 51 + ], + "BambooHangingSign": [ + 229, + 229, + 51 + ], + "BambooMosaic": [ + 229, + 229, + 51 + ], + "BambooMosaicSlab": [ + 229, + 229, + 51 + ], + "BambooPlanks": [ + 229, + 229, + 51 + ], + "BambooPressurePlate": [ + 229, + 229, + 51 + ], + "BambooSapling": [ + 143, + 119, + 72 + ], + "BambooShelf": [ + 229, + 229, + 51 + ], + "BambooSign": [ + 229, + 229, + 51 + ], + "BambooSlab": [ + 229, + 229, + 51 + ], + "BambooTrapdoor": [ + 229, + 229, + 51 + ], + "BambooWallHangingSign": [ + 229, + 229, + 51 + ], + "BambooWallSign": [ + 229, + 229, + 51 + ], + "Barrel": [ + 143, + 119, + 72 + ], + "Barrier": [ + 0, + 0, + 0 + ], + "Basalt": [ + 25, + 25, + 25 + ], + "Beacon": [ + 92, + 219, + 213 + ], + "Bedrock": [ + 112, + 112, + 112 + ], + "BeeNest": [ + 229, + 229, + 51 + ], + "Beehive": [ + 143, + 119, + 72 + ], + "Beetroots": [ + 0, + 124, + 0 + ], + "Bell": [ + 250, + 238, + 77 + ], + "BigDripleaf": [ + 0, + 124, + 0 + ], + "BigDripleafStem": [ + 0, + 124, + 0 + ], + "BirchDoor": [ + 247, + 233, + 163 + ], + "BirchFence": [ + 247, + 233, + 163 + ], + "BirchFenceGate": [ + 247, + 233, + 163 + ], + "BirchHangingSign": [ + 247, + 233, + 163 + ], + "BirchPlanks": [ + 247, + 233, + 163 + ], + "BirchPressurePlate": [ + 247, + 233, + 163 + ], + "BirchSapling": [ + 0, + 124, + 0 + ], + "BirchShelf": [ + 247, + 233, + 163 + ], + "BirchSign": [ + 247, + 233, + 163 + ], + "BirchSlab": [ + 247, + 233, + 163 + ], + "BirchTrapdoor": [ + 247, + 233, + 163 + ], + "BirchWallHangingSign": [ + 247, + 233, + 163 + ], + "BirchWallSign": [ + 247, + 233, + 163 + ], + "BirchWood": [ + 247, + 233, + 163 + ], + "BlackBanner": [ + 143, + 119, + 72 + ], + "BlackCarpet": [ + 25, + 25, + 25 + ], + "BlackConcrete": [ + 25, + 25, + 25 + ], + "BlackConcretePowder": [ + 25, + 25, + 25 + ], + "BlackGlazedTerracotta": [ + 25, + 25, + 25 + ], + "BlackTerracotta": [ + 37, + 22, + 16 + ], + "BlackWallBanner": [ + 143, + 119, + 72 + ], + "BlackWool": [ + 25, + 25, + 25 + ], + "Blackstone": [ + 25, + 25, + 25 + ], + "BlastFurnace": [ + 112, + 112, + 112 + ], + "BlueBanner": [ + 143, + 119, + 72 + ], + "BlueCarpet": [ + 51, + 76, + 178 + ], + "BlueConcrete": [ + 51, + 76, + 178 + ], + "BlueConcretePowder": [ + 51, + 76, + 178 + ], + "BlueGlazedTerracotta": [ + 51, + 76, + 178 + ], + "BlueIce": [ + 160, + 160, + 255 + ], + "BlueOrchid": [ + 0, + 124, + 0 + ], + "BlueTerracotta": [ + 76, + 62, + 92 + ], + "BlueWallBanner": [ + 143, + 119, + 72 + ], + "BlueWool": [ + 51, + 76, + 178 + ], + "BoneBlock": [ + 247, + 233, + 163 + ], + "Bookshelf": [ + 143, + 119, + 72 + ], + "BrainCoral": [ + 242, + 127, + 165 + ], + "BrainCoralBlock": [ + 242, + 127, + 165 + ], + "BrainCoralFan": [ + 242, + 127, + 165 + ], + "BrainCoralWallFan": [ + 242, + 127, + 165 + ], + "BrewingStand": [ + 167, + 167, + 167 + ], + "BrickSlab": [ + 153, + 51, + 51 + ], + "Bricks": [ + 153, + 51, + 51 + ], + "BrownBanner": [ + 143, + 119, + 72 + ], + "BrownCarpet": [ + 102, + 76, + 51 + ], + "BrownConcrete": [ + 102, + 76, + 51 + ], + "BrownConcretePowder": [ + 102, + 76, + 51 + ], + "BrownGlazedTerracotta": [ + 102, + 76, + 51 + ], + "BrownMushroom": [ + 102, + 76, + 51 + ], + "BrownMushroomBlock": [ + 151, + 109, + 77 + ], + "BrownTerracotta": [ + 76, + 50, + 35 + ], + "BrownWallBanner": [ + 143, + 119, + 72 + ], + "BrownWool": [ + 102, + 76, + 51 + ], + "BubbleColumn": [ + 64, + 64, + 255 + ], + "BubbleCoral": [ + 127, + 63, + 178 + ], + "BubbleCoralBlock": [ + 127, + 63, + 178 + ], + "BubbleCoralFan": [ + 127, + 63, + 178 + ], + "BubbleCoralWallFan": [ + 127, + 63, + 178 + ], + "BuddingAmethyst": [ + 127, + 63, + 178 + ], + "Bush": [ + 0, + 124, + 0 + ], + "Cactus": [ + 0, + 124, + 0 + ], + "CactusFlower": [ + 242, + 127, + 165 + ], + "Calcite": [ + 209, + 177, + 161 + ], + "Campfire": [ + 129, + 86, + 49 + ], + "Carrots": [ + 0, + 124, + 0 + ], + "CartographyTable": [ + 143, + 119, + 72 + ], + "CarvedPumpkin": [ + 216, + 127, + 51 + ], + "Cauldron": [ + 112, + 112, + 112 + ], + "CaveVines": [ + 0, + 124, + 0 + ], + "CaveVinesPlant": [ + 0, + 124, + 0 + ], + "ChainCommandBlock": [ + 102, + 127, + 51 + ], + "CherryDoor": [ + 209, + 177, + 161 + ], + "CherryFence": [ + 209, + 177, + 161 + ], + "CherryFenceGate": [ + 209, + 177, + 161 + ], + "CherryHangingSign": [ + 160, + 77, + 78 + ], + "CherryLeaves": [ + 242, + 127, + 165 + ], + "CherryPlanks": [ + 209, + 177, + 161 + ], + "CherryPressurePlate": [ + 209, + 177, + 161 + ], + "CherrySapling": [ + 242, + 127, + 165 + ], + "CherryShelf": [ + 209, + 177, + 161 + ], + "CherrySign": [ + 209, + 177, + 161 + ], + "CherrySlab": [ + 209, + 177, + 161 + ], + "CherryTrapdoor": [ + 209, + 177, + 161 + ], + "CherryWallHangingSign": [ + 160, + 77, + 78 + ], + "CherryWood": [ + 57, + 41, + 35 + ], + "Chest": [ + 143, + 119, + 72 + ], + "ChippedAnvil": [ + 167, + 167, + 167 + ], + "ChiseledBookshelf": [ + 143, + 119, + 72 + ], + "ChiseledNetherBricks": [ + 112, + 2, + 0 + ], + "ChiseledQuartzBlock": [ + 255, + 252, + 245 + ], + "ChiseledRedSandstone": [ + 216, + 127, + 51 + ], + "ChiseledResinBricks": [ + 159, + 82, + 36 + ], + "ChiseledSandstone": [ + 247, + 233, + 163 + ], + "ChiseledStoneBricks": [ + 112, + 112, + 112 + ], + "ChorusFlower": [ + 127, + 63, + 178 + ], + "ChorusPlant": [ + 127, + 63, + 178 + ], + "Clay": [ + 164, + 168, + 184 + ], + "ClosedEyeblossom": [ + 167, + 167, + 167 + ], + "CoalBlock": [ + 25, + 25, + 25 + ], + "CoalOre": [ + 112, + 112, + 112 + ], + "CoarseDirt": [ + 151, + 109, + 77 + ], + "Cobblestone": [ + 112, + 112, + 112 + ], + "CobblestoneSlab": [ + 112, + 112, + 112 + ], + "Cobweb": [ + 199, + 199, + 199 + ], + "Cocoa": [ + 0, + 124, + 0 + ], + "CommandBlock": [ + 102, + 76, + 51 + ], + "Composter": [ + 143, + 119, + 72 + ], + "Conduit": [ + 92, + 219, + 213 + ], + "CopperBlock": [ + 216, + 127, + 51 + ], + "CopperBulb": [ + 216, + 127, + 51 + ], + "CopperChest": [ + 216, + 127, + 51 + ], + "CopperDoor": [ + 216, + 127, + 51 + ], + "CopperGolemStatue": [ + 216, + 127, + 51 + ], + "CopperGrate": [ + 216, + 127, + 51 + ], + "CopperTrapdoor": [ + 216, + 127, + 51 + ], + "Cornflower": [ + 0, + 124, + 0 + ], + "CrackedNetherBricks": [ + 112, + 2, + 0 + ], + "CrackedStoneBricks": [ + 112, + 112, + 112 + ], + "Crafter": [ + 112, + 112, + 112 + ], + "CraftingTable": [ + 143, + 119, + 72 + ], + "CreakingHeart": [ + 216, + 127, + 51 + ], + "CrimsonDoor": [ + 148, + 63, + 97 + ], + "CrimsonFence": [ + 148, + 63, + 97 + ], + "CrimsonFenceGate": [ + 148, + 63, + 97 + ], + "CrimsonFungus": [ + 112, + 2, + 0 + ], + "CrimsonHangingSign": [ + 148, + 63, + 97 + ], + "CrimsonHyphae": [ + 92, + 25, + 29 + ], + "CrimsonNylium": [ + 189, + 48, + 49 + ], + "CrimsonPlanks": [ + 148, + 63, + 97 + ], + "CrimsonPressurePlate": [ + 148, + 63, + 97 + ], + "CrimsonRoots": [ + 112, + 2, + 0 + ], + "CrimsonShelf": [ + 148, + 63, + 97 + ], + "CrimsonSign": [ + 148, + 63, + 97 + ], + "CrimsonSlab": [ + 148, + 63, + 97 + ], + "CrimsonTrapdoor": [ + 148, + 63, + 97 + ], + "CrimsonWallHangingSign": [ + 148, + 63, + 97 + ], + "CrimsonWallSign": [ + 148, + 63, + 97 + ], + "CryingObsidian": [ + 25, + 25, + 25 + ], + "CutRedSandstone": [ + 216, + 127, + 51 + ], + "CutRedSandstoneSlab": [ + 216, + 127, + 51 + ], + "CutSandstone": [ + 247, + 233, + 163 + ], + "CutSandstoneSlab": [ + 247, + 233, + 163 + ], + "CyanBanner": [ + 143, + 119, + 72 + ], + "CyanCarpet": [ + 76, + 127, + 153 + ], + "CyanConcrete": [ + 76, + 127, + 153 + ], + "CyanConcretePowder": [ + 76, + 127, + 153 + ], + "CyanGlazedTerracotta": [ + 76, + 127, + 153 + ], + "CyanTerracotta": [ + 87, + 92, + 92 + ], + "CyanWallBanner": [ + 143, + 119, + 72 + ], + "CyanWool": [ + 76, + 127, + 153 + ], + "DamagedAnvil": [ + 167, + 167, + 167 + ], + "Dandelion": [ + 0, + 124, + 0 + ], + "DarkOakDoor": [ + 102, + 76, + 51 + ], + "DarkOakFence": [ + 102, + 76, + 51 + ], + "DarkOakFenceGate": [ + 102, + 76, + 51 + ], + "DarkOakPlanks": [ + 102, + 76, + 51 + ], + "DarkOakPressurePlate": [ + 102, + 76, + 51 + ], + "DarkOakSapling": [ + 0, + 124, + 0 + ], + "DarkOakSlab": [ + 102, + 76, + 51 + ], + "DarkOakTrapdoor": [ + 102, + 76, + 51 + ], + "DarkOakWood": [ + 102, + 76, + 51 + ], + "DarkPrismarine": [ + 92, + 219, + 213 + ], + "DarkPrismarineSlab": [ + 92, + 219, + 213 + ], + "DaylightDetector": [ + 143, + 119, + 72 + ], + "DeadBrainCoral": [ + 76, + 76, + 76 + ], + "DeadBrainCoralBlock": [ + 76, + 76, + 76 + ], + "DeadBrainCoralFan": [ + 76, + 76, + 76 + ], + "DeadBrainCoralWallFan": [ + 76, + 76, + 76 + ], + "DeadBubbleCoral": [ + 76, + 76, + 76 + ], + "DeadBubbleCoralBlock": [ + 76, + 76, + 76 + ], + "DeadBubbleCoralFan": [ + 76, + 76, + 76 + ], + "DeadBubbleCoralWallFan": [ + 76, + 76, + 76 + ], + "DeadBush": [ + 143, + 119, + 72 + ], + "DeadFireCoral": [ + 76, + 76, + 76 + ], + "DeadFireCoralBlock": [ + 76, + 76, + 76 + ], + "DeadFireCoralFan": [ + 76, + 76, + 76 + ], + "DeadFireCoralWallFan": [ + 76, + 76, + 76 + ], + "DeadHornCoral": [ + 76, + 76, + 76 + ], + "DeadHornCoralBlock": [ + 76, + 76, + 76 + ], + "DeadHornCoralFan": [ + 76, + 76, + 76 + ], + "DeadHornCoralWallFan": [ + 76, + 76, + 76 + ], + "DeadTubeCoral": [ + 76, + 76, + 76 + ], + "DeadTubeCoralBlock": [ + 76, + 76, + 76 + ], + "DeadTubeCoralFan": [ + 76, + 76, + 76 + ], + "DeadTubeCoralWallFan": [ + 76, + 76, + 76 + ], + "DecoratedPot": [ + 142, + 60, + 46 + ], + "Deepslate": [ + 100, + 100, + 100 + ], + "DeepslateCoalOre": [ + 100, + 100, + 100 + ], + "DeepslateCopperOre": [ + 100, + 100, + 100 + ], + "DeepslateDiamondOre": [ + 100, + 100, + 100 + ], + "DeepslateEmeraldOre": [ + 100, + 100, + 100 + ], + "DeepslateGoldOre": [ + 100, + 100, + 100 + ], + "DeepslateIronOre": [ + 100, + 100, + 100 + ], + "DeepslateLapisOre": [ + 100, + 100, + 100 + ], + "DeepslateRedstoneOre": [ + 100, + 100, + 100 + ], + "DiamondBlock": [ + 92, + 219, + 213 + ], + "DiamondOre": [ + 112, + 112, + 112 + ], + "Diorite": [ + 255, + 252, + 245 + ], + "Dirt": [ + 151, + 109, + 77 + ], + "DirtPath": [ + 151, + 109, + 77 + ], + "Dispenser": [ + 112, + 112, + 112 + ], + "DragonEgg": [ + 25, + 25, + 25 + ], + "DriedGhast": [ + 76, + 76, + 76 + ], + "DriedKelpBlock": [ + 102, + 127, + 51 + ], + "DripstoneBlock": [ + 76, + 50, + 35 + ], + "Dropper": [ + 112, + 112, + 112 + ], + "EmeraldBlock": [ + 0, + 217, + 58 + ], + "EmeraldOre": [ + 112, + 112, + 112 + ], + "EnchantingTable": [ + 153, + 51, + 51 + ], + "EndGateway": [ + 25, + 25, + 25 + ], + "EndPortal": [ + 25, + 25, + 25 + ], + "EndPortalFrame": [ + 102, + 127, + 51 + ], + "EndStone": [ + 247, + 233, + 163 + ], + "EndStoneBricks": [ + 247, + 233, + 163 + ], + "EnderChest": [ + 112, + 112, + 112 + ], + "ExposedCopper": [ + 135, + 107, + 98 + ], + "ExposedCopperBulb": [ + 135, + 107, + 98 + ], + "ExposedCopperChest": [ + 135, + 107, + 98 + ], + "ExposedCopperDoor": [ + 135, + 107, + 98 + ], + "ExposedCopperGolemStatue": [ + 135, + 107, + 98 + ], + "ExposedCopperGrate": [ + 135, + 107, + 98 + ], + "ExposedCopperTrapdoor": [ + 135, + 107, + 98 + ], + "ExposedLightningRod": [ + 135, + 107, + 98 + ], + "Farmland": [ + 151, + 109, + 77 + ], + "Fern": [ + 0, + 124, + 0 + ], + "Fire": [ + 255, + 0, + 0 + ], + "FireCoral": [ + 153, + 51, + 51 + ], + "FireCoralBlock": [ + 153, + 51, + 51 + ], + "FireCoralFan": [ + 153, + 51, + 51 + ], + "FireCoralWallFan": [ + 153, + 51, + 51 + ], + "FireflyBush": [ + 0, + 124, + 0 + ], + "FletchingTable": [ + 143, + 119, + 72 + ], + "FloweringAzalea": [ + 0, + 124, + 0 + ], + "Frogspawn": [ + 64, + 64, + 255 + ], + "FrostedIce": [ + 160, + 160, + 255 + ], + "Furnace": [ + 112, + 112, + 112 + ], + "GlowLichen": [ + 127, + 167, + 150 + ], + "Glowstone": [ + 247, + 233, + 163 + ], + "GoldBlock": [ + 250, + 238, + 77 + ], + "GoldOre": [ + 112, + 112, + 112 + ], + "GoldenDandelion": [ + 0, + 124, + 0 + ], + "Granite": [ + 151, + 109, + 77 + ], + "GrassBlock": [ + 127, + 178, + 56 + ], + "Gravel": [ + 112, + 112, + 112 + ], + "GrayBanner": [ + 143, + 119, + 72 + ], + "GrayCarpet": [ + 76, + 76, + 76 + ], + "GrayConcrete": [ + 76, + 76, + 76 + ], + "GrayConcretePowder": [ + 76, + 76, + 76 + ], + "GrayGlazedTerracotta": [ + 76, + 76, + 76 + ], + "GrayTerracotta": [ + 57, + 41, + 35 + ], + "GrayWallBanner": [ + 143, + 119, + 72 + ], + "GrayWool": [ + 76, + 76, + 76 + ], + "GreenBanner": [ + 143, + 119, + 72 + ], + "GreenCarpet": [ + 102, + 127, + 51 + ], + "GreenConcrete": [ + 102, + 127, + 51 + ], + "GreenConcretePowder": [ + 102, + 127, + 51 + ], + "GreenGlazedTerracotta": [ + 102, + 127, + 51 + ], + "GreenTerracotta": [ + 76, + 82, + 42 + ], + "GreenWallBanner": [ + 143, + 119, + 72 + ], + "GreenWool": [ + 102, + 127, + 51 + ], + "Grindstone": [ + 167, + 167, + 167 + ], + "HangingRoots": [ + 151, + 109, + 77 + ], + "HayBlock": [ + 229, + 229, + 51 + ], + "HeavyCore": [ + 167, + 167, + 167 + ], + "HeavyWeightedPressurePlate": [ + 167, + 167, + 167 + ], + "HoneyBlock": [ + 216, + 127, + 51 + ], + "HoneycombBlock": [ + 216, + 127, + 51 + ], + "Hopper": [ + 112, + 112, + 112 + ], + "HornCoral": [ + 229, + 229, + 51 + ], + "HornCoralBlock": [ + 229, + 229, + 51 + ], + "HornCoralFan": [ + 229, + 229, + 51 + ], + "HornCoralWallFan": [ + 229, + 229, + 51 + ], + "Ice": [ + 160, + 160, + 255 + ], + "InfestedChiseledStoneBricks": [ + 164, + 168, + 184 + ], + "InfestedCobblestone": [ + 164, + 168, + 184 + ], + "InfestedCrackedStoneBricks": [ + 164, + 168, + 184 + ], + "InfestedDeepslate": [ + 100, + 100, + 100 + ], + "InfestedMossyStoneBricks": [ + 164, + 168, + 184 + ], + "InfestedStone": [ + 164, + 168, + 184 + ], + "InfestedStoneBricks": [ + 164, + 168, + 184 + ], + "IronBlock": [ + 167, + 167, + 167 + ], + "IronDoor": [ + 167, + 167, + 167 + ], + "IronOre": [ + 112, + 112, + 112 + ], + "IronTrapdoor": [ + 167, + 167, + 167 + ], + "JackOLantern": [ + 216, + 127, + 51 + ], + "Jigsaw": [ + 153, + 153, + 153 + ], + "Jukebox": [ + 151, + 109, + 77 + ], + "JungleDoor": [ + 151, + 109, + 77 + ], + "JungleFence": [ + 151, + 109, + 77 + ], + "JungleFenceGate": [ + 151, + 109, + 77 + ], + "JunglePlanks": [ + 151, + 109, + 77 + ], + "JunglePressurePlate": [ + 151, + 109, + 77 + ], + "JungleSapling": [ + 0, + 124, + 0 + ], + "JungleSlab": [ + 151, + 109, + 77 + ], + "JungleTrapdoor": [ + 151, + 109, + 77 + ], + "JungleWood": [ + 151, + 109, + 77 + ], + "Kelp": [ + 64, + 64, + 255 + ], + "KelpPlant": [ + 64, + 64, + 255 + ], + "Lantern": [ + 167, + 167, + 167 + ], + "LapisBlock": [ + 74, + 128, + 255 + ], + "LapisOre": [ + 112, + 112, + 112 + ], + "LargeFern": [ + 0, + 124, + 0 + ], + "Lava": [ + 255, + 0, + 0 + ], + "LeafLitter": [ + 102, + 76, + 51 + ], + "Lectern": [ + 143, + 119, + 72 + ], + "Light": [ + 0, + 0, + 0 + ], + "LightBlueBanner": [ + 143, + 119, + 72 + ], + "LightBlueCarpet": [ + 102, + 153, + 216 + ], + "LightBlueConcrete": [ + 102, + 153, + 216 + ], + "LightBlueConcretePowder": [ + 102, + 153, + 216 + ], + "LightBlueGlazedTerracotta": [ + 102, + 153, + 216 + ], + "LightBlueTerracotta": [ + 112, + 108, + 138 + ], + "LightBlueWallBanner": [ + 143, + 119, + 72 + ], + "LightBlueWool": [ + 102, + 153, + 216 + ], + "LightGrayBanner": [ + 143, + 119, + 72 + ], + "LightGrayCarpet": [ + 153, + 153, + 153 + ], + "LightGrayConcrete": [ + 153, + 153, + 153 + ], + "LightGrayConcretePowder": [ + 153, + 153, + 153 + ], + "LightGrayGlazedTerracotta": [ + 153, + 153, + 153 + ], + "LightGrayTerracotta": [ + 135, + 107, + 98 + ], + "LightGrayWallBanner": [ + 143, + 119, + 72 + ], + "LightGrayWool": [ + 153, + 153, + 153 + ], + "LightWeightedPressurePlate": [ + 250, + 238, + 77 + ], + "LightningRod": [ + 216, + 127, + 51 + ], + "Lilac": [ + 0, + 124, + 0 + ], + "LilyOfTheValley": [ + 0, + 124, + 0 + ], + "LilyPad": [ + 0, + 124, + 0 + ], + "LimeBanner": [ + 143, + 119, + 72 + ], + "LimeCarpet": [ + 127, + 204, + 25 + ], + "LimeConcrete": [ + 127, + 204, + 25 + ], + "LimeConcretePowder": [ + 127, + 204, + 25 + ], + "LimeGlazedTerracotta": [ + 127, + 204, + 25 + ], + "LimeTerracotta": [ + 103, + 117, + 53 + ], + "LimeWallBanner": [ + 143, + 119, + 72 + ], + "LimeWool": [ + 127, + 204, + 25 + ], + "Lodestone": [ + 167, + 167, + 167 + ], + "Loom": [ + 143, + 119, + 72 + ], + "MagentaBanner": [ + 143, + 119, + 72 + ], + "MagentaCarpet": [ + 178, + 76, + 216 + ], + "MagentaConcrete": [ + 178, + 76, + 216 + ], + "MagentaConcretePowder": [ + 178, + 76, + 216 + ], + "MagentaGlazedTerracotta": [ + 178, + 76, + 216 + ], + "MagentaTerracotta": [ + 149, + 87, + 108 + ], + "MagentaWallBanner": [ + 143, + 119, + 72 + ], + "MagentaWool": [ + 178, + 76, + 216 + ], + "MagmaBlock": [ + 112, + 2, + 0 + ], + "MangroveDoor": [ + 153, + 51, + 51 + ], + "MangroveFence": [ + 153, + 51, + 51 + ], + "MangroveFenceGate": [ + 153, + 51, + 51 + ], + "MangrovePlanks": [ + 153, + 51, + 51 + ], + "MangrovePressurePlate": [ + 153, + 51, + 51 + ], + "MangrovePropagule": [ + 0, + 124, + 0 + ], + "MangroveRoots": [ + 129, + 86, + 49 + ], + "MangroveSlab": [ + 153, + 51, + 51 + ], + "MangroveTrapdoor": [ + 153, + 51, + 51 + ], + "MangroveWood": [ + 153, + 51, + 51 + ], + "Melon": [ + 127, + 204, + 25 + ], + "MelonStem": [ + 0, + 124, + 0 + ], + "MossBlock": [ + 102, + 127, + 51 + ], + "MossCarpet": [ + 102, + 127, + 51 + ], + "MossyCobblestone": [ + 112, + 112, + 112 + ], + "MossyStoneBricks": [ + 112, + 112, + 112 + ], + "MovingPiston": [ + 112, + 112, + 112 + ], + "Mud": [ + 87, + 92, + 92 + ], + "MudBrickSlab": [ + 135, + 107, + 98 + ], + "MudBricks": [ + 135, + 107, + 98 + ], + "MuddyMangroveRoots": [ + 129, + 86, + 49 + ], + "MushroomStem": [ + 199, + 199, + 199 + ], + "Mycelium": [ + 127, + 63, + 178 + ], + "NetherBrickFence": [ + 112, + 2, + 0 + ], + "NetherBrickSlab": [ + 112, + 2, + 0 + ], + "NetherBricks": [ + 112, + 2, + 0 + ], + "NetherGoldOre": [ + 112, + 2, + 0 + ], + "NetherQuartzOre": [ + 112, + 2, + 0 + ], + "NetherSprouts": [ + 76, + 127, + 153 + ], + "NetherWart": [ + 153, + 51, + 51 + ], + "NetherWartBlock": [ + 153, + 51, + 51 + ], + "NetheriteBlock": [ + 25, + 25, + 25 + ], + "Netherrack": [ + 112, + 2, + 0 + ], + "NoteBlock": [ + 143, + 119, + 72 + ], + "OakDoor": [ + 143, + 119, + 72 + ], + "OakFence": [ + 143, + 119, + 72 + ], + "OakFenceGate": [ + 143, + 119, + 72 + ], + "OakPlanks": [ + 143, + 119, + 72 + ], + "OakPressurePlate": [ + 143, + 119, + 72 + ], + "OakSapling": [ + 0, + 124, + 0 + ], + "OakShelf": [ + 143, + 119, + 72 + ], + "OakSign": [ + 143, + 119, + 72 + ], + "OakSlab": [ + 143, + 119, + 72 + ], + "OakTrapdoor": [ + 143, + 119, + 72 + ], + "OakWallSign": [ + 143, + 119, + 72 + ], + "OakWood": [ + 143, + 119, + 72 + ], + "Observer": [ + 112, + 112, + 112 + ], + "Obsidian": [ + 25, + 25, + 25 + ], + "OchreFroglight": [ + 247, + 233, + 163 + ], + "OpenEyeblossom": [ + 216, + 127, + 51 + ], + "OrangeBanner": [ + 143, + 119, + 72 + ], + "OrangeCarpet": [ + 216, + 127, + 51 + ], + "OrangeConcrete": [ + 216, + 127, + 51 + ], + "OrangeConcretePowder": [ + 216, + 127, + 51 + ], + "OrangeGlazedTerracotta": [ + 216, + 127, + 51 + ], + "OrangeTerracotta": [ + 159, + 82, + 36 + ], + "OrangeTulip": [ + 0, + 124, + 0 + ], + "OrangeWallBanner": [ + 143, + 119, + 72 + ], + "OrangeWool": [ + 216, + 127, + 51 + ], + "OxeyeDaisy": [ + 0, + 124, + 0 + ], + "OxidizedCopper": [ + 22, + 126, + 134 + ], + "OxidizedCopperBulb": [ + 22, + 126, + 134 + ], + "OxidizedCopperChest": [ + 22, + 126, + 134 + ], + "OxidizedCopperDoor": [ + 22, + 126, + 134 + ], + "OxidizedCopperGolemStatue": [ + 22, + 126, + 134 + ], + "OxidizedCopperGrate": [ + 22, + 126, + 134 + ], + "OxidizedCopperTrapdoor": [ + 22, + 126, + 134 + ], + "OxidizedLightningRod": [ + 22, + 126, + 134 + ], + "PackedIce": [ + 160, + 160, + 255 + ], + "PaleHangingMoss": [ + 153, + 153, + 153 + ], + "PaleMossBlock": [ + 153, + 153, + 153 + ], + "PaleMossCarpet": [ + 153, + 153, + 153 + ], + "PaleOakDoor": [ + 255, + 252, + 245 + ], + "PaleOakFence": [ + 255, + 252, + 245 + ], + "PaleOakFenceGate": [ + 255, + 252, + 245 + ], + "PaleOakHangingSign": [ + 255, + 252, + 245 + ], + "PaleOakLeaves": [ + 167, + 167, + 167 + ], + "PaleOakPlanks": [ + 255, + 252, + 245 + ], + "PaleOakPressurePlate": [ + 255, + 252, + 245 + ], + "PaleOakSapling": [ + 167, + 167, + 167 + ], + "PaleOakShelf": [ + 255, + 252, + 245 + ], + "PaleOakSign": [ + 255, + 252, + 245 + ], + "PaleOakSlab": [ + 255, + 252, + 245 + ], + "PaleOakTrapdoor": [ + 255, + 252, + 245 + ], + "PaleOakWallHangingSign": [ + 255, + 252, + 245 + ], + "PaleOakWallSign": [ + 255, + 252, + 245 + ], + "PaleOakWood": [ + 112, + 112, + 112 + ], + "PearlescentFroglight": [ + 242, + 127, + 165 + ], + "Peony": [ + 0, + 124, + 0 + ], + "PetrifiedOakSlab": [ + 143, + 119, + 72 + ], + "PinkBanner": [ + 143, + 119, + 72 + ], + "PinkCarpet": [ + 242, + 127, + 165 + ], + "PinkConcrete": [ + 242, + 127, + 165 + ], + "PinkConcretePowder": [ + 242, + 127, + 165 + ], + "PinkGlazedTerracotta": [ + 242, + 127, + 165 + ], + "PinkPetals": [ + 0, + 124, + 0 + ], + "PinkTerracotta": [ + 160, + 77, + 78 + ], + "PinkTulip": [ + 0, + 124, + 0 + ], + "PinkWallBanner": [ + 143, + 119, + 72 + ], + "PinkWool": [ + 242, + 127, + 165 + ], + "PistonHead": [ + 112, + 112, + 112 + ], + "PitcherCrop": [ + 0, + 124, + 0 + ], + "PitcherPlant": [ + 0, + 124, + 0 + ], + "Podzol": [ + 129, + 86, + 49 + ], + "PointedDripstone": [ + 76, + 50, + 35 + ], + "PolishedAndesite": [ + 112, + 112, + 112 + ], + "PolishedBasalt": [ + 25, + 25, + 25 + ], + "PolishedBlackstonePressurePlate": [ + 25, + 25, + 25 + ], + "PolishedDiorite": [ + 255, + 252, + 245 + ], + "PolishedGranite": [ + 151, + 109, + 77 + ], + "Poppy": [ + 0, + 124, + 0 + ], + "Potatoes": [ + 0, + 124, + 0 + ], + "PowderSnow": [ + 255, + 255, + 255 + ], + "Prismarine": [ + 76, + 127, + 153 + ], + "PrismarineBrickSlab": [ + 92, + 219, + 213 + ], + "PrismarineBricks": [ + 92, + 219, + 213 + ], + "PrismarineSlab": [ + 76, + 127, + 153 + ], + "Pumpkin": [ + 216, + 127, + 51 + ], + "PumpkinStem": [ + 0, + 124, + 0 + ], + "PurpleBanner": [ + 143, + 119, + 72 + ], + "PurpleCarpet": [ + 127, + 63, + 178 + ], + "PurpleConcrete": [ + 127, + 63, + 178 + ], + "PurpleConcretePowder": [ + 127, + 63, + 178 + ], + "PurpleGlazedTerracotta": [ + 127, + 63, + 178 + ], + "PurpleTerracotta": [ + 122, + 73, + 88 + ], + "PurpleWallBanner": [ + 143, + 119, + 72 + ], + "PurpleWool": [ + 127, + 63, + 178 + ], + "PurpurBlock": [ + 178, + 76, + 216 + ], + "PurpurPillar": [ + 178, + 76, + 216 + ], + "PurpurSlab": [ + 178, + 76, + 216 + ], + "QuartzBlock": [ + 255, + 252, + 245 + ], + "QuartzPillar": [ + 255, + 252, + 245 + ], + "QuartzSlab": [ + 255, + 252, + 245 + ], + "RawCopperBlock": [ + 216, + 127, + 51 + ], + "RawGoldBlock": [ + 250, + 238, + 77 + ], + "RawIronBlock": [ + 216, + 175, + 147 + ], + "RedBanner": [ + 143, + 119, + 72 + ], + "RedCarpet": [ + 153, + 51, + 51 + ], + "RedConcrete": [ + 153, + 51, + 51 + ], + "RedConcretePowder": [ + 153, + 51, + 51 + ], + "RedGlazedTerracotta": [ + 153, + 51, + 51 + ], + "RedMushroom": [ + 153, + 51, + 51 + ], + "RedMushroomBlock": [ + 153, + 51, + 51 + ], + "RedNetherBricks": [ + 112, + 2, + 0 + ], + "RedSand": [ + 216, + 127, + 51 + ], + "RedSandstone": [ + 216, + 127, + 51 + ], + "RedSandstoneSlab": [ + 216, + 127, + 51 + ], + "RedTerracotta": [ + 142, + 60, + 46 + ], + "RedTulip": [ + 0, + 124, + 0 + ], + "RedWallBanner": [ + 143, + 119, + 72 + ], + "RedWool": [ + 153, + 51, + 51 + ], + "RedstoneBlock": [ + 255, + 0, + 0 + ], + "RedstoneLamp": [ + 159, + 82, + 36 + ], + "RedstoneOre": [ + 112, + 112, + 112 + ], + "ReinforcedDeepslate": [ + 100, + 100, + 100 + ], + "RepeatingCommandBlock": [ + 127, + 63, + 178 + ], + "ResinBlock": [ + 159, + 82, + 36 + ], + "ResinBrickSlab": [ + 159, + 82, + 36 + ], + "ResinBrickWall": [ + 159, + 82, + 36 + ], + "ResinBricks": [ + 159, + 82, + 36 + ], + "ResinClump": [ + 159, + 82, + 36 + ], + "RespawnAnchor": [ + 25, + 25, + 25 + ], + "RootedDirt": [ + 151, + 109, + 77 + ], + "RoseBush": [ + 0, + 124, + 0 + ], + "Sand": [ + 247, + 233, + 163 + ], + "Sandstone": [ + 247, + 233, + 163 + ], + "SandstoneSlab": [ + 247, + 233, + 163 + ], + "Scaffolding": [ + 247, + 233, + 163 + ], + "Sculk": [ + 25, + 25, + 25 + ], + "SculkCatalyst": [ + 25, + 25, + 25 + ], + "SculkSensor": [ + 76, + 127, + 153 + ], + "SculkShrieker": [ + 25, + 25, + 25 + ], + "SculkVein": [ + 25, + 25, + 25 + ], + "SeaLantern": [ + 255, + 252, + 245 + ], + "SeaPickle": [ + 102, + 127, + 51 + ], + "Seagrass": [ + 64, + 64, + 255 + ], + "ShortDryGrass": [ + 229, + 229, + 51 + ], + "ShortGrass": [ + 0, + 124, + 0 + ], + "Shroomlight": [ + 153, + 51, + 51 + ], + "SlimeBlock": [ + 127, + 178, + 56 + ], + "SmallDripleaf": [ + 0, + 124, + 0 + ], + "SmithingTable": [ + 143, + 119, + 72 + ], + "Smoker": [ + 112, + 112, + 112 + ], + "SmoothQuartz": [ + 255, + 252, + 245 + ], + "SmoothRedSandstone": [ + 216, + 127, + 51 + ], + "SmoothSandstone": [ + 247, + 233, + 163 + ], + "SmoothStone": [ + 112, + 112, + 112 + ], + "SmoothStoneSlab": [ + 112, + 112, + 112 + ], + "SnifferEgg": [ + 153, + 51, + 51 + ], + "Snow": [ + 255, + 255, + 255 + ], + "SnowBlock": [ + 255, + 255, + 255 + ], + "SoulCampfire": [ + 129, + 86, + 49 + ], + "SoulFire": [ + 102, + 153, + 216 + ], + "SoulLantern": [ + 167, + 167, + 167 + ], + "SoulSand": [ + 102, + 76, + 51 + ], + "SoulSoil": [ + 102, + 76, + 51 + ], + "Spawner": [ + 112, + 112, + 112 + ], + "Sponge": [ + 229, + 229, + 51 + ], + "SporeBlossom": [ + 0, + 124, + 0 + ], + "SpruceDoor": [ + 129, + 86, + 49 + ], + "SpruceFence": [ + 129, + 86, + 49 + ], + "SpruceFenceGate": [ + 129, + 86, + 49 + ], + "SprucePlanks": [ + 129, + 86, + 49 + ], + "SprucePressurePlate": [ + 129, + 86, + 49 + ], + "SpruceSapling": [ + 0, + 124, + 0 + ], + "SpruceSlab": [ + 129, + 86, + 49 + ], + "SpruceTrapdoor": [ + 129, + 86, + 49 + ], + "SpruceWallHangingSign": [ + 143, + 119, + 72 + ], + "SpruceWood": [ + 129, + 86, + 49 + ], + "Stone": [ + 112, + 112, + 112 + ], + "StoneBrickSlab": [ + 112, + 112, + 112 + ], + "StoneBricks": [ + 112, + 112, + 112 + ], + "StonePressurePlate": [ + 112, + 112, + 112 + ], + "StoneSlab": [ + 112, + 112, + 112 + ], + "Stonecutter": [ + 112, + 112, + 112 + ], + "StrippedAcaciaWood": [ + 216, + 127, + 51 + ], + "StrippedBirchWood": [ + 247, + 233, + 163 + ], + "StrippedCherryWood": [ + 160, + 77, + 78 + ], + "StrippedCrimsonHyphae": [ + 92, + 25, + 29 + ], + "StrippedDarkOakWood": [ + 102, + 76, + 51 + ], + "StrippedJungleWood": [ + 151, + 109, + 77 + ], + "StrippedOakWood": [ + 143, + 119, + 72 + ], + "StrippedPaleOakWood": [ + 255, + 252, + 245 + ], + "StrippedSpruceWood": [ + 129, + 86, + 49 + ], + "StrippedWarpedHyphae": [ + 86, + 44, + 62 + ], + "StructureBlock": [ + 153, + 153, + 153 + ], + "SugarCane": [ + 0, + 124, + 0 + ], + "Sunflower": [ + 0, + 124, + 0 + ], + "SuspiciousGravel": [ + 112, + 112, + 112 + ], + "SuspiciousSand": [ + 247, + 233, + 163 + ], + "SweetBerryBush": [ + 0, + 124, + 0 + ], + "TallDryGrass": [ + 229, + 229, + 51 + ], + "TallGrass": [ + 0, + 124, + 0 + ], + "TallSeagrass": [ + 64, + 64, + 255 + ], + "Target": [ + 255, + 252, + 245 + ], + "Terracotta": [ + 216, + 127, + 51 + ], + "TestBlock": [ + 153, + 153, + 153 + ], + "TintedGlass": [ + 76, + 76, + 76 + ], + "Tnt": [ + 255, + 0, + 0 + ], + "Torchflower": [ + 0, + 124, + 0 + ], + "TorchflowerCrop": [ + 0, + 124, + 0 + ], + "TrappedChest": [ + 143, + 119, + 72 + ], + "TrialSpawner": [ + 112, + 112, + 112 + ], + "TubeCoral": [ + 51, + 76, + 178 + ], + "TubeCoralBlock": [ + 51, + 76, + 178 + ], + "TubeCoralFan": [ + 51, + 76, + 178 + ], + "TubeCoralWallFan": [ + 51, + 76, + 178 + ], + "Tuff": [ + 57, + 41, + 35 + ], + "TurtleEgg": [ + 247, + 233, + 163 + ], + "TwistingVines": [ + 76, + 127, + 153 + ], + "TwistingVinesPlant": [ + 76, + 127, + 153 + ], + "Vault": [ + 112, + 112, + 112 + ], + "VerdantFroglight": [ + 127, + 167, + 150 + ], + "Vine": [ + 0, + 124, + 0 + ], + "WarpedDoor": [ + 58, + 142, + 140 + ], + "WarpedFence": [ + 58, + 142, + 140 + ], + "WarpedFenceGate": [ + 58, + 142, + 140 + ], + "WarpedFungus": [ + 76, + 127, + 153 + ], + "WarpedHangingSign": [ + 58, + 142, + 140 + ], + "WarpedHyphae": [ + 86, + 44, + 62 + ], + "WarpedNylium": [ + 22, + 126, + 134 + ], + "WarpedPlanks": [ + 58, + 142, + 140 + ], + "WarpedPressurePlate": [ + 58, + 142, + 140 + ], + "WarpedRoots": [ + 76, + 127, + 153 + ], + "WarpedShelf": [ + 58, + 142, + 140 + ], + "WarpedSign": [ + 58, + 142, + 140 + ], + "WarpedSlab": [ + 58, + 142, + 140 + ], + "WarpedTrapdoor": [ + 58, + 142, + 140 + ], + "WarpedWallHangingSign": [ + 58, + 142, + 140 + ], + "WarpedWallSign": [ + 58, + 142, + 140 + ], + "WarpedWartBlock": [ + 20, + 180, + 133 + ], + "Water": [ + 64, + 64, + 255 + ], + "WeatheredCopper": [ + 58, + 142, + 140 + ], + "WeatheredCopperBulb": [ + 58, + 142, + 140 + ], + "WeatheredCopperChest": [ + 58, + 142, + 140 + ], + "WeatheredCopperDoor": [ + 58, + 142, + 140 + ], + "WeatheredCopperGolemStatue": [ + 58, + 142, + 140 + ], + "WeatheredCopperGrate": [ + 58, + 142, + 140 + ], + "WeatheredCopperTrapdoor": [ + 58, + 142, + 140 + ], + "WeatheredLightningRod": [ + 58, + 142, + 140 + ], + "WeepingVines": [ + 112, + 2, + 0 + ], + "WeepingVinesPlant": [ + 112, + 2, + 0 + ], + "WetSponge": [ + 229, + 229, + 51 + ], + "WhiteBanner": [ + 143, + 119, + 72 + ], + "WhiteCarpet": [ + 255, + 255, + 255 + ], + "WhiteConcrete": [ + 255, + 255, + 255 + ], + "WhiteConcretePowder": [ + 255, + 255, + 255 + ], + "WhiteGlazedTerracotta": [ + 255, + 255, + 255 + ], + "WhiteTerracotta": [ + 209, + 177, + 161 + ], + "WhiteTulip": [ + 0, + 124, + 0 + ], + "WhiteWallBanner": [ + 143, + 119, + 72 + ], + "WhiteWool": [ + 255, + 255, + 255 + ], + "Wildflowers": [ + 0, + 124, + 0 + ], + "WitherRose": [ + 0, + 124, + 0 + ], + "YellowBanner": [ + 143, + 119, + 72 + ], + "YellowCarpet": [ + 229, + 229, + 51 + ], + "YellowConcrete": [ + 229, + 229, + 51 + ], + "YellowConcretePowder": [ + 229, + 229, + 51 + ], + "YellowGlazedTerracotta": [ + 229, + 229, + 51 + ], + "YellowTerracotta": [ + 186, + 133, + 36 + ], + "YellowWallBanner": [ + 143, + 119, + 72 + ], + "YellowWool": [ + 229, + 229, + 51 + ] + }, + "transparent": [ + "Air", + "Barrier", + "BlackStainedGlass", + "BlackStainedGlassPane", + "BlueStainedGlass", + "BlueStainedGlassPane", + "BrownStainedGlass", + "BrownStainedGlassPane", + "CaveAir", + "CyanStainedGlass", + "CyanStainedGlassPane", + "Glass", + "GlassPane", + "GrayStainedGlass", + "GrayStainedGlassPane", + "GreenStainedGlass", + "GreenStainedGlassPane", + "Light", + "LightBlueStainedGlass", + "LightBlueStainedGlassPane", + "LightGrayStainedGlass", + "LightGrayStainedGlassPane", + "LimeStainedGlass", + "LimeStainedGlassPane", + "MagentaStainedGlass", + "MagentaStainedGlassPane", + "OrangeStainedGlass", + "OrangeStainedGlassPane", + "PinkStainedGlass", + "PinkStainedGlassPane", + "PurpleStainedGlass", + "PurpleStainedGlassPane", + "RedStainedGlass", + "RedStainedGlassPane", + "StructureVoid", + "TintedGlass", + "VoidAir", + "WhiteStainedGlass", + "WhiteStainedGlassPane", + "YellowStainedGlass", + "YellowStainedGlassPane" + ], + "water": [ + "Water" + ], + "ice": [ + "Ice", + "PackedIce", + "BlueIce", + "FrostedIce" + ] +} \ No newline at end of file diff --git a/MinecraftClient/Tui/MinimapColorMap.cs b/MinecraftClient/Tui/MinimapColorMap.cs new file mode 100644 index 00000000..ae1bf21c --- /dev/null +++ b/MinecraftClient/Tui/MinimapColorMap.cs @@ -0,0 +1,168 @@ +using System; +using System.Collections.Frozen; +using System.Collections.Generic; +using System.Reflection; +using System.Text.Json; +using Avalonia.Media; +using MinecraftClient.Mapping; + +namespace MinecraftClient.Tui +{ + /// + /// Maps block Materials to minimap colors using data extracted from Minecraft's + /// official MapColor table. Colors are loaded from the embedded MinimapBlockColors.json + /// resource generated by tools/gen_block_color_map.py. + /// + public static class MinimapColorMap + { + public static readonly Color WaterColor = Color.FromRgb(64, 64, 255); + public static readonly Color IceColor = Color.FromRgb(160, 160, 255); + public static readonly Color LavaColor = Color.FromRgb(255, 100, 0); + public static readonly Color DefaultColor = Color.FromRgb(60, 60, 60); + public static readonly Color VoidColor = Color.FromRgb(0, 0, 0); + + private static readonly FrozenDictionary ColorTable; + private static readonly FrozenSet FullyTransparentMats; + private static readonly FrozenSet WaterMats; + private static readonly FrozenSet IceMats; + + static MinimapColorMap() + { + var colors = new Dictionary(); + var transparent = new HashSet(); + var water = new HashSet(); + var ice = new HashSet(); + + try + { + using var stream = Assembly.GetExecutingAssembly() + .GetManifestResourceStream("MinimapBlockColors.json"); + if (stream is not null) + { + using var doc = JsonDocument.Parse(stream); + var root = doc.RootElement; + + if (root.TryGetProperty("colors", out var colorsEl)) + { + foreach (var prop in colorsEl.EnumerateObject()) + { + if (!Enum.TryParse(prop.Name, out var mat)) + continue; + var arr = prop.Value; + if (arr.GetArrayLength() < 3) continue; + byte r = (byte)arr[0].GetInt32(); + byte g = (byte)arr[1].GetInt32(); + byte b = (byte)arr[2].GetInt32(); + colors[mat] = Color.FromRgb(r, g, b); + } + } + + if (root.TryGetProperty("transparent", out var transEl)) + { + foreach (var item in transEl.EnumerateArray()) + { + if (Enum.TryParse(item.GetString(), out var mat)) + transparent.Add(mat); + } + } + + if (root.TryGetProperty("water", out var waterEl)) + { + foreach (var item in waterEl.EnumerateArray()) + { + if (Enum.TryParse(item.GetString(), out var mat)) + water.Add(mat); + } + } + + if (root.TryGetProperty("ice", out var iceEl)) + { + foreach (var item in iceEl.EnumerateArray()) + { + if (Enum.TryParse(item.GetString(), out var mat)) + ice.Add(mat); + } + } + } + } + catch (Exception ex) + { + ConsoleIO.WriteLineFormatted($"\u00a7e[Minimap] Failed to load color data: {ex.Message}"); + } + + if (transparent.Count == 0) + { + transparent.Add(Material.Air); + transparent.Add(Material.CaveAir); + transparent.Add(Material.VoidAir); + } + if (water.Count == 0) + water.Add(Material.Water); + if (ice.Count == 0) + { + ice.Add(Material.Ice); + ice.Add(Material.PackedIce); + ice.Add(Material.BlueIce); + ice.Add(Material.FrostedIce); + } + + ColorTable = colors.ToFrozenDictionary(); + FullyTransparentMats = transparent.ToFrozenSet(); + WaterMats = water.ToFrozenSet(); + IceMats = ice.ToFrozenSet(); + } + + public static bool IsFullyTransparent(Material m) => FullyTransparentMats.Contains(m); + + public static bool IsWater(Material m) => WaterMats.Contains(m); + + public static bool IsIce(Material m) => IceMats.Contains(m); + + public static Color GetBaseColor(Material m) + { + if (m == Material.Lava) + return LavaColor; + return ColorTable.GetValueOrDefault(m, DefaultColor); + } + + /// + /// Apply Minecraft-style height shading. The shade multiplier depends on + /// the height difference between the current block and the block to its north. + /// Vanilla maps use four brightness levels: LOW (180/255), NORMAL (220/255), + /// HIGH (255/255), and LOWEST (135/255). We use NORMAL as baseline and shift + /// up/down based on delta. + /// + public static Color ApplyHeightShade(Color baseColor, int heightDelta) + { + int multiplier = heightDelta switch + { + > 0 => 255, // higher than neighbor: brightest + 0 => 220, // same height: normal + _ => 180, // lower than neighbor: darker + }; + byte r = (byte)(baseColor.R * multiplier / 255); + byte g = (byte)(baseColor.G * multiplier / 255); + byte b = (byte)(baseColor.B * multiplier / 255); + return Color.FromRgb(r, g, b); + } + + public static Color BlendWaterColor(Color bottomColor, int waterDepth) + { + double alpha = Math.Min(0.85, 0.35 + waterDepth * 0.08); + return Blend(WaterColor, bottomColor, alpha); + } + + public static Color BlendIceColor(Color bottomColor) + { + return Blend(IceColor, bottomColor, 0.35); + } + + private static Color Blend(Color top, Color bottom, double topAlpha) + { + byte r = (byte)(top.R * topAlpha + bottom.R * (1.0 - topAlpha)); + byte g = (byte)(top.G * topAlpha + bottom.G * (1.0 - topAlpha)); + byte b = (byte)(top.B * topAlpha + bottom.B * (1.0 - topAlpha)); + return Color.FromRgb(r, g, b); + } + } +} diff --git a/MinecraftClient/Tui/MinimapControl.cs b/MinecraftClient/Tui/MinimapControl.cs new file mode 100644 index 00000000..44db58ba --- /dev/null +++ b/MinecraftClient/Tui/MinimapControl.cs @@ -0,0 +1,677 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Layout; +using Avalonia.Media; +using Avalonia.Threading; +using MinecraftClient.Mapping; + +namespace MinecraftClient.Tui +{ + /// + /// TUI minimap control rendered as a grid of TextBlocks using half-block characters. + /// Zoom is expressed as blocks-per-pixel (1 = 1:1, 16 = 16 blocks per pixel). + /// Entity names are drawn directly on the map below their icon. + /// + public class MinimapControl : UserControl + { + public const int MinZoom = 1; + public const int MaxZoom = 16; + public const int DefaultZoom = 2; + public const int DefaultWidth = 40; + public const int DefaultHeight = 40; + public const int DefaultRefreshMs = 1000; + public const int MinRefreshMs = 100; + public const int MaxRefreshMs = 5000; + + private int _mapWidth; + private int _mapHeight; + private int _cellRows; + + private int _blocksPerPixel = DefaultZoom; + private volatile bool _sampling; + private CancellationTokenSource? _cts; + + private readonly NameDisplayConfig _nameConfig = new(); + + private TextBlock[,] _cells; + private readonly StackPanel _infoRow; + private readonly StackPanel _legendPanel; + private readonly Grid _mapGrid; + private readonly DispatcherTimer _timer; + + public int BlocksPerPixel + { + get => _blocksPerPixel; + set => _blocksPerPixel = Math.Clamp(value, MinZoom, MaxZoom); + } + + public NameDisplayConfig NameConfig => _nameConfig; + + public int MapPixelWidth => _mapWidth; + public int MapPixelHeight => _mapHeight; + + public int RefreshIntervalMs + { + get => (int)_timer.Interval.TotalMilliseconds; + set => _timer.Interval = TimeSpan.FromMilliseconds(Math.Clamp(value, MinRefreshMs, MaxRefreshMs)); + } + + public MinimapControl() : this(DefaultWidth, DefaultHeight) { } + + public MinimapControl(int width, int height) + { + _mapWidth = Math.Max(10, width); + _mapHeight = Math.Max(4, height % 2 == 0 ? height : height + 1); + _cellRows = _mapHeight / 2; + + _mapGrid = new Grid(); + _cells = BuildGrid(_mapGrid, _cellRows, _mapWidth); + + _infoRow = new StackPanel { Orientation = Orientation.Horizontal }; + _legendPanel = new StackPanel { Orientation = Orientation.Horizontal }; + + var root = new StackPanel + { + Orientation = Orientation.Vertical, + Children = { _mapGrid, _infoRow, _legendPanel }, + }; + + Content = root; + + _timer = new DispatcherTimer + { + Interval = TimeSpan.FromMilliseconds(DefaultRefreshMs), + }; + _timer.Tick += (_, _) => RequestSample(); + } + + public void Resize(int width, int height) + { + _mapWidth = Math.Max(10, width); + _mapHeight = Math.Max(4, height % 2 == 0 ? height : height + 1); + _cellRows = _mapHeight / 2; + + _mapGrid.Children.Clear(); + _mapGrid.RowDefinitions.Clear(); + _mapGrid.ColumnDefinitions.Clear(); + _cells = BuildGrid(_mapGrid, _cellRows, _mapWidth); + } + + private static TextBlock[,] BuildGrid(Grid grid, int rows, int cols) + { + var cells = new TextBlock[rows, cols]; + for (int r = 0; r < rows; r++) + grid.RowDefinitions.Add(new RowDefinition(GridLength.Auto)); + for (int c = 0; c < cols; c++) + grid.ColumnDefinitions.Add(new ColumnDefinition(GridLength.Auto)); + + for (int r = 0; r < rows; r++) + { + for (int c = 0; c < cols; c++) + { + var tb = new TextBlock + { + Text = "\u2580", + Foreground = Brushes.Black, + Background = Brushes.Black, + Padding = new Thickness(0), + Margin = new Thickness(0), + FontSize = 1, + }; + Grid.SetRow(tb, r); + Grid.SetColumn(tb, c); + grid.Children.Add(tb); + cells[r, c] = tb; + } + } + return cells; + } + + public void Start() + { + _cts = new CancellationTokenSource(); + _timer.Start(); + RequestSample(); + } + + public void Stop() + { + _timer.Stop(); + _cts?.Cancel(); + _cts?.Dispose(); + _cts = null; + } + + private void RequestSample() + { + if (_sampling) return; + if (McClient.Instance is not McClient client) return; + if (!client.GetTerrainEnabled()) return; + + _sampling = true; + var ct = _cts?.Token ?? CancellationToken.None; + int bpp = _blocksPerPixel; + int w = _mapWidth; + int h = _mapHeight; + + bool showPlayers = _nameConfig.Players; + bool showHostile = _nameConfig.Hostile; + bool showNeutral = _nameConfig.Neutral; + bool showPassive = _nameConfig.Passive; + + Task.Run(() => + { + try + { + var result = SampleTerrain(client, bpp, w, h, + showPlayers, showHostile, showNeutral, showPassive, ct); + if (ct.IsCancellationRequested) return; + + Dispatcher.UIThread.Post(() => + { + ApplyPixelBuffer(result, w, h); + UpdateInfoBarAndLegend(client, bpp, result.VisibleCategories, w); + }); + } + catch (OperationCanceledException) { } + catch (Exception ex) + { + ConsoleIO.WriteLineFormatted($"\u00a7e[Minimap] Sample error: {ex.Message}"); + } + finally + { + _sampling = false; + } + }, ct); + } + + internal sealed class EntityLabel + { + public string Name = ""; + public Color LabelColor; + public int PixelX; + public int PixelY; + } + + private sealed class SampleResult + { + public Color[,] Pixels = null!; + public (char Ch, Color Fg, Color Bg)?[,] CharOverlay = null!; + public HashSet VisibleCategories = []; + public int[,] Heights = null!; + } + + private static bool ShouldShowNameLocal(MobCategory cat, + bool showPlayers, bool showHostile, bool showNeutral, bool showPassive) + { + return cat switch + { + MobCategory.Player => showPlayers, + MobCategory.Hostile => showHostile, + MobCategory.Neutral => showNeutral, + MobCategory.Passive => showPassive, + _ => false, + }; + } + + private static SampleResult SampleTerrain(McClient client, int bpp, int mapW, int mapH, + bool showPlayers, bool showHostile, bool showNeutral, bool showPassive, + CancellationToken ct) + { + var result = new SampleResult + { + Pixels = new Color[mapW, mapH], + CharOverlay = new (char, Color, Color)?[mapW, mapH / 2], + Heights = new int[mapW, mapH], + }; + var world = client.GetWorld(); + var playerLoc = client.GetCurrentLocation(); + + int playerBlockX = (int)Math.Floor(playerLoc.X); + int playerBlockZ = (int)Math.Floor(playerLoc.Z); + int playerBlockY = (int)Math.Floor(playerLoc.Y); + + var dim = World.GetDimension(); + int minY = dim.minY; + int scanTop = Math.Min(playerBlockY + 32, dim.maxY - 1); + + var entities = client.GetEntityHandlingEnabled() + ? client.GetEntities() + : null; + + var entityPixels = new Dictionary<(int, int), (Color Color, int Priority)>(); + int centerX = mapW / 2; + int centerY = mapH / 2; + + var nameLabels = new List(); + var uuidNameMap = client.GetOnlinePlayersWithUUID(); + + if (entities is not null) + { + int playerEntityId = client.GetPlayerEntityID(); + foreach (var kvp in entities) + { + if (ct.IsCancellationRequested) return result; + var entity = kvp.Value; + var cat = MinimapEntityClassifier.Classify(entity.Type); + if (cat == MobCategory.NonLiving) continue; + if (kvp.Key == playerEntityId) continue; + + if (!MinimapEntityClassifier.ShouldDisplay(cat, playerLoc.Y, entity.Location.Y)) + continue; + + double relX = (entity.Location.X - playerLoc.X) / bpp; + double relZ = (entity.Location.Z - playerLoc.Z) / bpp; + int px = (int)Math.Floor(relX) + centerX; + int py = (int)Math.Floor(relZ) + centerY; + + if (px < 0 || px >= mapW || py < 0 || py >= mapH) continue; + + var baseColor = MinimapEntityClassifier.GetBaseColor(cat); + Color color; + if (cat == MobCategory.Player) + color = baseColor; + else + color = MinimapEntityClassifier.ApplyDepthFade(baseColor, playerLoc.Y, entity.Location.Y); + int priority = MinimapEntityClassifier.GetPriority(cat); + + var key = (px, py); + if (!entityPixels.TryGetValue(key, out var existing) || priority > existing.Priority) + entityPixels[key] = (color, priority); + + result.VisibleCategories.Add(cat); + + if (ShouldShowNameLocal(cat, showPlayers, showHostile, showNeutral, showPassive)) + { + string name = ResolveEntityName(client, entity, cat, uuidNameMap); + nameLabels.Add(new EntityLabel + { + Name = name, + LabelColor = color, + PixelX = px, + PixelY = py, + }); + } + } + } + + entityPixels[(centerX, centerY)] = (MinimapEntityClassifier.PlayerColor, 5); + result.VisibleCategories.Add(MobCategory.Player); + + ChunkColumn? cachedColumn = null; + int cachedChunkX = int.MinValue, cachedChunkZ = int.MinValue; + + for (int px = 0; px < mapW; px++) + { + for (int py = 0; py < mapH; py++) + { + if (ct.IsCancellationRequested) return result; + + int baseX = playerBlockX + (px - centerX) * bpp; + int baseZ = playerBlockZ + (py - centerY) * bpp; + + if (bpp == 1) + { + var (color, surfY) = SampleColumn(world, baseX, baseZ, scanTop, minY, + ref cachedColumn, ref cachedChunkX, ref cachedChunkZ); + result.Pixels[px, py] = color; + result.Heights[px, py] = surfY; + } + else + { + var (color, surfY) = SampleAreaDominant(world, baseX, baseZ, bpp, scanTop, minY, + ref cachedColumn, ref cachedChunkX, ref cachedChunkZ); + result.Pixels[px, py] = color; + result.Heights[px, py] = surfY; + } + } + } + + for (int px = 0; px < mapW; px++) + { + for (int py = 0; py < mapH; py++) + { + if (entityPixels.ContainsKey((px, py))) continue; + + int northHeight = py > 0 ? result.Heights[px, py - 1] : result.Heights[px, py]; + int delta = result.Heights[px, py] - northHeight; + result.Pixels[px, py] = MinimapColorMap.ApplyHeightShade(result.Pixels[px, py], delta); + } + } + + foreach (var (key, info) in entityPixels) + { + var (px, py) = key; + if (px >= 0 && px < mapW && py >= 0 && py < mapH) + result.Pixels[px, py] = info.Color; + } + + BakeNameLabels(result, nameLabels, mapW, mapH); + + return result; + } + + private static string ResolveEntityName(McClient client, Entity entity, + MobCategory cat, Dictionary? uuidNameMap) + { + if (cat == MobCategory.Player) + { + if (!string.IsNullOrWhiteSpace(entity.Name)) + return entity.Name; + + if (entity.UUID != System.Guid.Empty) + { + var playerInfo = client.GetPlayerInfo(entity.UUID); + if (!string.IsNullOrWhiteSpace(playerInfo?.Name)) + return playerInfo.Name; + + if (uuidNameMap is not null && + uuidNameMap.TryGetValue(entity.UUID.ToString(), out string? mapped) && + !string.IsNullOrWhiteSpace(mapped)) + return mapped; + } + + return "Player"; + } + + if (!string.IsNullOrWhiteSpace(entity.Name)) + return entity.Name; + + return entity.Type.ToString(); + } + + private static void BakeNameLabels(SampleResult result, List labels, + int mapW, int mapH) + { + if (labels.Count == 0) return; + int cellRows = mapH / 2; + + var occupied = new HashSet<(int col, int row)>(); + + labels.Sort((a, b) => + { + int pa = MinimapEntityClassifier.GetPriority( + a.LabelColor == MinimapEntityClassifier.PlayerColor ? MobCategory.Player : + a.LabelColor == MinimapEntityClassifier.HostileColor ? MobCategory.Hostile : + a.LabelColor == MinimapEntityClassifier.NeutralColor ? MobCategory.Neutral : MobCategory.Passive); + int pb = MinimapEntityClassifier.GetPriority( + b.LabelColor == MinimapEntityClassifier.PlayerColor ? MobCategory.Player : + b.LabelColor == MinimapEntityClassifier.HostileColor ? MobCategory.Hostile : + b.LabelColor == MinimapEntityClassifier.NeutralColor ? MobCategory.Neutral : MobCategory.Passive); + return pb.CompareTo(pa); + }); + + foreach (var lbl in labels) + { + int cellRow = (lbl.PixelY / 2) + 1; + if (cellRow >= cellRows) cellRow = lbl.PixelY / 2 - 1; + if (cellRow < 0 || cellRow >= cellRows) continue; + + int startCol = lbl.PixelX - lbl.Name.Length / 2; + startCol = Math.Clamp(startCol, 0, mapW - 1); + + bool fits = true; + int endCol = Math.Min(startCol + lbl.Name.Length, mapW); + for (int c = startCol; c < endCol; c++) + { + if (occupied.Contains((c, cellRow))) + { + fits = false; + break; + } + } + if (!fits) continue; + + for (int i = 0; i < lbl.Name.Length && startCol + i < mapW; i++) + { + int col = startCol + i; + occupied.Add((col, cellRow)); + + var bgTop = result.Pixels[col, cellRow * 2]; + var bgBot = (cellRow * 2 + 1 < mapH) + ? result.Pixels[col, cellRow * 2 + 1] + : bgTop; + + var avgBg = Color.FromRgb( + (byte)((bgTop.R + bgBot.R) / 2), + (byte)((bgTop.G + bgBot.G) / 2), + (byte)((bgTop.B + bgBot.B) / 2)); + + result.CharOverlay[col, cellRow] = (lbl.Name[i], lbl.LabelColor, avgBg); + } + } + } + + private static (Color color, int surfaceY) SampleColumn(World world, int x, int z, + int scanTop, int minY, + ref ChunkColumn? cachedColumn, ref int cachedChunkX, ref int cachedChunkZ) + { + int chunkX = x >> 4; + int chunkZ = z >> 4; + if (chunkX != cachedChunkX || chunkZ != cachedChunkZ) + { + cachedColumn = world[chunkX, chunkZ]; + cachedChunkX = chunkX; + cachedChunkZ = chunkZ; + } + + if (cachedColumn is null) + return (MinimapColorMap.VoidColor, minY); + + int waterDepth = 0; + bool inIce = false; + int surfaceY = minY; + + for (int y = scanTop; y >= minY; y--) + { + var loc = new Mapping.Location(x, y, z); + var chunk = cachedColumn.GetChunk(loc); + if (chunk is null) continue; + + var block = chunk.GetBlock(loc); + var mat = block.Type; + + if (MinimapColorMap.IsFullyTransparent(mat)) + continue; + + if (MinimapColorMap.IsWater(mat)) + { + if (waterDepth == 0) surfaceY = y; + waterDepth++; + continue; + } + + if (MinimapColorMap.IsIce(mat) && !inIce) + { + if (waterDepth == 0) surfaceY = y; + inIce = true; + continue; + } + + if (waterDepth == 0 && !inIce) surfaceY = y; + + var baseColor = MinimapColorMap.GetBaseColor(mat); + + if (waterDepth > 0) + baseColor = MinimapColorMap.BlendWaterColor(baseColor, waterDepth); + if (inIce) + baseColor = MinimapColorMap.BlendIceColor(baseColor); + + return (baseColor, surfaceY); + } + + if (waterDepth > 0) + return (MinimapColorMap.WaterColor, surfaceY); + + return (MinimapColorMap.VoidColor, minY); + } + + private static (Color color, int surfaceY) SampleAreaDominant(World world, int baseX, int baseZ, + int size, int scanTop, int minY, + ref ChunkColumn? cachedColumn, ref int cachedChunkX, ref int cachedChunkZ) + { + var colorCounts = new Dictionary(); + + int step = Math.Max(1, size / 3); + for (int dx = 0; dx < size; dx += step) + { + for (int dz = 0; dz < size; dz += step) + { + var (c, surfY) = SampleColumn(world, baseX + dx, baseZ + dz, scanTop, minY, + ref cachedColumn, ref cachedChunkX, ref cachedChunkZ); + + if (colorCounts.TryGetValue(c, out var existing)) + colorCounts[c] = (existing.Count + 1, existing.SumY + surfY); + else + colorCounts[c] = (1, surfY); + } + } + + Color best = MinimapColorMap.VoidColor; + int bestCount = 0; + int avgY = minY; + foreach (var kvp in colorCounts) + { + if (kvp.Value.Count > bestCount) + { + bestCount = kvp.Value.Count; + best = kvp.Key; + avgY = kvp.Value.SumY / kvp.Value.Count; + } + } + return (best, avgY); + } + + private void ApplyPixelBuffer(SampleResult result, int w, int h) + { + int rows = h / 2; + for (int row = 0; row < rows && row < _cellRows; row++) + { + for (int col = 0; col < w && col < _mapWidth; col++) + { + var overlay = result.CharOverlay[col, row]; + if (overlay is not null) + { + var (ch, fg, bg) = overlay.Value; + _cells[row, col].Text = ch.ToString(); + _cells[row, col].Foreground = new SolidColorBrush(fg); + _cells[row, col].Background = new SolidColorBrush(bg); + } + else + { + var topColor = result.Pixels[col, row * 2]; + var bottomColor = result.Pixels[col, row * 2 + 1]; + + _cells[row, col].Text = "\u2580"; + _cells[row, col].Foreground = new SolidColorBrush(topColor); + _cells[row, col].Background = new SolidColorBrush(bottomColor); + } + } + } + } + + private void UpdateInfoBarAndLegend(McClient client, int bpp, + HashSet categories, int mapW) + { + var loc = client.GetCurrentLocation(); + float yaw = client.GetYaw(); + string arrow = GetDirectionArrow(yaw); + + int x = (int)Math.Floor(loc.X); + int y = (int)Math.Floor(loc.Y); + int z = (int)Math.Floor(loc.Z); + + string coordPart = $"{x}, {y}, {z} {arrow} {bpp}:1"; + + var legendParts = new List(); + var legendColors = new List(); + + var sorted = categories + .Where(c => c != MobCategory.NonLiving) + .OrderByDescending(MinimapEntityClassifier.GetPriority); + + int catCount = 0; + foreach (var cat in sorted) + { + if (catCount >= 4) break; + legendParts.Add(MinimapEntityClassifier.GetCategoryLabel(cat)); + legendColors.Add(MinimapEntityClassifier.GetBaseColor(cat)); + catCount++; + } + + int legendLen = 0; + for (int i = 0; i < legendParts.Count; i++) + legendLen += 1 + legendParts[i].Length + (i > 0 ? 1 : 0); + + bool fitsOnOneLine = legendParts.Count > 0 + && coordPart.Length + 2 + legendLen <= mapW; + + _infoRow.Children.Clear(); + _infoRow.Children.Add(new TextBlock + { + Text = coordPart, + Foreground = Brushes.Gray, + Padding = new Thickness(0), + }); + + if (fitsOnOneLine) + { + AppendLegendItems(_infoRow, legendParts, legendColors, leftMargin: 2); + _legendPanel.Children.Clear(); + _legendPanel.IsVisible = false; + } + else + { + _legendPanel.IsVisible = legendParts.Count > 0; + _legendPanel.Children.Clear(); + AppendLegendItems(_legendPanel, legendParts, legendColors, leftMargin: 0); + } + } + + private static void AppendLegendItems(StackPanel panel, + List parts, List colors, int leftMargin) + { + for (int i = 0; i < parts.Count; i++) + { + int ml = i == 0 ? leftMargin : 1; + panel.Children.Add(new TextBlock + { + Text = "\u25cf", + Foreground = new SolidColorBrush(colors[i]), + Padding = new Thickness(0), + Margin = ml > 0 ? new Thickness(ml, 0, 0, 0) : new Thickness(0), + }); + panel.Children.Add(new TextBlock + { + Text = parts[i], + Foreground = Brushes.Gray, + Padding = new Thickness(0), + Margin = new Thickness(0), + }); + } + } + + private static string GetDirectionArrow(float yaw) + { + double normalized = ((yaw % 360) + 360) % 360; + int index = (int)Math.Round(normalized / 45.0) % 8; + return index switch + { + 0 => "\u2193", // S + 1 => "\u2199", // SW + 2 => "\u2190", // W + 3 => "\u2196", // NW + 4 => "\u2191", // N + 5 => "\u2197", // NE + 6 => "\u2192", // E + 7 => "\u2198", // SE + _ => "\u2193", + }; + } + } +} diff --git a/MinecraftClient/Tui/MinimapEntityCategories.json b/MinecraftClient/Tui/MinimapEntityCategories.json new file mode 100644 index 00000000..c80b7c0b --- /dev/null +++ b/MinecraftClient/Tui/MinimapEntityCategories.json @@ -0,0 +1,167 @@ +{ + "version": "26.1-rc-2", + "hostile": [ + "Blaze", + "Bogged", + "Breeze", + "CamelHusk", + "Creaking", + "Creeper", + "Drowned", + "ElderGuardian", + "EnderDragon", + "Endermite", + "Evoker", + "Ghast", + "Giant", + "Guardian", + "Hoglin", + "Husk", + "Illusioner", + "MagmaCube", + "Parched", + "Phantom", + "Piglin", + "PiglinBrute", + "Pillager", + "Ravager", + "Shulker", + "Silverfish", + "Skeleton", + "Slime", + "Stray", + "Vex", + "Vindicator", + "Warden", + "Witch", + "Wither", + "WitherSkeleton", + "Zoglin", + "Zombie", + "ZombieNautilus", + "ZombieVillager" + ], + "passive": [ + "Allay", + "Armadillo", + "Axolotl", + "Bat", + "Camel", + "Cat", + "Chicken", + "Cod", + "Cow", + "Donkey", + "Fox", + "Frog", + "GlowSquid", + "HappyGhast", + "Horse", + "Mooshroom", + "Mule", + "Nautilus", + "Ocelot", + "Parrot", + "Pig", + "Pufferfish", + "Rabbit", + "Salmon", + "Sheep", + "SkeletonHorse", + "Sniffer", + "Squid", + "Strider", + "Tadpole", + "TropicalFish", + "Turtle", + "Villager", + "WanderingTrader", + "ZombieHorse" + ], + "neutral": [ + "Bee", + "CaveSpider", + "CopperGolem", + "Dolphin", + "Enderman", + "Goat", + "IronGolem", + "Llama", + "Panda", + "PolarBear", + "SnowGolem", + "Spider", + "TraderLlama", + "Wolf", + "ZombifiedPiglin" + ], + "non_living": [ + "AcaciaBoat", + "AcaciaChestBoat", + "AreaEffectCloud", + "ArmorStand", + "Arrow", + "BambooChestRaft", + "BambooRaft", + "BirchBoat", + "BirchChestBoat", + "BlockDisplay", + "BreezeWindCharge", + "CherryBoat", + "CherryChestBoat", + "ChestMinecart", + "CommandBlockMinecart", + "DarkOakBoat", + "DarkOakChestBoat", + "DragonFireball", + "Egg", + "EndCrystal", + "EnderPearl", + "EvokerFangs", + "ExperienceBottle", + "ExperienceOrb", + "EyeOfEnder", + "FallingBlock", + "Fireball", + "FireworkRocket", + "FishingBobber", + "FurnaceMinecart", + "GlowItemFrame", + "HopperMinecart", + "Interaction", + "Item", + "ItemDisplay", + "ItemFrame", + "JungleBoat", + "JungleChestBoat", + "LeashKnot", + "LightningBolt", + "LingeringPotion", + "LlamaSpit", + "MangroveBoat", + "MangroveChestBoat", + "Mannequin", + "Marker", + "Minecart", + "OakBoat", + "OakChestBoat", + "OminousItemSpawner", + "Painting", + "PaleOakBoat", + "PaleOakChestBoat", + "ShulkerBullet", + "SmallFireball", + "Snowball", + "SpawnerMinecart", + "SpectralArrow", + "SplashPotion", + "SpruceBoat", + "SpruceChestBoat", + "TextDisplay", + "Tnt", + "TntMinecart", + "Trident", + "WindCharge", + "WitherSkull" + ] +} \ No newline at end of file diff --git a/MinecraftClient/Tui/MinimapEntityClassifier.cs b/MinecraftClient/Tui/MinimapEntityClassifier.cs new file mode 100644 index 00000000..2daf1ea6 --- /dev/null +++ b/MinecraftClient/Tui/MinimapEntityClassifier.cs @@ -0,0 +1,178 @@ +using System; +using System.Collections.Frozen; +using System.Collections.Generic; +using System.Reflection; +using System.Text.Json; +using Avalonia.Media; +using MinecraftClient.Mapping; + +namespace MinecraftClient.Tui +{ + public enum MobCategory + { + Hostile, + Passive, + Neutral, + Player, + NonLiving, + } + + public enum MinimapPosition + { + top_left, + top_right, + center, + bottom_left, + bottom_right, + } + + public sealed class NameDisplayConfig + { + public volatile bool Players = false; + public volatile bool Hostile = false; + public volatile bool Neutral = false; + public volatile bool Passive = false; + + public bool AnyEnabled => Players || Hostile || Neutral || Passive; + + public void SetAll(bool value) + { + Players = value; + Hostile = value; + Neutral = value; + Passive = value; + } + + public bool ShouldShowName(MobCategory category) => category switch + { + MobCategory.Player => Players, + MobCategory.Hostile => Hostile, + MobCategory.Neutral => Neutral, + MobCategory.Passive => Passive, + _ => false, + }; + } + + /// + /// Classifies entities into minimap categories using data extracted from + /// Minecraft's MobCategory assignments. Categories are loaded from the + /// embedded MinimapEntityCategories.json resource generated by + /// tools/gen_entity_category_map.py. + /// + public static class MinimapEntityClassifier + { + public static readonly Color HostileColor = Color.FromRgb(255, 68, 68); + public static readonly Color PassiveColor = Color.FromRgb(68, 255, 68); + public static readonly Color NeutralColor = Color.FromRgb(255, 170, 0); + public static readonly Color PlayerColor = Color.FromRgb(255, 255, 255); + public static readonly Color FadedGray = Color.FromRgb(100, 100, 100); + + private static readonly FrozenDictionary CategoryTable; + + static MinimapEntityClassifier() + { + var table = new Dictionary(); + + try + { + using var stream = Assembly.GetExecutingAssembly() + .GetManifestResourceStream("MinimapEntityCategories.json"); + if (stream is not null) + { + using var doc = JsonDocument.Parse(stream); + var root = doc.RootElement; + + LoadCategory(root, "hostile", MobCategory.Hostile, table); + LoadCategory(root, "passive", MobCategory.Passive, table); + LoadCategory(root, "neutral", MobCategory.Neutral, table); + LoadCategory(root, "non_living", MobCategory.NonLiving, table); + } + } + catch (Exception ex) + { + ConsoleIO.WriteLogLine($"[Minimap] Failed to load entity categories: {ex.Message}"); + } + + CategoryTable = table.ToFrozenDictionary(); + } + + private static void LoadCategory(JsonElement root, string key, + MobCategory category, Dictionary table) + { + if (!root.TryGetProperty(key, out var arr)) + return; + + foreach (var el in arr.EnumerateArray()) + { + var name = el.GetString(); + if (name is not null && Enum.TryParse(name, out var et)) + table.TryAdd(et, category); + } + } + + public static MobCategory Classify(EntityType type) + { + if (type == EntityType.Player) + return MobCategory.Player; + return CategoryTable.GetValueOrDefault(type, MobCategory.NonLiving); + } + + public static Color GetBaseColor(MobCategory category) => category switch + { + MobCategory.Hostile => HostileColor, + MobCategory.Passive => PassiveColor, + MobCategory.Neutral => NeutralColor, + MobCategory.Player => PlayerColor, + _ => FadedGray, + }; + + public static Color ApplyDepthFade(Color baseColor, double playerY, double entityY) + { + double depth = playerY - entityY; + + if (depth <= 5.0) + return baseColor; + + if (depth >= 15.0) + return FadedGray; + + double t = (depth - 5.0) / 10.0; + return Lerp(baseColor, FadedGray, t); + } + + public static bool ShouldDisplay(MobCategory category, double playerY, double entityY) + { + if (category == MobCategory.Player) + return true; + if (entityY >= playerY) + return true; + return playerY - entityY <= 15.0; + } + + public static int GetPriority(MobCategory category) => category switch + { + MobCategory.Hostile => 4, + MobCategory.Player => 3, + MobCategory.Neutral => 2, + MobCategory.Passive => 1, + _ => 0, + }; + + public static string GetCategoryLabel(MobCategory category) => category switch + { + MobCategory.Hostile => Translations.tui_minimap_legend_hostile, + MobCategory.Passive => Translations.tui_minimap_legend_passive, + MobCategory.Neutral => Translations.tui_minimap_legend_neutral, + MobCategory.Player => Translations.tui_minimap_legend_player, + _ => "?", + }; + + private static Color Lerp(Color a, Color b, double t) + { + byte r = (byte)(a.R + (b.R - a.R) * t); + byte g = (byte)(a.G + (b.G - a.G) * t); + byte bl = (byte)(a.B + (b.B - a.B) * t); + return Color.FromRgb(r, g, bl); + } + } +} diff --git a/tools/README.md b/tools/README.md index 4dceae9c..7d33d2cf 100644 --- a/tools/README.md +++ b/tools/README.md @@ -135,6 +135,44 @@ Data source: `https://raw.githubusercontent.com/PrismarineJS/minecraft-data/mast Uses `curl` with resume (`-C -`) for reliable download over slow connections. Falls back to manual download if retries are exhausted. +## gen_block_color_map.py -- Generate minimap block color JSON + +Extracts block-to-MapColor RGB mappings from decompiled Minecraft source for the TUI minimap. + +```bash +python3 tools/gen_block_color_map.py MinecraftOfficial/26.1-rc-2-decompiled +# -> MinecraftClient/Tui/MinimapBlockColors.json +``` + +Parses three files from the decompiled source: +- `MapColor.java` -- extracts the 64 base MapColor constants and their RGB values +- `DyeColor.java` -- maps dye colors to MapColor constants +- `Blocks.java` -- determines each block's assigned MapColor via `.mapColor()` calls + +Output: `MinecraftClient/Tui/MinimapBlockColors.json` (embedded as a resource via `.csproj`). Contains color entries, plus lists of transparent, water, and ice materials. + +Validates each block name against MCC's `Material.cs` enum. Blocks without a matching enum value are skipped. + +## gen_entity_category_map.py -- Generate minimap entity category JSON + +Extracts entity-to-MobCategory mappings from decompiled Minecraft source for the TUI minimap. + +```bash +python3 tools/gen_entity_category_map.py MinecraftOfficial/26.1-rc-2-decompiled +# -> MinecraftClient/Tui/MinimapEntityCategories.json +``` + +Parses `EntityType.java` to read each entity's `MobCategory` assignment from the `EntityType.Builder.of(Factory, MobCategory.XXX)` call. Maps Minecraft categories to MCC minimap categories: +- `MONSTER` -> hostile +- `CREATURE`/`AMBIENT`/`AXOLOTLS`/`WATER_*` -> passive +- `MISC` -> non_living + +The script maintains manual override lists for: +- **Neutral mobs** (e.g. Enderman, Spider, Wolf, Bee) -- Minecraft has no "neutral" category; these are MONSTER or CREATURE in code but only attack when provoked +- **Passive overrides** (e.g. Villager, WanderingTrader) -- classified as MISC in Minecraft for spawning reasons but should appear as passive on the minimap + +Output: `MinecraftClient/Tui/MinimapEntityCategories.json` (embedded as a resource via `.csproj`). Validates each entity name against MCC's `EntityType.cs` enum. + ## Recommended workflow 1. Generate server reports (Step 0) @@ -145,6 +183,9 @@ Uses `curl` with resume (`-C -`) for reliable download over slow connections. Fa - Entities: `gen_entity_palette.py` - Metadata: `gen_entity_metadata_palette.py` 4. Update block collision shapes: `gen_block_shapes.py` -5. Add any missing enum values to `ItemType.cs`, `Material.cs`, `EntityType.cs`, `EntityMetaDataType.cs` -6. Update version routing (see SKILL.md) -7. Build and test +5. Update minimap data (if blocks or entities changed): + - Block colors: `gen_block_color_map.py` + - Entity categories: `gen_entity_category_map.py` +6. Add any missing enum values to `ItemType.cs`, `Material.cs`, `EntityType.cs`, `EntityMetaDataType.cs` +7. Update version routing (see SKILL.md) +8. Build and test diff --git a/tools/gen_block_color_map.py b/tools/gen_block_color_map.py new file mode 100644 index 00000000..69cbb502 --- /dev/null +++ b/tools/gen_block_color_map.py @@ -0,0 +1,268 @@ +#!/usr/bin/env python3 +""" +Generate MinimapBlockColors.json from decompiled Minecraft source. + +Parses MapColor.java for the 62 base map colors (ID -> RGB), then parses +Blocks.java to extract each block's mapColor assignment, and outputs a +JSON mapping from MCC Material enum names (PascalCase) to RGB triples. + +Usage: + python3 tools/gen_block_color_map.py + +Example: + python3 tools/gen_block_color_map.py MinecraftOfficial/26.1-rc-2-decompiled +""" + +import json +import re +import sys +from pathlib import Path + +OUTPUT_PATH = (Path(__file__).resolve().parent.parent + / "MinecraftClient" / "Tui" / "MinimapBlockColors.json") +MATERIAL_CS = (Path(__file__).resolve().parent.parent + / "MinecraftClient" / "Mapping" / "Material.cs") + + +def mc_name_to_csharp(mc_name: str) -> str: + name = mc_name.removeprefix("minecraft:") + return "".join(word.capitalize() for word in name.split("_")) + + +def parse_map_colors(map_color_java: Path) -> dict[str, tuple[int, int, int]]: + """Parse MapColor.java: extract name -> (R, G, B) for each constant.""" + text = map_color_java.read_text() + colors: dict[str, tuple[int, int, int]] = {} + + pattern = re.compile( + r'public static final MapColor\s+(\w+)\s*=\s*new\s+MapColor\(\s*(\d+)\s*,\s*(\d+)\s*\)') + for m in pattern.finditer(text): + name = m.group(1) + color_int = int(m.group(3)) + r = (color_int >> 16) & 0xFF + g = (color_int >> 8) & 0xFF + b = color_int & 0xFF + colors[name] = (r, g, b) + + return colors + + +def parse_dye_to_map_color(dye_color_java: Path) -> dict[str, str]: + """Parse DyeColor.java: extract DyeColor name -> MapColor name.""" + text = dye_color_java.read_text() + mapping: dict[str, str] = {} + + pattern = re.compile( + r'(\w+)\(\d+,\s*"[^"]+",\s*\d+,\s*MapColor\.(\w+)') + for m in pattern.finditer(text): + mapping[m.group(1)] = m.group(2) + + return mapping + + +def extract_block_declarations(text: str) -> list[tuple[str, str, str]]: + """Extract (field_name, block_id, full_register_body) for each block declaration. + + Returns list of (FIELD_NAME, "block_name", "register(...) content"). + """ + results = [] + + # Find all "public static final Block FIELD = register(...)" declarations. + # These span multiple lines and end with ");". + # Strategy: find start pattern, then track parens to find matching end. + field_pattern = re.compile( + r'public\s+static\s+final\s+Block\s+(\w+)\s*=\s*register\s*\(') + + pos = 0 + while pos < len(text): + m = field_pattern.search(text, pos) + if not m: + break + + field_name = m.group(1) + paren_start = m.end() - 1 # position of opening '(' + + # Find matching closing ')' then ';' + depth = 1 + i = paren_start + 1 + while i < len(text) and depth > 0: + if text[i] == '(': + depth += 1 + elif text[i] == ')': + depth -= 1 + i += 1 + + register_body = text[paren_start:i] + + # Extract block name string from register call + name_match = re.search(r'(?:BlockIds\.(\w+)|"(\w+)")', register_body) + if name_match: + raw_id = name_match.group(1) or name_match.group(2) + block_id = raw_id.lower() if raw_id.isupper() else raw_id + else: + block_id = field_name.lower() + + results.append((field_name, block_id, register_body)) + pos = i + + return results + + +def parse_blocks(blocks_java: Path, map_colors: dict[str, tuple[int, int, int]], + dye_to_map: dict[str, str]) -> dict[str, tuple[int, int, int]]: + """Parse Blocks.java: extract block_name -> (R, G, B).""" + text = blocks_java.read_text() + + declarations = extract_block_declarations(text) + print(f" Found {len(declarations)} block register() declarations") + + # First pass: assign MapColor name to each block + field_to_block_id: dict[str, str] = {} + block_color_name: dict[str, str] = {} + + map_color_direct = re.compile(r'\.mapColor\(MapColor\.(\w+)\)') + map_color_dye = re.compile(r'\.mapColor\(DyeColor\.(\w+)\)') + map_color_ref = re.compile(r'\.mapColor\((\w+)\.defaultMapColor\(\)') + map_color_waterlogged = re.compile(r'\.mapColor\(waterloggedMapColor\(MapColor\.(\w+)\)') + + for field_name, block_id, body in declarations: + field_to_block_id[field_name] = block_id + + mc = map_color_direct.search(body) + if mc: + block_color_name[block_id] = mc.group(1) + continue + + mc = map_color_dye.search(body) + if mc: + dye_name = mc.group(1) + if dye_name in dye_to_map: + block_color_name[block_id] = dye_to_map[dye_name] + continue + + mc = map_color_waterlogged.search(body) + if mc: + block_color_name[block_id] = mc.group(1) + continue + + mc = map_color_ref.search(body) + if mc: + ref_field = mc.group(1) + ref_block = field_to_block_id.get(ref_field) + if ref_block and ref_block in block_color_name: + block_color_name[block_id] = block_color_name[ref_block] + + # Second pass: resolve remaining BLOCK.defaultMapColor() references + for field_name, block_id, body in declarations: + if block_id in block_color_name: + continue + mc = map_color_ref.search(body) + if mc: + ref_field = mc.group(1) + ref_block = field_to_block_id.get(ref_field) + if ref_block and ref_block in block_color_name: + block_color_name[block_id] = block_color_name[ref_block] + + result: dict[str, tuple[int, int, int]] = {} + for block_id, color_name in block_color_name.items(): + if color_name in map_colors: + cs_name = mc_name_to_csharp(block_id) + result[cs_name] = map_colors[color_name] + + return result + + +def load_known_materials() -> set[str]: + known = set() + if MATERIAL_CS.exists(): + with open(MATERIAL_CS) as f: + for line in f: + m = re.match(r'\s+(\w+),?\s*$', line) + if m: + known.add(m.group(1)) + return known + + +TRANSPARENT_BLOCKS = [ + "Air", "CaveAir", "VoidAir", + "Glass", "GlassPane", + "WhiteStainedGlass", "OrangeStainedGlass", "MagentaStainedGlass", + "LightBlueStainedGlass", "YellowStainedGlass", "LimeStainedGlass", + "PinkStainedGlass", "GrayStainedGlass", "LightGrayStainedGlass", + "CyanStainedGlass", "PurpleStainedGlass", "BlueStainedGlass", + "BrownStainedGlass", "GreenStainedGlass", "RedStainedGlass", + "BlackStainedGlass", + "WhiteStainedGlassPane", "OrangeStainedGlassPane", "MagentaStainedGlassPane", + "LightBlueStainedGlassPane", "YellowStainedGlassPane", "LimeStainedGlassPane", + "PinkStainedGlassPane", "GrayStainedGlassPane", "LightGrayStainedGlassPane", + "CyanStainedGlassPane", "PurpleStainedGlassPane", "BlueStainedGlassPane", + "BrownStainedGlassPane", "GreenStainedGlassPane", "RedStainedGlassPane", + "BlackStainedGlassPane", + "TintedGlass", "Barrier", "Light", "StructureVoid", +] + +WATER_BLOCKS = ["Water"] +ICE_BLOCKS = ["Ice", "PackedIce", "BlueIce", "FrostedIce"] + + +def main(): + if len(sys.argv) != 2: + print(__doc__) + sys.exit(1) + + root = Path(sys.argv[1]) + if not root.is_dir(): + print(f"Error: {root} is not a directory") + sys.exit(1) + + map_color_java = root / "net/minecraft/world/level/material/MapColor.java" + dye_color_java = root / "net/minecraft/world/item/DyeColor.java" + blocks_java = root / "net/minecraft/world/level/block/Blocks.java" + + for f in [map_color_java, dye_color_java, blocks_java]: + if not f.exists(): + print(f"Error: {f} not found") + sys.exit(1) + + print("Parsing MapColor.java...") + map_colors = parse_map_colors(map_color_java) + print(f" Found {len(map_colors)} map colors") + + print("Parsing DyeColor.java...") + dye_to_map = parse_dye_to_map_color(dye_color_java) + print(f" Found {len(dye_to_map)} dye->map color mappings") + + print("Parsing Blocks.java...") + block_colors = parse_blocks(blocks_java, map_colors, dye_to_map) + print(f" Extracted colors for {len(block_colors)} blocks") + + known_materials = load_known_materials() + if known_materials: + matched = {k: v for k, v in block_colors.items() if k in known_materials} + unmatched = [k for k in block_colors if k not in known_materials] + if unmatched: + print(f"\n {len(unmatched)} blocks not in Material.cs (will be skipped):") + for name in sorted(unmatched)[:20]: + print(f" {name}") + if len(unmatched) > 20: + print(f" ... and {len(unmatched) - 20} more") + block_colors = matched + print(f" {len(block_colors)} blocks matched to Material.cs entries") + + output = { + "version": root.name.replace("-decompiled", "").replace("-client", ""), + "colors": {k: list(v) for k, v in sorted(block_colors.items())}, + "transparent": sorted(TRANSPARENT_BLOCKS), + "water": WATER_BLOCKS, + "ice": ICE_BLOCKS, + } + + OUTPUT_PATH.parent.mkdir(parents=True, exist_ok=True) + with open(OUTPUT_PATH, 'w') as f: + json.dump(output, f, indent=2) + print(f"\nGenerated {OUTPUT_PATH}") + print(f" {len(block_colors)} color entries") + + +if __name__ == "__main__": + main() diff --git a/tools/gen_entity_category_map.py b/tools/gen_entity_category_map.py new file mode 100644 index 00000000..e258d186 --- /dev/null +++ b/tools/gen_entity_category_map.py @@ -0,0 +1,200 @@ +#!/usr/bin/env python3 +""" +Generate MinimapEntityCategories.json from decompiled Minecraft source. + +Parses EntityType.java to extract each entity's MobCategory assignment, +then maps them to MCC minimap categories (hostile/passive/neutral/non_living). + +Minecraft's MobCategory values: + MONSTER -> hostile (with neutral overrides for conditionally hostile mobs) + CREATURE -> passive (with neutral overrides for conditionally hostile mobs) + AMBIENT -> passive + AXOLOTLS -> passive + WATER_CREATURE -> passive + WATER_AMBIENT -> passive + UNDERGROUND_WATER_CREATURE -> passive + MISC -> non_living + +Some mobs classified as MONSTER or CREATURE are actually "neutral" -- they +only attack when provoked. These are listed in NEUTRAL_OVERRIDES below and +should be updated when new conditionally-hostile mobs are added. + +Usage: + python3 tools/gen_entity_category_map.py + +Example: + python3 tools/gen_entity_category_map.py MinecraftOfficial/26.1-rc-2-decompiled +""" + +import json +import re +import sys +from pathlib import Path + +OUTPUT_PATH = (Path(__file__).resolve().parent.parent + / "MinecraftClient" / "Tui" / "MinimapEntityCategories.json") +ENTITY_TYPE_CS = (Path(__file__).resolve().parent.parent + / "MinecraftClient" / "Mapping" / "EntityType.cs") + + +def mc_name_to_csharp(mc_name: str) -> str: + name = mc_name.removeprefix("minecraft:") + return "".join(word.capitalize() for word in name.split("_")) + + +# Mobs that Minecraft classifies as MONSTER or CREATURE but behave as +# "neutral" -- they only attack when provoked. This list is maintained +# manually because there is no machine-readable flag in the game data. +NEUTRAL_OVERRIDES = { + "bee", "dolphin", "goat", "iron_golem", "llama", "panda", + "polar_bear", "snow_golem", "trader_llama", "wolf", + "zombified_piglin", "enderman", "spider", "cave_spider", + "copper_golem", +} + +# Entities whose MobCategory in the game code doesn't match how they +# should appear on the minimap. For example, Villager and WanderingTrader +# are MISC in MC code (for spawning reasons) but should be passive on the map. +# ZombieHorse is MONSTER but is a rideable passive mob in practice. +PASSIVE_OVERRIDES = { + "villager", "wandering_trader", "zombie_horse", +} + +# Player has its own category in MCC -- extracted from MISC to "player". +PLAYER_OVERRIDES = {"player"} + +MC_TO_MCC = { + "MONSTER": "hostile", + "CREATURE": "passive", + "AMBIENT": "passive", + "AXOLOTLS": "passive", + "WATER_CREATURE": "passive", + "WATER_AMBIENT": "passive", + "UNDERGROUND_WATER_CREATURE": "passive", + "MISC": "non_living", +} + + +def extract_entity_categories(entity_type_java: Path) -> list[tuple[str, str, str]]: + """Extract (entity_id, field_name, MobCategory) from EntityType.java. + + Returns list of (entity_id, FIELD_NAME, MobCategory_name). + """ + text = entity_type_java.read_text() + results = [] + + field_pat = re.compile( + r'public\s+static\s+final\s+EntityType<[^>]+>\s+(\w+)\s*=\s*register\s*\(') + + pos = 0 + while pos < len(text): + m = field_pat.search(text, pos) + if not m: + break + + field_name = m.group(1) + paren_start = m.end() - 1 + depth = 1 + i = paren_start + 1 + while i < len(text) and depth > 0: + if text[i] == '(': + depth += 1 + elif text[i] == ')': + depth -= 1 + i += 1 + + body = text[paren_start:i] + + name_match = re.search(r'"(\w+)"', body) + entity_id = name_match.group(1) if name_match else field_name.lower() + + cat_match = re.search(r'MobCategory\.(\w+)', body) + mob_cat = cat_match.group(1) if cat_match else "MISC" + + results.append((entity_id, field_name, mob_cat)) + pos = i + + return results + + +def load_known_entity_types() -> set[str]: + known = set() + if ENTITY_TYPE_CS.exists(): + with open(ENTITY_TYPE_CS) as f: + for line in f: + m = re.match(r'\s+(\w+),?\s*$', line) + if m: + known.add(m.group(1)) + return known + + +def main(): + if len(sys.argv) != 2: + print(__doc__) + sys.exit(1) + + root = Path(sys.argv[1]) + entity_type_java = root / "net/minecraft/world/entity/EntityType.java" + + if not entity_type_java.exists(): + print(f"Error: {entity_type_java} not found") + sys.exit(1) + + print("Parsing EntityType.java...") + entities = extract_entity_categories(entity_type_java) + print(f" Found {len(entities)} entity type declarations") + + known_types = load_known_entity_types() + + hostile = [] + passive = [] + neutral = [] + non_living = [] + + for entity_id, field_name, mob_cat in entities: + cs_name = mc_name_to_csharp(entity_id) + + if known_types and cs_name not in known_types: + continue + + if entity_id in PLAYER_OVERRIDES: + continue + elif entity_id in NEUTRAL_OVERRIDES: + neutral.append(cs_name) + elif entity_id in PASSIVE_OVERRIDES: + passive.append(cs_name) + elif mob_cat in MC_TO_MCC: + cat = MC_TO_MCC[mob_cat] + if cat == "hostile": + hostile.append(cs_name) + elif cat == "passive": + passive.append(cs_name) + elif cat == "non_living": + non_living.append(cs_name) + else: + non_living.append(cs_name) + else: + non_living.append(cs_name) + + output = { + "version": root.name.replace("-decompiled", "").replace("-client", ""), + "hostile": sorted(hostile), + "passive": sorted(passive), + "neutral": sorted(neutral), + "non_living": sorted(non_living), + } + + OUTPUT_PATH.parent.mkdir(parents=True, exist_ok=True) + with open(OUTPUT_PATH, 'w') as f: + json.dump(output, f, indent=2) + + print(f"\nGenerated {OUTPUT_PATH}") + print(f" hostile: {len(hostile)}") + print(f" passive: {len(passive)}") + print(f" neutral: {len(neutral)}") + print(f" non_living: {len(non_living)}") + print(f" total: {len(hostile) + len(passive) + len(neutral) + len(non_living)}") + + +if __name__ == "__main__": + main() From a1516e96806a99242b17231c9f9de14ba4159e98 Mon Sep 17 00:00:00 2001 From: BruceChen Date: Sun, 29 Mar 2026 18:48:00 +0800 Subject: [PATCH 2/4] Add message aggregation and relay options for DiscordBridge - Introduced message aggregation functionality with a configurable interval to reduce Discord API rate limits. - Added options to relay all messages from Minecraft, including system messages, to Discord. - Updated configuration comments to reflect new settings and their purposes. --- MinecraftClient/ChatBots/DiscordBridge.cs | 71 +++++++++++++++++-- .../ConfigComments/ConfigComments.resx | 8 ++- 2 files changed, 74 insertions(+), 5 deletions(-) diff --git a/MinecraftClient/ChatBots/DiscordBridge.cs b/MinecraftClient/ChatBots/DiscordBridge.cs index fa13f84e..3938ab6a 100644 --- a/MinecraftClient/ChatBots/DiscordBridge.cs +++ b/MinecraftClient/ChatBots/DiscordBridge.cs @@ -1,8 +1,11 @@ -using System; +using System; +using System.Collections.Concurrent; using System.Collections.Generic; using System.IO; using System.Linq; +using System.Text; using System.Text.RegularExpressions; +using System.Threading; using System.Threading.Tasks; using Brigadier.NET.Builder; using DSharpPlus; @@ -34,6 +37,9 @@ namespace MinecraftClient.ChatBots private DiscordChannel? discordChannel; private BridgeDirection bridgeDirection = BridgeDirection.Both; + private readonly ConcurrentQueue aggregationBuffer = new(); + private Timer? aggregationTimer; + public static Configs Config = new(); [TomlDoNotInlineObject] @@ -62,6 +68,12 @@ namespace MinecraftClient.ChatBots [TomlInlineComment("$ChatBot.DiscordBridge.AllowOtherBotMessages$")] public bool Allow_Other_Bot_Messages = false; + [TomlInlineComment("$ChatBot.DiscordBridge.RelayAllMessages$")] + public bool Relay_All_Messages = false; + + [TomlInlineComment("$ChatBot.DiscordBridge.MessageAggregationInterval$")] + public double Message_Aggregation_Interval = 3.0; + [TomlPrecedingComment("$ChatBot.DiscordBridge.Formats$")] public string PrivateMessageFormat = "**[Private Message]** {username}: {message}"; public string PublicMessageFormat = "{username}: {message}"; @@ -70,6 +82,8 @@ namespace MinecraftClient.ChatBots public void OnSettingUpdate() { Message_Send_Timeout = Message_Send_Timeout <= 0 ? 3 : Message_Send_Timeout; + if (Message_Aggregation_Interval < 0) + Message_Aggregation_Interval = 0; } } @@ -100,6 +114,12 @@ namespace MinecraftClient.ChatBots .Redirect(McClient.dispatcher.GetRoot().GetChild("help").GetChild(CommandName))) ); + if (Config.Message_Aggregation_Interval > 0) + { + var intervalMs = (int)(Config.Message_Aggregation_Interval * 1000); + aggregationTimer = new Timer(_ => FlushAggregationBuffer(), null, intervalMs, intervalMs); + } + Task.Run(async () => await MainAsync()); } @@ -107,6 +127,7 @@ namespace MinecraftClient.ChatBots { McClient.dispatcher.Unregister(CommandName); McClient.dispatcher.GetRoot().GetChild("help").RemoveChild(CommandName); + StopAggregation(); Disconnect(); } @@ -147,6 +168,40 @@ namespace MinecraftClient.ChatBots return r.SetAndReturn(CmdResult.Status.Done, string.Format(Translations.bot_DiscordBridge_direction, bridgeName)); } + private void FlushAggregationBuffer() + { + if (aggregationBuffer.IsEmpty || !CanSendMessages()) + return; + + var sb = new StringBuilder(); + while (aggregationBuffer.TryDequeue(out var line)) + { + if (sb.Length + line.Length + 1 > 1900) + { + SendMessage(sb.ToString()); + sb.Clear(); + } + + if (sb.Length > 0) + sb.AppendLine(); + sb.Append(line); + } + + if (sb.Length > 0) + SendMessage(sb.ToString()); + } + + private void StopAggregation() + { + if (aggregationTimer is not null) + { + aggregationTimer.Dispose(); + aggregationTimer = null; + } + + FlushAggregationBuffer(); + } + ~DiscordBridge() { Disconnect(); @@ -188,7 +243,6 @@ namespace MinecraftClient.ChatBots text = GetVerbatim(text).Trim(); - // Stop the crash when an empty text is recived somehow if (string.IsNullOrEmpty(text)) return; @@ -205,7 +259,10 @@ namespace MinecraftClient.ChatBots message = Config.TeleportRequestMessageFormat.Replace("{username}", username).Replace("{timestamp}", GetTimestamp()).Trim(); teleportRequest = true; } - else message = text; + else if (Config.Relay_All_Messages) + message = text; + else + return; if (teleportRequest) { @@ -223,7 +280,13 @@ namespace MinecraftClient.ChatBots SendMessage(messageBuilder); return; } - else SendMessage(GetDiscordText(message)); + + string discordText = GetDiscordText(message); + + if (Config.Message_Aggregation_Interval > 0) + aggregationBuffer.Enqueue(discordText); + else + SendMessage(discordText); } /// diff --git a/MinecraftClient/Resources/ConfigComments/ConfigComments.resx b/MinecraftClient/Resources/ConfigComments/ConfigComments.resx index d4b82e42..2b09765e 100644 --- a/MinecraftClient/Resources/ConfigComments/ConfigComments.resx +++ b/MinecraftClient/Resources/ConfigComments/ConfigComments.resx @@ -393,6 +393,12 @@ For Discord message formatting, check the following: https://mccteam.github.io/r When enabled, messages from other Discord bots in the channel will be relayed to Minecraft chat. The bridge always ignores its own messages to prevent loops. + + When enabled, all text received from the Minecraft server (including system messages, join/leave notifications, etc.) will be relayed to Discord, not just player chat and private messages. + + + Interval in seconds to aggregate messages before sending them to Discord. When set to 0, messages are sent immediately one by one. When set to a value like 1.0, messages received within that interval are batched into a single Discord message. Useful for reducing Discord API rate limits. + Automatically farms crops for you (plants, breaks and bonemeals them). Crop types available: Beetroot, Carrot, Melon, Netherwart, Pumpkin, Potato, Wheat. @@ -964,7 +970,7 @@ Note: This does NOT require a Bot Token, only an Application ID. Discord must be Show passive mob names on the minimap. - Minimap refresh interval in milliseconds (200-5000, default 1000). + Minimap refresh interval in milliseconds (100-5000). Yggdrasil authlib multi-user selection. From 0566f2518b6d0b6837f254973b8ef8ef1bab94ac Mon Sep 17 00:00:00 2001 From: BruceChen Date: Sun, 29 Mar 2026 19:16:33 +0800 Subject: [PATCH 3/4] Add tooltip functionality to MinimapControl - Introduced a tooltip system for displaying entity information on the minimap. - Enhanced the SampleResult class to include entity mapping and block type summaries. - Updated the rendering logic to incorporate tooltips and improve user interaction with the minimap. --- MinecraftClient/Tui/MinimapControl.cs | 354 ++++++++++++++++++++++++-- 1 file changed, 339 insertions(+), 15 deletions(-) diff --git a/MinecraftClient/Tui/MinimapControl.cs b/MinecraftClient/Tui/MinimapControl.cs index 44db58ba..1e93c390 100644 --- a/MinecraftClient/Tui/MinimapControl.cs +++ b/MinecraftClient/Tui/MinimapControl.cs @@ -5,6 +5,7 @@ using System.Threading; using System.Threading.Tasks; using Avalonia; using Avalonia.Controls; +using Avalonia.Input; using Avalonia.Layout; using Avalonia.Media; using Avalonia.Threading; @@ -44,6 +45,13 @@ namespace MinecraftClient.Tui private readonly Grid _mapGrid; private readonly DispatcherTimer _timer; + private readonly Canvas _tooltipCanvas; + private readonly Border _tooltipBorder; + private readonly StackPanel _tooltipContent; + private SampleResult? _lastResult; + private int _hoverCol = -1; + private int _hoverRow = -1; + public int BlocksPerPixel { get => _blocksPerPixel; @@ -75,14 +83,40 @@ namespace MinecraftClient.Tui _infoRow = new StackPanel { Orientation = Orientation.Horizontal }; _legendPanel = new StackPanel { Orientation = Orientation.Horizontal }; + _tooltipContent = new StackPanel { Orientation = Orientation.Vertical }; + _tooltipBorder = new Border + { + Background = new SolidColorBrush(Color.FromArgb(230, 20, 20, 20)), + BorderBrush = new SolidColorBrush(Color.FromRgb(120, 120, 120)), + BorderThickness = new Thickness(1), + Padding = new Thickness(1), + Child = _tooltipContent, + IsVisible = false, + }; + + _tooltipCanvas = new Canvas + { + IsHitTestVisible = false, + Children = { _tooltipBorder }, + }; + + var mapLayer = new Panel + { + ClipToBounds = true, + Children = { _mapGrid, _tooltipCanvas }, + }; + var root = new StackPanel { Orientation = Orientation.Vertical, - Children = { _mapGrid, _infoRow, _legendPanel }, + Children = { mapLayer, _infoRow, _legendPanel }, }; Content = root; + _mapGrid.PointerMoved += OnMapPointerMoved; + _mapGrid.PointerExited += OnMapPointerExited; + _timer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(DefaultRefreshMs), @@ -198,12 +232,29 @@ namespace MinecraftClient.Tui public int PixelY; } + internal sealed class PixelEntityInfo + { + public string Name = ""; + public MobCategory Category; + public float Health; + public float MaxHealth; + public int Priority; + } + private sealed class SampleResult { public Color[,] Pixels = null!; public (char Ch, Color Fg, Color Bg)?[,] CharOverlay = null!; public HashSet VisibleCategories = []; public int[,] Heights = null!; + public Material[,]? BlockTypes; + public List<(Material Mat, int Count)>?[,]? BlockSummary; + public List?[,]? EntityMap; + public int PlayerBlockX; + public int PlayerBlockZ; + public int CenterX; + public int CenterY; + public int Bpp; } private static bool ShouldShowNameLocal(MobCategory cat, @@ -228,6 +279,10 @@ namespace MinecraftClient.Tui Pixels = new Color[mapW, mapH], CharOverlay = new (char, Color, Color)?[mapW, mapH / 2], Heights = new int[mapW, mapH], + EntityMap = new List?[mapW, mapH], + BlockTypes = bpp == 1 ? new Material[mapW, mapH] : null, + BlockSummary = bpp > 1 ? new List<(Material, int)>?[mapW, mapH] : null, + Bpp = bpp, }; var world = client.GetWorld(); var playerLoc = client.GetCurrentLocation(); @@ -236,6 +291,11 @@ namespace MinecraftClient.Tui int playerBlockZ = (int)Math.Floor(playerLoc.Z); int playerBlockY = (int)Math.Floor(playerLoc.Y); + result.PlayerBlockX = playerBlockX; + result.PlayerBlockZ = playerBlockZ; + result.CenterX = mapW / 2; + result.CenterY = mapH / 2; + var dim = World.GetDimension(); int minY = dim.minY; int scanTop = Math.Min(playerBlockY + 32, dim.maxY - 1); @@ -286,6 +346,17 @@ namespace MinecraftClient.Tui result.VisibleCategories.Add(cat); + string eName = ResolveEntityName(client, entity, cat, uuidNameMap); + var pixelList = result.EntityMap![px, py] ??= []; + pixelList.Add(new PixelEntityInfo + { + Name = eName, + Category = cat, + Health = entity.Health, + MaxHealth = -1, + Priority = priority, + }); + if (ShouldShowNameLocal(cat, showPlayers, showHostile, showNeutral, showPassive)) { string name = ResolveEntityName(client, entity, cat, uuidNameMap); @@ -303,6 +374,16 @@ namespace MinecraftClient.Tui entityPixels[(centerX, centerY)] = (MinimapEntityClassifier.PlayerColor, 5); result.VisibleCategories.Add(MobCategory.Player); + var selfList = result.EntityMap![centerX, centerY] ??= []; + selfList.Add(new PixelEntityInfo + { + Name = client.GetUsername(), + Category = MobCategory.Player, + Health = client.GetHealth(), + MaxHealth = 20f, + Priority = 5, + }); + ChunkColumn? cachedColumn = null; int cachedChunkX = int.MinValue, cachedChunkZ = int.MinValue; @@ -317,17 +398,21 @@ namespace MinecraftClient.Tui if (bpp == 1) { - var (color, surfY) = SampleColumn(world, baseX, baseZ, scanTop, minY, + var (color, surfY, surfMat) = SampleColumn(world, baseX, baseZ, scanTop, minY, ref cachedColumn, ref cachedChunkX, ref cachedChunkZ); result.Pixels[px, py] = color; result.Heights[px, py] = surfY; + result.BlockTypes![px, py] = surfMat; } else { - var (color, surfY) = SampleAreaDominant(world, baseX, baseZ, bpp, scanTop, minY, + var (color, surfY, matSum) = SampleAreaDominant(world, baseX, baseZ, bpp, + scanTop, minY, result.BlockSummary is not null, ref cachedColumn, ref cachedChunkX, ref cachedChunkZ); result.Pixels[px, py] = color; result.Heights[px, py] = surfY; + if (result.BlockSummary is not null) + result.BlockSummary[px, py] = matSum; } } } @@ -447,7 +532,7 @@ namespace MinecraftClient.Tui } } - private static (Color color, int surfaceY) SampleColumn(World world, int x, int z, + private static (Color color, int surfaceY, Material surfaceMat) SampleColumn(World world, int x, int z, int scanTop, int minY, ref ChunkColumn? cachedColumn, ref int cachedChunkX, ref int cachedChunkZ) { @@ -461,11 +546,12 @@ namespace MinecraftClient.Tui } if (cachedColumn is null) - return (MinimapColorMap.VoidColor, minY); + return (MinimapColorMap.VoidColor, minY, Material.Air); int waterDepth = 0; bool inIce = false; int surfaceY = minY; + Material topMat = Material.Air; for (int y = scanTop; y >= minY; y--) { @@ -481,19 +567,19 @@ namespace MinecraftClient.Tui if (MinimapColorMap.IsWater(mat)) { - if (waterDepth == 0) surfaceY = y; + if (waterDepth == 0) { surfaceY = y; topMat = mat; } waterDepth++; continue; } if (MinimapColorMap.IsIce(mat) && !inIce) { - if (waterDepth == 0) surfaceY = y; + if (waterDepth == 0) { surfaceY = y; topMat = mat; } inIce = true; continue; } - if (waterDepth == 0 && !inIce) surfaceY = y; + if (waterDepth == 0 && !inIce) { surfaceY = y; topMat = mat; } var baseColor = MinimapColorMap.GetBaseColor(mat); @@ -502,33 +588,43 @@ namespace MinecraftClient.Tui if (inIce) baseColor = MinimapColorMap.BlendIceColor(baseColor); - return (baseColor, surfaceY); + return (baseColor, surfaceY, topMat); } if (waterDepth > 0) - return (MinimapColorMap.WaterColor, surfaceY); + return (MinimapColorMap.WaterColor, surfaceY, topMat); - return (MinimapColorMap.VoidColor, minY); + return (MinimapColorMap.VoidColor, minY, Material.Air); } - private static (Color color, int surfaceY) SampleAreaDominant(World world, int baseX, int baseZ, - int size, int scanTop, int minY, + private static (Color color, int surfaceY, List<(Material Mat, int Count)>? matSummary) + SampleAreaDominant(World world, int baseX, int baseZ, + int size, int scanTop, int minY, bool collectMats, ref ChunkColumn? cachedColumn, ref int cachedChunkX, ref int cachedChunkZ) { var colorCounts = new Dictionary(); + Dictionary? matCounts = collectMats ? [] : null; int step = Math.Max(1, size / 3); for (int dx = 0; dx < size; dx += step) { for (int dz = 0; dz < size; dz += step) { - var (c, surfY) = SampleColumn(world, baseX + dx, baseZ + dz, scanTop, minY, + var (c, surfY, surfMat) = SampleColumn(world, baseX + dx, baseZ + dz, scanTop, minY, ref cachedColumn, ref cachedChunkX, ref cachedChunkZ); if (colorCounts.TryGetValue(c, out var existing)) colorCounts[c] = (existing.Count + 1, existing.SumY + surfY); else colorCounts[c] = (1, surfY); + + if (matCounts is not null) + { + if (matCounts.TryGetValue(surfMat, out int mc)) + matCounts[surfMat] = mc + 1; + else + matCounts[surfMat] = 1; + } } } @@ -544,7 +640,17 @@ namespace MinecraftClient.Tui avgY = kvp.Value.SumY / kvp.Value.Count; } } - return (best, avgY); + + List<(Material, int)>? summary = null; + if (matCounts is not null && matCounts.Count > 0) + { + summary = matCounts + .OrderByDescending(kv => kv.Value) + .Select(kv => (kv.Key, kv.Value)) + .ToList(); + } + + return (best, avgY, summary); } private void ApplyPixelBuffer(SampleResult result, int w, int h) @@ -573,6 +679,224 @@ namespace MinecraftClient.Tui } } } + + _lastResult = result; + + if (_hoverCol >= 0 && _hoverRow >= 0) + UpdateTooltip(_hoverCol, _hoverRow); + } + + private void OnMapPointerMoved(object? sender, PointerEventArgs e) + { + var pos = e.GetPosition(_mapGrid); + int col = (int)pos.X; + int row = (int)pos.Y; + + if (col < 0 || col >= _mapWidth || row < 0 || row >= _cellRows) + { + HideTooltip(); + return; + } + + _hoverCol = col; + _hoverRow = row; + UpdateTooltip(col, row); + } + + private void OnMapPointerExited(object? sender, PointerEventArgs e) + { + HideTooltip(); + } + + private void HideTooltip() + { + _hoverCol = -1; + _hoverRow = -1; + _tooltipBorder.IsVisible = false; + } + + private void UpdateTooltip(int col, int row) + { + var result = _lastResult; + if (result is null) { _tooltipBorder.IsVisible = false; return; } + + int bpp = result.Bpp; + int centerX = result.CenterX; + int centerY = result.CenterY; + + int topPixelY = row * 2; + int botPixelY = row * 2 + 1; + + int baseX = result.PlayerBlockX + (col - centerX) * bpp; + int baseZ_top = result.PlayerBlockZ + (topPixelY - centerY) * bpp; + int baseZ_bot = result.PlayerBlockZ + (botPixelY - centerY) * bpp; + + _tooltipContent.Children.Clear(); + + if (bpp == 1) + { + int surfY_top = (topPixelY < result.Heights.GetLength(1)) ? result.Heights[col, topPixelY] : 0; + int surfY_bot = (botPixelY < result.Heights.GetLength(1)) ? result.Heights[col, botPixelY] : 0; + + string coordLine = baseZ_top == baseZ_bot + ? $"{baseX}, {surfY_top}, {baseZ_top}" + : $"{baseX}, {surfY_top}, {baseZ_top} / {baseX}, {surfY_bot}, {baseZ_bot}"; + _tooltipContent.Children.Add(MakeTooltipText(coordLine, Brushes.White)); + + if (result.BlockTypes is not null) + { + var mat_top = result.BlockTypes[col, topPixelY]; + var mat_bot = (botPixelY < result.BlockTypes.GetLength(1)) + ? result.BlockTypes[col, botPixelY] : mat_top; + string blockLine = mat_top == mat_bot + ? FormatMaterialName(mat_top) + : $"{FormatMaterialName(mat_top)} / {FormatMaterialName(mat_bot)}"; + _tooltipContent.Children.Add(MakeTooltipText(blockLine, Brushes.LightGray)); + } + } + else + { + int endX = baseX + bpp - 1; + int endZ_bot = baseZ_bot + bpp - 1; + string coordLine = $"X {baseX}~{endX} Z {baseZ_top}~{endZ_bot}"; + _tooltipContent.Children.Add(MakeTooltipText(coordLine, Brushes.White)); + + AppendBlockSummary(result, col, topPixelY, botPixelY); + } + + AppendEntityInfo(result, col, topPixelY, botPixelY); + + if (_tooltipContent.Children.Count == 0) + { + _tooltipBorder.IsVisible = false; + return; + } + + int maxTipW = Math.Max(10, _mapWidth / 2 - 2); + _tooltipBorder.MaxWidth = maxTipW; + _tooltipBorder.MaxHeight = _cellRows; + + bool showRight = col < _mapWidth / 2; + int tipX = showRight ? col + 2 : Math.Max(0, col - maxTipW - 1); + int tipY = Math.Clamp(row, 0, _cellRows - 1); + + Canvas.SetLeft(_tooltipBorder, tipX); + Canvas.SetTop(_tooltipBorder, tipY); + _tooltipBorder.IsVisible = true; + } + + private void AppendBlockSummary(SampleResult result, int col, int topPy, int botPy) + { + if (result.BlockSummary is null) return; + + var merged = new Dictionary(); + MergeBlockCounts(result.BlockSummary, col, topPy, merged); + if (botPy < result.BlockSummary.GetLength(1)) + MergeBlockCounts(result.BlockSummary, col, botPy, merged); + + if (merged.Count == 0) return; + + var sorted = merged.OrderByDescending(kv => kv.Value).Take(4); + int totalSamples = 0; + foreach (var kv in merged) totalSamples += kv.Value; + + var parts = new List(); + foreach (var kv in sorted) + { + if (kv.Key == Material.Air && merged.Count > 1) continue; + parts.Add(kv.Value > 1 + ? $"{FormatMaterialName(kv.Key)} x{kv.Value}" + : FormatMaterialName(kv.Key)); + } + + if (parts.Count == 0) return; + + string line = string.Join(", ", parts); + _tooltipContent.Children.Add(MakeTooltipText(line, Brushes.LightGray)); + } + + private static void MergeBlockCounts(List<(Material Mat, int Count)>?[,] summary, + int px, int py, Dictionary target) + { + var list = summary[px, py]; + if (list is null) return; + foreach (var (mat, count) in list) + { + if (target.TryGetValue(mat, out int c)) + target[mat] = c + count; + else + target[mat] = count; + } + } + + private void AppendEntityInfo(SampleResult result, int col, int topPy, int botPy) + { + var entityMap = result.EntityMap; + if (entityMap is null) return; + + var combined = new List(); + AddEntitiesFromPixel(entityMap, col, topPy, combined); + if (botPy < entityMap.GetLength(1)) + AddEntitiesFromPixel(entityMap, col, botPy, combined); + + if (combined.Count == 0) return; + + combined.Sort((a, b) => b.Priority.CompareTo(a.Priority)); + int shown = 0; + var seen = new HashSet(); + foreach (var ent in combined) + { + if (shown >= 4) break; + string key = $"{ent.Name}_{ent.Health:F0}"; + if (!seen.Add(key)) continue; + + var catColor = MinimapEntityClassifier.GetBaseColor(ent.Category); + string hpStr; + if (ent.Health > 0) + { + hpStr = ent.MaxHealth > 0 + ? $" HP:{ent.Health:F0}/{ent.MaxHealth:F0}" + : $" HP:{ent.Health:F0}"; + } + else + hpStr = ""; + + _tooltipContent.Children.Add(MakeTooltipText( + $"{ent.Name}{hpStr}", + new SolidColorBrush(catColor))); + shown++; + } + } + + private static void AddEntitiesFromPixel(List?[,] map, + int px, int py, List target) + { + if (px >= 0 && px < map.GetLength(0) && py >= 0 && py < map.GetLength(1)) + { + var list = map[px, py]; + if (list is not null) + target.AddRange(list); + } + } + + private static TextBlock MakeTooltipText(string text, IBrush foreground) + { + return new TextBlock + { + Text = text, + Foreground = foreground, + TextWrapping = TextWrapping.Wrap, + Padding = new Thickness(0), + Margin = new Thickness(0), + FontSize = 1, + }; + } + + private static string FormatMaterialName(Material mat) + { + if (mat == Material.Air) return "Air"; + string raw = mat.ToString(); + return raw.Replace('_', ' '); } private void UpdateInfoBarAndLegend(McClient client, int bpp, From d427a6e16080a63160ab0a756625a4cacc6dcaec Mon Sep 17 00:00:00 2001 From: BruceChen Date: Sun, 29 Mar 2026 19:53:31 +0800 Subject: [PATCH 4/4] Add tooltip service and integrate with MinimapControl - Introduced TuiTooltipService for managing tooltips across TUI components. - Updated MinimapControl to utilize the new tooltip service for enhanced entity information display. - Refactored tooltip rendering logic to improve visibility and interaction based on mouse position. --- MinecraftClient/Tui/MainTuiView.cs | 9 ++ MinecraftClient/Tui/MinimapControl.cs | 144 +++++++++++------------ MinecraftClient/Tui/TuiTooltipService.cs | 114 ++++++++++++++++++ 3 files changed, 194 insertions(+), 73 deletions(-) create mode 100644 MinecraftClient/Tui/TuiTooltipService.cs diff --git a/MinecraftClient/Tui/MainTuiView.cs b/MinecraftClient/Tui/MainTuiView.cs index 1cde95e4..34ff06db 100644 --- a/MinecraftClient/Tui/MainTuiView.cs +++ b/MinecraftClient/Tui/MainTuiView.cs @@ -45,6 +45,8 @@ namespace MinecraftClient.Tui private readonly MinimapControl _minimapControl; private volatile bool _minimapVisible; + private TuiTooltipService? _tooltipService; + private readonly Border _suggestionBorder; private readonly StackPanel _suggestionPanel; private CommandSuggestion[] _suggestions = Array.Empty(); @@ -57,6 +59,8 @@ namespace MinecraftClient.Tui private int MaxVisibleSuggestions => Math.Max(1, Settings.Config.Console.CommandSuggestion.Max_Displayed_Suggestions); + public TuiTooltipService? TooltipService => _tooltipService; + public MainTuiView() { Background = Brushes.Black; @@ -194,6 +198,10 @@ namespace MinecraftClient.Tui Children = { _mainContent, _minimapBorder, _notificationBorder, _suggestionBorder } }; + _tooltipService = new TuiTooltipService(_rootPanel); + _minimapControl.TooltipService = _tooltipService; + _minimapControl.Position = mmCfg.Position; + Content = _rootPanel; if (mmCfg.Enabled) @@ -1083,6 +1091,7 @@ namespace MinecraftClient.Tui _minimapBorder.HorizontalAlignment = hAlign; _minimapBorder.VerticalAlignment = vAlign; _minimapBorder.Margin = margin; + _minimapControl.Position = pos; Settings.Config.Console.Minimap.Position = pos; } diff --git a/MinecraftClient/Tui/MinimapControl.cs b/MinecraftClient/Tui/MinimapControl.cs index 1e93c390..33f25465 100644 --- a/MinecraftClient/Tui/MinimapControl.cs +++ b/MinecraftClient/Tui/MinimapControl.cs @@ -45,12 +45,11 @@ namespace MinecraftClient.Tui private readonly Grid _mapGrid; private readonly DispatcherTimer _timer; - private readonly Canvas _tooltipCanvas; - private readonly Border _tooltipBorder; - private readonly StackPanel _tooltipContent; private SampleResult? _lastResult; private int _hoverCol = -1; private int _hoverRow = -1; + private double _hoverGlobalX; + private double _hoverGlobalY; public int BlocksPerPixel { @@ -60,6 +59,10 @@ namespace MinecraftClient.Tui public NameDisplayConfig NameConfig => _nameConfig; + public TuiTooltipService? TooltipService { get; set; } + + public MinimapPosition Position { get; set; } = MinimapPosition.top_right; + public int MapPixelWidth => _mapWidth; public int MapPixelHeight => _mapHeight; @@ -83,33 +86,10 @@ namespace MinecraftClient.Tui _infoRow = new StackPanel { Orientation = Orientation.Horizontal }; _legendPanel = new StackPanel { Orientation = Orientation.Horizontal }; - _tooltipContent = new StackPanel { Orientation = Orientation.Vertical }; - _tooltipBorder = new Border - { - Background = new SolidColorBrush(Color.FromArgb(230, 20, 20, 20)), - BorderBrush = new SolidColorBrush(Color.FromRgb(120, 120, 120)), - BorderThickness = new Thickness(1), - Padding = new Thickness(1), - Child = _tooltipContent, - IsVisible = false, - }; - - _tooltipCanvas = new Canvas - { - IsHitTestVisible = false, - Children = { _tooltipBorder }, - }; - - var mapLayer = new Panel - { - ClipToBounds = true, - Children = { _mapGrid, _tooltipCanvas }, - }; - var root = new StackPanel { Orientation = Orientation.Vertical, - Children = { mapLayer, _infoRow, _legendPanel }, + Children = { _mapGrid, _infoRow, _legendPanel }, }; Content = root; @@ -236,6 +216,7 @@ namespace MinecraftClient.Tui { public string Name = ""; public MobCategory Category; + public double X, Y, Z; public float Health; public float MaxHealth; public int Priority; @@ -352,6 +333,9 @@ namespace MinecraftClient.Tui { Name = eName, Category = cat, + X = entity.Location.X, + Y = entity.Location.Y, + Z = entity.Location.Z, Health = entity.Health, MaxHealth = -1, Priority = priority, @@ -379,6 +363,9 @@ namespace MinecraftClient.Tui { Name = client.GetUsername(), Category = MobCategory.Player, + X = playerLoc.X, + Y = playerLoc.Y, + Z = playerLoc.Z, Health = client.GetHealth(), MaxHealth = 20f, Priority = 5, @@ -700,6 +687,19 @@ namespace MinecraftClient.Tui _hoverCol = col; _hoverRow = row; + + if (this.VisualRoot is Visual root + && _mapGrid.TranslatePoint(pos, root) is { } gp) + { + _hoverGlobalX = gp.X; + _hoverGlobalY = gp.Y; + } + else + { + _hoverGlobalX = pos.X; + _hoverGlobalY = pos.Y; + } + UpdateTooltip(col, row); } @@ -712,13 +712,14 @@ namespace MinecraftClient.Tui { _hoverCol = -1; _hoverRow = -1; - _tooltipBorder.IsVisible = false; + TooltipService?.Hide(); } private void UpdateTooltip(int col, int row) { + var svc = TooltipService; var result = _lastResult; - if (result is null) { _tooltipBorder.IsVisible = false; return; } + if (svc is null || result is null) { svc?.Hide(); return; } int bpp = result.Bpp; int centerX = result.CenterX; @@ -731,7 +732,7 @@ namespace MinecraftClient.Tui int baseZ_top = result.PlayerBlockZ + (topPixelY - centerY) * bpp; int baseZ_bot = result.PlayerBlockZ + (botPixelY - centerY) * bpp; - _tooltipContent.Children.Clear(); + var lines = new List(); if (bpp == 1) { @@ -741,7 +742,7 @@ namespace MinecraftClient.Tui string coordLine = baseZ_top == baseZ_bot ? $"{baseX}, {surfY_top}, {baseZ_top}" : $"{baseX}, {surfY_top}, {baseZ_top} / {baseX}, {surfY_bot}, {baseZ_bot}"; - _tooltipContent.Children.Add(MakeTooltipText(coordLine, Brushes.White)); + lines.Add(new TuiTooltipLine { Text = coordLine, Foreground = Brushes.White }); if (result.BlockTypes is not null) { @@ -751,7 +752,7 @@ namespace MinecraftClient.Tui string blockLine = mat_top == mat_bot ? FormatMaterialName(mat_top) : $"{FormatMaterialName(mat_top)} / {FormatMaterialName(mat_bot)}"; - _tooltipContent.Children.Add(MakeTooltipText(blockLine, Brushes.LightGray)); + lines.Add(new TuiTooltipLine { Text = blockLine, Foreground = Brushes.LightGray }); } } else @@ -759,33 +760,40 @@ namespace MinecraftClient.Tui int endX = baseX + bpp - 1; int endZ_bot = baseZ_bot + bpp - 1; string coordLine = $"X {baseX}~{endX} Z {baseZ_top}~{endZ_bot}"; - _tooltipContent.Children.Add(MakeTooltipText(coordLine, Brushes.White)); + lines.Add(new TuiTooltipLine { Text = coordLine, Foreground = Brushes.White }); - AppendBlockSummary(result, col, topPixelY, botPixelY); + AppendBlockSummaryLines(result, col, topPixelY, botPixelY, lines); } - AppendEntityInfo(result, col, topPixelY, botPixelY); + AppendEntityInfoLines(result, col, topPixelY, botPixelY, lines); - if (_tooltipContent.Children.Count == 0) + if (lines.Count == 0) { - _tooltipBorder.IsVisible = false; + svc.Hide(); return; } - int maxTipW = Math.Max(10, _mapWidth / 2 - 2); - _tooltipBorder.MaxWidth = maxTipW; - _tooltipBorder.MaxHeight = _cellRows; + bool preferRight = Position switch + { + MinimapPosition.top_left or MinimapPosition.bottom_left => true, + MinimapPosition.top_right or MinimapPosition.bottom_right => false, + _ => true, + }; - bool showRight = col < _mapWidth / 2; - int tipX = showRight ? col + 2 : Math.Max(0, col - maxTipW - 1); - int tipY = Math.Clamp(row, 0, _cellRows - 1); + double mx = _hoverGlobalX; + double my = _hoverGlobalY; - Canvas.SetLeft(_tooltipBorder, tipX); - Canvas.SetTop(_tooltipBorder, tipY); - _tooltipBorder.IsVisible = true; + if (Position == MinimapPosition.center + && this.VisualRoot is Visual root) + { + preferRight = mx < root.Bounds.Width / 2; + } + + svc.Show(mx, my, lines, preferRight); } - private void AppendBlockSummary(SampleResult result, int col, int topPy, int botPy) + private void AppendBlockSummaryLines(SampleResult result, int col, int topPy, int botPy, + List lines) { if (result.BlockSummary is null) return; @@ -797,8 +805,6 @@ namespace MinecraftClient.Tui if (merged.Count == 0) return; var sorted = merged.OrderByDescending(kv => kv.Value).Take(4); - int totalSamples = 0; - foreach (var kv in merged) totalSamples += kv.Value; var parts = new List(); foreach (var kv in sorted) @@ -811,8 +817,11 @@ namespace MinecraftClient.Tui if (parts.Count == 0) return; - string line = string.Join(", ", parts); - _tooltipContent.Children.Add(MakeTooltipText(line, Brushes.LightGray)); + lines.Add(new TuiTooltipLine + { + Text = string.Join(", ", parts), + Foreground = Brushes.LightGray, + }); } private static void MergeBlockCounts(List<(Material Mat, int Count)>?[,] summary, @@ -829,7 +838,8 @@ namespace MinecraftClient.Tui } } - private void AppendEntityInfo(SampleResult result, int col, int topPy, int botPy) + private static void AppendEntityInfoLines(SampleResult result, int col, int topPy, int botPy, + List lines) { var entityMap = result.EntityMap; if (entityMap is null) return; @@ -851,19 +861,20 @@ namespace MinecraftClient.Tui if (!seen.Add(key)) continue; var catColor = MinimapEntityClassifier.GetBaseColor(ent.Category); - string hpStr; + string coordStr = $"({ent.X:F1}, {ent.Y:F1}, {ent.Z:F1})"; + string hpStr = ""; if (ent.Health > 0) { hpStr = ent.MaxHealth > 0 - ? $" HP:{ent.Health:F0}/{ent.MaxHealth:F0}" - : $" HP:{ent.Health:F0}"; + ? $" HP:{ent.Health:F0}/{ent.MaxHealth:F0}" + : $" HP:{ent.Health:F0}"; } - else - hpStr = ""; - _tooltipContent.Children.Add(MakeTooltipText( - $"{ent.Name}{hpStr}", - new SolidColorBrush(catColor))); + lines.Add(new TuiTooltipLine + { + Text = $"{ent.Name} {coordStr}{hpStr}", + Foreground = new SolidColorBrush(catColor), + }); shown++; } } @@ -879,19 +890,6 @@ namespace MinecraftClient.Tui } } - private static TextBlock MakeTooltipText(string text, IBrush foreground) - { - return new TextBlock - { - Text = text, - Foreground = foreground, - TextWrapping = TextWrapping.Wrap, - Padding = new Thickness(0), - Margin = new Thickness(0), - FontSize = 1, - }; - } - private static string FormatMaterialName(Material mat) { if (mat == Material.Air) return "Air"; diff --git a/MinecraftClient/Tui/TuiTooltipService.cs b/MinecraftClient/Tui/TuiTooltipService.cs new file mode 100644 index 00000000..0ae51607 --- /dev/null +++ b/MinecraftClient/Tui/TuiTooltipService.cs @@ -0,0 +1,114 @@ +using System; +using System.Collections.Generic; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Media; + +namespace MinecraftClient.Tui +{ + public sealed class TuiTooltipLine + { + public string Text { get; init; } = ""; + public IBrush Foreground { get; init; } = Brushes.White; + } + + /// + /// Global tooltip that floats above all TUI content. + /// Owned by MainTuiView, used by minimap / chat / other components. + /// + public sealed class TuiTooltipService + { + private readonly Panel _rootPanel; + private readonly Canvas _canvas; + private readonly Border _border; + private readonly StackPanel _content; + + internal TuiTooltipService(Panel rootPanel) + { + _content = new StackPanel { Orientation = Avalonia.Layout.Orientation.Vertical }; + _border = new Border + { + Background = new SolidColorBrush(Color.FromArgb(230, 20, 20, 20)), + BorderBrush = new SolidColorBrush(Color.FromRgb(120, 120, 120)), + BorderThickness = new Thickness(1), + Padding = new Thickness(1), + Child = _content, + IsVisible = false, + }; + + _canvas = new Canvas + { + IsHitTestVisible = false, + Children = { _border }, + }; + + _rootPanel = rootPanel; + rootPanel.Children.Add(_canvas); + } + + /// Global X of the mouse cursor. + /// Global Y of the mouse cursor. + /// + /// If true, try placing tooltip to the right of mouseX; + /// if false, try placing to the left. + /// The service auto-flips when the tooltip would overflow the screen. + /// + public void Show(double mouseX, double mouseY, IReadOnlyList lines, + bool preferRight = true) + { + _content.Children.Clear(); + + if (lines.Count == 0) + { + _border.IsVisible = false; + return; + } + + int maxChars = 0; + foreach (var line in lines) + { + _content.Children.Add(new TextBlock + { + Text = line.Text, + Foreground = line.Foreground, + TextWrapping = TextWrapping.Wrap, + Padding = new Thickness(0), + Margin = new Thickness(0), + FontSize = 1, + }); + if (line.Text.Length > maxChars) + maxChars = line.Text.Length; + } + + double tipW = maxChars + 4; + double screenW = _rootPanel.Bounds.Width; + + const double gap = 1; + double gx; + if (preferRight) + { + gx = mouseX + gap; + if (gx + tipW > screenW) + gx = mouseX - tipW - gap; + } + else + { + gx = mouseX - tipW - gap; + if (gx < 0) + gx = mouseX + gap; + } + + Canvas.SetLeft(_border, Math.Max(0, gx)); + Canvas.SetTop(_border, Math.Max(0, mouseY)); + _border.IsVisible = true; + } + + public void Hide() + { + _border.IsVisible = false; + _content.Children.Clear(); + } + + public bool IsVisible => _border.IsVisible; + } +}