feat: height-redistribution curve, gated + biome-invariant (terrain-water task 05)
HeightCurve (pure static, D-035): calibrated monotonic piecewise map — identity at/below sea 0.15; ease-out toe to the orange ceiling 0.206 (75% coverage, knot t1=P75=0.628736 from batch-04's pooled flat-sea land CDF, 340.6M samples); linear rise to red 0.27 (t2=P90); smooth shoulder to the 50m plateau shelf 0.3492 (t3=P93); near-flat plateau step (t4=P96); accelerating spike to the 220m peak cap 1.0265; linear tail past the calibrated max. Strict monotonicity asserted numerically at startup, loud throw on violation. Applied in GenerateTopography AFTER noise+falloff+Trench, BEFORE the crater carve (carve cuts curved terrain; rim/bowl untouched by the curve). Biome oracle mechanism: a retained uncurved classify heightmap (alias of _heightMap when off, zero cost) feeds biome rules, both flood fills, and the shared water predicates — classification is curve-invariant by construction. Towns/roads/diagnostics/exported heights use curved terrain; town positions may legitimately move. Config gate TerrainCurve: "off"|"v1" (default v1), unknown values rejected loudly. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
9e55324668
commit
67868638be
3 changed files with 159 additions and 11 deletions
|
|
@ -26,6 +26,12 @@ namespace IslaApocalypse.Core // Change this if your namespace is different
|
||||||
public static string SeaLevelModel = "flat";
|
public static string SeaLevelModel = "flat";
|
||||||
public static float SeaLevelValue = 0.15f;
|
public static float SeaLevelValue = 0.15f;
|
||||||
|
|
||||||
|
// Height-redistribution curve (task 05, graduation M-7): "v1" applies the
|
||||||
|
// calibrated storm-ladder curve to above-sea terrain (see HeightCurve.cs);
|
||||||
|
// "off" is the raw legacy profile. Biome classification is curve-invariant
|
||||||
|
// by construction either way. Default: v1.
|
||||||
|
public static string TerrainCurve = "v1";
|
||||||
|
|
||||||
public static void LoadConfig()
|
public static void LoadConfig()
|
||||||
{
|
{
|
||||||
string path = "res://ServerConfig.json";
|
string path = "res://ServerConfig.json";
|
||||||
|
|
@ -95,6 +101,16 @@ namespace IslaApocalypse.Core // Change this if your namespace is different
|
||||||
SeaLevelValue = (float)data["SeaLevelValue"];
|
SeaLevelValue = (float)data["SeaLevelValue"];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Extract the terrain-curve gate
|
||||||
|
if (data.ContainsKey("TerrainCurve"))
|
||||||
|
{
|
||||||
|
string curve = (string)data["TerrainCurve"];
|
||||||
|
if (curve == "off" || curve == "v1")
|
||||||
|
TerrainCurve = curve;
|
||||||
|
else
|
||||||
|
GD.PrintErr($"[ConfigManager] Unknown TerrainCurve '{curve}'. Keeping '{TerrainCurve}'.");
|
||||||
|
}
|
||||||
|
|
||||||
switch (profile)
|
switch (profile)
|
||||||
{
|
{
|
||||||
case "4K":
|
case "4K":
|
||||||
|
|
|
||||||
104
Tools/Scripts/HeightCurve.cs
Normal file
104
Tools/Scripts/HeightCurve.cs
Normal file
|
|
@ -0,0 +1,104 @@
|
||||||
|
using Godot;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The height-redistribution curve (terrain-water task 05, graduation M-7) — a pure,
|
||||||
|
/// static, monotonic piecewise map over raw blueprint heights (D-035: numbers in,
|
||||||
|
/// numbers out, no lifecycle).
|
||||||
|
///
|
||||||
|
/// OUTPUT bands are fixed by design (the storm ladder; developer-approved
|
||||||
|
/// 75 % orange coverage / plateau 50 m above sea / peaks 220 m above sea).
|
||||||
|
/// INPUT knots were calibrated ONCE from measured data — the pooled CDF of
|
||||||
|
/// above-sea land heights across batch 04's ten flat-sea seeds (340,618,126
|
||||||
|
/// samples, 2026-08-07): P75 / P90 / P93 / P96 / max. The same knots apply to
|
||||||
|
/// every seed; per-seed band proportions vary a few points by design.
|
||||||
|
///
|
||||||
|
/// Shape, monotonic by construction (every segment's normalized slope is bounded
|
||||||
|
/// below by a positive constant) and asserted numerically at startup:
|
||||||
|
/// h ≤ sea (0.15) identity — water and the below-sea world untouched
|
||||||
|
/// sea → t1 smooth toe, ease-out blend (gentle rolling, never flat)
|
||||||
|
/// t1 → t2 linear rise into the red band
|
||||||
|
/// t2 → t3 smooth shoulder up to the plateau shelf
|
||||||
|
/// t3 → t4 near-flat plateau step (small positive slope)
|
||||||
|
/// t4 → hmaxCal accelerating spike to the peak cap
|
||||||
|
/// h > hmaxCal linear tail (keeps strict monotonicity, no clamp)
|
||||||
|
/// </summary>
|
||||||
|
public static class HeightCurve
|
||||||
|
{
|
||||||
|
public const ushort VERSION = 1;
|
||||||
|
|
||||||
|
// Input knots — calibrated from batch 04 B-flat pooled land CDF (see report).
|
||||||
|
public const float T1 = 0.628736f; // P75 — orange coverage boundary
|
||||||
|
public const float T2 = 0.819152f; // P90
|
||||||
|
public const float T3 = 0.879340f; // P93
|
||||||
|
public const float T4 = 0.962922f; // P96
|
||||||
|
public const float HMAX_CAL = 1.452219f; // pooled max
|
||||||
|
|
||||||
|
// Output bands — the storm ladder.
|
||||||
|
public const float SEA = 0.15f;
|
||||||
|
public const float ORANGE_CEIL = 0.206f; // 1000-yr storm ceiling
|
||||||
|
public const float RED_CEIL = 0.27f; // biblical ceiling
|
||||||
|
public const float PLATEAU_LO = SEA + 50f / 251f; // ≈ 0.34924 (50 m above sea)
|
||||||
|
public const float PLATEAU_HI = PLATEAU_LO + 0.02f; // ≈ 0.36924 (~5 m step relief)
|
||||||
|
public const float PEAK_CAP = SEA + 220f / 251f; // ≈ 1.02649 (220 m above sea)
|
||||||
|
public const float TAIL_SLOPE = 0.25f; // above HMAX_CAL
|
||||||
|
|
||||||
|
public static float Apply(float h)
|
||||||
|
{
|
||||||
|
if (h <= SEA) return h;
|
||||||
|
|
||||||
|
float u, s;
|
||||||
|
if (h < T1)
|
||||||
|
{
|
||||||
|
u = (h - SEA) / (T1 - SEA);
|
||||||
|
s = 0.3f * u + 0.7f * (u * (2f - u)); // ease-out, slope ≥ 0.3
|
||||||
|
return SEA + s * (ORANGE_CEIL - SEA);
|
||||||
|
}
|
||||||
|
if (h < T2)
|
||||||
|
{
|
||||||
|
u = (h - T1) / (T2 - T1);
|
||||||
|
return ORANGE_CEIL + u * (RED_CEIL - ORANGE_CEIL); // linear
|
||||||
|
}
|
||||||
|
if (h < T3)
|
||||||
|
{
|
||||||
|
u = (h - T2) / (T3 - T2);
|
||||||
|
s = 0.2f * u + 0.8f * (u * u * (3f - 2f * u)); // smoothstep blend, slope ≥ 0.2
|
||||||
|
return RED_CEIL + s * (PLATEAU_LO - RED_CEIL);
|
||||||
|
}
|
||||||
|
if (h < T4)
|
||||||
|
{
|
||||||
|
u = (h - T3) / (T4 - T3);
|
||||||
|
return PLATEAU_LO + u * (PLATEAU_HI - PLATEAU_LO); // near-flat, small positive slope
|
||||||
|
}
|
||||||
|
if (h < HMAX_CAL)
|
||||||
|
{
|
||||||
|
u = (h - T4) / (HMAX_CAL - T4);
|
||||||
|
s = 0.2f * u + 0.8f * (u * u * u); // ease-in spike, slope ≥ 0.2
|
||||||
|
return PLATEAU_HI + s * (PEAK_CAP - PLATEAU_HI);
|
||||||
|
}
|
||||||
|
return PEAK_CAP + (h - HMAX_CAL) * TAIL_SLOPE;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Numeric strict-monotonicity check across the whole plausible domain.
|
||||||
|
/// Cheap (runs once at generator start); a violation is a build bug, not a
|
||||||
|
/// data condition — fail loudly and refuse to generate.
|
||||||
|
/// </summary>
|
||||||
|
public static void AssertMonotonic()
|
||||||
|
{
|
||||||
|
float prev = Apply(-7f);
|
||||||
|
// Coarse below the identity region, fine through every knot, out past the tail.
|
||||||
|
for (double h = -7.0 + 0.01; h < 0.10; h += 0.01) prev = Step(prev, (float)h);
|
||||||
|
for (double h = 0.10; h <= 2.0; h += 0.0001) prev = Step(prev, (float)h);
|
||||||
|
for (double h = 2.0; h <= 8.0; h += 0.05) prev = Step(prev, (float)h);
|
||||||
|
GD.Print("[HeightCurve] Monotonicity assertion passed (v" + VERSION + ").");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static float Step(float prev, float h)
|
||||||
|
{
|
||||||
|
float v = Apply(h);
|
||||||
|
if (v <= prev)
|
||||||
|
throw new System.InvalidOperationException(
|
||||||
|
$"[HeightCurve] MONOTONICITY VIOLATION at h={h}: {v} <= {prev}. Refusing to generate.");
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -26,6 +26,17 @@ public partial class MapGenerator : TextureRect
|
||||||
private float _impactRadius;
|
private float _impactRadius;
|
||||||
|
|
||||||
private float[,] _heightMap;
|
private float[,] _heightMap;
|
||||||
|
|
||||||
|
// Classification heightmap (task 05): the UNCURVED heights (plus the crater
|
||||||
|
// carve), i.e. exactly what the curve-off pipeline produces. Biome rules, the
|
||||||
|
// two flood fills, and the shared water predicates read THIS map, so biome and
|
||||||
|
// water output is identical with the curve on or off — the bit-identical-biomes
|
||||||
|
// oracle holds by construction. Towns, roads, diagnostics, and the exported
|
||||||
|
// heights use the curved _heightMap (they live in the 3D world). When the curve
|
||||||
|
// is off this is the SAME array as _heightMap (aliased, no copy).
|
||||||
|
private float[,] _heightMapClassify;
|
||||||
|
private bool _curveOn;
|
||||||
|
|
||||||
private float[,] _tempMap;
|
private float[,] _tempMap;
|
||||||
private Biome[,] _biomeMap;
|
private Biome[,] _biomeMap;
|
||||||
private bool[,] _isTrueOcean;
|
private bool[,] _isTrueOcean;
|
||||||
|
|
@ -59,6 +70,9 @@ public partial class MapGenerator : TextureRect
|
||||||
this.CustomMinimumSize = new Vector2(MapSize, MapSize);
|
this.CustomMinimumSize = new Vector2(MapSize, MapSize);
|
||||||
|
|
||||||
_heightMap = new float[MapSize, MapSize];
|
_heightMap = new float[MapSize, MapSize];
|
||||||
|
_curveOn = ConfigManager.TerrainCurve == "v1";
|
||||||
|
if (_curveOn) HeightCurve.AssertMonotonic();
|
||||||
|
_heightMapClassify = _curveOn ? new float[MapSize, MapSize] : _heightMap;
|
||||||
_tempMap = new float[MapSize, MapSize];
|
_tempMap = new float[MapSize, MapSize];
|
||||||
_biomeMap = new Biome[MapSize, MapSize];
|
_biomeMap = new Biome[MapSize, MapSize];
|
||||||
_isTrueOcean = new bool[MapSize, MapSize];
|
_isTrueOcean = new bool[MapSize, MapSize];
|
||||||
|
|
@ -378,20 +392,31 @@ public partial class MapGenerator : TextureRect
|
||||||
float rawBase = (_noise.GetNoise2D(x, y) + 1.0f) / 2.0f;
|
float rawBase = (_noise.GetNoise2D(x, y) + 1.0f) / 2.0f;
|
||||||
float finalH = rawBase + mountainSpine - (finalFalloff * FalloffStrength);
|
float finalH = rawBase + mountainSpine - (finalFalloff * FalloffStrength);
|
||||||
|
|
||||||
|
// --- 4b. THE REDISTRIBUTION CURVE (task 05) ---
|
||||||
|
// Applied AFTER noise + falloff + Trench, BEFORE the crater carve, so
|
||||||
|
// the carve cuts into curved terrain and the rim/bowl shape is
|
||||||
|
// untouched by the curve. Identity at and below sea + this ordering
|
||||||
|
// preserve the Trench/ocean-border guarantee and the crater by
|
||||||
|
// construction. classifyH stays uncurved — see _heightMapClassify.
|
||||||
|
float classifyH = finalH;
|
||||||
|
float curvedH = _curveOn ? HeightCurve.Apply(finalH) : finalH;
|
||||||
|
|
||||||
// --- 5. CARVE THE CRATER (The Flooded Bay & Landbridge Fix!) ---
|
// --- 5. CARVE THE CRATER (The Flooded Bay & Landbridge Fix!) ---
|
||||||
float distToCrater = new Vector2(x, y).DistanceTo(_impactCenter);
|
float distToCrater = new Vector2(x, y).DistanceTo(_impactCenter);
|
||||||
|
|
||||||
// We only carve the physical hole at 80% of the radius to guarantee a landbridge!
|
// We only carve the physical hole at 80% of the radius to guarantee a landbridge!
|
||||||
float physicalCraterRadius = _impactRadius * 0.80f;
|
float physicalCraterRadius = _impactRadius * 0.80f;
|
||||||
if (distToCrater < physicalCraterRadius)
|
if (distToCrater < physicalCraterRadius)
|
||||||
{
|
{
|
||||||
float craterDepth = 1.0f - (distToCrater / physicalCraterRadius);
|
float craterDepth = 1.0f - (distToCrater / physicalCraterRadius);
|
||||||
// Dialed back to -0.15f as per your excellent instinct!
|
// Dialed back to -0.15f as per your excellent instinct!
|
||||||
finalH = Mathf.Lerp(finalH, GetSeaLevel(temperature) - 0.15f, craterDepth * 0.9f);
|
float carveTarget = GetSeaLevel(temperature) - 0.15f;
|
||||||
|
classifyH = Mathf.Lerp(classifyH, carveTarget, craterDepth * 0.9f);
|
||||||
|
curvedH = Mathf.Lerp(curvedH, carveTarget, craterDepth * 0.9f);
|
||||||
}
|
}
|
||||||
|
|
||||||
_heightMap[x, y] = finalH;
|
_heightMapClassify[x, y] = classifyH;
|
||||||
|
_heightMap[x, y] = curvedH;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -412,7 +437,7 @@ public partial class MapGenerator : TextureRect
|
||||||
Vector2I neighbor = current + dir;
|
Vector2I neighbor = current + dir;
|
||||||
if (neighbor.X >= 0 && neighbor.X < MapSize && neighbor.Y >= 0 && neighbor.Y < MapSize)
|
if (neighbor.X >= 0 && neighbor.X < MapSize && neighbor.Y >= 0 && neighbor.Y < MapSize)
|
||||||
{
|
{
|
||||||
if (!_isTrueOcean[neighbor.X, neighbor.Y] && _heightMap[neighbor.X, neighbor.Y] < GetSeaLevel(_tempMap[neighbor.X, neighbor.Y]))
|
if (!_isTrueOcean[neighbor.X, neighbor.Y] && _heightMapClassify[neighbor.X, neighbor.Y] < GetSeaLevel(_tempMap[neighbor.X, neighbor.Y]))
|
||||||
{
|
{
|
||||||
_isTrueOcean[neighbor.X, neighbor.Y] = true;
|
_isTrueOcean[neighbor.X, neighbor.Y] = true;
|
||||||
queue.Enqueue(neighbor);
|
queue.Enqueue(neighbor);
|
||||||
|
|
@ -427,7 +452,7 @@ public partial class MapGenerator : TextureRect
|
||||||
Queue<Vector2I> queue = new Queue<Vector2I>();
|
Queue<Vector2I> queue = new Queue<Vector2I>();
|
||||||
Vector2I center = new Vector2I(MapSize / 2, MapSize / 2);
|
Vector2I center = new Vector2I(MapSize / 2, MapSize / 2);
|
||||||
|
|
||||||
if (_heightMap[center.X, center.Y] >= GetSeaLevel(_tempMap[center.X, center.Y]))
|
if (_heightMapClassify[center.X, center.Y] >= GetSeaLevel(_tempMap[center.X, center.Y]))
|
||||||
{
|
{
|
||||||
queue.Enqueue(center);
|
queue.Enqueue(center);
|
||||||
_isMainland[center.X, center.Y] = true;
|
_isMainland[center.X, center.Y] = true;
|
||||||
|
|
@ -443,7 +468,7 @@ public partial class MapGenerator : TextureRect
|
||||||
Vector2I neighbor = current + dir;
|
Vector2I neighbor = current + dir;
|
||||||
if (neighbor.X >= 0 && neighbor.X < MapSize && neighbor.Y >= 0 && neighbor.Y < MapSize)
|
if (neighbor.X >= 0 && neighbor.X < MapSize && neighbor.Y >= 0 && neighbor.Y < MapSize)
|
||||||
{
|
{
|
||||||
if (!_isMainland[neighbor.X, neighbor.Y] && _heightMap[neighbor.X, neighbor.Y] >= GetSeaLevel(_tempMap[neighbor.X, neighbor.Y]))
|
if (!_isMainland[neighbor.X, neighbor.Y] && _heightMapClassify[neighbor.X, neighbor.Y] >= GetSeaLevel(_tempMap[neighbor.X, neighbor.Y]))
|
||||||
{
|
{
|
||||||
_isMainland[neighbor.X, neighbor.Y] = true;
|
_isMainland[neighbor.X, neighbor.Y] = true;
|
||||||
queue.Enqueue(neighbor);
|
queue.Enqueue(neighbor);
|
||||||
|
|
@ -460,7 +485,7 @@ public partial class MapGenerator : TextureRect
|
||||||
// grid and the water-body grid holds BY CONSTRUCTION, not by parallel
|
// grid and the water-body grid holds BY CONSTRUCTION, not by parallel
|
||||||
// implementations agreeing.
|
// implementations agreeing.
|
||||||
// =====================================================================
|
// =====================================================================
|
||||||
private bool IsWaterPixel(int x, int y) => _heightMap[x, y] < GetSeaLevel(_tempMap[x, y]);
|
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 IsOceanPixel(int x, int y) => IsWaterPixel(x, y) && _isTrueOcean[x, y];
|
||||||
private bool IsLakePixel(int x, int y) => IsWaterPixel(x, y) && !_isTrueOcean[x, y];
|
private bool IsLakePixel(int x, int y) => IsWaterPixel(x, y) && !_isTrueOcean[x, y];
|
||||||
|
|
||||||
|
|
@ -753,7 +778,10 @@ public partial class MapGenerator : TextureRect
|
||||||
{
|
{
|
||||||
for (int y = 0; y < MapSize; y++)
|
for (int y = 0; y < MapSize; y++)
|
||||||
{
|
{
|
||||||
float h = _heightMap[x, y];
|
// Biome rules classify against the UNCURVED heights (task 05) — the
|
||||||
|
// bit-identical-biomes oracle. Beach/mountain/snow bands and the
|
||||||
|
// water split all read the classify map.
|
||||||
|
float h = _heightMapClassify[x, y];
|
||||||
float t = _tempMap[x, y];
|
float t = _tempMap[x, y];
|
||||||
Biome b;
|
Biome b;
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue