Files
TheriapolisV3/Theriapolis.Game/Screens/CombatHUDScreen.cs
T
Christopher Wiebe b451f83174 Initial commit: Theriapolis baseline at port/godot branch point
Captures the pre-Godot-port state of the codebase. This is the rollback
anchor for the Godot port (M0 of theriapolis-rpg-implementation-plan-godot-port.md).
All Phase 0 through Phase 6.5 work is included; Phase 7 is in flight.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-30 20:40:51 -07:00

611 lines
24 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
/// <summary>
/// Phase 5 M5 turn-based combat overlay. Pushed by PlayScreen when an
/// encounter triggers; owns the live <see cref="Encounter"/>, 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
/// <see cref="SnapshotForSave"/>; on load, PlayScreen re-pushes this screen
/// with the rebuilt encounter via the rehydrate constructor.
/// </summary>
public sealed class CombatHUDScreen : IScreen
{
private readonly Encounter _encounter;
private readonly ActorManager _actors;
private readonly Theriapolis.Core.Data.ContentResolver? _content;
private readonly System.Action<EncounterEndResult> _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;
/// <summary>Build a fresh encounter from the supplied participants and push the HUD.</summary>
public CombatHUDScreen(
Encounter encounter,
ActorManager actors,
System.Action<EncounterEndResult> 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();
}
/// <summary>
/// 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.
/// </summary>
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();
}
/// <summary>
/// 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.
/// </summary>
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();
}
/// <summary>
/// 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.
/// </summary>
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();
}
/// <summary>
/// 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).
/// </summary>
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<Theriapolis.Core.Tactical.ChunkCoord, List<int>>();
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<int>();
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();
}
/// <summary>
/// Snapshot the encounter for save-anywhere. PlayScreen.CaptureBody
/// calls this and stores the result in <c>SaveBody.ActiveEncounter</c>.
/// </summary>
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() { }
}
/// <summary>
/// Reported back to PlayScreen when an encounter wraps so it can update
/// the chunk roster delta + decide whether to push the death screen.
/// </summary>
public sealed class EncounterEndResult
{
public Dictionary<Theriapolis.Core.Tactical.ChunkCoord, List<int>> Killed { get; init; }
= new();
public bool PlayerSurvived { get; init; } = true;
}