Presentation only. No generation file is touched — Topography, TerrainNoise, IslandFalloff, Pass1Result, TerrainGenConfig and GenerationScale are all unchanged. Batch naming, fixed in code. The prefix is the AUTHORING TASK number, not a running counter: 04_review means "the batch task 04 authored", not "the fifth batch". It had already drifted — tasks 02 and 03 produced 00_smoke through 05_trophy_10240 across two tasks, so no folder name said which task made what. Now the task number is an explicit argument to ToolingPaths.BatchRoot(taskNumber, descriptor), which composes the prefix itself and REFUSES a descriptor that carries its own. All three tools take it as ISLA_TASK. Verified: ISLA_BATCH=05_foo is refused with a stated reason and exit 2, and creates no folder. Also fixed: an exception out of _Ready does not stop Godot — it logs and the process sits there with no main loop, so a misconfigured run HUNG rather than failing. A hang looks like slow work, which is worse than a crash. The batch tools now catch, print what was refused, and exit non-zero. Grayscale mode: normalize a field to its own [min,max]. This is the one place per-image normalization is correct — everywhere else anchors are fixed so images compare, but here the point is to see one field at full contrast. The range is printed and indexed so a shade reads back to a height. You cannot judge noise through a palette: a ramp bends the distribution, a hillshade adds shape the data does not have. The wide Costa-Rica palette, and the reframing the developer asked for: the pretty map is the WIDE GRADIENT RENDERED FLAT, and relief is no longer the hero. On raw pass-1 noise a hillshade has nothing coherent to shade, so it renders fine fractal bumpiness as fuzz that actively hides the elevation the colour is showing. Strong hillshade is retained as diagnostic_relief and labelled a dev view — its bumpiness is exaggerated slope, not extra terrain. Subtle relief is kept for comparison, with the honest note that it still fuzzes until Phase 2 carves coherent landforms. Legend: a colour bar with ticks drawn from the palette's own ramp, so it cannot drift from the map beside it. Ticks are RELATIVE height and the image says NOT METRES — the conversion is Phase 2's, and labelling it "m" would invent a fact in the artefact a reader most trusts. Text comes from a 5x7 bitmap font written for this, specifically so a legend does not drag in the SubViewport capture path.
129 lines
5.1 KiB
C#
129 lines
5.1 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using Godot;
|
|
|
|
namespace IslaApocalypse.Tools
|
|
{
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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<string> { title, "RELATIVE HEIGHT", "NOT METRES" };
|
|
var footer = new List<string> { "BELOW SEA IS", "A DEPTH TINT" };
|
|
|
|
var ticks = new List<float> { 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);
|
|
}
|
|
}
|
|
}
|