Files

401 lines
18 KiB
C#
Raw Permalink Normal View History

using Theriapolis.Core.Util;
using Theriapolis.Core.World;
using Theriapolis.Core.World.Polylines;
namespace Theriapolis.Core.Tactical;
/// <summary>
/// Deterministic per-chunk generator. Pure function of (worldSeed, ChunkCoord, WorldState).
///
/// Pipeline:
/// 1. Ground layer — biome → surface variant per tactical tile
/// 2. Polyline burn-in — rivers and roads stamp water/road tiles
/// 3. Settlement burn-in — settlement footprints stamp cobble + walls
/// 4. Scatter — biome decorations seeded from a per-chunk sub-seed
/// 5. Spawn list — encounter density + sub-seed
///
/// Every step takes its randomness from a sub-seed derived from the chunk
/// coord, so adjacent chunks see independent (but globally deterministic)
/// scatters.
/// </summary>
public static class TacticalChunkGen
{
public static TacticalChunk Generate(ulong worldSeed, ChunkCoord cc, WorldState world)
=> Generate(worldSeed, cc, world, settlementContent: null);
/// <summary>
/// Phase 6 M0 — overload accepting building/layout content. When
/// <paramref name="settlementContent"/> is non-null, Pass 3 stamps
/// templated buildings; when null, falls back to the Phase-4
/// cobble-plaza + outer-wall-ring placeholder so headless tools and
/// older tests keep working unchanged.
/// </summary>
public static TacticalChunk Generate(
ulong worldSeed,
ChunkCoord cc,
WorldState world,
Theriapolis.Core.Data.SettlementContent? settlementContent)
{
var chunk = new TacticalChunk(cc);
ulong chunkHash = Hash(cc);
Pass1_Ground(worldSeed, chunk, world, chunkHash);
Pass2_Polylines(chunk, world);
Theriapolis.Core.World.Settlements.SettlementStamper.Stamp(worldSeed, chunk, world, settlementContent);
Pass4_Scatter(worldSeed, chunk, world, chunkHash);
Pass5_Spawns(worldSeed, chunk, world, chunkHash);
return chunk;
}
// ── Pass 1 — ground layer ─────────────────────────────────────────────
private static void Pass1_Ground(ulong seed, TacticalChunk chunk, WorldState world, ulong chunkHash)
{
var rng = SeededRng.ForSubsystem(seed, C.RNG_TACTICAL_GROUND ^ chunkHash);
int origX = chunk.OriginX;
int origY = chunk.OriginY;
for (int ly = 0; ly < C.TACTICAL_CHUNK_SIZE; ly++)
for (int lx = 0; lx < C.TACTICAL_CHUNK_SIZE; lx++)
{
int tx = origX + lx; // tactical-tile coord (= world pixel)
int ty = origY + ly;
int wx = tx / C.TACTICAL_PER_WORLD_TILE;
int wy = ty / C.TACTICAL_PER_WORLD_TILE;
wx = Math.Clamp(wx, 0, C.WORLD_WIDTH_TILES - 1);
wy = Math.Clamp(wy, 0, C.WORLD_HEIGHT_TILES - 1);
ref var src = ref world.TileAt(wx, wy);
ref var dst = ref chunk.Tiles[lx, ly];
(dst.Surface, dst.Variant) = PickGround(src, rng);
}
}
private static (TacticalSurface, byte) PickGround(in WorldTile w, SeededRng rng)
{
byte v = (byte)rng.NextInt(0, 4);
return w.Biome switch
{
BiomeId.Ocean => (TacticalSurface.DeepWater, 0),
BiomeId.Wetland => (TacticalSurface.Marsh, v),
BiomeId.MarshEdge => (rng.NextBool(0.4) ? TacticalSurface.Mud : TacticalSurface.Grass, v),
BiomeId.Mangrove => (rng.NextBool(0.5) ? TacticalSurface.ShallowWater : TacticalSurface.Mud, v),
BiomeId.TidalFlat => (TacticalSurface.Mud, v),
BiomeId.Beach => (TacticalSurface.Sand, v),
BiomeId.Coastal => (rng.NextBool(0.3) ? TacticalSurface.Sand : TacticalSurface.Grass, v),
BiomeId.Tundra => (rng.NextBool(0.6) ? TacticalSurface.Snow : TacticalSurface.Dirt, v),
BiomeId.MountainAlpine => (rng.NextBool(0.5) ? TacticalSurface.Rock : TacticalSurface.Snow, v),
BiomeId.MountainForested => (rng.NextBool(0.4) ? TacticalSurface.Rock : TacticalSurface.Dirt, v),
BiomeId.Cliff => (TacticalSurface.Rock, v),
BiomeId.Foothills => (rng.NextBool(0.3) ? TacticalSurface.Rock : TacticalSurface.Grass, v),
BiomeId.DesertCold => (rng.NextBool(0.4) ? TacticalSurface.Sand : TacticalSurface.Dirt, v),
BiomeId.Scrubland => (rng.NextBool(0.5) ? TacticalSurface.Dirt : TacticalSurface.Grass, v),
BiomeId.Boreal => (rng.NextBool(0.4) ? TacticalSurface.Dirt : TacticalSurface.Grass, v),
BiomeId.SubtropicalForest => (rng.NextBool(0.3) ? TacticalSurface.TallGrass : TacticalSurface.Grass, v),
BiomeId.TemperateDeciduous => (rng.NextBool(0.2) ? TacticalSurface.Dirt : TacticalSurface.Grass, v),
BiomeId.TemperateGrassland => (rng.NextBool(0.4) ? TacticalSurface.TallGrass : TacticalSurface.Grass, v),
BiomeId.RiverValley => (TacticalSurface.Grass, v),
BiomeId.ForestEdge => (TacticalSurface.Grass, v),
_ => (TacticalSurface.Grass, v),
};
}
// ── Pass 2 — polyline burn-in ─────────────────────────────────────────
private static void Pass2_Polylines(TacticalChunk chunk, WorldState world)
{
// World-pixel AABB for the chunk (since 1 tactical tile == 1 world pixel,
// local (0,0) = world pixel (origX, origY)).
int x0 = chunk.OriginX;
int y0 = chunk.OriginY;
int x1 = x0 + C.TACTICAL_CHUNK_SIZE;
int y1 = y0 + C.TACTICAL_CHUNK_SIZE;
// Roads first, then rivers — rivers stamp deeper water and override
// road surface where they cross (bridges are stamped later from
// World.Bridges).
foreach (var road in world.Roads)
{
var (surf, hw) = RoadStyle(road);
BurnPolyline(chunk, road, x0, y0, x1, y1, hw, surf, TacticalFlags.Road);
}
foreach (var river in world.Rivers)
BurnPolyline(chunk, river, x0, y0, x1, y1, RiverHalfWidth(river), TacticalSurface.ShallowWater, TacticalFlags.River);
// Bridges: short segments that re-stamp Cobble+Bridge across the river.
foreach (var b in world.Bridges)
{
BurnSegment(chunk, x0, y0, x1, y1,
new Vec2(b.Start.X, b.Start.Y), new Vec2(b.End.X, b.End.Y),
halfWidth: 2.5f,
surface: TacticalSurface.Cobble,
flag: TacticalFlags.Road | TacticalFlags.Bridge);
}
}
/// <summary>
/// Maps road class to a (surface, half-width) pair so each road tier
/// reads as a distinct material in tactical, not just a different width
/// of the same cobble. Footpaths use a narrower stamp than dirt roads
/// because they're walking trails, not cart routes.
/// </summary>
private static (TacticalSurface surface, float halfWidth) RoadStyle(Polyline p) =>
p.RoadClassification switch
{
RoadType.Highway => (TacticalSurface.Cobble, 2.5f),
RoadType.PostRoad => (TacticalSurface.Cobble, 1.5f),
RoadType.DirtRoad => (TacticalSurface.TroddenDirt, 1.0f),
_ => (TacticalSurface.Gravel, 0.7f), // Footpath
};
private static float RiverHalfWidth(Polyline p) => p.RiverClassification switch
{
RiverClass.MajorRiver => 4.0f,
RiverClass.River => 2.5f,
_ => 1.0f,
};
private static void BurnPolyline(
TacticalChunk chunk, Polyline p,
int x0, int y0, int x1, int y1,
float halfWidth, TacticalSurface surface, TacticalFlags flag)
{
var pts = p.Points;
if (pts.Count < 2) return;
// Cheap AABB rejection per polyline.
bool any = false;
for (int i = 0; i < pts.Count && !any; i++)
if (pts[i].X >= x0 - 8 && pts[i].X <= x1 + 8 &&
pts[i].Y >= y0 - 8 && pts[i].Y <= y1 + 8) any = true;
if (!any) return;
for (int i = 0; i < pts.Count - 1; i++)
BurnSegment(chunk, x0, y0, x1, y1, pts[i], pts[i + 1], halfWidth, surface, flag);
}
private static void BurnSegment(
TacticalChunk chunk,
int x0, int y0, int x1, int y1,
Vec2 a, Vec2 b,
float halfWidth, TacticalSurface surface, TacticalFlags flag)
{
// Walk the segment's AABB intersected with the chunk and test each pixel
// for distance ≤ halfWidth. CHUNK_SIZE is small (64) so this is cheap.
float minX = MathF.Min(a.X, b.X) - halfWidth;
float maxX = MathF.Max(a.X, b.X) + halfWidth;
float minY = MathF.Min(a.Y, b.Y) - halfWidth;
float maxY = MathF.Max(a.Y, b.Y) + halfWidth;
int sx = Math.Max(x0, (int)MathF.Floor(minX));
int sy = Math.Max(y0, (int)MathF.Floor(minY));
int ex = Math.Min(x1 - 1, (int)MathF.Ceiling(maxX));
int ey = Math.Min(y1 - 1, (int)MathF.Ceiling(maxY));
if (sx > ex || sy > ey) return;
Vec2 d = b - a;
float L2 = d.LengthSquared;
float hw2 = halfWidth * halfWidth;
for (int ty = sy; ty <= ey; ty++)
for (int tx = sx; tx <= ex; tx++)
{
// Tile centre in world pixels. Each tactical tile is 1 world pixel
// wide so the centre is at (tx + 0.5, ty + 0.5).
Vec2 q = new(tx + 0.5f, ty + 0.5f);
float t = L2 < 1e-6f ? 0f : Math.Clamp(Vec2.Dot(q - a, d) / L2, 0f, 1f);
Vec2 closest = a + d * t;
if (Vec2.DistSq(q, closest) > hw2) continue;
int lx = tx - chunk.OriginX;
int ly = ty - chunk.OriginY;
ref var dst = ref chunk.Tiles[lx, ly];
// For a river-on-water cell we want DeepWater near the centre line
// and ShallowWater near the bank. For a road, just stamp surface.
if (flag == TacticalFlags.River)
{
float dist = MathF.Sqrt(Vec2.DistSq(q, closest));
bool deep = dist < halfWidth * 0.5f;
if ((dst.Flags & (byte)TacticalFlags.Bridge) == 0)
dst.Surface = deep ? TacticalSurface.DeepWater : TacticalSurface.ShallowWater;
dst.Flags |= (byte)flag;
}
else
{
// Roads override river surface only when bridge flag is set
// (BurnSegment is called with Bridge for actual bridge spans).
if ((flag & TacticalFlags.Bridge) != 0 || (dst.Flags & (byte)TacticalFlags.River) == 0)
{
dst.Surface = surface;
}
dst.Flags |= (byte)flag;
// Bridge lookup forgets the underlying river flag for walkability.
if ((flag & TacticalFlags.Bridge) != 0)
dst.Flags &= unchecked((byte)~(byte)TacticalFlags.River);
}
// Polyline stamps clear scatter.
dst.Deco = TacticalDeco.None;
}
}
// ── Pass 3 — settlement stamping handed off to SettlementStamper ─────
// Phase 6 M0 moved the settlement burn-in into
// World/Settlements/SettlementStamper.cs, which falls back to the
// legacy plaza+wall-ring stamp when no content is supplied.
// ── Pass 4 — scatter ──────────────────────────────────────────────────
private static void Pass4_Scatter(ulong seed, TacticalChunk chunk, WorldState world, ulong chunkHash)
{
var rng = SeededRng.ForSubsystem(seed, C.RNG_TACTICAL_SCATTER ^ chunkHash);
for (int ly = 0; ly < C.TACTICAL_CHUNK_SIZE; ly++)
for (int lx = 0; lx < C.TACTICAL_CHUNK_SIZE; lx++)
{
ref var dst = ref chunk.Tiles[lx, ly];
// Don't scatter on impassable, water, road, settlement, or river tiles.
if (dst.Surface == TacticalSurface.DeepWater) continue;
if (dst.Surface == TacticalSurface.Wall) continue;
if ((dst.Flags & ((byte)TacticalFlags.Road | (byte)TacticalFlags.Settlement | (byte)TacticalFlags.River)) != 0) continue;
int wx = (chunk.OriginX + lx) / C.TACTICAL_PER_WORLD_TILE;
int wy = (chunk.OriginY + ly) / C.TACTICAL_PER_WORLD_TILE;
wx = Math.Clamp(wx, 0, C.WORLD_WIDTH_TILES - 1);
wy = Math.Clamp(wy, 0, C.WORLD_HEIGHT_TILES - 1);
ref var w = ref world.TileAt(wx, wy);
float treeP = TreeDensity(w.Biome);
float bushP = BushDensity(w.Biome);
float rockP = RockDensity(w.Biome);
double r = rng.NextDouble();
TacticalDeco candidate = TacticalDeco.None;
if (r < treeP) candidate = TacticalDeco.Tree;
else if (r < treeP + bushP) candidate = TacticalDeco.Bush;
else if (r < treeP + bushP + rockP)
candidate = rng.NextBool(0.15) ? TacticalDeco.Boulder : TacticalDeco.Rock;
else if (rng.NextBool(0.02))
candidate = TacticalDeco.Flower;
// Rule: no blocking decorations within 1 tactical tile of a road.
// Trees/boulders adjacent to a road would visually clutter the
// road edge and (since both are impassable) effectively narrow
// the road by half a tile on each side. Bushes, rocks, and
// flowers are walkable so they're allowed adjacent.
bool blocking = candidate == TacticalDeco.Tree || candidate == TacticalDeco.Boulder;
if (blocking && HasRoadNeighbour(chunk, lx, ly)) continue;
dst.Deco = candidate;
}
}
/// <summary>
/// Returns true if any of the 4 cardinal neighbours of (lx, ly) within
/// this chunk has the Road flag set. Cross-chunk neighbours aren't
/// inspected (would require streamer access during generation, breaking
/// determinism + adding latency); the worst-case artefact is one tile of
/// blocking deco at a chunk boundary, which is rare and barely visible.
/// </summary>
private static bool HasRoadNeighbour(TacticalChunk chunk, int lx, int ly)
{
const byte ROAD = (byte)TacticalFlags.Road;
if (lx > 0 && (chunk.Tiles[lx - 1, ly].Flags & ROAD) != 0) return true;
if (lx + 1 < C.TACTICAL_CHUNK_SIZE && (chunk.Tiles[lx + 1, ly].Flags & ROAD) != 0) return true;
if (ly > 0 && (chunk.Tiles[lx, ly - 1].Flags & ROAD) != 0) return true;
if (ly + 1 < C.TACTICAL_CHUNK_SIZE && (chunk.Tiles[lx, ly + 1].Flags & ROAD) != 0) return true;
return false;
}
private static float TreeDensity(BiomeId b) => b switch
{
BiomeId.Boreal => 0.25f,
BiomeId.SubtropicalForest => 0.30f,
BiomeId.TemperateDeciduous => 0.22f,
BiomeId.MountainForested => 0.18f,
BiomeId.ForestEdge => 0.10f,
BiomeId.RiverValley => 0.05f,
BiomeId.Mangrove => 0.20f,
BiomeId.Wetland => 0.04f,
_ => 0.02f,
};
private static float BushDensity(BiomeId b) => b switch
{
BiomeId.TemperateGrassland => 0.04f,
BiomeId.Scrubland => 0.10f,
BiomeId.Foothills => 0.05f,
BiomeId.ForestEdge => 0.06f,
_ => 0.02f,
};
private static float RockDensity(BiomeId b) => b switch
{
BiomeId.MountainAlpine => 0.20f,
BiomeId.MountainForested => 0.12f,
BiomeId.Cliff => 0.30f,
BiomeId.Foothills => 0.06f,
BiomeId.Tundra => 0.04f,
BiomeId.DesertCold => 0.05f,
_ => 0.01f,
};
// ── Pass 5 — spawn list ───────────────────────────────────────────────
private static void Pass5_Spawns(ulong seed, TacticalChunk chunk, WorldState world, ulong chunkHash)
{
var rng = SeededRng.ForSubsystem(seed, C.RNG_TACTICAL_SPAWN ^ chunkHash);
// Sample the encounter density at the chunk centre. Dense areas roll
// a few candidates, sparse ones roll one or none.
int cxw = (chunk.OriginX + C.TACTICAL_CHUNK_SIZE / 2) / C.TACTICAL_PER_WORLD_TILE;
int cyw = (chunk.OriginY + C.TACTICAL_CHUNK_SIZE / 2) / C.TACTICAL_PER_WORLD_TILE;
cxw = Math.Clamp(cxw, 0, C.WORLD_WIDTH_TILES - 1);
cyw = Math.Clamp(cyw, 0, C.WORLD_HEIGHT_TILES - 1);
// Phase 5 M5: stamp the chunk's danger zone. Pass5 is the natural place
// because chunks needing a zone are the same chunks needing spawns.
chunk.DangerZone = (byte)World.DangerZone.Compute(cxw, cyw, world);
float density = world.EncounterDensity?[cxw, cyw] ?? 0f;
int candidates = density switch
{
< 0.1f => 0,
< 0.3f => 1,
< 0.6f => 2,
_ => 3,
};
for (int i = 0; i < candidates; i++)
{
int lx = rng.NextInt(0, C.TACTICAL_CHUNK_SIZE);
int ly = rng.NextInt(0, C.TACTICAL_CHUNK_SIZE);
ref var t = ref chunk.Tiles[lx, ly];
if (!t.IsWalkable) continue;
if ((t.Flags & (byte)TacticalFlags.Settlement) != 0) continue;
SpawnKind kind = rng.NextDouble() switch
{
< 0.55 => SpawnKind.WildAnimal,
< 0.80 => SpawnKind.Brigand,
< 0.92 => SpawnKind.Patrol,
< 0.98 => SpawnKind.Merchant,
_ => SpawnKind.PoiGuard,
};
chunk.Spawns.Add(new TacticalSpawn(kind, lx, ly));
}
}
// ── Helper: mix the chunk coord into a sub-seed ───────────────────────
private static ulong Hash(ChunkCoord cc)
{
// SplitMix-style avalanche of (X, Y) into 64 bits.
ulong h = (ulong)(uint)cc.X | ((ulong)(uint)cc.Y << 32);
h += 0x9e3779b97f4a7c15UL;
h = (h ^ (h >> 30)) * 0xbf58476d1ce4e5b9UL;
h = (h ^ (h >> 27)) * 0x94d049bb133111ebUL;
return h ^ (h >> 31);
}
}