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,400 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user