feat(combat): Phase E.4 AttackTargetRequest + combat notification pipeline
Completes the client-side combat loop: send attacks, receive server's damage broadcasts, maintain per-entity health state for HP bars + damage floaters. All atop Phase F.1's GameEvent dispatcher. Wire layer: - AttackTargetRequest (0x0008 C→S, inside 0xF7B1): targetGuid + powerLevel + accuracyLevel + attackHeight. 28-byte body. - GameEvents parsers for all combat notifications from r08 §4: - VictimNotification (0x01AC) — you got hit, full details - KillerNotification (0x01AD) — you killed X - AttackerNotification (0x01B1) — you hit X for Y (damage%) - DefenderNotification (0x01B2) — X hit you - EvasionAttackerNotification (0x01B3) — X evaded - EvasionDefenderNotification (0x01B4) — you evaded X - AttackDone (0x01A7) — attack sequence completed Core layer: - CombatState: per-entity health-percent cache + typed events (HealthChanged, DamageTaken, DamageDealtAccepted, EvadedIncoming, MissedOutgoing, AttackDone). Each event carries enough detail for the UI to render damage floaters, HP bars, and a combat log panel. Server is authoritative; client only mirrors state. The server computes damage (armor, resist, crit, hit-chance); the client only displays results. Predictive UI like "estimated damage at 0.75 power" still works via the existing CombatMath helper class that was in the scaffold (r02 §5 formulas). Tests (13 new): - AttackTargetRequest byte-exact wire encoding - VictimNotification / AttackerNotification / EvasionAttacker / AttackDone round-trip parse. - CombatState: UpdateHealth caches + fires, Victim fires DamageTaken, Attacker fires DamageDealt, Evasion routes to right event, AttackDone carries sequence+error, Clear resets cache. Build green, 544 tests pass (up from 532). Ref: r02 §7 (wire formats), r08 §4 (event payloads), ACE GameEvent*Notification.cs families. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
2561f5599f
commit
2e3f9d7a04
5 changed files with 516 additions and 0 deletions
126
src/AcDream.Core/Combat/CombatState.cs
Normal file
126
src/AcDream.Core/Combat/CombatState.cs
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
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();
|
||||
|
||||
/// <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;
|
||||
|
||||
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 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);
|
||||
|
||||
public void OnEvasionDefenderNotification(string attackerName)
|
||||
=> EvadedIncoming?.Invoke(attackerName);
|
||||
|
||||
public void OnAttackDone(uint attackSequence, uint weenieError)
|
||||
=> AttackDone?.Invoke(attackSequence, weenieError);
|
||||
|
||||
public void Clear() => _healthByGuid.Clear();
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue