A* was searching almost the whole island for every road. Godot's stock estimate-to-goal is plain straight-line distance, which assumes every step costs 1 — but a step through mountains costs up to 401 and a step near an existing road cost 10,001. With the estimate that far below reality, A* cannot rule anything out, so it degenerates toward Dijkstra. Two changes, both aimed at that: 1. RoadPathGrid overrides the estimate to scale it by HEURISTIC_WEIGHT (3.0), i.e. weighted A*. Paths may be up to 3x costlier than the theoretical best in exchange for exploring far less. This does NOT push roads over mountains: a ridge costs hundreds of times more than going around, which a 3x bias nowhere near pays for. 2. ROAD_REPULSION_PENALTY replaces the hardcoded +10000 with 250. The old value made ground near a road effectively infinite, and it compounded — each road drawn made the next search slower. 250 still strongly discourages roads from running alongside each other. Terrain weights are untouched: the mountain curve (1 + elevation^3 * 400), beach and wasteland costs are exactly as before, so mountains remain expensive and roads still avoid them. Routing, tiers, smoothing and grid resolution are also unchanged. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1044 lines
No EOL
38 KiB
C#
1044 lines
No EOL
38 KiB
C#
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;
|
|
}
|
|
|
|
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<TownData> _towns = new List<TownData>();
|
|
|
|
internal List<Vector2[]> _highwayPaths = new List<Vector2[]>();
|
|
internal List<Vector2[]> _branchPaths = new List<Vector2[]>();
|
|
internal List<Vector2[]> _ruggedPaths = new List<Vector2[]>();
|
|
internal List<Vector2[]> _trailPaths = new List<Vector2[]>();
|
|
|
|
public override async void _Ready()
|
|
{
|
|
// 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);
|
|
|
|
GenerateTopography();
|
|
CalculateTrueOcean();
|
|
CalculateMainland();
|
|
AssignBiomesAndDraw();
|
|
GenerateTowns();
|
|
|
|
// NEW: We await the roads so the engine doesn't freeze!
|
|
await GenerateRoadsAsync();
|
|
|
|
ExportMapData();
|
|
SaveMapSnapshot();
|
|
}
|
|
|
|
private async void SaveMapSnapshot()
|
|
{
|
|
// 1. THE FIX: Wait exactly one frame to let Godot finish _Ready() and unlock the Scene Tree!
|
|
await ToSignal(GetTree(), "process_frame");
|
|
|
|
GD.Print("Starting offscreen map capture...");
|
|
|
|
// 2. Build the invisible, offscreen 4096 monitor
|
|
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
|
|
var drawProxy = new MapDrawProxy();
|
|
drawProxy.Source = this;
|
|
drawProxy.CustomMinimumSize = new Vector2(MapSize, MapSize);
|
|
offscreenVP.AddChild(drawProxy);
|
|
|
|
// 5. WAIT FOR THE GPU! (Wait 2 render frames to guarantee the framebuffer is painted)
|
|
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}.png";
|
|
|
|
Error saveResult = capture.SavePng(fileName);
|
|
|
|
if (saveResult == Error.Ok)
|
|
GD.Print($"Map saved successfully to: {ProjectSettings.GlobalizePath(fileName)}");
|
|
else
|
|
GD.PrintErr($"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();
|
|
string filePath = $"user://MapData_Seed_{seedStr}.dat";
|
|
|
|
using (FileStream stream = File.Open(ProjectSettings.GlobalizePath(filePath), 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("Binary Data Exported to: " + ProjectSettings.GlobalizePath(filePath));
|
|
}
|
|
|
|
// --- 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<Vector2I> queue = new Queue<Vector2I>();
|
|
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<Vector2I> queue = new Queue<Vector2I>();
|
|
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($"[A*] Starting Logistics Pathfinding for {MapSize}x{MapSize} world...");
|
|
_highwayPaths.Clear(); _branchPaths.Clear(); _ruggedPaths.Clear(); _trailPaths.Clear();
|
|
|
|
// RoadPathGrid is a stock AStarGrid2D with a scaled-up distance estimate, so the
|
|
// search stops flood-filling the island for every road. See the class for why.
|
|
AStarGrid2D astar = new RoadPathGrid();
|
|
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<Vector2I> highwayPixels = new HashSet<Vector2I>();
|
|
|
|
// --- 1. THE CONTINENTAL LOOP ---
|
|
List<TownData> 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($"[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("[A*] 2/4: Connecting the Snow Boss...");
|
|
await ToSignal(GetTree(), SceneTree.SignalName.ProcessFrame); // BREATHE
|
|
|
|
List<TownData> connectedTowns = new List<TownData>(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("[A*] 3/4 & 4/4: Weaving County Roads (Daisy-Chaining)...");
|
|
await ToSignal(GetTree(), SceneTree.SignalName.ProcessFrame); // BREATHE
|
|
|
|
List<TownData> 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($" -> ({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($" [!] 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($" [!] Path impossible (Trapped by terrain). Abandoning road.");
|
|
}
|
|
|
|
unconnectedTowns.Remove(bestUnconnected);
|
|
connectedTowns.Add(bestUnconnected);
|
|
}
|
|
|
|
GD.Print("[A*] Logistics Network Complete!");
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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<Vector2I> 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<Vector2I> 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);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// How much extra a step costs near an already-drawn road. This is what stops new
|
|
/// roads from lying on top of old ones — it is SEPARATION, not terrain avoidance,
|
|
/// so it is unrelated to the mountain/beach/wasteland weights.
|
|
///
|
|
/// It used to be 10,000, which was far past "discourage" — a normal step costs 1,
|
|
/// so it made the ground near a road effectively infinite. That wrecked the search:
|
|
/// every later road had to weigh routes costing tens of thousands, which is exactly
|
|
/// the situation A* handles by giving up on being clever and searching everything.
|
|
/// Worse, it compounded — each road drawn made the next one slower.
|
|
///
|
|
/// 250 still means "strongly prefer not to run alongside an existing road" (250x a
|
|
/// normal step) without dwarfing the search. Raise it if parallel roads crowd
|
|
/// together; lower it if roads take silly detours to avoid each other.
|
|
/// </summary>
|
|
private const float ROAD_REPULSION_PENALTY = 250f;
|
|
|
|
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)) {
|
|
astar.SetPointWeightScale(cell, astar.GetPointWeightScale(cell) + ROAD_REPULSION_PENALTY);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private Vector2[] SmoothPath(Vector2[] raw)
|
|
{
|
|
if (raw.Length < 3) return raw;
|
|
|
|
List<Vector2> decimated = RamerDouglasPeucker(raw, 4.0f);
|
|
if (decimated.Count < 3) return raw;
|
|
|
|
List<Vector2> smoothed = decimated;
|
|
for (int pass = 0; pass < 4; pass++) smoothed = ChaikinPass(smoothed, true);
|
|
|
|
return smoothed.ToArray();
|
|
}
|
|
|
|
private List<Vector2> RamerDouglasPeucker(Vector2[] points, float tolerance)
|
|
{
|
|
if (points.Length <= 2) return new List<Vector2>(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<Vector2> { 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<Vector2> ChaikinPass(List<Vector2> points, bool open)
|
|
{
|
|
var result = new List<Vector2>();
|
|
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!
|
|
/// <summary>
|
|
/// The road pathfinder's grid, with one change from stock: a scaled-up estimate.
|
|
///
|
|
/// WHY THIS EXISTS. A* explores outward from the start until the cheapest route it
|
|
/// has found is provably the best. It decides how far to keep looking by comparing
|
|
/// what a route has cost so far against an ESTIMATE of what remains. Godot's stock
|
|
/// estimate is plain straight-line distance — it assumes every step costs 1.
|
|
///
|
|
/// Our steps don't cost 1. A step through mountains costs up to 401, and a step near
|
|
/// an existing road used to cost 10,001. So the estimate was wildly optimistic about
|
|
/// the remaining journey, and A* responded the only way it can: by refusing to rule
|
|
/// anything out and searching almost the whole island for every single road.
|
|
///
|
|
/// Multiplying the estimate makes the search commit to a direction sooner. It's a
|
|
/// well-known trade ("weighted A*"): paths are allowed to be up to HEURISTIC_WEIGHT
|
|
/// times more expensive than the theoretical best, in exchange for exploring far less.
|
|
/// We want believable roads, not provably-optimal ones, so that's a good trade.
|
|
///
|
|
/// It does NOT make roads climb mountains. Crossing a ridge costs hundreds of times
|
|
/// more than walking around it — a 3x bias nowhere near pays for that.
|
|
/// </summary>
|
|
public partial class RoadPathGrid : AStarGrid2D
|
|
{
|
|
/// <summary>
|
|
/// How strongly the search commits toward the goal.
|
|
/// 1.0 = stock behaviour: guaranteed-shortest, explores enormously.
|
|
/// 3.0 = default: still routes sensibly around terrain, explores far less.
|
|
/// higher = faster still, but paths get progressively less considered.
|
|
/// Raise it if generation is still slow; lower it if roads start looking careless.
|
|
/// </summary>
|
|
public const float HEURISTIC_WEIGHT = 3.0f;
|
|
|
|
public override float _EstimateCost(Vector2I fromId, Vector2I toId)
|
|
{
|
|
return fromId.DistanceTo(toId) * HEURISTIC_WEIGHT;
|
|
}
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|
|
} |