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,29 @@
|
||||
using Theriapolis.Core.Util;
|
||||
|
||||
namespace Theriapolis.Core.World;
|
||||
|
||||
/// <summary>
|
||||
/// A bridge where a road or rail polyline crosses a river.
|
||||
/// Endpoints are in world-pixel space and follow the actual road polyline,
|
||||
/// so the deck visually matches the road at the crossing regardless of meander.
|
||||
/// </summary>
|
||||
public readonly struct Bridge
|
||||
{
|
||||
public readonly Vec2 Start;
|
||||
public readonly Vec2 End;
|
||||
public readonly int RoadId;
|
||||
|
||||
public Bridge(Vec2 start, Vec2 end, int roadId)
|
||||
{
|
||||
Start = start;
|
||||
End = end;
|
||||
RoadId = roadId;
|
||||
}
|
||||
|
||||
/// <summary>Center of the deck (midpoint of Start–End).</summary>
|
||||
public float WorldPixelX => (Start.X + End.X) * 0.5f;
|
||||
public float WorldPixelY => (Start.Y + End.Y) * 0.5f;
|
||||
|
||||
/// <summary>Road direction angle (radians) from Start to End.</summary>
|
||||
public float RoadAngle => MathF.Atan2(End.Y - Start.Y, End.X - Start.X);
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
using Theriapolis.Core.World.Polylines;
|
||||
|
||||
namespace Theriapolis.Core.World;
|
||||
|
||||
/// <summary>
|
||||
/// Phase 5 M5: per-chunk threat-tier index. Drives which template each
|
||||
/// <see cref="Tactical.SpawnKind"/> instantiates: zone 0 = safest (footpads,
|
||||
/// pups), zone 4 = deepest wilds (captains, dire wolves, brown bears).
|
||||
///
|
||||
/// Computed once per chunk at instantiation time from biome + distance to
|
||||
/// player-start + distance to nearest road + distance to nearest settlement.
|
||||
/// Stored on <see cref="Tactical.TacticalChunk.DangerZone"/> and folded into
|
||||
/// the chunk's hash so determinism tests catch any formula drift.
|
||||
/// </summary>
|
||||
public static class DangerZone
|
||||
{
|
||||
/// <summary>
|
||||
/// Compute the danger zone for a given world-tile center. Pass the
|
||||
/// world's player-start tile so distance-from-start is meaningful even
|
||||
/// before the actual <see cref="Entities.PlayerActor"/> spawns.
|
||||
/// </summary>
|
||||
public static int Compute(int worldTileX, int worldTileY, WorldState world, int startTileX, int startTileY)
|
||||
{
|
||||
int zone = 0;
|
||||
zone += DistanceFromStartZone(worldTileX, worldTileY, startTileX, startTileY);
|
||||
zone += DistanceFromRoadZone(worldTileX, worldTileY, world);
|
||||
zone += DistanceFromSettlementZone(worldTileX, worldTileY, world);
|
||||
zone += BiomeDangerBonus(in world.TileAt(
|
||||
System.Math.Clamp(worldTileX, 0, C.WORLD_WIDTH_TILES - 1),
|
||||
System.Math.Clamp(worldTileY, 0, C.WORLD_HEIGHT_TILES - 1)));
|
||||
return System.Math.Clamp(zone, C.DANGER_ZONE_MIN, C.DANGER_ZONE_MAX);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Convenience: pick the player-start tile from the world (Tier-1
|
||||
/// settlement if any, else map centre) and compute the zone.
|
||||
/// </summary>
|
||||
public static int Compute(int worldTileX, int worldTileY, WorldState world)
|
||||
{
|
||||
var (sx, sy) = ResolveStartTile(world);
|
||||
return Compute(worldTileX, worldTileY, world, sx, sy);
|
||||
}
|
||||
|
||||
private static int DistanceFromStartZone(int x, int y, int startX, int startY)
|
||||
{
|
||||
int dist = ChebyshevDistance(x, y, startX, startY);
|
||||
return dist / C.DANGER_DIST_FROM_START_PER_ZONE;
|
||||
}
|
||||
|
||||
private static int DistanceFromRoadZone(int worldTileX, int worldTileY, WorldState world)
|
||||
{
|
||||
if (world.Roads.Count == 0) return 1; // no roads at all → treat as remote
|
||||
int worldPxX = worldTileX * C.WORLD_TILE_PIXELS + C.WORLD_TILE_PIXELS / 2;
|
||||
int worldPxY = worldTileY * C.WORLD_TILE_PIXELS + C.WORLD_TILE_PIXELS / 2;
|
||||
float minDistSq = float.MaxValue;
|
||||
foreach (var road in world.Roads)
|
||||
{
|
||||
foreach (var pt in road.Points)
|
||||
{
|
||||
float dx = pt.X - worldPxX;
|
||||
float dy = pt.Y - worldPxY;
|
||||
float d2 = dx * dx + dy * dy;
|
||||
if (d2 < minDistSq) minDistSq = d2;
|
||||
}
|
||||
}
|
||||
float distTiles = (float)System.Math.Sqrt(minDistSq) / C.WORLD_TILE_PIXELS;
|
||||
return distTiles > C.DANGER_DIST_FROM_ROAD_THRESHOLD ? 1 : 0;
|
||||
}
|
||||
|
||||
private static int DistanceFromSettlementZone(int worldTileX, int worldTileY, WorldState world)
|
||||
{
|
||||
if (world.Settlements.Count == 0) return 1;
|
||||
int minDistTiles = int.MaxValue;
|
||||
foreach (var s in world.Settlements)
|
||||
{
|
||||
int d = ChebyshevDistance(worldTileX, worldTileY, s.TileX, s.TileY);
|
||||
if (d < minDistTiles) minDistTiles = d;
|
||||
}
|
||||
return minDistTiles > C.DANGER_DIST_FROM_SETTLE_THRESHOLD ? 1 : 0;
|
||||
}
|
||||
|
||||
private static int BiomeDangerBonus(in WorldTile tile) => tile.Biome switch
|
||||
{
|
||||
// Settled / safe biomes
|
||||
BiomeId.TemperateGrassland => 0,
|
||||
BiomeId.TemperateDeciduous => 0,
|
||||
BiomeId.RiverValley => 0,
|
||||
BiomeId.Beach => 0,
|
||||
BiomeId.Coastal => 0,
|
||||
BiomeId.Ocean => 0, // not walkable but won't generate spawns either
|
||||
// Mid-danger biomes
|
||||
BiomeId.Boreal => 1,
|
||||
BiomeId.Wetland => 1,
|
||||
BiomeId.Tundra => 1,
|
||||
BiomeId.SubtropicalForest => 1,
|
||||
BiomeId.Scrubland => 1,
|
||||
BiomeId.MountainForested => 1,
|
||||
BiomeId.ForestEdge => 1,
|
||||
BiomeId.Foothills => 1,
|
||||
BiomeId.MarshEdge => 1,
|
||||
BiomeId.Mangrove => 1,
|
||||
// High-danger biomes
|
||||
BiomeId.MountainAlpine => 2,
|
||||
BiomeId.DesertCold => 2,
|
||||
BiomeId.TidalFlat => 1,
|
||||
BiomeId.Cliff => 2,
|
||||
_ => 1,
|
||||
};
|
||||
|
||||
private static int ChebyshevDistance(int x1, int y1, int x2, int y2)
|
||||
=> System.Math.Max(System.Math.Abs(x1 - x2), System.Math.Abs(y1 - y2));
|
||||
|
||||
/// <summary>Tier-1 settlement tile if available, else map centre.</summary>
|
||||
public static (int x, int y) ResolveStartTile(WorldState world)
|
||||
{
|
||||
foreach (var s in world.Settlements)
|
||||
{
|
||||
if (s.Tier == 1 && !s.IsPoi)
|
||||
return (s.TileX, s.TileY);
|
||||
}
|
||||
return (C.WORLD_WIDTH_TILES / 2, C.WORLD_HEIGHT_TILES / 2);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
namespace Theriapolis.Core.World;
|
||||
|
||||
public enum FactionId : byte
|
||||
{
|
||||
CovenantEnforcers = 0,
|
||||
Inheritors = 1,
|
||||
ThornCouncil = 2,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Per-tile influence map for the three primary factions.
|
||||
/// Influence[factionIndex, x, y] ∈ [0, 1].
|
||||
/// </summary>
|
||||
public sealed class FactionInfluenceMap
|
||||
{
|
||||
public const int FactionCount = 3;
|
||||
|
||||
// Flat array: [faction * W * H + y * W + x]
|
||||
private readonly float[] _data;
|
||||
private const int W = C.WORLD_WIDTH_TILES;
|
||||
private const int H = C.WORLD_HEIGHT_TILES;
|
||||
|
||||
public FactionInfluenceMap()
|
||||
{
|
||||
_data = new float[FactionCount * W * H];
|
||||
}
|
||||
|
||||
public float Get(int faction, int x, int y) => _data[faction * W * H + y * W + x];
|
||||
public void Set(int faction, int x, int y, float value) =>
|
||||
_data[faction * W * H + y * W + x] = value;
|
||||
|
||||
public void Add(int faction, int x, int y, float delta)
|
||||
{
|
||||
int idx = faction * W * H + y * W + x;
|
||||
_data[idx] = Math.Max(0f, Math.Min(1f, _data[idx] + delta));
|
||||
}
|
||||
|
||||
/// <summary>Returns the index of the faction with highest influence at this tile, or -1 if all zero.</summary>
|
||||
public int DominantFaction(int x, int y)
|
||||
{
|
||||
float best = 0f;
|
||||
int idx = -1;
|
||||
for (int f = 0; f < FactionCount; f++)
|
||||
{
|
||||
float v = Get(f, x, y);
|
||||
if (v > best) { best = v; idx = f; }
|
||||
}
|
||||
return idx;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
using Theriapolis.Core.Util;
|
||||
|
||||
namespace Theriapolis.Core.World.Polylines;
|
||||
|
||||
public enum PolylineType : byte { River, Road, Rail }
|
||||
public enum RiverClass : byte { Stream, River, MajorRiver }
|
||||
public enum RoadType : byte { Footpath, DirtRoad, PostRoad, Highway }
|
||||
|
||||
/// <summary>
|
||||
/// A polyline in world-pixel space (0..32768 on each axis).
|
||||
/// Source of truth for rivers, roads, and rail. Per-tile flags on WorldTile are derived caches.
|
||||
/// </summary>
|
||||
public sealed class Polyline
|
||||
{
|
||||
public PolylineType Type { get; init; }
|
||||
public int Id { get; init; }
|
||||
public List<Vec2> Points { get; } = new();
|
||||
public List<Vec2>? SimplifiedPoints { get; set; }
|
||||
public float Width { get; set; }
|
||||
|
||||
// River-specific
|
||||
public RiverClass RiverClassification { get; set; }
|
||||
public int FlowAccumulation { get; set; }
|
||||
|
||||
// Road-specific
|
||||
public RoadType RoadClassification { get; set; }
|
||||
|
||||
// Infrastructure-specific (source/destination settlement IDs)
|
||||
public int FromSettlementId { get; set; } = -1;
|
||||
public int ToSettlementId { get; set; } = -1;
|
||||
|
||||
public bool IsEmpty => Points.Count < 2;
|
||||
}
|
||||
@@ -0,0 +1,442 @@
|
||||
using Theriapolis.Core.Util;
|
||||
using Theriapolis.Core.World;
|
||||
|
||||
namespace Theriapolis.Core.World.Polylines;
|
||||
|
||||
/// <summary>
|
||||
/// Static helpers for building, smoothing, and rasterizing polylines in world-pixel space.
|
||||
/// </summary>
|
||||
public static class PolylineBuilder
|
||||
{
|
||||
// ── Catmull-Rom spline smoothing ─────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Apply Catmull-Rom spline smoothing to a list of control points.
|
||||
/// Produces <see cref="C.SPLINE_SUBDIVISIONS"/> intermediate points per segment.
|
||||
/// </summary>
|
||||
public static List<Vec2> CatmullRomSmooth(IReadOnlyList<Vec2> points, int subdivisions = C.SPLINE_SUBDIVISIONS)
|
||||
{
|
||||
var result = new List<Vec2>(points.Count * subdivisions);
|
||||
if (points.Count < 2) { result.AddRange(points); return result; }
|
||||
|
||||
for (int i = 0; i < points.Count - 1; i++)
|
||||
{
|
||||
var p0 = points[Math.Max(0, i - 1)];
|
||||
var p1 = points[i];
|
||||
var p2 = points[i + 1];
|
||||
var p3 = points[Math.Min(points.Count - 1, i + 2)];
|
||||
|
||||
for (int s = 0; s < subdivisions; s++)
|
||||
{
|
||||
float t = s / (float)subdivisions;
|
||||
result.Add(CatmullRomPoint(p0, p1, p2, p3, t));
|
||||
}
|
||||
}
|
||||
result.Add(points[^1]);
|
||||
return result;
|
||||
}
|
||||
|
||||
private static Vec2 CatmullRomPoint(Vec2 p0, Vec2 p1, Vec2 p2, Vec2 p3, float t)
|
||||
{
|
||||
float t2 = t * t;
|
||||
float t3 = t2 * t;
|
||||
float x = 0.5f * ((2f * p1.X)
|
||||
+ (-p0.X + p2.X) * t
|
||||
+ (2f * p0.X - 5f * p1.X + 4f * p2.X - p3.X) * t2
|
||||
+ (-p0.X + 3f * p1.X - 3f * p2.X + p3.X) * t3);
|
||||
float y = 0.5f * ((2f * p1.Y)
|
||||
+ (-p0.Y + p2.Y) * t
|
||||
+ (2f * p0.Y - 5f * p1.Y + 4f * p2.Y - p3.Y) * t2
|
||||
+ (-p0.Y + 3f * p1.Y - 3f * p2.Y + p3.Y) * t3);
|
||||
return new Vec2(x, y);
|
||||
}
|
||||
|
||||
// ── Perpendicular meander noise ──────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Apply a perpendicular noise offset to each point along a smoothed polyline.
|
||||
/// Uses FastNoiseLite for deterministic, seeded noise.
|
||||
/// </summary>
|
||||
public static void ApplyMeanderNoise(
|
||||
List<Vec2> points,
|
||||
float amplitude,
|
||||
float frequency,
|
||||
ulong noiseSeed)
|
||||
{
|
||||
if (points.Count < 2) return;
|
||||
|
||||
// Build a FastNoiseLite instance seeded for this polyline
|
||||
var noise = new FastNoiseLite
|
||||
{
|
||||
Seed = (int)(noiseSeed & 0x7FFFFFFFu),
|
||||
Noise = FastNoiseLite.NoiseType.OpenSimplex2,
|
||||
Frequency = frequency,
|
||||
};
|
||||
|
||||
for (int i = 0; i < points.Count; i++)
|
||||
{
|
||||
// Perpendicular direction at this point
|
||||
Vec2 tangent;
|
||||
if (i == 0)
|
||||
tangent = (points[1] - points[0]).Normalized;
|
||||
else if (i == points.Count - 1)
|
||||
tangent = (points[^1] - points[^2]).Normalized;
|
||||
else
|
||||
tangent = (points[i + 1] - points[i - 1]).Normalized;
|
||||
|
||||
Vec2 perp = tangent.Perp;
|
||||
|
||||
// Sample noise at world-pixel position
|
||||
float offset = noise.GetNoise(points[i].X * 0.01f, points[i].Y * 0.01f) * amplitude;
|
||||
points[i] = points[i] + perp * offset;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Ramer-Douglas-Peucker simplification ─────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Ramer-Douglas-Peucker polyline simplification for LOD rendering.
|
||||
/// Returns a simplified point list with perpendicular distance tolerance in world pixels.
|
||||
/// </summary>
|
||||
public static List<Vec2> RDPSimplify(IReadOnlyList<Vec2> points, float tolerance = C.RDP_TOLERANCE)
|
||||
{
|
||||
if (points.Count <= 2) return new List<Vec2>(points);
|
||||
|
||||
var result = new List<Vec2>();
|
||||
var stack = new Stack<(int start, int end)>();
|
||||
var keep = new bool[points.Count];
|
||||
keep[0] = keep[^1] = true;
|
||||
|
||||
stack.Push((0, points.Count - 1));
|
||||
while (stack.Count > 0)
|
||||
{
|
||||
var (start, end) = stack.Pop();
|
||||
float maxDist = 0f;
|
||||
int maxIndex = start;
|
||||
|
||||
for (int i = start + 1; i < end; i++)
|
||||
{
|
||||
float d = PerpendicularDistance(points[i], points[start], points[end]);
|
||||
if (d > maxDist) { maxDist = d; maxIndex = i; }
|
||||
}
|
||||
|
||||
if (maxDist > tolerance)
|
||||
{
|
||||
keep[maxIndex] = true;
|
||||
stack.Push((start, maxIndex));
|
||||
stack.Push((maxIndex, end));
|
||||
}
|
||||
}
|
||||
|
||||
for (int i = 0; i < points.Count; i++)
|
||||
if (keep[i]) result.Add(points[i]);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static float PerpendicularDistance(Vec2 pt, Vec2 lineStart, Vec2 lineEnd)
|
||||
{
|
||||
Vec2 d = lineEnd - lineStart;
|
||||
float len = d.Length;
|
||||
if (len < 1e-6f) return Vec2.Dist(pt, lineStart);
|
||||
return MathF.Abs(Vec2.Dot(d.Perp, pt - lineStart)) / len;
|
||||
}
|
||||
|
||||
// ── Tile flag rasterization ──────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Rasterizes a polyline (in world-pixel space) onto the world tile grid,
|
||||
/// setting the appropriate FeatureFlags and direction on each touched tile and its neighbors.
|
||||
/// </summary>
|
||||
public static void RasterizeToTileFlags(
|
||||
WorldState world,
|
||||
Polyline polyline,
|
||||
bool setAdjacency = true)
|
||||
{
|
||||
var pts = polyline.Points;
|
||||
if (pts.Count < 2) return;
|
||||
|
||||
int px = C.WORLD_TILE_PIXELS;
|
||||
|
||||
for (int seg = 0; seg < pts.Count - 1; seg++)
|
||||
{
|
||||
int x0 = (int)(pts[seg].X / px);
|
||||
int y0 = (int)(pts[seg].Y / px);
|
||||
int x1 = (int)(pts[seg + 1].X / px);
|
||||
int y1 = (int)(pts[seg + 1].Y / px);
|
||||
|
||||
// Direction of this segment
|
||||
int ddx = Math.Sign(x1 - x0);
|
||||
int ddy = Math.Sign(y1 - y0);
|
||||
byte dir = Dir.FromDelta(ddx == 0 && ddy == 0 ? 0 : ddx, ddy);
|
||||
|
||||
// Bresenham line in tile space
|
||||
BresenhamLine(x0, y0, x1, y1, (tx, ty) =>
|
||||
{
|
||||
if ((uint)tx >= C.WORLD_WIDTH_TILES || (uint)ty >= C.WORLD_HEIGHT_TILES) return;
|
||||
if (world.TileAt(tx, ty).Biome == BiomeId.Ocean) return;
|
||||
ref var tile = ref world.TileAt(tx, ty);
|
||||
|
||||
switch (polyline.Type)
|
||||
{
|
||||
case PolylineType.River:
|
||||
tile.Features |= FeatureFlags.HasRiver;
|
||||
if (tile.RiverFlowDir == Dir.None) tile.RiverFlowDir = dir;
|
||||
break;
|
||||
case PolylineType.Road:
|
||||
// Allow HasRoad on river tiles (bridge crossings) so that
|
||||
// SplitByExistingFeature treats shared crossings as existing road
|
||||
// and doesn't create redundant short segments.
|
||||
// Still skip rail tiles (Addendum A §2).
|
||||
if ((tile.Features & FeatureFlags.HasRail) == 0 ||
|
||||
(tile.Features & FeatureFlags.IsSettlement) != 0)
|
||||
tile.Features |= FeatureFlags.HasRoad;
|
||||
break;
|
||||
case PolylineType.Rail:
|
||||
// Bridges: don't mark a river tile as also having rail.
|
||||
// The crossing is implied by the route; co-location would be a violation.
|
||||
if ((tile.Features & FeatureFlags.HasRiver) == 0 ||
|
||||
(tile.Features & FeatureFlags.IsSettlement) != 0)
|
||||
{
|
||||
tile.Features |= FeatureFlags.HasRail;
|
||||
if (tile.RailDir == Dir.None) tile.RailDir = dir;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
if (!setAdjacency) return;
|
||||
|
||||
// Mark 8 neighbors as adjacent
|
||||
for (int ny = ty - 1; ny <= ty + 1; ny++)
|
||||
for (int nx = tx - 1; nx <= tx + 1; nx++)
|
||||
{
|
||||
if (nx == tx && ny == ty) continue;
|
||||
if ((uint)nx >= C.WORLD_WIDTH_TILES || (uint)ny >= C.WORLD_HEIGHT_TILES) continue;
|
||||
if (world.TileAt(nx, ny).Biome == BiomeId.Ocean) continue;
|
||||
ref var neighbor = ref world.TileAt(nx, ny);
|
||||
|
||||
switch (polyline.Type)
|
||||
{
|
||||
case PolylineType.River:
|
||||
neighbor.Features |= FeatureFlags.RiverAdjacent;
|
||||
if (neighbor.RiverFlowDir == Dir.None) neighbor.RiverFlowDir = dir;
|
||||
break;
|
||||
case PolylineType.Rail:
|
||||
neighbor.Features |= FeatureFlags.RailroadAdjacent;
|
||||
if (neighbor.RailDir == Dir.None) neighbor.RailDir = dir;
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static void BresenhamLine(int x0, int y0, int x1, int y1, Action<int, int> visit)
|
||||
{
|
||||
int dx = Math.Abs(x1 - x0);
|
||||
int dy = Math.Abs(y1 - y0);
|
||||
int sx = x0 < x1 ? 1 : -1;
|
||||
int sy = y0 < y1 ? 1 : -1;
|
||||
int err = dx - dy;
|
||||
|
||||
while (true)
|
||||
{
|
||||
visit(x0, y0);
|
||||
if (x0 == x1 && y0 == y1) break;
|
||||
int e2 = 2 * err;
|
||||
if (e2 > -dy) { err -= dy; x0 += sx; }
|
||||
if (e2 < dx) { err += dx; y0 += sy; }
|
||||
}
|
||||
}
|
||||
|
||||
// ── Staircase smoothing ───────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Post-processes an A* tile path to reduce staircase artefacts in the
|
||||
/// final smoothed polyline. A* tiebreaking can produce sequences like
|
||||
/// <c>SE, E, SE, SE, SE</c> where a cardinal move lands in the middle of
|
||||
/// runs of diagonal moves — Catmull-Rom smoothing then develops a visible
|
||||
/// kink.
|
||||
///
|
||||
/// This walks the path looking for (cardinal, diagonal) move pairs and
|
||||
/// swaps them to (diagonal, cardinal) when the alternative intermediate
|
||||
/// tile is free of preserve-flag features and remains passable under
|
||||
/// <paramref name="costFn"/>. Leaves total movement cost unchanged (the
|
||||
/// two legs just get reordered) so A*'s optimality choice is respected.
|
||||
/// Bubbling is forward-only, so any remaining cardinals end up at the
|
||||
/// end of diagonal runs.
|
||||
/// </summary>
|
||||
public static void SmoothStaircases(
|
||||
WorldState world,
|
||||
List<(int X, int Y)> path,
|
||||
FeatureFlags preserveMask,
|
||||
Func<int, int, int, int, byte, float> costFn)
|
||||
{
|
||||
if (path.Count < 3) return;
|
||||
|
||||
bool changed = true;
|
||||
int guard = path.Count; // bound passes to prevent oscillation
|
||||
while (changed && guard-- > 0)
|
||||
{
|
||||
changed = false;
|
||||
for (int i = 0; i < path.Count - 2; i++)
|
||||
{
|
||||
var a = path[i];
|
||||
var b = path[i + 1];
|
||||
var c = path[i + 2];
|
||||
|
||||
// Preserve tiles with load-bearing features (existing road/rail/
|
||||
// river bridges, settlement footprint). Those are deliberate A*
|
||||
// choices and junction/endpoint anchors downstream rely on them.
|
||||
var bFeatures = world.Tiles[b.X, b.Y].Features;
|
||||
if ((bFeatures & preserveMask) != 0) continue;
|
||||
|
||||
int dx1 = b.X - a.X, dy1 = b.Y - a.Y;
|
||||
int dx2 = c.X - b.X, dy2 = c.Y - b.Y;
|
||||
|
||||
bool isCard1 = (dx1 == 0) ^ (dy1 == 0);
|
||||
bool isDiag2 = (dx2 != 0) && (dy2 != 0);
|
||||
if (!isCard1 || !isDiag2) continue;
|
||||
|
||||
// Alternative intermediate: do the diagonal first, then cardinal.
|
||||
int nbx = a.X + dx2, nby = a.Y + dy2;
|
||||
if ((uint)nbx >= C.WORLD_WIDTH_TILES || (uint)nby >= C.WORLD_HEIGHT_TILES) continue;
|
||||
|
||||
// Don't swap into a tile whose features A* would have weighed
|
||||
// differently — stays equivalent in character to the old b.
|
||||
if ((world.Tiles[nbx, nby].Features & preserveMask) != 0) continue;
|
||||
|
||||
byte dirToNb = Dir.FromDelta(dx2, dy2);
|
||||
byte dirToC = Dir.FromDelta(dx1, dy1);
|
||||
if (float.IsPositiveInfinity(costFn(a.X, a.Y, nbx, nby, dirToNb))) continue;
|
||||
if (float.IsPositiveInfinity(costFn(nbx, nby, c.X, c.Y, dirToC))) continue;
|
||||
|
||||
path[i + 1] = (nbx, nby);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Turn-angle limiter ────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Caps the deflection angle at every internal vertex of an A* tile path.
|
||||
/// A vertex whose turn (angle between incoming and outgoing edges) exceeds
|
||||
/// <paramref name="maxTurnDegrees"/> is elided when the shortcut from its
|
||||
/// predecessor to successor is a single passable A* step under
|
||||
/// <paramref name="costFn"/>. Vertices carrying any flag in
|
||||
/// <paramref name="preserveMask"/> are never removed — those are load-bearing
|
||||
/// (existing rail/road junctions, river-bridge crossings, settlement footprint),
|
||||
/// matching the contract used by <see cref="SmoothStaircases"/>.
|
||||
///
|
||||
/// Used by rail generation to avoid 90°/135° bends that look unrealistic
|
||||
/// for heavy rail traffic. The grid produces turns in 45° steps, so a cap
|
||||
/// of 75° permits 0°/45° turns and forces 90°/135° corners to be smoothed
|
||||
/// into two consecutive diagonals.
|
||||
/// </summary>
|
||||
public static void LimitTurnAngle(
|
||||
WorldState world,
|
||||
List<(int X, int Y)> path,
|
||||
float maxTurnDegrees,
|
||||
FeatureFlags preserveMask,
|
||||
Func<int, int, int, int, byte, float> costFn)
|
||||
{
|
||||
if (path.Count < 3) return;
|
||||
float minCosAllowed = MathF.Cos(maxTurnDegrees * MathF.PI / 180f);
|
||||
|
||||
bool changed = true;
|
||||
int guard = path.Count * 2;
|
||||
while (changed && guard-- > 0)
|
||||
{
|
||||
changed = false;
|
||||
for (int i = 1; i < path.Count - 1; i++)
|
||||
{
|
||||
var a = path[i - 1];
|
||||
var b = path[i];
|
||||
var c = path[i + 1];
|
||||
|
||||
float vx1 = b.X - a.X, vy1 = b.Y - a.Y;
|
||||
float vx2 = c.X - b.X, vy2 = c.Y - b.Y;
|
||||
float l1 = MathF.Sqrt(vx1 * vx1 + vy1 * vy1);
|
||||
float l2 = MathF.Sqrt(vx2 * vx2 + vy2 * vy2);
|
||||
if (l1 < 1e-6f || l2 < 1e-6f) continue;
|
||||
float cosTurn = (vx1 * vx2 + vy1 * vy2) / (l1 * l2);
|
||||
if (cosTurn >= minCosAllowed) continue; // already gentle enough
|
||||
|
||||
// Keep load-bearing vertices — junctions, bridges, endpoints.
|
||||
if ((world.Tiles[b.X, b.Y].Features & preserveMask) != 0) continue;
|
||||
|
||||
// Shortcut a→c must be a single-step move (Chebyshev 1) and
|
||||
// remain passable. Larger gaps would span multi-tile stretches
|
||||
// the pathfinder never evaluated.
|
||||
int dx = c.X - a.X, dy = c.Y - a.Y;
|
||||
if (dx == 0 && dy == 0) continue;
|
||||
if (Math.Max(Math.Abs(dx), Math.Abs(dy)) != 1) continue;
|
||||
byte dir = Dir.FromDelta(Math.Sign(dx), Math.Sign(dy));
|
||||
if (float.IsPositiveInfinity(costFn(a.X, a.Y, c.X, c.Y, dir))) continue;
|
||||
|
||||
path.RemoveAt(i);
|
||||
changed = true;
|
||||
break; // restart — indices shifted
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Path splitting for shared infrastructure ──────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Splits a tile path into sub-paths covering only "new construction" — runs of tiles
|
||||
/// that do NOT already have the given feature flag. Each sub-path includes one overlap
|
||||
/// tile at each junction so the smoothed polyline visually connects to the existing feature.
|
||||
/// Returns an empty list if the entire path is already covered.
|
||||
///
|
||||
/// </summary>
|
||||
public static List<List<(int X, int Y)>> SplitByExistingFeature(
|
||||
WorldState world,
|
||||
List<(int X, int Y)> path,
|
||||
FeatureFlags feature)
|
||||
{
|
||||
var segments = new List<List<(int X, int Y)>>();
|
||||
List<(int X, int Y)>? current = null;
|
||||
|
||||
for (int i = 0; i < path.Count; i++)
|
||||
{
|
||||
var (x, y) = path[i];
|
||||
var tileFeatures = world.Tiles[x, y].Features;
|
||||
bool exists = (tileFeatures & feature) != 0;
|
||||
|
||||
if (!exists)
|
||||
{
|
||||
if (current == null)
|
||||
{
|
||||
current = new List<(int X, int Y)>();
|
||||
// Include the last "existing" tile as a junction anchor
|
||||
if (i > 0) current.Add(path[i - 1]);
|
||||
}
|
||||
current.Add(path[i]);
|
||||
}
|
||||
else if (current != null)
|
||||
{
|
||||
// Ending a new-construction run — include this existing tile as endpoint anchor
|
||||
current.Add(path[i]);
|
||||
if (current.Count >= 2) segments.Add(current);
|
||||
current = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Trailing new-construction segment (path ends on new tiles)
|
||||
if (current != null && current.Count >= 2)
|
||||
segments.Add(current);
|
||||
|
||||
return segments;
|
||||
}
|
||||
|
||||
// ── Control point from tile coords ───────────────────────────────────────
|
||||
|
||||
/// <summary>Convert world tile coordinate to world-pixel center.</summary>
|
||||
public static Vec2 TileToWorldPixel(int tileX, int tileY)
|
||||
{
|
||||
float px = C.WORLD_TILE_PIXELS;
|
||||
return new Vec2(tileX * px + px * 0.5f, tileY * px + px * 0.5f);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
namespace Theriapolis.Core.World;
|
||||
|
||||
public enum NarrativeAnchor : byte
|
||||
{
|
||||
Millhaven, Thornfield, FortDustwall, TheTangles,
|
||||
SanctumFidelis, Heartstone
|
||||
}
|
||||
|
||||
public enum SettlementEconomy : byte
|
||||
{
|
||||
Farming, Mining, Manufacturing, Trade, Military, Fishing
|
||||
}
|
||||
|
||||
public enum SettlementGovernance : byte
|
||||
{
|
||||
Council, Mayor, MilitaryCommandant, ClanElder, Corporate, Anarchic
|
||||
}
|
||||
|
||||
public enum PoiType : byte
|
||||
{
|
||||
None, ImperiumRuin, AbandonedMine, CultDen, NaturalCave, OvergrownSettlement
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A placed settlement or point of interest on the world map.
|
||||
/// Tier 1–4 are inhabited settlements; Tier 5 are PoIs (IsPoi == true).
|
||||
/// </summary>
|
||||
public sealed class Settlement
|
||||
{
|
||||
public int Id { get; init; }
|
||||
public string Name { get; set; } = "";
|
||||
public int Tier { get; init; }
|
||||
public int TileX { get; init; }
|
||||
public int TileY { get; init; }
|
||||
|
||||
/// <summary>World-pixel X (tile center).</summary>
|
||||
public float WorldPixelX => TileX * C.WORLD_TILE_PIXELS + C.WORLD_TILE_PIXELS * 0.5f;
|
||||
/// <summary>World-pixel Y (tile center).</summary>
|
||||
public float WorldPixelY => TileY * C.WORLD_TILE_PIXELS + C.WORLD_TILE_PIXELS * 0.5f;
|
||||
|
||||
/// <summary>Narrative anchor this settlement fulfills, or null for non-anchors.</summary>
|
||||
public NarrativeAnchor? Anchor { get; init; }
|
||||
|
||||
// ── Generated attributes (set by SettlementAttributesStage) ─────────────
|
||||
public SettlementEconomy Economy { get; set; }
|
||||
public SettlementGovernance Governance { get; set; }
|
||||
public string[] CladeRatios { get; set; } = Array.Empty<string>();
|
||||
public float WealthLevel { get; set; }
|
||||
public int Population { get; set; }
|
||||
public float HybridPct { get; set; }
|
||||
public string ScentProfile { get; set; } = "";
|
||||
|
||||
// ── Derived after infrastructure gen ────────────────────────────────────
|
||||
public bool HasRailStation { get; set; }
|
||||
public bool IsOnRiver { get; set; }
|
||||
|
||||
// ── PoI-specific ─────────────────────────────────────────────────────────
|
||||
public bool IsPoi { get; init; }
|
||||
public PoiType PoiType { get; set; }
|
||||
|
||||
// ── Phase 6 M0 — building footprints ────────────────────────────────────
|
||||
/// <summary>
|
||||
/// Buildings stamped inside this settlement, derived deterministically
|
||||
/// from the matched <see cref="Data.SettlementLayoutDef"/>. Populated
|
||||
/// lazily by <see cref="Settlements.SettlementStamper"/> on first chunk
|
||||
/// generation that touches this settlement; identical across reloads.
|
||||
/// </summary>
|
||||
public List<Settlements.BuildingFootprint> Buildings { get; } = new();
|
||||
|
||||
/// <summary>True once <see cref="Buildings"/> has been resolved.</summary>
|
||||
public bool BuildingsResolved { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
using Theriapolis.Core.Entities;
|
||||
|
||||
namespace Theriapolis.Core.World.Settlements;
|
||||
|
||||
/// <summary>
|
||||
/// Phase 6 M1 — runtime map between symbolic ids used by quest scripts /
|
||||
/// dialogue conditions and live world entities. Quest scripts never embed
|
||||
/// world coordinates; they reference NPCs by role tag and locations by
|
||||
/// anchor id (per master plan §8.4):
|
||||
///
|
||||
/// <code>
|
||||
/// anchor:millhaven → the live Settlement
|
||||
/// role:millhaven.innkeeper → the live NpcActor for that named role
|
||||
/// </code>
|
||||
///
|
||||
/// Built lazily as chunks stream in: when a settlement's buildings resolve
|
||||
/// (and any named NPC instantiates), the entry registers here. Phase 6 M2
|
||||
/// persists the registry; M1 rebuilds it on every load from the live
|
||||
/// settlement list and active NpcActors.
|
||||
/// </summary>
|
||||
public sealed class AnchorRegistry
|
||||
{
|
||||
private readonly Dictionary<string, int> _anchorToSettlementId = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly Dictionary<string, int> _roleToNpcId = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>Register a settlement under its anchor id (e.g. "anchor:millhaven").</summary>
|
||||
public void RegisterAnchor(NarrativeAnchor anchor, int settlementId)
|
||||
{
|
||||
string key = $"anchor:{anchor.ToString().ToLowerInvariant()}";
|
||||
_anchorToSettlementId[key] = settlementId;
|
||||
}
|
||||
|
||||
/// <summary>Register an NpcActor under its named role tag (e.g. "role:millhaven.innkeeper").</summary>
|
||||
public void RegisterRole(string roleTag, int npcId)
|
||||
{
|
||||
if (string.IsNullOrEmpty(roleTag)) return;
|
||||
if (!roleTag.Contains('.')) return; // generic role; only named (anchor.role) roles register
|
||||
_roleToNpcId[$"role:{roleTag.ToLowerInvariant()}"] = npcId;
|
||||
}
|
||||
|
||||
/// <summary>Forget the role mapping (called on chunk evict / NPC despawn).</summary>
|
||||
public void UnregisterRole(string roleTag)
|
||||
{
|
||||
if (string.IsNullOrEmpty(roleTag)) return;
|
||||
_roleToNpcId.Remove($"role:{roleTag.ToLowerInvariant()}");
|
||||
}
|
||||
|
||||
/// <summary>Resolve "anchor:millhaven" → SettlementId (or null when not registered yet).</summary>
|
||||
public int? ResolveAnchor(string id)
|
||||
{
|
||||
return _anchorToSettlementId.TryGetValue(id, out int sid) ? sid : null;
|
||||
}
|
||||
|
||||
/// <summary>Resolve "role:millhaven.innkeeper" → NpcId (or null when not loaded / not yet streamed).</summary>
|
||||
public int? ResolveRole(string id)
|
||||
{
|
||||
return _roleToNpcId.TryGetValue(id, out int nid) ? nid : null;
|
||||
}
|
||||
|
||||
/// <summary>Bulk re-register every settlement's anchor (e.g. after world load).</summary>
|
||||
public void RegisterAllAnchors(WorldState world)
|
||||
{
|
||||
foreach (var s in world.Settlements)
|
||||
if (s.Anchor is { } a)
|
||||
RegisterAnchor(a, s.Id);
|
||||
}
|
||||
|
||||
/// <summary>For diagnostics: every (id → entityId) mapping currently held.</summary>
|
||||
public IReadOnlyDictionary<string, int> AllAnchors => _anchorToSettlementId;
|
||||
public IReadOnlyDictionary<string, int> AllRoles => _roleToNpcId;
|
||||
|
||||
public void Clear()
|
||||
{
|
||||
_anchorToSettlementId.Clear();
|
||||
_roleToNpcId.Clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
namespace Theriapolis.Core.World.Settlements;
|
||||
|
||||
/// <summary>
|
||||
/// Phase 6 M0 — runtime record of a single stamped building inside a
|
||||
/// settlement. Created by <see cref="SettlementStamper"/> at chunk-gen time
|
||||
/// and attached to the parent <see cref="World.Settlement"/>.
|
||||
///
|
||||
/// Buildings can straddle chunk boundaries; the footprint is in
|
||||
/// world-pixel (= tactical-tile) coordinates so cross-chunk lookups
|
||||
/// (e.g. "is the player inside the Millhaven inn?") work without per-chunk
|
||||
/// reconstruction.
|
||||
/// </summary>
|
||||
public sealed class BuildingFootprint
|
||||
{
|
||||
/// <summary>Unique id within the parent settlement (sequential, 0-based).</summary>
|
||||
public int Id { get; init; }
|
||||
|
||||
/// <summary>Building template id (e.g. "inn_small").</summary>
|
||||
public string TemplateId { get; init; } = "";
|
||||
|
||||
/// <summary>Inclusive minimum X in world-pixel space.</summary>
|
||||
public int MinX { get; init; }
|
||||
|
||||
/// <summary>Inclusive minimum Y in world-pixel space.</summary>
|
||||
public int MinY { get; init; }
|
||||
|
||||
/// <summary>Inclusive maximum X in world-pixel space.</summary>
|
||||
public int MaxX { get; init; }
|
||||
|
||||
/// <summary>Inclusive maximum Y in world-pixel space.</summary>
|
||||
public int MaxY { get; init; }
|
||||
|
||||
/// <summary>Door positions in world-pixel space (one entry per door).</summary>
|
||||
public (int X, int Y)[] Doors { get; init; } = Array.Empty<(int, int)>();
|
||||
|
||||
/// <summary>Resident slots: role tag (possibly anchor-prefixed) → spawn position in world-pixel space.</summary>
|
||||
public BuildingResidentSlot[] Residents { get; init; } = Array.Empty<BuildingResidentSlot>();
|
||||
|
||||
public bool ContainsTile(int worldPxX, int worldPxY)
|
||||
=> worldPxX >= MinX && worldPxX <= MaxX
|
||||
&& worldPxY >= MinY && worldPxY <= MaxY;
|
||||
}
|
||||
|
||||
/// <summary>One resident slot inside a building.</summary>
|
||||
public readonly struct BuildingResidentSlot
|
||||
{
|
||||
/// <summary>Role tag — either generic ("innkeeper") or anchor-qualified ("millhaven.innkeeper").</summary>
|
||||
public readonly string RoleTag;
|
||||
|
||||
/// <summary>Spawn point in world-pixel (tactical-tile) coordinates.</summary>
|
||||
public readonly int SpawnX;
|
||||
public readonly int SpawnY;
|
||||
|
||||
/// <summary>Optional category match for procedural residents — passed through from BuildingTemplateDef.Category.</summary>
|
||||
public readonly string Category;
|
||||
|
||||
public BuildingResidentSlot(string roleTag, int spawnX, int spawnY, string category)
|
||||
{
|
||||
RoleTag = roleTag;
|
||||
SpawnX = spawnX;
|
||||
SpawnY = spawnY;
|
||||
Category = category;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,465 @@
|
||||
using Theriapolis.Core.Data;
|
||||
using Theriapolis.Core.Tactical;
|
||||
using Theriapolis.Core.Util;
|
||||
|
||||
namespace Theriapolis.Core.World.Settlements;
|
||||
|
||||
/// <summary>
|
||||
/// Phase 6 M0 — drives building stamping for tactical chunks.
|
||||
///
|
||||
/// The original Phase 4 plan promised template-driven building burn-in
|
||||
/// (<c>theriapolis-rpg-implementation-plan-phase4.md §3.4 step 3</c>) but
|
||||
/// only a placeholder cobble plaza + outer wall ring shipped. This module
|
||||
/// catches that up.
|
||||
///
|
||||
/// Two paths:
|
||||
/// 1. **Content-driven** — when a <see cref="SettlementContent"/> is
|
||||
/// available the stamper resolves a <see cref="SettlementLayoutDef"/>
|
||||
/// for each settlement (preset by anchor, else procedural by tier),
|
||||
/// materialises buildings on the settlement (lazily, once), and stamps
|
||||
/// walls/floors/doors/decos for any building intersecting the chunk.
|
||||
/// Per-resident <see cref="TacticalSpawn"/>s are emitted for Phase 6 M1
|
||||
/// instantiation.
|
||||
/// 2. **Fallback** — when content is unavailable (e.g. headless tools,
|
||||
/// early-stage tests) the stamper writes the original cobble plaza +
|
||||
/// outer wall ring so the existing Phase 5 visual baseline holds.
|
||||
///
|
||||
/// Determinism:
|
||||
/// - Building list per settlement is resolved with a SeededRng keyed by
|
||||
/// <c>worldSeed ^ RNG_BUILDING_LAYOUT ^ settlementId</c>; identical across
|
||||
/// reloads.
|
||||
/// - Procedural Tier 2–5 layouts roll templates and offsets from this RNG;
|
||||
/// preset (Tier 1 / anchor) layouts are entirely data-driven.
|
||||
/// </summary>
|
||||
public static class SettlementStamper
|
||||
{
|
||||
/// <summary>
|
||||
/// Half-width (in tactical tiles) of the gateway carved through a
|
||||
/// settlement's wall ring wherever a road passes. Sized to the widest
|
||||
/// road the world can produce plus 1-tile slop on each side. Mirrors
|
||||
/// the Phase-4 placeholder constant.
|
||||
/// </summary>
|
||||
private const int GatewayHaloTiles = 3;
|
||||
|
||||
public static void Stamp(
|
||||
ulong worldSeed,
|
||||
TacticalChunk chunk,
|
||||
WorldState world,
|
||||
SettlementContent? content)
|
||||
{
|
||||
int x0 = chunk.OriginX;
|
||||
int y0 = chunk.OriginY;
|
||||
int x1 = x0 + C.TACTICAL_CHUNK_SIZE;
|
||||
int y1 = y0 + C.TACTICAL_CHUNK_SIZE;
|
||||
|
||||
foreach (var s in world.Settlements)
|
||||
{
|
||||
// Cheap chunk-vs-settlement overlap reject — covers both the
|
||||
// plaza ring (Phase 5 placeholder behaviour) and any building
|
||||
// footprints we may have stamped on the settlement.
|
||||
if (!OverlapsChunk(s, content, x0, y0, x1, y1)) continue;
|
||||
|
||||
// Always lay the plaza + ring first; buildings stamp on top.
|
||||
StampPlazaAndWallRing(chunk, s, x0, y0, x1, y1);
|
||||
|
||||
if (content is not null)
|
||||
StampBuildings(worldSeed, chunk, s, content, x0, y0, x1, y1);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Lazy resolution of per-settlement building list ─────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Build the settlement's <see cref="Settlement.Buildings"/> list from
|
||||
/// its matched layout. Idempotent — only runs once per settlement.
|
||||
/// </summary>
|
||||
public static void EnsureBuildingsResolved(ulong worldSeed, Settlement s, SettlementContent content)
|
||||
{
|
||||
if (s.BuildingsResolved) return;
|
||||
var layout = content.ResolveFor(s);
|
||||
if (layout is null)
|
||||
{
|
||||
s.BuildingsResolved = true; // nothing to stamp — bail with empty list
|
||||
return;
|
||||
}
|
||||
|
||||
var rng = SeededRng.ForSubsystem(worldSeed, C.RNG_BUILDING_LAYOUT ^ (ulong)s.Id);
|
||||
var buildings = layout.Kind == "preset"
|
||||
? ResolvePreset(s, layout, content)
|
||||
: ResolveProcedural(s, layout, content, rng);
|
||||
|
||||
s.Buildings.Clear();
|
||||
s.Buildings.AddRange(buildings);
|
||||
s.BuildingsResolved = true;
|
||||
}
|
||||
|
||||
private static IEnumerable<BuildingFootprint> ResolvePreset(
|
||||
Settlement s,
|
||||
SettlementLayoutDef layout,
|
||||
SettlementContent content)
|
||||
{
|
||||
int cxPx = (int)s.WorldPixelX;
|
||||
int cyPx = (int)s.WorldPixelY;
|
||||
int next = 0;
|
||||
|
||||
foreach (var p in layout.Buildings)
|
||||
{
|
||||
if (!content.Buildings.TryGetValue(p.Template, out var def)) continue;
|
||||
int ox = p.Offset.Length >= 1 ? p.Offset[0] : 0;
|
||||
int oy = p.Offset.Length >= 2 ? p.Offset[1] : 0;
|
||||
|
||||
int minX = cxPx + ox - def.FootprintWTiles / 2;
|
||||
int minY = cyPx + oy - def.FootprintHTiles / 2;
|
||||
int maxX = minX + def.FootprintWTiles - 1;
|
||||
int maxY = minY + def.FootprintHTiles - 1;
|
||||
|
||||
yield return BuildFootprint(next++, def, minX, minY, maxX, maxY, p.RoleOverrides);
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerable<BuildingFootprint> ResolveProcedural(
|
||||
Settlement s,
|
||||
SettlementLayoutDef layout,
|
||||
SettlementContent content,
|
||||
SeededRng rng)
|
||||
{
|
||||
// Pull eligible templates. Tier numbering is inverted: Tier 1 is the
|
||||
// capital (largest), Tier 5 is the hamlet (smallest). "Min tier
|
||||
// eligible" is the *smallest settlement* where this template fits;
|
||||
// a magistrate with min_tier_eligible=2 belongs in Tier 1 capitals
|
||||
// and Tier 2 cities only — so the predicate is s.Tier <= MinTier.
|
||||
var eligible = content.Buildings.Values
|
||||
.Where(b => s.Tier <= b.MinTierEligible)
|
||||
.Where(b => layout.CategoryWeights.ContainsKey(b.Category))
|
||||
.OrderBy(b => b.Id, System.StringComparer.Ordinal) // stable order before RNG roll
|
||||
.ToArray();
|
||||
if (eligible.Length == 0) yield break;
|
||||
|
||||
int cxPx = (int)s.WorldPixelX;
|
||||
int cyPx = (int)s.WorldPixelY;
|
||||
int plazaR = layout.PlazaRadiusTiles > 0
|
||||
? layout.PlazaRadiusTiles
|
||||
: DefaultPlazaRadiusTiles(s.Tier);
|
||||
|
||||
// Slot grid: try concentric rings inside the plaza for placement.
|
||||
var placed = new List<(int minX, int minY, int maxX, int maxY)>();
|
||||
int placedCount = 0;
|
||||
int attempts = 0;
|
||||
int next = 0;
|
||||
|
||||
while (placedCount < layout.TargetBuildingCount && attempts < layout.TargetBuildingCount * 8)
|
||||
{
|
||||
attempts++;
|
||||
// Weighted roll by category, then template.
|
||||
string category = WeightedPick(layout.CategoryWeights, rng);
|
||||
var inCategory = eligible.Where(b => string.Equals(b.Category, category, System.StringComparison.OrdinalIgnoreCase)).ToArray();
|
||||
if (inCategory.Length == 0) continue;
|
||||
var def = WeightedPickByTemplateWeight(inCategory, rng);
|
||||
|
||||
// Pick a candidate offset anywhere inside the plaza, biased toward outer rings to leave a centre.
|
||||
int rx = rng.NextInt(-plazaR + def.FootprintWTiles / 2 + 2,
|
||||
plazaR - def.FootprintWTiles / 2 - 1);
|
||||
int ry = rng.NextInt(-plazaR + def.FootprintHTiles / 2 + 2,
|
||||
plazaR - def.FootprintHTiles / 2 - 1);
|
||||
|
||||
int minX = cxPx + rx - def.FootprintWTiles / 2;
|
||||
int minY = cyPx + ry - def.FootprintHTiles / 2;
|
||||
int maxX = minX + def.FootprintWTiles - 1;
|
||||
int maxY = minY + def.FootprintHTiles - 1;
|
||||
|
||||
// Reject if it overlaps another placed building (with the gap padding).
|
||||
bool collide = false;
|
||||
foreach (var p in placed)
|
||||
{
|
||||
if (RectsOverlap(
|
||||
minX - C.SETTLEMENT_BUILDING_GAP_MIN, minY - C.SETTLEMENT_BUILDING_GAP_MIN,
|
||||
maxX + C.SETTLEMENT_BUILDING_GAP_MIN, maxY + C.SETTLEMENT_BUILDING_GAP_MIN,
|
||||
p.minX, p.minY, p.maxX, p.maxY))
|
||||
{
|
||||
collide = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (collide) continue;
|
||||
|
||||
placed.Add((minX, minY, maxX, maxY));
|
||||
placedCount++;
|
||||
yield return BuildFootprint(next++, def, minX, minY, maxX, maxY, roleOverrides: null);
|
||||
}
|
||||
}
|
||||
|
||||
private static BuildingFootprint BuildFootprint(
|
||||
int id, BuildingTemplateDef def,
|
||||
int minX, int minY, int maxX, int maxY,
|
||||
Dictionary<string, string>? roleOverrides)
|
||||
{
|
||||
var doors = new (int X, int Y)[def.Doors.Length];
|
||||
for (int i = 0; i < def.Doors.Length; i++)
|
||||
doors[i] = (minX + def.Doors[i].X, minY + def.Doors[i].Y);
|
||||
|
||||
var residents = new BuildingResidentSlot[def.Roles.Length];
|
||||
for (int i = 0; i < def.Roles.Length; i++)
|
||||
{
|
||||
var r = def.Roles[i];
|
||||
string role = r.Tag;
|
||||
if (roleOverrides is not null && roleOverrides.TryGetValue(r.Tag, out var named))
|
||||
role = named;
|
||||
residents[i] = new BuildingResidentSlot(
|
||||
role,
|
||||
minX + r.SpawnAt[0],
|
||||
minY + r.SpawnAt[1],
|
||||
def.Category);
|
||||
}
|
||||
|
||||
return new BuildingFootprint
|
||||
{
|
||||
Id = id,
|
||||
TemplateId = def.Id,
|
||||
MinX = minX,
|
||||
MinY = minY,
|
||||
MaxX = maxX,
|
||||
MaxY = maxY,
|
||||
Doors = doors,
|
||||
Residents = residents,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Tile stamping ────────────────────────────────────────────────────
|
||||
|
||||
private static void StampPlazaAndWallRing(
|
||||
TacticalChunk chunk, Settlement s,
|
||||
int x0, int y0, int x1, int y1)
|
||||
{
|
||||
// Replicates the original Phase 5 placeholder behaviour. Buildings
|
||||
// (if any) stamp on top.
|
||||
int tilesRadius = s.Tier switch { 1 => 2, <= 4 => 1, _ => 0 };
|
||||
int cxw = s.TileX, cyw = s.TileY;
|
||||
int minWX = Math.Max(0, cxw - tilesRadius);
|
||||
int minWY = Math.Max(0, cyw - tilesRadius);
|
||||
int maxWX = Math.Min(C.WORLD_WIDTH_TILES - 1, cxw + tilesRadius);
|
||||
int maxWY = Math.Min(C.WORLD_HEIGHT_TILES - 1, cyw + tilesRadius);
|
||||
|
||||
int fx0 = minWX * C.TACTICAL_PER_WORLD_TILE;
|
||||
int fy0 = minWY * C.TACTICAL_PER_WORLD_TILE;
|
||||
int fx1 = (maxWX + 1) * C.TACTICAL_PER_WORLD_TILE;
|
||||
int fy1 = (maxWY + 1) * C.TACTICAL_PER_WORLD_TILE;
|
||||
|
||||
int sx = Math.Max(x0, fx0);
|
||||
int sy = Math.Max(y0, fy0);
|
||||
int ex = Math.Min(x1, fx1);
|
||||
int ey = Math.Min(y1, fy1);
|
||||
if (sx >= ex || sy >= ey) return;
|
||||
|
||||
int cxPx = (int)s.WorldPixelX;
|
||||
int cyPx = (int)s.WorldPixelY;
|
||||
int plazaR = s.Tier switch { 1 => 24, 2 => 18, 3 => 14, 4 => 10, _ => 6 };
|
||||
int wallR = plazaR + 2;
|
||||
|
||||
for (int ty = sy; ty < ey; ty++)
|
||||
for (int tx = sx; tx < ex; tx++)
|
||||
{
|
||||
int dx = tx - cxPx;
|
||||
int dy = ty - cyPx;
|
||||
int dist = Math.Max(Math.Abs(dx), Math.Abs(dy));
|
||||
int lx = tx - chunk.OriginX;
|
||||
int ly = ty - chunk.OriginY;
|
||||
ref var dst = ref chunk.Tiles[lx, ly];
|
||||
|
||||
if (dist <= plazaR)
|
||||
{
|
||||
if ((dst.Flags & (byte)TacticalFlags.River) == 0)
|
||||
dst.Surface = TacticalSurface.Cobble;
|
||||
dst.Flags |= (byte)TacticalFlags.Settlement;
|
||||
dst.Deco = TacticalDeco.None;
|
||||
}
|
||||
else if (dist == wallR && !s.IsPoi)
|
||||
{
|
||||
if ((dst.Flags & (byte)TacticalFlags.River) == 0 &&
|
||||
!HasRoadInHalo(chunk, lx, ly, GatewayHaloTiles))
|
||||
{
|
||||
dst.Surface = TacticalSurface.Wall;
|
||||
dst.Flags |= (byte)TacticalFlags.Settlement | (byte)TacticalFlags.Impassable;
|
||||
dst.Deco = TacticalDeco.None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void StampBuildings(
|
||||
ulong worldSeed,
|
||||
TacticalChunk chunk,
|
||||
Settlement s,
|
||||
SettlementContent content,
|
||||
int x0, int y0, int x1, int y1)
|
||||
{
|
||||
EnsureBuildingsResolved(worldSeed, s, content);
|
||||
|
||||
foreach (var b in s.Buildings)
|
||||
{
|
||||
// Quick AABB intersect with this chunk's tactical window.
|
||||
if (b.MaxX < x0 || b.MinX >= x1 || b.MaxY < y0 || b.MinY >= y1) continue;
|
||||
|
||||
int sx = Math.Max(x0, b.MinX);
|
||||
int sy = Math.Max(y0, b.MinY);
|
||||
int ex = Math.Min(x1, b.MaxX + 1);
|
||||
int ey = Math.Min(y1, b.MaxY + 1);
|
||||
if (sx >= ex || sy >= ey) continue;
|
||||
|
||||
for (int ty = sy; ty < ey; ty++)
|
||||
for (int tx = sx; tx < ex; tx++)
|
||||
{
|
||||
int lx = tx - chunk.OriginX;
|
||||
int ly = ty - chunk.OriginY;
|
||||
ref var dst = ref chunk.Tiles[lx, ly];
|
||||
|
||||
bool perimeter = (tx == b.MinX || tx == b.MaxX || ty == b.MinY || ty == b.MaxY);
|
||||
if (perimeter)
|
||||
{
|
||||
dst.Surface = TacticalSurface.Wall;
|
||||
// Walls block but doorways don't — we patch doors next.
|
||||
dst.Flags |= (byte)(TacticalFlags.Settlement | TacticalFlags.Building | TacticalFlags.Impassable);
|
||||
dst.Deco = TacticalDeco.None;
|
||||
}
|
||||
else
|
||||
{
|
||||
dst.Surface = TacticalSurface.Floor;
|
||||
dst.Flags |= (byte)(TacticalFlags.Settlement | TacticalFlags.Building);
|
||||
// Clear Impassable for floors (a wall may have been stamped previously).
|
||||
dst.Flags &= unchecked((byte)~(byte)TacticalFlags.Impassable);
|
||||
dst.Deco = TacticalDeco.None;
|
||||
}
|
||||
}
|
||||
|
||||
// Doors override perimeter walls so the building is enterable.
|
||||
foreach (var (dx, dy) in b.Doors)
|
||||
{
|
||||
if (dx < x0 || dx >= x1 || dy < y0 || dy >= y1) continue;
|
||||
int lx = dx - chunk.OriginX;
|
||||
int ly = dy - chunk.OriginY;
|
||||
ref var dst = ref chunk.Tiles[lx, ly];
|
||||
dst.Surface = TacticalSurface.Floor;
|
||||
dst.Deco = TacticalDeco.Door;
|
||||
dst.Flags |= (byte)(TacticalFlags.Settlement | TacticalFlags.Building | TacticalFlags.Doorway);
|
||||
dst.Flags &= unchecked((byte)~(byte)TacticalFlags.Impassable);
|
||||
}
|
||||
|
||||
// Interior decos.
|
||||
if (content.Buildings.TryGetValue(b.TemplateId, out var def))
|
||||
{
|
||||
foreach (var deco in def.Decos)
|
||||
{
|
||||
int dx = b.MinX + deco.X;
|
||||
int dy = b.MinY + deco.Y;
|
||||
if (dx < x0 || dx >= x1 || dy < y0 || dy >= y1) continue;
|
||||
int lx = dx - chunk.OriginX;
|
||||
int ly = dy - chunk.OriginY;
|
||||
ref var dst = ref chunk.Tiles[lx, ly];
|
||||
dst.Deco = ParseDeco(deco.Deco);
|
||||
}
|
||||
}
|
||||
|
||||
// Resident spawn records.
|
||||
foreach (var r in b.Residents)
|
||||
{
|
||||
if (r.SpawnX < x0 || r.SpawnX >= x1 || r.SpawnY < y0 || r.SpawnY >= y1) continue;
|
||||
int lx = r.SpawnX - chunk.OriginX;
|
||||
int ly = r.SpawnY - chunk.OriginY;
|
||||
chunk.Spawns.Add(new TacticalSpawn(SpawnKind.Resident, lx, ly));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
private static bool OverlapsChunk(Settlement s, SettlementContent? content, int x0, int y0, int x1, int y1)
|
||||
{
|
||||
// If buildings have already been resolved on this settlement, use
|
||||
// their union AABB; otherwise fall back to the plaza+wall radius
|
||||
// we'd stamp anyway.
|
||||
int cxPx = (int)s.WorldPixelX;
|
||||
int cyPx = (int)s.WorldPixelY;
|
||||
int plazaR = s.Tier switch { 1 => 28, 2 => 22, 3 => 18, 4 => 14, _ => 10 };
|
||||
int extentMinX = cxPx - plazaR, extentMinY = cyPx - plazaR;
|
||||
int extentMaxX = cxPx + plazaR, extentMaxY = cyPx + plazaR;
|
||||
|
||||
if (s.BuildingsResolved && s.Buildings.Count > 0)
|
||||
{
|
||||
foreach (var b in s.Buildings)
|
||||
{
|
||||
extentMinX = Math.Min(extentMinX, b.MinX);
|
||||
extentMinY = Math.Min(extentMinY, b.MinY);
|
||||
extentMaxX = Math.Max(extentMaxX, b.MaxX);
|
||||
extentMaxY = Math.Max(extentMaxY, b.MaxY);
|
||||
}
|
||||
}
|
||||
return !(extentMaxX < x0 || extentMinX >= x1 || extentMaxY < y0 || extentMinY >= y1);
|
||||
}
|
||||
|
||||
private static bool HasRoadInHalo(TacticalChunk chunk, int lx, int ly, int halo)
|
||||
{
|
||||
const byte ROAD = (byte)TacticalFlags.Road;
|
||||
int sx = Math.Max(0, lx - halo);
|
||||
int sy = Math.Max(0, ly - halo);
|
||||
int ex = Math.Min(C.TACTICAL_CHUNK_SIZE - 1, lx + halo);
|
||||
int ey = Math.Min(C.TACTICAL_CHUNK_SIZE - 1, ly + halo);
|
||||
for (int qy = sy; qy <= ey; qy++)
|
||||
for (int qx = sx; qx <= ex; qx++)
|
||||
if ((chunk.Tiles[qx, qy].Flags & ROAD) != 0) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool RectsOverlap(int aMinX, int aMinY, int aMaxX, int aMaxY,
|
||||
int bMinX, int bMinY, int bMaxX, int bMaxY)
|
||||
=> !(aMaxX < bMinX || bMaxX < aMinX || aMaxY < bMinY || bMaxY < aMinY);
|
||||
|
||||
private static int DefaultPlazaRadiusTiles(int tier) => tier switch
|
||||
{
|
||||
1 => 24,
|
||||
2 => 18,
|
||||
3 => 14,
|
||||
4 => 10,
|
||||
_ => 6,
|
||||
};
|
||||
|
||||
private static TacticalDeco ParseDeco(string raw) => raw.ToLowerInvariant() switch
|
||||
{
|
||||
"counter" => TacticalDeco.Counter,
|
||||
"bed" => TacticalDeco.Bed,
|
||||
"hearth" => TacticalDeco.Hearth,
|
||||
"sign" => TacticalDeco.Sign,
|
||||
_ => TacticalDeco.None,
|
||||
};
|
||||
|
||||
private static string WeightedPick(IReadOnlyDictionary<string, float> weights, SeededRng rng)
|
||||
{
|
||||
// Stable order — keys sorted ascending — so identical seeds always
|
||||
// pick identically regardless of dictionary insertion order.
|
||||
var keys = weights.Keys.OrderBy(k => k, System.StringComparer.Ordinal).ToArray();
|
||||
float total = 0f;
|
||||
foreach (var k in keys) total += System.Math.Max(0f, weights[k]);
|
||||
if (total <= 0f) return keys[0];
|
||||
float roll = rng.NextFloat() * total;
|
||||
float acc = 0f;
|
||||
foreach (var k in keys)
|
||||
{
|
||||
acc += System.Math.Max(0f, weights[k]);
|
||||
if (roll <= acc) return k;
|
||||
}
|
||||
return keys[^1];
|
||||
}
|
||||
|
||||
private static BuildingTemplateDef WeightedPickByTemplateWeight(BuildingTemplateDef[] templates, SeededRng rng)
|
||||
{
|
||||
float total = 0f;
|
||||
foreach (var t in templates) total += System.Math.Max(0f, t.Weight);
|
||||
if (total <= 0f) return templates[0];
|
||||
float roll = rng.NextFloat() * total;
|
||||
float acc = 0f;
|
||||
foreach (var t in templates)
|
||||
{
|
||||
acc += System.Math.Max(0f, t.Weight);
|
||||
if (roll <= acc) return t;
|
||||
}
|
||||
return templates[^1];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
using Theriapolis.Core.Data;
|
||||
using Theriapolis.Core.World.Polylines;
|
||||
|
||||
namespace Theriapolis.Core.World;
|
||||
|
||||
/// <summary>
|
||||
/// The runtime world model. Holds all canonical simulation data for the generated continent.
|
||||
/// Arrays are indexed [x, y] with (0,0) at the top-left (north-west).
|
||||
/// </summary>
|
||||
public sealed class WorldState
|
||||
{
|
||||
public ulong WorldSeed { get; init; }
|
||||
|
||||
// ── Canonical arrays ─────────────────────────────────────────────────────
|
||||
public WorldTile[,] Tiles { get; } = new WorldTile[C.WORLD_WIDTH_TILES, C.WORLD_HEIGHT_TILES];
|
||||
|
||||
// Convenience accessors into the tile array (avoid struct copies in hot paths)
|
||||
public ref WorldTile TileAt(int x, int y) => ref Tiles[x, y];
|
||||
|
||||
// ── Macro grid ────────────────────────────────────────────────────────────
|
||||
public MacroCell[,]? MacroGrid { get; set; }
|
||||
|
||||
// ── Content defs (loaded from JSON, not generated) ────────────────────────
|
||||
public BiomeDef[]? BiomeDefs { get; set; }
|
||||
public FactionDef[]? FactionDefs { get; set; }
|
||||
|
||||
// ── Phase 2+3: Polylines (source of truth for linear features) ────────────
|
||||
public List<Polyline> Rivers { get; } = new();
|
||||
public List<Polyline> Roads { get; } = new();
|
||||
public List<Polyline> Rails { get; } = new();
|
||||
|
||||
// ── Phase 2+3: Settlements ───────────────────────────────────────────────
|
||||
public List<Settlement> Settlements { get; } = new();
|
||||
|
||||
// ── Phase 2+3: Bridges (road/rail crossings over rivers) ────────────────
|
||||
public List<Bridge> Bridges { get; } = new();
|
||||
|
||||
// ── Phase 2+3: Computed maps ─────────────────────────────────────────────
|
||||
public float[,]? Habitability { get; set; }
|
||||
public float[,]? EncounterDensity { get; set; }
|
||||
public FactionInfluenceMap? FactionInfluence { get; set; }
|
||||
|
||||
// ── Stage hashes for save integrity ───────────────────────────────────────
|
||||
// Each stage appends its hash here after completing.
|
||||
public Dictionary<string, ulong> StageHashes { get; } = new();
|
||||
|
||||
// ── Helper: macro cell for a given world tile coordinate ─────────────────
|
||||
/// <summary>
|
||||
/// Looks up a macro cell by unwarped grid position. Use this only for
|
||||
/// pre-ElevationGen stages or places where you explicitly want the raw
|
||||
/// grid lookup. Most callers should use <see cref="MacroCellForTile"/>.
|
||||
/// </summary>
|
||||
public MacroCell MacroCellAt(int tileX, int tileY)
|
||||
{
|
||||
if (MacroGrid is null) throw new InvalidOperationException("MacroGrid not loaded yet.");
|
||||
int mx = Math.Clamp(tileX / (C.WORLD_WIDTH_TILES / C.MACRO_GRID_WIDTH), 0, C.MACRO_GRID_WIDTH - 1);
|
||||
int my = Math.Clamp(tileY / (C.WORLD_HEIGHT_TILES / C.MACRO_GRID_HEIGHT), 0, C.MACRO_GRID_HEIGHT - 1);
|
||||
return MacroGrid[mx, my];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the macro cell stored on the given tile. <see cref="ElevationGenStage"/>
|
||||
/// overwrites each tile's <see cref="WorldTile.MacroX"/> and <see cref="WorldTile.MacroY"/>
|
||||
/// with border-warped coordinates so that macro cell boundaries follow
|
||||
/// organic wiggly curves instead of grid-aligned lines (Addendum A §1).
|
||||
/// All post-ElevationGen stages and tests should use this method.
|
||||
/// </summary>
|
||||
public MacroCell MacroCellForTile(in WorldTile tile)
|
||||
{
|
||||
if (MacroGrid is null) throw new InvalidOperationException("MacroGrid not loaded yet.");
|
||||
return MacroGrid[tile.MacroX, tile.MacroY];
|
||||
}
|
||||
|
||||
// ── Sea level constant ─────────────────────────────────────────────────────
|
||||
// Tiles with elevation < SeaLevel are ocean.
|
||||
public const float SeaLevel = 0.35f;
|
||||
|
||||
// ── Fast hash for determinism tests ──────────────────────────────────────
|
||||
/// <summary>FNV-1a hash over all elevation values (for determinism tests).</summary>
|
||||
public ulong HashElevation()
|
||||
{
|
||||
const ulong FNV_PRIME = 1099511628211UL;
|
||||
const ulong FNV_OFFSET = 14695981039346656037UL;
|
||||
ulong hash = FNV_OFFSET;
|
||||
for (int y = 0; y < C.WORLD_HEIGHT_TILES; y++)
|
||||
for (int x = 0; x < C.WORLD_WIDTH_TILES; x++)
|
||||
{
|
||||
uint bits = BitConverter.SingleToUInt32Bits(Tiles[x, y].Elevation);
|
||||
hash = (hash ^ bits) * FNV_PRIME;
|
||||
}
|
||||
return hash;
|
||||
}
|
||||
|
||||
public ulong HashMoisture()
|
||||
{
|
||||
const ulong FNV_PRIME = 1099511628211UL;
|
||||
const ulong FNV_OFFSET = 14695981039346656037UL;
|
||||
ulong hash = FNV_OFFSET;
|
||||
for (int y = 0; y < C.WORLD_HEIGHT_TILES; y++)
|
||||
for (int x = 0; x < C.WORLD_WIDTH_TILES; x++)
|
||||
{
|
||||
uint bits = BitConverter.SingleToUInt32Bits(Tiles[x, y].Moisture);
|
||||
hash = (hash ^ bits) * FNV_PRIME;
|
||||
}
|
||||
return hash;
|
||||
}
|
||||
|
||||
public ulong HashTemperature()
|
||||
{
|
||||
const ulong FNV_PRIME = 1099511628211UL;
|
||||
const ulong FNV_OFFSET = 14695981039346656037UL;
|
||||
ulong hash = FNV_OFFSET;
|
||||
for (int y = 0; y < C.WORLD_HEIGHT_TILES; y++)
|
||||
for (int x = 0; x < C.WORLD_WIDTH_TILES; x++)
|
||||
{
|
||||
uint bits = BitConverter.SingleToUInt32Bits(Tiles[x, y].Temperature);
|
||||
hash = (hash ^ bits) * FNV_PRIME;
|
||||
}
|
||||
return hash;
|
||||
}
|
||||
|
||||
public ulong HashBiomes()
|
||||
{
|
||||
const ulong FNV_PRIME = 1099511628211UL;
|
||||
const ulong FNV_OFFSET = 14695981039346656037UL;
|
||||
ulong hash = FNV_OFFSET;
|
||||
for (int y = 0; y < C.WORLD_HEIGHT_TILES; y++)
|
||||
for (int x = 0; x < C.WORLD_WIDTH_TILES; x++)
|
||||
hash = (hash ^ (byte)Tiles[x, y].Biome) * FNV_PRIME;
|
||||
return hash;
|
||||
}
|
||||
|
||||
/// <summary>FNV-1a hash over all settlements (sorted by ID).</summary>
|
||||
public ulong HashSettlements()
|
||||
{
|
||||
const ulong FNV_PRIME = 1099511628211UL;
|
||||
const ulong FNV_OFFSET = 14695981039346656037UL;
|
||||
ulong hash = FNV_OFFSET;
|
||||
foreach (var s in Settlements.OrderBy(s => s.Id))
|
||||
{
|
||||
hash = (hash ^ (ulong)s.Id) * FNV_PRIME;
|
||||
hash = (hash ^ (ulong)s.Tier) * FNV_PRIME;
|
||||
hash = (hash ^ (ulong)s.TileX) * FNV_PRIME;
|
||||
hash = (hash ^ (ulong)s.TileY) * FNV_PRIME;
|
||||
}
|
||||
return hash;
|
||||
}
|
||||
|
||||
/// <summary>FNV-1a hash over all polyline points (rivers, then roads, then rails).</summary>
|
||||
public ulong HashPolylines()
|
||||
{
|
||||
const ulong FNV_PRIME = 1099511628211UL;
|
||||
const ulong FNV_OFFSET = 14695981039346656037UL;
|
||||
ulong hash = FNV_OFFSET;
|
||||
foreach (var polylineList in new[] { Rivers, Roads, Rails })
|
||||
foreach (var p in polylineList.OrderBy(p => p.Id))
|
||||
foreach (var pt in p.Points)
|
||||
{
|
||||
hash = (hash ^ BitConverter.SingleToUInt32Bits(pt.X)) * FNV_PRIME;
|
||||
hash = (hash ^ BitConverter.SingleToUInt32Bits(pt.Y)) * FNV_PRIME;
|
||||
}
|
||||
return hash;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
namespace Theriapolis.Core.World;
|
||||
|
||||
/// <summary>
|
||||
/// Per-tile data for a single world tile (1/1024 of the continent's width).
|
||||
/// All float fields are normalized to [0, 1] unless noted.
|
||||
/// </summary>
|
||||
public struct WorldTile
|
||||
{
|
||||
public float Elevation; // 0 = sea floor, 1 = mountain peak
|
||||
public float Moisture; // 0 = arid, 1 = saturated
|
||||
public float Temperature; // 0 = polar, 1 = equatorial
|
||||
public BiomeId Biome;
|
||||
public FeatureFlags Features;
|
||||
|
||||
/// <summary>Macro-grid cell index (x + y*32) for this tile.</summary>
|
||||
public byte MacroX;
|
||||
public byte MacroY;
|
||||
|
||||
// ── Phase 2+3 additions ──────────────────────────────────────────────────
|
||||
/// <summary>
|
||||
/// Direction (Dir.N..Dir.NW, or Dir.None) that a river polyline flows through this tile.
|
||||
/// Set by HydrologyGenStage when rasterizing river polylines.
|
||||
/// </summary>
|
||||
public byte RiverFlowDir;
|
||||
|
||||
/// <summary>
|
||||
/// Direction (Dir.N..Dir.NW, or Dir.None) that a rail polyline passes through this tile.
|
||||
/// Set by RailNetworkGenStage when rasterizing rail polylines.
|
||||
/// </summary>
|
||||
public byte RailDir;
|
||||
|
||||
/// <summary>
|
||||
/// Settlement ID (1-based) of the settlement whose footprint covers this tile.
|
||||
/// 0 = no settlement.
|
||||
/// </summary>
|
||||
public ushort SettlementId;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Biome identifiers. Enums, not magic ints.
|
||||
/// </summary>
|
||||
public enum BiomeId : byte
|
||||
{
|
||||
None = 0,
|
||||
Ocean,
|
||||
Tundra,
|
||||
Boreal,
|
||||
TemperateDeciduous,
|
||||
TemperateGrassland,
|
||||
MountainAlpine,
|
||||
MountainForested,
|
||||
SubtropicalForest,
|
||||
Wetland,
|
||||
Coastal,
|
||||
RiverValley,
|
||||
Scrubland,
|
||||
DesertCold,
|
||||
|
||||
// Transition biomes (assigned by BorderDistortionGen)
|
||||
ForestEdge,
|
||||
Foothills,
|
||||
MarshEdge,
|
||||
Beach,
|
||||
Cliff,
|
||||
TidalFlat,
|
||||
Mangrove,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bitmask of linear and special features on a tile.
|
||||
/// Per-tile flags are derived caches — polylines are the source of truth for rivers/roads/rail.
|
||||
/// </summary>
|
||||
[Flags]
|
||||
public enum FeatureFlags : ushort
|
||||
{
|
||||
None = 0,
|
||||
HasRiver = 1 << 0,
|
||||
HasRoad = 1 << 1,
|
||||
HasRail = 1 << 2,
|
||||
IsSettlement = 1 << 3,
|
||||
IsPoi = 1 << 4,
|
||||
IsCoast = 1 << 5,
|
||||
IsBorder = 1 << 6, // biome or land/ocean border (set during distortion pass)
|
||||
RiverAdjacent = 1 << 7,
|
||||
RailroadAdjacent = 1 << 8,
|
||||
}
|
||||
Reference in New Issue
Block a user