Compare commits

...

2 commits

Author SHA1 Message Date
ad541dfbd8 feat: timestamped generation progress + staged snapshots (biomes/towns/roads)
Two additions, both observability only — no generation, routing, weight,
terrain or mesher behaviour changes.

1. Every progress print is now prefixed with elapsed time since generation
   started, e.g. [+ 142.7s], including a line per pipeline stage and per county
   road. The per-stage split (setup / continental loop / branch / county) can
   now be read straight off the console. Two A* fix attempts have been reasoned
   from structure rather than measurement; this makes the measurement free.

2. The end-of-run PNG capture was extracted into a reusable stage capture that
   can be called at any pipeline boundary. Snapshots are now taken after
   biomes, after towns, and after roads. The draw proxy renders whatever exists
   at the time, so stages that have not run simply do not appear — and a future
   water stage will show up automatically once it populates its data.

   The existing capture path is reused unchanged: same offscreen SubViewport,
   same draw proxy, same frame-yield plus double GPU wait. Those waits are
   load-bearing, not superstition, and each staged capture honours them.

Snapshot filenames gain a stage suffix: Map_Seed_<seed>_1_biomes.png,
_2_towns.png, _3_roads.png. Nothing reads these but a human, verified.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 22:43:19 -04:00
e070bbc9da fix: restore comment marker on WorldSeed line (build blocker)
Uncommenting the random-seed line left its trailing prose 'if 0 also
randomizes' as code, which stopped the whole project compiling. Only the
comment marker is restored — the choice of random-vs-fixed seed is left as set.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 22:43:19 -04:00
2 changed files with 75 additions and 23 deletions

View file

