Compare commits
11 commits
5720f05a5b
...
1b98fb5a17
| Author | SHA1 | Date | |
|---|---|---|---|
| 1b98fb5a17 | |||
| 822c1530c3 | |||
| 21dc3d16ce | |||
| 45fe2ce034 | |||
| c71026fd94 | |||
| 4797086a13 | |||
| 02577872f2 | |||
| 68f7bca8e0 | |||
| 4d2e024b2f | |||
| a046486a58 | |||
| ae7431ac87 |
10 changed files with 735 additions and 213 deletions
|
|
@ -55,7 +55,8 @@ Every section: `[u32 tag][u64 payload-length in bytes][payload]`.
|
|||
| `HGTS` / `0x53544748` | `MapSize²` × `f32` height, **X outer / Y inner** | Mandatory. The second index is the map's north/south axis; the server consumes it as world **Z**. (This axis convention was never written down for v1 — it is now normative.) Length must equal `4·MapSize²`. |
|
||||
| `BIOM` / `0x4D4F4942` | `MapSize²` × `u8` biome ordinal, same pixel order | Mandatory. Ordinals from `Enums.cs::Biome` — **append-only, never reorder** (the ordinal IS the wire value). Writer refuses ordinals > 255; reader rejects ordinals ≥ the known biome count (parse-time validation; the palette's runtime Dirt fallback for in-memory values is unchanged). Length must equal `MapSize²`. |
|
||||
| `TOWN` / `0x4E574F54` | `i32 count`, then per town: `f32 X` · `f32 Y` · `u8 tier` · `u8 isHighwayNode` (0/1) | Tier ordinals from `Enums.cs::TownTier`, append-only, range-checked on read. The highway-node flag is what the generator's road topology was built from (v1 dropped it); carried and exposed on the parsed blueprint, consumed by nothing server-side yet. |
|
||||
| `TCRV` / `0x56524354` | 50 B: `u16 curveVersion` · input knots `f32 t1..t4` · `f32 spikeMax` · output bands `f32 sea` · `f32 orangeCeil` · `f32 redCeil` · `f32 plateauLo` · `f32 plateauHi` · `f32 peakCap` · `f32 tailSlope` | Optional — present iff the height-redistribution curve shaped this blueprint's `HGTS` (config `TerrainCurve: "v2"`); absent = raw legacy profile. **`spikeMax` semantics by version:** v1 = the pooled calibration max (identical every seed); **v2 = the SEED'S own effective raw pre-curve maximum** — the per-seed spike normalizer, which is why blueprints are no longer reproducible from curve constants alone and the value is recorded here. **Metadata only:** exported heights are already curved; nothing re-applies the map. The curve lives in `Tools/Scripts/HeightCurve.cs` (calibration provenance in its header + the task-05/06 reports). |
|
||||
| `TCRV` / `0x56524354` | 50 B base: `u16 curveVersion` · `f32 knot1..knot4` · `f32 spikeMax` · `f32 sea` · `f32 orangeCeil` · `f32 redCeil` · `f32 benchLo` · `f32 benchHi` · `f32 peakCap` · `f32 tailSlope`. **When `curveVersion ≥ 4`, a 36 B modulation extension follows:** `f32 benchAmp` · `f32 plateauAmp` · `f32 shelfSpanMin` · `f32 shelfSpanMax` · `f32 elevFreqIslands` · `f32 strengthFreqIslands` · `i32 benchSeedOffset` · `i32 plateauSeedOffset` · `i32 strengthSeedOffset`. **When `curveVersion ≥ 5`, a 9 B preset extension follows:** `u8 presetId` (1 = compact, 2 = balanced) · `f32 k5` · `f32 k6` — with the four base knot slots this makes the effective curve unambiguous from the record alone (record total 95 B; the length-framed section + version byte keep every layout change safe) | Optional — present iff the height-redistribution curve shaped this blueprint's `HGTS` (config `TerrainCurve: "v3"`); absent = raw legacy profile. **The `u16 curveVersion` selects the field semantics and the unserialized anchor set:** v1 — knots t1..t4, spikeMax = pooled calibration max, benchLo/Hi = the 50 m plateau's lo/hi. v2 — as v1 but spikeMax = the seed's effective raw pre-curve maximum (per-seed spike normalizer). v3 — knots = K1..K4 of six (K5 = 0.930304, K6 = 1.050720 are version constants, not serialized), spikeMax per-seed, benchLo/benchHi = the fixed 100 m / 220 m shelves. **v4 (current)** — as v3, but benchLo/benchHi are the shelf **BASE** anchors and the extension record carries the spatial-modulation parameters: shelf elevations vary ±benchAmp/±plateauAmp and shelf strength blends the output span across `[shelfSpanMin, shelfSpanMax]`, via Simplex fields seeded `resolvedWorldSeed + seedOffset` at `freq/MapSize` (frequencies stated in undulations per island width). Because spikeMax is per-seed (v2+), blueprints are not reproducible from curve constants alone — that is why it is recorded. **Metadata only:** exported heights are already curved; nothing re-applies the map. Calibration provenance: `HeightCurve.cs` header + the task-05/06/07 reports. |
|
||||
| `TDTL` / `0x4C544454` | 38 B: `u16 detailVersion` · `f32 reliefAmpM` · `f32 reliefFreqIslands` · `f32 incK` · `f32 incP` · `f32 incCapM` · `f32 seaClampRaw` · `f32 craterExclFactor` · `f32 shelfIncWeight` · `i32 reliefSeedOffset` | Optional — present iff the terrain detail passes shaped this blueprint's `HGTS` (config `TerrainDetail: "v1"`, requires the curve): shelf micro-relief (±reliefAmpM, shelf-ness weighted) and D8 drainage incision (`depth = K·accum^p·slope`, capped, riser-masked, sea+1 m clamped, crater-excluded). **Metadata only** — heights are already detailed; the incision channels are the designated future river routes (Phase C). Machinery: `Tools/Scripts/TerrainDetailPass.cs`. |
|
||||
| `WBID` / `0x44494257` | `MapSize²` × `u16` water-body id, same pixel order as `HGTS` | Optional (absent = no water data, e.g. a legacy re-encode). `0` = no water, `1` = **the** ocean body, `2..N` = lakes. Ids assigned in deterministic scan order (X outer / Y inner, first-encountered pixel), lakes labeled with the **same 4-connectivity as `CalculateTrueOcean`**. Membership is exactly the generator's water classification — the biome grid's Ocean/Lake pixels and this grid's nonzero pixels are the same set **by construction** (shared predicates). Length must equal `2·MapSize²`. |
|
||||
| `WBTB` / `0x42544257` | `i32 count`, then per body (20 B): `u16 id` · `u8 type` (0 ocean, 1 lake) · `u8 salinity` (0 fresh, 1 salt) · `f32 surfaceLevel` · `i32 pixelCount` · `f32 centroidX` · `f32 centroidY` | Optional, paired with `WBID`. **`surfaceLevel` is a documented TRANSITIONAL rule:** one flat level per body — `GetSeaLevel` at the body's pixel centroid (ocean: at the map centre) under the still-live latitude field; superseded by the flat-scalar sea model (minted, lands with the coast change set). The field's per-pixel slope is deliberately NOT baked into any section. **Salinity is a provisional default** (ocean salt, lake fresh) — a placeholder for the future fresh/salt irrigation mechanic, not a mechanic. |
|
||||
| `WSRF` / `0x46525357` | `MapSize²` × `u16` quantized water-surface elevation, same pixel order | Optional, paired with `WBID`. `0` is the reserved **no-water sentinel**; a real level `L` (raw height units) encodes as `1 + round(L × 32768)` so it can never encode to 0; decode `(q − 1)/32768` (`BlueprintFormat.EncodeWaterLevel`/`DecodeWaterLevel`). Covers `[0 … ~1.99997]` raw at `1/32768` raw ≈ **7.7 mm** of world height (1 raw = 251 m) — far finer than the 1 m voxel. Nonzero exactly where `WBID` is nonzero; the value is the pixel's body level. |
|
||||
|
|
@ -106,12 +107,17 @@ body).
|
|||
which model produced it (the `WBTB` levels are the observable).
|
||||
- **`SkipRoads`** (bool, default false) — exports with present-but-empty road sections (see the
|
||||
road-section table note).
|
||||
- **`TerrainCurve`** (`"v2"` | `"off"`, default `"v2"`; `"v1"` retired by the task-06
|
||||
recalibration and rejected loudly) — whether the height-redistribution curve (storm ladder:
|
||||
75 % orange coverage, 50 m plateau, **per-seed-normalized 420 m peaks**) shapes `HGTS`.
|
||||
Biome/water classification is curve-invariant by construction (it classifies the retained
|
||||
uncurved heights); town positions and everything 3D follow the curved terrain. When on,
|
||||
`TCRV` records the effective parameters including the per-seed `spikeMax`.
|
||||
- **`TerrainCurve`** (`"v5"` | `"off"`, default `"v5"` — the task-09 gate's winner, the
|
||||
BALANCED preset; the tri-state and `"v1"`–`"v4"` are retired and rejected loudly) — whether
|
||||
the height-redistribution curve (terraced ascent, corner-eased junctions, spatially modulated
|
||||
shelves at 100±12 m / 220±20 m, per-seed-normalized 420 m summit spires, 60 % lowland) shapes
|
||||
`HGTS`.
|
||||
- **`TerrainDetail`** (`"v1"` | `"off"`, default `"v1"`; no-op without the curve) — the task-10
|
||||
detail passes as one judged unit: shelf micro-relief (`ShelfReliefAmp` metres, default 3) and
|
||||
drainage incision. When on, `TDTL` records the parameters. Biome/water classification is curve-invariant by
|
||||
construction (it classifies the retained uncurved heights); town positions and everything 3D
|
||||
follow the curved terrain. When on, `TCRV` records the effective parameters including the
|
||||
per-seed `spikeMax`.
|
||||
|
||||
### Deliberately NOT a section: basins (`BSIN`)
|
||||
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ namespace IslaApocalypse.Core
|
|||
public const uint TAG_WATER_BODY_TABLE = 0x42544257; // "WBTB"
|
||||
public const uint TAG_WATER_SURFACE = 0x46525357; // "WSRF"
|
||||
public const uint TAG_TERRAIN_CURVE = 0x56524354; // "TCRV"
|
||||
public const uint TAG_TERRAIN_DETAIL = 0x4C544454; // "TDTL"
|
||||
|
||||
// WSRF quantization: u16, 0 reserved as the no-water sentinel. A real level L
|
||||
// (raw blueprint height units) encodes as 1 + round(L × 32768), so a genuine
|
||||
|
|
|
|||
|
|
@ -33,6 +33,8 @@ namespace IslaApocalypse.Core
|
|||
WriteSection(writer, BlueprintFormat.TAG_PARAMS, w => WriteParams(w, bp));
|
||||
if (bp.TerrainCurve != null)
|
||||
WriteSection(writer, BlueprintFormat.TAG_TERRAIN_CURVE, w => WriteTerrainCurve(w, bp.TerrainCurve));
|
||||
if (bp.TerrainDetail != null)
|
||||
WriteSection(writer, BlueprintFormat.TAG_TERRAIN_DETAIL, w => WriteTerrainDetail(w, bp.TerrainDetail));
|
||||
WriteSection(writer, BlueprintFormat.TAG_HEIGHTS, w => WriteHeights(w, bp));
|
||||
WriteSection(writer, BlueprintFormat.TAG_BIOMES, w => WriteBiomes(w, bp));
|
||||
|
||||
|
|
@ -132,6 +134,33 @@ namespace IslaApocalypse.Core
|
|||
writer.Write(c.Sea); writer.Write(c.OrangeCeil); writer.Write(c.RedCeil);
|
||||
writer.Write(c.PlateauLo); writer.Write(c.PlateauHi); writer.Write(c.PeakCap);
|
||||
writer.Write(c.TailSlope);
|
||||
|
||||
// v4+ extension: shelf-modulation parameters.
|
||||
if (c.Version >= 4)
|
||||
{
|
||||
writer.Write(c.BenchAmp); writer.Write(c.PlateauAmp);
|
||||
writer.Write(c.ShelfSpanMin); writer.Write(c.ShelfSpanMax);
|
||||
writer.Write(c.ElevFreqIslands); writer.Write(c.StrengthFreqIslands);
|
||||
writer.Write(c.BenchSeedOffset); writer.Write(c.PlateauSeedOffset);
|
||||
writer.Write(c.StrengthSeedOffset);
|
||||
}
|
||||
|
||||
// v5+ extension: knot preset id + K5/K6.
|
||||
if (c.Version >= 5)
|
||||
{
|
||||
writer.Write(c.PresetId);
|
||||
writer.Write(c.K5); writer.Write(c.K6);
|
||||
}
|
||||
}
|
||||
|
||||
private static void WriteTerrainDetail(BinaryWriter writer, TerrainDetailInfo d)
|
||||
{
|
||||
writer.Write(d.Version);
|
||||
writer.Write(d.ReliefAmpM); writer.Write(d.ReliefFreqIslands);
|
||||
writer.Write(d.IncK); writer.Write(d.IncP); writer.Write(d.IncCapM);
|
||||
writer.Write(d.SeaClampRaw); writer.Write(d.CraterExclFactor);
|
||||
writer.Write(d.ShelfIncWeight);
|
||||
writer.Write(d.ReliefSeedOffset);
|
||||
}
|
||||
|
||||
private static void WriteWaterBodyIds(BinaryWriter writer, WorldBlueprint bp)
|
||||
|
|
|
|||
|
|
@ -26,12 +26,19 @@ namespace IslaApocalypse.Core // Change this if your namespace is different
|
|||
public static string SeaLevelModel = "flat";
|
||||
public static float SeaLevelValue = 0.15f;
|
||||
|
||||
// Height-redistribution curve (tasks 05/06, graduation M-7): "v2" applies the
|
||||
// calibrated storm-ladder curve with the per-seed peak spike (HeightCurve.cs);
|
||||
// "off" is the raw legacy profile. "v1" was dropped with the v2 recalibration
|
||||
// (task-05 blueprints are regenerable). Biome classification is curve-invariant
|
||||
// by construction either way. Default: v2.
|
||||
public static string TerrainCurve = "v2";
|
||||
// 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 + drainage
|
||||
// incision as one judged unit (requires the curve; no-op when it is off);
|
||||
// "off" disables both. ShelfReliefAmp is the micro-relief amplitude in
|
||||
// metres. Defaults: v1, 3 m.
|
||||
public static string TerrainDetail = "v1";
|
||||
public static float ShelfReliefAmp = 3.0f;
|
||||
|
||||
public static void LoadConfig()
|
||||
{
|
||||
|
|
@ -106,14 +113,32 @@ namespace IslaApocalypse.Core // Change this if your namespace is different
|
|||
if (data.ContainsKey("TerrainCurve"))
|
||||
{
|
||||
string curve = (string)data["TerrainCurve"];
|
||||
if (curve == "off" || curve == "v2")
|
||||
if (curve == "off" || curve == "v5")
|
||||
TerrainCurve = curve;
|
||||
else if (curve == "v1")
|
||||
GD.PrintErr($"[ConfigManager] TerrainCurve 'v1' was retired by the v2 recalibration (task 06). Keeping '{TerrainCurve}' — use \"v2\" or \"off\".");
|
||||
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"];
|
||||
}
|
||||
|
||||
switch (profile)
|
||||
{
|
||||
case "4K":
|
||||
|
|
|
|||
|
|
@ -74,6 +74,31 @@ namespace IslaApocalypse.Core
|
|||
// constants alone, which is exactly why it is recorded here.
|
||||
public float T1, T2, T3, T4, SpikeMax;
|
||||
public float Sea, OrangeCeil, RedCeil, PlateauLo, PlateauHi, PeakCap, TailSlope; // output bands
|
||||
|
||||
// v4 extension — shelf-modulation parameters (record layout versioned by the
|
||||
// curve Version byte; zero-valued when parsing an older record).
|
||||
public float BenchAmp, PlateauAmp, ShelfSpanMin, ShelfSpanMax;
|
||||
public float ElevFreqIslands, StrengthFreqIslands;
|
||||
public int BenchSeedOffset, PlateauSeedOffset, StrengthSeedOffset;
|
||||
|
||||
// v5 extension — knot preset (1 = compact, 2 = balanced) and the two knots the
|
||||
// four base slots cannot carry; with these the effective curve is unambiguous.
|
||||
public byte PresetId;
|
||||
public float K5, K6;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The terrain detail passes that shaped this blueprint's HGTS (v2 TDTL section,
|
||||
/// terrain-water task 10): shelf micro-relief + drainage incision parameters.
|
||||
/// Null when detail was off. Metadata only — heights are already detailed.
|
||||
/// </summary>
|
||||
public class TerrainDetailInfo
|
||||
{
|
||||
public ushort Version;
|
||||
public float ReliefAmpM, ReliefFreqIslands;
|
||||
public float IncK, IncP, IncCapM;
|
||||
public float SeaClampRaw, CraterExclFactor, ShelfIncWeight;
|
||||
public int ReliefSeedOffset;
|
||||
}
|
||||
|
||||
public class WorldBlueprint
|
||||
|
|
@ -103,6 +128,9 @@ namespace IslaApocalypse.Core
|
|||
|
||||
// The curve that shaped HeightMap (v2 TCRV section); null = raw legacy profile.
|
||||
public TerrainCurveInfo TerrainCurve;
|
||||
|
||||
// The detail passes that shaped HeightMap (TDTL section); null = no detail.
|
||||
public TerrainDetailInfo TerrainDetail;
|
||||
}
|
||||
|
||||
// 2. The Parser Utility
|
||||
|
|
@ -226,6 +254,7 @@ namespace IslaApocalypse.Core
|
|||
else if (tag == BlueprintFormat.TAG_WATER_SURFACE) sectionOk = ParseWaterGrid(reader, blueprint, payloadLength, isSurface: true);
|
||||
else if (tag == BlueprintFormat.TAG_WATER_BODY_TABLE) sectionOk = ParseWaterBodyTable(reader, blueprint);
|
||||
else if (tag == BlueprintFormat.TAG_TERRAIN_CURVE) sectionOk = ParseTerrainCurve(reader, blueprint);
|
||||
else if (tag == BlueprintFormat.TAG_TERRAIN_DETAIL) sectionOk = ParseTerrainDetail(reader, blueprint);
|
||||
else
|
||||
{
|
||||
// The property the redesign exists to buy: future sections (water,
|
||||
|
|
@ -399,10 +428,39 @@ namespace IslaApocalypse.Core
|
|||
c.RedCeil = reader.ReadSingle(); c.PlateauLo = reader.ReadSingle();
|
||||
c.PlateauHi = reader.ReadSingle(); c.PeakCap = reader.ReadSingle();
|
||||
c.TailSlope = reader.ReadSingle();
|
||||
|
||||
// v4+ extension: shelf-modulation parameters (older records simply end here —
|
||||
// the section is length-framed, so the version byte is the dispatcher).
|
||||
if (c.Version >= 4)
|
||||
{
|
||||
c.BenchAmp = reader.ReadSingle(); c.PlateauAmp = reader.ReadSingle();
|
||||
c.ShelfSpanMin = reader.ReadSingle(); c.ShelfSpanMax = reader.ReadSingle();
|
||||
c.ElevFreqIslands = reader.ReadSingle(); c.StrengthFreqIslands = reader.ReadSingle();
|
||||
c.BenchSeedOffset = reader.ReadInt32(); c.PlateauSeedOffset = reader.ReadInt32();
|
||||
c.StrengthSeedOffset = reader.ReadInt32();
|
||||
}
|
||||
if (c.Version >= 5)
|
||||
{
|
||||
c.PresetId = reader.ReadByte();
|
||||
c.K5 = reader.ReadSingle(); c.K6 = reader.ReadSingle();
|
||||
}
|
||||
blueprint.TerrainCurve = c;
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool ParseTerrainDetail(BinaryReader reader, WorldBlueprint blueprint)
|
||||
{
|
||||
var d = new TerrainDetailInfo();
|
||||
d.Version = reader.ReadUInt16();
|
||||
d.ReliefAmpM = reader.ReadSingle(); d.ReliefFreqIslands = reader.ReadSingle();
|
||||
d.IncK = reader.ReadSingle(); d.IncP = reader.ReadSingle(); d.IncCapM = reader.ReadSingle();
|
||||
d.SeaClampRaw = reader.ReadSingle(); d.CraterExclFactor = reader.ReadSingle();
|
||||
d.ShelfIncWeight = reader.ReadSingle();
|
||||
d.ReliefSeedOffset = reader.ReadInt32();
|
||||
blueprint.TerrainDetail = d;
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool ParseRoadTier(BinaryReader reader, List<Vector2[]> into)
|
||||
{
|
||||
int pathCount = reader.ReadInt32();
|
||||
|
|
|
|||
|
|
@ -1,135 +1,183 @@
|
|||
using Godot;
|
||||
|
||||
/// <summary>
|
||||
/// The height-redistribution curve, v2 (terrain-water task 06) — a pure, static,
|
||||
/// monotonic piecewise map over raw blueprint heights (D-035: numbers in, numbers
|
||||
/// out; the per-seed spike maximum is an explicit PARAMETER, not hidden state).
|
||||
/// One preset's input knots for the v5 curve. Two presets exist for the task-09
|
||||
/// taste batch — COMPACT (maximum lowland, cordillera-from-plains) and BALANCED
|
||||
/// (the task-07/08 lineage, gradual highland approach). The loser retires after
|
||||
/// the developer's gate; the winner becomes plain "v5".
|
||||
/// </summary>
|
||||
public sealed class CurveKnots
|
||||
{
|
||||
public readonly byte PresetId;
|
||||
public readonly string Name;
|
||||
public readonly float K1, K2, K3, K4, K5, K6;
|
||||
|
||||
public CurveKnots(byte id, string name, float k1, float k2, float k3, float k4, float k5, float k6)
|
||||
{
|
||||
PresetId = id; Name = name;
|
||||
K1 = k1; K2 = k2; K3 = k3; K4 = k4; K5 = k5; K6 = k6;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The height-redistribution curve, v5 (terrain-water task 09) — v4's spatially
|
||||
/// modulated shelves plus the THREE CORNER FIXES, with PRESET-PARAMETERIZED knots.
|
||||
/// Pure, static, monotonic; every per-column input and the knot set are explicit
|
||||
/// PARAMETERS (D-035).
|
||||
///
|
||||
/// v2 changes (developer's task-05 hillshade-gate verdict; everything below the
|
||||
/// plateau step is behaviorally byte-identical to v1):
|
||||
/// - PER-SEED SPIKE NORMALIZATION: the spike's input domain runs from t4 to the
|
||||
/// current seed's own raw pre-curve maximum (hMaxSeed), so every island's
|
||||
/// tallest pixel reaches the ceiling — v1 mapped against the pooled
|
||||
/// calibration max and mid-range seeds topped out at 110–175 m.
|
||||
/// - STIFFER SPIKE: ease-in 0.1u + 0.9·u⁴ (was 0.2u + 0.8·u³) — a wall, not a ramp.
|
||||
/// - PEAK CEILING 420 m above sea (was 220 m).
|
||||
/// The corner fixes (both presets — the naturalness work):
|
||||
/// 1. Riser endpoint slope floor 0.2 → 0.1 (blend 0.1u + 0.9·smoothstep): climbs
|
||||
/// decelerate into shelves and accelerate out of them — no machined edges.
|
||||
/// 2. Summit-spike base floor 0.1 → 0.05 (blend 0.05u + 0.95·u⁴): the spike
|
||||
/// leaves the plateau gently — no hard skirt under the peaks.
|
||||
/// 3. SHELF_SPAN_MIN 2 m → 6 m: pronounced shelves keep a gentle tilt — flat to
|
||||
/// build on, never snooker-table flat.
|
||||
///
|
||||
/// Lower knots/bands are v1's, calibrated 2026-08-07 from batch 04's ten flat-sea
|
||||
/// heightmaps (pooled above-sea land CDF, 340,618,126 samples): P75/P90/P93/P96.
|
||||
/// Presets (input land-fraction targets; knots calibrated 2026-08-08 from the same
|
||||
/// pooled batch-04 flat-sea land CDF as tasks 05/07, 340,618,126 samples; achieved
|
||||
/// fractions exact by construction):
|
||||
/// COMPACT — 65/12/7/4/6/3/3 (orange/red/foothill-riser/bench/mid-riser/plateau/spike)
|
||||
/// BALANCED — 60/13/10/5/8/3/1
|
||||
///
|
||||
/// Shape (strictly monotonic; every segment's normalized slope bounded below by a
|
||||
/// positive constant; asserted numerically per generation against the EFFECTIVE
|
||||
/// per-seed curve once hMaxSeed is known):
|
||||
/// h ≤ sea (0.15) identity — water and the below-sea world untouched
|
||||
/// sea → t1 smooth toe, ease-out blend (gentle rolling, never flat)
|
||||
/// t1 → t2 linear rise into the red band
|
||||
/// t2 → t3 smooth shoulder up to the plateau shelf
|
||||
/// t3 → t4 near-flat plateau step (small positive slope)
|
||||
/// t4 → spikeMax accelerating u⁴ spike to the 420 m peak cap (per-seed domain)
|
||||
/// h > spikeMax linear tail (strict monotonicity, no clamp; reachable only
|
||||
/// in the degenerate near-flat guard case)
|
||||
/// Unchanged from v4: storm-ladder anchors, bench 100±12 m, plateau 220±20 m,
|
||||
/// strength modulation (span max 25 m), 420 m cap, per-seed spike normalization,
|
||||
/// modulation fields/seed offsets, the classify-map invariant.
|
||||
/// </summary>
|
||||
public static class HeightCurve
|
||||
{
|
||||
public const ushort VERSION = 2;
|
||||
public const ushort VERSION = 5;
|
||||
|
||||
// Input knots — v1 calibration, unchanged (see class header).
|
||||
public const float T1 = 0.628736f; // P75 — orange coverage boundary
|
||||
public const float T2 = 0.819152f; // P90
|
||||
public const float T3 = 0.879340f; // P93
|
||||
public const float T4 = 0.962922f; // P96
|
||||
// The task-09 taste gate's WINNER: BALANCED (id 2). COMPACT retired with the
|
||||
// verdict; its knots survive only in the task-09 report/batch for the record.
|
||||
public static readonly CurveKnots V5 = new CurveKnots(2, "balanced",
|
||||
0.515899f, 0.612157f, 0.710472f, 0.784045f, 0.962922f, 1.119118f); // P60/73/83/88/96/99
|
||||
|
||||
// Output bands — the storm ladder. Lower anchors unchanged from v1.
|
||||
// Fixed output anchors — storm ladder + ceiling (frozen).
|
||||
public const float SEA = 0.15f;
|
||||
public const float ORANGE_CEIL = 0.206f; // 1000-yr storm ceiling
|
||||
public const float RED_CEIL = 0.27f; // biblical ceiling
|
||||
public const float PLATEAU_LO = SEA + 50f / 251f; // ≈ 0.34924 (50 m above sea)
|
||||
public const float PLATEAU_HI = PLATEAU_LO + 0.02f; // ≈ 0.36924 (~5 m step relief)
|
||||
public const float PEAK_CAP = SEA + 420f / 251f; // ≈ 1.82869 (420 m above sea; v1: 220 m)
|
||||
public const float TAIL_SLOPE = 0.25f; // above spikeMax (degenerate guard only)
|
||||
|
||||
// Degenerate/near-flat guard: the spike domain is [T4, max(hMaxSeed, T4 + SPIKE_MIN_SPAN)],
|
||||
// so a pathological seed whose raw max sits at or below t4 still yields a positive,
|
||||
// monotonic domain (its cap is then simply never reached; heights above spikeMax — none in
|
||||
// practice — would ride the tail).
|
||||
public const float ORANGE_CEIL = 0.206f;
|
||||
public const float RED_CEIL = 0.27f;
|
||||
public const float PEAK_CAP = SEA + 420f / 251f;
|
||||
public const float TAIL_SLOPE = 0.25f;
|
||||
public const float SPIKE_MIN_SPAN = 0.01f;
|
||||
|
||||
/// <summary>The effective spike-domain top for a seed's raw maximum, guard applied.</summary>
|
||||
public static float EffectiveSpikeMax(float hMaxSeed)
|
||||
// Modulated shelf anchors (unchanged from v4).
|
||||
public const float BENCH_BASE = SEA + 100f / 251f;
|
||||
public const float BENCH_AMP = 12f / 251f;
|
||||
public const float PLATEAU_BASE = SEA + 220f / 251f;
|
||||
public const float PLATEAU_AMP = 20f / 251f;
|
||||
|
||||
// Corner fix 3: pronounced shelves keep ~6 m of tilt across the shelf band.
|
||||
public const float SHELF_SPAN_MIN = 6f / 251f; // ≈ 0.0239 (v4: 0.008 ≈ 2 m)
|
||||
public const float SHELF_SPAN_MAX = 0.10f;
|
||||
|
||||
// Modulation-field derivation (unchanged from v4).
|
||||
public const int BENCH_SEED_OFFSET = 7101;
|
||||
public const int PLATEAU_SEED_OFFSET = 7207;
|
||||
public const int STRENGTH_SEED_OFFSET = 7303;
|
||||
public const float ELEV_FREQ_ISLANDS = 3.0f;
|
||||
public const float STRENGTH_FREQ_ISLANDS = 5.0f;
|
||||
|
||||
public static float EffectiveSpikeMax(float hMaxSeed, CurveKnots k)
|
||||
{
|
||||
return Mathf.Max(hMaxSeed, T4 + SPIKE_MIN_SPAN);
|
||||
return Mathf.Max(hMaxSeed, k.K6 + SPIKE_MIN_SPAN);
|
||||
}
|
||||
|
||||
/// <param name="h">Raw pre-curve height.</param>
|
||||
/// <param name="hMaxSeed">The seed's raw pre-curve maximum (post noise/falloff/Trench/spine,
|
||||
/// pre-carve) — the same field the curve consumes. Makes the map seed-dependent (v2).</param>
|
||||
public static float Apply(float h, float hMaxSeed)
|
||||
public static float ShelfSpan(float strength01)
|
||||
{
|
||||
return Mathf.Lerp(SHELF_SPAN_MAX, SHELF_SPAN_MIN, Mathf.Clamp(strength01, 0f, 1f));
|
||||
}
|
||||
|
||||
public static float Apply(float h, float hMaxSeed,
|
||||
float benchLo, float benchSpan, float plateauLo, float plateauSpan, CurveKnots k)
|
||||
{
|
||||
if (h <= SEA) return h;
|
||||
|
||||
float u, s;
|
||||
if (h < T1)
|
||||
if (h < k.K1)
|
||||
{
|
||||
u = (h - SEA) / (T1 - SEA);
|
||||
s = 0.3f * u + 0.7f * (u * (2f - u)); // ease-out, slope ≥ 0.3
|
||||
u = (h - SEA) / (k.K1 - SEA);
|
||||
s = 0.3f * u + 0.7f * (u * (2f - u)); // frozen ease-out toe
|
||||
return SEA + s * (ORANGE_CEIL - SEA);
|
||||
}
|
||||
if (h < T2)
|
||||
if (h < k.K2)
|
||||
{
|
||||
u = (h - T1) / (T2 - T1);
|
||||
return ORANGE_CEIL + u * (RED_CEIL - ORANGE_CEIL); // linear
|
||||
u = (h - k.K1) / (k.K2 - k.K1);
|
||||
return ORANGE_CEIL + u * (RED_CEIL - ORANGE_CEIL); // frozen linear rise
|
||||
}
|
||||
if (h < T3)
|
||||
if (h < k.K3)
|
||||
{
|
||||
u = (h - T2) / (T3 - T2);
|
||||
s = 0.2f * u + 0.8f * (u * u * (3f - 2f * u)); // smoothstep blend, slope ≥ 0.2
|
||||
return RED_CEIL + s * (PLATEAU_LO - RED_CEIL);
|
||||
u = (h - k.K2) / (k.K3 - k.K2);
|
||||
s = 0.1f * u + 0.9f * (u * u * (3f - 2f * u)); // foothill riser — corner fix 1
|
||||
return RED_CEIL + s * (benchLo - RED_CEIL);
|
||||
}
|
||||
if (h < T4)
|
||||
if (h < k.K4)
|
||||
{
|
||||
u = (h - T3) / (T4 - T3);
|
||||
return PLATEAU_LO + u * (PLATEAU_HI - PLATEAU_LO); // near-flat, small positive slope
|
||||
u = (h - k.K3) / (k.K4 - k.K3);
|
||||
return benchLo + u * benchSpan; // bench (min span 6 m — fix 3)
|
||||
}
|
||||
float spikeMax = EffectiveSpikeMax(hMaxSeed);
|
||||
float benchTop = benchLo + benchSpan;
|
||||
if (h < k.K5)
|
||||
{
|
||||
u = (h - k.K4) / (k.K5 - k.K4);
|
||||
s = 0.1f * u + 0.9f * (u * u * (3f - 2f * u)); // mid riser — corner fix 1
|
||||
return benchTop + s * (plateauLo - benchTop);
|
||||
}
|
||||
if (h < k.K6)
|
||||
{
|
||||
u = (h - k.K5) / (k.K6 - k.K5);
|
||||
return plateauLo + u * plateauSpan; // plateau
|
||||
}
|
||||
float plateauTop = plateauLo + plateauSpan;
|
||||
float spikeMax = EffectiveSpikeMax(hMaxSeed, k);
|
||||
if (h < spikeMax)
|
||||
{
|
||||
u = (h - T4) / (spikeMax - T4);
|
||||
s = 0.1f * u + 0.9f * (u * u * u * u); // ease-in u⁴ wall, slope ≥ 0.1
|
||||
return PLATEAU_HI + s * (PEAK_CAP - PLATEAU_HI);
|
||||
u = (h - k.K6) / (spikeMax - k.K6);
|
||||
s = 0.05f * u + 0.95f * (u * u * u * u); // summit spike — corner fix 2
|
||||
return plateauTop + s * (PEAK_CAP - plateauTop);
|
||||
}
|
||||
return PEAK_CAP + (h - spikeMax) * TAIL_SLOPE;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Numeric strict-monotonicity check of the EFFECTIVE per-seed curve — call once
|
||||
/// per generation after hMaxSeed is known, before the curve pass. A violation is
|
||||
/// a build bug, not a data condition — fail loudly and refuse to generate.
|
||||
/// Per-generation numeric strict-monotonicity check of the EFFECTIVE curve for
|
||||
/// the selected preset: all 8 modulation-extreme corners × per-seed spikeMax.
|
||||
/// The corner fixes lower the slope floors (risers 0.1, spike base 0.05) — the
|
||||
/// sweep proves they stay strictly positive everywhere. Loud throw on failure.
|
||||
/// </summary>
|
||||
public static void AssertMonotonic(float hMaxSeed)
|
||||
public static void AssertMonotonic(float hMaxSeed, CurveKnots k)
|
||||
{
|
||||
float prevH = -7f;
|
||||
float prev = Apply(prevH, hMaxSeed);
|
||||
float[] benchLos = { BENCH_BASE - BENCH_AMP, BENCH_BASE + BENCH_AMP };
|
||||
float[] plateauLos = { PLATEAU_BASE - PLATEAU_AMP, PLATEAU_BASE + PLATEAU_AMP };
|
||||
float[] spans = { SHELF_SPAN_MIN, SHELF_SPAN_MAX };
|
||||
|
||||
// Successive double samples can round to the SAME float32 — only strictly
|
||||
// increasing float samples are compared (task-05 incident fix, kept).
|
||||
void Check(double hd)
|
||||
foreach (float bl in benchLos)
|
||||
{
|
||||
float h = (float)hd;
|
||||
if (h <= prevH) return;
|
||||
float v = Apply(h, hMaxSeed);
|
||||
if (v <= prev)
|
||||
throw new System.InvalidOperationException(
|
||||
$"[HeightCurve] MONOTONICITY VIOLATION at h={h} (hMaxSeed={hMaxSeed}): {v} <= {prev}. Refusing to generate.");
|
||||
prev = v;
|
||||
prevH = h;
|
||||
}
|
||||
foreach (float pl in plateauLos)
|
||||
{
|
||||
foreach (float sp in spans)
|
||||
{
|
||||
float prevH = -7f;
|
||||
float prev = Apply(prevH, hMaxSeed, bl, sp, pl, sp, k);
|
||||
|
||||
// Coarse below the identity region, fine through every knot, out past the
|
||||
// per-seed spike top and the tail.
|
||||
double top = System.Math.Max(2.0, EffectiveSpikeMax(hMaxSeed) + 0.5);
|
||||
for (double h = -7.0 + 0.01; h < 0.10; h += 0.01) Check(h);
|
||||
for (double h = 0.10; h <= top; h += 0.0001) Check(h);
|
||||
for (double h = top + 0.05; h <= top + 6.0; h += 0.05) Check(h);
|
||||
GD.Print($"[HeightCurve] Monotonicity assertion passed (v{VERSION}, effective spikeMax {EffectiveSpikeMax(hMaxSeed):F6}).");
|
||||
void Check(double hd)
|
||||
{
|
||||
float h = (float)hd;
|
||||
if (h <= prevH) return; // dedupe float32 samples (task-05 fix)
|
||||
float v = Apply(h, hMaxSeed, bl, sp, pl, sp, k);
|
||||
if (v <= prev)
|
||||
throw new System.InvalidOperationException(
|
||||
$"[HeightCurve] MONOTONICITY VIOLATION at h={h} (preset {k.Name}, hMaxSeed={hMaxSeed}, benchLo={bl}, plateauLo={pl}, span={sp}): {v} <= {prev}. Refusing to generate.");
|
||||
prev = v;
|
||||
prevH = h;
|
||||
}
|
||||
|
||||
double top = System.Math.Max(2.0, EffectiveSpikeMax(hMaxSeed, k) + 0.5);
|
||||
for (double hh = -7.0 + 0.01; hh < 0.10; hh += 0.01) Check(hh);
|
||||
for (double hh = 0.10; hh <= top; hh += 0.0001) Check(hh);
|
||||
for (double hh = top + 0.05; hh <= top + 6.0; hh += 0.05) Check(hh);
|
||||
}
|
||||
}
|
||||
}
|
||||
GD.Print($"[HeightCurve] Monotonicity assertion passed (v{VERSION} preset '{k.Name}', 8 modulation corners, effective spikeMax {EffectiveSpikeMax(hMaxSeed, k):F6}).");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,6 +37,24 @@ public partial class MapGenerator : TextureRect
|
|||
private float[,] _heightMapClassify;
|
||||
private bool _curveOn;
|
||||
|
||||
// Curve-v4 shelf-modulation fields (task 08): two decorrelated elevation fields
|
||||
// (bench 100±12 m, plateau 220±20 m) plus a strength field blending each shelf
|
||||
// between pronounced-flat and barely-a-hint. Seeds derive from the RESOLVED
|
||||
// noise seed + fixed offsets (no config knob); frequencies scale with MapSize.
|
||||
// Sampled per column in pass 2 only — the classify path never sees them.
|
||||
private FastNoiseLite _benchNoise;
|
||||
private FastNoiseLite _plateauNoise;
|
||||
private FastNoiseLite _strengthNoise;
|
||||
|
||||
// v5: the selected knot preset; null = curve off.
|
||||
private CurveKnots _curveKnots;
|
||||
|
||||
// Task-10 detail passes (shelf micro-relief + drainage incision): gated by
|
||||
// TerrainDetail, active only with the curve on (the masks are curve-band
|
||||
// defined). The relief noise seeds from resolvedSeed + 7409.
|
||||
private bool _detailOn;
|
||||
private FastNoiseLite _reliefNoise;
|
||||
|
||||
// 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
|
||||
// GenerateTopography pass 1; recorded in TCRV (effective, guard applied).
|
||||
|
|
@ -75,7 +93,8 @@ public partial class MapGenerator : TextureRect
|
|||
this.CustomMinimumSize = new Vector2(MapSize, MapSize);
|
||||
|
||||
_heightMap = new float[MapSize, MapSize];
|
||||
_curveOn = ConfigManager.TerrainCurve == "v2";
|
||||
_curveKnots = ConfigManager.TerrainCurve == "v5" ? HeightCurve.V5 : null;
|
||||
_curveOn = _curveKnots != null;
|
||||
// (The monotonicity assertion now runs inside GenerateTopography, against the
|
||||
// effective per-seed curve, once hMaxSeed is known.)
|
||||
_heightMapClassify = _curveOn ? new float[MapSize, MapSize] : _heightMap;
|
||||
|
|
@ -86,11 +105,23 @@ public partial class MapGenerator : TextureRect
|
|||
|
||||
float scaleFactor = MapSize / 1024f; // Equals 4
|
||||
|
||||
// --- MASTER SEED & DETAILED NOISE (Restored!) ---
|
||||
// --- MASTER SEED & DETAILED NOISE (Restored!) ---
|
||||
_noise = new FastNoiseLite();
|
||||
_noise.Seed = ConfigManager.WorldSeed == 0 ? (int)GD.Randi() : ConfigManager.WorldSeed;
|
||||
_noise.Seed = ConfigManager.WorldSeed == 0 ? (int)GD.Randi() : ConfigManager.WorldSeed;
|
||||
_noise.NoiseType = FastNoiseLite.NoiseTypeEnum.Simplex;
|
||||
_noise.Frequency = 0.004f / scaleFactor;
|
||||
_noise.Frequency = 0.004f / scaleFactor;
|
||||
|
||||
if (_curveOn)
|
||||
{
|
||||
_benchNoise = MakeModulationNoise(HeightCurve.BENCH_SEED_OFFSET, HeightCurve.ELEV_FREQ_ISLANDS);
|
||||
_plateauNoise = MakeModulationNoise(HeightCurve.PLATEAU_SEED_OFFSET, HeightCurve.ELEV_FREQ_ISLANDS);
|
||||
_strengthNoise = MakeModulationNoise(HeightCurve.STRENGTH_SEED_OFFSET, HeightCurve.STRENGTH_FREQ_ISLANDS);
|
||||
}
|
||||
_detailOn = _curveOn && ConfigManager.TerrainDetail == "v1";
|
||||
if (_detailOn)
|
||||
_reliefNoise = MakeModulationNoise(TerrainDetailPass.RELIEF_SEED_OFFSET, TerrainDetailPass.RELIEF_FREQ_ISLANDS);
|
||||
else if (ConfigManager.TerrainDetail == "v1" && !_curveOn)
|
||||
GD.Print("[MapGenerator] TerrainDetail v1 requires the curve — no-op with TerrainCurve 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!
|
||||
|
|
@ -256,14 +287,39 @@ public partial class MapGenerator : TextureRect
|
|||
TrailRoads = _trailPaths,
|
||||
WaterBodyIds = _waterBodyIds,
|
||||
WaterBodies = _waterBodies ?? new List<WaterBodyInfo>(),
|
||||
// TCRV under curve v4: knot slots carry K1..K4 (K5/K6 are version constants);
|
||||
// the bench slots carry the two BASE anchors; the v4 extension record carries
|
||||
// the modulation parameters (amplitudes, spans, frequencies, seed offsets) —
|
||||
// the blueprint stays self-describing.
|
||||
// TCRV v5: knot slots carry the PRESET's K1..K4; the extension carries the
|
||||
// modulation params plus preset id and K5/K6 — the effective curve is
|
||||
// unambiguous from the record alone.
|
||||
TerrainCurve = _curveOn ? new TerrainCurveInfo
|
||||
{
|
||||
Version = HeightCurve.VERSION,
|
||||
T1 = HeightCurve.T1, T2 = HeightCurve.T2, T3 = HeightCurve.T3, T4 = HeightCurve.T4,
|
||||
SpikeMax = HeightCurve.EffectiveSpikeMax(_hMaxSeed), // per-seed (v2)
|
||||
T1 = _curveKnots.K1, T2 = _curveKnots.K2, T3 = _curveKnots.K3, T4 = _curveKnots.K4,
|
||||
SpikeMax = HeightCurve.EffectiveSpikeMax(_hMaxSeed, _curveKnots), // per-seed
|
||||
Sea = HeightCurve.SEA, OrangeCeil = HeightCurve.ORANGE_CEIL, RedCeil = HeightCurve.RED_CEIL,
|
||||
PlateauLo = HeightCurve.PLATEAU_LO, PlateauHi = HeightCurve.PLATEAU_HI,
|
||||
PeakCap = HeightCurve.PEAK_CAP, TailSlope = HeightCurve.TAIL_SLOPE
|
||||
PlateauLo = HeightCurve.BENCH_BASE, PlateauHi = HeightCurve.PLATEAU_BASE,
|
||||
PeakCap = HeightCurve.PEAK_CAP, TailSlope = HeightCurve.TAIL_SLOPE,
|
||||
BenchAmp = HeightCurve.BENCH_AMP, PlateauAmp = HeightCurve.PLATEAU_AMP,
|
||||
ShelfSpanMin = HeightCurve.SHELF_SPAN_MIN, ShelfSpanMax = HeightCurve.SHELF_SPAN_MAX,
|
||||
ElevFreqIslands = HeightCurve.ELEV_FREQ_ISLANDS, StrengthFreqIslands = HeightCurve.STRENGTH_FREQ_ISLANDS,
|
||||
BenchSeedOffset = HeightCurve.BENCH_SEED_OFFSET, PlateauSeedOffset = HeightCurve.PLATEAU_SEED_OFFSET,
|
||||
StrengthSeedOffset = HeightCurve.STRENGTH_SEED_OFFSET,
|
||||
PresetId = _curveKnots.PresetId, K5 = _curveKnots.K5, K6 = _curveKnots.K6
|
||||
} : null,
|
||||
TerrainDetail = _detailOn ? new TerrainDetailInfo
|
||||
{
|
||||
Version = TerrainDetailPass.VERSION,
|
||||
ReliefAmpM = ConfigManager.ShelfReliefAmp,
|
||||
ReliefFreqIslands = TerrainDetailPass.RELIEF_FREQ_ISLANDS,
|
||||
IncK = TerrainDetailPass.INC_K, IncP = TerrainDetailPass.INC_P,
|
||||
IncCapM = TerrainDetailPass.INC_CAP_M,
|
||||
SeaClampRaw = TerrainDetailPass.SEA_CLAMP,
|
||||
CraterExclFactor = TerrainDetailPass.CRATER_EXCL_FACTOR,
|
||||
ShelfIncWeight = TerrainDetailPass.SHELF_INC_WEIGHT,
|
||||
ReliefSeedOffset = TerrainDetailPass.RELIEF_SEED_OFFSET
|
||||
} : null,
|
||||
FormatVersion = 2,
|
||||
Params = new BlueprintParams
|
||||
|
|
@ -420,7 +476,7 @@ public partial class MapGenerator : TextureRect
|
|||
// 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.
|
||||
if (_curveOn) HeightCurve.AssertMonotonic(_hMaxSeed);
|
||||
if (_curveOn) HeightCurve.AssertMonotonic(_hMaxSeed, _curveKnots);
|
||||
|
||||
// --- PASS 2: curve (task 05/06) + crater carve ---
|
||||
// Curve applied AFTER noise + falloff + Trench, BEFORE the crater carve, so
|
||||
|
|
@ -428,16 +484,54 @@ public partial class MapGenerator : TextureRect
|
|||
// the curve. Identity at and below sea + this ordering preserve the
|
||||
// Trench/ocean-border guarantee and the crater by construction. classifyH
|
||||
// stays uncurved — see _heightMapClassify; hMaxSeed never touches it.
|
||||
float physicalCraterRadius = _impactRadius * 0.80f;
|
||||
// --- PASS 2a: curve + shelf micro-relief (task 10 pass A) ---
|
||||
// classify stays RAW; curved gets the v5 curve plus, when TerrainDetail is on,
|
||||
// the shelf-ness-weighted noise skin (risers and peaks untouched).
|
||||
float reliefAmpRaw = ConfigManager.ShelfReliefAmp / 251f;
|
||||
for (int x = 0; x < MapSize; x++)
|
||||
{
|
||||
for (int y = 0; y < MapSize; y++)
|
||||
{
|
||||
float raw = _heightMap[x, y];
|
||||
float classifyH = raw;
|
||||
float curvedH = _curveOn ? HeightCurve.Apply(raw, _hMaxSeed) : raw;
|
||||
float curvedH;
|
||||
if (_curveOn)
|
||||
{
|
||||
// per-column shelf modulation (v4) — anchors and strength from the
|
||||
// low-frequency fields; ordering safety by construction.
|
||||
float benchLo = HeightCurve.BENCH_BASE + _benchNoise.GetNoise2D(x, y) * HeightCurve.BENCH_AMP;
|
||||
float plateauLo = HeightCurve.PLATEAU_BASE + _plateauNoise.GetNoise2D(x, y) * HeightCurve.PLATEAU_AMP;
|
||||
float shelfSpan = HeightCurve.ShelfSpan((_strengthNoise.GetNoise2D(x, y) + 1f) * 0.5f);
|
||||
curvedH = HeightCurve.Apply(raw, _hMaxSeed, benchLo, shelfSpan, plateauLo, shelfSpan, _curveKnots);
|
||||
|
||||
// --- 5. CARVE THE CRATER (The Flooded Bay & Landbridge Fix!) ---
|
||||
if (_detailOn)
|
||||
{
|
||||
float wShelf = TerrainDetailPass.ShelfWeight(raw, _curveKnots);
|
||||
if (wShelf > 0f)
|
||||
curvedH += _reliefNoise.GetNoise2D(x, y) * reliefAmpRaw * wShelf;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
curvedH = raw;
|
||||
}
|
||||
|
||||
_heightMapClassify[x, y] = raw; // uncurved; carve joins in pass 2c
|
||||
_heightMap[x, y] = curvedH;
|
||||
}
|
||||
}
|
||||
|
||||
// --- PASS 2b: drainage incision (task 10 pass B) ---
|
||||
if (_curveOn && _detailOn)
|
||||
RunIncisionPass();
|
||||
|
||||
// --- PASS 2c: THE CRATER CARVE (The Flooded Bay & Landbridge Fix!) ---
|
||||
// The carve remains the FINAL authority on its own terrain: applied after
|
||||
// curve/relief/incision, to both maps, with the original expression.
|
||||
float physicalCraterRadius = _impactRadius * 0.80f;
|
||||
for (int x = 0; x < MapSize; x++)
|
||||
{
|
||||
for (int y = 0; y < MapSize; y++)
|
||||
{
|
||||
float distToCrater = new Vector2(x, y).DistanceTo(_impactCenter);
|
||||
|
||||
// We only carve the physical hole at 80% of the radius to guarantee a landbridge!
|
||||
|
|
@ -446,16 +540,82 @@ public partial class MapGenerator : TextureRect
|
|||
float craterDepth = 1.0f - (distToCrater / physicalCraterRadius);
|
||||
// Dialed back to -0.15f as per your excellent instinct!
|
||||
float carveTarget = GetSeaLevel(_tempMap[x, y]) - 0.15f;
|
||||
classifyH = Mathf.Lerp(classifyH, carveTarget, craterDepth * 0.9f);
|
||||
curvedH = Mathf.Lerp(curvedH, carveTarget, craterDepth * 0.9f);
|
||||
// Read BOTH before writing EITHER: with the curve off the two maps
|
||||
// alias the same array, and a sequential read-modify-write carved
|
||||
// the crater twice (caught by the task-10 continuity oracle).
|
||||
float preClassify = _heightMapClassify[x, y];
|
||||
float preCurved = _heightMap[x, y];
|
||||
_heightMapClassify[x, y] = Mathf.Lerp(preClassify, carveTarget, craterDepth * 0.9f);
|
||||
_heightMap[x, y] = Mathf.Lerp(preCurved, carveTarget, craterDepth * 0.9f);
|
||||
}
|
||||
|
||||
_heightMapClassify[x, y] = classifyH;
|
||||
_heightMap[x, y] = curvedH;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Task-10 pass B: D8 flow accumulation over the curved+relieved land, then
|
||||
/// depth = K · accum^p · slope, masked to the risers (shelves feathered to 30 %,
|
||||
/// toe and peaks zero, crater excluded), capped, and clamped to sea + 1 m.
|
||||
/// Prints its own MEASURED depth distribution — the tuning/report source.
|
||||
/// </summary>
|
||||
private void RunIncisionPass()
|
||||
{
|
||||
ulong t0 = Time.GetTicksMsec();
|
||||
int n = MapSize;
|
||||
int total = n * n;
|
||||
|
||||
float[] flat = new float[total];
|
||||
for (int x = 0; x < n; x++)
|
||||
for (int y = 0; y < n; y++)
|
||||
flat[x * n + y] = _heightMap[x, y];
|
||||
|
||||
int[] accum = TerrainDetailPass.FlowAccumulation(flat, n, out float[] drop);
|
||||
double accumSeconds = (Time.GetTicksMsec() - t0) / 1000.0;
|
||||
|
||||
float capRaw = TerrainDetailPass.INC_CAP_M / 251f;
|
||||
float exclR = _impactRadius * TerrainDetailPass.CRATER_EXCL_FACTOR;
|
||||
float featherR = _impactRadius * TerrainDetailPass.CRATER_FEATHER_FACTOR;
|
||||
long incised = 0, clampHits = 0;
|
||||
var depthsM = new System.Collections.Generic.List<float>(1 << 20);
|
||||
|
||||
for (int x = 0; x < n; x++)
|
||||
{
|
||||
for (int y = 0; y < n; y++)
|
||||
{
|
||||
float raw = _heightMapClassify[x, y];
|
||||
float w = TerrainDetailPass.IncisionWeight(raw, _curveKnots);
|
||||
if (w <= 0f) continue;
|
||||
|
||||
float distToCrater = new Vector2(x, y).DistanceTo(_impactCenter);
|
||||
if (distToCrater < exclR) continue;
|
||||
if (distToCrater < featherR)
|
||||
w *= (distToCrater - exclR) / (featherR - exclR);
|
||||
|
||||
int i = x * n + y;
|
||||
float depth = TerrainDetailPass.INC_K
|
||||
* Mathf.Pow(accum[i], TerrainDetailPass.INC_P) * drop[i];
|
||||
depth = Mathf.Min(depth, capRaw) * w;
|
||||
if (depth <= 0f) continue;
|
||||
|
||||
float nh = _heightMap[x, y] - depth;
|
||||
if (nh < TerrainDetailPass.SEA_CLAMP)
|
||||
{
|
||||
nh = TerrainDetailPass.SEA_CLAMP;
|
||||
clampHits++;
|
||||
}
|
||||
float realized = _heightMap[x, y] - nh;
|
||||
if (realized * 251f >= 0.5f) { incised++; depthsM.Add(realized * 251f); }
|
||||
_heightMap[x, y] = nh;
|
||||
}
|
||||
}
|
||||
|
||||
depthsM.Sort();
|
||||
float P(double q) => depthsM.Count == 0 ? 0 : depthsM[Mathf.Clamp((int)(q * depthsM.Count), 0, depthsM.Count - 1)];
|
||||
double totalSeconds = (Time.GetTicksMsec() - t0) / 1000.0;
|
||||
GD.Print($"{T()} [Incision] accumulation {accumSeconds:F1}s, total {totalSeconds:F1}s.");
|
||||
GD.Print($"{T()} [Incision] incised cells (≥0.5 m): {incised}; depth m: p50 {P(0.5):F1}, p90 {P(0.9):F1}, p99 {P(0.99):F1}, max {(depthsM.Count > 0 ? depthsM[depthsM.Count - 1] : 0):F1}; sea-clamp hits {clampHits}.");
|
||||
}
|
||||
|
||||
private void CalculateTrueOcean()
|
||||
{
|
||||
Queue<Vector2I> queue = new Queue<Vector2I>();
|
||||
|
|
@ -520,6 +680,15 @@ public partial class MapGenerator : TextureRect
|
|||
// grid and the water-body grid holds BY CONSTRUCTION, not by parallel
|
||||
// implementations agreeing.
|
||||
// =====================================================================
|
||||
private FastNoiseLite MakeModulationNoise(int seedOffset, float periodsPerIsland)
|
||||
{
|
||||
var n = new FastNoiseLite();
|
||||
n.Seed = _noise.Seed + seedOffset; // deterministic from the RESOLVED seed
|
||||
n.NoiseType = FastNoiseLite.NoiseTypeEnum.Simplex;
|
||||
n.Frequency = periodsPerIsland / MapSize; // frequency stated in island-widths
|
||||
return n;
|
||||
}
|
||||
|
||||
private bool IsWaterPixel(int x, int y) => _heightMapClassify[x, y] < GetSeaLevel(_tempMap[x, y]);
|
||||
private bool IsOceanPixel(int x, int y) => IsWaterPixel(x, y) && _isTrueOcean[x, y];
|
||||
private bool IsLakePixel(int x, int y) => IsWaterPixel(x, y) && !_isTrueOcean[x, y];
|
||||
|
|
@ -800,7 +969,8 @@ public partial class MapGenerator : TextureRect
|
|||
Color shallowSea = new Color(0.25f, 0.45f, 0.65f);
|
||||
Color green = new Color(0.44f, 0.62f, 0.36f); // orange band terrain: lowland green
|
||||
Color tan = new Color(0.76f, 0.70f, 0.46f); // red band: tan
|
||||
Color brown = new Color(0.55f, 0.41f, 0.28f); // shoulder + plateau: brown
|
||||
Color brown = new Color(0.55f, 0.41f, 0.28f); // foothill riser + bench: brown
|
||||
Color darkBrown = new Color(0.42f, 0.32f, 0.24f); // mid riser: darker brown
|
||||
Color white = new Color(0.97f, 0.97f, 0.98f); // peaks
|
||||
Color grey = new Color(0.72f, 0.72f, 0.70f);
|
||||
|
||||
|
|
@ -820,10 +990,14 @@ public partial class MapGenerator : TextureRect
|
|||
}
|
||||
else if (h < HeightCurve.ORANGE_CEIL) tint = green;
|
||||
else if (h < HeightCurve.RED_CEIL) tint = tan;
|
||||
else if (h < HeightCurve.PLATEAU_HI) tint = brown;
|
||||
// v4 note: shelf anchors are spatially modulated, so these tint bands
|
||||
// use the base±amplitude envelopes — approximate banding, visualization only.
|
||||
else if (h < HeightCurve.BENCH_BASE + HeightCurve.BENCH_AMP) tint = brown; // riser + bench envelope
|
||||
else if (h < HeightCurve.PLATEAU_BASE - HeightCurve.PLATEAU_AMP) tint = darkBrown; // mid riser
|
||||
else
|
||||
{
|
||||
float t2 = Mathf.Clamp((h - HeightCurve.PLATEAU_HI) / (HeightCurve.PEAK_CAP - HeightCurve.PLATEAU_HI), 0f, 1f);
|
||||
// Plateau envelope through the summit spike to the 420 m cap.
|
||||
float t2 = Mathf.Clamp((h - (HeightCurve.PLATEAU_BASE - HeightCurve.PLATEAU_AMP)) / (HeightCurve.PEAK_CAP - HeightCurve.PLATEAU_BASE + HeightCurve.PLATEAU_AMP), 0f, 1f);
|
||||
tint = grey.Lerp(white, t2);
|
||||
}
|
||||
|
||||
|
|
@ -1367,40 +1541,40 @@ public partial class MapGenerator : TextureRect
|
|||
}
|
||||
|
||||
private void SetBaseAStarWeights(AStarGrid2D astar) {
|
||||
for (int x = 0; x < MapSize; x++) {
|
||||
for (int y = 0; y < MapSize; y++) {
|
||||
Biome b = _biomeMap[x, y];
|
||||
|
||||
// 1. CRITICAL FIX: Make the crater physically impassable first!
|
||||
if (b == Biome.Crater) {
|
||||
astar.SetPointSolid(new Vector2I(x, y), true);
|
||||
continue; // "Skip the rest, go to next pixel"
|
||||
}
|
||||
for (int x = 0; x < MapSize; x++) {
|
||||
for (int y = 0; y < MapSize; y++) {
|
||||
Biome b = _biomeMap[x, y];
|
||||
|
||||
// 1. CRITICAL FIX: Make the crater physically impassable first!
|
||||
if (b == Biome.Crater) {
|
||||
astar.SetPointSolid(new Vector2I(x, y), true);
|
||||
continue; // "Skip the rest, go to next pixel"
|
||||
}
|
||||
|
||||
// 2. Existing water/mainland checks
|
||||
if (b == Biome.Ocean || b == Biome.Lake || !_isMainland[x, y]) {
|
||||
astar.SetPointSolid(new Vector2I(x, y), true);
|
||||
continue; // "Skip the rest, go to next pixel"
|
||||
}
|
||||
|
||||
// 3. We only reach this point if it's mainland AND not a crater!
|
||||
float h = _heightMap[x, y];
|
||||
float localSea = GetSeaLevel(_tempMap[x, y]);
|
||||
float normalizedElevation = Mathf.Clamp((h - localSea) / (1.0f - localSea), 0.0f, 1.0f);
|
||||
// 2. Existing water/mainland checks
|
||||
if (b == Biome.Ocean || b == Biome.Lake || !_isMainland[x, y]) {
|
||||
astar.SetPointSolid(new Vector2I(x, y), true);
|
||||
continue; // "Skip the rest, go to next pixel"
|
||||
}
|
||||
|
||||
// 3. We only reach this point if it's mainland AND not a crater!
|
||||
float h = _heightMap[x, y];
|
||||
float localSea = GetSeaLevel(_tempMap[x, y]);
|
||||
float normalizedElevation = Mathf.Clamp((h - localSea) / (1.0f - localSea), 0.0f, 1.0f);
|
||||
|
||||
// Simple, steep curve to avoid mountains
|
||||
float weight = 1.0f + Mathf.Pow(normalizedElevation, 3.0f) * 400.0f;
|
||||
// Simple, steep curve to avoid mountains
|
||||
float weight = 1.0f + Mathf.Pow(normalizedElevation, 3.0f) * 400.0f;
|
||||
|
||||
// Push off the beach (not an insane wall, just enough to prefer grass)
|
||||
if (b == Biome.Beach) weight += 15.0f;
|
||||
|
||||
// Let it path through the wasteland normally
|
||||
if (b == Biome.Wasteland) weight += 3.0f;
|
||||
// Push off the beach (not an insane wall, just enough to prefer grass)
|
||||
if (b == Biome.Beach) weight += 15.0f;
|
||||
|
||||
// Let it path through the wasteland normally
|
||||
if (b == Biome.Wasteland) weight += 3.0f;
|
||||
|
||||
astar.SetPointWeightScale(new Vector2I(x, y), weight);
|
||||
}
|
||||
}
|
||||
}
|
||||
astar.SetPointWeightScale(new Vector2I(x, y), weight);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void PenalizePath(AStarGrid2D astar, Vector2[] path, int radius)
|
||||
{
|
||||
|
|
@ -1413,7 +1587,7 @@ public partial class MapGenerator : TextureRect
|
|||
int ny = Mathf.Clamp((int)p.Y + dy, 0, MapSize - 1);
|
||||
var cell = new Vector2I(nx, ny);
|
||||
if (!astar.IsPointSolid(cell)) {
|
||||
// The true Iron Curtain
|
||||
// The true Iron Curtain
|
||||
astar.SetPointWeightScale(cell, astar.GetPointWeightScale(cell) + 10000f);
|
||||
}
|
||||
}
|
||||
|
|
@ -1488,67 +1662,67 @@ public partial class MapGenerator : TextureRect
|
|||
{
|
||||
float scaleMod = MapSize / 1024f; // equals 4 at 4096
|
||||
|
||||
// Trails: Thin brown
|
||||
foreach (var path in _trailPaths) DrawPolyline(path, new Color(0.5f, 0.4f, 0.3f, 0.7f), 1.0f * scaleMod, true);
|
||||
|
||||
// Rugged: Thicker dark brown
|
||||
foreach (var path in _ruggedPaths) DrawPolyline(path, new Color(0.35f, 0.25f, 0.15f), 1.5f * scaleMod, true);
|
||||
// Trails: Thin brown
|
||||
foreach (var path in _trailPaths) DrawPolyline(path, new Color(0.5f, 0.4f, 0.3f, 0.7f), 1.0f * scaleMod, true);
|
||||
|
||||
// Rugged: Thicker dark brown
|
||||
foreach (var path in _ruggedPaths) DrawPolyline(path, new Color(0.35f, 0.25f, 0.15f), 1.5f * scaleMod, true);
|
||||
|
||||
// Main Roads: Solid Black
|
||||
foreach (var path in _branchPaths) DrawPolyline(path, Colors.Black, 1.0f * scaleMod, true);
|
||||
// Main Roads: Solid Black
|
||||
foreach (var path in _branchPaths) DrawPolyline(path, Colors.Black, 1.0f * scaleMod, true);
|
||||
|
||||
// Highway: Thick Red
|
||||
foreach (var path in _highwayPaths) DrawPolyline(path, new Color(0.9f, 0.1f, 0.1f), 2.0f * scaleMod, true);
|
||||
// Highway: Thick Red
|
||||
foreach (var path in _highwayPaths) DrawPolyline(path, new Color(0.9f, 0.1f, 0.1f), 2.0f * scaleMod, true);
|
||||
|
||||
// Draw Towns
|
||||
foreach (var town in _towns) {
|
||||
float radius = (town.Tier == TownTier.Capitol) ? 12f : (town.Tier == TownTier.Hub ? 8f : 4f);
|
||||
radius *= scaleMod; // Scale the circles up!
|
||||
|
||||
Color c = town.IsHighwayNode ? Colors.Yellow : (town.Tier == TownTier.Outpost ? Colors.Cyan : Colors.Orange);
|
||||
if (town.Tier == TownTier.IslandLoot) c = Colors.Red;
|
||||
if (town.Tier == TownTier.MiniPOI) { radius = 2.5f * scaleMod; c = Colors.SaddleBrown; }
|
||||
// Draw Towns
|
||||
foreach (var town in _towns) {
|
||||
float radius = (town.Tier == TownTier.Capitol) ? 12f : (town.Tier == TownTier.Hub ? 8f : 4f);
|
||||
radius *= scaleMod; // Scale the circles up!
|
||||
|
||||
Color c = town.IsHighwayNode ? Colors.Yellow : (town.Tier == TownTier.Outpost ? Colors.Cyan : Colors.Orange);
|
||||
if (town.Tier == TownTier.IslandLoot) c = Colors.Red;
|
||||
if (town.Tier == TownTier.MiniPOI) { radius = 2.5f * scaleMod; c = Colors.SaddleBrown; }
|
||||
|
||||
DrawCircle(town.Position, radius + (1.5f * scaleMod), Colors.Black);
|
||||
DrawCircle(town.Position, radius, c);
|
||||
}
|
||||
DrawCircle(town.Position, radius + (1.5f * scaleMod), Colors.Black);
|
||||
DrawCircle(town.Position, radius, c);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Place this at the very bottom of the file!
|
||||
public partial class MapDrawProxy : Control
|
||||
{
|
||||
public MapGenerator Source;
|
||||
public MapGenerator Source;
|
||||
|
||||
public override void _Draw()
|
||||
{
|
||||
if (Source == null) return;
|
||||
|
||||
float scaleMod = Source.MapSize / 1024f;
|
||||
public override void _Draw()
|
||||
{
|
||||
if (Source == null) return;
|
||||
|
||||
float scaleMod = Source.MapSize / 1024f;
|
||||
|
||||
foreach (var path in Source._trailPaths)
|
||||
DrawPolyline(path, new Color(0.5f, 0.4f, 0.3f, 0.7f), 1.0f * scaleMod, true);
|
||||
foreach (var path in Source._trailPaths)
|
||||
DrawPolyline(path, new Color(0.5f, 0.4f, 0.3f, 0.7f), 1.0f * scaleMod, true);
|
||||
|
||||
foreach (var path in Source._ruggedPaths)
|
||||
DrawPolyline(path, new Color(0.35f, 0.25f, 0.15f), 1.5f * scaleMod, true);
|
||||
foreach (var path in Source._ruggedPaths)
|
||||
DrawPolyline(path, new Color(0.35f, 0.25f, 0.15f), 1.5f * scaleMod, true);
|
||||
|
||||
foreach (var path in Source._branchPaths)
|
||||
DrawPolyline(path, Colors.Black, 1.0f * scaleMod, true);
|
||||
foreach (var path in Source._branchPaths)
|
||||
DrawPolyline(path, Colors.Black, 1.0f * scaleMod, true);
|
||||
|
||||
foreach (var path in Source._highwayPaths)
|
||||
DrawPolyline(path, new Color(0.9f, 0.1f, 0.1f), 2.0f * scaleMod, true);
|
||||
foreach (var path in Source._highwayPaths)
|
||||
DrawPolyline(path, new Color(0.9f, 0.1f, 0.1f), 2.0f * scaleMod, true);
|
||||
|
||||
foreach (var town in Source._towns)
|
||||
{
|
||||
float radius = (town.Tier == TownTier.Capitol) ? 12f : (town.Tier == TownTier.Hub ? 8f : 4f);
|
||||
radius *= scaleMod;
|
||||
foreach (var town in Source._towns)
|
||||
{
|
||||
float radius = (town.Tier == TownTier.Capitol) ? 12f : (town.Tier == TownTier.Hub ? 8f : 4f);
|
||||
radius *= scaleMod;
|
||||
|
||||
Color c = town.IsHighwayNode ? Colors.Yellow : (town.Tier == TownTier.Outpost ? Colors.Cyan : Colors.Orange);
|
||||
if (town.Tier == TownTier.IslandLoot) c = Colors.Red;
|
||||
if (town.Tier == TownTier.MiniPOI) { radius = 2.5f * scaleMod; c = Colors.SaddleBrown; }
|
||||
Color c = town.IsHighwayNode ? Colors.Yellow : (town.Tier == TownTier.Outpost ? Colors.Cyan : Colors.Orange);
|
||||
if (town.Tier == TownTier.IslandLoot) c = Colors.Red;
|
||||
if (town.Tier == TownTier.MiniPOI) { radius = 2.5f * scaleMod; c = Colors.SaddleBrown; }
|
||||
|
||||
DrawCircle(town.Position, radius + (1.5f * scaleMod), Colors.Black);
|
||||
DrawCircle(town.Position, radius, c);
|
||||
}
|
||||
}
|
||||
}
|
||||
DrawCircle(town.Position, radius + (1.5f * scaleMod), Colors.Black);
|
||||
DrawCircle(town.Position, radius, c);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,12 +11,18 @@ Generates the entire 2D blueprint. Roughly in order:
|
|||
2. **Topography** — FastNoiseLite base height plus a mountain spine, minus a squircle distance
|
||||
falloff, giving a guaranteed island. Noise frequency is divided by `scaleFactor`
|
||||
(`MapSize / 1024f`) so terrain features stay the same real-world size at any map profile.
|
||||
When `TerrainCurve: "v2"` (the default), the calibrated height-redistribution curve
|
||||
(`HeightCurve.cs` — flat farmable lowlands, 50 m plateau shelf, and a per-seed-normalized
|
||||
spike to the 420 m peak cap: every island's tallest point reaches the ceiling) reshapes
|
||||
above-sea terrain after noise/falloff/Trench and before the crater carve; biome
|
||||
classification reads a retained uncurved map, so biomes are identical either way. Drops the
|
||||
`0_height` hillshade snapshot (hypsometric bands × NW hillshade) in both modes.
|
||||
When `TerrainCurve: "v5"` (the default — the task-09 gate's BALANCED winner), the calibrated
|
||||
height-redistribution curve
|
||||
(`HeightCurve.cs` — the terraced ascent with spatially modulated shelves: flat farmable
|
||||
lowlands, a walkable foothill bench at 100±12 m, the white mountain-town plateau at
|
||||
220±20 m — shelf heights and strength drift across the island so no two flanks wear the same
|
||||
ring — and a per-seed-normalized summit spike to the 420 m cap) reshapes above-sea terrain
|
||||
after noise/falloff/Trench and before the crater carve; biome classification reads a
|
||||
retained uncurved map, so biomes are identical either way. With `TerrainDetail: "v1"` (the
|
||||
default) two detail passes follow the curve: shelf micro-relief (±3 m rolling skin on the
|
||||
benches/plateaus) and D8 drainage incision (rain-cut ravines in the risers — the future
|
||||
river routes; `TerrainDetailPass.cs`). Drops the `0_height` hillshade snapshot (hypsometric
|
||||
bands × NW hillshade) in both modes.
|
||||
3. **Sea level and water** — sea level per the configured model (`SeaLevelModel`: `"flat"` scalar
|
||||
— the default, `SeaLevelValue` 0.15 — or the legacy `"field"` latitude Lerp); flood fill
|
||||
separates true ocean from inland lakes; a mainland fill guarantees one contiguous landmass.
|
||||
|
|
|
|||
|
|
@ -121,6 +121,7 @@ public partial class RoundTripHarness : Node
|
|||
ok &= CompareRoads("Trail", a.TrailRoads, b.TrailRoads);
|
||||
ok &= CompareWater(a, b);
|
||||
ok &= CompareTerrainCurve(a, b);
|
||||
ok &= CompareTerrainDetail(a, b);
|
||||
|
||||
if (ok)
|
||||
GD.Print($"[Harness] Semantic equality holds: {a.MapSize}x{a.MapSize} grid, " +
|
||||
|
|
@ -200,8 +201,14 @@ public partial class RoundTripHarness : Node
|
|||
}
|
||||
var ca = a.TerrainCurve; var cb = b.TerrainCurve;
|
||||
bool same = ca.Version == cb.Version;
|
||||
float[] fa = { ca.T1, ca.T2, ca.T3, ca.T4, ca.SpikeMax, ca.Sea, ca.OrangeCeil, ca.RedCeil, ca.PlateauLo, ca.PlateauHi, ca.PeakCap, ca.TailSlope };
|
||||
float[] fb = { cb.T1, cb.T2, cb.T3, cb.T4, cb.SpikeMax, cb.Sea, cb.OrangeCeil, cb.RedCeil, cb.PlateauLo, cb.PlateauHi, cb.PeakCap, cb.TailSlope };
|
||||
float[] fa = { ca.T1, ca.T2, ca.T3, ca.T4, ca.SpikeMax, ca.Sea, ca.OrangeCeil, ca.RedCeil, ca.PlateauLo, ca.PlateauHi, ca.PeakCap, ca.TailSlope,
|
||||
ca.BenchAmp, ca.PlateauAmp, ca.ShelfSpanMin, ca.ShelfSpanMax, ca.ElevFreqIslands, ca.StrengthFreqIslands,
|
||||
ca.BenchSeedOffset, ca.PlateauSeedOffset, ca.StrengthSeedOffset,
|
||||
ca.PresetId, ca.K5, ca.K6 };
|
||||
float[] fb = { cb.T1, cb.T2, cb.T3, cb.T4, cb.SpikeMax, cb.Sea, cb.OrangeCeil, cb.RedCeil, cb.PlateauLo, cb.PlateauHi, cb.PeakCap, cb.TailSlope,
|
||||
cb.BenchAmp, cb.PlateauAmp, cb.ShelfSpanMin, cb.ShelfSpanMax, cb.ElevFreqIslands, cb.StrengthFreqIslands,
|
||||
cb.BenchSeedOffset, cb.PlateauSeedOffset, cb.StrengthSeedOffset,
|
||||
cb.PresetId, cb.K5, cb.K6 };
|
||||
for (int i = 0; i < fa.Length; i++)
|
||||
if (System.BitConverter.SingleToInt32Bits(fa[i]) != System.BitConverter.SingleToInt32Bits(fb[i])) same = false;
|
||||
if (!same) { GD.PrintErr("[Harness] TCRV fields differ."); return false; }
|
||||
|
|
@ -209,6 +216,31 @@ public partial class RoundTripHarness : Node
|
|||
return true;
|
||||
}
|
||||
|
||||
private bool CompareTerrainDetail(WorldBlueprint a, WorldBlueprint b)
|
||||
{
|
||||
if (a.TerrainDetail == null && b.TerrainDetail == null)
|
||||
{
|
||||
GD.Print("[Harness] TDTL: absent in source — nothing to compare (and none reappeared).");
|
||||
return true;
|
||||
}
|
||||
if (a.TerrainDetail == null || b.TerrainDetail == null)
|
||||
{
|
||||
GD.PrintErr("[Harness] TDTL presence mismatch between source and reread.");
|
||||
return false;
|
||||
}
|
||||
var da = a.TerrainDetail; var db = b.TerrainDetail;
|
||||
float[] fa = { da.Version, da.ReliefAmpM, da.ReliefFreqIslands, da.IncK, da.IncP, da.IncCapM, da.SeaClampRaw, da.CraterExclFactor, da.ShelfIncWeight, da.ReliefSeedOffset };
|
||||
float[] fb = { db.Version, db.ReliefAmpM, db.ReliefFreqIslands, db.IncK, db.IncP, db.IncCapM, db.SeaClampRaw, db.CraterExclFactor, db.ShelfIncWeight, db.ReliefSeedOffset };
|
||||
for (int i = 0; i < fa.Length; i++)
|
||||
if (System.BitConverter.SingleToInt32Bits(fa[i]) != System.BitConverter.SingleToInt32Bits(fb[i]))
|
||||
{
|
||||
GD.PrintErr("[Harness] TDTL fields differ.");
|
||||
return false;
|
||||
}
|
||||
GD.Print($"[Harness] TDTL equal (detail v{da.Version}).");
|
||||
return true;
|
||||
}
|
||||
|
||||
private bool CompareRoads(string tier, List<Vector2[]> a, List<Vector2[]> b)
|
||||
{
|
||||
if (a.Count != b.Count)
|
||||
|
|
|
|||
143
Tools/Scripts/TerrainDetailPass.cs
Normal file
143
Tools/Scripts/TerrainDetailPass.cs
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
using Godot;
|
||||
using System;
|
||||
|
||||
/// <summary>
|
||||
/// The terrain DETAIL passes (terrain-water task 10) — pure numeric array machinery
|
||||
/// (D-035; a named future C++ candidate, kept standalone):
|
||||
///
|
||||
/// PASS A — shelf micro-relief: a medium-frequency noise skin (±ShelfReliefAmp,
|
||||
/// default 3 m) weighted by shelf-ness, so the compressed shelves get their
|
||||
/// rolling texture back while risers and peaks stay untouched.
|
||||
///
|
||||
/// PASS B — drainage incision: D8 steepest-descent flow routing + accumulation
|
||||
/// over the curved terrain; depth = K · accum^p · localSlope (capped), masked to
|
||||
/// the risers (feathered ~30 % onto shelves, zero on the toe and above the
|
||||
/// plateau top, zero near the crater), clamped so carved terrain never drops
|
||||
/// below sea + 1 m. The channels double as the future river routes (Phase C).
|
||||
///
|
||||
/// Ordering (enforced by the caller): curve → micro-relief → incision → crater
|
||||
/// carve. The classify map never sees any of it.
|
||||
/// </summary>
|
||||
public static class TerrainDetailPass
|
||||
{
|
||||
public const ushort VERSION = 1;
|
||||
|
||||
// Pass A — micro-relief.
|
||||
public const float RELIEF_AMP_DEFAULT_M = 3f; // config dial: ShelfReliefAmp (metres)
|
||||
public const float RELIEF_FREQ_ISLANDS = 40f; // ~40 undulations per island width (~200 m features)
|
||||
public const int RELIEF_SEED_OFFSET = 7409;
|
||||
|
||||
// Pass B — incision. K/p tuned against the depth targets (gullies 8–15 m,
|
||||
// trunks ~25 m, cap 30 m); the tuning run's achieved distribution is in the
|
||||
// task-10 report.
|
||||
public const float INC_K = 1.40f;
|
||||
public const float INC_P = 0.45f; // concave: many fingers, few deep trunks
|
||||
public const float INC_CAP_M = 30f; // IncisionMax
|
||||
public const float SEA_CLAMP = 0.15f + 1f / 251f; // carved height ≥ sea + 1 m
|
||||
public const float SHELF_INC_WEIGHT = 0.3f; // shelves get washes, not gorges
|
||||
public const float CRATER_EXCL_FACTOR = 1.2f; // zero incision inside this × CraterRadius
|
||||
public const float CRATER_FEATHER_FACTOR = 1.4f; // ...feathering to full by this × CraterRadius
|
||||
|
||||
/// <summary>
|
||||
/// Shelf-ness weight from the RAW input height: 1 mid-shelf, feathering to 0
|
||||
/// through the risers (feather extends 30 % of the band half-width past each
|
||||
/// shelf edge). Covers both shelves.
|
||||
/// </summary>
|
||||
public static float ShelfWeight(float raw, CurveKnots k)
|
||||
{
|
||||
return Mathf.Max(BandBump(raw, k.K3, k.K4), BandBump(raw, k.K5, k.K6));
|
||||
}
|
||||
|
||||
private static float BandBump(float h, float lo, float hi)
|
||||
{
|
||||
float half = (hi - lo) * 0.5f;
|
||||
float t = Mathf.Abs(h - (lo + half)) / half; // 0 centre, 1 at band edge
|
||||
// full inside 60 % of the band, linear feather to zero at 130 %
|
||||
return Mathf.Clamp(1f - (t - 0.6f) / 0.7f, 0f, 1f);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Incision mask from the RAW input height: 0 below the red-ceiling input (K2)
|
||||
/// and above the plateau top (K6); 1 on the riser bands; SHELF_INC_WEIGHT on the
|
||||
/// shelf bands; smooth feathers (15 % of the local band width) at every boundary.
|
||||
/// </summary>
|
||||
public static float IncisionWeight(float raw, CurveKnots k)
|
||||
{
|
||||
if (raw <= k.K2 || raw >= k.K6) return 0f;
|
||||
if (raw < k.K3) // foothill riser: feather in from K2, feather toward shelf weight at K3
|
||||
return EdgeBlend(raw, k.K2, k.K3, 0f, 1f, SHELF_INC_WEIGHT);
|
||||
if (raw < k.K4) // bench
|
||||
return SHELF_INC_WEIGHT;
|
||||
if (raw < k.K5) // mid riser
|
||||
return EdgeBlend(raw, k.K4, k.K5, SHELF_INC_WEIGHT, 1f, SHELF_INC_WEIGHT);
|
||||
// plateau band: shelf weight, feathering to zero at K6
|
||||
float w = (k.K6 - raw) / ((k.K6 - k.K5) * 0.15f);
|
||||
return Mathf.Min(SHELF_INC_WEIGHT, Mathf.Clamp(w, 0f, 1f) * SHELF_INC_WEIGHT);
|
||||
}
|
||||
|
||||
private static float EdgeBlend(float h, float lo, float hi, float wIn, float wMid, float wOut)
|
||||
{
|
||||
float f = (hi - lo) * 0.15f;
|
||||
if (h < lo + f) return Mathf.Lerp(wIn, wMid, (h - lo) / f);
|
||||
if (h > hi - f) return Mathf.Lerp(wMid, wOut, (h - (hi - f)) / f);
|
||||
return wMid;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// D8 flow accumulation over a height field (row-major idx = x·n + y).
|
||||
/// Steepest-descent routing (drop / distance, diagonals ÷√2), deterministic
|
||||
/// tie-break (fixed neighbour order, first winner). Cells with no lower
|
||||
/// neighbour are pits/outlets (no outflow). accum = upslope contributing cells
|
||||
/// including self; steepestDrop = drop per pixel toward the chosen neighbour.
|
||||
/// </summary>
|
||||
public static int[] FlowAccumulation(float[] h, int n, out float[] steepestDrop)
|
||||
{
|
||||
int total = n * n;
|
||||
int[] downstream = new int[total];
|
||||
steepestDrop = new float[total];
|
||||
int[] dx = { 1, -1, 0, 0, 1, 1, -1, -1 };
|
||||
int[] dy = { 0, 0, 1, -1, 1, -1, 1, -1 };
|
||||
float[] invDist = { 1f, 1f, 1f, 1f, 0.7071068f, 0.7071068f, 0.7071068f, 0.7071068f };
|
||||
|
||||
for (int x = 0; x < n; x++)
|
||||
{
|
||||
for (int y = 0; y < n; y++)
|
||||
{
|
||||
int i = x * n + y;
|
||||
float hc = h[i];
|
||||
float best = 0f;
|
||||
int bestIdx = -1;
|
||||
for (int d = 0; d < 8; d++)
|
||||
{
|
||||
int nx = x + dx[d], ny = y + dy[d];
|
||||
if (nx < 0 || nx >= n || ny < 0 || ny >= n) continue;
|
||||
int ni = nx * n + ny;
|
||||
float grade = (hc - h[ni]) * invDist[d];
|
||||
if (grade > best)
|
||||
{
|
||||
best = grade;
|
||||
bestIdx = ni;
|
||||
}
|
||||
}
|
||||
downstream[i] = bestIdx;
|
||||
steepestDrop[i] = best;
|
||||
}
|
||||
}
|
||||
|
||||
// Height-descending order: each cell pushes its accumulated count downstream.
|
||||
float[] keys = (float[])h.Clone();
|
||||
int[] order = new int[total];
|
||||
for (int i = 0; i < total; i++) order[i] = i;
|
||||
Array.Sort(keys, order); // ascending
|
||||
|
||||
int[] accum = new int[total];
|
||||
for (int i = 0; i < total; i++) accum[i] = 1;
|
||||
for (int i = total - 1; i >= 0; i--)
|
||||
{
|
||||
int c = order[i];
|
||||
int d = downstream[c];
|
||||
if (d >= 0) accum[d] += accum[c];
|
||||
}
|
||||
return accum;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue