Deposition now spreads over the same cone brush as carving, and the per-cell erosion ledger becomes a signed NET displacement ledger, so both caps are measured from the height the pass found. Brush-spreading alone cut the spike 15.49 -> 6.12 m at task-17 dials, but NOT at hierarchy dials (long paths carry far more sediment; a loaded droplet meeting a rise dumps min(rise, load) at once -> 25.5 m). So deposition also gets governor 4, ErosionDepositCap (default 6 m, <= 0 = unbounded), asserted on exit like the carve cap. Dial defaults retuned for a drainage HIERARCHY: 250k droplets x 384 steps at inertia 0.35 / evaporation 0.004, erode 0.12, carve cap 15 m. Diagnosis was that lifetime 48 let a droplet travel at most 48 px on a ~3000 px island radius, so paths could not overlap into trunks: the deepest task-17 features were 43x36 px patches. Now 177 channel systems of 200+ cells at the 3 m threshold, top one 204x279 px with 14 tributary tips; cells past 5 m up 8.6x (4,948 -> 42,385) while the fine rills are retained. Verified descending, not contour-locked: 0/155 components have drop/extent < 0.12 (median 1.13). EROS body version -> 2 (deposit cap inserted after the carve cap); v1 payloads are skipped whole by the existing rule. HydraulicErosion.VERSION now reads BlueprintFormat.EROS_VERSION instead of restating it — the local copy had already drifted and stamped a v2 body as v1, which readers decode with every float shifted by one field. Oracle green on both seeds (1280587109, 1512575962): 1_biomes/0_water md5-identical erosion-ON vs OFF, BIOM/WBID bitwise equal. Flood guard 0 new water px, 0 below-sea cells touched, crater zone untouched, island top unchanged, carve field isotropic to 1.4% across the folded 45deg period. Erosion pass 34 s at these dials. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
329 lines
14 KiB
C#
329 lines
14 KiB
C#
|
||
using Godot;
|
||
using Godot.Collections; // Required for Godot's built-in JSON parser
|
||
|
||
namespace IslaApocalypse.Core // Change this if your namespace is different
|
||
{
|
||
public static class ConfigManager
|
||
{
|
||
// Default Fallbacks
|
||
// public static int WorldSeed = 1063685222;
|
||
public static int WorldSeed = (int)GD.Randi(); // if 0 also randomizes
|
||
public static int MapSize = 8192;
|
||
public static float CraterRadius = 400f;
|
||
public static float DensityMultiplier = 1.0f; // <-- Replaces TownCount
|
||
public static int ChunkRadius = 24; // Default chunk size, can be overridden by config
|
||
|
||
// Iteration toggle: skip the ~25-min A* road pass entirely. The blueprint is
|
||
// still exported (road sections present but empty) — an iteration artifact,
|
||
// not a shippable world. Default false.
|
||
public static bool SkipRoads = false;
|
||
|
||
// Sea-level model (D-033, terrain-water task 04): "flat" = one scalar sea
|
||
// level everywhere (SeaLevelValue); "field" = the legacy latitude Lerp
|
||
// (0.26 north .. 0.15 south). Generator-only — the runtime never computes
|
||
// sea level. Default: flat 0.15.
|
||
public static string SeaLevelModel = "flat";
|
||
public static float SeaLevelValue = 0.15f;
|
||
|
||
// Height-redistribution curve (tasks 05–10, graduation M-7): "v5" is the
|
||
// task-09 gate's winner — the BALANCED terraced ascent with corner easing and
|
||
// modulated shelves; "off" is the raw legacy profile. "v1"–"v4" and the
|
||
// taste-batch tri-state ("v5-compact" lost, "v5-balanced" became plain "v5")
|
||
// are retired. Default: v5.
|
||
public static string TerrainCurve = "v5";
|
||
|
||
// Terrain detail passes (task 10): "v1" = shelf micro-relief + shelf-edge
|
||
// variation as one judged unit (requires the curve; no-op when it is off);
|
||
// "off" disables both. ShelfReliefAmp is the micro-relief amplitude in metres
|
||
// of OUTPUT height. ShelfEdgeVariation is the shelf-edge warp amplitude in
|
||
// metres of INPUT height — how far the shelf/riser boundary contour is
|
||
// displaced, not an elevation change; it is clamped at load time to the
|
||
// largest shift the curve's bands can absorb. Defaults: v1, 3 m, 12 m.
|
||
public static string TerrainDetail = "v1";
|
||
public static float ShelfReliefAmp = 3.0f;
|
||
public static float ShelfEdgeVariation = 12.0f;
|
||
|
||
// Hydraulic erosion (task 17, Phase C0): droplet-based carve-and-deposit on
|
||
// the RENDER height map only — the classify path (biomes/water) never sees
|
||
// it. "off" until the developer's gate approves it; the batch that turns it
|
||
// on does so explicitly. The three GOVERNORS hard-bound the pass:
|
||
// DropletCount (cost/detail), DropletLifetime (max steps per droplet),
|
||
// CarveCap (max erosion depth per cell, metres — the runaway-trench guard
|
||
// and what keeps erosion a detailing pass). ErosionSeaMargin is the flood
|
||
// guard: no cell is ever carved below sea + margin, and below-sea cells are
|
||
// never touched at all, so the rendered coastline cannot move. The remaining
|
||
// dials are the standard droplet-model strength constants; slopes/amounts
|
||
// are in METRES (1 raw height unit = 251 m).
|
||
// Task-18 defaults tune for a DRAINAGE HIERARCHY: long-lived, committed
|
||
// droplets (lifetime 384 at inertia 0.35, evaporation 0.004) travel far
|
||
// enough down a flank that their paths overlap and deepen shared low lines
|
||
// into trunk channels, instead of dying as independent 48-px scratches;
|
||
// the carve cap is raised to 15 m so trunks can separate from the fine
|
||
// rills instead of both piling up against the same ceiling. A modest
|
||
// erode rate keeps the total material moved in detailing range.
|
||
// ErosionDepositCap is governor 4 (task 18): brush-spread deposition alone
|
||
// does not bound a spike once droplets carry long-path loads.
|
||
public static string Erosion = "off";
|
||
public static int ErosionDropletCount = 250000;
|
||
public static int ErosionDropletLifetime = 384;
|
||
public static float ErosionCarveCap = 15.0f; // m per cell
|
||
public static float ErosionDepositCap = 6.0f; // m per cell; <= 0 = unbounded
|
||
public static float ErosionSeaMargin = 0.5f; // m above sea, carve floor
|
||
public static int ErosionBrushRadius = 2; // px
|
||
public static float ErosionInertia = 0.35f;
|
||
public static float ErosionCapacity = 4.0f;
|
||
public static float ErosionMinSlope = 0.02f; // m per px, capacity floor
|
||
public static float ErosionErodeRate = 0.12f;
|
||
public static float ErosionDepositRate = 0.15f;
|
||
public static float ErosionEvaporation = 0.004f;
|
||
public static float ErosionGravity = 4.0f;
|
||
|
||
// Island falloff shaping (task 11).
|
||
//
|
||
// CoastProfile: "wide" adds the submarine shelf — the height curve is identity
|
||
// at and below sea, so it never reached the seabed, which still dropped ~6.7x
|
||
// steeper than the land it meets. "steep" is the pre-task-11 seabed, kept for
|
||
// A/B. The shelf cannot move the waterline, so biomes and water are identical
|
||
// either way. Default: wide.
|
||
//
|
||
// IslandAxisX/Y: the falloff axis ratios. These MOVE THE COASTLINE and
|
||
// therefore move biomes, so the DEFAULT is the shape the gate approved.
|
||
// Task 11 shipped 1.30/0.78 as the default and the developer's gate REJECTED
|
||
// that elongation; the defaults are back to 1.15/0.90 so that omitting the
|
||
// keys can no longer silently produce the rejected island (task 12 §4).
|
||
// NOTE, measured in task 11: the island is already Trench-clamped in x at
|
||
// ~90% of the map width, so AxisX is a weak lever — aspect responds almost
|
||
// entirely to AxisY, which trades against land area.
|
||
//
|
||
// OffshoreIslandDensity: fraction of the ocean noise field above the islet
|
||
// threshold. 0 disables the layer. Islets never touch the Trench and are held
|
||
// off the mainland by a depth moat.
|
||
public static string CoastProfile = "wide";
|
||
public static float IslandAxisX = LEGACY_AXIS_X; // 1.15 — the gate's verdict
|
||
public static float IslandAxisY = LEGACY_AXIS_Y; // 0.90
|
||
public static float OffshoreIslandDensity = 0.02f;
|
||
|
||
// The gate-approved island shape, named so the defaults above and the bad-value
|
||
// restore below both point at one place. (`const`, so using it in a field
|
||
// initialiser declared earlier resolves at compile time.)
|
||
public const float LEGACY_AXIS_X = 1.15f;
|
||
public const float LEGACY_AXIS_Y = 0.90f;
|
||
|
||
public static void LoadConfig()
|
||
{
|
||
string path = "res://ServerConfig.json";
|
||
|
||
if (!FileAccess.FileExists(path))
|
||
{
|
||
GD.PrintErr("[ConfigManager] ServerConfig.json not found! Defaulting to 8K.");
|
||
return;
|
||
}
|
||
|
||
// Read the file
|
||
using var file = FileAccess.Open(path, FileAccess.ModeFlags.Read);
|
||
string content = file.GetAsText();
|
||
|
||
// Parse the JSON
|
||
var json = new Json();
|
||
var error = json.Parse(content);
|
||
if (error != Error.Ok)
|
||
{
|
||
GD.PrintErr($"[ConfigManager] JSON Parse Error: {json.GetErrorMessage()}");
|
||
return;
|
||
}
|
||
|
||
var data = (Dictionary)json.Data;
|
||
|
||
// Extract the Seed
|
||
if (data.ContainsKey("WorldSeed"))
|
||
{
|
||
WorldSeed = (int)data["WorldSeed"];
|
||
}
|
||
|
||
// Extract the MapProfile and run your Switch/Case logic!
|
||
string profile = "8K";
|
||
if (data.ContainsKey("MapProfile"))
|
||
{
|
||
profile = (string)data["MapProfile"];
|
||
}
|
||
|
||
// Extract the TownDensity and run your Switch/Case logic!
|
||
string townDensity = "Normal";
|
||
if (data.ContainsKey("TownDensity"))
|
||
{
|
||
townDensity = (string)data["TownDensity"];
|
||
}
|
||
// Extract the ChunkRadius
|
||
if (data.ContainsKey("ChunkRadius")) {
|
||
ChunkRadius = (int)data["ChunkRadius"];
|
||
}
|
||
|
||
// Extract the SkipRoads iteration toggle
|
||
if (data.ContainsKey("SkipRoads"))
|
||
{
|
||
SkipRoads = (bool)data["SkipRoads"];
|
||
}
|
||
|
||
// Extract the sea-level model
|
||
if (data.ContainsKey("SeaLevelModel"))
|
||
{
|
||
string model = (string)data["SeaLevelModel"];
|
||
if (model == "flat" || model == "field")
|
||
SeaLevelModel = model;
|
||
else
|
||
GD.PrintErr($"[ConfigManager] Unknown SeaLevelModel '{model}'. Keeping '{SeaLevelModel}'.");
|
||
}
|
||
if (data.ContainsKey("SeaLevelValue"))
|
||
{
|
||
SeaLevelValue = (float)data["SeaLevelValue"];
|
||
}
|
||
|
||
// Extract the terrain-curve gate
|
||
if (data.ContainsKey("TerrainCurve"))
|
||
{
|
||
string curve = (string)data["TerrainCurve"];
|
||
if (curve == "off" || curve == "v5")
|
||
TerrainCurve = curve;
|
||
else if (curve == "v5-balanced")
|
||
GD.PrintErr($"[ConfigManager] TerrainCurve 'v5-balanced' won the task-09 gate and is now plain \"v5\". Keeping '{TerrainCurve}'.");
|
||
else if (curve == "v5-compact")
|
||
GD.PrintErr($"[ConfigManager] TerrainCurve 'v5-compact' was retired by the task-09 verdict (BALANCED won). Keeping '{TerrainCurve}' — use \"v5\" or \"off\".");
|
||
else if (curve == "v1" || curve == "v2" || curve == "v3" || curve == "v4")
|
||
GD.PrintErr($"[ConfigManager] TerrainCurve '{curve}' was retired by a later recalibration (v5, tasks 09/10). Keeping '{TerrainCurve}' — use \"v5\" or \"off\".");
|
||
else
|
||
GD.PrintErr($"[ConfigManager] Unknown TerrainCurve '{curve}'. Keeping '{TerrainCurve}'.");
|
||
}
|
||
|
||
// Extract the terrain-detail gate + relief amplitude
|
||
if (data.ContainsKey("TerrainDetail"))
|
||
{
|
||
string detail = (string)data["TerrainDetail"];
|
||
if (detail == "off" || detail == "v1")
|
||
TerrainDetail = detail;
|
||
else
|
||
GD.PrintErr($"[ConfigManager] Unknown TerrainDetail '{detail}'. Keeping '{TerrainDetail}'.");
|
||
}
|
||
if (data.ContainsKey("ShelfReliefAmp"))
|
||
{
|
||
ShelfReliefAmp = (float)data["ShelfReliefAmp"];
|
||
}
|
||
if (data.ContainsKey("ShelfEdgeVariation"))
|
||
{
|
||
ShelfEdgeVariation = (float)data["ShelfEdgeVariation"];
|
||
}
|
||
|
||
// Extract the erosion gate + dials (task 17)
|
||
if (data.ContainsKey("Erosion"))
|
||
{
|
||
string erosion = (string)data["Erosion"];
|
||
if (erosion == "off" || erosion == "v1")
|
||
Erosion = erosion;
|
||
else
|
||
GD.PrintErr($"[ConfigManager] Unknown Erosion '{erosion}'. Keeping '{Erosion}'.");
|
||
}
|
||
if (data.ContainsKey("ErosionDropletCount")) ErosionDropletCount = (int)data["ErosionDropletCount"];
|
||
if (data.ContainsKey("ErosionDropletLifetime")) ErosionDropletLifetime = (int)data["ErosionDropletLifetime"];
|
||
if (data.ContainsKey("ErosionCarveCap")) ErosionCarveCap = (float)data["ErosionCarveCap"];
|
||
if (data.ContainsKey("ErosionDepositCap")) ErosionDepositCap = (float)data["ErosionDepositCap"];
|
||
if (data.ContainsKey("ErosionSeaMargin")) ErosionSeaMargin = (float)data["ErosionSeaMargin"];
|
||
if (data.ContainsKey("ErosionBrushRadius")) ErosionBrushRadius = (int)data["ErosionBrushRadius"];
|
||
if (data.ContainsKey("ErosionInertia")) ErosionInertia = (float)data["ErosionInertia"];
|
||
if (data.ContainsKey("ErosionCapacity")) ErosionCapacity = (float)data["ErosionCapacity"];
|
||
if (data.ContainsKey("ErosionMinSlope")) ErosionMinSlope = (float)data["ErosionMinSlope"];
|
||
if (data.ContainsKey("ErosionErodeRate")) ErosionErodeRate = (float)data["ErosionErodeRate"];
|
||
if (data.ContainsKey("ErosionDepositRate")) ErosionDepositRate = (float)data["ErosionDepositRate"];
|
||
if (data.ContainsKey("ErosionEvaporation")) ErosionEvaporation = (float)data["ErosionEvaporation"];
|
||
if (data.ContainsKey("ErosionGravity")) ErosionGravity = (float)data["ErosionGravity"];
|
||
|
||
// Governor bounds are enforced HERE, loudly, so a bad dial is a refused
|
||
// dial rather than a silently absurd generation. The clamps are wide —
|
||
// they exist to catch typos (an extra zero), not to tune.
|
||
int rawCount = ErosionDropletCount; int rawLife = ErosionDropletLifetime;
|
||
float rawCap = ErosionCarveCap;
|
||
ErosionDropletCount = Mathf.Clamp(ErosionDropletCount, 0, 50_000_000);
|
||
ErosionDropletLifetime = Mathf.Clamp(ErosionDropletLifetime, 1, 4096);
|
||
ErosionCarveCap = Mathf.Clamp(ErosionCarveCap, 0f, 60f);
|
||
if (rawCount != ErosionDropletCount || rawLife != ErosionDropletLifetime || rawCap != ErosionCarveCap)
|
||
GD.PrintErr($"[ConfigManager] Erosion governor out of bounds — clamped: count {rawCount}->{ErosionDropletCount}, lifetime {rawLife}->{ErosionDropletLifetime}, cap {rawCap}->{ErosionCarveCap} m.");
|
||
// Negative is meaningless; 0 is the documented "unbounded" escape hatch.
|
||
ErosionDepositCap = Mathf.Clamp(ErosionDepositCap, 0f, 60f);
|
||
ErosionSeaMargin = Mathf.Clamp(ErosionSeaMargin, 0f, 5f);
|
||
ErosionBrushRadius = Mathf.Clamp(ErosionBrushRadius, 0, 8);
|
||
ErosionInertia = Mathf.Clamp(ErosionInertia, 0f, 0.99f);
|
||
ErosionCapacity = Mathf.Max(ErosionCapacity, 0f);
|
||
ErosionMinSlope = Mathf.Max(ErosionMinSlope, 0f);
|
||
ErosionErodeRate = Mathf.Clamp(ErosionErodeRate, 0f, 1f);
|
||
ErosionDepositRate = Mathf.Clamp(ErosionDepositRate, 0f, 1f);
|
||
ErosionEvaporation = Mathf.Clamp(ErosionEvaporation, 0f, 0.5f);
|
||
ErosionGravity = Mathf.Max(ErosionGravity, 0f);
|
||
|
||
// Extract the island-falloff dials (task 11)
|
||
if (data.ContainsKey("CoastProfile"))
|
||
{
|
||
string coast = (string)data["CoastProfile"];
|
||
if (coast == "steep" || coast == "wide")
|
||
CoastProfile = coast;
|
||
else
|
||
GD.PrintErr($"[ConfigManager] Unknown CoastProfile '{coast}'. Keeping '{CoastProfile}'.");
|
||
}
|
||
if (data.ContainsKey("IslandAxisX")) IslandAxisX = (float)data["IslandAxisX"];
|
||
if (data.ContainsKey("IslandAxisY")) IslandAxisY = (float)data["IslandAxisY"];
|
||
if (data.ContainsKey("OffshoreIslandDensity")) OffshoreIslandDensity = (float)data["OffshoreIslandDensity"];
|
||
|
||
// The axis ratios divide map extents; a zero or negative one is a divide-by-
|
||
// zero that would silently produce an all-ocean map. Refuse it loudly.
|
||
if (IslandAxisX <= 0.01f || IslandAxisY <= 0.01f)
|
||
{
|
||
GD.PrintErr($"[ConfigManager] IslandAxisX/Y must be > 0.01 (got {IslandAxisX}/{IslandAxisY}). Restoring legacy {LEGACY_AXIS_X}/{LEGACY_AXIS_Y}.");
|
||
IslandAxisX = LEGACY_AXIS_X;
|
||
IslandAxisY = LEGACY_AXIS_Y;
|
||
}
|
||
OffshoreIslandDensity = Mathf.Clamp(OffshoreIslandDensity, 0f, 0.5f);
|
||
|
||
switch (profile)
|
||
{
|
||
case "4K":
|
||
MapSize = 4096;
|
||
CraterRadius = 400f; // Scaled down crater
|
||
break;
|
||
case "6K":
|
||
MapSize = 6144;
|
||
CraterRadius = 600f;
|
||
break;
|
||
case "8K":
|
||
MapSize = 8192;
|
||
CraterRadius = 800f; // Your original crater size spread over an 8K map
|
||
break;
|
||
case "10K":
|
||
MapSize = 10240;
|
||
CraterRadius = 1000f;
|
||
break;
|
||
default:
|
||
GD.PrintErr($"[ConfigManager] Unknown MapProfile '{profile}'. Defaulting to 8K.");
|
||
MapSize = 8192;
|
||
CraterRadius = 800f;
|
||
break;
|
||
}
|
||
|
||
switch (townDensity)
|
||
{
|
||
case "Sparse":
|
||
DensityMultiplier = 0.5f;
|
||
break;
|
||
case "Normal":
|
||
DensityMultiplier = 1.0f;
|
||
break;
|
||
case "Dense":
|
||
DensityMultiplier = 2.0f;
|
||
break;
|
||
default:
|
||
GD.PrintErr($"[ConfigManager] Unknown TownDensity '{townDensity}'. Defaulting to Normal.");
|
||
DensityMultiplier = 1.0f;
|
||
break;
|
||
}
|
||
|
||
GD.Print($"[ConfigManager] Successfully loaded {profile} Profile. MapSize set to {MapSize}.");
|
||
}
|
||
}
|
||
}
|