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.Entities; using Theriapolis.Core.Entities.Ai; using Theriapolis.Core.Persistence; using Theriapolis.Core.Rules.Combat; using Theriapolis.Core.Rules.Stats; using Theriapolis.Core.Util; namespace Theriapolis.Game.Screens; /// /// Phase 5 M5 turn-based combat overlay. Pushed by PlayScreen when an /// encounter triggers; owns the live , drives input /// on the player's turn, ticks NPC behaviors on theirs, and on victory /// writes results back to the live actors and pops itself. /// /// Player input (during player's turn): /// WASD / arrows → move 1 tactical tile (5 ft. of movement budget) /// SPACE → attack closest hostile in reach /// ENTER → end turn /// /// Save-anywhere works mid-combat: PlayScreen.CaptureBody calls /// ; on load, PlayScreen re-pushes this screen /// with the rebuilt encounter via the rehydrate constructor. /// public sealed class CombatHUDScreen : IScreen { private readonly Encounter _encounter; private readonly ActorManager _actors; private readonly Theriapolis.Core.Data.ContentResolver? _content; private readonly System.Action _onEnd; private Game1 _game = null!; private Desktop _desktop = null!; private Label? _initLabel; private Label? _logLabel; private Label? _actionLabel; // NPC turn pacing — instant-resolve NPC turns frame-by-frame so the log scrolls. private float _npcTurnDelay; private const float NPC_TURN_SECONDS = 0.4f; // Edge-detect input. private bool _spaceWas, _enterWas, _wWas, _aWas, _sWas, _dWas; private bool _upWas, _downWas, _leftWas, _rightWas; private bool _rWas, _tWas; // Phase 6.5 M1 — class feature hotkeys: H = heal (Field Repair / Lay on // Paws), V = vocalize (Vocalization Dice). private bool _hWas, _vWas; // Phase 6.5 M3 — P = pheromone (Scent-Broker), O = oath (Covenant-Keeper). private bool _pWas, _oWas; public Encounter Encounter => _encounter; public bool IsOver => _encounter.IsOver; /// Build a fresh encounter from the supplied participants and push the HUD. public CombatHUDScreen( Encounter encounter, ActorManager actors, System.Action onEnd, Theriapolis.Core.Data.ContentResolver? content = null) { _encounter = encounter ?? throw new System.ArgumentNullException(nameof(encounter)); _actors = actors ?? throw new System.ArgumentNullException(nameof(actors)); _onEnd = onEnd ?? throw new System.ArgumentNullException(nameof(onEnd)); _content = content; } public void Initialize(Game1 game) { _game = game; BuildUI(); } private void BuildUI() { var root = new VerticalStackPanel { Spacing = 4, HorizontalAlignment = HorizontalAlignment.Stretch, VerticalAlignment = VerticalAlignment.Bottom, Padding = new Thickness(12, 8, 12, 8), Background = new SolidBrush(new Color(0, 0, 0, 200)), }; _initLabel = new Label { Text = "" }; root.Widgets.Add(_initLabel); _logLabel = new Label { Text = "" }; root.Widgets.Add(_logLabel); _actionLabel = new Label { Text = "WASD: move · SPACE: attack · ENTER: end turn" }; root.Widgets.Add(_actionLabel); _desktop = new Desktop { Root = root }; Refresh(); } public void Update(GameTime gt) { if (_encounter.IsOver) { Resolve(); return; } var actor = _encounter.CurrentActor; if (actor.IsDown) { // Phase 5 M6: player death-save loop. Roll once at the start of // the player's turn while at 0 HP, then end turn. NPC combatants // skip this and go straight to EndTurn (they're removed from // initiative since IsAlive is false). if (actor.SourceCharacter is not null && actor.DeathSaves is not null) { var outcome = actor.DeathSaves.Roll(_encounter, actor); if (outcome == Theriapolis.Core.Rules.Combat.DeathSaveOutcome.Dead) { PushDefeated(actor.Name + " fell to a final-blow death save."); return; } } _encounter.EndTurn(); Refresh(); return; } // Find this combatant's live actor (by id) so we can dispatch behavior or read input. var liveActor = FindLiveActor(actor.Id); if (liveActor is NpcActor npc) { _npcTurnDelay += (float)gt.ElapsedGameTime.TotalSeconds; if (_npcTurnDelay < NPC_TURN_SECONDS) return; _npcTurnDelay = 0f; var ctx = new AiContext(_encounter); BehaviorRegistry.For(npc.BehaviorId).TakeTurn(actor, ctx); _encounter.EndTurn(); Refresh(); return; } // Player turn — wait for input. DrivePlayerTurn(gt); Refresh(); } private void DrivePlayerTurn(GameTime gt) { var ks = Keyboard.GetState(); bool space = JustPressed(ks, Keys.Space, ref _spaceWas); bool enter = JustPressed(ks, Keys.Enter, ref _enterWas); bool w = JustPressed(ks, Keys.W, ref _wWas); bool a = JustPressed(ks, Keys.A, ref _aWas); bool s = JustPressed(ks, Keys.S, ref _sWas); bool d = JustPressed(ks, Keys.D, ref _dWas); bool up = JustPressed(ks, Keys.Up, ref _upWas); bool down = JustPressed(ks, Keys.Down, ref _downWas); bool left = JustPressed(ks, Keys.Left, ref _leftWas); bool right = JustPressed(ks, Keys.Right, ref _rightWas); int dx = 0, dy = 0; if (w || up) dy = -1; if (s || down) dy = +1; if (a || left) dx = -1; if (d || right) dx = +1; if (dx != 0 || dy != 0) TryMovePlayer(dx, dy); if (space) TryAttack(); if (enter) { _encounter.EndTurn(); Refresh(); } // Phase 5 M6: class-feature toggles. bool r = JustPressed(ks, Keys.R, ref _rWas); bool t = JustPressed(ks, Keys.T, ref _tWas); if (r) TryToggleRage(); if (t) TryToggleSentinelStance(); // Phase 6.5 M1: heal + vocalize hotkeys. H prefers Lay on Paws when // available (Covenant-Keeper); falls through to Field Repair // (Claw-Wright). V grants a Vocalization Die to the closest ally. bool h = JustPressed(ks, Keys.H, ref _hWas); bool v = JustPressed(ks, Keys.V, ref _vWas); if (h) TryHealAction(); if (v) TryVocalize(); // Phase 6.5 M3: pheromone + oath hotkeys. P emits a Fear pheromone // (the most universally useful default; future UI can let the // player pick the type). O declares an oath against the closest // hostile. bool p = JustPressed(ks, Keys.P, ref _pWas); bool o = JustPressed(ks, Keys.O, ref _oWas); if (p) TryEmitPheromone(); if (o) TryDeclareOath(); } /// /// Phase 6.5 M3 — Scent-Broker Pheromone Craft hotkey. Bonus action; /// emits a Fear pheromone in 10-ft radius. Hostiles in range CON-save /// or get Frightened. Future UI iteration can offer a type picker. /// private void TryEmitPheromone() { var actor = _encounter.CurrentActor; var c = actor.SourceCharacter; if (c is null || c.ClassDef.Id != "scent_broker") return; if (c.Level < 2) { _encounter.AppendLog(CombatLogEntry.Kind.Note, $"{actor.Name}: Pheromone Craft unlocks at level 2."); return; } if (c.PheromoneUsesRemaining <= 0) { _encounter.AppendLog(CombatLogEntry.Kind.Note, $"{actor.Name}: no Pheromone Craft uses remaining."); return; } if (Theriapolis.Core.Rules.Combat.FeatureProcessor.TryEmitPheromone( _encounter, actor, Theriapolis.Core.Rules.Combat.PheromoneType.Fear)) _encounter.CurrentTurn.ConsumeBonusAction(); } /// /// Phase 6.5 M3 — Covenant-Keeper Covenant's Authority hotkey. Bonus /// action; declares an oath against the closest hostile, inflicting /// -2 to attack rolls vs. the Covenant-Keeper for 10 rounds. /// private void TryDeclareOath() { var actor = _encounter.CurrentActor; var c = actor.SourceCharacter; if (c is null || c.ClassDef.Id != "covenant_keeper") return; if (c.Level < 2) { _encounter.AppendLog(CombatLogEntry.Kind.Note, $"{actor.Name}: Covenant's Authority unlocks at level 2."); return; } var aiCtx = new Theriapolis.Core.Entities.Ai.AiContext(_encounter); var target = aiCtx.FindClosestHostile(actor); if (target is null) { _encounter.AppendLog(CombatLogEntry.Kind.Note, $"{actor.Name}: no hostile target in sight."); return; } if (Theriapolis.Core.Rules.Combat.FeatureProcessor.TryDeclareOath( _encounter, actor, target)) _encounter.CurrentTurn.ConsumeBonusAction(); } /// /// Phase 6.5 M1 — heal hotkey. Auto-targets the most-damaged friendly /// (self or ally). Uses Lay on Paws (Covenant-Keeper) when there's pool /// remaining, else falls through to Field Repair (Claw-Wright). /// Consumes the action and a bonus action where appropriate. No-op for /// non-healer classes. /// private void TryHealAction() { if (!_encounter.CurrentTurn.ActionAvailable) return; var actor = _encounter.CurrentActor; var c = actor.SourceCharacter; if (c is null) return; var aiCtx = new Theriapolis.Core.Entities.Ai.AiContext(_encounter); var target = aiCtx.FindMostDamagedFriendly(actor) ?? actor; bool acted = false; if (c.ClassDef.Id == "covenant_keeper" && c.LayOnPawsPoolRemaining > 0) { // Spend up to 5 (or whatever's needed to top up the target) per // press — keeps the no-target-picker UX quick to use repeatedly. int request = System.Math.Max(1, target.MaxHp - target.CurrentHp); if (request > 5) request = 5; acted = Theriapolis.Core.Rules.Combat.FeatureProcessor.TryLayOnPaws(_encounter, actor, target, request); } else if (c.ClassDef.Id == "claw_wright" && c.FieldRepairUsesRemaining > 0) { acted = Theriapolis.Core.Rules.Combat.FeatureProcessor.TryFieldRepair(_encounter, actor, target); } else { _encounter.AppendLog(CombatLogEntry.Kind.Note, $"{actor.Name} has no heal action available."); } if (acted) _encounter.CurrentTurn.ConsumeAction(); } /// /// Phase 6.5 M1 — Vocalization Dice. Bonus action for Muzzle-Speakers; /// auto-targets the closest ally (excludes self). No-op when no ally is /// in combat (the typical M1 case — the player is alone). /// private void TryVocalize() { var actor = _encounter.CurrentActor; var c = actor.SourceCharacter; if (c is null || c.ClassDef.Id != "muzzle_speaker") return; if (c.VocalizationDiceRemaining <= 0) { _encounter.AppendLog(CombatLogEntry.Kind.Note, $"{actor.Name}: no Vocalization Dice remaining."); return; } var aiCtx = new Theriapolis.Core.Entities.Ai.AiContext(_encounter); var ally = aiCtx.FindClosestAlly(actor); if (ally is null) { _encounter.AppendLog(CombatLogEntry.Kind.Note, $"{actor.Name}: no ally in range to inspire."); return; } if (Theriapolis.Core.Rules.Combat.FeatureProcessor.TryGrantVocalizationDie(_encounter, actor, ally)) _encounter.CurrentTurn.ConsumeBonusAction(); } private void TryToggleRage() { var actor = _encounter.CurrentActor; if (actor.RageActive) { actor.RageActive = false; _encounter.AppendLog(CombatLogEntry.Kind.Note, $"{actor.Name} ends the rage."); return; } if (!FeatureProcessor.TryActivateRage(_encounter, actor)) _encounter.AppendLog(CombatLogEntry.Kind.Note, $"{actor.Name} can't enter rage right now."); _encounter.CurrentTurn.ConsumeBonusAction(); } private void TryToggleSentinelStance() { var actor = _encounter.CurrentActor; FeatureProcessor.ToggleSentinelStance(_encounter, actor); _encounter.CurrentTurn.ConsumeBonusAction(); } private void TryMovePlayer(int dx, int dy) { if (_encounter.CurrentTurn.RemainingMovementFt < 5) return; var actor = _encounter.CurrentActor; var newPos = new Vec2((int)actor.Position.X + dx, (int)actor.Position.Y + dy); actor.Position = newPos; _encounter.AppendLog(CombatLogEntry.Kind.Move, $"{actor.Name} moves to ({(int)newPos.X},{(int)newPos.Y})."); _encounter.CurrentTurn.ConsumeMovement(5); } private void TryAttack() { if (!_encounter.CurrentTurn.ActionAvailable) return; var actor = _encounter.CurrentActor; var ctx = new AiContext(_encounter); var target = ctx.FindClosestHostile(actor); if (target is null) return; var attack = actor.AttackOptions[0]; if (!ReachAndCover.IsInReach(actor, target, attack)) { _encounter.AppendLog(CombatLogEntry.Kind.Note, $"{actor.Name}: target out of reach."); return; } Resolver.AttemptAttack(_encounter, actor, target, attack); _encounter.CurrentTurn.ConsumeAction(); } private static bool JustPressed(KeyboardState ks, Keys k, ref bool was) { bool now = ks.IsKeyDown(k); bool jp = now && !was; was = now; return jp; } private void Refresh() { if (_initLabel is not null) { var sb = new System.Text.StringBuilder(); sb.Append($"R{_encounter.RoundNumber} "); for (int i = 0; i < _encounter.InitiativeOrder.Count; i++) { var c = _encounter.Participants[_encounter.InitiativeOrder[i]]; if (i == _encounter.CurrentTurnIndex) sb.Append("→"); sb.Append($"[{c.Name} {c.CurrentHp}/{c.MaxHp}] "); } _initLabel.Text = sb.ToString(); } if (_logLabel is not null) { int start = System.Math.Max(0, _encounter.Log.Count - 6); var sb = new System.Text.StringBuilder(); for (int i = start; i < _encounter.Log.Count; i++) { var e = _encounter.Log[i]; sb.AppendLine(e.Message); } _logLabel.Text = sb.ToString(); } if (_actionLabel is not null) { var actor = _encounter.IsOver ? null : _encounter.CurrentActor; if (actor is null) _actionLabel.Text = "Encounter ended."; else if (FindLiveActor(actor.Id) is NpcActor) _actionLabel.Text = $"{actor.Name}'s turn (NPC) …"; else { string featureHints = ""; var c = actor.SourceCharacter; if (c is not null) { if (c.ClassDef.Id == "feral") featureHints += actor.RageActive ? $" [Raging — R to end]" : $" [R: Rage ({c.RageUsesRemaining} left)]"; if (c.ClassDef.Id == "bulwark") featureHints += actor.SentinelStanceActive ? " [Stance — T to leave]" : " [T: Sentinel Stance]"; // Phase 6.5 M1 hotkey hints. if (c.ClassDef.Id == "covenant_keeper" && c.LayOnPawsPoolRemaining > 0) featureHints += $" [H: Lay on Paws ({c.LayOnPawsPoolRemaining} HP)]"; if (c.ClassDef.Id == "claw_wright" && c.FieldRepairUsesRemaining > 0) featureHints += $" [H: Field Repair ({c.FieldRepairUsesRemaining})]"; if (c.ClassDef.Id == "muzzle_speaker" && c.VocalizationDiceRemaining > 0) featureHints += $" [V: Vocalize ({c.VocalizationDiceRemaining})]"; // Phase 6.5 M3 hotkey hints. if (c.ClassDef.Id == "scent_broker" && c.Level >= 2 && c.PheromoneUsesRemaining > 0) featureHints += $" [P: Pheromone ({c.PheromoneUsesRemaining})]"; if (c.ClassDef.Id == "covenant_keeper" && c.Level >= 2 && c.CovenantAuthorityUsesRemaining > 0) featureHints += $" [O: Oath ({c.CovenantAuthorityUsesRemaining})]"; } _actionLabel.Text = $"{actor.Name}'s turn — WASD: move ({_encounter.CurrentTurn.RemainingMovementFt}ft left) · SPACE: attack · ENTER: end turn{featureHints}"; } } } private Actor? FindLiveActor(int id) { foreach (var a in _actors.All) if (a.Id == id) return a; return null; } private void Resolve() { // Write combatant state back to live actors and remove dead NPCs. // Phase 5 M6: roll loot per killed NPC and auto-pickup into player // inventory. Loot RNG is a sub-stream of the encounter seed so save+load // round-trips produce identical drops. var killedByChunk = new Dictionary>(); var pickedUp = new List<(string Name, int Qty)>(); var lootRng = _content is null ? null : new Theriapolis.Core.Util.SeededRng(_encounter.EncounterSeed ^ Theriapolis.Core.C.RNG_LOOT); Theriapolis.Core.Items.Inventory? playerInv = null; int xpEarned = 0; // Phase 6.5 M0 — sum of killed-NPC XpAward values; awarded to player below. foreach (var c in _encounter.Participants) { var live = FindLiveActor(c.Id); if (live is NpcActor npc) { npc.CurrentHp = c.CurrentHp; npc.Position = c.Position; if (c.IsDown) { if (npc.SourceChunk is { } chunk && npc.SourceSpawnIndex is int idx) { if (!killedByChunk.TryGetValue(chunk, out var list)) killedByChunk[chunk] = list = new List(); list.Add(idx); } // Auto-pickup loot into player inventory. Residents // (Phase 6 M1) don't have a loot table — only Phase 5 // hostiles do. if (lootRng is not null && _content is not null && npc.Template is not null) { var drops = Theriapolis.Core.Loot.LootRoller.Roll( npc.Template.LootTable, _content.LootTables, _content.Items, lootRng); foreach (var d in drops) { playerInv ??= _actors.Player?.Character?.Inventory; if (playerInv is null) break; playerInv.Add(d.Def, d.Qty); pickedUp.Add((d.Def.Name, d.Qty)); } } // Phase 6.5 M0 — award XP for the kill. Templates' XpAward // was loaded since Phase 5 but never consumed; this is // the wiring. if (npc.Template is not null && npc.Template.XpAward > 0) xpEarned += npc.Template.XpAward; _actors.RemoveActor(npc.Id); } } else if (live is PlayerActor pa && pa.Character is not null) { pa.Character.CurrentHp = c.CurrentHp; pa.Position = c.Position; pa.Character.Conditions.Clear(); foreach (var cond in c.Conditions) pa.Character.Conditions.Add(cond); } } if (pickedUp.Count > 0) { string lootLine = string.Join(", ", pickedUp.Select(p => p.Qty > 1 ? $"{p.Name} ×{p.Qty}" : p.Name)); _encounter.AppendLog(CombatLogEntry.Kind.Note, "Picked up: " + lootLine); } // Phase 6.5 M0 — award accumulated combat XP to the player. if (xpEarned > 0) { var pcChar = _actors.Player?.Character; if (pcChar is not null) { pcChar.Xp += xpEarned; _encounter.AppendLog(CombatLogEntry.Kind.Note, $"+{xpEarned} XP."); if (Theriapolis.Core.Rules.Character.LevelUpFlow.CanLevelUp(pcChar)) _encounter.AppendLog(CombatLogEntry.Kind.Note, "Level up available — open the pause menu."); } } var result = new EncounterEndResult { Killed = killedByChunk, PlayerSurvived = _encounter.Participants.Any(c => c.Allegiance == Theriapolis.Core.Rules.Character.Allegiance.Player && !c.IsDown), }; _onEnd(result); _game.Screens.Pop(); } /// /// Snapshot the encounter for save-anywhere. PlayScreen.CaptureBody /// calls this and stores the result in SaveBody.ActiveEncounter. /// private void PushDefeated(string cause) { // Pop ourselves so the play-screen sits underneath; then push the // DefeatedScreen which the player can dismiss to return to title. _onEnd(new EncounterEndResult { PlayerSurvived = false }); _game.Screens.Pop(); _game.Screens.Push(new DefeatedScreen(cause)); } public EncounterState SnapshotForSave() { var snaps = new CombatantSnapshot[_encounter.Participants.Count]; for (int i = 0; i < _encounter.Participants.Count; i++) { var c = _encounter.Participants[i]; var snap = new CombatantSnapshot { Id = c.Id, Name = c.Name, IsPlayer = c.SourceCharacter is not null, CurrentHp = c.CurrentHp, PositionX = c.Position.X, PositionY = c.Position.Y, Conditions = c.Conditions.Select(x => (byte)x).ToArray(), }; if (c.SourceTemplate is not null) { snap.NpcTemplateId = c.SourceTemplate.Id; var live = FindLiveActor(c.Id); if (live is NpcActor npc) { snap.NpcChunkX = npc.SourceChunk?.X; snap.NpcChunkY = npc.SourceChunk?.Y; snap.NpcSpawnIndex = npc.SourceSpawnIndex; } } snaps[i] = snap; } var initOrder = new int[_encounter.InitiativeOrder.Count]; for (int i = 0; i < initOrder.Length; i++) initOrder[i] = _encounter.InitiativeOrder[i]; return new EncounterState { EncounterId = _encounter.EncounterId, RollCount = _encounter.RollCount, CurrentTurnIndex = _encounter.CurrentTurnIndex, RoundNumber = _encounter.RoundNumber, InitiativeOrder = initOrder, Combatants = snaps, }; } public void Draw(GameTime gt, SpriteBatch sb) { // Don't clear — let the play-screen's last frame stay visible underneath. _desktop.Render(); } public void Deactivate() { } public void Reactivate() { } } /// /// Reported back to PlayScreen when an encounter wraps so it can update /// the chunk roster delta + decide whether to push the death screen. /// public sealed class EncounterEndResult { public Dictionary> Killed { get; init; } = new(); public bool PlayerSurvived { get; init; } = true; }