@ -7,8 +7,8 @@ namespace IslaApocalypse.Core // Change this if your namespace is different
public static class ConfigManager public static class ConfigManager
{ {
// Default Fallbacks // Default Fallbacks
public static int WorldSeed = 1063685222; // public static int WorldSeed = 1063685222;
// public static int WorldSeed = (int)GD.Randi(); if 0 also randomizes public static int WorldSeed = (int)GD.Randi(); // if 0 also randomizes
public static int MapSize = 8192; public static int MapSize = 8192;
public static float CraterRadius = 400f; public static float CraterRadius = 400f;
public static float DensityMultiplier = 1.0f; // <-- Replaces TownCount public static float DensityMultiplier = 1.0f; // <-- Replaces TownCount

View file

@ -17,6 +17,10 @@ public partial class MapGenerator : TextureRect
public bool IsHighwayNode; 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 FastNoiseLite _noise; // The one and only original detailed noise!
private Vector2 _impactCenter; private Vector2 _impactCenter;
private float _impactRadius; private float _impactRadius;
@ -35,6 +39,8 @@ public partial class MapGenerator : TextureRect
public override async void _Ready() public override async void _Ready()
{ {
_pipelineStartMs = Time.GetTicksMsec();
// 1. LOAD CONFIGURATION // 1. LOAD CONFIGURATION
ConfigManager.LoadConfig(); ConfigManager.LoadConfig();
MapSize = ConfigManager.MapSize; MapSize = ConfigManager.MapSize;
@ -66,27 +72,71 @@ public partial class MapGenerator : TextureRect
float randomY = (float)GD.RandRange(0.05f, 0.12f); float randomY = (float)GD.RandRange(0.05f, 0.12f);
_impactCenter = new Vector2(MapSize * randomX, MapSize * randomY); _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(); GenerateTopography();
GD.Print($"{T()} Topography done (height + temperature).");
CalculateTrueOcean(); CalculateTrueOcean();
CalculateMainland(); CalculateMainland();
GD.Print($"{T()} Ocean and mainland masks done.");
AssignBiomesAndDraw(); AssignBiomesAndDraw();
GD.Print($"{T()} Biomes done.");
await CaptureStage("1_biomes");
GenerateTowns(); GenerateTowns();
GD.Print($"{T()} Towns placed: {_towns.Count}.");
await CaptureStage("2_towns");
// NEW: We await the roads so the engine doesn't freeze! // NEW: We await the roads so the engine doesn't freeze!
await GenerateRoadsAsync(); await GenerateRoadsAsync();
ExportMapData(); ExportMapData();
SaveMapSnapshot(); GD.Print($"{T()} Blueprint exported.");
await CaptureStage("3_roads");
GD.Print($"{T()} GENERATION COMPLETE.");
} }
private async void SaveMapSnapshot() /// <summary>
/// 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.
/// </summary>
private string T()
{ {
// 1. THE FIX: Wait exactly one frame to let Godot finish _Ready() and unlock the Scene Tree! double seconds = (Time.GetTicksMsec() - _pipelineStartMs) / 1000.0;
return $"[+{seconds,6:F1}s]";
}
/// <summary>
/// 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.
/// </summary>
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"); await ToSignal(GetTree(), "process_frame");
GD.Print("Starting offscreen map capture..."); GD.Print($"{T()} Capturing snapshot: {label}...");
// 2. Build the invisible, offscreen 4096 monitor // 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(); var offscreenVP = new SubViewport();
offscreenVP.Size = new Vector2I(MapSize, MapSize); offscreenVP.Size = new Vector2I(MapSize, MapSize);
offscreenVP.RenderTargetUpdateMode = SubViewport.UpdateMode.Always; offscreenVP.RenderTargetUpdateMode = SubViewport.UpdateMode.Always;
@ -95,31 +145,33 @@ public partial class MapGenerator : TextureRect
// 3. Stamp the raw terrain texture onto it // 3. Stamp the raw terrain texture onto it
var bgRect = new TextureRect(); var bgRect = new TextureRect();
bgRect.Texture = this.Texture; bgRect.Texture = this.Texture;
bgRect.CustomMinimumSize = new Vector2(MapSize, MapSize); bgRect.CustomMinimumSize = new Vector2(MapSize, MapSize);
offscreenVP.AddChild(bgRect); offscreenVP.AddChild(bgRect);
// 4. Attach the proxy to draw the roads and towns on top // 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(); var drawProxy = new MapDrawProxy();
drawProxy.Source = this; drawProxy.Source = this;
drawProxy.CustomMinimumSize = new Vector2(MapSize, MapSize); drawProxy.CustomMinimumSize = new Vector2(MapSize, MapSize);
offscreenVP.AddChild(drawProxy); offscreenVP.AddChild(drawProxy);
// 5. WAIT FOR THE GPU! (Wait 2 render frames to guarantee the framebuffer is painted) // 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);
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! // 6. Pull the image from the invisible monitor and save it!
Image capture = offscreenVP.GetTexture().GetImage(); Image capture = offscreenVP.GetTexture().GetImage();
string seedStr = _noise.Seed.ToString(); string seedStr = _noise.Seed.ToString();
string fileName = $"user://Map_Seed_{seedStr}.png"; string fileName = $"user://Map_Seed_{seedStr}_{label}.png";
Error saveResult = capture.SavePng(fileName); Error saveResult = capture.SavePng(fileName);
if (saveResult == Error.Ok) if (saveResult == Error.Ok)
GD.Print($"Map saved successfully to: {ProjectSettings.GlobalizePath(fileName)}"); GD.Print($"{T()} Map saved: {ProjectSettings.GlobalizePath(fileName)}");
else else
GD.PrintErr($"Failed to save map. Godot Error code: {saveResult}"); GD.PrintErr($"{T()} Failed to save map. Godot Error code: {saveResult}");
// 7. Delete the invisible monitor to free up RAM // 7. Delete the invisible monitor to free up RAM
offscreenVP.QueueFree(); offscreenVP.QueueFree();
@ -622,7 +674,7 @@ public partial class MapGenerator : TextureRect
// --- LOGISTICS & ROAD GENERATION --- // --- LOGISTICS & ROAD GENERATION ---
private async Task GenerateRoadsAsync() private async Task GenerateRoadsAsync()
{ {
GD.Print($"[A*] Starting Logistics Pathfinding for {MapSize}x{MapSize} world..."); GD.Print($"{T()} [A*] Starting Logistics Pathfinding for {MapSize}x{MapSize} world...");
_highwayPaths.Clear(); _branchPaths.Clear(); _ruggedPaths.Clear(); _trailPaths.Clear(); _highwayPaths.Clear(); _branchPaths.Clear(); _ruggedPaths.Clear(); _trailPaths.Clear();
AStarGrid2D astar = new AStarGrid2D(); AStarGrid2D astar = new AStarGrid2D();
@ -645,7 +697,7 @@ public partial class MapGenerator : TextureRect
return angleA.CompareTo(angleB); return angleA.CompareTo(angleB);
}); });
GD.Print($"[A*] 1/4: Plotting the {highwayNodes.Count}-Node Continental Loop..."); GD.Print($"{T()} [A*] 1/4: Plotting the {highwayNodes.Count}-Node Continental Loop...");
await ToSignal(GetTree(), SceneTree.SignalName.ProcessFrame); // BREATHE await ToSignal(GetTree(), SceneTree.SignalName.ProcessFrame); // BREATHE
if (highwayNodes.Count >= 3) { if (highwayNodes.Count >= 3) {
@ -668,7 +720,7 @@ public partial class MapGenerator : TextureRect
} }
// --- 2. THE MOUNTAIN BOSS BRANCH --- // --- 2. THE MOUNTAIN BOSS BRANCH ---
GD.Print("[A*] 2/4: Connecting the Snow Boss..."); GD.Print($"{T()} [A*] 2/4: Connecting the Snow Boss...");
await ToSignal(GetTree(), SceneTree.SignalName.ProcessFrame); // BREATHE await ToSignal(GetTree(), SceneTree.SignalName.ProcessFrame); // BREATHE
List<TownData> connectedTowns = new List<TownData>(highwayNodes); List<TownData> connectedTowns = new List<TownData>(highwayNodes);
@ -700,7 +752,7 @@ public partial class MapGenerator : TextureRect
} }
// --- 3 & 4. COUNTY ROADS (The Daisy-Chain Protocol) --- // --- 3 & 4. COUNTY ROADS (The Daisy-Chain Protocol) ---
GD.Print("[A*] 3/4 & 4/4: Weaving County Roads (Daisy-Chaining)..."); GD.Print($"{T()} [A*] 3/4 & 4/4: Weaving County Roads (Daisy-Chaining)...");
await ToSignal(GetTree(), SceneTree.SignalName.ProcessFrame); // BREATHE await ToSignal(GetTree(), SceneTree.SignalName.ProcessFrame); // BREATHE
List<TownData> unconnectedTowns = _towns.FindAll(t => !connectedTowns.Contains(t) && t.Tier != TownTier.IslandLoot); List<TownData> unconnectedTowns = _towns.FindAll(t => !connectedTowns.Contains(t) && t.Tier != TownTier.IslandLoot);
@ -731,7 +783,7 @@ public partial class MapGenerator : TextureRect
float actualDist = Mathf.Sqrt(shortestDist); float actualDist = Mathf.Sqrt(shortestDist);
// NEW: Live Diagnostics! Print the exact town we are testing BEFORE we calculate. // 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"); GD.Print($"{T()} -> ({count}/{totalToConnect}) Pathing {bestUnconnected.Tier} to grid. Dist: {actualDist:F1}px");
await ToSignal(GetTree(), SceneTree.SignalName.ProcessFrame); // BREATHE! await ToSignal(GetTree(), SceneTree.SignalName.ProcessFrame); // BREATHE!
Vector2[] countyPath = new Vector2[0]; Vector2[] countyPath = new Vector2[0];
@ -739,7 +791,7 @@ public partial class MapGenerator : TextureRect
// THE ABANDON PROTOCOL: If it's more than 8% of the map away, it's a hermit camp. Leave it! // 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)) if (actualDist > (MapSize * 0.08f))
{ {
GD.Print($" [!] Too isolated. Abandoning road."); GD.Print($"{T()} [!] Too isolated. Abandoning road.");
} }
else else
{ {
@ -759,14 +811,14 @@ public partial class MapGenerator : TextureRect
} }
else if (actualDist <= (MapSize * 0.08f)) else if (actualDist <= (MapSize * 0.08f))
{ {
GD.Print($" [!] Path impossible (Trapped by terrain). Abandoning road."); GD.Print($"{T()} [!] Path impossible (Trapped by terrain). Abandoning road.");
} }
unconnectedTowns.Remove(bestUnconnected); unconnectedTowns.Remove(bestUnconnected);
connectedTowns.Add(bestUnconnected); connectedTowns.Add(bestUnconnected);
} }
GD.Print("[A*] Logistics Network Complete!"); GD.Print($"{T()} [A*] Logistics Network Complete!");
} }
/// <summary> /// <summary>