Files
TheriapolisV3/Theriapolis.Core/Tactical/ChunkStreamer.cs
T

244 lines
9.6 KiB
C#
Raw Normal View History

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 make every active chunk live in the cache. Get()
// handles all three paths: hit cache, drain a pre-warmed inflight
// task, or generate fresh. The previous version skipped chunks in
// _inflight, which left them stuck there indefinitely once they
// entered the active set on a later frame — visible to renderers
// that subscribe to OnChunkLoaded (e.g. the Godot port) as
// permanently-uncached gaps. The MonoGame TacticalRenderer dodged
// this by calling Get() inside its draw loop; M4 of the port made
// that drain unnecessary by fixing it here.
foreach (var cc in active)
{
if (!_cache.ContainsKey(cc))
_ = Get(cc); // hits cache, drains inflight, or generates
}
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;
}
}
}