baseline: working salvaged prototype on Godot 4.7.1 (pre-F1)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Stewart Howe 2026-08-05 01:45:41 -04:00
commit 7f0f43b5fb
48 changed files with 2913 additions and 0 deletions

52
Tools/README.md Normal file
View file

@ -0,0 +1,52 @@
# 🧰 Tools Directory - Isla Apocalypse
## Overview
The `/Tools` directory houses all offline developer utilities, administrative scenes, and generation scripts.
**Core Philosophy:** Nothing in this folder is shipped to the end-user. These tools are used exclusively by the developers or server admins to generate the immutable "Source Data" (blueprints, prefabs, configs) that the `/Server` and `/Client` will later ingest. By keeping this logic completely isolated, we ensure the game client remains lightweight and secure from reverse-engineered world-generation exploits.
---
## 🛠️ Current Tools
### 1. The 2D World Generator (`MapCaptureTool.tscn` & `MapPreview.tscn`)
**Status:** Active / Upgraded to 1:1 Scale Architecture
This tool handles the heavy mathematical lifting of procedural generation. It creates the topographical and logistical foundation of the island before any 3D rendering occurs.
* **Primary Script:** `MapGenerator.cs`
* **Resolution:** 4096 x 4096 pixels (1:1 Scale. 1 Pixel = 1 In-Game Meter / 1 Voxel footprint).
* **Capture Architecture:** Uses a dynamic, C#-driven offscreen `SubViewport` and a custom `MapDrawProxy`. This bypasses Godot's UI layout engine, allowing the generation of massive, high-resolution maps regardless of the developer's physical monitor size.
* **Key Sub-Systems:**
* *Topography:* Uses `FastNoiseLite` and distance-falloff math to guarantee a mainland island shape with cold/mountainous northern regions.
* *Logistics:* A* Pathfinding that generates looping coastal highways, connecting branch roads, and rugged mountain trails. Pathing is dynamically scaled to the map resolution.
* *Zoning:* Biome mapping and hierarchical Town/POI placement based on slope, temperature, and proximity.
**Outputs (Saved to `user://`):**
1. **The Blueprint (`MapData_Seed_[X].dat`):** A heavily compressed binary file containing the map size, heightmap floats, biome enums, road vectors, and POI coordinates. This is the master file read by the Dedicated Server.
2. **The Snapshot (`Map_Seed_[X].png`):** A high-resolution 1:1 visual render of the map layout (including all drawn logistical routes) for admin review and seed selection. Timestamps have been removed to prevent directory bloating.
---
## 🚀 Future Roadmap & Planned Additions
As we move into Phase 2 (3D Voxels) and Phase 3 (Gameplay), this directory will expand to include the following standalone utilities:
### 2. The Prefab Editor (Planned - Phase 3)
A standalone 3D sandbox scene used to build custom Points of Interest (POIs) block-by-block (e.g., houses, bunkers, gas stations).
* **Function:** Allows the developer to build structures visually and define "Loot Container" or "Zombie Spawn" block locations.
* **Output:** Generates lightweight `.prefab` or `.json` files that the Server's Chunk Manager reads to spawn buildings at the coordinates dictated by the `MapData.dat` file.
### 3. Voxel Palette Manager (Planned - Phase 2)
A data-entry tool or inspector script to map 2D Biome Enums to 3D Voxel IDs.
* **Function:** Defines that `Biome.Jungle` at `Height 0.5` should spawn "Voxel ID 12 (Jungle Grass)", while `Height 0.2` should spawn "Voxel ID 4 (Dirt)".
### 4. Loot & Balance Tweaker (Planned - Phase 3)
A simple UI tool to adjust spawn weights and loot tables without having to dig through massive JSON files manually.
* **Output:** Bakes final configuration files for the Server to use when rolling loot table RNG.
---
## ⚠️ Directory Rules
1. **No Client Dependencies:** Scripts in this folder must *never* reference UI, Player Controllers, or Shaders located in the `/Client` folder.
2. **Data Agnostic:** Tools here should output raw data (`.dat`, `.json`, `.png`). They should not directly modify the live server's save state to avoid corruption.

