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