using Godot; using System.Collections.Generic; using System.IO; using System.Threading.Tasks; using IslaApocalypse.Core; public partial class MapGenerator : TextureRect { public int MapSize = 4096; [Export] public float FalloffStrength = 1.0f; // [Export] adds to the "inspector" in Godot so we can easily tweak this value! public struct TownData { public Vector2 Position; public TownTier Tier; public bool IsHighwayNode; } // When generation began, in engine milliseconds. Every progress print is stamped with // the time since this, so the cost of each stage is readable straight off the console. private ulong _pipelineStartMs; private FastNoiseLite _noise; // The one and only original detailed noise! private Vector2 _impactCenter; private float _impactRadius; private float[,] _heightMap; // Classification heightmap (task 05): the UNCURVED heights (plus the crater // carve), i.e. exactly what the curve-off pipeline produces. Biome rules, the // two flood fills, and the shared water predicates read THIS map, so biome and // water output is identical with the curve on or off — the bit-identical-biomes // oracle holds by construction. Towns, roads, diagnostics, and the exported // heights use the curved _heightMap (they live in the 3D world). When the curve // is off this is the SAME array as _heightMap (aliased, no copy). private float[,] _heightMapClassify; private bool _curveOn; // Curve-v4 shelf-modulation fields (task 08): two decorrelated elevation fields // (bench 100±12 m, plateau 220±20 m) plus a strength field blending each shelf // between pronounced-flat and barely-a-hint. Seeds derive from the RESOLVED // noise seed + fixed offsets (no config knob); frequencies scale with MapSize. // Sampled per column in pass 2 only — the classify path never sees them. private FastNoiseLite _benchNoise; private FastNoiseLite _plateauNoise; private FastNoiseLite _strengthNoise; // The seed's raw pre-curve height maximum (post noise/falloff/Trench/spine, // pre-carve) — the v2 curve's per-seed spike normalizer. Computed in // GenerateTopography pass 1; recorded in TCRV (effective, guard applied). private float _hMaxSeed = float.MinValue; private float[,] _tempMap; private Biome[,] _biomeMap; private bool[,] _isTrueOcean; 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(); internal List _trailPaths = new List(); public override async void _Ready() { _pipelineStartMs = Time.GetTicksMsec(); // 1. LOAD CONFIGURATION ConfigManager.LoadConfig(); MapSize = ConfigManager.MapSize; _impactRadius = ConfigManager.CraterRadius; // LOCK THE GLOBAL RNG TO OUR SEED! GD.Seed((ulong)ConfigManager.WorldSeed); // 2. OVERRIDE GODOT INSPECTOR this.CustomMinimumSize = new Vector2(MapSize, MapSize); _heightMap = new float[MapSize, MapSize]; _curveOn = ConfigManager.TerrainCurve == "v4"; // (The monotonicity assertion now runs inside GenerateTopography, against the // effective per-seed curve, once hMaxSeed is known.) _heightMapClassify = _curveOn ? new float[MapSize, MapSize] : _heightMap; _tempMap = new float[MapSize, MapSize]; _biomeMap = new Biome[MapSize, MapSize]; _isTrueOcean = new bool[MapSize, MapSize]; _isMainland = new bool[MapSize, MapSize]; float scaleFactor = MapSize / 1024f; // Equals 4 // --- MASTER SEED & DETAILED NOISE (Restored!) --- _noise = new FastNoiseLite(); _noise.Seed = ConfigManager.WorldSeed == 0 ? (int)GD.Randi() : ConfigManager.WorldSeed; _noise.NoiseType = FastNoiseLite.NoiseTypeEnum.Simplex; _noise.Frequency = 0.004f / scaleFactor; if (_curveOn) { _benchNoise = MakeModulationNoise(HeightCurve.BENCH_SEED_OFFSET, HeightCurve.ELEV_FREQ_ISLANDS); _plateauNoise = MakeModulationNoise(HeightCurve.PLATEAU_SEED_OFFSET, HeightCurve.ELEV_FREQ_ISLANDS); _strengthNoise = MakeModulationNoise(HeightCurve.STRENGTH_SEED_OFFSET, HeightCurve.STRENGTH_FREQ_ISLANDS); } // THE CRATER FIX: Push it into the ocean (scales via percentage of MapSize!) // Supposedly! We will have to test this manually on other map sizes to confirm the crater is properly scaled and submerged on the north coast! float randomX = (float)GD.RandRange(0.35f, 0.65f); float randomY = (float)GD.RandRange(0.05f, 0.12f); _impactCenter = new Vector2(MapSize * randomX, MapSize * randomY); // --- THE GENERATION PIPELINE --- // Each stage prints its elapsed time and drops a snapshot, so you can watch the // world appear in layers instead of waiting for the whole run to judge any of it. // A future water stage slots in as another CaptureStage call at its own boundary. GenerateTopography(); GD.Print($"{T()} Topography done (height + temperature). TerrainCurve: {(_curveOn ? "v1" : "off")}."); DrawHeightStageTexture(); await CaptureStage("0_height"); CalculateTrueOcean(); 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"); GenerateTowns(); GD.Print($"{T()} Towns placed: {_towns.Count}."); await CaptureStage("2_towns"); if (ConfigManager.SkipRoads) { // Iteration toggle (terrain-water task 03): the road pass is ~25 min of a // ~26-min generation. Skipping it leaves all four road lists empty, so the // export writes present-but-empty road sections. GD.Print("=========================================================="); GD.Print($"{T()} ⚠⚠ SKIPROADS IS ON — ROAD GENERATION SKIPPED ENTIRELY."); GD.Print($"{T()} ⚠⚠ This blueprint has NO ROADS. It is an ITERATION"); GD.Print($"{T()} ⚠⚠ ARTIFACT, not a world. Do not judge or ship it."); GD.Print("=========================================================="); } else { // NEW: We await the roads so the engine doesn't freeze! await GenerateRoadsAsync(); } ExportMapData(); GD.Print($"{T()} Blueprint exported."); if (!ConfigManager.SkipRoads) await CaptureStage("3_roads"); GD.Print($"{T()} GENERATION COMPLETE."); } /// /// Elapsed time since generation started, e.g. "[+12.4s]". Prefixed onto the progress /// prints so the cost of each stage can be read straight off the console — no stopwatch. /// private string T() { double seconds = (Time.GetTicksMsec() - _pipelineStartMs) / 1000.0; return $"[+{seconds,6:F1}s]"; } /// /// Saves a PNG of the map AS IT STANDS RIGHT NOW, tagged with a stage label. /// /// Call this at any pipeline boundary. It draws whatever exists at that moment — after /// biomes you get bare terrain, after towns the town markers appear, after roads the /// road network does. Nothing needs to be passed in or turned on; the draw proxy just /// renders the lists as they currently are, so a future stage (water, for instance) /// shows up automatically once it populates its data. /// /// ⚠ DO NOT "SIMPLIFY" THE WAITS BELOW. They look like superstition and are not — each /// one is there because removing it produced blank or half-drawn images. The design /// vault records why (01_Design/tooling_notes.md). Symptoms of getting this wrong are /// postage-stamp, black, or road-less captures. /// private async Task CaptureStage(string label) { // 1. Yield one frame. Godot locks the scene tree while it is processing, and we are // about to add nodes to it. Without this, the AddChild below is unsafe. await ToSignal(GetTree(), "process_frame"); GD.Print($"{T()} Capturing snapshot: {label}..."); // 2. Build an invisible, offscreen "monitor" at the map's real size. It must be a // SubViewport we size ourselves — rendering through the scene's UI layout makes // Godot shrink the image to fit the physical screen. var offscreenVP = new SubViewport(); offscreenVP.Size = new Vector2I(MapSize, MapSize); offscreenVP.RenderTargetUpdateMode = SubViewport.UpdateMode.Always; offscreenVP.TransparentBg = false; GetTree().Root.AddChild(offscreenVP); // The tree is now unlocked, so this is safe! // 3. Stamp the raw terrain texture onto it var bgRect = new TextureRect(); bgRect.Texture = this.Texture; bgRect.CustomMinimumSize = new Vector2(MapSize, MapSize); offscreenVP.AddChild(bgRect); // 4. Attach the proxy to draw the roads and towns on top. Reading the viewport's // texture alone would miss them — _Draw() output is not part of the base texture. var drawProxy = new MapDrawProxy(); drawProxy.Source = this; drawProxy.CustomMinimumSize = new Vector2(MapSize, MapSize); offscreenVP.AddChild(drawProxy); // 5. WAIT FOR THE GPU! Issuing draw calls is not the same as having drawn. // Two frames — one proved insufficient in practice. await ToSignal(RenderingServer.Singleton, RenderingServer.SignalName.FramePostDraw); await ToSignal(RenderingServer.Singleton, RenderingServer.SignalName.FramePostDraw); // 6. Pull the image from the invisible monitor and save it! Image capture = offscreenVP.GetTexture().GetImage(); string seedStr = _noise.Seed.ToString(); string fileName = $"user://Map_Seed_{seedStr}_{label}.png"; Error saveResult = capture.SavePng(fileName); if (saveResult == Error.Ok) GD.Print($"{T()} Map saved: {ProjectSettings.GlobalizePath(fileName)}"); else GD.PrintErr($"{T()} Failed to save map. Godot Error code: {saveResult}"); // 7. Delete the invisible monitor to free up RAM offscreenVP.QueueFree(); } private void ExportMapData() { string seedStr = _noise.Seed.ToString(); // PRIMARY: the v2 tagged-section container (Core/Scripts/BLUEPRINT_FORMAT.md), // under the name the server looks for. string v2Path = ProjectSettings.GlobalizePath($"user://MapData_Seed_{seedStr}.dat"); BlueprintWriter.WriteV2(v2Path, BuildBlueprint()); // SAFETY NET: the legacy v1 format beside it, until the developer has lived // with v2 across several regenerations. Removal is a future task. ExportMapDataV1(ProjectSettings.GlobalizePath($"user://MapData_Seed_{seedStr}_v1.dat")); } /// /// Packages the generator's internal arrays as a WorldBlueprint (plus the resolved /// generation params) for BlueprintWriter. Arrays are shared, not copied. /// private WorldBlueprint BuildBlueprint() { var blueprint = new WorldBlueprint { MapSize = MapSize, HeightMap = _heightMap, BiomeMap = _biomeMap, Highways = _highwayPaths, BranchRoads = _branchPaths, RuggedRoads = _ruggedPaths, TrailRoads = _trailPaths, WaterBodyIds = _waterBodyIds, WaterBodies = _waterBodies ?? new List(), // TCRV under curve v4: knot slots carry K1..K4 (K5/K6 are version constants); // the bench slots carry the two BASE anchors; the v4 extension record carries // the modulation parameters (amplitudes, spans, frequencies, seed offsets) — // the blueprint stays self-describing. TerrainCurve = _curveOn ? new TerrainCurveInfo { Version = HeightCurve.VERSION, T1 = HeightCurve.K1, T2 = HeightCurve.K2, T3 = HeightCurve.K3, T4 = HeightCurve.K4, SpikeMax = HeightCurve.EffectiveSpikeMax(_hMaxSeed), // per-seed Sea = HeightCurve.SEA, OrangeCeil = HeightCurve.ORANGE_CEIL, RedCeil = HeightCurve.RED_CEIL, PlateauLo = HeightCurve.BENCH_BASE, PlateauHi = HeightCurve.PLATEAU_BASE, PeakCap = HeightCurve.PEAK_CAP, TailSlope = HeightCurve.TAIL_SLOPE, BenchAmp = HeightCurve.BENCH_AMP, PlateauAmp = HeightCurve.PLATEAU_AMP, ShelfSpanMin = HeightCurve.SHELF_SPAN_MIN, ShelfSpanMax = HeightCurve.SHELF_SPAN_MAX, ElevFreqIslands = HeightCurve.ELEV_FREQ_ISLANDS, StrengthFreqIslands = HeightCurve.STRENGTH_FREQ_ISLANDS, BenchSeedOffset = HeightCurve.BENCH_SEED_OFFSET, PlateauSeedOffset = HeightCurve.PLATEAU_SEED_OFFSET, StrengthSeedOffset = HeightCurve.STRENGTH_SEED_OFFSET } : null, FormatVersion = 2, Params = new BlueprintParams { WorldSeed = _noise.Seed, // the RESOLVED seed — also names the file MapSize = MapSize, CraterRadius = _impactRadius, DensityMultiplier = ConfigManager.DensityMultiplier, ImpactCenter = _impactCenter } }; foreach (var town in _towns) { blueprint.Towns.Add(new TownLocation { Position = town.Position, Tier = town.Tier, IsHighwayNode = town.IsHighwayNode }); } return blueprint; } // =============================================================== // LEGACY v1 WRITER — the original "ISLA_V1" positional format. // Kept intact as the dual-write safety net; removal is a future // task. Do not extend it — new content belongs in the v2 writer. // =============================================================== private void ExportMapDataV1(string absolutePath) { using (FileStream stream = File.Open(absolutePath, FileMode.Create)) { using (BinaryWriter writer = new BinaryWriter(stream)) { // 1. Header & Versioning writer.Write("ISLA_V1"); writer.Write(MapSize); // 2. RAW DATA (Height & Temp) for (int x = 0; x < MapSize; x++) { for (int y = 0; y < MapSize; y++) { writer.Write(_heightMap[x, y]); writer.Write((int)_biomeMap[x, y]); } } // 3. LOGISTICAL ANCHORS (Towns & POIs) writer.Write(_towns.Count); foreach (var town in _towns) { writer.Write(town.Position.X); writer.Write(town.Position.Y); writer.Write((int)town.Tier); } // 4. ROAD NETWORKS (Exported by Tier for Voxel Painting!) // Highway (Paved) writer.Write(_highwayPaths.Count); foreach (var path in _highwayPaths) { writer.Write(path.Length); foreach (Vector2 p in path) { writer.Write(p.X); writer.Write(p.Y); } } // Branch (Paved/Gravel) writer.Write(_branchPaths.Count); foreach (var path in _branchPaths) { writer.Write(path.Length); foreach (Vector2 p in path) { writer.Write(p.X); writer.Write(p.Y); } } // Rugged (Dirt) writer.Write(_ruggedPaths.Count); foreach (var path in _ruggedPaths) { writer.Write(path.Length); foreach (Vector2 p in path) { writer.Write(p.X); writer.Write(p.Y); } } // Trails (Faded Dirt) writer.Write(_trailPaths.Count); foreach (var path in _trailPaths) { writer.Write(path.Length); foreach (Vector2 p in path) { writer.Write(p.X); writer.Write(p.Y); } } } } GD.Print("Legacy v1 Binary Data Exported to: " + absolutePath); } // --- THE ORIGINAL MAP GENERATION CODE (Restored!) --- private void GenerateTopography() { Vector2 center = new Vector2(MapSize / 2.0f, MapSize / 2.0f); for (int x = 0; x < MapSize; x++) { for (int y = 0; y < MapSize; y++) { // --- 1. TEMPERATURE --- float temperature = (float)y / MapSize; float tempNoise = (_noise.GetNoise2D(x + 1000, y + 1000) + 1.0f) / 2.0f; temperature += (tempNoise * 0.2f) - 0.1f; _tempMap[x, y] = temperature; // --- 2. THE ORIGINAL ISLAND FALLOFF --- float nx = Mathf.Abs(x - center.X) / (MapSize / 2.0f * 1.15f); float ny = Mathf.Abs(y - center.Y) / (MapSize / 2.0f * 0.90f); float squircleFalloff = Mathf.Max(nx, ny); Vector2 ellipticalPos = new Vector2((x - center.X) / 1.15f, (y - center.Y) / 0.90f); float ellipticalFalloff = ellipticalPos.Length() / (MapSize / 1.3f); float finalFalloff = Mathf.Lerp(ellipticalFalloff, squircleFalloff, 0.5f); // The beautiful jagged edge noise! float edgeNoise = (_noise.GetNoise2D(x * 2.5f, y * 2.5f) + 1.0f) / 2.0f; finalFalloff += (edgeNoise * 0.15f) * squircleFalloff; // --- NEW: THE SOUTHERN ARCHIPELAGO FIX --- // If we are in the bottom 25% of the map, apply extra sinking pressure float southThreshold = MapSize * 0.75f; if (y > southThreshold) { float southDepth = (y - southThreshold) / (MapSize - southThreshold); finalFalloff += southDepth * 0.6f; // Sink the stretched land bridges! } finalFalloff = Mathf.Pow(finalFalloff, 2.5f); float distX = Mathf.Abs(x - center.X) / (MapSize / 2.0f); float distY = Mathf.Abs(y - center.Y) / (MapSize / 2.0f); if (distX > 0.90f) finalFalloff += (distX - 0.90f) * 15.0f; if (distY > 0.90f) finalFalloff += (distY - 0.90f) * 15.0f; // --- 3. THE MOUNTAIN SPINE --- float mountainSpine = 0f; if (temperature < 0.65f) { float distanceToCenterX = Mathf.Abs(x - center.X) / (MapSize / 2.0f * 1.15f); mountainSpine = 1.0f - distanceToCenterX; float southernFade = Mathf.Clamp((0.65f - temperature) * 4.0f, 0.0f, 1.0f); mountainSpine = Mathf.Pow(mountainSpine, 3.0f) * southernFade * 0.6f; } // --- 4. COMBINE HEIGHT --- // PASS 1 stores the RAW pre-curve height and tracks the seed maximum; // the curve (which is per-seed in v2 — its spike normalizes against // hMaxSeed) and the crater carve are applied in PASS 2 below. float rawBase = (_noise.GetNoise2D(x, y) + 1.0f) / 2.0f; float finalH = rawBase + mountainSpine - (finalFalloff * FalloffStrength); if (finalH > _hMaxSeed) _hMaxSeed = finalH; _heightMap[x, y] = finalH; } } // The v2 curve is SEED-DEPENDENT: its spike maps [t4, hMaxSeed] onto the peak // band, so the monotonicity assertion must run against the EFFECTIVE per-seed // curve — after hMaxSeed is known, before any pixel is curved. if (_curveOn) HeightCurve.AssertMonotonic(_hMaxSeed); // --- PASS 2: curve (task 05/06) + crater carve --- // Curve applied AFTER noise + falloff + Trench, BEFORE the crater carve, so // the carve cuts into curved terrain and the rim/bowl shape is untouched by // the curve. Identity at and below sea + this ordering preserve the // Trench/ocean-border guarantee and the crater by construction. classifyH // stays uncurved — see _heightMapClassify; hMaxSeed never touches it. float physicalCraterRadius = _impactRadius * 0.80f; for (int x = 0; x < MapSize; x++) { for (int y = 0; y < MapSize; y++) { float raw = _heightMap[x, y]; float classifyH = raw; float curvedH; if (_curveOn) { // v4: per-column shelf modulation — anchors and strength from the // low-frequency fields; ordering safety by construction (amplitudes // bounded; asserted at all 8 field-extreme corners per generation). float benchLo = HeightCurve.BENCH_BASE + _benchNoise.GetNoise2D(x, y) * HeightCurve.BENCH_AMP; float plateauLo = HeightCurve.PLATEAU_BASE + _plateauNoise.GetNoise2D(x, y) * HeightCurve.PLATEAU_AMP; float shelfSpan = HeightCurve.ShelfSpan((_strengthNoise.GetNoise2D(x, y) + 1f) * 0.5f); curvedH = HeightCurve.Apply(raw, _hMaxSeed, benchLo, shelfSpan, plateauLo, shelfSpan); } else { curvedH = raw; } // --- 5. CARVE THE CRATER (The Flooded Bay & Landbridge Fix!) --- float distToCrater = new Vector2(x, y).DistanceTo(_impactCenter); // We only carve the physical hole at 80% of the radius to guarantee a landbridge! if (distToCrater < physicalCraterRadius) { float craterDepth = 1.0f - (distToCrater / physicalCraterRadius); // Dialed back to -0.15f as per your excellent instinct! float carveTarget = GetSeaLevel(_tempMap[x, y]) - 0.15f; classifyH = Mathf.Lerp(classifyH, carveTarget, craterDepth * 0.9f); curvedH = Mathf.Lerp(curvedH, carveTarget, craterDepth * 0.9f); } _heightMapClassify[x, y] = classifyH; _heightMap[x, y] = curvedH; } } } private void CalculateTrueOcean() { Queue queue = new Queue(); queue.Enqueue(new Vector2I(0, 0)); _isTrueOcean[0, 0] = true; Vector2I[] directions = { Vector2I.Up, Vector2I.Down, Vector2I.Left, Vector2I.Right }; while (queue.Count > 0) { Vector2I current = queue.Dequeue(); foreach (var dir in directions) { Vector2I neighbor = current + dir; if (neighbor.X >= 0 && neighbor.X < MapSize && neighbor.Y >= 0 && neighbor.Y < MapSize) { if (!_isTrueOcean[neighbor.X, neighbor.Y] && _heightMapClassify[neighbor.X, neighbor.Y] < GetSeaLevel(_tempMap[neighbor.X, neighbor.Y])) { _isTrueOcean[neighbor.X, neighbor.Y] = true; queue.Enqueue(neighbor); } } } } } private void CalculateMainland() { Queue queue = new Queue(); Vector2I center = new Vector2I(MapSize / 2, MapSize / 2); if (_heightMapClassify[center.X, center.Y] >= GetSeaLevel(_tempMap[center.X, center.Y])) { queue.Enqueue(center); _isMainland[center.X, center.Y] = true; } Vector2I[] directions = { Vector2I.Up, Vector2I.Down, Vector2I.Left, Vector2I.Right }; while (queue.Count > 0) { Vector2I current = queue.Dequeue(); foreach (var dir in directions) { Vector2I neighbor = current + dir; if (neighbor.X >= 0 && neighbor.X < MapSize && neighbor.Y >= 0 && neighbor.Y < MapSize) { if (!_isMainland[neighbor.X, neighbor.Y] && _heightMapClassify[neighbor.X, neighbor.Y] >= GetSeaLevel(_tempMap[neighbor.X, neighbor.Y])) { _isMainland[neighbor.X, neighbor.Y] = true; queue.Enqueue(neighbor); } } } } } // ===================================================================== // 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 FastNoiseLite MakeModulationNoise(int seedOffset, float periodsPerIsland) { var n = new FastNoiseLite(); n.Seed = _noise.Seed + seedOffset; // deterministic from the RESOLVED seed n.NoiseType = FastNoiseLite.NoiseTypeEnum.Simplex; n.Frequency = periodsPerIsland / MapSize; // frequency stated in island-widths return n; } private bool IsWaterPixel(int x, int y) => _heightMapClassify[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 height-stage snapshot (task 05): hypsometric tint by storm-ladder /// band × Lambert hillshade from the height gradient (NW light), over the CURVED /// heights — this is the snapshot that makes relief visible. Runs in both curve /// modes; with the curve off it shows the legacy profile under the same bands. /// Pure numeric pass over the heightmap. /// private void DrawHeightStageTexture() { Image img = Image.CreateEmpty(MapSize, MapSize, false, Image.Format.Rgba8); Vector3 light = new Vector3(-0.55f, -0.55f, 0.63f).Normalized(); // NW, ~39° up Color deepSea = new Color(0.07f, 0.15f, 0.32f); Color shallowSea = new Color(0.25f, 0.45f, 0.65f); Color green = new Color(0.44f, 0.62f, 0.36f); // orange band terrain: lowland green Color tan = new Color(0.76f, 0.70f, 0.46f); // red band: tan Color brown = new Color(0.55f, 0.41f, 0.28f); // foothill riser + bench: brown Color darkBrown = new Color(0.42f, 0.32f, 0.24f); // mid riser: darker brown Color white = new Color(0.97f, 0.97f, 0.98f); // peaks Color grey = new Color(0.72f, 0.72f, 0.70f); int n = MapSize; for (int x = 0; x < n; x++) { for (int y = 0; y < n; y++) { float h = _heightMap[x, y]; float sea = GetSeaLevel(_tempMap[x, y]); Color tint; if (h < sea) { float depth = Mathf.Clamp((sea - h) / 0.5f, 0f, 1f); tint = shallowSea.Lerp(deepSea, depth); } else if (h < HeightCurve.ORANGE_CEIL) tint = green; else if (h < HeightCurve.RED_CEIL) tint = tan; // v4 note: shelf anchors are spatially modulated, so these tint bands // use the base±amplitude envelopes — approximate banding, visualization only. else if (h < HeightCurve.BENCH_BASE + HeightCurve.BENCH_AMP) tint = brown; // riser + bench envelope else if (h < HeightCurve.PLATEAU_BASE - HeightCurve.PLATEAU_AMP) tint = darkBrown; // mid riser else { // Plateau envelope through the summit spike to the 420 m cap. float t2 = Mathf.Clamp((h - (HeightCurve.PLATEAU_BASE - HeightCurve.PLATEAU_AMP)) / (HeightCurve.PEAK_CAP - HeightCurve.PLATEAU_BASE + HeightCurve.PLATEAU_AMP), 0f, 1f); tint = grey.Lerp(white, t2); } // Lambert hillshade on the world-scale gradient (1 px = 1 m, height ×251 m). int xm = x > 0 ? x - 1 : x, xp = x < n - 1 ? x + 1 : x; int ym = y > 0 ? y - 1 : y, yp = y < n - 1 ? y + 1 : y; float gx = (_heightMap[xp, y] - _heightMap[xm, y]) * 251f / (xp - xm == 0 ? 1 : xp - xm); float gy = (_heightMap[x, yp] - _heightMap[x, ym]) * 251f / (yp - ym == 0 ? 1 : yp - ym); Vector3 nrm = new Vector3(-gx, -gy, 1f).Normalized(); float shade = Mathf.Clamp(nrm.Dot(light), 0f, 1f); Color c = tint * (0.35f + 0.65f * shade); c.A = 1f; img.SetPixel(x, y, c); } } Texture = ImageTexture.CreateFromImage(img); } /// /// 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); for (int x = 0; x < MapSize; x++) { for (int y = 0; y < MapSize; y++) { // Biome rules classify against the UNCURVED heights (task 05) — the // bit-identical-biomes oracle. Beach/mountain/snow bands and the // water split all read the classify map. float h = _heightMapClassify[x, y]; float t = _tempMap[x, y]; Biome b; float currentSeaLevel = GetSeaLevel(t); // 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); float craterNoise = _noise.GetNoise2D(x * 2.5f, y * 2.5f) * 25.0f; float warpedDist = baseDist + craterNoise; if (warpedDist < _impactRadius * 0.5f) b = Biome.Crater; else { float beachThickness = Mathf.Lerp(0.04f, 0.10f, Mathf.Clamp(t, 0f, 1f)); if (h < currentSeaLevel + beachThickness) b = Biome.Beach; else { float snowDetail = (_noise.GetNoise2D(x * 5.0f, y * 5.0f) + 1.0f) / 2.0f; float wastelandFray = _noise.GetNoise2D(x * 1.5f + 5000, y * 1.5f + 5000) * 60.0f; // Add a separate, jagged noise layer just for the wasteland boundary if (h > 0.88f || (h > 0.80f && snowDetail > 0.6f)) b = Biome.Snow; else if (h > 0.72f) b = Biome.Mountain; else if (warpedDist < _impactRadius + wastelandFray + 20.0f) b = Biome.Wasteland; else if (warpedDist < _impactRadius * 1.35f || t < 0.35f) b = Biome.Jungle; else if (t > 0.65f) b = Biome.Paradise; else b = Biome.Tropical; } } } _biomeMap[x, y] = b; Color c = Colors.Black; switch(b) { case Biome.Ocean: c = new Color(0.1f, 0.4f, 0.7f); break; case Biome.Lake: c = new Color(0.2f, 0.6f, 0.8f); break; case Biome.Beach: c = new Color(0.8f, 0.7f, 0.4f); break; case Biome.Snow: c = new Color(0.9f, 0.9f, 0.9f); break; case Biome.Mountain: c = new Color(0.5f, 0.5f, 0.5f); break; case Biome.Crater: c = new Color(0.3f, 0.1f, 0.3f); break; case Biome.Wasteland: c = new Color(0.5f, 0.2f, 0.5f); break; case Biome.Jungle: c = new Color(0.1f, 0.4f, 0.1f); break; case Biome.Paradise: c = new Color(0.4f, 0.8f, 0.4f); break; case Biome.Tropical: c = new Color(0.2f, 0.6f, 0.2f); break; } mapImage.SetPixel(x, y, c); } } Texture = ImageTexture.CreateFromImage(mapImage); } private void GenerateTowns() { _towns.Clear(); // 1. Calculate Multipliers (Used ONLY for lower-tier towns now!) float sizeRatio = MapSize / 4096f; float areaMulti = Mathf.Max(1.0f, sizeRatio * sizeRatio); float densityMulti = ConfigManager.DensityMultiplier; int Calc(int baseCount) { return Mathf.Max(1, Mathf.RoundToInt(baseCount * areaMulti * densityMulti)); } // --- 2. THE HIGHWAY ANCHORS (Strictly 1 per biome, ignoring multipliers) --- PlaceCapitol(); // Capitol is automatically a Highway Node bool southIsLeft = GD.Randi() % 2 == 0; MapHalf southHalf = southIsLeft ? MapHalf.Left : MapHalf.Right; MapHalf midHalf = southIsLeft ? MapHalf.Right : MapHalf.Left; // Spawn exactly 1 Hub per green biome PlaceTownNodes(1, TownTier.Hub, Biome.Paradise, false, true, true, 0.04f, southHalf); PlaceTownNodes(1, TownTier.Hub, Biome.Tropical, false, true, true, 0.04f, midHalf, true, false, 0.40f, 0.60f); PlaceTownNodes(1, TownTier.Hub, Biome.Jungle, true, false, false, 0.04f, MapHalf.Any); // Safety check: Force all currently spawned Hubs to be active Highway Nodes for (int i = 0; i < _towns.Count; i++) { var t = _towns[i]; t.IsHighwayNode = true; _towns[i] = t; } // --- 3. THE MOUNTAIN BOSS (Strictly 1, Center Biased, NOT a Highway Node) --- int beforeSnowCount = _towns.Count; // We use `true` for the 9th parameter (centerBias) to push it deep into the frozen mountains PlaceTownNodes(1, TownTier.Hub, Biome.Snow, true, false, false, 0.15f, MapHalf.Any, true); // Safety check: Force the Snow Hub to be excluded from the Highway Loop for (int i = beforeSnowCount; i < _towns.Count; i++) { var t = _towns[i]; t.IsHighwayNode = false; _towns[i] = t; } // --- 4. THE RUGGED NETWORK & POIs (Scaled by Config Multipliers) --- PlaceTownNodes(Calc(1), TownTier.Village, Biome.Paradise, false, true, false, 0.04f, MapHalf.Any); PlaceTownNodes(Calc(2), TownTier.Village, Biome.Paradise, true, false, false, 0.04f, MapHalf.Any); PlaceTownNodes(Calc(1), TownTier.Village, Biome.Tropical, false, true, false, 0.04f, MapHalf.Any); PlaceTownNodes(Calc(3), TownTier.Village, Biome.Tropical, true, false, false, 0.04f, MapHalf.Any); PlaceTownNodes(Calc(2), TownTier.Village, Biome.Jungle, true, false, false, 0.05f, MapHalf.Any); PlaceTownNodes(Calc(1), TownTier.Village, Biome.Snow, true, false, false, 0.05f, MapHalf.Any); PlaceTownNodes(Calc(2), TownTier.Outpost, Biome.Snow, true, false, false, 0.12f, MapHalf.Any); PlaceTownNodes(Calc(1), TownTier.Outpost, Biome.Mountain, true, false, false, 0.15f, MapHalf.Any); PlaceTownNodes(Calc(1), TownTier.IslandLoot, Biome.Paradise, false, false, false, 0.08f, MapHalf.Any, false, true); PlaceTownNodes(Calc(1), TownTier.IslandLoot, Biome.Tropical, false, false, false, 0.08f, MapHalf.Any, false, true); PlaceTownNodes(Calc(3), TownTier.MiniPOI, Biome.Jungle, true, false, false, 0.1f, MapHalf.Any); PlaceTownNodes(Calc(3), TownTier.MiniPOI, Biome.Tropical, true, false, false, 0.1f, MapHalf.Any); PlaceTownNodes(Calc(2), TownTier.MiniPOI, Biome.Paradise, true, false, false, 0.1f, MapHalf.Any); QueueRedraw(); } private void PlaceCapitol() // This method is a bit more complex than the others because of the unique requirements for the Capitol's location. We want it to be in the Wasteland biome, on the coast (but not too high elevation), and safely outside the blast radius. To achieve this, we'll use a tiered approach with multiple fallback strategies. { bool TestCandidate(int cx, int cy, bool requireCoastal) { if (!_isMainland[cx, cy]) return false; // MUST be mainland float localSeaLevel = GetSeaLevel(_tempMap[cx, cy]); if (_heightMap[cx, cy] < localSeaLevel) return false; // Must be land if (_biomeMap[cx, cy] != Biome.Wasteland) return false; // Must be Wasteland if (requireCoastal && _heightMap[cx, cy] >= localSeaLevel + 0.15f) return false; return true; } float minDist = _impactRadius + 30f; float maxDist = _impactRadius + 150f; // TIER 1: Ideal (Wasteland + Coastal + Mainland) for (int i = 0; i < 5000; i++) { float angle = (float)GD.RandRange(0, Mathf.Tau); float dist = (float)GD.RandRange(minDist, maxDist); Vector2 pos = _impactCenter + new Vector2(Mathf.Cos(angle), Mathf.Sin(angle)) * dist; int cx = Mathf.Clamp((int)pos.X, 0, MapSize - 1); int cy = Mathf.Clamp((int)pos.Y, 0, MapSize - 1); if (TestCandidate(cx, cy, requireCoastal: true)) { CommitCapitol(cx, cy); return; } } // TIER 2: Relax coastal requirement for (int i = 0; i < 10000; i++) { float angle = (float)GD.RandRange(0, Mathf.Tau); float dist = (float)GD.RandRange(minDist, _impactRadius + 400f); Vector2 pos = _impactCenter + new Vector2(Mathf.Cos(angle), Mathf.Sin(angle)) * dist; int cx = Mathf.Clamp((int)pos.X, 0, MapSize - 1); int cy = Mathf.Clamp((int)pos.Y, 0, MapSize - 1); if (TestCandidate(cx, cy, requireCoastal: false)) { CommitCapitol(cx, cy); return; } } // TIER 3: Deterministic Scan (Find closest valid pixel) GD.PrintErr("Capitol: Random tiers failed. Running deterministic wasteland scan..."); var candidates = new List<(int x, int y, float dist)>(); for (int x = 0; x < MapSize; x++) { for (int y = 0; y < MapSize; y++) { if (_isMainland[x, y] && _biomeMap[x, y] == Biome.Wasteland) { candidates.Add((x, y, new Vector2(x, y).DistanceTo(_impactCenter))); } } } if (candidates.Count > 0) { candidates.Sort((a, b) => a.dist.CompareTo(b.dist)); int pickIdx = (int)GD.RandRange(0, Mathf.Max(1, candidates.Count / 20)); CommitCapitol(candidates[pickIdx].x, candidates[pickIdx].y); return; } // TIER 4: Doomsday (Walk south until we hit mainland) GD.PrintErr("Capitol: No mainland wasteland found! Placing at fallback offset."); int fx = Mathf.Clamp((int)_impactCenter.X, 0, MapSize - 1); int fy = Mathf.Clamp((int)(_impactCenter.Y + _impactRadius + 50f), 0, MapSize - 1); while (fy < MapSize - 1 && !_isMainland[fx, fy]) fy++; CommitCapitol(fx, fy); } private void CommitCapitol(int cx, int cy) { _towns.Add(new TownData { Position = new Vector2(cx, cy), Tier = TownTier.Capitol, IsHighwayNode = true }); GD.Print($"Capitol placed at ({cx},{cy})."); } private void PlaceTownNodes(int count, TownTier tier, Biome targetBiome, bool requireInland, bool requireCoastal, bool isHighway, float maxSlope, MapHalf halfConstraint, bool requireMainland = true, bool requireIsland = false, float minTemp = 0f, float maxTemp = 1f) { int placed = 0; int attempts = 0; // --- TUNED SPACING --- // Big Hubs/Capitol need 120px (1.8km) spacing. Villages/POIs only need 60px (900m). float minDistance = (tier == TownTier.Hub || tier == TownTier.Capitol) ? MapSize * 0.12f : MapSize * 0.06f; bool ignoreBiome = false; while (placed < count && attempts < 80000) { attempts++; if (attempts == 35000) { if (requireIsland) ignoreBiome = true; else { if (!isHighway) requireCoastal = false; requireInland = false; maxSlope = 0.08f; minTemp = 0f; maxTemp = 1f; } } if (attempts == 65000 && requireIsland) { requireIsland = false; requireMainland = true; requireCoastal = true; } int rx = (int)GD.RandRange(20, MapSize - 20); int ry = (int)GD.RandRange(20, MapSize - 20); if (halfConstraint == MapHalf.Left && rx > MapSize / 2) continue; if (halfConstraint == MapHalf.Right && rx < MapSize / 2) continue; float t = _tempMap[rx, ry]; float currentSeaLevel = GetSeaLevel(t); if (_heightMap[rx, ry] < currentSeaLevel) continue; if (!ignoreBiome && _biomeMap[rx, ry] != targetBiome) continue; if (requireMainland && !_isMainland[rx, ry]) continue; if (requireIsland && _isMainland[rx, ry]) continue; if (t < minTemp || t > maxTemp) continue; float centerH = _heightMap[rx, ry]; // Sample slope a bit wider (10px) due to higher resolution. // Samples are clamped to map bounds (task 05): candidates spawn at // [20, MapSize-20] but slopeRadius is 40 at 8K, so the unclamped reads // were out of bounds near the border — previously unreachable only // because border land stayed underwater and failed the sea test first. int slopeRadius = (int)(MapSize * 0.005f); // Automatically scales! int sxHi = Mathf.Clamp(rx + slopeRadius, 0, MapSize - 1); int sxLo = Mathf.Clamp(rx - slopeRadius, 0, MapSize - 1); int syHi = Mathf.Clamp(ry + slopeRadius, 0, MapSize - 1); int syLo = Mathf.Clamp(ry - slopeRadius, 0, MapSize - 1); if (Mathf.Abs(_heightMap[sxHi, ry] - centerH) > maxSlope) continue; if (Mathf.Abs(_heightMap[sxLo, ry] - centerH) > maxSlope) continue; if (Mathf.Abs(_heightMap[rx, syHi] - centerH) > maxSlope) continue; if (Mathf.Abs(_heightMap[rx, syLo] - centerH) > maxSlope) continue; bool nearTrueOcean = false; bool nearAnyWater = false; // --- TUNED WATER DETECTION --- // Increased to 24px (approx 375m) to ensure coastal towns are actually near the surf int waterSearch = (int)(MapSize * 0.015f); // Automatically scales! for(int i = -waterSearch; i <= waterSearch; i += 2) { for(int j = -waterSearch; j <= waterSearch; j += 2) { int checkX = Mathf.Clamp(rx + i, 0, MapSize - 1); int checkY = Mathf.Clamp(ry + j, 0, MapSize - 1); float checkSeaLevel = GetSeaLevel(_tempMap[checkX, checkY]); if (_isTrueOcean[checkX, checkY]) nearTrueOcean = true; if (_heightMap[checkX, checkY] < checkSeaLevel) nearAnyWater = true; } } if (!requireIsland) { if (requireCoastal && !nearTrueOcean) continue; if (requireInland && nearAnyWater) continue; } bool tooClose = false; foreach (var town in _towns) { if (new Vector2(rx, ry).DistanceTo(town.Position) < minDistance) tooClose = true; } if (tooClose) continue; _towns.Add(new TownData { Position = new Vector2(rx, ry), Tier = tier, IsHighwayNode = isHighway }); placed++; } } // --- LOGISTICS & ROAD GENERATION --- private async Task GenerateRoadsAsync() { GD.Print($"{T()} [A*] Starting Logistics Pathfinding for {MapSize}x{MapSize} world..."); _highwayPaths.Clear(); _branchPaths.Clear(); _ruggedPaths.Clear(); _trailPaths.Clear(); AStarGrid2D astar = new AStarGrid2D(); astar.Region = new Rect2I(0, 0, MapSize, MapSize); astar.CellSize = new Vector2I(1, 1); astar.DiagonalMode = AStarGrid2D.DiagonalModeEnum.OnlyIfNoObstacles; astar.Update(); SetBaseAStarWeights(astar); int loopRepulsion = (int)(120 * (MapSize / 4096f)); Vector2 center = new Vector2(MapSize / 2f, MapSize / 2f); HashSet highwayPixels = new HashSet(); // --- 1. THE CONTINENTAL LOOP --- List highwayNodes = _towns.FindAll(t => t.IsHighwayNode); highwayNodes.Sort((a, b) => { float angleA = Mathf.Atan2(a.Position.Y - center.Y, a.Position.X - center.X); float angleB = Mathf.Atan2(b.Position.Y - center.Y, b.Position.X - center.X); return angleA.CompareTo(angleB); }); GD.Print($"{T()} [A*] 1/4: Plotting the {highwayNodes.Count}-Node Continental Loop..."); await ToSignal(GetTree(), SceneTree.SignalName.ProcessFrame); // BREATHE if (highwayNodes.Count >= 3) { for (int i = 0; i < highwayNodes.Count; i++) { Vector2 startPos = highwayNodes[i].Position; Vector2 endPos = highwayNodes[(i + 1) % highwayNodes.Count].Position; Vector2[] rawPath = FindPath(astar, new Vector2I((int)startPos.X, (int)startPos.Y), new Vector2I((int)endPos.X, (int)endPos.Y) ); if (rawPath.Length > 1) { _highwayPaths.Add(SmoothPath(rawPath)); foreach(var p in rawPath) highwayPixels.Add(new Vector2I((int)p.X, (int)p.Y)); PenalizePath(astar, rawPath, loopRepulsion); } await ToSignal(GetTree(), SceneTree.SignalName.ProcessFrame); // BREATHE } } // --- 2. THE MOUNTAIN BOSS BRANCH --- GD.Print($"{T()} [A*] 2/4: Connecting the Snow Boss..."); await ToSignal(GetTree(), SceneTree.SignalName.ProcessFrame); // BREATHE List connectedTowns = new List(highwayNodes); TownData snowBoss = _towns.Find(t => t.Tier == TownTier.Hub && !t.IsHighwayNode); if (snowBoss.Tier == TownTier.Hub) { Vector2I bestPixel = new Vector2I((int)center.X, (int)center.Y); float closestDist = float.MaxValue; int skip = 0; foreach (var pixel in highwayPixels) { if (skip++ % 5 != 0) continue; float d = snowBoss.Position.DistanceSquaredTo(new Vector2(pixel.X, pixel.Y)); if (d < closestDist) { closestDist = d; bestPixel = pixel; } } Vector2[] bossPath = FindPath(astar, new Vector2I((int)snowBoss.Position.X, (int)snowBoss.Position.Y), bestPixel ); if (bossPath.Length > 1) { _branchPaths.Add(SmoothPath(bossPath)); PenalizePath(astar, bossPath, loopRepulsion); } connectedTowns.Add(snowBoss); } // --- 3 & 4. COUNTY ROADS (The Daisy-Chain Protocol) --- GD.Print($"{T()} [A*] 3/4 & 4/4: Weaving County Roads (Daisy-Chaining)..."); await ToSignal(GetTree(), SceneTree.SignalName.ProcessFrame); // BREATHE List unconnectedTowns = _towns.FindAll(t => !connectedTowns.Contains(t) && t.Tier != TownTier.IslandLoot); int count = 0; int totalToConnect = unconnectedTowns.Count; // Prim's Algorithm: Organically grow the network out from the Hubs while (unconnectedTowns.Count > 0) { TownData bestUnconnected = unconnectedTowns[0]; TownData bestConnected = connectedTowns[0]; float shortestDist = float.MaxValue; // Find the single closest gap between the Unconnected wilderness and the Connected grid foreach (var unconn in unconnectedTowns) { foreach (var conn in connectedTowns) { float d = unconn.Position.DistanceSquaredTo(conn.Position); if (d < shortestDist) { shortestDist = d; bestUnconnected = unconn; bestConnected = conn; } } } count++; float actualDist = Mathf.Sqrt(shortestDist); // NEW: Live Diagnostics! Print the exact town we are testing BEFORE we calculate. GD.Print($"{T()} -> ({count}/{totalToConnect}) Pathing {bestUnconnected.Tier} to grid. Dist: {actualDist:F1}px"); await ToSignal(GetTree(), SceneTree.SignalName.ProcessFrame); // BREATHE! Vector2[] countyPath = new Vector2[0]; // THE ABANDON PROTOCOL: If it's more than 8% of the map away, it's a hermit camp. Leave it! if (actualDist > (MapSize * 0.08f)) { GD.Print($"{T()} [!] Too isolated. Abandoning road."); } else { // Attempt the connection. (If it freezes here, we know exactly which town caused it!) countyPath = FindPath(astar, new Vector2I((int)bestUnconnected.Position.X, (int)bestUnconnected.Position.Y), new Vector2I((int)bestConnected.Position.X, (int)bestConnected.Position.Y) ); } // If a path was found, draw it and penalize the pixels if (countyPath.Length > 1) { if (bestUnconnected.Tier == TownTier.Village) _ruggedPaths.Add(SmoothPath(countyPath)); else _trailPaths.Add(SmoothPath(countyPath)); PenalizePath(astar, countyPath, loopRepulsion / 2); } else if (actualDist <= (MapSize * 0.08f)) { GD.Print($"{T()} [!] Path impossible (Trapped by terrain). Abandoning road."); } unconnectedTowns.Remove(bestUnconnected); connectedTowns.Add(bestUnconnected); } GD.Print($"{T()} [A*] Logistics Network Complete!"); } /// /// The one place a road path is actually searched for. /// /// Every road — highway, branch, county — comes through here, so this is the single /// seam where pathfinding can be changed without touching any routing decision /// (which towns connect, in what order, at which tier). Those all live above and /// only ever deal in town positions. /// private Vector2[] FindPath(AStarGrid2D astar, Vector2I from, Vector2I to) { return astar.GetPointPath(from, to); } // Sea-level model gate (D-033, task 04): "flat" returns the configured scalar; // "field" is the legacy latitude Lerp. Pure numeric mapping either way (D-035); // all nine call sites inherit whichever model config selects. private float GetSeaLevel(float t) => ConfigManager.SeaLevelModel == "flat" ? ConfigManager.SeaLevelValue : Mathf.Lerp(0.26f, 0.15f, Mathf.Clamp(t, 0f, 1f)); private Vector2I FindClosestPixel(Vector2 pos, HashSet set) { Vector2I closest = new Vector2I(0,0); float min = float.MaxValue; foreach(var p in set) { float d = pos.DistanceSquaredTo(p); if(d < min){ min = d; closest = p; }} return closest; } private float GetShortestDistToSet(Vector2 pos, HashSet set) { float shortest = float.MaxValue; Vector2I pInt = new Vector2I((int)pos.X, (int)pos.Y); foreach (var p in set) { float dist = pInt.DistanceSquaredTo(p); if (dist < shortest) shortest = dist; } return shortest; } private void SetBaseAStarWeights(AStarGrid2D astar) { for (int x = 0; x < MapSize; x++) { for (int y = 0; y < MapSize; y++) { Biome b = _biomeMap[x, y]; // 1. CRITICAL FIX: Make the crater physically impassable first! if (b == Biome.Crater) { astar.SetPointSolid(new Vector2I(x, y), true); continue; // "Skip the rest, go to next pixel" } // 2. Existing water/mainland checks if (b == Biome.Ocean || b == Biome.Lake || !_isMainland[x, y]) { astar.SetPointSolid(new Vector2I(x, y), true); continue; // "Skip the rest, go to next pixel" } // 3. We only reach this point if it's mainland AND not a crater! float h = _heightMap[x, y]; float localSea = GetSeaLevel(_tempMap[x, y]); float normalizedElevation = Mathf.Clamp((h - localSea) / (1.0f - localSea), 0.0f, 1.0f); // Simple, steep curve to avoid mountains float weight = 1.0f + Mathf.Pow(normalizedElevation, 3.0f) * 400.0f; // Push off the beach (not an insane wall, just enough to prefer grass) if (b == Biome.Beach) weight += 15.0f; // Let it path through the wasteland normally if (b == Biome.Wasteland) weight += 3.0f; astar.SetPointWeightScale(new Vector2I(x, y), weight); } } } private void PenalizePath(AStarGrid2D astar, Vector2[] path, int radius) { int step = Mathf.Max(1, radius / 2); for (int i = 0; i < path.Length; i += step) { Vector2 p = path[i]; for (int dx = -radius; dx <= radius; dx++) { for (int dy = -radius; dy <= radius; dy++) { int nx = Mathf.Clamp((int)p.X + dx, 0, MapSize - 1); int ny = Mathf.Clamp((int)p.Y + dy, 0, MapSize - 1); var cell = new Vector2I(nx, ny); if (!astar.IsPointSolid(cell)) { // The true Iron Curtain astar.SetPointWeightScale(cell, astar.GetPointWeightScale(cell) + 10000f); } } } } } private Vector2[] SmoothPath(Vector2[] raw) { if (raw.Length < 3) return raw; List decimated = RamerDouglasPeucker(raw, 4.0f); if (decimated.Count < 3) return raw; List smoothed = decimated; for (int pass = 0; pass < 4; pass++) smoothed = ChaikinPass(smoothed, true); return smoothed.ToArray(); } private List RamerDouglasPeucker(Vector2[] points, float tolerance) { if (points.Length <= 2) return new List(points); float maxDist = 0f; int maxIdx = 0; Vector2 lineStart = points[0]; Vector2 lineEnd = points[points.Length - 1]; for (int i = 1; i < points.Length - 1; i++) { float dist = PerpendicularDistance(points[i], lineStart, lineEnd); if (dist > maxDist) { maxDist = dist; maxIdx = i; } } if (maxDist > tolerance) { var left = RamerDouglasPeucker(points[..(maxIdx + 1)], tolerance); var right = RamerDouglasPeucker(points[maxIdx..], tolerance); left.RemoveAt(left.Count - 1); left.AddRange(right); return left; } else { return new List { lineStart, lineEnd }; } } private float PerpendicularDistance(Vector2 point, Vector2 lineStart, Vector2 lineEnd) { Vector2 line = lineEnd - lineStart; float len = line.Length(); if (len < 0.0001f) return point.DistanceTo(lineStart); return Mathf.Abs((lineEnd.X - lineStart.X) * (lineStart.Y - point.Y) - (lineStart.X - point.X) * (lineEnd.Y - lineStart.Y)) / len; } private List ChaikinPass(List points, bool open) { var result = new List(); if (open) result.Add(points[0]); for (int i = 0; i < points.Count - 1; i++) { Vector2 p0 = points[i]; Vector2 p1 = points[(i + 1) % points.Count]; result.Add(p0 * 0.75f + p1 * 0.25f); result.Add(p0 * 0.25f + p1 * 0.75f); } if (open) result.Add(points[points.Count - 1]); return result; } // --- REFINED DRAWING FOR SCALE --- public override void _Draw() { float scaleMod = MapSize / 1024f; // equals 4 at 4096 // Trails: Thin brown foreach (var path in _trailPaths) DrawPolyline(path, new Color(0.5f, 0.4f, 0.3f, 0.7f), 1.0f * scaleMod, true); // Rugged: Thicker dark brown foreach (var path in _ruggedPaths) DrawPolyline(path, new Color(0.35f, 0.25f, 0.15f), 1.5f * scaleMod, true); // Main Roads: Solid Black foreach (var path in _branchPaths) DrawPolyline(path, Colors.Black, 1.0f * scaleMod, true); // Highway: Thick Red foreach (var path in _highwayPaths) DrawPolyline(path, new Color(0.9f, 0.1f, 0.1f), 2.0f * scaleMod, true); // Draw Towns foreach (var town in _towns) { float radius = (town.Tier == TownTier.Capitol) ? 12f : (town.Tier == TownTier.Hub ? 8f : 4f); radius *= scaleMod; // Scale the circles up! Color c = town.IsHighwayNode ? Colors.Yellow : (town.Tier == TownTier.Outpost ? Colors.Cyan : Colors.Orange); if (town.Tier == TownTier.IslandLoot) c = Colors.Red; if (town.Tier == TownTier.MiniPOI) { radius = 2.5f * scaleMod; c = Colors.SaddleBrown; } DrawCircle(town.Position, radius + (1.5f * scaleMod), Colors.Black); DrawCircle(town.Position, radius, c); } } } // Place this at the very bottom of the file! public partial class MapDrawProxy : Control { public MapGenerator Source; public override void _Draw() { if (Source == null) return; float scaleMod = Source.MapSize / 1024f; foreach (var path in Source._trailPaths) DrawPolyline(path, new Color(0.5f, 0.4f, 0.3f, 0.7f), 1.0f * scaleMod, true); foreach (var path in Source._ruggedPaths) DrawPolyline(path, new Color(0.35f, 0.25f, 0.15f), 1.5f * scaleMod, true); foreach (var path in Source._branchPaths) DrawPolyline(path, Colors.Black, 1.0f * scaleMod, true); foreach (var path in Source._highwayPaths) DrawPolyline(path, new Color(0.9f, 0.1f, 0.1f), 2.0f * scaleMod, true); foreach (var town in Source._towns) { float radius = (town.Tier == TownTier.Capitol) ? 12f : (town.Tier == TownTier.Hub ? 8f : 4f); radius *= scaleMod; Color c = town.IsHighwayNode ? Colors.Yellow : (town.Tier == TownTier.Outpost ? Colors.Cyan : Colors.Orange); if (town.Tier == TownTier.IslandLoot) c = Colors.Red; if (town.Tier == TownTier.MiniPOI) { radius = 2.5f * scaleMod; c = Colors.SaddleBrown; } DrawCircle(town.Position, radius + (1.5f * scaleMod), Colors.Black); DrawCircle(town.Position, radius, c); } } }