From fdf52f61ee0f126d812afd1d2cc16146a7526777 Mon Sep 17 00:00:00 2001 From: beezm Date: Wed, 19 Aug 2026 22:55:53 -0400 Subject: [PATCH] Phase 1 review: raw grayscale, the wide gradient as hero, fixed batch naming MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- Core/Scripts/ToolingPaths.cs | 48 +++- Tools/README.md | 62 +++++- Tools/Scenes/ReviewBatchTool.tscn | 6 + Tools/Scripts/GrayscaleRenderer.cs | 51 +++++ Tools/Scripts/GrayscaleRenderer.cs.uid | 1 + Tools/Scripts/LegendRenderer.cs | 129 +++++++++++ Tools/Scripts/LegendRenderer.cs.uid | 1 + Tools/Scripts/LookConfig.cs | 51 +++-- Tools/Scripts/ReliefPalette.cs | 63 +++++- Tools/Scripts/ReliefRenderTool.cs | 10 +- Tools/Scripts/ReliefRenderer.cs | 17 +- Tools/Scripts/ReviewBatchTool.cs | 292 +++++++++++++++++++++++++ Tools/Scripts/ReviewBatchTool.cs.uid | 1 + Tools/Scripts/TerrainGenTool.cs | 10 +- Tools/Scripts/TinyFont.cs | 114 ++++++++++ Tools/Scripts/TinyFont.cs.uid | 1 + 16 files changed, 820 insertions(+), 37 deletions(-) create mode 100644 Tools/Scenes/ReviewBatchTool.tscn create mode 100644 Tools/Scripts/GrayscaleRenderer.cs create mode 100644 Tools/Scripts/GrayscaleRenderer.cs.uid create mode 100644 Tools/Scripts/LegendRenderer.cs create mode 100644 Tools/Scripts/LegendRenderer.cs.uid create mode 100644 Tools/Scripts/ReviewBatchTool.cs create mode 100644 Tools/Scripts/ReviewBatchTool.cs.uid create mode 100644 Tools/Scripts/TinyFont.cs create mode 100644 Tools/Scripts/TinyFont.cs.uid 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