feat: river bed carving with SHORT/LOWGROUND lowland routing A/B (terrain-water task 22, C0b part 2a)
Executes the frozen task-21b plan as real terrain — the first river pass to modify the render map. NO WATER (part 2b waters the gated routing style). RiverCarvePass (D-035 numeric): reruns DrainageAnalysis in-pipeline (deterministic — same seed, same eroded surface, same plan), routes each routed giant's lowland reach to the nearest ocean by deterministic Dijkstra under two config-gated cost models — 'short' (distance + uphill penalty; heads direct) vs 'lowground' (cost ~ elevation above sea; follows the lowest ground and meanders) — then carves every promoted course as a parabolic channel with a smoothstep shoulder. Width/depth grow downstream with sqrt(drainage); bed profile forced monotone non-increasing toward the outlet (water must flow) and clamped to sea + RiverSeaMargin EVERYWHERE, with below-sea cells read-only and the crater core excluded — the erosion flood-guard discipline, asserted per generation by the water-pixel A/B (throws on any change). Pipeline slot: AFTER GenerateTowns (towns read the render map for land/slope/ water checks — carving first would move towns and destabilise every A/B) and BEFORE roads (a full run's A* should see the beds). ISLA_SERVER_CONFIG env override added to ConfigManager: batch/A-B tooling loads an alternate config file and the developer's live ServerConfig.json is never written by tooling again (the task-19 restore near-miss class is retired). Measured, seed 1280587109 (both variants): oracle md5-identical to baseline (BIOM/WBID bitwise equal), 0 newly-below-sea cells, below-sea untouched, island top 457.65 m exact, lowest carved cell exactly sea+margin (37.85 m), and Rivers='off' is bitwise identical to the task-19 blueprint. All routed giants reach the ocean in both styles. Wander (polyline/straight): SHORT 1.02-1.06 vs LOWGROUND 1.24-1.38; SHORT carves 811k m3 (cuts through rises, max cumulative cut 14.6 m), LOWGROUND 481k m3 (goes around, 14.3 m). Pass cost ~25 s (plan 21 s, routing 1.4-2.5 s, carve 0.3 s). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
a53d4e8512
commit
6960c2a2e8
3 changed files with 507 additions and 0 deletions
|
|
@ -105,6 +105,24 @@ namespace IslaApocalypse.Core // Change this if your namespace is different
|
||||||
public const float CRATER_EROSION_CORE_DEFAULT = 0.80f;
|
public const float CRATER_EROSION_CORE_DEFAULT = 0.80f;
|
||||||
public const float CRATER_EROSION_FEATHER_DEFAULT = 1.05f;
|
public const float CRATER_EROSION_FEATHER_DEFAULT = 1.05f;
|
||||||
|
|
||||||
|
// Rivers (task 22, C0b part 2a): carve the frozen task-21b river plan's
|
||||||
|
// beds into the RENDER map — ocean trunks, routed giants (lowland reach to
|
||||||
|
// the sea), lake-enders. NO WATER yet (part 2b). "off" until the routing-
|
||||||
|
// style gate; the A/B batch turns it on explicitly. RiverRoutingStyle picks
|
||||||
|
// the lowland routing for routed giants: "short" heads direct (lightly
|
||||||
|
// terrain-aware), "lowground" follows the lowest ground and wanders like a
|
||||||
|
// real river — the task-22 gate decides which ships; "lowground" is the
|
||||||
|
// provisional default pending that verdict. Width/depth scales are taste
|
||||||
|
// dials on the flow-proportional bed profile. RiverSeaMargin is the bed's
|
||||||
|
// 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.
|
||||||
|
public static string Rivers = "off";
|
||||||
|
public static string RiverRoutingStyle = "lowground";
|
||||||
|
public static float RiverWidthScale = 1.0f;
|
||||||
|
public static float RiverDepthScale = 1.0f;
|
||||||
|
public static float RiverSeaMargin = 0.2f; // m above sea, bed floor
|
||||||
|
|
||||||
// Island falloff shaping (task 11).
|
// Island falloff shaping (task 11).
|
||||||
//
|
//
|
||||||
// CoastProfile: "wide" adds the submarine shelf — the height curve is identity
|
// CoastProfile: "wide" adds the submarine shelf — the height curve is identity
|
||||||
|
|
@ -140,6 +158,24 @@ namespace IslaApocalypse.Core // Change this if your namespace is different
|
||||||
{
|
{
|
||||||
string path = "res://ServerConfig.json";
|
string path = "res://ServerConfig.json";
|
||||||
|
|
||||||
|
// Iteration override (task 22): ISLA_SERVER_CONFIG names an alternate
|
||||||
|
// config FILE to load instead of the repo's ServerConfig.json. Batch and
|
||||||
|
// A/B runs point this at a scratch config, so the developer's live
|
||||||
|
// ServerConfig.json is never written by tooling again — the whole
|
||||||
|
// backup/restore dance (and its task-19 near-miss) goes away. Loud, so
|
||||||
|
// a forgotten env var cannot silently masquerade as the repo config.
|
||||||
|
string envPath = OS.GetEnvironment("ISLA_SERVER_CONFIG");
|
||||||
|
if (!string.IsNullOrEmpty(envPath))
|
||||||
|
{
|
||||||
|
if (FileAccess.FileExists(envPath))
|
||||||
|
{
|
||||||
|
path = envPath;
|
||||||
|
GD.Print($"[ConfigManager] ⚠ ISLA_SERVER_CONFIG override: loading '{envPath}' (NOT the repo ServerConfig.json).");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
GD.PrintErr($"[ConfigManager] ISLA_SERVER_CONFIG set but '{envPath}' does not exist — falling back to the repo config.");
|
||||||
|
}
|
||||||
|
|
||||||
if (!FileAccess.FileExists(path))
|
if (!FileAccess.FileExists(path))
|
||||||
{
|
{
|
||||||
GD.PrintErr("[ConfigManager] ServerConfig.json not found! Defaulting to 8K.");
|
GD.PrintErr("[ConfigManager] ServerConfig.json not found! Defaulting to 8K.");
|
||||||
|
|
@ -272,6 +308,30 @@ namespace IslaApocalypse.Core // Change this if your namespace is different
|
||||||
ErosionCarveCap = Mathf.Clamp(ErosionCarveCap, 0f, 60f);
|
ErosionCarveCap = Mathf.Clamp(ErosionCarveCap, 0f, 60f);
|
||||||
if (rawCount != ErosionDropletCount || rawLife != ErosionDropletLifetime || rawCap != ErosionCarveCap)
|
if (rawCount != ErosionDropletCount || rawLife != ErosionDropletLifetime || rawCap != ErosionCarveCap)
|
||||||
GD.PrintErr($"[ConfigManager] Erosion governor out of bounds — clamped: count {rawCount}->{ErosionDropletCount}, lifetime {rawLife}->{ErosionDropletLifetime}, cap {rawCap}->{ErosionCarveCap} m.");
|
GD.PrintErr($"[ConfigManager] Erosion governor out of bounds — clamped: count {rawCount}->{ErosionDropletCount}, lifetime {rawLife}->{ErosionDropletLifetime}, cap {rawCap}->{ErosionCarveCap} m.");
|
||||||
|
// Rivers gate + dials (task 22)
|
||||||
|
if (data.ContainsKey("Rivers"))
|
||||||
|
{
|
||||||
|
string rv = (string)data["Rivers"];
|
||||||
|
if (rv == "off" || rv == "v1")
|
||||||
|
Rivers = rv;
|
||||||
|
else
|
||||||
|
GD.PrintErr($"[ConfigManager] Unknown Rivers '{rv}'. Keeping '{Rivers}'.");
|
||||||
|
}
|
||||||
|
if (data.ContainsKey("RiverRoutingStyle"))
|
||||||
|
{
|
||||||
|
string st = (string)data["RiverRoutingStyle"];
|
||||||
|
if (st == "short" || st == "lowground")
|
||||||
|
RiverRoutingStyle = st;
|
||||||
|
else
|
||||||
|
GD.PrintErr($"[ConfigManager] Unknown RiverRoutingStyle '{st}'. Keeping '{RiverRoutingStyle}'.");
|
||||||
|
}
|
||||||
|
if (data.ContainsKey("RiverWidthScale")) RiverWidthScale = (float)data["RiverWidthScale"];
|
||||||
|
if (data.ContainsKey("RiverDepthScale")) RiverDepthScale = (float)data["RiverDepthScale"];
|
||||||
|
if (data.ContainsKey("RiverSeaMargin")) RiverSeaMargin = (float)data["RiverSeaMargin"];
|
||||||
|
RiverWidthScale = Mathf.Clamp(RiverWidthScale, 0.1f, 5f);
|
||||||
|
RiverDepthScale = Mathf.Clamp(RiverDepthScale, 0.1f, 5f);
|
||||||
|
RiverSeaMargin = Mathf.Clamp(RiverSeaMargin, 0f, 5f);
|
||||||
|
|
||||||
// Crater erosion treatment (task 19)
|
// Crater erosion treatment (task 19)
|
||||||
if (data.ContainsKey("CraterErosionMode"))
|
if (data.ContainsKey("CraterErosionMode"))
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,7 @@ public partial class MapGenerator : TextureRect
|
||||||
// Hydraulic erosion (task 17): output-only droplet pass on the RENDER map,
|
// Hydraulic erosion (task 17): output-only droplet pass on the RENDER map,
|
||||||
// after detail, before the crater carve. The classify map never sees it.
|
// after detail, before the crater carve. The classify map never sees it.
|
||||||
private bool _erosionOn;
|
private bool _erosionOn;
|
||||||
|
private bool _riversOn;
|
||||||
private byte _craterErosionMode;
|
private byte _craterErosionMode;
|
||||||
|
|
||||||
// The carve's radius as a fraction of CraterRadius — the ONLY place this number
|
// The carve's radius as a fraction of CraterRadius — the ONLY place this number
|
||||||
|
|
@ -120,6 +121,7 @@ public partial class MapGenerator : TextureRect
|
||||||
_curveKnots = ConfigManager.TerrainCurve == "v5" ? HeightCurve.V5 : null;
|
_curveKnots = ConfigManager.TerrainCurve == "v5" ? HeightCurve.V5 : null;
|
||||||
_curveOn = _curveKnots != null;
|
_curveOn = _curveKnots != null;
|
||||||
_erosionOn = ConfigManager.Erosion == "v1";
|
_erosionOn = ConfigManager.Erosion == "v1";
|
||||||
|
_riversOn = ConfigManager.Rivers == "v1";
|
||||||
_craterErosionMode = ConfigManager.CraterErosionMode == "feather"
|
_craterErosionMode = ConfigManager.CraterErosionMode == "feather"
|
||||||
? HydraulicErosion.CRATER_MODE_FEATHER : HydraulicErosion.CRATER_MODE_FULL;
|
? HydraulicErosion.CRATER_MODE_FEATHER : HydraulicErosion.CRATER_MODE_FULL;
|
||||||
// (The monotonicity assertion now runs inside GenerateTopography, against the
|
// (The monotonicity assertion now runs inside GenerateTopography, against the
|
||||||
|
|
@ -228,6 +230,15 @@ public partial class MapGenerator : TextureRect
|
||||||
GD.Print($"{T()} Towns placed: {_towns.Count}.");
|
GD.Print($"{T()} Towns placed: {_towns.Count}.");
|
||||||
await CaptureStage("2_towns");
|
await CaptureStage("2_towns");
|
||||||
|
|
||||||
|
// --- RIVERS (task 22, C0b part 2a): carve the frozen plan's beds. ---
|
||||||
|
// AFTER towns (GenerateTowns reads the render map for land/slope/water
|
||||||
|
// checks, so carving first would move towns and destabilise every A/B) and
|
||||||
|
// BEFORE roads (a full run's A* should see the carved beds). RENDER map
|
||||||
|
// only: biomes and WBID are already computed from classify — the oracle is
|
||||||
|
// untouched by construction. NO WATER — part 2b.
|
||||||
|
if (_riversOn)
|
||||||
|
CarveRivers();
|
||||||
|
|
||||||
if (ConfigManager.SkipRoads)
|
if (ConfigManager.SkipRoads)
|
||||||
{
|
{
|
||||||
// Iteration toggle (terrain-water task 03): the road pass is ~25 min of a
|
// Iteration toggle (terrain-water task 03): the road pass is ~25 min of a
|
||||||
|
|
@ -790,6 +801,81 @@ public partial class MapGenerator : TextureRect
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The river-carving pass (task 22): recomputes the frozen task-21b plan on the
|
||||||
|
/// eroded surface, routes the routed giants' lowland reaches (SHORT/LOWGROUND),
|
||||||
|
/// and carves every promoted bed. Render map only; flood-guarded exactly like
|
||||||
|
/// erosion (water-pixel count A/B around the pass, throw on any change).
|
||||||
|
/// </summary>
|
||||||
|
private void CarveRivers()
|
||||||
|
{
|
||||||
|
ulong tRiv0 = Time.GetTicksMsec();
|
||||||
|
float[,] seaMap = null;
|
||||||
|
float seaFlat = ConfigManager.SeaLevelValue;
|
||||||
|
if (ConfigManager.SeaLevelModel != "flat")
|
||||||
|
{
|
||||||
|
seaMap = new float[MapSize, MapSize];
|
||||||
|
for (int x = 0; x < MapSize; x++)
|
||||||
|
for (int y = 0; y < MapSize; y++)
|
||||||
|
seaMap[x, y] = GetSeaLevel(_tempMap[x, y]);
|
||||||
|
}
|
||||||
|
long wetBefore = CountRenderWaterPixels(seaMap, seaFlat);
|
||||||
|
float topBefore = 0f;
|
||||||
|
for (int x = 0; x < MapSize; x++)
|
||||||
|
for (int y = 0; y < MapSize; y++)
|
||||||
|
if (_heightMap[x, y] > topBefore) topBefore = _heightMap[x, y];
|
||||||
|
|
||||||
|
// Masks from the SHARED water predicates — identical by construction to the
|
||||||
|
// WBID-derived masks the task-21b tool used.
|
||||||
|
bool[] isOcean = new bool[MapSize * MapSize];
|
||||||
|
bool[] isClassifyWater = new bool[MapSize * MapSize];
|
||||||
|
for (int x = 0; x < MapSize; x++)
|
||||||
|
for (int y = 0; y < MapSize; y++)
|
||||||
|
{
|
||||||
|
isOcean[x * MapSize + y] = IsOceanPixel(x, y);
|
||||||
|
isClassifyWater[x * MapSize + y] = IsWaterPixel(x, y);
|
||||||
|
}
|
||||||
|
|
||||||
|
float southX = -1f, southY = -1f;
|
||||||
|
foreach (var t in _towns)
|
||||||
|
if (t.Position.Y > southY) { southX = t.Position.X; southY = t.Position.Y; }
|
||||||
|
|
||||||
|
var p = new RiverCarvePass.Params
|
||||||
|
{
|
||||||
|
RoutingStyle = ConfigManager.RiverRoutingStyle == "short"
|
||||||
|
? RiverCarvePass.STYLE_SHORT : RiverCarvePass.STYLE_LOWGROUND,
|
||||||
|
WidthScale = ConfigManager.RiverWidthScale,
|
||||||
|
DepthScale = ConfigManager.RiverDepthScale,
|
||||||
|
SeaMarginM = ConfigManager.RiverSeaMargin
|
||||||
|
};
|
||||||
|
var st = RiverCarvePass.Apply(_heightMap, MapSize, isOcean, isClassifyWater,
|
||||||
|
southX, southY, seaMap, seaFlat,
|
||||||
|
_impactCenter.X, _impactCenter.Y,
|
||||||
|
_impactRadius * ConfigManager.CraterErosionCore,
|
||||||
|
() => (Time.GetTicksMsec()) / 1000.0, p);
|
||||||
|
|
||||||
|
long wetAfter = CountRenderWaterPixels(seaMap, seaFlat);
|
||||||
|
if (wetAfter != wetBefore)
|
||||||
|
throw new System.InvalidOperationException(
|
||||||
|
$"[MapGenerator] RIVER FLOOD-GUARD VIOLATION: render-map water pixels {wetBefore} -> {wetAfter}. Refusing to generate.");
|
||||||
|
float topAfter = 0f;
|
||||||
|
for (int x = 0; x < MapSize; x++)
|
||||||
|
for (int y = 0; y < MapSize; y++)
|
||||||
|
if (_heightMap[x, y] > topAfter) topAfter = _heightMap[x, y];
|
||||||
|
|
||||||
|
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). " +
|
||||||
|
$"{st.CarvedCells} cell-writes, {st.CarvedVolumeM3:F0} m³, max cut {st.MaxCutM:F1} m. " +
|
||||||
|
$"Water pixels {wetBefore} -> {wetAfter} (flood guard holds); island top {topBefore * 251f:F2} -> {topAfter * 251f:F2} m.");
|
||||||
|
foreach (var r in st.Rivers)
|
||||||
|
GD.Print($"{T()} [Rivers] {r.Name} [{r.Kind}{(r.SouthernCandidate ? " SOUTHERN" : "")}]: " +
|
||||||
|
$"drainage {r.DrainagePx}, course {r.CourseLenPx} px" +
|
||||||
|
(r.RouteLenPx > 0 ? $", lowland route {r.RouteLenPx} px (straight {r.RouteStraightPx:F0}, wander {r.WanderRatio:F2})" : "") +
|
||||||
|
$", {(r.ReachedOcean ? "reaches ocean/terminal" : "*** ROUTE INCOMPLETE ***")}, " +
|
||||||
|
$"max cut {r.MaxCutM:F1} m, {r.VolumeM3:F0} m³.");
|
||||||
|
}
|
||||||
|
|
||||||
// Render-map water pixel count — the erosion flood-guard's external check.
|
// Render-map water pixel count — the erosion flood-guard's external check.
|
||||||
private long CountRenderWaterPixels(float[,] seaMap, float seaFlat)
|
private long CountRenderWaterPixels(float[,] seaMap, float seaFlat)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
361
Tools/Scripts/RiverCarvePass.cs
Normal file
361
Tools/Scripts/RiverCarvePass.cs
Normal file
|
|
@ -0,0 +1,361 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// River bed carving — C0b part 2a (terrain-water task 22). The first river pass
|
||||||
|
/// that MODIFIES terrain: executes the frozen task-21b plan by carving channel
|
||||||
|
/// beds for the promoted rivers. NO WATER — part 2b puts water into these beds
|
||||||
|
/// once the routing-style gate picks SHORT or LOWGROUND.
|
||||||
|
///
|
||||||
|
/// Standalone numeric (D-035 family). Runs the task-21 DrainageAnalysis in-
|
||||||
|
/// pipeline (deterministic: same seed → same eroded surface → same plan), then:
|
||||||
|
///
|
||||||
|
/// 1. LOWLAND ROUTING (the A/B): each routed giant gets a route from its
|
||||||
|
/// pooling terminal to the nearest OCEAN cell by deterministic Dijkstra.
|
||||||
|
/// SHORT — cost ≈ distance, uphill penalised: heads direct, avoids walls.
|
||||||
|
/// LOWGROUND — cost ≈ elevation above sea: follows the lowest available
|
||||||
|
/// ground and wanders like a real lowland river.
|
||||||
|
/// 2. BED CARVING: every promoted course (trunks, routed giants + their lowland
|
||||||
|
/// reaches, lake-enders, tributaries) is stamped as a parabolic channel with
|
||||||
|
/// a smoothstep shoulder — a bed for water to sit in, not a canyon. Width and
|
||||||
|
/// depth grow downstream with drainage. The bed elevation is made MONOTONE
|
||||||
|
/// NON-INCREASING toward the outlet (water must flow), and is clamped to
|
||||||
|
/// sea + margin everywhere — the flood-guard discipline erosion established:
|
||||||
|
/// below-sea cells are read-only, no carve may create inland below-sea cells,
|
||||||
|
/// so the rendered coastline cannot move. The bed meets the ocean AT the
|
||||||
|
/// coast, where the terrain itself descends through sea level.
|
||||||
|
/// 3. Crater: nothing inside the protected core is modified (erosion's rule).
|
||||||
|
///
|
||||||
|
/// Output-only: the caller applies this to the RENDER map after classify-side
|
||||||
|
/// data (biomes, WBID) is already computed — the oracle stays byte-identical.
|
||||||
|
/// </summary>
|
||||||
|
public static class RiverCarvePass
|
||||||
|
{
|
||||||
|
public const float M_PER_UNIT = 251f;
|
||||||
|
public const byte STYLE_SHORT = 0;
|
||||||
|
public const byte STYLE_LOWGROUND = 1;
|
||||||
|
|
||||||
|
private static readonly int[] DX = { -1, -1, -1, 0, 0, 1, 1, 1 };
|
||||||
|
private static readonly int[] DY = { -1, 0, 1, -1, 1, -1, 0, 1 };
|
||||||
|
private static readonly float[] DIST = {
|
||||||
|
1.41421356f, 1f, 1.41421356f, 1f, 1f, 1.41421356f, 1f, 1.41421356f };
|
||||||
|
|
||||||
|
// Routing cost constants. SHORT pays lightly for climbing (8 per metre of rise,
|
||||||
|
// so a 10 m wall costs like an 80 px detour — walls are avoided, direction is
|
||||||
|
// kept). LOWGROUND pays for BEING high (1 per metre of elevation per px) plus
|
||||||
|
// heavily for climbing, so the cheapest corridor is the lowest ground even when
|
||||||
|
// that wanders.
|
||||||
|
private const float SHORT_UPHILL_PER_M = 8f;
|
||||||
|
private const float LOWGROUND_ELEV_PER_M = 1f;
|
||||||
|
private const float LOWGROUND_BASE = 0.05f;
|
||||||
|
private const float LOWGROUND_UPHILL_PER_M = 50f;
|
||||||
|
|
||||||
|
// Bed geometry: sizes grow downstream from head to mouth, scaled by
|
||||||
|
// sqrt(drainage / 1e6) so a 2.3M px giant carves roughly 2.3× deeper/wider at
|
||||||
|
// the mouth than a 0.4M px trunk. Kept channel-scale, per the erosion
|
||||||
|
// detailing philosophy.
|
||||||
|
private const float DEPTH_HEAD_M = 1.0f;
|
||||||
|
private const float DEPTH_MOUTH_M = 6.0f; // × sizeFactor × DepthScale
|
||||||
|
private const float HALFWIDTH_HEAD_PX = 2.0f;
|
||||||
|
private const float HALFWIDTH_MOUTH_PX = 12.0f; // × sizeFactor × WidthScale
|
||||||
|
private const float MIN_BED_SLOPE = 0.002f; // m per px of enforced descent
|
||||||
|
|
||||||
|
public class Params
|
||||||
|
{
|
||||||
|
public byte RoutingStyle = STYLE_LOWGROUND;
|
||||||
|
public float WidthScale = 1.0f;
|
||||||
|
public float DepthScale = 1.0f;
|
||||||
|
public float SeaMarginM = 0.2f; // bed floor above sea, everywhere
|
||||||
|
public DrainageAnalysis.Params PlanParams = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public class RiverStat
|
||||||
|
{
|
||||||
|
public string Name;
|
||||||
|
public string Kind;
|
||||||
|
public bool SouthernCandidate;
|
||||||
|
public long DrainagePx;
|
||||||
|
public int CourseLenPx;
|
||||||
|
public int RouteLenPx; // lowland reach only (routed giants)
|
||||||
|
public float RouteStraightPx;
|
||||||
|
public float WanderRatio; // routeLen / straight-line
|
||||||
|
public bool ReachedOcean;
|
||||||
|
public float MaxCutM;
|
||||||
|
public double VolumeM3;
|
||||||
|
}
|
||||||
|
|
||||||
|
public class Stats
|
||||||
|
{
|
||||||
|
public List<RiverStat> Rivers = new();
|
||||||
|
public long CarvedCells;
|
||||||
|
public double CarvedVolumeM3;
|
||||||
|
public float MaxCutM;
|
||||||
|
public double AnalysisSeconds, RoutingSeconds, CarveSeconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Stats Apply(float[,] height, int mapSize, bool[] isOcean,
|
||||||
|
bool[] isClassifyWater, float southX, float southY,
|
||||||
|
float[,] seaMap, float seaFlat,
|
||||||
|
float craterCx, float craterCy, float craterCoreRadius,
|
||||||
|
Func<double> secondsNow, Params p)
|
||||||
|
{
|
||||||
|
int n = mapSize;
|
||||||
|
var stats = new Stats();
|
||||||
|
float SeaAt(int x, int y) => seaMap != null ? seaMap[x, y] : seaFlat;
|
||||||
|
float coreSq = craterCoreRadius * craterCoreRadius;
|
||||||
|
|
||||||
|
// --- the frozen plan, recomputed deterministically in-pipeline ---
|
||||||
|
double t0 = secondsNow();
|
||||||
|
var plan = DrainageAnalysis.Run(height, mapSize, isOcean, isClassifyWater, southX, southY, p.PlanParams);
|
||||||
|
stats.AnalysisSeconds = secondsNow() - t0;
|
||||||
|
|
||||||
|
// --- lowland routing for the routed giants (the A/B) ---
|
||||||
|
t0 = secondsNow();
|
||||||
|
var giantRoutes = new List<List<(float x, float y)>>();
|
||||||
|
foreach (var g in plan.Giants)
|
||||||
|
{
|
||||||
|
if (g.Kind != "routed") { giantRoutes.Add(null); continue; }
|
||||||
|
giantRoutes.Add(RouteToOcean(height, n, isOcean,
|
||||||
|
(int)g.Terminal.x, (int)g.Terminal.y, p.RoutingStyle, SeaAt));
|
||||||
|
}
|
||||||
|
stats.RoutingSeconds = secondsNow() - t0;
|
||||||
|
|
||||||
|
// --- carve ---
|
||||||
|
t0 = secondsNow();
|
||||||
|
int riverIdx = 0;
|
||||||
|
foreach (var t in plan.Trunks)
|
||||||
|
{
|
||||||
|
riverIdx++;
|
||||||
|
var course = new List<(float x, float y)>(t.Course);
|
||||||
|
course.Reverse(); // head → mouth
|
||||||
|
var rs = CarveRiver($"trunk{riverIdx}", "ocean-trunk", t.DrainageAreaPx,
|
||||||
|
course, null, height, n, SeaAt, coreSq, craterCx, craterCy, p, stats);
|
||||||
|
rs.ReachedOcean = true; // outlet is on the coast by construction
|
||||||
|
foreach (var trib in t.Tributaries)
|
||||||
|
CarveTributary(trib, height, n, SeaAt, coreSq, craterCx, craterCy, p, stats);
|
||||||
|
}
|
||||||
|
int gi = 0;
|
||||||
|
foreach (var g in plan.Giants)
|
||||||
|
{
|
||||||
|
var route = giantRoutes[gi]; gi++;
|
||||||
|
var course = new List<(float x, float y)>(g.Course);
|
||||||
|
course.Reverse(); // head → terminal
|
||||||
|
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);
|
||||||
|
foreach (var trib in g.Tributaries)
|
||||||
|
CarveTributary(trib, height, n, SeaAt, coreSq, craterCx, craterCy, p, stats);
|
||||||
|
}
|
||||||
|
stats.CarveSeconds = secondsNow() - t0;
|
||||||
|
return stats;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Deterministic Dijkstra from the start cell to the nearest ocean cell under
|
||||||
|
/// the selected style's cost model. Returns the path start → ocean (1-px steps),
|
||||||
|
/// 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)
|
||||||
|
{
|
||||||
|
int total = n * n;
|
||||||
|
var gcost = new float[total];
|
||||||
|
var parent = new int[total];
|
||||||
|
var closed = new bool[total];
|
||||||
|
Array.Fill(gcost, float.MaxValue);
|
||||||
|
Array.Fill(parent, -1);
|
||||||
|
|
||||||
|
float ElevM(int x, int y) => MathF.Max(0f, (height[x, y] - seaAt(x, y)) * M_PER_UNIT);
|
||||||
|
|
||||||
|
var pq = new PriorityQueue<int, (float c, int i)>();
|
||||||
|
int start = sx * n + sy;
|
||||||
|
gcost[start] = 0f;
|
||||||
|
pq.Enqueue(start, (0f, start));
|
||||||
|
int goal = -1;
|
||||||
|
|
||||||
|
while (pq.Count > 0)
|
||||||
|
{
|
||||||
|
int c = pq.Dequeue();
|
||||||
|
if (closed[c]) continue;
|
||||||
|
closed[c] = true;
|
||||||
|
if (isOcean[c]) { goal = c; break; }
|
||||||
|
int cx = c / n, cy = c % n;
|
||||||
|
float hc = height[cx, cy];
|
||||||
|
for (int k = 0; k < 8; k++)
|
||||||
|
{
|
||||||
|
int nx = cx + DX[k], ny = cy + DY[k];
|
||||||
|
if (nx < 0 || nx >= n || ny < 0 || ny >= n) continue;
|
||||||
|
int ni = nx * n + ny;
|
||||||
|
if (closed[ni]) continue;
|
||||||
|
float dhM = MathF.Max(0f, (height[nx, ny] - hc) * M_PER_UNIT);
|
||||||
|
float step = style == STYLE_SHORT
|
||||||
|
? DIST[k] + dhM * SHORT_UPHILL_PER_M
|
||||||
|
: DIST[k] * (LOWGROUND_BASE + ElevM(nx, ny) * LOWGROUND_ELEV_PER_M)
|
||||||
|
+ dhM * LOWGROUND_UPHILL_PER_M;
|
||||||
|
float nc = gcost[c] + step;
|
||||||
|
if (nc < gcost[ni])
|
||||||
|
{
|
||||||
|
gcost[ni] = nc;
|
||||||
|
parent[ni] = c;
|
||||||
|
pq.Enqueue(ni, (nc, ni));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var path = new List<(float x, float y)>();
|
||||||
|
if (goal >= 0)
|
||||||
|
{
|
||||||
|
for (int c = goal; c >= 0; c = parent[c])
|
||||||
|
path.Add((c / n, c % n));
|
||||||
|
path.Reverse();
|
||||||
|
}
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void CarveTributary(DrainageAnalysis.Stream trib, float[,] height, int n,
|
||||||
|
Func<int, int, float> seaAt, float coreSq, float craterCx, float craterCy,
|
||||||
|
Params p, Stats stats)
|
||||||
|
{
|
||||||
|
var course = new List<(float x, float y)>(trib.Course);
|
||||||
|
course.Reverse(); // head → confluence
|
||||||
|
CarveRiver(null, "tributary", trib.DrainageAreaPx, course, null,
|
||||||
|
height, n, seaAt, coreSq, craterCx, craterCy, p, stats);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Carves one river: densify the course, build a monotone-descending clamped
|
||||||
|
/// bed profile, stamp the channel. Returns the per-river stat (also appended
|
||||||
|
/// to stats.Rivers unless name is null — tributaries fold into the totals).
|
||||||
|
/// </summary>
|
||||||
|
private static RiverStat CarveRiver(string name, string kind, long drainagePx,
|
||||||
|
List<(float x, float y)> upland, List<(float x, float y)> lowlandRoute,
|
||||||
|
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.
|
||||||
|
var pts = new List<(float x, float y)>(upland);
|
||||||
|
if (lowlandRoute != null && lowlandRoute.Count > 1)
|
||||||
|
pts.AddRange(lowlandRoute.GetRange(1, lowlandRoute.Count - 1));
|
||||||
|
|
||||||
|
// Densify to ~1-px samples (plan courses are decimated ×4).
|
||||||
|
var dense = new List<(float x, float y)>();
|
||||||
|
for (int i = 0; i + 1 < pts.Count; i++)
|
||||||
|
{
|
||||||
|
var a = pts[i]; var b = pts[i + 1];
|
||||||
|
float segLen = MathF.Sqrt((b.x - a.x) * (b.x - a.x) + (b.y - a.y) * (b.y - a.y));
|
||||||
|
int steps = Math.Max(1, (int)MathF.Ceiling(segLen));
|
||||||
|
for (int s2 = 0; s2 < steps; s2++)
|
||||||
|
dense.Add((a.x + (b.x - a.x) * s2 / steps, a.y + (b.y - a.y) * s2 / steps));
|
||||||
|
}
|
||||||
|
if (pts.Count > 0) dense.Add(pts[^1]);
|
||||||
|
if (dense.Count < 2) return new RiverStat();
|
||||||
|
|
||||||
|
float sizeFactor = MathF.Sqrt(drainagePx / 1_000_000f);
|
||||||
|
var rs = new RiverStat
|
||||||
|
{
|
||||||
|
Name = name, Kind = kind, DrainagePx = drainagePx,
|
||||||
|
CourseLenPx = dense.Count,
|
||||||
|
RouteLenPx = lowlandRoute?.Count ?? 0
|
||||||
|
};
|
||||||
|
if (lowlandRoute != null && lowlandRoute.Count > 1)
|
||||||
|
{
|
||||||
|
var a = lowlandRoute[0]; var b = lowlandRoute[^1];
|
||||||
|
rs.RouteStraightPx = MathF.Sqrt((b.x - a.x) * (b.x - a.x) + (b.y - a.y) * (b.y - a.y));
|
||||||
|
// Wander = POLYLINE length over straight-line — cell count undercounts
|
||||||
|
// diagonal steps and can read below 1, which is geometrically impossible.
|
||||||
|
float polyLen = 0f;
|
||||||
|
for (int i = 1; i < lowlandRoute.Count; i++)
|
||||||
|
{
|
||||||
|
float sdx = lowlandRoute[i].x - lowlandRoute[i - 1].x;
|
||||||
|
float sdy = lowlandRoute[i].y - lowlandRoute[i - 1].y;
|
||||||
|
polyLen += MathF.Sqrt(sdx * sdx + sdy * sdy);
|
||||||
|
}
|
||||||
|
rs.RouteLenPx = (int)polyLen;
|
||||||
|
rs.WanderRatio = rs.RouteStraightPx > 1f ? polyLen / rs.RouteStraightPx : 1f;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bed profile: raw = terrain − depth(t); then monotone non-increasing
|
||||||
|
// downstream; then clamped to sea + margin. The clamp can flatten the tail
|
||||||
|
// near the mouth — allowed: non-increasing is what water needs, and the
|
||||||
|
// flood guard is absolute.
|
||||||
|
int m = dense.Count;
|
||||||
|
var bed = new float[m];
|
||||||
|
var depth = new float[m];
|
||||||
|
var halfW = new float[m];
|
||||||
|
for (int i = 0; i < m; i++)
|
||||||
|
{
|
||||||
|
float t = m > 1 ? (float)i / (m - 1) : 1f;
|
||||||
|
depth[i] = (DEPTH_HEAD_M + (DEPTH_MOUTH_M * sizeFactor - DEPTH_HEAD_M) * t) * p.DepthScale;
|
||||||
|
if (depth[i] < 0.5f) depth[i] = 0.5f;
|
||||||
|
halfW[i] = (HALFWIDTH_HEAD_PX + (HALFWIDTH_MOUTH_PX * sizeFactor - HALFWIDTH_HEAD_PX) * t) * p.WidthScale;
|
||||||
|
if (halfW[i] < 1.5f) halfW[i] = 1.5f;
|
||||||
|
int cx = (int)dense[i].x, cy = (int)dense[i].y;
|
||||||
|
bed[i] = height[cx, cy] - depth[i] / M_PER_UNIT;
|
||||||
|
}
|
||||||
|
for (int i = 1; i < m; i++)
|
||||||
|
{
|
||||||
|
float maxAllowed = bed[i - 1] - MIN_BED_SLOPE / M_PER_UNIT;
|
||||||
|
if (bed[i] > maxAllowed) bed[i] = maxAllowed;
|
||||||
|
}
|
||||||
|
for (int i = 0; i < m; i++)
|
||||||
|
{
|
||||||
|
int cx = (int)dense[i].x, cy = (int)dense[i].y;
|
||||||
|
float floor = seaAt(cx, cy) + p.SeaMarginM / M_PER_UNIT;
|
||||||
|
if (bed[i] < floor) bed[i] = floor;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stamp: parabolic channel to the rim, smoothstep shoulder back to terrain.
|
||||||
|
for (int i = 0; i < m; i++)
|
||||||
|
{
|
||||||
|
float hw = halfW[i];
|
||||||
|
float outer = hw * 2f;
|
||||||
|
int cx0 = (int)MathF.Floor(dense[i].x - outer), cx1 = (int)MathF.Ceiling(dense[i].x + outer);
|
||||||
|
int cy0 = (int)MathF.Floor(dense[i].y - outer), cy1 = (int)MathF.Ceiling(dense[i].y + outer);
|
||||||
|
float rimH = bed[i] + depth[i] / M_PER_UNIT;
|
||||||
|
for (int x = cx0; x <= cx1; x++)
|
||||||
|
{
|
||||||
|
if (x < 0 || x >= n) continue;
|
||||||
|
for (int y = cy0; y <= cy1; y++)
|
||||||
|
{
|
||||||
|
if (y < 0 || y >= n) continue;
|
||||||
|
float rx = x - dense[i].x, ry = y - dense[i].y;
|
||||||
|
float r = MathF.Sqrt(rx * rx + ry * ry);
|
||||||
|
if (r > outer) continue;
|
||||||
|
float ddx = x - craterCx, ddy = y - craterCy;
|
||||||
|
if (ddx * ddx + ddy * ddy < coreSq) continue; // crater core protected
|
||||||
|
float sea = seaAt(x, y);
|
||||||
|
float old = height[x, y];
|
||||||
|
if (old < sea) continue; // below-sea cells read-only
|
||||||
|
float target;
|
||||||
|
if (r <= hw)
|
||||||
|
{
|
||||||
|
float f = r / hw;
|
||||||
|
target = bed[i] + (depth[i] / M_PER_UNIT) * f * f;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
float f = (r - hw) / hw; // 0..1 across the shoulder
|
||||||
|
f = f * f * (3f - 2f * f); // smoothstep
|
||||||
|
target = rimH + (old - rimH) * f;
|
||||||
|
}
|
||||||
|
float floor = sea + p.SeaMarginM / M_PER_UNIT;
|
||||||
|
if (target < floor) target = floor;
|
||||||
|
if (target < old)
|
||||||
|
{
|
||||||
|
float cutM = (old - 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;
|
||||||
|
rs.VolumeM3 += cutM;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name != null) stats.Rivers.Add(rs);
|
||||||
|
return rs;
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue