diff --git a/Core/Scripts/ToolingPaths.cs b/Core/Scripts/ToolingPaths.cs index 1247543..63ccb63 100644 --- a/Core/Scripts/ToolingPaths.cs +++ b/Core/Scripts/ToolingPaths.cs @@ -90,10 +90,52 @@ namespace IslaApocalypse.Core public static string BatchScratch(string batchDir) => Path.Combine(batchDir, "scratch"); /// - /// A batch directory: batches/NN_<name>/<seed>_<variant>/. + /// ⭐ A BATCH ROOT: batches/<task>_<descriptor>/. + /// + /// ═══ ⚠⚠ THE PREFIX IS THE AUTHORING TASK NUMBER. IT IS NOT A COUNTER. ═══ + /// + /// 04_review means "the batch task 04 authored". It does NOT mean "the fifth batch". + /// A task that produces six batches produces six 04_* folders, not 04_ through + /// 09_. + /// + /// This is enforced here, in code, because it already drifted once: tasks 02 and 03 used the + /// prefix as a global running counter and produced 00_smoke05_trophy_10240 + /// across two tasks, so nothing in the folder name said which task made what. Passing the + /// task number as an explicit argument — rather than letting a caller compose a free-form + /// string — is what makes the convention unbreakable rather than remembered. + /// → `Design - Tooling - Iteration and Batching.md`. + /// + /// The descriptor must NOT carry its own numeric prefix; that is the mistake this method + /// exists to prevent, so it is refused rather than silently accepted. /// - public static string BatchDir(int batchNumber, string batchName, long seed, string variant) - => Path.Combine(BatchesRoot, $"{batchNumber:D2}_{batchName}", $"{seed}_{variant}"); + public static string BatchRoot(int taskNumber, string descriptor) + { + if (taskNumber < 0) + throw new ArgumentOutOfRangeException(nameof(taskNumber), taskNumber, + "A batch is named for the task that authored it; there is no negative task."); + if (string.IsNullOrWhiteSpace(descriptor)) + throw new ArgumentException("A batch needs a descriptor — '04_' alone is not browsable.", nameof(descriptor)); + + string d = descriptor.Trim(); + + // Refuse "04_review", "4_review", "05_foo" — the caller is re-adding a prefix, which is + // exactly how the counter drifted. The task number is this method's job, not theirs. + int us = d.IndexOf('_'); + if (us > 0 && int.TryParse(d.Substring(0, us), out _)) + throw new ArgumentException( + $"Descriptor '{d}' starts with its own numeric prefix. Pass the task number as " + + $"taskNumber and the descriptor WITHOUT one (e.g. \"review\", not \"04_review\") — " + + "the prefix is composed here so it cannot drift.", nameof(descriptor)); + + return Path.Combine(BatchesRoot, $"{taskNumber:D2}_{d}"); + } + + /// + /// A variant directory inside a batch: + /// batches/<task>_<descriptor>/<seed>_<variant>/. + /// + public static string BatchDir(int taskNumber, string descriptor, long seed, string variant) + => Path.Combine(BatchRoot(taskNumber, descriptor), $"{seed}_{variant}"); private static string Override(string variable) { diff --git a/Tools/README.md b/Tools/README.md index 74b28a5..f6e628e 100644 --- a/Tools/README.md +++ b/Tools/README.md @@ -37,7 +37,7 @@ constants, carried over verbatim — not re-derived from a design summary** (→ | `Scripts/TerrainGenTool.cs` | Batch entry point (ladder + seed batch + `INDEX.md`) | | `Scenes/TerrainGenTool.tscn` | Run this | -### The relief render — presentation (Phase 1, the look) +### The map renders — presentation (Phase 1, the look) **Presentation only** (→ `Design - Rendering - Roughness Is Presentation.md`): it changes how height is *shown*, never how it is *made*. It **cannot** change the terrain — it is handed a height field @@ -45,12 +45,44 @@ loaded from a `.f32` dump and has no way to produce one. | File | What it is | |---|---| -| `Scripts/Hillshade.cs` | Horn 3×3 shaded relief; the `ZExaggeration` slope table | +| `Scripts/GrayscaleRenderer.cs` | ⭐ Plain grayscale — **for judging the noise itself** | | `Scripts/ReliefPalette.cs` | The hypsometric palettes, stops placed on measured percentiles | -| `Scripts/ReliefRenderer.cs` | ⭐ The tint + hillshade blend | -| `Scripts/LookConfig.cs` | The look dials and the three named variants | +| `Scripts/ReliefRenderer.cs` | The tint (+ optional hillshade) blend | +| `Scripts/Hillshade.cs` | Horn 3×3 shaded relief; the `ZExaggeration` slope table | +| `Scripts/LegendRenderer.cs` | ⭐ The elevation legend strip | +| `Scripts/TinyFont.cs` | A 5×7 bitmap font, so a legend needs no viewport | +| `Scripts/LookConfig.cs` | The look dials and the named variants | +| `Scripts/ReviewBatchTool.cs` | ⭐ The review batch — grayscale + hero gradient + labelled relief | | `Scripts/ReliefRenderTool.cs` | Taste-gate batch runner | -| `Scenes/ReliefRenderTool.tscn` | Run this | +| `Scenes/ReviewBatchTool.tscn`, `Scenes/ReliefRenderTool.tscn` | Run these | + +> ### ⭐ The pretty map is the WIDE GRADIENT, rendered FLAT. Relief is not the hero. +> +> A reference relief map gets its look from **wide colour + subtle relief + COHERENT TERRAIN.** We +> have the first. The third is Phase 2's curve and erosion. On raw pass-1 noise a hillshade has +> nothing coherent to shade, so it renders fine fractal bumpiness as **visual fuzz that actively +> hides the elevation the colour is showing.** So: +> +> | Look | Role | +> |---|---| +> | **`gradient_flat`** | ⭐ **the hero.** Wide Costa-Rica gradient, `HillshadeStrength = 0`, no relief at all | +> | `subtle_relief` | gentle relief (zex 18, strength 0.30) for comparison — **still fuzzes on raw noise** | +> | ⚠ `diagnostic_relief` | **A DEV VIEW, NEVER THE PRETTY MAP.** Strong exaggeration to make slope artifacts jump out. Its bumpiness is exaggerated *slope*, not extra terrain | +> | `atlas`, `dusk` | the task-03 plates, kept reachable | +> +> Relief comes into its own once erosion carves ridges and valleys worth lighting. + +**Plain grayscale** (`GrayscaleRenderer`) normalizes 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. +Here the point is to see one field at full contrast, so the range used is printed and written into the +`INDEX.md`, and a shade reads back to a height. **You cannot judge noise through a palette:** a ramp +bends the value distribution and a hillshade adds shape the data does not have. + +**The legend** (`LegendRenderer`) draws a colour bar with ticks from the palette's own ramp, so it +cannot drift from the map beside it. ⚠ **Ticks are RELATIVE height, not metres** — the metres +conversion is Phase 2's elevation profile, and labelling the bar "m" now would invent a fact. It says +so on the image. Text comes from `TinyFont`, a 5×7 bitmap font, specifically so a legend does not drag +in the SubViewport capture path (which awaits render frames and is why `--headless` hangs). ```bash Godot_v4.7.2-stable_mono_linux.x86_64 --headless \ @@ -197,11 +229,25 @@ Enforced by `Core/Scripts/FileSafety.cs`, which throws rather than advises. ### 4. Batch layout ``` -batches/NN_/_/ -batches/NN_/INDEX.md -batches/NN_/scratch/ ← persistent; never cleaned +batches/_/_/ +batches/_/INDEX.md +batches/_/scratch/ ← persistent; never cleaned ``` +> ### ⚠⚠ THE PREFIX IS THE AUTHORING TASK NUMBER. IT IS NOT A COUNTER. +> +> `04_review` means *"the batch task 04 authored"*. It does **not** mean "the fifth batch". A task +> that produces six batches produces six `04_*` folders — not `04_` through `09_`. +> +> **This already drifted once.** Tasks 02 and 03 used the prefix as a global running counter and +> produced `00_smoke` … `05_trophy_10240` across two tasks, so nothing in a folder name said which +> task made what. +> +> **Enforced in code, not remembered:** 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** (`ISLA_BATCH=05_foo` → `REFUSED`, exit 2). Tools take it as +> `ISLA_TASK`. + **A/B comparisons are browsed by a human, and a flat directory of same-named PNGs is not browsable.** The `INDEX.md` is what makes a batch readable a week later. diff --git a/Tools/Scenes/ReviewBatchTool.tscn b/Tools/Scenes/ReviewBatchTool.tscn new file mode 100644 index 0000000..c65621b --- /dev/null +++ b/Tools/Scenes/ReviewBatchTool.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3 uid="uid://bqp1x2v3isla4"] + +[ext_resource type="Script" path="res://Tools/Scripts/ReviewBatchTool.cs" id="1_rbt"] + +[node name="ReviewBatchTool" type="Node"] +script = ExtResource("1_rbt") diff --git a/Tools/Scripts/GrayscaleRenderer.cs b/Tools/Scripts/GrayscaleRenderer.cs new file mode 100644 index 0000000..130d1dd --- /dev/null +++ b/Tools/Scripts/GrayscaleRenderer.cs @@ -0,0 +1,51 @@ +using Godot; + +namespace IslaApocalypse.Tools +{ + /// + /// A plain grayscale render: normalize a float field to its own [min, max] and map to + /// [black, white]. No colour, no hillshade, no interpretation. + /// + /// ═══ WHY THIS EXISTS ═══ + /// + /// Because you cannot judge noise through a palette. A hypsometric ramp and a hillshade are both + /// opinionated transforms — one bends the value distribution onto chosen stops, the other adds + /// shape the data does not contain. Both are the right thing for a map and the wrong thing for + /// asking "is the noise itself good?". This mode answers that question and no other. + /// + /// ⚠ THIS IS THE ONE PLACE PER-IMAGE NORMALIZATION IS CORRECT. Everywhere else the palette + /// anchors are fixed so images can be compared. Here the point is to see the full structure of + /// ONE field at full contrast, so it is normalized to its own range — and the range used is + /// printed and written into the INDEX, so a shade reads back to a height. + /// + public static class GrayscaleRenderer + { + /// Render to grayscale. Returns the (min, max) used for the normalization. + public static (float min, float max) SavePng(float[,] field, int mapSize, string absolutePath) + { + float min = float.MaxValue, max = float.MinValue; + for (int x = 0; x < mapSize; x++) + for (int y = 0; y < mapSize; y++) + { + float v = field[x, y]; + if (v < min) min = v; + if (v > max) max = v; + } + + float span = max - min; + float inv = span > 1e-9f ? 1f / span : 0f; + + var img = Image.CreateEmpty(mapSize, mapSize, false, Image.Format.Rgb8); + for (int x = 0; x < mapSize; x++) + for (int y = 0; y < mapSize; y++) + { + float g = (field[x, y] - min) * inv; + img.SetPixel(x, y, new Color(g, g, g)); + } + + Error err = img.SavePng(absolutePath); + if (err != Error.Ok) GD.PrintErr($"[GrayscaleRenderer] SavePng failed ({err}) for {absolutePath}"); + return (min, max); + } + } +} diff --git a/Tools/Scripts/GrayscaleRenderer.cs.uid b/Tools/Scripts/GrayscaleRenderer.cs.uid new file mode 100644 index 0000000..5ab7684 --- /dev/null +++ b/Tools/Scripts/GrayscaleRenderer.cs.uid @@ -0,0 +1 @@ +uid://bfqaec3of3i7e diff --git a/Tools/Scripts/LegendRenderer.cs b/Tools/Scripts/LegendRenderer.cs new file mode 100644 index 0000000..11e9f71 --- /dev/null +++ b/Tools/Scripts/LegendRenderer.cs @@ -0,0 +1,129 @@ +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); + } + } +} diff --git a/Tools/Scripts/LegendRenderer.cs.uid b/Tools/Scripts/LegendRenderer.cs.uid new file mode 100644 index 0000000..eea10f4 --- /dev/null +++ b/Tools/Scripts/LegendRenderer.cs.uid @@ -0,0 +1 @@ +uid://bamko0n3adsbv diff --git a/Tools/Scripts/LookConfig.cs b/Tools/Scripts/LookConfig.cs index 4da1d91..9369e37 100644 --- a/Tools/Scripts/LookConfig.cs +++ b/Tools/Scripts/LookConfig.cs @@ -62,28 +62,53 @@ namespace IslaApocalypse.Tools /// /// The three looks offered to the taste gate. Each pair isolates one question: - /// atlas vs relief → how strong should the relief be? (same palette and light) - /// atlas vs dusk → which palette, and how low a sun? (comparable relief) + /// gradient_flat ⭐ the hero — wide colour, NO relief + /// subtle_relief the same gradient with gentle relief, for comparison + /// diagnostic_relief ⚠ a DEV VIEW for spotting artifacts. Never the pretty map. + /// atlas / dusk the task-03 plates, kept reachable /// public static LookConfig[] Variants() => new[] { - // Classic physical-atlas plate: tint-forward, relief present but polite. + // ⭐ THE HERO. A wide, vivid gradient rendered FLAT — no relief at all. + // + // The developer's reference maps get their look from WIDE COLOUR + subtle relief + + // COHERENT TERRAIN. We have the first; the third is Phase 2's curve and erosion. On raw + // pre-Phase-2 noise a hillshade has nothing coherent to shade, so it renders the fine + // fractal bumpiness as visual fuzz and actively HIDES the elevation the colour is + // showing. So the pretty map leads with colour and no relief, and gets its shape from + // relief later, once there is shape worth lighting. + new LookConfig + { + Name = "gradient_flat", Palette = ReliefPalette.Kind.CostaRica, + HillshadeStrength = 0f, + }, + + // Tasteful relief, for comparison. ⚠ Still fuzzes on raw noise — that is the terrain, + // not the setting. Comes into its own after Phase 2 carves coherent landforms. + new LookConfig + { + Name = "subtle_relief", Palette = ReliefPalette.Kind.CostaRica, + ZExaggeration = 18f, LightAzimuth = 315f, LightAltitude = 45f, + HillshadeStrength = 0.30f, + }, + + // ⚠⚠ A DIAGNOSTIC, NOT A PRETTY MAP. Strong exaggeration makes slope artifacts jump + // out — a centre-line crease, a stair-step, a seam. Its bumpiness is EXAGGERATED SLOPE, + // not extra terrain. Never present this as the deliverable. + new LookConfig + { + Name = "diagnostic_relief", Palette = ReliefPalette.Kind.CostaRica, + ZExaggeration = 100f, LightAzimuth = 315f, LightAltitude = 45f, + HillshadeStrength = 0.80f, + }, + + // The task-03 atlas plates, kept so that look is still reachable for comparison. new LookConfig { Name = "atlas", Palette = ReliefPalette.Kind.Atlas, ZExaggeration = 75f, LightAzimuth = 315f, LightAltitude = 45f, HillshadeStrength = 0.55f, }, - - // Shape-forward: the same palette and light, harder relief. Isolates the relief dial. - new LookConfig - { - Name = "relief", Palette = ReliefPalette.Kind.Atlas, - ZExaggeration = 120f, LightAzimuth = 315f, LightAltitude = 45f, - HillshadeStrength = 0.80f, - }, - - // Warmer palette, lower sun, longer shadows. Isolates palette + light. new LookConfig { Name = "dusk", Palette = ReliefPalette.Kind.Dusk, diff --git a/Tools/Scripts/ReliefPalette.cs b/Tools/Scripts/ReliefPalette.cs index 34bc836..e669106 100644 --- a/Tools/Scripts/ReliefPalette.cs +++ b/Tools/Scripts/ReliefPalette.cs @@ -47,7 +47,7 @@ namespace IslaApocalypse.Tools public static class ReliefPalette { /// Named palettes. Gated so the look can be A/B'd like any other change. - public enum Kind { Atlas, Dusk } + public enum Kind { Atlas, Dusk, CostaRica } /// /// Bathymetric compression constant, in raw height units. The sea ramp is indexed by @@ -108,20 +108,75 @@ namespace IslaApocalypse.Tools (1.000f, new Color(0.012f, 0.047f, 0.125f)), }; + // ---- COSTA RICA: the WIDE gradient. The hero. ------------------------- + // + // ⭐ The point of this ramp is RANGE. The atlas palettes are muted by design — they are + // imitating a printed plate, where ink is expensive and subtlety is the aesthetic. This one + // imitates the modern relief maps the developer pointed at (Costa Rica, Hispaniola), which + // use a wide, vivid gradient so that ELEVATION IS LEGIBLE AT A GLANCE without a hillshade + // doing the work. + // + // Same percentile placement as the others — the stops sit on the measured land distribution, + // not spread evenly — but the colours travel much further: deep green all the way to white, + // through a full yellow and orange stage the atlas ramps skip. + // + // ⚠ HONEST EXPECTATION: this terrain is mostly lowland (median 0.456 of a 1.415 peak), so it + // reads green→yellow with the spine in orange/brown/white. That is ACCURATE for a lowland + // island with a central range, not a palette failure. The fullest spread arrives when Phase + // 2's redistribution curve moves the height distribution; the palette is got right now so + // that Phase 2 inherits it rather than re-tuning it. + private static readonly (float h, Color c)[] CostaRicaLand = + { + (0.150f, new Color(0.078f, 0.361f, 0.216f)), // shoreline — deep green + (0.220f, new Color(0.153f, 0.475f, 0.243f)), // p10 green + (0.310f, new Color(0.322f, 0.596f, 0.259f)), // p25 mid green + (0.460f, new Color(0.569f, 0.706f, 0.278f)), // p50 yellow-green + (0.560f, new Color(0.784f, 0.796f, 0.298f)), // yellow + (0.650f, new Color(0.914f, 0.792f, 0.310f)), // p75 golden + (0.750f, new Color(0.933f, 0.667f, 0.271f)), // yellow-orange + (0.840f, new Color(0.882f, 0.541f, 0.239f)), // p90 orange + (0.980f, new Color(0.749f, 0.412f, 0.220f)), // p95 tan-brown + (1.120f, new Color(0.573f, 0.318f, 0.208f)), // p99 brown + (1.220f, new Color(0.427f, 0.271f, 0.216f)), // dark brown + (1.320f, new Color(0.667f, 0.635f, 0.620f)), // p99.9 grey + (1.450f, new Color(1.000f, 1.000f, 1.000f)), // peak white + }; + + private static readonly (float h, Color c)[] CostaRicaSea = + { + (0.000f, new Color(0.616f, 0.851f, 0.859f)), // waterline — bright shallow teal + (0.125f, new Color(0.376f, 0.694f, 0.780f)), + (0.310f, new Color(0.208f, 0.510f, 0.686f)), + (0.590f, new Color(0.114f, 0.341f, 0.557f)), + (0.760f, new Color(0.059f, 0.212f, 0.408f)), + (0.880f, new Color(0.031f, 0.125f, 0.278f)), + (1.000f, new Color(0.016f, 0.063f, 0.165f)), // abyss — flat + }; + /// The hypsometric tint for a height, before any relief shading. public static Color Tint(Kind kind, float height, float seaLevel) { - bool dusk = kind == Kind.Dusk; + var (land, sea) = Ramps(kind); if (height >= seaLevel) - return Sample(dusk ? DuskLand : AtlasLand, height); + return Sample(land, height); // Compressed bathymetry: index by d/(d+k), so the shallows get the range. float depth = seaLevel - height; float t = depth / (depth + SeaCompression); - return Sample(dusk ? DuskSea : AtlasSea, t); + return Sample(sea, t); } + private static ((float h, Color c)[] land, (float h, Color c)[] sea) Ramps(Kind kind) => kind switch + { + Kind.Dusk => (DuskLand, DuskSea), + Kind.CostaRica => (CostaRicaLand, CostaRicaSea), + _ => (AtlasLand, AtlasSea), + }; + + /// The land stops of a palette, for drawing a legend. + public static (float h, Color c)[] LandStops(Kind kind) => Ramps(kind).land; + private static Color Sample((float h, Color c)[] stops, float v) { if (v <= stops[0].h) return stops[0].c; diff --git a/Tools/Scripts/ReliefRenderTool.cs b/Tools/Scripts/ReliefRenderTool.cs index 236048e..2a47460 100644 --- a/Tools/Scripts/ReliefRenderTool.cs +++ b/Tools/Scripts/ReliefRenderTool.cs @@ -24,7 +24,8 @@ namespace IslaApocalypse.Tools /// ISLA_MAPSIZE side in columns (default 2048) /// ISLA_SEEDS comma-separated positive (default: the 4 pinned seeds) /// ISLA_LOOKS comma-separated look names (default: atlas,relief,dusk) - /// ISLA_BATCH output batch folder (default 03_relief_taste) + /// ISLA_TASK authoring task number (default 3) + /// ISLA_BATCH descriptor, NO prefix (default "relief_taste") /// ISLA_SOURCE batch to read .f32 from (default 01_pass1_port) /// ISLA_DUMP_RAW "1" to also dump .f32 when a field had to be generated /// @@ -38,12 +39,15 @@ namespace IslaApocalypse.Tools int mapSize = EnvInt("ISLA_MAPSIZE", 2048); int[] seeds = EnvSeeds("ISLA_SEEDS", DefaultSeeds); - string batch = EnvStr("ISLA_BATCH", "03_relief_taste"); + int task = EnvInt("ISLA_TASK", 3); // the task that authored this batch + string batch = EnvStr("ISLA_BATCH", "relief_taste"); string source = EnvStr("ISLA_SOURCE", "01_pass1_port"); bool dumpRaw = EnvStr("ISLA_DUMP_RAW", "0") == "1"; LookConfig[] looks = SelectLooks(EnvStr("ISLA_LOOKS", null)); - string batchRoot = Path.Combine(ToolingPaths.BatchesRoot, batch); + // ⚠ Composed by BatchRoot, never free-form: the prefix is the AUTHORING TASK number, + // not a counter, and a descriptor carrying its own prefix is refused. → Tools/README.md. + string batchRoot = ToolingPaths.BatchRoot(task, batch); string sourceRoot = Path.Combine(ToolingPaths.BatchesRoot, source); DirAccess.MakeDirRecursiveAbsolute(batchRoot); DirAccess.MakeDirRecursiveAbsolute(ToolingPaths.BatchScratch(batchRoot)); diff --git a/Tools/Scripts/ReliefRenderer.cs b/Tools/Scripts/ReliefRenderer.cs index f902aa0..7af17eb 100644 --- a/Tools/Scripts/ReliefRenderer.cs +++ b/Tools/Scripts/ReliefRenderer.cs @@ -46,6 +46,19 @@ namespace IslaApocalypse.Tools { /// Render a height field to a shaded-relief PNG. Returns the path written. public static string SavePng(float[,] height, int mapSize, LookConfig look, string absolutePath) + { + Image img = Render(height, mapSize, look); + Error err = img.SavePng(absolutePath); + if (err != Error.Ok) GD.PrintErr($"[ReliefRenderer] SavePng failed ({err}) for {absolutePath}"); + return absolutePath; + } + + /// + /// Render to an in-memory image, so a caller can composite onto it (a legend, say) before + /// saving. ⚠ With HillshadeStrength = 0 this is a FLAT hypsometric tint and no relief + /// at all — which is the intended hero render, not a degenerate case. + /// + public static Image Render(float[,] height, int mapSize, LookConfig look) { var img = Image.CreateEmpty(mapSize, mapSize, false, Image.Format.Rgb8); @@ -108,9 +121,7 @@ namespace IslaApocalypse.Tools } } - Error err = img.SavePng(absolutePath); - if (err != Error.Ok) GD.PrintErr($"[ReliefRenderer] SavePng failed ({err}) for {absolutePath}"); - return absolutePath; + return img; } } } diff --git a/Tools/Scripts/ReviewBatchTool.cs b/Tools/Scripts/ReviewBatchTool.cs new file mode 100644 index 0000000..4dbc30e --- /dev/null +++ b/Tools/Scripts/ReviewBatchTool.cs @@ -0,0 +1,292 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; +using Godot; +using IslaApocalypse.Core; + +namespace IslaApocalypse.Tools +{ + /// + /// Builds the Phase-1 REVIEW batch: plain grayscale views so the noise itself can be judged, the + /// wide gradient with a legend as the pretty map, and two labelled relief comparisons. + /// + /// ⚠ PRESENTATION ONLY. Reads existing `.f32` dumps; generates nothing unless a dump is missing. + /// + /// ⚠ THE BATCH FOLDER IS `<task>_<descriptor>` AND THE TASK NUMBER IS EXPLICIT. + /// It is passed as ISLA_TASK and composed by , which + /// refuses a descriptor carrying its own prefix. The prefix names the task that AUTHORED the + /// batch; it is not a running counter. → Tools/README.md. + /// + /// ═══ RUNNING IT ═══ + /// + /// Godot_v4.7.2-stable_mono_linux.x86_64 --headless \ + /// --path ~/celerNexus/islaApocalypse-v2 res://Tools/Scenes/ReviewBatchTool.tscn + /// + /// ISLA_TASK authoring task number (default 4) + /// ISLA_BATCH descriptor, NO prefix (default "review") + /// ISLA_SOURCE batch holding the .f32 (default 01_pass1_port) + /// ISLA_MAPSIZE side in columns (default 2048) + /// ISLA_SEEDS comma-separated positive (default: the 4 pinned seeds) + /// + public partial class ReviewBatchTool : Node + { + private static readonly int[] DefaultSeeds = { 1063685222, 20260819, 777001, 424242 }; + + /// Top of the legend's scale, in raw height units. Covers the measured max (1.415). + private const float LegendTop = 1.45f; + + public override void _Ready() + { + // ⚠ An exception thrown out of _Ready does NOT stop the engine — Godot logs it and the + // process sits there with no main loop to end it, so a misconfigured batch run HANGS + // instead of failing. Measured, not assumed: a deliberately bad ISLA_BATCH hung until + // killed. A tool that hangs on bad input is worse than one that crashes, because a + // hang looks like slow work. So: catch, say what went wrong, exit non-zero. + try + { + Run(); + } + catch (Exception e) + { + GD.PrintErr("=================================================================="); + GD.PrintErr($" REFUSED: {e.Message}"); + GD.PrintErr("=================================================================="); + GetTree().Quit(2); + } + } + + private void Run() + { + ToolingPaths.Configure(OS.GetUserDataDir()); + + int task = EnvInt("ISLA_TASK", 4); + string descr = EnvStr("ISLA_BATCH", "review"); + string source = EnvStr("ISLA_SOURCE", "01_pass1_port"); + int mapSize = EnvInt("ISLA_MAPSIZE", 2048); + int[] seeds = EnvSeeds("ISLA_SEEDS", DefaultSeeds); + + string batchRoot = ToolingPaths.BatchRoot(task, descr); // ⚠ composed, never free-form + string sourceRoot = Path.Combine(ToolingPaths.BatchesRoot, source); + DirAccess.MakeDirRecursiveAbsolute(batchRoot); + DirAccess.MakeDirRecursiveAbsolute(ToolingPaths.BatchScratch(batchRoot)); + + GD.Print("=================================================================="); + GD.Print(" PHASE-1 REVIEW — raw grayscale, the wide gradient, labelled relief"); + GD.Print("=================================================================="); + GD.Print($"batch : {batchRoot}"); + GD.Print($"source : {sourceRoot}"); + GD.Print($"MapSize : {mapSize} seeds: {string.Join(", ", seeds)}"); + GD.Print("=================================================================="); + + var notes = new List(); + int primary = seeds[0]; + + // ---- 1. the raw crown-jewel noise, uncoloured (base-noise-only ablation) ---- + float[,] baseNoise = Load(sourceRoot, $"{primary}_ab1_base_only", mapSize); + if (baseNoise != null) + notes.Add(Gray(baseNoise, mapSize, batchRoot, "1_noise_grayscale", primary, + "the raw crown-jewel noise, before any island shaping")); + else + GD.PrintErr($" ⚠ no ab1_base_only dump for {primary} at {mapSize} — 1_noise_grayscale SKIPPED, not faked."); + + // ---- 2..5, all from the full pass-1 field ---- + foreach (int seed in seeds) + { + float[,] h = Load(sourceRoot, $"{seed}_full", mapSize); + if (h == null) + { + GD.PrintErr($" ⚠ no full dump for {seed} at {mapSize} — generating deterministically."); + h = Topography.Generate(new TerrainGenConfig { MapSize = mapSize, Seed = seed }).Height; + } + + notes.Add(Gray(h, mapSize, batchRoot, "2_height_grayscale", seed, + "the same noise, shaped into an island")); + + notes.Add(Gradient(h, mapSize, batchRoot, "3_gradient_flat", seed)); + + // The two relief comparisons are for the primary seed only — they answer a question + // about the LOOK, and asking it four times is the iteration fatigue the batch + // discipline warns about. + if (seed == primary) + { + notes.Add(Relief(h, mapSize, batchRoot, "4_subtle_relief", seed, "subtle_relief")); + notes.Add(Relief(h, mapSize, batchRoot, "5_diagnostic_relief", seed, "diagnostic_relief")); + } + } + + WriteIndex(batchRoot, source, mapSize, seeds, notes); + + GD.Print("\n=================================================================="); + GD.Print($" DONE — {notes.Count} images in {batchRoot}"); + GD.Print("=================================================================="); + GetTree().Quit(0); + } + + private static float[,] Load(string sourceRoot, string folder, int mapSize) + => HeightField.Load(Path.Combine(sourceRoot, folder, "height.f32"), mapSize); + + private static string Gray(float[,] field, int mapSize, string batchRoot, string sub, int seed, string what) + { + ulong t0 = Time.GetTicksMsec(); + string dir = Path.Combine(batchRoot, sub); + DirAccess.MakeDirRecursiveAbsolute(dir); + string path = Path.Combine(dir, $"{seed}.png"); + var (min, max) = GrayscaleRenderer.SavePng(field, mapSize, path); + ulong ms = Time.GetTicksMsec() - t0; + + GD.Print($" {sub}/{seed}.png black={min:F3} white={max:F3} {ms} ms ({what})"); + return $"| `{sub}/{seed}.png` | {seed} | grayscale | black `{min:F3}` → white `{max:F3}` | {ms} ms |"; + } + + private static string Gradient(float[,] h, int mapSize, string batchRoot, string sub, int seed) + { + ulong t0 = Time.GetTicksMsec(); + string dir = Path.Combine(batchRoot, sub); + DirAccess.MakeDirRecursiveAbsolute(dir); + + var look = new LookConfig + { + Name = "gradient_flat", Palette = ReliefPalette.Kind.CostaRica, HillshadeStrength = 0f, + }; + Image map = ReliefRenderer.Render(h, mapSize, look); + Image withLegend = LegendRenderer.WithLegend(map, look.Palette, look.SeaLevel, LegendTop, $"SEED {seed}"); + + string path = Path.Combine(dir, $"{seed}.png"); + withLegend.SavePng(path); + ulong ms = Time.GetTicksMsec() - t0; + + GD.Print($" {sub}/{seed}.png wide gradient + legend, FLAT (no relief) {ms} ms"); + return $"| `{sub}/{seed}.png` | {seed} | ⭐ wide gradient, flat + legend | CostaRica, no hillshade | {ms} ms |"; + } + + private static string Relief(float[,] h, int mapSize, string batchRoot, string sub, int seed, string lookName) + { + ulong t0 = Time.GetTicksMsec(); + string dir = Path.Combine(batchRoot, sub); + DirAccess.MakeDirRecursiveAbsolute(dir); + + LookConfig look = null; + foreach (LookConfig l in LookConfig.Variants()) + if (l.Name == lookName) look = l; + + string path = Path.Combine(dir, $"{seed}.png"); + ReliefRenderer.SavePng(h, mapSize, look, path); + ulong ms = Time.GetTicksMsec() - t0; + + GD.Print($" {sub}/{seed}.png {look} {ms} ms"); + return $"| `{sub}/{seed}.png` | {seed} | {lookName} | zex {look.ZExaggeration:F0}, strength {look.HillshadeStrength:F2} | {ms} ms |"; + } + + private static void WriteIndex(string batchRoot, string source, int mapSize, int[] seeds, List rows) + { + var sb = new StringBuilder(); + sb.AppendLine("# Batch — Phase 1 review: judge the noise, confirm the colour direction"); + sb.AppendLine(); + sb.AppendLine("**Presentation only.** Every image here is the *same terrain* as"); + sb.AppendLine($"`{source}`, loaded from its `.f32` dumps. Nothing here can change the world — only how it"); + sb.AppendLine("is drawn."); + sb.AppendLine(); + sb.AppendLine($"- **MapSize:** {mapSize} · **Seeds:** {string.Join(", ", seeds)}"); + sb.AppendLine("- **Sea colour boundary:** 0.15 — a *colour* boundary, **not a water surface.** No water is modelled (Phase 2)."); + sb.AppendLine(); + sb.AppendLine("---"); + sb.AppendLine(); + sb.AppendLine("## What each folder is"); + sb.AppendLine(); + sb.AppendLine("### `1_noise_grayscale` — the raw noise, uncoloured"); + sb.AppendLine(); + sb.AppendLine("**This is the crown jewel with nothing done to it.** No island, no falloff, no spine — just"); + sb.AppendLine("the FastNoiseLite field the whole world is built from, normalized black-to-white. If the"); + sb.AppendLine("noise itself is good or bad, this is where you can see it: a palette bends the value"); + sb.AppendLine("distribution and a hillshade adds shape the data doesn't have, so neither can answer that"); + sb.AppendLine("question. This one can."); + sb.AppendLine(); + sb.AppendLine("### `2_height_grayscale` — the same noise, shaped into an island"); + sb.AppendLine(); + sb.AppendLine("Identical noise, now with the island mask, the edge roughness, the southern sinker, the"); + sb.AppendLine("Trench and the mountain spine applied. Still uncoloured, so you are seeing the SHAPE and"); + sb.AppendLine("nothing else. Compare against `1_` to see exactly what the shaping did."); + sb.AppendLine(); + sb.AppendLine("### ⭐ `3_gradient_flat` — THE PRETTY MAP. This is the direction."); + sb.AppendLine(); + sb.AppendLine("The wide Costa-Rica-style gradient, **rendered flat — no hillshade at all**, with an"); + sb.AppendLine("elevation legend down the right side. Colour alone carries the elevation."); + sb.AppendLine(); + sb.AppendLine("**⚠ The legend is in RELATIVE units, not metres.** Raw height runs sea `0.15` to peak"); + sb.AppendLine("`~1.45`. The metres conversion is Phase 2's elevation profile; labelling this bar \"m\""); + sb.AppendLine("now would be inventing a fact."); + sb.AppendLine(); + sb.AppendLine("**⚠ Honest expectation:** this island is mostly lowland — median land height `0.456`"); + sb.AppendLine("against a `1.415` peak — so it reads green-to-yellow with the spine in orange, brown and"); + sb.AppendLine("white. That is *accurate for a lowland island with a central range*, not a palette that"); + sb.AppendLine("failed to use its range. The fuller Costa-Rica spread arrives when **Phase 2's"); + sb.AppendLine("redistribution curve** moves the height distribution upward; the palette is built now so"); + sb.AppendLine("Phase 2 inherits it rather than re-tuning it."); + sb.AppendLine(); + sb.AppendLine("### `4_subtle_relief` — the same gradient with gentle relief"); + sb.AppendLine(); + sb.AppendLine("What tasteful relief looks like (exaggeration 18, strength 0.30) versus the amped version."); + sb.AppendLine(); + sb.AppendLine("**⚠ Even this still shows fuzz, and that is the terrain, not the setting.** Raw pass-1"); + sb.AppendLine("noise is fine-grained and incoherent — there is nothing yet for a light to model. Relief"); + sb.AppendLine("comes into its own after **Phase 2's erosion carves coherent landforms**: ridges with"); + sb.AppendLine("valleys between them, drainage that runs somewhere. Then a subtle hillshade has real shape"); + sb.AppendLine("to light, and it will look like the reference maps."); + sb.AppendLine(); + sb.AppendLine("### ⚠ `5_diagnostic_relief` — A DEV TOOL. NOT A PRETTY MAP."); + sb.AppendLine(); + sb.AppendLine("Strong exaggeration (100) and strength (0.80). **This is for spotting artifacts** — a"); + sb.AppendLine("centre-line crease, a stair-step, a seam, a discontinuity — by making slope impossible to"); + sb.AppendLine("miss. **Its bumpiness is exaggerated SLOPE, not extra terrain.** The terrain in this image"); + sb.AppendLine("is identical to `3_gradient_flat`; only the lighting lies. Do not judge the world by it,"); + sb.AppendLine("and do not show it as the deliverable."); + sb.AppendLine(); + sb.AppendLine("---"); + sb.AppendLine(); + sb.AppendLine("## ⭐ What Phase 2 changes"); + sb.AppendLine(); + sb.AppendLine("The fine bumpiness you see everywhere is **raw fractal noise** — statistically correct and"); + sb.AppendLine("geologically meaningless. Phase 2's **redistribution curve** reshapes the height"); + sb.AppendLine("distribution (flats become flat, peaks become peaks) and **hydraulic erosion** carves"); + sb.AppendLine("drainage into it, turning that fuzz into **coherent farmland, ridgelines and river"); + sb.AppendLine("valleys**. The colour direction settled here is what those landforms will be painted with."); + sb.AppendLine(); + sb.AppendLine("---"); + sb.AppendLine(); + sb.AppendLine("## Images"); + sb.AppendLine(); + sb.AppendLine("| File | Seed | View | Settings / range | Time |"); + sb.AppendLine("|---|---|---|---|---|"); + foreach (string r in rows) sb.AppendLine(r); + sb.AppendLine(); + sb.AppendLine("`scratch/` is persistent and is never cleaned."); + sb.AppendLine(); + sb.AppendLine("> **Folder naming:** the `04_` prefix is the number of the TASK that authored this batch —"); + sb.AppendLine("> not a running counter. Everything task 04 produces is `04_*`."); + + using var f = Godot.FileAccess.Open(Path.Combine(batchRoot, "INDEX.md"), Godot.FileAccess.ModeFlags.Write); + if (f == null) { GD.PrintErr("could not write INDEX.md"); return; } + f.StoreString(sb.ToString()); + } + + private static string EnvStr(string k, string fallback) + { + string v = System.Environment.GetEnvironmentVariable(k); + return string.IsNullOrWhiteSpace(v) ? fallback : v; + } + + private static int EnvInt(string k, int fallback) + => int.TryParse(EnvStr(k, null) ?? "", out int v) ? v : fallback; + + private static int[] EnvSeeds(string k, int[] fallback) + { + string v = EnvStr(k, null); + if (v == null) return fallback; + var o = new List(); + foreach (string p in v.Split(',', StringSplitOptions.RemoveEmptyEntries)) + if (int.TryParse(p.Trim(), out int s) && s > 0) o.Add(s); + return o.Count > 0 ? o.ToArray() : fallback; + } + } +} diff --git a/Tools/Scripts/ReviewBatchTool.cs.uid b/Tools/Scripts/ReviewBatchTool.cs.uid new file mode 100644 index 0000000..3f39bd1 --- /dev/null +++ b/Tools/Scripts/ReviewBatchTool.cs.uid @@ -0,0 +1 @@ +uid://bo683btnmgy36 diff --git a/Tools/Scripts/TerrainGenTool.cs b/Tools/Scripts/TerrainGenTool.cs index 9cac756..799023e 100644 --- a/Tools/Scripts/TerrainGenTool.cs +++ b/Tools/Scripts/TerrainGenTool.cs @@ -24,7 +24,8 @@ namespace IslaApocalypse.Tools /// /// ISLA_MAPSIZE map side in columns (default 2048 — iteration size) /// ISLA_SEEDS comma-separated positive ints (default: the pinned batch below) - /// ISLA_BATCH batch folder name (default 01_pass1_port) + /// ISLA_TASK authoring task number (default 2) + /// ISLA_BATCH descriptor, NO prefix (default "pass1_port") /// ISLA_OUTPUT_DIR where batches/ lives (Core/ToolingPaths) /// ISLA_SKIP_RAW "1" to skip the .f32 dumps /// ISLA_LADDER "0" to skip the ablation ladder (for a full-size confirmation pass) @@ -45,12 +46,15 @@ namespace IslaApocalypse.Tools int mapSize = EnvInt("ISLA_MAPSIZE", DefaultMapSize); int[] seeds = EnvSeeds("ISLA_SEEDS", DefaultSeeds); - string batch = EnvStr("ISLA_BATCH", "01_pass1_port"); + int task = EnvInt("ISLA_TASK", 2); // the task that authored this batch + string batch = EnvStr("ISLA_BATCH", "pass1_port"); bool skipRaw = EnvStr("ISLA_SKIP_RAW", "0") == "1"; bool ladder = EnvStr("ISLA_LADDER", "1") == "1"; var scale = new GenerationScale(mapSize); - string batchRoot = Path.Combine(ToolingPaths.BatchesRoot, batch); + // ⚠ Composed by BatchRoot, never free-form. → Tools/README.md, "the prefix is the + // AUTHORING TASK number, not a counter". + string batchRoot = ToolingPaths.BatchRoot(task, batch); string scratch = ToolingPaths.BatchScratch(batchRoot); DirAccess.MakeDirRecursiveAbsolute(batchRoot); DirAccess.MakeDirRecursiveAbsolute(scratch); // persistent; never cleaned diff --git a/Tools/Scripts/TinyFont.cs b/Tools/Scripts/TinyFont.cs new file mode 100644 index 0000000..245d7af --- /dev/null +++ b/Tools/Scripts/TinyFont.cs @@ -0,0 +1,114 @@ +using System.Collections.Generic; +using Godot; + +namespace IslaApocalypse.Tools +{ + /// + /// A 5x7 bitmap font, drawn straight into a . + /// + /// ═══ WHY A HAND-ROLLED FONT AND NOT GODOT'S ═══ + /// + /// Godot's Font renders through a CanvasItem, which means a viewport — the SubViewport + /// capture path the renderer deliberately avoids, because it awaits render frames and is why + /// `--headless` hangs. A legend is a handful of digits; paying the whole capture path for it + /// would be a poor trade, and would drag a frame-loop dependency into a tool that currently + /// needs no display at all. + /// + /// So: 5x7 glyphs, integer-scaled, drawn per-pixel. Digits, a few punctuation marks and A–Z. + /// Unknown characters draw as blank rather than throwing — a legend with a missing letter is a + /// cosmetic problem, not a reason to lose the render. + /// + public static class TinyFont + { + public const int GlyphW = 5; + public const int GlyphH = 7; + + // Each glyph is 7 rows of 5 columns. '#' = ink. Written out rather than hex-packed so a + // reader can verify a letter by looking at it. + private static readonly Dictionary Glyphs = new() + { + [' '] = new[] { ".....", ".....", ".....", ".....", ".....", ".....", "....." }, + ['.'] = new[] { ".....", ".....", ".....", ".....", ".....", "..##.", "..##." }, + ['-'] = new[] { ".....", ".....", ".....", "#####", ".....", ".....", "....." }, + [':'] = new[] { ".....", "..##.", "..##.", ".....", "..##.", "..##.", "....." }, + ['/'] = new[] { "....#", "...#.", "...#.", "..#..", ".#...", ".#...", "#...." }, + ['('] = new[] { "...#.", "..#..", ".#...", ".#...", ".#...", "..#..", "...#." }, + [')'] = new[] { ".#...", "..#..", "...#.", "...#.", "...#.", "..#..", ".#..." }, + + ['0'] = new[] { ".###.", "#...#", "#..##", "#.#.#", "##..#", "#...#", ".###." }, + ['1'] = new[] { "..#..", ".##..", "..#..", "..#..", "..#..", "..#..", ".###." }, + ['2'] = new[] { ".###.", "#...#", "....#", "...#.", "..#..", ".#...", "#####" }, + ['3'] = new[] { "#####", "...#.", "..#..", "...#.", "....#", "#...#", ".###." }, + ['4'] = new[] { "...#.", "..##.", ".#.#.", "#..#.", "#####", "...#.", "...#." }, + ['5'] = new[] { "#####", "#....", "####.", "....#", "....#", "#...#", ".###." }, + ['6'] = new[] { "..##.", ".#...", "#....", "####.", "#...#", "#...#", ".###." }, + ['7'] = new[] { "#####", "....#", "...#.", "..#..", ".#...", ".#...", ".#..." }, + ['8'] = new[] { ".###.", "#...#", "#...#", ".###.", "#...#", "#...#", ".###." }, + ['9'] = new[] { ".###.", "#...#", "#...#", ".####", "....#", "...#.", ".##.." }, + + ['A'] = new[] { ".###.", "#...#", "#...#", "#####", "#...#", "#...#", "#...#" }, + ['B'] = new[] { "####.", "#...#", "#...#", "####.", "#...#", "#...#", "####." }, + ['C'] = new[] { ".###.", "#...#", "#....", "#....", "#....", "#...#", ".###." }, + ['D'] = new[] { "###..", "#..#.", "#...#", "#...#", "#...#", "#..#.", "###.." }, + ['E'] = new[] { "#####", "#....", "#....", "####.", "#....", "#....", "#####" }, + ['F'] = new[] { "#####", "#....", "#....", "####.", "#....", "#....", "#...." }, + ['G'] = new[] { ".###.", "#...#", "#....", "#.###", "#...#", "#...#", ".####" }, + ['H'] = new[] { "#...#", "#...#", "#...#", "#####", "#...#", "#...#", "#...#" }, + ['I'] = new[] { ".###.", "..#..", "..#..", "..#..", "..#..", "..#..", ".###." }, + ['J'] = new[] { "..###", "...#.", "...#.", "...#.", "...#.", "#..#.", ".##.." }, + ['K'] = new[] { "#...#", "#..#.", "#.#..", "##...", "#.#..", "#..#.", "#...#" }, + ['L'] = new[] { "#....", "#....", "#....", "#....", "#....", "#....", "#####" }, + ['M'] = new[] { "#...#", "##.##", "#.#.#", "#.#.#", "#...#", "#...#", "#...#" }, + ['N'] = new[] { "#...#", "##..#", "#.#.#", "#..##", "#...#", "#...#", "#...#" }, + ['O'] = new[] { ".###.", "#...#", "#...#", "#...#", "#...#", "#...#", ".###." }, + ['P'] = new[] { "####.", "#...#", "#...#", "####.", "#....", "#....", "#...." }, + ['Q'] = new[] { ".###.", "#...#", "#...#", "#...#", "#.#.#", "#..#.", ".##.#" }, + ['R'] = new[] { "####.", "#...#", "#...#", "####.", "#.#..", "#..#.", "#...#" }, + ['S'] = new[] { ".####", "#....", "#....", ".###.", "....#", "....#", "####." }, + ['T'] = new[] { "#####", "..#..", "..#..", "..#..", "..#..", "..#..", "..#.." }, + ['U'] = new[] { "#...#", "#...#", "#...#", "#...#", "#...#", "#...#", ".###." }, + ['V'] = new[] { "#...#", "#...#", "#...#", "#...#", "#...#", ".#.#.", "..#.." }, + ['W'] = new[] { "#...#", "#...#", "#...#", "#.#.#", "#.#.#", "##.##", "#...#" }, + ['X'] = new[] { "#...#", "#...#", ".#.#.", "..#..", ".#.#.", "#...#", "#...#" }, + ['Y'] = new[] { "#...#", "#...#", ".#.#.", "..#..", "..#..", "..#..", "..#.." }, + ['Z'] = new[] { "#####", "....#", "...#.", "..#..", ".#...", "#....", "#####" }, + }; + + /// Pixel width of at the given integer scale. + public static int Width(string text, int scale) => text.Length * (GlyphW + 1) * scale; + + /// Pixel height of one line at the given integer scale. + public static int Height(int scale) => GlyphH * scale; + + /// + /// Draw with its top-left at (x, y). Uppercases automatically — + /// the font has no lowercase, and silently dropping letters would be worse than shouting. + /// + public static void Draw(Image img, string text, int x, int y, int scale, Color color) + { + int cursor = x; + foreach (char raw in text.ToUpperInvariant()) + { + if (Glyphs.TryGetValue(raw, out string[] g)) + { + for (int row = 0; row < GlyphH; row++) + for (int col = 0; col < GlyphW; col++) + if (g[row][col] == '#') + FillCell(img, cursor + col * scale, y + row * scale, scale, color); + } + cursor += (GlyphW + 1) * scale; + } + } + + private static void FillCell(Image img, int px, int py, int scale, Color color) + { + for (int dy = 0; dy < scale; dy++) + for (int dx = 0; dx < scale; dx++) + { + int tx = px + dx, ty = py + dy; + if (tx >= 0 && ty >= 0 && tx < img.GetWidth() && ty < img.GetHeight()) + img.SetPixel(tx, ty, color); + } + } + } +} diff --git a/Tools/Scripts/TinyFont.cs.uid b/Tools/Scripts/TinyFont.cs.uid new file mode 100644 index 0000000..51c7f83 --- /dev/null +++ b/Tools/Scripts/TinyFont.cs.uid @@ -0,0 +1 @@ +uid://bmk2lhoex5vog