diff --git a/Core/Scripts/MapDataParser.cs b/Core/Scripts/MapDataParser.cs index 1a084fe..7234950 100644 --- a/Core/Scripts/MapDataParser.cs +++ b/Core/Scripts/MapDataParser.cs @@ -36,6 +36,28 @@ namespace IslaApocalypse.Core public string GeneratorGitHash = ""; // short hash of the generator repo, "" if unknown } + /// + /// One water body from the blueprint's WBTB section: the ocean (exactly one, id 1) + /// or a lake (ids 2..N). SurfaceLevel is the documented TRANSITIONAL rule — one flat + /// level per body, GetSeaLevel at the body's pixel centroid under the still-live + /// latitude field; superseded when the flat-scalar sea model lands. Salinity is a + /// provisional placeholder for the future fresh/salt mechanic (ocean salt, lake fresh). + /// + public class WaterBodyInfo + { + public const byte TYPE_OCEAN = 0; + public const byte TYPE_LAKE = 1; + public const byte SALINITY_FRESH = 0; + public const byte SALINITY_SALT = 1; + + public ushort Id; + public byte Type; + public byte Salinity; + public float SurfaceLevel; // raw blueprint height units + public int PixelCount; + public Vector2 Centroid; // map pixels + } + public class WorldBlueprint { public int MapSize; diff --git a/Tools/Scripts/MapGenerator.cs b/Tools/Scripts/MapGenerator.cs index d0cceca..9eb05ad 100644 --- a/Tools/Scripts/MapGenerator.cs +++ b/Tools/Scripts/MapGenerator.cs @@ -29,9 +29,15 @@ public partial class MapGenerator : TextureRect private float[,] _tempMap; private Biome[,] _biomeMap; private bool[,] _isTrueOcean; - private bool[,] _isMainland; + private bool[,] _isMainland; internal List _towns = new List(); + // Water-bodies stage outputs (terrain-water task 03): 0 = no water, 1 = the + // ocean, 2..N = lakes. Filled by IdentifyWaterBodies, serialized by the v2 + // writer, consumed by nothing at runtime yet. + internal ushort[,] _waterBodyIds; + internal List _waterBodies; + internal List _highwayPaths = new List(); internal List _branchPaths = new List(); internal List _ruggedPaths = new List(); @@ -84,6 +90,10 @@ public partial class MapGenerator : TextureRect CalculateMainland(); GD.Print($"{T()} Ocean and mainland masks done."); + IdentifyWaterBodies(); + DrawWaterStageTexture(); + await CaptureStage("0_water"); + AssignBiomesAndDraw(); GD.Print($"{T()} Biomes done."); await CaptureStage("1_biomes"); @@ -441,6 +451,299 @@ public partial class MapGenerator : TextureRect } } + // ===================================================================== + // SHARED WATER PREDICATES (terrain-water task 03) + // The single source of per-pixel water truth. AssignBiomesAndDraw and + // IdentifyWaterBodies both call these; correspondence between the biome + // grid and the water-body grid holds BY CONSTRUCTION, not by parallel + // implementations agreeing. + // ===================================================================== + private bool IsWaterPixel(int x, int y) => _heightMap[x, y] < GetSeaLevel(_tempMap[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]; + + /// + /// The water-bodies stage (terrain-water task 03). Promotes the world's EXISTING + /// water into explicit data: body 1 = the ocean (all IsOceanPixel pixels), bodies + /// 2..N = lakes, connected-component labeled with the SAME 4-connectivity as + /// CalculateTrueOcean's flood fill (Up/Down/Left/Right), in deterministic scan + /// order (X outer, Y inner; a body's id is fixed by its first-encountered pixel). + /// Membership comes only from the shared predicates — this stage groups pixels, + /// it never adds or removes any. + /// + /// Each body carries ONE surface level: GetSeaLevel at the body's pixel centroid + /// (ocean: at the map centre). This is the documented TRANSITIONAL rule — see + /// BLUEPRINT_FORMAT.md (WBTB) — superseded when the flat-scalar sea model lands. + /// + private void IdentifyWaterBodies() + { + ulong t0 = Time.GetTicksMsec(); + _waterBodyIds = new ushort[MapSize, MapSize]; + _waterBodies = new List(); + + // --- Body 1: the ocean, one body, first-class --- + long oceanCount = 0; + double oceanCx = 0, oceanCy = 0; + for (int x = 0; x < MapSize; x++) + { + for (int y = 0; y < MapSize; y++) + { + if (IsOceanPixel(x, y)) + { + _waterBodyIds[x, y] = 1; + oceanCount++; + oceanCx += x; oceanCy += y; + } + } + } + Vector2 oceanCentroid = oceanCount > 0 + ? new Vector2((float)(oceanCx / oceanCount), (float)(oceanCy / oceanCount)) + : Vector2.Zero; + _waterBodies.Add(new WaterBodyInfo + { + Id = 1, + Type = WaterBodyInfo.TYPE_OCEAN, + Salinity = WaterBodyInfo.SALINITY_SALT, // provisional default + SurfaceLevel = GetSeaLevel(_tempMap[MapSize / 2, MapSize / 2]), // ocean: map centre + PixelCount = (int)oceanCount, + Centroid = oceanCentroid + }); + + // --- Bodies 2..N: lakes, 4-connected like CalculateTrueOcean --- + Vector2I[] directions = { Vector2I.Up, Vector2I.Down, Vector2I.Left, Vector2I.Right }; + Queue queue = new Queue(); + int nextId = 2; + long lakePixels = 0; + + for (int x = 0; x < MapSize; x++) + { + for (int y = 0; y < MapSize; y++) + { + if (_waterBodyIds[x, y] != 0 || !IsLakePixel(x, y)) continue; + + if (nextId > ushort.MaxValue) + { + GD.PrintErr($"[WaterBodies] ⚠⚠ More than {ushort.MaxValue - 1} water bodies — u16 id space exhausted. Remaining lakes left unlabeled."); + x = MapSize; break; + } + + ushort id = (ushort)nextId++; + long count = 0; + double cx = 0, cy = 0; + + _waterBodyIds[x, y] = id; + queue.Enqueue(new Vector2I(x, y)); + while (queue.Count > 0) + { + Vector2I current = queue.Dequeue(); + count++; cx += current.X; cy += current.Y; + foreach (var dir in directions) + { + Vector2I nb = current + dir; + if (nb.X < 0 || nb.X >= MapSize || nb.Y < 0 || nb.Y >= MapSize) continue; + if (_waterBodyIds[nb.X, nb.Y] != 0 || !IsLakePixel(nb.X, nb.Y)) continue; + _waterBodyIds[nb.X, nb.Y] = id; + queue.Enqueue(nb); + } + } + + lakePixels += count; + int centX = Mathf.Clamp((int)Mathf.Round((float)(cx / count)), 0, MapSize - 1); + int centY = Mathf.Clamp((int)Mathf.Round((float)(cy / count)), 0, MapSize - 1); + _waterBodies.Add(new WaterBodyInfo + { + Id = id, + Type = WaterBodyInfo.TYPE_LAKE, + Salinity = WaterBodyInfo.SALINITY_FRESH, // provisional default + SurfaceLevel = GetSeaLevel(_tempMap[centX, centY]), + PixelCount = (int)count, + Centroid = new Vector2((float)(cx / count), (float)(cy / count)) + }); + } + } + + double seconds = (Time.GetTicksMsec() - t0) / 1000.0; + GD.Print($"{T()} [WaterBodies] {_waterBodies.Count} bodies in {seconds:F1}s: ocean {oceanCount} px, {_waterBodies.Count - 1} lakes totalling {lakePixels} px."); + + RunPriorityFloodDiagnostics(); + } + + /// + /// Priority-flood pit-filling over the post-topography heightmap — VALIDATED + /// DIAGNOSTICS ONLY. Serializes nothing (deliberately no BSIN section: basin data + /// goes stale the moment coast smoothing changes terrain; the rivers stage + /// recomputes fresh — see BLUEPRINT_FORMAT.md). Reports closed-basin statistics + /// and asserts two invariants: (a) filled ≥ original everywhere; (b) on the filled + /// surface every pixel has a non-ascending 8-neighbour path to the map border + /// (checked in full via a reverse BFS, not a sample). + /// + private void RunPriorityFloodDiagnostics() + { + ulong t0 = Time.GetTicksMsec(); + int n = MapSize; + int total = n * n; + + // 1-D row-major copies (idx = x * n + y) for speed. + float[] original = new float[total]; + for (int x = 0; x < n; x++) + for (int y = 0; y < n; y++) + original[x * n + y] = _heightMap[x, y]; + float[] filled = (float[])original.Clone(); + + // --- Priority-flood (Barnes et al. variant: heap + plain pit queue) --- + bool[] visited = new bool[total]; + var heap = new PriorityQueue(); + var pit = new Queue(); + + void Seed(int idx) { if (!visited[idx]) { visited[idx] = true; heap.Enqueue(idx, filled[idx]); } } + for (int x = 0; x < n; x++) { Seed(x * n); Seed(x * n + (n - 1)); } + for (int y = 0; y < n; y++) { Seed(y); Seed((n - 1) * n + y); } + + while (heap.Count > 0 || pit.Count > 0) + { + int c = pit.Count > 0 ? pit.Dequeue() : heap.Dequeue(); + float fc = filled[c]; + int cx = c / n, cy = c % n; + for (int dx = -1; dx <= 1; dx++) + { + for (int dy = -1; dy <= 1; dy++) + { + if (dx == 0 && dy == 0) continue; + int nx = cx + dx, ny = cy + dy; + if (nx < 0 || nx >= n || ny < 0 || ny >= n) continue; + int ni = nx * n + ny; + if (visited[ni]) continue; + visited[ni] = true; + if (filled[ni] <= fc) { filled[ni] = fc; pit.Enqueue(ni); } + else heap.Enqueue(ni, filled[ni]); + } + } + } + double floodSeconds = (Time.GetTicksMsec() - t0) / 1000.0; + + // --- Invariant (a): filled ≥ original everywhere --- + long invariantAViolations = 0; + for (int i = 0; i < total; i++) + if (filled[i] < original[i]) invariantAViolations++; + + // --- Invariant (b): full reverse BFS from the border over non-descending + // edges; a pixel is reachable iff it has a non-ascending 8-neighbour path + // down to the border on the filled surface. --- + bool[] reachable = new bool[total]; + var bfs = new Queue(); + void SeedB(int idx) { if (!reachable[idx]) { reachable[idx] = true; bfs.Enqueue(idx); } } + for (int x = 0; x < n; x++) { SeedB(x * n); SeedB(x * n + (n - 1)); } + for (int y = 0; y < n; y++) { SeedB(y); SeedB((n - 1) * n + y); } + while (bfs.Count > 0) + { + int c = bfs.Dequeue(); + float fc = filled[c]; + int cx = c / n, cy = c % n; + for (int dx = -1; dx <= 1; dx++) + { + for (int dy = -1; dy <= 1; dy++) + { + if (dx == 0 && dy == 0) continue; + int nx = cx + dx, ny = cy + dy; + if (nx < 0 || nx >= n || ny < 0 || ny >= n) continue; + int ni = nx * n + ny; + if (reachable[ni] || filled[ni] < fc) continue; + reachable[ni] = true; + bfs.Enqueue(ni); + } + } + } + long invariantBViolations = 0; + for (int i = 0; i < total; i++) + if (!reachable[i]) invariantBViolations++; + + if (invariantAViolations > 0) + GD.PrintErr($"[PriorityFlood] ⚠⚠ INVARIANT (a) VIOLATED: {invariantAViolations} pixels have filled < original."); + if (invariantBViolations > 0) + GD.PrintErr($"[PriorityFlood] ⚠⚠ INVARIANT (b) VIOLATED: {invariantBViolations} pixels lack a non-ascending path to the border."); + + // --- Closed-basin statistics on LAND (shared predicate), 8-connected --- + long landPixels = 0, basinLandPixels = 0; + var basinAreas = new List(); + var basinDepths = new List(); + bool[] counted = new bool[total]; + var comp = new Queue(); + for (int x = 0; x < n; x++) + { + for (int y = 0; y < n; y++) + { + int i = x * n + y; + bool land = !IsWaterPixel(x, y); + if (land) landPixels++; + if (!land || counted[i] || filled[i] <= original[i]) continue; + + long area = 0; float maxDepth = 0f; + counted[i] = true; + comp.Enqueue(i); + while (comp.Count > 0) + { + int c = comp.Dequeue(); + area++; + float d = filled[c] - original[c]; + if (d > maxDepth) maxDepth = d; + int cx2 = c / n, cy2 = c % n; + for (int dx = -1; dx <= 1; dx++) + { + for (int dy = -1; dy <= 1; dy++) + { + if (dx == 0 && dy == 0) continue; + int nx = cx2 + dx, ny = cy2 + dy; + if (nx < 0 || nx >= n || ny < 0 || ny >= n) continue; + int ni = nx * n + ny; + if (counted[ni] || filled[ni] <= original[ni] || IsWaterPixel(nx, ny)) continue; + counted[ni] = true; + comp.Enqueue(ni); + } + } + } + basinLandPixels += area; + basinAreas.Add(area); + basinDepths.Add(maxDepth); + } + } + + basinAreas.Sort(); + basinDepths.Sort(); + long P(List s, double q) => s.Count == 0 ? 0 : s[Mathf.Clamp((int)(q * s.Count), 0, s.Count - 1)]; + float Pf(List s, double q) => s.Count == 0 ? 0 : s[Mathf.Clamp((int)(q * s.Count), 0, s.Count - 1)]; + + double totalSeconds = (Time.GetTicksMsec() - t0) / 1000.0; + GD.Print($"{T()} [PriorityFlood] flood {floodSeconds:F1}s, total (with invariants+stats) {totalSeconds:F1}s."); + GD.Print($"{T()} [PriorityFlood] invariants: (a) {(invariantAViolations == 0 ? "PASS" : "FAIL")}, (b) {(invariantBViolations == 0 ? "PASS" : "FAIL")} (full check, no sampling)."); + GD.Print($"{T()} [PriorityFlood] closed basins on land: {basinAreas.Count}; land px in basins {basinLandPixels}/{landPixels} ({(landPixels > 0 ? 100.0 * basinLandPixels / landPixels : 0):F1}%)."); + GD.Print($"{T()} [PriorityFlood] area px: p50 {P(basinAreas, 0.5)}, p90 {P(basinAreas, 0.9)}, max {(basinAreas.Count > 0 ? basinAreas[basinAreas.Count - 1] : 0)}; " + + $"count ≥100px {basinAreas.FindAll(a => a >= 100).Count}, ≥1000px {basinAreas.FindAll(a => a >= 1000).Count}, ≥10000px {basinAreas.FindAll(a => a >= 10000).Count}."); + GD.Print($"{T()} [PriorityFlood] max depth (raw h): p50 {Pf(basinDepths, 0.5):F4}, p90 {Pf(basinDepths, 0.9):F4}, max {(basinDepths.Count > 0 ? basinDepths[basinDepths.Count - 1] : 0):F4}; " + + $"deeper than 0.01: {basinDepths.FindAll(d => d > 0.01f).Count}, deeper than 0.05: {basinDepths.FindAll(d => d > 0.05f).Count}."); + } + + /// + /// Paints the water-stage snapshot from the stage's own outputs (the biome grid + /// does not exist yet at this point in the pipeline): ocean deep blue, lakes a + /// distinct lighter blue, land neutral grey. + /// + private void DrawWaterStageTexture() + { + Image img = Image.CreateEmpty(MapSize, MapSize, false, Image.Format.Rgba8); + Color land = new Color(0.45f, 0.45f, 0.42f); + Color ocean = new Color(0.05f, 0.2f, 0.45f); + Color lake = new Color(0.35f, 0.7f, 0.9f); + for (int x = 0; x < MapSize; x++) + { + for (int y = 0; y < MapSize; y++) + { + ushort id = _waterBodyIds[x, y]; + img.SetPixel(x, y, id == 0 ? land : (id == 1 ? ocean : lake)); + } + } + Texture = ImageTexture.CreateFromImage(img); + } + private void AssignBiomesAndDraw() { Image mapImage = Image.CreateEmpty(MapSize, MapSize, false, Image.Format.Rgba8); @@ -451,10 +754,15 @@ public partial class MapGenerator : TextureRect float h = _heightMap[x, y]; float t = _tempMap[x, y]; Biome b; - + float currentSeaLevel = GetSeaLevel(t); - if (h < currentSeaLevel) b = _isTrueOcean[x, y] ? Biome.Ocean : Biome.Lake; + // Water classification comes from the SHARED predicates (task 03), so the + // water-bodies stage and the biome classifier cannot disagree. Same rules + // as before, verbatim: below local sea level -> Ocean if true-ocean + // connected, else Lake. + if (IsOceanPixel(x, y)) b = Biome.Ocean; + else if (IsLakePixel(x, y)) b = Biome.Lake; else { float baseDist = new Vector2(x, y).DistanceTo(_impactCenter);