View file

@ -0,0 +1,7 @@
[gd_scene format=3 uid="uid://croq62eckpnvx"]
[ext_resource type="Script" uid="uid://bl6pk71fngm8p" path="res://Tools/Scripts/MapGenerator.cs" id="1_56pfr"]
[node name="MapPreview" type="TextureRect" unique_id=971733442]
custom_minimum_size = Vector2(4096, 4096)
script = ExtResource("1_56pfr")

47
Tools/Scenes/README.md Normal file
View file

@ -0,0 +1,47 @@
# 🎬 /Tools/Scenes - Developer Environments
## Overview
This directory contains the standalone Godot scenes (`.tscn` files) used to execute our offline generation scripts. These scenes act as isolated "sandboxes" for the developers to build the world data.
**CRITICAL RULE:** These scenes must **never** be added to the main game's build export or scene tree. They are run strictly inside the Godot Editor by pressing **F6 (Play Current Scene)**.
---
## 🏗️ Core Scenes
### 1. `MapPreview.tscn`
The original 2D map generation scene.
* **Root Node:** `TextureRect`
* **Attached Script:** `MapGenerator.cs`
* **Function:** Runs the FastNoiseLite math, builds the `_heightMap`, and executes the `_Draw()` commands to visually plot the A* road networks and town circles.
### 2. `MapCaptureTool.tscn` (The Wrapper)
The master scene used to actually execute and capture the map generation.
* **Root Node:** `SubViewportContainer`
* **Child Nodes:** `SubViewport` -> `MapPreview`
* **Function:** Acts as a massive, invisible "virtual monitor" to capture the high-resolution PNG of the map without the Godot UI layout engine interfering.
---
## ⚔️ The Great 4096 Struggle (Beating the UI Engine)
When we upgraded *Isla Apocalypse* from a 1024 to a 4096 (1:1 scale) map, we encountered a massive rendering roadblock. Godot's UI Layout Engine is designed to make things fit on a player's physical monitor.
When we asked `MapPreview` to render a 4096x4096 texture, Godot panicked. It saw that the developer's monitor was only 1080p, so it violently crushed the map down to fit the screen, resulting in exported PNGs that were either 1KB postage stamps or completely black.
#### The "Virtual Monitor" Solution
To bypass the UI engine, we had to build `MapCaptureTool.tscn`.
1. **The Container Trap:** We initially used a `SubViewportContainer` with "Stretch" enabled. This was a trap. It locked the resolution to the editor window. We had to forcefully disable `Stretch` in the C# code.
2. **The `SubViewport`:** We placed `MapPreview` inside a `SubViewport` set explicitly to 4096x4096. This acts as an offscreen virtual monitor that doesn't care about the physical screen size.
3. **The `MapDrawProxy`:** We realized that `Texture.GetImage()` only grabs the base pixels, completely ignoring the colored roads and towns drawn by `_Draw()`. We had to dynamically instantiate a `Control` proxy node to stamp the roads onto the virtual monitor *before* taking the picture.
4. **The Flash Timing:** Generating a 4096 map takes time. We had to implement `await ToSignal(GetTree(), "process_frame");` to let Godot unlock the scene tree, and `await ToSignal(RenderingServer... FramePostDraw)` to force the camera to wait until the GPU physically finished painting the roads before snapping the photo.
---
## 🚀 How to Generate a New World
1. Open `MapCaptureTool.tscn` in the Godot Editor.
2. Ensure you do **not** click the main Play button.
3. Press **F6** (Play Current Scene).
4. The editor will freeze for 10-30 seconds while the `ExportMapData` function writes the binary `.dat` file.
5. Once the console prints the success message, check your `user://` folder for the massive, full-resolution `.png` and the `.dat` blueprint.

