feat(tvt): per-character scoreboard (kills, statues, statue damage)
Server: new 0xFD HeykelSavasiScoreboard packet + view plugin; HeykelSavasiContext tracks per-player kills (Player.AfterKilledPlayerAsync hook), statues broken (OnDestructibleDied), and statue damage (AttackableNpcBase.AttackByAsync hook); broadcast top-8 by damage (desc) every ~1s, omitting zero-damage characters.
This commit is contained in:
@@ -248,6 +248,18 @@ public class HeykelSavasiContext : MiniGameContext
|
||||
/// </summary>
|
||||
private int _bossesSpawned;
|
||||
|
||||
/// <summary>
|
||||
/// Per-character contribution stats for the scoreboard (<see cref="IHeykelSavasiScoreboardPlugIn"/>):
|
||||
/// enemy kills, statues broken, and total damage dealt to statues. Keyed by the participating player;
|
||||
/// fed by <see cref="RecordKill"/>, <see cref="RecordStatueBreak"/> and <see cref="RecordStatueDamage"/>.
|
||||
/// </summary>
|
||||
private readonly ConcurrentDictionary<Player, PlayerScore> _scores = new();
|
||||
|
||||
/// <summary>
|
||||
/// The maximum number of characters shown on the scoreboard (top contributors by statue damage).
|
||||
/// </summary>
|
||||
private const int ScoreboardMaxEntries = 8;
|
||||
|
||||
/// <summary>
|
||||
/// Synchronizes the check-then-act sequence of <see cref="TryJoinTeamAsync"/>, so that concurrent
|
||||
/// join attempts cannot both pass the balance check before either of them reserves a slot.
|
||||
@@ -448,6 +460,64 @@ public class HeykelSavasiContext : MiniGameContext
|
||||
/// <param name="player">The player.</param>
|
||||
private void RemoveTeam(Player player) => this._teams.TryRemove(player, out _);
|
||||
|
||||
/// <summary>
|
||||
/// Records that <paramref name="killer"/> killed an enemy-team player (for the scoreboard kill count).
|
||||
/// Called from <c>Player.AfterKilledPlayerAsync</c> when both players are in this event on opposing teams.
|
||||
/// </summary>
|
||||
/// <param name="killer">The killing player.</param>
|
||||
public void RecordKill(Player killer)
|
||||
{
|
||||
if (killer is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var score = this._scores.GetOrAdd(killer, _ => new PlayerScore());
|
||||
Interlocked.Increment(ref score.Kills);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records that the character named <paramref name="killerName"/> broke a statue (dealt the killing blow),
|
||||
/// for the scoreboard statue count. Called from <see cref="OnDestructibleDied"/>, which only knows the
|
||||
/// killer's name; it is matched to the participating player of that name.
|
||||
/// </summary>
|
||||
/// <param name="killerName">The character name of the player who broke the statue.</param>
|
||||
public void RecordStatueBreak(string killerName)
|
||||
{
|
||||
if (string.IsNullOrEmpty(killerName))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var player = this._teams.Keys.FirstOrDefault(p => string.Equals(p.Name, killerName, StringComparison.Ordinal));
|
||||
if (player is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var score = this._scores.GetOrAdd(player, _ => new PlayerScore());
|
||||
Interlocked.Increment(ref score.Statues);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records <paramref name="damage"/> dealt by <paramref name="player"/> to <paramref name="statue"/>, for the
|
||||
/// scoreboard damage ranking. Called from <c>AttackableNpcBase.AttackByAsync</c> for every hit that lands on a
|
||||
/// statue of this event.
|
||||
/// </summary>
|
||||
/// <param name="player">The attacking player.</param>
|
||||
/// <param name="statue">The statue NPC that was hit.</param>
|
||||
/// <param name="damage">The health damage dealt by this hit.</param>
|
||||
public void RecordStatueDamage(Player player, AttackableNpcBase statue, uint damage)
|
||||
{
|
||||
if (player is null || damage == 0 || !this._statues.ContainsKey(statue))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var score = this._scores.GetOrAdd(player, _ => new PlayerScore());
|
||||
Interlocked.Add(ref score.Damage, damage);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes a player from their team when they leave the event map for good (disconnect, or warp
|
||||
/// to another map such as Lorencia). A same-map death-respawn does NOT trigger this (RespawnAtAsync
|
||||
@@ -591,6 +661,9 @@ public class HeykelSavasiContext : MiniGameContext
|
||||
this.HandleStatueDestroyed(info.Defender, info.Index, attacker, spawnNext: false);
|
||||
var count = this.GetProgress(attacker);
|
||||
|
||||
// Credit the statue break to the destroyer's character for the scoreboard.
|
||||
this.RecordStatueBreak(e.KillerName);
|
||||
|
||||
// Fire-and-forget the async side effects (break announcement, buff application, or finishing the
|
||||
// event); the death event handler itself must stay synchronous.
|
||||
_ = this.OnStatueDestroyedSideEffectsAsync(attacker, count, e.KillerName);
|
||||
@@ -1083,6 +1156,7 @@ public class HeykelSavasiContext : MiniGameContext
|
||||
{
|
||||
await this.BroadcastHudStateAsync().ConfigureAwait(false);
|
||||
await this.BroadcastTeamRosterAsync().ConfigureAwait(false);
|
||||
await this.BroadcastScoreboardAsync().ConfigureAwait(false);
|
||||
}
|
||||
while (await timer.WaitForNextTickAsync(cancellationToken).ConfigureAwait(false));
|
||||
}
|
||||
@@ -1133,6 +1207,32 @@ public class HeykelSavasiContext : MiniGameContext
|
||||
.AsTask()).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends the per-character scoreboard to every entered player via <see cref="IHeykelSavasiScoreboardPlugIn"/>:
|
||||
/// the top <see cref="ScoreboardMaxEntries"/> contributors ordered by total statue damage (descending).
|
||||
/// Characters that dealt no statue damage are omitted. Broadcast on the same ~1s cadence as the HUD state.
|
||||
/// </summary>
|
||||
private async ValueTask BroadcastScoreboardAsync()
|
||||
{
|
||||
var entries = this._scores
|
||||
.Select(kv => (kv.Key.Name, Score: kv.Value))
|
||||
.Where(x => x.Score.Damage > 0)
|
||||
.OrderByDescending(x => x.Score.Damage)
|
||||
.ThenByDescending(x => x.Score.Statues)
|
||||
.ThenByDescending(x => x.Score.Kills)
|
||||
.Take(ScoreboardMaxEntries)
|
||||
.Select(x => (
|
||||
Name: x.Name ?? string.Empty,
|
||||
Kills: (ushort)Math.Min(x.Score.Kills, ushort.MaxValue),
|
||||
Statues: (byte)Math.Min(x.Score.Statues, byte.MaxValue),
|
||||
Damage: (uint)Math.Min(Interlocked.Read(ref x.Score.Damage), uint.MaxValue)))
|
||||
.ToList();
|
||||
|
||||
await this.ForEachPlayerAsync(player => player.InvokeViewPlugInAsync<IHeykelSavasiScoreboardPlugIn>(p =>
|
||||
p.UpdateScoreboardAsync(entries))
|
||||
.AsTask()).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines the current HUD phase and the estimated number of seconds remaining in it, purely from
|
||||
/// <see cref="MiniGameContext.State"/> and wall-clock timestamps recorded (lazily, on first observation) at
|
||||
@@ -1191,6 +1291,22 @@ public class HeykelSavasiContext : MiniGameContext
|
||||
/// <see cref="IsJoinAllowed"/>, extracted for the same reason). Kept <c>internal</c> (rather than
|
||||
/// <c>private</c>) specifically so <c>MUnique.OpenMU.Tests</c> can drive it directly.
|
||||
/// </summary>
|
||||
/// <summary>
|
||||
/// Mutable per-character contribution counters for the scoreboard. Fields (not properties) so they can be
|
||||
/// updated atomically with <see cref="Interlocked"/> from concurrent hit/kill handlers.
|
||||
/// </summary>
|
||||
private sealed class PlayerScore
|
||||
{
|
||||
/// <summary>Number of enemy-team players killed.</summary>
|
||||
public int Kills;
|
||||
|
||||
/// <summary>Number of statues broken (killing blow).</summary>
|
||||
public int Statues;
|
||||
|
||||
/// <summary>Total damage dealt to statues.</summary>
|
||||
public long Damage;
|
||||
}
|
||||
|
||||
internal sealed class StatueProgressState
|
||||
{
|
||||
private readonly ConcurrentDictionary<HeykelSavasiTeam, int> _progress = new()
|
||||
|
||||
Reference in New Issue
Block a user