From 4d2e024b2f2deeaabb6a6d34876fa5641f901a22 Mon Sep 17 00:00:00 2001 From: beezm Date: Sat, 8 Aug 2026 03:30:27 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20curve=20v4=20=E2=80=94=20spatially=20mo?= =?UTF-8?q?dulated=20shelves=20(terrain-water=20task=2008)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per the task-07 gate: the terraces work but uniform anchors put a flat ring at exactly 100 m and 220 m on every mountain. v4 keeps v3's structure (frozen toe/rise, three risers, per-seed u4 spike to 420 m) and turns the shelf anchors into smooth spatial fields: bench 100+-12 m and plateau 220+-20 m via two decorrelated very-low-freq Simplex fields (~3 undulations per island width), plus a strength field (~5/island) blending each shelf's output span between ~2 m (pronounced flat) and ~25 m (barely a hint) — the optional strength modulation shipped, ordering-safe by construction (worst-case bench top 0.6962 < plateau min 0.9468). Field seeds derive from the RESOLVED noise seed + fixed offsets (7101/7207/7303) — no config knob. Apply is per-column with all modulated values as pure parameters (D-035); the classify path never sees them. Monotonicity assertion now sweeps all 8 modulation-extreme corners x per-seed spikeMax. TerrainCurve gate "off"|"v4" (default v4); v1-v3 retired loudly. TCRV extended (+36 B when version>=4: amplitudes, span range, frequencies, seed offsets — length-framed section makes the layout change safe; parser dispatches on the curve version byte; harness compares the extension). Note: MapGenerator.cs also carries the developer's editor whitespace normalization (spaces->tabs, git diff -w empty before this change). Co-Authored-By: Claude Fable 5 --- Core/Scripts/BlueprintWriter.cs | 10 ++ Core/Scripts/ConfigManager.cs | 18 +-- Core/Scripts/MapDataParser.cs | 17 +++ Tools/Scripts/HeightCurve.cs | 193 ++++++++++++++----------- Tools/Scripts/MapGenerator.cs | 229 ++++++++++++++++++------------ Tools/Scripts/RoundTripHarness.cs | 8 +- 6 files changed, 292 insertions(+), 183 deletions(-) diff --git a/Core/Scripts/BlueprintWriter.cs b/Core/Scripts/BlueprintWriter.cs index 56bc07c..4ed218d 100644 --- a/Core/Scripts/BlueprintWriter.cs +++ b/Core/Scripts/BlueprintWriter.cs @@ -132,6 +132,16 @@ namespace IslaApocalypse.Core writer.Write(c.Sea); writer.Write(c.OrangeCeil); writer.Write(c.RedCeil); writer.Write(c.PlateauLo); writer.Write(c.PlateauHi); writer.Write(c.PeakCap); writer.Write(c.TailSlope); + + // v4+ extension: shelf-modulation parameters. + if (c.Version >= 4) + { + writer.Write(c.BenchAmp); writer.Write(c.PlateauAmp); + writer.Write(c.ShelfSpanMin); writer.Write(c.ShelfSpanMax); + writer.Write(c.ElevFreqIslands); writer.Write(c.StrengthFreqIslands); + writer.Write(c.BenchSeedOffset); writer.Write(c.PlateauSeedOffset); + writer.Write(c.StrengthSeedOffset); + } } private static void WriteWaterBodyIds(BinaryWriter writer, WorldBlueprint bp) diff --git a/Core/Scripts/ConfigManager.cs b/Core/Scripts/ConfigManager.cs index d03b34b..6030555 100644 --- a/Core/Scripts/ConfigManager.cs +++ b/Core/Scripts/ConfigManager.cs @@ -26,12 +26,12 @@ namespace IslaApocalypse.Core // Change this if your namespace is different public static string SeaLevelModel = "flat"; public static float SeaLevelValue = 0.15f; - // Height-redistribution curve (tasks 05/06/07, graduation M-7): "v3" applies - // the terraced-ascent curve with the per-seed peak spike (HeightCurve.cs); - // "off" is the raw legacy profile. "v1"/"v2" were retired by their successor - // recalibrations (old blueprints are regenerable). Biome classification is - // curve-invariant by construction either way. Default: v3. - public static string TerrainCurve = "v3"; + // Height-redistribution curve (tasks 05–08, graduation M-7): "v4" applies the + // terraced-ascent curve with spatially modulated shelves and the per-seed peak + // spike (HeightCurve.cs); "off" is the raw legacy profile. "v1"–"v3" were + // retired by successor recalibrations (old blueprints are regenerable). Biome + // classification is curve-invariant by construction either way. Default: v4. + public static string TerrainCurve = "v4"; public static void LoadConfig() { @@ -106,10 +106,10 @@ namespace IslaApocalypse.Core // Change this if your namespace is different if (data.ContainsKey("TerrainCurve")) { string curve = (string)data["TerrainCurve"]; - if (curve == "off" || curve == "v3") + if (curve == "off" || curve == "v4") TerrainCurve = curve; - else if (curve == "v1" || curve == "v2") - GD.PrintErr($"[ConfigManager] TerrainCurve '{curve}' was retired by a later recalibration (v3, task 07). Keeping '{TerrainCurve}' — use \"v3\" or \"off\"."); + else if (curve == "v1" || curve == "v2" || curve == "v3") + GD.PrintErr($"[ConfigManager] TerrainCurve '{curve}' was retired by a later recalibration (v4, task 08). Keeping '{TerrainCurve}' — use \"v4\" or \"off\"."); else GD.PrintErr($"[ConfigManager] Unknown TerrainCurve '{curve}'. Keeping '{TerrainCurve}'."); } diff --git a/Core/Scripts/MapDataParser.cs b/Core/Scripts/MapDataParser.cs index c40ec40..00e03a6 100644 --- a/Core/Scripts/MapDataParser.cs +++ b/Core/Scripts/MapDataParser.cs @@ -74,6 +74,12 @@ namespace IslaApocalypse.Core // constants alone, which is exactly why it is recorded here. public float T1, T2, T3, T4, SpikeMax; public float Sea, OrangeCeil, RedCeil, PlateauLo, PlateauHi, PeakCap, TailSlope; // output bands + + // v4 extension — shelf-modulation parameters (record layout versioned by the + // curve Version byte; zero-valued when parsing an older record). + public float BenchAmp, PlateauAmp, ShelfSpanMin, ShelfSpanMax; + public float ElevFreqIslands, StrengthFreqIslands; + public int BenchSeedOffset, PlateauSeedOffset, StrengthSeedOffset; } public class WorldBlueprint @@ -399,6 +405,17 @@ namespace IslaApocalypse.Core c.RedCeil = reader.ReadSingle(); c.PlateauLo = reader.ReadSingle(); c.PlateauHi = reader.ReadSingle(); c.PeakCap = reader.ReadSingle(); c.TailSlope = reader.ReadSingle(); + + // v4+ extension: shelf-modulation parameters (older records simply end here — + // the section is length-framed, so the version byte is the dispatcher). + if (c.Version >= 4) + { + c.BenchAmp = reader.ReadSingle(); c.PlateauAmp = reader.ReadSingle(); + c.ShelfSpanMin = reader.ReadSingle(); c.ShelfSpanMax = reader.ReadSingle(); + c.ElevFreqIslands = reader.ReadSingle(); c.StrengthFreqIslands = reader.ReadSingle(); + c.BenchSeedOffset = reader.ReadInt32(); c.PlateauSeedOffset = reader.ReadInt32(); + c.StrengthSeedOffset = reader.ReadInt32(); + } blueprint.TerrainCurve = c; return true; } diff --git a/Tools/Scripts/HeightCurve.cs b/Tools/Scripts/HeightCurve.cs index aacfaf3..fd6f1ed 100644 --- a/Tools/Scripts/HeightCurve.cs +++ b/Tools/Scripts/HeightCurve.cs @@ -1,75 +1,89 @@ using Godot; /// -/// The height-redistribution curve, v3 — the TERRACED ASCENT (terrain-water task 07). -/// Pure, static, monotonic piecewise map over raw blueprint heights (D-035: numbers -/// in, numbers out; the per-seed spike maximum is an explicit PARAMETER). +/// The height-redistribution curve, v4 — SPATIALLY MODULATED SHELVES (terrain-water +/// task 08). Pure, static, monotonic piecewise map; every per-column input is an +/// explicit PARAMETER (D-035): the seed spike max and the four modulated shelf +/// values. The generator samples the modulation fields; this class never touches +/// noise. /// -/// v3 (developer's task-06 ground-test verdict — floor and 420 m ceiling frozen, the -/// ascent between them rebuilt): v2 spent ~87 % of the vertical budget in the last -/// ~4 % of input, reading as flat-then-wall. v3 climbs in TERRACES — two benches, -/// three risers — and relocates the white mountain-town plateau from 50 m to 220 m: +/// v4 (developer's task-07 gate finding: the terraces work, but uniform anchors put +/// a flat ring at exactly 100 m and exactly 220 m on every mountain — the bathtub +/// rings): the bench and plateau OUTPUT anchors become smooth spatial fields — +/// benchLo = 100 m ± 12 m and plateauLo = 220 m ± 20 m via two decorrelated +/// very-low-frequency noise fields — and a third field modulates SHELF STRENGTH, +/// blending each shelf between "pronounced flat" (output span ~2 m) and "barely a +/// hint" (~25 m of gentle slope), so not every flank at shelf height develops the +/// full terrace. Shelves stay locally flat; the two magic altitudes stop existing. /// -/// h ≤ sea (0.15) identity — water and the below-sea world untouched -/// sea → K1 toe, ease-out — the lowlands (orange coverage 59 %) -/// K1 → K2 linear rise into the red band (storm anchors frozen) -/// K2 → K3 FOOTHILL RISER — smoothstep climb to the 100 m bench -/// K3 → K4 BENCH 1 — near-flat walkable foothill shelf (~2 m rise) -/// K4 → K5 MID RISER — the big middle climb to the white plateau -/// K5 → K6 WHITE PLATEAU — near-flat shelf at 220 m (mountain -/// towns / snow band, where it was always intended) -/// K6 → spikeMax SUMMIT SPIKE — per-seed normalized (hMaxSeed), stiff -/// ease-in kept from v2: spires off the plateau to 420 m -/// h > spikeMax linear tail (strict monotonicity, no clamp) +/// Structure otherwise v3's, unchanged: identity ≤ sea, frozen toe/rise (storm +/// ladder), foothill riser → bench → mid riser → plateau → per-seed-normalized +/// stiff spike (u⁴, 420 m cap) → tail. Input knots are v3's calibration. /// -/// Input knots recalibrated 2026-08-08 from the SAME pooled batch-04 flat-sea land -/// CDF as v1/v2 (340,618,126 samples): P59/P72/P82/P87/P95/P98, giving pooled land -/// fractions orange 59 / red 13 / foothill-riser 10 / bench 5 / mid-riser 8 / -/// plateau 3 / spike 2 (exact by construction). Per-seed proportions vary — the -/// wrinkle variety. -/// -/// Strictly monotonic (every segment's normalized slope bounded below by a positive -/// constant); asserted numerically per generation against the EFFECTIVE per-seed -/// curve once hMaxSeed is known. +/// ORDERING SAFETY BY CONSTRUCTION (asserted): with the amplitudes below, at every +/// column: red ceiling 0.27 < benchLo−… (bench min 0.5006), bench top max 0.6962 < +/// plateauLo min 0.9468, plateau top max 1.2062 < peak cap 1.8287. The numeric +/// assertion additionally sweeps all 8 modulation-extreme corners per generation. /// public static class HeightCurve { - public const ushort VERSION = 3; + public const ushort VERSION = 4; - // Input knots — v3 calibration (see class header). K1..K4 are serialized in - // TCRV's four knot slots; K5/K6 are version constants documented in - // BLUEPRINT_FORMAT.md (the TCRV version byte selects the anchor set). - public const float K1 = 0.509179f; // P59 — orange coverage boundary - public const float K2 = 0.604081f; // P72 — red top - public const float K3 = 0.698485f; // P82 — foothill riser top - public const float K4 = 0.767213f; // P87 — bench 1 top - public const float K5 = 0.930304f; // P95 — mid riser top - public const float K6 = 1.050720f; // P98 — plateau top / spike foot + // Input knots — v3 calibration (pooled batch-04 land CDF, P59/72/82/87/95/98). + public const float K1 = 0.509179f; + public const float K2 = 0.604081f; + public const float K3 = 0.698485f; + public const float K4 = 0.767213f; + public const float K5 = 0.930304f; + public const float K6 = 1.050720f; - // Output anchors — storm ladder (frozen) + the terraces. + // Fixed output anchors — storm ladder + ceiling (frozen). public const float SEA = 0.15f; - public const float ORANGE_CEIL = 0.206f; // 1000-yr storm ceiling (frozen) - public const float RED_CEIL = 0.27f; // biblical ceiling (frozen) - public const float FOOTHILL_BENCH = SEA + 100f / 251f; // ≈ 0.54841 (100 m above sea) - public const float BENCH_STEP = 0.008f; // ~2 m rise across each bench - public const float FOOTHILL_TOP = FOOTHILL_BENCH + BENCH_STEP; - public const float PLATEAU = SEA + 220f / 251f; // ≈ 1.02649 (220 m — the white plateau) - public const float PLATEAU_TOP = PLATEAU + BENCH_STEP; - public const float PEAK_CAP = SEA + 420f / 251f; // ≈ 1.82869 (frozen from v2) + public const float ORANGE_CEIL = 0.206f; + public const float RED_CEIL = 0.27f; + public const float PEAK_CAP = SEA + 420f / 251f; // ≈ 1.82869 public const float TAIL_SLOPE = 0.25f; - - // Degenerate/near-flat guard (unchanged from v2): spike domain floored at - // K6 + SPIKE_MIN_SPAN so it stays positive and monotonic on any input. public const float SPIKE_MIN_SPAN = 0.01f; + // Modulated shelf anchors: base ± amplitude (raw units; 251 m per unit). + public const float BENCH_BASE = SEA + 100f / 251f; // ≈ 0.54841 (100 m) + public const float BENCH_AMP = 12f / 251f; // ± 12 m + public const float PLATEAU_BASE = SEA + 220f / 251f; // ≈ 1.02649 (220 m) + public const float PLATEAU_AMP = 20f / 251f; // ± 20 m + + // Shelf strength: output span of each shelf segment, blended by the strength + // field. Strong (t=1) → SPAN_MIN (~2 m, pronounced flat). Weak (t=0) → + // SPAN_MAX (~25 m, barely a hint of a shelf). + public const float SHELF_SPAN_MIN = 0.008f; + public const float SHELF_SPAN_MAX = 0.10f; + + // Modulation-field derivation (generator-side, recorded in TCRV): field seed = + // RESOLVED WorldSeed + offset; frequency = (periods per island width) / MapSize. + public const int BENCH_SEED_OFFSET = 7101; + public const int PLATEAU_SEED_OFFSET = 7207; + public const int STRENGTH_SEED_OFFSET = 7303; + public const float ELEV_FREQ_ISLANDS = 3.0f; // ~3 undulations across the island + public const float STRENGTH_FREQ_ISLANDS = 5.0f; // finer patchiness for shelf strength + public static float EffectiveSpikeMax(float hMaxSeed) { return Mathf.Max(hMaxSeed, K6 + SPIKE_MIN_SPAN); } + /// Shelf output span for a strength sample t ∈ [0,1]. + public static float ShelfSpan(float strength01) + { + return Mathf.Lerp(SHELF_SPAN_MAX, SHELF_SPAN_MIN, Mathf.Clamp(strength01, 0f, 1f)); + } + /// Raw pre-curve height. - /// The seed's raw pre-curve maximum — per-seed spike normalizer. - public static float Apply(float h, float hMaxSeed) + /// Seed's raw pre-curve maximum (per-seed spike normalizer). + /// This column's bench anchor (BENCH_BASE ± BENCH_AMP). + /// This column's bench output span (ShelfSpan of the strength field). + /// This column's plateau anchor (PLATEAU_BASE ± PLATEAU_AMP). + /// This column's plateau output span. + public static float Apply(float h, float hMaxSeed, + float benchLo, float benchSpan, float plateauLo, float plateauSpan) { if (h <= SEA) return h; @@ -77,72 +91,89 @@ public static class HeightCurve if (h < K1) { u = (h - SEA) / (K1 - SEA); - s = 0.3f * u + 0.7f * (u * (2f - u)); // ease-out toe, slope ≥ 0.3 + s = 0.3f * u + 0.7f * (u * (2f - u)); // frozen ease-out toe return SEA + s * (ORANGE_CEIL - SEA); } if (h < K2) { u = (h - K1) / (K2 - K1); - return ORANGE_CEIL + u * (RED_CEIL - ORANGE_CEIL); // linear rise + return ORANGE_CEIL + u * (RED_CEIL - ORANGE_CEIL); // frozen linear rise } if (h < K3) { u = (h - K2) / (K3 - K2); - s = 0.2f * u + 0.8f * (u * u * (3f - 2f * u)); // foothill riser, slope ≥ 0.2 - return RED_CEIL + s * (FOOTHILL_BENCH - RED_CEIL); + s = 0.2f * u + 0.8f * (u * u * (3f - 2f * u)); // foothill riser + return RED_CEIL + s * (benchLo - RED_CEIL); } if (h < K4) { u = (h - K3) / (K4 - K3); - return FOOTHILL_BENCH + u * BENCH_STEP; // bench 1, near-flat + return benchLo + u * benchSpan; // bench — modulated } + float benchTop = benchLo + benchSpan; if (h < K5) { u = (h - K4) / (K5 - K4); - s = 0.2f * u + 0.8f * (u * u * (3f - 2f * u)); // mid riser, slope ≥ 0.2 - return FOOTHILL_TOP + s * (PLATEAU - FOOTHILL_TOP); + s = 0.2f * u + 0.8f * (u * u * (3f - 2f * u)); // mid riser + return benchTop + s * (plateauLo - benchTop); } if (h < K6) { u = (h - K5) / (K6 - K5); - return PLATEAU + u * BENCH_STEP; // white plateau, near-flat + return plateauLo + u * plateauSpan; // plateau — modulated } + float plateauTop = plateauLo + plateauSpan; float spikeMax = EffectiveSpikeMax(hMaxSeed); if (h < spikeMax) { u = (h - K6) / (spikeMax - K6); - s = 0.1f * u + 0.9f * (u * u * u * u); // summit spike (v2 shape kept), slope ≥ 0.1 - return PLATEAU_TOP + s * (PEAK_CAP - PLATEAU_TOP); + s = 0.1f * u + 0.9f * (u * u * u * u); // summit spike (v2/v3 shape) + return plateauTop + s * (PEAK_CAP - plateauTop); } return PEAK_CAP + (h - spikeMax) * TAIL_SLOPE; } /// - /// Numeric strict-monotonicity check of the EFFECTIVE per-seed curve — call once - /// per generation after hMaxSeed is known. Loud throw, refuses to generate. + /// Per-generation numeric strict-monotonicity check of the EFFECTIVE curve: + /// sweeps the full domain at every one of the 8 modulation-extreme corners + /// (bench anchor ±, plateau anchor ±, strength min/max) with the per-seed + /// spikeMax — the adversarial corner set for the ordering constraints. Loud + /// throw, refuses to generate. /// public static void AssertMonotonic(float hMaxSeed) { - float prevH = -7f; - float prev = Apply(prevH, hMaxSeed); + float[] benchLos = { BENCH_BASE - BENCH_AMP, BENCH_BASE + BENCH_AMP }; + float[] plateauLos = { PLATEAU_BASE - PLATEAU_AMP, PLATEAU_BASE + PLATEAU_AMP }; + float[] spans = { SHELF_SPAN_MIN, SHELF_SPAN_MAX }; - // Only strictly increasing float32 samples are compared (task-05 incident fix). - void Check(double hd) + foreach (float bl in benchLos) { - float h = (float)hd; - if (h <= prevH) return; - float v = Apply(h, hMaxSeed); - if (v <= prev) - throw new System.InvalidOperationException( - $"[HeightCurve] MONOTONICITY VIOLATION at h={h} (hMaxSeed={hMaxSeed}): {v} <= {prev}. Refusing to generate."); - prev = v; - prevH = h; - } + foreach (float pl in plateauLos) + { + foreach (float sp in spans) + { + float prevH = -7f; + float prev = Apply(prevH, hMaxSeed, bl, sp, pl, sp); - double top = System.Math.Max(2.0, EffectiveSpikeMax(hMaxSeed) + 0.5); - for (double h = -7.0 + 0.01; h < 0.10; h += 0.01) Check(h); - for (double h = 0.10; h <= top; h += 0.0001) Check(h); - for (double h = top + 0.05; h <= top + 6.0; h += 0.05) Check(h); - GD.Print($"[HeightCurve] Monotonicity assertion passed (v{VERSION}, effective spikeMax {EffectiveSpikeMax(hMaxSeed):F6})."); + void Check(double hd) + { + float h = (float)hd; + if (h <= prevH) return; // dedupe float32 samples (task-05 fix) + float v = Apply(h, hMaxSeed, bl, sp, pl, sp); + if (v <= prev) + throw new System.InvalidOperationException( + $"[HeightCurve] MONOTONICITY VIOLATION at h={h} (hMaxSeed={hMaxSeed}, benchLo={bl}, plateauLo={pl}, span={sp}): {v} <= {prev}. Refusing to generate."); + prev = v; + prevH = h; + } + + double top = System.Math.Max(2.0, EffectiveSpikeMax(hMaxSeed) + 0.5); + for (double hh = -7.0 + 0.01; hh < 0.10; hh += 0.01) Check(hh); + for (double hh = 0.10; hh <= top; hh += 0.0001) Check(hh); + for (double hh = top + 0.05; hh <= top + 6.0; hh += 0.05) Check(hh); + } + } + } + GD.Print($"[HeightCurve] Monotonicity assertion passed (v{VERSION}, 8 modulation corners, effective spikeMax {EffectiveSpikeMax(hMaxSeed):F6})."); } } diff --git a/Tools/Scripts/MapGenerator.cs b/Tools/Scripts/MapGenerator.cs index 6547792..b311779 100644 --- a/Tools/Scripts/MapGenerator.cs +++ b/Tools/Scripts/MapGenerator.cs @@ -37,6 +37,15 @@ public partial class MapGenerator : TextureRect private float[,] _heightMapClassify; private bool _curveOn; + // Curve-v4 shelf-modulation fields (task 08): two decorrelated elevation fields + // (bench 100±12 m, plateau 220±20 m) plus a strength field blending each shelf + // between pronounced-flat and barely-a-hint. Seeds derive from the RESOLVED + // noise seed + fixed offsets (no config knob); frequencies scale with MapSize. + // Sampled per column in pass 2 only — the classify path never sees them. + private FastNoiseLite _benchNoise; + private FastNoiseLite _plateauNoise; + private FastNoiseLite _strengthNoise; + // The seed's raw pre-curve height maximum (post noise/falloff/Trench/spine, // pre-carve) — the v2 curve's per-seed spike normalizer. Computed in // GenerateTopography pass 1; recorded in TCRV (effective, guard applied). @@ -75,7 +84,7 @@ public partial class MapGenerator : TextureRect this.CustomMinimumSize = new Vector2(MapSize, MapSize); _heightMap = new float[MapSize, MapSize]; - _curveOn = ConfigManager.TerrainCurve == "v3"; + _curveOn = ConfigManager.TerrainCurve == "v4"; // (The monotonicity assertion now runs inside GenerateTopography, against the // effective per-seed curve, once hMaxSeed is known.) _heightMapClassify = _curveOn ? new float[MapSize, MapSize] : _heightMap; @@ -86,11 +95,18 @@ public partial class MapGenerator : TextureRect float scaleFactor = MapSize / 1024f; // Equals 4 - // --- MASTER SEED & DETAILED NOISE (Restored!) --- + // --- MASTER SEED & DETAILED NOISE (Restored!) --- _noise = new FastNoiseLite(); - _noise.Seed = ConfigManager.WorldSeed == 0 ? (int)GD.Randi() : ConfigManager.WorldSeed; + _noise.Seed = ConfigManager.WorldSeed == 0 ? (int)GD.Randi() : ConfigManager.WorldSeed; _noise.NoiseType = FastNoiseLite.NoiseTypeEnum.Simplex; - _noise.Frequency = 0.004f / scaleFactor; + _noise.Frequency = 0.004f / scaleFactor; + + if (_curveOn) + { + _benchNoise = MakeModulationNoise(HeightCurve.BENCH_SEED_OFFSET, HeightCurve.ELEV_FREQ_ISLANDS); + _plateauNoise = MakeModulationNoise(HeightCurve.PLATEAU_SEED_OFFSET, HeightCurve.ELEV_FREQ_ISLANDS); + _strengthNoise = MakeModulationNoise(HeightCurve.STRENGTH_SEED_OFFSET, HeightCurve.STRENGTH_FREQ_ISLANDS); + } // THE CRATER FIX: Push it into the ocean (scales via percentage of MapSize!) // Supposedly! We will have to test this manually on other map sizes to confirm the crater is properly scaled and submerged on the north coast! @@ -256,17 +272,23 @@ public partial class MapGenerator : TextureRect TrailRoads = _trailPaths, WaterBodyIds = _waterBodyIds, WaterBodies = _waterBodies ?? new List(), - // TCRV under curve v3: the four knot slots carry K1..K4 (K5/K6 are version - // constants, documented in BLUEPRINT_FORMAT.md); PlateauLo/Hi carry the two - // bench anchors (foothill 100 m / white plateau 220 m). + // TCRV under curve v4: knot slots carry K1..K4 (K5/K6 are version constants); + // the bench slots carry the two BASE anchors; the v4 extension record carries + // the modulation parameters (amplitudes, spans, frequencies, seed offsets) — + // the blueprint stays self-describing. TerrainCurve = _curveOn ? new TerrainCurveInfo { Version = HeightCurve.VERSION, T1 = HeightCurve.K1, T2 = HeightCurve.K2, T3 = HeightCurve.K3, T4 = HeightCurve.K4, SpikeMax = HeightCurve.EffectiveSpikeMax(_hMaxSeed), // per-seed Sea = HeightCurve.SEA, OrangeCeil = HeightCurve.ORANGE_CEIL, RedCeil = HeightCurve.RED_CEIL, - PlateauLo = HeightCurve.FOOTHILL_BENCH, PlateauHi = HeightCurve.PLATEAU, - PeakCap = HeightCurve.PEAK_CAP, TailSlope = HeightCurve.TAIL_SLOPE + PlateauLo = HeightCurve.BENCH_BASE, PlateauHi = HeightCurve.PLATEAU_BASE, + PeakCap = HeightCurve.PEAK_CAP, TailSlope = HeightCurve.TAIL_SLOPE, + BenchAmp = HeightCurve.BENCH_AMP, PlateauAmp = HeightCurve.PLATEAU_AMP, + ShelfSpanMin = HeightCurve.SHELF_SPAN_MIN, ShelfSpanMax = HeightCurve.SHELF_SPAN_MAX, + ElevFreqIslands = HeightCurve.ELEV_FREQ_ISLANDS, StrengthFreqIslands = HeightCurve.STRENGTH_FREQ_ISLANDS, + BenchSeedOffset = HeightCurve.BENCH_SEED_OFFSET, PlateauSeedOffset = HeightCurve.PLATEAU_SEED_OFFSET, + StrengthSeedOffset = HeightCurve.STRENGTH_SEED_OFFSET } : null, FormatVersion = 2, Params = new BlueprintParams @@ -438,7 +460,21 @@ public partial class MapGenerator : TextureRect { float raw = _heightMap[x, y]; float classifyH = raw; - float curvedH = _curveOn ? HeightCurve.Apply(raw, _hMaxSeed) : raw; + float curvedH; + if (_curveOn) + { + // v4: per-column shelf modulation — anchors and strength from the + // low-frequency fields; ordering safety by construction (amplitudes + // bounded; asserted at all 8 field-extreme corners per generation). + float benchLo = HeightCurve.BENCH_BASE + _benchNoise.GetNoise2D(x, y) * HeightCurve.BENCH_AMP; + float plateauLo = HeightCurve.PLATEAU_BASE + _plateauNoise.GetNoise2D(x, y) * HeightCurve.PLATEAU_AMP; + float shelfSpan = HeightCurve.ShelfSpan((_strengthNoise.GetNoise2D(x, y) + 1f) * 0.5f); + curvedH = HeightCurve.Apply(raw, _hMaxSeed, benchLo, shelfSpan, plateauLo, shelfSpan); + } + else + { + curvedH = raw; + } // --- 5. CARVE THE CRATER (The Flooded Bay & Landbridge Fix!) --- float distToCrater = new Vector2(x, y).DistanceTo(_impactCenter); @@ -523,6 +559,15 @@ public partial class MapGenerator : TextureRect // grid and the water-body grid holds BY CONSTRUCTION, not by parallel // implementations agreeing. // ===================================================================== + private FastNoiseLite MakeModulationNoise(int seedOffset, float periodsPerIsland) + { + var n = new FastNoiseLite(); + n.Seed = _noise.Seed + seedOffset; // deterministic from the RESOLVED seed + n.NoiseType = FastNoiseLite.NoiseTypeEnum.Simplex; + n.Frequency = periodsPerIsland / MapSize; // frequency stated in island-widths + return n; + } + private bool IsWaterPixel(int x, int y) => _heightMapClassify[x, y] < GetSeaLevel(_tempMap[x, y]); private bool IsOceanPixel(int x, int y) => IsWaterPixel(x, y) && _isTrueOcean[x, y]; private bool IsLakePixel(int x, int y) => IsWaterPixel(x, y) && !_isTrueOcean[x, y]; @@ -824,12 +869,14 @@ public partial class MapGenerator : TextureRect } else if (h < HeightCurve.ORANGE_CEIL) tint = green; else if (h < HeightCurve.RED_CEIL) tint = tan; - else if (h < HeightCurve.FOOTHILL_TOP) tint = brown; // foothill riser + 100 m bench - else if (h < HeightCurve.PLATEAU) tint = darkBrown; // the mid riser + // v4 note: shelf anchors are spatially modulated, so these tint bands + // use the base±amplitude envelopes — approximate banding, visualization only. + else if (h < HeightCurve.BENCH_BASE + HeightCurve.BENCH_AMP) tint = brown; // riser + bench envelope + else if (h < HeightCurve.PLATEAU_BASE - HeightCurve.PLATEAU_AMP) tint = darkBrown; // mid riser else { - // White plateau (220 m) through the summit spike to the 420 m cap. - float t2 = Mathf.Clamp((h - HeightCurve.PLATEAU) / (HeightCurve.PEAK_CAP - HeightCurve.PLATEAU), 0f, 1f); + // Plateau envelope through the summit spike to the 420 m cap. + float t2 = Mathf.Clamp((h - (HeightCurve.PLATEAU_BASE - HeightCurve.PLATEAU_AMP)) / (HeightCurve.PEAK_CAP - HeightCurve.PLATEAU_BASE + HeightCurve.PLATEAU_AMP), 0f, 1f); tint = grey.Lerp(white, t2); } @@ -1373,40 +1420,40 @@ public partial class MapGenerator : TextureRect } private void SetBaseAStarWeights(AStarGrid2D astar) { - for (int x = 0; x < MapSize; x++) { - for (int y = 0; y < MapSize; y++) { - Biome b = _biomeMap[x, y]; - - // 1. CRITICAL FIX: Make the crater physically impassable first! - if (b == Biome.Crater) { - astar.SetPointSolid(new Vector2I(x, y), true); - continue; // "Skip the rest, go to next pixel" - } + for (int x = 0; x < MapSize; x++) { + for (int y = 0; y < MapSize; y++) { + Biome b = _biomeMap[x, y]; + + // 1. CRITICAL FIX: Make the crater physically impassable first! + if (b == Biome.Crater) { + astar.SetPointSolid(new Vector2I(x, y), true); + continue; // "Skip the rest, go to next pixel" + } - // 2. Existing water/mainland checks - if (b == Biome.Ocean || b == Biome.Lake || !_isMainland[x, y]) { - astar.SetPointSolid(new Vector2I(x, y), true); - continue; // "Skip the rest, go to next pixel" - } - - // 3. We only reach this point if it's mainland AND not a crater! - float h = _heightMap[x, y]; - float localSea = GetSeaLevel(_tempMap[x, y]); - float normalizedElevation = Mathf.Clamp((h - localSea) / (1.0f - localSea), 0.0f, 1.0f); + // 2. Existing water/mainland checks + if (b == Biome.Ocean || b == Biome.Lake || !_isMainland[x, y]) { + astar.SetPointSolid(new Vector2I(x, y), true); + continue; // "Skip the rest, go to next pixel" + } + + // 3. We only reach this point if it's mainland AND not a crater! + float h = _heightMap[x, y]; + float localSea = GetSeaLevel(_tempMap[x, y]); + float normalizedElevation = Mathf.Clamp((h - localSea) / (1.0f - localSea), 0.0f, 1.0f); - // Simple, steep curve to avoid mountains - float weight = 1.0f + Mathf.Pow(normalizedElevation, 3.0f) * 400.0f; + // Simple, steep curve to avoid mountains + float weight = 1.0f + Mathf.Pow(normalizedElevation, 3.0f) * 400.0f; - // Push off the beach (not an insane wall, just enough to prefer grass) - if (b == Biome.Beach) weight += 15.0f; - - // Let it path through the wasteland normally - if (b == Biome.Wasteland) weight += 3.0f; + // Push off the beach (not an insane wall, just enough to prefer grass) + if (b == Biome.Beach) weight += 15.0f; + + // Let it path through the wasteland normally + if (b == Biome.Wasteland) weight += 3.0f; - astar.SetPointWeightScale(new Vector2I(x, y), weight); - } - } - } + astar.SetPointWeightScale(new Vector2I(x, y), weight); + } + } + } private void PenalizePath(AStarGrid2D astar, Vector2[] path, int radius) { @@ -1419,7 +1466,7 @@ public partial class MapGenerator : TextureRect int ny = Mathf.Clamp((int)p.Y + dy, 0, MapSize - 1); var cell = new Vector2I(nx, ny); if (!astar.IsPointSolid(cell)) { - // The true Iron Curtain + // The true Iron Curtain astar.SetPointWeightScale(cell, astar.GetPointWeightScale(cell) + 10000f); } } @@ -1494,67 +1541,67 @@ public partial class MapGenerator : TextureRect { float scaleMod = MapSize / 1024f; // equals 4 at 4096 - // Trails: Thin brown - foreach (var path in _trailPaths) DrawPolyline(path, new Color(0.5f, 0.4f, 0.3f, 0.7f), 1.0f * scaleMod, true); - - // Rugged: Thicker dark brown - foreach (var path in _ruggedPaths) DrawPolyline(path, new Color(0.35f, 0.25f, 0.15f), 1.5f * scaleMod, true); + // Trails: Thin brown + foreach (var path in _trailPaths) DrawPolyline(path, new Color(0.5f, 0.4f, 0.3f, 0.7f), 1.0f * scaleMod, true); + + // Rugged: Thicker dark brown + foreach (var path in _ruggedPaths) DrawPolyline(path, new Color(0.35f, 0.25f, 0.15f), 1.5f * scaleMod, true); - // Main Roads: Solid Black - foreach (var path in _branchPaths) DrawPolyline(path, Colors.Black, 1.0f * scaleMod, true); + // Main Roads: Solid Black + foreach (var path in _branchPaths) DrawPolyline(path, Colors.Black, 1.0f * scaleMod, true); - // Highway: Thick Red - foreach (var path in _highwayPaths) DrawPolyline(path, new Color(0.9f, 0.1f, 0.1f), 2.0f * scaleMod, true); + // Highway: Thick Red + foreach (var path in _highwayPaths) DrawPolyline(path, new Color(0.9f, 0.1f, 0.1f), 2.0f * scaleMod, true); - // Draw Towns - foreach (var town in _towns) { - float radius = (town.Tier == TownTier.Capitol) ? 12f : (town.Tier == TownTier.Hub ? 8f : 4f); - radius *= scaleMod; // Scale the circles up! - - Color c = town.IsHighwayNode ? Colors.Yellow : (town.Tier == TownTier.Outpost ? Colors.Cyan : Colors.Orange); - if (town.Tier == TownTier.IslandLoot) c = Colors.Red; - if (town.Tier == TownTier.MiniPOI) { radius = 2.5f * scaleMod; c = Colors.SaddleBrown; } + // Draw Towns + foreach (var town in _towns) { + float radius = (town.Tier == TownTier.Capitol) ? 12f : (town.Tier == TownTier.Hub ? 8f : 4f); + radius *= scaleMod; // Scale the circles up! + + Color c = town.IsHighwayNode ? Colors.Yellow : (town.Tier == TownTier.Outpost ? Colors.Cyan : Colors.Orange); + if (town.Tier == TownTier.IslandLoot) c = Colors.Red; + if (town.Tier == TownTier.MiniPOI) { radius = 2.5f * scaleMod; c = Colors.SaddleBrown; } - DrawCircle(town.Position, radius + (1.5f * scaleMod), Colors.Black); - DrawCircle(town.Position, radius, c); - } + DrawCircle(town.Position, radius + (1.5f * scaleMod), Colors.Black); + DrawCircle(town.Position, radius, c); + } } } // Place this at the very bottom of the file! public partial class MapDrawProxy : Control { - public MapGenerator Source; + public MapGenerator Source; - public override void _Draw() - { - if (Source == null) return; - - float scaleMod = Source.MapSize / 1024f; + public override void _Draw() + { + if (Source == null) return; + + float scaleMod = Source.MapSize / 1024f; - foreach (var path in Source._trailPaths) - DrawPolyline(path, new Color(0.5f, 0.4f, 0.3f, 0.7f), 1.0f * scaleMod, true); + foreach (var path in Source._trailPaths) + DrawPolyline(path, new Color(0.5f, 0.4f, 0.3f, 0.7f), 1.0f * scaleMod, true); - foreach (var path in Source._ruggedPaths) - DrawPolyline(path, new Color(0.35f, 0.25f, 0.15f), 1.5f * scaleMod, true); + foreach (var path in Source._ruggedPaths) + DrawPolyline(path, new Color(0.35f, 0.25f, 0.15f), 1.5f * scaleMod, true); - foreach (var path in Source._branchPaths) - DrawPolyline(path, Colors.Black, 1.0f * scaleMod, true); + foreach (var path in Source._branchPaths) + DrawPolyline(path, Colors.Black, 1.0f * scaleMod, true); - foreach (var path in Source._highwayPaths) - DrawPolyline(path, new Color(0.9f, 0.1f, 0.1f), 2.0f * scaleMod, true); + foreach (var path in Source._highwayPaths) + DrawPolyline(path, new Color(0.9f, 0.1f, 0.1f), 2.0f * scaleMod, true); - foreach (var town in Source._towns) - { - float radius = (town.Tier == TownTier.Capitol) ? 12f : (town.Tier == TownTier.Hub ? 8f : 4f); - radius *= scaleMod; + foreach (var town in Source._towns) + { + float radius = (town.Tier == TownTier.Capitol) ? 12f : (town.Tier == TownTier.Hub ? 8f : 4f); + radius *= scaleMod; - Color c = town.IsHighwayNode ? Colors.Yellow : (town.Tier == TownTier.Outpost ? Colors.Cyan : Colors.Orange); - if (town.Tier == TownTier.IslandLoot) c = Colors.Red; - if (town.Tier == TownTier.MiniPOI) { radius = 2.5f * scaleMod; c = Colors.SaddleBrown; } + Color c = town.IsHighwayNode ? Colors.Yellow : (town.Tier == TownTier.Outpost ? Colors.Cyan : Colors.Orange); + if (town.Tier == TownTier.IslandLoot) c = Colors.Red; + if (town.Tier == TownTier.MiniPOI) { radius = 2.5f * scaleMod; c = Colors.SaddleBrown; } - DrawCircle(town.Position, radius + (1.5f * scaleMod), Colors.Black); - DrawCircle(town.Position, radius, c); - } - } -} \ No newline at end of file + DrawCircle(town.Position, radius + (1.5f * scaleMod), Colors.Black); + DrawCircle(town.Position, radius, c); + } + } +} diff --git a/Tools/Scripts/RoundTripHarness.cs b/Tools/Scripts/RoundTripHarness.cs index 7b2393a..d912926 100644 --- a/Tools/Scripts/RoundTripHarness.cs +++ b/Tools/Scripts/RoundTripHarness.cs @@ -200,8 +200,12 @@ public partial class RoundTripHarness : Node } var ca = a.TerrainCurve; var cb = b.TerrainCurve; bool same = ca.Version == cb.Version; - float[] fa = { ca.T1, ca.T2, ca.T3, ca.T4, ca.SpikeMax, ca.Sea, ca.OrangeCeil, ca.RedCeil, ca.PlateauLo, ca.PlateauHi, ca.PeakCap, ca.TailSlope }; - float[] fb = { cb.T1, cb.T2, cb.T3, cb.T4, cb.SpikeMax, cb.Sea, cb.OrangeCeil, cb.RedCeil, cb.PlateauLo, cb.PlateauHi, cb.PeakCap, cb.TailSlope }; + float[] fa = { ca.T1, ca.T2, ca.T3, ca.T4, ca.SpikeMax, ca.Sea, ca.OrangeCeil, ca.RedCeil, ca.PlateauLo, ca.PlateauHi, ca.PeakCap, ca.TailSlope, + ca.BenchAmp, ca.PlateauAmp, ca.ShelfSpanMin, ca.ShelfSpanMax, ca.ElevFreqIslands, ca.StrengthFreqIslands, + ca.BenchSeedOffset, ca.PlateauSeedOffset, ca.StrengthSeedOffset }; + float[] fb = { cb.T1, cb.T2, cb.T3, cb.T4, cb.SpikeMax, cb.Sea, cb.OrangeCeil, cb.RedCeil, cb.PlateauLo, cb.PlateauHi, cb.PeakCap, cb.TailSlope, + cb.BenchAmp, cb.PlateauAmp, cb.ShelfSpanMin, cb.ShelfSpanMax, cb.ElevFreqIslands, cb.StrengthFreqIslands, + cb.BenchSeedOffset, cb.PlateauSeedOffset, cb.StrengthSeedOffset }; for (int i = 0; i < fa.Length; i++) if (System.BitConverter.SingleToInt32Bits(fa[i]) != System.BitConverter.SingleToInt32Bits(fb[i])) same = false; if (!same) { GD.PrintErr("[Harness] TCRV fields differ."); return false; }