feat: stepped river water + widen + lake-ender join + the task-22 nits (terrain-water task 23, C0b finale)
THE PAYOFF: rivers now carry water. Each carved main river becomes a chain of stepped flat water-body reaches — a new reach every RiverStepDropM (2 m) of bed descent, sitting RiverWaterDepthM (1.2 m) above its bed, strictly descending to the outlet. Reaches are ordinary water bodies (WBID cells + WBTB type 2 'river', fresh; WSRF derives from body levels as ever), so river water renders through the C1 path with the task-15 presence rule at its banks, untouched. Existing bodies are never overwritten — a river MEETS its lake or the sea. Measured on 1280587109: 298 reaches across 6 rivers, 184,620 wet px, levels stepping 278 m down to 39.5 m; in-engine, one valley frame renders surfaces 46.2-114.5 m. Beds widened (RiverWidthScale default 1.75). Lake-enders: the stem's pooling terminal IS a local minimum, so the first extension attempt (blind steepest descent) dead-ended in 0 steps — replaced with the lowground Dijkstra to the nearest classify-water cell; the E-lagoon river now joins its lake (87 px). Smoothing is applied ONLY to lowland routes: smoothing upland stems moved them off their carved valley floors into the walls (max cut 14.6 -> 27.3 m measured; split restores valley-floor fidelity). TYPE_RIVER added to the WBTB registry (parser validation extended; doc updated in the docs commit). Task-22 nits fixed: max-cut is now CUMULATIVE vs the pre-pass surface (the honest number: p95 carve 7.0 m; 339 cells island-wide exceed 20 m — localized outlet-gorge notches where stems cross deposit ridges, deepest 26 m); and 0_height/0_water are re-drawn and re-captured AFTER the river pass so the exported snapshots show beds and water. Guards: flood guard holds (water pixels unchanged around the carve; the water stage touches no heights), BIOME oracle md5-identical to the task-22 baseline, 0_water changed (that IS the river water), island top 457.65 m exact, crater core excluded. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
148602b4c5
commit
8f5767308a
4 changed files with 210 additions and 13 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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). " +
|
||||
|
|
|
|||
|
|
@ -84,12 +84,26 @@ public static class RiverCarvePass
|
|||
public double VolumeM3;
|
||||
}
|
||||
|
||||
/// <summary>The carved geometry the water stage consumes (main rivers only).</summary>
|
||||
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<RiverStat> Rivers = new();
|
||||
public List<CarvedRiver> 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).
|
||||
/// </summary>
|
||||
private static List<(float x, float y)> RouteToOcean(float[,] height, int n,
|
||||
bool[] isOcean, int sx, int sy, byte style, Func<int, int, float> seaAt)
|
||||
bool[] targets, int sx, int sy, byte style, Func<int, int, float> 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<int, int, float> 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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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<Reach> AddSteppedWater(float[,] height, int mapSize,
|
||||
ushort[,] wbid, ushort firstId, List<CarvedRiver> 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<Reach>();
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue