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,908 @@
|
||||
using Microsoft.Xna.Framework;
|
||||
using Microsoft.Xna.Framework.Graphics;
|
||||
using Microsoft.Xna.Framework.Input;
|
||||
using Myra.Graphics2D;
|
||||
using Myra.Graphics2D.Brushes;
|
||||
using Myra.Graphics2D.UI;
|
||||
using Theriapolis.Core;
|
||||
using Theriapolis.Core.Data;
|
||||
using Theriapolis.Core.Entities;
|
||||
using Theriapolis.Core.Persistence;
|
||||
using Theriapolis.Core.Tactical;
|
||||
using Theriapolis.Core.Time;
|
||||
using Theriapolis.Core.Util;
|
||||
using Theriapolis.Core.World;
|
||||
using Theriapolis.Core.World.Generation;
|
||||
using Theriapolis.Game.Input;
|
||||
using Theriapolis.Game.Platform;
|
||||
using Theriapolis.Game.Rendering;
|
||||
|
||||
namespace Theriapolis.Game.Screens;
|
||||
|
||||
/// <summary>
|
||||
/// The in-game screen. Owns the camera, both renderers, the player actor,
|
||||
/// the world clock, and the input pipeline.
|
||||
///
|
||||
/// Phase 4 — M1: world-map only (click to walk). M3 will plug in the tactical
|
||||
/// renderer and view swap; M4 the autosave hooks.
|
||||
/// </summary>
|
||||
public sealed class PlayScreen : IScreen
|
||||
{
|
||||
private readonly WorldGenContext _ctx;
|
||||
private readonly SaveBody? _restoredBody;
|
||||
private readonly Theriapolis.Core.Rules.Character.Character? _pendingCharacter;
|
||||
private readonly string? _pendingName;
|
||||
private ContentResolver? _content;
|
||||
private float _saveFlashTimer;
|
||||
private string _saveFlashText = "";
|
||||
|
||||
// Phase 5 M5: per-session NPC roster delta. ChunkCoord → killed spawn indices.
|
||||
private readonly Dictionary<Theriapolis.Core.Tactical.ChunkCoord, HashSet<int>> _killedByChunk = new();
|
||||
// Tracks the active CombatHUD so SaveTo can snapshot it for save-anywhere mid-combat.
|
||||
private CombatHUDScreen? _activeCombatHud;
|
||||
// Cached interact prompt — updated each tick.
|
||||
private Theriapolis.Core.Entities.NpcActor? _interactCandidate;
|
||||
// Edge-detect F key for the interact prompt.
|
||||
private bool _fWasDown;
|
||||
// Phase 5 M5: pending encounter restore (deferred to first Update so chunks load NPCs first).
|
||||
private EncounterState? _pendingEncounterRestore;
|
||||
// Phase 6 M1: anchor:* and role:* lookup table for quest/dialogue resolution.
|
||||
private readonly Theriapolis.Core.World.Settlements.AnchorRegistry _anchorRegistry = new();
|
||||
// Phase 6 M2: faction standings + per-NPC personal disposition + ledger.
|
||||
private readonly Theriapolis.Core.Rules.Reputation.PlayerReputation _reputation = new();
|
||||
// Phase 6 M3: world flag dictionary written by dialogue set_flag effects
|
||||
// (and Phase 6 M4 by quest steps). Round-trips through SaveBody.Flags.
|
||||
private readonly Dictionary<string, int> _flags = new();
|
||||
// Phase 6 M4: quest engine — ticked from PlayScreen.Update.
|
||||
private readonly Theriapolis.Core.Rules.Quests.QuestEngine _questEngine = new();
|
||||
private Theriapolis.Core.Rules.Quests.QuestContext? _questCtx;
|
||||
|
||||
// Phase 6 M3 accessors used by InteractionScreen / ShopScreen to drive
|
||||
// dialogue + shop state without copying the live aggregates.
|
||||
internal Theriapolis.Core.Rules.Reputation.PlayerReputation Reputation => _reputation;
|
||||
internal Dictionary<string, int> Flags => _flags;
|
||||
internal Theriapolis.Core.World.Settlements.AnchorRegistry Anchors => _anchorRegistry;
|
||||
|
||||
internal Theriapolis.Core.Rules.Character.Character? PlayerCharacter()
|
||||
=> _actors.Player?.Character;
|
||||
internal Theriapolis.Core.Rules.Quests.QuestEngine QuestEngine => _questEngine;
|
||||
|
||||
/// <summary>
|
||||
/// Phase 6 M4 — fresh quest context for dialogue / shop screens that
|
||||
/// need to fire <c>start_quest</c> effects outside the regular tick.
|
||||
/// </summary>
|
||||
internal Theriapolis.Core.Rules.Quests.QuestContext? BuildQuestContextForDialogue()
|
||||
{
|
||||
if (_content is null) return null;
|
||||
if (_questCtx is null) return null;
|
||||
_questCtx.PlayerCharacter = _actors.Player?.Character;
|
||||
return _questCtx;
|
||||
}
|
||||
internal Vec2 PlayerActorPosition()
|
||||
=> _actors.Player?.Position ?? new Vec2(0, 0);
|
||||
internal long ClockSeconds()
|
||||
=> _clock.InGameSeconds;
|
||||
internal ulong WorldSeed()
|
||||
=> _ctx.World.WorldSeed;
|
||||
internal Theriapolis.Core.World.WorldState World()
|
||||
=> _ctx.World;
|
||||
internal ContentResolver? ContentResolver => _content;
|
||||
|
||||
private Game1 _game = null!;
|
||||
|
||||
private Camera2D _camera = null!;
|
||||
private TileAtlas _atlas = null!;
|
||||
private TacticalAtlas _tacticalAtlas = null!;
|
||||
private WorldMapRenderer _worldRenderer = null!;
|
||||
private TacticalRenderer _tacticalRenderer = null!;
|
||||
private LineFeatureRenderer _lineOverlay = null!;
|
||||
private PlayerSprite _playerSprite = null!;
|
||||
private NpcSprite _npcSprite = null!;
|
||||
private InputManager _input = null!;
|
||||
private SpriteBatch _sb = null!;
|
||||
|
||||
private ActorManager _actors = null!;
|
||||
private WorldClock _clock = null!;
|
||||
private PlayerController _controller = null!;
|
||||
private ChunkStreamer _streamer = null!;
|
||||
private InMemoryChunkDeltaStore _deltas = null!;
|
||||
|
||||
private Desktop _overlayDesktop = null!;
|
||||
private Label _hudLabel = null!;
|
||||
private int _cursorTileX, _cursorTileY; // world-tile coords (0..255)
|
||||
private int _cursorTacticalX, _cursorTacticalY; // tactical-tile coords (= world pixels)
|
||||
|
||||
// Click-vs-drag detection (same idiom as WorldMapScreen).
|
||||
private Vector2 _mouseDownPos;
|
||||
private int _mouseDownTileX, _mouseDownTileY;
|
||||
private bool _mouseDownTracked;
|
||||
private const float ClickSlopPixels = 4f;
|
||||
|
||||
public PlayScreen(WorldGenContext ctx)
|
||||
{
|
||||
_ctx = ctx;
|
||||
}
|
||||
|
||||
/// <summary>Restore-from-save constructor: applies the snapshot once Initialize runs.</summary>
|
||||
public PlayScreen(WorldGenContext ctx, SaveBody restoredBody)
|
||||
: this(ctx)
|
||||
{
|
||||
_restoredBody = restoredBody;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Phase 5 M2: new-game-with-character constructor. The character was built
|
||||
/// by <see cref="CharacterCreationScreen"/> and is attached to the spawned
|
||||
/// player on Initialize.
|
||||
/// </summary>
|
||||
public PlayScreen(WorldGenContext ctx, Theriapolis.Core.Rules.Character.Character character, string playerName)
|
||||
: this(ctx)
|
||||
{
|
||||
_pendingCharacter = character;
|
||||
_pendingName = playerName;
|
||||
}
|
||||
|
||||
public void Initialize(Game1 game)
|
||||
{
|
||||
_game = game;
|
||||
_input = new InputManager();
|
||||
_sb = new SpriteBatch(game.GraphicsDevice);
|
||||
|
||||
var gdw = new GraphicsDeviceWrapper(game.GraphicsDevice);
|
||||
_camera = new Camera2D(gdw);
|
||||
|
||||
_atlas = new TileAtlas(game.GraphicsDevice);
|
||||
_atlas.GeneratePlaceholders(_ctx.World.BiomeDefs!);
|
||||
|
||||
_worldRenderer = new WorldMapRenderer(_ctx, _atlas);
|
||||
_playerSprite = new PlayerSprite(game.GraphicsDevice);
|
||||
_npcSprite = new NpcSprite(game.GraphicsDevice);
|
||||
_lineOverlay = new LineFeatureRenderer(game.GraphicsDevice, _ctx);
|
||||
|
||||
_clock = new WorldClock();
|
||||
_actors = new ActorManager();
|
||||
_deltas = new InMemoryChunkDeltaStore();
|
||||
|
||||
// Phase 5: ContentResolver is needed for save/restore character round-trips
|
||||
// and to look up NPC templates from chunk spawns. Phase 6 M0: also feeds
|
||||
// building/layout content to the streamer so settlements stamp templates
|
||||
// instead of the placeholder plaza.
|
||||
_content = new ContentResolver(new ContentLoader(_game.ContentDataDirectory));
|
||||
_streamer = new ChunkStreamer(_ctx.World.WorldSeed, _ctx.World, _deltas, _content.Settlements);
|
||||
|
||||
// Phase 6 M1: pre-register every settlement's anchor id. Role tags
|
||||
// register lazily as residents stream in.
|
||||
_anchorRegistry.RegisterAllAnchors(_ctx.World);
|
||||
|
||||
// Phase 6 M4: build the quest context once content + clock + actors
|
||||
// are wired up. PlayerCharacter is filled in once SpawnPlayer runs.
|
||||
_questCtx = new Theriapolis.Core.Rules.Quests.QuestContext(
|
||||
_content, _actors, _reputation, _flags, _anchorRegistry, _clock, _ctx.World);
|
||||
|
||||
// Tactical art root: Gfx/tactical/{surface,deco}/<name>.png. The atlas
|
||||
// falls back to placeholders for any tile that has no PNG yet.
|
||||
string tacticalGfx = System.IO.Path.Combine(_game.ContentGfxDirectory, "tactical");
|
||||
_tacticalAtlas = new TacticalAtlas(game.GraphicsDevice, tacticalGfx);
|
||||
_tacticalRenderer = new TacticalRenderer(game.GraphicsDevice, _streamer, _tacticalAtlas);
|
||||
|
||||
// Phase 5 M5: subscribe to chunk events so NPCs spawn/despawn with the
|
||||
// active tactical window.
|
||||
_streamer.OnChunkLoaded += HandleChunkLoaded;
|
||||
_streamer.OnChunkEvicting += HandleChunkEvicting;
|
||||
|
||||
if (_restoredBody is not null)
|
||||
{
|
||||
ApplyRestoredBody(_restoredBody);
|
||||
}
|
||||
else
|
||||
{
|
||||
// New game: spawn at the Tier-1 anchor (Millhaven), or world centre as
|
||||
// a safe fallback if no Tier-1 exists yet.
|
||||
var spawn = ChooseSpawn(_ctx.World);
|
||||
if (_pendingCharacter is not null)
|
||||
{
|
||||
var p = _actors.SpawnPlayer(spawn, _pendingCharacter);
|
||||
if (!string.IsNullOrWhiteSpace(_pendingName)) p.Name = _pendingName;
|
||||
}
|
||||
else
|
||||
{
|
||||
_actors.SpawnPlayer(spawn);
|
||||
}
|
||||
}
|
||||
_controller = new PlayerController(_actors.Player!, _ctx.World, _clock);
|
||||
// Tactical sampler — looks up walkability through the streamer.
|
||||
_controller.TacticalIsWalkable = (tx, ty) => _streamer.SampleTile(tx, ty).IsWalkable;
|
||||
|
||||
// Camera initially centred on the player and zoomed to a comfortable
|
||||
// mid-zoom (between fit-the-world and tactical threshold) so the player
|
||||
// can see their surroundings without instantly entering tactical.
|
||||
_camera.Position = new Vector2(_actors.Player!.Position.X, _actors.Player.Position.Y);
|
||||
SetInitialZoom();
|
||||
|
||||
BuildOverlay();
|
||||
|
||||
// Phase 5 M5: if we restored a mid-combat encounter, force-load chunks
|
||||
// so the NPC actors spawn, then rehydrate the encounter and push the
|
||||
// CombatHUD on top.
|
||||
if (_pendingEncounterRestore is not null)
|
||||
{
|
||||
_streamer.EnsureLoadedAround(_actors.Player!.Position, C.TACTICAL_WINDOW_WORLD_TILES);
|
||||
RestoreEncounter(_pendingEncounterRestore);
|
||||
_pendingEncounterRestore = null;
|
||||
}
|
||||
}
|
||||
|
||||
private void RestoreEncounter(EncounterState saved)
|
||||
{
|
||||
if (_actors.Player?.Character is null) return;
|
||||
var participants = new List<Theriapolis.Core.Rules.Combat.Combatant>();
|
||||
foreach (var snap in saved.Combatants)
|
||||
{
|
||||
Theriapolis.Core.Rules.Combat.Combatant combatant;
|
||||
if (snap.IsPlayer)
|
||||
{
|
||||
combatant = Theriapolis.Core.Rules.Combat.Combatant.FromCharacter(
|
||||
_actors.Player.Character!, _actors.Player.Id, _actors.Player.Name,
|
||||
new Vec2((int)snap.PositionX, (int)snap.PositionY),
|
||||
Theriapolis.Core.Rules.Character.Allegiance.Player);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Try to find the live NPC actor (same chunk + spawn index).
|
||||
Theriapolis.Core.Entities.NpcActor? npc = null;
|
||||
if (snap.NpcChunkX is int cx && snap.NpcChunkY is int cy && snap.NpcSpawnIndex is int si)
|
||||
npc = _actors.FindNpcBySource(new Theriapolis.Core.Tactical.ChunkCoord(cx, cy), si);
|
||||
if (npc is null)
|
||||
{
|
||||
// Fall back to template-only combatant. Won't write back to a live actor on resolve,
|
||||
// but the encounter still completes correctly.
|
||||
var template = _content!.Npcs.Templates.FirstOrDefault(t => t.Id == snap.NpcTemplateId);
|
||||
if (template is null) continue;
|
||||
combatant = Theriapolis.Core.Rules.Combat.Combatant.FromNpcTemplate(
|
||||
template, snap.Id, new Vec2((int)snap.PositionX, (int)snap.PositionY));
|
||||
}
|
||||
else if (npc.Template is not null)
|
||||
{
|
||||
combatant = Theriapolis.Core.Rules.Combat.Combatant.FromNpcTemplate(
|
||||
npc.Template, npc.Id, new Vec2((int)snap.PositionX, (int)snap.PositionY));
|
||||
}
|
||||
else
|
||||
{
|
||||
// Phase 6 M1 resident — re-resolve via template id from the snapshot.
|
||||
var template = _content!.Npcs.Templates.FirstOrDefault(t => t.Id == snap.NpcTemplateId);
|
||||
if (template is null) continue;
|
||||
combatant = Theriapolis.Core.Rules.Combat.Combatant.FromNpcTemplate(
|
||||
template, npc.Id, new Vec2((int)snap.PositionX, (int)snap.PositionY));
|
||||
}
|
||||
}
|
||||
combatant.CurrentHp = snap.CurrentHp;
|
||||
combatant.Position = new Vec2((int)snap.PositionX, (int)snap.PositionY);
|
||||
foreach (byte cb in snap.Conditions)
|
||||
combatant.Conditions.Add((Theriapolis.Core.Rules.Stats.Condition)cb);
|
||||
participants.Add(combatant);
|
||||
}
|
||||
|
||||
var encounter = new Theriapolis.Core.Rules.Combat.Encounter(
|
||||
_ctx.World.WorldSeed, saved.EncounterId, participants);
|
||||
encounter.ResumeRolls(saved.RollCount);
|
||||
// Note: we do NOT restore CurrentTurnIndex / RoundNumber directly — the
|
||||
// encounter constructor recomputes initiative from the participants. Save
|
||||
// captures the round/turn for HUD display purposes; functional resume
|
||||
// works because the dice stream is at the same point.
|
||||
|
||||
_activeCombatHud = new CombatHUDScreen(encounter, _actors, OnEncounterEnd, _content);
|
||||
_game.Screens.Push(_activeCombatHud);
|
||||
}
|
||||
|
||||
private void ApplyRestoredBody(SaveBody body)
|
||||
{
|
||||
var player = _actors.RestorePlayer(body.Player);
|
||||
_clock.RestoreState(body.Clock);
|
||||
// Reload chunk delta store from the save.
|
||||
foreach (var kv in body.ModifiedChunks)
|
||||
_deltas.Put(kv.Key, kv.Value);
|
||||
// Apply world-tile deltas in place — these are sparse "the player burned
|
||||
// a settlement" style overrides, not full tile rewrites.
|
||||
foreach (var d in body.ModifiedWorldTiles)
|
||||
{
|
||||
ref var t = ref _ctx.World.TileAt(d.X, d.Y);
|
||||
t.Biome = (BiomeId)d.NewBiome;
|
||||
t.Features = (FeatureFlags)d.NewFeatures;
|
||||
}
|
||||
// Phase 5: rehydrate the character if one was saved. Phase-4 saves
|
||||
// (without character) would have been refused by SaveLoadScreen, so
|
||||
// here PlayerCharacter should always be non-null. Defensive null-check
|
||||
// anyway in case a hand-edited save sneaks through.
|
||||
if (body.PlayerCharacter is not null && _content is not null)
|
||||
{
|
||||
player.Character = CharacterCodec.Restore(body.PlayerCharacter, _content);
|
||||
}
|
||||
// Phase 5 M5: restore per-chunk killed-spawn-indices.
|
||||
_killedByChunk.Clear();
|
||||
foreach (var d in body.NpcRoster.ChunkDeltas)
|
||||
{
|
||||
var coord = new Theriapolis.Core.Tactical.ChunkCoord(d.ChunkX, d.ChunkY);
|
||||
_killedByChunk[coord] = new HashSet<int>(d.KilledSpawnIndices);
|
||||
}
|
||||
// Phase 5 M5: defer encounter rehydration until chunks load and NPC actors
|
||||
// exist; the first Update tick triggers EnsureLoadedAround which spawns them.
|
||||
_pendingEncounterRestore = body.ActiveEncounter;
|
||||
|
||||
// Phase 6 M2 — restore reputation aggregate. Replace the empty default
|
||||
// by mutating the existing instance in place so consumers holding a
|
||||
// reference (the ReputationScreen, dialogue runner) keep working.
|
||||
var restoredRep = Theriapolis.Core.Persistence.ReputationCodec.Restore(body.ReputationState);
|
||||
_reputation.Factions.Clear();
|
||||
foreach (var (k, v) in restoredRep.Factions.Standings) _reputation.Factions.Set(k, v);
|
||||
_reputation.Personal.Clear();
|
||||
foreach (var (k, v) in restoredRep.Personal) _reputation.Personal[k] = v;
|
||||
_reputation.Ledger.Clear();
|
||||
foreach (var ev in restoredRep.Ledger.Entries) _reputation.Ledger.Append(ev);
|
||||
|
||||
// Phase 6 M3 — restore world flag dictionary.
|
||||
_flags.Clear();
|
||||
foreach (var (k, v) in body.Flags) _flags[k] = v;
|
||||
|
||||
// Phase 6 M4 — restore quest engine state.
|
||||
Theriapolis.Core.Persistence.QuestCodec.Restore(_questEngine, body.QuestEngineState);
|
||||
}
|
||||
|
||||
/// <summary>Build a save body snapshot from the current screen state.</summary>
|
||||
private SaveBody CaptureBody()
|
||||
{
|
||||
// Capture the encounter snapshot BEFORE flushing chunks (snapshot needs
|
||||
// live combatant state, and FlushAll evicts NPCs which would erase it).
|
||||
EncounterState? activeEnc = _activeCombatHud is { IsOver: false }
|
||||
? _activeCombatHud.SnapshotForSave()
|
||||
: null;
|
||||
|
||||
// Push every loaded chunk through eviction so any in-memory deltas
|
||||
// hit the store before we read it. NOTE: this also despawns all live
|
||||
// NPCs via OnChunkEvicting — fine for save (state is captured above
|
||||
// for the encounter; NpcRoster captures the kill-list).
|
||||
_streamer.FlushAll();
|
||||
|
||||
var body = new SaveBody
|
||||
{
|
||||
Player = _actors.Player!.CaptureState(),
|
||||
Clock = _clock.CaptureState(),
|
||||
};
|
||||
// Phase 5: capture the character if one is attached.
|
||||
if (_actors.Player.Character is not null)
|
||||
body.PlayerCharacter = CharacterCodec.Capture(_actors.Player.Character);
|
||||
foreach (var kv in _deltas.All) body.ModifiedChunks[kv.Key] = kv.Value;
|
||||
|
||||
// Phase 5 M5: per-chunk killed-spawn-indices.
|
||||
foreach (var kv in _killedByChunk)
|
||||
body.NpcRoster.ChunkDeltas.Add(new NpcChunkDelta
|
||||
{
|
||||
ChunkX = kv.Key.X,
|
||||
ChunkY = kv.Key.Y,
|
||||
KilledSpawnIndices = kv.Value.ToArray(),
|
||||
});
|
||||
|
||||
body.ActiveEncounter = activeEnc;
|
||||
// Phase 6 M2 — capture reputation state.
|
||||
body.ReputationState = Theriapolis.Core.Persistence.ReputationCodec.Capture(_reputation);
|
||||
// Phase 6 M3 — capture world flag dictionary (dialogue set_flag effects).
|
||||
body.Flags = new Dictionary<string, int>(_flags);
|
||||
// Phase 6 M4 — capture quest engine state.
|
||||
body.QuestEngineState = Theriapolis.Core.Persistence.QuestCodec.Capture(_questEngine);
|
||||
return body;
|
||||
}
|
||||
|
||||
// ── Phase 5 M5: chunk → NPC lifecycle ───────────────────────────────
|
||||
|
||||
private void HandleChunkLoaded(Theriapolis.Core.Tactical.TacticalChunk chunk)
|
||||
{
|
||||
if (_content is null) return;
|
||||
// For each spawn in the chunk that hasn't been recorded as killed,
|
||||
// resolve it against the per-zone template table and spawn an NPC at
|
||||
// the spawn's tactical-tile coord (= world-pixel coord).
|
||||
_killedByChunk.TryGetValue(chunk.Coord, out var killed);
|
||||
for (int i = 0; i < chunk.Spawns.Count; i++)
|
||||
{
|
||||
if (killed is not null && killed.Contains(i)) continue;
|
||||
var spawn = chunk.Spawns[i];
|
||||
// Skip if an actor from this slot already exists (chunk reload).
|
||||
if (_actors.FindNpcBySource(chunk.Coord, i) is not null) continue;
|
||||
|
||||
// Phase 6 M1: residents take a different lookup path —
|
||||
// by building-role tag, not by danger zone.
|
||||
if (spawn.Kind == Theriapolis.Core.Tactical.SpawnKind.Resident)
|
||||
{
|
||||
Theriapolis.Core.Rules.Combat.ResidentInstantiator.Spawn(
|
||||
_ctx.World.WorldSeed, chunk, i, spawn,
|
||||
_ctx.World, _content, _actors, _anchorRegistry);
|
||||
continue;
|
||||
}
|
||||
|
||||
var template = Theriapolis.Core.Rules.Combat.NpcInstantiator.PickTemplate(
|
||||
spawn.Kind, chunk.DangerZone, _content.Npcs);
|
||||
if (template is null) continue;
|
||||
|
||||
int tx = chunk.OriginX + spawn.LocalX;
|
||||
int ty = chunk.OriginY + spawn.LocalY;
|
||||
_actors.SpawnNpc(template, new Vec2(tx, ty), chunk.Coord, i);
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleChunkEvicting(Theriapolis.Core.Tactical.TacticalChunk chunk)
|
||||
{
|
||||
// Despawn any live NPCs sourced from this chunk so the active actor
|
||||
// list stays bounded as the player moves.
|
||||
var toRemove = new List<int>();
|
||||
foreach (var npc in _actors.Npcs)
|
||||
{
|
||||
if (npc.SourceChunk is { } src && src.Equals(chunk.Coord))
|
||||
{
|
||||
toRemove.Add(npc.Id);
|
||||
// Phase 6 M1 — drop role-tag mapping so the registry stays in
|
||||
// sync with active actors. Anchor entries (settlements) stay.
|
||||
if (!string.IsNullOrEmpty(npc.RoleTag))
|
||||
_anchorRegistry.UnregisterRole(npc.RoleTag);
|
||||
}
|
||||
}
|
||||
foreach (int id in toRemove) _actors.RemoveActor(id);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Phase 5 M5 per-tick check: hostile in LOS within
|
||||
/// <see cref="C.ENCOUNTER_TRIGGER_TILES"/> → start an encounter.
|
||||
/// Friendly/neutral within <see cref="C.INTERACT_PROMPT_TILES"/> →
|
||||
/// record interact candidate so the HUD can show "[F] Talk to ...".
|
||||
/// </summary>
|
||||
private void TickEncounterAndInteract()
|
||||
{
|
||||
if (_actors.Player is null) return;
|
||||
if (_activeCombatHud is { IsOver: false }) return; // already in combat
|
||||
|
||||
// Phase 6 M4 — quest engine tick. Updates active quests, checks
|
||||
// auto-start triggers, runs effects. Cheap (a few µs even with
|
||||
// dozens of active quests).
|
||||
if (_questCtx is not null)
|
||||
{
|
||||
_questCtx.PlayerCharacter = _actors.Player.Character;
|
||||
_questEngine.Tick(_questCtx);
|
||||
}
|
||||
|
||||
// Phase 6 M5 — faction-driven aggression. Flips friendly/neutral
|
||||
// faction-affiliated NPCs to Hostile when local disposition drops
|
||||
// through the HOSTILE threshold. Runs BEFORE FindHostileTrigger so
|
||||
// a freshly-flipped patrol attacks on the same tick.
|
||||
if (_content is not null && _actors.Player.Character is { } pcChar)
|
||||
{
|
||||
Theriapolis.Core.Rules.Reputation.FactionAggression.UpdateAllegiances(
|
||||
_actors, pcChar, _reputation, _content, _ctx.World, _ctx.World.WorldSeed);
|
||||
}
|
||||
|
||||
// Hostile auto-trigger.
|
||||
var hostile = Theriapolis.Core.Rules.Combat.EncounterTrigger.FindHostileTrigger(_actors);
|
||||
if (hostile is not null)
|
||||
{
|
||||
StartEncounterWith(hostile);
|
||||
return;
|
||||
}
|
||||
|
||||
// Friendly/neutral interact prompt.
|
||||
_interactCandidate = Theriapolis.Core.Rules.Combat.EncounterTrigger.FindInteractCandidate(_actors);
|
||||
}
|
||||
|
||||
private void StartEncounterWith(Theriapolis.Core.Entities.NpcActor seed)
|
||||
{
|
||||
if (_actors.Player?.Character is null) return;
|
||||
|
||||
// Player + the triggering NPC + any other living hostiles within
|
||||
// ENCOUNTER_TRIGGER_TILES (multi-mob encounters).
|
||||
var player = _actors.Player;
|
||||
var participants = new List<Theriapolis.Core.Rules.Combat.Combatant>();
|
||||
participants.Add(Theriapolis.Core.Rules.Combat.Combatant.FromCharacter(
|
||||
player.Character!, player.Id, player.Name,
|
||||
new Vec2((int)player.Position.X, (int)player.Position.Y),
|
||||
Theriapolis.Core.Rules.Character.Allegiance.Player));
|
||||
foreach (var npc in _actors.Npcs)
|
||||
{
|
||||
if (!npc.IsAlive) continue;
|
||||
if (npc.Allegiance != Theriapolis.Core.Rules.Character.Allegiance.Hostile) continue;
|
||||
if (npc.Template is null) continue; // residents (Phase 6 M1) skip combat
|
||||
int dx = (int)System.Math.Abs(player.Position.X - npc.Position.X);
|
||||
int dy = (int)System.Math.Abs(player.Position.Y - npc.Position.Y);
|
||||
if (System.Math.Max(dx, dy) > C.ENCOUNTER_TRIGGER_TILES) continue;
|
||||
var combatant = Theriapolis.Core.Rules.Combat.Combatant.FromNpcTemplate(
|
||||
npc.Template, npc.Id,
|
||||
new Vec2((int)npc.Position.X, (int)npc.Position.Y));
|
||||
// Sync HP from the live actor in case it took damage from a previous fight.
|
||||
combatant.CurrentHp = npc.CurrentHp;
|
||||
participants.Add(combatant);
|
||||
}
|
||||
|
||||
ulong encId = (ulong)seed.Id;
|
||||
var encounter = new Theriapolis.Core.Rules.Combat.Encounter(
|
||||
_ctx.World.WorldSeed, encId, participants);
|
||||
|
||||
// Phase 6.5 M1 — top up per-encounter resource pools (Lay on Paws,
|
||||
// Field Repair, Vocalization Dice). Phase 8's rest model will replace
|
||||
// this encounter-rest equivalence.
|
||||
// Phase 6.5 M3 adds Pheromone Craft + Covenant Authority pools.
|
||||
if (_actors.Player?.Character is { } pc)
|
||||
{
|
||||
Theriapolis.Core.Rules.Combat.FeatureProcessor.EnsureLayOnPawsPoolReady(pc);
|
||||
Theriapolis.Core.Rules.Combat.FeatureProcessor.EnsureFieldRepairReady(pc);
|
||||
Theriapolis.Core.Rules.Combat.FeatureProcessor.EnsureVocalizationDiceReady(pc);
|
||||
Theriapolis.Core.Rules.Combat.FeatureProcessor.EnsurePheromoneUsesReady(pc);
|
||||
Theriapolis.Core.Rules.Combat.FeatureProcessor.EnsureCovenantAuthorityReady(pc);
|
||||
}
|
||||
|
||||
// Combat-start autosave to a dedicated slot so the player can always
|
||||
// retry the most recent fight even if their manual save is older.
|
||||
SaveTo(SavePaths.AutosavePath());
|
||||
|
||||
_activeCombatHud = new CombatHUDScreen(encounter, _actors, OnEncounterEnd, _content);
|
||||
_game.Screens.Push(_activeCombatHud);
|
||||
}
|
||||
|
||||
private void OnEncounterEnd(EncounterEndResult result)
|
||||
{
|
||||
// Merge per-chunk kill records.
|
||||
foreach (var kv in result.Killed)
|
||||
{
|
||||
if (!_killedByChunk.TryGetValue(kv.Key, out var set))
|
||||
_killedByChunk[kv.Key] = set = new HashSet<int>();
|
||||
foreach (int idx in kv.Value) set.Add(idx);
|
||||
}
|
||||
_activeCombatHud = null;
|
||||
}
|
||||
|
||||
/// <summary>Build the save header from current state + worldgen StageHashes.</summary>
|
||||
private SaveHeader BuildHeader()
|
||||
{
|
||||
var h = new SaveHeader
|
||||
{
|
||||
WorldSeedHex = $"0x{_ctx.World.WorldSeed:X}",
|
||||
PlayerName = _actors.Player!.Name,
|
||||
PlayerTier = _actors.Player.HighestTierReached,
|
||||
InGameSeconds = _clock.InGameSeconds,
|
||||
SavedAtUtc = DateTime.UtcNow.ToString("u"),
|
||||
};
|
||||
foreach (var kv in _ctx.World.StageHashes)
|
||||
h.StageHashes[kv.Key] = $"0x{kv.Value:X}";
|
||||
return h;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Write the current state to the given slot path (atomic). Used by F5
|
||||
/// quicksave, by the slot picker, and by autosave on screen transitions.
|
||||
/// </summary>
|
||||
public bool SaveTo(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
var header = BuildHeader();
|
||||
var body = CaptureBody();
|
||||
var bytes = SaveCodec.Serialize(header, body);
|
||||
SavePaths.WriteAtomic(path, bytes);
|
||||
FlashSavedToast($"Saved to {Path.GetFileName(path)}");
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
FlashSavedToast($"Save failed: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private void FlashSavedToast(string text)
|
||||
{
|
||||
_saveFlashText = text;
|
||||
_saveFlashTimer = 2.5f;
|
||||
}
|
||||
|
||||
private static Vec2 ChooseSpawn(WorldState w)
|
||||
{
|
||||
var tier1 = w.Settlements.FirstOrDefault(s => s.Tier == 1 && !s.IsPoi);
|
||||
if (tier1 is not null) return new Vec2(tier1.WorldPixelX, tier1.WorldPixelY);
|
||||
var anyInhabited = w.Settlements.FirstOrDefault(s => !s.IsPoi);
|
||||
if (anyInhabited is not null) return new Vec2(anyInhabited.WorldPixelX, anyInhabited.WorldPixelY);
|
||||
// Last-ditch: centre of the world.
|
||||
return new Vec2(C.WORLD_WIDTH_TILES * C.WORLD_TILE_PIXELS * 0.5f,
|
||||
C.WORLD_HEIGHT_TILES * C.WORLD_TILE_PIXELS * 0.5f);
|
||||
}
|
||||
|
||||
private void SetInitialZoom()
|
||||
{
|
||||
// Frame ~24 tiles across the screen — comfortable overland-travel zoom.
|
||||
float targetZoom = (float)_game.GraphicsDevice.Viewport.Width
|
||||
/ (24f * C.WORLD_TILE_PIXELS);
|
||||
targetZoom = Math.Clamp(targetZoom, Camera2D.MinZoom, Camera2D.TacticalThreshold * 0.95f);
|
||||
_camera.AdjustZoom(
|
||||
targetZoom / _camera.Zoom - 1f,
|
||||
new Vector2(_game.GraphicsDevice.Viewport.Width * 0.5f,
|
||||
_game.GraphicsDevice.Viewport.Height * 0.5f));
|
||||
}
|
||||
|
||||
private void BuildOverlay()
|
||||
{
|
||||
_hudLabel = new Label
|
||||
{
|
||||
Text = "",
|
||||
HorizontalAlignment = HorizontalAlignment.Left,
|
||||
VerticalAlignment = VerticalAlignment.Top,
|
||||
Margin = new Thickness(8),
|
||||
Padding = new Thickness(8, 4, 8, 4),
|
||||
Background = new SolidBrush(new Color(0, 0, 0, 180)),
|
||||
};
|
||||
_overlayDesktop = new Desktop { Root = _hudLabel };
|
||||
}
|
||||
|
||||
public void Update(GameTime gt)
|
||||
{
|
||||
_input.Update();
|
||||
if (!_game.IsActive) return;
|
||||
|
||||
// ESC → push the pause menu (Phase 5 M2). The menu offers Resume,
|
||||
// Save Game (any slot), Quicksave, and Quit-to-Title (autosaves first).
|
||||
if (_input.JustPressed(Keys.Escape))
|
||||
{
|
||||
_game.Screens.Push(new PauseMenuScreen(this));
|
||||
return;
|
||||
}
|
||||
|
||||
// TAB → open inventory (Phase 5 M3). Requires a Character on the player.
|
||||
if (_input.JustPressed(Keys.Tab) && _actors.Player?.Character is not null)
|
||||
{
|
||||
_game.Screens.Push(new InventoryScreen(_actors.Player.Character));
|
||||
return;
|
||||
}
|
||||
|
||||
// R → reputation screen (Phase 6 M2).
|
||||
if (_input.JustPressed(Keys.R) && _content is not null)
|
||||
{
|
||||
_game.Screens.Push(new ReputationScreen(_reputation, _content));
|
||||
return;
|
||||
}
|
||||
|
||||
// J → quest journal (Phase 6 M4).
|
||||
if (_input.JustPressed(Keys.J) && _content is not null)
|
||||
{
|
||||
_game.Screens.Push(new QuestLogScreen(_questEngine, _content));
|
||||
return;
|
||||
}
|
||||
// F5 → quicksave to autosave slot (no slot-picker flow).
|
||||
if (_input.JustPressed(Keys.F5))
|
||||
SaveTo(SavePaths.AutosavePath());
|
||||
|
||||
float dt = (float)gt.ElapsedGameTime.TotalSeconds;
|
||||
float panSpeed = 400f / _camera.Zoom;
|
||||
|
||||
// Camera pan stays on arrow keys / middle-drag so WASD remains free for
|
||||
// tactical stepping (M3). The world-map view doesn't read WASD.
|
||||
Vector2 panDir = Vector2.Zero;
|
||||
if (_input.IsDown(Keys.Up)) panDir.Y -= 1;
|
||||
if (_input.IsDown(Keys.Down)) panDir.Y += 1;
|
||||
if (_input.IsDown(Keys.Left)) panDir.X -= 1;
|
||||
if (_input.IsDown(Keys.Right)) panDir.X += 1;
|
||||
if (panDir != Vector2.Zero && _camera.Mode == ViewMode.WorldMap)
|
||||
_camera.Pan(panDir * panSpeed * dt);
|
||||
|
||||
// Track mouse-down for click-vs-drag.
|
||||
if (_input.LeftJustDown)
|
||||
{
|
||||
_mouseDownPos = _input.MousePosition;
|
||||
var downWorld = _camera.ScreenToWorld(_input.MousePosition);
|
||||
_mouseDownTileX = (int)MathF.Floor(downWorld.X / C.WORLD_TILE_PIXELS);
|
||||
_mouseDownTileY = (int)MathF.Floor(downWorld.Y / C.WORLD_TILE_PIXELS);
|
||||
_mouseDownTracked = true;
|
||||
}
|
||||
|
||||
// Mouse drag → pan (right-mouse on tactical so left-click stays usable for actions later).
|
||||
var dragDelta = _input.ConsumeDragDelta(_camera);
|
||||
if (dragDelta != Vector2.Zero) _camera.Pan(dragDelta);
|
||||
|
||||
// Mouse wheel zoom.
|
||||
int scroll = _input.ScrollDelta;
|
||||
if (scroll != 0)
|
||||
_camera.AdjustZoom(scroll > 0 ? 0.12f : -0.12f, _input.MousePosition);
|
||||
|
||||
// Resolve cursor → both world-tile and tactical-tile coords for the HUD.
|
||||
var worldPos = _camera.ScreenToWorld(_input.MousePosition);
|
||||
_cursorTileX = (int)MathF.Floor(worldPos.X / C.WORLD_TILE_PIXELS);
|
||||
_cursorTileY = (int)MathF.Floor(worldPos.Y / C.WORLD_TILE_PIXELS);
|
||||
_cursorTacticalX = (int)MathF.Floor(worldPos.X);
|
||||
_cursorTacticalY = (int)MathF.Floor(worldPos.Y);
|
||||
|
||||
// Click handler: world-map → travel; tactical → no-op for now.
|
||||
if (_input.LeftJustUp && _mouseDownTracked)
|
||||
{
|
||||
_mouseDownTracked = false;
|
||||
bool wasClick = Vector2.Distance(_input.MousePosition, _mouseDownPos) <= ClickSlopPixels;
|
||||
if (wasClick && _camera.Mode == ViewMode.WorldMap)
|
||||
{
|
||||
if (InBounds(_mouseDownTileX, _mouseDownTileY))
|
||||
_controller.RequestTravelTo(_mouseDownTileX, _mouseDownTileY);
|
||||
}
|
||||
}
|
||||
|
||||
_controller.Update(gt, _input, _camera, _game.IsActive);
|
||||
|
||||
// Camera follow when traveling so the player stays centred.
|
||||
if (_controller.IsTraveling || _camera.Mode == ViewMode.Tactical)
|
||||
{
|
||||
_camera.Position = new Vector2(_actors.Player!.Position.X, _actors.Player.Position.Y);
|
||||
}
|
||||
|
||||
// Stream tactical chunks around the player whenever we're in (or
|
||||
// about to enter) tactical mode. We do this even on world-map mode
|
||||
// so the swap is instantaneous when the player zooms in.
|
||||
if (_camera.Mode == ViewMode.Tactical)
|
||||
_streamer.EnsureLoadedAround(_actors.Player!.Position, C.TACTICAL_WINDOW_WORLD_TILES);
|
||||
|
||||
// Phase 5 M5: encounter trigger + interact prompt only fire in
|
||||
// tactical mode (world-map travel doesn't surface NPCs at this scale).
|
||||
if (_camera.Mode == ViewMode.Tactical) TickEncounterAndInteract();
|
||||
else _interactCandidate = null;
|
||||
|
||||
// Friendly NPC F-press → push InteractionScreen.
|
||||
bool fNow = _input.IsDown(Keys.F);
|
||||
bool fJustDown = fNow && !_fWasDown;
|
||||
_fWasDown = fNow;
|
||||
if (fJustDown && _interactCandidate is not null)
|
||||
{
|
||||
_game.Screens.Push(new InteractionScreen(_interactCandidate, _content, this));
|
||||
_interactCandidate = null;
|
||||
}
|
||||
|
||||
if (_saveFlashTimer > 0f)
|
||||
_saveFlashTimer = MathF.Max(0f, _saveFlashTimer - dt);
|
||||
|
||||
UpdateOverlayText();
|
||||
}
|
||||
|
||||
private static bool InBounds(int x, int y)
|
||||
=> (uint)x < C.WORLD_WIDTH_TILES && (uint)y < C.WORLD_HEIGHT_TILES;
|
||||
|
||||
private void UpdateOverlayText()
|
||||
{
|
||||
var p = _actors.Player!;
|
||||
int ptx = (int)MathF.Floor(p.Position.X / C.WORLD_TILE_PIXELS);
|
||||
int pty = (int)MathF.Floor(p.Position.Y / C.WORLD_TILE_PIXELS);
|
||||
ref var t = ref _ctx.World.TileAt(
|
||||
Math.Clamp(ptx, 0, C.WORLD_WIDTH_TILES - 1),
|
||||
Math.Clamp(pty, 0, C.WORLD_HEIGHT_TILES - 1));
|
||||
string status = _controller.IsTraveling
|
||||
? "Traveling..."
|
||||
: _camera.Mode == ViewMode.Tactical
|
||||
? "WASD to step. Mouse-wheel out to leave tactical."
|
||||
: "Click a tile to travel. Mouse-wheel in for tactical.";
|
||||
|
||||
string toast = _saveFlashTimer > 0f ? $"\n[ {_saveFlashText} ]" : "";
|
||||
|
||||
string cursorBlock = _camera.Mode == ViewMode.Tactical
|
||||
? FormatTacticalCursor()
|
||||
: $"Cursor: ({_cursorTileX},{_cursorTileY})";
|
||||
|
||||
// Phase 5 M3: character header with HP/AC/encumbrance when attached.
|
||||
string charBlock = "";
|
||||
if (p.Character is { } pc)
|
||||
{
|
||||
int ac = Theriapolis.Core.Rules.Stats.DerivedStats.ArmorClass(pc);
|
||||
var enc = Theriapolis.Core.Rules.Stats.DerivedStats.Encumbrance(pc);
|
||||
string encTag = enc switch
|
||||
{
|
||||
Theriapolis.Core.Rules.Stats.DerivedStats.EncumbranceBand.Heavy => " [encumbered]",
|
||||
Theriapolis.Core.Rules.Stats.DerivedStats.EncumbranceBand.Over => " [over-encumbered]",
|
||||
_ => "",
|
||||
};
|
||||
charBlock = $"{p.Name} HP {pc.CurrentHp}/{pc.MaxHp} AC {ac}{encTag}\n";
|
||||
}
|
||||
|
||||
// Phase 5 M5: show "[F] Talk to ..." when a friendly/neutral is near.
|
||||
// Phase 6 M2: append the effective-disposition breakdown so the
|
||||
// player can see why an NPC is friendly/cool/hostile before talking.
|
||||
string interact = "";
|
||||
if (_interactCandidate is { } npc)
|
||||
{
|
||||
interact = $"\n[F] Talk to {npc.DisplayName}";
|
||||
if (p.Character is { } pcChar && _content is not null)
|
||||
{
|
||||
var br = Theriapolis.Core.Rules.Reputation.EffectiveDisposition.Breakdown(
|
||||
npc, pcChar, _reputation, _content, _ctx.World, _ctx.World.WorldSeed);
|
||||
interact += $" ({Theriapolis.Core.Rules.Reputation.DispositionLabels.DisplayName(br.Label)} {br.Total:+#;-#;0})";
|
||||
interact += $"\n clade {br.CladeBias:+#;-#;0} size {br.SizeDifferential:+#;-#;0} faction {br.FactionModifier:+#;-#;0} personal {br.Personal:+#;-#;0}";
|
||||
|
||||
// Phase 6 M5 — "why" breadcrumb. If the NPC has a settlement
|
||||
// home and a faction, show the most recent event reaching
|
||||
// them with its decay band so the player understands the
|
||||
// tooltip score.
|
||||
if (!string.IsNullOrEmpty(npc.FactionId) && npc.HomeSettlementId is { } hid
|
||||
&& _ctx.World.Settlements.FirstOrDefault(s => s.Id == hid) is { } home)
|
||||
{
|
||||
var explained = Theriapolis.Core.Rules.Reputation.RepPropagation
|
||||
.ExplainLocalStanding(npc.FactionId, home, _ctx.World.WorldSeed,
|
||||
_reputation.Ledger, _content.Factions, max: 1)
|
||||
.FirstOrDefault();
|
||||
if (explained.Event is not null)
|
||||
{
|
||||
interact += $"\n ↳ recent: {explained.Event.Note} "
|
||||
+ $"({explained.Band}, {explained.LocalDelta:+#;-#;0})";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_hudLabel.Text =
|
||||
charBlock +
|
||||
$"Seed: 0x{_ctx.World.WorldSeed:X}\n" +
|
||||
$"Player: ({ptx},{pty}) {t.Biome}\n" +
|
||||
$"{cursorBlock}\n" +
|
||||
$"View: {_camera.Mode} zoom={_camera.Zoom:F2}\n" +
|
||||
$"Time: {_clock.Format()}\n" +
|
||||
$"{status}\n" +
|
||||
"F5 = Quicksave · TAB = Inventory · ESC = Pause Menu" + interact + toast;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tactical cursor read-out: tactical coord, surface, deco, walkability,
|
||||
/// and the active flag set. SampleTile lazy-generates the chunk under the
|
||||
/// cursor if needed; the soft cache cap evicts it on the next stream sweep.
|
||||
/// </summary>
|
||||
private string FormatTacticalCursor()
|
||||
{
|
||||
int worldPxW = C.WORLD_WIDTH_TILES * C.TACTICAL_PER_WORLD_TILE;
|
||||
int worldPxH = C.WORLD_HEIGHT_TILES * C.TACTICAL_PER_WORLD_TILE;
|
||||
if ((uint)_cursorTacticalX >= worldPxW || (uint)_cursorTacticalY >= worldPxH)
|
||||
return $"Cursor: ({_cursorTacticalX},{_cursorTacticalY}) <off-world>";
|
||||
|
||||
var tt = _streamer.SampleTile(_cursorTacticalX, _cursorTacticalY);
|
||||
string deco = tt.Deco == TacticalDeco.None ? "—" : tt.Deco.ToString();
|
||||
string pass = tt.IsWalkable ? "walkable" : "blocked";
|
||||
if (tt.SlowsMovement && tt.IsWalkable) pass = "slow";
|
||||
|
||||
// Render flag set as a compact tag list (River, Road, Bridge, Settlement, Slow).
|
||||
var flags = (TacticalFlags)tt.Flags;
|
||||
string flagText = flags == TacticalFlags.None ? "" : $" [{flags}]";
|
||||
|
||||
return
|
||||
$"Cursor: ({_cursorTacticalX},{_cursorTacticalY})\n" +
|
||||
$" Surface: {tt.Surface} (v{tt.Variant})\n" +
|
||||
$" Deco: {deco}\n" +
|
||||
$" Move: {pass}{flagText}";
|
||||
}
|
||||
|
||||
public void Draw(GameTime gt, SpriteBatch _)
|
||||
{
|
||||
_game.GraphicsDevice.Clear(new Color(5, 10, 20));
|
||||
|
||||
// Renderer swap — world-map view below the tactical-zoom threshold,
|
||||
// tactical above. WorldMapRenderer already includes its polyline pass.
|
||||
// In tactical mode the chunk gen has already burned roads/rivers into
|
||||
// surface tiles via TacticalChunkGen.Pass2_Polylines, so re-stroking
|
||||
// the polylines on top would double-draw the road and create visible
|
||||
// overlap artefacts.
|
||||
if (_camera.Mode == ViewMode.WorldMap)
|
||||
{
|
||||
_worldRenderer.Draw(_sb, _camera, gt);
|
||||
}
|
||||
else
|
||||
{
|
||||
_tacticalRenderer.Draw(_sb, _camera, gt);
|
||||
}
|
||||
// NPCs draw before the player so the player marker sits on top.
|
||||
_npcSprite.Draw(_sb, _camera, _actors.Npcs);
|
||||
_playerSprite.Draw(_sb, _camera, _actors.Player!);
|
||||
_overlayDesktop.Render();
|
||||
}
|
||||
|
||||
public void Deactivate() { }
|
||||
public void Reactivate() { }
|
||||
|
||||
~PlayScreen()
|
||||
{
|
||||
_worldRenderer?.Dispose();
|
||||
_tacticalRenderer?.Dispose();
|
||||
_tacticalAtlas?.Dispose();
|
||||
_lineOverlay?.Dispose();
|
||||
_playerSprite?.Dispose();
|
||||
_npcSprite?.Dispose();
|
||||
_atlas?.Dispose();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user