View file

@ -0,0 +1,975 @@
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();
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<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 = astar.GetPointPath(
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 = astar.GetPointPath(
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 = astar.GetPointPath(
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!");
}
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);
}
}
}
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<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!
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);
}
}
}

View file

@ -0,0 +1 @@
uid://bl6pk71fngm8p

View file

@ -0,0 +1,47 @@
# MapGenerator.cs - Architecture & Historical Decisions
**Date:** March 2026
**Purpose:** Generates a 4096x4096 2D topographical blueprint (Topography, Biomes, Cities, and Road Networks) to be parsed and translated into a 3D Voxel World.
---
## 1. Topography & The Impact Crater
* **Base Generation:** Uses `FastNoiseLite` to generate standard heightmaps and temperature maps, which drive dynamic biomes.
* **Dynamic Sea Level:** Sea level is calculated based on the temperature map (simulating ice caps/equatorial swelling).
* **The Crater:** A massive (radius 400) blast zone is forced along the northern coast. Surrounding it is a `Biome.Wasteland` with a noise-driven "fray" on its borders.
* **Design Choice:** We allowed the crater to occasionally spawn slightly inland rather than strictly on the beach. This creates natural topographical "pinch points" that will result in highly memorable, claustrophobic 3D driving mechanics.
## 2. City Placement & The Capitol Tether
* **The Challenge:** The Capitol City must spawn inside the radioactive Wasteland, but it MUST be connected to the contiguous mainland for the A* highway to reach it. Early iterations spawned the Capitol underwater or stranded it on offshore islands caused by the Wasteland noise bleeding over the ocean.
* **The Solution (Smart Tiered Spawner):** We abandoned a rigid `while` loop in favor of a tiered attempt system.
* **Tier 1:** Searches for ideal (Wasteland + Coastal + Mainland).
* **Tier 2:** Relaxes the coastal requirement.
* **Tier 3:** Scans the entire array for the closest valid pixel to the impact center.
* **Tier 4 (Doomsday):** Walks strictly south until it hits the mainland.
## 3. The Pathfinding Wars (A* Road Optimization)
The road network generation was the hardest logistical hurdle. Standard grid-based `AStarGrid2D` math does not scale cleanly to a 4096 map.
### Phase 1: The 16-Minute Bottleneck
* **The Flaw:** To force the highway to create a large loop connecting our 3 Hubs, we used an `ApplyRepulsion` function that penalized pixels in a 400px radius around the first drawn road.
* **The Math:** Repelling a 2,000-point path with a 400px radius resulted in over **1.3 Billion** nested loop iterations. The CPU choked, and generation took 11–16 minutes.
* **The Result:** Unusable generation times, extreme stair-stepping (zig-zagging), and roads cutting straight over mountains.
### Phase 2: The Speedrun & The Double-Back
* **The Flaw:** We ripped out the repulsion completely to save CPU time, dropping the render to **1 minute**.
* **The Result:** Because A* is deterministic, the pathfinder just used the exact same pixels to return to the start, completely breaking the 3-point loop and causing the highway to double back on itself.
### Phase 3: The Over-Engineered Spaghetti (The LLM Trap)
* **The Flaw:** We brought in an external LLM to fix the pathing. It introduced a "Midland Sweet Spot" to force roads off the coast, and a "Daisy-Chain" mechanic for branch roads.
* **The Result:** The highway started tracing narrow elevation contour lines like a drunk driver. The branch roads turned into a game of "Snake," meandering wildly to connect to each other instead of taking logical paths to the highway. Furthermore, adding a soft weight to the crater combined with our loop repulsion created a "Wasteland Phobia" where the A* algorithm mathematically folded and refused to complete the loop.
### Phase 4: The Tactical Revert (Our Final State)
We executed a deliberate tactical revert, stripping out the over-engineered LLM spaghetti and keeping only robust, simple math:
1. **Direct Branches:** Black roads path directly to the closest highway pixel. No daisy-chaining.
2. **The Iron Curtain Repulsion:** We restored a `+10000f` penalty to old highway segments with a radius of 120px to physically force the 3-point loop. We optimized it by only applying the penalty to every `step` (half the radius) pixel. Render time remains at ~1 minute.
3. **Simple Elevation Weights:** Instead of a complex U-curve, we use `Mathf.Pow(normalizedElevation, 3.0f) * 400.0f` to aggressively punish mountains, and a flat `+15.0f` penalty to gently push roads off the beach sand.
## 4. The AAA Smoothing Pipeline
Standard A* creates jagged, octagonal stair-steps that look terrible in a 3D voxel world. We completely eradicated this using a 2-step geometric smoothing pipeline:
1. **Ramer-Douglas-Peucker (RDP):** Decimates the raw A* path, removing thousands of useless straight-line grid points and leaving only the major turns.
2. **Chaikin's Algorithm:** Performs 4 passes of corner-cutting on the decimated points, resulting in buttery-smooth, sweeping, AAA-style highway curves ready for 3D mesh generation.

