Files

909 lines
40 KiB
C#
Raw Permalink Normal View History

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();
}
}