diff --git a/Core/Scripts/ConfigManager.cs b/Core/Scripts/ConfigManager.cs
index f7443db..cb1a953 100644
--- a/Core/Scripts/ConfigManager.cs
+++ b/Core/Scripts/ConfigManager.cs
@@ -119,9 +119,16 @@ namespace IslaApocalypse.Core // Change this if your namespace is different
// absolute floor above sea — the erosion flood-guard discipline: no river
// bed may create inland below-sea cells, so the rendered coastline cannot
// move even with rivers carved.
+ // RiverStepDropM/RiverWaterDepthM (task 23): the stepped-water dials — each
+ // river is a chain of flat water-body reaches; a new reach starts every
+ // StepDrop metres of bed descent and sits WaterDepth metres above its bed.
+ // Smaller drop = more, finer steps = smoother water (the smoothing dial;
+ // tilted continuous-slope water is the deferred model B).
public static string Rivers = "off";
public static string RiverRoutingStyle = "lowground";
- public static float RiverWidthScale = 1.0f;
+ public static float RiverWidthScale = 1.75f; // widened at the task-23 gate's ask
+ public static float RiverStepDropM = 2.0f;
+ public static float RiverWaterDepthM = 1.2f;
public static float RiverDepthScale = 1.0f;
public static float RiverSeaMargin = 0.2f; // m above sea, bed floor
@@ -330,6 +337,10 @@ namespace IslaApocalypse.Core // Change this if your namespace is different
if (data.ContainsKey("RiverWidthScale")) RiverWidthScale = (float)data["RiverWidthScale"];
if (data.ContainsKey("RiverDepthScale")) RiverDepthScale = (float)data["RiverDepthScale"];
if (data.ContainsKey("RiverSeaMargin")) RiverSeaMargin = (float)data["RiverSeaMargin"];
+ if (data.ContainsKey("RiverStepDropM")) RiverStepDropM = (float)data["RiverStepDropM"];
+ if (data.ContainsKey("RiverWaterDepthM")) RiverWaterDepthM = (float)data["RiverWaterDepthM"];
+ RiverStepDropM = Mathf.Clamp(RiverStepDropM, 0.25f, 10f);
+ RiverWaterDepthM = Mathf.Clamp(RiverWaterDepthM, 0.2f, 5f);
RiverWidthScale = Mathf.Clamp(RiverWidthScale, 0.1f, 5f);
RiverDepthScale = Mathf.Clamp(RiverDepthScale, 0.1f, 5f);
RiverSeaMargin = Mathf.Clamp(RiverSeaMargin, 0f, 5f);
diff --git a/Core/Scripts/MapDataParser.cs b/Core/Scripts/MapDataParser.cs
index d86ed9b..b91f73a 100644
--- a/Core/Scripts/MapDataParser.cs
+++ b/Core/Scripts/MapDataParser.cs
@@ -47,6 +47,7 @@ namespace IslaApocalypse.Core
{
public const byte TYPE_OCEAN = 0;
public const byte TYPE_LAKE = 1;
+ public const byte TYPE_RIVER = 2; // task 23: a stepped river REACH (one flat level)
public const byte SALINITY_FRESH = 0;
public const byte SALINITY_SALT = 1;
@@ -432,7 +433,7 @@ namespace IslaApocalypse.Core
body.SurfaceLevel = reader.ReadSingle();
body.PixelCount = reader.ReadInt32();
body.Centroid = new Vector2(reader.ReadSingle(), reader.ReadSingle());
- if (body.Type > WaterBodyInfo.TYPE_LAKE)
+ if (body.Type > WaterBodyInfo.TYPE_RIVER)
{
GD.PrintErr($"[MapDataParser] ERROR: water body {i} has unknown type {body.Type}.");
return false;
diff --git a/Tools/Scripts/MapGenerator.cs b/Tools/Scripts/MapGenerator.cs
index e8ff480..4390107 100644
--- a/Tools/Scripts/MapGenerator.cs
+++ b/Tools/Scripts/MapGenerator.cs
@@ -237,7 +237,16 @@ public partial class MapGenerator : TextureRect
// only: biomes and WBID are already computed from classify — the oracle is
// untouched by construction. NO WATER — part 2b.
if (_riversOn)
+ {
CarveRivers();
+ // The 0_height/0_water snapshots were captured before rivers existed —
+ // re-draw and re-capture so the exported PNGs show the carved beds and
+ // the new river water (task-22 nit 2).
+ DrawHeightStageTexture();
+ await CaptureStage("0_height");
+ DrawWaterStageTexture();
+ await CaptureStage("0_water");
+ }
if (ConfigManager.SkipRoads)
{
@@ -863,6 +872,36 @@ public partial class MapGenerator : TextureRect
for (int y = 0; y < MapSize; y++)
if (_heightMap[x, y] > topAfter) topAfter = _heightMap[x, y];
+ // --- Stepped river water (task 23, part 2b): reaches as flat water bodies.
+ // Levels-not-cells, the existing model: WBID cells + WBTB entries per reach;
+ // the writer derives WSRF from body levels. Touches no heights, so the
+ // flood-guard count above stays valid; touches no classify data, so the
+ // BIOME oracle holds (0_water changes — that IS the river water).
+ ushort nextBodyId = 1;
+ foreach (var b in _waterBodies)
+ if (b.Id >= nextBodyId) nextBodyId = (ushort)(b.Id + 1);
+ var reaches = RiverCarvePass.AddSteppedWater(_heightMap, MapSize, _waterBodyIds,
+ nextBodyId, st.Carved, seaMap, seaFlat,
+ _impactCenter.X, _impactCenter.Y,
+ _impactRadius * ConfigManager.CraterErosionCore,
+ ConfigManager.RiverStepDropM, ConfigManager.RiverWaterDepthM);
+ long riverWetPx = 0;
+ foreach (var reach in reaches)
+ {
+ _waterBodies.Add(new WaterBodyInfo
+ {
+ Id = reach.Id,
+ Type = WaterBodyInfo.TYPE_RIVER,
+ Salinity = WaterBodyInfo.SALINITY_FRESH,
+ SurfaceLevel = reach.Level,
+ PixelCount = reach.PixelCount,
+ Centroid = new Vector2((float)reach.Cx, (float)reach.Cy)
+ });
+ riverWetPx += reach.PixelCount;
+ }
+ GD.Print($"{T()} [Rivers] water: {reaches.Count} stepped reaches across {st.Carved.Count} rivers, " +
+ $"{riverWetPx} wet px, step drop {ConfigManager.RiverStepDropM:F1} m, depth {ConfigManager.RiverWaterDepthM:F1} m.");
+
GD.Print($"{T()} [Rivers] v1 '{ConfigManager.RiverRoutingStyle}': plan {st.AnalysisSeconds:F1}s, " +
$"routing {st.RoutingSeconds:F1}s, carve {st.CarveSeconds:F1}s " +
$"({(Time.GetTicksMsec() - tRiv0) / 1000.0:F1}s total). " +
diff --git a/Tools/Scripts/RiverCarvePass.cs b/Tools/Scripts/RiverCarvePass.cs
index 26f5530..cf4dd35 100644
--- a/Tools/Scripts/RiverCarvePass.cs
+++ b/Tools/Scripts/RiverCarvePass.cs
@@ -84,12 +84,26 @@ public static class RiverCarvePass
public double VolumeM3;
}
+ /// The carved geometry the water stage consumes (main rivers only).
+ public class CarvedRiver
+ {
+ public string Name, Kind;
+ public bool Southern;
+ public long DrainagePx;
+ public List<(float x, float y)> Dense; // head → mouth, ~1-px samples
+ public float[] Bed; // raw units, monotone non-increasing
+ public float[] HalfW; // px
+ public bool ReachedWaterTerminal; // lake-enders: extension reached classify water
+ }
+
public class Stats
{
public List Rivers = new();
+ public List Carved = new(); // for the stepped-water stage (task 23)
+ internal float[] PrePass; // cumulative-cut baseline
public long CarvedCells;
public double CarvedVolumeM3;
- public float MaxCutM;
+ public float MaxCutM; // CUMULATIVE vs pre-pass heights (task-22 nit 1 fixed)
public double AnalysisSeconds, RoutingSeconds, CarveSeconds;
}
@@ -122,6 +136,14 @@ public static class RiverCarvePass
// --- carve ---
t0 = secondsNow();
+ // Pre-pass snapshot: max-cut is measured CUMULATIVELY against the heights
+ // this pass found, not per-write — overlapping stamps re-cut a cell and the
+ // per-write number understated the true deepest cut ~4× (task-22 nit 1).
+ float[] pre = new float[n * n];
+ for (int x = 0; x < n; x++)
+ for (int y = 0; y < n; y++)
+ pre[x * n + y] = height[x, y];
+ stats.PrePass = pre;
int riverIdx = 0;
foreach (var t in plan.Trunks)
{
@@ -140,10 +162,25 @@ public static class RiverCarvePass
var route = giantRoutes[gi]; gi++;
var course = new List<(float x, float y)>(g.Course);
course.Reverse(); // head → terminal
+
+ // Lake-enders (task 23): the stem pools on dry ground short of its lake
+ // BECAUSE its pooling point is a local minimum — a blind descent walk
+ // dead-ends there immediately (measured: 0 steps). Route to the nearest
+ // classify-water cell with the same lowground Dijkstra the routed giants
+ // use, so the bed (and then the water) actually joins the lake.
+ bool reachedLake = false;
+ if (g.Kind == "lake-ender")
+ {
+ var ext = RouteToOcean(height, n, isClassifyWater,
+ (int)g.Terminal.x, (int)g.Terminal.y, STYLE_LOWGROUND, SeaAt);
+ if (ext.Count > 0) { route = ext; reachedLake = true; }
+ }
+
var rs = CarveRiver($"giant{gi}", g.Kind, g.DrainageAreaPx,
course, route, height, n, SeaAt, coreSq, craterCx, craterCy, p, stats);
rs.SouthernCandidate = g.SouthernCandidate;
- rs.ReachedOcean = g.Kind != "routed" || (route != null && route.Count > 0);
+ rs.ReachedOcean = g.Kind == "routed" ? (route != null && route.Count > 0) : reachedLake;
+ if (stats.Carved.Count > 0) stats.Carved[^1].ReachedWaterTerminal = reachedLake;
foreach (var trib in g.Tributaries)
CarveTributary(trib, height, n, SeaAt, coreSq, craterCx, craterCy, p, stats);
}
@@ -205,7 +242,7 @@ public static class RiverCarvePass
/// or an empty list if no path exists (reported upstream, never asserted away).
///
private static List<(float x, float y)> RouteToOcean(float[,] height, int n,
- bool[] isOcean, int sx, int sy, byte style, Func seaAt)
+ bool[] targets, int sx, int sy, byte style, Func seaAt)
{
int total = n * n;
var gcost = new float[total];
@@ -227,7 +264,7 @@ public static class RiverCarvePass
int c = pq.Dequeue();
if (closed[c]) continue;
closed[c] = true;
- if (isOcean[c]) { goal = c; break; }
+ if (targets[c]) { goal = c; break; }
int cx = c / n, cy = c % n;
float hc = height[cx, cy];
for (int k = 0; k < 8; k++)
@@ -281,12 +318,17 @@ public static class RiverCarvePass
float[,] height, int n, Func seaAt,
float coreSq, float craterCx, float craterCy, Params p, Stats stats)
{
- // Full head→mouth polyline: upland stem, then the lowland reach if any —
- // then SMOOTHED (task 23) so the carved centreline carries no routing kinks.
+ // Full head→mouth polyline: upland stem, then the lowland reach if any.
+ // ONLY the lowland reach is smoothed: the Dijkstra 45° kinks live there, on
+ // near-flat ground where a rounded corner costs nothing. The upland stems
+ // already thread the carved valley FLOORS — smoothing them off-line cut
+ // valley walls (measured: max cut 14.6 → 27.3 m before this was split).
var pts = new List<(float x, float y)>(upland);
if (lowlandRoute != null && lowlandRoute.Count > 1)
- pts.AddRange(lowlandRoute.GetRange(1, lowlandRoute.Count - 1));
- pts = SmoothCourse(pts);
+ {
+ var smoothedRoute = SmoothCourse(lowlandRoute);
+ pts.AddRange(smoothedRoute.GetRange(1, smoothedRoute.Count - 1));
+ }
// Densify to ~1-px samples (plan courses are decimated ×4).
var dense = new List<(float x, float y)>();
@@ -394,18 +436,122 @@ public static class RiverCarvePass
if (target < old)
{
float cutM = (old - target) * M_PER_UNIT;
+ // Cumulative depth vs the PRE-PASS surface (nit 1): the
+ // honest "how deep did we cut here in total" number.
+ float cumM = (stats.PrePass[x * n + y] - target) * M_PER_UNIT;
height[x, y] = target;
stats.CarvedCells++;
stats.CarvedVolumeM3 += cutM;
- if (cutM > stats.MaxCutM) stats.MaxCutM = cutM;
- if (cutM > rs.MaxCutM) rs.MaxCutM = cutM;
+ if (cumM > stats.MaxCutM) stats.MaxCutM = cumM;
+ if (cumM > rs.MaxCutM) rs.MaxCutM = cumM;
rs.VolumeM3 += cutM;
}
}
}
}
- if (name != null) stats.Rivers.Add(rs);
+ if (name != null)
+ {
+ stats.Rivers.Add(rs);
+ stats.Carved.Add(new CarvedRiver
+ {
+ Name = name, Kind = kind, DrainagePx = drainagePx,
+ Dense = dense, Bed = bed, HalfW = halfW
+ });
+ }
return rs;
}
+
+ ///
+ /// The stepped-water builder (task 23, part 2b): segments each carved main
+ /// river into REACHES — flat water bodies stepping down the bed toward the
+ /// outlet — and stamps their ids into the WBID grid. Reuses the existing
+ /// levels-not-cells water model exactly: one body per reach, one flat level
+ /// each; the writer derives WSRF from body levels as it always has. The step
+ /// drops are the smoothing dial (smaller drop = more, finer steps); tilted
+ /// water is the deferred model B and is NOT built here.
+ ///
+ /// Emission is plain data (no engine types): the caller turns reaches into
+ /// WBTB entries. Wet cells: inside the channel half-width, currently dry in
+ /// WBID, at/above sea (below-sea cells belong to the ocean/crater-seam rule),
+ /// bed below the reach level. Existing water bodies are never overwritten —
+ /// a river MEETS a lake or the sea, it does not repaint them.
+ ///
+ public class Reach
+ {
+ public ushort Id;
+ public string River;
+ public float Level; // raw units
+ public int PixelCount;
+ public double Cx, Cy; // centroid accumulators → mean
+ }
+
+ public static List AddSteppedWater(float[,] height, int mapSize,
+ ushort[,] wbid, ushort firstId, List rivers,
+ float[,] seaMap, float seaFlat, float craterCx, float craterCy,
+ float craterCoreRadius, float stepDropM, float waterDepthM)
+ {
+ int n = mapSize;
+ float SeaAt(int x, int y) => seaMap != null ? seaMap[x, y] : seaFlat;
+ float coreSq = craterCoreRadius * craterCoreRadius;
+ var reaches = new List();
+ ushort nextId = firstId;
+
+ foreach (var r in rivers)
+ {
+ int m = r.Dense.Count;
+ if (m < 2) continue;
+ int i = 0;
+ float lastLevel = float.MaxValue;
+ while (i < m)
+ {
+ // Reach spans from i while the bed stays within stepDropM of the
+ // reach's starting bed; its flat level sits waterDepthM above that
+ // start (deepening toward the next step — the pool behind a riffle).
+ float startBed = r.Bed[i];
+ float level = startBed + waterDepthM / M_PER_UNIT;
+ if (level >= lastLevel) // enforce strict descent
+ level = lastLevel - 0.01f / M_PER_UNIT;
+ int j = i;
+ while (j < m && r.Bed[j] > startBed - stepDropM / M_PER_UNIT) j++;
+
+ var reach = new Reach { Id = nextId, River = r.Name, Level = level };
+ for (int k2 = i; k2 < j; k2++)
+ {
+ float hw = r.HalfW[k2];
+ int x0 = (int)MathF.Floor(r.Dense[k2].x - hw), x1 = (int)MathF.Ceiling(r.Dense[k2].x + hw);
+ int y0 = (int)MathF.Floor(r.Dense[k2].y - hw), y1 = (int)MathF.Ceiling(r.Dense[k2].y + hw);
+ for (int x = x0; x <= x1; x++)
+ {
+ if (x < 0 || x >= n) continue;
+ for (int y = y0; y <= y1; y++)
+ {
+ if (y < 0 || y >= n) continue;
+ if (wbid[x, y] != 0) continue; // never repaint existing water
+ float rx = x - r.Dense[k2].x, ry = y - r.Dense[k2].y;
+ if (rx * rx + ry * ry > hw * hw) continue;
+ float ddx = x - craterCx, ddy = y - craterCy;
+ if (ddx * ddx + ddy * ddy < coreSq) continue;
+ float h = height[x, y];
+ float sea = SeaAt(x, y);
+ if (h < sea) continue; // ocean/seam territory
+ if (h >= level) continue; // bank above the water line
+ wbid[x, y] = nextId;
+ reach.PixelCount++;
+ reach.Cx += x; reach.Cy += y;
+ }
+ }
+ }
+ if (reach.PixelCount > 0)
+ {
+ reach.Cx /= reach.PixelCount; reach.Cy /= reach.PixelCount;
+ reaches.Add(reach);
+ nextId++;
+ lastLevel = level;
+ }
+ i = j;
+ }
+ }
+ return reaches;
+ }
}