acdream/src/AcDream.Core/Combat/CombatState.cs

164 lines
6.1 KiB
C#

using System;
using System.Collections.Concurrent;
namespace AcDream.Core.Combat;
/// <summary>
/// Client-side combat state — tracks per-entity health percent and
/// emits typed events when UpdateHealth / Victim / Attacker / Defender
/// notifications arrive. Powers target HP bars, damage floaters, combat
/// log panel.
///
/// <para>
/// Retail client-side combat responsibilities (r02 §7):
/// <list type="bullet">
/// <item><description>
/// Maintain a cache of "last known health percent" per entity guid.
/// UpdateHealth (0x01C0) is sent when the player queries or the
/// server broadcasts a change.
/// </description></item>
/// <item><description>
/// Convert raw damage events into UI-ready notifications (colored
/// floating numbers, "Critical!" flashes, body-part locations).
/// </description></item>
/// <item><description>
/// Track self-centered notifications (you hit / you got hit / you
/// evaded / you were evaded) so the log panel can format them
/// correctly.
/// </description></item>
/// </list>
/// </para>
///
/// <para>
/// The server is authoritative: this class does NOT simulate damage
/// locally (exception: a predictive-ish "estimated damage" display for
/// the attack bar UI, which can use <see cref="CombatMath"/>).
/// </para>
/// </summary>
public sealed class CombatState
{
private readonly ConcurrentDictionary<uint, float> _healthByGuid = new();
public CombatMode CurrentMode { get; private set; } = CombatMode.NonCombat;
/// <summary>Fires when a target's health percent changes (from UpdateHealth).</summary>
public event Action<uint /*guid*/, float /*percent*/>? HealthChanged;
/// <summary>You (the player) got hit for some damage.</summary>
public event Action<DamageIncoming>? DamageTaken;
/// <summary>You (the player) dealt some damage.</summary>
public event Action<DamageDealt>? DamageDealtAccepted;
/// <summary>You (the player) evaded an incoming hit.</summary>
public event Action<string>? EvadedIncoming;
/// <summary>The target evaded your hit.</summary>
public event Action<string>? MissedOutgoing;
/// <summary>An attack commit completed (0x01A7). WeenieError = 0 on success.</summary>
public event Action<uint /*attackSeq*/, uint /*weenieError*/>? AttackDone;
/// <summary>The server accepted the attack and the power bar/animation can begin.</summary>
public event Action? AttackCommenced;
/// <summary>The locally requested or server-confirmed combat mode changed.</summary>
public event Action<CombatMode>? CombatModeChanged;
/// <summary>
/// Fires when the server confirms the player landed a killing blow
/// (GameEvent <c>KillerNotification (0x01AD)</c>). Event payload is
/// the victim's display name + their server GUID. Used by killfeed UI
/// (future panel) and any plugin scoring kill counts.
/// </summary>
public event Action<string /*victimName*/, uint /*victimGuid*/>? KillLanded;
public readonly record struct DamageIncoming(
string AttackerName,
uint AttackerGuid,
uint DamageType,
uint Damage,
uint HitQuadrant,
bool Critical,
uint AttackType);
public readonly record struct DamageDealt(
string DefenderName,
uint DamageType,
uint Damage,
float DamagePercent);
/// <summary>Retrieve last known health percent for a guid, or 1.0 if unknown.</summary>
public float GetHealthPercent(uint guid) =>
_healthByGuid.TryGetValue(guid, out var pct) ? pct : 1f;
public int TrackedTargetCount => _healthByGuid.Count;
// ── Inbound handlers (wired from WorldSession.GameEvents) ────────────────
public void OnUpdateHealth(uint targetGuid, float healthPercent)
{
_healthByGuid[targetGuid] = healthPercent;
HealthChanged?.Invoke(targetGuid, healthPercent);
}
public void SetCombatMode(CombatMode mode)
{
if (CurrentMode == mode)
return;
CurrentMode = mode;
CombatModeChanged?.Invoke(mode);
}
public void OnVictimNotification(
string attackerName, uint attackerGuid, uint damageType, uint damage,
uint hitQuadrant, uint critical, uint attackType)
{
DamageTaken?.Invoke(new DamageIncoming(
attackerName, attackerGuid, damageType, damage, hitQuadrant,
critical != 0, attackType));
}
public void OnDefenderNotification(
string attackerName, uint attackerGuid, uint damageType, uint damage,
uint hitQuadrant, uint critical)
{
// DefenderNotification is semantically the same as VictimNotification
// from the defender's POV — the client log merges them.
DamageTaken?.Invoke(new DamageIncoming(
attackerName, attackerGuid, damageType, damage, hitQuadrant,
critical != 0, 0));
}
public void OnAttackerNotification(
string defenderName, uint damageType, uint damage, float damagePercent)
{
DamageDealtAccepted?.Invoke(new DamageDealt(
defenderName, damageType, damage, damagePercent));
}
public void OnEvasionAttackerNotification(string defenderName)
=> MissedOutgoing?.Invoke(defenderName);
/// <summary>
/// Server confirmation that the player landed a killing blow on a
/// target. Wire source: GameEvent <c>KillerNotification (0x01AD)</c>
/// — the parser at <c>GameEvents.ParseKillerNotification</c> shipped
/// alongside victim/defender notifications but was never registered
/// for dispatch until 2026-04-25 (per ISSUES.md #10).
/// </summary>
public void OnKillerNotification(string victimName, uint victimGuid)
=> KillLanded?.Invoke(victimName, victimGuid);
public void OnEvasionDefenderNotification(string attackerName)
=> EvadedIncoming?.Invoke(attackerName);
public void OnAttackDone(uint attackSequence, uint weenieError)
=> AttackDone?.Invoke(attackSequence, weenieError);
public void OnCombatCommenceAttack()
=> AttackCommenced?.Invoke();
public void Clear() => _healthByGuid.Clear();
}