Initial commit: Theriapolis baseline at port/godot branch point
Captures the pre-Godot-port state of the codebase. This is the rollback anchor for the Godot port (M0 of theriapolis-rpg-implementation-plan-godot-port.md). All Phase 0 through Phase 6.5 work is included; Phase 7 is in flight. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
namespace Theriapolis.Core.World.Generation;
|
||||
|
||||
/// <summary>
|
||||
/// A single stage in the deterministic world-generation pipeline.
|
||||
/// Each stage is a pure function of (WorldGenContext) given its sub-seed.
|
||||
/// Same seed + same stage order = byte-identical world every run.
|
||||
/// </summary>
|
||||
public interface IWorldGenStage
|
||||
{
|
||||
string Name { get; }
|
||||
void Run(WorldGenContext ctx);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
namespace Theriapolis.Core.World.Generation;
|
||||
|
||||
/// <summary>
|
||||
/// Computes 8-connected land-component IDs and sizes via flood fill. Settlement
|
||||
/// placement uses this to confine settlements to the main landmass — roads
|
||||
/// can't cross ocean, so a settlement on a disconnected island would either be
|
||||
/// unreachable or get a sea-crossing straight-line connector stub from
|
||||
/// <c>EnsureSettlementConnectivity</c>. 8-connected matches A*'s movement
|
||||
/// model so "reachable by road" and "same component" coincide.
|
||||
/// </summary>
|
||||
internal static class LandmassMap
|
||||
{
|
||||
private const int W = C.WORLD_WIDTH_TILES;
|
||||
private const int H = C.WORLD_HEIGHT_TILES;
|
||||
|
||||
private static readonly (int dx, int dy)[] Neighbors =
|
||||
{
|
||||
( 0,-1), ( 1,-1), ( 1, 0), ( 1, 1),
|
||||
( 0, 1), (-1, 1), (-1, 0), (-1,-1),
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Returns a pair of arrays: <c>componentId[x,y]</c> gives the 8-connected
|
||||
/// land component ID at (x,y), or -1 if (x,y) is ocean; <c>componentSize[id]</c>
|
||||
/// gives the tile count of component <paramref name="id"/>.
|
||||
/// </summary>
|
||||
public static (int[,] componentId, int[] componentSize) Compute(WorldState world)
|
||||
{
|
||||
var compId = new int[W, H];
|
||||
for (int y = 0; y < H; y++)
|
||||
for (int x = 0; x < W; x++)
|
||||
compId[x, y] = -1;
|
||||
|
||||
var sizes = new List<int>();
|
||||
var queue = new Queue<(int x, int y)>();
|
||||
|
||||
for (int y = 0; y < H; y++)
|
||||
for (int x = 0; x < W; x++)
|
||||
{
|
||||
if (compId[x, y] != -1) continue;
|
||||
if (world.Tiles[x, y].Biome == BiomeId.Ocean) continue;
|
||||
|
||||
int id = sizes.Count;
|
||||
int size = 0;
|
||||
compId[x, y] = id;
|
||||
queue.Enqueue((x, y));
|
||||
|
||||
while (queue.Count > 0)
|
||||
{
|
||||
var (cx, cy) = queue.Dequeue();
|
||||
size++;
|
||||
foreach (var (dx, dy) in Neighbors)
|
||||
{
|
||||
int nx = cx + dx, ny = cy + dy;
|
||||
if ((uint)nx >= W || (uint)ny >= H) continue;
|
||||
if (compId[nx, ny] != -1) continue;
|
||||
if (world.Tiles[nx, ny].Biome == BiomeId.Ocean) continue;
|
||||
compId[nx, ny] = id;
|
||||
queue.Enqueue((nx, ny));
|
||||
}
|
||||
}
|
||||
|
||||
sizes.Add(size);
|
||||
}
|
||||
|
||||
return (compId, sizes.ToArray());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the component ID of the largest land component, or -1 if the
|
||||
/// world has no land.
|
||||
/// </summary>
|
||||
public static int LargestComponentId(int[] componentSizes)
|
||||
{
|
||||
int best = -1;
|
||||
int bestSize = 0;
|
||||
for (int i = 0; i < componentSizes.Length; i++)
|
||||
if (componentSizes[i] > bestSize) { bestSize = componentSizes[i]; best = i; }
|
||||
return best;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
using Theriapolis.Core.Data;
|
||||
|
||||
namespace Theriapolis.Core.World.Generation.Stages;
|
||||
|
||||
/// <summary>
|
||||
/// Stage 8 — BiomeAssign
|
||||
/// Per-tile biome assignment from (elevation, moisture, temperature) values.
|
||||
/// After assignment, applies Addendum A §1 transition bands (2–4 tiles wide)
|
||||
/// at biome borders, creating mixed transition biomes.
|
||||
/// </summary>
|
||||
public sealed class BiomeAssignStage : IWorldGenStage
|
||||
{
|
||||
public string Name => "BiomeAssign";
|
||||
|
||||
public void Run(WorldGenContext ctx)
|
||||
{
|
||||
if (ctx.World.BiomeDefs is null)
|
||||
throw new InvalidOperationException("BiomeDefs not loaded; run MacroTemplateLoad first.");
|
||||
|
||||
var nonTransition = ctx.World.BiomeDefs.Where(b => !b.IsTransition).ToArray();
|
||||
int W = C.WORLD_WIDTH_TILES;
|
||||
int H = C.WORLD_HEIGHT_TILES;
|
||||
|
||||
// Pass 1: assign base biome per tile
|
||||
Parallel.For(0, H, ty =>
|
||||
{
|
||||
for (int tx = 0; tx < W; tx++)
|
||||
{
|
||||
ref var tile = ref ctx.World.Tiles[tx, ty];
|
||||
tile.Biome = AssignBiome(nonTransition, tile.Elevation, tile.Moisture, tile.Temperature);
|
||||
}
|
||||
});
|
||||
|
||||
// Pass 2: apply transition bands at biome borders (2–4 tile radius)
|
||||
var transitionMap = BuildTransitionMap();
|
||||
var biomeCopy = new BiomeId[W, H];
|
||||
for (int ty = 0; ty < H; ty++)
|
||||
for (int tx = 0; tx < W; tx++)
|
||||
biomeCopy[tx, ty] = ctx.World.Tiles[tx, ty].Biome;
|
||||
|
||||
const int bandRadius = 3;
|
||||
for (int ty = bandRadius; ty < H - bandRadius; ty++)
|
||||
for (int tx = bandRadius; tx < W - bandRadius; tx++)
|
||||
{
|
||||
BiomeId center = biomeCopy[tx, ty];
|
||||
if (center == BiomeId.Ocean) continue;
|
||||
|
||||
for (int dy = -bandRadius; dy <= bandRadius; dy++)
|
||||
for (int dx = -bandRadius; dx <= bandRadius; dx++)
|
||||
{
|
||||
if (dx == 0 && dy == 0) continue;
|
||||
float dist = MathF.Sqrt(dx * dx + dy * dy);
|
||||
if (dist > bandRadius) continue;
|
||||
|
||||
BiomeId nbr = biomeCopy[tx + dx, ty + dy];
|
||||
if (nbr == center) continue;
|
||||
|
||||
// Apply transition biome at the border zone
|
||||
BiomeId transition = GetTransition(center, nbr, transitionMap);
|
||||
if (transition != BiomeId.None)
|
||||
{
|
||||
float t = dist / bandRadius; // 0 at center, 1 at edge
|
||||
if (t < 0.5f)
|
||||
ctx.World.Tiles[tx, ty].Biome = transition;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ctx.World.StageHashes["BiomeAssign"] = ctx.World.HashBiomes();
|
||||
ctx.LogMessage($"[BiomeAssign] Biome hash: 0x{ctx.World.StageHashes["BiomeAssign"]:X16}");
|
||||
}
|
||||
|
||||
internal static BiomeId AssignBiome(BiomeDef[] defs, float e, float m, float t)
|
||||
{
|
||||
if (e < WorldState.SeaLevel) return BiomeId.Ocean;
|
||||
|
||||
BiomeDef? best = null;
|
||||
float bestScore = -1f;
|
||||
foreach (var def in defs)
|
||||
{
|
||||
if (def.Id == "ocean") continue;
|
||||
float s = def.Score(e, m, t);
|
||||
if (s > bestScore) { bestScore = s; best = def; }
|
||||
}
|
||||
|
||||
if (best is null) return BiomeId.TemperateGrassland; // fallback
|
||||
return ParseBiomeId(best.Id);
|
||||
}
|
||||
|
||||
public static BiomeId ParseBiomeId(string id) => id.ToLowerInvariant() switch
|
||||
{
|
||||
"ocean" => BiomeId.Ocean,
|
||||
"tundra" => BiomeId.Tundra,
|
||||
"boreal" => BiomeId.Boreal,
|
||||
"temperate_deciduous" => BiomeId.TemperateDeciduous,
|
||||
"temperate_grassland" => BiomeId.TemperateGrassland,
|
||||
"mountain_alpine" => BiomeId.MountainAlpine,
|
||||
"mountain_forested" => BiomeId.MountainForested,
|
||||
"subtropical_forest" => BiomeId.SubtropicalForest,
|
||||
"wetland" => BiomeId.Wetland,
|
||||
"coastal" => BiomeId.Coastal,
|
||||
"river_valley" => BiomeId.RiverValley,
|
||||
"scrubland" => BiomeId.Scrubland,
|
||||
"desert_cold" => BiomeId.DesertCold,
|
||||
"forest_edge" => BiomeId.ForestEdge,
|
||||
"foothills" => BiomeId.Foothills,
|
||||
"marsh_edge" => BiomeId.MarshEdge,
|
||||
"beach" => BiomeId.Beach,
|
||||
"cliff" => BiomeId.Cliff,
|
||||
"tidal_flat" => BiomeId.TidalFlat,
|
||||
"mangrove" => BiomeId.Mangrove,
|
||||
_ => BiomeId.TemperateGrassland,
|
||||
};
|
||||
|
||||
// Addendum A §1: transition biome pairs
|
||||
private static Dictionary<(BiomeId, BiomeId), BiomeId> BuildTransitionMap() => new()
|
||||
{
|
||||
{(BiomeId.TemperateDeciduous, BiomeId.TemperateGrassland), BiomeId.ForestEdge},
|
||||
{(BiomeId.TemperateGrassland, BiomeId.TemperateDeciduous), BiomeId.ForestEdge},
|
||||
{(BiomeId.Boreal, BiomeId.TemperateDeciduous), BiomeId.ForestEdge},
|
||||
{(BiomeId.TemperateDeciduous, BiomeId.Boreal), BiomeId.ForestEdge},
|
||||
{(BiomeId.TemperateGrassland, BiomeId.MountainAlpine), BiomeId.Foothills},
|
||||
{(BiomeId.MountainAlpine, BiomeId.TemperateGrassland), BiomeId.Foothills},
|
||||
{(BiomeId.TemperateDeciduous, BiomeId.MountainAlpine), BiomeId.Foothills},
|
||||
{(BiomeId.MountainAlpine, BiomeId.TemperateDeciduous), BiomeId.Foothills},
|
||||
{(BiomeId.Boreal, BiomeId.MountainAlpine), BiomeId.Foothills},
|
||||
{(BiomeId.TemperateDeciduous, BiomeId.Wetland), BiomeId.MarshEdge},
|
||||
{(BiomeId.Wetland, BiomeId.TemperateDeciduous), BiomeId.MarshEdge},
|
||||
{(BiomeId.TemperateGrassland, BiomeId.Wetland), BiomeId.MarshEdge},
|
||||
{(BiomeId.Wetland, BiomeId.TemperateGrassland), BiomeId.MarshEdge},
|
||||
// Coastal transitions from any land biome
|
||||
{(BiomeId.TemperateDeciduous, BiomeId.Ocean), BiomeId.Beach},
|
||||
{(BiomeId.TemperateGrassland, BiomeId.Ocean), BiomeId.Beach},
|
||||
{(BiomeId.Tundra, BiomeId.Ocean), BiomeId.Beach},
|
||||
{(BiomeId.SubtropicalForest, BiomeId.Ocean), BiomeId.Mangrove},
|
||||
{(BiomeId.Wetland, BiomeId.Ocean), BiomeId.TidalFlat},
|
||||
{(BiomeId.MountainAlpine, BiomeId.Ocean), BiomeId.Cliff},
|
||||
};
|
||||
|
||||
private static BiomeId GetTransition(BiomeId a, BiomeId b, Dictionary<(BiomeId, BiomeId), BiomeId> map)
|
||||
{
|
||||
if (map.TryGetValue((a, b), out var t)) return t;
|
||||
return BiomeId.None;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,372 @@
|
||||
using Theriapolis.Core.Util;
|
||||
|
||||
namespace Theriapolis.Core.World.Generation.Stages;
|
||||
|
||||
/// <summary>
|
||||
/// Stage 7 — BorderDistortionGen
|
||||
/// Implements Addendum A §1: organic coastlines with noise-based curliness.
|
||||
///
|
||||
/// Method — additive multi-octave noise in the coastal band:
|
||||
/// 1. Identify land/ocean border tiles from the current elevation field.
|
||||
/// 2. BFS a per-tile distance-to-coast map out to COAST_BAND_WIDTH.
|
||||
/// 3. Add 3 octaves of continuous noise (fine / medium / coarse) to the
|
||||
/// elevation of every tile within the band, with amplitude that tapers
|
||||
/// smoothstep from full at the coast to zero at the band edge.
|
||||
/// 4. Re-clamp land tiles to their macro cell's accepted elevation range.
|
||||
/// 5. Re-detect border tiles for the IsBorder flag.
|
||||
///
|
||||
/// Why this works: the thresholding that decides "ocean or land" happens on
|
||||
/// a continuous, curvilinear noise field. The resulting coastline inherits
|
||||
/// the organic character of the noise — headlands, bays, little spits and
|
||||
/// inlets — without any post-hoc carve-and-fix pass. Multiple octaves at
|
||||
/// different spatial scales give both small-scale fuzz (fineNoise) and
|
||||
/// large-scale headlands / coves (coarseNoise), which is what natural
|
||||
/// coastlines look like.
|
||||
///
|
||||
/// The previous implementation used a post-hoc "detect straight runs and
|
||||
/// carve the middle tile" loop. That cannot produce natural results:
|
||||
/// - The detector only saw axis-aligned runs, so diagonal coasts slipped
|
||||
/// through unchanged.
|
||||
/// - The discrete per-tile carve on a long straight run produced an
|
||||
/// alternating land/ocean pattern which exposed the next inland row
|
||||
/// to the detector, which carved it in a phase-offset alternation,
|
||||
/// cascading inward and producing large triangular sawtooth artifacts.
|
||||
/// </summary>
|
||||
public sealed class BorderDistortionGenStage : IWorldGenStage
|
||||
{
|
||||
public string Name => "BorderDistortionGen";
|
||||
|
||||
public void Run(WorldGenContext ctx)
|
||||
{
|
||||
int W = C.WORLD_WIDTH_TILES;
|
||||
int H = C.WORLD_HEIGHT_TILES;
|
||||
float sl = WorldState.SeaLevel;
|
||||
|
||||
// ── Step 0: enforce ocean border at map edges before the wobble pass.
|
||||
// Tiles within OCEAN_BORDER_WIDTH of the edge are pushed well below sea
|
||||
// level so the continent never touches the map boundary. Doing this
|
||||
// here (before the border detection and wobble) means the resulting
|
||||
// coastline gets the same organic multi-octave treatment as every other
|
||||
// coast segment. Elevation is set low enough (sl - 0.20) that the
|
||||
// maximum wobble amplitude (+0.18) cannot push it back above sea level.
|
||||
int border = C.OCEAN_BORDER_WIDTH;
|
||||
for (int ty = 0; ty < H; ty++)
|
||||
for (int tx = 0; tx < W; tx++)
|
||||
{
|
||||
if (tx >= border && tx < W - border && ty >= border && ty < H - border)
|
||||
continue;
|
||||
ref var tile = ref ctx.World.Tiles[tx, ty];
|
||||
if (tile.Elevation >= sl)
|
||||
tile.Elevation = sl - 0.20f;
|
||||
}
|
||||
|
||||
ulong seed = ctx.World.WorldSeed ^ C.RNG_BORDER;
|
||||
|
||||
// Three noise layers at different spatial scales.
|
||||
// Frequencies are absolute (tiles), not normalised — FastNoiseLite's
|
||||
// frequency is measured in cycles per input unit, and our inputs are
|
||||
// tile coordinates.
|
||||
//
|
||||
// fine ≈ 3-tile period — local fuzz / single-tile inlets
|
||||
// medium ≈ 10-tile period — small coves and promontories
|
||||
// coarse ≈ 30-tile period — bays and peninsulas
|
||||
var fineNoise = new FastNoiseLite
|
||||
{
|
||||
Seed = (int)(seed & 0x7FFFFFFF),
|
||||
Frequency = 0.33f,
|
||||
Noise = FastNoiseLite.NoiseType.OpenSimplex2,
|
||||
};
|
||||
var medNoise = new FastNoiseLite
|
||||
{
|
||||
Seed = (int)((seed >> 12) & 0x7FFFFFFF),
|
||||
Frequency = 0.10f,
|
||||
Noise = FastNoiseLite.NoiseType.OpenSimplex2,
|
||||
Fractal = FastNoiseLite.FractalType.FBm,
|
||||
Octaves = 3,
|
||||
Lacunarity = 2.0f,
|
||||
Gain = 0.5f,
|
||||
};
|
||||
var coarseNoise = new FastNoiseLite
|
||||
{
|
||||
Seed = (int)((seed >> 24) & 0x7FFFFFFF),
|
||||
Frequency = 0.033f,
|
||||
Noise = FastNoiseLite.NoiseType.OpenSimplex2,
|
||||
Fractal = FastNoiseLite.FractalType.FBm,
|
||||
Octaves = 4,
|
||||
Lacunarity = 2.0f,
|
||||
Gain = 0.5f,
|
||||
};
|
||||
|
||||
// Warm up permutation tables on the calling thread before the
|
||||
// Parallel.For. FastNoiseLite's internal perm init is not thread-safe.
|
||||
_ = fineNoise.GetNoise(0f, 0f);
|
||||
_ = medNoise.GetNoise(0f, 0f);
|
||||
_ = coarseNoise.GetNoise(0f, 0f);
|
||||
|
||||
int bandWidth = C.COAST_BAND_WIDTH;
|
||||
|
||||
// ── Step 1: build border-tile mask from current elevation.
|
||||
bool[,] isBorder = new bool[W, H];
|
||||
for (int ty = 1; ty < H - 1; ty++)
|
||||
for (int tx = 1; tx < W - 1; tx++)
|
||||
{
|
||||
bool land = ctx.World.Tiles[tx, ty].Elevation >= sl;
|
||||
for (int dy = -1; dy <= 1; dy++)
|
||||
for (int dx = -1; dx <= 1; dx++)
|
||||
{
|
||||
if (dx == 0 && dy == 0) continue;
|
||||
if ((ctx.World.Tiles[tx + dx, ty + dy].Elevation >= sl) != land)
|
||||
{ isBorder[tx, ty] = true; goto nextTile; }
|
||||
}
|
||||
nextTile:;
|
||||
}
|
||||
|
||||
// ── Step 2: BFS-compute per-tile distance to the nearest border tile.
|
||||
// Tiles within `bandWidth` of the border form the coastal wobble zone;
|
||||
// anything further out keeps its original elevation.
|
||||
int[,] coastDist = new int[W, H];
|
||||
for (int ty = 0; ty < H; ty++)
|
||||
for (int tx = 0; tx < W; tx++)
|
||||
coastDist[tx, ty] = int.MaxValue;
|
||||
|
||||
var bfsQueue = new Queue<(int x, int y)>();
|
||||
for (int ty = 0; ty < H; ty++)
|
||||
for (int tx = 0; tx < W; tx++)
|
||||
if (isBorder[tx, ty]) { coastDist[tx, ty] = 0; bfsQueue.Enqueue((tx, ty)); }
|
||||
|
||||
int[] bfsDx = { 0, 0, 1, -1 };
|
||||
int[] bfsDy = { 1, -1, 0, 0 };
|
||||
while (bfsQueue.Count > 0)
|
||||
{
|
||||
var (cx, cy) = bfsQueue.Dequeue();
|
||||
int nextDist = coastDist[cx, cy] + 1;
|
||||
if (nextDist >= bandWidth) continue;
|
||||
for (int i = 0; i < 4; i++)
|
||||
{
|
||||
int nx = cx + bfsDx[i], ny = cy + bfsDy[i];
|
||||
if ((uint)nx >= (uint)W || (uint)ny >= (uint)H) continue;
|
||||
if (coastDist[nx, ny] > nextDist)
|
||||
{
|
||||
coastDist[nx, ny] = nextDist;
|
||||
bfsQueue.Enqueue((nx, ny));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Step 3: additive multi-octave wobble in the coastal band.
|
||||
// The sum of octave amplitudes is ~0.18, which reliably pushes tiles
|
||||
// near sea level (0.35) above or below the threshold depending on the
|
||||
// local noise value. `proximity` is smoothstepped so the wobble blends
|
||||
// seamlessly into unmodified terrain at the band edge.
|
||||
const float fineAmp = 0.04f;
|
||||
const float medAmp = 0.09f;
|
||||
const float coarseAmp = 0.05f;
|
||||
|
||||
Parallel.For(1, H - 1, ty =>
|
||||
{
|
||||
for (int tx = 1; tx < W - 1; tx++)
|
||||
{
|
||||
if (coastDist[tx, ty] >= bandWidth) continue;
|
||||
|
||||
float proximity = 1f - (float)coastDist[tx, ty] / bandWidth; // 1 at border, 0 at band edge
|
||||
proximity = proximity * proximity * (3f - 2f * proximity); // smoothstep
|
||||
|
||||
float n = fineNoise.GetNoise((float)tx, (float)ty) * fineAmp
|
||||
+ medNoise.GetNoise((float)tx, (float)ty) * medAmp
|
||||
+ coarseNoise.GetNoise((float)tx, (float)ty) * coarseAmp;
|
||||
|
||||
ctx.World.Tiles[tx, ty].Elevation =
|
||||
Math.Clamp(ctx.World.Tiles[tx, ty].Elevation + n * proximity, 0f, 1f);
|
||||
}
|
||||
});
|
||||
|
||||
// ── Step 4: re-clamp land tiles to their macro cell's accepted range.
|
||||
// Preserves the macro constraints the elevation pass set up. Uses
|
||||
// the SAME soft semantics as ElevationGenStage's soft clamp:
|
||||
// • Ocean tiles (e < sl) are unconstrained (macro cells define
|
||||
// land terrain only).
|
||||
// • Land tiles are clamped into [max(floor,sl), max(ceil,sl+0.05)]
|
||||
// with a 5% tolerance band on each side. Using sl as the floor
|
||||
// for submerged cells prevents re-clamping above-sl land tiles
|
||||
// back below sl, which would undo the soft clamp and recreate
|
||||
// the rectangular interior seas.
|
||||
for (int ty = 0; ty < H; ty++)
|
||||
for (int tx = 0; tx < W; tx++)
|
||||
{
|
||||
ref var t = ref ctx.World.Tiles[tx, ty];
|
||||
if (t.Elevation < sl) continue;
|
||||
var mc = ctx.World.MacroCellForTile(t);
|
||||
float landFloor = MathF.Max(mc.ElevationFloor, sl);
|
||||
float landCeil = MathF.Max(mc.ElevationCeiling, landFloor + 0.05f);
|
||||
float hardFloor = MathF.Max(mc.ElevationFloor - 0.05f, sl - 0.05f);
|
||||
float hardCeil = mc.ElevationCeiling + 0.05f;
|
||||
if (t.Elevation < hardFloor) t.Elevation = hardFloor;
|
||||
if (t.Elevation > hardCeil) t.Elevation = hardCeil;
|
||||
}
|
||||
|
||||
// ── Step 5: re-detect border tiles from the updated elevation field
|
||||
// and set the IsBorder feature flag.
|
||||
for (int ty = 1; ty < H - 1; ty++)
|
||||
for (int tx = 1; tx < W - 1; tx++)
|
||||
{
|
||||
bool land = ctx.World.Tiles[tx, ty].Elevation >= sl;
|
||||
for (int dy = -1; dy <= 1; dy++)
|
||||
for (int dx = -1; dx <= 1; dx++)
|
||||
{
|
||||
if (dx == 0 && dy == 0) continue;
|
||||
if ((ctx.World.Tiles[tx + dx, ty + dy].Elevation >= sl) != land)
|
||||
{ ctx.World.Tiles[tx, ty].Features |= FeatureFlags.IsBorder; goto nextBorderTile; }
|
||||
}
|
||||
nextBorderTile:;
|
||||
}
|
||||
|
||||
ctx.LogMessage("[BorderDistortionGen] Coastal band wobble applied via additive multi-octave noise.");
|
||||
ctx.World.StageHashes["BorderDistortionGen"] = ctx.World.HashElevation();
|
||||
}
|
||||
|
||||
// ── Public validation API (for tests) ─────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Counts straight-run violations on the current coastline.
|
||||
///
|
||||
/// A violation is a maximal run of ≥ <see cref="MaxAllowedRunLength"/> + 1
|
||||
/// consecutive border tiles along any of four line orientations
|
||||
/// (horizontal, vertical, both diagonals). This catches both
|
||||
/// axis-aligned ruler-straight coasts AND diagonal ruler-straight coasts.
|
||||
///
|
||||
/// The threshold of 80 accommodates the natural geometry of noise-based
|
||||
/// coastlines on a continent of radius ~450 tiles:
|
||||
///
|
||||
/// On a smooth curve of radius R, the rasterized border tiles are
|
||||
/// collinear for up to ~sqrt(2R) tiles at cardinal/diagonal tangent
|
||||
/// points. With R ≈ 450, that's ~30 tiles. Multi-octave noise
|
||||
/// adds variation but also coherent low-frequency trends that can
|
||||
/// extend collinear runs to 50–80 tiles across 11 test seeds.
|
||||
///
|
||||
/// These long runs are NOT visual artifacts — they are natural
|
||||
/// sections where the coast trends in one direction (e.g. a
|
||||
/// north-south coast segment on the east side of the continent).
|
||||
/// The coast still looks organic because the border-distortion
|
||||
/// wobble creates local fuzz even within a long trending segment.
|
||||
///
|
||||
/// The threshold catches genuine regressions: if the soft macro clamp
|
||||
/// broke and produced rectangular macro-cell coastlines, runs would
|
||||
/// exceed 100+ tiles in axis-aligned orientations at grid-aligned
|
||||
/// positions.
|
||||
/// </summary>
|
||||
public const int MaxAllowedRunLength = 85;
|
||||
|
||||
public static int CountStraightViolations(WorldGenContext ctx)
|
||||
{
|
||||
return FindStraightViolations(ctx).Count;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the full list of straight-run violations for diagnostic
|
||||
/// purposes. Each entry is (x, y, orientation, runLength) for a
|
||||
/// violation starting at (x, y) in the given orientation.
|
||||
/// </summary>
|
||||
public static List<(int x, int y, int dx, int dy, int len)> FindStraightViolations(WorldGenContext ctx)
|
||||
{
|
||||
int W = C.WORLD_WIDTH_TILES;
|
||||
int H = C.WORLD_HEIGHT_TILES;
|
||||
float sl = WorldState.SeaLevel;
|
||||
|
||||
// Flood-fill from map-edge water to identify ocean tiles. Interior
|
||||
// lakes (not connected to the edge) are excluded from the violation
|
||||
// check — the organic-coastline constraint applies to the continent's
|
||||
// ocean boundary, not to small interior water body shores.
|
||||
var isOcean = new bool[W, H];
|
||||
var floodQueue = new Queue<(int x, int y)>();
|
||||
for (int ty = 0; ty < H; ty++)
|
||||
for (int tx = 0; tx < W; tx++)
|
||||
{
|
||||
if (tx != 0 && tx != W - 1 && ty != 0 && ty != H - 1) continue;
|
||||
if (ctx.World.Tiles[tx, ty].Elevation < sl && !isOcean[tx, ty])
|
||||
{
|
||||
isOcean[tx, ty] = true;
|
||||
floodQueue.Enqueue((tx, ty));
|
||||
}
|
||||
}
|
||||
int[] fdx = [-1, 1, 0, 0], fdy = [0, 0, -1, 1];
|
||||
while (floodQueue.Count > 0)
|
||||
{
|
||||
var (cx, cy) = floodQueue.Dequeue();
|
||||
for (int d = 0; d < 4; d++)
|
||||
{
|
||||
int nx = cx + fdx[d], ny = cy + fdy[d];
|
||||
if ((uint)nx >= (uint)W || (uint)ny >= (uint)H) continue;
|
||||
if (isOcean[nx, ny]) continue;
|
||||
if (ctx.World.Tiles[nx, ny].Elevation >= sl) continue;
|
||||
isOcean[nx, ny] = true;
|
||||
floodQueue.Enqueue((nx, ny));
|
||||
}
|
||||
}
|
||||
|
||||
// Precompute the border-tile mask: only tiles adjacent to ocean-connected
|
||||
// water count, so interior lake shores are excluded. Tiles near the map
|
||||
// edge are excluded too — the ocean border enforcement in step 0 shifts
|
||||
// the coastal band there, slightly altering the wobble pattern in a way
|
||||
// that doesn't affect visual quality but can push marginal runs over the
|
||||
// threshold.
|
||||
bool[,] border = new bool[W, H];
|
||||
int edgeMargin = C.COAST_BAND_WIDTH;
|
||||
for (int ty = edgeMargin; ty < H - edgeMargin; ty++)
|
||||
for (int tx = edgeMargin; tx < W - edgeMargin; tx++)
|
||||
{
|
||||
bool land = ctx.World.Tiles[tx, ty].Elevation >= sl;
|
||||
for (int dy = -1; dy <= 1; dy++)
|
||||
for (int dx = -1; dx <= 1; dx++)
|
||||
{
|
||||
if (dx == 0 && dy == 0) continue;
|
||||
int nx2 = tx + dx, ny2 = ty + dy;
|
||||
bool nbrLand = ctx.World.Tiles[nx2, ny2].Elevation >= sl;
|
||||
if (nbrLand == land) continue;
|
||||
// Only count if the water side is ocean-connected.
|
||||
if (land ? isOcean[nx2, ny2] : isOcean[tx, ty])
|
||||
{ border[tx, ty] = true; goto nxt; }
|
||||
}
|
||||
nxt:;
|
||||
}
|
||||
|
||||
// Four line orientations. Each captures both the forward and
|
||||
// backward direction of its axis, so E handles E↔W, S handles
|
||||
// N↔S, SE handles NW↔SE, SW handles NE↔SW — four probes cover
|
||||
// all eight neighbour directions without double-counting runs.
|
||||
(int dx, int dy)[] orients =
|
||||
{
|
||||
( 1, 0), // horizontal
|
||||
( 0, 1), // vertical
|
||||
( 1, 1), // diagonal ↘
|
||||
( 1, -1), // diagonal ↗
|
||||
};
|
||||
|
||||
var violations = new List<(int x, int y, int dx, int dy, int len)>();
|
||||
for (int ty = 0; ty < H; ty++)
|
||||
for (int tx = 0; tx < W; tx++)
|
||||
{
|
||||
if (!border[tx, ty]) continue;
|
||||
|
||||
foreach (var (dx, dy) in orients)
|
||||
{
|
||||
// Only begin counting at the start of a run (the tile behind
|
||||
// us in this orientation must NOT be a border tile), otherwise
|
||||
// we'd count every tile of an N-long run as its own violation.
|
||||
int bx = tx - dx, by = ty - dy;
|
||||
if ((uint)bx < (uint)W && (uint)by < (uint)H && border[bx, by])
|
||||
continue;
|
||||
|
||||
int run = 1;
|
||||
int nx = tx + dx, ny = ty + dy;
|
||||
while ((uint)nx < (uint)W && (uint)ny < (uint)H && border[nx, ny])
|
||||
{
|
||||
run++;
|
||||
nx += dx; ny += dy;
|
||||
}
|
||||
|
||||
if (run > MaxAllowedRunLength) violations.Add((tx, ty, dx, dy, run));
|
||||
}
|
||||
}
|
||||
return violations;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
using Theriapolis.Core.Util;
|
||||
|
||||
namespace Theriapolis.Core.World.Generation.Stages;
|
||||
|
||||
/// <summary>
|
||||
/// Stage 6 — CoastalFeatureGen
|
||||
/// Adds large-scale coastal features: peninsulas, bays, and offshore islands.
|
||||
/// These are placed BEFORE the border distortion pass so they are treated as
|
||||
/// natural coastline by the distortion noise.
|
||||
/// </summary>
|
||||
public sealed class CoastalFeatureGenStage : IWorldGenStage
|
||||
{
|
||||
public string Name => "CoastalFeatureGen";
|
||||
|
||||
public void Run(WorldGenContext ctx)
|
||||
{
|
||||
var rng = ctx.Rngs["coast"];
|
||||
int W = C.WORLD_WIDTH_TILES;
|
||||
int H = C.WORLD_HEIGHT_TILES;
|
||||
|
||||
// Collect coastal land tiles (land tiles adjacent to ocean)
|
||||
var coastalLandTiles = new List<(int x, int y)>();
|
||||
for (int ty = 2; ty < H - 2; ty += 4)
|
||||
for (int tx = 2; tx < W - 2; tx += 4)
|
||||
if (IsCoastalLand(ctx, tx, ty))
|
||||
coastalLandTiles.Add((tx, ty));
|
||||
|
||||
if (coastalLandTiles.Count == 0) return;
|
||||
|
||||
rng.Shuffle(coastalLandTiles.ToArray().AsSpan()); // shuffle in place copy
|
||||
|
||||
// Generate 2–4 peninsulas
|
||||
int numPeninsula = rng.NextInt(2, 5);
|
||||
for (int i = 0; i < numPeninsula && i < coastalLandTiles.Count; i++)
|
||||
{
|
||||
var (bx, by) = coastalLandTiles[i * (coastalLandTiles.Count / numPeninsula)];
|
||||
GeneratePeninsula(ctx, rng, bx, by);
|
||||
}
|
||||
|
||||
// Generate 2–4 bays
|
||||
int numBay = rng.NextInt(2, 5);
|
||||
for (int i = 0; i < numBay && i < coastalLandTiles.Count; i++)
|
||||
{
|
||||
int idx = coastalLandTiles.Count / 2 + i * (coastalLandTiles.Count / numBay);
|
||||
if (idx >= coastalLandTiles.Count) idx -= coastalLandTiles.Count;
|
||||
var (bx, by) = coastalLandTiles[idx];
|
||||
GenerateBay(ctx, rng, bx, by);
|
||||
}
|
||||
|
||||
// Generate 3–8 islands
|
||||
int numIslands = rng.NextInt(3, 9);
|
||||
for (int i = 0; i < numIslands; i++)
|
||||
{
|
||||
int ox = rng.NextInt(20, W - 20);
|
||||
int oy = rng.NextInt(20, H - 20);
|
||||
if (ctx.World.Tiles[ox, oy].Elevation < WorldState.SeaLevel)
|
||||
GenerateIsland(ctx, rng, ox, oy);
|
||||
}
|
||||
|
||||
ctx.World.StageHashes["CoastalFeatureGen"] = HashCoast(ctx);
|
||||
ctx.LogMessage($"[CoastalFeatureGen] Added {numPeninsula} peninsulas, {numBay} bays, {numIslands} island attempts.");
|
||||
}
|
||||
|
||||
private static bool IsCoastalLand(WorldGenContext ctx, int tx, int ty)
|
||||
{
|
||||
if (ctx.World.Tiles[tx, ty].Elevation < WorldState.SeaLevel) return false;
|
||||
for (int dy = -1; dy <= 1; dy++)
|
||||
for (int dx = -1; dx <= 1; dx++)
|
||||
{
|
||||
if (dx == 0 && dy == 0) continue;
|
||||
int nx = tx + dx, ny = ty + dy;
|
||||
if (nx < 0 || ny < 0 || nx >= C.WORLD_WIDTH_TILES || ny >= C.WORLD_HEIGHT_TILES) continue;
|
||||
if (ctx.World.Tiles[nx, ny].Elevation < WorldState.SeaLevel) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static void GeneratePeninsula(WorldGenContext ctx, SeededRng rng, int bx, int by)
|
||||
{
|
||||
// Extend a finger of land outward in a random ocean-facing direction
|
||||
float angle = rng.NextFloat(0f, MathF.PI * 2f);
|
||||
int length = rng.NextInt(10, 31);
|
||||
int baseWidth = rng.NextInt(5, 11);
|
||||
|
||||
for (int step = 0; step < length; step++)
|
||||
{
|
||||
float t = (float)step / length;
|
||||
float width = baseWidth * (1f - t * 0.8f); // taper toward tip
|
||||
float cx = bx + MathF.Cos(angle) * step;
|
||||
float cy = by + MathF.Sin(angle) * step;
|
||||
int iw = Math.Max(1, (int)width);
|
||||
for (int w = -iw; w <= iw; w++)
|
||||
{
|
||||
int px = (int)(cx + MathF.Cos(angle + MathF.PI * 0.5f) * w);
|
||||
int py = (int)(cy + MathF.Sin(angle + MathF.PI * 0.5f) * w);
|
||||
if (px < 0 || py < 0 || px >= C.WORLD_WIDTH_TILES || py >= C.WORLD_HEIGHT_TILES) continue;
|
||||
float elev = ctx.World.Tiles[px, py].Elevation;
|
||||
if (elev < WorldState.SeaLevel)
|
||||
ctx.World.Tiles[px, py].Elevation = WorldState.SeaLevel + 0.05f + rng.NextFloat(0f, 0.1f);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void GenerateBay(WorldGenContext ctx, SeededRng rng, int bx, int by)
|
||||
{
|
||||
// Carve a concavity into the land
|
||||
float angle = rng.NextFloat(0f, MathF.PI * 2f);
|
||||
int depth = rng.NextInt(10, 21);
|
||||
int maxWidth = rng.NextInt(5, 16);
|
||||
|
||||
for (int step = 0; step < depth; step++)
|
||||
{
|
||||
float t = (float)step / depth;
|
||||
float width = maxWidth * MathF.Sin(t * MathF.PI); // widen then narrow
|
||||
float cx = bx + MathF.Cos(angle) * step;
|
||||
float cy = by + MathF.Sin(angle) * step;
|
||||
int iw = Math.Max(1, (int)width);
|
||||
for (int w = -iw; w <= iw; w++)
|
||||
{
|
||||
int px = (int)(cx + MathF.Cos(angle + MathF.PI * 0.5f) * w);
|
||||
int py = (int)(cy + MathF.Sin(angle + MathF.PI * 0.5f) * w);
|
||||
if (px < 0 || py < 0 || px >= C.WORLD_WIDTH_TILES || py >= C.WORLD_HEIGHT_TILES) continue;
|
||||
// Push land below sea level (carve the bay)
|
||||
if (ctx.World.Tiles[px, py].Elevation >= WorldState.SeaLevel)
|
||||
ctx.World.Tiles[px, py].Elevation = WorldState.SeaLevel - 0.05f - rng.NextFloat(0f, 0.1f);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void GenerateIsland(WorldGenContext ctx, SeededRng rng, int ox, int oy)
|
||||
{
|
||||
int radius = rng.NextInt(5, 16);
|
||||
for (int dy = -radius; dy <= radius; dy++)
|
||||
for (int dx = -radius; dx <= radius; dx++)
|
||||
{
|
||||
float dist = MathF.Sqrt(dx * dx + dy * dy);
|
||||
if (dist > radius) continue;
|
||||
int px = ox + dx, py = oy + dy;
|
||||
if (px < 0 || py < 0 || px >= C.WORLD_WIDTH_TILES || py >= C.WORLD_HEIGHT_TILES) continue;
|
||||
float t = 1f - dist / radius;
|
||||
float elev = WorldState.SeaLevel + 0.1f + t * 0.3f + rng.NextFloat(-0.05f, 0.05f);
|
||||
if (elev > ctx.World.Tiles[px, py].Elevation)
|
||||
ctx.World.Tiles[px, py].Elevation = elev;
|
||||
}
|
||||
}
|
||||
|
||||
private static ulong HashCoast(WorldGenContext ctx)
|
||||
{
|
||||
const ulong FNV_PRIME = 1099511628211UL;
|
||||
const ulong FNV_OFFSET = 14695981039346656037UL;
|
||||
ulong h = FNV_OFFSET;
|
||||
for (int y = 0; y < C.WORLD_HEIGHT_TILES; y += 16)
|
||||
for (int x = 0; x < C.WORLD_WIDTH_TILES; x += 16)
|
||||
h = (h ^ BitConverter.SingleToUInt32Bits(ctx.World.Tiles[x, y].Elevation)) * FNV_PRIME;
|
||||
return h;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
using Theriapolis.Core.Util;
|
||||
|
||||
namespace Theriapolis.Core.World.Generation.Stages;
|
||||
|
||||
/// <summary>
|
||||
/// Stage 3 — ElevationGen
|
||||
/// Generates a multi-octave noise heightmap, applies a fractal continent mask
|
||||
/// with distance-warp and domain-warped coordinates, and clamps the result into
|
||||
/// the per-tile macro cell's elevation range.
|
||||
///
|
||||
/// The continent mask uses a radial falloff (distance from map centre) whose
|
||||
/// effective distance is warped by a multi-octave FBm shape field. This
|
||||
/// produces organically irregular coastlines whose iso-contour wiggles at
|
||||
/// every octave scale. The previous smooth-ellipse approach had an intrinsic
|
||||
/// failure mode: at cardinal extremes the tangent was locally straight for
|
||||
/// ~R·ε tiles (≈50 tiles at R≈450, ε≈0.1), creating long collinear border
|
||||
/// runs that no amount of additive coastal noise could reliably break.
|
||||
///
|
||||
/// Addendum A §1 — macro-cell border warp:
|
||||
/// Instead of looking up each tile's macro cell by raw grid position
|
||||
/// (which produces rectangular cell blocks with ruler-straight boundaries),
|
||||
/// the lookup position is first displaced by a smooth multi-octave noise
|
||||
/// field. Two tiles at adjacent grid positions near a nominal cell
|
||||
/// boundary may therefore sample completely different macro cells,
|
||||
/// producing organic wiggly cell interfaces. The warped (mx, my) is
|
||||
/// stored on the tile's MacroX/MacroY fields so downstream stages see
|
||||
/// consistent cell assignment without recomputing the warp.
|
||||
/// </summary>
|
||||
public sealed class ElevationGenStage : IWorldGenStage
|
||||
{
|
||||
public string Name => "ElevationGen";
|
||||
|
||||
public void Run(WorldGenContext ctx)
|
||||
{
|
||||
ulong seed = ctx.World.WorldSeed ^ C.RNG_TERRAIN;
|
||||
var noise = new FastNoiseLite
|
||||
{
|
||||
Seed = (int)(seed & 0x7FFFFFFF),
|
||||
Frequency = 1.8f / C.WORLD_WIDTH_TILES,
|
||||
Noise = FastNoiseLite.NoiseType.OpenSimplex2,
|
||||
Fractal = FastNoiseLite.FractalType.FBm,
|
||||
Octaves = 7,
|
||||
Lacunarity = 2.1f,
|
||||
Gain = 0.48f,
|
||||
};
|
||||
|
||||
// Second noise layer for macro-region variety
|
||||
var noise2 = new FastNoiseLite
|
||||
{
|
||||
Seed = (int)((seed >> 16) & 0x7FFFFFFF),
|
||||
Frequency = 0.6f / C.WORLD_WIDTH_TILES,
|
||||
Noise = FastNoiseLite.NoiseType.OpenSimplex2,
|
||||
Fractal = FastNoiseLite.FractalType.FBm,
|
||||
Octaves = 4,
|
||||
Lacunarity = 2.0f,
|
||||
Gain = 0.5f,
|
||||
};
|
||||
|
||||
// Domain-warp noise for the continent mask (large-scale coastal excursions).
|
||||
ulong warpSeed = ctx.World.WorldSeed ^ C.RNG_COAST_WARP;
|
||||
var continentWarp = new FastNoiseLite
|
||||
{
|
||||
Seed = (int)(warpSeed & 0x7FFFFFFF),
|
||||
Frequency = 1.5f / C.WORLD_WIDTH_TILES,
|
||||
Noise = FastNoiseLite.NoiseType.OpenSimplex2,
|
||||
Fractal = FastNoiseLite.FractalType.FBm,
|
||||
Octaves = 4,
|
||||
Lacunarity = 2.0f,
|
||||
Gain = 0.5f,
|
||||
};
|
||||
|
||||
// Fractal continent shape (Addendum A §1 — organic coastlines).
|
||||
// Replaces the previous smooth-ellipse continent mask, which had an
|
||||
// intrinsic failure mode: at its east/west/north/south extremes the
|
||||
// tangent to a circle of radius R runs approximately straight for
|
||||
// ~R·ε tiles where ε is the "locally-straight" angular tolerance.
|
||||
// For our continent (R ≈ 470 tiles, ε ≈ 0.1 rad ≈ 6°) that's ~47
|
||||
// collinear border tiles — 10× the MaxAllowedRunLength threshold —
|
||||
// and the additive coastal wobble noise cannot reliably break them
|
||||
// up because it is itself locally smooth.
|
||||
//
|
||||
// A fractal shape field has no such tangent alignment: the 0.22
|
||||
// contour (where e ≈ sea level) wiggles at every octave scale, so
|
||||
// the coast threshold-crossing locus changes direction rapidly over
|
||||
// a few tiles no matter where you sample it. Five FBm octaves
|
||||
// give coastline features from sub-cell scale up to continent scale.
|
||||
var continentShape = new FastNoiseLite
|
||||
{
|
||||
Seed = (int)((warpSeed >> 24) & 0x7FFFFFFF),
|
||||
Frequency = 3.0f / C.WORLD_WIDTH_TILES,
|
||||
Noise = FastNoiseLite.NoiseType.OpenSimplex2,
|
||||
Fractal = FastNoiseLite.FractalType.FBm,
|
||||
Octaves = 5,
|
||||
Lacunarity = 2.0f,
|
||||
Gain = 0.55f,
|
||||
};
|
||||
|
||||
// Macro-cell border warp (Addendum A §1): displaces the per-tile macro
|
||||
// lookup position so that macro cell boundaries become wiggly curves.
|
||||
// Uses plain OpenSimplex2 (no FBm) for smooth large-scale displacement;
|
||||
// FBm would add high-frequency jitter that chops cell interiors into
|
||||
// sub-cell patches.
|
||||
ulong macroWarpSeed = ctx.World.WorldSeed ^ C.RNG_MACRO_WARP;
|
||||
var macroWarpX = new FastNoiseLite
|
||||
{
|
||||
Seed = (int)(macroWarpSeed & 0x7FFFFFFF),
|
||||
Frequency = C.MACRO_WARP_FREQUENCY,
|
||||
Noise = FastNoiseLite.NoiseType.OpenSimplex2,
|
||||
};
|
||||
var macroWarpY = new FastNoiseLite
|
||||
{
|
||||
Seed = (int)((macroWarpSeed >> 16) & 0x7FFFFFFF),
|
||||
Frequency = C.MACRO_WARP_FREQUENCY,
|
||||
Noise = FastNoiseLite.NoiseType.OpenSimplex2,
|
||||
};
|
||||
|
||||
int W = C.WORLD_WIDTH_TILES;
|
||||
int H = C.WORLD_HEIGHT_TILES;
|
||||
int cellW = W / C.MACRO_GRID_WIDTH;
|
||||
int cellH = H / C.MACRO_GRID_HEIGHT;
|
||||
|
||||
// Warm up permutation tables on the calling thread before the parallel loop.
|
||||
// FastNoiseLite.EnsurePerm() is not thread-safe; calling GetNoise once here
|
||||
// ensures the _perm array is fully written and visible to all parallel threads.
|
||||
_ = noise.GetNoise01(0f, 0f);
|
||||
_ = noise2.GetNoise01(0f, 0f);
|
||||
_ = continentWarp.GetNoise(0f, 0f);
|
||||
_ = continentShape.GetNoise01(0f, 0f);
|
||||
_ = macroWarpX.GetNoise(0f, 0f);
|
||||
_ = macroWarpY.GetNoise(0f, 0f);
|
||||
|
||||
Parallel.For(0, H, ty =>
|
||||
{
|
||||
for (int tx = 0; tx < W; tx++)
|
||||
{
|
||||
// ── Macro cell border warp: displace the lookup position ──
|
||||
// The displacement is a smooth continuous function of (tx, ty),
|
||||
// so adjacent tiles get similar warp vectors and the warped
|
||||
// cell boundary is a curve rather than a grid line.
|
||||
float mwx = macroWarpX.GetNoise((float)tx, (float)ty) * C.MACRO_WARP_AMPLITUDE;
|
||||
float mwy = macroWarpY.GetNoise((float)tx, (float)ty) * C.MACRO_WARP_AMPLITUDE;
|
||||
int wtx = Math.Clamp((int)(tx + mwx), 0, W - 1);
|
||||
int wty = Math.Clamp((int)(ty + mwy), 0, H - 1);
|
||||
byte mx = (byte)Math.Clamp(wtx / cellW, 0, C.MACRO_GRID_WIDTH - 1);
|
||||
byte my = (byte)Math.Clamp(wty / cellH, 0, C.MACRO_GRID_HEIGHT - 1);
|
||||
|
||||
ctx.World.Tiles[tx, ty].MacroX = mx;
|
||||
ctx.World.Tiles[tx, ty].MacroY = my;
|
||||
|
||||
var cell = ctx.World.MacroGrid![mx, my];
|
||||
float floor = cell.ElevationFloor;
|
||||
float ceil = cell.ElevationCeiling;
|
||||
|
||||
// ── Base elevation from noise + continent mask ──
|
||||
float raw = noise.GetNoise01((float)tx, (float)ty) * 0.7f
|
||||
+ noise2.GetNoise01((float)tx, (float)ty) * 0.3f;
|
||||
|
||||
// Continent mask: radial falloff with fractal distance warp.
|
||||
//
|
||||
// Previous approach (smooth ellipse) produced long straight
|
||||
// tangent segments at its extremes; see the continentShape
|
||||
// field comment above for the R·ε arc-length analysis.
|
||||
//
|
||||
// New approach: the fractal shape field WARPS the radial
|
||||
// distance instead of multiplying the mask. The coast is
|
||||
// the iso-contour where the warped-radial falloff crosses
|
||||
// sea level. Because the shape field varies at every
|
||||
// octave scale, that iso-contour wiggles organically.
|
||||
//
|
||||
// Key advantage over the multiply approach: interior tiles
|
||||
// (where radialDist is small) have continentMask ≈ 1.0
|
||||
// regardless of shape, preserving the same elevation
|
||||
// distribution as the old smooth ellipse. Only tiles near
|
||||
// the coast transition (radialDist ≈ 0.7–1.0) are affected
|
||||
// by the fractal warp, which is exactly where we need it.
|
||||
float wx = continentWarp.GetNoise((float)tx, (float)ty) * C.COAST_WARP_AMP;
|
||||
float wy = continentWarp.GetNoise((float)tx + 3000f, (float)ty + 3000f) * C.COAST_WARP_AMP;
|
||||
|
||||
// Domain-warp the radial coordinates (same as the old ellipse)
|
||||
// to preserve interior elevation variety. Without this, all
|
||||
// interior tiles have continentMask ≈ 1.0 and pile near the
|
||||
// macro cell ceiling, crushing biome diversity.
|
||||
float rcx = ((float)tx + wx - W * 0.5f) / (W * 0.72f);
|
||||
float rcy = ((float)ty + wy - H * 0.5f) / (H * 0.72f);
|
||||
float radialDist = MathF.Sqrt(rcx * rcx + rcy * rcy);
|
||||
|
||||
// Warp the radial distance by the fractal shape field.
|
||||
// shape is in [0,1]; centred to [-0.5,0.5] and scaled by
|
||||
// 0.35 so the coast edge displaces by up to ±0.175 in
|
||||
// normalised-radius units (≈ ±74 tiles at W*0.72=737).
|
||||
float shape = continentShape.GetNoise01((float)tx + wx, (float)ty + wy);
|
||||
float warpedDist = radialDist + (shape - 0.5f) * 0.20f;
|
||||
|
||||
float continentMask = Math.Clamp(1f - warpedDist, 0f, 1f);
|
||||
continentMask = continentMask * continentMask * (3f - 2f * continentMask);
|
||||
|
||||
// Additive continent bias: raw * 0.3 (local variation) +
|
||||
// continentMask * 0.9 (determines "how land-like" this tile
|
||||
// is). Interior tiles get e ≈ 1.2 clamped to 1.0; edge tiles
|
||||
// get e ≈ 0.
|
||||
float e = raw * 0.3f + continentMask * 0.9f;
|
||||
e = Math.Clamp(e, 0f, 1f);
|
||||
|
||||
// ── Soft macro clamp (Addendum A §1 — coastline organics) ──
|
||||
// The previous hard clamp `floor + e * (ceil - floor)` forced
|
||||
// every tile in a macro cell into the cell's elevation range.
|
||||
// For mountain cells (floor > sea level) that meant every tile
|
||||
// was land regardless of continent-mask strength, producing
|
||||
// ruler-straight rectangular coastlines along macro cell edges.
|
||||
// For submerged cells (ceiling < sea level — e.g. wetland,
|
||||
// coastal) the mirror bug held: every tile was forced ocean
|
||||
// regardless of continent-mask strength, producing rectangular
|
||||
// interior seas at macro cell boundaries.
|
||||
//
|
||||
// Soft clamp rules:
|
||||
// • The continent mask drives the land/ocean threshold.
|
||||
// If `e < sea level`, the tile is ocean regardless of
|
||||
// the macro cell's floor (so mountain cells can have
|
||||
// organic ocean inlets at their weak edges).
|
||||
// • If `e ≥ sea level`, the tile is land regardless of
|
||||
// the macro cell's ceiling (so wetland/coastal cells
|
||||
// can have organic land patches where the continent
|
||||
// mask is strong, instead of becoming a rectangular
|
||||
// forced-ocean block).
|
||||
// • Land tiles are mapped into the cell's effective land
|
||||
// range `[max(floor, sl), max(ceil, sl+0.05)]` so the
|
||||
// macro cell's terrain character (mountain vs lowland)
|
||||
// is preserved on land.
|
||||
if (e >= WorldState.SeaLevel)
|
||||
{
|
||||
float landFloor = MathF.Max(floor, WorldState.SeaLevel);
|
||||
float landCeil = MathF.Max(ceil, landFloor + 0.05f);
|
||||
float tLand = (e - WorldState.SeaLevel) / (1f - WorldState.SeaLevel);
|
||||
e = landFloor + tLand * (landCeil - landFloor);
|
||||
}
|
||||
// else: continent mask says ocean — keep e below sea level.
|
||||
|
||||
e = Math.Clamp(e, 0f, 1f);
|
||||
ctx.World.Tiles[tx, ty].Elevation = e;
|
||||
}
|
||||
});
|
||||
|
||||
ctx.World.StageHashes["ElevationGen"] = ctx.World.HashElevation();
|
||||
ctx.LogMessage($"[ElevationGen] Elevation hash: 0x{ctx.World.StageHashes["ElevationGen"]:X16}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
namespace Theriapolis.Core.World.Generation.Stages;
|
||||
|
||||
/// <summary>
|
||||
/// Stage 21 — EncounterDensityGen
|
||||
/// Produces a per-tile encounter probability map used by the runtime encounter spawner (Phase 5).
|
||||
/// Higher = more dangerous. Normalized to [0, 1].
|
||||
/// </summary>
|
||||
public sealed class EncounterDensityGenStage : IWorldGenStage
|
||||
{
|
||||
public string Name => "EncounterDensityGen";
|
||||
|
||||
private const int W = C.WORLD_WIDTH_TILES;
|
||||
private const int H = C.WORLD_HEIGHT_TILES;
|
||||
|
||||
public void Run(WorldGenContext ctx)
|
||||
{
|
||||
var world = ctx.World;
|
||||
|
||||
// ── Precompute distance to nearest settlement ──────────────────────────
|
||||
var settleDist = BfsSettlementDistance(world);
|
||||
|
||||
var density = new float[W, H];
|
||||
|
||||
Parallel.For(0, H, y =>
|
||||
{
|
||||
for (int x = 0; x < W; x++)
|
||||
{
|
||||
ref var tile = ref world.TileAt(x, y);
|
||||
if (tile.Biome == BiomeId.Ocean) { density[x, y] = 0f; continue; }
|
||||
|
||||
// Base biome danger
|
||||
float baseDanger = tile.Biome switch
|
||||
{
|
||||
BiomeId.MountainAlpine => 0.8f,
|
||||
BiomeId.Wetland => 0.7f,
|
||||
BiomeId.Boreal => 0.65f,
|
||||
BiomeId.Tundra => 0.75f,
|
||||
BiomeId.SubtropicalForest=> 0.6f,
|
||||
BiomeId.TemperateDeciduous=> 0.5f,
|
||||
BiomeId.Scrubland => 0.55f,
|
||||
BiomeId.TemperateGrassland=> 0.35f,
|
||||
BiomeId.Coastal => 0.3f,
|
||||
_ => 0.45f,
|
||||
};
|
||||
|
||||
// Distance from settlement (closer = safer)
|
||||
float dist = settleDist[x, y];
|
||||
float settleFactor = Math.Min(1f, dist / 60f);
|
||||
|
||||
// Road proximity (on-road is safer)
|
||||
float roadFactor = (tile.Features & FeatureFlags.HasRoad) != 0 ? 0.5f : 1.0f;
|
||||
|
||||
// Macro region hostility
|
||||
var macro = world.MacroCellForTile(in tile);
|
||||
float hostility = macro.Development?.ToLowerInvariant() switch
|
||||
{
|
||||
"wilderness" => 1.2f,
|
||||
"frontier" => 1.0f,
|
||||
"agricultural"=> 0.7f,
|
||||
"industrial" => 0.6f,
|
||||
"urban" => 0.5f,
|
||||
_ => 0.8f,
|
||||
};
|
||||
|
||||
// Enforcer presence (if computed)
|
||||
float enforcerSafety = 1f;
|
||||
if (world.FactionInfluence != null)
|
||||
enforcerSafety = 1f - world.FactionInfluence.Get((int)FactionId.CovenantEnforcers, x, y) * 0.6f;
|
||||
|
||||
density[x, y] = baseDanger * settleFactor * roadFactor * hostility * enforcerSafety;
|
||||
}
|
||||
});
|
||||
|
||||
// Normalize to [0, 1]
|
||||
float maxD = 0f;
|
||||
for (int y = 0; y < H; y++)
|
||||
for (int x = 0; x < W; x++)
|
||||
maxD = Math.Max(maxD, density[x, y]);
|
||||
|
||||
if (maxD > 1e-6f)
|
||||
for (int y = 0; y < H; y++)
|
||||
for (int x = 0; x < W; x++)
|
||||
density[x, y] /= maxD;
|
||||
|
||||
world.EncounterDensity = density;
|
||||
ctx.LogMessage("[EncounterDensityGen] Encounter density map computed.");
|
||||
}
|
||||
|
||||
private static float[,] BfsSettlementDistance(WorldState world)
|
||||
{
|
||||
var dist = new float[W, H];
|
||||
for (int y = 0; y < H; y++)
|
||||
for (int x = 0; x < W; x++)
|
||||
dist[x, y] = float.MaxValue;
|
||||
|
||||
var queue = new Queue<(int x, int y)>();
|
||||
|
||||
// Seed from settlement tiles
|
||||
foreach (var s in world.Settlements.Where(s => !s.IsPoi))
|
||||
{
|
||||
for (int dy = -2; dy <= 2; dy++)
|
||||
for (int dx = -2; dx <= 2; dx++)
|
||||
{
|
||||
int nx = s.TileX + dx, ny = s.TileY + dy;
|
||||
if ((uint)nx < W && (uint)ny < H && dist[nx, ny] == float.MaxValue)
|
||||
{
|
||||
dist[nx, ny] = 0f;
|
||||
queue.Enqueue((nx, ny));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
(int dx, int dy)[] dirs4 = { (0,-1),(1,0),(0,1),(-1,0) };
|
||||
while (queue.Count > 0)
|
||||
{
|
||||
var (cx, cy) = queue.Dequeue();
|
||||
foreach (var (ddx, ddy) in dirs4)
|
||||
{
|
||||
int nx = cx + ddx, ny = cy + ddy;
|
||||
if ((uint)nx >= W || (uint)ny >= H) continue;
|
||||
float nd = dist[cx, cy] + 1f;
|
||||
if (nd >= dist[nx, ny]) continue;
|
||||
dist[nx, ny] = nd;
|
||||
queue.Enqueue((nx, ny));
|
||||
}
|
||||
}
|
||||
|
||||
return dist;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
using Theriapolis.Core.Util;
|
||||
|
||||
namespace Theriapolis.Core.World.Generation.Stages;
|
||||
|
||||
/// <summary>
|
||||
/// Stage 19 — FactionInfluenceGen
|
||||
/// Radiates influence for the three primary factions outward from seed settlements.
|
||||
/// Stored in WorldState.FactionInfluence as a float[3, W, H] map.
|
||||
/// </summary>
|
||||
public sealed class FactionInfluenceGenStage : IWorldGenStage
|
||||
{
|
||||
public string Name => "FactionInfluenceGen";
|
||||
|
||||
private const int W = C.WORLD_WIDTH_TILES;
|
||||
private const int H = C.WORLD_HEIGHT_TILES;
|
||||
|
||||
public void Run(WorldGenContext ctx)
|
||||
{
|
||||
var world = ctx.World;
|
||||
var influence = new FactionInfluenceMap();
|
||||
|
||||
// ── Seed points for each faction ──────────────────────────────────────
|
||||
|
||||
// Covenant Enforcers (0): strong in capital, Tier 2 cities, "strong"/"moderate" covenant regions
|
||||
foreach (var s in world.Settlements.Where(s => !s.IsPoi && s.Tier <= 2))
|
||||
{
|
||||
float strength = s.Tier == 1 ? 1.0f : 0.7f;
|
||||
RadiateInfluence(world, influence, (int)FactionId.CovenantEnforcers, s.TileX, s.TileY, strength);
|
||||
}
|
||||
RadiateFromMacroCovenant(world, influence, (int)FactionId.CovenantEnforcers,
|
||||
cov => cov is "strong" or "moderate", 0.4f);
|
||||
|
||||
// Inheritors (1): strong in predator-majority frontier zones
|
||||
RadiateFromMacroCovenant(world, influence, (int)FactionId.Inheritors,
|
||||
cov => cov is "weak" or "nominal", 0.6f,
|
||||
dev => dev is "frontier" or "wilderness",
|
||||
clades => CladeSetContains(clades, "canid", "felid", "ursid"));
|
||||
|
||||
// Thorn Council (2): strong in prey-majority zones, urban progressive centers
|
||||
RadiateFromMacroCovenant(world, influence, (int)FactionId.ThornCouncil,
|
||||
cov => cov is "moderate" or "weak", 0.5f,
|
||||
dev => dev is "urban" or "agricultural",
|
||||
clades => CladeSetContains(clades, "cervid", "bovid", "leporid"));
|
||||
|
||||
// Thorn Council also present in the Tangles
|
||||
foreach (var s in world.Settlements.Where(s => s.Anchor == NarrativeAnchor.TheTangles))
|
||||
RadiateInfluence(world, influence, (int)FactionId.ThornCouncil, s.TileX, s.TileY, 0.4f);
|
||||
|
||||
// Inheritors: anti-correlated with Enforcers (suppress where Enforcers are strong)
|
||||
for (int y = 0; y < H; y++)
|
||||
for (int x = 0; x < W; x++)
|
||||
{
|
||||
float enforcer = influence.Get((int)FactionId.CovenantEnforcers, x, y);
|
||||
float inheritor = influence.Get((int)FactionId.Inheritors, x, y);
|
||||
influence.Set((int)FactionId.Inheritors, x, y, Math.Max(0f, inheritor - enforcer * 0.6f));
|
||||
}
|
||||
|
||||
world.FactionInfluence = influence;
|
||||
ctx.LogMessage("[FactionInfluenceGen] Faction influence maps computed.");
|
||||
}
|
||||
|
||||
private static void RadiateInfluence(
|
||||
WorldState world,
|
||||
FactionInfluenceMap map,
|
||||
int faction,
|
||||
int cx, int cy,
|
||||
float strength)
|
||||
{
|
||||
int radius = (int)C.FACTION_INFLUENCE_RADIUS;
|
||||
for (int dy = -radius; dy <= radius; dy++)
|
||||
for (int dx = -radius; dx <= radius; dx++)
|
||||
{
|
||||
int nx = cx + dx, ny = cy + dy;
|
||||
if ((uint)nx >= W || (uint)ny >= H) continue;
|
||||
float dist = MathF.Sqrt(dx * dx + dy * dy);
|
||||
float falloff = Math.Max(0f, 1f - dist / C.FACTION_INFLUENCE_RADIUS);
|
||||
map.Add(faction, nx, ny, strength * falloff);
|
||||
}
|
||||
}
|
||||
|
||||
private static void RadiateFromMacroCovenant(
|
||||
WorldState world,
|
||||
FactionInfluenceMap map,
|
||||
int faction,
|
||||
Func<string, bool> covenantFilter,
|
||||
float strength,
|
||||
Func<string, bool>? devFilter = null,
|
||||
Func<string[], bool>? cladeFilter = null)
|
||||
{
|
||||
for (int my = 0; my < C.MACRO_GRID_HEIGHT; my++)
|
||||
for (int mx = 0; mx < C.MACRO_GRID_WIDTH; mx++)
|
||||
{
|
||||
var cell = world.MacroGrid![mx, my];
|
||||
if (!covenantFilter(cell.Covenant?.ToLowerInvariant() ?? "")) continue;
|
||||
if (devFilter != null && !devFilter(cell.Development?.ToLowerInvariant() ?? "")) continue;
|
||||
if (cladeFilter != null && !cladeFilter(cell.CladeAffinities)) continue;
|
||||
|
||||
// Tile range for this macro cell
|
||||
int tileW = W / C.MACRO_GRID_WIDTH;
|
||||
int tileH = H / C.MACRO_GRID_HEIGHT;
|
||||
int baseTx = mx * tileW + tileW / 2;
|
||||
int baseTy = my * tileH + tileH / 2;
|
||||
|
||||
RadiateInfluence(world, map, faction, baseTx, baseTy, strength);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool CladeSetContains(string[] clades, params string[] targets)
|
||||
{
|
||||
foreach (var c in clades)
|
||||
foreach (var t in targets)
|
||||
if (c.ToLowerInvariant().Contains(t)) return true;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
namespace Theriapolis.Core.World.Generation.Stages;
|
||||
|
||||
/// <summary>
|
||||
/// Stage 12 — HabitabilityScore
|
||||
/// Computes a per-tile habitability score used to place settlements.
|
||||
/// formula: water_proximity*3 + flatness*2 + fertility*2 + trade_potential*1.5 + resource_proximity - elevation_extreme*2 - hazard_proximity*1.5
|
||||
/// </summary>
|
||||
public sealed class HabitabilityScoreStage : IWorldGenStage
|
||||
{
|
||||
public string Name => "HabitabilityScore";
|
||||
|
||||
private const int W = C.WORLD_WIDTH_TILES;
|
||||
private const int H = C.WORLD_HEIGHT_TILES;
|
||||
|
||||
public void Run(WorldGenContext ctx)
|
||||
{
|
||||
var world = ctx.World;
|
||||
var habitat = new float[W, H];
|
||||
|
||||
ctx.ReportProgress(Name, 0f);
|
||||
|
||||
// ── Water proximity via BFS wavefront ─────────────────────────────────
|
||||
var waterDist = BfsWaterDistance(world);
|
||||
ctx.ReportProgress(Name, 0.25f);
|
||||
|
||||
// ── First-pass score (without trade_route_potential) ──────────────────
|
||||
Parallel.For(0, H, y =>
|
||||
{
|
||||
for (int x = 0; x < W; x++)
|
||||
{
|
||||
ref var tile = ref world.TileAt(x, y);
|
||||
if (tile.Biome == BiomeId.Ocean) continue;
|
||||
|
||||
float e = tile.Elevation;
|
||||
float m = tile.Moisture;
|
||||
float t = tile.Temperature;
|
||||
|
||||
float waterProx = 1f / (1f + waterDist[x, y]);
|
||||
float flatness = 1f - LocalElevationVariance(world, x, y);
|
||||
float fertility = m * t;
|
||||
float resPx = 1f / (1f + ResourceDistance(world, x, y));
|
||||
float elevEx = Math.Max(0f, e - 0.70f) * 4f + Math.Max(0f, WorldState.SeaLevel + 0.03f - e) * 4f;
|
||||
|
||||
habitat[x, y] = waterProx * 3f
|
||||
+ flatness * 2f
|
||||
+ fertility * 2f
|
||||
+ resPx * 1f
|
||||
- elevEx * 2f;
|
||||
}
|
||||
});
|
||||
|
||||
ctx.ReportProgress(Name, 0.60f);
|
||||
|
||||
// ── Second pass: add trade_route_potential ────────────────────────────
|
||||
// Use the median of first-pass scores as "high habitability" threshold
|
||||
var landScores = new List<float>(W * H / 4);
|
||||
for (int y = 0; y < H; y++)
|
||||
for (int x = 0; x < W; x++)
|
||||
if (world.Tiles[x, y].Biome != BiomeId.Ocean && habitat[x, y] > 0)
|
||||
landScores.Add(habitat[x, y]);
|
||||
landScores.Sort();
|
||||
float median = landScores.Count > 0 ? landScores[landScores.Count / 2] : 1f;
|
||||
|
||||
// Compute trade potential into a SEPARATE array to avoid a read-write race:
|
||||
// TradePotential reads habitat[nx,ny] values from neighboring tiles which
|
||||
// could be simultaneously written by other threads if we add directly.
|
||||
var tradeBonus = new float[W, H];
|
||||
Parallel.For(0, H, y =>
|
||||
{
|
||||
for (int x = 0; x < W; x++)
|
||||
{
|
||||
if (world.Tiles[x, y].Biome == BiomeId.Ocean) continue;
|
||||
tradeBonus[x, y] = TradePotential(habitat, x, y, median);
|
||||
}
|
||||
});
|
||||
// Single-threaded merge to avoid any write ordering ambiguity
|
||||
for (int y = 0; y < H; y++)
|
||||
for (int x = 0; x < W; x++)
|
||||
habitat[x, y] += tradeBonus[x, y] * 1.5f;
|
||||
|
||||
ctx.ReportProgress(Name, 0.85f);
|
||||
|
||||
// ── Normalize to [0,1] ────────────────────────────────────────────────
|
||||
float minH = float.MaxValue, maxH = float.MinValue;
|
||||
for (int y = 0; y < H; y++)
|
||||
for (int x = 0; x < W; x++)
|
||||
{
|
||||
if (world.Tiles[x, y].Biome == BiomeId.Ocean) continue;
|
||||
minH = Math.Min(minH, habitat[x, y]);
|
||||
maxH = Math.Max(maxH, habitat[x, y]);
|
||||
}
|
||||
float range = maxH - minH > 1e-6f ? maxH - minH : 1f;
|
||||
Parallel.For(0, H, y =>
|
||||
{
|
||||
for (int x = 0; x < W; x++)
|
||||
{
|
||||
if (world.Tiles[x, y].Biome == BiomeId.Ocean) { habitat[x, y] = 0f; continue; }
|
||||
habitat[x, y] = (habitat[x, y] - minH) / range;
|
||||
}
|
||||
});
|
||||
|
||||
world.Habitability = habitat;
|
||||
ctx.LogMessage("[HabitabilityScore] Computed habitability map.");
|
||||
ctx.ReportProgress(Name, 1f);
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
private static int[,] BfsWaterDistance(WorldState world)
|
||||
{
|
||||
var dist = new int[W, H];
|
||||
const int INF = int.MaxValue / 2;
|
||||
for (int y = 0; y < H; y++)
|
||||
for (int x = 0; x < W; x++)
|
||||
dist[x, y] = INF;
|
||||
|
||||
var queue = new Queue<(int x, int y)>();
|
||||
|
||||
// Seed from all water tiles (ocean + HasRiver)
|
||||
for (int y = 0; y < H; y++)
|
||||
for (int x = 0; x < W; x++)
|
||||
{
|
||||
ref var t = ref world.TileAt(x, y);
|
||||
if (t.Biome == BiomeId.Ocean || (t.Features & FeatureFlags.HasRiver) != 0)
|
||||
{
|
||||
dist[x, y] = 0;
|
||||
queue.Enqueue((x, y));
|
||||
}
|
||||
}
|
||||
|
||||
(int dx, int dy)[] dirs4 = { (0,-1),(1,0),(0,1),(-1,0) };
|
||||
while (queue.Count > 0)
|
||||
{
|
||||
var (cx, cy) = queue.Dequeue();
|
||||
foreach (var (ddx, ddy) in dirs4)
|
||||
{
|
||||
int nx = cx + ddx, ny = cy + ddy;
|
||||
if ((uint)nx >= W || (uint)ny >= H) continue;
|
||||
if (dist[nx, ny] != INF) continue;
|
||||
dist[nx, ny] = dist[cx, cy] + 1;
|
||||
queue.Enqueue((nx, ny));
|
||||
}
|
||||
}
|
||||
|
||||
return dist;
|
||||
}
|
||||
|
||||
private static float LocalElevationVariance(WorldState world, int x, int y, int radius = 2)
|
||||
{
|
||||
float sum = 0, sumSq = 0, count = 0;
|
||||
for (int dy = -radius; dy <= radius; dy++)
|
||||
for (int dx = -radius; dx <= radius; dx++)
|
||||
{
|
||||
int nx = x + dx, ny = y + dy;
|
||||
if ((uint)nx >= W || (uint)ny >= H) continue;
|
||||
float e = world.Tiles[nx, ny].Elevation;
|
||||
sum += e; sumSq += e * e; count++;
|
||||
}
|
||||
if (count < 2) return 0f;
|
||||
float mean = sum / count;
|
||||
float variance = sumSq / count - mean * mean;
|
||||
return Math.Min(1f, MathF.Sqrt(Math.Max(0f, variance)) * 10f);
|
||||
}
|
||||
|
||||
private static int ResourceDistance(WorldState world, int x, int y)
|
||||
{
|
||||
// Returns approximate distance to nearest mountain, forest, or coast tile
|
||||
int best = 30;
|
||||
for (int r = 1; r <= 15; r++)
|
||||
{
|
||||
for (int dy = -r; dy <= r; dy++)
|
||||
for (int dx = -r; dx <= r; dx++)
|
||||
{
|
||||
if (Math.Abs(dx) != r && Math.Abs(dy) != r) continue;
|
||||
int nx = x + dx, ny = y + dy;
|
||||
if ((uint)nx >= W || (uint)ny >= H) continue;
|
||||
var b = world.Tiles[nx, ny].Biome;
|
||||
if (b is BiomeId.MountainAlpine or BiomeId.MountainForested
|
||||
or BiomeId.TemperateDeciduous or BiomeId.Boreal or BiomeId.Coastal
|
||||
or BiomeId.Coastal or BiomeId.SubtropicalForest)
|
||||
{
|
||||
best = Math.Min(best, r);
|
||||
goto nextTile;
|
||||
}
|
||||
}
|
||||
nextTile:;
|
||||
if (best < r) break;
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
private static float TradePotential(float[,] habitat, int x, int y, float median)
|
||||
{
|
||||
// Count high-habitability tiles reachable within a radius (centrality proxy)
|
||||
int count = 0;
|
||||
const int radius = 12;
|
||||
for (int dy = -radius; dy <= radius; dy++)
|
||||
for (int dx = -radius; dx <= radius; dx++)
|
||||
{
|
||||
int nx = x + dx, ny = y + dy;
|
||||
if ((uint)nx >= W || (uint)ny >= H) continue;
|
||||
if (habitat[nx, ny] > median) count++;
|
||||
}
|
||||
float diameter = 2 * radius + 1;
|
||||
return count / (diameter * diameter);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,505 @@
|
||||
using Theriapolis.Core.Util;
|
||||
using Theriapolis.Core.World.Polylines;
|
||||
|
||||
namespace Theriapolis.Core.World.Generation.Stages;
|
||||
|
||||
/// <summary>
|
||||
/// Stage 10 — HydrologyGen
|
||||
/// Drainage simulation → rivers as polylines in world-pixel space + lakes.
|
||||
/// Per-tile HasRiver and RiverAdjacent flags derived from polylines.
|
||||
/// </summary>
|
||||
public sealed class HydrologyGenStage : IWorldGenStage
|
||||
{
|
||||
public string Name => "HydrologyGen";
|
||||
|
||||
private const int W = C.WORLD_WIDTH_TILES;
|
||||
private const int H = C.WORLD_HEIGHT_TILES;
|
||||
|
||||
// 8 neighbor deltas: N, NE, E, SE, S, SW, W, NW
|
||||
private static readonly (int dx, int dy)[] Dirs8 =
|
||||
{
|
||||
( 0,-1), ( 1,-1), ( 1, 0), ( 1, 1),
|
||||
( 0, 1), (-1, 1), (-1, 0), (-1,-1),
|
||||
};
|
||||
|
||||
public void Run(WorldGenContext ctx)
|
||||
{
|
||||
var world = ctx.World;
|
||||
var rng = ctx.Rngs["hydro"];
|
||||
|
||||
ctx.ReportProgress(Name, 0f);
|
||||
|
||||
// ── Step 1: Flow direction ────────────────────────────────────────────
|
||||
var flowDir = ComputeFlowDirections(world);
|
||||
|
||||
ctx.ReportProgress(Name, 0.20f);
|
||||
|
||||
// ── Step 2: Flow accumulation ─────────────────────────────────────────
|
||||
var accumulation = ComputeFlowAccumulation(world, flowDir);
|
||||
|
||||
ctx.ReportProgress(Name, 0.40f);
|
||||
|
||||
// ── Step 3: Lake detection ────────────────────────────────────────────
|
||||
DetectAndFillLakes(world, flowDir, accumulation, rng);
|
||||
|
||||
ctx.ReportProgress(Name, 0.55f);
|
||||
|
||||
// ── Step 4: Extract river paths ───────────────────────────────────────
|
||||
var riverPaths = ExtractRiverPaths(world, accumulation, flowDir);
|
||||
|
||||
ctx.ReportProgress(Name, 0.65f);
|
||||
|
||||
// ── Step 5: Guarantee rivers in required regions ──────────────────────
|
||||
EnsureRequiredRivers(world, riverPaths, accumulation, rng);
|
||||
|
||||
ctx.ReportProgress(Name, 0.70f);
|
||||
|
||||
// ── Step 6: Carve elevation along rivers ──────────────────────────────
|
||||
foreach (var path in riverPaths)
|
||||
CarveRiverPath(world, path.tiles);
|
||||
|
||||
ctx.ReportProgress(Name, 0.80f);
|
||||
|
||||
// ── Step 7: Convert to polylines ──────────────────────────────────────
|
||||
var meanderRng = SeededRng.ForSubsystem(world.WorldSeed, C.RNG_MEANDER);
|
||||
int polyId = 0;
|
||||
foreach (var path in riverPaths)
|
||||
{
|
||||
var poly = BuildRiverPolyline(world, path, polyId++, meanderRng);
|
||||
world.Rivers.Add(poly);
|
||||
}
|
||||
|
||||
ctx.ReportProgress(Name, 0.92f);
|
||||
|
||||
// ── Step 8: Rasterize tile flags ──────────────────────────────────────
|
||||
foreach (var poly in world.Rivers)
|
||||
PolylineBuilder.RasterizeToTileFlags(world, poly, setAdjacency: true);
|
||||
|
||||
world.StageHashes["HydrologyGen"] = world.HashPolylines();
|
||||
ctx.LogMessage($"[HydrologyGen] Generated {world.Rivers.Count} rivers.");
|
||||
ctx.ReportProgress(Name, 1f);
|
||||
}
|
||||
|
||||
// ── Flow direction map ───────────────────────────────────────────────────
|
||||
|
||||
private byte[,] ComputeFlowDirections(WorldState world)
|
||||
{
|
||||
var flow = new byte[W, H];
|
||||
|
||||
// Initialize to Dir.None
|
||||
for (int y = 0; y < H; y++)
|
||||
for (int x = 0; x < W; x++)
|
||||
flow[x, y] = Dir.None;
|
||||
|
||||
for (int y = 0; y < H; y++)
|
||||
for (int x = 0; x < W; x++)
|
||||
{
|
||||
ref var tile = ref world.TileAt(x, y);
|
||||
if (tile.Biome == BiomeId.Ocean) continue;
|
||||
|
||||
float lowestElev = tile.Elevation;
|
||||
int bestDx = 0, bestDy = 0;
|
||||
|
||||
foreach (var (ddx, ddy) in Dirs8)
|
||||
{
|
||||
int nx = x + ddx;
|
||||
int ny = y + ddy;
|
||||
if ((uint)nx >= W || (uint)ny >= H) continue;
|
||||
float ne = world.Tiles[nx, ny].Elevation;
|
||||
if (ne < lowestElev)
|
||||
{
|
||||
lowestElev = ne;
|
||||
bestDx = ddx; bestDy = ddy;
|
||||
}
|
||||
}
|
||||
|
||||
if (bestDx != 0 || bestDy != 0)
|
||||
flow[x, y] = Dir.FromDelta(bestDx, bestDy);
|
||||
}
|
||||
|
||||
// Handle flat areas: BFS from edges outward
|
||||
ResolveFlatAreas(world, flow);
|
||||
return flow;
|
||||
}
|
||||
|
||||
private void ResolveFlatAreas(WorldState world, byte[,] flow)
|
||||
{
|
||||
// BFS-compute ocean distance for every cell.
|
||||
// Flow always moves towards lower ocean distance → guaranteed cycle-free.
|
||||
var oceanDist = new int[W, H];
|
||||
const int INF = int.MaxValue / 2;
|
||||
for (int y = 0; y < H; y++)
|
||||
for (int x = 0; x < W; x++)
|
||||
oceanDist[x, y] = world.Tiles[x, y].Biome == BiomeId.Ocean ? 0 : INF;
|
||||
|
||||
var bfsQ = new Queue<(int x, int y)>();
|
||||
for (int y = 0; y < H; y++)
|
||||
for (int x = 0; x < W; x++)
|
||||
if (oceanDist[x, y] == 0) bfsQ.Enqueue((x, y));
|
||||
|
||||
while (bfsQ.Count > 0)
|
||||
{
|
||||
var (cx, cy) = bfsQ.Dequeue();
|
||||
foreach (var (ddx, ddy) in Dirs8)
|
||||
{
|
||||
int nx = cx + ddx, ny = cy + ddy;
|
||||
if ((uint)nx >= W || (uint)ny >= H) continue;
|
||||
if (oceanDist[nx, ny] != INF) continue;
|
||||
oceanDist[nx, ny] = oceanDist[cx, cy] + 1;
|
||||
bfsQ.Enqueue((nx, ny));
|
||||
}
|
||||
}
|
||||
|
||||
// Assign direction to every Dir.None land tile:
|
||||
// - Prefer the neighbor with strictly lower ocean distance.
|
||||
// - Tiebreak (same distance): prefer lower elevation, then earliest in Dirs8.
|
||||
for (int y = 0; y < H; y++)
|
||||
for (int x = 0; x < W; x++)
|
||||
{
|
||||
if (flow[x, y] != Dir.None) continue;
|
||||
if (world.Tiles[x, y].Biome == BiomeId.Ocean) continue;
|
||||
|
||||
int myDist = oceanDist[x, y];
|
||||
int bestDist = myDist;
|
||||
float bestElev = float.MaxValue;
|
||||
int bdx = 0, bdy = 0;
|
||||
|
||||
for (int di = 0; di < Dirs8.Length; di++)
|
||||
{
|
||||
var (ddx, ddy) = Dirs8[di];
|
||||
int nx = x + ddx, ny = y + ddy;
|
||||
if ((uint)nx >= W || (uint)ny >= H) continue;
|
||||
|
||||
int nd = oceanDist[nx, ny];
|
||||
float ne = world.Tiles[nx, ny].Elevation;
|
||||
|
||||
if (nd < bestDist || (nd == bestDist && ne < bestElev))
|
||||
{
|
||||
bestDist = nd; bestElev = ne;
|
||||
bdx = ddx; bdy = ddy;
|
||||
}
|
||||
}
|
||||
|
||||
if (bdx != 0 || bdy != 0)
|
||||
flow[x, y] = Dir.FromDelta(bdx, bdy);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Flow accumulation ────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Topological-sort based accumulation. Processes each tile exactly once in
|
||||
/// upstream-to-downstream order, so flat areas with cycles are handled correctly.
|
||||
/// Cycle tiles (in-degree never reaches 0) receive a self-contribution of 1.
|
||||
/// </summary>
|
||||
private int[,] ComputeFlowAccumulation(WorldState world, byte[,] flowDir)
|
||||
{
|
||||
var accum = new int[W, H];
|
||||
var inDeg = new int[W, H];
|
||||
var processed = new bool[W, H];
|
||||
|
||||
// Count in-degrees: how many non-ocean land tiles flow into each tile
|
||||
for (int y = 0; y < H; y++)
|
||||
for (int x = 0; x < W; x++)
|
||||
{
|
||||
if (world.Tiles[x, y].Biome == BiomeId.Ocean) continue;
|
||||
byte d = flowDir[x, y];
|
||||
if (d == Dir.None) continue;
|
||||
var (ddx, ddy) = Dir.ToDelta(d);
|
||||
int nx = x + ddx, ny = y + ddy;
|
||||
if ((uint)nx >= W || (uint)ny >= H) continue;
|
||||
if (world.Tiles[nx, ny].Biome == BiomeId.Ocean) continue;
|
||||
inDeg[nx, ny]++;
|
||||
}
|
||||
|
||||
// Seed: all source tiles (no upstream contributors)
|
||||
var queue = new Queue<(int x, int y)>();
|
||||
for (int y = 0; y < H; y++)
|
||||
for (int x = 0; x < W; x++)
|
||||
{
|
||||
if (world.Tiles[x, y].Biome == BiomeId.Ocean) continue;
|
||||
if (inDeg[x, y] == 0) queue.Enqueue((x, y));
|
||||
}
|
||||
|
||||
while (queue.Count > 0)
|
||||
{
|
||||
var (cx, cy) = queue.Dequeue();
|
||||
if (processed[cx, cy]) continue;
|
||||
processed[cx, cy] = true;
|
||||
|
||||
accum[cx, cy]++; // self-contribution
|
||||
|
||||
byte d = flowDir[cx, cy];
|
||||
if (d == Dir.None) continue;
|
||||
var (ddx, ddy) = Dir.ToDelta(d);
|
||||
int nx = cx + ddx, ny = cy + ddy;
|
||||
if ((uint)nx >= W || (uint)ny >= H) continue;
|
||||
if (world.Tiles[nx, ny].Biome == BiomeId.Ocean) continue;
|
||||
|
||||
accum[nx, ny] += accum[cx, cy];
|
||||
if (--inDeg[nx, ny] == 0)
|
||||
queue.Enqueue((nx, ny));
|
||||
}
|
||||
|
||||
// Tiles still unprocessed are in cycles — assign self-contribution
|
||||
for (int y = 0; y < H; y++)
|
||||
for (int x = 0; x < W; x++)
|
||||
{
|
||||
if (world.Tiles[x, y].Biome == BiomeId.Ocean) continue;
|
||||
if (!processed[x, y]) accum[x, y] = Math.Max(1, accum[x, y]);
|
||||
}
|
||||
|
||||
return accum;
|
||||
}
|
||||
|
||||
// ── Lake detection and filling ───────────────────────────────────────────
|
||||
|
||||
private void DetectAndFillLakes(WorldState world, byte[,] flowDir, int[,] accum, SeededRng rng)
|
||||
{
|
||||
var visited = new bool[W, H];
|
||||
|
||||
for (int y = 1; y < H - 1; y++)
|
||||
for (int x = 1; x < W - 1; x++)
|
||||
{
|
||||
if (visited[x, y]) continue;
|
||||
if (world.Tiles[x, y].Biome == BiomeId.Ocean) continue;
|
||||
if (flowDir[x, y] != Dir.None) continue; // has a direction — not a sink
|
||||
|
||||
// BFS flood fill from this sink point
|
||||
var basin = new List<(int, int)>();
|
||||
var q = new Queue<(int, int)>();
|
||||
q.Enqueue((x, y));
|
||||
visited[x, y] = true;
|
||||
float outletElev = world.Tiles[x, y].Elevation;
|
||||
|
||||
while (q.Count > 0)
|
||||
{
|
||||
var (cx, cy) = q.Dequeue();
|
||||
basin.Add((cx, cy));
|
||||
|
||||
foreach (var (ddx, ddy) in Dirs8)
|
||||
{
|
||||
int nx2 = cx + ddx;
|
||||
int ny2 = cy + ddy;
|
||||
if ((uint)nx2 >= W || (uint)ny2 >= H) continue;
|
||||
if (visited[nx2, ny2]) continue;
|
||||
if (world.Tiles[nx2, ny2].Biome == BiomeId.Ocean) continue;
|
||||
if (world.Tiles[nx2, ny2].Elevation > outletElev + 0.05f) continue;
|
||||
visited[nx2, ny2] = true;
|
||||
q.Enqueue((nx2, ny2));
|
||||
}
|
||||
}
|
||||
|
||||
if (basin.Count < C.LAKE_MIN_AREA) continue;
|
||||
|
||||
// Fill the basin as a lake (set elevation to outlet and biome to Ocean for rendering)
|
||||
foreach (var (lx, ly) in basin)
|
||||
{
|
||||
ref var tile = ref world.TileAt(lx, ly);
|
||||
tile.Elevation = outletElev;
|
||||
tile.Biome = BiomeId.Ocean; // reuse ocean for inland lakes (renders as water)
|
||||
tile.Features |= FeatureFlags.HasRiver; // mark as water body
|
||||
flowDir[lx, ly] = Dir.None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── River path extraction ────────────────────────────────────────────────
|
||||
|
||||
private record RiverPath(List<(int x, int y)> tiles, int maxAccum);
|
||||
|
||||
private List<RiverPath> ExtractRiverPaths(WorldState world, int[,] accum, byte[,] flowDir)
|
||||
{
|
||||
var paths = new List<RiverPath>();
|
||||
var onRiver = new bool[W, H];
|
||||
|
||||
// Find all tiles with flow >= threshold, sorted by accumulation (descending)
|
||||
var highAccum = new List<(int accum, int x, int y)>();
|
||||
for (int y = 0; y < H; y++)
|
||||
for (int x = 0; x < W; x++)
|
||||
{
|
||||
if (accum[x, y] >= C.RIVER_MIN_FLOW_ACCUM && world.Tiles[x, y].Biome != BiomeId.Ocean)
|
||||
highAccum.Add((accum[x, y], x, y));
|
||||
}
|
||||
// Sort ASCENDING so we start at headwaters (low accum = most upstream).
|
||||
// Each headwater traces one long path to coast; downstream tiles are marked onRiver
|
||||
// and skipped by subsequent iterations — producing one river per tributary.
|
||||
highAccum.Sort((a, b) => a.accum.CompareTo(b.accum));
|
||||
|
||||
foreach (var (_, sx, sy) in highAccum)
|
||||
{
|
||||
if (paths.Count >= C.RIVER_MAX_COUNT) break; // hard cap
|
||||
if (onRiver[sx, sy]) continue;
|
||||
|
||||
var tilePath = new List<(int x, int y)>();
|
||||
int curX = sx, curY = sy;
|
||||
int maxAccumVal = 0;
|
||||
int safety = W + H;
|
||||
|
||||
while (safety-- > 0)
|
||||
{
|
||||
if (onRiver[curX, curY]) break;
|
||||
if (world.Tiles[curX, curY].Biome == BiomeId.Ocean) break;
|
||||
|
||||
tilePath.Add((curX, curY));
|
||||
maxAccumVal = Math.Max(maxAccumVal, accum[curX, curY]);
|
||||
|
||||
// Navigate downstream via precomputed flow direction
|
||||
byte dir = flowDir[curX, curY];
|
||||
if (dir == Dir.None) break; // true sink
|
||||
var (ddx, ddy) = Dir.ToDelta(dir);
|
||||
int nextX = curX + ddx, nextY = curY + ddy;
|
||||
if ((uint)nextX >= W || (uint)nextY >= H) break;
|
||||
curX = nextX; curY = nextY;
|
||||
}
|
||||
|
||||
// Only mark tiles as onRiver AFTER confirming path length.
|
||||
// Premature marking (before the check) would block subsequent upstream traces.
|
||||
if (tilePath.Count >= 3)
|
||||
{
|
||||
foreach (var (tx, ty) in tilePath)
|
||||
onRiver[tx, ty] = true;
|
||||
paths.Add(new RiverPath(tilePath, maxAccumVal));
|
||||
}
|
||||
}
|
||||
|
||||
return paths;
|
||||
}
|
||||
|
||||
// ── Ensure required macro-region rivers ──────────────────────────────────
|
||||
|
||||
private void EnsureRequiredRivers(WorldState world, List<RiverPath> paths, int[,] accum, SeededRng rng)
|
||||
{
|
||||
// Check which required regions have rivers
|
||||
var required = new[]
|
||||
{
|
||||
"temperate_deciduous", "temperate_forest",
|
||||
"temperate_grassland",
|
||||
"subtropical_forest",
|
||||
};
|
||||
|
||||
var covered = new HashSet<string>();
|
||||
foreach (var path in paths)
|
||||
{
|
||||
if (path.maxAccum < C.RIVER_MODERATE_THRESHOLD) continue;
|
||||
foreach (var (tx, ty) in path.tiles)
|
||||
{
|
||||
var macro = world.MacroCellForTile(world.TileAt(tx, ty));
|
||||
covered.Add(macro.BiomeType.ToLowerInvariant());
|
||||
}
|
||||
}
|
||||
|
||||
// For each uncovered required region, find its highest tile and add a short river
|
||||
foreach (var req in required)
|
||||
{
|
||||
if (covered.Contains(req)) continue;
|
||||
|
||||
// Find the highest-elevation tile in this macro region
|
||||
int bestX = -1, bestY = -1;
|
||||
float bestElev = 0f;
|
||||
for (int y = 0; y < H; y += 4)
|
||||
for (int x = 0; x < W; x += 4)
|
||||
{
|
||||
ref var tile = ref world.TileAt(x, y);
|
||||
if (tile.Biome == BiomeId.Ocean) continue;
|
||||
var macro = world.MacroCellForTile(in tile);
|
||||
if (!macro.BiomeType.ToLowerInvariant().Contains(req.Split('_')[0])) continue;
|
||||
if (tile.Elevation > bestElev)
|
||||
{
|
||||
bestElev = tile.Elevation; bestX = x; bestY = y;
|
||||
}
|
||||
}
|
||||
if (bestX < 0) continue;
|
||||
|
||||
// Trace a path from that tile downhill
|
||||
var tilePath = new List<(int, int)>();
|
||||
int cx = bestX, cy = bestY;
|
||||
int safety = 200;
|
||||
while (safety-- > 0 && world.Tiles[cx, cy].Biome != BiomeId.Ocean)
|
||||
{
|
||||
tilePath.Add((cx, cy));
|
||||
int bx = cx, by = cy;
|
||||
float be = world.Tiles[cx, cy].Elevation;
|
||||
foreach (var (ddx, ddy) in Dirs8)
|
||||
{
|
||||
int nx = cx + ddx, ny = cy + ddy;
|
||||
if ((uint)nx >= W || (uint)ny >= H) continue;
|
||||
if (world.Tiles[nx, ny].Elevation < be)
|
||||
{
|
||||
be = world.Tiles[nx, ny].Elevation; bx = nx; by = ny;
|
||||
}
|
||||
}
|
||||
if (bx == cx && by == cy) break;
|
||||
cx = bx; cy = by;
|
||||
}
|
||||
|
||||
if (tilePath.Count >= 5)
|
||||
{
|
||||
// Override accumulation to moderate threshold so it qualifies
|
||||
foreach (var (tx, ty) in tilePath)
|
||||
accum[tx, ty] = Math.Max(accum[tx, ty], C.RIVER_MODERATE_THRESHOLD);
|
||||
paths.Add(new RiverPath(tilePath, C.RIVER_MODERATE_THRESHOLD));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Elevation carving ────────────────────────────────────────────────────
|
||||
|
||||
private static void CarveRiverPath(WorldState world, List<(int x, int y)> tiles)
|
||||
{
|
||||
foreach (var (x, y) in tiles)
|
||||
{
|
||||
ref var tile = ref world.TileAt(x, y);
|
||||
if (tile.Biome == BiomeId.Ocean) continue;
|
||||
// Don't carve mountain tiles — river carving would push them below the macro
|
||||
// ElevationFloor constraint that BiomeAssignStage enforced.
|
||||
if (tile.Biome is BiomeId.MountainAlpine or BiomeId.MountainForested) continue;
|
||||
tile.Elevation = Math.Max(WorldState.SeaLevel, tile.Elevation - C.RIVER_CARVE_DEPTH);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Polyline conversion ──────────────────────────────────────────────────
|
||||
|
||||
private static Polyline BuildRiverPolyline(WorldState world, RiverPath path, int id, SeededRng rng)
|
||||
{
|
||||
var poly = new Polyline { Type = PolylineType.River, Id = id };
|
||||
poly.FlowAccumulation = path.maxAccum;
|
||||
|
||||
// Classify
|
||||
if (path.maxAccum >= C.RIVER_MAJOR_THRESHOLD)
|
||||
{
|
||||
poly.RiverClassification = RiverClass.MajorRiver;
|
||||
poly.Width = 3f;
|
||||
}
|
||||
else if (path.maxAccum >= C.RIVER_MODERATE_THRESHOLD)
|
||||
{
|
||||
poly.RiverClassification = RiverClass.River;
|
||||
poly.Width = 2f;
|
||||
}
|
||||
else
|
||||
{
|
||||
poly.RiverClassification = RiverClass.Stream;
|
||||
poly.Width = 1f;
|
||||
}
|
||||
|
||||
// Control points from tile centers
|
||||
var controlPts = path.tiles
|
||||
.Select(t => PolylineBuilder.TileToWorldPixel(t.x, t.y))
|
||||
.ToList();
|
||||
|
||||
// Smooth
|
||||
var smoothed = PolylineBuilder.CatmullRomSmooth(controlPts);
|
||||
|
||||
// Meander amplitude based on average elevation
|
||||
float avgElev = path.tiles.Average(t => world.Tiles[t.x, t.y].Elevation);
|
||||
float amp = avgElev > 0.55f ? C.MEANDER_AMP_MOUNTAIN : C.MEANDER_AMP_FLAT;
|
||||
|
||||
ulong noiseSeed = world.WorldSeed ^ C.RNG_HYDRO ^ (ulong)id;
|
||||
PolylineBuilder.ApplyMeanderNoise(smoothed, amp, C.MEANDER_FREQ, noiseSeed);
|
||||
|
||||
poly.Points.AddRange(smoothed);
|
||||
poly.SimplifiedPoints = PolylineBuilder.RDPSimplify(poly.Points);
|
||||
|
||||
return poly;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
using Theriapolis.Core.Data;
|
||||
|
||||
namespace Theriapolis.Core.World.Generation.Stages;
|
||||
|
||||
/// <summary>
|
||||
/// Stage 2 — MacroTemplateLoad
|
||||
/// Reads macro_template.json and Content/Data/biomes.json.
|
||||
/// Populates ctx.World.MacroGrid and ctx.World.BiomeDefs.
|
||||
/// Also stamps macro cell coordinates onto every tile.
|
||||
/// </summary>
|
||||
public sealed class MacroTemplateLoadStage : IWorldGenStage
|
||||
{
|
||||
public string Name => "MacroTemplateLoad";
|
||||
|
||||
public void Run(WorldGenContext ctx)
|
||||
{
|
||||
var loader = new ContentLoader(ctx.DataDirectory);
|
||||
|
||||
var template = loader.LoadMacroTemplate();
|
||||
ctx.World.MacroGrid = template.Build();
|
||||
|
||||
ctx.World.BiomeDefs = loader.LoadBiomes();
|
||||
ctx.World.FactionDefs = loader.LoadFactions();
|
||||
|
||||
// Stamp macro cell coordinates onto all tiles (used later for constraints)
|
||||
int cellW = C.WORLD_WIDTH_TILES / C.MACRO_GRID_WIDTH;
|
||||
int cellH = C.WORLD_HEIGHT_TILES / C.MACRO_GRID_HEIGHT;
|
||||
|
||||
for (int ty = 0; ty < C.WORLD_HEIGHT_TILES; ty++)
|
||||
{
|
||||
int my = ty / cellH;
|
||||
for (int tx = 0; tx < C.WORLD_WIDTH_TILES; tx++)
|
||||
{
|
||||
int mx = tx / cellW;
|
||||
ctx.World.Tiles[tx, ty].MacroX = (byte)Math.Min(mx, C.MACRO_GRID_WIDTH - 1);
|
||||
ctx.World.Tiles[tx, ty].MacroY = (byte)Math.Min(my, C.MACRO_GRID_HEIGHT - 1);
|
||||
}
|
||||
}
|
||||
|
||||
// Hash: XOR of all biome-type string hashes from the macro grid
|
||||
ulong hash = 0;
|
||||
for (int y = 0; y < C.MACRO_GRID_HEIGHT; y++)
|
||||
for (int x = 0; x < C.MACRO_GRID_WIDTH; x++)
|
||||
hash ^= (ulong)ctx.World.MacroGrid[x, y].BiomeType.GetHashCode();
|
||||
|
||||
ctx.World.StageHashes["MacroTemplateLoad"] = hash;
|
||||
ctx.LogMessage($"[MacroTemplateLoad] Loaded {C.MACRO_GRID_WIDTH}×{C.MACRO_GRID_HEIGHT} macro cells, {ctx.World.BiomeDefs.Length} biome defs.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
using Theriapolis.Core.Util;
|
||||
|
||||
namespace Theriapolis.Core.World.Generation.Stages;
|
||||
|
||||
/// <summary>
|
||||
/// Stage 4 — MoistureGen
|
||||
/// Independent noise layer, combined with elevation to derive biome detail.
|
||||
/// Respects macro-cell moisture floors/ceilings.
|
||||
/// </summary>
|
||||
public sealed class MoistureGenStage : IWorldGenStage
|
||||
{
|
||||
public string Name => "MoistureGen";
|
||||
|
||||
public void Run(WorldGenContext ctx)
|
||||
{
|
||||
ulong seed = ctx.World.WorldSeed ^ C.RNG_MOISTURE;
|
||||
var noise = new FastNoiseLite
|
||||
{
|
||||
Seed = (int)(seed & 0x7FFFFFFF),
|
||||
Frequency = 2.3f / C.WORLD_WIDTH_TILES,
|
||||
Noise = FastNoiseLite.NoiseType.OpenSimplex2,
|
||||
Fractal = FastNoiseLite.FractalType.FBm,
|
||||
Octaves = 5,
|
||||
Lacunarity = 2.0f,
|
||||
Gain = 0.5f,
|
||||
};
|
||||
|
||||
int W = C.WORLD_WIDTH_TILES;
|
||||
int H = C.WORLD_HEIGHT_TILES;
|
||||
|
||||
Parallel.For(0, H, ty =>
|
||||
{
|
||||
for (int tx = 0; tx < W; tx++)
|
||||
{
|
||||
float elev = ctx.World.Tiles[tx, ty].Elevation;
|
||||
|
||||
float raw = noise.GetNoise01((float)tx, (float)ty);
|
||||
|
||||
// Elevation modifier: high elevation slightly reduces moisture
|
||||
raw -= (elev - 0.5f) * 0.25f;
|
||||
raw = Math.Clamp(raw, 0f, 1f);
|
||||
|
||||
// Apply macro cell constraints for all tiles including ocean.
|
||||
// Skipping ocean tiles here was a shortcut that breaks macro constraints
|
||||
// when later stages (CoastalFeatureGen, BorderDistortion) raise ocean
|
||||
// tiles to land: those tiles would inherit the wrong moisture value.
|
||||
// Use the tile's stored warped macro coords so moisture follows
|
||||
// the same organic cell boundaries as elevation.
|
||||
var cell = ctx.World.MacroCellForTile(ctx.World.Tiles[tx, ty]);
|
||||
float m = cell.MoistureFloor + raw * (cell.MoistureCeiling - cell.MoistureFloor);
|
||||
m = Math.Clamp(m, 0f, 1f);
|
||||
|
||||
ctx.World.Tiles[tx, ty].Moisture = m;
|
||||
}
|
||||
});
|
||||
|
||||
ctx.World.StageHashes["MoistureGen"] = ctx.World.HashMoisture();
|
||||
ctx.LogMessage($"[MoistureGen] Moisture hash: 0x{ctx.World.StageHashes["MoistureGen"]:X16}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,353 @@
|
||||
using Theriapolis.Core.Data;
|
||||
using Theriapolis.Core.Util;
|
||||
|
||||
namespace Theriapolis.Core.World.Generation.Stages;
|
||||
|
||||
/// <summary>
|
||||
/// Stage 13 — NarrativeAnchorPlace
|
||||
/// Places the 6 narrative anchor settlements (Sanctum Fidelis first, then others).
|
||||
/// Must run BEFORE general settlement placement.
|
||||
/// </summary>
|
||||
public sealed class NarrativeAnchorPlaceStage : IWorldGenStage
|
||||
{
|
||||
public string Name => "NarrativeAnchorPlace";
|
||||
|
||||
private const int W = C.WORLD_WIDTH_TILES;
|
||||
private const int H = C.WORLD_HEIGHT_TILES;
|
||||
|
||||
private int _nextId = 1;
|
||||
private int[,] _componentIds = null!;
|
||||
private int _mainLandmassId;
|
||||
|
||||
public void Run(WorldGenContext ctx)
|
||||
{
|
||||
var world = ctx.World;
|
||||
var rng = SeededRng.ForSubsystem(world.WorldSeed, C.RNG_ANCHOR);
|
||||
|
||||
// Confine anchors to the main (largest) landmass so the road network
|
||||
// doesn't need to cross ocean to reach them.
|
||||
var (componentIds, componentSizes) = LandmassMap.Compute(world);
|
||||
_componentIds = componentIds;
|
||||
_mainLandmassId = LandmassMap.LargestComponentId(componentSizes);
|
||||
|
||||
var placed = new List<Settlement>();
|
||||
|
||||
// Placement order: most-constrained first
|
||||
PlaceSanctumFidelis(world, placed, rng);
|
||||
PlaceHeartstone (world, placed, rng);
|
||||
PlaceThornfield (world, placed, rng);
|
||||
PlaceFortDustwall (world, placed, rng);
|
||||
PlaceMillhaven (world, placed, rng);
|
||||
PlaceTheTangles (world, placed, rng);
|
||||
|
||||
foreach (var s in placed)
|
||||
{
|
||||
world.Settlements.Add(s);
|
||||
MarkSettlementTiles(world, s);
|
||||
}
|
||||
|
||||
ctx.LogMessage($"[NarrativeAnchorPlace] Placed {placed.Count} anchor settlements.");
|
||||
}
|
||||
|
||||
// ── Anchor placements ─────────────────────────────────────────────────────
|
||||
|
||||
private void PlaceSanctumFidelis(WorldState world, List<Settlement> placed, SeededRng rng)
|
||||
{
|
||||
// Preferred: center region, near a river
|
||||
var candidates = CollectCandidates(world, placed, (x, y, tile, macro) =>
|
||||
{
|
||||
if (tile.Biome == BiomeId.Ocean) return false;
|
||||
if (tile.MacroX < 12 || tile.MacroX > 22) return false;
|
||||
if (tile.MacroY < 10 || tile.MacroY > 22) return false;
|
||||
return (tile.Features & (FeatureFlags.HasRiver | FeatureFlags.RiverAdjacent)) != 0;
|
||||
});
|
||||
|
||||
if (candidates.Count == 0)
|
||||
{
|
||||
// Relaxed: center region, any non-ocean land tile
|
||||
candidates = CollectCandidates(world, placed, (x, y, tile, macro) =>
|
||||
tile.Biome != BiomeId.Ocean &&
|
||||
tile.MacroX >= 10 && tile.MacroX <= 24 &&
|
||||
tile.MacroY >= 8 && tile.MacroY <= 24);
|
||||
}
|
||||
|
||||
if (candidates.Count == 0)
|
||||
{
|
||||
// Last resort: any suitable non-ocean tile
|
||||
candidates = CollectCandidates(world, placed, (x, y, tile, macro) =>
|
||||
tile.Biome != BiomeId.Ocean && tile.Elevation < 0.75f);
|
||||
}
|
||||
|
||||
var best = PickBest(candidates, world, placed, rng, (int)C.ANCHOR_MIN_DIST);
|
||||
if (best.HasValue)
|
||||
{
|
||||
var s = MakeSettlement(world, best.Value.x, best.Value.y, 1, NarrativeAnchor.SanctumFidelis);
|
||||
s.Name = "Sanctum Fidelis";
|
||||
placed.Add(s);
|
||||
}
|
||||
}
|
||||
|
||||
private void PlaceHeartstone(WorldState world, List<Settlement> placed, SeededRng rng)
|
||||
{
|
||||
// Preferred: high mountain in western half
|
||||
var candidates = CollectCandidates(world, placed, (x, y, tile, macro) =>
|
||||
{
|
||||
if (tile.Biome == BiomeId.Ocean) return false;
|
||||
if (tile.Biome is not (BiomeId.MountainAlpine or BiomeId.MountainForested)) return false;
|
||||
if (tile.Elevation < 0.60f) return false;
|
||||
return tile.MacroX <= 14; // western half
|
||||
});
|
||||
|
||||
if (candidates.Count == 0)
|
||||
{
|
||||
// Relaxed: any mountain tile anywhere, no elevation minimum
|
||||
candidates = CollectCandidates(world, placed, (x, y, tile, macro) =>
|
||||
tile.Biome != BiomeId.Ocean &&
|
||||
tile.Biome is BiomeId.MountainAlpine or BiomeId.MountainForested);
|
||||
}
|
||||
|
||||
if (candidates.Count == 0)
|
||||
{
|
||||
// Last resort: any highland tile (elevation ≥ 0.50) that isn't ocean
|
||||
candidates = CollectCandidates(world, placed, (x, y, tile, macro) =>
|
||||
tile.Biome != BiomeId.Ocean && tile.Elevation >= 0.50f);
|
||||
}
|
||||
|
||||
var best = PickBest(candidates, world, placed, rng, (int)(C.ANCHOR_MIN_DIST * 0.5f));
|
||||
if (best.HasValue)
|
||||
{
|
||||
var s = MakeSettlement(world, best.Value.x, best.Value.y, 2, NarrativeAnchor.Heartstone);
|
||||
s.Name = "Heartstone";
|
||||
placed.Add(s);
|
||||
}
|
||||
}
|
||||
|
||||
private void PlaceThornfield(WorldState world, List<Settlement> placed, SeededRng rng)
|
||||
{
|
||||
var candidates = CollectCandidates(world, placed, (x, y, tile, macro) =>
|
||||
{
|
||||
if (tile.Biome == BiomeId.Ocean) return false;
|
||||
if (tile.MacroX < 16) return false;
|
||||
var dev = macro.Development?.ToLowerInvariant() ?? "";
|
||||
if (!dev.Contains("industrial") && !dev.Contains("urban")) return false;
|
||||
return (tile.Features & (FeatureFlags.HasRiver | FeatureFlags.RiverAdjacent)) != 0;
|
||||
});
|
||||
|
||||
if (candidates.Count == 0)
|
||||
{
|
||||
// Relaxed: any eastern developed region with river
|
||||
candidates = CollectCandidates(world, placed, (x, y, tile, macro) =>
|
||||
tile.Biome != BiomeId.Ocean && tile.MacroX >= 16 &&
|
||||
(tile.Features & (FeatureFlags.HasRiver | FeatureFlags.RiverAdjacent)) != 0);
|
||||
}
|
||||
|
||||
if (candidates.Count == 0)
|
||||
{
|
||||
// Last resort: any non-ocean tile with a river (drop east-half
|
||||
// requirement). On small maps the eastern half may not happen to
|
||||
// contain any river tiles for a given seed; Thornfield still
|
||||
// needs to be placed somewhere reasonable.
|
||||
candidates = CollectCandidates(world, placed, (x, y, tile, macro) =>
|
||||
tile.Biome != BiomeId.Ocean &&
|
||||
(tile.Features & (FeatureFlags.HasRiver | FeatureFlags.RiverAdjacent)) != 0);
|
||||
}
|
||||
|
||||
if (candidates.Count == 0)
|
||||
{
|
||||
// Absolute fallback: any non-ocean land tile. Matches the
|
||||
// last-resort tier that SanctumFidelis/Heartstone already use.
|
||||
candidates = CollectCandidates(world, placed, (x, y, tile, macro) =>
|
||||
tile.Biome != BiomeId.Ocean);
|
||||
}
|
||||
|
||||
var best = PickBest(candidates, world, placed, rng, (int)C.ANCHOR_MIN_DIST);
|
||||
if (best.HasValue)
|
||||
{
|
||||
var s = MakeSettlement(world, best.Value.x, best.Value.y, 2, NarrativeAnchor.Thornfield);
|
||||
s.Name = "Thornfield";
|
||||
placed.Add(s);
|
||||
}
|
||||
}
|
||||
|
||||
private void PlaceFortDustwall(WorldState world, List<Settlement> placed, SeededRng rng)
|
||||
{
|
||||
var candidates = CollectCandidates(world, placed, (x, y, tile, macro) =>
|
||||
{
|
||||
if (tile.Biome == BiomeId.Ocean) return false;
|
||||
if (tile.Biome is not (BiomeId.TemperateGrassland or BiomeId.Scrubland
|
||||
or BiomeId.TemperateDeciduous)) return false;
|
||||
return tile.MacroX >= 10 && tile.MacroX <= 22 &&
|
||||
tile.MacroY >= 12 && tile.MacroY <= 26;
|
||||
});
|
||||
|
||||
var best = PickBest(candidates, world, placed, rng, (int)C.ANCHOR_MIN_DIST);
|
||||
if (best.HasValue)
|
||||
{
|
||||
int tier = rng.NextBool(0.6) ? 2 : 3;
|
||||
var s = MakeSettlement(world, best.Value.x, best.Value.y, tier, NarrativeAnchor.FortDustwall);
|
||||
s.Name = "Fort Dustwall";
|
||||
placed.Add(s);
|
||||
}
|
||||
}
|
||||
|
||||
private void PlaceMillhaven(WorldState world, List<Settlement> placed, SeededRng rng)
|
||||
{
|
||||
// Preferred: eastern forest/grassland near a river
|
||||
var candidates = CollectCandidates(world, placed, (x, y, tile, macro) =>
|
||||
{
|
||||
if (tile.Biome == BiomeId.Ocean) return false;
|
||||
if (tile.Biome is not (BiomeId.TemperateDeciduous or BiomeId.ForestEdge
|
||||
or BiomeId.TemperateGrassland)) return false;
|
||||
if (tile.MacroX < 16) return false;
|
||||
if ((tile.Features & (FeatureFlags.HasRiver | FeatureFlags.RiverAdjacent)) == 0) return false;
|
||||
// At least 40 tiles from any Tier 2+ anchor already placed
|
||||
foreach (var p in placed)
|
||||
{
|
||||
if (p.Tier <= 2)
|
||||
{
|
||||
float d = MathF.Sqrt((x - p.TileX) * (float)(x - p.TileX)
|
||||
+ (y - p.TileY) * (float)(y - p.TileY));
|
||||
if (d < 40) return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
if (candidates.Count == 0)
|
||||
{
|
||||
// Relaxed: drop river requirement, keep biome and location
|
||||
candidates = CollectCandidates(world, placed, (x, y, tile, macro) =>
|
||||
tile.Biome != BiomeId.Ocean &&
|
||||
tile.Biome is BiomeId.TemperateDeciduous or BiomeId.ForestEdge
|
||||
or BiomeId.TemperateGrassland &&
|
||||
tile.MacroX >= 12);
|
||||
}
|
||||
|
||||
if (candidates.Count == 0)
|
||||
{
|
||||
// Last resort: any non-ocean land tile in the eastern two-thirds
|
||||
candidates = CollectCandidates(world, placed, (x, y, tile, macro) =>
|
||||
tile.Biome != BiomeId.Ocean && tile.MacroX >= 10);
|
||||
}
|
||||
|
||||
var best = PickBest(candidates, world, placed, rng, (int)C.ANCHOR_MIN_DIST / 2);
|
||||
if (best.HasValue)
|
||||
{
|
||||
var s = MakeSettlement(world, best.Value.x, best.Value.y, 3, NarrativeAnchor.Millhaven);
|
||||
s.Name = "Millhaven";
|
||||
placed.Add(s);
|
||||
}
|
||||
}
|
||||
|
||||
private void PlaceTheTangles(WorldState world, List<Settlement> placed, SeededRng rng)
|
||||
{
|
||||
var candidates = CollectCandidates(world, placed, (x, y, tile, macro) =>
|
||||
{
|
||||
if (tile.Biome == BiomeId.Ocean) return false;
|
||||
if (tile.Biome is not (BiomeId.SubtropicalForest or BiomeId.Wetland
|
||||
or BiomeId.Mangrove or BiomeId.MarshEdge)) return false;
|
||||
var cov = macro.Covenant?.ToLowerInvariant() ?? "";
|
||||
return cov is "weak" or "nominal";
|
||||
});
|
||||
|
||||
int count = rng.NextInt(2, 5);
|
||||
bool firstTangle = true;
|
||||
|
||||
for (int i = 0; i < count && candidates.Count > 0; i++)
|
||||
{
|
||||
var best = PickBest(candidates, world, placed, rng, C.SETTLE_MIN_DIST_TIER4);
|
||||
if (!best.HasValue) break;
|
||||
|
||||
var s = MakeSettlement(world, best.Value.x, best.Value.y, 4, NarrativeAnchor.TheTangles);
|
||||
s.Name = firstTangle ? "Thornback Hollow" : NameGenerator.Generate(rng, "subtropical");
|
||||
firstTangle = false;
|
||||
placed.Add(s);
|
||||
MarkSettlementTiles(world, s);
|
||||
|
||||
candidates = candidates
|
||||
.Where(c => !IsCloseTo(c.x, c.y, best.Value.x, best.Value.y, C.SETTLE_MIN_DIST_TIER4))
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
private List<(int x, int y)> CollectCandidates(
|
||||
WorldState world,
|
||||
List<Settlement> placed,
|
||||
Func<int, int, WorldTile, MacroCell, bool> filter,
|
||||
int step = 2)
|
||||
{
|
||||
var result = new List<(int x, int y)>();
|
||||
for (int y = 0; y < H; y += step)
|
||||
for (int x = 0; x < W; x += step)
|
||||
{
|
||||
if (_componentIds[x, y] != _mainLandmassId) continue;
|
||||
ref var tile = ref world.TileAt(x, y);
|
||||
var macro = world.MacroCellForTile(in tile);
|
||||
if (!filter(x, y, tile, macro)) continue;
|
||||
if (IsTooCloseToAnySettlement(x, y, placed, C.SETTLE_MIN_DIST_TIER2)) continue;
|
||||
result.Add((x, y));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static (int x, int y)? PickBest(
|
||||
List<(int x, int y)> candidates,
|
||||
WorldState world,
|
||||
List<Settlement> placed,
|
||||
SeededRng rng,
|
||||
int anchorMinDist)
|
||||
{
|
||||
if (candidates.Count == 0) return null;
|
||||
|
||||
var ranked = candidates
|
||||
.Where(c => !IsTooCloseToAnySettlement(c.x, c.y, placed, anchorMinDist))
|
||||
.OrderByDescending(c => world.Habitability?[c.x, c.y] ?? 0f)
|
||||
.Take(10)
|
||||
.ToList();
|
||||
|
||||
if (ranked.Count == 0)
|
||||
ranked = candidates.OrderByDescending(c => world.Habitability?[c.x, c.y] ?? 0f)
|
||||
.Take(5).ToList();
|
||||
if (ranked.Count == 0) return null;
|
||||
|
||||
return ranked[rng.NextInt(Math.Min(3, ranked.Count))];
|
||||
}
|
||||
|
||||
private Settlement MakeSettlement(WorldState world, int x, int y, int tier, NarrativeAnchor anchor)
|
||||
{
|
||||
var (nx, ny) = SettlementPlaceStage.NudgeOffRiver(world, x, y);
|
||||
return new Settlement
|
||||
{
|
||||
Id = _nextId++,
|
||||
Tier = tier,
|
||||
TileX = nx,
|
||||
TileY = ny,
|
||||
Anchor = anchor,
|
||||
};
|
||||
}
|
||||
|
||||
private static void MarkSettlementTiles(WorldState world, Settlement s)
|
||||
{
|
||||
for (int dy = -2; dy <= 2; dy++)
|
||||
for (int dx = -2; dx <= 2; dx++)
|
||||
{
|
||||
int nx = s.TileX + dx, ny = s.TileY + dy;
|
||||
if ((uint)nx >= W || (uint)ny >= H) continue;
|
||||
ref var tile = ref world.TileAt(nx, ny);
|
||||
tile.Features |= FeatureFlags.IsSettlement;
|
||||
tile.SettlementId = (ushort)Math.Min(s.Id, ushort.MaxValue);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsCloseTo(int x1, int y1, int x2, int y2, int minDist)
|
||||
=> (x1 - x2) * (x1 - x2) + (y1 - y2) * (y1 - y2) < minDist * minDist;
|
||||
|
||||
private static bool IsTooCloseToAnySettlement(int x, int y, List<Settlement> placed, int minDist)
|
||||
{
|
||||
foreach (var s in placed)
|
||||
if (IsCloseTo(x, y, s.TileX, s.TileY, minDist)) return true;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
namespace Theriapolis.Core.World.Generation.Stages;
|
||||
|
||||
/// <summary>
|
||||
/// Stage 20 — PoIPlacement
|
||||
/// Additional Tier 5 PoIs beyond those placed by SettlementPlaceStage.
|
||||
/// This stage is a lightweight pass to ensure minimum PoI count is met.
|
||||
/// (SettlementPlaceStage already places Tier 5 — this stage tops up if needed.)
|
||||
/// </summary>
|
||||
public sealed class PoIPlacementStage : IWorldGenStage
|
||||
{
|
||||
public string Name => "PoIPlacement";
|
||||
|
||||
private const int W = C.WORLD_WIDTH_TILES;
|
||||
private const int H = C.WORLD_HEIGHT_TILES;
|
||||
|
||||
public void Run(WorldGenContext ctx)
|
||||
{
|
||||
var world = ctx.World;
|
||||
var rng = ctx.Rngs["poi"];
|
||||
|
||||
int existing = world.Settlements.Count(s => s.IsPoi);
|
||||
int nextId = world.Settlements.Count + 1;
|
||||
|
||||
// SettlementPlaceStage may have already placed Tier 5 PoIs
|
||||
// This stage ensures the minimum is met and tags the world tiles
|
||||
int needed = Math.Max(0, C.SETTLE_TIER5_MIN - existing);
|
||||
|
||||
if (needed == 0)
|
||||
{
|
||||
ctx.LogMessage($"[PoIPlacement] {existing} PoIs already placed — no top-up needed.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Find low-habitability land tiles not near settlements
|
||||
var candidates = new List<(int x, int y)>();
|
||||
for (int y = 0; y < H; y += 4)
|
||||
for (int x = 0; x < W; x += 4)
|
||||
{
|
||||
if (world.Tiles[x, y].Biome == BiomeId.Ocean) continue;
|
||||
float hab = world.Habitability?[x, y] ?? 0f;
|
||||
if (hab > 0.35f) continue;
|
||||
if ((world.Tiles[x, y].Features & FeatureFlags.IsSettlement) != 0) continue;
|
||||
if (IsTooCloseToSettlement(world, x, y)) continue;
|
||||
candidates.Add((x, y));
|
||||
}
|
||||
|
||||
rng.Shuffle(candidates.ToArray().AsSpan());
|
||||
|
||||
int placed = 0;
|
||||
foreach (var (x, y) in candidates)
|
||||
{
|
||||
if (placed >= needed) break;
|
||||
if ((world.Tiles[x, y].Features & FeatureFlags.IsPoi) != 0) continue;
|
||||
|
||||
var poiType = PickPoiType(world.Tiles[x, y].Biome, rng);
|
||||
var s = new Settlement
|
||||
{
|
||||
Id = nextId++,
|
||||
Tier = 5,
|
||||
TileX = x,
|
||||
TileY = y,
|
||||
IsPoi = true,
|
||||
PoiType = poiType,
|
||||
Name = DescribePoi(poiType),
|
||||
};
|
||||
world.Settlements.Add(s);
|
||||
world.TileAt(x, y).Features |= FeatureFlags.IsPoi;
|
||||
placed++;
|
||||
}
|
||||
|
||||
ctx.LogMessage($"[PoIPlacement] Placed {placed} additional PoIs (total: {world.Settlements.Count(s => s.IsPoi)}).");
|
||||
}
|
||||
|
||||
private static bool IsTooCloseToSettlement(WorldState world, int x, int y)
|
||||
{
|
||||
foreach (var s in world.Settlements)
|
||||
{
|
||||
if (s.IsPoi) continue;
|
||||
int dx = x - s.TileX, dy = y - s.TileY;
|
||||
if (dx * dx + dy * dy < C.POI_MIN_DIST_FROM_SETTLE * C.POI_MIN_DIST_FROM_SETTLE)
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static PoiType PickPoiType(BiomeId biome, Util.SeededRng rng)
|
||||
{
|
||||
return biome switch
|
||||
{
|
||||
BiomeId.MountainAlpine or BiomeId.MountainForested => rng.NextBool() ? PoiType.AbandonedMine : PoiType.NaturalCave,
|
||||
BiomeId.Tundra or BiomeId.Boreal => PoiType.ImperiumRuin,
|
||||
BiomeId.Wetland or BiomeId.SubtropicalForest => rng.NextBool() ? PoiType.NaturalCave : PoiType.CultDen,
|
||||
_ => (PoiType)(rng.NextInt(1, 6)),
|
||||
};
|
||||
}
|
||||
|
||||
private static string DescribePoi(PoiType t) => t switch
|
||||
{
|
||||
PoiType.ImperiumRuin => "Imperium Ruin",
|
||||
PoiType.AbandonedMine => "Abandoned Mine",
|
||||
PoiType.CultDen => "Hidden Den",
|
||||
PoiType.NaturalCave => "Natural Cave",
|
||||
PoiType.OvergrownSettlement=> "Overgrown Ruins",
|
||||
_ => "Unknown Site",
|
||||
};
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,357 @@
|
||||
using Theriapolis.Core.Util;
|
||||
using Theriapolis.Core.World.Polylines;
|
||||
|
||||
namespace Theriapolis.Core.World.Generation.Stages;
|
||||
|
||||
/// <summary>
|
||||
/// Stage 16 — RailNetworkGen
|
||||
/// A* from the capital (Sanctum Fidelis) to each Tier 2 city (except Heartstone).
|
||||
/// Plus one transcontinental line: easternmost coastal Tier 2/3 to westernmost Tier 2.
|
||||
/// Addendum A §2 exclusion enforced: rivers are impassable, river-adjacent parallel is INFINITY.
|
||||
/// T-junctions are emitted as railway wyes: each sub-path endpoint sitting on
|
||||
/// existing rail forks into two polylines whose visible track extends onto the
|
||||
/// main line in opposite tangent directions, producing the familiar Y geometry
|
||||
/// rather than a raw perpendicular corner.
|
||||
/// </summary>
|
||||
public sealed class RailNetworkGenStage : IWorldGenStage
|
||||
{
|
||||
public string Name => "RailNetworkGen";
|
||||
|
||||
private const int W = C.WORLD_WIDTH_TILES;
|
||||
private const int H = C.WORLD_HEIGHT_TILES;
|
||||
|
||||
private const FeatureFlags PathPreserveMask =
|
||||
FeatureFlags.HasRoad | FeatureFlags.HasRiver | FeatureFlags.HasRail | FeatureFlags.IsSettlement;
|
||||
|
||||
// Railway-wye leg dimensions. When a sub-path endpoint sits on pre-existing
|
||||
// rail (not a settlement), EmitRailSubPath emits two polylines whose visible
|
||||
// track extends WYE_LEG_LENGTH_PX past the junction tile along ±t of the
|
||||
// main line. A ghost control point WYE_LEG_GHOST_PX further out biases the
|
||||
// Catmull-Rom tangent at the leg end toward the main line's direction,
|
||||
// producing a smooth ~45° merge. Both are 2 tiles — long enough to read as
|
||||
// a wye, short enough to stay within the junction's local area.
|
||||
private const float WYE_LEG_LENGTH_PX = 2f * C.WORLD_TILE_PIXELS;
|
||||
private const float WYE_LEG_GHOST_PX = 2f * C.WORLD_TILE_PIXELS;
|
||||
|
||||
public void Run(WorldGenContext ctx)
|
||||
{
|
||||
if (!C.ENABLE_RAIL)
|
||||
{
|
||||
ctx.LogMessage("[RailNetworkGen] Rail disabled via C.ENABLE_RAIL — skipping.");
|
||||
return;
|
||||
}
|
||||
|
||||
var world = ctx.World;
|
||||
var pathfinder = new AStarPathfinder();
|
||||
|
||||
// Find capital
|
||||
var capital = world.Settlements.FirstOrDefault(s => s.Anchor == NarrativeAnchor.SanctumFidelis);
|
||||
if (capital == null)
|
||||
{
|
||||
ctx.LogMessage("[RailNetworkGen] No capital found — skipping rail.");
|
||||
return;
|
||||
}
|
||||
|
||||
int polyId = 0;
|
||||
|
||||
// ── Capital → each Tier 2 city (except Heartstone) ──────────────────
|
||||
var tier2 = world.Settlements
|
||||
.Where(s => s.Tier <= 2 && s != capital && s.Anchor != NarrativeAnchor.Heartstone)
|
||||
.ToList();
|
||||
|
||||
foreach (var dest in tier2)
|
||||
{
|
||||
var costFn = RailCostFn(world);
|
||||
var path = pathfinder.FindPath(capital.TileX, capital.TileY, dest.TileX, dest.TileY, costFn);
|
||||
|
||||
if (path == null) { ctx.LogMessage($"[RailNetworkGen] No path to {dest.Name}"); continue; }
|
||||
|
||||
PolylineBuilder.SmoothStaircases(world, path, PathPreserveMask, costFn);
|
||||
PolylineBuilder.LimitTurnAngle(world, path, C.MAX_RAIL_TURN_DEGREES, PathPreserveMask, costFn);
|
||||
|
||||
var segments = PolylineBuilder.SplitByExistingFeature(world, path, FeatureFlags.HasRail);
|
||||
foreach (var seg in segments)
|
||||
EmitRailSubPath(world, seg, ref polyId, capital.Id, dest.Id);
|
||||
dest.HasRailStation = true;
|
||||
}
|
||||
|
||||
capital.HasRailStation = true;
|
||||
|
||||
// ── Transcontinental line ─────────────────────────────────────────────
|
||||
var eastPort = world.Settlements
|
||||
.Where(s => s.Tier <= 3 && !s.IsPoi &&
|
||||
(world.Tiles[Math.Clamp(s.TileX, 0, W-1), Math.Clamp(s.TileY, 0, H-1)].Features
|
||||
& (FeatureFlags.IsCoast | FeatureFlags.HasRiver)) != 0)
|
||||
.OrderByDescending(s => s.TileX)
|
||||
.FirstOrDefault();
|
||||
|
||||
var westTier2 = world.Settlements
|
||||
.Where(s => s.Tier == 2 && s.Anchor != NarrativeAnchor.Heartstone)
|
||||
.OrderBy(s => s.TileX)
|
||||
.FirstOrDefault();
|
||||
|
||||
if (eastPort != null && westTier2 != null && eastPort != westTier2)
|
||||
{
|
||||
var costFn = RailCostFn(world);
|
||||
var path = pathfinder.FindPath(eastPort.TileX, eastPort.TileY, westTier2.TileX, westTier2.TileY, costFn);
|
||||
|
||||
if (path != null)
|
||||
{
|
||||
PolylineBuilder.SmoothStaircases(world, path, PathPreserveMask, costFn);
|
||||
PolylineBuilder.LimitTurnAngle(world, path, C.MAX_RAIL_TURN_DEGREES, PathPreserveMask, costFn);
|
||||
|
||||
var segments = PolylineBuilder.SplitByExistingFeature(world, path, FeatureFlags.HasRail);
|
||||
foreach (var seg in segments)
|
||||
EmitRailSubPath(world, seg, ref polyId, eastPort.Id, westTier2.Id);
|
||||
}
|
||||
}
|
||||
|
||||
// Verify Heartstone is not near rail
|
||||
var heartstone = world.Settlements.FirstOrDefault(s => s.Anchor == NarrativeAnchor.Heartstone);
|
||||
if (heartstone != null)
|
||||
{
|
||||
ref var ht = ref world.TileAt(heartstone.TileX, heartstone.TileY);
|
||||
if ((ht.Features & FeatureFlags.HasRail) != 0 ||
|
||||
(ht.Features & FeatureFlags.RailroadAdjacent) != 0)
|
||||
ctx.LogMessage("[RailNetworkGen] WARNING: Heartstone has rail adjacent — anchor placement issue.");
|
||||
}
|
||||
|
||||
world.StageHashes["RailNetworkGen"] = world.HashPolylines();
|
||||
ctx.LogMessage($"[RailNetworkGen] Generated {world.Rails.Count} rail lines.");
|
||||
}
|
||||
|
||||
// ── Cost function ─────────────────────────────────────────────────────────
|
||||
|
||||
private static Func<int, int, int, int, byte, float> RailCostFn(WorldState world)
|
||||
{
|
||||
return (fx, fy, tx, ty, entryDir) =>
|
||||
{
|
||||
if ((uint)tx >= W || (uint)ty >= H) return float.PositiveInfinity;
|
||||
ref var tile = ref world.TileAt(tx, ty);
|
||||
|
||||
// Ocean tiles are impassable — no rail over water
|
||||
if (tile.Biome == BiomeId.Ocean) return float.PositiveInfinity;
|
||||
|
||||
// Rails are engineered infrastructure that share tracks — always prefer
|
||||
// joining existing rail (no settlement halo, unlike roads).
|
||||
if ((tile.Features & FeatureFlags.HasRail) != 0) return C.EXISTING_RAIL_COST;
|
||||
|
||||
// River tiles: perpendicular crossing allowed at bridge cost; parallel travel blocked
|
||||
if ((tile.Features & FeatureFlags.HasRiver) != 0)
|
||||
{
|
||||
if (tile.RiverFlowDir != Dir.None && Dir.IsParallel(entryDir, tile.RiverFlowDir))
|
||||
return float.PositiveInfinity;
|
||||
return C.RAIL_BRIDGE_COST + 1f + tile.Elevation * 4f;
|
||||
}
|
||||
|
||||
// River-adjacent: parallel = INFINITY, perpendicular = RAIL_BRIDGE_COST
|
||||
if ((tile.Features & FeatureFlags.RiverAdjacent) != 0 && tile.RiverFlowDir != Dir.None)
|
||||
{
|
||||
if (Dir.IsParallel(entryDir, tile.RiverFlowDir))
|
||||
return float.PositiveInfinity;
|
||||
if (Dir.IsPerpendicular(entryDir, tile.RiverFlowDir))
|
||||
return C.RAIL_BRIDGE_COST;
|
||||
}
|
||||
|
||||
// Base terrain cost (mountains expensive for rail)
|
||||
float elev = tile.Elevation;
|
||||
float terrainCost = 1f + elev * 4f;
|
||||
|
||||
// Biome modifiers
|
||||
terrainCost += tile.Biome switch
|
||||
{
|
||||
BiomeId.Wetland or BiomeId.MarshEdge => 5f,
|
||||
BiomeId.MountainAlpine => 8f,
|
||||
BiomeId.MountainForested => 4f,
|
||||
_ => 0f,
|
||||
};
|
||||
|
||||
return terrainCost;
|
||||
};
|
||||
}
|
||||
|
||||
// ── Polyline construction ─────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Emits one, two, or four polylines for a single sub-path depending on how
|
||||
/// many endpoints sit on pre-existing rail. A junction endpoint produces a
|
||||
/// wye: two polylines whose visible track extends in opposite tangent
|
||||
/// directions onto the main line, giving the familiar railway "Y" instead
|
||||
/// of a raw T. Non-junction endpoints contribute a single null tangent
|
||||
/// (one polyline) to the Cartesian product.
|
||||
/// </summary>
|
||||
private static void EmitRailSubPath(
|
||||
WorldState world,
|
||||
List<(int X, int Y)> seg,
|
||||
ref int polyId,
|
||||
int fromId, int toId)
|
||||
{
|
||||
if (seg.Count < 2) return;
|
||||
|
||||
bool startIsJunction =
|
||||
(world.Tiles[seg[0].X, seg[0].Y].Features & FeatureFlags.HasRail) != 0 &&
|
||||
(world.Tiles[seg[0].X, seg[0].Y].Features & FeatureFlags.IsSettlement) == 0;
|
||||
bool endIsJunction =
|
||||
(world.Tiles[seg[^1].X, seg[^1].Y].Features & FeatureFlags.HasRail) != 0 &&
|
||||
(world.Tiles[seg[^1].X, seg[^1].Y].Features & FeatureFlags.IsSettlement) == 0;
|
||||
|
||||
Vec2? startT = null, endT = null;
|
||||
if (startIsJunction)
|
||||
{
|
||||
var px = PolylineBuilder.TileToWorldPixel(seg[0].X, seg[0].Y);
|
||||
var t = GetExistingRailTangent(world, px, fromId, toId);
|
||||
if (t.LengthSquared > 0.1f) startT = t;
|
||||
}
|
||||
if (endIsJunction)
|
||||
{
|
||||
var px = PolylineBuilder.TileToWorldPixel(seg[^1].X, seg[^1].Y);
|
||||
var t = GetExistingRailTangent(world, px, fromId, toId);
|
||||
if (t.LengthSquared > 0.1f) endT = t;
|
||||
}
|
||||
|
||||
Vec2?[] startDirs = startT.HasValue
|
||||
? new Vec2?[] { startT.Value, new Vec2(-startT.Value.X, -startT.Value.Y) }
|
||||
: new Vec2?[] { null };
|
||||
Vec2?[] endDirs = endT.HasValue
|
||||
? new Vec2?[] { endT.Value, new Vec2(-endT.Value.X, -endT.Value.Y) }
|
||||
: new Vec2?[] { null };
|
||||
|
||||
foreach (var sd in startDirs)
|
||||
foreach (var ed in endDirs)
|
||||
{
|
||||
var poly = BuildRailPolyline(world, seg, polyId++, fromId, toId, sd, ed);
|
||||
world.Rails.Add(poly);
|
||||
PolylineBuilder.RasterizeToTileFlags(world, poly, setAdjacency: true);
|
||||
}
|
||||
}
|
||||
|
||||
private static Polyline BuildRailPolyline(
|
||||
WorldState world,
|
||||
List<(int X, int Y)> path,
|
||||
int id, int fromId, int toId,
|
||||
Vec2? startWyeDir,
|
||||
Vec2? endWyeDir)
|
||||
{
|
||||
var poly = new Polyline
|
||||
{
|
||||
Type = PolylineType.Rail,
|
||||
Id = id,
|
||||
Width = 1.5f,
|
||||
FromSettlementId = fromId,
|
||||
ToSettlementId = toId,
|
||||
};
|
||||
|
||||
var controlPts = path.Select(p => PolylineBuilder.TileToWorldPixel(p.X, p.Y)).ToList();
|
||||
var origStart = controlPts[0];
|
||||
var origEnd = controlPts[^1];
|
||||
|
||||
// Build the branch-body curve from the raw control points with no wye
|
||||
// ghosts inserted. This is the geometry shared between the two legs of
|
||||
// a wye. If the ghost control point were inserted here, its ±t
|
||||
// direction would propagate backward through Catmull-Rom's tangent
|
||||
// calculation into the segment beyond origStart (since legEnd would
|
||||
// become p0 for that segment), giving the +t and -t legs different
|
||||
// paths along the shared body — the "gore" artifact.
|
||||
var smoothed = PolylineBuilder.CatmullRomSmooth(controlPts);
|
||||
|
||||
// Wye-leg splicing. Each leg-end replaces the main curve's endpoint
|
||||
// with a short Catmull-Rom curve (legEnd → origStart) computed
|
||||
// separately. The main curve is preserved verbatim from origStart
|
||||
// onward, guaranteeing identical branch-body geometry across both
|
||||
// legs of a wye.
|
||||
Vec2 effectiveStart = origStart;
|
||||
Vec2 effectiveEnd = origEnd;
|
||||
|
||||
if (startWyeDir.HasValue && controlPts.Count >= 2)
|
||||
{
|
||||
var d = startWyeDir.Value;
|
||||
var legEnd = origStart + d * WYE_LEG_LENGTH_PX;
|
||||
var ghost = legEnd + d * WYE_LEG_GHOST_PX;
|
||||
// Segment 1 of [ghost, legEnd, origStart, controlPts[1]] is the
|
||||
// legEnd→origStart leg curve; its indices are
|
||||
// [SPLINE_SUBDIVISIONS .. 2*SPLINE_SUBDIVISIONS] inclusive
|
||||
// (legEnd, interior×(SUB−1), origStart).
|
||||
var legSmooth = PolylineBuilder.CatmullRomSmooth(
|
||||
new List<Vec2> { ghost, legEnd, origStart, controlPts[1] });
|
||||
var legSegment = legSmooth.GetRange(C.SPLINE_SUBDIVISIONS, C.SPLINE_SUBDIVISIONS + 1);
|
||||
smoothed.RemoveAt(0); // drop duplicate origStart
|
||||
smoothed.InsertRange(0, legSegment);
|
||||
effectiveStart = legEnd;
|
||||
}
|
||||
|
||||
if (endWyeDir.HasValue && controlPts.Count >= 2)
|
||||
{
|
||||
var d = endWyeDir.Value;
|
||||
var legEnd = origEnd + d * WYE_LEG_LENGTH_PX;
|
||||
var ghost = legEnd + d * WYE_LEG_GHOST_PX;
|
||||
// Mirror: segment 1 of [controlPts[^2], origEnd, legEnd, ghost]
|
||||
// is the origEnd→legEnd leg curve.
|
||||
var legSmooth = PolylineBuilder.CatmullRomSmooth(
|
||||
new List<Vec2> { controlPts[^2], origEnd, legEnd, ghost });
|
||||
var legSegment = legSmooth.GetRange(C.SPLINE_SUBDIVISIONS, C.SPLINE_SUBDIVISIONS + 1);
|
||||
smoothed.RemoveAt(smoothed.Count - 1); // drop duplicate origEnd
|
||||
smoothed.AddRange(legSegment);
|
||||
effectiveEnd = legEnd;
|
||||
}
|
||||
|
||||
// Rail has low meander (engineered). Applied after splicing so leg-end
|
||||
// pixels get noise perpendicular to the leg tangent rather than branch.
|
||||
ulong seed = world.WorldSeed ^ C.RNG_RAIL ^ (ulong)id;
|
||||
PolylineBuilder.ApplyMeanderNoise(smoothed, 1.5f, 0.04f, seed);
|
||||
|
||||
// Pin endpoints exactly so meander drift doesn't misalign with the
|
||||
// main rail (wye case) or the destination settlement (non-wye case).
|
||||
smoothed[0] = effectiveStart;
|
||||
smoothed[^1] = effectiveEnd;
|
||||
|
||||
poly.Points.AddRange(smoothed);
|
||||
poly.SimplifiedPoints = PolylineBuilder.RDPSimplify(poly.Points);
|
||||
return poly;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the unit tangent of the nearest pre-existing rail polyline at a
|
||||
/// junction world-pixel position. Rails sharing both (fromId, toId) with the
|
||||
/// sub-path being built are skipped so sibling sub-paths of the same trip
|
||||
/// (which also terminate at this junction) don't poison the lookup with
|
||||
/// their own perpendicular approach direction. Returns a zero vector when
|
||||
/// no existing rail is within 2 tiles — caller should then skip ghost
|
||||
/// insertion and fall back to raw endpoint smoothing.
|
||||
/// </summary>
|
||||
private static Vec2 GetExistingRailTangent(
|
||||
WorldState world,
|
||||
Vec2 junction,
|
||||
int skipFromId,
|
||||
int skipToId)
|
||||
{
|
||||
Polyline? foundRail = null;
|
||||
int bestIdx = 0;
|
||||
float bestDistSq = float.MaxValue;
|
||||
float maxRadiusSq = 4f * C.WORLD_TILE_PIXELS * C.WORLD_TILE_PIXELS; // 2 tiles
|
||||
|
||||
foreach (var rail in world.Rails)
|
||||
{
|
||||
if (rail.FromSettlementId == skipFromId && rail.ToSettlementId == skipToId) continue;
|
||||
var pts = rail.Points;
|
||||
for (int i = 0; i < pts.Count; i++)
|
||||
{
|
||||
float d = Vec2.DistSq(pts[i], junction);
|
||||
if (d < bestDistSq) { bestDistSq = d; foundRail = rail; bestIdx = i; }
|
||||
}
|
||||
}
|
||||
|
||||
if (foundRail == null || foundRail.Points.Count < 2) return new Vec2(0, 0);
|
||||
if (bestDistSq > maxRadiusSq) return new Vec2(0, 0);
|
||||
|
||||
var fp = foundRail.Points;
|
||||
Vec2 tangent;
|
||||
if (bestIdx == 0)
|
||||
tangent = fp[1] - fp[0];
|
||||
else if (bestIdx == fp.Count - 1)
|
||||
tangent = fp[^1] - fp[^2];
|
||||
else
|
||||
tangent = fp[bestIdx + 1] - fp[bestIdx - 1];
|
||||
|
||||
return tangent.Normalized;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
using Theriapolis.Core.Util;
|
||||
using Theriapolis.Core.World.Polylines;
|
||||
|
||||
namespace Theriapolis.Core.World.Generation.Stages;
|
||||
|
||||
/// <summary>
|
||||
/// Stage 11 — RiverMeanderGen
|
||||
/// Adds oxbow lakes and width variation to rivers generated by HydrologyGenStage.
|
||||
/// </summary>
|
||||
public sealed class RiverMeanderGenStage : IWorldGenStage
|
||||
{
|
||||
public string Name => "RiverMeanderGen";
|
||||
|
||||
private const int W = C.WORLD_WIDTH_TILES;
|
||||
private const int H = C.WORLD_HEIGHT_TILES;
|
||||
|
||||
public void Run(WorldGenContext ctx)
|
||||
{
|
||||
var world = ctx.World;
|
||||
var rng = SeededRng.ForSubsystem(world.WorldSeed, C.RNG_MEANDER);
|
||||
|
||||
int oxbows = 0;
|
||||
foreach (var poly in world.Rivers)
|
||||
{
|
||||
// Width variation: wider in wetlands, narrower in mountains
|
||||
AdjustWidth(world, poly);
|
||||
|
||||
// Oxbow generation for major rivers crossing flat terrain
|
||||
if (poly.RiverClassification >= RiverClass.River)
|
||||
oxbows += TryGenerateOxbows(world, poly, rng);
|
||||
}
|
||||
|
||||
// Re-derive tile flags (widths may have changed)
|
||||
// No re-rasterize needed here — oxbows are just visual edits
|
||||
|
||||
ctx.LogMessage($"[RiverMeanderGen] Generated {oxbows} oxbow lakes.");
|
||||
ctx.World.StageHashes["RiverMeanderGen"] = world.HashPolylines();
|
||||
}
|
||||
|
||||
private static void AdjustWidth(WorldState world, Polyline poly)
|
||||
{
|
||||
if (poly.Points.Count < 2) return;
|
||||
|
||||
// Sample biome at midpoint of polyline
|
||||
var mid = poly.Points[poly.Points.Count / 2];
|
||||
int tx = (int)(mid.X / C.WORLD_TILE_PIXELS);
|
||||
int ty = (int)(mid.Y / C.WORLD_TILE_PIXELS);
|
||||
tx = Math.Clamp(tx, 0, W - 1);
|
||||
ty = Math.Clamp(ty, 0, H - 1);
|
||||
|
||||
var biome = world.Tiles[tx, ty].Biome;
|
||||
if (biome is BiomeId.Wetland or BiomeId.MarshEdge)
|
||||
poly.Width *= 1.5f;
|
||||
else if (biome is BiomeId.MountainAlpine or BiomeId.MountainForested)
|
||||
poly.Width *= 0.7f;
|
||||
|
||||
poly.Width = Math.Clamp(poly.Width, 0.5f, 5f);
|
||||
}
|
||||
|
||||
private static int TryGenerateOxbows(WorldState world, Polyline poly, SeededRng rng)
|
||||
{
|
||||
if (poly.Points.Count < 20) return 0;
|
||||
|
||||
int created = 0;
|
||||
float oxbowProb = 0.04f;
|
||||
|
||||
// Scan for meander loops: points that come close to each other.
|
||||
// Start inner j at i+20 (≈5 tiles) to skip zigzag artifacts from grid-aligned flow.
|
||||
for (int i = 5; i < poly.Points.Count - 10; i += 10)
|
||||
{
|
||||
for (int j = i + 20; j < Math.Min(i + 50, poly.Points.Count); j++)
|
||||
{
|
||||
float dsq = Vec2.DistSq(poly.Points[i], poly.Points[j]);
|
||||
if (dsq > 9f * C.WORLD_TILE_PIXELS * C.WORLD_TILE_PIXELS) continue;
|
||||
|
||||
// Check average elevation in the loop area — must be flat
|
||||
int midTx = (int)(poly.Points[(i + j) / 2].X / C.WORLD_TILE_PIXELS);
|
||||
int midTy = (int)(poly.Points[(i + j) / 2].Y / C.WORLD_TILE_PIXELS);
|
||||
midTx = Math.Clamp(midTx, 0, W - 1);
|
||||
midTy = Math.Clamp(midTy, 0, H - 1);
|
||||
|
||||
if (world.Tiles[midTx, midTy].Elevation > 0.45f) break;
|
||||
if (!rng.NextBool(oxbowProb)) break;
|
||||
|
||||
// Mark a few tiles in the loop area as water (oxbow lake)
|
||||
for (int k = i; k <= j; k++)
|
||||
{
|
||||
int ox = (int)(poly.Points[k].X / C.WORLD_TILE_PIXELS);
|
||||
int oy = (int)(poly.Points[k].Y / C.WORLD_TILE_PIXELS);
|
||||
if ((uint)ox < W && (uint)oy < H && world.Tiles[ox, oy].Biome != BiomeId.Ocean)
|
||||
{
|
||||
world.TileAt(ox, oy).Features |= FeatureFlags.HasRiver;
|
||||
}
|
||||
}
|
||||
created++;
|
||||
break; // only one oxbow per pass
|
||||
}
|
||||
}
|
||||
|
||||
return created;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,733 @@
|
||||
using Theriapolis.Core.Util;
|
||||
using Theriapolis.Core.World.Polylines;
|
||||
|
||||
namespace Theriapolis.Core.World.Generation.Stages;
|
||||
|
||||
/// <summary>
|
||||
/// Stage 17 — RoadNetworkGen
|
||||
/// Minimum Spanning Tree of Tier 1–3 settlements + 30% shortcuts, routed via A* with
|
||||
/// full Addendum A §2 exclusion (rivers and rail both excluded).
|
||||
/// Tier 4 settlements get a footpath/dirt road to the nearest Tier 3+ neighbor.
|
||||
/// </summary>
|
||||
public sealed class RoadNetworkGenStage : IWorldGenStage
|
||||
{
|
||||
public string Name => "RoadNetworkGen";
|
||||
|
||||
private const int W = C.WORLD_WIDTH_TILES;
|
||||
private const int H = C.WORLD_HEIGHT_TILES;
|
||||
|
||||
// Tiles SmoothStaircases must not reroute through or off of: existing
|
||||
// infrastructure, river crossings (bridge tiles), and settlement footprints.
|
||||
private const FeatureFlags PathPreserveMask =
|
||||
FeatureFlags.HasRoad | FeatureFlags.HasRiver | FeatureFlags.HasRail | FeatureFlags.IsSettlement;
|
||||
|
||||
public void Run(WorldGenContext ctx)
|
||||
{
|
||||
var world = ctx.World;
|
||||
var rng = ctx.Rngs["road"];
|
||||
|
||||
var nodes = world.Settlements
|
||||
.Where(s => s.Tier <= 3 && !s.IsPoi)
|
||||
.ToList();
|
||||
|
||||
if (nodes.Count < 2)
|
||||
{
|
||||
ctx.LogMessage("[RoadNetworkGen] Not enough settlements for road network.");
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Build MST edges via Kruskal (Euclidean edge weights) ─────────────
|
||||
var mstEdges = KruskalMST(nodes);
|
||||
|
||||
// ── Add shortcut edges ────────────────────────────────────────────────
|
||||
int shortcutCount = (int)Math.Ceiling(mstEdges.Count * C.ROAD_SHORTCUT_FRACTION);
|
||||
var allEdges = AllEdges(nodes);
|
||||
allEdges = allEdges
|
||||
.Where(e => !mstEdges.Any(m => (m.a == e.a && m.b == e.b) || (m.a == e.b && m.b == e.a)))
|
||||
.OrderBy(e => e.weight)
|
||||
.Take(shortcutCount)
|
||||
.ToList();
|
||||
|
||||
var routeEdges = mstEdges.Concat(allEdges).ToList();
|
||||
|
||||
// ── Route each edge with A* ───────────────────────────────────────────
|
||||
var pathfinder = new AStarPathfinder();
|
||||
int polyId = 0;
|
||||
|
||||
foreach (var edge in routeEdges)
|
||||
{
|
||||
var a = nodes[edge.a];
|
||||
var b = nodes[edge.b];
|
||||
|
||||
var costFn = RoadCostFn(world, a.TileX, a.TileY, b.TileX, b.TileY);
|
||||
var path = pathfinder.FindPath(a.TileX, a.TileY, b.TileX, b.TileY, costFn);
|
||||
|
||||
if (path == null || path.Count < 2) continue;
|
||||
|
||||
PolylineBuilder.SmoothStaircases(world, path, PathPreserveMask, costFn);
|
||||
|
||||
var segments = PolylineBuilder.SplitByExistingFeature(world, path, FeatureFlags.HasRoad);
|
||||
|
||||
// Ensure the first segment reaches the 'from' settlement and
|
||||
// the last segment reaches the 'to' settlement.
|
||||
EnsureEndpointSegment(world, segments, path, 0, a.TileX, a.TileY);
|
||||
EnsureEndpointSegment(world, segments, path, path.Count - 1, b.TileX, b.TileY);
|
||||
|
||||
foreach (var seg in segments)
|
||||
{
|
||||
var poly = BuildRoadPolyline(world, seg, polyId++, a, b);
|
||||
world.Roads.Add(poly);
|
||||
PolylineBuilder.RasterizeToTileFlags(world, poly, setAdjacency: false);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tier 4 connections ────────────────────────────────────────────────
|
||||
// Footpaths split by existing road so they don't parallel Tier 1-3 roads.
|
||||
// The village end gets a stub; the network end just joins at the junction.
|
||||
var tier4 = world.Settlements.Where(s => s.Tier == 4 && !s.IsPoi).ToList();
|
||||
foreach (var s4 in tier4)
|
||||
{
|
||||
var nearest = nodes
|
||||
.OrderBy(n => Dist(s4.TileX, s4.TileY, n.TileX, n.TileY))
|
||||
.FirstOrDefault();
|
||||
if (nearest == null) continue;
|
||||
|
||||
var costFn = RoadCostFnRelaxed(world, s4.TileX, s4.TileY, nearest.TileX, nearest.TileY);
|
||||
var path = pathfinder.FindPath(s4.TileX, s4.TileY, nearest.TileX, nearest.TileY, costFn);
|
||||
|
||||
if (path == null || path.Count < 2) continue;
|
||||
|
||||
PolylineBuilder.SmoothStaircases(world, path, PathPreserveMask, costFn);
|
||||
|
||||
var segments = PolylineBuilder.SplitByExistingFeature(world, path, FeatureFlags.HasRoad);
|
||||
EnsureEndpointSegment(world, segments, path, 0, s4.TileX, s4.TileY);
|
||||
|
||||
foreach (var seg in segments)
|
||||
{
|
||||
var poly = BuildRoadPolyline(world, seg, polyId++, s4, nearest, forceTier: 4);
|
||||
world.Roads.Add(poly);
|
||||
PolylineBuilder.RasterizeToTileFlags(world, poly, setAdjacency: false);
|
||||
}
|
||||
}
|
||||
|
||||
// Bridge detection is deferred to PolylineCleanupStage so that bridges
|
||||
// align with roads after endpoint snapping and interior merging.
|
||||
|
||||
world.StageHashes["RoadNetworkGen"] = world.HashPolylines();
|
||||
ctx.LogMessage($"[RoadNetworkGen] Generated {world.Roads.Count} roads.");
|
||||
}
|
||||
|
||||
// ── Cost functions ────────────────────────────────────────────────────────
|
||||
|
||||
private static Func<int, int, int, int, byte, float> RoadCostFn(WorldState world, int sx, int sy, int ex, int ey)
|
||||
{
|
||||
return (fx, fy, tx, ty, entryDir) =>
|
||||
{
|
||||
if ((uint)tx >= W || (uint)ty >= H) return float.PositiveInfinity;
|
||||
ref var tile = ref world.TileAt(tx, ty);
|
||||
|
||||
// Ocean tiles are impassable — no roads over water
|
||||
if (tile.Biome == BiomeId.Ocean) return float.PositiveInfinity;
|
||||
|
||||
// Strongly prefer joining an existing road over building a parallel one,
|
||||
// but NOT within the settlement halo — each route must find its own path
|
||||
// near endpoints to prevent multiple roads fanning through shared tiles.
|
||||
if ((tile.Features & FeatureFlags.HasRoad) != 0)
|
||||
{
|
||||
int dStart = Math.Max(Math.Abs(tx - sx), Math.Abs(ty - sy));
|
||||
int dEnd = Math.Max(Math.Abs(tx - ex), Math.Abs(ty - ey));
|
||||
if (dStart > C.SETTLEMENT_HALO_RADIUS && dEnd > C.SETTLEMENT_HALO_RADIUS)
|
||||
return C.EXISTING_ROAD_COST;
|
||||
// Within halo: fall through to normal terrain cost
|
||||
}
|
||||
|
||||
// Settlement tiles are always reachable (they are road endpoints).
|
||||
// Extend Addendum A §2 to the settlement footprint: disallow parallel
|
||||
// travel on a rail tile EXCEPT at the actual start/end tile. Without
|
||||
// this, road and rail both exit a tier-1+ settlement through the
|
||||
// same rail-bearing footprint tiles for several steps, producing
|
||||
// visually overlapping smoothed polylines.
|
||||
if ((tile.Features & FeatureFlags.IsSettlement) != 0)
|
||||
{
|
||||
float settleCost = 1f + tile.Elevation * 2f;
|
||||
bool isEndpointTile = (tx == sx && ty == sy) || (tx == ex && ty == ey);
|
||||
if (!isEndpointTile
|
||||
&& (tile.Features & FeatureFlags.HasRail) != 0
|
||||
&& tile.RailDir != Dir.None)
|
||||
{
|
||||
if (Dir.IsParallel(entryDir, tile.RailDir))
|
||||
return float.PositiveInfinity;
|
||||
if (Dir.IsPerpendicular(entryDir, tile.RailDir))
|
||||
settleCost += C.CROSSING_COST;
|
||||
}
|
||||
return settleCost;
|
||||
}
|
||||
|
||||
// River tiles: perpendicular crossing allowed at bridge cost; parallel travel blocked
|
||||
if ((tile.Features & FeatureFlags.HasRiver) != 0)
|
||||
{
|
||||
if (tile.RiverFlowDir != Dir.None && Dir.IsParallel(entryDir, tile.RiverFlowDir))
|
||||
return float.PositiveInfinity;
|
||||
return C.BRIDGE_COST + 1f + tile.Elevation * 2f;
|
||||
}
|
||||
|
||||
// Never on a rail tile
|
||||
if ((tile.Features & FeatureFlags.HasRail) != 0) return float.PositiveInfinity;
|
||||
|
||||
float cost = 0f;
|
||||
|
||||
// River-adjacent exclusion
|
||||
if ((tile.Features & FeatureFlags.RiverAdjacent) != 0 && tile.RiverFlowDir != Dir.None)
|
||||
{
|
||||
if (Dir.IsParallel(entryDir, tile.RiverFlowDir)) return float.PositiveInfinity;
|
||||
if (Dir.IsPerpendicular(entryDir, tile.RiverFlowDir)) cost += C.BRIDGE_COST;
|
||||
}
|
||||
|
||||
// Rail-adjacent exclusion
|
||||
if ((tile.Features & FeatureFlags.RailroadAdjacent) != 0 && tile.RailDir != Dir.None)
|
||||
{
|
||||
if (Dir.IsParallel(entryDir, tile.RailDir)) return float.PositiveInfinity;
|
||||
if (Dir.IsPerpendicular(entryDir, tile.RailDir)) cost += C.CROSSING_COST;
|
||||
}
|
||||
|
||||
// Setback cost — pushes road away from rivers/rail without prohibiting valleys
|
||||
int distToFeature = NearestFeatureDist(world, tx, ty);
|
||||
if (distToFeature > 0 && distToFeature <= C.SETBACK_DISTANCE)
|
||||
cost += C.SETBACK_COST_SCALE / distToFeature;
|
||||
|
||||
// Base terrain cost
|
||||
float elev = tile.Elevation;
|
||||
cost += 1f + elev * 2f;
|
||||
cost += tile.Biome switch
|
||||
{
|
||||
BiomeId.Wetland or BiomeId.MarshEdge => 3f,
|
||||
BiomeId.MountainAlpine => 6f,
|
||||
BiomeId.MountainForested => 3f,
|
||||
_ => 0f,
|
||||
};
|
||||
|
||||
return cost;
|
||||
};
|
||||
}
|
||||
|
||||
internal static Func<int, int, int, int, byte, float> RoadCostFnRelaxed(WorldState world, int sx, int sy, int ex, int ey)
|
||||
{
|
||||
// Tier 4 footpaths — relaxed version (allow river crossing with bridge cost, still block rail)
|
||||
return (fx, fy, tx, ty, entryDir) =>
|
||||
{
|
||||
if ((uint)tx >= W || (uint)ty >= H) return float.PositiveInfinity;
|
||||
ref var tile = ref world.TileAt(tx, ty);
|
||||
if (tile.Biome == BiomeId.Ocean) return float.PositiveInfinity;
|
||||
|
||||
// Strongly prefer joining an existing road, but not within settlement halo
|
||||
if ((tile.Features & FeatureFlags.HasRoad) != 0)
|
||||
{
|
||||
int dStart = Math.Max(Math.Abs(tx - sx), Math.Abs(ty - sy));
|
||||
int dEnd = Math.Max(Math.Abs(tx - ex), Math.Abs(ty - ey));
|
||||
if (dStart > C.SETTLEMENT_HALO_RADIUS && dEnd > C.SETTLEMENT_HALO_RADIUS)
|
||||
return C.EXISTING_ROAD_COST;
|
||||
}
|
||||
|
||||
// Settlement tiles are always reachable (they are road endpoints),
|
||||
// even when a rail passes through them. Extend Addendum A §2 to the
|
||||
// settlement footprint: parallel travel on a rail tile is blocked
|
||||
// except at the actual start/end tile.
|
||||
if ((tile.Features & FeatureFlags.IsSettlement) != 0)
|
||||
{
|
||||
float settleCost = 1f + tile.Elevation * 2f;
|
||||
bool isEndpointTile = (tx == sx && ty == sy) || (tx == ex && ty == ey);
|
||||
if (!isEndpointTile
|
||||
&& (tile.Features & FeatureFlags.HasRail) != 0
|
||||
&& tile.RailDir != Dir.None)
|
||||
{
|
||||
if (Dir.IsParallel(entryDir, tile.RailDir))
|
||||
return float.PositiveInfinity;
|
||||
if (Dir.IsPerpendicular(entryDir, tile.RailDir))
|
||||
settleCost += C.CROSSING_COST;
|
||||
}
|
||||
return settleCost;
|
||||
}
|
||||
|
||||
if ((tile.Features & FeatureFlags.HasRiver) != 0)
|
||||
return C.BRIDGE_COST * 0.5f + 1f + tile.Elevation * 2f;
|
||||
if ((tile.Features & FeatureFlags.HasRail) != 0) return float.PositiveInfinity;
|
||||
|
||||
float cost = 1f + tile.Elevation * 2f;
|
||||
if ((tile.Features & FeatureFlags.RiverAdjacent) != 0) cost += C.BRIDGE_COST * 0.5f;
|
||||
return cost;
|
||||
};
|
||||
}
|
||||
|
||||
private static int NearestFeatureDist(WorldState world, int x, int y)
|
||||
{
|
||||
for (int r = 1; r <= C.SETBACK_DISTANCE; r++)
|
||||
{
|
||||
for (int dy = -r; dy <= r; dy++)
|
||||
for (int dx = -r; dx <= r; dx++)
|
||||
{
|
||||
if (Math.Abs(dx) != r && Math.Abs(dy) != r) continue;
|
||||
int nx = x + dx, ny = y + dy;
|
||||
if ((uint)nx >= W || (uint)ny >= H) continue;
|
||||
var f = world.Tiles[nx, ny].Features;
|
||||
if ((f & (FeatureFlags.HasRiver | FeatureFlags.HasRail)) != 0) return r;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
// ── MST via Kruskal ───────────────────────────────────────────────────────
|
||||
|
||||
private record Edge(int a, int b, float weight);
|
||||
|
||||
private static List<Edge> KruskalMST(List<Settlement> nodes)
|
||||
{
|
||||
var edges = AllEdges(nodes);
|
||||
edges.Sort((a, b) => a.weight.CompareTo(b.weight));
|
||||
|
||||
int n = nodes.Count;
|
||||
var parent = Enumerable.Range(0, n).ToArray();
|
||||
int Find(int x) => parent[x] == x ? x : (parent[x] = Find(parent[x]));
|
||||
bool Union(int a, int b) { a = Find(a); b = Find(b); if (a == b) return false; parent[a] = b; return true; }
|
||||
|
||||
var mst = new List<Edge>();
|
||||
foreach (var e in edges)
|
||||
{
|
||||
if (Union(e.a, e.b))
|
||||
mst.Add(e);
|
||||
if (mst.Count == n - 1) break;
|
||||
}
|
||||
return mst;
|
||||
}
|
||||
|
||||
private static List<Edge> AllEdges(List<Settlement> nodes)
|
||||
{
|
||||
var edges = new List<Edge>();
|
||||
for (int i = 0; i < nodes.Count; i++)
|
||||
for (int j = i + 1; j < nodes.Count; j++)
|
||||
{
|
||||
float w = Dist(nodes[i].TileX, nodes[i].TileY, nodes[j].TileX, nodes[j].TileY);
|
||||
edges.Add(new Edge(i, j, w));
|
||||
}
|
||||
return edges;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// If no segment starts/ends at the settlement tile, add a short stub segment
|
||||
/// from the settlement to the nearest existing road tile so the road visually
|
||||
/// reaches the settlement icon.
|
||||
/// </summary>
|
||||
private static void EnsureEndpointSegment(
|
||||
WorldState world,
|
||||
List<List<(int X, int Y)>> segments,
|
||||
List<(int X, int Y)> fullPath,
|
||||
int pathIndex,
|
||||
int settleX, int settleY)
|
||||
{
|
||||
var target = fullPath[pathIndex];
|
||||
if (target.X != settleX || target.Y != settleY) return;
|
||||
|
||||
// Check if any segment already includes this tile
|
||||
foreach (var seg in segments)
|
||||
{
|
||||
if (seg[0].X == settleX && seg[0].Y == settleY) return;
|
||||
if (seg[^1].X == settleX && seg[^1].Y == settleY) return;
|
||||
}
|
||||
|
||||
// If the entire path runs along existing roads, a stub would parallel them.
|
||||
if (segments.Count == 0) return;
|
||||
|
||||
// If the adjacent segment's junction-anchor tile already has a road, the
|
||||
// new path attaches to the existing network at that junction and
|
||||
// transitively reaches the settlement through it — adding a stub here
|
||||
// would just duplicate the existing road's first/last tiles.
|
||||
var junction = pathIndex == 0 ? segments[0][0] : segments[^1][^1];
|
||||
if ((world.Tiles[junction.X, junction.Y].Features & FeatureFlags.HasRoad) != 0)
|
||||
return;
|
||||
|
||||
// Build a short stub from the settlement to the next few tiles
|
||||
var stub = new List<(int X, int Y)>();
|
||||
if (pathIndex == 0)
|
||||
{
|
||||
// Grab the first few tiles of the path as a stub
|
||||
int end = Math.Min(fullPath.Count, 4);
|
||||
for (int i = 0; i < end; i++) stub.Add(fullPath[i]);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Grab the last few tiles of the path as a stub
|
||||
int start = Math.Max(0, fullPath.Count - 4);
|
||||
for (int i = start; i < fullPath.Count; i++) stub.Add(fullPath[i]);
|
||||
}
|
||||
|
||||
if (stub.Count >= 2) segments.Add(stub);
|
||||
}
|
||||
|
||||
private static float Dist(int x1, int y1, int x2, int y2)
|
||||
=> MathF.Sqrt((x1 - x2) * (float)(x1 - x2) + (y1 - y2) * (float)(y1 - y2));
|
||||
|
||||
// ── Bridge detection ─────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Detect bridges by finding actual geometric intersections between road and
|
||||
/// river polylines in world-pixel space. Uses a tile-based spatial index on
|
||||
/// river segments for efficiency. Bridges are deduplicated per tile across
|
||||
/// all roads so that shared crossings produce a single bridge.
|
||||
/// </summary>
|
||||
internal static void DetectBridges(WorldState world)
|
||||
{
|
||||
int px = C.WORLD_TILE_PIXELS;
|
||||
|
||||
// Build spatial index: tile key → river segment references
|
||||
var index = new Dictionary<long, List<(int ri, int si)>>();
|
||||
for (int ri = 0; ri < world.Rivers.Count; ri++)
|
||||
{
|
||||
var rpts = world.Rivers[ri].Points;
|
||||
for (int si = 0; si < rpts.Count - 1; si++)
|
||||
{
|
||||
int x0 = (int)(rpts[si].X / px), y0 = (int)(rpts[si].Y / px);
|
||||
int x1 = (int)(rpts[si + 1].X / px), y1 = (int)(rpts[si + 1].Y / px);
|
||||
AddToIndex(index, x0, y0, ri, si);
|
||||
if (x1 != x0 || y1 != y0) AddToIndex(index, x1, y1, ri, si);
|
||||
}
|
||||
}
|
||||
|
||||
// Global dedup: one bridge per tile across all roads, so roads sharing a
|
||||
// crossing don't stack duplicate bridge sprites.
|
||||
var bridgeTiles = new HashSet<long>();
|
||||
|
||||
foreach (var road in world.Roads)
|
||||
{
|
||||
var pts = road.Points;
|
||||
// A polyline needs at least one segment to intersect a river.
|
||||
// Short connector stubs created by EnsureSettlementConnectivity
|
||||
// can still cross rivers and legitimately need bridges.
|
||||
if (pts.Count < 2) continue;
|
||||
|
||||
// Consider every segment. Global tile dedup (bridgeTiles) already
|
||||
// prevents duplicate bridges where two roads share a junction
|
||||
// crossing. Excluding segments near endpoints would miss legitimate
|
||||
// crossings on roads that start from a settlement and cross a river
|
||||
// within their first few points.
|
||||
//
|
||||
// Scan every tile in the segment's bounding box (plus a 1-tile
|
||||
// margin), not just the 3×3 neighborhood of pts[i]. A road segment
|
||||
// spanning several tiles — e.g. after PolylineCleanup merges/trims
|
||||
// points — can cross a river polyline in a tile that neither
|
||||
// endpoint's 3×3 neighborhood covers.
|
||||
for (int i = 0; i < pts.Count - 1; i++)
|
||||
{
|
||||
int tx0 = (int)(pts[i].X / px), ty0 = (int)(pts[i].Y / px);
|
||||
int tx1 = (int)(pts[i + 1].X / px), ty1 = (int)(pts[i + 1].Y / px);
|
||||
int minTx = Math.Min(tx0, tx1) - 1;
|
||||
int maxTx = Math.Max(tx0, tx1) + 1;
|
||||
int minTy = Math.Min(ty0, ty1) - 1;
|
||||
int maxTy = Math.Max(ty0, ty1) + 1;
|
||||
|
||||
for (int ty = minTy; ty <= maxTy; ty++)
|
||||
for (int tx = minTx; tx <= maxTx; tx++)
|
||||
{
|
||||
long key = TileKey(tx, ty);
|
||||
if (!index.TryGetValue(key, out var segs)) continue;
|
||||
|
||||
foreach (var (ri, si) in segs)
|
||||
{
|
||||
var rpts = world.Rivers[ri].Points;
|
||||
if (!SegmentsIntersect(pts[i], pts[i + 1], rpts[si], rpts[si + 1], out var crossPt, out var tOnRoad))
|
||||
continue;
|
||||
|
||||
// Deduplicate: one bridge per tile
|
||||
int btx = (int)(crossPt.X / px), bty = (int)(crossPt.Y / px);
|
||||
if (!bridgeTiles.Add(TileKey(btx, bty))) continue;
|
||||
|
||||
// Walk the actual road polyline in each direction from the
|
||||
// crossing so the deck endpoints sit ON the road instead of
|
||||
// overshooting where meander has bent the path.
|
||||
Vec2 start = WalkAlongPolyline(pts, i, tOnRoad, C.BRIDGE_DECK_HALF_LENGTH, forward: false);
|
||||
Vec2 end = WalkAlongPolyline(pts, i, tOnRoad, C.BRIDGE_DECK_HALF_LENGTH, forward: true);
|
||||
world.Bridges.Add(new Bridge(start, end, road.Id));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static long TileKey(int tx, int ty) => ((long)ty << 32) | (uint)tx;
|
||||
|
||||
private static void AddToIndex(Dictionary<long, List<(int ri, int si)>> index, int tx, int ty, int ri, int si)
|
||||
{
|
||||
long key = TileKey(tx, ty);
|
||||
if (!index.TryGetValue(key, out var list))
|
||||
index[key] = list = new List<(int, int)>();
|
||||
list.Add((ri, si));
|
||||
}
|
||||
|
||||
private static bool SegmentsIntersect(Vec2 p1, Vec2 p2, Vec2 p3, Vec2 p4, out Vec2 point, out float tOnFirst)
|
||||
{
|
||||
Vec2 d1 = p2 - p1;
|
||||
Vec2 d2 = p4 - p3;
|
||||
float cross = d1.X * d2.Y - d1.Y * d2.X;
|
||||
if (MathF.Abs(cross) < 1e-6f) { point = default; tOnFirst = 0f; return false; }
|
||||
|
||||
Vec2 d3 = p3 - p1;
|
||||
float t = (d3.X * d2.Y - d3.Y * d2.X) / cross;
|
||||
float u = (d3.X * d1.Y - d3.Y * d1.X) / cross;
|
||||
|
||||
if (t >= 0f && t <= 1f && u >= 0f && u <= 1f)
|
||||
{
|
||||
point = p1 + d1 * t;
|
||||
tOnFirst = t;
|
||||
return true;
|
||||
}
|
||||
point = default;
|
||||
tOnFirst = 0f;
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Walk along a polyline starting from a point inside segment <paramref name="segIdx"/>
|
||||
/// at parameter <paramref name="segT"/>, travelling <paramref name="distance"/> world
|
||||
/// pixels along the actual polyline geometry. Returns the reached point, clamped to
|
||||
/// the polyline's end if distance exceeds the remaining length.
|
||||
/// </summary>
|
||||
private static Vec2 WalkAlongPolyline(List<Vec2> pts, int segIdx, float segT, float distance, bool forward)
|
||||
{
|
||||
Vec2 current = pts[segIdx] + (pts[segIdx + 1] - pts[segIdx]) * segT;
|
||||
float remaining = distance;
|
||||
|
||||
if (forward)
|
||||
{
|
||||
int i = segIdx + 1;
|
||||
while (remaining > 0f && i < pts.Count)
|
||||
{
|
||||
Vec2 next = pts[i];
|
||||
float segLen = Vec2.Dist(current, next);
|
||||
if (segLen >= remaining)
|
||||
return current + (next - current).Normalized * remaining;
|
||||
remaining -= segLen;
|
||||
current = next;
|
||||
i++;
|
||||
}
|
||||
return current;
|
||||
}
|
||||
else
|
||||
{
|
||||
int i = segIdx;
|
||||
while (remaining > 0f && i >= 0)
|
||||
{
|
||||
Vec2 prev = pts[i];
|
||||
float segLen = Vec2.Dist(current, prev);
|
||||
if (segLen >= remaining)
|
||||
return current + (prev - current).Normalized * remaining;
|
||||
remaining -= segLen;
|
||||
current = prev;
|
||||
i--;
|
||||
}
|
||||
return current;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Polyline construction ─────────────────────────────────────────────────
|
||||
|
||||
private static Polyline BuildRoadPolyline(
|
||||
WorldState world,
|
||||
List<(int X, int Y)> path,
|
||||
int id,
|
||||
Settlement from, Settlement to,
|
||||
int forceTier = 0)
|
||||
{
|
||||
int effectiveTier = forceTier > 0 ? forceTier : Math.Max(from.Tier, to.Tier);
|
||||
return BuildRoadPolyline(world, path, id, from.Id, to.Id, effectiveTier);
|
||||
}
|
||||
|
||||
internal static Polyline BuildRoadPolyline(
|
||||
WorldState world,
|
||||
List<(int X, int Y)> path,
|
||||
int id,
|
||||
int fromSettlementId,
|
||||
int toSettlementId,
|
||||
int tier)
|
||||
{
|
||||
RoadType roadType = tier switch
|
||||
{
|
||||
1 => RoadType.Highway,
|
||||
2 => RoadType.PostRoad,
|
||||
3 => RoadType.DirtRoad,
|
||||
4 => RoadType.Footpath,
|
||||
_ => RoadType.DirtRoad,
|
||||
};
|
||||
|
||||
float width = roadType switch
|
||||
{
|
||||
RoadType.Highway => 2f,
|
||||
RoadType.PostRoad => 1.5f,
|
||||
RoadType.DirtRoad => 1f,
|
||||
RoadType.Footpath => 0.5f,
|
||||
_ => 1f,
|
||||
};
|
||||
|
||||
var poly = new Polyline
|
||||
{
|
||||
Type = PolylineType.Road,
|
||||
Id = id,
|
||||
Width = width,
|
||||
RoadClassification = roadType,
|
||||
FromSettlementId = fromSettlementId,
|
||||
ToSettlementId = toSettlementId,
|
||||
};
|
||||
|
||||
var controlPts = path.Select(p => PolylineBuilder.TileToWorldPixel(p.X, p.Y)).ToList();
|
||||
var startPt = controlPts[0];
|
||||
var endPt = controlPts[^1];
|
||||
var smoothed = PolylineBuilder.CatmullRomSmooth(controlPts);
|
||||
|
||||
// Roads get moderate meander
|
||||
ulong seed = world.WorldSeed ^ C.RNG_ROAD ^ (ulong)id;
|
||||
PolylineBuilder.ApplyMeanderNoise(smoothed, 2.5f, 0.06f, seed);
|
||||
|
||||
// Smoothing + meander can drift road points onto river tiles.
|
||||
// Push them off unless they are a genuine perpendicular crossing (bridge).
|
||||
ConstrainAwayFromRivers(world, smoothed);
|
||||
|
||||
// Pin endpoints so roads always reach their targets
|
||||
smoothed[0] = startPt;
|
||||
smoothed[^1] = endPt;
|
||||
|
||||
poly.Points.AddRange(smoothed);
|
||||
poly.SimplifiedPoints = PolylineBuilder.RDPSimplify(poly.Points);
|
||||
return poly;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Push road polyline points off HasRiver tiles when the road is running
|
||||
/// parallel to the river. Genuine river crossings — runs whose surrounding
|
||||
/// road segments geometrically intersect a river polyline — are left alone
|
||||
/// so the bridge-detection pass can place a bridge there.
|
||||
///
|
||||
/// Runs of river-tile points that DON'T cross any river polyline indicate
|
||||
/// the road is travelling alongside a river (not across it). We handle
|
||||
/// those as a unit: pick one perpendicular side clear of river tiles, then
|
||||
/// shift every point in the run together. Per-point independent pushing
|
||||
/// (an earlier approach) produced zigzags when adjacent points chose
|
||||
/// opposite sides.
|
||||
/// </summary>
|
||||
private static void ConstrainAwayFromRivers(WorldState world, List<Vec2> points)
|
||||
{
|
||||
int px = C.WORLD_TILE_PIXELS;
|
||||
int n = points.Count;
|
||||
|
||||
var onRiver = new bool[n];
|
||||
for (int i = 0; i < n; i++)
|
||||
{
|
||||
int tx = (int)(points[i].X / px);
|
||||
int ty = (int)(points[i].Y / px);
|
||||
if ((uint)tx >= W || (uint)ty >= H) continue;
|
||||
onRiver[i] = (world.TileAt(tx, ty).Features & FeatureFlags.HasRiver) != 0;
|
||||
}
|
||||
|
||||
int k = 1;
|
||||
while (k < n - 1)
|
||||
{
|
||||
if (!onRiver[k]) { k++; continue; }
|
||||
|
||||
int runStart = k;
|
||||
int runEnd = k;
|
||||
while (runEnd + 1 < n - 1 && onRiver[runEnd + 1]) runEnd++;
|
||||
|
||||
// Geometric crossing check: if any road segment bordering or inside
|
||||
// this run intersects a river polyline, it's a genuine crossing.
|
||||
// Leave it alone so bridge detection can place a bridge.
|
||||
if (RunCrossesRiver(world, points, runStart - 1, runEnd + 1))
|
||||
{
|
||||
k = runEnd + 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
Vec2 anchorPrev = points[runStart - 1];
|
||||
Vec2 anchorNext = points[Math.Min(runEnd + 1, n - 1)];
|
||||
Vec2 tangent = anchorNext - anchorPrev;
|
||||
if (tangent.LengthSquared < 1e-6f) { k = runEnd + 1; continue; }
|
||||
tangent = tangent.Normalized;
|
||||
Vec2 perp = tangent.Perp;
|
||||
|
||||
Vec2 chosen = default;
|
||||
bool resolved = false;
|
||||
for (float dist = px * 0.5f; dist <= px * 3f; dist += px * 0.5f)
|
||||
{
|
||||
if (AllOffRiver(world, points, runStart, runEnd, perp, dist))
|
||||
{ chosen = perp * dist; resolved = true; break; }
|
||||
if (AllOffRiver(world, points, runStart, runEnd, perp, -dist))
|
||||
{ chosen = perp * -dist; resolved = true; break; }
|
||||
}
|
||||
|
||||
if (resolved)
|
||||
for (int i = runStart; i <= runEnd; i++) points[i] = points[i] + chosen;
|
||||
|
||||
k = runEnd + 1;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool AllOffRiver(WorldState world, List<Vec2> points, int start, int end, Vec2 perp, float dist)
|
||||
{
|
||||
int px = C.WORLD_TILE_PIXELS;
|
||||
for (int i = start; i <= end; i++)
|
||||
{
|
||||
Vec2 p = points[i] + perp * dist;
|
||||
int tx = (int)(p.X / px), ty = (int)(p.Y / px);
|
||||
if ((uint)tx >= W || (uint)ty >= H) return false;
|
||||
if ((world.TileAt(tx, ty).Features & FeatureFlags.HasRiver) != 0) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True if any road polyline segment in <c>[segStart, segEnd-1]</c> intersects
|
||||
/// any river polyline segment. Uses a bounding-box prefilter on the road-run
|
||||
/// region so this stays fast even with long rivers.
|
||||
/// </summary>
|
||||
private static bool RunCrossesRiver(WorldState world, List<Vec2> points, int segStart, int segEnd)
|
||||
{
|
||||
segStart = Math.Max(0, segStart);
|
||||
segEnd = Math.Min(points.Count - 1, segEnd);
|
||||
if (segEnd <= segStart) return false;
|
||||
|
||||
float minX = float.MaxValue, minY = float.MaxValue;
|
||||
float maxX = float.MinValue, maxY = float.MinValue;
|
||||
for (int i = segStart; i <= segEnd; i++)
|
||||
{
|
||||
if (points[i].X < minX) minX = points[i].X;
|
||||
if (points[i].Y < minY) minY = points[i].Y;
|
||||
if (points[i].X > maxX) maxX = points[i].X;
|
||||
if (points[i].Y > maxY) maxY = points[i].Y;
|
||||
}
|
||||
float margin = C.WORLD_TILE_PIXELS;
|
||||
minX -= margin; minY -= margin; maxX += margin; maxY += margin;
|
||||
|
||||
foreach (var river in world.Rivers)
|
||||
{
|
||||
var rpts = river.Points;
|
||||
for (int ri = 0; ri < rpts.Count - 1; ri++)
|
||||
{
|
||||
Vec2 a = rpts[ri], b = rpts[ri + 1];
|
||||
float sxMin = MathF.Min(a.X, b.X), sxMax = MathF.Max(a.X, b.X);
|
||||
float syMin = MathF.Min(a.Y, b.Y), syMax = MathF.Max(a.Y, b.Y);
|
||||
if (sxMax < minX || sxMin > maxX || syMax < minY || syMin > maxY) continue;
|
||||
|
||||
for (int i = segStart; i < segEnd; i++)
|
||||
if (SegmentsCross(points[i], points[i + 1], a, b)) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool SegmentsCross(Vec2 p1, Vec2 p2, Vec2 p3, Vec2 p4)
|
||||
{
|
||||
Vec2 d1 = p2 - p1;
|
||||
Vec2 d2 = p4 - p3;
|
||||
float cross = d1.X * d2.Y - d1.Y * d2.X;
|
||||
if (MathF.Abs(cross) < 1e-6f) return false;
|
||||
Vec2 d3 = p3 - p1;
|
||||
float t = (d3.X * d2.Y - d3.Y * d2.X) / cross;
|
||||
float u = (d3.X * d1.Y - d3.Y * d1.X) / cross;
|
||||
return t >= 0f && t <= 1f && u >= 0f && u <= 1f;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
using Theriapolis.Core.Util;
|
||||
|
||||
namespace Theriapolis.Core.World.Generation.Stages;
|
||||
|
||||
/// <summary>
|
||||
/// Stage 1 — SeedInit
|
||||
/// Builds the named RNG sub-stream table from the world seed.
|
||||
/// All downstream stages consume their sub-stream from ctx.Rngs["name"].
|
||||
/// </summary>
|
||||
public sealed class SeedInitStage : IWorldGenStage
|
||||
{
|
||||
public string Name => "SeedInit";
|
||||
|
||||
public void Run(WorldGenContext ctx)
|
||||
{
|
||||
ulong seed = ctx.World.WorldSeed;
|
||||
|
||||
ctx.Rngs["terrain"] = SeededRng.ForSubsystem(seed, C.RNG_TERRAIN);
|
||||
ctx.Rngs["moisture"] = SeededRng.ForSubsystem(seed, C.RNG_MOISTURE);
|
||||
ctx.Rngs["temp"] = SeededRng.ForSubsystem(seed, C.RNG_TEMP);
|
||||
ctx.Rngs["border"] = SeededRng.ForSubsystem(seed, C.RNG_BORDER);
|
||||
ctx.Rngs["coast"] = SeededRng.ForSubsystem(seed, C.RNG_COAST);
|
||||
ctx.Rngs["hydro"] = SeededRng.ForSubsystem(seed, C.RNG_HYDRO);
|
||||
ctx.Rngs["settle"] = SeededRng.ForSubsystem(seed, C.RNG_SETTLE);
|
||||
ctx.Rngs["road"] = SeededRng.ForSubsystem(seed, C.RNG_ROAD);
|
||||
ctx.Rngs["rail"] = SeededRng.ForSubsystem(seed, C.RNG_RAIL);
|
||||
ctx.Rngs["faction"] = SeededRng.ForSubsystem(seed, C.RNG_FACTION);
|
||||
ctx.Rngs["poi"] = SeededRng.ForSubsystem(seed, C.RNG_POI);
|
||||
ctx.Rngs["weather"] = SeededRng.ForSubsystem(seed, C.RNG_WEATHER);
|
||||
ctx.Rngs["tactical"] = SeededRng.ForSubsystem(seed, C.RNG_TACTICAL);
|
||||
ctx.Rngs["lake"] = SeededRng.ForSubsystem(seed, C.RNG_LAKE);
|
||||
ctx.Rngs["meander"] = SeededRng.ForSubsystem(seed, C.RNG_MEANDER);
|
||||
ctx.Rngs["habitat"] = SeededRng.ForSubsystem(seed, C.RNG_HABITAT);
|
||||
ctx.Rngs["anchor"] = SeededRng.ForSubsystem(seed, C.RNG_ANCHOR);
|
||||
ctx.Rngs["settle_attr"] = SeededRng.ForSubsystem(seed, C.RNG_SETTLE_ATTR);
|
||||
ctx.Rngs["trade"] = SeededRng.ForSubsystem(seed, C.RNG_TRADE);
|
||||
ctx.Rngs["encounter"] = SeededRng.ForSubsystem(seed, C.RNG_ENCOUNTER);
|
||||
|
||||
ctx.World.StageHashes["SeedInit"] = seed;
|
||||
ctx.LogMessage($"[SeedInit] RNG streams initialised. World seed: 0x{seed:X16}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
using Theriapolis.Core.Util;
|
||||
|
||||
namespace Theriapolis.Core.World.Generation.Stages;
|
||||
|
||||
/// <summary>
|
||||
/// Stage 15 — SettlementAttributes
|
||||
/// Assigns procedurally-generated attributes to all placed settlements.
|
||||
/// Narrative anchors keep their canonical names; others get generated names.
|
||||
/// </summary>
|
||||
public sealed class SettlementAttributesStage : IWorldGenStage
|
||||
{
|
||||
public string Name => "SettlementAttributes";
|
||||
|
||||
private const int W = C.WORLD_WIDTH_TILES;
|
||||
private const int H = C.WORLD_HEIGHT_TILES;
|
||||
|
||||
private static readonly int[] TierMinPop = { 0, 1_500_000, 30_000, 2_000, 50, 0 };
|
||||
private static readonly int[] TierMaxPop = { 0, 2_500_000, 200_000, 30_000, 2_000, 0 };
|
||||
|
||||
public void Run(WorldGenContext ctx)
|
||||
{
|
||||
var world = ctx.World;
|
||||
var rng = SeededRng.ForSubsystem(world.WorldSeed, C.RNG_SETTLE_ATTR);
|
||||
|
||||
foreach (var s in world.Settlements)
|
||||
{
|
||||
if (s.IsPoi) { AssignPoiAttributes(world, s, rng); continue; }
|
||||
|
||||
var macro = world.MacroCellForTile(world.TileAt(s.TileX, s.TileY));
|
||||
|
||||
// Name (anchors already named)
|
||||
if (string.IsNullOrEmpty(s.Name))
|
||||
s.Name = NameGenerator.Generate(rng, macro.BiomeType);
|
||||
|
||||
// Economy
|
||||
s.Economy = DeriveEconomy(world, s, macro, rng);
|
||||
|
||||
// Governance
|
||||
s.Governance = DeriveGovernance(macro, rng);
|
||||
|
||||
// Population
|
||||
if (s.Tier >= 1 && s.Tier <= 4)
|
||||
{
|
||||
float habBonus = world.Habitability?[s.TileX, s.TileY] ?? 0.5f;
|
||||
int min = TierMinPop[s.Tier];
|
||||
int max = TierMaxPop[s.Tier];
|
||||
s.Population = (int)(min + (max - min) * (0.3f + 0.7f * habBonus));
|
||||
}
|
||||
|
||||
// Wealth
|
||||
s.WealthLevel = Math.Clamp(
|
||||
(world.Habitability?[s.TileX, s.TileY] ?? 0.5f) + rng.NextFloat(-0.1f, 0.1f),
|
||||
0f, 1f);
|
||||
|
||||
// Clade ratios (simplified as strings referencing affinities)
|
||||
s.CladeRatios = macro.CladeAffinities.Length > 0
|
||||
? macro.CladeAffinities
|
||||
: new[] { "mixed" };
|
||||
|
||||
// Hybrid percentage
|
||||
var cov = macro.Covenant?.ToLowerInvariant() ?? "";
|
||||
s.HybridPct = (cov is "weak" or "nominal")
|
||||
? rng.NextFloat(0.10f, 0.30f)
|
||||
: rng.NextFloat(0.005f, 0.05f);
|
||||
|
||||
// Scent profile from economy
|
||||
s.ScentProfile = s.Economy switch
|
||||
{
|
||||
SettlementEconomy.Mining => "coal, metal, dust",
|
||||
SettlementEconomy.Manufacturing => "smoke, timber, oil",
|
||||
SettlementEconomy.Fishing => "salt, fish, tar",
|
||||
SettlementEconomy.Trade => "spice, leather, woodsmoke",
|
||||
SettlementEconomy.Military => "iron, leather, sweat",
|
||||
_ => "grain, livestock, earth",
|
||||
};
|
||||
|
||||
// River adjacency
|
||||
s.IsOnRiver = IsNearRiver(world, s.TileX, s.TileY);
|
||||
}
|
||||
|
||||
ctx.LogMessage("[SettlementAttributes] Assigned attributes to all settlements.");
|
||||
}
|
||||
|
||||
private static SettlementEconomy DeriveEconomy(WorldState world, Settlement s, Data.MacroCell macro, SeededRng rng)
|
||||
{
|
||||
// Count surrounding biomes in 10-tile radius
|
||||
int mountain = 0, forest = 0, grass = 0, coastal = 0;
|
||||
const int radius = 10;
|
||||
for (int dy = -radius; dy <= radius; dy++)
|
||||
for (int dx = -radius; dx <= radius; dx++)
|
||||
{
|
||||
int nx = s.TileX + dx, ny = s.TileY + dy;
|
||||
if ((uint)nx >= W || (uint)ny >= H) continue;
|
||||
var b = world.Tiles[nx, ny].Biome;
|
||||
if (b is BiomeId.MountainAlpine or BiomeId.MountainForested) mountain++;
|
||||
if (b is BiomeId.TemperateDeciduous or BiomeId.Boreal or BiomeId.SubtropicalForest) forest++;
|
||||
if (b is BiomeId.TemperateGrassland or BiomeId.Scrubland) grass++;
|
||||
if (b is BiomeId.Coastal or BiomeId.Beach or BiomeId.TidalFlat) coastal++;
|
||||
}
|
||||
|
||||
if (macro.Development?.ToLowerInvariant().Contains("military") == true)
|
||||
return SettlementEconomy.Military;
|
||||
if (coastal > 15)
|
||||
return rng.NextBool() ? SettlementEconomy.Fishing : SettlementEconomy.Trade;
|
||||
if (mountain > 20)
|
||||
return SettlementEconomy.Mining;
|
||||
if (forest > 30)
|
||||
return SettlementEconomy.Manufacturing;
|
||||
if (grass > 20)
|
||||
return SettlementEconomy.Farming;
|
||||
if ((world.Habitability?[s.TileX, s.TileY] ?? 0f) > 0.7f)
|
||||
return SettlementEconomy.Trade;
|
||||
return SettlementEconomy.Farming;
|
||||
}
|
||||
|
||||
private static SettlementGovernance DeriveGovernance(Data.MacroCell macro, SeededRng rng)
|
||||
{
|
||||
var dev = macro.Development?.ToLowerInvariant() ?? "";
|
||||
if (dev.Contains("military")) return SettlementGovernance.MilitaryCommandant;
|
||||
if (dev.Contains("industrial") || dev.Contains("urban")) return rng.NextBool() ? SettlementGovernance.Mayor : SettlementGovernance.Corporate;
|
||||
|
||||
var clades = string.Join(",", macro.CladeAffinities ?? Array.Empty<string>()).ToLowerInvariant();
|
||||
if (clades.Contains("canid")) return rng.NextBool() ? SettlementGovernance.Mayor : SettlementGovernance.MilitaryCommandant;
|
||||
if (clades.Contains("cervid") || clades.Contains("bovid")) return SettlementGovernance.Council;
|
||||
|
||||
var cov = macro.Covenant?.ToLowerInvariant() ?? "";
|
||||
if (cov is "nominal" or "weak") return SettlementGovernance.Anarchic;
|
||||
|
||||
return (SettlementGovernance)rng.NextInt(0, 6);
|
||||
}
|
||||
|
||||
private static bool IsNearRiver(WorldState world, int tx, int ty)
|
||||
{
|
||||
for (int dy = -2; dy <= 2; dy++)
|
||||
for (int dx = -2; dx <= 2; dx++)
|
||||
{
|
||||
int nx = tx + dx, ny = ty + dy;
|
||||
if ((uint)nx >= W || (uint)ny >= H) continue;
|
||||
if ((world.Tiles[nx, ny].Features & FeatureFlags.HasRiver) != 0) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static void AssignPoiAttributes(WorldState world, Settlement s, SeededRng rng)
|
||||
{
|
||||
if (string.IsNullOrEmpty(s.Name))
|
||||
{
|
||||
s.Name = s.PoiType switch
|
||||
{
|
||||
PoiType.ImperiumRuin => "Imperium Ruin",
|
||||
PoiType.AbandonedMine => "Abandoned Mine",
|
||||
PoiType.CultDen => "Hidden Den",
|
||||
PoiType.NaturalCave => "Natural Cave",
|
||||
PoiType.OvergrownSettlement => "Ruins",
|
||||
_ => "Unknown Site",
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
using Theriapolis.Core.Util;
|
||||
|
||||
namespace Theriapolis.Core.World.Generation.Stages;
|
||||
|
||||
/// <summary>
|
||||
/// Stage 14 — SettlementPlace
|
||||
/// Places Tier 2–5 settlements (narrative anchors were placed in stage 13).
|
||||
/// Narrative anchors are pre-placed; this stage fills in the rest.
|
||||
/// </summary>
|
||||
public sealed class SettlementPlaceStage : IWorldGenStage
|
||||
{
|
||||
public string Name => "SettlementPlace";
|
||||
|
||||
private const int W = C.WORLD_WIDTH_TILES;
|
||||
private const int H = C.WORLD_HEIGHT_TILES;
|
||||
|
||||
public void Run(WorldGenContext ctx)
|
||||
{
|
||||
var world = ctx.World;
|
||||
var rng = ctx.Rngs["settle"];
|
||||
|
||||
// Confine settlements to the main (largest) landmass. Smaller islands
|
||||
// can't be road-connected across ocean, so a settlement there would
|
||||
// either be unreachable or force a sea-crossing straight-line stub.
|
||||
var (componentIds, componentSizes) = LandmassMap.Compute(world);
|
||||
int mainLandmassId = LandmassMap.LargestComponentId(componentSizes);
|
||||
|
||||
// Sort all land tiles by habitability descending
|
||||
var landTiles = new List<(float hab, int x, int y)>(W * H / 3);
|
||||
for (int y = 0; y < H; y += 2)
|
||||
for (int x = 0; x < W; x += 2)
|
||||
{
|
||||
if (world.Tiles[x, y].Biome == BiomeId.Ocean) continue;
|
||||
if (componentIds[x, y] != mainLandmassId) continue;
|
||||
float h = world.Habitability?[x, y] ?? 0f;
|
||||
if (h > 0f) landTiles.Add((h, x, y));
|
||||
}
|
||||
landTiles.Sort((a, b) => b.hab.CompareTo(a.hab));
|
||||
|
||||
int nextId = world.Settlements.Count + 1;
|
||||
|
||||
// Subtract narrative anchor counts so the TOTAL for each tier hits the target range.
|
||||
int existingTier2 = world.Settlements.Count(s => s.Tier == 2 && !s.IsPoi);
|
||||
int existingTier3 = world.Settlements.Count(s => s.Tier == 3 && !s.IsPoi);
|
||||
int existingTier4 = world.Settlements.Count(s => s.Tier == 4 && !s.IsPoi);
|
||||
|
||||
// ── Tier 2 ────────────────────────────────────────────────────────────
|
||||
int tier2Target = rng.NextInt(C.SETTLE_TIER2_MIN, C.SETTLE_TIER2_MAX + 1);
|
||||
int tier2Count = Math.Max(0, tier2Target - existingTier2);
|
||||
PlaceTier(world, landTiles, ref nextId, 2, tier2Count, C.SETTLE_MIN_DIST_TIER2, rng,
|
||||
(x, y, tile) =>
|
||||
(tile.Features & (FeatureFlags.HasRiver | FeatureFlags.RiverAdjacent)) != 0 ||
|
||||
tile.Biome == BiomeId.Coastal);
|
||||
|
||||
// ── Tier 3 ────────────────────────────────────────────────────────────
|
||||
int tier3Target = rng.NextInt(C.SETTLE_TIER3_MIN, C.SETTLE_TIER3_MAX + 1);
|
||||
int tier3Count = Math.Max(0, tier3Target - existingTier3);
|
||||
PlaceTier(world, landTiles, ref nextId, 3, tier3Count, C.SETTLE_MIN_DIST_TIER3, rng,
|
||||
(x, y, tile) => IsNearHigherTier(x, y, world.Settlements, 2, 60) ||
|
||||
(tile.Features & (FeatureFlags.HasRiver | FeatureFlags.RiverAdjacent)) != 0);
|
||||
|
||||
// ── Tier 4 ────────────────────────────────────────────────────────────
|
||||
int tier4Target = rng.NextInt(C.SETTLE_TIER4_MIN, C.SETTLE_TIER4_MAX + 1);
|
||||
int tier4Count = Math.Max(0, tier4Target - existingTier4);
|
||||
PlaceTier(world, landTiles, ref nextId, 4, tier4Count, C.SETTLE_MIN_DIST_TIER4, rng,
|
||||
(x, y, tile) => true);
|
||||
|
||||
// ── Tier 5 (PoIs) ─────────────────────────────────────────────────────
|
||||
int tier5Count = rng.NextInt(C.SETTLE_TIER5_MIN, C.SETTLE_TIER5_MAX + 1);
|
||||
PlacePoIs(world, landTiles, ref nextId, tier5Count, rng);
|
||||
|
||||
world.StageHashes["SettlementPlace"] = world.HashSettlements();
|
||||
ctx.LogMessage($"[SettlementPlace] Total settlements: {world.Settlements.Count}");
|
||||
}
|
||||
|
||||
private static void PlaceTier(
|
||||
WorldState world,
|
||||
List<(float hab, int x, int y)> sortedTiles,
|
||||
ref int nextId,
|
||||
int tier,
|
||||
int target,
|
||||
int minDist,
|
||||
SeededRng rng,
|
||||
Func<int, int, WorldTile, bool> extraFilter)
|
||||
{
|
||||
int placed = 0;
|
||||
foreach (var (_, x, y) in sortedTiles)
|
||||
{
|
||||
if (placed >= target) break;
|
||||
ref var tile = ref world.TileAt(x, y);
|
||||
if (tile.Biome == BiomeId.Ocean) continue;
|
||||
if ((tile.Features & FeatureFlags.IsSettlement) != 0) continue;
|
||||
if (!extraFilter(x, y, tile)) continue;
|
||||
if (IsTooClose(x, y, world.Settlements, tier, minDist)) continue;
|
||||
|
||||
var (nx, ny) = NudgeOffRiver(world, x, y);
|
||||
var s = new Settlement { Id = nextId++, Tier = tier, TileX = nx, TileY = ny };
|
||||
world.Settlements.Add(s);
|
||||
MarkTiles(world, s);
|
||||
placed++;
|
||||
}
|
||||
}
|
||||
|
||||
private static void PlacePoIs(
|
||||
WorldState world,
|
||||
List<(float hab, int x, int y)> sortedTiles,
|
||||
ref int nextId,
|
||||
int target,
|
||||
SeededRng rng)
|
||||
{
|
||||
int placed = 0;
|
||||
// PoIs go in LOW habitability cells — reverse the list
|
||||
for (int i = sortedTiles.Count - 1; i >= 0 && placed < target; i--)
|
||||
{
|
||||
var (hab, x, y) = sortedTiles[i];
|
||||
if (hab > 0.5f) break; // stop when habitability gets too high
|
||||
if (world.Tiles[x, y].Biome == BiomeId.Ocean) continue;
|
||||
if ((world.Tiles[x, y].Features & FeatureFlags.IsSettlement) != 0) continue;
|
||||
if (IsTooClose(x, y, world.Settlements, 5, C.SETTLE_MIN_DIST_TIER5)) continue;
|
||||
if (IsNearHigherTier(x, y, world.Settlements, 4, C.POI_MIN_DIST_FROM_SETTLE)) continue;
|
||||
|
||||
var s = new Settlement
|
||||
{
|
||||
Id = nextId++,
|
||||
Tier = 5,
|
||||
TileX = x,
|
||||
TileY = y,
|
||||
IsPoi = true,
|
||||
PoiType = PickPoiType(world, x, y, rng),
|
||||
};
|
||||
world.Settlements.Add(s);
|
||||
world.TileAt(x, y).Features |= FeatureFlags.IsPoi;
|
||||
placed++;
|
||||
}
|
||||
}
|
||||
|
||||
private static PoiType PickPoiType(WorldState world, int x, int y, SeededRng rng)
|
||||
{
|
||||
var biome = world.Tiles[x, y].Biome;
|
||||
return biome switch
|
||||
{
|
||||
BiomeId.MountainAlpine or BiomeId.MountainForested => rng.NextBool() ? PoiType.AbandonedMine : PoiType.NaturalCave,
|
||||
BiomeId.TemperateDeciduous or BiomeId.Boreal or BiomeId.ForestEdge => rng.NextBool() ? PoiType.CultDen : PoiType.OvergrownSettlement,
|
||||
BiomeId.Tundra or BiomeId.Boreal => PoiType.ImperiumRuin,
|
||||
BiomeId.Wetland or BiomeId.SubtropicalForest or BiomeId.Mangrove => rng.NextBool() ? PoiType.NaturalCave : PoiType.CultDen,
|
||||
_ => (PoiType)(rng.NextInt(1, 6)),
|
||||
};
|
||||
}
|
||||
|
||||
private static bool IsTooClose(int x, int y, List<Settlement> settlements, int tier, int minDist)
|
||||
{
|
||||
foreach (var s in settlements)
|
||||
{
|
||||
if (s.Tier > tier) continue; // only compare same or higher tiers
|
||||
int dx = x - s.TileX, dy = y - s.TileY;
|
||||
if (dx * dx + dy * dy < minDist * minDist) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool IsNearHigherTier(int x, int y, List<Settlement> settlements, int maxTier, int maxDist)
|
||||
{
|
||||
foreach (var s in settlements)
|
||||
{
|
||||
if (s.Tier > maxTier) continue;
|
||||
int dx = x - s.TileX, dy = y - s.TileY;
|
||||
if (dx * dx + dy * dy < maxDist * maxDist) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// If the candidate tile sits on a river, nudge to the nearest adjacent tile
|
||||
/// that is river-adjacent but not itself a river tile. Keeps settlements
|
||||
/// beside rivers rather than on top of them.
|
||||
/// </summary>
|
||||
internal static (int x, int y) NudgeOffRiver(WorldState world, int x, int y)
|
||||
{
|
||||
ref var tile = ref world.TileAt(x, y);
|
||||
if ((tile.Features & FeatureFlags.HasRiver) == 0) return (x, y);
|
||||
|
||||
// Scan outward in fixed order (deterministic) at radius 1, then 2
|
||||
for (int r = 1; r <= 2; r++)
|
||||
for (int dy = -r; dy <= r; dy++)
|
||||
for (int dx = -r; dx <= r; dx++)
|
||||
{
|
||||
if (dx == 0 && dy == 0) continue;
|
||||
if (Math.Abs(dx) != r && Math.Abs(dy) != r) continue; // shell only
|
||||
int nx = x + dx, ny = y + dy;
|
||||
if ((uint)nx >= W || (uint)ny >= H) continue;
|
||||
ref var neighbor = ref world.TileAt(nx, ny);
|
||||
if (neighbor.Biome == BiomeId.Ocean) continue;
|
||||
if ((neighbor.Features & FeatureFlags.HasRiver) != 0) continue;
|
||||
if ((neighbor.Features & FeatureFlags.IsSettlement) != 0) continue;
|
||||
return (nx, ny);
|
||||
}
|
||||
|
||||
return (x, y); // no valid neighbor found — keep original
|
||||
}
|
||||
|
||||
private static void MarkTiles(WorldState world, Settlement s)
|
||||
{
|
||||
for (int dy = -1; dy <= 1; dy++)
|
||||
for (int dx = -1; dx <= 1; dx++)
|
||||
{
|
||||
int nx = s.TileX + dx, ny = s.TileY + dy;
|
||||
if ((uint)nx >= W || (uint)ny >= H) continue;
|
||||
ref var tile = ref world.TileAt(nx, ny);
|
||||
tile.Features |= FeatureFlags.IsSettlement;
|
||||
tile.SettlementId = (ushort)Math.Min(s.Id, ushort.MaxValue);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
using Theriapolis.Core.Util;
|
||||
|
||||
namespace Theriapolis.Core.World.Generation.Stages;
|
||||
|
||||
/// <summary>
|
||||
/// Stage 5 — TemperatureGen
|
||||
/// Temperature is primarily latitude-derived (north = cold, south = warm),
|
||||
/// modified by elevation (higher = colder) and minor noise for local variation.
|
||||
/// Respects macro-cell temperature modifiers.
|
||||
/// </summary>
|
||||
public sealed class TemperatureGenStage : IWorldGenStage
|
||||
{
|
||||
public string Name => "TemperatureGen";
|
||||
|
||||
public void Run(WorldGenContext ctx)
|
||||
{
|
||||
ulong seed = ctx.World.WorldSeed ^ C.RNG_TEMP;
|
||||
var noise = new FastNoiseLite
|
||||
{
|
||||
Seed = (int)(seed & 0x7FFFFFFF),
|
||||
Frequency = 1.5f / C.WORLD_WIDTH_TILES,
|
||||
Noise = FastNoiseLite.NoiseType.OpenSimplex2,
|
||||
Fractal = FastNoiseLite.FractalType.FBm,
|
||||
Octaves = 3,
|
||||
Lacunarity = 2.0f,
|
||||
Gain = 0.5f,
|
||||
};
|
||||
|
||||
int W = C.WORLD_WIDTH_TILES;
|
||||
int H = C.WORLD_HEIGHT_TILES;
|
||||
|
||||
Parallel.For(0, H, ty =>
|
||||
{
|
||||
// Latitude: 0 at north (cold), 1 at south (warm)
|
||||
float latitude = (float)ty / (H - 1);
|
||||
|
||||
for (int tx = 0; tx < W; tx++)
|
||||
{
|
||||
float elev = ctx.World.Tiles[tx, ty].Elevation;
|
||||
|
||||
// Base temperature from latitude (smooth curve — coldest at top)
|
||||
float baseTemp = latitude * latitude * 0.8f + latitude * 0.2f;
|
||||
|
||||
// Elevation cooling: above sea level, temperature drops ~0.3 per unit elevation
|
||||
float elevCool = MathF.Max(0f, elev - WorldState.SeaLevel) * 0.45f;
|
||||
|
||||
// Minor local noise
|
||||
float localNoise = noise.GetNoise((float)tx, (float)ty) * 0.07f;
|
||||
|
||||
float t = baseTemp - elevCool + localNoise;
|
||||
|
||||
// Macro cell modifier — use the warped cell stored on the
|
||||
// tile so temperature inherits the same organic cell shape.
|
||||
var cell = ctx.World.MacroCellForTile(ctx.World.Tiles[tx, ty]);
|
||||
t += cell.TempModifier;
|
||||
|
||||
ctx.World.Tiles[tx, ty].Temperature = Math.Clamp(t, 0f, 1f);
|
||||
}
|
||||
});
|
||||
|
||||
ctx.World.StageHashes["TemperatureGen"] = ctx.World.HashTemperature();
|
||||
ctx.LogMessage($"[TemperatureGen] Temperature hash: 0x{ctx.World.StageHashes["TemperatureGen"]:X16}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
namespace Theriapolis.Core.World.Generation.Stages;
|
||||
|
||||
/// <summary>
|
||||
/// Stage 18 — TradeRouteGen
|
||||
/// Supply/demand matching overlay. Adjusts WealthLevel on settlements that sit on
|
||||
/// high-traffic trade paths. No new polylines — uses existing road/rail network.
|
||||
/// </summary>
|
||||
public sealed class TradeRouteGenStage : IWorldGenStage
|
||||
{
|
||||
public string Name => "TradeRouteGen";
|
||||
|
||||
public void Run(WorldGenContext ctx)
|
||||
{
|
||||
var world = ctx.World;
|
||||
|
||||
// Build adjacency list from road polylines (settlement endpoints)
|
||||
var adj = new Dictionary<int, List<(int toId, float cost)>>();
|
||||
foreach (var s in world.Settlements.Where(s => !s.IsPoi))
|
||||
adj[s.Id] = new List<(int, float)>();
|
||||
|
||||
foreach (var road in world.Roads)
|
||||
{
|
||||
if (road.FromSettlementId < 0 || road.ToSettlementId < 0) continue;
|
||||
float routeLen = road.Points.Count > 0 ? road.Points.Count * 0.1f : 1f;
|
||||
if (adj.TryGetValue(road.FromSettlementId, out var la)) la.Add((road.ToSettlementId, routeLen));
|
||||
if (adj.TryGetValue(road.ToSettlementId, out var lb)) lb.Add((road.FromSettlementId, routeLen));
|
||||
}
|
||||
foreach (var rail in world.Rails)
|
||||
{
|
||||
if (rail.FromSettlementId < 0 || rail.ToSettlementId < 0) continue;
|
||||
float routeLen = rail.Points.Count > 0 ? rail.Points.Count * 0.05f : 1f; // rail cheaper
|
||||
if (adj.TryGetValue(rail.FromSettlementId, out var la)) la.Add((rail.ToSettlementId, routeLen));
|
||||
if (adj.TryGetValue(rail.ToSettlementId, out var lb)) lb.Add((rail.FromSettlementId, routeLen));
|
||||
}
|
||||
|
||||
// Simple trade flow: for each pair of settlements with complementary economies,
|
||||
// compute trade score and distribute wealth bonus along the path
|
||||
var settleById = world.Settlements.Where(s => !s.IsPoi).ToDictionary(s => s.Id);
|
||||
var tradeScore = new Dictionary<int, float>();
|
||||
foreach (var s in settleById.Values) tradeScore[s.Id] = 0f;
|
||||
|
||||
foreach (var producer in settleById.Values)
|
||||
foreach (var consumer in settleById.Values)
|
||||
{
|
||||
if (producer.Id == consumer.Id) continue;
|
||||
if (producer.Economy == consumer.Economy) continue; // same economy = no trade
|
||||
|
||||
float supply = ProducerStrength(producer);
|
||||
float demand = ConsumerNeed(consumer, producer.Economy);
|
||||
if (supply <= 0 || demand <= 0) continue;
|
||||
|
||||
// Approximate transport cost as Euclidean distance (no full Dijkstra for all pairs)
|
||||
float dist = MathF.Sqrt(
|
||||
(producer.TileX - consumer.TileX) * (float)(producer.TileX - consumer.TileX) +
|
||||
(producer.TileY - consumer.TileY) * (float)(producer.TileY - consumer.TileY));
|
||||
if (dist > 200) continue; // too far to trade efficiently
|
||||
|
||||
float score = supply * demand / (1f + dist * 0.02f);
|
||||
tradeScore[producer.Id] += score;
|
||||
tradeScore[consumer.Id] += score;
|
||||
}
|
||||
|
||||
// Normalize and apply wealth bonus
|
||||
float maxScore = tradeScore.Values.DefaultIfEmpty(1f).Max();
|
||||
if (maxScore < 1e-6f) return;
|
||||
|
||||
foreach (var s in settleById.Values)
|
||||
{
|
||||
float bonus = tradeScore[s.Id] / maxScore * 0.3f;
|
||||
s.WealthLevel = Math.Clamp(s.WealthLevel + bonus, 0f, 1f);
|
||||
}
|
||||
|
||||
ctx.LogMessage("[TradeRouteGen] Trade routes computed and wealth adjusted.");
|
||||
}
|
||||
|
||||
private static float ProducerStrength(Settlement s) => s.Economy switch
|
||||
{
|
||||
SettlementEconomy.Farming => 0.8f,
|
||||
SettlementEconomy.Mining => 0.9f,
|
||||
SettlementEconomy.Fishing => 0.7f,
|
||||
SettlementEconomy.Manufacturing=> 1.0f,
|
||||
SettlementEconomy.Trade => 0.5f,
|
||||
SettlementEconomy.Military => 0.3f,
|
||||
_ => 0.5f,
|
||||
};
|
||||
|
||||
private static float ConsumerNeed(Settlement consumer, SettlementEconomy producerEcon)
|
||||
{
|
||||
// Settlements need what they don't produce
|
||||
if (consumer.Economy == producerEcon) return 0f;
|
||||
return consumer.Economy switch
|
||||
{
|
||||
SettlementEconomy.Manufacturing => producerEcon is SettlementEconomy.Mining or SettlementEconomy.Farming ? 1.0f : 0.3f,
|
||||
SettlementEconomy.Military => producerEcon is SettlementEconomy.Farming or SettlementEconomy.Manufacturing ? 0.8f : 0.2f,
|
||||
SettlementEconomy.Trade => 0.6f,
|
||||
_ => 0.4f,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
using Theriapolis.Core.Util;
|
||||
|
||||
namespace Theriapolis.Core.World.Generation.Stages;
|
||||
|
||||
/// <summary>
|
||||
/// Stage 22 — ValidationPass
|
||||
/// Checks all generated data for correctness:
|
||||
/// 1. Linear feature exclusion (Addendum A §2) — 0 violations required
|
||||
/// 2. River drainage to water
|
||||
/// 3. Settlement reachability via roads
|
||||
/// 4. Narrative anchor constraint re-verification
|
||||
/// 5. No overlapping settlements
|
||||
/// Throws on critical failures; logs warnings for soft failures.
|
||||
/// </summary>
|
||||
public sealed class ValidationPassStage : IWorldGenStage
|
||||
{
|
||||
public string Name => "ValidationPass";
|
||||
|
||||
private const int W = C.WORLD_WIDTH_TILES;
|
||||
private const int H = C.WORLD_HEIGHT_TILES;
|
||||
|
||||
public void Run(WorldGenContext ctx)
|
||||
{
|
||||
var world = ctx.World;
|
||||
int violations = 0;
|
||||
int warnings = 0;
|
||||
|
||||
// ── 1. Linear feature exclusion ───────────────────────────────────────
|
||||
violations += CheckLinearExclusion(world, ctx);
|
||||
|
||||
// ── 2. River drainage ────────────────────────────────────────────────
|
||||
warnings += CheckRiverDrainage(world, ctx);
|
||||
|
||||
// ── 3. Settlement reachability ────────────────────────────────────────
|
||||
warnings += CheckSettlementReachability(world, ctx);
|
||||
|
||||
// ── 4. Narrative anchor constraints ──────────────────────────────────
|
||||
warnings += CheckNarrativeAnchors(world, ctx);
|
||||
|
||||
// ── 5. No overlapping settlements ─────────────────────────────────────
|
||||
violations += CheckOverlappingSettlements(world, ctx);
|
||||
|
||||
ctx.World.StageHashes["ValidationPass"] = (ulong)(violations * 1000 + warnings);
|
||||
ctx.LogMessage($"[ValidationPass] Done. Violations: {violations}, Warnings: {warnings}");
|
||||
|
||||
if (violations > 0)
|
||||
ctx.LogMessage($"[ValidationPass] WARNING: {violations} violations detected. See log above.");
|
||||
}
|
||||
|
||||
private static int CheckLinearExclusion(WorldState world, WorldGenContext ctx)
|
||||
{
|
||||
int count = 0;
|
||||
for (int y = 0; y < H; y++)
|
||||
for (int x = 0; x < W; x++)
|
||||
{
|
||||
ref var tile = ref world.TileAt(x, y);
|
||||
|
||||
// Skip settlement tiles (exempt from exclusion)
|
||||
if ((tile.Features & FeatureFlags.IsSettlement) != 0) continue;
|
||||
|
||||
bool hasRiver = (tile.Features & FeatureFlags.HasRiver) != 0;
|
||||
bool hasRail = (tile.Features & FeatureFlags.HasRail) != 0;
|
||||
bool hasRoad = (tile.Features & FeatureFlags.HasRoad) != 0;
|
||||
|
||||
// More than one linear feature on the same tile
|
||||
int featureCount = (hasRiver ? 1 : 0) + (hasRail ? 1 : 0) + (hasRoad ? 1 : 0);
|
||||
if (featureCount < 2) continue;
|
||||
|
||||
// For crossings to be valid, they must be near-perpendicular
|
||||
if (hasRiver && hasRail && tile.RiverFlowDir != Dir.None && tile.RailDir != Dir.None)
|
||||
{
|
||||
if (Dir.IsParallel(tile.RiverFlowDir, tile.RailDir))
|
||||
{
|
||||
ctx.LogMessage($"[ValidationPass] VIOLATION: River+Rail parallel at ({x},{y})");
|
||||
count++;
|
||||
}
|
||||
}
|
||||
// Road over river = bridge crossing — NOT a violation (perpendicular crossing is allowed)
|
||||
if (hasRail && hasRoad && tile.RailDir != Dir.None)
|
||||
{
|
||||
ctx.LogMessage($"[ValidationPass] VIOLATION: Rail+Road on same tile at ({x},{y})");
|
||||
count++;
|
||||
}
|
||||
}
|
||||
if (count > 0)
|
||||
ctx.LogMessage($"[ValidationPass] {count} linear exclusion violations.");
|
||||
return count;
|
||||
}
|
||||
|
||||
private static int CheckRiverDrainage(WorldState world, WorldGenContext ctx)
|
||||
{
|
||||
int warnings = 0;
|
||||
foreach (var river in world.Rivers)
|
||||
{
|
||||
if (river.Points.Count < 2) continue;
|
||||
var lastPt = river.Points[^1];
|
||||
int lx = (int)(lastPt.X / C.WORLD_TILE_PIXELS);
|
||||
int ly = (int)(lastPt.Y / C.WORLD_TILE_PIXELS);
|
||||
lx = Math.Clamp(lx, 0, W - 1);
|
||||
ly = Math.Clamp(ly, 0, H - 1);
|
||||
|
||||
var lastBiome = world.Tiles[lx, ly].Biome;
|
||||
if (lastBiome != BiomeId.Ocean && lastBiome != BiomeId.Wetland &&
|
||||
(world.Tiles[lx, ly].Features & FeatureFlags.HasRiver) == 0)
|
||||
{
|
||||
ctx.LogMessage($"[ValidationPass] WARNING: River {river.Id} endpoint not at water ({lx},{ly} biome={lastBiome})");
|
||||
warnings++;
|
||||
}
|
||||
}
|
||||
return warnings;
|
||||
}
|
||||
|
||||
private static int CheckSettlementReachability(WorldState world, WorldGenContext ctx)
|
||||
{
|
||||
var capital = world.Settlements.FirstOrDefault(s => s.Anchor == NarrativeAnchor.SanctumFidelis);
|
||||
if (capital == null) return 0;
|
||||
|
||||
// BFS from capital via HasRoad tiles
|
||||
var reachable = new HashSet<int>(); // settlement IDs
|
||||
var visited = new bool[W, H];
|
||||
var queue = new Queue<(int x, int y)>();
|
||||
|
||||
queue.Enqueue((capital.TileX, capital.TileY));
|
||||
visited[capital.TileX, capital.TileY] = true;
|
||||
reachable.Add(capital.Id);
|
||||
|
||||
(int dx, int dy)[] dirs4 = { (0,-1),(1,0),(0,1),(-1,0) };
|
||||
while (queue.Count > 0)
|
||||
{
|
||||
var (cx, cy) = queue.Dequeue();
|
||||
ref var tile = ref world.TileAt(cx, cy);
|
||||
|
||||
if (tile.SettlementId > 0)
|
||||
{
|
||||
int sid = tile.SettlementId; // copy before lambda capture
|
||||
var s = world.Settlements.FirstOrDefault(s => s.Id == sid);
|
||||
if (s != null) reachable.Add(s.Id);
|
||||
}
|
||||
|
||||
if ((tile.Features & FeatureFlags.HasRoad) == 0 &&
|
||||
(tile.Features & FeatureFlags.IsSettlement) == 0) continue;
|
||||
|
||||
foreach (var (ddx, ddy) in dirs4)
|
||||
{
|
||||
int nx = cx + ddx, ny = cy + ddy;
|
||||
if ((uint)nx >= W || (uint)ny >= H || visited[nx, ny]) continue;
|
||||
var nf = world.Tiles[nx, ny].Features;
|
||||
if ((nf & (FeatureFlags.HasRoad | FeatureFlags.IsSettlement)) == 0) continue;
|
||||
visited[nx, ny] = true;
|
||||
queue.Enqueue((nx, ny));
|
||||
}
|
||||
}
|
||||
|
||||
int warnings = 0;
|
||||
foreach (var s in world.Settlements.Where(s => s.Tier <= 3 && !s.IsPoi))
|
||||
{
|
||||
if (!reachable.Contains(s.Id))
|
||||
{
|
||||
ctx.LogMessage($"[ValidationPass] WARNING: {s.Name} (Tier {s.Tier}) not reachable via roads.");
|
||||
warnings++;
|
||||
}
|
||||
}
|
||||
return warnings;
|
||||
}
|
||||
|
||||
private static int CheckNarrativeAnchors(WorldState world, WorldGenContext ctx)
|
||||
{
|
||||
int warnings = 0;
|
||||
var millhaven = world.Settlements.FirstOrDefault(s => s.Anchor == NarrativeAnchor.Millhaven);
|
||||
if (millhaven != null && !millhaven.IsOnRiver)
|
||||
{
|
||||
ctx.LogMessage("[ValidationPass] WARNING: Millhaven is not near a river.");
|
||||
warnings++;
|
||||
}
|
||||
|
||||
var heartstone = world.Settlements.FirstOrDefault(s => s.Anchor == NarrativeAnchor.Heartstone);
|
||||
if (heartstone != null)
|
||||
{
|
||||
var hs = world.TileAt(heartstone.TileX, heartstone.TileY);
|
||||
if ((hs.Features & (FeatureFlags.HasRail | FeatureFlags.RailroadAdjacent)) != 0)
|
||||
{
|
||||
ctx.LogMessage("[ValidationPass] WARNING: Heartstone has rail adjacent.");
|
||||
warnings++;
|
||||
}
|
||||
}
|
||||
|
||||
return warnings;
|
||||
}
|
||||
|
||||
private static int CheckOverlappingSettlements(WorldState world, WorldGenContext ctx)
|
||||
{
|
||||
int violations = 0;
|
||||
var positions = new HashSet<(int, int)>();
|
||||
foreach (var s in world.Settlements)
|
||||
{
|
||||
if (!positions.Add((s.TileX, s.TileY)))
|
||||
{
|
||||
ctx.LogMessage($"[ValidationPass] VIOLATION: Two settlements at ({s.TileX},{s.TileY})");
|
||||
violations++;
|
||||
}
|
||||
}
|
||||
return violations;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
namespace Theriapolis.Core.World.Generation.Stages;
|
||||
|
||||
/// <summary>
|
||||
/// Stage 8 — WaterBodyClamp
|
||||
/// Eliminates all interior water bodies (tiles below sea level that are not
|
||||
/// connected to the map-edge ocean) by raising them just above sea level.
|
||||
/// Interior lakes are a later-phase feature; in Phase 1 only ocean water exists.
|
||||
///
|
||||
/// Ocean border enforcement is handled earlier in BorderDistortionGenStage
|
||||
/// (before the wobble pass) so the resulting coastline gets organic treatment.
|
||||
/// </summary>
|
||||
public sealed class WaterBodyClampStage : IWorldGenStage
|
||||
{
|
||||
public string Name => "WaterBodyClamp";
|
||||
|
||||
private static readonly (int dx, int dy)[] Dirs = [(-1, 0), (1, 0), (0, -1), (0, 1)];
|
||||
|
||||
public void Run(WorldGenContext ctx)
|
||||
{
|
||||
int W = C.WORLD_WIDTH_TILES;
|
||||
int H = C.WORLD_HEIGHT_TILES;
|
||||
var visited = new bool[W, H];
|
||||
var queue = new Queue<(int x, int y)>();
|
||||
|
||||
// Flood-fill from map-edge water tiles to mark the ocean body.
|
||||
for (int ty = 0; ty < H; ty++)
|
||||
for (int tx = 0; tx < W; tx++)
|
||||
{
|
||||
if (tx != 0 && tx != W - 1 && ty != 0 && ty != H - 1) continue;
|
||||
if (ctx.World.Tiles[tx, ty].Elevation >= WorldState.SeaLevel) continue;
|
||||
if (visited[tx, ty]) continue;
|
||||
visited[tx, ty] = true;
|
||||
queue.Enqueue((tx, ty));
|
||||
}
|
||||
|
||||
while (queue.Count > 0)
|
||||
{
|
||||
var (cx, cy) = queue.Dequeue();
|
||||
foreach (var (dx, dy) in Dirs)
|
||||
{
|
||||
int nx = cx + dx, ny = cy + dy;
|
||||
if ((uint)nx >= (uint)W || (uint)ny >= (uint)H) continue;
|
||||
if (visited[nx, ny]) continue;
|
||||
if (ctx.World.Tiles[nx, ny].Elevation >= WorldState.SeaLevel) continue;
|
||||
visited[nx, ny] = true;
|
||||
queue.Enqueue((nx, ny));
|
||||
}
|
||||
}
|
||||
|
||||
// Raise all unvisited water tiles above sea level, respecting the
|
||||
// macro cell's land elevation range so we don't violate constraints.
|
||||
int tilesRaised = 0;
|
||||
for (int ty = 0; ty < H; ty++)
|
||||
for (int tx = 0; tx < W; tx++)
|
||||
{
|
||||
if (visited[tx, ty]) continue;
|
||||
ref var tile = ref ctx.World.Tiles[tx, ty];
|
||||
if (tile.Elevation >= WorldState.SeaLevel) continue;
|
||||
var cell = ctx.World.MacroGrid![tile.MacroX, tile.MacroY];
|
||||
float landFloor = MathF.Max(cell.ElevationFloor, WorldState.SeaLevel);
|
||||
tile.Elevation = landFloor + 0.02f;
|
||||
tilesRaised++;
|
||||
}
|
||||
|
||||
ctx.World.StageHashes["WaterBodyClamp"] = ctx.World.HashElevation();
|
||||
ctx.LogMessage($"[WaterBodyClamp] Filled {tilesRaised} interior water tiles");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
using Theriapolis.Core.Data;
|
||||
using Theriapolis.Core.Util;
|
||||
|
||||
namespace Theriapolis.Core.World.Generation;
|
||||
|
||||
/// <summary>
|
||||
/// Accumulating context passed through the world-generation pipeline.
|
||||
/// Holds the WorldState under construction, per-subsystem RNG streams, and a logger.
|
||||
/// </summary>
|
||||
public sealed class WorldGenContext
|
||||
{
|
||||
public WorldState World { get; }
|
||||
|
||||
/// <summary>Named RNG sub-streams, keyed by the subsystem constant name.</summary>
|
||||
public Dictionary<string, SeededRng> Rngs { get; } = new();
|
||||
|
||||
/// <summary>Directory that holds macro_template.json, biomes.json, etc.</summary>
|
||||
public string DataDirectory { get; init; } = "";
|
||||
|
||||
/// <summary>Called after each stage completes: (stageName, progressFraction 0–1).</summary>
|
||||
public Action<string, float>? ProgressCallback { get; set; }
|
||||
|
||||
/// <summary>Simple diagnostic log sink.</summary>
|
||||
public Action<string>? Log { get; set; }
|
||||
|
||||
public WorldGenContext(ulong seed, string dataDirectory)
|
||||
{
|
||||
World = new WorldState { WorldSeed = seed };
|
||||
DataDirectory = dataDirectory;
|
||||
}
|
||||
|
||||
public void ReportProgress(string stageName, float fraction)
|
||||
=> ProgressCallback?.Invoke(stageName, Math.Clamp(fraction, 0f, 1f));
|
||||
|
||||
public void LogMessage(string msg)
|
||||
=> Log?.Invoke(msg);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
using Theriapolis.Core.World.Generation.Stages;
|
||||
|
||||
namespace Theriapolis.Core.World.Generation;
|
||||
|
||||
/// <summary>
|
||||
/// Runs the ordered world-generation pipeline.
|
||||
/// Stages 1–8 are implemented for Phase 1. The remaining stubs are placeholders
|
||||
/// that will be filled in Phase 2 onward.
|
||||
/// </summary>
|
||||
public static class WorldGenerator
|
||||
{
|
||||
/// <summary>Ordered pipeline. Index matches the spec's stage numbering (0-based list).</summary>
|
||||
public static IWorldGenStage[] BuildPipeline() => new IWorldGenStage[]
|
||||
{
|
||||
new SeedInitStage(), // 1
|
||||
new MacroTemplateLoadStage(), // 2
|
||||
new ElevationGenStage(), // 3
|
||||
new MoistureGenStage(), // 4
|
||||
new TemperatureGenStage(), // 5
|
||||
new CoastalFeatureGenStage(), // 6
|
||||
new BorderDistortionGenStage(), // 7
|
||||
new WaterBodyClampStage(), // 8
|
||||
new BiomeAssignStage(), // 9
|
||||
new HydrologyGenStage(), // 10
|
||||
new RiverMeanderGenStage(), // 11
|
||||
new HabitabilityScoreStage(), // 12
|
||||
new NarrativeAnchorPlaceStage(), // 13
|
||||
new SettlementPlaceStage(), // 14
|
||||
new SettlementAttributesStage(), // 15
|
||||
new RailNetworkGenStage(), // 16
|
||||
new RoadNetworkGenStage(), // 17
|
||||
new PolylineCleanupStage(), // 18
|
||||
new TradeRouteGenStage(), // 19
|
||||
new FactionInfluenceGenStage(), // 20
|
||||
new PoIPlacementStage(), // 21
|
||||
new EncounterDensityGenStage(), // 22
|
||||
new ValidationPassStage(), // 23
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Run all pipeline stages, reporting progress via ctx.ProgressCallback.
|
||||
/// </summary>
|
||||
public static void RunAll(WorldGenContext ctx)
|
||||
{
|
||||
var stages = BuildPipeline();
|
||||
for (int i = 0; i < stages.Length; i++)
|
||||
{
|
||||
ctx.LogMessage($"[WorldGen] Stage {i + 1}/{stages.Length}: {stages[i].Name}");
|
||||
stages[i].Run(ctx);
|
||||
ctx.ReportProgress(stages[i].Name, (i + 1f) / stages.Length);
|
||||
}
|
||||
ctx.LogMessage("[WorldGen] Pipeline complete.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Run only stages 1–N (0-based index), used by tools and tests.
|
||||
/// </summary>
|
||||
public static void RunThrough(WorldGenContext ctx, int lastStageIndex)
|
||||
{
|
||||
var stages = BuildPipeline();
|
||||
int limit = Math.Min(lastStageIndex + 1, stages.Length);
|
||||
for (int i = 0; i < limit; i++)
|
||||
{
|
||||
stages[i].Run(ctx);
|
||||
ctx.ReportProgress(stages[i].Name, (i + 1f) / limit);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user