Stage 1 fills the IslandFalloff.cs stub from the reference, verbatim: the coast shelf (below-sea only, depth-preserving, held strictly below sea by MathF.BitDecrement — the clamp that makes "cannot move the waterline" exact rather than statistical) and the islet layer (OffshoreBlob, CalibrateThreshold against the field's ACTUAL distribution, the OffshoreZoneWeight moat + pre-Trench-falloff test). Both run as pass 1b — OffshorePass, a second sweep over the finished pass-1 arrays with the same per-pixel arithmetic in the same order — and HMaxSeed is retaken AFTER them, as the reference did. That closes chat2/00 Drift §2. The value did not move on any of 15 runs; the order is now right by construction and oracle (l) prints it every time. Stage 2 is the reshape, OffshoreSettings.Hybrid(): a seeded floor of ≥2 N / ≥4 S islands placed by a PCG32 off the world seed — min-separated, clear of ALL existing land by a gap so each stamp is its own connected component by construction, fully inside the zone so the moat and falloff protections gate the floor exactly as they gate the organic layer — plus the noise layer on top, smaller (freq 16), lower (24 m pre-curve, which the preserved toe squashes to ~5 m and keeps there across curve tweaks), flatter (core 0.25), crisper (edge sharpness 2.5, stamp rim jittered so it is rigid without being a compass disc), south-weighted (0.007 N / 0.012 S, blended across the midline), corners allowed (trench mask 0.90→0.97). Every lifted cell is tagged with its hemisphere — NORTH is rows [0, N/2), y runs south, read off the spine's southern fade and the southern sinker, not invented — and carried through Pass1Result → Pass2Result for a consumer that does not exist yet. Two things the probes taught, both now in the code: the first organic densities produced 183 blobs of which 174 were noise debris (a six-config sweep fixed that), and the reference's lerp-to-crest leaves shallow humps across the seabed wherever a blob fails to surface (a guard reverts them outside any surviving island's skirt; specks by component membership, bumps by location). The faithful control keeps both, because it is the reference — its islets measure min 1 cell, median 28. Oracle, all hard checks passing: floor met on every hybrid seed (N 2–4, S 9–16); zero land bridges; every offshore-OFF land cell bit-identical with offshore ON; tag ↔ coastline consistent in both fields; curve-off still bit-identical to Phase 1's dump and continuous_restored to task 03's. Both gates default OFF, deliberately: flipping them is the act that retires the Phase-1 regression dumps, and that belongs in a task that re-baselines the oracles. The faithful control on seed 8675309 produced one north island. That is the gap the floor exists to close. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DCWNaDZPfTiAy3meGNGgqt
454 lines
22 KiB
C#
454 lines
22 KiB
C#
using System;
|
||
using System.Collections.Generic;
|
||
using Godot;
|
||
using IslaApocalypse.Core;
|
||
|
||
namespace IslaApocalypse.Tools
|
||
{
|
||
/// <summary>
|
||
/// ⭐⭐ PASS 1b — THE COAST SHELF AND THE OFFSHORE ISLETS (chat2/05). Runs over the finished
|
||
/// pass-1 arrays, IN PLACE, before <c>HMaxSeed</c> is taken and before anything classifies.
|
||
///
|
||
/// ═══ WHERE THIS SITS, AND WHY IT IS A SECOND SWEEP ═══
|
||
///
|
||
/// The reference did all of this INSIDE the pass-1 pixel loop (<c>MapGenerator.cs:621-664</c>):
|
||
/// combine → shelf → islets → <c>_hMaxSeed</c> → write. v2 runs the same arithmetic as a second
|
||
/// sweep over the arrays the first loop produced. Per pixel the inputs are identical — the raw
|
||
/// height, the sea level, the pre-Trench falloff, (x, y) — and the operations are applied in the
|
||
/// same order on the same floats, so the FAITHFUL mode reproduces the reference cell for cell.
|
||
/// What the second sweep buys is the seeded floor: choosing island centres needs the whole
|
||
/// depth/falloff field to exist first, which a single pixel loop cannot provide.
|
||
///
|
||
/// ⚠⚠ THE ORDERING THAT CLOSES chat2/00 DRIFT §2: the caller recomputes <c>HMaxSeed</c> AFTER this
|
||
/// pass, as the reference did, so the curve's per-seed peak normalization sees the same maximum
|
||
/// the reference saw. Expected to be unchanged (a ~34 m crest is far below any peak) — reported,
|
||
/// not assumed.
|
||
///
|
||
/// ═══ THE THREE SUB-PASSES ═══
|
||
///
|
||
/// 1. SHELF every below-sea cell; depth-preserving; held strictly below sea by BitDecrement.
|
||
/// 2. ORGANIC the reference's noise layer — faithful, or reshaped (smaller / lower / flatter /
|
||
/// crisper, south-weighted) — every below-sea cell inside the zone mask.
|
||
/// 3. FLOOR (Hybrid only) a seed-derived RNG places ≥N north / ≥S south stamps, each
|
||
/// min-separated from the others AND clear of ALL existing land by a gap, so each
|
||
/// stamp is its own connected component BY CONSTRUCTION. Positions vary per seed;
|
||
/// there are no reserved zones. Refuses loudly if the floor cannot be placed.
|
||
///
|
||
/// ═══ THE TWO PROTECTIONS ARE APPLIED TO EVERYTHING, INCLUDING THE FLOOR ═══
|
||
///
|
||
/// The moat (min depth) and the "actually offshore" test (pre-Trench falloff) gate the organic
|
||
/// layer per pixel — that is the reference. They ALSO gate every seeded stamp: a centre must sit
|
||
/// fully inside the zone (weight exactly 1), and the stamp's every pixel is still multiplied by
|
||
/// the zone weight. A guaranteed island cannot be guaranteed onto the mainland or into a lake.
|
||
///
|
||
/// ═══ ⚠ THE ONE HONEST COUPLING ═══
|
||
///
|
||
/// Unlike the curve, this pass TURNS WATER INTO LAND. New land is new classification downstream
|
||
/// (biome/water pixels that did not exist before). That is precisely why it runs here, in the base
|
||
/// shape, before anything classifies: change an island dial, regenerate, and classification
|
||
/// re-runs consistently. Known property, stated in code, not a surprise.
|
||
///
|
||
/// Every cell lifted from below sea to at/above sea is TAGGED (<c>Result.Tag</c>) with its
|
||
/// hemisphere (<c>Result.Hemi</c>). That tag is data the shape pass sets and carries; nothing in
|
||
/// this phase reads it. → <see cref="OffshoreAnalysis"/> for the hemisphere convention.
|
||
/// </summary>
|
||
public static class OffshorePass
|
||
{
|
||
public sealed class Result
|
||
{
|
||
public bool[,] Tag; // null when the islet layer is off (shelf only)
|
||
public byte[,] Hemi;
|
||
public long ShelfCells, LiftedOrganic, LiftedSeeded;
|
||
public float ThresholdNorth = float.NaN, ThresholdSouth = float.NaN;
|
||
public List<(int x, int y, int r)> Centres = new();
|
||
public List<string> Notes = new();
|
||
public int CountNorth, CountSouth, Bridged;
|
||
public long LiftedReverted; // specks the guard put back
|
||
public long RaisedReverted; // submerged debris bumps the guard put back
|
||
internal List<(int x, int y, float h0)> LiftedOrigin = new(); // every SURFACED lift
|
||
internal List<(int x, int y, float h0)> RaisedOrigin = new(); // every organic raise, surfaced or not (guard on)
|
||
}
|
||
|
||
/// <summary>
|
||
/// Apply the shelf and/or islets to <paramref name="height"/> IN PLACE. Returns null when
|
||
/// both are off (nothing touched, nothing allocated).
|
||
/// </summary>
|
||
public static Result Apply(float[,] height, float[,] preTrench, int mapSize, int seed,
|
||
float sea, TerrainGenConfig cfg)
|
||
{
|
||
OffshoreSettings s = cfg.Offshore;
|
||
bool shelfOn = cfg.CoastShelf;
|
||
bool isletsOn = s != null && s.Mode != OffshoreMode.Off;
|
||
if (!shelfOn && !isletsOn) return null;
|
||
|
||
var r = new Result();
|
||
GenerationScale scale = cfg.Scale;
|
||
|
||
// ⚠ MAP-anchored, exactly as the reference: `center = MapSize / 2.0f`, `distX = |x − cx| / (MapSize / 2.0f)`.
|
||
float centerX = mapSize / 2.0f, centerY = mapSize / 2.0f;
|
||
float halfSpan = mapSize / 2.0f;
|
||
|
||
// ═══ 1. THE COAST SHELF ═══
|
||
if (shelfOn)
|
||
{
|
||
// ⭐ THE CLAMP THAT MAKES "CANNOT MOVE THE WATERLINE" EXACT. Reference: the remap is
|
||
// strictly positive on positive depth, so in exact arithmetic the waterline cannot
|
||
// move; in float32 it can — a pixel a few microns under water rounds back up to
|
||
// exactly sea and `h < sea` then calls it land. That cost 5 px of 67 M on the
|
||
// reference's first batch. Hold the result strictly below sea and the invariant is
|
||
// exact. Do not port this without the clamp.
|
||
float strictlyBelowSea = MathF.BitDecrement(sea);
|
||
for (int x = 0; x < mapSize; x++)
|
||
{
|
||
for (int y = 0; y < mapSize; y++)
|
||
{
|
||
float h = height[x, y];
|
||
if (h >= sea) continue; // below-sea ONLY
|
||
float depthM = WorldScale.MetresFromRaw(sea - h); // (seaHere − finalH) * 251f
|
||
height[x, y] = MathF.Min(
|
||
sea - WorldScale.RawFromMetres(IslandFalloff.CoastShelf(depthM, cfg.ShelfStrength, cfg.ShelfScaleM)),
|
||
strictlyBelowSea);
|
||
r.ShelfCells++;
|
||
}
|
||
}
|
||
r.Notes.Add($"[Offshore] coast shelf: {r.ShelfCells:N0} below-sea cells remapped " +
|
||
$"(strength {cfg.ShelfStrength:F3}, scale {cfg.ShelfScaleM:F0} m) — held strictly below sea.");
|
||
}
|
||
|
||
if (!isletsOn) return r;
|
||
|
||
r.Tag = new bool[mapSize, mapSize];
|
||
r.Hemi = new byte[mapSize, mapSize];
|
||
|
||
bool faithful = s.Mode == OffshoreMode.Faithful;
|
||
bool guardOn = !faithful && s.MinIslandAreaFrac > 0f;
|
||
float crest = sea + WorldScale.RawFromMetres(s.CrestM); // seaHere + OFFSHORE_ISLAND_H_M / 251f
|
||
|
||
// ═══ 2. THE ORGANIC LAYER ═══
|
||
var liftedOrigin = r.LiftedOrigin; // every lift, for the debris guard
|
||
if (s.Organic)
|
||
{
|
||
FastNoiseLite noise = TerrainNoise.CreateModulation(seed, s.SeedOffset, s.FreqPerMapWidth, scale);
|
||
|
||
// ⭐ Calibrate against the field's ACTUAL distribution, not 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 range turns out
|
||
// to be. Verbatim: stride 8, (side)² samples, (noise + 1) * 0.5.
|
||
const int stride = 8;
|
||
int side = mapSize / stride;
|
||
var samples = new float[side * side];
|
||
for (int i = 0; i < side; i++)
|
||
for (int j = 0; j < side; j++)
|
||
samples[i * side + j] = (noise.GetNoise2D(i * stride, j * stride) + 1f) * 0.5f;
|
||
|
||
float thrN = IslandFalloff.CalibrateThreshold(samples, s.DensityNorth);
|
||
float thrS = faithful ? thrN : IslandFalloff.CalibrateThreshold(samples, s.DensitySouth);
|
||
r.ThresholdNorth = thrN; r.ThresholdSouth = thrS;
|
||
|
||
float mid = mapSize * 0.5f;
|
||
float band = MathF.Max(1f, s.HemisphereBlendHalfWidth * mapSize);
|
||
|
||
for (int x = 0; x < mapSize; x++)
|
||
{
|
||
for (int y = 0; y < mapSize; y++)
|
||
{
|
||
float h = height[x, y];
|
||
if (h >= sea) continue; // below-sea ONLY — never touches land
|
||
|
||
float ambientDepthM = WorldScale.MetresFromRaw(sea - h);
|
||
float zone = IslandFalloff.OffshoreZoneWeight(
|
||
ambientDepthM, preTrench[x, y],
|
||
MathF.Abs(x - centerX) / halfSpan, MathF.Abs(y - centerY) / halfSpan,
|
||
s.MinDepthM, s.DepthFeatherM, s.MinFalloff, s.FalloffFeather,
|
||
s.TrenchInner, s.TrenchOuter);
|
||
if (zone <= 0f) continue;
|
||
|
||
float v = (noise.GetNoise2D(x, y) + 1.0f) * 0.5f;
|
||
|
||
// The threshold: one number in faithful mode; north/south blended smoothly
|
||
// across the midline in the reshape, so a straddling island is not sliced.
|
||
float thr;
|
||
if (faithful) thr = thrN;
|
||
else
|
||
{
|
||
float t = Math.Clamp((y - mid) / band * 0.5f + 0.5f, 0f, 1f);
|
||
t = t * t * (3f - 2f * t);
|
||
thr = thrN + (thrS - thrN) * t;
|
||
}
|
||
|
||
float blob = (faithful
|
||
? IslandFalloff.OffshoreBlob(v, thr)
|
||
: IslandFalloff.RigidBlob(v, thr, s.CoreFraction, s.EdgeSharpness)) * zone;
|
||
if (blob <= 0f) continue;
|
||
|
||
// ⭐ LERP TOWARD THE CREST, never add — surfaces at any ambient depth.
|
||
float before = h;
|
||
h = h + (crest - h) * blob; // Mathf.Lerp, written out
|
||
height[x, y] = h;
|
||
if (guardOn) r.RaisedOrigin.Add((x, y, before)); // for the debris guard (reshape only)
|
||
|
||
if (before < sea && h >= sea)
|
||
{
|
||
r.LiftedOrganic++;
|
||
r.Tag[x, y] = true;
|
||
r.Hemi[x, y] = OffshoreAnalysis.HemisphereOfRow(y, mapSize);
|
||
liftedOrigin.Add((x, y, before));
|
||
}
|
||
}
|
||
}
|
||
|
||
r.Notes.Add($"[Offshore] organic ({(faithful ? "faithful" : "reshaped")}): threshold N {thrN:F4}" +
|
||
$"{(faithful ? "" : $" S {thrS:F4}")} from {samples.Length:N0} samples " +
|
||
$"(range {Min(samples):F3}..{Max(samples):F3}); lifted {r.LiftedOrganic:N0} cells above sea.");
|
||
}
|
||
|
||
// ═══ 3. THE SEEDED FLOOR (Hybrid only) ═══
|
||
if (s.Mode == OffshoreMode.Hybrid && (s.FloorNorth > 0 || s.FloorSouth > 0))
|
||
{
|
||
var rng = new Pcg32(seed + s.PlacementSeedOffset);
|
||
int radius = Math.Max(2, (int)MathF.Round(s.StampRadiusFrac * mapSize));
|
||
// Rim jitter field: fine enough to put a few lobes around a stamp's rim (wavelength
|
||
// ≈ radius / 1.5), stated per map width so the look holds at every size. Seeded off
|
||
// the islet offset so it is decorrelated from the organic field but just as deterministic.
|
||
FastNoiseLite jitterNoise = s.StampEdgeJitter > 0f
|
||
? TerrainNoise.CreateModulation(seed, s.SeedOffset + 1, 1.5f / MathF.Max(s.StampRadiusFrac, 1e-4f), scale)
|
||
: null;
|
||
int reachRadius = (int)MathF.Ceiling(radius * (1f + s.StampEdgeJitter));
|
||
int gap = Math.Max(1, (int)MathF.Round(s.LandGapFrac * mapSize));
|
||
float sepPx = s.SeparationFrac * mapSize;
|
||
float zoneFloorDepth = s.MinDepthM + s.DepthFeatherM; // zone weight exactly 1
|
||
float zoneFloorFalloff = s.MinFalloff + s.FalloffFeather;
|
||
var placed = new List<(int x, int y)>();
|
||
|
||
foreach (byte hemi in new[] { OffshoreAnalysis.HemiNorth, OffshoreAnalysis.HemiSouth })
|
||
{
|
||
int need = hemi == OffshoreAnalysis.HemiNorth ? s.FloorNorth : s.FloorSouth;
|
||
if (need <= 0) continue;
|
||
string name = OffshoreAnalysis.HemisphereName(hemi);
|
||
int y0 = hemi == OffshoreAnalysis.HemiNorth ? 0 : mapSize / 2;
|
||
int y1 = hemi == OffshoreAnalysis.HemiNorth ? mapSize / 2 : mapSize;
|
||
|
||
int got = 0, attempts = 0;
|
||
while (got < need)
|
||
{
|
||
if (++attempts > s.MaxPlacementAttempts)
|
||
throw new InvalidOperationException(
|
||
$"[OffshorePass] could not place the seeded floor: {name} needed {need}, placed {got} " +
|
||
$"after {attempts - 1} attempts. The valid ocean zone is too small or too crowded " +
|
||
$"for sep {s.SeparationFrac:F3} / gap {s.LandGapFrac:F3} / trench ≤ {s.TrenchInner:F2}. " +
|
||
"Refusing rather than under-delivering a guaranteed floor.");
|
||
|
||
int cx = rng.NextInt(mapSize);
|
||
int cy = y0 + rng.NextInt(y1 - y0);
|
||
|
||
// Valid ocean: below sea, FULLY inside the zone (moat + falloff + trench),
|
||
// so the centre's own weight is exactly 1 and the stamp surfaces.
|
||
float hc = height[cx, cy];
|
||
if (hc >= sea) continue;
|
||
if (WorldScale.MetresFromRaw(sea - hc) < zoneFloorDepth) continue;
|
||
if (preTrench[cx, cy] < zoneFloorFalloff) continue;
|
||
float dTr = MathF.Max(MathF.Abs(cx - centerX) / halfSpan, MathF.Abs(cy - centerY) / halfSpan);
|
||
if (dTr > s.TrenchInner) continue;
|
||
|
||
// Separation from the other seeded centres.
|
||
bool tooClose = false;
|
||
foreach (var (px, py) in placed)
|
||
{
|
||
float ddx = px - cx, ddy = py - cy;
|
||
if (ddx * ddx + ddy * ddy < sepPx * sepPx) { tooClose = true; break; }
|
||
}
|
||
if (tooClose) continue;
|
||
|
||
// ⭐ THE LAND GAP — the guarantee's mechanism. No land of ANY kind (mainland or
|
||
// organic) within radius + gap of the centre, so this stamp can touch nothing
|
||
// and is its own connected component by construction.
|
||
if (LandWithin(height, mapSize, cx, cy, reachRadius + gap, sea)) continue;
|
||
|
||
long lifted = Stamp(height, preTrench, r, cx, cy, radius, s, sea, crest, mapSize, centerX, centerY, halfSpan, jitterNoise);
|
||
if (lifted == 0)
|
||
throw new InvalidOperationException(
|
||
$"[OffshorePass] a seeded stamp at ({cx},{cy}) lifted no cells — the zone test and the stamp disagree. Refusing.");
|
||
|
||
placed.Add((cx, cy));
|
||
r.Centres.Add((cx, cy, radius));
|
||
r.LiftedSeeded += lifted;
|
||
got++;
|
||
r.Notes.Add($"[Offshore] floor {name} #{got} at ({cx},{cy}) r{radius}: lifted {lifted:N0} cells (attempt {attempts})");
|
||
}
|
||
}
|
||
}
|
||
|
||
// ═══ 3b. THE DEBRIS GUARD — reshape only; runs LAST, over everything the pass raised ═══
|
||
//
|
||
// Two kinds of debris, one rule. (a) SPECKS: a local maximum that barely clears the
|
||
// threshold surfaces a cap of a few cells — not an island. (b) SUBMERGED BUMPS: every
|
||
// blob with any weight lerps the seabed toward the crest whether or not it surfaces, so
|
||
// the ocean fills with shallow humps — which the first probe plate showed as a field of
|
||
// pale speckles across the southern sea, and which the water pass would later draw as a
|
||
// reef field nobody asked for. (The faithful control keeps both; that is the reference.)
|
||
//
|
||
// The rule: find the surviving islands (components at or above the minimum area), take
|
||
// each one's bounding box with a margin as its KEEP region, and put every organic raise
|
||
// OUTSIDE all keep regions back to the height it had — surfaced or not, tagged or not. A
|
||
// real island keeps its own submerged skirt; everything else goes back to seabed.
|
||
//
|
||
// ⚠ Runs after the floor so a stamp's rim satellite (w·zone crossing the waterline
|
||
// non-monotonically) is caught too; the stamp bodies are ~800+ cells and survive by
|
||
// construction. The floor is still asserted below.
|
||
if (guardOn && (r.RaisedOrigin.Count > 0 || r.LiftedOrigin.Count > 0))
|
||
{
|
||
long minCells = Math.Max(1L, (long)Math.Round(s.MinIslandAreaFrac * (double)mapSize * mapSize));
|
||
var pre = OffshoreAnalysis.Components(r.Tag, height, sea, mapSize, out int[] compId);
|
||
|
||
// Rule 1 — SPECKS go by MEMBERSHIP: any surfaced cell whose component is below the
|
||
// minimum is reverted and untagged, wherever it sits (a speck inside a real island's
|
||
// skirt is still a speck — the first keep-box cut let those survive as 1-cell islands).
|
||
// Rule 2 — SUBMERGED BUMPS go by LOCATION: a raise outside every surviving island's
|
||
// skirt (bbox + margin) goes back to seabed; inside, it is that island's own slope.
|
||
var small = new HashSet<int>();
|
||
var keep = new List<(int x0, int y0, int x1, int y1)>();
|
||
foreach (var c in pre)
|
||
{
|
||
if (c.Cells < minCells) { small.Add(c.Id); continue; }
|
||
int w = c.MaxX - c.MinX + 1, hgt = c.MaxY - c.MinY + 1;
|
||
int margin = Math.Max(4, Math.Max(w, hgt) / 2);
|
||
keep.Add((c.MinX - margin, c.MinY - margin, c.MaxX + margin, c.MaxY + margin));
|
||
}
|
||
|
||
bool Kept(int x, int y)
|
||
{
|
||
foreach (var (x0, y0, x1, y1) in keep)
|
||
if (x >= x0 && x <= x1 && y >= y0 && y <= y1) return true;
|
||
return false;
|
||
}
|
||
|
||
long revertedRaised = 0, revertedLifted = 0;
|
||
foreach (var (x, y, h0) in r.LiftedOrigin)
|
||
{
|
||
if (!small.Contains(compId[x * mapSize + y])) continue;
|
||
height[x, y] = h0; r.Tag[x, y] = false; r.Hemi[x, y] = OffshoreAnalysis.HemiNone;
|
||
revertedLifted++;
|
||
}
|
||
foreach (var (x, y, h0) in r.RaisedOrigin)
|
||
{
|
||
if (r.Tag[x, y]) continue; // a kept island's own surfaced cell
|
||
if (Kept(x, y)) continue; // inside a kept island's skirt
|
||
if (height[x, y] > h0) { height[x, y] = h0; revertedRaised++; }
|
||
}
|
||
r.LiftedReverted = revertedLifted;
|
||
r.RaisedReverted = revertedRaised;
|
||
r.Notes.Add($"[Offshore] debris guard: {small.Count} of {pre.Count} islands below {minCells:N0} cells reverted " +
|
||
$"({revertedLifted:N0} surfaced cells), plus {revertedRaised:N0} submerged bump cells outside any kept island's skirt.");
|
||
}
|
||
|
||
// ═══ PROVE THE FLOOR ON THE FINISHED FIELD — in the pass, before anyone looks ═══
|
||
var comps = OffshoreAnalysis.Components(r.Tag, height, sea, mapSize);
|
||
(r.CountNorth, r.CountSouth) = OffshoreAnalysis.CountByHemisphere(comps);
|
||
r.Bridged = OffshoreAnalysis.BridgedCount(comps);
|
||
var (szMin, szMed, szMean, szMax, _) = OffshoreAnalysis.SizeSummary(comps, 0);
|
||
r.Notes.Add($"[Offshore] islands: {r.CountNorth} north, {r.CountSouth} south ({comps.Count} components, " +
|
||
$"{r.Bridged} bridged to mainland); lifted {r.LiftedOrganic + r.LiftedSeeded - r.LiftedReverted:N0} cells net" +
|
||
$"{(r.RaisedReverted > 0 ? $", {r.RaisedReverted:N0} submerged debris cells reverted" : "")}; " +
|
||
$"size cells min {szMin} median {szMed} mean {szMean:F0} max {szMax}.");
|
||
|
||
if (s.Mode == OffshoreMode.Hybrid && (r.CountNorth < s.FloorNorth || r.CountSouth < s.FloorSouth))
|
||
throw new InvalidOperationException(
|
||
$"[OffshorePass] FLOOR VIOLATION after placement: {r.CountNorth} N / {r.CountSouth} S against " +
|
||
$"{s.FloorNorth} N / {s.FloorSouth} S. The land gap should have made this impossible. Refusing.");
|
||
if (r.Bridged > 0)
|
||
throw new InvalidOperationException(
|
||
$"[OffshorePass] MOAT VIOLATION: {r.Bridged} island(s) touch mainland land. Refusing.");
|
||
|
||
return r;
|
||
}
|
||
|
||
/// <summary>One seeded stamp: a flat-topped disc lerped toward the crest, zone-masked per pixel.</summary>
|
||
private static long Stamp(float[,] height, float[,] preTrench, Result r, int cx, int cy, int radius,
|
||
OffshoreSettings s, float sea, float crest, int mapSize, float centerX, float centerY, float halfSpan,
|
||
FastNoiseLite jitterNoise)
|
||
{
|
||
long lifted = 0;
|
||
int reach = (int)MathF.Ceiling(radius * (1f + s.StampEdgeJitter));
|
||
for (int dx = -reach; dx <= reach; dx++)
|
||
{
|
||
int px = cx + dx;
|
||
if (px < 0 || px >= mapSize) continue;
|
||
for (int dy = -reach; dy <= reach; dy++)
|
||
{
|
||
int py = cy + dy;
|
||
if (py < 0 || py >= mapSize) continue;
|
||
|
||
// The rim wanders: the effective radius at this pixel is the nominal one scaled
|
||
// by ±jitter from a fine noise field. Flat top and crisp shore are kept; the
|
||
// compass-drawn outline is not.
|
||
float rEff = radius;
|
||
if (jitterNoise != null)
|
||
rEff = radius * (1f + s.StampEdgeJitter * jitterNoise.GetNoise2D(px, py));
|
||
|
||
float dist = MathF.Sqrt(dx * dx + dy * dy);
|
||
float w = IslandFalloff.StampWeight(dist, rEff, s.StampCoreFrac);
|
||
if (w <= 0f) continue;
|
||
|
||
float h = height[px, py];
|
||
if (h >= sea) continue; // never touches land
|
||
|
||
// ⭐ The moat and the falloff test apply to the FLOOR too. Absolute.
|
||
float zone = IslandFalloff.OffshoreZoneWeight(
|
||
WorldScale.MetresFromRaw(sea - h), preTrench[px, py],
|
||
MathF.Abs(px - centerX) / halfSpan, MathF.Abs(py - centerY) / halfSpan,
|
||
s.MinDepthM, s.DepthFeatherM, s.MinFalloff, s.FalloffFeather,
|
||
s.TrenchInner, s.TrenchOuter);
|
||
float t = w * zone;
|
||
if (t <= 0f) continue;
|
||
|
||
float before = h;
|
||
h = h + (crest - h) * t;
|
||
height[px, py] = h;
|
||
|
||
if (before < sea && h >= sea)
|
||
{
|
||
lifted++;
|
||
r.Tag[px, py] = true;
|
||
r.Hemi[px, py] = OffshoreAnalysis.HemisphereOfRow(py, mapSize);
|
||
r.LiftedOrigin.Add((px, py, before));
|
||
}
|
||
}
|
||
}
|
||
return lifted;
|
||
}
|
||
|
||
/// <summary>Any land cell (at/above sea) within a Chebyshev box of <paramref name="reach"/>? Conservative by design.</summary>
|
||
private static bool LandWithin(float[,] height, int mapSize, int cx, int cy, int reach, float sea)
|
||
{
|
||
int x0 = Math.Max(0, cx - reach), x1 = Math.Min(mapSize - 1, cx + reach);
|
||
int y0 = Math.Max(0, cy - reach), y1 = Math.Min(mapSize - 1, cy + reach);
|
||
for (int x = x0; x <= x1; x++)
|
||
for (int y = y0; y <= y1; y++)
|
||
if (height[x, y] >= sea) return true;
|
||
return false;
|
||
}
|
||
|
||
private static float Min(float[] a) { float m = float.MaxValue; foreach (float v in a) if (v < m) m = v; return m; }
|
||
private static float Max(float[] a) { float m = float.MinValue; foreach (float v in a) if (v > m) m = v; return m; }
|
||
|
||
/// <summary>
|
||
/// PCG32 (O'Neill) — the same tiny deterministic generator the reference's erosion pass
|
||
/// used. Seeded from the world seed + an offset, so island POSITIONS vary per world and
|
||
/// reproduce exactly for one. Trivially portable to C++.
|
||
/// </summary>
|
||
private struct Pcg32
|
||
{
|
||
private ulong _state;
|
||
public Pcg32(int seed) { _state = 0; NextU(); _state += (ulong)(uint)seed; NextU(); }
|
||
public uint NextU()
|
||
{
|
||
ulong old = _state;
|
||
_state = old * 6364136223846793005UL + 1442695040888963407UL;
|
||
uint xorshifted = (uint)(((old >> 18) ^ old) >> 27);
|
||
int rot = (int)(old >> 59);
|
||
return (xorshifted >> rot) | (xorshifted << (-rot & 31));
|
||
}
|
||
public int NextInt(int n) => (int)(NextU() % (uint)n);
|
||
}
|
||
}
|
||
}
|