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:
Christopher Wiebe
2026-04-30 20:40:51 -07:00
commit b451f83174
525 changed files with 75786 additions and 0 deletions
+41
View File
@@ -0,0 +1,41 @@
namespace Theriapolis.Core.Tactical;
/// <summary>
/// Integer (cx, cy) key for a tactical chunk. The chunk covers world-pixel
/// rectangle [cx*CHUNK_SIZE, cy*CHUNK_SIZE, (cx+1)*CHUNK_SIZE, (cy+1)*CHUNK_SIZE).
/// At the current constants (CHUNK_SIZE = 64, TACTICAL_PER_WORLD_TILE = 32),
/// each chunk covers 2×2 world tiles.
/// </summary>
public readonly struct ChunkCoord : IEquatable<ChunkCoord>
{
public readonly int X;
public readonly int Y;
public ChunkCoord(int x, int y) { X = x; Y = y; }
/// <summary>Chunk that contains the given tactical-tile (world-pixel) coordinate.</summary>
public static ChunkCoord ForTactical(int tx, int ty)
=> new(FloorDiv(tx, C.TACTICAL_CHUNK_SIZE), FloorDiv(ty, C.TACTICAL_CHUNK_SIZE));
/// <summary>Chunk that contains the given world-tile coordinate.</summary>
public static ChunkCoord ForWorldTile(int wx, int wy)
{
// 1 world tile = TACTICAL_PER_WORLD_TILE world pixels = TACTICAL_PER_WORLD_TILE
// tactical tiles. One chunk covers CHUNK_SIZE / TACTICAL_PER_WORLD_TILE world tiles.
int worldTilesPerChunk = C.TACTICAL_CHUNK_SIZE / C.TACTICAL_PER_WORLD_TILE;
return new(FloorDiv(wx, worldTilesPerChunk), FloorDiv(wy, worldTilesPerChunk));
}
private static int FloorDiv(int a, int b)
{
int q = a / b;
if ((a ^ b) < 0 && q * b != a) q--;
return q;
}
public bool Equals(ChunkCoord other) => X == other.X && Y == other.Y;
public override bool Equals(object? obj) => obj is ChunkCoord c && Equals(c);
public override int GetHashCode() => HashCode.Combine(X, Y);
public static bool operator ==(ChunkCoord a, ChunkCoord b) => a.Equals(b);
public static bool operator !=(ChunkCoord a, ChunkCoord b) => !a.Equals(b);
public override string ToString() => $"({X},{Y})";
}
+236
View File
@@ -0,0 +1,236 @@
using Theriapolis.Core.World;
namespace Theriapolis.Core.Tactical;
/// <summary>
/// In-memory cache of generated tactical chunks. Generation happens lazily on
/// first access; eviction happens whenever the active set is recomputed by
/// <see cref="EnsureLoadedAround"/>.
///
/// The cache is bounded by <see cref="C.CHUNK_CACHE_SOFT_MAX"/>: anything above
/// that count and outside the active window gets dropped (delta flushed first).
///
/// Single-threaded; pre-warming uses background tasks but the streamer itself
/// is mutated only from the caller thread.
/// </summary>
public sealed class ChunkStreamer
{
private readonly ulong _worldSeed;
private readonly WorldState _world;
private readonly IChunkDeltaStore _deltas;
private readonly Dictionary<ChunkCoord, TacticalChunk> _cache = new();
private readonly Dictionary<ChunkCoord, Task<TacticalChunk>> _inflight = new();
private readonly Theriapolis.Core.Data.SettlementContent? _settlementContent;
public IReadOnlyDictionary<ChunkCoord, TacticalChunk> Loaded => _cache;
/// <summary>
/// Phase 5 M5: fires after a chunk is freshly inserted into the cache.
/// Subscribers (typically PlayScreen) use this to spawn NPCs from
/// <see cref="TacticalChunk.Spawns"/>. Does not fire for chunks already
/// in cache.
/// </summary>
public event System.Action<TacticalChunk>? OnChunkLoaded;
/// <summary>
/// Phase 5 M5: fires immediately before a chunk is evicted from cache.
/// Subscribers despawn NPCs sourced from this chunk so the active actor
/// list stays bounded.
/// </summary>
public event System.Action<TacticalChunk>? OnChunkEvicting;
public ChunkStreamer(ulong worldSeed, WorldState world, IChunkDeltaStore deltas)
: this(worldSeed, world, deltas, settlementContent: null) { }
/// <summary>
/// Phase 6 M0 — pass <paramref name="settlementContent"/> to enable
/// templated settlement stamping. <c>null</c> falls back to the
/// Phase-4 plaza+wall-ring placeholder.
/// </summary>
public ChunkStreamer(ulong worldSeed, WorldState world, IChunkDeltaStore deltas,
Theriapolis.Core.Data.SettlementContent? settlementContent)
{
_worldSeed = worldSeed;
_world = world;
_deltas = deltas;
_settlementContent = settlementContent;
}
/// <summary>
/// Returns the chunk for the given coord, generating + applying deltas if
/// necessary. Synchronous: blocks the caller for ~1 ms in the worst case
/// (chunk gen at 64×64 is well under our budget).
/// </summary>
public TacticalChunk Get(ChunkCoord cc)
{
if (_cache.TryGetValue(cc, out var cached)) return cached;
// If a pre-warm Task is in flight for this coord, drain it.
if (_inflight.TryGetValue(cc, out var task))
{
var fromTask = task.GetAwaiter().GetResult();
_inflight.Remove(cc);
_cache[cc] = fromTask;
ApplyDelta(fromTask);
OnChunkLoaded?.Invoke(fromTask);
return fromTask;
}
var chunk = TacticalChunkGen.Generate(_worldSeed, cc, _world, _settlementContent);
ApplyDelta(chunk);
_cache[cc] = chunk;
OnChunkLoaded?.Invoke(chunk);
return chunk;
}
/// <summary>
/// Returns the tactical tile at the given world-pixel coordinate, generating
/// the containing chunk on demand. Out-of-world coords return a default
/// "ocean" tile so callers don't need bounds checks.
/// </summary>
public TacticalTile SampleTile(int tx, int ty)
{
// Tactical tiles outside the world map are deep water — keeps the
// player from walking off the edge.
int worldPxW = C.WORLD_WIDTH_TILES * C.TACTICAL_PER_WORLD_TILE;
int worldPxH = C.WORLD_HEIGHT_TILES * C.TACTICAL_PER_WORLD_TILE;
if ((uint)tx >= worldPxW || (uint)ty >= worldPxH)
return new TacticalTile { Surface = TacticalSurface.DeepWater };
var cc = ChunkCoord.ForTactical(tx, ty);
var chunk = Get(cc);
int lx = tx - chunk.OriginX;
int ly = ty - chunk.OriginY;
return chunk.Tiles[lx, ly];
}
/// <summary>
/// Make sure every chunk overlapping the given world-pixel position +/- the
/// given radius (in world tiles) is loaded; evict everything else above the
/// soft cap. Pre-warms neighbours on the threadpool to hide latency on
/// world-tile crossings.
/// </summary>
public void EnsureLoadedAround(Util.Vec2 worldPixelPos, int worldTileRadius)
{
int playerWX = (int)MathF.Floor(worldPixelPos.X / C.WORLD_TILE_PIXELS);
int playerWY = (int)MathF.Floor(worldPixelPos.Y / C.WORLD_TILE_PIXELS);
// Compute the active set of chunks: every chunk overlapping the
// (2*radius+1)² world-tile window around the player.
var active = new HashSet<ChunkCoord>();
int worldTilesPerChunk = C.TACTICAL_CHUNK_SIZE / C.TACTICAL_PER_WORLD_TILE;
int chunkRadius = worldTileRadius / worldTilesPerChunk + 1;
var centre = ChunkCoord.ForWorldTile(playerWX, playerWY);
for (int cy = centre.Y - chunkRadius; cy <= centre.Y + chunkRadius; cy++)
for (int cx = centre.X - chunkRadius; cx <= centre.X + chunkRadius; cx++)
{
active.Add(new ChunkCoord(cx, cy));
}
// Synchronously generate any missing active chunks (the player needs
// them this frame). Pre-warm the next ring on the threadpool.
foreach (var cc in active)
{
if (!_cache.ContainsKey(cc) && !_inflight.ContainsKey(cc))
_ = Get(cc); // synchronous generate + cache
}
for (int cy = centre.Y - chunkRadius - 1; cy <= centre.Y + chunkRadius + 1; cy++)
for (int cx = centre.X - chunkRadius - 1; cx <= centre.X + chunkRadius + 1; cx++)
{
var cc = new ChunkCoord(cx, cy);
if (active.Contains(cc)) continue;
if (_cache.ContainsKey(cc) || _inflight.ContainsKey(cc)) continue;
// Kick off a background generation; the result will be drained by
// a future Get() call if it's needed.
var t = Task.Run(() => TacticalChunkGen.Generate(_worldSeed, cc, _world, _settlementContent));
_inflight[cc] = t;
}
EvictExcept(active);
}
private void EvictExcept(HashSet<ChunkCoord> keep)
{
// Evict cached chunks that aren't in the active set, oldest-first
// (HashMap iteration order isn't stable; we just take whatever's first).
if (_cache.Count <= C.CHUNK_CACHE_SOFT_MAX) return;
var toEvict = new List<ChunkCoord>();
foreach (var key in _cache.Keys)
if (!keep.Contains(key)) toEvict.Add(key);
foreach (var key in toEvict)
{
if (_cache.Count <= C.CHUNK_CACHE_SOFT_MAX) break;
FlushAndDrop(key);
}
}
/// <summary>
/// Force-flush every loaded chunk's delta back to the store and clear the
/// cache. Called at save time so nothing in-memory is lost.
/// </summary>
public void FlushAll()
{
foreach (var kv in _cache.ToArray())
FlushAndDrop(kv.Key);
_cache.Clear();
}
private void FlushAndDrop(ChunkCoord cc)
{
if (!_cache.TryGetValue(cc, out var chunk)) return;
OnChunkEvicting?.Invoke(chunk);
if (chunk.HasDelta)
{
// Compute the delta vs the deterministic baseline. Cheap because
// the baseline is itself recomputable.
var baseline = TacticalChunkGen.Generate(_worldSeed, cc, _world, _settlementContent);
_deltas.Put(cc, ComputeDelta(baseline, chunk));
}
_cache.Remove(cc);
}
private void ApplyDelta(TacticalChunk fresh)
{
var d = _deltas.Get(fresh.Coord);
if (d is null) return;
foreach (var m in d.TileMods)
{
ref var t = ref fresh.Tiles[m.LocalX, m.LocalY];
t.Surface = m.Surface;
t.Deco = m.Deco;
t.Flags = m.Flags;
}
if (d.SpawnsConsumed) fresh.Spawns.Clear();
fresh.HasDelta = true;
}
private static ChunkDelta ComputeDelta(TacticalChunk baseline, TacticalChunk current)
{
var d = new ChunkDelta();
for (int y = 0; y < C.TACTICAL_CHUNK_SIZE; y++)
for (int x = 0; x < C.TACTICAL_CHUNK_SIZE; x++)
{
var b = baseline.Tiles[x, y];
var c = current.Tiles[x, y];
if (b.Surface != c.Surface || b.Deco != c.Deco || b.Flags != c.Flags || b.Variant != c.Variant)
d.TileMods.Add(new TileMod(x, y, c.Surface, c.Deco, c.Flags));
}
if (current.Spawns.Count == 0 && baseline.Spawns.Count > 0)
d.SpawnsConsumed = true;
return d;
}
/// <summary>Mark a tile as user-modified so the streamer flushes it on eviction.</summary>
public void RecordModification(int tx, int ty)
{
var cc = ChunkCoord.ForTactical(tx, ty);
if (_cache.TryGetValue(cc, out var ch))
{
ch.HasDelta = true;
ch.Dirty = true;
}
}
}
@@ -0,0 +1,57 @@
namespace Theriapolis.Core.Tactical;
/// <summary>
/// Persists per-chunk player-caused modifications (chopped trees, dropped items,
/// emptied bandit camps). Implementations: in-memory (Phase 4 tests + runtime
/// cache), MessagePack-backed (Phase 4 save body — see <see cref="ChunkDelta"/>).
/// </summary>
public interface IChunkDeltaStore
{
/// <summary>Returns the delta for a chunk, or null if no modifications recorded.</summary>
ChunkDelta? Get(ChunkCoord cc);
/// <summary>Records a chunk's delta. Overwrites any existing entry.</summary>
void Put(ChunkCoord cc, ChunkDelta delta);
/// <summary>Erases a chunk's delta — the chunk reverts to its deterministic baseline.</summary>
void Remove(ChunkCoord cc);
/// <summary>Snapshot of every recorded delta, used by SaveCodec to flush the world.</summary>
IReadOnlyDictionary<ChunkCoord, ChunkDelta> All { get; }
}
/// <summary>
/// Sparse delta for a single chunk: just the tactical-tile cells that diverge
/// from <see cref="TacticalChunkGen.Generate"/>'s output. Phase 4 stores both
/// the surface and the deco; richer per-tile state arrives later.
/// </summary>
public sealed class ChunkDelta
{
public List<TileMod> TileMods { get; } = new();
/// <summary>True if the spawn list has been consumed (all spawns cleared).</summary>
public bool SpawnsConsumed { get; set; }
}
public readonly struct TileMod
{
public readonly byte LocalX;
public readonly byte LocalY;
public readonly TacticalSurface Surface;
public readonly TacticalDeco Deco;
public readonly byte Flags;
public TileMod(int lx, int ly, TacticalSurface s, TacticalDeco d, byte f)
{
LocalX = (byte)lx; LocalY = (byte)ly; Surface = s; Deco = d; Flags = f;
}
}
/// <summary>Trivial in-memory implementation. Used by tests and at runtime before save.</summary>
public sealed class InMemoryChunkDeltaStore : IChunkDeltaStore
{
private readonly Dictionary<ChunkCoord, ChunkDelta> _store = new();
public ChunkDelta? Get(ChunkCoord cc) => _store.TryGetValue(cc, out var d) ? d : null;
public void Put(ChunkCoord cc, ChunkDelta delta) => _store[cc] = delta;
public void Remove(ChunkCoord cc) => _store.Remove(cc);
public IReadOnlyDictionary<ChunkCoord, ChunkDelta> All => _store;
}
+102
View File
@@ -0,0 +1,102 @@
namespace Theriapolis.Core.Tactical;
/// <summary>
/// One streamed chunk of the tactical world. Always
/// <see cref="C.TACTICAL_CHUNK_SIZE"/>² tactical tiles.
///
/// Chunks are produced by <see cref="TacticalChunkGen"/> from the deterministic
/// inputs (worldSeed, ChunkCoord, WorldState) plus an optional delta overlay
/// from the save layer. The chunk itself does not know whether deltas have
/// been applied — that's <see cref="ChunkStreamer"/>'s job.
/// </summary>
public sealed class TacticalChunk
{
public ChunkCoord Coord { get; }
/// <summary>Indexed [tx, ty] in chunk-local coordinates (0..CHUNK_SIZE-1).</summary>
public TacticalTile[,] Tiles { get; } = new TacticalTile[C.TACTICAL_CHUNK_SIZE, C.TACTICAL_CHUNK_SIZE];
/// <summary>Phase-4 spawn list. Stored, not yet acted on (Phase 5 reads it).</summary>
public List<TacticalSpawn> Spawns { get; } = new();
/// <summary>
/// Phase 5 M5 difficulty tier (0..C.DANGER_ZONE_MAX). Drives which template
/// each <see cref="TacticalSpawn.Kind"/> instantiates as a live NPC. Set
/// in <see cref="TacticalChunkGen"/>'s spawn pass; folded into the hash
/// so determinism tests catch zone-formula drift.
/// </summary>
public byte DangerZone { get; set; }
/// <summary>Set true by ChunkStreamer when a delta is applied; flushed back on eviction.</summary>
public bool Dirty { get; set; }
/// <summary>True if any field has been modified relative to the deterministic baseline.</summary>
public bool HasDelta { get; set; }
public TacticalChunk(ChunkCoord coord) { Coord = coord; }
/// <summary>Top-left tactical tile coordinate of this chunk in world-pixel space.</summary>
public int OriginX => Coord.X * C.TACTICAL_CHUNK_SIZE;
public int OriginY => Coord.Y * C.TACTICAL_CHUNK_SIZE;
/// <summary>Returns the tile at chunk-local (lx, ly) — bounds-checked.</summary>
public ref TacticalTile TileAt(int lx, int ly)
{
if ((uint)lx >= C.TACTICAL_CHUNK_SIZE || (uint)ly >= C.TACTICAL_CHUNK_SIZE)
throw new ArgumentOutOfRangeException($"({lx},{ly}) outside chunk");
return ref Tiles[lx, ly];
}
/// <summary>FNV-1a hash over every tile. Used by determinism tests.</summary>
public ulong Hash()
{
const ulong FNV_PRIME = 1099511628211UL;
const ulong FNV_OFFSET = 14695981039346656037UL;
ulong h = FNV_OFFSET;
for (int y = 0; y < C.TACTICAL_CHUNK_SIZE; y++)
for (int x = 0; x < C.TACTICAL_CHUNK_SIZE; x++)
{
ref var t = ref Tiles[x, y];
h = (h ^ (byte)t.Surface) * FNV_PRIME;
h = (h ^ (byte)t.Deco) * FNV_PRIME;
h = (h ^ t.Variant) * FNV_PRIME;
h = (h ^ t.Flags) * FNV_PRIME;
}
// Fold spawn list into the hash so any non-determinism there shows up.
foreach (var s in Spawns)
{
h = (h ^ (byte)s.Kind) * FNV_PRIME;
h = (h ^ (uint)s.LocalX) * FNV_PRIME;
h = (h ^ (uint)s.LocalY) * FNV_PRIME;
}
// Fold the DangerZone in so changes to the zone formula register as
// a chunk-hash change in the determinism tests.
h = (h ^ DangerZone) * FNV_PRIME;
return h;
}
}
public enum SpawnKind : byte
{
None = 0,
WildAnimal,
Brigand,
Merchant,
Patrol,
PoiGuard,
/// <summary>
/// Phase 6 M0 — emitted by <see cref="World.Settlements.SettlementStamper"/>
/// for each occupied building role. Phase 6 M1 instantiates these as
/// friendly <see cref="Entities.NpcActor"/>s with role-specific dialogue.
/// </summary>
Resident = 16,
}
/// <summary>Single spawn record in chunk-local coordinates. Phase 5 acts on these.</summary>
public readonly struct TacticalSpawn
{
public readonly SpawnKind Kind;
public readonly int LocalX;
public readonly int LocalY;
public TacticalSpawn(SpawnKind kind, int lx, int ly) { Kind = kind; LocalX = lx; LocalY = ly; }
}
@@ -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);
}
}
+126
View File
@@ -0,0 +1,126 @@
namespace Theriapolis.Core.Tactical;
/// <summary>
/// Tactical-scale ground class. One tile = 1 world pixel.
/// Phase 4 keeps this enum tight; richer typing (subtypes per biome, special
/// surface effects) lands in later phases when art shows up.
/// </summary>
public enum TacticalSurface : byte
{
None = 0,
Grass,
TallGrass,
Dirt,
Sand,
Mud,
Snow,
Rock,
Cobble, // city plaza / highway / post road
Gravel, // footpath
TroddenDirt, // dirt road — visually distinct from wild Dirt biome ground
ShallowWater, // wadeable
DeepWater, // impassable
Marsh,
Floor, // building interior placeholder
Wall, // building wall (impassable)
// ── Phase 7 M1: dungeon surfaces ─────────────────────────────────────
// Distinct from settlement Floor / Wall so the renderer can pick the
// right tile-art family per dungeon type, and so the savegame can tell
// a building floor apart from a dungeon floor when (eventually) building
// deltas and dungeon state share a chunk-coord namespace.
DungeonFloor, // generic dungeon-interior floor (Imperium / general)
DungeonRubble, // damaged / collapsed floor — slows movement
DungeonTile, // mosaic / inlay floor (narrative rooms)
Cave, // natural-cave floor (Cult Den / Natural Cave dungeon types)
MineFloor, // worked tunnel floor (Abandoned Mine)
}
/// <summary>
/// Decoration on top of the ground (rendered above surface, may affect walkability).
/// </summary>
public enum TacticalDeco : byte
{
None = 0,
Tree,
Bush, // slows but does not block
Rock, // small — does not block
Boulder, // blocks
Flower,
Crop,
Reed,
Snag,
// Phase 6 M0 — interior decorations stamped by SettlementStamper.
Door = 16, // walkable, marks a building entrance
Counter, // shop / inn furniture, blocks movement
Bed, // furniture, blocks
Hearth, // furniture, blocks
Sign, // outdoor signpost, blocks (one tile)
// ── Phase 7 M1: dungeon decorations ─────────────────────────────────
// Stairs is the PoI-entrance interaction tile on the surface chunk
// *and* the dungeon-exit interaction tile inside a dungeon. The
// PlayScreen tells them apart by the active scene (chunk vs dungeon).
Stairs = 32, // walkable; player E to enter / exit
DungeonDoor, // walkable when open; lockable per RoomDoor
Container, // chest / sarcophagus / locked box (loot)
Trap, // tripwire (Phase 7 only ships this kind)
Brazier, // light source furniture, blocks
Pillar, // structural pillar, blocks
ImperiumStatue, // Imperium-themed deco, blocks (cover; flavour)
}
/// <summary>
/// Per-tactical-tile data. 16 bytes (well below the 64-byte cache line) so a
/// 64×64 chunk fits comfortably in L1.
/// </summary>
public struct TacticalTile
{
public TacticalSurface Surface;
public TacticalDeco Deco;
public byte Variant; // small RNG nibble for visual variation
public byte Flags; // packed bool flags, see TacticalFlags
public bool IsWalkable
{
get
{
if ((Flags & (byte)TacticalFlags.Impassable) != 0) return false;
return Surface switch
{
TacticalSurface.Wall => false,
TacticalSurface.DeepWater => false,
_ => true,
}
&& Deco != TacticalDeco.Tree && Deco != TacticalDeco.Boulder
&& Deco != TacticalDeco.Counter && Deco != TacticalDeco.Bed
&& Deco != TacticalDeco.Hearth && Deco != TacticalDeco.Sign
// Phase 7 M1 — dungeon decos that block movement.
&& Deco != TacticalDeco.Brazier && Deco != TacticalDeco.Pillar
&& Deco != TacticalDeco.ImperiumStatue && Deco != TacticalDeco.Container;
}
}
public bool SlowsMovement
=> (Flags & (byte)TacticalFlags.Slow) != 0
|| Deco == TacticalDeco.Bush
|| Surface == TacticalSurface.ShallowWater
|| Surface == TacticalSurface.Marsh
|| Surface == TacticalSurface.Mud
// Phase 7 M1 — dungeon rubble slows movement.
|| Surface == TacticalSurface.DungeonRubble;
}
[Flags]
public enum TacticalFlags : byte
{
None = 0,
Impassable = 1 << 0,
Slow = 1 << 1,
Bridge = 1 << 2, // walkable even though water is below
River = 1 << 3, // burned in by a river polyline
Road = 1 << 4, // burned in by a road polyline
Settlement = 1 << 5, // inside a settlement footprint
// Phase 6 M0 — building structure flags. Interior is derived
// (Settlement && Building && Surface==Floor) so we don't waste a bit.
Building = 1 << 6, // building wall or floor (subset of Settlement)
Doorway = 1 << 7, // walkable building entrance (subset of Building)
}