diff --git a/Core/Scripts/ConfigManager.cs b/Core/Scripts/ConfigManager.cs
index 8fb28b0..ec947a4 100644
--- a/Core/Scripts/ConfigManager.cs
+++ b/Core/Scripts/ConfigManager.cs
@@ -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)
{
diff --git a/Tools/Scripts/IslandFalloff.cs b/Tools/Scripts/IslandFalloff.cs
index c64ec40..92257fe 100644
--- a/Tools/Scripts/IslandFalloff.cs
+++ b/Tools/Scripts/IslandFalloff.cs
@@ -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;
+
+ ///
+ /// Blob weight in [0,1] for one ocean column. 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.
+ ///
+ 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);
+ }
+
+ ///
+ /// The noise value that of
+ /// exceed. Sorts a copy, so the caller's array is left alone.
+ ///
+ 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)];
+ }
+
+ ///
+ /// 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.
+ ///
+ 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;
+ }
}
diff --git a/Tools/Scripts/MapGenerator.cs b/Tools/Scripts/MapGenerator.cs
index 5b533cc..66b6991 100644
--- a/Tools/Scripts/MapGenerator.cs
+++ b/Tools/Scripts/MapGenerator.cs
@@ -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.