diff --git a/MinecraftClient/Commands/Minimap.cs b/MinecraftClient/Commands/Minimap.cs index 897c88ae..0b6ca00f 100644 --- a/MinecraftClient/Commands/Minimap.cs +++ b/MinecraftClient/Commands/Minimap.cs @@ -11,7 +11,7 @@ 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 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] | minimap cave [auto|on|off]"; public override string CmdDesc => Translations.cmd_minimap_desc; public override void RegisterCommand(CommandDispatcher dispatcher) @@ -78,6 +78,14 @@ namespace MinecraftClient.Commands .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("cave") + .Executes(r => DoCaveInfo(r.Source)) + .Then(l => l.Literal("auto") + .Executes(r => DoCaveSet(r.Source, CaveModeOption.auto))) + .Then(l => l.Literal("on") + .Executes(r => DoCaveSet(r.Source, CaveModeOption.on))) + .Then(l => l.Literal("off") + .Executes(r => DoCaveSet(r.Source, CaveModeOption.off)))) .Then(l => l.Literal("_help") .Executes(r => GetUsage(r.Source, string.Empty)) .Redirect(dispatcher.GetRoot().GetChild("help")?.GetChild(CmdName))) @@ -251,6 +259,26 @@ namespace MinecraftClient.Commands string.Format(Translations.cmd_minimap_position_set, pos)); } + private static int DoCaveInfo(CmdResult r) + { + var view = GetTuiView(r); + if (view is null) return (int)r.status; + + var mode = view.GetMinimapCaveMode(); + return r.SetAndReturn(Status.Done, + string.Format(Translations.cmd_minimap_cave_current, mode)); + } + + private static int DoCaveSet(CmdResult r, CaveModeOption mode) + { + var view = GetTuiView(r); + if (view is null) return (int)r.status; + + Dispatcher.UIThread.Post(() => view.SetMinimapCaveMode(mode)); + return r.SetAndReturn(Status.Done, + string.Format(Translations.cmd_minimap_cave_set, mode)); + } + private static string BoolStr(bool v) => v ? "ON" : "OFF"; } } diff --git a/MinecraftClient/Resources/ConfigComments/ConfigComments.resx b/MinecraftClient/Resources/ConfigComments/ConfigComments.resx index 4816d2de..8f3e4964 100644 --- a/MinecraftClient/Resources/ConfigComments/ConfigComments.resx +++ b/MinecraftClient/Resources/ConfigComments/ConfigComments.resx @@ -975,6 +975,9 @@ Note: This does NOT require a Bot Token, only an Application ID. Discord must be Minimap refresh interval in milliseconds (100-5000). + + Cave rendering mode: "auto" (detect ceiling), "on" (always cave view), "off" (always surface view). + Yggdrasil authlib multi-user selection. diff --git a/MinecraftClient/Resources/Translations/Translations.Designer.cs b/MinecraftClient/Resources/Translations/Translations.Designer.cs index d2883376..de6784ae 100644 --- a/MinecraftClient/Resources/Translations/Translations.Designer.cs +++ b/MinecraftClient/Resources/Translations/Translations.Designer.cs @@ -7187,6 +7187,24 @@ namespace MinecraftClient { } } + /// + /// Looks up a localized string similar to Current cave mode: {0}. + /// + internal static string cmd_minimap_cave_current { + get { + return ResourceManager.GetString("cmd.minimap.cave_current", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Cave mode set to: {0}. + /// + internal static string cmd_minimap_cave_set { + get { + return ResourceManager.GetString("cmd.minimap.cave_set", resourceCulture); + } + } + /// /// Looks up a localized string similar to list achievements/advancements from the server.. /// diff --git a/MinecraftClient/Resources/Translations/Translations.resx b/MinecraftClient/Resources/Translations/Translations.resx index b67f453b..fddf6400 100644 --- a/MinecraftClient/Resources/Translations/Translations.resx +++ b/MinecraftClient/Resources/Translations/Translations.resx @@ -2533,6 +2533,12 @@ see item details. Minimap position set to: {0} + + Current cave mode: {0} + + + Cave mode set to: {0} + list achievements/advancements from the server. diff --git a/MinecraftClient/Settings.cs b/MinecraftClient/Settings.cs index 8f556a5c..ededd636 100644 --- a/MinecraftClient/Settings.cs +++ b/MinecraftClient/Settings.cs @@ -1286,6 +1286,9 @@ namespace MinecraftClient [TomlInlineComment("$Console.Minimap.RefreshInterval$")] public int RefreshInterval = Tui.MinimapControl.DefaultRefreshMs; + [TomlInlineComment("$Console.Minimap.CaveMode$")] + public Tui.CaveModeOption CaveMode = Tui.CaveModeOption.auto; + public void OnSettingUpdate() { Zoom = Math.Clamp(Zoom, Tui.MinimapControl.MinZoom, Tui.MinimapControl.MaxZoom); diff --git a/MinecraftClient/Tui/MainTuiView.cs b/MinecraftClient/Tui/MainTuiView.cs index 1106f763..d8155d48 100644 --- a/MinecraftClient/Tui/MainTuiView.cs +++ b/MinecraftClient/Tui/MainTuiView.cs @@ -167,6 +167,7 @@ namespace MinecraftClient.Tui _minimapControl.NameConfig.Hostile = mmCfg.ShowHostileNames; _minimapControl.NameConfig.Neutral = mmCfg.ShowNeutralNames; _minimapControl.NameConfig.Passive = mmCfg.ShowPassiveNames; + _minimapControl.CaveMode = mmCfg.CaveMode; var (hAlign, vAlign, margin) = GetMinimapAlignment(mmCfg.Position); _minimapBorder = new Border @@ -1097,6 +1098,14 @@ namespace MinecraftClient.Tui public MinimapPosition GetMinimapPosition() => Settings.Config.Console.Minimap.Position; + public void SetMinimapCaveMode(CaveModeOption mode) + { + _minimapControl.CaveMode = mode; + Settings.Config.Console.Minimap.CaveMode = mode; + } + + public CaveModeOption GetMinimapCaveMode() => _minimapControl.CaveMode; + 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)), diff --git a/MinecraftClient/Tui/MinimapColorMap.cs b/MinecraftClient/Tui/MinimapColorMap.cs index ae1bf21c..b0b09596 100644 --- a/MinecraftClient/Tui/MinimapColorMap.cs +++ b/MinecraftClient/Tui/MinimapColorMap.cs @@ -20,6 +20,8 @@ namespace MinecraftClient.Tui 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); + public static readonly Color CaveBorderColor = Color.FromRgb(16, 16, 16); + public static readonly Color CaveSolidColor = Color.FromRgb(24, 20, 18); private static readonly FrozenDictionary ColorTable; private static readonly FrozenSet FullyTransparentMats; @@ -114,6 +116,14 @@ namespace MinecraftClient.Tui public static bool IsFullyTransparent(Material m) => FullyTransparentMats.Contains(m); + /// + /// Returns true for materials that block light propagation (solid, liquids), + /// used by cave mode to find the surface from the player's Y level. + /// Mirrors VoxelMap's lightDampening > 0 check. + /// + public static bool IsLightBlocking(Material m) + => (m == Material.Lava) || (!FullyTransparentMats.Contains(m) && m.IsSolid()); + public static bool IsWater(Material m) => WaterMats.Contains(m); public static bool IsIce(Material m) => IceMats.Contains(m); @@ -137,8 +147,8 @@ namespace MinecraftClient.Tui int multiplier = heightDelta switch { > 0 => 255, // higher than neighbor: brightest - 0 => 220, // same height: normal - _ => 180, // lower than neighbor: darker + 0 => 220, // same height: normal + _ => 180, // lower than neighbor: darker }; byte r = (byte)(baseColor.R * multiplier / 255); byte g = (byte)(baseColor.G * multiplier / 255); @@ -157,6 +167,19 @@ namespace MinecraftClient.Tui return Blend(IceColor, bottomColor, 0.35); } + /// + /// Darken a color to simulate underground lighting. Cave floors receive + /// a minimum brightness of ~32/255 for non-solid blocks (matching VoxelMap), + /// while solid/unreachable columns render as near-black. + /// + public static Color ApplyCaveDarkening(Color baseColor, double factor = 0.55) + { + byte r = (byte)(baseColor.R * factor); + byte g = (byte)(baseColor.G * factor); + byte b = (byte)(baseColor.B * factor); + return Color.FromRgb(r, g, b); + } + private static Color Blend(Color top, Color bottom, double topAlpha) { byte r = (byte)(top.R * topAlpha + bottom.R * (1.0 - topAlpha)); diff --git a/MinecraftClient/Tui/MinimapControl.cs b/MinecraftClient/Tui/MinimapControl.cs index 33f25465..616b44e5 100644 --- a/MinecraftClient/Tui/MinimapControl.cs +++ b/MinecraftClient/Tui/MinimapControl.cs @@ -13,6 +13,8 @@ using MinecraftClient.Mapping; namespace MinecraftClient.Tui { + public enum CaveModeOption { auto, on, off } + /// /// 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). @@ -63,6 +65,8 @@ namespace MinecraftClient.Tui public MinimapPosition Position { get; set; } = MinimapPosition.top_right; + public CaveModeOption CaveMode { get; set; } = CaveModeOption.auto; + public int MapPixelWidth => _mapWidth; public int MapPixelHeight => _mapHeight; @@ -177,19 +181,21 @@ namespace MinecraftClient.Tui bool showHostile = _nameConfig.Hostile; bool showNeutral = _nameConfig.Neutral; bool showPassive = _nameConfig.Passive; + var caveOpt = CaveMode; Task.Run(() => { try { var result = SampleTerrain(client, bpp, w, h, - showPlayers, showHostile, showNeutral, showPassive, ct); + showPlayers, showHostile, showNeutral, showPassive, caveOpt, ct); if (ct.IsCancellationRequested) return; Dispatcher.UIThread.Post(() => { ApplyPixelBuffer(result, w, h); - UpdateInfoBarAndLegend(client, bpp, result.VisibleCategories, w); + UpdateInfoBarAndLegend(client, bpp, result.VisibleCategories, w, + result.CaveModeActive); }); } catch (OperationCanceledException) { } @@ -236,6 +242,7 @@ namespace MinecraftClient.Tui public int CenterX; public int CenterY; public int Bpp; + public bool CaveModeActive; } private static bool ShouldShowNameLocal(MobCategory cat, @@ -253,7 +260,7 @@ namespace MinecraftClient.Tui private static SampleResult SampleTerrain(McClient client, int bpp, int mapW, int mapH, bool showPlayers, bool showHostile, bool showNeutral, bool showPassive, - CancellationToken ct) + CaveModeOption caveOpt, CancellationToken ct) { var result = new SampleResult { @@ -281,6 +288,9 @@ namespace MinecraftClient.Tui int minY = dim.minY; int scanTop = Math.Min(playerBlockY + 32, dim.maxY - 1); + bool caveMode = ResolveCaveMode(caveOpt, world, dim, playerBlockX, playerBlockY, playerBlockZ, scanTop); + result.CaveModeActive = caveMode; + var entities = client.GetEntityHandlingEnabled() ? client.GetEntities() : null; @@ -374,6 +384,8 @@ namespace MinecraftClient.Tui ChunkColumn? cachedColumn = null; int cachedChunkX = int.MinValue, cachedChunkZ = int.MinValue; + bool[,]? caveMask = caveMode ? new bool[mapW, mapH] : null; + for (int px = 0; px < mapW; px++) { for (int py = 0; py < mapH; py++) @@ -383,23 +395,51 @@ namespace MinecraftClient.Tui int baseX = playerBlockX + (px - centerX) * bpp; int baseZ = playerBlockZ + (py - centerY) * bpp; - if (bpp == 1) + if (caveMode) { - 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; + if (bpp == 1) + { + var (color, surfY, surfMat, inCave) = SampleColumnCave( + world, baseX, baseZ, playerBlockY, minY, dim.maxY - 1, + ref cachedColumn, ref cachedChunkX, ref cachedChunkZ); + result.Pixels[px, py] = color; + result.Heights[px, py] = surfY; + result.BlockTypes![px, py] = surfMat; + caveMask![px, py] = inCave; + } + else + { + var (color, surfY, matSum, inCave) = SampleAreaDominantCave( + world, baseX, baseZ, bpp, playerBlockY, minY, dim.maxY - 1, + 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; + caveMask![px, py] = inCave; + } } else { - 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; + if (bpp == 1) + { + 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, 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; + } } } } @@ -416,6 +456,9 @@ namespace MinecraftClient.Tui } } + if (caveMask is not null) + ApplyCaveBorder(result, caveMask, mapW, mapH, entityPixels); + foreach (var (key, info) in entityPixels) { var (px, py) = key; @@ -640,6 +683,230 @@ namespace MinecraftClient.Tui return (best, avgY, summary); } + /// + /// Determine whether cave mode should be active for this frame. + /// Mirrors VoxelMap's detection: hasCeiling dimensions always use cave mode, + /// otherwise check whether the player's column has a solid block above. + /// + private static bool ResolveCaveMode(CaveModeOption opt, World world, Dimension dim, + int playerX, int playerY, int playerZ, int scanTop) + { + if (opt == CaveModeOption.off) return false; + if (opt == CaveModeOption.on) return true; + + if (dim.hasCeiling) return true; + + for (int y = playerY + 2; y <= scanTop; y++) + { + var mat = world.GetBlock(new Mapping.Location(playerX, y, playerZ)).Type; + if (MinimapColorMap.IsLightBlocking(mat)) + return true; + } + return false; + } + + /// + /// Cave-mode column sampler. Starting from playerY, scans down through air + /// to find the first light-blocking block (the cave floor), or scans up if + /// the player is embedded in solid. Returns the floor block color with cave + /// darkening applied, plus an inCave flag indicating the column has a reachable + /// air pocket at the player's Y level. + /// + private static (Color color, int surfaceY, Material surfaceMat, bool inCave) SampleColumnCave( + World world, int x, int z, int playerY, int minY, int maxY, + 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, Material.Air, false); + + int caveFloorY = FindCaveFloorY(cachedColumn, x, z, playerY, minY, maxY); + + if (caveFloorY == int.MinValue) + { + var fallback = SampleColumn(world, x, z, maxY, minY, + ref cachedColumn, ref cachedChunkX, ref cachedChunkZ); + return (MinimapColorMap.CaveSolidColor, fallback.surfaceY, fallback.surfaceMat, false); + } + + var loc = new Mapping.Location(x, caveFloorY, z); + var chunk = cachedColumn.GetChunk(loc); + if (chunk is null) + return (MinimapColorMap.CaveSolidColor, caveFloorY, Material.Air, false); + + var block = chunk.GetBlock(loc); + var mat = block.Type; + var color = MinimapColorMap.GetBaseColor(mat); + color = MinimapColorMap.ApplyCaveDarkening(color); + + return (color, caveFloorY, mat, true); + } + + /// + /// Find the cave floor Y at (x, z) by scanning from playerY. + /// If the block at playerY is air-like, scan down for the first solid block. + /// If the block at playerY is solid, scan up (up to playerY + 10) for the + /// first air block, then return that Y (the cave ceiling opening). + /// Returns int.MinValue if no cave floor is found. + /// + private static int FindCaveFloorY(ChunkColumn column, int x, int z, int playerY, int minY, int maxY) + { + var startLoc = new Mapping.Location(x, playerY, z); + var startChunk = column.GetChunk(startLoc); + + bool startIsAir; + if (startChunk is null) + { + startIsAir = true; + } + else + { + var startMat = startChunk.GetBlock(startLoc).Type; + startIsAir = !MinimapColorMap.IsLightBlocking(startMat); + } + + if (startIsAir) + { + for (int y = playerY - 1; y >= minY; y--) + { + var loc = new Mapping.Location(x, y, z); + var chunk = column.GetChunk(loc); + if (chunk is null) continue; + + var mat = chunk.GetBlock(loc).Type; + if (MinimapColorMap.IsLightBlocking(mat)) + return y; + } + return minY; + } + else + { + int upLimit = Math.Min(playerY + 10, maxY); + for (int y = playerY + 1; y <= upLimit; y++) + { + var loc = new Mapping.Location(x, y, z); + var chunk = column.GetChunk(loc); + if (chunk is null) continue; + + var mat = chunk.GetBlock(loc).Type; + if (!MinimapColorMap.IsLightBlocking(mat)) + { + for (int y2 = y - 1; y2 >= minY; y2--) + { + var loc2 = new Mapping.Location(x, y2, z); + var chunk2 = column.GetChunk(loc2); + if (chunk2 is null) continue; + + var mat2 = chunk2.GetBlock(loc2).Type; + if (MinimapColorMap.IsLightBlocking(mat2)) + return y2; + } + return minY; + } + } + return int.MinValue; + } + } + + private static (Color color, int surfaceY, List<(Material Mat, int Count)>? matSummary, bool inCave) + SampleAreaDominantCave(World world, int baseX, int baseZ, + int size, int playerY, int minY, int maxY, bool collectMats, + ref ChunkColumn? cachedColumn, ref int cachedChunkX, ref int cachedChunkZ) + { + var colorCounts = new Dictionary(); + Dictionary? matCounts = collectMats ? [] : null; + int caveCount = 0; + + 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, surfMat, inCave) = SampleColumnCave( + world, baseX + dx, baseZ + dz, playerY, minY, maxY, + ref cachedColumn, ref cachedChunkX, ref cachedChunkZ); + + if (inCave) caveCount++; + + 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; + } + } + } + + 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; + } + } + + 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(); + } + + int totalSamples = 0; + foreach (var kvp in colorCounts) + totalSamples += kvp.Value.Count; + + bool majorityInCave = caveCount * 2 >= totalSamples; + return (best, avgY, summary, majorityInCave); + } + + /// + /// Draw a 1-pixel dark border around the boundary between cave-reachable pixels + /// and non-cave (solid/surface) pixels, giving the cave region a visible edge. + /// + private static void ApplyCaveBorder(SampleResult result, bool[,] caveMask, + int mapW, int mapH, Dictionary<(int, int), (Color, int)> entityPixels) + { + for (int px = 0; px < mapW; px++) + { + for (int py = 0; py < mapH; py++) + { + if (entityPixels.ContainsKey((px, py))) continue; + if (caveMask[px, py]) continue; + + bool neighborInCave = false; + if (px > 0 && caveMask[px - 1, py]) neighborInCave = true; + if (!neighborInCave && px < mapW - 1 && caveMask[px + 1, py]) neighborInCave = true; + if (!neighborInCave && py > 0 && caveMask[px, py - 1]) neighborInCave = true; + if (!neighborInCave && py < mapH - 1 && caveMask[px, py + 1]) neighborInCave = true; + + if (neighborInCave) + result.Pixels[px, py] = MinimapColorMap.CaveBorderColor; + } + } + } + private void ApplyPixelBuffer(SampleResult result, int w, int h) { int rows = h / 2; @@ -898,7 +1165,7 @@ namespace MinecraftClient.Tui } private void UpdateInfoBarAndLegend(McClient client, int bpp, - HashSet categories, int mapW) + HashSet categories, int mapW, bool caveModeActive) { var loc = client.GetCurrentLocation(); float yaw = client.GetYaw(); @@ -908,7 +1175,8 @@ namespace MinecraftClient.Tui int y = (int)Math.Floor(loc.Y); int z = (int)Math.Floor(loc.Z); - string coordPart = $"{x}, {y}, {z} {arrow} {bpp}:1"; + string caveSuffix = caveModeActive ? " \u25bc" : ""; + string coordPart = $"{x}, {y}, {z} {arrow} {bpp}:1{caveSuffix}"; var legendParts = new List(); var legendColors = new List();