b451f83174
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>
909 lines
40 KiB
C#
909 lines
40 KiB
C#
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();
|
|
}
|
|
}
|