using System; using System.Collections.Generic; using Godot; namespace IslaApocalypse.Tools { /// /// Composites an elevation LEGEND down the right-hand side of a rendered map — a vertical colour /// bar with height ticks, in the manner of the scale on a physical relief plate. /// /// ═══ ⚠⚠ THE TICKS ARE IN RAW / RELATIVE UNITS, NOT METRES ═══ /// /// The height field is raw noise units (sea 0.15, peaks ~1.45). It is NOT metres, and there is /// no conversion available yet — the metres-per-unit mapping belongs to Phase 2's elevation /// profile. Labelling this bar "m" would be inventing a fact about the world in the one artefact /// a reader is most likely to trust. So it says RELATIVE HEIGHT, NOT METRES, and it says it on /// the image where it cannot be separated from the picture. /// /// The bar is drawn from the palette's own ramp, so it cannot drift from the map beside it: if a /// stop moves, the legend moves with it. /// /// ⚠ THE LAYOUT IS DERIVED FROM TEXT METRICS, NOT FROM GUESSED FRACTIONS. The first cut sized /// the font off the map height and the strip off the map width independently, so at 2048 the /// title was 450 px of text in a 235 px strip — clipped, and overlapping the bar. Everything /// below is now computed from the longest string that must fit, and the bar is placed between /// the measured header and footer blocks rather than at a fixed offset. /// public static class LegendRenderer { public static Image WithLegend(Image map, ReliefPalette.Kind palette, float seaLevel, float landTop, string title) { int mapW = map.GetWidth(), mapH = map.GetHeight(); // ---- content first, then a layout that fits it ---- var header = new List { title, "RELATIVE HEIGHT", "NOT METRES" }; var footer = new List { "BELOW SEA IS", "A DEPTH TINT" }; var ticks = new List { landTop, 1.20f, 1.00f, 0.80f, 0.60f, 0.46f, 0.31f, seaLevel }; ticks.RemoveAll(h => h > landTop || h < seaLevel); int stripW = Mathf.Max(220, Mathf.RoundToInt(mapW * 0.16f)); int pad = Mathf.Max(10, Mathf.RoundToInt(stripW * 0.075f)); // Longest header line decides the font scale — everything else is narrower by design. int longest = 0; foreach (string h in header) longest = Math.Max(longest, h.Length); int usable = stripW - pad * 2; int scale = Mathf.Clamp(usable / (longest * (TinyFont.GlyphW + 1)), 1, 6); int textH = TinyFont.Height(scale); int lineGap = Mathf.Max(2, scale); int lineH = textH + lineGap; int barX = pad; int barW = Mathf.Max(12, Mathf.RoundToInt(stripW * 0.18f)); int tickLen = Mathf.Max(4, barW / 3); int labelX = barX + barW + tickLen + Mathf.Max(4, scale * 2); int barTop = pad + header.Count * lineH + pad; int barBottom = mapH - pad - footer.Count * lineH - pad; int barH = barBottom - barTop; if (barH < 32) return map; // absurdly small map — a broken legend is worse than none var outImg = Image.CreateEmpty(mapW + stripW, mapH, false, Image.Format.Rgb8); var paper = new Color(0.098f, 0.106f, 0.125f); var ink = new Color(0.941f, 0.949f, 0.961f); var faint = new Color(0.565f, 0.596f, 0.643f); outImg.Fill(paper); outImg.BlitRect(map, new Rect2I(0, 0, mapW, mapH), new Vector2I(0, 0)); int sx = mapW; // strip origin // ---- header ---- for (int i = 0; i < header.Count; i++) TinyFont.Draw(outImg, header[i], sx + pad, pad + i * lineH, scale, i == 0 ? ink : faint); // ---- the colour bar: TOP = highest, so it reads the way the terrain does ---- for (int row = 0; row < barH; row++) { float t = 1f - row / (float)(barH - 1); float h = Mathf.Lerp(seaLevel, landTop, t); Color c = ReliefPalette.Tint(palette, h, seaLevel); for (int px = 0; px < barW; px++) outImg.SetPixel(sx + barX + px, barTop + row, c); } // A thin frame, so the bar reads as an object rather than a smear. for (int row = -1; row <= barH; row++) { SetSafe(outImg, sx + barX - 1, barTop + row, faint); SetSafe(outImg, sx + barX + barW, barTop + row, faint); } for (int px = -1; px <= barW; px++) { SetSafe(outImg, sx + barX + px, barTop - 1, faint); SetSafe(outImg, sx + barX + px, barTop + barH, faint); } // ---- ticks. The waterline gets the one word that means something. ---- foreach (float h in ticks) { float t = (h - seaLevel) / (landTop - seaLevel); int row = barTop + Mathf.RoundToInt((1f - t) * (barH - 1)); for (int px = 0; px < tickLen; px++) SetSafe(outImg, sx + barX + barW + px, row, ink); string label = h.ToString("0.00") + (Mathf.Abs(h - seaLevel) < 0.0001f ? " SEA" : ""); int ty = Mathf.Clamp(row - textH / 2, barTop - textH, mapH - textH - 1); TinyFont.Draw(outImg, label, sx + labelX, ty, scale, ink); } // ---- footer ---- int fy = barBottom + pad; for (int i = 0; i < footer.Count; i++) TinyFont.Draw(outImg, footer[i], sx + pad, fy + i * lineH, scale, faint); return outImg; } private static void SetSafe(Image img, int x, int y, Color c) { if (x >= 0 && y >= 0 && x < img.GetWidth() && y < img.GetHeight()) img.SetPixel(x, y, c); } } }