611 lines
24 KiB
C#
611 lines
24 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.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;
|
|||
|
|
}
|