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