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; private float[,] _tempMap; private Biome[,] _biomeMap; private bool[,] _isTrueOcean; private bool[,] _isMainland; internal List _towns = new List(); 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]; _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; // 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)."); CalculateTrueOcean(); CalculateMainland(); GD.Print($"{T()} Ocean and mainland masks done."); AssignBiomesAndDraw(); GD.Print($"{T()} Biomes done."); await CaptureStage("1_biomes"); GenerateTowns(); GD.Print($"{T()} Towns placed: {_towns.Count}."); await CaptureStage("2_towns"); // NEW: We await the roads so the engine doesn't freeze! await GenerateRoadsAsync(); ExportMapData(); GD.Print($"{T()} Blueprint exported."); 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, 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 --- float rawBase = (_noise.GetNoise2D(x, y) + 1.0f) / 2.0f; float finalH = rawBase + mountainSpine - (finalFalloff * FalloffStrength); // --- 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! float physicalCraterRadius = _impactRadius * 0.80f; if (distToCrater < physicalCraterRadius) { float craterDepth = 1.0f - (distToCrater / physicalCraterRadius); // Dialed back to -0.15f as per your excellent instinct! finalH = Mathf.Lerp(finalH, GetSeaLevel(temperature) - 0.15f, craterDepth * 0.9f); } _heightMap[x, y] = finalH; } } } 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] && _heightMap[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 (_heightMap[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] && _heightMap[neighbor.X, neighbor.Y] >= GetSeaLevel(_tempMap[neighbor.X, neighbor.Y])) { _isMainland[neighbor.X, neighbor.Y] = true; queue.Enqueue(neighbor); } } } } } 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++) { 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; 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 int slopeRadius = (int)(MapSize * 0.005f); // Automatically scales! if (Mathf.Abs(_heightMap[rx+slopeRadius, ry] - centerH) > maxSlope) continue; if (Mathf.Abs(_heightMap[rx-slopeRadius, ry] - centerH) > maxSlope) continue; if (Mathf.Abs(_heightMap[rx, ry+slopeRadius] - centerH) > maxSlope) continue; if (Mathf.Abs(_heightMap[rx, ry-slopeRadius] - 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); } private float GetSeaLevel(float t) => 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); } } }