feat: offshore islands (terrain-water task 11)
Sparse discoverable islets from a low-frequency ocean noise layer, seeded
resolved+7607 at 14 undulations/island (~585 px blobs).
Placed by LERPING the seabed toward a target crest (34 m raw above sea)
rather than adding to it, so an islet can surface at any ambient depth
instead of only where the seafloor happens to be shallow. After the curve's
toe compresses them they stand 5-8 m above the water: low, sandy, ringed by
beach -- which is what the existing biome rules make of that height, with no
biome-rule change.
THE THREE CONSTRAINTS, each by construction rather than by hope:
Never merges with the mainland. The raise is EXACTLY zero wherever the
ambient water is shallower than 14 m, so any continuous path from the
mainland shore to an islet must cross that depth contour, and every pixel
on it is untouched water. Measured: 0 of 13 islets merged, closest
approach 78 px.
Never touches the Trench. A distance mask zeroes the layer by 0.86 of
half-map, and the ramp starts at 0.90. Measured max islet reach 0.795.
All four corners stay below sea.
Never spawns inland. Depth alone is not a test -- a deep lake and the
carved crater bay are also below sea. The pre-Trench falloff is the honest
discriminator (mainland coast sits near f = 0.66; islets need f > 0.72),
and it is axis-invariant, because elongation moves WHERE a given f occurs,
not the f at which land ends.
Two bugs found and fixed by measuring instead of trusting the first run:
1. The density dial treated [-1,1] as the noise's actual range. Simplex
is concentrated well inside it -- this field measures 0.050..0.958 --
so "top 6%" resolved to a threshold almost nothing cleared: the first
build raised 171 px on the entire map, none above sea. The threshold is
now CALIBRATED against the field's own distribution (quantile over a
1M-sample stride grid, deterministic from the seed), so the dial means
what it says whatever FastNoiseLite returns.
2. At 26/island the layer produced 77 islets under 200 px -- scatter, not
"a handful". Retuned to 14/island at density 0.02: 13 islets of
200-4792 px.
The pass prints its own islet area, so every run says what it did.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
566abe9784
commit
6c318e4df0
3 changed files with 161 additions and 15 deletions
|
|
@ -46,22 +46,26 @@ namespace IslaApocalypse.Core // Change this if your namespace is different
|
|||
|
||||
// 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 — measured, land rises from
|
||||
// the shoreline at 0.038 m/px while the seabed drops at 0.258 m/px. "steep" is
|
||||
// the pre-task-11 seabed, kept for A/B. The shelf cannot move the waterline, so
|
||||
// biomes and water are bit-identical either way. Default: wide.
|
||||
// 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 (pre-task-11: 1.15 / 0.90). NOTE,
|
||||
// measured: 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. These MOVE THE COASTLINE, so they move biomes by
|
||||
// design; elongated seeds have a new biome baseline.
|
||||
// measured: 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.
|
||||
// These MOVE THE COASTLINE and therefore move biomes, by design.
|
||||
//
|
||||
// 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 = 1.30f;
|
||||
public static float IslandAxisY = 0.78f;
|
||||
public static float OffshoreIslandDensity = 0.02f;
|
||||
|
||||
// The pre-task-11 axis ratios, so legacy/steep runs can restore them exactly.
|
||||
// The pre-task-11 axis ratios, so "steep"/legacy runs can restore them exactly.
|
||||
public const float LEGACY_AXIS_X = 1.15f;
|
||||
public const float LEGACY_AXIS_Y = 0.90f;
|
||||
|
||||
|
|
@ -179,6 +183,7 @@ namespace IslaApocalypse.Core // Change this if your namespace is different
|
|||
}
|
||||
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.
|
||||
|
|
@ -188,6 +193,7 @@ namespace IslaApocalypse.Core // Change this if your namespace is different
|
|||
IslandAxisX = LEGACY_AXIS_X;
|
||||
IslandAxisY = LEGACY_AXIS_Y;
|
||||
}
|
||||
OffshoreIslandDensity = Mathf.Clamp(OffshoreIslandDensity, 0f, 0.5f);
|
||||
|
||||
switch (profile)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -17,6 +17,8 @@ using Godot;
|
|||
/// so the shallows extend much further out, leaving deep water and the Trench
|
||||
/// essentially untouched.
|
||||
///
|
||||
/// OFFSHORE BLOB — sparse discoverable islets, seeded from an ocean noise layer.
|
||||
///
|
||||
/// Every one of these is monotone in the sign of (sea - height): none of them can
|
||||
/// turn water into land or land into water on its own. The coast shelf therefore
|
||||
/// leaves the biome and water classification bit-identical, which is why it is
|
||||
|
|
@ -58,4 +60,84 @@ public static class IslandFalloff
|
|||
if (depthMetres <= 0f) return depthMetres;
|
||||
return depthMetres * (1f - SHELF_STRENGTH * Mathf.Exp(-depthMetres / SHELF_SCALE_M));
|
||||
}
|
||||
|
||||
// ---- offshore islands -------------------------------------------------
|
||||
// Islets are placed by lerping the seabed TOWARD a target height, not by adding to
|
||||
// it, so they can surface at any ambient depth instead of only where the seafloor
|
||||
// happens to be shallow.
|
||||
public const float OFFSHORE_FREQ_ISLANDS = 14f; // ~585 px blobs at 8K — few and sizeable,
|
||||
// not a scatter of 50 px debris
|
||||
public const int OFFSHORE_SEED_OFFSET = 7607;
|
||||
public const float OFFSHORE_ISLAND_H_M = 34f; // target crest, metres above sea (pre-curve)
|
||||
public const float OFFSHORE_CORE = 0.45f; // fraction of a blob's excess that saturates
|
||||
|
||||
// The moat that keeps islets off the mainland. The raise is EXACTLY zero wherever
|
||||
// the ambient water is shallower than this, so the ring of water between the
|
||||
// mainland shore and any islet cannot be bridged: a continuous path from shore to
|
||||
// islet must cross this depth contour, and every pixel on it is untouched water.
|
||||
public const float OFFSHORE_MIN_DEPTH_M = 14f;
|
||||
public const float OFFSHORE_DEPTH_FEATHER_M = 10f;
|
||||
|
||||
// ...the margin that keeps them out of the Trench ramp (which starts at 0.90)...
|
||||
public const float OFFSHORE_TRENCH_INNER = 0.78f;
|
||||
public const float OFFSHORE_TRENCH_OUTER = 0.86f;
|
||||
|
||||
// ...and the test for "actually offshore". Depth alone is not enough: a deep LAKE
|
||||
// or the carved crater bay is also below sea level, and islets have no business in
|
||||
// either. The pre-Trench falloff is the honest discriminator — the mainland coast
|
||||
// sits near f = 0.66 (where f^2.5 ~ rawBase - sea), and inland water is far below
|
||||
// that whatever the axis ratios are, because elongation moves WHERE a given f
|
||||
// occurs, not the f at which land ends.
|
||||
public const float OFFSHORE_MIN_FALLOFF = 0.72f;
|
||||
public const float OFFSHORE_FALLOFF_FEATHER = 0.06f;
|
||||
|
||||
/// <summary>
|
||||
/// Blob weight in [0,1] for one ocean column. <paramref name="threshold"/> comes
|
||||
/// from CalibrateThreshold, NOT from the density directly — Simplex output is
|
||||
/// concentrated well inside [-1,1] (in practice it rarely passes ±0.87), so
|
||||
/// treating density as a fraction of the theoretical range produces a threshold
|
||||
/// almost nothing clears. That bug shipped in the first task-11 build and raised
|
||||
/// 171 pixels on the whole map, none of them above sea.
|
||||
/// </summary>
|
||||
public static float OffshoreBlob(float noise01, float threshold)
|
||||
{
|
||||
if (noise01 <= threshold) return 0f;
|
||||
float core = Mathf.Max((1f - threshold) * OFFSHORE_CORE, 1e-4f);
|
||||
float k = Mathf.Clamp((noise01 - threshold) / core, 0f, 1f);
|
||||
return k * k * (3f - 2f * k);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The noise value that <paramref name="density"/> of <paramref name="samples"/>
|
||||
/// exceed. Sorts a copy, so the caller's array is left alone.
|
||||
/// </summary>
|
||||
public static float CalibrateThreshold(float[] samples, float density)
|
||||
{
|
||||
if (samples.Length == 0 || density <= 0f) return 1f;
|
||||
float[] s = (float[])samples.Clone();
|
||||
System.Array.Sort(s);
|
||||
int idx = (int)((1f - Mathf.Clamp(density, 0f, 1f)) * (s.Length - 1));
|
||||
return s[Mathf.Clamp(idx, 0, s.Length - 1)];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// How much of the blob is allowed here: zero in shallow water near the mainland
|
||||
/// (the moat), zero anywhere that is not genuinely outside the island body, zero
|
||||
/// in and near the Trench ramp, full in the open ocean between.
|
||||
/// </summary>
|
||||
public static float OffshoreZoneWeight(float ambientDepthMetres, float preTrenchFalloff,
|
||||
float distX01, float distY01)
|
||||
{
|
||||
if (ambientDepthMetres < OFFSHORE_MIN_DEPTH_M) return 0f;
|
||||
if (preTrenchFalloff < OFFSHORE_MIN_FALLOFF) return 0f;
|
||||
|
||||
float w = Mathf.Clamp((ambientDepthMetres - OFFSHORE_MIN_DEPTH_M) / OFFSHORE_DEPTH_FEATHER_M, 0f, 1f);
|
||||
w *= Mathf.Clamp((preTrenchFalloff - OFFSHORE_MIN_FALLOFF) / OFFSHORE_FALLOFF_FEATHER, 0f, 1f);
|
||||
|
||||
float d = Mathf.Max(distX01, distY01);
|
||||
if (d >= OFFSHORE_TRENCH_OUTER) return 0f;
|
||||
if (d > OFFSHORE_TRENCH_INNER)
|
||||
w *= 1f - (d - OFFSHORE_TRENCH_INNER) / (OFFSHORE_TRENCH_OUTER - OFFSHORE_TRENCH_INNER);
|
||||
return w;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -60,10 +60,13 @@ public partial class MapGenerator : TextureRect
|
|||
private float _edgeAmpRaw;
|
||||
private float _maxEdgeShiftRaw;
|
||||
|
||||
// Task-11 island-falloff shaping: the submarine coast shelf. Acts on BELOW-SEA
|
||||
// height in pass 1; the axis ratios that reshape the island itself are read
|
||||
// straight from config in GenerateTopography.
|
||||
// Task-11 island-falloff shaping: the submarine coast shelf and the offshore
|
||||
// islet layer. Both act on BELOW-SEA height in pass 1; the axis ratios that
|
||||
// reshape the island itself are read straight from config in GenerateTopography.
|
||||
private bool _coastWide;
|
||||
private bool _offshoreOn;
|
||||
private FastNoiseLite _offshoreNoise;
|
||||
private float _offshoreThreshold = 1f;
|
||||
|
||||
// The seed's raw pre-curve height maximum (post noise/falloff/Trench/spine,
|
||||
// pre-carve) — the v2 curve's per-seed spike normalizer. Computed in
|
||||
|
|
@ -150,8 +153,30 @@ public partial class MapGenerator : TextureRect
|
|||
GD.Print("[MapGenerator] TerrainDetail v1 requires the curve — no-op with TerrainCurve off.");
|
||||
|
||||
_coastWide = ConfigManager.CoastProfile == "wide";
|
||||
GD.Print($"[MapGenerator] Island falloff: axis {ConfigManager.IslandAxisX:F2}x/{ConfigManager.IslandAxisY:F2}y, coast '{ConfigManager.CoastProfile}'" +
|
||||
(_coastWide ? $" (shelf strength {IslandFalloff.SHELF_STRENGTH:F3}, scale {IslandFalloff.SHELF_SCALE_M:F0} m)." : "."));
|
||||
_offshoreOn = ConfigManager.OffshoreIslandDensity > 0f;
|
||||
if (_offshoreOn)
|
||||
{
|
||||
_offshoreNoise = MakeModulationNoise(IslandFalloff.OFFSHORE_SEED_OFFSET, IslandFalloff.OFFSHORE_FREQ_ISLANDS);
|
||||
|
||||
// Calibrate the islet threshold against the field's ACTUAL distribution
|
||||
// rather than the theoretical [-1,1]: sample on a stride grid and take the
|
||||
// quantile. Deterministic from the seed, and it makes the density dial mean
|
||||
// what it says whatever FastNoiseLite's output range turns out to be.
|
||||
const int stride = 8;
|
||||
int side = MapSize / stride;
|
||||
float[] samples = new float[side * side];
|
||||
for (int i = 0; i < side; i++)
|
||||
for (int j = 0; j < side; j++)
|
||||
samples[i * side + j] = (_offshoreNoise.GetNoise2D(i * stride, j * stride) + 1f) * 0.5f;
|
||||
_offshoreThreshold = IslandFalloff.CalibrateThreshold(samples, ConfigManager.OffshoreIslandDensity);
|
||||
GD.Print($"[MapGenerator] Offshore islet threshold {_offshoreThreshold:F4} " +
|
||||
$"(density {ConfigManager.OffshoreIslandDensity:F3} of {samples.Length} samples; field range {System.Linq.Enumerable.Min(samples):F3}..{System.Linq.Enumerable.Max(samples):F3}).");
|
||||
}
|
||||
GD.Print($"[MapGenerator] Island falloff: axis {ConfigManager.IslandAxisX:F2}x/{ConfigManager.IslandAxisY:F2}y, " +
|
||||
$"coast '{ConfigManager.CoastProfile}'" +
|
||||
(_coastWide ? $" (shelf strength {IslandFalloff.SHELF_STRENGTH:F3}, scale {IslandFalloff.SHELF_SCALE_M:F0} m)" : "") +
|
||||
$", offshore islets density {ConfigManager.OffshoreIslandDensity:F3}" +
|
||||
(_offshoreOn ? $" @ {IslandFalloff.OFFSHORE_FREQ_ISLANDS:F0}/island, crest {IslandFalloff.OFFSHORE_ISLAND_H_M:F0} m, moat {IslandFalloff.OFFSHORE_MIN_DEPTH_M:F0} m" : " (off)") + ".");
|
||||
|
||||
// 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!
|
||||
|
|
@ -443,6 +468,7 @@ public partial class MapGenerator : TextureRect
|
|||
Vector2 center = new Vector2(MapSize / 2.0f, MapSize / 2.0f);
|
||||
float axisX = ConfigManager.IslandAxisX;
|
||||
float axisY = ConfigManager.IslandAxisY;
|
||||
long offshoreLandPx = 0; // pixels the islet layer lifted from water to land
|
||||
for (int x = 0; x < MapSize; x++)
|
||||
{
|
||||
for (int y = 0; y < MapSize; y++)
|
||||
|
|
@ -478,6 +504,10 @@ public partial class MapGenerator : TextureRect
|
|||
finalFalloff += southDepth * 0.6f; // Sink the stretched land bridges!
|
||||
}
|
||||
|
||||
// Captured before the power and before the Trench wall: this is the
|
||||
// "how far past the island body are we" number the offshore layer reads.
|
||||
float preTrenchFalloff = finalFalloff;
|
||||
|
||||
finalFalloff = Mathf.Pow(finalFalloff, 2.5f);
|
||||
|
||||
float distX = Mathf.Abs(x - center.X) / (MapSize / 2.0f);
|
||||
|
|
@ -518,11 +548,39 @@ public partial class MapGenerator : TextureRect
|
|||
finalH = seaHere - IslandFalloff.CoastShelf(depthM) / 251f;
|
||||
}
|
||||
|
||||
// --- 6. OFFSHORE ISLANDS (task 11) ---
|
||||
// Sparse islets lerped TOWARD a target crest rather than added to the
|
||||
// seabed, so they surface at any ambient depth. Held off the mainland by
|
||||
// a depth moat and out of the Trench ramp by a distance mask.
|
||||
if (_offshoreOn && finalH < seaHere)
|
||||
{
|
||||
float ambientDepthM = (seaHere - finalH) * 251f;
|
||||
float zone = IslandFalloff.OffshoreZoneWeight(
|
||||
ambientDepthM, preTrenchFalloff,
|
||||
Mathf.Abs(x - center.X) / (MapSize / 2.0f),
|
||||
Mathf.Abs(y - center.Y) / (MapSize / 2.0f));
|
||||
if (zone > 0f)
|
||||
{
|
||||
float v = (_offshoreNoise.GetNoise2D(x, y) + 1.0f) * 0.5f;
|
||||
float blob = IslandFalloff.OffshoreBlob(v, _offshoreThreshold) * zone;
|
||||
if (blob > 0f)
|
||||
{
|
||||
float before = finalH;
|
||||
finalH = Mathf.Lerp(finalH, seaHere + IslandFalloff.OFFSHORE_ISLAND_H_M / 251f, blob);
|
||||
if (before < seaHere && finalH >= seaHere) offshoreLandPx++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (finalH > _hMaxSeed) _hMaxSeed = finalH;
|
||||
_heightMap[x, y] = finalH;
|
||||
}
|
||||
}
|
||||
|
||||
if (_offshoreOn)
|
||||
GD.Print($"{T()} [Offshore] islet layer lifted {offshoreLandPx} px above sea " +
|
||||
$"({offshoreLandPx / (float)(MapSize * MapSize) * 100f:F3}% of the map).");
|
||||
|
||||
// The v2 curve is SEED-DEPENDENT: its spike maps [t4, hMaxSeed] onto the peak
|
||||
// band, so the monotonicity assertion must run against the EFFECTIVE per-seed
|
||||
// curve — after hMaxSeed is known, before any pixel is curved.
|
||||
|
|
|
|||
Loading…
Reference in a new issue