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; + } }