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; /// /// 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. /// 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> _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 _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 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; /// /// Phase 6 M4 — fresh quest context for dialogue / shop screens that /// need to fire start_quest effects outside the regular tick. /// 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; } /// Restore-from-save constructor: applies the snapshot once Initialize runs. public PlayScreen(WorldGenContext ctx, SaveBody restoredBody) : this(ctx) { _restoredBody = restoredBody; } /// /// Phase 5 M2: new-game-with-character constructor. The character was built /// by and is attached to the spawned /// player on Initialize. /// 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}/.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(); 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(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); } /// Build a save body snapshot from the current screen state. 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(_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(); 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); } /// /// Phase 5 M5 per-tick check: hostile in LOS within /// → start an encounter. /// Friendly/neutral within → /// record interact candidate so the HUD can show "[F] Talk to ...". /// 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(); 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(); foreach (int idx in kv.Value) set.Add(idx); } _activeCombatHud = null; } /// Build the save header from current state + worldgen StageHashes. 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; } /// /// Write the current state to the given slot path (atomic). Used by F5 /// quicksave, by the slot picker, and by autosave on screen transitions. /// 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; } /// /// 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. /// 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}) "; 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(); } }