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.