31
Tools/Scripts/README.md Normal file
View file

@ -0,0 +1,31 @@
# 📜 /Tools/Scripts - Generation Logic
## Overview
This directory contains the raw C# backend logic for all of our offline developer tools. These scripts are strictly for generating source data (Maps, Prefabs, Balance Configs) and are completely decoupled from the live game server and client.
---
## 🏗️ Core Scripts
### `MapGenerator.cs`
**The Master World Builder.** This script is responsible for generating the 2D topographical and logistical blueprint of *Isla Apocalypse*. It runs FastNoiseLite math, calculates distance falloffs, plots A* road networks, and zones the biomes.
#### ⚔️ The Great 4096 Struggle (The 1:1 Scale Transition)
Originally, this script generated a 1024x1024 map where 1 pixel equaled 1 chunk (4 meters). To accommodate industry-standard human-scale base building, we transitioned the engine to a 1:1 scale (1 pixel = 1 meter), effectively quadrupling the map size to 4096x4096.
This transition broke almost everything, requiring a massive architectural overhaul:
1. **The "TV Static" Math Fix:** Multiplying the map size by 4 caused the noise generator to pack 4 kilometers of terrain into 1 kilometer of space, creating visual static. We introduced a dynamic `scaleFactor` (`MapSize / 1024f`) to stretch the noise frequency back out, preserving the massive, sweeping "chonky" biomes while maintaining the 1:1 scale.
2. **Eradicating Magic Numbers:** Hardcoded pixel checks (e.g., "check 10 pixels for water") were completely broken by the upscaling. We refactored the logic to use dynamic map percentages for slope checking, water detection, and A* highway repulsion.
3. **The 1KB Postage Stamp (Defeating the UI Engine):** Trying to capture a 4096 image inside a standard Godot UI layout node resulted in the engine crushing the map into a tiny 1KB square. To fix this, `SaveMapSnapshot()` was rewritten to build an invisible, offscreen `SubViewport` entirely in C#.
4. **The Draw Proxy:** Because raw `Texture.GetImage()` does not capture Godot's `_Draw()` functions, we created `MapDrawProxy`. This stamps the colored roads and towns onto the offscreen monitor. We then implemented asynchronous thread-locking (`await ToSignal(RenderingServer...)`) to force the code to wait for the GPU to physically paint the roads before snapping the PNG.
**Outputs:**
* `MapData_Seed_[X].dat` - The binary map data for the Server.
* `Map_Seed_[X].png` - The 4096 high-resolution visual render.
---
## ⚠️ Scripting Rules
1. **No Magic Numbers:** Any distance, radius, or length must be calculated as a percentage of `MapSize` or use the `scaleFactor`.
2. **GPU Respect:** Any script exporting visual data must use `CallDeferred` or `await` frames to ensure the Godot rendering engine has finished processing.